本章内容:
- 使用单个库暴露多个宏
- 通过功能特性来添加或禁用功能
- 使用属性控制代码生成
- 为宏库编写文档并发布
- 探索本书之外的有趣宏主题
在前几章中,我们经常为采取捷径做了些解释,说明“生产级”宏会如何更好或以不同的方式完成这些任务。在这一章——我们最后的一章中——我们将创建一个宏,用于提供 YAML 配置,力求做到一切都正确。虽然其功能非常有限,但它会有适当的测试、错误处理、文档等,使它(几乎)可以供其他人使用。
这非常棒,因为发布一个库意味着你的宏可能会在其他开发者中得到应用,丰富你所喜爱的编程语言的生态系统。即使是公司内部编写的、专门为特定用例设计的库,开源也能带来好处。人们可能会发现 bugs,或者提交带有修复和改进的拉取请求,帮助你提升代码质量,从而使每个人都受益。本章还提供了一个机会,将一些杂项话题结合在一起,比如功能特性,它们可以帮助你使宏尽可能轻量化。
10.1 一个类似函数的配置宏
让我们来看一下我们宏的第一个版本,它暴露了一个名为 config 的函数式宏。调用该宏会生成一个名为 Config 的结构体,其中包含一个 HashMap<String, String>。我们还生成了一个新的方法,用于填充配置属性的映射。
这意味着,以下 YAML 配置和调用 new 方法将生成一个包含 user 和 password 键,并具有对应值 "admin" 和 "pass" 的映射:
user: "admin"
password: "pass"
为了简单起见,我们不允许嵌套的 YAML 结构——只允许键值对,其中键为字符串,值为字符串。
10.1.1 宏项目结构
我们再次选择了一个包含两个目录(config-macro 和 config-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"
宏本身有 serde 和 serde_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.rs 和 output.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_fields、generate_inits、generate_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。以下代码有个缺点,只允许使用一个属性:path 或 exclude。如果你想解决这个问题,可以参考练习部分。
示例 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_from 和 generate_from_method)帮助我们生成正确的 From 实现。同时,generate_annotation_struct 从 lib.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 在解析依赖时依赖于此规则。我们的宏遵循这个规则,因为它的两个特性(struct 和 functional)都是添加宏。然而,如果理论上我们使用特性来排除默认集中的 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 中的示例,具有更多信息(比如之前的测试),并通过超链接提供。
同时,内部文档注释(//!)用于为整个文件或 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 社区会欣赏成百上千个功能有限的相同配置宏。
在我们发布之前,我们应该在 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),两个属性宏(server 和 component),以及两个函数式宏(template 和 view)。它有用于服务器端渲染和跟踪的特性标志。
为了完成本章,我们将详细探讨 #[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),并包含了常见的依赖项(syn、quote 和 proc-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 解析的属性创建配置(如前所述,AttributeArgs 在 syn 版本 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 未来的方向
恭喜你读完了本书!我们一起探索了声明式和过程式宏在各种用例中的应用。有时我们向结构体和枚举添加方法;在其他情况下,我们更改了它们的字段,或修改了函数签名和返回值。此外,你还学习了测试、错误处理和文档编写,并看到了许多实际的例子。
尽管如此,还有一些有趣的工具和函数未被介绍。例如,synstructure(docs.rs/synstructur…)是一个帮助你实现 derive 宏的工具;proc-macro-crate(crates.io/crates/proc…)帮助你找到宏所在的 crate 名称,即使它在用户的应用中被更改。并且,syn 的功能标志后隐藏着 visit(docs.rs/syn/latest/…)和 fold(docs.rs/syn/latest/…),这是两个强大的助手,可以遍历类似表达式的节点。你可以在文档中找到关于如何使用这两个工具的信息。你也可以查看我写的关于 Fold 的博客文章(mng.bz/DdjR),它可以为你提供一些灵感或指导。此外,还有许多使用宏以不同寻常、创新、富有创意的方式的库,等待你去探索。如果遇到困难,你可以在 Stack Overflow 和 Reddit 上找到很多乐于回答宏相关问题的人。
感谢你阅读这本书。希望这些内容对你有所帮助。最后,用 AWS 的 Werner Vogels 的话来结束:“现在去构建吧!”