Rust Web 开发之Axum使用手册

Rust
48
0
0
2024-05-05
❝生活的刁难,并不是要你变得气急败坏,而是要你变得更加从容 ❞

大家好,我是「柒八九」。一个「专注于前端开发技术/RustAI应用知识分享」Coder

前言

之前,我们在很多文章都提到过Rust Web框架。

其中有一个Rust Web框架的出现频率都很高 -- 那就是axum[1]。

并且在crate trend[2]的下载量来看axum也是遥遥领先。

所以,我们今天这篇文章就来简单介绍一下axum的用法。

好了,天不早了,干点正事哇。

我们能所学到的知识点

  1. 前置知识点
  2. Axum 中的路由
  3. 在 Axum 中添加数据库
  4. 在 Axum 中的应用状态
  5. Axum 中的提取器
  6. Axum 中的中间件
  7. 在 Axum 中提供静态文件
  8. 部署 Axum

1. 前置知识点

「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」 同时,由于阅读我文章的群体有很多,所以有些知识点可能「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」。 ❞

REST

❝REST[3] is an acronym for REpresentational State Transfer and an architectural style for distributed hypermedia systems. ❞

翻译成中文就是:RESTREpresentational State Transfer的首字母缩写,也是分布式超媒体系统的架构风格。

REST不是一种协议或标准,而是一种「架构风格」。它通常基于HTTP协议,使用标准的HTTP方法(如GETPOSTPUTDELETE)进行通信。RESTful API的设计目标是简单、可扩展、易于理解,并与现有的Web标准兼容。

REST 基于一些约束和原则,这些约束和原则促进了设计中的简单性、可伸缩性和无状态性。RESTful 架构的六个指导原则或约束是:

2. Axum 中的路由

不知道大家使用过Express[4]构建过应用没?如果没有,那也没关系。

const express = require("express");
const app = express();

// 当向主页发出 GET 请求时,以 "hello front789"作为回应
app.get("/", (req, res) => {
  res.send("hello front789");
});

这是截取Express官网的关于路由的例子。仅用寥寥几行代码就构建了一个网络服务。

而,我们今天的主角Axum同样拥有和Express的神奇功能。它们都遵循类RESTful的 API 设计。我们可以创建处理程序函数handler)并将它们附加到axum::Router上。


async fn hello_front789() -> &'static str {
  "前端柒八九!"
}

然后我们可以像下面这样将其添加到Router中:


use axum::{Router, routing::get};

fn init_router() -> Router {
  Router::new()
    .route("/", get(hello_front789))
}

上面的例子和Express达到了相同的效果当向主页发出 GET 请求时,以 "前端柒八九"作为回应

对于处理程序函数来说,它需要是一个axum::response::Response类型,或者实现axum::response::IntoResponse。这对于大多数基本类型(可以参考Rust 学习之数据类型[5])

例如,如果我们想向用户发送一些 JSON 数据,我们可以使用 AxumJSON 类型,使用 axum::Json 类型封装我们要发送回的数据。

use axum::Json;
async fn json() -> Json<Vec<String>> {
    Json(vec!["front".to_owned(), "789".to_owned()])
}

像我们刚开始提供的代码,我们也可以返回直接返回一个字符串切片(&'static str)。

我们也可以直接使用 impl IntoResponse。但是,直接使用也意味着需要「确保所有返回类型都是相同的类型」!也就是我们可能会遇到不必要的错误。所以,我们可以为返回类型实现一个 enumstruct来达到「返回类型都是相同类型」的制约条件。

use axum::{
  response::{
    Response,
    IntoResponse
  },
  Json,
  http::StatusCode
};
use serde::Serialize;

// 用于封装 `JSON` 响应体的数据。
#[derive(Serialize)]
struct Message {
  message: String
}

// 定义了几种 `API` 的响应类型。
// 1. `OK` 和 `Created` 对应不同的 `HTTP` 状态码;
// 2. `JsonData` 包装了 `Vec<Message>` 的 `JSON` 数据。
enum ApiResponse {
  OK,
  Created,
  JsonData(Vec<Message>),
}

// 这让 `ApiResponse` 可以被自动转换成一个 `axum Response`。
impl IntoResponse for ApiResponse {
  fn into_response(self) -> Response {
   // 检查枚举变量,返回相应的 HTTP 状态码和数据。
    match self {
      Self::OK => (StatusCode::OK).into_response(),
      Self::Created => (StatusCode::CREATED).into_response(),
      Self::JsonData(data) => (StatusCode::OK, Json(data)).into_response()
    }
  }
}

所以通过 ApiResponse 枚举和 IntoResponse 实现,可以非常方便的生成符合结构的 JSON API 响应。并可以轻松的「兼容不同类型的响应状态码」

然后在处理程序函数中实现该 enum:

async fn my_function() -> ApiResponse {
  ApiResponse::JsonData(vec![Message {
    message: "hello 789".to_owned()
  }])
}

当然,我们也可以对返回值使用 Result[6] 类型!尽管错误类型在技术上也可以接受任何可以转化为 HTTP 响应的内容,但我们也可以实现一个错误类型来表示 HTTP 请求在我们的应用程序中可能失败的几种不同方式,就像我们对成功的 HTTP 请求 enum 所做的那样。例如:

enum ApiError {
  BadRequest,
  Forbidden,
  Unauthorised,
  InternalServerError
}

// ... 省略ApiResponse的代码

async fn my_function() -> Result<ApiResponse, ApiError> {
   //
}

这样我们的路由就可以区分错误和成功的请求了。

3. 在 Axum 中添加数据库

Rust中使用数据库,那么sqlx[7]肯定是绕不过的。

通常在设置数据库时,我们可能需要设置数据库连接:

use axum::{Router, routing::get};
use sqlx::PgPoolOptions;

#[derive(Clone)]
struct AppState {
  db: PgPool
}

#[tokio::main]
async fn main() {
  let pool = PgPoolOptions::new()
    .max_connections(5)
    .connect(<数据库地址>).await;

  let state = AppState { pool };

  let router = Router::new().route("/", get(hello_world)).with_state(state);

  //... 其余代码
}

我们需要提供自己的 Postgres[8] 实例,无论是在本地计算机上本地安装,还是通过 Docker 设置或者其他方式。但是,这里,我们使用 Shuttle[9] 可以简化我们的操作。

#[shuttle_runtime::main]
async fn axum(
  #[shuttle_shared_db::Postgres] pool: PgPool,
) -> shuttle_axum::ShuttleAxum {
  let state = AppState { pool };

  // .. 其余代码
}

4. 在 Axum 中的应用状态

Axum中我们可以使用axum::Extension[10]来处理应用全局变量存储的问题。但是,它唯一的缺点就是类型不安全。在大多数 Rust Web 框架(包括 Axum)中,我们使用所谓的「应用状态」(app state) - 一个专门用于在应用程序的路由之间共享的所有变量的结构体。 在 Axum 中完成此操作的唯一要求是该结构体需要实现 Clone

use sqlx::PgPool; // 这是一个Postgres连接池

#[derive(Clone)]
struct AppState {
  pool: PgPool,
}

#[shuttle_runtime::main]
async fn axum(
  #[shuttle_shared_db::Postgres] pool: PgPool,
) -> shuttle_axum::ShuttleAxum {
  let state = AppState { pool };

  // .. 其余代码
}

要使用它,我们将其插入路由器中,并通过将状态作为参数传递给处理函数中:

use axum::{Router, routing::get, extract::State};

fn init_router() -> Router {
  Router::new()
    .route("/", get(hello_front))
    .route("/do_something", get(do_something))
    .with_state(state)
}

// 注意添加应用状态不是强制的 - 仅在想要使用它时
async fn hello_front() -> &'static str {
  "Hello 789!"
}

async fn do_something(
  State(state): State<AppState>
) -> Result<ApiResponse, ApiError> {
  // .. 我们的代码
}

除了使用#[derive(Clone)]之外,我们还可以使用原子引用计数器(std::sync::Arc)封装应用状态结构体。Arcs 是一种垃圾收集形式,可以跟踪克隆的数量,并且只有当没有副本时才会删除:

use std::sync::Arc;

let state = Arc::new(AppState { db });

现在当我们将状态添加到应用程序时,我们需要确保引用 State 提取器类型为 State<Arc<AppState>>而不是 State<AppState>

我们还可以「从应用程序状态派生子状态」! 当我们需要来自主状态的一些变量但想限制给定路由可以访问的内容的访问控制权限时,这非常有用。例如:

// 应用程序状态
#[derive(Clone)]
struct AppState {
  // 保存一些api特定状态
  api_state: ApiState,
}

// api特定状态
#[derive(Clone)]
struct ApiState {}

// 支持将一个 `AppState` 转换为一个 `ApiState`
impl FromRef<AppState> for ApiState {
  fn from_ref(app_state: &AppState) -> ApiState {
    app_state.api_state.clone()
  }
}

5. Axum 中的提取器

提取器(Extractors)正如其名:它们从 HTTP 请求中提取内容,并且将它们作为参数传递给处理程序函数来工作。目前,它已经对常规数据都有了原生支持,比如获取单独的 header、路径、查询、表单和 JSON

例如,我们可以使用 axum::Json 类型通过从 HTTP 请求中提取 JSON 请求体来处理 HTTP 请求。

use axum::Json;
use serde_json::Value;

async fn my_function(
  Json(json): Json<Value>
) -> Result<ApiResponse, ApiError> {
  // ...我们的代码
}

上面代码虽然能够获取到数据,但是因为我们使用的是 serde_json::Value,它的结构的动态多变的,可以包含任何内容。(在Rust 赋能前端-开发一款属于我们的前端脚手架中我们使用serde_json处理json文件)

为了能够达到我们想要的目标,我们尝试使用一个实现了 serde::Deserialize 的 Rust 结构体——这是将「原始数据转化为结构体本身所必需」的:

use axum::Json;
use serde::Deserialize;

#[derive(Deserialize)]
pub struct Submission {
  message: String
}

async fn my_function(
  Json(json): Json<Submission>
) -> Result<ApiResponse, ApiError> {
  println!("{}", json.message);

  // ...我们的代码
}

表单和 URL 查询参数也可以通过将适当的类型添加到处理程序函数来以相同的方式处理 - 例如,表单提取器可能如下所示:

async fn my_function(
  Form(form): Form<Submission>
) -> Result<ApiResponse, ApiError> {
  println!("{}", json.message);

  // ...我们的代码
}

在发送 HTTP 请求到 APIHTML 端,当然我们还需要确保发送了正确的内容类型。

header也可以以相同的方式处理,只是header不会消耗请求体!我们可以使用 TypedHeader[11] 类型来做到这一点。 对于 Axum 0.6,我们需要启用headers功能,但是在 0.7 中,它已移至 axum-extra[12] crate,我们需要添加 typed-header 功能,如下所示:

cargo add axum-extra -F typed-header

使用类型化headers可以简单地将其作为参数添加到处理程序函数中:

use headers::ContentType;
use axum::{TypedHeader, headers::Origin}; // 在axum 0.6上使用
use axum_extra::{TypedHeader, headers::Origin}; // 在axum 0.7上使用

async fn my_function(
  TypedHeader(origin): TypedHeader<Origin>
) -> Result<ApiResponse, ApiError> {
  println!("{}", origin.hostname);

  // ...我们的代码
}

除了 TypedHeaders 之外,axum-extra 还提供了许多其他有用的类型可以使用。例如,它有一个 CookieJar 提取器,可以帮助管理 cookie

Axum 中的自定义提取器

现在我们对提取器有了更多了解,我们可能希望知道我们如何创建自己的提取器 - 例如,让我们假设我们需要创建一个提取器,根据请求体是 Json 还是表单进行解析。让我们设置我们的结构和处理程序函数:

#[derive(Debug, Serialize, Deserialize)]
struct Payload {
  foo: String,
}

async fn handler(JsonOrForm(payload): JsonOrForm<Payload>) {
  dbg!(payload);
}

struct JsonOrForm<T>(T);

现在我们可以为 JsonOrForm 结构实现 FromRequest<S, B>!

//实现 `FromRequest` trait。这让 `JsonOrForm` 可以作为 `axum extractor` 使用。
#[async_trait]
impl<S, B, T> FromRequest<S, B> for JsonOrForm<T>
where
  B: Send + 'static,
  S: Send + Sync,
  Json<T>: FromRequest<(), B>,
  Form<T>: FromRequest<(), B>,
  T: 'static,
{

  type Rejection = Response;

  async fn from_request(req: Request<B>, _state: &S) -> Result<Self, Self::Rejection> {
    // 首先获取 `content-type` 请求头。
    let content_type_header = req.headers().get(CONTENT_TYPE);
    let content_type = content_type_header.and_then(|value| value.to_str().ok());

    if let Some(content_type) = content_type {
      //  如果是 `application/json`,使用 `req.extract()` extractor 提取为 `Json<T>`。
      if content_type.starts_with("application/json") {
        let Json(payload) = req.extract().await.map_err(IntoResponse::into_response)?;
        return Ok(Self(payload));
      }
      // 如果是 `application/x-www-form-urlencoded`,提取为 `Form<T>`。
      if content_type.starts_with("application/x-www-form-urlencoded") {
        let Form(payload) = req.extract().await.map_err(IntoResponse::into_response)?;
        return Ok(Self(payload));
      }
    }
    // 返回 `Unsupported Media Type` 的错误。
    Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response())
  }

}

所以这段代码让我们可以灵活的处理 JSONForm 格式的请求 Body,作为一个方便的 extractorhandler 中使用。

这避免了针对不同请求重复提取解析的代码。并且也统一了 handler 的签名。

Axum 0.7 中,这略有修改。axum::body::Body 不再重新导出 hyper::body::Body,而是自己的类型 - 这意味着它不再是泛型的,并且 Request 类型将始终使用 axum::body::Body。这实质上意味着我们只需要删除 B 泛型:

#[async_trait]
impl<S, T> FromRequest<S> for JsonOrForm<T>
where
  S: Send + Sync,
  Json<T>: FromRequest<()>,
  Form<T>: FromRequest<()>,
  T: 'static,
{

  // ...同上

}

6. Axum 中的中间件

如前所述,与其他框架相比,Axum 的一个巨大优势在于它与 tower crates 兼容,这意味着我们可以为我们的 Rust API 使用「任何想要的 Tower 中间件」!例如,我们可以添加一个 Tower 中间件来压缩响应:

use tower_http::compression::CompressionLayer;
use axum::{routing::get, Router};

fn init_router() -> Router {
  Router::new()
    .route("/", get(hello_world))
    .layer(CompressionLayer::new)
}

有许多由 Tower 中间件组成的 crate 可供使用,而无需我们自己编写任何中间件!如果我们已经在任何应用程序中使用 Tower 中间件,这是一种很好的方式来重用我们的中间件,而无需编写更多代码,因为兼容性可确保没有问题。

我们也可以通过编写函数来创建自己的中间件。该函数需要对 RequestNext 类型进行<B>泛型绑定,因为 Axumbody 类型在 0.6 中是泛型的。下面是一个例子:

use axum::{http::Request, middleware::Next};

async fn check_hello_world<B>(
  req: Request<B>,
  next: Next<B>
) -> Result<Response, StatusCode> {

  // 需要http crate来获取header名称
  if req.headers().get(CONTENT_TYPE).unwrap() != "application/json" {
    return Err(StatusCode::BAD_REQUEST);
  }

  Ok(next.run(req).await)
}

在 Axum 0.7 中,我们会删除<B>约束,因为 Axumaxum::body::Body 类型不再是泛型的:

use axum::{http::Request, middleware::Next};

async fn check_hello_world(
  req: Request,
  next: Next
) -> Result<Response, StatusCode> {

  // ...同上

}

要在我们的应用程序中实现新的中间件,我们要使用 axumaxum::middleware::from_fn 函数,它允许我们将函数用作处理程序。在实践中,它看起来像这样:

use axum::middleware::self;

fn init_router() -> Router {
  Router::new()
    .route("/", get(hello_world))
    .layer(middleware::from_fn(check_hello_world))
}

如果我们需要向中间件添加应用程序状态,可以将其添加到处理程序函数中,然后使用 middleware::from_fn_with_state:

fn init_router() -> Router {
  let state = setup_state(); // 初始化应用状态

  Router::new()
    .route("/", get(hello_world))
    .layer(middleware::from_fn_with_state(state.clone(), check_hello_world))
    .with_state(state)
}

总而言之,Axum 通过其与 Tower 的兼容性,为在 Rust API 中使用强大的中间件提供了极大的便利。

7. 在 Axum 中提供静态文件

假设我们想在 Axum 中提供一些静态文件 —— 或者我们使用了像 React 这样的前端 JavaScript 框架来构建应用程序,并且想将其与 Rust Axum 后端结合成一个大型应用程序,而不是分别托管前端和后端。

Axum 本身没有提供这方面的功能;然而,它具有与 tower-http相同的功能,后者提供了为我们自己的静态文件提供服务的方式,无论我们是运行SPA,还是使用 Next.js 等框架生成的静态文件,又或者是简单的 HTMLCSSJavaScript都可以与Axum进行融合。

如果我们使用静态生成的文件,我们可以轻松地将它插入路由器中(假设我们的静态文件在项目根目录的 dist 文件夹中):

use tower_http::services::ServeDir;

fn init_router() -> Router {
  Router::new()
    .nest_service("/", ServeDir::new("dist"))
}

如果我们使用 ReactVue,可以将bundle构建到相关文件夹中,然后使用以下内容:

use tower_http::services::{ServeDir, ServeFile};

fn init_router() -> Router {
  Router::new().nest_service(
    "/", ServeDir::new("dist")
      .not_found_service(ServeFile::new("dist/index.html")),
  )
}

我们还可以使用 askama[13]、tera[14] 和 maud[15] (在用 Rust 搭建 React Server Components 的 Web 服务器[16] 之类的轻量级 JavaScript 库相结合,以加快投产速度。

8. 部署 Axum

由于需要使用 Dockerfile,使用 Rust 后端程序进行部署总是有点麻烦。 但是,如果我们使用 Shuttle,只需使用 cargo shuttle deploy 即可完成部署。无需设置。