Пошук уроків, статей та іншого контенту
Реалізуєте асинхронні події без очікування відповіді та побудуєте слабко зв’язані сервіси.
Подія описує факт, який уже відбувся:
замовлення створено;
користувач зареєструвався;
платіж успішно виконано;
файл завантажено.
Сервіс, який створює подію, не повинен знати, хто саме на неї відреагує. Він лише повідомляє про факт:
OrderService → order.created
↓
EmailListener
AuditListener
StatisticsListenerТакий підхід зменшує зв’язаність між сервісами. OrderService не потрібно напряму імпортувати сервіси надсилання email, аудиту чи статистики.
У NestJS для роботи з подіями використовується пакет @nestjs/event-emitter, який побудований на EventEmitter2.
Встановіть пакет:
npm install @nestjs/event-emitter eventemitter2Підключіть модуль подій у кореневому модулі застосунку:
// app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [
EventEmitterModule.forRoot(),
OrdersModule,
],
})
export class AppModule {}EventEmitterModule.forRoot() реєструє EventEmitter2 у контейнері залежностей NestJS.
Після цього EventEmitter2 можна інжектити у будь-який сервіс:
import { EventEmitter2 } from '@nestjs/event-emitter';
constructor(private readonly eventEmitter: EventEmitter2) {}Для кожної події зручно створити окремий клас або інтерфейс, який описує її дані.
Наприклад, подія створення замовлення:
// orders/events/order-created.event.ts
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly total: number,
) {}
}Клас події має містити тільки дані, потрібні слухачам. Не варто передавати в події цілі об’єкти сервісів або великі об’єкти доменної моделі без потреби.
Подія публікується через метод emit:
this.eventEmitter.emit(
'order.created',
new OrderCreatedEvent(order.id, order.userId, order.total),
);Перший аргумент — ім’я події, другий — її дані.
Імена подій зазвичай пишуть у форматі:
resource.actionНаприклад:
order.created
user.registered
payment.completedНазва події повинна описувати факт, а не команду. Порівняйте:
order.created // факт
send.order.email // командаПодія повідомляє, що замовлення створено. Вона не повинна вимагати від конкретного слухача надіслати email.
Слухач — це метод, позначений декоратором @OnEvent.
// orders/listeners/order-created.listener.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OrderCreatedEvent } from '../events/order-created.event';
@Injectable()
export class OrderCreatedListener {
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.sendConfirmationEmail(event.userId, event.orderId);
}
private async sendConfirmationEmail(
userId: string,
orderId: string,
): Promise<void> {
// Імітація асинхронної операції надсилання листа
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(
`Confirmation email sent to user ${userId} for order ${orderId}`,
);
}
}Клас слухача потрібно зареєструвати як провайдер у модулі:
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrderCreatedListener } from './listeners/order-created.listener';
import { OrdersService } from './orders.service';
@Module({
providers: [
OrdersService,
OrderCreatedListener,
],
exports: [OrdersService],
})
export class OrdersModule {}Якщо клас слухача не додати до providers, NestJS не створить його екземпляр, а обробник події не буде зареєстрований.
Розглянемо сервіс замовлень:
// orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { randomUUID } from 'node:crypto';
import { OrderCreatedEvent } from './events/order-created.event';
@Injectable()
export class OrdersService {
constructor(
private readonly eventEmitter: EventEmitter2,
) {}
async createOrder(userId: string, total: number) {
const order = {
id: randomUUID(),
userId,
total,
};
// Подія запускає додаткові обробники,
// але сервіс не очікує завершення їхньої роботи.
this.eventEmitter.emit(
'order.created',
new OrderCreatedEvent(order.id, order.userId, order.total),
);
return order;
}
}Під час виклику emit слухач запускається, але OrdersService не очікує завершення його Promise.
Це означає:
замовлення створюється;
подія публікується;
обробник починає надсилати email;
createOrder одразу повертає результат створення замовлення.
Оскільки emit не повертає результат асинхронного слухача, не потрібно робити так:
await this.eventEmitter.emit('order.created', event);emit не є асинхронним методом, який очікує завершення обробників. Навіть якщо слухач оголошений як async, його Promise не очікується викликачем.
Для HTTP API це корисно, коли додаткова операція не повинна затримувати відповідь:
// orders/orders.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { OrdersService } from './orders.service';
@Controller('orders')
export class OrdersController {
constructor(
private readonly ordersService: OrdersService,
) {}
@Post()
createOrder(
@Body() body: { userId: string; total: number },
) {
return this.ordersService.createOrder(body.userId, body.total);
}
}Відповідь API може бути надіслана одразу після створення замовлення, поки слухач продовжує виконувати свою роботу.
На одну подію можна зареєструвати кілька слухачів. Наприклад, окремо надсилати email і записувати аудит:
// orders/listeners/order-audit.listener.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OrderCreatedEvent } from '../events/order-created.event';
@Injectable()
export class OrderAuditListener {
@OnEvent('order.created')
handleOrderCreated(event: OrderCreatedEvent): void {
console.log(
`Audit: order ${event.orderId} was created by user ${event.userId}`,
);
}
}Додайте цей клас до providers модуля:
@Module({
providers: [
OrdersService,
OrderCreatedListener,
OrderAuditListener,
],
exports: [OrdersService],
})
export class OrdersModule {}Тепер OrdersService не знає ні про email-слухача, ні про audit-слухача. Щоб додати нову реакцію на подію, достатньо створити новий слухач і зареєструвати його в модулі.
Коли слухач виконує асинхронну операцію, обробляйте помилки всередині самого слухача:
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OrderCreatedEvent } from '../events/order-created.event';
@Injectable()
export class OrderCreatedListener {
private readonly logger = new Logger(OrderCreatedListener.name);
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
try {
await this.sendConfirmationEmail(event.userId, event.orderId);
} catch (error) {
this.logger.error(
`Failed to send email for order ${event.orderId}`,
error instanceof Error ? error.stack : undefined,
);
}
}
private async sendConfirmationEmail(
userId: string,
orderId: string,
): Promise<void> {
// Тут могла б бути інтеграція із сервісом email
console.log(`Sending email to user ${userId} for order ${orderId}`);
}
}Сервіс, який опублікував подію, не отримує результат роботи слухача. Тому слухач сам відповідає за:
логування помилок;
повторні спроби, якщо вони потрібні;
повідомлення про невдалу операцію;
коректне завершення обробки.
Внутрішні події застосунку підходять для операцій, втрата яких не руйнує основний бізнес-процес. Якщо повідомлення має бути гарантовано доставлене навіть після перезапуску застосунку, простого in-memory event emitter недостатньо — для цього потрібен зовнішній брокер або черга повідомлень.
Без подій сервіс замовлень міг би напряму залежати від кількох інших сервісів:
constructor(
private readonly emailService: EmailService,
private readonly auditService: AuditService,
private readonly statisticsService: StatisticsService,
) {}У такому випадку зміна будь-якого з цих сервісів впливатиме на OrdersService.
З подіями залежність виглядає простіше:
constructor(
private readonly eventEmitter: EventEmitter2,
) {}OrdersService залежить лише від механізму публікації подій. Слухачі можуть змінюватися, додаватися або видалятися незалежно від нього.
Це особливо корисно, коли:
одна дія запускає кілька незалежних реакцій;
додаткові операції не повинні затримувати основну відповідь;
модулі мають залишатися незалежними;
кількість реакцій на подію може зростати.
Декоратор @OnEvent сам по собі не робить клас доступним для NestJS. Клас слухача потрібно додати до providers.
@Module({
providers: [OrderCreatedListener],
})
export class OrdersModule {}Ім’я під час публікації та під час підписки повинно збігатися:
this.eventEmitter.emit('order.created', event);@OnEvent('order.created')Помилка навіть в одному символі призведе до того, що слухач не буде викликаний.
Не покладайтеся на дані, які слухач повинен додатково отримувати з контексту виклику. Подія має містити все необхідне для своєї обробки:
new OrderCreatedEvent(order.id, order.userId, order.total)Якщо слухачу потрібен лише orderId, достатньо передати саме його. Якщо потрібні користувач і сума, вони мають бути частиною події.
emitemit не повертає результат асинхронних слухачів. Не використовуйте подію, якщо основному процесу потрібна відповідь від обробника.
У такому випадку краще викликати потрібний сервіс напряму. Події призначені для повідомлення про факт, а не для запитів і повернення результату.
Асинхронний слухач може завершитися помилкою вже після того, як основний сервіс повернув відповідь. Тому помилки потрібно обробляти в самому слухачі та додавати достатньо контексту до журналу.
Подія повідомляє про факт, який уже відбувся.
У NestJS події публікуються через EventEmitter2.
Слухачі оголошуються за допомогою @OnEvent.
emit запускає слухачі без очікування завершення їхніх асинхронних операцій.
Один тип події може мати кілька незалежних слухачів.
Події зменшують зв’язаність між сервісами та модулями.
Класи слухачів потрібно реєструвати в providers.
Помилки асинхронних слухачів слід обробляти всередині самих слухачів.
In-memory події не гарантують доставку після перезапуску застосунку.