前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >优雅地组合:谈谈 axum

优雅地组合:谈谈 axum

作者头像
tyrchen
发布2022-03-29 10:12:16
8K1
发布2022-03-29 10:12:16
举报
文章被收录于专栏:程序人生程序人生

Axum 是 tokio 官方出品的一个非常优秀的 web 开发框架,一经推出,就博得了我的好感,让我迅速成为它的粉丝。相比之前我使用过的 Rust web 框架,如 rocket,actix-web,axum 对我最大的吸引力就是它优雅的架构:它没有选择从零开始另起炉灶,而是以同样非常优秀的 tower 库的 Service trait 为基石,构建其功能。

我们可以想想,通讯过程中最普遍的请求-响应模型该如何构建?其实,我们只需要一个处理 Request,并返回 Response 的异步函数就可以表达这个模型:

代码语言:javascript
复制
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:

代码语言:javascript
复制
#[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 自己的独到之处呢?官网上给出了这几点:

  • 复用 tower / tower-http 的生态。这个就不说了。
  • 路由处理没有使用宏(确切地说,没有使用过程宏)。这就意味着路由的 handler 可以很容易复用。
  • 可以使用 Extractor 声明式地解析 requests。声明式开发最大的好处就是组合,就是可复用性。
  • 简单直观的错误处理。你的 Error 只需要实现 IntoResponse 即可。
  • 构建 response 时只需要很少量的脚手架代码。

这里,我们重点说 Extractor。我非常喜欢这个设计。一个 extractor 实际上就是实现了 FromRequest trait 的一个数据结构:

代码语言:javascript
复制
#[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:

代码语言:javascript
复制
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:

代码语言:javascript
复制
#[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 即可:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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() 做了什么。如果翻翻代码,会发现它实际上会被展开成:

代码语言:javascript
复制
let app = Router::new().route("/users", on(MethodFilter::POST, create_user));

我们看 axum::routing::on 这个函数:

代码语言:javascript
复制
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 长什么样子:

代码语言:javascript
复制
#[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 看着比较复杂,但它真正需要实现的是:

代码语言:javascript
复制
async fn call(self, req: Request<B>) -> Response;

进一步看文档你可以发现对于 0-16 个参数的函数(FnOnce)都自动实现了 Handler trait,我们挑一个有两个参数的实现看看:

代码语言:javascript
复制
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 个不同参数的实现是怎么创建出来的呢?想想看。

没错,是通过声明宏:

代码语言:javascript
复制
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 这个方法的实现,其中:

代码语言:javascript
复制
$(
    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)],否则编译器会报警),然后将它们传入:

代码语言:javascript
复制
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 方法添加这个共享的状态:

代码语言:javascript
复制
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 函数都可以使用:

代码语言:javascript
复制
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 为两个参数的函数的实现:

代码语言:javascript
复制
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:

代码语言:javascript
复制
pub trait IntoResponse {
    fn into_response(self) -> Response<UnsyncBoxBody<Bytes, Error>>;
}

在 axum 里,很多类型都已经实现了 IntoResponse,包括 &’static str 和 Json<T>。我们看 &’static str 的实现:

代码语言:javascript
复制
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/)

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-01-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 程序人生 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档