编写强大的 Rust 宏——宏与外部世界

316 阅读28分钟

本章内容:

  • 使用单个库暴露多个宏
  • 通过功能特性来添加或禁用功能
  • 使用属性控制代码生成
  • 为宏库编写文档并发布
  • 探索本书之外的有趣宏主题

在前几章中,我们经常为采取捷径做了些解释,说明“生产级”宏会如何更好或以不同的方式完成这些任务。在这一章——我们最后的一章中——我们将创建一个宏,用于提供 YAML 配置,力求做到一切都正确。虽然其功能非常有限,但它会有适当的测试、错误处理、文档等,使它(几乎)可以供其他人使用。

这非常棒,因为发布一个库意味着你的宏可能会在其他开发者中得到应用,丰富你所喜爱的编程语言的生态系统。即使是公司内部编写的、专门为特定用例设计的库,开源也能带来好处。人们可能会发现 bugs,或者提交带有修复和改进的拉取请求,帮助你提升代码质量,从而使每个人都受益。本章还提供了一个机会,将一些杂项话题结合在一起,比如功能特性,它们可以帮助你使宏尽可能轻量化。

10.1 一个类似函数的配置宏

让我们来看一下我们宏的第一个版本,它暴露了一个名为 config 的函数式宏。调用该宏会生成一个名为 Config 的结构体,其中包含一个 HashMap<String, String>。我们还生成了一个新的方法,用于填充配置属性的映射。

这意味着,以下 YAML 配置和调用 new 方法将生成一个包含 userpassword 键,并具有对应值 "admin""pass" 的映射:

user: "admin"
password: "pass"

为了简单起见,我们不允许嵌套的 YAML 结构——只允许键值对,其中键为字符串,值为字符串。

10.1.1 宏项目结构

我们再次选择了一个包含两个目录(config-macroconfig-macro-usage)的项目结构,并使用可选的 Cargo 工作空间。在这两个目录的同一级别上,还有一个名为 configuration 的目录,其中包含一个示例的 config.yaml 文件。

列表 10.1 示例配置

user: "admin"
password: "admin"

在我们的 usage 目录中,已经添加了 trybuild 依赖,用于编译测试。

列表 10.2 config-macro-usage 中的 Cargo.toml 依赖

[dependencies]
config-macro = { path = "../config-macro", features = ["struct"] }

[dev-dependencies]
trybuild = "1.0.85"

宏本身有 serdeserde_yaml 依赖,用于读取 YAML 文件。

列表 10.3 config-macro 中的 Cargo.toml

[dependencies]
quote = "1.0.33"
syn = { version = "2.0.39", features = ["extra-traits"]}
proc-macro2 = "1.0.69"
serde = "1.0.192"
serde_yaml = "0.9.27"

工作空间看起来像以下所示:

列表 10.4 config-macro-usage 中的 Cargo.toml

[workspace]
resolver = "2"

members = [
    "config-macro",
    "config-macro-usage"
]

config-macro-usage 中,main.rs 包含了如何使用宏的示例,以及一些成功路径的测试。还有一个 tests 目录,其中包含编译失败测试,出于简洁考虑这些测试没有展示,但可以在本书的代码仓库中找到(github.com/VanOvermeir…)。

列表 10.5 main.rs 中的使用示例和测试

fn main() {
    config!();
    let cfg = Config::new();
    let user = cfg.0.get("user").unwrap();
    println!("{user}");
}

// 一些成功路径的测试

接下来,让我们看看实现部分。

10.1.2 代码概述

宏的代码包含了你之前见过的内容,因此我们将简要介绍。我们采用了模块化的方法,已经创建了 input.rsoutput.rs 文件,除了 lib.rs 之外。

首先从 lib.rs 开始,它使用来自 input.rs 的结构体读取 token 流输入,并通过一个辅助函数生成输出。它还包含了我们宏的一些核心逻辑:查找和读取 YAML 文件。我们会查找具有默认路径或重写路径的文件,并将该文件传递给 serde_yaml,将其转换为 HashMap<String, String>。错误会得到妥善处理,避免了 panic。

列表 10.6 lib.rs 协调辅助函数

// 导入,mod input, mod output

fn find_yaml_values(input: ConfigInput)
    -> Result<HashMap<String, String>, syn::Error> {
    let file_name = input.path #1
        .unwrap_or_else(|| {
            "./configuration/config.yaml".to_string()
        });                           

    let file = fs::File::open(&file_name)
        .map_err(|err| {
            syn::Error::new(
                Span::call_site(),
                format!(
                    "could not read config with path {}: {}",
                    &file_name,
                    err
                )
            )
        })?;                           #2
    Ok(serde_yaml::from_reader(file) #3
        .map_err(|e| {
            syn::Error::new(Span::call_site(), e.to_string())
        })?)                  
}

#[proc_macro]
pub fn config(item: TokenStream) -> TokenStream {
    let input: ConfigInput = parse_macro_input!(item);      #4
    match find_yaml_values(input) { #5
        Ok(values) => generate_config_struct(values).into(),
        Err(e) => e.into_compile_error().into()
    }                         
}

#1 使用重写路径,或回退到默认配置文件位置 #2 打开文件,如果失败,返回一个有用的错误信息 #3 使用 serde_yaml 读取内容并转换为 HashMap #4 使用自定义结构体解析输入 #5 使用 find_yaml_values 读取配置,并将其传递给生成输出的函数

input.rs 解析传入的 TokenStream。目前,它期望一个可选的参数:用于重写配置文件位置的路径,我们为此创建了一个自定义关键字。因为我们预见到将来可能会有其他参数,我们采用了键值对风格的方法(path = "./path.yaml"),这使得以后添加更多键变得更加容易。我们还确保了正确的错误处理。

列表 10.7 input.rs 中的解析代码

// syn 导入

pub(crate) mod kw {
    syn::custom_keyword!(path);
}

#[derive(Debug)]
pub struct ConfigInput {
    pub path: Option<String>,
}

impl Parse for ConfigInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        if input.is_empty() {
            return Ok(ConfigInput {
                path: None,
            });
        }

        if !input.peek(kw::path) {
            return Err(
                syn::Error::new(
                    input.span(),
                    "config macro only allows for 'path' input",
                )
            );
        }

        let _: kw::path = input.parse()
            .expect("checked that this exists");
        let _: Token!(=) = input.parse()
            .map_err(|_| syn::Error::new(
                input.span(),
                "expected equals sign after path"
            ))?;
        let value: LitStr = input.parse()
            .map_err(|_| syn::Error::new(
                input.span(),
                "expected value after the equals sign"
            ))?;

        Ok(ConfigInput {
            path: Some(value.value()),
        })
    }
}

output.rs 看起来也非常熟悉。至少,在经过前几章的学习后,我希望你对它已经很熟悉了。它从配置文件中获取值,创建结构体并实现一个新方法。

列表 10.8 output.rs 创建正确的输出

// 导入

fn generate_inserts(yaml_values: HashMap<String, String>) #1
    -> Vec<TokenStream> {
    yaml_values.iter().map(|v| {
        let key = v.0;
        let value = v.1;
        quote!(map.insert(#key.to_string(), #value.to_string());)
    }).collect()
}                 

pub fn generate_config_struct(yaml_values: HashMap<String, String>)
    -> TokenStream {
    let inserts = generate_inserts(yaml_values);
    quote! { #2
        pub struct Config(
            pub std::collections::HashMap<String,String>
        );

        impl Config {
            pub fn new() -> Self {
                let mut map = std::collections::HashMap::new();
                #(#inserts)*
                Config(map)
            }
        }
    }
}

#1 这个函数生成我们需要插入到映射中的所有单个插入 #2 在这里,我们生成结构体和实现。

这样,我们就可以从配置中生成一个结构体了。

10.1.3 使用完整路径

这段代码中一个新的、有趣的地方是我们使用了 HashMap 的完整路径(例如 struct Config(pub std::collections::HashMap<String, String>); 以及 new 方法中的类似用法)。为什么要这样做呢?有两个原因。

首先,HashMap 并未包含在 Rust 的预导入中,所以在我们的用户代码中默认无法使用。如果没有使用完整路径,Rust 将不知道这个类型来自哪里,这将导致我们的代码出现错误,并且编译器会建议导入标准库中的 HashMap

error[E0412]: cannot find type `HashMap` in this scope
 --> config-macro-usage/src/main.rs:5:5
  |
5 |     config!();
  |     ^^^^^^^^^ not found in this scope
  |

试想一下发生了什么。我们正在向一个 Rust 文件中添加代码。如果我们添加 struct Config(HashMap<String, String>);,而没有指定路径,实际上我们是在引用一个在用户代码中不熟悉的类型,而不是我们自己代码中的类型。用户必须修复这个问题,可能是通过导入 std::collections::HashMap。也许其中一些用户非常幸运,已经有了相应的导入。在这种情况下,他们可能会在没有意识到任何宏编译问题的情况下继续编写代码。

然而,用户操作并不是一个好的结果。这不仅不够友好,而且直接暴露了我们正在使用 HashMap,这是一个我们可能想要隐藏的实现细节。如果我们隐藏了 HashMap,如果有需要更改为其他类型,将会更加容易,而不需要更改现有的应用程序代码。因此,通过包含完整路径,我们可以让客户端的使用变得更轻松,同时也能隐藏(某些)实现细节。

第二个原因是,用户可能已经在代码中使用了一个 HashMap,但它可能并不是我们所期望的那个类型。也许他们创建了一个同名的结构体:

struct HashMap {}

如果我们的宏没有使用完整路径,那么这些用户会遇到一个令人困惑的错误,因为 std::collections::HashMap 需要泛型参数,而本地定义的 HashMap 没有泛型参数:

error[E0107]: this struct takes 0 generic arguments but 2 generic
 arguments were supplied

这是我们必须避免的,因为随着项目和宏的规模增大,应用代码与库代码之间发生冲突的可能性也会增大。所以在生成的代码中始终使用完整路径是一种良好的实践。即使是像 std::vec::Vec 这样的预导入项,使用完整路径也可能是值得的,因为这些在某些环境中可能不可用,或者可能会被应用代码中声明的内容覆盖。

STRING OR STR

在本书中,我经常使用 String 类型而不是字符串切片(str),部分原因是 String 是一个简单且常见的类型,有时也是唯一正确的选择。在其他一些情况下,使用字符串帮助避免了与讨论无关的复杂性。

但是就这一次,我们来考虑一下字符串切片,因为它们是一个有效的替代方案,并且在这个特定示例中并不难使用。毕竟,我们正在生成一个新方法,其中有硬编码的值。这些字面量是静态的,意味着我们可以使用具有静态生命周期的切片:struct Config(pub std::collections::HashMap<&'static str, &'static str>);。除了这个签名,只有 generate_insert 需要改变,因为它不再需要调用 to_string

fn generate_inserts(yaml_values: HashMap<String, String>)
    -> Vec`<TokenStream>` {
    yaml_values.iter().map(|v| {
        let key = v.0;
        let value = v.1;
        quote!(map.insert(#key, #value);)
    }).collect()
}

pub fn generate_config_struct(yaml_values: HashMap<String, String>)
    -> TokenStream {
    let inserts = generate_inserts(yaml_values);
    quote! {
        pub struct Config(
            pub std::collections::HashMap<&'static str, &'static str>
        );
        // unchanged new method
    }
}

10.2 添加另一个宏

目前,我们暴露了一个非常好的类似函数的宏。但是,如果我们的用户更喜欢使用属性宏来修改现有的结构体,而不是生成新的结构体呢?为什么不提供这个选项呢?虽然一个 proc-macro 库只能暴露过程宏(而不能暴露普通函数、结构体等),但一个库可以有任意数量的宏,正如在第 9.9 节中简要提到的那样。

现在,让我们自己来实现这个功能。我们不会展示新的使用示例或测试,而是专注于宏目录中的更改部分。我们重用了 ConfigInput 结构体来解析属性 TokenStream。如果它包含一个自定义路径(例如 (path = …)),我们可以自动处理它。为了获取注解结构体的相关细节,我们使用 DeriveInput,原因和之前一样(即它是默认可用的,且适用于我们的用例),然后将其传递给输出生成器。

示例 10.9:附加宏

#[proc_macro_attribute]
pub fn config_struct(attr: TokenStream, item: TokenStream)
    -> TokenStream {
    let input: ConfigInput = parse_macro_input!( #1
        attr
    );                         
    let ast: DeriveInput = parse_macro_input!( #2
        item
    );                         

    match find_yaml_values(input) {
        Ok(values) => generate_annotation_struct(ast, values) #3
            .into(),                     
        Err(e) => e.into_compile_error()
            .into()
    }
}
  • #1 重用了 ConfigInput
  • #2 将属性宏解析为 DeriveInput
  • #3 将解析后的结果传递给输出函数。

input.rs 没有任何变化,它仍然按预期工作。但 output.rs 必须生成不同类型的输出:它需要重新创建结构体、它的新方法和字段声明。

示例 10.10:重新创建结构体

// imports 和之前的代码

fn generate_fields(yaml_values: &HashMap<String, String>)
    -> Vec<TokenStream> {
    yaml_values.iter().map(|v| {
        let key = Ident::new(v.0, Span::call_site());
        quote! {
            pub #key: String
        }
    }).collect()
}

fn generate_inits(yaml_values: &HashMap<String, String>)
    -> Vec<TokenStream> {
    yaml_values.iter().map(|v| {
        let key = Ident::new(v.0, Span::call_site());
        let value = v.1;
        quote! {
            #key: #value.to_string()
        }
    }).collect()
}

pub fn generate_annotation_struct(
        input: DeriveInput,
        yaml_values: HashMap<String, String>
    ) -> TokenStream {
    let attributes = &input.attrs;
    let name = &input.ident;
    let fields = generate_fields(&yaml_values);
    let inits = generate_inits(&yaml_values);

    quote! {
        #(#attributes)*             #1
        pub struct #name {
            #(#fields,)*
        }

        impl #name {
            pub fn new() -> Self {
                #name {
                    #(#inits,)*
                }
            }
        }
    }
}
  • #1 保留现有的属性。

到目前为止,这一切都非常简单——只是我一时忘记了必须将键转换为标识符,因此遇到了一个预期的标识符错误。确保也使用了 proc_macro2 中的 Ident。如果你的 IDE 选择了 proc_macro 中的那个,你会收到警告,指出 proc_macro::Ident: ToTokens 特性约束未满足。quote 期望一个 ToTokens 实现,将你的代码转换为 TokenStream,而另一个类型的 Ident 缺少这个实现。

有了这段代码,我们可以选择 config!#[config_struct] ——或者两者都用!但这可能不太可取。

10.3 特性(Features)

有多个宏执行相同工作的一大缺点是,我们迫使用户引入他们可能不需要的很多代码。为了避免这种情况,我们可以使用特性(features)。你可能已经熟悉特性了,但为了确保准确,特性——有时也叫做特性标志——允许我们将代码的某些部分设为可选。只有当用户决定他们需要某个特性并启用它时,相关部分才会被引入到项目中。

让我们将属性宏设为可选,隐藏在名为 struct 的特性后面(因为我们需要一个结构体来使用它)。首先,我们在宏的 Cargo.toml 中添加它。

示例 10.11:Cargo.toml 中的特性

[features]
struct = []

接下来,为了简化,我们将所有生成代码的函数(如 generate_fieldsgenerate_initsgenerate_annotation_struct)从 output.rs 移动到一个单独的文件 struct_output.rs

现在我们为库添加特性配置。我们为属性宏的入口点添加了 #[cfg(feature = "struct")] 注解,这意味着它只有在启用 struct 特性时才会被包含。我们也可以对 DeriveInput 导入做同样的处理。更重要的是,我们的新文件模块 struct_output 仅在特性激活时导入。这就是我们将这些函数移到单独文件的原因:它使得一次性隐藏所有三个函数变得容易。为了避免额外的导入,我使用了 struct_output::generate_annotation_struct 的完整路径。

示例 10.12:lib.rs 使用特性

// 其他导入,mod input,mod output
#[cfg(feature = "struct")]              #1
use syn::DeriveInput;
#[cfg(feature = "struct")]     
mod struct_output;

// find_yaml_values
// 函数式宏

#[cfg(feature = "struct")]               #2
#[proc_macro_attribute]
pub fn config_struct(attr: TokenStream, item: TokenStream)
    -> TokenStream {
    let input: ConfigInput = parse_macro_input!(attr);
    let ast: DeriveInput = parse_macro_input!(item);

    match find_yaml_values(&input) {
        Ok(values) => struct_output::generate_annotation_struct( #3
                ast, values, &input.exclude_from
            ).into(),                        
        Err(e) => e.into_compile_error().into()
    }
}
  • #1 这些导入只有在设置了“struct”标志时才会启用。
  • #2 我们的宏入口点也做了同样的处理。
  • #3 使用完整路径调用函数,否则我们就需要一个特性相关的额外导入。

你可以通过在 config-macro-usage 中运行这段代码来验证我们的代码是否正确。

示例 10.13:使用示例

use config_macro::config_struct;

#[config_struct]
#[derive(Debug)]              #1
struct ConfigStruct {}

fn main() {
    let config = ConfigStruct::new();
    println!("{config:?}");           
}
  • #1 为结构体添加 Debug 派生以支持打印。

这会失败,因为属性宏现在是未知的。另一方面,函数式宏仍然可以继续工作。将对宏库的依赖修改为 config-macro = { path = "../config-macro", features = ["struct"] },一切将重新正常工作。

这意味着,喜欢使用属性宏而非函数式宏的用户无法排除后者。所以我们也可以将它隐藏在一个特性后面——或许叫做 functional。这是否意味着我们的代码默认不会暴露任何宏(虽然从技术上讲是允许的,但还是很奇怪)?在这种情况下,添加一个默认特性可能是个好主意。这样,用户可以通过 config-macro = { path = "../config-macro", features = ["struct"], default-features = false } 去除它,但我们至少仍然默认暴露一个宏:

[features]
default = ["functional"]
struct = []
functional = []

这涵盖了如何避免不需要的库代码。但我们生成的代码呢?假设属性宏还生成了一个 From 实现,这样可以将结构体转换为 HashMap,以防有人更喜欢这样做而不是访问字段:

let cfg = MyConfigStruct::new();
let as_map: HashMap<String, String> = cfg.into();

并不是每个人都会使用这个方法,但照目前的情况,它仍然会被生成。

注意: 是的,Rust 可能会通过移除未使用的函数等来优化你的代码。但这并不能保证一定会发生。而且移除未使用的代码仍然需要时间。因此,一开始就不生成它是值得的。

也许我们可以避免这种情况。一个选项是向我们的宏添加一个属性,表示我们希望排除某些代码生成。我们将其称为 exclude。目前,唯一允许的值是 from,它确保不会生成 From 实现:

#[config_struct(exclude = "from")]
struct ConfigStruct {}

为了支持这个属性,我们需要稍微调整 input.rs 的解析。我们添加了 exclude 关键字,一个 exclude_from 布尔属性,并解析 exclude 的代码。如果其字符串值为 from,则将该属性设置为 true。在所有其他情况下,设置为 false。以下代码有个缺点,只允许使用一个属性:pathexclude。如果你想解决这个问题,可以参考练习部分。

示例 10.14:ConfigInput 的附加属性

// syn 导入

pub(crate) mod kw {
    syn::custom_keyword!(path);
    syn::custom_keyword!(exclude);         #1
}

#[derive(Debug)]
pub struct ConfigInput {
    pub path: Option<String>,
    pub exclude_from: bool,         #2
}

impl Parse for ConfigInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        if input.is_empty() {
            // 与之前的代码类似;exclude 为 false
        } else if input.peek(kw::path) {
            // 与之前的代码类似;exclude 为 false
        } else if input.peek(kw::exclude) {
            let _: kw::exclude = input.parse()
                .expect("检查过此关键字");
            let _: Token!(=) = input.parse()
                .map_err(|_| syn::Error::new(
                    input.span(),
                    "expected equals sign after path"
                ))?;
            let value: LitStr = input.parse()
                .map_err(|_| syn::Error::new(
                    input.span(),
                    "expected value after the equals sign"
                ))?;
            let exclude_from = value.value() == "from";

            Ok(ConfigInput {
                path: None,
                exclude_from, #3
            })                     
        }
        // 错误处理
    }
}
  • #1 我们添加了 exclude 关键字。
  • #2 我们需要一个额外的属性来捕获排除信息。
  • #3 如果关键字存在并且匹配字符串 "from",则将属性设置为 true

其他的变化位于 struct_output.rs 中。两个函数(generate_inserts_for_fromgenerate_from_method)帮助我们生成正确的 From 实现。同时,generate_annotation_structlib.rs 接收一个布尔值,我们从 ConfigInput 中获取该值。根据其值,我们要么调用新函数生成输出,要么生成一个空的 TokenStream

示例 10.15:struct_output.rs 中的附加方法和更改

// 导入,generate_fields,generate_inits

fn generate_inserts_for_from(yaml_values: &HashMap<String, String>) #1
    -> Vec<TokenStream> {
    yaml_values.iter().map(|v| {
        let key = v.0;
        let key_as_ident = Ident::new(key, Span::call_site());
        quote!(map.insert(#key.to_string(), value.#key_as_ident);)
    }).collect()
}                     

fn generate_from_method(
        name: &Ident,
        yaml_values: &HashMap<String, String>
    ) -> TokenStream {
    let inserts = generate_inserts_for_from(yaml_values);
    quote! {
        impl From <#name> for std::collections::HashMap<String,String> {
            fn from(value: #name) -> Self {
                let mut map = std::collections::HashMap::new();
                #(#inserts)*
                map
            }
        }
    }
}                     

pub fn generate_annotation_struct(
        input: DeriveInput,
        yaml_values: HashMap<String, String>,
        exclude_from_method: &bool
    ) -> TokenStream {
    // 其他的 token 流
    let from = if !exclude_from_method { #2
        generate_from_method(name, &yaml_values)
    } else {
        quote!()
    };                  
    quote! {
        // 结构体和方法生成
        #from
    }
}

#1 额外的函数帮助生成 From 实现。 #2 如果布尔值为 false,则生成 From 实现,否则返回空流。

使用这种替代方法的一个优点是它非常灵活。毕竟,这是手工编写的代码——它可以做任何事情。比如说,我们可以检索一些环境变量,将它们与本地文件进行比较,然后根据结果决定生成什么内容。但是在许多情况下,包括本例中,您可以通过特性实现目标(请参阅练习部分)。除非有非常充分的理由,否则您应该优先使用内置工具,而不是自定义解决方案。

注意: 在 Rust 中,特性应该是“增量”的,意味着它们增加了功能,但永远不会禁用已存在的功能。Cargo 在解析依赖时依赖于此规则。我们的宏遵循这个规则,因为它的两个特性(structfunctional)都是添加宏。然而,如果理论上我们使用特性来排除默认集中的 From 实现,那就违反了这个规则。

10.4 文档化宏

如果你想编写一个其他人也会使用的宏,文档化是必不可少的。没有人喜欢在使用时发现没有文档的 crate 或项目。虽然这不是我的职责去告诉你应该文档化什么内容——那取决于具体情况——但我可以向你展示一些可用的选项。

除了用于内部使用的普通代码注释(//),我们还有外部文档注释(///),它用于为单个项提供文档。也就是说,我们可以为我们的宏添加项特定的文档。以下列出了 config 宏的一个简短示例。

示例 10.16:为 config 宏编写外部文档

/// This function-like macro will generate a struct called `Config`
/// which contains a ‘HashMap<String,String>’ with all
/// the yaml config properties.
#[proc_macro]
pub fn config(item: TokenStream) -> TokenStream {
    // 实现
}

如果你运行 cargo doc --open,你将看到专门为 config 宏生成的文档。

我们在第 6 章提到了 doctests 的存在。我们可以为我们的另一个宏添加一个 doctest。因为这些测试会在执行 cargo test 时运行,所以你应该确保它们实际上能工作——这就是为什么我们需要导入并指定配置路径的原因,因为 lib.rs 和我的使用示例所在的位置不同。请注意我对 cfg 做的更改。如果没有它,Rust 将不会为这个结构体生成文档,因为它被隐藏在一个特性后面。添加 doc 配置文件是使它可见的一种方式。

示例 10.17:为 config_struct 宏编写外部文档

/// This macro allows manipulation of an existing struct
/// to serve as a 'config' struct.
/// It will replace any existing fields with those present
/// in the configuration.
///
/// ```rust
/// use config_macro::config_struct;
///
/// #[config_struct(path  = "./configuration/config.yaml")]
/// struct Example {}
///
/// // Example now has a new method
/// let e = Example::new();
///
/// // e now contains a 'user' field that we can access
/// println!("{}", e.user);
/// ```
///
#[cfg(any(feature = "struct", doc))]  #1
#[proc_macro_attribute]
pub fn config_struct(attr: TokenStream, item: TokenStream)
    -> TokenStream {
    // 实现
}
#1 该属性会在启用 `struct` 特性或 `doc` 配置文件时激活。

现在我们的文档就像图 10.1 中的示例,具有更多信息(比如之前的测试),并通过超链接提供。

image.png

同时,内部文档注释(//!)用于为整个文件或 crate 提供文档。我们可以在 lib.rs 中添加以下内容:

//! ## Documentation from lib.rs
//! Here is documentation placed directly within lib.rs...

// imports, code

另一个巧妙的技巧是,你可以从外部 Markdown 文档导入文档。这可以让你的 Rust 文件保持更简洁,避免重复信息,并且写起来可能更容易。以下是位于 config-macro 目录中的 README.md 文件示例:

# Config Macro

## Overview

This crate contains macros that allow you to transform yaml config
into a struct that you can use in your application.

## Usage

Left out for brevity's sake.

我们现在应该在 lib.rs 的顶部添加以下命令:

#![doc = include_str!("../README.md")]

//! ## Documentation from lib.rs
//! Here is documentation placed directly within lib.rs.

// imports, code

导入的文本将出现在内联内部文档注释之上。这样,我们就为我们的 crate 添加了一些通用文档,放在之前编写的具体(外部)文档之上(见图 10.2)。

10.5 发布我们的宏

我们的宏现在已经经过测试,能够负责任地处理错误,拥有文档,并且——同样很重要——做了一些别人可能会觉得有用的事情。也许是时候使用 cargo publish 命令将其发布到 crates.io 上了。

注意:不过请不要发布这个示例。我怀疑 Rust 社区会欣赏成百上千个功能有限的相同配置宏。

image.png

在我们发布之前,我们应该在 config-macro 目录的 Cargo.toml 文件中添加更多信息。以下是添加了一些有用字段的示例。

Listing 10.18 config-macro Cargo.toml 添加内容

[package]
name = "config-macro"
version = "0.1.0"
edition = "2021"

description = "Macros for using config as a struct within your app"
license = "MIT"
homepage = "https://github.com/some-page"
repository = "https://github.com/some-page"
readme = "README.md"                                 #1
keywords = ["configuration", "yaml", "macro"]

# dependencies, lib, features

#1:这是默认的配置,因此将其添加到此处不是必需的。

通常,Rust 建议在编写库时不要提交 Cargo.lock 文件,因此最好现在将该文件添加到 .gitignore 中。如果你已经提交了该文件,请从 Git 中移除它,然后更新 .gitignore

你可能还注意到,我将文档和发布信息放在了 config-macro 目录中。原因是发布我们的使用或配置目录没有太大意义。这些目录用于测试库,但没有提供任何功能,因此你可能只想发布 config-macro 目录,而不是整个项目。最后有两个提示:你需要有发布到 crates.io 的凭据,cargo publish 还有一个 --dry-run 选项,可以先用它来试一下。

注意:发布是我们选择使用两个目录结构的原因之一,因为目前无法发布带有路径依赖的 crate。这意味着我们在早期章节展示的“三个目录”结构更难发布,因为在这种情况下,我们的宏入口点在一个库中,而它的实现通过路径依赖拉入。这并不意味着在这种情况下发布不可能,但在—如果有的话—允许带路径依赖的发布之前,确实会带来一些复杂性。

一旦 crate 发布并稳定下来,达到版本 1,开发者会期望你在更新库时使用语义版本控制(也称为 semver)。这意味着修复 bug 会增加补丁版本(例如,1.1.0 => 1.1.1),向后兼容的功能会增加次版本(例如,1.1.0 => 1.2.0),而不兼容的更改会增加主版本(例如,1.0.0 => 2.0.0)。在 Rust 中,这一点尤其重要,因为 Cargo 允许你仅通过主版本来指定依赖项,例如 trybuild = "1"。引入破坏性更改而没有增加主版本,可能会导致项目突然和意外地中断。

10.6 现实世界中的例子

许多 Rust 库都有出色的文档。如果你查看本书中讨论的库的 lib.rs,你会发现它们不仅有内部文档,还对最重要的功能提供了外部文档。我会让你自己探索它们的文档。

相反,我们可以简要讨论特性和多个宏。你是否遇到过这样的情况?你将 serde 添加到项目中,并在结构体上添加 derive(Deserialize),但是 Rust 不知道该怎么做,因为你忘了宏是隐藏在一个特性背后的。毫不奇怪,你会在 serde/Cargo.toml 中找到 serde 特性列表,其中包括一个名为 derive 的特性。它还会激活 serde_derive 依赖项:

[features]
default = ["std"]
# Provide derive(Serialize, Deserialize) macros.
derive = ["serde_derive"]
# etc.

serde/lib.rs 中,你会找到两个 derive 宏的重新导出,它们被隐藏在该特性背后:

#[cfg(feature = "serde_derive")]
#[macro_use]
extern crate serde_derive;

#[cfg(feature = "serde_derive")]
pub use serde_derive::{Deserialize, Serialize};

以 Leptos 为例,它有一个 leptos_macro 目录,其中 lib.rs 导出了一个 derive 宏(Params),两个属性宏(servercomponent),以及两个函数式宏(templateview)。它有用于服务器端渲染和跟踪的特性标志。

为了完成本章,我们将详细探讨 #[tokio::main] 是如何从头到尾工作的。在 Tokio 仓库的 tokio 目录下,我们找到一个特性列表,其中包括 macros 特性。它指向 tokio-macros 依赖项。

Listing 10.19 Tokio 的根 Cargo.toml

[features]
macros = ["tokio-macros"]
# ...
[dependencies]
tokio-macros = { version = "1.7.0", path = "../tokio-macros", optional = true }

转到 tokio-macros 目录。在其 Cargo.toml 文件中,我们发现这确实是一个过程宏库(proc-macro = true),并包含了常见的依赖项(synquoteproc-macro2)。继续查看 lib.rs,我们看到它暴露了多个属性宏,包括 #[tokio::test]#[tokio::main]。每个宏都有广泛的外部文档——例如,main 宏的文档大约有 170 行。我们可以看到,属性和这个属性宏装饰的项都被 entry::main 函数使用。Listing 10.20 中提到的解决方法是由于这个函数名为 main,在 Rust 中,该名称作为入口点具有特殊用途。

Listing 10.20 tokio-macros 目录中的 lib.rs

/// Marks async function to be executed by the selected runtime.
/// ...
#[proc_macro_attribute]
#[cfg(not(test))] // Work around for rust-lang/rust#62127
pub fn main(args: TokenStream, item: TokenStream) -> TokenStream {
    entry::main(args, item, true)
}

entry::main 中,首先将项解析为 ItemFn。换句话说,Tokio 期望一个函数,如果不是函数,则会将 syn 错误转换为编译错误。接下来,经过几个检查后,基于通过 AttributeArgs 解析的属性创建配置(如前所述,AttributeArgssyn 版本 2 中不再可用)。

如果输入和配置有效,parse_knobs 会创建输出流。

Listing 10.21 entry.rs 中的宏入口点

pub(crate) fn main(args: TokenStream, item: TokenStream, multi_thread: bool)
    -> TokenStream {
    let input: syn::ItemFn = match syn::parse(item.clone()) { #1
        Ok(it) => it,
        Err(e) => return token_stream_with_error(item, e),
    };                 

    let config = if input.sig.ident == "main"
    && !input.sig.inputs.is_empty() {
        let msg = "the main function cannot accept arguments";
        Err(syn::Error::new_spanned(&input.sig.ident, msg))
    } else {
        AttributeArgs::parse_terminated #2
            .parse(args)
            .and_then(|args| build_config(
                input.clone(),
                args,
                false,
                multi_thread))
    };                

    match config {
        Ok(config) => parse_knobs(input, false, config), #3
        Err(e) => token_stream_with_error(
            parse_knobs(input, false, DEFAULT_ERROR_CONFIG),
            e
        ),
    }            
}
#1 解析输入为函数,或返回错误
#2 如果函数签名有效,解析属性并构建配置
#3 如果函数和配置有效,生成输出

接下来的代码清单展示了 build_config,我们将分两部分展示。在确保期望的 async 关键字存在后,会创建一个新的可变配置。接下来,代码会遍历可用的参数,将每个键值对(NameValue)逐个添加到配置中。如果发现未知的标识符,则返回错误。

Listing 10.22 entry.rs 中的 build_config(第 1 部分)

fn build_config(
    input: syn::ItemFn, args: AttributeArgs,
    is_test: bool, rt_multi_thread: bool,
) -> Result<FinalConfig, syn::Error> {
    if input.sig.asyncness.is_none() {
        let msg = "the `async` keyword is missing ...";
        return Err(syn::Error::new_spanned(input.sig.fn_token, msg));
    }
    let mut config = Configuration::new(is_test, rt_multi_thread);
    let macro_name = config.macro_name();

    for arg in args {
        match arg {
            syn::NestedMeta::Meta(syn::Meta::NameValue(namevalue)) => {
                let ident = namevalue.path.get_ident()
                    .ok_or_else(|| {
                        syn::Error::new_spanned(
                            &namevalue,
                            "Must have specified ident"
                        )
                    })?
                    .to_string().to_lowercase();
                match ident.as_str() {
                    "worker_threads" => {
                        config.set_worker_threads(
                            namevalue.lit.clone(),
                            syn::spanned::Spanned::span(&namevalue.lit),
                        )?;
                    }
                    // more matching
                    name => {
                        let msg = format!(
                            "Unknown attribute {} is specified ...",
                            name,
                        );
                        return Err(syn::Error::new_spanned(namevalue, msg));
                    }
                }
            }
            // ...
}

接下来的代码清单展示了,尽管 Tokio 只允许属性使用键值对,但 match 会检查路径值是否存在。为什么这么做?是为了提供有用的错误信息!给定的路径属性会与已知名称进行匹配。这样,错误可以给出如何设置该特定参数的提示。例如,给函数添加 #[tokio::main(multi_thread)] 会导致错误:Set the runtime flavor with #[tokio::main(flavor = "multi_thread")]。对于未知的路径参数以及其他未识别的参数,会抛出一个通用的“未知属性”错误,并指向正确的令牌。

Listing 10.23 entry.rs 中的 build_config(第 2 部分)

fn build_config(
    input: syn::ItemFn, args: AttributeArgs,
    is_test: bool, rt_multi_thread: bool,
) -> Result<FinalConfig, syn::Error> {
    // ...
    syn::NestedMeta::Meta(syn::Meta::Path(path)) => {
        let name = path
            .get_ident()
            .ok_or_else(|| syn::Error::new_spanned(
                &path,
                "必须指定标识符"
            ))?
            .to_string()
            .to_lowercase();
        let msg = match name.as_str() {
            "threaded_scheduler" | "multi_thread" => {
                format!(
                    "使用 ... 设置运行时的风格", #1
                    macro_name
                )
            }             
            // 针对其他可能的属性和未知属性执行相同的操作
        };
        return Err(syn::Error::new_spanned(path, msg));
    }
    other => {
        return Err(syn::Error::new_spanned( #2
            other,
            "宏中有未知的属性",
        ));
    }               
}
config.build()

#1 Tokio 不接受路径参数。但如果存在路径参数,它会尝试给出一个有用的错误信息。 #2 对未知参数给出一个通用的错误,并指向出错的令牌(other)。

parse_knobs 的代码过长,无法完全展示,因此我省略了一些只与测试宏相关的内容,以及处理 tokio 包重命名的代码。首先,输入的签名会被修改,移除 async 关键字。这是因为 Rust 不允许其 main 函数是异步的。因此,在移除 async 后,Tokio 会构建一个运行时,并在现有代码(位于 body 中)上进行阻塞,从而不再需要异步性。还有两点需要注意:

  • 根据传入的布尔值,头部(header)部分会有条件地生成,类似于本章中使用 exclude 时的方式。
  • 禁用了一些 Clippy 的警告,比如在构建 Runtime 时使用 expect 的警告。你不希望向用户显示无法解决的警告。

Listing 10.24 entry.rs 中的 parse_knobs

fn parse_knobs(mut input: syn::ItemFn, is_test: bool, config: FinalConfig)
    -> TokenStream {
    input.sig.asyncness = None;

    let mut rt = // ...           #1
    if let Some(v) = config.worker_threads {
        rt = quote! { #rt.worker_threads(#v) }; #2
    }                      
    // 更多配置

    let header = if is_test { #3
        quote! {
            #[::core::prelude::v1::test]
        }
    } else {
        quote! {}
    };                  

    let body = &input.block;
    let brace_token = input.block.brace_token;
    let body_ident = quote! { body };
    let block_expr = quote_spanned! {last_stmt_end_span=>
        #[allow(clippy::expect_used,
        [clippy::diverging_sub_expression)]]
        {
            return #rt
                .enable_all()
                .build()
                .expect("构建 Runtime 失败")
                .block_on(#body_ident); #4
        }
    };                     

    input.block = syn::parse2(quote! {
        {
            #body
            #block_expr
        }
    }).expect("解析失败");
    input.block.brace_token = brace_token;

    let result = quote! {
        #header
        #input
    };

    result.into()             #5
}

#1 创建基本的运行时配置 #2 如果存在额外信息,则将其添加进去 #3 头部部分有条件地添加到生成的代码中 #4 构建运行时,并在函数的现有内容上进行阻塞 #5 在将所有内容添加到结果中后返回

通过这些内容,你就大致了解了这个著名的宏是如何工作的。

10.7 未来的方向

恭喜你读完了本书!我们一起探索了声明式和过程式宏在各种用例中的应用。有时我们向结构体和枚举添加方法;在其他情况下,我们更改了它们的字段,或修改了函数签名和返回值。此外,你还学习了测试、错误处理和文档编写,并看到了许多实际的例子。

尽管如此,还有一些有趣的工具和函数未被介绍。例如,synstructuredocs.rs/synstructur…)是一个帮助你实现 derive 宏的工具;proc-macro-cratecrates.io/crates/proc…)帮助你找到宏所在的 crate 名称,即使它在用户的应用中被更改。并且,syn 的功能标志后隐藏着 visitdocs.rs/syn/latest/…)和 folddocs.rs/syn/latest/…),这是两个强大的助手,可以遍历类似表达式的节点。你可以在文档中找到关于如何使用这两个工具的信息。你也可以查看我写的关于 Fold 的博客文章(mng.bz/DdjR),它可以为你提供一些灵感或指导。此外,还有许多使用宏以不同寻常、创新、富有创意的方式的库,等待你去探索。如果遇到困难,你可以在 Stack Overflow 和 Reddit 上找到很多乐于回答宏相关问题的人。

感谢你阅读这本书。希望这些内容对你有所帮助。最后,用 AWS 的 Werner Vogels 的话来结束:“现在去构建吧!”