本章内容包括:
- 理解基础设施即代码(Infrastructure as Code)的理念
- 使用结构体和关键字解析自定义语法
- 考虑解析的权衡
- 通过结合过程宏和声明式宏避免重复
- 在宏中调用异步函数并创建云资源
到目前为止,我们创建的大多数宏都在 Rust 中工作,生成新的函数,向枚举添加方法等。但我们也知道,宏不仅仅能做这些。例如,它们可以生成编译时检查的 HTML(Yew),或者基于注解为代码创建基础设施(Shuttle)。
本章展示了一个宏,它大胆地超越了 Rust 的局限,创建了亚马逊 Web 服务(AWS)中的基础设施。这很有趣,因为它使我们能够在同一个代码库和语言中管理应用程序和基础设施逻辑,这可能使得扩展和维护代码变得更容易。希望这个例子也能为读者提供灵感,让他们思考如何使用过程宏来做更多的事情。但首先,让我们回顾一下,确保我们对这个话题有共同的理解。
9.1 什么是 IaC?什么是 AWS?
随着云服务提供商的崛起,用户可以通过点击按钮来轻松创建或销毁数百台服务器,IT 基础设施的组织方式需要发生变化。在过去,系统管理员可以手动管理服务器,或者通过一些简单的脚本来进行管理。但这种方法无法扩展,且在云环境中表现较差。手动管理数百台服务器已经足够困难了,管理员需要登录到每一台服务器,并输入正确的 Bash 命令序列进行配置。但如今,许多云资源具有临时性。你从云服务提供商那里获得的服务器可能一天内会被重启、更新或更改多次。你该怎么办?每隔几分钟手动检查服务器,当它们被替换时重新输入命令?
工程师们认为“危机中不容浪费”,因此诞生了一种新理念——服务器不再被视为宠物(即,每台服务器都是特别且独一无二的),而是像牛群一样对待(即,具有可替换性的组件,可以根据一些特征轻松描述)。牛群的管理方式更加自动化。因此,为了实现自动化,服务器和服务的配置应以可读格式声明,如 JSON 或某种领域特定语言(DSL)。这样,我们就有了一份书面的需求规范(比如:操作系统、内存、CPU、存储等)。这种规范不仅使我们可以轻松销毁和创建资源和环境,还充当了基础设施的实时、版本控制文档。这使得推理变得更加容易,因为一切都是/应该按照描述的方式进行。如果所有的信息都存储在文本文件中,你不需要再去查找规格、操作系统、语言、依赖等内容,就能复现你在服务器上遇到的 bug。当运行应用程序所需的一切都处于可读格式,并且能够以某种方式执行来创建该应用程序时,我们称之为基础设施即代码(IaC)。
注意:上述故事是一个简化的版本。自动化和版本控制基础设施的理念有着悠久的历史。然而,得益于云技术的普及,这些理念的受欢迎程度激增。
AWS(亚马逊网络服务)是亚马逊的云计算部门,目前是云业务的最大参与者,领先于 Azure 和 Google Cloud 等其他大公司。互联网上很大一部分(包括 Netflix)都依赖其基础设施,当 AWS 出现问题时,许多网站无法访问,问题变得显而易见。
AWS 约二十年前起步,提供计算(Elastic Compute Cloud [EC2])、存储(Simple Storage Service [S3])和队列服务(Simple Queue Service [SQS],是的,AWS 确实喜欢“简单”的东西)。如今,很少有人能列举出它提供的所有服务。
AWS 也一直是推动 IaC 的云服务提供商之一,早在 12 多年前就推出了 IaC 工具——AWS CloudFormation。CloudFormation 让你可以使用 JSON 或 YAML 模板描述基础设施,这些模板用于创建包含所有实际应用基础设施的堆栈。其他 AWS 工具基于 CloudFormation 的功能进行扩展。AWS SAM 提供了一个更容易使用的 CloudFormation 版本,适用于创建无服务器应用程序。AWS CDK 让你可以用 Typescript 或 Python 等语言编写基础设施代码,这些代码会被转换成 CloudFormation 模板。
此外,还有许多独立的工具,如 Terraform、Pulumi 或 Serverless Framework,它们允许你为多个云提供商创建服务。尽管如此,它们的核心理念保持一致:用文本或代码描述你想要的内容,让框架帮你实现基础设施梦想!但是,这些工具是如何将你的需求传递给云服务提供商的呢?通常,它们通过云服务提供的 REST 接口进行服务的创建、销毁和使用,或者通过软件开发工具包(SDK),抽象掉无关细节,使你能够直接使用这些服务与最喜欢的编程语言进行交互。
注意:在后台,CloudFormation 也会向 AWS 发出服务调用来创建和更新服务。但不完全清楚这些调用具体是什么:是 REST 接口吗?还是自定义接口?
在本章中,我们将构建一个使用 AWS SDK for Rust 创建资源的领域特定语言(DSL),重点关注 Amazon S3 和 AWS Lambda。S3 提供基于对象的存储,通过桶实现理论上的无限存储。Lambda 函数允许你在不担心基础设施的情况下运行计算。你提供代码和一些配置(如所需内存量和允许运行的最大时间 [超时]),AWS 会确保你的代码被执行,并且你只为实际执行的时间付费。非常简洁高效。
9.2 我们的 DSL 如何工作
除了为您提供一种美丽的新方式来通过代码创建两个 AWS 服务外,我们还希望为用户提供快速反馈。像 CloudFormation 这样的基于文本的工具有一个缺点,那就是当你犯错时,没有内建的反馈机制。这几乎迫使用户使用 IDE 插件和命令行工具作为替代方案,因为你不想在开始部署应用程序后几分钟才发现模板中有一个拼写错误。
Rust 不同:它喜欢在程序运行之前给你很多反馈,我们也是如此。简而言之,我们想要提供的是一种简单的语言,用于描述基础设施,能够自动创建指定的资源,并在出现问题时尽早提醒你,最好能够及时发现错误。
例如,创建一个名为 unique 的桶可能如下所示:
iac! {
bucket unique
}
这看起来简单易懂。作为出错时警告的示例,当我们输入 bucke 而不是 bucket 时,我们会返回以下错误:
error: only 'bucket' and 'lambda' resources are supported
--> src/main.rs:7:9
|
7 | bucke unique
| ^^^^^
我们指出了拼写错误并提供了额外的信息。这看起来足够具体(其中一个练习试图使错误消息更加完善)。
除了限制服务的数量外,我们还限制了可以传递的属性数量,因为不仅桶和 Lambda 函数有大量的属性,而且如果允许传递所有属性,这一章节的附加值会迅速下降,因为需要的代码非常相似。我们允许的操作包括创建桶(必需属性:name),创建 Lambda 函数(必需属性:name;可选属性:memory 和 timeout),以及将事件从桶发送到函数。后者提供了非常有用的功能,允许你响应对象的创建或更改,这为各种用例提供了支持,比如更新数据库或触发业务工作流。因此,这些桶事件被广泛使用,即使现在 Amazon EventBridge 已经成为 AWS 中事件处理的中央中介。
总而言之,本章的宏将解析一个包含创建 Lambda 和/或桶指令的 DSL(见图 9.1)。这些信息将传递给客户端,客户端将使用 AWS SDK 在云中创建指定的资源。
9.3 解析我们的输入
我们将分两阶段编写这个宏。在第一阶段,我们集中精力将输入解析成自定义的结构体。只有在第二阶段,我们才开始对解析后的信息进行处理。
分两阶段工作的一大原因是,解析和创建云资源是非常不同的任务,因此将这两者分开处理是合理的。如果我们将解析和云资源的创建分开,理论上它可以与具体的云提供商解耦。如果我们的示例是像 iac! { object-storage unique } 这样的内容,那么命名将聚焦于可以通过不同方式解决的高层需求。对于 AWS 后端,这个 object-storage 将创建一个桶;对于 Azure Cloud 后端,它将创建 Azure Blob 存储。我们将为每个云提供商编写一个后端,但只需要编写一个解析器。尽管如此,实际上不同云提供商之间通常存在细微的差异,这使得在 IaC 中完全不泄露实现细节变得困难。
另一个原因是实践性的问题:创建真实的云基础设施会增加许多复杂性,并且还需要一个 AWS 账户。通过将云部分限制到本章的第二部分,我们最小化了在编码过程中需要与 AWS 交互的代码示例数量。
9.3.1 项目设置和使用示例
我们可以使用早期项目中的两目录设置,将宏放在名为 iac-macro 的子目录中,而使用示例放在名为 iac-macro-usage 的目录中。以下列出了宏本身的 Cargo.toml 文件。
列表 9.1 iac-macro 的 Cargo.toml
[package]
name = "iac-macro"
version = "0.1.0"
edition = "2021"
[dependencies]
quote = "1.0.33"
syn = { version = "2.0.39", features = ["extra-traits"]}
[lib]
proc-macro = true
以下列出了使用项目的 Cargo.toml 文件。
列表 9.2 iac-macro-usage 的 Cargo.toml
[package]
name = "iac"
version = "0.1.0"
edition = "2021"
[dependencies]
iac-macro = { path = "../iac-macro" }
[dev-dependencies]
trybuild = "1.0.85"
main.rs 包含了许多使用示例:你可以创建一个桶、一个 Lambda 函数、一个带有 Lambda 的桶,或者一个通过事件将桶与 Lambda 关联起来的桶。对于最后两个示例,我们使用箭头(=>)来表示来自桶的事件并传递到 Lambda。
列表 9.3 使用示例
use iac_macro::iac;
fn main() {
iac! {
bucket uniquename #1
}
iac! {
lambda a_name #2
}
iac! {
lambda my_name mem 1024 time 15
}
iac! {
lambda name bucket uniquename #3
}
iac! {
bucket uniquename => lambda anothername #4
}
iac! {
bucket b => lambda l mem 1024 time 15
}
}
#1 创建一个桶
#2 创建一个 Lambda,允许传入内存(mem)和超时(time)作为可选参数
#3 你也可以同时创建桶和 Lambda。在这种情况下,顺序无关紧要。
#4 如果同时创建桶和 Lambda,可以通过添加箭头 `=>` 来让 Lambda 监听来自桶的 `object-created` 事件。
使用目录中还包含了编译测试(位于 tests 目录中),用于测试失败的情况,本文未展示这些。
我们的解析实现大约有 140 行代码。入口点将工作委托给一个自定义结构体。解析完成后,它打印结果并返回一个空的 TokenStream,因为我们没有什么有意义的返回值。
列表 9.4 lib.rs 中的宏入口点
#[proc_macro]
pub fn iac(item: TokenStream) -> TokenStream {
let ii: IacInput = parse_macro_input!(item);
eprintln!("{:?}", ii);
quote!().into()
}
由于我们使用自定义结构体来进行解析,我们应该为 IacInput 实现 Parse trait。
9.3.2 为我们的结构体实现 Parse trait
在以下代码中,你可以看到 IacInput 负责高层次的解析,将具体的桶(bucket)和 Lambda 细节交给其他结构体处理。只要有输入,我们就继续查找桶和 Lambda,并尝试解析这些输入。(如果需要,我们可以很容易地扩展这部分代码,以允许多个桶和 Lambda。)为此,我们使用 peek,它查看流中的下一个令牌。
如果接收到其他内容,我们抛出错误,因为我们要么遇到了未知的资源,要么有不可解析的残留内容。在这里,我们转而使用 lookahead1,它不仅允许我们看到下一个令牌,还能为特定的代码部分返回错误。在我们的案例中,我们获取范围并返回我们自定义的错误。
一旦输入完全解析,我们检查我们的桶是否将 has_event 属性设置为 true。如果是这种情况,我们预期 Lambda 也会存在,因为它是接收这些事件所必需的。最后,当我们从桶中获取事件时,Rust 尚未明确知道桶变量的类型,因此无法推断,我们必须显式地声明它:let mut bucket: Option<Bucket>;。
注意:在本章中,我尽力遵循 Rust 的标准,即错误信息以小写字母开头并且没有后缀标点符号。尽管大写对我来说更自然,而且并非每个项目都遵循此标准,但最好还是遵循现有的惯例。
列表 9.5 IacInput 定义和 Parse 实现
#[derive(Debug)]
struct IacInput {
bucket: Option<Bucket>,
lambda: Option<Lambda>,
}
impl Parse for IacInput {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let mut bucket: Option<Bucket> = None;
let mut lambda = None;
loop {
if input.peek(kw::bucket) { #1
bucket = Some(input.parse()?); #2
} else if input.peek(kw::lambda) {
lambda = Some(input.parse()?);
} else if !input.is_empty() {
return Err(syn::Error::new( #3
input.lookahead1().error().span(),
"only 'bucket' and 'lambda' resources are supported")
);
} else {
break; #4
}
}
if bucket.as_ref().map(|v| v.has_event).unwrap_or(false)
&& lambda.is_none() {
return Err(syn::Error::new(
input.span(),
"a lambda is required for an event ('=>')"
))
}
Ok(
IacInput {
bucket,
lambda,
}
)
}
}
#1 这些是自定义的关键字(稍后讨论)。
#2 如果我们有一个桶或 Lambda,将解析委托给 `Bucket` 或 `Lambda` 结构体。
#3 如果有其他输入,返回一个带有帮助信息的错误和范围。
#4 没有剩余的输入;停止循环。
可能仍然不清楚的一点是:什么是 kw::bucket 和 kw::lambda?是的,这两个确实需要一些解释。在我们的宏中,lambda 和 bucket 有特殊的含义。它们都像某种“资源声明”的关键字,后面跟着—必需的和可选的—资源详细信息。但对于 Rust/syn 来说,没有理由把这两个词当作特别的内容来处理。因此,我们可以使用 custom_keyword 宏来告诉 syn 这两个词在我们的宏中是关键字。按照惯例,这样的自定义关键字会放在一个名为 kw(“关键字”的缩写)的独立模块中,如下所示。
列表 9.6 我们的四个关键字:bucket、lambda、memory 和 timeout
pub(crate) mod kw {
syn::custom_keyword!(bucket);
syn::custom_keyword!(lambda);
syn::custom_keyword!(mem);
syn::custom_keyword!(time);
}
在幕后,宏将给定的关键字转换为实现 Parse trait 的结构体,并为其提供一个允许 peek 的方法。这非常有用,因为在代码中我们同时使用了 parse 和 peek。
现在我们可以继续看一下 Bucket,这个结构体负责解析 S3 桶的信息。解析代码去掉了桶令牌,获取了名称,并检查是否有事件令牌(=>)。
列表 9.7 Bucket 及其 Parse 实现
#[derive(Debug)]
struct Bucket {
name: String,
has_event: bool,
}
impl Parse for Bucket {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let bucket_token = input.parse::<kw::bucket>()
.expect("we just checked for this token"); #1
let bucket_name = input.parse() #2
.map(|v: Ident| v.to_string())
.map_err(|_| syn::Error::new(
bucket_token.span(),
"bucket needs a name"
))?;
let event_needed = if !input.peek(kw::lambda)
&& input.peek(Token!(=>)) {
let _ = input.parse::<Token!(=>)>().unwrap();
true #3
} else {
false
};
Ok(Bucket {
name: bucket_name,
has_event: event_needed,
})
}
}
#1 我们解析了桶令牌,如果我们进入这个方法,这个令牌应该已经存在。
#2 接下来的标识符应该是桶的名称。
#3 如果我们遇到 `=>`,需要一个事件,因此将 `has_event` 设置为 `true`,并去掉箭头。
大部分代码与之前相似:我们在解析输入时,处理了那些我们不打算使用的令牌(例如桶令牌和箭头),但必须去掉它们。与 IacInput 类似,我们尽力提供了良好的错误处理。但当我们期望某些事情总是能正常工作时,我们使用 expect 来解释我们的思路并提供错误信息。
解析 Lambda 的代码类似,但由于需要处理附加的属性,它稍微长一些。我们解析令牌和名称。如果仍有输入,并且下一个令牌不是桶声明的开始,那么就有可选的属性。我们循环检查是否存在 mem 和 time 这两个自定义关键字。如果我们遇到其他内容,就会返回错误。由于我们的可选属性只能是数字,我们将它们解析为 LitInt(这与前面章节中的 LitStr 非常相似)。这意味着库会在值不是数字时返回合理的错误。最后,我们将接收到的值解析为 u16 类型(见图 9.2)。
为什么选择 u16? 很简单:这样,我们可以让编译器为我们做更多的工作!内存和超时的值只能是正数,所以不需要使用 i 类型。而且因为内存和时间的值可能大于 255(u8 的最大值),因此 u16 是下一个合适的选择,它将限制非法值不超过 65,535。对错误处理的进一步改进可以是在 syn 和编译器为我们处理的基础上增加额外的检查。例如,在超时(time)部分,我们可以检查值是否小于等于 900。
列表 9.8 Lambda 及其 Parse 实现
#[derive(Debug)]
struct Lambda {
name: String,
memory: Option<u16>,
time: Option<u16>,
}
impl Parse for Lambda {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let lambda_token = input.parse::<kw::lambda>()
.expect("we just checked for this token");
let lambda_name = input.parse()
.map(|v: Ident| v.to_string())
.map_err(|_| {
syn::Error::new(lambda_token.span, "lambda needs a name")
})?;
let mut lambda_memory = None;
let mut lambda_timeout = None;
while !input.is_empty() && !input.peek(kw::bucket) { #1
if input.peek(kw::mem) {
let _ = input.parse::<kw::mem>()
.expect("we just checked for this token");
lambda_memory = Some(
input.parse()
.map(|v: LitInt| v.to_string() #2
.parse()
.map_err(|_| {
syn::Error::new(
v.span(),
"memory needs positive value <= 10240"
)
})
)??
);
} else if input.peek(kw::time) {
let _ = input.parse::<kw::time>()
.expect("we just checked for this token");
lambda_timeout = Some(
input.parse()
.map(|v: LitInt| v.to_string()
.parse()
.map_err(|_| {
syn::Error::new(
v.span(),
"timeout needs positive value <= 900"
)
})
)??
);
} else {
Err(syn::Error::new( #3
input.span(),
"unknown property passed to lambda"
))?
}
}
Ok(Lambda {
name: lambda_name,
memory: lambda_memory,
time: lambda_timeout,
})
}
}
#1 当输入仍有内容且不是桶声明时,继续循环
#2 可选属性应为可以解析为 `u16` 的 `LitInt` 值。如果不是这样,我们会返回错误。
#3 我们没有其他可选属性,除了 `time` 和 `mem`,所以如果我们到达这里,就应该返回错误。
现在,main 运行时,日志显示一切都已成功解析:
IacInput { bucket: Some(Bucket { name: "uniquename", event: true }),
lambda: Some(Lambda { name: "my_name", memory: Some(1024),
time: Some(15) }) }
此外,我们在编译时提醒用户许多错误(我敢打赌你已经厌倦了我反复提到这个),比如忘记给 Lambda 命名时:
error: lambda needs a name
--> tests/fails/bucket_and_no_lambda_name.rs:5:23
|
5 | bucket unique lambda
| ^^^^^^
当我们将字符串作为内存属性传递时:
error: expected integer literal
--> tests/fails/lambda_time_not_a_number.rs:5:25
|
5 | lambda name mem "yes"
| ^^^^^
或者当我们传递的数字无效时:
error: memory needs positive value <= 10240
--> tests/fails/lambda_negative_time.rs:5:25
|
5 | lambda name mem -10
| ^
现在我们有了一个漂亮的小型有用解析器,接下来我们将开始使用它。
注意:这是一个有用的解析器,但并不完美,因为它允许一些奇怪的行为。例如,我们可以传递多个桶,但只会保存一个。我们不会在这里修复所有这些异常,尽管练习要求你做一个小改进。
9.4 两种替代解析方法
首先让我展示一些替代方案。为了让事情更有趣一点,我们将对 DSL 语法做一些小调整。也许当前的样式有些过于宽松,我们可以通过改进语法,让资源的开始和结束更加明确。因此,对于 Lambda 和它的多个属性,我们可以将所有内容放在括号内,并用等号分隔属性名称和值。
列表 9.9 替代示例调用
fn main() {
iac! {
bucket uniquename => lambda (
name = my_name, mem = 1024, time = 15
)
}
}
因此,在我们的替代方案中,我们重点关注 Lambda 结构体,它必须因更新的语法而发生变化。
9.4.1 使用 Punctuated 和自定义结构体
在第一个替代的 Parse 实现中,我们现在需要获取括号中的内容。我们可以选择手动解析这些括号(这是一种选择),但我们引入了 parenthesized 宏,它可以为我们完成这项工作。它的用法与我们之前遇到的 braced 很相似:将括号或圆括号中的输入传递给宏,并提供一个变量来存储内容。
当我们获取到内容后,我们知道这些属性是由逗号分隔的,每个属性由一个键和值组成。这种重复性很适合使用 Punctuated。因此,我们用它来解析属性。对于 name = value 部分,我们创建了一个名为 KeyValue 的结构体(表示键值对),稍后我们将讨论这个结构体。获取所有键值对后,我们遍历结果,并将它们与我们的键的字符串值进行比较,以找到正确的属性。当遇到无法识别的属性时,我们抛出错误。如果没有找到必需的 name 属性,也会抛出错误。
列表 9.10 Lambda 及其新的 Parse
// 其他导入
use syn::parenthesized;
use syn::punctuated::Punctuated;
impl Parse for Lambda {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let _ = input.parse::<kw::lambda>()
.expect("we just checked for this token");
let mut lambda_name = None;
let mut lambda_memory = None;
let mut lambda_timeout = None;
let content;
parenthesized!(content in input); #1
let kvs = Punctuated::<KeyValue, Token!(,)>::parse_terminated( #2
&content
)?;
kvs.into_iter().for_each(|kv| {
if kv.key == "name" {
lambda_name = Some(kv.value); #3
} else if kv.key == "mem" {
lambda_memory = Some(
kv.value.parse().unwrap() #4
);
} else if kv.key == "time" {
lambda_timeout = Some(
kv.value.parse().unwrap()
);
}
});
Ok(Lambda {
name: lambda_name.ok_or(syn::Error::new( #5
input.span(),
"lambda needs a name"
))?,
memory: lambda_memory,
time: lambda_timeout,
})
}
}
#1 `parenthesized` 类似于 `braced`,它会将括号内的内容放入 `content` 中。
#2 使用 `Punctuated` 和自定义结构体,我们解析内容,如果遇到问题,就用 `?` 提前返回。
#3 我们将键与属性的字符串值进行比较。
#4 我们应该实际返回错误,但为了简洁起见,我们跳过了这个步骤。
#5 `name` 是必需的,所以缺少 `lambda_name` 就是一个错误。
由于这次我们进行的是字符串比较,因此我们不再需要 mem 和 time 的关键字,它们可以被移除。
KeyValue 的定义并没有太多惊讶。我们将键和值保存为字符串,因为它适用于所有属性。在 Parse 中,我们提取键,它应该是输入的第一部分。接下来,我们去掉了不需要的等号令牌。但由于它是我们语法的一部分,如果没有这个等号,我们会抛出错误。请注意,这是唯一一个我们检查等号的地方,因此如果我们想使用其他分隔符,比如冒号(:),只需修改一行代码。
对于属性的值,我们期望 name 使用 Ident 类型。如果我们使用 name = "some_name",则会是 LitStr 类型。对于其他两个属性,我们使用 LitInt,这可以防止一些无效输入。在所有其他情况下,属性未被识别,这意味着是时候抛出错误了。
列表 9.11 lib.rs 和新的 KeyValue 结构体
#[derive(Debug)]
struct KeyValue {
key: String,
value: String,
}
impl Parse for KeyValue {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let key = input.parse() #1
.map(|v: Ident| v.to_string())
.map_err(|_| syn::Error::new(
input.span(),
"should have property keys within parentheses"
))?;
let _: Token!(=) = input.parse() #2
.map_err(|_| syn::Error::new(
input.span(),
"prop name and value should be separated by ="
))?;
let value = if key == "name" {
input.parse() #3
.map(|v: Ident| v.to_string())
.map_err(|_| syn::Error::new(
input.span(),
"Name property needs a value"
))
} else if key == "mem" || key == "time" {
input.parse()
.map(|v: LitInt| v.to_string())
.map_err(|_| {
syn::Error::new(
input.span(),
"memory and time needs a positive value")
})
} else {
Err(syn::Error::new(
input.span(),
format!("unknown property for lambda: {}", key)
))
}?;
Ok(KeyValue {
key,
value,
})
}
}
#1 获取键
#2 解析等号只是为了去掉它
#3 根据属性解析值,如果属性未知则返回错误(注意最后的 `?`)
这种替代方法的“正常路径”与之前的设置完全相同,输出也是一样的。错误处理基本相同,尽管稍微简化了一些。使用圆括号和等号意味着我们有一些额外的错误信息,例如:
error: prop name and value should be separated by =
--> tests/fails/lambda_colon_instead_of_equals.rs:5:22
|
5 | lambda (name :)
| ^
这就完成了第一部分(变体)。有一个好处是,它通过让 Punctuated 做部分工作,避免了使用 while 循环来获取属性。将内容放在圆括号内也没有坏处。它还展示了关键字是一种方便的工具,但并非强制要求使用。
9.4.2 使用 Punctuated 配合自定义枚举和构建器
现在,来看一下下一个方法。从表面上看,它与之前的方法相似:去掉令牌并获取内容。不过,这次我们在 Punctuated 中使用了 LambdaProperty,并通过使用构建器将结果折叠/简化到我们的输出中。
列表 9.12 lib.rs 中的 Lambda 结构体
impl Parse for Lambda {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let _ = input.parse::<kw::lambda>()
.expect("we just checked for this token");
let content;
parenthesized!(content in input);
let kvs = Punctuated::<LambdaProperty, Token!(,)>::parse_terminated( #1
&content
)?;
let builder = kvs
.into_iter()
.fold(Lambda::builder(content.span()), |acc, curr| { #2
match curr {
LambdaProperty::Name(val) => acc.name(val),
LambdaProperty::Memory(val) => acc.memory(val),
LambdaProperty::Time(val) => acc.time(val),
}
});
Ok(builder.build()?) #3
}
}
#1 解析为 `LambdaProperty` 的 `Punctuated`,这是一种新的结构体
#2 使用 `LambdaBuilder` 折叠结果,这是另一个新的结构体
#3 调用 `build`,如果有错误则提前返回
列表 9.13 展示了部分构建器的定义。得益于前几章的内容,你应该对概念和实现都非常熟悉。我们在结构体中保存了一个 span,以便在缺少必需的 name 参数时生成错误。build 返回一个 Result,因为正如我们刚才所说,当 name 不存在时,操作可能会失败。
列表 9.13 LambdaBuilder 结构体及其 Lambda 方法的实例化
struct LambdaBuilder {
input_span: Span,
name: Option<String>,
memory: Option<u16>,
time: Option<u16>,
}
impl LambdaBuilder {
fn name(mut self, name: String) -> Self {
self.name = Some(name);
self
}
// 内存和超时的类似设置器
fn build(self) -> Result<Lambda, syn::Error> {
let name = self.name.ok_or(
syn::Error::new(
self.input_span,
"name is required for lambda"
)
)?; #1
Ok(Lambda {
name,
memory: self.memory,
time: self.time,
})
}
}
impl Lambda {
fn builder(input_span: Span) -> LambdaBuilder {
LambdaBuilder {
input_span,
name: None,
memory: None,
time: None,
}
}
}
#1 `name` 是必需的,因此如果缺少 `name`,我们应该返回一个指向原始代码的错误和范围。
我们在 LambdaBuilder 中存储的 span 来自 proc_macro2,因为这是 syn::Error 所要求的 span 类型。这意味着我们需要将 proc-macro2 = "1.0.69" 添加到我们的依赖中。
LambdaProperty 是一个包含三种已知 Lambda 属性的枚举,它允许我们立即传入正确类型的值(字符串或数字)。与此同时,在 parse 中,我们的代码类似于我们第一次方法中的代码。我们检查属性是否与任何关键字匹配。如果匹配,我们去掉不需要的部分并返回适当的变体。
列表 9.14 LambdaProperty 枚举
pub(crate) mod kw {
// 之前的关键字
syn::custom_keyword!(name);
}
#[derive(Debug)]
enum LambdaProperty {
Name(String),
Memory(u16),
Time(u16),
}
impl Parse for LambdaProperty {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let lookahead = input.lookahead1();
if lookahead.peek(kw::name) {
let _ = input.parse::<kw::name>()
.expect("we just checked for this token");
let _: Token!(=) = input.parse()
.map_err(|_| syn::Error::new(
input.span(),
"prop name and value should be separated by ="
))?;
let value = input.parse() #1
.map(|v: Ident| v.to_string())
.map_err(|_| syn::Error::new(
input.span(),
"name property needs a value"
))?;
Ok(LambdaProperty::Name(value))
} else if lookahead.peek(kw::mem) {
let value = parse_number::<kw::mem>( #2
input,
"memory needs a positive value <= 10240"
)?;
Ok(LambdaProperty::Memory(value))
} else if lookahead.peek(kw::time) {
let value = parse_number::<kw::time>(
input,
"time needs a positive value <= 900"
)?;
Ok(LambdaProperty::Time(value))
} else {
Err(syn::Error::new( #3
input.span(),
format!("unknown property for lambda")
))
}
}
}
#1 获取关键字和等号后,解析名称值并创建正确的变体
#2 使用辅助函数处理数字属性
#3 如果属性未知,则返回错误
这次我们为内存和超时的解析提供了一个辅助函数,因为在这两种情况下,我们都希望使用 LitInt 作为解析类型来防止错误,这需要额外的步骤将结果转换为 u16 类型。因此,我们不能仅仅使用解析名称的函数。
parse_number 的最有趣之处在于,我们使其成为泛型并依赖 Parse trait。这是必要的,因为我们需要解析 mem 和 time 关键字,而 Parse 是自定义关键字实现的 trait。因此,正如前面所见,当我们要解析超时时,我们只需调用 parse_number::<kw::time>。
列表 9.15 泛型辅助函数 parse_number
fn parse_number<T>(input: ParseStream, error_message: &str)
-> Result<u16, syn::Error>
where T: Parse { #1
let _ = input.parse::<T>() #2
.expect("we just checked for this token");
let _: Token!(=) = input.parse()
.map_err(|_| syn::Error::new(
input.span(),
"prop name and value should be separated by ="
))?;
let value = input.parse()
.map(|v: LitInt| v.to_string()
.parse()
.map_err(|_| {
syn::Error::new(
v.span(),
error_message,
)
})
)??;
Ok(value)
}
#1 我们有一个实现了 `Parse` 的泛型 `T`,它非常适合我们的关键字。
#2 该泛型用于解析 `mem` 和 `time` 关键字。
今天我们提供了三种实现方式,当然你还可以想到更多的实现方式。像往常一样,最佳选择取决于个人偏好和项目的具体需求。我们第一种方法是最简洁的,主要是因为它将大部分繁重的工作交给了一个结构体(Lambda)。对于一个像这样的宏来说,这是一个完全合适的解决方案。第二种方法虽然稍长,但通过增加另一个结构体来进行解析,可能会在长远来看带来更大的抽象价值。第三种方法则更长,对于像这样的宏来说,可能有点过于复杂。然而,如果应用程序不断扩展,拥有一个灵活且专门为用例设计的枚举可能会变得非常有价值。
9.5 实际创建服务
接下来,我们将使用 AWS SDK for Rust 来请求 AWS 创建所需的服务。
注意事项
在这个项目的这一部分,有几个需要注意的事项:
桶与 Lambda 的关联可能会偶尔失败,因为 AWS 可能会错误地认为你刚创建的桶或 Lambda 在不到一秒的时间内不存在。一个生产级别的版本应该能够检测到这种失败并进行重试。当前的代码仅仅是创建资源。如果你第二次运行它,会得到一个错误,提示资源已经存在。生产级别的代码会通过名称或保持状态来检查这些资源的存在,就像 Terraform 一样。具体示例请参考练习部分。
在某些 IDE 中运行这段代码时要小心。例如,IntelliJ 可能会展开宏以提供更好的反馈,然而在我们的例子中,展开宏的副作用是创建资源——虽然这不会花费你任何费用,因为桶和 Lambda 是免费的,但这可能会导致代码失败,因为你选择的名称已经被 IDE 占用。
针对 IntelliJ,你可以尝试在知道 IDE 展开宏时跳过资源创建:
impl IacInput { pub fn has_resources(&self) -> bool { !is_ide_completion() && (self.bucket.is_some() || self.lambda.is_some()) } } fn is_ide_completion() -> bool { match std::env::var_os( "RUST_IDE_PROC_MACRO_COMPLETION_DUMMY_IDENTIFIER" ) { None => false, Some(dummy_identifier) => !dummy_identifier.is_empty(), } }或者,你可以使用
RUST_IDE_PROC_MACRO_COMPLETION环境变量,它将被设置为1。当然,这并不能保证适用于你的 IntelliJ 设置或 IDE 的未来版本。最后一个注意事项,我们将使用之前介绍的第一种方法来解析输入。
我们的项目现在有两个非常不同的任务要执行。它仍然需要解析输入,就像我们在前面的章节中做的那样。但它也需要与 AWS 进行通信。将我们的项目拆分成多个模块/文件,分别处理更专业的任务可能是最好的方法:
lib.rs仍然是入口点和协调者。input.rs包含与解析相关的所有内容。lambda.rs包含处理创建 Lambda 函数的代码。s3.rs同样包含用于创建桶的代码。errors.rs包含一个自定义错误,用于将 AWS SDK 错误转换为有用的syn错误。由input.rs处理的错误已经是syn类型,因此我们不做修改。
example 目录包含一个基本的 JavaScript 函数 handler.js,我们将在 AWS Lambda 中执行该函数。
有一个小的改动,未在此处展示,那就是我们的一些解析结构体的字段已经变为公共字段,以允许 lambda.rs 和 s3.rs 在创建资源时访问它们。
创建一个单独的错误文件并不总是一个好主意。局部性,即将与某个功能相关的所有内容——包括错误处理——放在一个地方,可能是一个很大的优点。而在大型项目中,为错误创建单独的文件可能会变得难以管理。在这个特定的情况下,这样做给了我们一个很好的方式来统一 Lambda 和 S3 的错误,同时让我们可以一次性讨论所有的错误处理。
我们从查看 lib.rs 开始。我们的第一步仍然是解析输入。当解析完成后,我们使用一个辅助函数来确定是否有任何资源需要创建。这是一个优化,有助于在不必要时避免一些工作。
什么样的工作呢?AWS SDK 客户端的初始化(通过 new 方法)是异步的。而且,这些客户端暴露的大多数方法也是异步的。对于这种行为,AWS SDK 依赖于 Tokio 作为异步运行时(即,执行异步 Rust 代码的环境)。
注意:我们之前在谈到 tokio::main 宏时遇到过 Tokio。
因此,AWS SDK 的异步行为和 Tokio 运行时会影响到我们的宏,这意味着当我们有资源并且需要使用 SDK 时,我们必须启动一个运行时并调用 block_on,直到基础设施创建完成。
列表 9.16 lib.rs 入口点
// 导入和模块
#[proc_macro]
pub fn iac(item: TokenStream) -> TokenStream {
let ii: IacInput = parse_macro_input!(item);
if ii.has_resources() {
let rt = tokio::runtime::Runtime::new()
.unwrap(); #1
match rt.block_on(create_infra(ii)) {
Ok(_) => quote!().into(), #2
Err(e) => e.into_compile_error() #3
}
} else {
quote!().into()
}
}
#1 创建一个 Tokio 运行时,并在 `create_infra` 函数上阻塞等待
#2 如果宏没有错误地结束,我们返回一个空的 `TokenStream`。
#3 如果在创建基础设施时发生错误,我们将其转换为适当的令牌。
由于我们现在使用了 Tokio,你需要将其作为依赖项添加到项目中。令人惊讶的是,rt 特性并不足以使这段代码正常工作:new 方法实际上隐藏在 rt-multi-thread 标志背后。我们还需要添加我们所需的 AWS SDK 依赖项——s3 和 lambda,以及一个用于创建这些库客户端配置的依赖项:
aws-config = "1.1.1"
aws-sdk-s3 = "1.11.0"
aws-sdk-lambda = "1.9.0"
tokio = { version = "1.26.0", features = ["rt", "rt-multi-thread"] }
如果你认为对于如此简单的用例,Tokio 是一个过大的依赖项,你可以尝试使用更轻量的替代库,如 pollster crate,它没有依赖项,且比 Tokio 小得多。它们的用法非常相似:
use pollster::FutureExt as _;
pub fn iac(item: TokenStream) -> TokenStream {
// ...
match create_infra(ii).block_on() {
Ok(_) => quote!().into(),
Err(e) => e.into_compile_error()
}
}
然而,对于这个特定的用例,这不是一个选项,因为——正如我们之前提到的——AWS SDK 依赖于 Tokio 运行时。这意味着在使用任何替代运行时时,你会遇到错误(没有运行的反应器,必须在 Tokio 1.x 运行时的上下文中调用)。
异步函数 create_infra 如列表 9.17 所示。 它创建用于调用 Amazon S3 和 AWS Lambda 的 SDK 客户端。如你所见,这些操作是异步的,因为我们需要 await 它们——因此需要一个异步函数。当客户端准备好后,我们创建 Lambda,并将结果保存到 output 中。为什么这么做?因为接下来我们将创建一个桶,如果它有事件,我们需要知道 Lambda 的 Amazon 资源名称(ARN,唯一标识符),以便知道将事件发送到哪里。我们还需要添加所谓的 AWS 身份和访问管理(IAM)权限,告诉 Lambda 我们的桶可以发送事件给它。函数内部的错误会被转换为 IacError,这是一种自定义错误类型,最终会被我们的入口点转换为 _compile_error。
注意:你可以想象在解析阶段以某种方式编码“我们有一个关联的桶和 Lambda”,也许通过一个枚举变体 BucketLinkedToLambda(Bucket, Lambda)。这将使创建基础设施的这部分变得更容易和更安全。具体内容请参考练习部分。
列表 9.17 异步的 create_infra 函数,协调基础设施的设置
async fn create_infra(iac_input: IacInput) -> Result<(), IacError> {
let s3_client = S3Client::new() #1
.await;
let lambda_client = LambdaClient::new()
.await;
let mut output = None;
if let Some(lambda) = &iac_input.lambda {
eprintln!("creating lambda...");
output = Some(lambda_client.create_lambda(lambda).await?); #2
}
if let Some(bucket) = &iac_input.bucket {
eprintln!("creating bucket...");
s3_client.create_bucket(bucket).await?; #3
if bucket.has_event {
eprintln!("linking bucket and lambda by an event...");
let lambda_arn_output = output
.expect("when we have an event, we should have a lambda");
let lambda = iac_input.lambda
.expect("when we have an event, we should have a lambda");
let lambda_arn = lambda_arn_output.function_arn()
.expect("creating a lambda should return its ARN");
lambda_client.add_bucket_permission(
&lambda, &bucket.name
).await?; #4
s3_client.link_bucket_with_lambda(
bucket, lambda_arn
).await?;
}
}
Ok(())
}
#1 创建 AWS SDK 客户端
#2 如果我们有一个 Lambda,则创建它
#3 创建桶
#4 如果 `has_event` 为真,我们应该拥有一个 Lambda,并可以设置其权限,同时配置桶以发送事件。
一旦输入被解析,我们检查是否有需要创建的资源。如果有桶和/或 Lambda,我们启动一个运行时,阻塞在异步函数上,并等待它使用两个 SDK 客户端来创建资源。
9.6 两个 AWS 客户端
接下来需要做的就是深入到两个客户端的代码中。我们使用 aws_config(这个额外的库)来创建正确的配置,依赖于本地可用的凭证和我们传入的 AWS 区域(如 eu-west-1,即爱尔兰——如果你不在欧洲,可能需要选择其他区域)。也就是说,要在本地运行这段代码,确保你有有效的 AWS 管理员凭证。
列表 9.18 LambdaClient 的 new 方法
// 导入
pub struct LambdaClient {
client: Client,
}
impl LambdaClient {
pub async fn new() -> Self {
let config = aws_config::defaults(BehaviorVersion::latest()) #1
.region(Region::new("eu-west-1"))
.load()
.await;
LambdaClient { #2
client: Client::new(&config),
}
}
}
#1 `aws_config` 使用本地凭证加载配置。我们将区域设置为爱尔兰。
#2 配置准备好后,我们创建 AWS 客户端和我们的 `LambdaClient`。
在实际项目中,我们可以使用 Default 来进行默认设置,允许用户在调用 new 时选择区域。
接下来是我们已经看到的函数。create_lambda 使用构建器将 Lambda 配置信息发送到 AWS。我们设置名称,并可以选择性地添加时间和内存大小。我们还需要填充其他几个属性,包括一个角色(在我的账户中已经存在)和一个包含代码的压缩文件,这个文件已经上传到我账户中的一个桶("my-lambda-bucket")。
列表 9.19 create_lambda
pub async fn create_lambda(&self, lambda: &Lambda)
-> Result<CreateFunctionOutput, SdkError<CreateFunctionError>> {
let mut builder = self.client
.create_function()
.function_name(&lambda.name) #1
.role("arn:aws:iam::11111111111:role/change") #2
.code(FunctionCode::builder()
.s3_bucket("my-lambda-bucket")
.s3_key("example.zip") #3
.build()
)
.runtime(Runtime::Nodejs18x) #4
.handler("handler.handler"); #5
if let Some(time) = lambda.time { #6
builder = builder.timeout(time.into());
};
if let Some(mem) = lambda.memory {
builder = builder.memory_size(mem.into())
};
builder
.send()
.await #7
}
#1 设置 Lambda 函数的名称
#2 设置角色(Lambda 应该能够“假设”该角色)
#3 告诉 AWS 该 Lambda 函数的代码位于名为 "example.zip" 的桶对象中
#4 由于代码是 JavaScript,运行时应该是 Nodejs
#5 压缩包包含一个名为 "handler" 的文件,该文件有一个名为 "handler" 的函数。我们因此告诉 AWS 执行 `handler.handler`。
#6 如果提供了时间和内存,我们将进行设置
#7 等待 AWS 创建资源
下面的代码是 example.zip 中的 handler.js 文件。如果你只想让宏起作用(而不是创建 Lambda),你甚至可以上传一个空文件:
exports.handler = async () => {
return {
hello: 'world'
};
};
为了使方法更易于单元测试,你可以将构建器提取到一个单独的函数中,这个函数是纯粹的,它包含大部分代码且不需要模拟。send 操作是非纯粹的,应该放在另一个函数中:
pub async fn create_lambda(&self, lambda: &Lambda)
-> Result<CreateFunctionOutput, SdkError<CreateFunctionError>> {
self.create_lambda_builder(&lambda) #1
.send()
.await
}
#1 辅助函数包含大部分代码,且是纯粹的。
第二个函数 add_bucket_permission 允许桶调用 Lambda。它同样使用构建器和 send 来与 AWS 通信。在这里,我们做了一些基本的 AWS IAM 操作,比如允许任何人("*")调用 Lambda,只要调用源是我们的桶。这样,桶就可以在有事件准备好时调用该函数。
列表 9.20 add_bucket_permission
pub async fn add_bucket_permission(&self, lambda: &Lambda, bucket: &str)
-> Result<AddPermissionOutput, SdkError<AddPermissionError>> {
self.client.add_permission()
.function_name(&lambda.name)
.principal("*") #1
.statement_id("StatementId")
.action("lambda:InvokeFunction") #2
.source_arn(
format!("arn:aws:s3:::{}", bucket) #3
)
.send()
.await
}
#1 允许任何人 . . .
#2 . . . 调用该 Lambda 函数 . . .
#3 . . . 只要调用源是我们的桶
现在让我们看看 S3Client。它的字段和 new 方法与 LambdaClient 非常相似:我们加载配置并将客户端和区域添加到结构体中。
列表 9.21 S3Client
pub struct S3Client {
client: Client,
region: String,
}
impl S3Client {
pub async fn new() -> Self {
let config = aws_config::defaults(BehaviorVersion::latest())
.load()
.await;
S3Client {
client: Client::new(&config),
region: "eu-west-1".to_string(),
}
}
}
可能是因为 S3 桶是比 Lambda 更全球化的概念,而 Lambda 是区域特定的(就像 AWS 中的大多数服务一样),所以在配置时我们没有传递区域。不过,在创建桶时,我们确实需要提供 location_constraint 来确保资源被放置在正确的区域。
列表 9.22 create_bucket
pub async fn create_bucket(&self, bucket: &Bucket)
-> Result<CreateBucketOutput, SdkError<CreateBucketError>> {
let constraint = BucketLocationConstraint::from(self.region.as_str());
let cfg = CreateBucketConfiguration::builder()
.location_constraint(constraint)
.build();
self.client.create_bucket()
.bucket(&bucket.name)
.create_bucket_configuration(cfg) #1
.send()
.await #2
}
#1 在我们偏好的区域创建桶
#2 异步的 `send` 请求 AWS 创建资源。
我们已经有了一个桶和一个 Lambda,并且桶被允许调用 Lambda。但我们还没有处理事件!我们的最后一个方法确保在创建对象时发送事件("s3:ObjectCreated:*")。
列表 9.23 link_bucket_with_lambda
pub async fn link_bucket_with_lambda(
&self,
bucket: &Bucket,
lambda_arn: &str) -> Result<
PutBucketNotificationConfigurationOutput,
SdkError<PutBucketNotificationConfigurationError>
> {
self.client.put_bucket_notification_configuration()
.bucket(&bucket.name)
.notification_configuration(NotificationConfiguration::builder()
.lambda_function_configurations(
LambdaFunctionConfiguration::builder()
.lambda_function_arn(lambda_arn) #1
.events(
Event::from("s3:ObjectCreated:*") #2
)
.build()
.expect("to create valid lambda function config")
).build())
.send()
.await
}
#1 发送事件到具有此 ARN(即唯一标识符)的 Lambda . . .
#2 . . . 当我们的桶中创建对象时
简而言之,我们的代码与 AWS 通信,设置所需的资源。当我们想要发送事件时,我们需要做很多额外的工作,因为 AWS 需要桶和 Lambda 双方的同意才能允许事件发送。
9.7 错误和声明式宏
这就是所有内容了——等等,我忘记提到 errors.rs 文件了。将错误放在单独的文件中的缺点就是,可能得展示很多类似的代码。我们的自定义 IacError 是一个枚举,每个变体对应一种可能出现的错误类型:创建桶或 Lambda 可能会失败,事件设置可能会出错。IacError 实现了 Error 并具备了所需的 Display 实现。它还拥有一个自定义方法,用于将错误转换为 TokenStream,这正是我们需要输出的内容。
由于 s3 和 lambda 中调用 AWS 的四个函数都会抛出各自的错误,我们为每种 AWS 错误实现了 From trait。每个实现都将其中一个 AWS 错误转换为我们自己的错误,并从错误中提取出消息。
列表 9.24 我们的自定义 IacError
#[derive(Debug)]
pub enum IacError { #1
BucketError(String),
LambdaError(String),
EventError(String),
}
impl IacError {
pub fn into_compile_error(self) -> TokenStream { #2
match self {
IacError::BucketError(message) => {
syn::Error::new(
Span::call_site(),
format!("bucket could not be created: {}", message)
).into_compile_error().into()
},
// 对其他两个类似的处理
}
}
}
impl From<SdkError<CreateBucketError>> for IacError { #3
fn from(value: SdkError<CreateBucketError>) -> Self {
let message = value.message()
.map(|v| v.to_string())
.unwrap_or_else(|| "no message".to_string());
IacError::BucketError(message)
}
}
// 为其他三个错误实现类似的代码
// 以及简单的错误和显示实现
#1 我们的 `IacError` 是三种可能出错的情况的组合。
#2 这个自定义方法会为每个变体返回一个合理的 `syn::Error`。
#3 我们为 AWS 抛出的各种错误实现了 `From`。当原始错误中有有用的消息时,我们保留它。
似乎在一本讲解宏作为避免冗余代码工具的书中写出冗余代码是浪费。然而,实际上没有什么可以阻止我们在宏中使用额外的声明式或过程宏——不过后者需要设置另一个项目。在这种情况下,声明式宏就足够了。我们只需要传入两个内容:我们的错误枚举的变体(expr 足够,虽然 path 也是一个不错的选择)和 AWS 错误的类型(ty,如你从之前的内容中可能记得的那样)。
注意:我写这段代码和本章时,Rust SDK 还处于开发预览阶段。在版本 0.24 中,你可以直接匹配错误并始终得到相同的全局类型(例如 ServiceError)。现在 SDK 已经公开发布,匹配错误会返回非常具体的错误(如 BucketAlreadyExists)。更具体的错误很好,但对于我们正在做的去重类型的工作来说,它们并不理想。为了展示过程宏和声明式宏的结合,我选择了简单地提取错误消息。这不是最好的选择,但它适用于这个示例。
列表 9.25 用于 From 实现的声明式宏
macro_rules! generate_from_error {
($mine:expr, $aws:ty) => {
impl From<SdkError<$aws>> for IacError {
fn from(value: SdkError<$aws>) -> Self {
// 获取消息,跟之前一样
$mine(message)
}
}
}
}
generate_from_error!(IacError::BucketError,CreateBucketError);
generate_from_error!(IacError::LambdaError,CreateFunctionError);
generate_from_error!(
IacError::EventError,
PutBucketNotificationConfigurationError
);
generate_from_error!(IacError::EventError,AddPermissionError);
现在,如果你愿意,可以运行代码,这应该会产生一个错误(如果你没有凭证,或者一个桶或 Lambda 已经存在,等等),或者是你请求的资源。这意味着你已经成功地(滥用)Rust 宏来创建实际的云基础设施。恭喜!
注意:thiserror(docs.rs/thiserror/l…)是另一种减少错误冗余代码的好方法,特别是在你不想自己编写错误时。它是一个派生宏,到现在你可能已经理解了它的大部分代码。
以目前的形式,我们的宏还不是特别有用,但它提供了很多可能性。例如,我们不仅可以创建一个桶,还可以将桶的详细信息保存到结构体中,并添加方法将内容存储到 S3 中。由于我们是通过宏来创建桶的,这使得我们在运行时更安全:如果使用给定名称的桶无法用于存储内容,我们会在编译时就看到应用程序失败。
9.8 适当的测试方式
在本书前面讨论或使用测试时,我们主要集中在单元测试上。当时,单元测试是最合适的工具:快速、简单且易于维护。我们为什么还需要更多的测试呢,毕竟我们只是在生成或转换函数、结构体或枚举?
然而,本章中介绍的 DSL 是不同的。以目前的形式,它并没有产生任何有用的输出。即使它产生了某些东西,正如前一节所提到的,这也不足以证明我们已经完成了我们所承诺的工作(即在云中创建资源)。显然并非如此。为了验证这样的宏,我们需要一种更复杂的测试方法。端到端测试就是一个选择。我们可以设置一个项目来测试我们的宏——我们已经有了相当多的这方面经验——并使用 AWS SDK 来验证资源是否被创建。最好在测试后还有一个清理步骤。我喜欢结合使用我的持续集成/持续交付(CI/CD)管道和 AWS Lambda 来进行测试。后者为我的代码提供了一个现实的测试环境,因为不排除将来我的宏会在 Lambda 中被使用。而且,Lambda 也很便宜,容易启动。遗憾的是,本章已经足够长,因此在你喜欢的 CI/CD 工具中实现这类型的测试将留给你自己。
9.9 来自真实世界的示例
我们已经接触过几个提供简化创建内容的 DSL 的 crate。让我们回顾一下:SQLx 让我们编写 SQL 查询;通过 Yew,我们可以编写 HTML;而通过 Leptos,我们将 Rust 和 HTML 混合在一起——这些都是通过宏实现的。Shuttle 也在多个地方作为 IaC 的示例被提及,它将这一概念进一步扩展。你编写代码,Shuttle 会自动处理你所需的基础设施,而无需你明确地定义它。
例如,运行 cargo shuttle deploy,你将获得一个返回 “Hello, World” 的活动端点:
async fn hello_world() -> &'static str {
"Hello, world!"
}
#[shuttle_runtime::main]
async fn axum() -> shuttle_axum::ShuttleAxum {
let router = Router::new().route("/hello", get(hello_world));
Ok(router.into())
}
另一个简短的注释:许多项目像我们在这里做的那样,结合使用声明式和过程宏。Shuttle 就是如此使用它们(即为了避免重复):
macro_rules! aws_engine {
($feature:expr, $pool_path:path, $option_path:path, $struct_ident:ident)
=> {
paste! {
#[cfg(feature = $feature)]
pub struct $struct_ident{
local_uri: Option<String>,
}
// 结构体实现
}
};
}
是的,paste! 是一个在具有过程宏的 crate 中调用的声明式宏。但反过来也是存在的。Rocket 有多个声明式宏生成过程宏(是的,这里有复数形式;我们将在下一章简要讨论如何在一个项目中使用多个宏):
macro_rules! route_attribute {
($name:ident => $method:expr) => (
// ...
#[proc_macro_attribute] #1
pub fn $name(args: TokenStream, input: TokenStream) -> TokenStream
{
emit!(attribute::route::route_attribute($method, args, input))
}
)
}
#1 生成一个属性宏
SQLx 著名的查询宏也是一个声明式宏,生成一个过程宏。注意,这个过程宏的入口点(意外地)与我们的非常相似,它做了三件事:
- 将解析委托给一个结构体
- 让另一个函数处理解析结果
- 将结果或错误返回给用户
#[macro_export]
#[cfg_attr(docsrs, doc(cfg(feature = "macros")))]
macro_rules! query (
// ...
($query:expr) => ({
$crate::sqlx_macros::expand_query!(source = $query) #1
});
);
// 其他文件 //
#[proc_macro]
pub fn expand_query(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(
input as query::QueryMacroInput
);
match query::expand_input(input, FOSS_DRIVERS) {
Ok(ts) => ts.into(),
Err(e) => {
if let Some(parse_err) = e.downcast_ref::<syn::Error>() {
parse_err.to_compile_error().into()
} else {
let msg = e.to_string();
quote!(::std::compile_error!(#msg)).into()
}
}
}
}
#1 生成一个函数式宏
最后一点:在本章中,我们的宏也使用了许多 println 语句来向用户提供信息,可能是显示解析的内容,或者告知用户正在创建资源。真实的 Rust(宏)crate 如何处理日志记录呢?Leptos 为其日志记录提供了自己的宏。如注释所述,在幕后,如果不在浏览器中,它会使用普通的 println:
/// 使用 `println!()` 风格的格式化来将内容日志记录到控制台
/// (在浏览器中)
/// 或通过 `println!()`(如果不在浏览器中)。
#[macro_export]
macro_rules! log { #1
($($t:tt)*) => ($crate::console_log(&format_args!($($t)*).to_string()))
}
#1 还有类似的宏用于 `warn` 和 `error`。
Rocket 也有宏来帮助进行日志记录,尽管这个宏并没有在幕后使用 print(如注释中对 write_out 的说明):
macro_rules! define_log_macro {
($name:ident: $kind:ident, $target:expr, $d:tt) => (
#[doc(hidden)]
#[macro_export]
macro_rules! $name {
($d ($t:tt)*) => (
$crate::log::private::$kind!(target: $target, $d ($t)*)
)
}
);
// 更多实现
}
define_log_macro!(error, error_); #1
// `print!` 当标准输出不可用时会导致 panic,但这个宏不会。
#[cfg(not(any(debug_assertions, test, doctest)))]
macro_rules! write_out {
($($arg:tt)*) => ({
use std::io::{Write, stdout, stderr};
let _ = write!(stdout(), $($arg)*)
.or_else(|e| write!(stderr(), "{}", e));
})
}
#1 同样适用于 `warn`, `info` 等。
而一些项目只是使用传统的 println 和 eprintln 语句。