Первая программа на 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 или отсутствии
textAxum вернёт ошибку валидации запроса.
Общее состояние — 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 только на запись в память, без сетевых вызовов внутри. |
Как развивать проект дальше
- Вынести маршруты в
routes.rs, обработчики вhandlers/, модели вmodels.rs. - Подключить sqlx + PostgreSQL вместо
VecвAppState. - Заменить
unwrap()наResultи крейты anyhow / thiserror (обработка ошибок). - Покрыть API тестами — Тестирование.
- Сравнить с 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 полезны базовые сигналы:
- Логи запросов — метод, путь, статус, длительность.
- Идентификатор запроса (
request_id) в логах и ответах. - Метрики — количество запросов, доля
5xx, p95/p99 latency.
Если добавить эти три слоя сразу, последующая отладка становится заметно проще, особенно при переходе к внешней БД и нескольким инстансам сервиса.
Чек-лист перед публикацией
- Порт и адрес читаются из конфигурации, а не "зашиты" в код.
unwrap()в боевом пути заменён на обработку ошибок.- Для POST/PUT есть валидация входных данных. См. Проверка и валидация.
- Health endpoint проверяет базовые зависимости (минимум БД-соединение).
- Минимальный набор тестов есть для happy-path и ошибок (Тестирование).
- Документация API содержит примеры запросов и ответов.
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.
В подборках
Статья входит в тематические подборки и блок "С чего начать?" на главной. Соседние шаги того же маршрута:
Первые шаги (маршрут подборки) — Первая программа на Fiber, Первая программа Electron с React, Первая программа на Echo, Первая программа на React Native, Первая программа на Gin, Первая программа на Expo.