Бекенд на Rust

Post Thumbnail

Создать веб-сервер на Rust не сложно. С помощью фреймворка Axum можно написать веб-сервер без лишних хлопот. На Rust решать задачи и реализовывать веб-сервисы также просто, как и на других языках, а иногда даже проще. В этой статье мы поговорим о том, как создать и развернуть простой веб-сервер с помощью Axum.

Какой фреймворк Rust мне использовать?

Хотя вариантов много, мой личный выбор — Axum, и вот почему:

  • Axum использует обобщённые типы (generics) и трейты. Это позволяет задействовать инструменты языка Rust намного плотнее, чем в других фреймворках.
  • У Axum знакомый синтаксис (используются функции-обработчики для маршрутизации).
  • Он обладает исключительно высокой совместимостью с крейтами из экосистемы tower. Это позволяет, если потребуется, опускаться на очень низкий уровень.

Давайте начнем

Чтобы начать, вам потребуется установить Rust на вашу систему. Подробнее про установку можно почитать на этой странице.

Создаем новый проект:

cargo init example
cd example
cargo add axum
cargo add tokio --features full # обязательно нужно, чтобы все заработало

Axum разработан для работы с Tokio и Hyper. Независимость от среды выполнения (runtime) и транспортного уровня не является целью, по крайней мере, на данный момент.

Теперь можем написать минимальный "hello world" пример:

use axum::{Router, routing::get};
use std::net::SocketAddr;
use tokio::net::TcpListener;

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

#[tokio::main]
async fn main() {
    let router = Router::new().route("/", get(hello_world));

    let addr = SocketAddr::from(([127,0,0,1], 8000));
    let tcp = TcpListener::bind(&addr).await.unwrap();

    axum::serve(tcp, router).await.unwrap();
}

Как видно из кода выше, мы реализуем несколько вещей:

  • Настраиваем маршрутизатор с заданными путями (роутами) и функциями, которые должны вызываться.

  • Определяем адрес, по которому наш веб-сервер будет принимать запросы, и привязываем его к TCP-слушателю.

  • TCP-слушатель затем отвечает на запросы и отправляет соответствующие ответы.

Если вы использовали команду cargo run для запуска этой программы, то при переходе в браузере по адресу localhost:8000 вы увидите "Hello world!" в виде обычного текста.

Роутинг

HTTP-ответы при маршрутизации в рамках Rust-фреймворков могут возвращать любые данные, которые реализуют трейт, представляющий HTTP-ответ. В Axum этим трейтом является axum::response::IntoResponse (или axum::response::IntoResponseParts для заголовков и других частей ответа, не являющихся телом). Веб-серверы могут возвращать только то, что является допустимым HTTP-ответом. Реализация IntoResponse (и, соответственно, IntoResponseParts) гарантирует это. Кстати, это чем-то напоминает Go-шный http.ResponseWriter, который используется в обработчиках.

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

Для ответов в формате JSON Axum предоставляет удобную структуру Json<T>, которую мы можем использовать как тип ответа, обернув в неё нужный тип.

Например, приведённый ниже фрагмент кода показывает, как можно вернуть "сырой" JSON. Для удобства будем использовать библиотеку serde_json.

Установим библиотеку:

cargo add serde_json

И теперь добавим новый обработчик, который возвращает ответ в JSON формате:

use axum::{Router, routing::get};
use std::net::SocketAddr;
use tokio::net::TcpListener;
use serde_json::{json, Value};
use axum::Json;

async fn return_some_json() -> Json<Value> {
    let json = json!({"hello":"world"});

    Json(json)
}

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

#[tokio::main]
async fn main() {
    let router = Router::new()
        .route("/", get(hello_world))
        .route("/json", get(return_some_json));

    let addr = SocketAddr::from(([127,0,0,1], 8000));
    let tcp = TcpListener::bind(&addr).await.unwrap();

    axum::serve(tcp, router).await.unwrap();
}

Правда, более вероятная ситуация — это когда вам нужно вернуть данные, соответствующие определённой схеме, или вернуть какую-то модель. Для этого мы можем использовать трейты Deserialize и Serialize из крейта serde. Мы можем легко применить эти трейты, добавив соответствующую возможность (feature) в Cargo.toml, а затем указав их как производные макросы для структуры.

Для начала нам нужно будет добавить ещё одну библиотеку для работы с JSON. Нужно уметь сериализовать и десериализовать данные, поэтому устанавливаем библиотеку со всеми её нужными частями:

cargo add serde --features std,alloc,derive

На этом моменте наш Cargo.toml должен выглядеть как-то так:

[package]
name = "example"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = "0.8.8"
serde = { version = "1.0.228", features = ["std", "alloc", "derive"] }
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["full"] }

Отлично, теперь можем сделать свою модель с данными:

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct MyStruct {
    my_field: String
}

Эту модель разместим в файле models.rs. И теперь будем использовать её в нашем сервере в обработчике return_a_struct_as_json:

mod models;

//...
use models::MyStruct;

// ...
async fn return_a_struct_as_json() -> Json<MyStruct> {
     let my_struct = MyStruct { my_field: "Hello world!".to_string() };

     Json(my_struct)
}

#[tokio::main]
async fn main() {
    let router = Router::new()
        .route("/", get(hello_world))
        .route("/struct", get(return_a_struct_as_json))
        .route("/json", get(return_some_json));

    //...
}

Экстракторы

Экстракторы — это аргументы функций-обработчиков (handler functions). Они извлекают части HTTP-запроса и превращают их в простые переменные, которые мы можем использовать в нашем приложении. Экстракторы можно применять для многих целей:

  • Доступ к общему изменяемому состоянию (путём добавления состояния в приложение и последующего обращения к нему в функции).

  • Извлечение типизированного заголовка для авторизации.

  • Получение тела запроса для извлечения данных в формате JSON или получение данных из формы. В зависимости от ваших потребностей.

Если для вашего конкретного случая нет готового решения, вы всегда можете реализовать экстрактор самостоятельно. Экстракторы — это тип, который реализует трейт FromRequest или FromRequestParts. Они позволяют разбирать входящий запрос, чтобы получить части, необходимые вашему обработчику.

Вот пример того, как можно использовать экстракторы. Обратите внимание, что здесь мы применяем деструктуризацию, чтобы автоматически получить внутреннюю переменную из Json<MyStruct>, так как этот подход выглядит чище.

//...
use axum::routing::post;

async fn with_extractors(
    Json(json): Json<MyStruct>,
) -> impl IntoResponse {
    format!("The contents of my_field is: {}", json.my_field)
}

#[tokio::main]
async fn main() {
    let router = Router::new()
        .route("/", get(hello_world))
        .route("/struct", get(return_a_struct_as_json))
        .route("/extractor", post(with_extractors))
        .route("/json", get(return_some_json));
    //...
}

Запускаем приложение и тестируем его POST-запросом:

POST http://localhost:8000/extractor
Content-Type: application/json

{
    "my_field": "example"
}

В ответ мы должны получить вот такой текст:

The contents of my_field is: example

Базы данных

Работа с базами данных в Rust, как правило, не сильно отличается от других языков. Основное отличие в том, что веб-фреймворки на Rust обычно используют общее изменяемое состояние (shared mutable state) для передачи данных. Это означает, что сначала нужно инициализировать подключение (или пул подключений) к базе данных, а затем передавать его через состояние (state).

В Axum состояние должно реализовывать трейт Clone. Если ваш тип не может реализовать Clone, потому что один или несколько его компонентов его не поддерживают, вы можете обернуть структуру состояния в std::sync::Arc, который реализует Clone.

Добавляем зависимости для работы c SQLite:

cargo add sqlx --features sqlite,runtime-tokio

И выносим логику создания пула для работы с базой в отдельный файл database.rs:

use sqlx::{Pool, Sqlite};
use sqlx::Executor;

pub async fn db_pool() -> Pool<Sqlite>{
    let opt = sqlx::sqlite::SqliteConnectOptions::new().filename("test.db").create_if_missing(true);

    let pool = sqlx::sqlite::SqlitePool::connect_with(opt).await.unwrap();

    pool.execute("
      CREATE TABLE if not exists users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT
      )
    ").await.unwrap();

    pool
}

В main.rs создаём новое состояние и передаем его в роуты:

//...
mod database;

//...
use database::db_pool;

//...

#[derive(Clone)]
struct MyState {
    db: Pool<Sqlite>
}

#[tokio::main]
async fn main() {
    let state = MyState {
        db: db_pool().await
    };

    let router = Router::new()
        .route("/", get(hello_world))
        .route("/struct", get(return_a_struct_as_json))
        .route("/extractor", post(with_extractors))
        .route("/json", get(return_some_json))
        .with_state(state);
    //...
}

Отлично, теперь можем запилить обработчик, который использует базу данных и делает простые запросы:

use axum::{extract::State, http::StatusCode};

async fn hello_world_from_db(
    State(state): State<MyState>
) -> StatusCode {
    sqlx::query("SELECT 'Hello world!'")
         .execute(&state.db)
         .await
         .unwrap();

    StatusCode::OK
}

Добавляем новый обработчик:

#[tokio::main]
async fn main() {
    let state = MyState {
        db: db_pool().await
    };

    let router = Router::new()
        .route("/", get(hello_world))
        .route("/struct", get(return_a_struct_as_json))
        .route("/extractor", post(with_extractors))
        .route("/json", get(return_some_json))
        .route("/db", get(hello_world_from_db))
        .with_state(state);
    //...
}

Статика

Чтобы начать работу со статическими файлами в Axum, мы создадим папку в корне нашего проекта с названием static.

Чтобы обслуживать эту папку static в Axum, нам нужно импортировать некоторые компоненты из tower-http. Для этого выполним следующую команду в терминале:

cargo add tower-http -F fs

Чтобы раздавать файлы, нужно создать эти файлы. В корне проекта добавляем папку static и в ней создаем файл index.html.

И теперь добавляем новый роут для статичных файлов:

//...
use tower_http::services::{ServeDir};

#[tokio::main]
async fn main() {
    let state = MyState {
        db: db_pool().await
    };

    let router = Router::new()
        .route("/", get(hello_world))
        .route("/struct", get(return_a_struct_as_json))
        .route("/extractor", post(with_extractors))
        .route("/json", get(return_some_json))
        .route("/db", get(hello_world_from_db))
        .nest_service("/static", ServeDir::new("static"))
        .with_state(state);
    //...
}

Это основные моменты работы любого веб-приложения. Конечно, это минимальный минимум, но от этого можно отталкиваться и добавлять новые части.

Ссылки

Что я использовал для этой статьи: