我是一个热爱 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 百的下载量。
在权衡利弊之后,我辞职了。
正如我上面所写的,之前的工作,不管什么项目,都是增删改查,想要拿这些项目经验去找 Rust 相关的工作,我感觉有点难,最起码,简历上得有点值得一说的项目。所以在辞职之后,我正式开始用 Rust 实现一个 Spring Boot。
一开始,我先阅读社区中各 Web 框架的代码,先看看 Web 框架是怎么实现的。虽然我用 Java 和 Rust 写过很多 Web 项目,但是我对 Web 框架的实现原理却一直不是很了解。我阅读了包括但不限于 axum,poem-openapi,salvo, volo-http,loco 这些框架的代码。尤其要感谢 volo-http,在我阅读代码的时候,volo-http 刚刚开始发展,代码量很少,但是实现很完善,非常适合我这种初学者阅读。在阅读了这些库的代码之后,我发现,Web 框架的原理其实很简单,就是把 N 个 handler 组合起来,等待请求进来,然后把请求交给不同的 handler 处理,最后返回一个响应。很简单,也很神奇。
搞清楚 Web 框架的原理之后,就是构思我想要的 Web 框架是什么样的,它的 API 应该怎么设计。在这点上,我仍然是通过阅读社区中已有的 Web 框架的代码,看看这个,瞅瞅那个,有我喜欢的,就直接拿过来;不完全符合我口味的,就稍微改一改;没有我想要的,就得自己想。突出一个,缝了但没完全缝。
在经过了 4 个月的开发之后,我终于完成了上述 3 步,能像 Spring Boot 那样一行代码启动。我把这个项目叫作 predawn,没有特别的意思,就是想不出什么更好且没有被占用的名字。
顺便说一个开发过程中让我纠结了很久的功能。启动 Web 服务时,初始化日志采集器,扫描配置文件,这 2 个启动步骤,我不知道该哪个前,哪个后。如果先初始化日志采集器,那就没办法根据配置文件自定义日志采集器的行为;如果先扫描配置文件,就没法打印扫描过程中的日志,因为当前上下文还没有日志采集器。这个问题困扰了我很久,直到我看到 loco 的代码中对此的解决办法:扫描 2 次配置文件。先扫描配置文件,然后初始化日志采集器,再扫描配置文件,第 2 次扫描纯粹是为了打印日志。我看到后大为震惊,还可以这样?这个解决方案非常简单,但是我却没有想到,这让我知道还是要多看别人的代码,你遇到的绝大多数问题别人也遇到了,不要自己一个人钻牛角尖。
use predawn::{ app::{run_app, Hooks}, controller, extract::query::Query, ToParameters, }; use rudi::Singleton; use serde::{Deserialize, Serialize}; struct App; impl Hooks for App {} # 堆代码 duidaima.com #[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) } }从上述示例可以看出这么几项:
运行上述代码后,用浏览器打开 http://localhost:9612/p/rapidoc 就能看到 RapiDoc 的界面,展示了一个简单的 OpenAPI 文档。也可以将 URL 中的 /p/rapidoc 替换为 /p/swagger-ui,就能看到 Swagger UI 的界面。
#[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 结构体实现了 ToSchema 和 ToParameters trait 之后,Person 结构体就可以表示一个 OpenAPI 文档中的 Schema 对象和多个 Parameter 对象。
一句话,ToParameters 用于请求头,ToSchema 用于请求体和响应体。(虽然定义响应头的宏还没实现,但不是用 ToParameters)
#[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 泛型,这个以后有机会再说,下面说说几个重要的地方:
#[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.parameters 和 request_body 方法,用于生成 OpenAPI 文档中的 Parameter 和 RequestBody 对象。
pub trait IntoResponse { type Error: ResponseError; fn into_response(self) -> Result<Response, Self::Error>; fn responses(components: &mut Components) -> Option<BTreeMap<StatusCode, openapi::Response>>; }有几点重要的地方:
#[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 字符时,返回这个错误。
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/,控制台会打印出:
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
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 返回,并对所有返回的错误都打印了一条日志。
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 已经有了一个大致的了解。