Skip to main content

Як працюють мікросервіси в 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, клієнт надсилає масив чисел і отримує суму.

typescript
// 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();
typescript
// 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

Два декоратори, що покривають майже всі потреби в комунікації:

ДекораторМетод клієнтаОтримує відповідь?Типовий випадок
@MessagePatternclient.send()Так (Observable)Отримати дані користувача, підрахувати суму
@EventPatternclient.emit()НіПодія "замовлення створено", сповіщення

client.send() повертає RxJS Observable. Його чекають через .toPromise() або firstValueFrom(). client.emit() повертає void - відправив і забув.

typescript
// 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-oncePub/sub, події поруч із кешуванням
NATSДуже низькаДуже високаAt-least-onceReal-time fan-out, великий потік
RabbitMQСередняВисокаExactly-once (з ACK)Черги замовлень, все що потребує підтвердження
KafkaСередняЕкстремальнаAt-least-onceEvent 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 для внутрішніх подій.

typescript
// 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 у мікросервісі:

typescript
@Get('add') // ігнорується в TCP-режимі - немає Express, немає маршрутів add() { ... }

Виправлення: @MessagePattern({ cmd: 'add' }).

Відсутність ACK у RabbitMQ:

typescript
@MessagePattern('process') process(@Payload() data: any) { doWork(data); // немає ack - при краші повідомлення знову доставляється, нескінченний цикл }

Це найпоширеніша RabbitMQ-помилка в NestJS-проектах. Виправлення: додай @Ctx() ctx: RmqContext і виклич ctx.getChannelRef().ack(ctx.getMessage()) після завершення роботи. І встанови noAck: false в bootstrap-конфігурації.

typescript
@MessagePattern('process') process(@Payload() data: any, @Ctx() ctx: RmqContext) { doWork(data); ctx.getChannelRef().ack(ctx.getMessage()); // RabbitMQ: повідомлення оброблено, не повторювати }

Гібридний додаток без connectMicroservice:

typescript
// Неправильно: створює тільки HTTP-сервер, TCP-опція ігнорується const app = await NestFactory.create(AppModule, { transport: Transport.TCP });

Потрібен app.connectMicroservice({...}) після NestFactory.create.

Передача Buffer через JSON-транспорт:

typescript
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.

typescript
// 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); } }
typescript
// 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 споживає і підтверджує вручну, щоб уникнути нескінченної повторної доставки при краші.

typescript
// 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; } } }
typescript
// 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.

protobuf
// 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; }
typescript
// 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 в першу чергу.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?