Skip to content

Работа с Result в Axum

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

Содержание

Основы Result в Axum

В Axum обработчики маршрутов могут возвращать Result<T, E>, где:

  • T - успешный результат, реализующий IntoResponse
  • E - ошибка, реализующая IntoResponse
rust
use axum::{
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct User {
    id: String,
    name: String,
}

// Простой обработчик с Result
async fn get_user(id: String) -> Result<Json<User>, StatusCode> {
    if id == "123" {
        Ok(Json(User {
            id: "123".to_string(),
            name: "Иван".to_string(),
        }))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

Для более сложных ошибок можно использовать кортежи:

rust
// Обработчик с более сложным типом ошибки
async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Result<Json<User>, (StatusCode, String)> {
    if payload.name.is_empty() {
        return Err((StatusCode::BAD_REQUEST, "Имя не может быть пустым".to_string()));
    }
    
    if payload.email.is_empty() {
        return Err((StatusCode::BAD_REQUEST, "Email не может быть пустым".to_string()));
    }
    
    // Создание пользователя...
    Ok(Json(User {
        id: "456".to_string(),
        name: payload.name,
    }))
}

Обработка ошибок с помощью операторов ? и map_err

Оператор ? позволяет рано возвращать ошибки, делая код более компактным и читаемым:

rust
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use sqlx::PgPool;

// Использование ? в асинхронном обработчике
async fn get_user_from_db(
    Path(user_id): Path<i64>,
    State(db): State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
    // Получение пользователя из БД с использованием ?
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(user_id)
        .fetch_one(&db)
        .await
        .map_err(|err| {
            match err {
                sqlx::Error::RowNotFound => StatusCode::NOT_FOUND,
                _ => {
                    eprintln!("Ошибка базы данных: {:?}", err);
                    StatusCode::INTERNAL_SERVER_ERROR
                }
            }
        })?;
    
    Ok(Json(user))
}

Оператор map_err позволяет преобразовывать ошибки одного типа в другой:

rust
// Использование map_err для преобразования ошибок
async fn create_post(
    State(db): State<PgPool>,
    Json(payload): Json<CreatePostRequest>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    // Валидация
    if payload.title.is_empty() {
        return Err((StatusCode::BAD_REQUEST, "Заголовок не может быть пустым".to_string()));
    }
    
    // Сохранение в базу данных с преобразованием ошибок
    let post_id = sqlx::query_scalar::<_, i64>("INSERT INTO posts (title, content) VALUES ($1, $2) RETURNING id")
        .bind(&payload.title)
        .bind(&payload.content)
        .fetch_one(&db)
        .await
        .map_err(|err| {
            eprintln!("Ошибка базы данных: {:?}", err);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Не удалось создать запись".to_string()
            )
        })?;
    
    Ok((StatusCode::CREATED, Json(json!({ "id": post_id }))))
}

Результаты с собственными типами ошибок

Для повышения поддерживаемости кода рекомендуется создавать собственные типы ошибок:

rust
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

// Определение собственного типа ошибок
#[derive(Debug, Error)]
enum ApiError {
    #[error("Ошибка базы данных: {0}")]
    Database(#[from] sqlx::Error),
    
    #[error("Ошибка валидации: {0}")]
    Validation(String),
    
    #[error("Не найдено: {0}")]
    NotFound(String),
    
    #[error("Внутренняя ошибка: {0}")]
    Internal(String),
}

// Реализация IntoResponse для типа ошибок
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            ApiError::Database(err) => {
                eprintln!("Ошибка базы данных: {:?}", err);
                (StatusCode::INTERNAL_SERVER_ERROR, "Ошибка базы данных".to_string())
            },
            ApiError::Validation(message) => {
                (StatusCode::BAD_REQUEST, message.clone())
            },
            ApiError::NotFound(resource) => {
                (StatusCode::NOT_FOUND, format!("Не найдено: {}", resource))
            },
            ApiError::Internal(message) => {
                eprintln!("Внутренняя ошибка: {}", message);
                (StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера".to_string())
            },
        };
        
        (status, Json(json!({ "error": message }))).into_response()
    }
}

// Определение типизированного Result для использования в приложении
type Result<T> = std::result::Result<T, ApiError>;

// Использование типизированного Result в обработчике
async fn get_user(
    Path(user_id): Path<i64>,
    State(db): State<PgPool>,
) -> Result<Json<User>> {
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(user_id)
        .fetch_optional(&db)
        .await?;  // Автоматическое преобразование sqlx::Error в ApiError
    
    match user {
        Some(user) => Ok(Json(user)),
        None => Err(ApiError::NotFound(format!("Пользователь с ID {}", user_id))),
    }
}

// Бизнес-логика, возвращающая Result
async fn validate_and_create_user(
    db: &PgPool,
    payload: CreateUserRequest,
) -> Result<User> {
    // Валидация
    if payload.name.is_empty() {
        return Err(ApiError::Validation("Имя не может быть пустым".to_string()));
    }
    
    if payload.email.is_empty() {
        return Err(ApiError::Validation("Email не может быть пустым".to_string()));
    }
    
    // Проверка на уникальность email
    let existing_user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
        .bind(&payload.email)
        .fetch_optional(db)
        .await?;
    
    if existing_user.is_some() {
        return Err(ApiError::Validation(format!("Пользователь с email {} уже существует", payload.email)));
    }
    
    // Создание пользователя
    let user = sqlx::query_as::<_, User>(
        "INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING *",
    )
        .bind(&payload.name)
        .bind(&payload.email)
        .bind(&hash_password(&payload.password)?)
        .fetch_one(db)
        .await?;
    
    Ok(user)
}

// Обработчик, использующий бизнес-функцию
async fn create_user(
    State(db): State<PgPool>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>> {
    let user = validate_and_create_user(&db, payload).await?;
    Ok(Json(user))
}

Комбинирование и композиция Result

Rust предоставляет множество методов для комбинирования и преобразования Result:

rust
use std::result::Result as StdResult;

// Комбинирование нескольких операций с Result
async fn process_transaction(
    db: &PgPool,
    from_account_id: i64,
    to_account_id: i64,
    amount: i64,
) -> Result<()> {
    // Проверка существования счетов
    let from_account = get_account(db, from_account_id).await?;
    let to_account = get_account(db, to_account_id).await?;
    
    // Проверка баланса
    if from_account.balance < amount {
        return Err(ApiError::Validation("Недостаточно средств".to_string()));
    }
    
    // Начало транзакции
    let mut tx = db.begin().await?;
    
    // Уменьшение баланса отправителя
    sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2")
        .bind(amount)
        .bind(from_account_id)
        .execute(&mut tx)
        .await?;
    
    // Увеличение баланса получателя
    sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2")
        .bind(amount)
        .bind(to_account_id)
        .execute(&mut tx)
        .await?;
    
    // Запись в журнал транзакций
    sqlx::query(
        "INSERT INTO transactions (from_account_id, to_account_id, amount) VALUES ($1, $2, $3)",
    )
        .bind(from_account_id)
        .bind(to_account_id)
        .bind(amount)
        .execute(&mut tx)
        .await?;
    
    // Фиксация транзакции
    tx.commit().await?;
    
    Ok(())
}

// Обработчик, использующий функцию с несколькими Result
async fn transfer_money(
    State(db): State<PgPool>,
    Json(payload): Json<TransferRequest>,
) -> Result<impl IntoResponse> {
    process_transaction(&db, payload.from_account_id, payload.to_account_id, payload.amount).await?;
    
    Ok(StatusCode::OK)
}

// Комбинирование Result с Option
async fn get_user_by_email(
    db: &PgPool,
    email: &str,
) -> Result<User> {
    sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
        .bind(email)
        .fetch_optional(db)
        .await?
        .ok_or_else(|| ApiError::NotFound(format!("Пользователь с email {}", email)))
}

Работа с асинхронными Result

В асинхронном контексте важно правильно работать с цепочками Result:

rust
// Цепочка асинхронных операций с Result
async fn process_user_registration(
    db: &PgPool,
    payload: RegistrationRequest,
) -> Result<User> {
    // Валидация
    validate_registration_data(&payload).await?;
    
    // Проверка уникальности email
    let existing_user = get_user_by_email_opt(db, &payload.email).await?;
    
    if existing_user.is_some() {
        return Err(ApiError::Validation(format!("Email {} уже используется", payload.email)));
    }
    
    // Хеширование пароля
    let password_hash = hash_password(&payload.password).await?;
    
    // Создание пользователя в транзакции
    let mut tx = db.begin().await?;
    
    let user = sqlx::query_as::<_, User>(
        "INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING *",
    )
        .bind(&payload.name)
        .bind(&payload.email)
        .bind(&password_hash)
        .fetch_one(&mut tx)
        .await?;
    
    // Создание профиля пользователя
    sqlx::query(
        "INSERT INTO profiles (user_id, display_name) VALUES ($1, $2)",
    )
        .bind(user.id)
        .bind(&payload.name)
        .execute(&mut tx)
        .await?;
    
    // Отправка приветственного email (не блокируем транзакцию)
    let email_task = tokio::spawn(send_welcome_email(user.email.clone()));
    
    // Фиксация транзакции
    tx.commit().await?;
    
    // Проверка результата отправки email
    if let Err(err) = email_task.await {
        eprintln!("Ошибка при отправке welcome email: {:?}", err);
        // Не возвращаем ошибку, так как пользователь уже создан
    }
    
    Ok(user)
}

Рекомендации и лучшие практики

  1. Использование типизированных Result

    rust
    // Определение типизированного Result для всего приложения
    type Result<T> = std::result::Result<T, AppError>;
    
    // Обработчик, использующий типизированный Result
    async fn get_user(Path(id): Path<String>) -> Result<Json<User>> {
        let user = find_user(&id).await?;
        Ok(Json(user))
    }
  2. Раннее возвращение ошибок

    rust
    fn validate_input(input: &UserInput) -> Result<()> {
        if input.name.is_empty() {
            return Err(AppError::Validation("Имя не может быть пустым".to_string()));
        }
        
        if input.age < 18 {
            return Err(AppError::Validation("Возраст должен быть не менее 18 лет".to_string()));
        }
        
        Ok(())
    }
  3. Использование контекста для ошибок

    rust
    use anyhow::Context;
    
    async fn get_data_with_context() -> anyhow::Result<Data> {
        let config = load_config()
            .context("Не удалось загрузить конфигурацию")?;
            
        let connection = establish_connection(&config)
            .context("Не удалось установить соединение с базой данных")?;
            
        let data = fetch_data(&connection)
            .context("Ошибка при получении данных")?;
            
        Ok(data)
    }
  4. Разделение бизнес-логики и обработки ошибок

    rust
    // Бизнес-функция возвращает Result
    async fn create_post(db: &PgPool, post: &CreatePostRequest) -> Result<Post> {
        // Бизнес-логика с Result
        // ...
    }
    
    // Слой API преобразует бизнес-результаты в HTTP-ответы
    async fn handle_create_post(
        State(db): State<PgPool>,
        Json(post): Json<CreatePostRequest>,
    ) -> impl IntoResponse {
        match create_post(&db, &post).await {
            Ok(post) => (StatusCode::CREATED, Json(post)),
            Err(err) => {
                // Преобразование ошибок бизнес-логики в HTTP-ответы
                match err {
                    ApiError::Validation(msg) => (
                        StatusCode::BAD_REQUEST,
                        Json(json!({ "error": msg })),
                    ),
                    ApiError::Database(err) => {
                        eprintln!("Database error: {:?}", err);
                        (
                            StatusCode::INTERNAL_SERVER_ERROR,
                            Json(json!({ "error": "Database error" })),
                        )
                    },
                    // ... другие типы ошибок
                }
            }
        }
    }
  5. Использование Result в цепочках методов

    rust
    // Цепочки методов с Result
    fn process_input(input: &str) -> Result<i32> {
        input
            .parse::<i32>()
            .map_err(|_| ApiError::Validation("Некорректное число".to_string()))
            .and_then(|num| {
                if num > 0 {
                    Ok(num)
                } else {
                    Err(ApiError::Validation("Число должно быть положительным".to_string()))
                }
            })
    }
  6. Обработка нескольких результатов

    rust
    async fn get_multiple_users(
        ids: Vec<i64>,
        db: &PgPool,
    ) -> Result<Vec<User>> {
        let mut users = Vec::new();
        
        for id in ids {
            match get_user_by_id(id, db).await {
                Ok(user) => users.push(user),
                Err(ApiError::NotFound(_)) => {
                    // Пропускаем отсутствующих пользователей
                    continue;
                },
                Err(err) => return Err(err),
            }
        }
        
        Ok(users)
    }

Эффективное использование Result в Axum позволяет писать более надежный и поддерживаемый код. Комбинируя Result с типажом IntoResponse, вы можете создавать элегантные и безопасные веб-API.