-
-
Notifications
You must be signed in to change notification settings - Fork 0
Хуки
RAprogramm edited this page Jan 7, 2026
·
2 revisions
Выполнение пользовательской логики до и после операций с сущностями. Хуки позволяют реализовать валидацию, нормализацию, побочные эффекты и авторизацию.
#[derive(Entity)]
#[entity(table = "users", hooks)]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub email: String,
#[field(create, response)]
pub name: String,
#[field(skip)]
pub password_hash: String,
#[field(response)]
#[auto]
pub created_at: DateTime<Utc>,
}Атрибут hooks генерирует асинхронный трейт:
/// Сгенерировано entity-derive
#[async_trait]
pub trait UserHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
/// Вызывается перед созданием новой сущности.
/// Модифицируйте DTO или верните ошибку для отмены.
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
/// Вызывается после создания сущности.
async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
/// Вызывается перед обновлением сущности.
/// Модифицируйте DTO или верните ошибку для отмены.
async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;
/// Вызывается после обновления сущности.
async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;
/// Вызывается перед удалением сущности.
/// Верните ошибку для отмены.
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
/// Вызывается после удаления сущности.
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}use async_trait::async_trait;
struct UserService {
pool: PgPool,
cache: RedisPool,
email_sender: EmailService,
}
#[async_trait]
impl UserHooks for UserService {
type Error = AppError;
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
// Нормализация email
dto.email = dto.email.trim().to_lowercase();
// Валидация формата email
if !dto.email.contains('@') {
return Err(AppError::Validation("Неверный формат email".into()));
}
// Проверка на дубликат email
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)"
)
.bind(&dto.email)
.fetch_one(&self.pool)
.await?;
if exists {
return Err(AppError::Conflict("Email уже зарегистрирован".into()));
}
Ok(())
}
async fn after_create(&self, entity: &User) -> Result<(), Self::Error> {
// Отправка приветственного email
self.email_sender
.send_welcome(&entity.email, &entity.name)
.await?;
// Кэширование нового пользователя
self.cache.set(&format!("user:{}", entity.id), entity).await?;
Ok(())
}
async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error> {
// Нормализация email если предоставлен
if let Some(ref mut email) = dto.email {
*email = email.trim().to_lowercase();
// Проверка на дубликат (исключая текущего пользователя)
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND id != $2)"
)
.bind(&*email)
.bind(id)
.fetch_one(&self.pool)
.await?;
if exists {
return Err(AppError::Conflict("Email уже используется".into()));
}
}
Ok(())
}
async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
// Инвалидация кэша
self.cache.del(&format!("user:{}", entity.id)).await?;
Ok(())
}
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
// Проверка возможности удаления
let has_orders = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = $1 AND status = 'pending')"
)
.bind(id)
.fetch_one(&self.pool)
.await?;
if has_orders {
return Err(AppError::Forbidden("Нельзя удалить пользователя с активными заказами".into()));
}
Ok(())
}
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
// Инвалидация кэша
self.cache.del(&format!("user:{}", id)).await?;
// Очистка связанных данных
sqlx::query("DELETE FROM user_sessions WHERE user_id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}async fn before_create(&self, dto: &mut CreateProductRequest) -> Result<(), Self::Error> {
// Валидация цены
if dto.price_cents <= 0 {
return Err(AppError::Validation("Цена должна быть положительной".into()));
}
// Валидация формата SKU
if !dto.sku.chars().all(|c| c.is_alphanumeric() || c == '-') {
return Err(AppError::Validation("Неверный формат SKU".into()));
}
Ok(())
}async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
// Нормализация email
dto.email = dto.email.trim().to_lowercase();
// Нормализация имени
dto.name = dto.name.trim().to_string();
// Заглавная буква для каждого слова
dto.name = dto.name
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ");
Ok(())
}async fn before_update(&self, id: &Uuid, _dto: &mut UpdatePostRequest) -> Result<(), Self::Error> {
// Получение текущего пользователя из контекста
let current_user = self.current_user()?;
// Проверка владения
let post = sqlx::query_as::<_, Post>(
"SELECT * FROM posts WHERE id = $1"
)
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or(AppError::NotFound)?;
if post.author_id != current_user.id && !current_user.is_admin {
return Err(AppError::Forbidden("Нельзя редактировать чужие посты".into()));
}
Ok(())
}async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
// Обновление инвентаря
for item in &entity.items {
sqlx::query(
"UPDATE products SET stock = stock - $1 WHERE id = $2"
)
.bind(item.quantity)
.bind(item.product_id)
.execute(&self.pool)
.await?;
}
// Отправка уведомления
self.notifications.send_order_confirmation(entity).await?;
// Планирование задачи выполнения
self.job_queue.enqueue(FulfillOrderJob { order_id: entity.id }).await?;
Ok(())
}async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
sqlx::query(
"INSERT INTO audit_log (entity_type, entity_id, action, performed_by, performed_at)
VALUES ('user', $1, 'update', $2, NOW())"
)
.bind(entity.id)
.bind(self.current_user_id())
.execute(&self.pool)
.await?;
Ok(())
}При включённом soft_delete генерируются дополнительные хуки:
#[derive(Entity)]
#[entity(table = "documents", hooks, soft_delete)]
pub struct Document { /* ... */ }Генерируемые хуки:
#[async_trait]
pub trait DocumentHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Стандартные CRUD-хуки...
async fn before_create(&self, dto: &mut CreateDocumentRequest) -> Result<(), Self::Error>;
async fn after_create(&self, entity: &Document) -> Result<(), Self::Error>;
async fn before_update(&self, id: &Uuid, dto: &mut UpdateDocumentRequest) -> Result<(), Self::Error>;
async fn after_update(&self, entity: &Document) -> Result<(), Self::Error>;
async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>; // Мягкое удаление
async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
// Хуки мягкого удаления
async fn before_restore(&self, id: &Uuid) -> Result<(), Self::Error>;
async fn after_restore(&self, entity: &Document) -> Result<(), Self::Error>;
async fn before_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
async fn after_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}При одновременном включении commands и hooks генерируются хуки команд:
#[derive(Entity)]
#[entity(table = "orders", hooks, commands)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }Дополнительные хуки:
#[async_trait]
pub trait OrderHooks: Send + Sync {
type Error: std::error::Error + Send + Sync;
// Стандартные CRUD-хуки...
// Хуки команд
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}Использование:
async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error> {
match cmd {
OrderCommand::Place(place) => {
// Валидация возможности размещения заказа
if place.items.is_empty() {
return Err(AppError::Validation("Заказ должен содержать товары".into()));
}
}
OrderCommand::Cancel(cancel) => {
// Проверка возможности отмены
let order = self.find_order(cancel.id).await?;
if order.status == "shipped" {
return Err(AppError::Forbidden("Нельзя отменить отправленный заказ".into()));
}
}
}
Ok(())
}
async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error> {
match (cmd, result) {
(OrderCommand::Place(_), OrderCommandResult::Place(order)) => {
self.send_order_confirmation(order).await?;
}
(OrderCommand::Cancel(_), OrderCommandResult::Cancel) => {
// Логика возврата средств
}
}
Ok(())
}- Быстрые хуки — Длительные операции выносите в фоновые задачи
- Используйте транзакции — Оборачивайте хук + вызов репозитория в транзакцию
- Обработка ошибок — Возвращайте осмысленные типы ошибок
- Не дублируйте логику — Используйте хуки для сквозной функциональности
- Тестируйте независимо — Unit-тестируйте реализации хуков
#[derive(Debug)]
pub enum HookError {
Validation(String),
Authorization(String),
Conflict(String),
Database(sqlx::Error),
}
impl std::error::Error for HookError {}
impl std::fmt::Display for HookError { /* ... */ }
impl From<sqlx::Error> for HookError {
fn from(err: sqlx::Error) -> Self {
HookError::Database(err)
}
}
#[async_trait]
impl UserHooks for UserService {
type Error = HookError;
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
if dto.email.is_empty() {
return Err(HookError::Validation("Email обязателен".into()));
}
Ok(())
}
}- События — События жизненного цикла для журнала аудита
- Команды — Паттерн CQRS с хуками команд
- Лучшие-практики — Советы для продакшена
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
Getting Started
Features
Advanced
Начало работы
Возможности
Продвинутое
시작하기
기능
고급
Comenzando
Características
Avanzado
入门
功能
高级