我们的第一个Endpoint - 简单的可用性检测

让我们尝试通过实现一个健康检查端点来开始:当我们收到对 /health_check 的 GET 请求时,我们希望返回一个不带正文的 200 OK 响应。

我们可以使用 /health_check 来验证应用程序是否已启动并准备好接受传入请求。

将它与 pingdom.com 这样的 SaaS 服务结合使用,您可以在 API 出现故障时收到警报——这对于您正在运行的电子邮件简报来说是一个很好的基准。

如果您使用容器编排器 (例如 KubernetesNomad)来协调您的应用程序,那么健康检查端点也会非常方便:编排器可以调用 /health_check 来检测 API 是否无响应并触发重启。

编写 Actix-Web

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

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

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
}

让我们把示例代码粘贴到 main.rs中

让我们运行 cargo check

error[E0432]: unresolved import `actix_web`
 --> src/main.rs:1:5
  |
1 | use actix_web::{App, HttpRequest, HttpServer, Responder, web};
  |     ^^^^^^^^^ use of unresolved module or unlinked crate `actix_web`
  |
  = help: if you wanted to use a crate named `actix_web`, use `cargo add actix_web` to add it to your `Cargo.toml`

error[E0433]: failed to resolve: use of unresolved module or unlinked crate `tokio`
 --> src/main.rs:8:3
  |
8 | #[tokio::main]
  |   ^^^^^ use of unresolved module or unlinked crate `tokio`

Some errors have detailed explanations: E0432, E0433.
For more information about an error, try `rustc --explain E0432`.

等..等等...为什么会这样

我们尚未将 actix-web 和 tokio 添加到依赖项列表中,因此编译器无法解析我们导入的内容。

我们可以手动修复此问题,方法是在 Cargo.toml 中添加

#! Cargo.toml

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

或许我们可以执行 cargo add actix-web 来快速添加 actix-web 依赖

让我们再次运行 cargo check! 现在应该一切正常了!

让我们尝试运行一下应用程序!

cargo run

在你喜欢的终端尝试一下API吧

curl http://127.0.0.1:8000

太好了! 这可以用!

现在, 你可以按下 Ctrl+C来停止web应用程序

actix-web 应用程序的剖析

现在让我们回过头仔细看看我们刚刚在 main.rs 文件中复制粘贴的内容。

//! src/main.rs

// [...]

#[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
}

服务器 - HttpServer

HttpServer 是支撑我们应用程序的骨干。它负责处理以下事项:

  • 应用程序应该在哪里监听传入的请求?TCP socket (例如 127.0.0.1:8000)? Unix 域套接字?
  • 我们应该允许的最大并发连接数是多少?单位时间内可以创建多少个新连接?
  • 我们应该启用传输层安全性 (TLS) 吗?
  • 等等

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

之后会发生什么?当 HttpServer 与我们的 API 客户端建立了新的连接,而我们需要开始处理他们的请求时,它会做什么?

这时,App 就派上用场了!

应用程序 - App

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

App 组件的作用是接收传入的请求并返回响应。

让我们仔细地看一下如下代码片段:

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

App 是builder模式的一个实际示例: new() 为我们提供了一个干净的平台,我们可以使用流畅的 API(即链式调用)一点一点地添加新的行为。

我们将在整本书中根据需要了解 App 的大部分 API 接口: 读完本书后,您应该至少接触过一次它的大多数方法。

端点 - Route

如何向我们的应用添加新的端点?route 方法可能是最简单的方法 ——毕竟,我们已经在 Hello World! 示例中使用过了!

route 方法接受两个参数:

  • path - 一个字符串,可能为模板(例如/{name}), 用于容纳动态路径
  • route - Route 结构体的实例

Route 将处理程序与一组守卫组合在一起。

守卫指定请求必须满足的条件才能“匹配”并传递给处理程序。从实现的角度来看,守卫是 Guard 特性的实现者: Guard::check 是奇迹发生的地方。

在我们的代码片段中

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

"/" 将匹配所有在基本路径后不带任何段的请求,例如 http://localhost:8000/web::get()Route::new().guard(guard::Get()) 的快捷方式,也就是说,当且仅当请求的 HTTP 方法是 GET 时,该请求才会传递给处理程序。

您可以想象一下,当一个新请求到来时会发生什么:应用会遍历所有已注册的端点,直到找到一个匹配的端点(路径模板和保护条件都满足),然后将请求对象传递给处理程序。

这并非 100% 准确,但目前来说,这是一个足够好的思维模型。

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

目前我们只有一个示例,greet:

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

运行时 - Tokio

我们从整个 HttpServer 深入到 Route。让我们再看一下整个 main 函数:

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

#[tokio::main] 有什么用? 当然, 让我们删掉它看看会发生什么!

很不幸的, cargo check 给出了如下的报错

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:9:1
  |
9 | async fn main() -> std::io::Result<()> {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

For more information about this error, try `rustc --explain E0752`.
error: could not compile `zero2prod` (bin "zero2prod") due to 1 previous error

我们需要 main 函数是异步的,因为 HttpServer::run 是一个异步方法,但 main 函数(我们二进制文件的入口点)不能是异步函数。为什么呢?

Rust 中的异步编程建立在 Future trait 之上: Future 代表一个可能尚未到达的值。所有 Future 都公开一个 poll 方法,必须调用该方法才能使 Future 继续执行并最终解析出最终值。你可以将 Rust 的 Future 视为惰性的:除非进行轮询,否则无法保证它们会执行完成。与其他语言采用的推送模型相比,这通常被描述为一种拉模型。

Rust 的标准库在设计上不包含异步运行时:你应该将其作为依赖项引入你的项目,在 Cargo.toml 文件的 [dependencies] 下添加一个 crate。这种方法非常灵活:您可以自由地实现自己的运行时,并根据用例的特定需求进行优化(参见 Fuchsia 项目或 bastion 的 Actor 框架)。

这就解释了为什么 main 不能是异步函数:谁负责调用它的 poll 方法?

没有特殊的配置语法来告诉 Rust 编译器你的依赖项之一是异步运行时(例如,我们对分配器所做的配置),而且,公平地说,甚至没有一个关于运行时的标准化定义(例如,Executor trait)。 因此,你应该在 main 函数的顶部启动异步运行时,

然后用它来驱动你的 Future 完成。

你现在可能已经猜到 #[tokio::main] 的用途了,但仅仅猜测是不够的:

我们想看到它。

但是怎么做呢?

tokio::main 是一个过程宏,这是一个引入 cargo expand 的绝佳机会,它对于 Rust 开发来说是一个很棒的补充:

cargo install cargo-expand

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

我们如何调试或检查特定宏的运行情况?您可以检查它输出的 token!

这正是 cargo expand 的亮点所在:它会扩展代码中的所有宏,而无需将输出传递给编译器,让您可以单步执行并了解正在发生的事情。

让我们使用 cargo expand 来揭开 #[tokio::main] 的神秘面纱:

cargo expand
fn main() -> std::io::Result<()> {
    let body = async { run().await };
    #[allow(
        clippy::expect_used,
        clippy::diverging_sub_expression,
        clippy::needless_return
    )]
    {
        return tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .expect("Failed building the Runtime")
            .block_on(body);
    }
}

我们终于可以看看宏扩展后的代码了!

#[tokio::main] 扩展后传递给 Rust 编译器的 main 函数 确实是同步 (Sync) 的, 这也解释了为什么它编译时没有任何问题。

关键的一行是:tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("[...]").block_on(/*[...]*/)

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

换句话说,#[tokio::main] 的作用是让我们产生能够定义异步主函数的错觉,而实际上,它只是获取我们的主要异步代码,并编写必要的样板代码,使其在 tokio 的运行时之上运行。

实现 Health Check Handler

我们已经回顾了 actix_web 的 Hello World! 示例中所有需要移动的部分:HttpServerApprouteactix_web::main

我们当然已经了解了足够多的内容,可以修改示例,使健康检查能够按预期工作:

/health_check 收到 GET 请求时,返回一个不带正文的 200 OK 响应。

让我们重新回顾一下我们的起点:

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

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
}

首先,我们需要一个请求处理程序。模仿 greet 函数,我们可以从以下签名开始:

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

我们说过,Responder 只不过是一个转换为 HttpResponse 的 trait。那么直接返回一个 HttpResponse 实例应该就行了!

查看它的文档,我们可以使用 HttpResponse::Ok 来获取一个已准备好 200 状态码的 HttpResponseBuilder。HttpResponseBuilder 提供了一个丰富的流畅 API,可以逐步构建 HttpResponse 响应,但我们在这里不需要它: 我们可以通过在构建器上调用 finish 来获取一个带有空主体的 HttpResponse。

将所有内容结合在一起:

use actix_web::{HttpRequest, HttpResponse, Responder};

// [...]

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

快速运行一下 cargo check,确认我们的处理程序没有做任何奇怪的事情。

仔细查看一下 HttpResponseBuilder 的定义, 会发现它也实现了 Responder 接口——因此,我们可以省略对 finish 的调用,并将处理程序简化为:

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

下一步是处理程序注册 - 我们需要通过 route 将其添加到我们的 App 中: (别忘了删除示例中的greet方法和相关route注册的代码)

//! src/main.rs

// [...]
#[tokio::main]
async fn main() -> std::io::Result<()> {
    // [...]
    App::new()
        // [...]
        .route("/health_check", web::get().to(health_check))
    // [...]
}

现在我们的代码看起来是这样

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

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

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

你可能看到 cargo check 在抱怨变量 req没有用到

我们的健康检查响应确实是静态的,并且不使用任何与传入 HTTP 请求捆绑的数据(路由除外)。我们可以遵循编译器的建议,在 req 前添加下划线...... 或者,我们可以从 health_check 中完全删除该输入参数:

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

惊喜啊,它编译通过了!actix-web 在后台运行着一些相当高级的类型处理程序, 并且它接受各种各样的签名作为请求处理程序——稍后会详细介绍。

接下来做什么?

好吧,来个小测试!

curl -v http://127.0.0.1:8000/health_check
$ curl -v http://127.0.0.1:8000/health_check

*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
* using HTTP/1.x
> GET /health_check HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.15.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< content-length: 0
< date: Wed, 20 Aug 2025 06:36:57 GMT
< 
* Connection #0 to host 127.0.0.1 left intact

你现在应该可以在响应中看到类似HTTP/1.1 200 OK的字样

这太棒了! 我们的Health Check正在工作!

恭喜你实现了第一个 actix-web 端点!