Skip to content

Extractors (Экстракторы)

Экстракторы — это один из ключевых механизмов Axum, который позволяет извлекать данные из HTTP-запросов и предоставлять их хендлерам в типизированном виде. В этом разделе мы рассмотрим концепцию экстракторов, их типы и способы использования, а также научимся создавать собственные экстракторы для нестандартных задач.

Концепция экстракторов

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

rust
// Без экстракторов код выглядел бы примерно так:
async fn create_user_manual(request: Request) -> Response {
    // Извлечение и парсинг JSON из тела запроса
    let body_bytes = hyper::body::to_bytes(request.into_body()).await.unwrap();
    let user: CreateUser = serde_json::from_slice(&body_bytes).unwrap();
    
    // Далее логика обработки...
}

// С экстракторами код становится более декларативным:
async fn create_user(
    Json(user): Json<CreateUser>,
) -> impl IntoResponse {
    // Сразу получаем типизированные данные
    // Логика обработки...
}

Встроенные экстракторы

Axum предоставляет множество встроенных экстракторов для различных случаев использования.

1. Экстракторы пути (Path)

Экстрактор Path извлекает параметры из URL-пути запроса:

rust
use axum::{
    extract::Path,
    routing::get,
    Router,
};
use serde::Deserialize;

// Извлечение одного параметра
async fn get_user(Path(user_id): Path<u64>) -> String {
    format!("Получен пользователь с ID: {}", user_id)
}

// Извлечение нескольких параметров с использованием кортежа
async fn get_post_comment(
    Path((post_id, comment_id)): Path<(u64, u64)>
) -> String {
    format!("Получен комментарий {} для поста {}", comment_id, post_id)
}

// Извлечение параметров в структуру
#[derive(Deserialize)]
struct UserParams {
    user_id: u64,
    action: String,
}

async fn user_action(Path(params): Path<UserParams>) -> String {
    format!("Действие {} для пользователя {}", params.action, params.user_id)
}

// Регистрация маршрутов
let app = Router::new()
    .route("/users/:user_id", get(get_user))
    .route("/posts/:post_id/comments/:comment_id", get(get_post_comment))
    .route("/users/:user_id/:action", get(user_action));

2. Экстракторы Query-параметров (Query)

Экстрактор Query извлекает параметры из строки запроса (query string):

rust
use axum::{
    extract::Query,
    routing::get,
    Router,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct Pagination {
    page: Option<u32>,
    per_page: Option<u32>,
    sort: Option<String>,
    direction: Option<String>,
}

async fn list_users(Query(params): Query<Pagination>) -> String {
    let page = params.page.unwrap_or(1);
    let per_page = params.per_page.unwrap_or(10);
    let sort = params.sort.unwrap_or_else(|| "created_at".to_string());
    let direction = params.direction.unwrap_or_else(|| "desc".to_string());
    
    format!(
        "Список пользователей: страница {}, элементов {}, сортировка {} {}",
        page, per_page, sort, direction
    )
}

// Можно десериализовывать в HashMap для произвольных параметров
async fn dynamic_params(
    Query(params): Query<std::collections::HashMap<String, String>>
) -> String {
    format!("Получены параметры: {:?}", params)
}

let app = Router::new()
    .route("/users", get(list_users))
    .route("/search", get(dynamic_params));

3. Экстракторы тела запроса (Json, Form)

Экстракторы Json и Form извлекают и десериализуют тело запроса:

rust
use axum::{
    extract::{Json, Form},
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
    age: Option<u8>,
}

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
    email: String,
    age: Option<u8>,
}

// Обработка JSON-данных из тела запроса
async fn create_user_json(
    Json(payload): Json<CreateUser>
) -> Json<User> {
    // Создание пользователя...
    Json(User {
        id: 1,
        name: payload.name,
        email: payload.email,
        age: payload.age,
    })
}

// Обработка данных формы
async fn create_user_form(
    Form(payload): Form<CreateUser>
) -> Json<User> {
    // Создание пользователя...
    Json(User {
        id: 1,
        name: payload.name,
        email: payload.email,
        age: payload.age,
    })
}

let app = Router::new()
    .route("/users/json", post(create_user_json))
    .route("/users/form", post(create_user_form));

4. Экстрактор состояния (State)

Экстрактор State предоставляет доступ к общему состоянию приложения:

rust
use axum::{
    extract::State,
    routing::{get, post},
    Router,
};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};

// Тип состояния приложения
struct AppState {
    db_pool: PgPool,  // Подключение к базе данных
    config: AppConfig,  // Конфигурация приложения
    user_counter: Mutex<u64>,  // Счетчик пользователей
}

// Использование состояния в хендлере
async fn get_users(
    State(state): State<Arc<AppState>>
) -> String {
    let users = sqlx::query_as::<_, User>("SELECT * FROM users")
        .fetch_all(&state.db_pool)
        .await
        .unwrap();
    
    format!("Найдено {} пользователей", users.len())
}

// Изменение состояния
async fn increment_counter(
    State(state): State<Arc<AppState>>
) -> String {
    let mut counter = state.user_counter.lock().unwrap();
    *counter += 1;
    format!("Текущее значение счетчика: {}", *counter)
}

// Инициализация состояния и приложения
let state = Arc::new(AppState {
    db_pool: create_pg_pool().await,
    config: load_config(),
    user_counter: Mutex::new(0),
});

let app = Router::new()
    .route("/users", get(get_users))
    .route("/increment", post(increment_counter))
    .with_state(state);

5. Экстракторы HTTP-примитивов (Method, HeaderMap, Uri)

Axum также позволяет извлекать различные HTTP-примитивы:

rust
use axum::{
    routing::get,
    Router,
    http::{Method, Uri, HeaderMap, Version},
};

// Извлечение HTTP-метода
async fn method_handler(method: Method) -> String {
    format!("HTTP-метод: {}", method)
}

// Извлечение заголовков
async fn headers_handler(headers: HeaderMap) -> String {
    let user_agent = headers.get("user-agent")
        .map(|v| v.to_str().unwrap_or_default())
        .unwrap_or_default();
    
    format!("User-Agent: {}", user_agent)
}

// Извлечение URI
async fn uri_handler(uri: Uri) -> String {
    format!("URI: {}", uri)
}

// Извлечение версии HTTP
async fn version_handler(version: Version) -> String {
    format!("HTTP версия: {:?}", version)
}

let app = Router::new()
    .route("/method", get(method_handler))
    .route("/headers", get(headers_handler))
    .route("/uri", get(uri_handler))
    .route("/version", get(version_handler));

6. Экстрактор для запросов WebSocket

Axum предоставляет специальный экстрактор для обработки WebSocket-подключений:

rust
use axum::{
    extract::ws::{WebSocket, WebSocketUpgrade},
    routing::get,
    response::IntoResponse,
    Router,
};

async fn websocket_handler(
    ws: WebSocketUpgrade,
) -> impl IntoResponse {
    ws.on_upgrade(handle_socket)
}

async fn handle_socket(socket: WebSocket) {
    let (mut sender, mut receiver) = socket.split();
    
    // Пример эхо-сервера
    while let Some(msg) = receiver.next().await {
        if let Ok(msg) = msg {
            if sender.send(msg).await.is_err() {
                break;
            }
        } else {
            break;
        }
    }
}

let app = Router::new()
    .route("/ws", get(websocket_handler));

7. Экстрактор для загрузки файлов (Multipart)

Для работы с загрузкой файлов используется экстрактор Multipart:

rust
use axum::{
    extract::Multipart,
    routing::post,
    Router,
};
use tokio::{fs::File, io::AsyncWriteExt};

async fn upload_handler(mut multipart: Multipart) -> String {
    let mut uploaded_files = 0;
    
    while let Some(field) = multipart.next_field().await.unwrap() {
        let name = field.name().unwrap().to_string();
        let file_name = field.file_name().unwrap().to_string();
        let content_type = field.content_type().unwrap().to_string();
        let data = field.bytes().await.unwrap();
        
        // Сохранение файла
        let path = format!("./uploads/{}", file_name);
        let mut file = File::create(&path).await.unwrap();
        file.write_all(&data).await.unwrap();
        
        uploaded_files += 1;
    }
    
    format!("Загружено {} файлов", uploaded_files)
}

let app = Router::new()
    .route("/upload", post(upload_handler));

Комбинирование экстракторов

Одно из ключевых преимуществ системы экстракторов Axum — возможность комбинировать несколько экстракторов в одном хендлере:

rust
use axum::{
    extract::{Path, Query, Json, State},
    routing::{get, post},
    http::HeaderMap,
    Router,
};
use serde::{Deserialize, Serialize};

// Хендлер, использующий несколько экстракторов
async fn complex_handler(
    State(state): State<AppState>,
    Path(user_id): Path<u64>,
    Query(params): Query<FilterParams>,
    headers: HeaderMap,
    Json(payload): Json<UpdateUser>,
) -> impl IntoResponse {
    // Доступ к всем извлеченным данным
    // ...
}

let app = Router::new()
    .route("/users/:user_id", post(complex_handler));

При использовании нескольких экстракторов важно учитывать, что:

  1. Экстракторы применяются в порядке параметров функции
  2. Если один из экстракторов возвращает ошибку, обработка запроса прерывается
  3. Некоторые экстракторы потребляют тело запроса (Json, Form, Multipart), поэтому можно использовать только один такой экстрактор

Обработка ошибок экстракции

Когда экстрактор не может извлечь данные из запроса (например, из-за неверного формата JSON), он возвращает ошибку, которая преобразуется в HTTP-ответ с соответствующим статус-кодом:

rust
use axum::{
    extract::Json,
    routing::post,
    http::StatusCode,
    response::IntoResponse,
    Router,
};
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

// При неверном формате JSON Axum автоматически вернет ошибку 400 Bad Request
async fn create_user(Json(user): Json<CreateUser>) -> impl IntoResponse {
    // Обработка данных...
}

// Можно также обрабатывать ошибки вручную с помощью экстрактора rejection
async fn create_user_custom(
    payload: Result<Json<CreateUser>, axum::extract::rejection::JsonRejection>,
) -> impl IntoResponse {
    match payload {
        Ok(Json(user)) => {
            // Обработка валидных данных
            (StatusCode::CREATED, "Пользователь создан")
        },
        Err(err) => {
            // Кастомная обработка ошибки
            let error_message = format!("Ошибка JSON: {}", err);
            tracing::error!(error_message);
            
            let payload = json!({
                "error": "invalid_json",
                "message": error_message,
            });
            
            (StatusCode::BAD_REQUEST, axum::Json(payload))
        }
    }
}

let app = Router::new()
    .route("/users", post(create_user))
    .route("/users/custom", post(create_user_custom));

Опциональные экстракторы

Иногда требуется, чтобы экстрактор был опциональным. Axum поддерживает обертку экстракторов в Option:

rust
use axum::{
    extract::{Query, Json},
    routing::{get, post},
    Router,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Filters {
    sort: Option<String>,
    limit: Option<u32>,
}

// Опциональные query-параметры
async fn list_items(
    query: Option<Query<Filters>>,
) -> String {
    let filters = query.unwrap_or_else(|| Query(Filters {
        sort: None,
        limit: None,
    }));
    
    let sort = filters.0.sort.unwrap_or_else(|| "id".to_string());
    let limit = filters.0.limit.unwrap_or(10);
    
    format!("Список элементов: сортировка {}, лимит {}", sort, limit)
}

// Опциональное тело запроса
async fn optional_body(
    json: Option<Json<CreateUser>>,
) -> String {
    match json {
        Some(Json(user)) => format!("Получены данные: {}", user.name),
        None => "Тело запроса отсутствует".to_string(),
    }
}

let app = Router::new()
    .route("/items", get(list_items))
    .route("/optional", post(optional_body));

Создание собственных экстракторов

Одно из мощных свойств Axum — возможность создавать собственные экстракторы для нестандартных задач. Для этого нужно реализовать трейт FromRequest:

rust
use axum::{
    async_trait,
    extract::{FromRequest, RequestParts},
    http::{StatusCode, header},
    routing::get,
    Router,
};

// Экстрактор для извлечения API-ключа из заголовков
struct ApiKey(String);

#[async_trait]
impl<B> FromRequest<B> for ApiKey
where
    B: Send,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получаем заголовки запроса
        let headers = req.headers();
        
        // Проверяем наличие и валидность API-ключа
        let api_key = headers
            .get(header::AUTHORIZATION)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| {
                if value.starts_with("ApiKey ") {
                    Some(value[7..].to_string())
                } else {
                    None
                }
            });
            
        match api_key {
            Some(key) if !key.is_empty() => Ok(ApiKey(key)),
            _ => Err((StatusCode::UNAUTHORIZED, "Отсутствует или неверный API-ключ")),
        }
    }
}

// Использование кастомного экстрактора в хендлере
async fn protected_handler(
    ApiKey(key): ApiKey,
) -> String {
    format!("Доступ разрешен с ключом: {}", key)
}

let app = Router::new()
    .route("/protected", get(protected_handler));

Вот еще несколько примеров кастомных экстракторов:

Экстрактор для проверки авторизации

rust
struct AuthUser {
    user_id: u64,
    role: String,
}

#[async_trait]
impl<B> FromRequest<B> for AuthUser
where
    B: Send,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получаем заголовки
        let headers = req.headers();
        
        // Получаем токен из заголовка Authorization
        let token = headers
            .get(header::AUTHORIZATION)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| {
                if value.starts_with("Bearer ") {
                    Some(value[7..].to_string())
                } else {
                    None
                }
            })
            .ok_or((StatusCode::UNAUTHORIZED, "Отсутствует токен"))?;
        
        // Проверяем токен и получаем информацию о пользователе
        // (в реальном приложении здесь была бы проверка через JWT, базу данных и т.д.)
        if token == "valid_token" {
            Ok(AuthUser {
                user_id: 1,
                role: "admin".to_string(),
            })
        } else {
            Err((StatusCode::UNAUTHORIZED, "Недействительный токен"))
        }
    }
}

Экстрактор для валидации данных

rust
use validator::Validate;

struct ValidatedJson<T>(T);

#[async_trait]
impl<B, T> FromRequest<B> for ValidatedJson<T>
where
    B: Send,
    T: DeserializeOwned + Validate + Send,
    Json<T>: FromRequest<B, Rejection = JsonRejection>,
{
    type Rejection = (StatusCode, String);

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Сначала используем стандартный Json экстрактор
        let Json(data) = Json::<T>::from_request(req)
            .await
            .map_err(|err| (StatusCode::BAD_REQUEST, format!("Ошибка JSON: {}", err)))?;
        
        // Затем валидируем данные
        data.validate()
            .map_err(|err| {
                let error_message = err
                    .field_errors()
                    .iter()
                    .map(|(field, errors)| {
                        format!(
                            "Поле '{}': {}",
                            field,
                            errors[0].message.clone().unwrap_or_default()
                        )
                    })
                    .collect::<Vec<_>>()
                    .join(", ");
                
                (StatusCode::BAD_REQUEST, error_message)
            })?;
        
        Ok(ValidatedJson(data))
    }
}

Типобезопасные пути с экстракторами

Экстракторы особенно полезны для создания типобезопасных API с проверкой параметров на уровне компиляции:

rust
use axum::{
    extract::Path,
    routing::get,
    Router,
};
use uuid::Uuid;
use chrono::{DateTime, Utc};

// Типобезопасный UUID вместо строки
async fn get_user_by_uuid(
    Path(user_id): Path<Uuid>,
) -> String {
    format!("Получен пользователь с UUID: {}", user_id)
}

// Типобезопасная дата/время
async fn get_logs_by_date(
    Path(date): Path<DateTime<Utc>>,
) -> String {
    format!("Логи за: {}", date.format("%Y-%m-%d %H:%M:%S"))
}

let app = Router::new()
    .route("/users/:user_id", get(get_user_by_uuid))
    .route("/logs/:date", get(get_logs_by_date));

Лучшие практики использования экстракторов

1. Порядок экстракторов имеет значение

Порядок параметров в функции хендлера определяет порядок применения экстракторов. Рекомендуется использовать следующий порядок для оптимальной производительности:

  1. State — доступ к состоянию приложения
  2. Экстракторы, которые не потребляют тело запроса (Path, Query, HeaderMap и т.д.)
  3. Экстракторы, потребляющие тело запроса (Json, Form, Multipart)
rust
async fn recommended_order(
    State(state): State<AppState>,  // Сначала состояние
    Path(id): Path<u64>,            // Затем параметры пути
    Query(params): Query<Params>,   // Затем query-параметры
    headers: HeaderMap,             // Затем заголовки
    Json(payload): Json<Data>,      // В конце тело запроса
) -> impl IntoResponse {
    // ...
}

2. Используйте кастомные экстракторы для повторяющейся логики

Если у вас есть логика, которая повторяется в нескольких хендлерах (например, проверка авторизации, валидация данных), инкапсулируйте её в кастомный экстрактор.

3. Используйте обработку ошибок для улучшения UX

Предоставляйте понятные сообщения об ошибках при неудачной экстракции данных.

rust
async fn create_user_with_feedback(
    result: Result<Json<CreateUser>, JsonRejection>,
) -> impl IntoResponse {
    match result {
        Ok(Json(user)) => {
            // Обработка данных...
            (StatusCode::CREATED, "Пользователь создан")
        },
        Err(JsonRejection::JsonDataError(err)) => {
            (
                StatusCode::BAD_REQUEST,
                format!("Ошибка в структуре JSON: {}", err)
            )
        },
        Err(JsonRejection::JsonSyntaxError(err)) => {
            (
                StatusCode::BAD_REQUEST,
                format!("Синтаксическая ошибка в JSON: {}", err)
            )
        },
        Err(err) => {
            (
                StatusCode::BAD_REQUEST,
                format!("Ошибка при обработке JSON: {}", err)
            )
        },
    }
}

4. Используйте типобезопасные экстракторы

Предпочитайте строго типизированные экстракторы для раннего обнаружения ошибок:

rust
// Вместо этого (строковый ID)
async fn get_user_string(Path(id): Path<String>) -> impl IntoResponse {
    // Преобразование строки в число и обработка ошибок
    let user_id = id.parse::<u64>().map_err(|_| StatusCode::BAD_REQUEST)?;
    // ...
}

// Используйте это (типизированный ID)
async fn get_user_typed(Path(id): Path<u64>) -> impl IntoResponse {
    // ID уже является числом
    // ...
}

Заключение

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

В следующих разделах мы рассмотрим другие аспекты работы с Axum, такие как обработка JSON-данных, работа с формами, валидация входных данных и многое другое.