優雅地組合:談談 axu

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 實現了 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 需要是 DeserializeOwned,也就是任何實現了 serde::Deserialize 的數據結構,就可以使用 Json 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)使用。這裏我們還用到了模式匹配,讓 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。我們看 &’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 + 少量的輔助宏,可以大大提升用戶代碼的靈活性、易用性以及可組合性。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mrZfqYIBKBCOXiiCj3wdHg