Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як працюють мікросервіси в NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**NestJS мікросервіси** - це сервіси, що спілкуються через транспорти на кшталт TCP, Redis або Kafka за допомогою `@MessagePattern` замість HTTP-маршрутів. ```typescript const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, { transport: Transport.TCP, options: { host: 'localhost', port: 3001 }, }); await app.listen(); ``` **Ключове:** та сама DI-система і модулі NestJS, але патерни повідомлень замінюють URL-маршрути, а транспорт замінює HTTP.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 Два декоратори, що покривають майже всі потреби в комунікації: | Декоратор | Метод клієнта | Отримує відповідь? | Типовий випадок | |---|---|---|---| | `@MessagePattern` | `client.send()` | Так (Observable) | Отримати дані користувача, підрахувати суму | | `@EventPattern` | `client.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-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 для внутрішніх подій. ```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` в першу чергу.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.