[Перевод] Начало работы с Axum — самым популярным веб-фреймворком Rust

mapolvqq4uunxfqoaviv3g9km9y.jpeg


Когда дело доходит до выбора серверного веб-фреймворка в экосистеме Rust, можно запутаться из-за большого количества вариантов. В прошлом, лидером по популярности был Rocket, но сейчас за первенство сражаются Axum и actix-web, и Axum все больше набирает обороты. В этой статье мы немного погрузимся в Axum — веб-фреймворк для создания REST API на Rust, разрабатываемый командой Tokio. Он прост в использовании и хорошо совместим с Tower — надежной библиотекой для создания модульных компонентов сетевых приложений.

В этой статье мы подробно рассмотрим, как использовать Axum для создания веб-сервиса. Кроме того, мы рассмотрим изменения, которые произошли в версии 0.7.


Маршрутизация

Axum использует стиль API под названием REST, подобно Express, где мы создаем обработчики (handlers) и подключаем их к типу axum::Router. Пример маршрута выглядит следующим образом:

async fn hello_world() -> &'static str {
    "Hello world!"
}

Затем мы можем добавить его к маршрутизатору:

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

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

Чтобы обработчик был действительным (валидным), он должен либо иметь тип axum::response::Response, либо реализовывать axum::response::IntoResponse. Это уже реализовано для большинства примитивных типов и всех собственных типов Axum. Например, если необходимо отправить пользователю некоторые данные в формате JSON, это можно легко сделать, обернув данные в тип axum::Json и указав этот тип в качестве возвращаемого значения. Как видно из примера выше, можно просто вернуть строку (срез — slice).

Можно напрямую использовать impl IntoResponse, что позволяет избавиться от необходимости определять конкретный тип возвращаемого значения. Однако, использование такого подхода означает, что все возвращаемые значения должны иметь один и тот же тип. Это может вызвать ошибки и проблемы. Вместо этого, можно реализовать Into Response для перечислений или структур, и затем использовать их в качестве возвращаемого типа.

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

// Тип, реализующий `Serialize + Send`
#[derive(Serialize)]
struct Message {
    message: String
}

enum ApiResponse {
    OK,
    Created,
    JsonData(Vec),
}

impl IntoResponse for ApiResponse {
    fn into_response(self) -> Response {
        match self {
            Self::OK => (StatusCode::OK).into_response(),
            Self::Created => (StatusCode::CREATED).into_response(),
            Self::JsonData(data) => (StatusCode::OK, Json(data)).into_response()
        }
    }
}

Затем можно реализовать перечисление в обработчике следующим образом:

async fn my_function() -> ApiResponse {
    // ...
}

Для возврата из обработчиков можно использовать тип Result. Он может быть использован для представления любых ошибок, соответствующих HTTP-ответу. Кроме того, можно создать свой собственный тип ошибки, который будет представлять различные типы провалов при обработке HTTP-запросов в приложении:

enum ApiError {
    BadRequest,
    Forbidden,
    Unauthorized,
    InternalServerError
}

// реализация `IntoResponse`

async fn my_function() -> Result {
    // ...
}

Это позволяет различать ошибки и успешные запросы при создании роутов в Axum.


База данных

Во время настройки базы данных часто возникает необходимость настроить подключение к ней.

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, будь то локальный сервер на компьютере, сервер, запущенный в контейнере Docker и т.д. С помощью Shuttle можно облегчить этот процесс — среда выполнения автоматически готовит базу данных.

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

    // ...
}

Локально это делается с помощью Docker. Однако при развертывании (деплое) существует один процесс, который берет на себя все необходимые действия. Не нужно выполнять никаких дополнительных шагов. Наши разработчики подготовили базу данных AWS RDS, для которой не требуется никаких специальных знаний AWS. Переходите на эту страницу для получения дополнительной информации.


Состояние приложения

Возможно, вы задаетесь вопросом, как сохранить свой пул баз данных и другие зависящие от состояния переменные без необходимости их инициализации каждый раз при выполнении операций. Это вполне резонный вопрос, на который легко ответить! Как вы могли заметить, ранее мы использовали тип axum::Extension для хранения, и в некоторых случаях это прекрасное решение. Однако у него есть недостаток — отсутствие полной типобезопасности (typesafe).

Большинство веб-фреймворков на Rust, включая 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_world))
        .route("/do_something", get(do_something))
        .with_state(state)
}

// Добавление состояния приложения является опциональным
async fn hello_world() -> &'static str {
    "Hello world!"
}

async fn do_something(
    State(state): State
) -> Result {
    // ...
}

Также следует отметить, что помимо использования #[derive(Clone)], можно обернуть структуру состояния приложения в атомарный счетчик ссылок (atomic reference counter) std::sync::Arc. Arc — это особая форма сборки мусора, которая отслеживает, сколько существует копий данного объекта, и сбрасывается только при отсутствии таких (активных) копий. Это полезный тип данных, о котором следует знать.

use std::sync::Arc;

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

При добавлении состояния в приложение важно убедиться, что мы ссылаемся на тип извлечения состояния (state extractor type) State>, а не на State.

Из личного опыта могу сказать, что не всегда ясно, какой метод лучше выбрать. Возможно, Arc будет лучше работать в микробенчмарках (micro-benchmark), но приведет ли это к реальным улучшениям будет зависеть от конкретного случая использования.

Также из состояния приложения можно вывести (derive) подсостояние (sub-state). Это может быть полезным в случаях, когда требуются некоторые переменные из основного состояния, но мы хотим ограничить доступ только теми данными, которые нужны для данного маршрута. Пример:

// Состояние приложения
#[derive(Clone)]
struct AppState {
    // Специфичное для api состояние
    api_state: ApiState,
}

// Специфичное для api состояние
#[derive(Clone)]
struct ApiState {}

// Преобразование `AppState` в `ApiState`
impl FromRef for ApiState {
    fn from_ref(app_state: &AppState) -> ApiState {
        app_state.api_state.clone()
    }
}


Экстракторы

Экстракторы (extractors) — это инструменты, которые извлекают данные из HTTP-запроса и позволяют передавать их в обработчик в качестве аргументов. В Axum уже встроена поддержка широкого спектра экстракторов, таких как извлечение заголовков, путей, параметров запроса, данных форм и JSON. Кроме того, существует поддержка сообщества таких вещей, как MsgPack, JWT-экстракторы и др. Можно создавать собственные экстракторы, о чем мы поговорим позже.

Как пример, рассмотрим тип axum::Json, который используется для обработки HTTP-запроса путем извлечения тела запроса в формате JSON. Вот как это можно сделать:

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

async fn my_function(
    Json(json): Json
) -> Result {
    // ...
}

Использование serde_json::Value может быть не очень удобным, так как он не имеет четкой структуры и может содержать данные любого типа. Исправим это и используем структуру Rust, которая реализует трейт serde::Deserialize, что позволяет преобразовать сырые (raw) данные в конкретную структуру:

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

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

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

    // ...
}

Обратите внимание, что любые поля, которых нет в структуре, будут проигнорированы. В определенных случаях это удобно. Например, если мы получаем запрос веб-хука (webhook request), и нас интересуют только определенные поля, можно определить в структуре только эти поля, а остальные будут проигнорированы.

Данные форм и параметры запросов можно обрабатывать одинаково, добавляя соответствующий тип в обработчик. Вот пример экстрактора для обработки формы в Axum:

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

    // ...
}

На стороне HTML, при отправке HTTP-запроса в API, важно убедиться, что указан правильный тип контента.

В Axum можно обрабатывать заголовки запроса таким же образом, как и другие данные. Для этого можно использовать тип TypedHeader. В Axum 0.6 это можно сделать с помощью функции headers, а в Axum 0.7 эта функциональность была перенесена в пакет axum-extra, который необходим для использования функции typed-header:

cargo add axum-extra -F typed-header

Использование типизированных заголовков может быть таким же простым, как добавление их в качестве параметра в обработчик:

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
) -> Result {
    println!("{}", origin.hostname);

    // ...
}

Документацию для TypedHeader можно найти здесь.

Кроме TypedHeaders axum-extra также предоставляет много других полезных типов. Например, экстрактор CookieJar, который помогает управлять файлами cookie. Еще одним примером является protobuf — экстрактор для работы с gRPC. Документацию по библиотеке axum-extra можно найти здесь.


Кастомные экстракторы

Теперь, когда мы познакомились с экстракторами, вероятно, вам интересно, как создавать собственные. Допустим, нам требуется экстрактор, который определяет тип тела запроса (JSON или форма). Определим необходимые структуры и функцию-обработчик.

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

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

struct JsonOrForm(T);

Теперь можно реализовать From Request для структуры JsonOrForm.

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

    async fn from_request(req: Request, _state: &S) -> Result {
        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 {
            if content_type.starts_with("application/json") {
                let Json(payload) = req.extract().await.map_err(IntoResponse::into_response)?;
                return Ok(Self(payload));
            }

            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));
            }
        }

        Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response())
    }
}

В Axum 0.7 произошли изменения относительно типа axum::body::Body. Он больше не реэкспортирует тип hyper::body::Body, а стал собственным типом. Это значит, что он больше не является универсальным, и тип запроса будет всегда использовать axum::body::Body. В связи с этим, нужно просто удалить параметр типа B. Вот как будет выглядеть обновленный код:

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

    async fn from_request(req: Request, _state: &S) -> Result {
        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 {
            if content_type.starts_with("application/json") {
                let Json(payload) = req.extract().await.map_err(IntoResponse::into_response)?;
                return Ok(Self(payload));
            }

            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));
            }
        }

        Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response())
    }
}


Посредники

Как упоминалось ранее, одним из главных преимуществ Axum перед другими фреймворками является его полная совместимость с библиотеками Tower, что позволяет эффективно использовать промежуточное программное обеспечение (middleware) Tower в Rust API. Например, можно добавить посредника 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, которые можно использовать без необходимости создавать собственных посредников. Если вы уже используете посредника Tower в своем приложении, то это отличный способ повторного использования посредника без дублирования кода, так как совместимость гарантирует отсутствие проблем.

Кроме того, можно создавать собственных посредников. Для этого используется общий шаблон для типов Request и Next (в Axum 0.6), поскольку тип тела запроса (axum::body::Body) является общим (распределенным) в этой версии.

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

async fn check_hello_world(
    req: Request,
    next: Next
) -> Result {
    // Требуется крейт для извлечения названия заголовка
    if req.headers().get(CONTENT_TYPE).unwrap() != "application/json" {
        return Err(StatusCode::BAD_REQUEST);
    }

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

В Axum 0.7 нужно удалить ограничение , поскольку тип axum::body::Body больше не является универсальным:

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

async fn check_hello_world(
    req: Request,
    next: Next
) -> Result {
    if req.headers().get(CONTENT_TYPE).unwrap() != "application/json" {
        return Err(StatusCode::BAD_REQUEST);
    }

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

Для создания нового посредника можно использовать функцию axum::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, или объединить серверную часть Rust Axum с фреймворком JavaScript (например, React), для создания одного большого приложения. Как это сделать?

Axum сам по себе не предоставляет функциональности для обслуживания статических файлов. Однако, благодаря его совместимости с библиотекой tower-http, для этого можно использовать ее утилиты, будь то SPA (одностраничное приложение), статически сгенерированные файлы из фреймворка, такого как Next.js, или просто необработанные файлы HTML, CSS и JavaScript.

Например, статически сгенерированные файлы можно легко добавить в маршрутизатор (при условии, что они находятся в директории dist в корне проекта):

use tower_http::services::ServeDir;

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

Если мы работаем с фреймворком SPA, например React, Vue и т.п., можно разместить ресурсы в нужной директории и использовать следующий код:

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")),
    )
}

Стоит отметить, что можно использовать HTML-шаблоны с помощью таких фреймворков, как askama, tera и maud. Использование этих инструментов вместе с легковесными JavaScript-библиотеками, например htmx, существенно ускоряет процесс разработки. Если вам интересно узнать больше о том, как работать с HTML в Rust, ознакомьтесь с этой статьей. Отдельно стоит отметить совместную работу с Stefan Baumgartner о том, как использовать HTML в Askama!


Как развернуть Axum

При развертывании серверных приложений на Rust, могут возникать определенные сложности, связанные с использованием Dockerfiles. Однако, если у вас есть опыт работы с Docker, это не будет большой проблемой, особенно при использовании инструмента cargo-chef. При использовании Shuttle, достаточно выполнить команду cargo shuttle deploy, и все будет готово. Дополнительные настройки не требуются.


Заключение

Спасибо за ваш интерес к этой статье! Axum — отличный фреймворк, который обладает сильной командой поддержки и хорошо вписывается в веб-экосистему Rust. Сейчас лучшее время, чтобы начать разработку REST API на Rust.


b5pjofdoxth14ro-rjsrn7sbmiy.png

© Habrahabr.ru