Axum 是 tokio 官方出品的一个非常优秀的 web 开发框架,一经推出,就博得了我的好感,让我迅速成为它的粉丝。相比之前我使用过的 Rust web 框架,如 rocket,actix-web,axum 对我最大的吸引力就是它优雅的架构:它没有选择从零开始另起炉灶,而是以同样非常优秀的 tower 库的 Service trait 为基石,构建其功能。
我们可以想想,通讯过程中最普遍的请求-响应模型该如何构建?其实,我们只需要一个处理 Request,并返回 Response 的异步函数就可以表达这个模型:
async fn(Request) -> Result<Response, Error>
它不光对 HTTP 适用,也对其它任何协议都适用。当然,如果你用不同的模型,如 Pub-Sub 模型,那么就不能使用这个异步函数。
在真实的 web 世界中,一个请求往往需要由一个复杂的流程来处理。比如我们拿到请求后,要先看负载决定要不要执行,如果要执行就从 HTTP header 中拿到 token,得到用户信息,然后验证请求(包括 header,url 和 body),然后再做一系列的处理,最后得到一个 Response 返回。如果把所有的逻辑都塞到同一个处理函数中,那么,代码会非常冗长且可复用性很差。所以,几乎所有的 web 框架都提供了流水线处理的逻辑,使得流程中公共的部分可以被抽取出来,成为可复用的 middleware。然而,大部分这样的框架都只关注 web,甚至只关注 HTTP/1,并没有把这个逻辑抽象到适用于所有请求-响应的场景。Tower 则考虑到了通用性,其 Service trait 本身跟 HTTP 无关,因而我们可以构建通用的 middleware,比如 retry,reconnect,load_shed,load_balance 等:
这些通用的 middleware 对任何满足请求-响应场景的协议都适用。同时,我们也可以构造 HTTP 专属的 middleware,比如:auth token 的处理,header/body 的解析等等:
所以,当 axum 构建在 tower 生态之上的那一刻起,就注定了 axum 可以组合使用这个生态下的已有的 middleware。比如,你可以这样构建自己的 service:
#[tokio::main]
async fn main() {
// Construct the shared state.
let state = State {
pool: DatabaseConnectionPool::new(),
};
// Use tower's `ServiceBuilder` API to build a stack of tower middleware
// wrapping our request handler.
let service = ServiceBuilder::new()
// Mark the `Authorization` request header as sensitive so it doesn't show in logs
.layer(SetSensitiveRequestHeadersLayer::new(once(AUTHORIZATION)))
// High level logging of requests and responses
.layer(TraceLayer::new_for_http())
// Share an `Arc<State>` with all requests
.layer(AddExtensionLayer::new(Arc::new(state)))
// Compress responses
.layer(CompressionLayer::new())
// Propagate `X-Request-Id`s from requests to responses
.layer(PropagateHeaderLayer::new(HeaderName::from_static("x-request-id")))
// If the response has a known size set the `Content-Length` header
.layer(SetResponseHeaderLayer::overriding(CONTENT_TYPE, content_length_from_response))
// Authorize requests using a token
.layer(RequireAuthorizationLayer::bearer("passwordlol"))
// Wrap a `Service` in our middleware stack
.service_fn(handler);
// And run our service using `hyper`
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
Server::bind(&addr)
.serve(Shared::new(service))
.await
.expect("server error");
}
这同样也意味着,你所写的每一个 middleware,都潜在可以被其它使用了 tower 的网络应用调用,比如 tonic(rust GRPC 实现)。
好,说了这么多,都是 tower 的好处,那 axum 自己的独到之处呢?官网上给出了这几点:
这里,我们重点说 Extractor。我非常喜欢这个设计。一个 extractor 实际上就是实现了 FromRequest trait 的一个数据结构:
#[async_trait]
pub trait FromRequest<B>: Sized {
/// If the extractor fails it'll use this "rejection" type. A rejection is
/// a kind of error that can be converted into a response.
type Rejection: IntoResponse;
/// Perform the extraction.
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection>;
}
比如 axum::Json<T> 实现了 FromRequest:
pub struct Json<T>(pub T);
#[async_trait]
impl<T, B> FromRequest<B> for Json<T>
where
T: DeserializeOwned,
B: HttpBody + Send,
B::Data: Send,
B::Error: Into<BoxError>,
{
type Rejection = JsonRejection;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
if json_content_type(req)? {
let bytes = Bytes::from_request(req).await?;
let value = serde_json::from_slice(&bytes).map_err(InvalidJsonBody::from_err)?;
Ok(Json(value))
} else {
Err(MissingJsonContentType.into())
}
}
}
这个实现很好理解,就是判断如果 request body 是 Json,就使用 serde_json 反序列化出 T,返回 Json<T>。这里 T 需要是 DeserializeOwned,也就是任何实现了 serde::Deserialize 的数据结构,就可以使用 Json<T> extractor 从 request body 中得到反序列化好的结果。
我们看如何使用这个 extractor:
#[derive(Deserialize)]
struct CreateUser {
email: String,
password: String,
}
async fn create_user(Json(payload): Json<CreateUser>) {
// ...
}
let app = Router::new().route("/users", post(create_user));
这里,我们只需要声明 CreateUser 是 Deserialize,就可以在路由的 handler create_user
中直接作为参数(Json<CreateUser>)使用。这里我们还用到了模式匹配,让 payload 直接匹配到 CreateUser,所以在 create_user 函数中,我们就可以直接操作反序列化成 CreateUser 的 payload,做需要的处理。
如果我们在创建用户的时候需要 http header 中的 user agent,来得到用户创建时的来源,那么只需要在 create_user
函数中添加 TypedHeader 这个 extractor 即可:
async fn create_user(
TypedHeader(user_agent): TypedHeader<UserAgent>,
Json(payload): Json<CreateUser>
) {
// ...
}
let app = Router::new().route("/users", post(create_user));
这看上去似乎不可思议,一个有严格类型定义的 Rust 函数,怎么可以像 javascript 一样如此「动态」地使用,而编译器不报错呢?
这里一定有一些很 NB 的处理。
如果你仔细研究这段代码,会发现 route 方法第二个参数 T 需要一个实现了 tower Service trait 的数据结构 T:
pub fn route<T>(mut self, path: &str, service: T) -> Self
where
T: Service<Request<B>, Response = Response, Error = Infallible> + Clone + Send + 'static,
T::Future: Send + 'static
{ ... }
我们的 create_user
函数显然不满足这个要求。那么,肯定是 post()
做了什么。如果翻翻代码,会发现它实际上会被展开成:
let app = Router::new().route("/users", on(MethodFilter::POST, create_user));
我们看 axum::routing::on
这个函数:
pub fn on<H, T, B>(
filter: MethodFilter,
handler: H
) -> MethodRouter<B, Infallible>
where
H: Handler<T, B>,
B: Send + 'static,
T: 'static,
它返回 MethodRouter,而 MethodRouter 实现了 Service trait。那么,我们的函数 create_user
满足 Handler trait 么?因为这是 on 函数的要求,如果要编译通过,就一定需要满足。我们再来看一下 Handler trait 长什么样子:
#[async_trait]
pub trait Handler<T, B = Body>: Clone + Send + Sized + 'static {
// This seals the trait. We cannot use the regular "sealed super trait"
// approach due to coherence.
#[doc(hidden)]
type Sealed: sealed::HiddenTrait;
/// Call the handler with the given request.
async fn call(self, req: Request<B>) -> Response;
fn layer<L>(self, layer: L) -> Layered<L::Service, T>
where
L: Layer<IntoService<Self, T, B>>,
{ ... }
fn into_service(self) -> IntoService<Self, T, B> { ... }
fn into_make_service(self) -> IntoMakeService<IntoService<Self, T, B>> { ... }
fn into_make_service_with_connect_info<C, Target>(
self
) -> IntoMakeServiceWithConnectInfo<IntoService<Self, T, B>, C>
where
C: Connected<Target>,
{ ... }
}
这个 trait 看着比较复杂,但它真正需要实现的是:
async fn call(self, req: Request<B>) -> Response;
进一步看文档你可以发现对于 0-16 个参数的函数(FnOnce)都自动实现了 Handler trait,我们挑一个有两个参数的实现看看:
impl<F, Fut, B, Res, T1, T2> Handler<(T1, T2), B> for F
where
F: FnOnce(T1, T2) -> Fut + Clone + Send + 'static,
Fut: Future<Output = Res> + Send,
B: Send + 'static,
Res: IntoResponse,
T1: FromRequest<B> + Send,
T2: FromRequest<B> + Send
{ ... }
是不是一下子豁然开朗了?只要 create_user
传入的每个参数都是个 extractor(实现了 FromRequest trait),那么它就可以被当做 Handler 使用,由此整条链都打通了。
那么,这 0-16 个不同参数的实现是怎么创建出来的呢?想想看。
没错,是通过声明宏:
macro_rules! impl_handler {
( $($ty:ident),* $(,)? ) => {
#[async_trait]
#[allow(non_snake_case)]
impl<F, Fut, B, Res, $($ty,)*> Handler<($($ty,)*), B> for F
where
F: FnOnce($($ty,)*) -> Fut + Clone + Send + 'static,
Fut: Future<Output = Res> + Send,
B: Send + 'static,
Res: IntoResponse,
$( $ty: FromRequest<B> + Send,)*
{
type Sealed = sealed::Hidden;
async fn call(self, req: Request<B>) -> Response {
let mut req = RequestParts::new(req);
$(
let $ty = match $ty::from_request(&mut req).await {
Ok(value) => value,
Err(rejection) => return rejection.into_response(),
};
)*
let res = self($($ty,)*).await;
res.into_response()
}
}
};
}
注意看 call 这个方法的实现,其中:
$(
let $ty = match $ty::from_request(&mut req).await {
Ok(value) => value,
Err(rejection) => return rejection.into_response(),
};
)*
这是对每个 $ty(T1, T2, ...)依次调用 from_request(),得到你的 handler 需要的变量(也用 T1, T2 ...,这是为什么需要 #[allow(non_snake_case)]
,否则编译器会报警),然后将它们传入:
let res = self($($ty,)*).await;
所以当你的 create_user
需要一个或者两个参数时,能够被正确调用 —— 因为你需要的参数都可以通过 from_request() 生成,并传入。
到现在为止,我们应该领略到了 axum 设计上的精妙:通过 extractor,axum 成功解决了两个看似互斥的困扰 web 框架的问题:灵活性和可复用性。我们可以为各种场景构建正交但可以组合在一起的 extractor,然后把它们作为参数传给 handler 就可以。从使用的体验上看,简直比动态语言还要「动态」。
不过,虽然这样做大大提升了易用性,但也增加了错误消息的复杂度。如果你输入的参数因为某些原因,不符合 FromRequest trait,那么,axum 编译期产生的错误会非常难懂。所以,一旦你的路由的 handler 出现奇奇怪怪的编译错误,先不要忙着盘查错误本身,首先检查一下所有的参数是否满足了 FromRequest trait。
那么,axum 默认都实现了哪些 extractor 呢?我们看下图:
这些都是你可以在路由的 handler 中随便组合使用的。这里提一句 Extension:当你需要访问共享的状态时(比如一个数据库连接池),可以通过 layer 方法添加这个共享的状态:
struct State {
// ...
}
async fn handler(state: Extension<Arc<State>>) {
// ...
}
let state = Arc::new(State { /* ... */ });
let app = Router::new().route("/", get(handler))
// Add middleware that inserts the state into all incoming request's
// extensions.
.layer(AddExtensionLayer::new(state));
要注意的是,extension 需要数据结构实现 Clone,因为在每个 request 到达时都会复制这个 extension,所以 extension 一般都使用 Arc::new() 创建出来的 state 比较好。
最后,再谈谈 Response。讲完了 extractor 背后的神秘逻辑后,你现在也许不会惊讶于下面这样的路由 handler 函数都可以使用:
async fn plain_text() -> &'static str {
"foo"
}
async fn json() -> Json<Value> {
Json(json!({ "data": 42 }))
}
let app = Router::new()
.route("/plain_text", get(plain_text))
.route("/json", get(json));
这里面的魔法还是 trait。我们再仔细看看刚才讲过的 Handler trait 为两个参数的函数的实现:
impl<F, Fut, B, Res, T1, T2> Handler<(T1, T2), B> for F
where
F: FnOnce(T1, T2) -> Fut + Clone + Send + 'static,
Fut: Future<Output = Res> + Send,
B: Send + 'static,
Res: IntoResponse,
T1: FromRequest<B> + Send,
T2: FromRequest<B> + Send
{ ... }
可以看到,这个函数 F 返回的 Future 吐出来的 Output 需要是 Res 类型,而 Res 需要满足 IntoResponse:
pub trait IntoResponse {
fn into_response(self) -> Response<UnsyncBoxBody<Bytes, Error>>;
}
在 axum 里,很多类型都已经实现了 IntoResponse,包括 &’static str 和 Json<T>。我们看 &’static str 的实现:
impl IntoResponse for &'static str {
#[inline]
fn into_response(self) -> Response {
Cow::Borrowed(self).into_response()
}
}
impl IntoResponse for Cow<'static, str> {
fn into_response(self) -> Response {
let mut res = Response::new(boxed(Full::from(self)));
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()),
);
res
}
}
它会创建一个 Response,还贴心地添加了 text/plain; charset=utf-8
这个 content-type。
axum 刚推出来时,给我带来的震撼是全方位的,它颠覆了我对静态语言的认知。我从未想过可以写出这么灵活,且还是做了严格类型检查的 Rust 代码。axum 给了我很大的设计上的启示:在构建基础框架时,trait + 少量的辅助宏,可以大大提升用户代码的灵活性、易用性以及可组合性。
(题图来自 pexel 用户 7inchs:https://www.pexels.com/photo/unrecognizable-lady-swimming-underwater-after-jumping-in-ocean-6702555/)