Перейти к основному содержимому

Первая программа на Axum

Разработчику Архитектору

Первая программа на Axum

Вы уже собрали первую консольную программу в Rust (первая программа на Rust). Теперь можно перейти к более "живому" сценарию — поднять небольшой веб-сервис. Такой сервис слушает порт, принимает HTTP-запросы и возвращает JSON или текст.

В Rust для этого часто используют связку Tokio (асинхронный runtime) и Axum (удобные маршруты и обработчики поверх Tokio). В этой статье вы шаг за шагом соберёте мини-API и увидите, как всё связывается в цельную картину.

Axum — это современный, модульный и высокопроизводительный веб-фреймворк для языка программирования Rust. Он разработан командой Tokio (популярной библиотеки асинхронного ввода-вывода в Rust) и предназначен для создания быстрых HTTP-серверов, микросервисов и REST API.

Ключевые особенности:

  • Экосистема Tokio. Нативно работает с асинхронным рантаймом tokio.
  • Архитектура на экстракторах. Позволяет декларативно извлекать данные из запросов (JSON, параметры строки, заголовки) прямо в аргументы функций-обработчиков.
  • Совместимость с Tower. Использует экосистему модульных компонентов tower для middleware, что упрощает добавление авторизации, логирования, трассировки и таймаутов.
  • Высокая производительность. Построен поверх низкоуровневой HTTP-библиотеки hyper, благодаря чему потребляет минимум памяти и обеспечивает огромную пропускную способность.
  • Безопасность типов. Ошибки маршрутизации и несовпадения типов данных выявляются еще на этапе компиляции кода.

Создание простейшего сервера на Axum выглядит следующим образом:

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

#[tokio::main]
async fn main() {
// Настройка маршрутов
let app = Router::new().route("/", get(|| async { "Hello, World!" }));

// Запуск сервера на порту 3000
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

Axum идеально подходит бэкенд-разработчикам, которым требуется максимальная скорость работы приложения и минимальное потребление системных ресурсов (например, при разработке высоконагруженных микросервисов). Наряду с actix-web, сегодня он является одним из самых популярных и де-факто стандартных веб-фреймворков в мире Rust.

Tokio, Hyper и Tower — это фундаментальные блоки (библиотеки), на базе которых построен Axum. Они составляют ядро всей современной веб-разработки на языке Rust.

Tokio — это асинхронный рантайм (среда выполнения) для Rust. По умолчанию Rust не умеет самостоятельно выполнять асинхронный код (async/await) — ему нужен планировщик задач. Tokio берет эту роль на себя. Управляет потоками, распределяет задачи, а также предоставляет асинхронные аналоги для работы с сетью (TcpListener), файлами и таймерами. Он позволяет серверу обрабатывать сотни тысяч одновременных соединений (I/O-bound задачи) без блокировки процессора.

Hyper — это низкоуровневая, невероятно быстрая и безопасная библиотека для работы с протоколом HTTP. Tokio умеет работать только с «сырыми» сетевыми байтами (TCP-пакетами). Hyper превращает эти байты в понятные HTTP-запросы и ответы. Реализует стандарты HTTP/1, HTTP/2 и HTTP/3. Он разбирает заголовки, тела запросов и следит за сетевым протоколом, но ничего не знает про маршруты (роутинг) вашего приложения.

Tower — это абстрактный каркас для создания модульных сетевых сервисов. Чтобы не писать код авторизации, логирования или ограничения запросов заново для каждого нового проекта. Вводит универсальный интерфейс Service, который принимает запрос и возвращает ответ. Благодаря этому вы можете собирать приложение как конструктор Lego, нанизывая друг на друга готовые слои (middlewares) — например, слой для подсчета таймаутов, слой для защиты от спама (Rate Limiting) или слой авторизации.

Обзор фреймворков: Фреймворки Rust, Экосистема. Асинхронность подробнее: глава про async. Тесты для такого API: Тестирование.


Термины, которые пригодятся сразу

ТерминПростыми словами
HTTPПротокол обмена "запрос → ответ" между клиентом (браузер, curl, мобильное приложение) и сервером.
REST APIНабор URL (маршрутов), к которым обращаются методами GET, POST, DELETE и получают данные в формате JSON.
Маршрут (route)Правило "если пришёл запрос на /notes методом GET — вызвать такую-то функцию".
JSONТекстовый формат данных: {"id": 1, "text": "купить молоко"}. В Rust его связывают с структурами через serde.
TokioБиблиотека, которая запускает асинхронный код: пока один запрос ждёт сеть, обрабатываются другие.
AxumФреймворк поверх Tokio: маршруты, извлечение тела запроса, общее состояние приложения.
async / awaitСинтаксис "подождать операцию, не блокируя весь сервер"; обработчик запроса обычно объявляют async fn.
ExtractorПараметр обработчика, из которого Axum автоматически достаёт данные из запроса (JSON, id из URL, состояние приложения).

Что мы построим

Соберём мини-API заметок в памяти (без базы данных), чтобы сосредоточиться на базовых принципах:

МетодURLДействие
GET/healthПроверка "сервер жив"
GET/notesСписок всех заметок
POST/notesСоздать заметку (тело JSON)
DELETE/notes/:idУдалить заметку по id

После cargo run сервер будет доступен по адресу http://127.0.0.1:3000.


Создание проекта

cargo new axum-notes
cd axum-notes

Разбор:

  • cargo new axum-notes создаёт новый Rust-проект с базовой структурой файлов.
  • cd axum-notes переводит терминал в каталог проекта, где лежит Cargo.toml.
  • После перехода все дальнейшие команды Cargo нужно выполнять уже в этой папке.
  • Имя проекта axum-notes станет именем пакета, если его не изменить вручную.
  • Такой старт гарантирует корректную структуру для сборки и запуска API.

Cargo создаёт каталог с Cargo.toml (манифест: имя, версии библиотек) и src/main.rs (точка входа).


Зависимости в Cargo.toml

[package]
name = "axum-notes"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }

Разбор по строкам:

  • axum — маршрутизатор и HTTP-обработчики.
  • tokio с features = ["full"] — для учебного проекта подключают полный набор возможностей runtime (таймеры, TCP, макрос #[tokio::main]). В продакшене иногда оставляют только нужные features, чтобы ускорить сборку.
  • serde — сериализация: Rust-структура ↔ JSON. Атрибут derive включает автогенерацию кода через макросы Serialize / Deserialize.

Версии 0.7 и 1 — пример; при копировании в свой проект сверяйтесь с crates.io.


Полный main.rs и общая схема

Сначала — код целиком, затем разбор по блокам.

Код ITЗагрузка примера кода…

Разбор:

  • use axum::... подключает маршрутизацию, extractors и типы HTTP-ответов.
  • Note и NoteCreate описывают формат JSON для ответа и создания заметки.
  • AppState — общее состояние API: список заметок и счётчик следующего id.
  • #[tokio::main] запускает async-runtime, без него async fn main не стартует.
  • Router::new().route(...) связывает URL и обработчики, .with_state внедряет состояние.
  • TcpListener::bind открывает порт, axum::serve принимает входящие HTTP-запросы.
  • health, list_notes, create_note, delete_note — четыре endpoint из таблицы выше.

Запуск:

cargo run

Разбор:

  • cargo run соберёт проект с указанными зависимостями и сразу запустит HTTP-сервер.
  • Если зависимости ещё не скачаны, команда дополнительно выполнит их загрузку и компиляцию.
  • По умолчанию сборка идёт в dev-профиле, что удобно для разработки и отладки.
  • Запущенный процесс начнёт слушать порт, заданный в коде (3000).
  • Остановить сервер можно Ctrl+C в том же терминале.

Модели данных — Note и NoteCreate

#[derive(Clone, Serialize)]
struct Note {
id: u32,
text: String,
}

#[derive(Deserialize)]
struct NoteCreate {
text: String,
}
  • Serialize — Rust → JSON (ответ клиенту).
  • Deserialize — JSON из тела POST → поля структуры.
  • Clone — возможность копировать заметку при push и при возврате ответа (для учебного примера достаточно; в больших проектах иногда возвращают ссылку или id без полного клонирования).

Клиент при создании шлёт только текст; id выдаёт сервер:

{ "text": "первая заметка" }

Разбор:

  • Это тело POST-запроса в формате JSON для создания новой заметки.
  • Ключ text должен совпадать с полем структуры NoteCreate в Rust.
  • Значение "первая заметка" будет распарсено и сохранено сервером.
  • Поле id клиент не передаёт: его генерирует обработчик на сервере.
  • При неверном JSON или отсутствии text Axum вернёт ошибку валидации запроса.

Общее состояние — Arc, Mutex, AppState

type AppState = Arc<Mutex<(Vec<Note>, u32)>>;
ЧастьНазначение
Vec<Note>Хранилище заметок в оперативной памяти.
u32Счётчик следующего id (второй элемент кортежа).
Mutex<...>В один момент времени только один обработчик может изменять данные — защита от гонок при параллельных запросах.
Arc<...>Разделяемое владение: одна копия состояния на все потоки Tokio; Arc::clone увеличивает счётчик ссылок, данные остаются те же.

type AppState = ... — псевдоним типа, чтобы в сигнатурах писать короче State<AppState>.

В продакшене вместо Vec в памяти подключают PostgreSQL (sqlx) или другое хранилище; идея State остаётся: в неё кладут пул соединений с БД или сервис.


Точка входа — main по шагам

#[tokio::main]
async fn main() {

Макрос #[tokio::main] разворачивает async fn main в обычную fn main, которая создаёт runtime Tokio и запускает асинхронную функцию. Без этого async fn main в Rust напрямую недоступна.

let state: AppState = Arc::new(Mutex::new((Vec::new(), 1)));

Пустой список заметок и следующий id = 1.

let app = Router::new()
.route("/health", get(health))
.route("/notes", get(list_notes).post(create_note))
.route("/notes/:id", delete(delete_note))
.with_state(state);
  • Router — таблица маршрутов.
  • .route(path, method(handler)) — привязка URL к функции.
  • На /notes висят два метода: GET и POST (цепочка .get(...).post(...)).
  • :id в пути — параметр; Axum передаст его в Path(id).
  • .with_state(state) — внедрение состояния во все обработчики, которым нужен State<AppState>.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
  • 0.0.0.0 — слушать на всех сетевых интерфейсах машины (локально заходите на 127.0.0.1:3000).
  • .await — ждём, пока сокет привязан; затем serve крутит цикл приёма соединений.
  • unwrap() — при ошибке bind программа завершится с паникой; в учебном коде так проще; в сервисе ошибку логируют и возвращают Result.

Обработчики и extractors

health — минимальный ответ

async fn health() -> &'static str {
"ok"
}

Тип возврата &'static str Axum превращает в тело ответа с кодом 200 OK и text/plain. Параметров нет — extractors не нужны.


list_notes — состояние и JSON

async fn list_notes(State(state): State<AppState>) -> Json<Vec<Note>> {
let guard = state.lock().unwrap();
Json(guard.0.clone())
}
  • State(state) — extractor: Axum достаёт из приложения клон AppState (внутри снова Arc).
  • lock() — захват мьютекса; unwrap() здесь сработает, если другой поток запаниковал, удерживая lock (редкий случай).
  • guard.0 — первый элемент кортежа, вектор заметок.
  • Json(...) — обёртка ответа: сериализация в JSON, заголовок Content-Type: application/json.

create_note — тело запроса и код 201

async fn create_note(
State(state): State<AppState>,
Json(body): Json<NoteCreate>,
) -> (StatusCode, Json<Note>) {

Два extractor’а в одной функции — порядок параметров для Axum не важен (типы различаются).

  • Json(body) — десериализация тела POST; при невалидном JSON клиент получит 400.
  • Возврат (StatusCode::CREATED, Json(note)) — код 201 и тело с созданной заметкой.

Логика id: читаем guard.1, увеличиваем, пушим в guard.0.

Полное тело create_note:

async fn create_note(
State(state): State<AppState>,
Json(body): Json<NoteCreate>,
) -> (StatusCode, Json<Note>) {
let mut guard = state.lock().unwrap();
let id = guard.1;
guard.1 += 1;
let note = Note { id, text: body.text };
guard.0.push(note.clone());
(StatusCode::CREATED, Json(note))
}

Разбор:

  • State(state) достаёт общий AppState из приложения.
  • Json(body) десериализует тело POST в NoteCreate.
  • lock() защищает данные от одновременной записи из нескольких запросов.
  • guard.1 — текущий id; после инкремента следующий запрос получит новый номер.
  • note.clone() нужен, чтобы вернуть копию в ответе и оставить оригинал в векторе.
  • (StatusCode::CREATED, Json(note)) отдаёт HTTP 201 и JSON созданной заметки.

delete_note — параметр из URL

async fn delete_note(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> StatusCode {

Path(id) — сегмент :id из /notes/5 превращается в u32. Если id не число — Axum ответит ошибкой маршрутизации.

retain(|n| n.id != id) — оставляет в векторе только элементы, у которых id другой (удаляем совпадения). Сравниваем длину до и после: уменьшилась — 204 No Content, иначе заметки с таким id не было — 404.


Проверка вручную (curl)

В другом терминале:

curl http://127.0.0.1:3000/health

Разбор:

  • curl отправляет GET-запрос на endpoint проверки доступности сервиса.
  • Адрес 127.0.0.1:3000 означает локальный сервер на стандартном тестовом порту из примера.
  • Ожидаемое тело ответа - ok, что подтверждает работу маршрута /health.
  • Этот запрос обычно используют как базовую smoke-проверку после запуска.
  • Если соединение не устанавливается, значит сервер не поднялся или слушает другой порт.
curl -X POST http://127.0.0.1:3000/notes \
-H "Content-Type: application/json" \
-d "{\"text\":\"учебная заметка\"}"

Разбор:

  • -X POST задаёт HTTP-метод создания ресурса.
  • Заголовок Content-Type: application/json сообщает серверу формат тела запроса.
  • -d ... передаёт JSON-объект с полем text для десериализации в NoteCreate.
  • Обратные слеши \ разбивают длинную команду на строки для читаемости.
  • При успехе сервер вернёт 201 Created и JSON созданной заметки.
curl http://127.0.0.1:3000/notes

Разбор:

  • Этот GET-запрос получает весь текущий список заметок.
  • Ответ приходит в JSON-массиве ([], если заметок пока нет).
  • Маршрут удобен для быстрой проверки, что POST действительно сохранил данные.
  • Формат ответа соответствует типу Json<Vec<Note>> в обработчике.
  • Запрос не требует тела и дополнительных заголовков.
curl -X DELETE http://127.0.0.1:3000/notes/1 -i

Разбор:

  • -X DELETE отправляет запрос на удаление заметки по id из URL.
  • /notes/1 соответствует маршруту "/notes/:id", где id = 1.
  • Флаг -i печатает заголовки и статус-код ответа вместе с телом.
  • При успешном удалении ожидается 204 No Content, при отсутствии записи - 404 Not Found.
  • Повторный DELETE того же id чаще всего вернёт уже 404, что тоже нормальное поведение.

Флаг -i покажет статус-код (204 или 404).


Middleware (промежуточный слой)

Middleware выполняется до или после обработчика — логирование, CORS, сжатие, аутентификация. В экосистеме Tokio часто используют crate tower и готовые слои tower-http.

use tower_http::trace::TraceLayer;

let app = Router::new()
// ... маршруты ...
.layer(TraceLayer::new_for_http());

Разбор:

  • TraceLayer::new_for_http() добавляет middleware логирования HTTP-запросов.
  • .layer(...) оборачивает все маршруты Router единым промежуточным слоем.
  • Слой выполняется до/после handler и фиксирует метод, путь, статус и время.
  • Такой подход не требует менять сигнатуры обработчиков.
  • Для production обычно комбинируют trace с CORS и compression.

В Cargo.toml добавьте:

tower-http = { version = "0.5", features = ["trace"] }

Разбор:

  • Добавляется зависимость tower-http, которая содержит готовые middleware-слои.
  • Версия 0.5 должна быть совместима с используемой версией Axum/Tower в проекте.
  • Feature trace включает именно слой трассировки HTTP-запросов.
  • После добавления строки нужно пересобрать проект, чтобы crate подтянулся.
  • Этот пакет помогает быстро получить базовое логирование без собственного middleware-кода.

Каждый запрос попадёт в трассировку (метод, путь, время) — удобно при отладке.


Типичные ошибки новичка

СимптомЧто проверить
Address already in useПорт 3000 занят другим процессом — смените порт или завершите старый cargo run.
trait bound ... SerializeНа структуре ответа забыли #[derive(Serialize)] или не подключили serde с derive.
State<AppState> не находитсяЗабыли .with_state(...) на Router или тип state в handler другой.
Пустой ответ на POSTНет заголовка Content-Type: application/json или неверное поле (нужно "text", как в NoteCreate).
Долгая "заморозка" под нагрузкойДолгий код под Mutex — держите lock только на запись в память, без сетевых вызовов внутри.

Как развивать проект дальше

  1. Вынести маршруты в routes.rs, обработчики в handlers/, модели в models.rs.
  2. Подключить sqlx + PostgreSQL вместо Vec в AppState.
  3. Заменить unwrap() на Result и крейты anyhow / thiserror (обработка ошибок).
  4. Покрыть API тестами — Тестирование.
  5. Сравнить с Actix — обзор фреймворков.

Минимальная архитектура для реального проекта

Учебный пример полезно быстро перевести в "рабочий каркас", чтобы код рос без хаоса:

src/
main.rs # запуск сервера, bind порта, graceful shutdown
lib.rs # pub fn app(state) -> Router
config.rs # чтение env и конфигурации
routes/
mod.rs
health.rs
notes.rs
services/
notes_service.rs
storage/
mod.rs
memory.rs
postgres.rs
error.rs # единый тип ошибки API

Разбор:

  • Это пример рекомендуемой структуры проекта при росте API-сервиса.
  • main.rs оставляют тонким — конфиг, запуск, graceful shutdown.
  • lib.rs содержит сборку Router, чтобы её можно было тестировать без TCP.
  • Разделение по папкам routes/services/storage уменьшает связность и упрощает сопровождение.
  • Явный error.rs помогает держать единый формат ошибок во всех endpoint.

Такой расклад даёт три преимущества:

  • Тесты проверяют lib::app(...) без реального TCP-порта.
  • Хранилище меняется с in-memory на PostgreSQL без переписывания маршрутов.
  • Ошибки и формат ответов единообразны для всех endpoint.

Подход особенно удобен, если сервис вырастает в workspace из нескольких crate — Cargo workspace.

Вынесение Router в lib.rs (удобно для тестов):

// src/lib.rs
use axum::Router;

pub fn app(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/notes", get(list_notes).post(create_note))
.route("/notes/:id", delete(delete_note))
.with_state(state)
}
// src/main.rs
#[tokio::main]
async fn main() {
let state = AppState::default();
let app = axum_notes::app(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

Разбор:

  • pub fn app(...) собирает маршруты в одном месте и экспортирует наружу.
  • main.rs остаётся тонким: только запуск сервера и bind порта.
  • Тесты могут вызывать axum_notes::app(...) без реального TCP.
  • Имя crate (axum_notes) берётся из name в Cargo.toml.
  • Такой шаблон снижает дублирование между production и test-кодом.

Чтение порта из переменной окружения:

let port: u16 = std::env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3000);
let addr = format!("0.0.0.0:{port}");

Разбор:

  • std::env::var("PORT") читает переменную окружения как Result<String, _>.
  • .ok() превращает Result в Option, чтобы упростить цепочку.
  • .and_then(|s| s.parse().ok()) пытается распарсить строку в u16.
  • unwrap_or(3000) задаёт порт по умолчанию, если переменная не задана или невалидна.
  • format! собирает адрес bind динамически, без "зашитого" порта в коде.

Формат ошибок API

Клиентам удобнее получать стабильный JSON-формат ошибок, а не случайный текст:

{
"error": "validation_failed",
"message": "поле text обязательно",
"request_id": "f95a8c4f"
}

Разбор:

  • error - машинно-ориентированный код типа ошибки для клиентской логики.
  • message - человеко-читаемое объяснение, которое можно показать в UI.
  • request_id - идентификатор запроса для быстрого поиска соответствующей записи в логах.
  • Такой формат помогает отделить внутренние детали сервера от стабильного внешнего контракта API.
  • Единая структура ошибок резко упрощает интеграцию фронтенда и мониторинг.

Пример единого типа ошибки в Rust:

#[derive(Serialize)]
struct ApiError {
error: &'static str,
message: String,
request_id: String,
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(StatusCode::BAD_REQUEST, Json(self)).into_response()
}
}

Разбор:

  • ApiError повторяет JSON-контракт, который видит клиент.
  • IntoResponse позволяет возвращать ошибку из handler как обычный HTTP-ответ.
  • StatusCode::BAD_REQUEST задаёт код 400 для ошибок валидации.
  • Json(self) сериализует структуру в тело ответа.
  • request_id удобно прокидывать из middleware трассировки.

Базовая практика:

  • все ошибки в обработчиках приводить к единому типу;
  • возвращать корректные HTTP-коды (400, 404, 409, 500);
  • логировать внутренние детали в серверных логах, клиенту отдавать безопасное описание.

Технику удобно совместить с материалом про обработку ошибок.


Наблюдаемость с первого дня

Даже учебному API полезны базовые сигналы:

  1. Логи запросов — метод, путь, статус, длительность.
  2. Идентификатор запроса (request_id) в логах и ответах.
  3. Метрики — количество запросов, доля 5xx, p95/p99 latency.

Если добавить эти три слоя сразу, последующая отладка становится заметно проще, особенно при переходе к внешней БД и нескольким инстансам сервиса.


Чек-лист перед публикацией

  • Порт и адрес читаются из конфигурации, а не "зашиты" в код.
  • unwrap() в боевом пути заменён на обработку ошибок.
  • Для POST/PUT есть валидация входных данных. См. Проверка и валидация.
  • Health endpoint проверяет базовые зависимости (минимум БД-соединение).
  • Минимальный набор тестов есть для happy-path и ошибок (Тестирование).
  • Документация API содержит примеры запросов и ответов.

Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.


В подборках

Статья входит в тематические подборки и блок "С чего начать?" на главной. Соседние шаги того же маршрута:

Первые шаги (маршрут подборки) — Первая программа на Fiber, Первая программа Electron с React, Первая программа на Echo, Первая программа на React Native, Первая программа на Gin, Первая программа на Expo.