RUST 从零到生产

 

我们专注于在一个由四到五名具有不同经验和熟练程度的工程师组成的团队中编写云原生应用程序 如何对 Rust 应用程序进行检测,以收集日志、跟踪和度量,从而能够观察我们的系统

  • 我们专注于在一个由四到五名具有不同经验和熟练程度的工程师组成的团队中编写云原生应用程序
  • 如何对 Rust 应用程序进行检测,以收集日志、跟踪和度量,从而能够观察我们的系统

1. 开始

1.1 安装 rust

  • 通过 rustup 安装
  • rustup 是一个 rust 工具链管理程序
    • 工具链是编译目标(compilation target)和发布渠道(release channel)的组合

1.1.1 编译目标

Rust 编译器的主要目的是将 Rust 代码转换为机器代码。对于每个编译目标,需要一个不同的 Rust 编译器后端。Rust 项目努力支持广泛的编译目标和不同级别的安全保证。目标被划分为多个层次,从第一级的“保证工作”到第三层的“尽最大努力可运行”,详尽的最新清单可在这里找到

1.1.2 发布渠道

永远不必担心升级到新的稳定版 Rust,升级是无痛的,并给你带来新的特性,更少的错误,更快的编译时间。对于应用程序开发,我们应该依赖最新发布的编译器版本(stable channel)来运行、构建和测试您的软件软件。

发布渠道包括:

  • 每六周发布的稳定渠道
  • Beta,下一个版本的候选者
  • nightly,rust-lang/rust 的每日构建版本
    • 每日服务于一个不同的目的: 它让早期的采用者在发布之前就可以访问未完成的特性(甚至在稳定的轨道上)
    • 但有可能特性会删除,所以最好不要用于生产
  • 更多内容看这里

1.1.3 我们需要什么工具链

  • 更新工具链:rustup update
  • 列出系统中安装的工具链:rustup toolchain list

1.2 项目设置

通过 rustup 安装的工具链会附带其他组件:

  • rustc 编译器:rustc --version
  • cargo: 构建和测试 rust 应用程序的工程管理软件:cargo --version

      cargo new zero2prod
    
    • 创建好了一个git仓库
    • 如果您计划在 GitHub 上托管这个项目,那么只需要创建一个新的空存储库并运行

        cd zero2prod
        git add .
        git commit -am "Project skeleton"
        git remote add origin git@github.com:YourGitHubNickName/zero2prod.git
        git push -u origin main
      
  • 由于 Rust 项目的 CI/CD 设置,beta 版本很少包含问题。它最有趣的组件之一是 crater

1.3 IDE

我们目前用的是 vscode + Rust-analyzer

1.4 内部开发流程

在进行我们的项目时,我们将一遍又一遍地经历相同的步骤,这也被称为内部开发循环

  • 修改
  • 编译
  • 运行测试
  • 运行程序

如果编译和运行应用程序需要 5 分钟,那么你最多可以在一小时内完成 12 次迭代。把它缩短到 2 分钟,你现在可以在同一小时内完成 30 次迭代!编译速度可能成为大型项目的一个痛点,看看如果解决这个问题

1.4.1 更快的链接

我们主要关注的是增量编译的性能——在对源代码做了一个小小的更改之后,需要多长时间来重建我们的二进制文件。默认链接器做得很好,但是根据您使用的操作系统,还有其他更快的选择:

  • lld (Windows 和 Linux),这是 LLVM 项目开发的一个链接器
  • zld (MacOS)

为了加速链接阶段,你必须在你的机器上安装替代的链接器,并将这个配置文件添加到项目中:

# .cargo/config.toml

# On Windows
# ```
# cargo install -f cargo-binutils
# rustup component add llvm-tools-preview
# ```

[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

# On Linux:
# - Ubuntu, `sudo apt-get install lld clang`
# - Arch, `sudo pacman -S lld clang`

[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "linker=clang", "-C", "link-arg=-fuse-ld=lld"]

# On MacOS, `brew install michaeleisel/zld/zld`
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/usr/local/bin/zld"]
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/usr/local/bin/zld"]

很快,lld 将成为 rust 默认的链接器,不需要自己配置

1.4.2 cargo-watch

减少编译时间来减轻对我们生产力的影响,即减少 cargo checkcargo run 的时间。cargo-watch 工具可以帮助我们:

cargo install cargo-watch

Cargo-watch 监视您的源代码,以便在每次文件更改时触发一系列命令,比如 cargo watch -x check 会在每次代码更改后进行 cargo check,这减少了感知到的编译时间:你仍然在你的 IDE 中,重新阅读你刚刚修改的代码;与此同时,cargo-watch 已经启动了编译过程; 一旦切换到终端,编译器就已经完成了一半!

Cargo-watch 也支持命令链:

cargo watch -x check -x test -x run

它将从运行 cargo check 开始,成功后再运行 cargo test,成功后执行 cargo run

1.5 持续集成

在基于主干的开发中,我们应该能够在任何时候部署我们的主要分支。团队中的每个成员都可以从 main 中分离出来,开发一个小功能或者修复一个 bug,然后合并回 main 并发布给我们的用户。

每个提交上运行一系列自动化检查,如果其中一个检查失败,你不能合并到主分支

1.5.1 CI 步骤

测试是 rust 中的一级概念,Cargo test 负责在运行测试之前构建项目(并进行单元测试和集成测试),因此您不需要事先运行 cargo build

1.5.1.2 Code Coverage

虽然使用代码覆盖率作为质量检查有几个缺点,但我确实认为,这是一种快速收集信息的方法,可以发现代码库的某些部分是否随着时间的推移而被忽略,而且测试的确很差

测量 Rust 项目代码覆盖率的最简单方法是通过 cargo tarpaulin,这是 xd009642 开发的 cargo 子命令。你可以使用

# At the time of writing tarpaulin only supports
# x86_64 CPU architectures running Linux.
cargo install cargo-tarpaulin

测试覆盖率,

cargo tarpaulin --ignore-tests

将计算应用程序代码的代码覆盖率,同时忽略您的测试函数。tarpaulin 可以上传代码覆盖指标到流行的服务,比如 CodecovCoveralls,上传指令可以在 tarpaulin 的 README 中找到

1.5.1.3 Linting

在你的学习之旅开始的时候,很容易就能找到相当复杂的问题解决方案,而这些问题本来可以用更简单的方法来解决。linter 试图发现单原语代码、过于复杂的构造和常见错误/低效率。Rust 采用 clippy,可以通过 rustup component add clippy 安装,在项目中运行 cargo clippy

在我们的 CI 流水线中,如果 clippy 发出任何警告,我们希望让 linter 检查失败:

cargo clippy -- -D warnings

有时候,clippy 可能会建议一些您认为既不正确也不可取的更改,可以通过 #[allow(clippy::lint_name)] 属性关闭这段代码块的 clippy 警告,或者使用 clippy.toml 中的 配置为整个项目禁用 linter,或者项目级别的 #![allow(clippy::lint_name)]

有关可用 lint 的详细信息,以及如何根据特定目的调整它们,可以在 clippy 的 readme 中找到。

1.5.1.4 Formatting

  • rustfmt 是官方的格式化工具,可以用 rustup component add rustfmt 安装。
  • 使用 cargo fmt 格式化整个项目
  • 在 CI 流水线中增加一个格式化步骤:cargo fmt -- --check
    • 当提交未格式化的代码时,它将会失败,并将差异打印到控制台上
    • 您可以使用配置文件 rustfmt.toml 对项目的 rustfmt 进行调优,详细参数 rustfmt 的 readme

1.5.1.5 安全漏洞

Rust 安全代码工作小组 维护了一个发布在 crate.io 上的咨询数据库,保存了最新的关于 cargo 漏洞的报告。

rust 还提供 cargo-audit,这是一个方便的 cargo 子命令,用于检查项目依赖关系树中的箱子是否存在漏洞。你可以使用 cargo install cargo-audit 安装,运行 cargo audit 扫描你的依赖树。

我们将按照每天的时间表运行它,以保持对项目依赖性的新漏洞的了解

Embark Studios 开发的 cargo-deny 是另一个依赖关系树进行漏洞扫描的 cargo 子工具,

它帮助你

  • 识别未维护的 crate
  • 定义规则来限制允许的软件许可证集
  • 并发现当您有多个版本的同一 crate 在您的 toml.lock 文件中(浪费编译周期!)

1.5.2 准备就绪的 CI 流水线

学习如何使用 CI 提供商使用的配置语言的特殊风格可能需要花费数小时的时间,而且调试经验往往是相当痛苦的。因此,我决定为最流行的 CI 提供商收集一组现成的配置文件:

2 建立一个电子邮件通讯

2.1 我们的示例

2.1.1 问题导向的学习

选择一个你想解决的问题。

让问题推动新概念和新技术的引入。

它颠覆了你习惯的层次结构:你正在学习的材料不是因为有人声称它是相关的;它是相关的,因为它有助于更接近解决方案。你学习新的技术,并在有意义的时候去使用它们。

问题导向的学习的关键在于细节: 基于问题导向的学习方式可能是令人愉快的,但是很容易让人痛苦地错误判断这个过程中的每一步是多么具有挑战性。

我们需要有一个示例:

  • 小到足以让我们在一本书中解决,而不走弯路
  • 复杂到足以表现出大型系统中出现的大多数关键主题
  • 足够有趣,让读者在阅读过程中参与其中

2.2 我们的时事通讯应该做什么?

如果你愿意在你的博客中添加一个电子邮件订阅页面,我们将尝试建立一个电子邮件通讯服务,支持你所需要的起步——仅此而已

2.2.1 捕捉需求: 用户故事

上面的产品简介留下了一些解释的空间–为了更好地界定我们的服务应该支持什么,我们将利用用户故事。格式非常简单:

  • 作为一名……
  • 我想……
  • 以便……

一个用户故事可以帮助我们捕捉我们正在为谁构建(a) ,他们想要执行(想要)的行为以及他们的动机 (以便)。

我们将完成三个用户故事:

  • 作为一个博客访问者,我想订阅时事通讯,这样我就可以在博客上发布新内容时收到电子邮件更新
  • 作为博客作者,我想给我所有的订阅者发一封邮件,这样我就可以在新内容发布时通知他们;
  • 作为用户,我希望能够取消订阅时事通讯,这样我就不用再收到博客的邮件更新了。

我们将不会添加新功能

  • 管理多种通讯;
  • 在多个受众中细分订阅者;
  • 跟踪开放和点击率。

2.3 在迭代中工作

对于用户故事:作为博客作者,我想给我所有的订阅者发一封邮件,这样我就可以在新内容发布时通知他们。这在实践中意味着什么? 我们需要建立什么?

一旦你开始仔细研究这个问题,很多问题就会冒出来——例如,我们如何确保打电话的人就是博客的作者?我们是否需要引入一个认证机制?我们是支持电子邮件中的 HTML 还是坚持使用纯文本? 那表情符号呢?

我们可以很容易地花费几个月的时间来实现一个非常完美的电子邮件发送系统,甚至没有一个基本的订阅/取消订阅功能。

我们可能会成为发送电子邮件的佼佼者,但没有人会使用我们的电子邮件通讯服务–它没有覆盖整个旅程

相反,我们将尝试构建足够的功能,以在一定程度上满足我们第一个版本中所有故事的需求,而不是深入研究一个故事

然后,我们将回过头来改进: 增加电子邮件传递的容错性和重试,为新用户增加确认电子邮件等等。

我们将在迭代中工作: 每个迭代花费固定的时间,并且给我们一个稍微好一点的产品版本,改善我们用户的体验。

值得强调的是,我们是在对产品特性进行迭代,而不是对工程质量进行迭代: 在每次迭代中产生的代码都将得到测试和适当的文档化,即使它只交付了一个微小的、完全功能化的代码

3 注册一个新用户

本章将首先尝试实现这个用户故事: 作为一个博客访问者,我想订阅时事通讯,这样我就可以在博客上发布新内容时收到电子邮件更新。

  • 我们希望我们的博客访问者将他们的电子邮件地址输入到嵌入在网页上的表单中
  • 表单将触发对后端服务器的 API 调用,后者将实际处理信息、存储信息并发送回响应
  • 本章将集中讨论后端服务器——我们将实现 /subscriptions POST 端点

3.1 我们的策略

我们正在从头开始一个新的项目——有相当数量的前期工作需要我们处理:

  • 选择一个网络框架
  • 定义我们的测试策略
  • 选择一个 crate 与我们的数据库交互(我们将不得不保存这些电子邮件!)
  • 定义如何随着时间的推移管理数据库模式的变化(又名迁移)
  • 写一些查询

这是一个很大的问题,一头扎进去可能会让人不知所措。

我们将添加一个垫脚石,使整个过程更容易:在处理 /subscriptions 之前,我们将实现一个 /health_check 端点。没有业务逻辑,但这是一个很好的机会,可以让我们熟悉的网络框架友,并了解其所有不同的活动部分。

我们将依赖于我们的 CI 来保持我们在整个过程中的检查

3.2 选择网络架构

我们应该使用什么样的 web 框架来编写 Rust API?查看这篇文章:Choosing a Rust web framework, 2020 edition

截至 2022 年 3 月,actix-web 应该成为你的首选 web 框架,当它涉及到 Rust api 的产品 使用时——它在过去的几年中得到了广泛的应用,它有一个庞大而健康的社区支持,它在 tokio 上运 行,因此最大限度地减少了不同异步运行时间之间不兼容/互操作的可能性。

在本章以及以后的内容中,我建议你打开几个额外的网站:

3.3 我们的第一个端点: 基本检查

让我们尝试通过实现一个健康检查端点来开始工作: 当我们收到一个 GET 请求 /health_check 时, 我们希望返回一个没有 body 的 200 OK 响应

3.3.1 接线 actix-web

我们的出发点是 actix-web 主页上的 Hello World! 示例:

use actix_web::{web, App, HttpRequest, HttpServer, Responder};

async fn greet(req: HttpRequest) -> impl Responder {
    let name = req.match_info().get("name").unwrap_or("World");
    format!("Hello {}!", &name)
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(greet))
            .route("/{name}", web::get().to(greet))
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

我们在 Cargo.tmol 中加入

[package]
name = "zero2prod"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

或者通过

cargo add actix-web --vers 4.0.0

快速加入 crate。

cargo add 不是 cargo 默认命令,它是由 cargo-edit 提供的子命令,你可以通过 cargo install cargo-edit 安装

然后通过 cargo check 检查,cargo run 启动服务器,用 curl http://localhost:8000 手动检查,或者打开这个网页

3.3.2 actix-web 应用的剖析

3.3.2.1 Server - HttpServer

HttpServer 是支持我们的应用程序的骨干,它负责以下工作:

  • 应用应在何处侦听传入的要求?是 TCP socket?是 Unix 域的 socket?
  • 我们应该允许的并发连接的最大数量是多少? 每单位时间内有多少新的连接?
  • 我们应该启用传输层安全性(TLS)吗?
  • 等等

换句话说,HttpServer 处理所有传输级别的问题。

当 HttpServer 与 API 的客户端建立了新的连接,我们需要开始处理他们的请求时,它会怎么做?这就是 App 发挥作用的地方!

3.3.2.2 Application

App 是您所有应用程序逻辑的所在: 路由、中间件、请求处理程序等。

App 是一个组件,它的工作就是接收一个传入的请求作为输入,然后输出一个响应。

App::new()
    .route("/", web::get().to(greet))
    .route("/{name}", web::get().to(greet))

new() 为我们提供了一个干净的基础,我们可以一点一点地添加新的行为。

3.3.2.3 Endpoint

如何为我们的 App 添加一个新的端点?route 方法可能是最简单的方法,route 有两个参数:

  • path:一个字符串,可能是模板化的,(例如/{name})来容纳动态路径段
  • rote:Route 结构的一个实例
    • Route 将一个处理程序和一组保护(guards)组合在一起
    • Guards 指定请求必须满足的条件,以便“匹配”并传递给处理程序
    • guards 是 Guard trait 的实现,Guard::check 就是匹配发生的地方

代码中,我们有

.route("/", web::get().to(greet))

”/”将匹配所有基本路径之后没有任何分段的请求 - 即 http://localhost:8000/

web::get()Route::new().guard(guard::Get())的简写,也就是说,当且仅当其HTTP方法是GET时,该请求应被传递给处理程序。

您可以开始想象当一个新请求进来时会发生什么:

  • App 迭代所有已注册的端点
  • 直到找到一个匹配的端点(路径模板和守卫都满足)
  • 并将请求对象传递给处理程序

处理程序是什么样子的? 它的函数签名是什么?

async fn greet(req: HttpRequest) -> impl Responder {
    [...]
}

greet是一个异步函数,它接受一个HttpRequest作为输入,并返回一些实现Responder trait 的东西

3.3.2.4 Runtime - tokio

#[tokio::main] 是做什么的?

我们需要 main 是异步的,因为 HttpServer::run 是一个异步方法,但是 main 不是一个异步函数。Rust 中的异步编程是建立在 Future trait 之上的: Future 代表一个可能还不存在的值。所有的 futures 值都暴露了一个 poll 方法,这个方法必须被调用,以允许 future 进行处理展并确定其最终值。可以认为 rust 的 futures 是惰性的,除非轮询否则不能保证它们会执行完毕。与其他语言采用的推送模型相比,这称为拉取模型。

Rust 的标准库在设计上并不包含异步运行时: 您应该将一个异步运行时作为依赖项引入项目。这种方法非常灵活: 您可以自由地实现自己的运行时, 优化以满足用例的特定需求。这解释了为什么 main 不能是一个异步函数: 谁负责调用 poll?因此,您需要在主函数的顶部启动异步运行时,然后使用它来推动 futures 的完成。

查看 async/wait 的发布说明 withoutboats 在 2019 Rust LATAM 大会中的演讲 Futures Explained in 200 Lines of Rust

tokio::main 是 rust 宏,Rust 宏操作在 token 级别: 它们接收一个符号流(例如,在我们的示例中,整个主函数)并输出一个新符号流,然后将该符号流传递给编译器。换句话说,Rust 宏的主要目的是生成代码。

我们如何调试或检查特定宏发生了什么?你可以检查它输出的 tokens,这正是 cargo expand 的亮点(通过cargo install cargo-expand 安装),它扩展代码中的所有宏,而不需要将输出传递给编译器,允许您逐 步了解它并理解正在发生的事情。cargo expand 运行在 nightly 版,所以我们安装 nightly:

rustup toolchain install nightly --allow-downgrade

由 rustup 捆绑安装的包的一些组件可能在最新的夜间版本中损坏/丢失:allow-downgrade 告诉 rustup 所有需要的组件都安装最新的夜间版本。

我们可以切换工具链到 nightly,cargo 也允许我们对命令指定工具链:

# Use the nightly toolchain just for this command invocation
cargo +nightly expand

我们可以看到宏展开的代码:

fn main() -> std::io::Result<()> {
    let body = async {
        HttpServer::new(|| {
                App::new()
                    .route("/", web::get().to(greet))
                    .route("/{name}", web::get().to(greet))
            })
            .bind("127.0.0.1:8000")?
            .run()
            .await
    };
    #[allow(clippy::expect_used, clippy::diverging_sub_expression)]
    {
        return tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .expect("Failed building the Runtime")
            .block_on(body);
    }
}

扩展 #[tokio::main]后传递给 Rust 编译器的主函数实际上是同步的,所以它编译才没有任何问题,关键在

tokio::runtime::Builder::new_multi_thread()
    .enable_all()
    .build()
    .expect("Failed building the Runtime")
    .block_on(body);

我们正在启动 tokio 的异步运行时,我们用它来驱动 HttpServer::run 返回的 future 执行完成。

换句话说,#[tokio::main]的作用是给我们一种能够定义异步 main 的错觉,而实际上,它只需要我们的main异步主体,并编写必要的样板,使其在 tokio 的运行时之上运行

3.3.3 实现 Health Check Handler

首先,我们需要一个请求处理程序

async fn health_check(req: HttpRequest) -> impl Responder {
    todo!()
}

看一下它的文档,我们可以使用HttpResponse::Ok来获得一个带有200状态代码的HttpResponseBuilder。HttpResponseBuilder暴露了一个丰富的流畅的API来逐步建立一个HttpResponse响应,但我们在这里不需要它:我们可以通过在构建器上调用finish来获得一个空体的HttpResponse。

async fn health_check(req: HttpRequest) -> impl Responder {
    HttpResponse::Ok().finish()
}

仔细观察 HttpResponseBuilder 就会发现它也实现了 Responder ——因此我们可以省略 finish 调用并将处理程序缩短为:

async fn health_check() -> impl Responder {
    HttpResponse::Ok()
}

下一步是处理程序注册-我们需要通过路由将其添加到我们的应用程序:

App::new()
    .route("/health_check", web::get().to(health_check))

3.4 我们的第一次集成测试

我们希望尽可能地实现自动化: 为了防止回滚(regression),每次我们提交更改时,都应该在 CI 管道中运行这些检查。

3.4.1 如何测试端点?

API 是达到目的的一种手段: 一种向外界公开的工具,用于执行某种任务(例如存储文档、发布电子邮件等)。

我们在 API 中公开的端点定义了我们和客户机之间的契约: 关于系统输入和输出的共享协议及其接口。

契约可能会随着时间的推移而发展,我们可以粗略地描述两个场景:

  • 向后兼容的更改(例如添加一个新的端点);
  • 中断的更改(例如删除一个端点或从其输出的模式中删除一个字段)

什么是最可靠的方法来检查我们没有引入用户可见的回归?通过与 API 进行交互来测试 API,方式与用户完全相同: 针对 API 执行 HTTP 请求,并验证我们对接收到的响应的假设。这通常被称为黑盒测试: 我们通过检查给定一组输入的输出来验证系统的行为,而无需访问其内部实现的详细信息

遵循这个原则,我们不会满足于直接调用处理函数的测试,比如:

#[cfg(test)]
mod tests {
    use crate::health_check;
    #[tokio::test]
    async fn health_check_succeeds() {
        let response = health_check().await;
        // This requires changing the return type of `health_check`
        // from `impl Responder` to `HttpResponse` to compile
        // You also need to import it with `use actix_web::HttpResponse`!
        assert!(response.status().is_success())
    }
}
  • 我们没有检查处理程序是否在 GET 请求中被调用
  • 我们没有检查处理程序是否以 /health_check 作为路径调用
  • 更改这两个属性中的任何一个都会破坏 API 契约,但是我们的测试仍然会通过——这还不够好

我们将选择一个完全的黑盒解决方案: 我们将在每个测试开始时启动我们的应用程序,并使用一个现成的 HTTP 客户机(例如 reqwest)与它进行交互

3.4.2 我应该把测试放在哪里?

Rust 为您提供了三种编写测试的选择:

  • 在你的代码中嵌入测试模块

      #[cfg(test)]
      mod tests {
          use super::*;
          }
      } 
    
  • 在外部 tests 文件夹

      src/
      tests/
      Cargo.toml
      Cargo.lock
    
  • 作为公开文件的一部分(文件测试)

      /// pub Check if a number is even.
      /// ```rust
      /// use zero2prod::is_even;
      /// assert!(is_even(2));
      /// assert!(!is_even(1));
      /// ```
      fn is_even(x: u64) -> bool {
          x % 2 == 0
      }
    

它们有什么区别?

  • 嵌入式测试模块是项目的一部分
    • 隐藏在配置条件检查 #[cfg(test)] 之后
    • 嵌入式测试模块具有访问其旁边代码的特权: 它可以与结构、方法、字段和函数进行交互
    • 这些结构、方法、字段和函数没有被标记为公共的
    • 您可以利用嵌入式测试模块为私有子组件编写单元测试,以增加您对整个项目正确性的总体信心
  • 测试文件夹下的任何东西和你的文档测试
    • 都是用它们各自独立的二进制文件编译的
    • 外部tests文件夹和 doc 测试中的 Tests 具有与在另一个项目中将 crate 作为依赖项添加时相同的访问代码的级别
    • 因此,它们主要用于集成测试,也就是用与用户完全相同的方式调用代码来测试代码

3.4.3 改变我们的项目结构,使测试更简单

任何在 tests 目录下的代码都会被编译成它自己的二进制代码,也就是我们所有在 test 目录下的代码都是作为一个 crate 导入的。但是目前我们的项目是二进制的: 它应该被执行,而不是被共享。因此我们不能像现在这样在 tests 中导入我们的主函数。

我们需要将我们的项目重构成一个库和一个二进制文件: 我们所有的逻辑都存在于库中,而二进制文件本身只是 mian 函数非常简单的入口点。

  • 第一步: 我们需要改变我们的 Cargo.toml
    • 我们依赖 cargo 的默认行为:
      • 除非将某个内容写出来,否则它将查找 src/main.rs 文件作为二进制入口点
      • 并使用 package.name 字段作为二进制名称
      • 查看toml 清单目标规范,我们需要添加一个 lib 部分来添加一个库到我们的项目中
        [package]
        name = "zero2prod"
        version = "0.1.0"
        edition = "2021"
      
        [lib]
        # 我们可以使用任何路径,但我们遵循的是社区惯例
        # 我们可以使用name字段指定一个库名。如果未指定
        # Cargo 将默认为 package.name
        path = "src/lib.rs"
      
    • 尽管它正在工作,但是我们的 Cargo.toml 文件并不一目了然
    • 我们在toml中看到了一个库,但看不到bin,最好把所有内容都写出来

        [package]
        name = "zero2prod"
        version = "0.1.0"
        edition = "2021"
      
        # 我们可以使用任何路径,但我们遵循的是社区惯例
        # 我们可以使用name字段指定一个库名。如果未指定
        # Cargo 将默认为 package.name
        [lib]
        path = "src/lib.rs"
      
        # 注意双方括号: 这是 TOML 语法中的一个数组。
        # 我们在一个项目中只能有一个库,但是我们可以有多个二进制文件!
        # 如果你想在同一个仓库中管理多个库
        # 看一下工作空间特性——我们稍后将介绍它
        [[bin]]
        path = "src/main.rs"
        name = "zero2prod"
      
    • 我们可以将主函数迁移到我们的库中(命名为 run 以避免冲突)
      • main.rs

          use zero2prod::run;
        
          #[tokio::main]
          async fn main() -> std::io::Result<()> {
              run().await
          }
        
      • lib.rs

          use actix_web::{web, App, HttpResponse, HttpServer};
        
          async fn health_check() -> HttpResponse {
              HttpResponse::Ok().finish()
          }
          // We need to mark `run` as public.
          // It is no longer a binary entrypoint, therefore we can mark it as async
          // without having to use any proc-macro incantation.
          pub async fn run() -> std::io::Result<()> {
              HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
                  .bind("127.0.0.1:8000")?
                  .run()
                  .await
          }
        

3.5 实现我们的第一个集成测试

我们对健康检查端点的规范是:当我们收到 GET 请求 /health_check 时,我们返回 200 OK 响应,但没有返回正文。让我们把它转换成一个测试,尽可能多地写:

//! tests/health_check.rs

// `tokio::test` is the testing equivalent of `tokio::main`.
// It also spares you from having to specify the `#[test]` attribute.
//
// You can inspect what code gets generated using
// `cargo expand --test health_check` (<- name of the test file)
#[tokio::test]
async fn health_check_works() {
    // Arrange
    spawn_app().await.expect("Failed to spawn our app.");
    // 我们需要引入‘reqwest’
    // 对我们的应用程序执行 HTTP 请求
    let client = reqwest::Client::new();
    // Act
    let response = client
        .get("http://127.0.0.1:8000/health_check")
        .send()
        .await
        .expect("Failed to execute request.");
    // Assert
    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

// Launch our application in the background ~somehow~
async fn spawn_app() -> std::io::Result<()> {
    todo!()
}

在 Cargo.toml 中加入

# Dev 依赖关系仅在运行测试或示例时使用
# 它们不包含在最终的应用程序二进制文件中
[dev-dependencies]
reqwest = "0.11"

spawn_app 是唯一依赖 app 的代码,其他都与底层实现细节分离。如果以后我们放弃 rust,用 ruby 重写 app,我们仍然可以使用相同的测试大件来检查,只要用适当的触发器替换 spawn_app.测试还涵盖了我们感兴趣的所有属性:

  • 暴露在 /health_check 中的健全性检查
  • 健康检查是 GET 方法
  • 健康检查总是返回 200
  • 健康的反馈没有正文

但我们运行时发现 spawn_app 还没有实现,如果实现成:

async fn spawn_app() -> std::io::Result<()> {
    zero2prod::run().await
}

进行 cargo test 后测试不会终止。因为在 zero2prod::run 中我们调用(并等待) HttpServer::run,而HttpServer::run 返回一个 Server 实例,当我们调用 .await 后它开始无限期地监听我们指定的地址: 它将在传入请求到达时处理它们,但它永远不会自己关机或“完成”,这样 spawn_app 不会返回,测试也不会开始。

所以我们要让 app 在后台任务中运行。

这样处理 tokio::spwn 非常方便:tokio::spwn接收一个future,并将其交给运行时进行轮询,而不等待其完成;因此它与下游的 futures 和任务(例如我们的测试逻辑)同时运行。

/*
pub async fn run() -> std::io::Result<()> {
    HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind("127.0.0.1:8000")?
        .run()
        .await
}
*/

// We return `Server` on the happy path and we dropped the `async` keyword
// 我们在路径上返回“服务器”,我们去掉了“异步”关键字
// We have no .await call, so it is not needed anymore.
// 我们没有调用 .await,所以不再需要它了
pub fn run() -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind("127.0.0.1:8000")?
        .run();
    // No .await here!
    Ok(server)
}

main.rs 也要修改

#[tokio::main]
async fn main_origin() -> std::io::Result<()> {
    run().await
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // 如果我们未能绑定地址,就会冒出 io::Error 
    // 否则在 Server 上调用.wait
    run()?.await
}

再写 spawn_app

// 没有.await调用,因此不需要 `spawn_app` 是异步的
// 我们还在进行测试,因此不值得传播错误:
// 如果我们不能执行所需的设置,我们可能只是恐慌和崩溃
fn spawn_app() {
    let server = zero2prod::run().expect("Failed to bind address");
    // 启动服务器作为后台任务
    // tokio::spawn 返回一个产生 future 的句柄
    // 但是我们在这里没有使用它,因此用_绑定 
    let _ = tokio::spawn(server);
}

然后调整我们的测试以适应 spawn_app 签名的变化:

#[tokio::test]
async fn health_check_works() {
    // Arrange
    spawn_app();
    // [...]
}

现在运行 cargo test,它测试通过了

对于我的环境,要记得:

  1. 修改 curl 的代理 ~/.curlrc
  #socks5 = localhost:6789
  #proxy = localhost:6789
  1. 关闭终端代理 sysnops
  alias sysnops="unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY"

3.5.1 Polishing

3.5.1.1 Clean Up

测试结束后,我们在后台运行的应用程序资源会自动清除。因为 tokio::spawn 运行完成后会自动释放它们

3.5.1.2 Choosing A Random Port

如果端口 8000 被我们机器上的另一个程序使用(例如我们自己的应用程序)测试将失败; 如果我们尝试并行运行两个或多个测试,只有其中一个将设法绑定端口,其他所有测试都将失败。

测试应该在随机可用的端口上运行它们的后台应用程序。

首先,我们需要改变 run 函数——它应该把应用程序地址作为参数:

pub fn run(address: &str) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| 
            App::new()
                .route("/health_check", web::get().to(health_check)))
        .bind(address)?
        .run();
    // No .await here!
    Ok(server)
}

如何为我们的测试找到一个随机可用的端口?端口 0 在操作系统级别是特殊情况: 尝试绑定端口 0 将触发对可用端口的操作系统扫描,然后可用端口将绑定到应用程序。

因此,将 spawn_app 更改为:

fn spawn_app() {
    let server = zero2prod::run("127.0.0.1:0").expect("Failed to bind address");
    // 启动服务器作为后台任务
    // tokio::spawn 返回一个产生 future 的句柄
    // 但是我们在这里没有使用它,因此用_绑定 
    let _ = tokio::spawn(server);
}

为了测试,我们需要以某种方式找出操作系统给我们的应用程序提供了什么端口,并将它从 spawn_app 返回。

有几种方法可以做到这一点——我们将使用 std::net::TcpListener。HttpServer 执行了双重任务:

  • 绑一个定给的地址
  • 然后启动应用程序

我们将自己用 TcpListener 绑定端口,然后用 listen 将端口交给 HttpServer。

TcpListener::local_addr 返回一个 SocketAddr 地址,这个地址会暴露我们通过 .port 绑定的端口

run 函数:

pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .listen(listener)?
        .run();
    Ok(server)
}

spawn_app() 函数:

fn spawn_app() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").expect("随机端口绑定失败");
    let port = listener.local_addr().unwrap().port();
    let server = zero2prod::run(listener).expect("Failed to bind address");
    // 启动服务器作为后台任务
    // tokio::spawn 返回一个产生 future 的句柄
    // 但是我们在这里没有使用它,因此用_绑定
    let _ = tokio::spawn(server);
    format!("http://127.0.0.1:{port}")
}

health_check_works:

async fn health_check_works() {
    // Arrange
    let address = spawn_app();
    // 我们需要引入‘reqwest’
    // 对我们的应用程序执行 HTTP 请求
    let client = reqwest::Client::new();
    // Act
    let response = client
        .get(&format!("{}/health_check", &address))
        .send()
        .await
        .expect("Failed to execute request.");
    // Assert
    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

3.6 Refocus

现在是时候利用我们学到的东西,最终完成我们的电子邮件通讯项目的第一个用户故事了:

  • 作为一个博客访问者,
  • 我想订阅时事通讯,
  • 这样我就可以在博客上发布新内容时收到电子邮件更新

我们希望我们的博客访问者将他们的电子邮件地址输入到嵌入在网页上的表单中。

该表单将触发一个 POST/subscription 调用到我们的后端 API,该 API 将实际处理信息,存储信息并发送回应

我们必须深入调查:

  • 如何在 actix-web 中读取 HTML 表单中收集的数据
  • 可以使用哪些库来处理 Rust 中的 PostgreSQL 数据库(diesel vs sqlx vs tokio-postgres)
  • 如何为我们的数据库设置和管理迁移
  • 如何在 API 请求处理程序中获得数据库连接
  • 如何在集成测试中测试副作用(也就是存储数据)
  • 如何在使用数据库时避免测试之间的奇怪交互

3.7 使用 HTML 表格

3.7.1 优化我们的需求