Работа с Result в Axum
Тип Result
является основным механизмом обработки ошибок в Rust и активно используется в Axum. В этом разделе рассмотрим, как эффективно работать с Result
в контексте веб-приложений на Axum.
Содержание
- Основы Result в Axum
- Обработка ошибок с помощью операторов ? и map_err
- Результаты с собственными типами ошибок
- Комбинирование и композиция Result
- Работа с асинхронными Result
- Рекомендации и лучшие практики
Основы Result в Axum
В Axum обработчики маршрутов могут возвращать Result<T, E>
, где:
T
- успешный результат, реализующийIntoResponse
E
- ошибка, реализующаяIntoResponse
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)
}
}
Для более сложных ошибок можно использовать кортежи:
// Обработчик с более сложным типом ошибки
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
Оператор ?
позволяет рано возвращать ошибки, делая код более компактным и читаемым:
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
позволяет преобразовывать ошибки одного типа в другой:
// Использование 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 }))))
}
Результаты с собственными типами ошибок
Для повышения поддерживаемости кода рекомендуется создавать собственные типы ошибок:
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
:
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
:
// Цепочка асинхронных операций с 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)
}
Рекомендации и лучшие практики
Использование типизированных 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)) }
Раннее возвращение ошибок
rustfn 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(()) }
Использование контекста для ошибок
rustuse 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) }
Разделение бизнес-логики и обработки ошибок
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" })), ) }, // ... другие типы ошибок } } } }
Использование 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())) } }) }
Обработка нескольких результатов
rustasync 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.