辞职 4 个月后,我用 Rust 实现了个简单的 Spring Boot

大家好,我是 ZihanType,一个热爱 Rust 的程序员。我在 2023 年 11 月辞职,目前正在找工作。在这段时间里,我用 Rust 写了一个 Spring Boot,这个项目叫做 predawn.

辞职

我的上一份工作是在一家北京的公司做 Java 后端开发,工作了整两年。刚开始进入公司的时候,我还能从工作中获得一些成就感,但是随着时间的推移,对手头的事熟练之后,我发现自己的工作变得越来越枯燥,甚至有些厌倦。名义上做了 N 个项目,实际上都是增删改查。此外,公司的后端一直用的是 Java 8,没有用上新的特性也就罢了,还要常常和 Java 8 的历史遗留问题作无休止的斗争,这让我对 Java 甚至对编程都产生了厌倦。

于是我开始在休息时间使用 Rust 写代码,让我喜欢的 Rust 来治愈我。写了一些小项目之后,我在社区中发现了 poem-openapi,这是一个 Web 后端框架,它写起来很像 Spring Boot,但实际上差别很大,比如说,没有依赖注入。我开始思考,要怎么用 Rust 实现一个 Spring Boot 那样的框架,因为我虽然对 Java 产生了厌倦,但是对 Spring Boot 还是很喜欢的。我一直觉得 Spring Boot 哪哪都好,唯一的缺点是它是用 Java 实现的。

想要写一个 Spring Boot,先要写一个 Spring,要有依赖注入。于是我利用休息时间,参考 koin,用 Rust 写了一个依赖注入框架,叫 rudi。我为 rudi 编写了详细的文档、文档测试、测试,然后发布到 crates.io 上,收获了一些固定的使用者,目前每个版本都有近 3 百的下载量。

在我工作中痛苦地写 Java,休息时开心地写 Rust 的过程中,我发现我对编程的热情又回来了。我开始思考,是不是我应该换个写 Rust 的工作,一方面,我更喜欢写 Rust;另一方面,Rust 的使用率在逐渐提高,由此带来的岗位也越来越多。换个赛道,未尝不可。

在权衡利弊之后,我辞职了。

开发

正如我上面所写的,之前的工作,不管什么项目,都是增删改查,想要拿这些项目经验去找 Rust 相关的工作,我感觉有点难,最起码,简历上得有点值得一说的项目。所以在辞职之后,我正式开始用 Rust 实现一个 Spring Boot

一开始,我先阅读社区中各 Web 框架的代码,先看看 Web 框架是怎么实现的。虽然我用 Java 和 Rust 写过很多 Web 项目,但是我对 Web 框架的实现原理却一直不是很了解。我阅读了包括但不限于 axumpoem-openapisalvo, volo-httploco 这些框架的代码。尤其要感谢 volo-http,在我阅读代码的时候,volo-http 刚刚开始发展,代码量很少,但是实现很完善,非常适合我这种初学者阅读。在阅读了这些库的代码之后,我发现,Web 框架的原理其实很简单,就是把 N 个 handler 组合起来,等待请求进来,然后把请求交给不同的 handler 处理,最后返回一个响应。很简单,也很神奇。

搞清楚 Web 框架的原理之后,就是构思我想要的 Web 框架是什么样的,它的 API 应该怎么设计。在这点上,我仍然是通过阅读社区中已有的 Web 框架的代码,看看这个,瞅瞅那个,有我喜欢的,就直接拿过来;不完全符合我口味的,就稍微改一改;没有我想要的,就得自己想。突出一个,缝了但没完全缝。

在构思完 API 之后,就是开始写代码了。我为开发过程定下了 3 步走策略:

  1. 先实现最基本的功能,能够启动一个 Web 服务,接收请求,返回响应。
  2. 集成 OpenAPI,开发者只需要正常的写代码,就能够生成 OpenAPI 文档。像 poem-openapi 一样。
  3. 扫描配置文件,自动依赖注入,一行代码启动服务。像 Spring Boot 一样。

在经过了 4 个月的开发之后,我终于完成了上述 3 步,能像 Spring Boot 那样一行代码启动。我把这个项目叫作 predawn,没有特别的意思,就是想不出什么更好且没有被占用的名字。

顺便说一个开发过程中让我纠结了很久的功能。启动 Web 服务时,初始化日志采集器,扫描配置文件,这 2 个启动步骤,我不知道该哪个前,哪个后。如果先初始化日志采集器,那就没办法根据配置文件自定义日志采集器的行为;如果先扫描配置文件,就没法打印扫描过程中的日志,因为当前上下文还没有日志采集器。这个问题困扰了我很久,直到我看到 loco 的代码中对此的解决办法:扫描 2 次配置文件。先扫描配置文件,然后初始化日志采集器,再扫描配置文件,第 2 次扫描纯粹是为了打印日志。我看到后大为震惊,还可以这样?这个解决方案非常简单,但是我却没有想到,这让我知道还是要多看别人的代码,你遇到的绝大多数问题别人也遇到了,不要自己一个人钻牛角尖。

简单示例

下面是一个简单的示例,展示了如何使用 predawn 来启动一个 Web 服务。

rust 复制代码
use predawn::{
    app::{run_app, Hooks},
    controller,
    extract::query::Query,
    ToParameters,
};
use rudi::Singleton;
use serde::{Deserialize, Serialize};

struct App;

impl Hooks for App {}

#[tokio::main]
async fn main() {
    run_app::<App>().await;
}

#[derive(Serialize, Deserialize, ToParameters)]
struct Hello {
    name: String,
}

#[Singleton]
#[derive(Clone)]
struct Controller;

#[controller]
impl Controller {
    #[handler(paths = ["/"], methods = [get])]
    async fn index(&self, Query(hello): Query<Hello>) -> String {
        format!("Hello, {}!", hello.name)
    }
}

从上述示例可以看出这么几项:

  1. 定义了一个 App 结构体,实现了 Hooks trait,这个结构体是整个 Web 服务的入口。
  2. Hooks trait 中定义了所有启动服务时可以自定义的方法,且都有默认实现。
  3. main 函数中,调用 run_app 函数,传入 App 结构体,启动 Web 服务。
  4. 定义一个 Hello 结构体,实现了序列化和反序列化,以及 ToParameters trait,用于从请求头中提取出 Hello 结构体。
  5. #[Singleton] 会将 Controller 结构体注册为单例。
  6. #[controller] 会将 Controller 结构体注册为一个控制器。
  7. #[handler] 会将 index 方法转换为一个 handler,当请求路径为 /,请求方法为 GET 时,调用 index 方法。
  8. index 方法接收一个 Query<Hello> 参数,即一个名为 name 的 URL 参数,返回一个字符串。

运行上述代码后,用浏览器打开 http://localhost:9612/p/rapidoc 就能看到 RapiDoc 的界面,展示了一个简单的 OpenAPI 文档。也可以将 URL 中的 /p/rapidoc 替换为 /p/swagger-ui,就能看到 Swagger UI 的界面。

定义类型

定义类型时有 2 个基本的 trait ,ToSchemaToParameters,都有同名的派生宏。ToSchema 用于定义单个 OpenAPI 文档中的 Schema 对象,每个实现了 ToSchema trait 的实例都可以表示一个 Schema 对象。ToParameters 用于定义多个 OpenAPI 文档中的 Parameter 对象,每个实现了 ToParameters trait 的实例都可以表示多个 Parameter 对象。

举个例子:

rust 复制代码
#[derive(Serialize, Deserialize, ToSchema, ToParameters)]
pub struct Person {
    name: String,
    age: u16,
}

#[Singleton]
#[derive(Clone)]
struct Controller;

#[controller]
impl Controller {
    #[handler(paths = ["/"], methods = [get])]
    async fn index(&self, Query(person): Query<Person>) -> Json<Person> {
        Json(person)
    }
}

Person 结构体实现了 ToSchemaToParameters trait 之后,Person 结构体就可以表示一个 OpenAPI 文档中的 Schema 对象和多个 Parameter 对象。

index 方法中:

  1. Query<Person> 表示 2 个 Parameternameage
  2. Json<Person> 表示 1 个 SchemaPerson

一句话,ToParameters 用于请求头,ToSchema 用于请求体和响应体。(虽然定义响应头的宏还没实现,但不是用 ToParameters

定义 API

如上述示例所示,定义 API 有 3 个基本的宏,#[Singleton]#[controller]#[handler]

  1. #[Singleton] 用于定义并自动注册一个单例,还有另外 2 个定义的宏,详情见 rudi
  2. #[controller] 用于定义并自动注册一个控制器。
    1. #[controller] 宏有 2 个参数(当前),pathsmiddleware,分别表示请求路径和中间件。
      1. paths 是一个表示字符串的表达式的列表,表示请求路径,默认值是 ["/"]
      2. middleware 是一个函数的路径,用于为控制器的所有方法添加中间件,默认值是 None
  3. #[handler] 用于定义一个 handler。
    1. #[handler] 宏有 3 个参数(当前),pathsmethodsmiddleware,分别表示请求路径、请求方法、中间件。
      1. pathsmiddleware#[controller] 上的同名参数一样。
      2. methods 的值是请求方法的列表,大小写不敏感,默认是所有方法。
    2. handler 宏的方法必须是异步的。
    3. 第一个参数必须是 &self
    4. 参数类型必须是实现了 FromRequest trait 或 FromRequestHead trait 的类型,且只允许最后一个参数实现了 FromRequest trait,即从请求体中提取数据。
    5. 返回值类型必须是实现了 IntoResponse trait 的类型。

定义请求

在上述示例中,我们使用了 Query 提取 URL 参数。除了 Query,还有 PathJsonForm 等提取器,用于提取不同的请求参数。如果这些提取器不能满足你的需求,你可以自定义提取器。

想要自定义请求,需要实现 FromRequest trait 或 FromRequestHead trait,从功能上来说和社区中其他 Web 框架的同名 trait 是一样的,但有一些细节上的不同。下面是 FromRequest trait 和 FromRequestHead trait 的定义:

rust 复制代码
#[async_trait]
pub trait FromRequest<'a, M = private::ViaRequest>: Sized {
    type Error: ResponseError;

    async fn from_request(head: &'a Head, body: RequestBody) -> Result<Self, Self::Error>;

    fn parameters(components: &mut Components) -> Option<Vec<Parameter>>;

    fn request_body(components: &mut Components) -> Option<openapi::RequestBody>;
}

#[async_trait]
pub trait FromRequestHead<'a>: Sized {
    type Error: ResponseError;

    async fn from_request_head(head: &'a Head) -> Result<Self, Self::Error>;

    fn parameters(components: &mut Components) -> Option<Vec<Parameter>>;
}

先不看 FromRequest trait 中的 M 泛型,这个以后有机会再说,下面说说几个重要的地方:

  1. 关联类型 Error,用于定义提取器提取失败时返回的错误类型,且必须实现 ResponseError trait。这不同于 axum 中同名的 FromRequest trait 的关联类型 RejectionRejection 需要实现 IntoResponse trait,IntoResponse trait 只是负责把一个类型转换为 Response,而不考虑错误处理。一旦出现错误,层层嵌套,层层传递,Rejection 是没办法提供原因信息的。而 ResponseError trait 继承了 std::error::Error trait,同时提供转换为 Response 和保存错误原因的能力,这样能够更好地处理错误。

  2. 生命周期参数 'a,出现在 trait 定义和 head 参数中,这可以让从请求头中提取数据的提取器只使用引用而不用克隆,从而提高性能。 例如,上述代码中的 Hello 类型,也可以这么定义:

    rust 复制代码
    #[derive(Serialize, Deserialize, ToParameters)]
    struct Hello<'a> {
        name: &'a str,
    }
    
    #[Singleton]
    #[derive(Clone)]
    struct Controller;
    
    #[controller]
    impl Controller {
        #[handler(paths = ["/"], methods = [get])]
        async fn index(&self, Query(hello): Query<Hello<'_>>) -> String {
            format!("Hello, {}!", hello.name)
        }
    }
  3. parametersrequest_body 方法,用于生成 OpenAPI 文档中的 ParameterRequestBody 对象。

定义响应

在上述示例中,我们使用了 Json 返回 JSON 数据。除了 Json,还有 From 响应,目前只实现了这 2 个,后续还会有 HtmlXml 等响应,用于返回不同的响应。同样,如果这些响应不能满足你的需求,你可以自定义。

自定义响应需要实现 IntoResponse trait,和社区中其他 Web 框架的同名 trait 功能相似,但也有些许不同:

rust 复制代码
pub trait IntoResponse {
    type Error: ResponseError;

    fn into_response(self) -> Result<Response, Self::Error>;

    fn responses(components: &mut Components) -> Option<BTreeMap<StatusCode, openapi::Response>>;
}

有几点重要的地方:

  1. 关联类型 Error。这和上述的 FromRequest trait 的关联类型 Error 是一样的,用于定义响应失败时返回的错误类型,且必须实现 ResponseError trait。其他 Web 框架中的同名 trait 甚至没有关联类型,into_response 方法的返回类型只有 Response,没有错误处理的能力。
  2. responses 方法,用于定义 OpenAPI 文档中的 Responses 对象。

定义错误

在上述示例中,我们没有定义错误,因为没有出现错误。但是,在实际开发过程中,出现并处理错误是更常见的场景。在 predawn 中,错误处理相比其他的 Web 框架做得更好,当然,这主要是因为我站在前人的肩膀上。错误是通过实现 std::error::Error trait 和 ResponseError trait 来定义的,ResponseError trait 继承了 std::error::Error trait,同时提供了转换为 Response 和保存错误原因的能力。

rust 复制代码
#[derive(Debug, thiserror::Error)]
#[error("name is not ascii")]
struct NameIsNotAscii;

impl ResponseError for NameIsNotAscii {
    fn as_status(&self) -> StatusCode {
        StatusCode::BAD_REQUEST
    }

    fn status_codes() -> HashSet<StatusCode> {
        [StatusCode::BAD_REQUEST].into()
    }
}

#[derive(Serialize, Deserialize, ToParameters)]
struct Hello {
    name: String,
}

#[Singleton]
#[derive(Clone)]
struct Controller;

#[controller]
impl Controller {
    #[handler(paths = ["/"], methods = [get, post])]
    async fn index(&self, Query(hello): Query<Hello>) -> Result<String, NameIsNotAscii> {
        if !hello.name.is_ascii() {
            Err(NameIsNotAscii)
        } else {
            Ok(format!("Hello, {}!", hello.name))
        }
    }
}

在上述代码中,我们定义了一个错误类型 NameIsNotAscii,当 name 不是 ASCII 字符时,返回这个错误。

有几点值得注意:

  1. 一般用 thiserror 这个 crate 来为类型实现 std::error::Error trait。当然,也可以手动实现。
  2. ResponseError trait 中有 2 个需要实现的方法:as_statusstatus_codes
    1. as_status 方法有个 &self 参数,实际场景中定义的错误类型往往是一个枚举,需要根据当前的实例的状态决定返回什么 StatusCode
    2. status_codes 方法返回所有可能的状态码,用于生成 OpenAPI 文档中的 Responses 对象。

中间件

有 3 个地方可以添加中间件,分别是 HookscontrollerhandlerHooks 中添加中间件对所有 handler 生效,controller 上添加的中间件对该 controller 下的 handler 生效,handler 上的中间件只对它自己生效。

rust 复制代码
use predawn::{
    app::{run_app, Hooks},
    controller,
    handler::{Handler, HandlerExt},
};
use rudi::{Context, Singleton};

struct App;

impl Hooks for App {
    async fn before_run<H: Handler>(cx: Context, router: H) -> (Context, impl Handler) {
        let router = router.around(|handler, req| async move {
            tracing::info!("before hooks");
            let response = handler.call(req).await?;
            tracing::info!("after hooks");
            Ok(response)
        });

        (cx, router)
    }
}

#[tokio::main]
async fn main() {
    run_app::<App>().await;
}

fn controller_middle<H: Handler>(handler: H, _cx: &mut Context) -> impl Handler {
    handler.around(|handler, req| async move {
        tracing::info!("before controller");
        let response = handler.call(req).await?;
        tracing::info!("after controller");
        Ok(response)
    })
}

fn method_middle<H: Handler>(handler: H, _cx: &mut Context) -> impl Handler {
    handler.around(|handler, req| async move {
        tracing::info!("before method");
        let response = handler.call(req).await?;
        tracing::info!("after method");
        Ok(response)
    })
}

#[Singleton]
#[derive(Clone)]
struct Controller;

#[controller(middleware = controller_middle)]
impl Controller {
    #[handler(paths = ["/"], methods = [get], middleware = method_middle)]
    async fn index(&self) -> String {
        "Hello World".to_string()
    }
}

启动服务,访问地址 http://localhost:9612/,控制台会打印出:

shell 复制代码
2024-03-23T11:30:28.642737Z  INFO predawn_run: before hooks
2024-03-23T11:30:28.642810Z  INFO predawn_run: before controller
2024-03-23T11:30:28.642832Z  INFO predawn_run: before method
2024-03-23T11:30:28.642868Z  INFO predawn_run: after method
2024-03-23T11:30:28.642881Z  INFO predawn_run: after controller
2024-03-23T11:30:28.642893Z  INFO predawn_run: after hooks

错误处理

predawn 中,错误处理,本质上就是中间件,和上面的中间件一样,也是可以在 3 个地方添加。值得注意的是,在 Hooks 上添加错误处理中间件,可以达到 Spring Boot 中,全局异常处理的效果。

rust 复制代码
use std::collections::HashSet;

use http::StatusCode;
use predawn::{
    app::{run_app, Hooks},
    controller,
    handler::{Handler, HandlerExt},
    response_error::ResponseError,
};
use rudi::{Context, Singleton};

struct App;

impl Hooks for App {
    async fn before_run<H: Handler>(cx: Context, router: H) -> (Context, impl Handler) {
        let router = router
            .catch_error(|e: SomeError| async move {
                tracing::error!("catch {:?}", e);
                e.to_string()
            })
            .inspect_all_error(|e| {
                tracing::error!("inspect {:?}", e);
            });

        (cx, router)
    }
}

#[tokio::main]
async fn main() {
    run_app::<App>().await;
}

#[derive(Debug, thiserror::Error)]
#[error("some error")]
struct SomeError;

impl ResponseError for SomeError {
    fn as_status(&self) -> StatusCode {
        StatusCode::INTERNAL_SERVER_ERROR
    }

    fn status_codes() -> HashSet<StatusCode> {
        [StatusCode::INTERNAL_SERVER_ERROR].into()
    }
}

#[Singleton]
#[derive(Clone)]
struct Controller;

#[controller]
impl Controller {
    #[handler(paths = ["/"], methods = [get])]
    async fn index(&self) -> Result<String, SomeError> {
        Err(SomeError)
    }
}

在上述示例中,我们捕获了 SomeError,打印了一条捕获成功的日志,将它转换成 String 返回,并对所有返回的错误都打印了一条日志。

Hooks

Spring Boot 中,有时需要实现某些特定的接口,或者定义返回特定类型的 Bean,以实现诸如启动时执行某些操作等功能。这些特定的接口和特定的类型,是 Spring Boot 留给开发者的扩展点。在 predawn 中,Hooks trait 就是这样的扩展点。

Hooks trait 中有很多方法,这些方法都有默认实现。具体的方法和默认实现可以查看 predawn 的文档。

可能会用到的有:

  1. load_config:这是用来加载配置文件的,如果不想从默认的文件夹加载配置文件,可以重写这个方法。
  2. init_logger:这是用来初始化日志采集器的,如果对日志采集器有更定制化的需求,可以重写这个方法。
  3. after_routes:这个方法默认没有任何默认行为,主要是用来调试的时候打印所有的路由,看看路由是否正确注册了。
  4. before_run:这个方法是在启动服务之前调用的,可以在这里添加中间件,或者做一些其他的操作。
  5. start_server:这是启动服务的方法,如果有特殊的需求,如要启动 HTTP3 的服务,可以重写这个方法,当然,目前还没有自带的 HTTP3 Server,后续会添加。

集成测试

一个好的框架,必不能少好的测试组件。在 predawn 中,集成测试是通过 TestClient 结构体实现的,它可以启动一个测试服务器,发送请求,接收响应。

rust 复制代码
use predawn::{
    app::{run_app, Hooks},
    controller,
};
use rudi::Singleton;

struct App;

impl Hooks for App {}

#[tokio::main]
async fn main() {
    run_app::<App>().await;
}

#[Singleton]
#[derive(Clone)]
struct Controller;

#[controller]
impl Controller {
    #[handler(paths = ["/"], methods = [post])]
    async fn index(&self, name: String) -> String {
        format!("Hello, {}!", name)
    }
}

#[cfg(test)]
mod tests {
    use predawn::test_client::TestClient;

    use super::*;

    #[tokio::test]
    async fn test_controller() {
        let client = TestClient::new::<App>().await;
        let resp = client.post("/").body("world").send().await.unwrap();
        assert_eq!(resp.status(), 200);
        assert_eq!(resp.text().await.unwrap(), "Hello, world!");
    }
}

TestClient 内部用到的是 reqwest,是 Rust 社区最流行的 HTTP 客户端库,使用起来没有额外的学习成本。

结尾

上述内容包括了当前 predawn 中绝大部分功能,当然,仍然有一部分功能由于篇幅原因没有展示出来。但是如果你能看完本文,相信你对 predawn 已经有了一个大致的了解。

除此之外,还有很多功能没有实现,比如 WebSocket、文件上传、认证、参数校验等。这些功能我会在后续的开发中逐步实现。

我的目标是将 predawn 打造为 Rust 社区中最好用的 Web 框架,能够提供比 Spring Boot 更好的开发体验和运行效率。

最后,我目前仍在求职中,如果你对我感兴趣,可以联系我,我的邮箱是 zihantype@qq.com,谢谢。

相关推荐
许野平15 小时前
Rust: Warp RESTful API 如何得到客户端IP?
tcp/ip·rust·restful·ip地址
许野平19 小时前
Rust:Result 和 Error
开发语言·后端·rust·error·result
Freestyle Coding21 小时前
使用rust自制操作系统内核
c语言·汇编·microsoft·rust·操作系统
许野平2 天前
Rust 编译器使用的 C++ 编译器吗?
c++·rust
怪我冷i2 天前
Rust GUI框架Tauri V1 入门
rust
新知图书2 天前
Rust的常量
算法·机器学习·rust
白总Server2 天前
php语言基本语法
开发语言·ide·后端·golang·rust·github·php
凄凄迷人2 天前
前端基于Rust实现的Wasm进行图片压缩的技术文档
前端·rust·wasm·图片压缩
winddevil2 天前
[rCore学习笔记 027]地址空间
rust·嵌入式·rcore
bluebonnet273 天前
【Rust练习】15.match 和 if let
开发语言·后端·rust