Як працюють мікросервіси в NestJS?
NestJS мікросервіси - це незалежні сервіси, що спілкуються через транспортний рівень: TCP, Redis, gRPC тощо. Вони використовують ті ж самі декоратори і модулі, що й звичайні HTTP-додатки, але без Express чи Koa під капотом.
Теорія
TL;DR
- Аналогія: API-шлюз - як головний офіціант, що приймає замовлення (HTTP-запити) і передає їх спеціалізованим кухням (user-service, order-service), кожна з яких слухає свій транспорт.
- Головна різниця: HTTP-контролери використовують
@Get/@Post; мікросервісні контролери -@MessagePatternі слухають TCP, Redis або Kafka замість HTTP. - Два стилі комунікації: запит-відповідь (
@MessagePattern+client.send()) і "відправив і забув" (@EventPattern+client.emit()). - Правило вибору: два або більше сервіси мають масштабуватись незалежно або спілкуватись без HTTP? Мікросервіси. Одна команда, один деплой? Залишайся на HTTP-контролерах.
@nestjs/microservicesпідтримує сім транспортів: TCP, Redis, NATS, RabbitMQ, Kafka, gRPC та MQTT.
Швидкий приклад
TCP-сервіс для математики: сервер слухає порт 3001, клієнт надсилає масив чисел і отримує суму.
// math-service/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { MathModule } from './math.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(MathModule, {
transport: Transport.TCP,
options: { host: 'localhost', port: 3001 },
});
await app.listen(); // без HTTP-порту, тільки TCP
}
bootstrap();// math.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
@Controller()
export class MathController {
@MessagePattern({ cmd: 'add' }) // спрацьовує на client.send({ cmd: 'add' }, ...)
add(@Payload() data: number[]): number {
return data.reduce((a, b) => a + b, 0); // повертає 5 для [2, 3]
}
}Жодного @Get, жодного HTTP-сервера, жодного Express. Тільки патерн, що відповідає обробнику.
Головна відмінність від HTTP-контролерів
HTTP-контролери прив'язуються до URL-шляхів і HTTP-дієслів. Мікросервісні контролери - до патернів повідомлень і транспортів. Коли клієнт викликає client.send({ cmd: 'add' }, [2, 3]), NestJS серіалізує payload у JSON (або Protobuf у gRPC), відправляє через мережу, а відповідний @MessagePattern-обробник десеріалізує і виконує логіку. Відповідь повертається тим самим шляхом.
Це означає одне: можна змінити транспорт без зміни бізнес-логіки. Обробник, що сьогодні працює через TCP, завтра запрацює через RabbitMQ після зміни конфігурації в main.ts.
@MessagePattern проти @EventPattern
Два декоратори, що покривають майже всі потреби в комунікації:
| Декоратор | Метод клієнта | Отримує відповідь? | Типовий випадок |
|---|---|---|---|
@MessagePattern | client.send() | Так (Observable) | Отримати дані користувача, підрахувати суму |
@EventPattern | client.emit() | Ні | Подія "замовлення створено", сповіщення |
client.send() повертає RxJS Observable. Його чекають через .toPromise() або firstValueFrom(). client.emit() повертає void - відправив і забув.
// API-шлюз - обидва патерни
@Get('sum')
async sum() {
return this.mathClient
.send({ cmd: 'add' }, [1, 2, 3]) // чекає відповіді
.toPromise();
}
@Post('order')
async order(@Body() body: CreateOrderDto) {
this.orderClient.emit('order_created', body); // відправив і забув
return { status: 'queued' };
}Вибір транспорту
Вибір транспорту - це вибір компромісу між затримкою, пропускною здатністю і гарантіями доставки.
| Транспорт | Затримка | Пропускна здатність | Доставка | Для чого |
|---|---|---|---|---|
| TCP | Низька | Середня | At-most-once | Внутрішній RPC між сервісами |
| Redis | Низька | Висока | At-most-once | Pub/sub, події поруч із кешуванням |
| NATS | Дуже низька | Дуже висока | At-least-once | Real-time fan-out, великий потік |
| RabbitMQ | Середня | Висока | Exactly-once (з ACK) | Черги замовлень, все що потребує підтвердження |
| Kafka | Середня | Екстремальна | At-least-once | Event log, повторне відтворення, >1M повідомлень/сек |
| gRPC | Дуже низька | Висока | At-most-once | Типізовані API, Protobuf, поліглот-системи |
Починай з TCP, якщо просто розбиваєш моноліт і все залишається в одній мережі. Kafka або RabbitMQ - коли потрібна надійність і можливість повторного відтворення подій.
Як це працює зсередини
NestJS створює екземпляр Server для кожного транспорту. Для TCP це net.Server у Node.js, прив'язаний до вказаного порту. Коли надходить повідомлення, NestJS десеріалізує буфер (за замовчуванням JSON), знаходить відповідний обробник через внутрішній MessageBroker і викликає його. Відповідь серіалізується назад через те саме з'єднання.
Event loop Node.js обробляє все асинхронно, жодних потоків. Для потокових транспортів на кшталт Kafka і NATS обробники можуть повертати RxJS Observables, які NestJS передає клієнту як стрім.
gRPC відрізняється одним: він використовує .proto-файли для генерації коду. Реєстрація патернів і пошук обробників працюють так само, але payload кодується через Protobuf замість JSON. За даними бенчмарків NestJS, Protobuf дає приблизно 7x вищу пропускну здатність порівняно з JSON/TCP для бінарно-важких даних.
Гібридний додаток
Іноді потрібно HTTP і транспортний рівень в одному процесі. Наприклад, API-шлюз, що відкриває REST назовні і слухає Redis для внутрішніх подій.
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule); // HTTP-сервер
app.connectMicroservice({
transport: Transport.REDIS,
options: { host: 'localhost', port: 6379 },
});
await app.startAllMicroservices(); // запустити Redis-слухач
await app.listen(3000); // запустити HTTP-сервер
}Обидва працюють в одному NestJS-екземплярі. Контролери з @Get обробляють HTTP. Контролери з @MessagePattern - Redis-повідомлення. Часта помилка тут: передавати опції транспорту напряму у NestFactory.create замість виклику connectMicroservice. Опція TCP у такому разі мовчки ігнорується.
Типові помилки
Використання @Get у мікросервісі:
@Get('add') // ігнорується в TCP-режимі - немає Express, немає маршрутів
add() { ... }Виправлення: @MessagePattern({ cmd: 'add' }).
Відсутність ACK у RabbitMQ:
@MessagePattern('process')
process(@Payload() data: any) {
doWork(data);
// немає ack - при краші повідомлення знову доставляється, нескінченний цикл
}Це найпоширеніша RabbitMQ-помилка в NestJS-проектах. Виправлення: додай @Ctx() ctx: RmqContext і виклич ctx.getChannelRef().ack(ctx.getMessage()) після завершення роботи. І встанови noAck: false в bootstrap-конфігурації.
@MessagePattern('process')
process(@Payload() data: any, @Ctx() ctx: RmqContext) {
doWork(data);
ctx.getChannelRef().ack(ctx.getMessage()); // RabbitMQ: повідомлення оброблено, не повторювати
}Гібридний додаток без connectMicroservice:
// Неправильно: створює тільки HTTP-сервер, TCP-опція ігнорується
const app = await NestFactory.create(AppModule, { transport: Transport.TCP });Потрібен app.connectMicroservice({...}) після NestFactory.create.
Передача Buffer через JSON-транспорт:
client.send({ cmd: 'upload' }, Buffer.from(imageData)); // пошкоджує даніJSON.stringify не вміє серіалізувати Buffer. Використовуй власний serializer або переходь на gRPC з Protobuf для бінарних payload.
Стан, що поділяється між інстансами: Два інстанси одного мікросервісу з локальним кешем розсинхронізуються під навантаженням. Тримай обробники без стану. Зберігай стан у Redis, базі даних або сховищі повідомлень.
Де зустрічається в реальних проектах
- Внутрішній RPC: user-service і order-service по TCP всередині Kubernetes-кластера без публічного доступу.
- E-commerce: order-service публікує
order_createdу RabbitMQ; notification-service і inventory-service незалежно споживають цю подію. - Event log: додатки для виклику таксі логують кожне оновлення локації водія як Kafka-подію. Сервіси відтворюють лог для відновлення стану після рестарту.
- gRPC-шлюз: API-шлюз проксіює типізовані запити від мобільних клієнтів до внутрішніх сервісів через Protobuf.
- Strangler pattern: запускаєш HTTP і TCP на одному додатку через
connectMicroservice, потім поступово виводиш сервіси один за одним по мірі того, як кожен підтверджує стабільність у продакшені.
Питання для поглиблення
Q: Яка різниця між @MessagePattern і @EventPattern?
A: @MessagePattern - це запит-відповідь, клієнт чекає на reply. @EventPattern - "відправив і забув", клієнт не чекає нічого у відповідь. Використовуй @MessagePattern для запитів і команд, що потребують підтвердження; @EventPattern - для сповіщень.
Q: Як NestJS обробляє збої транспорту?
A: Налаштовуєш retryAttempts і retryDelay в опціях клієнта. NestJS застосовує RxJS retryWhen на Observable. Для надійної доставки все одно потрібен брокер з підтримкою ACKs - RabbitMQ або Kafka.
Q: Як одночасно запустити HTTP і мікросервісний транспорт?
A: NestFactory.create для HTTP, потім app.connectMicroservice({...}) для другого транспорту, потім app.startAllMicroservices() перед app.listen(). Обидва в одному процесі. NestJS направляє HTTP до @Get/@Post обробників, повідомлення - до @MessagePattern.
Q: TCP проти gRPC - коли різниця реально важлива?
A: Для внутрішніх JSON-payload при помірному навантаженні TCP простіший і достатній. gRPC потрібен, коли payload бінарно важкий, потрібне строге дотримання контракту через .proto, або коли сервіси написані різними мовами. Protobuf-кодування дає приблизно 7x вищу пропускну здатність порівняно з JSON для бінарних даних.
Q: Спроектуй відмовостійкий order-service з Kafka. Як обробляти події не в тому порядку і забезпечити exactly-once обробку?
A: Розбий Kafka-топіки по orderId так, щоб всі події одного замовлення потрапляли в один партишн по черзі. Використовуй consumer group ID для уникнення дублювання між інстансами. Для exactly-once - увімкни Kafka transactions і пиши ідемпотентні обробники: зберігай UUID вже обробленої події в Redis перед виконанням, пропускай дублікати при повторній доставці. Події не в тому порядку обробляй через версіонування стану замовлення - відхиляй події зі старішою версією, ніж та що зберігається.
Приклади
Базовий TCP-мікросервіс
Мінімальний сетап: один сервіс обробляє математику, один шлюз викликає його через HTTP.
// math-service/math.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
@Controller()
export class MathController {
@MessagePattern({ cmd: 'sum' })
sum(@Payload() numbers: number[]): number {
return numbers.reduce((acc, n) => acc + n, 0);
}
}// api-gateway/gateway.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
@Controller('math')
export class GatewayController {
constructor(@Inject('MATH_SERVICE') private client: ClientProxy) {}
@Get('sum')
async sum() {
const result = await firstValueFrom(
this.client.send({ cmd: 'sum' }, [1, 2, 3, 4, 5])
);
return { result }; // { result: 15 }
}
}Реєструй MATH_SERVICE через ClientsModule.register([{ name: 'MATH_SERVICE', transport: Transport.TCP, options: { host: 'localhost', port: 3001 } }]). Шлюз залишається HTTP, math-service - чистий TCP.
RabbitMQ з ручним ACK (e-commerce потік замовлень)
Order-service публікує, user-service споживає і підтверджує вручну, щоб уникнути нескінченної повторної доставки при краші.
// user-service/user.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload, Ctx, RmqContext } from '@nestjs/microservices';
@Controller()
export class UserController {
@MessagePattern('user_create')
async create(
@Payload() data: { email: string },
@Ctx() ctx: RmqContext,
) {
const channel = ctx.getChannelRef();
const originalMsg = ctx.getMessage();
try {
const user = await this.userService.create(data.email);
channel.ack(originalMsg); // готово, не повторювати
return { id: user.id, email: user.email };
} catch (err) {
channel.nack(originalMsg, false, true); // повернути в чергу при помилці
throw err;
}
}
}// user-service/main.ts - конфігурація bootstrap
{
transport: Transport.RMQ,
options: {
urls: ['amqp://localhost:5672'],
queue: 'user_queue',
noAck: false, // обов'язково для ручного ACK
},
}Без noAck: false і явного channel.ack кожен краш повторно доставлятиме повідомлення без кінця.
gRPC-стрімінг (senior-рівень)
gRPC дозволяє стрімити відповіді, а не відправляти лише одну. Для цього потрібен .proto-файл і пакет @grpc/grpc-js.
// math.proto
syntax = "proto3";
package math;
service MathService {
rpc StreamNumbers(NumbersRequest) returns (stream NumberResponse);
}
message NumbersRequest { repeated int32 numbers = 1; }
message NumberResponse { int32 result = 1; }// math.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { Observable, from } from 'rxjs';
import { map } from 'rxjs/operators';
@Controller()
export class MathController {
@GrpcMethod('MathService', 'StreamNumbers')
streamNumbers(data: { numbers: number[] }): Observable<{ result: number }> {
// відправляє одну відповідь на кожне число, стрімить до клієнта
return from(data.numbers).pipe(
map(n => ({ result: n * n }))
);
}
}Поширена пастка: якщо не встановити @grpc/grpc-js або неправильно вказати protoPath, отримаєш мовчазну помилку "method not found" під час виконання, а не при компіляції. Перевір пакет і protoPath в GrpcOptions в першу чергу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.