Introduction aux microservices avec NestJS
Vue d’ensemble
En plus des architectures d’application traditionnelles (parfois appelées monolithiques), Nest prend en charge nativement le style architectural de développement des microservices. La plupart des concepts abordés ailleurs dans cette documentation, tels que l’injection de dépendances, les décorateurs, les filtres d’exception, les pipes, les gardes et les intercepteurs, s’appliquent également aux microservices. Dans la mesure du possible, Nest abstrait les détails d’implémentation afin que les mêmes composants puissent fonctionner sur des plateformes basées sur HTTP, WebSockets et des microservices. Cette section couvre les aspects de Nest qui sont spécifiques aux microservices.
En Nest, un microservice est fondamentalement une application qui utilise un couche de transport différente de HTTP.
Nest prend en charge plusieurs implémentations de couche de transport intégrées, appelées transporteurs, qui sont responsables de la transmission des messages entre différentes instances de microservices. La plupart des transporteurs prennent en charge de manière native à la fois les styles de message demande-réponse et basés sur des événements. Nest abstrait les détails d’implémentation de chaque transporteur derrière une interface canonique pour les messages basés sur la demande-réponse et ceux basés sur des événements. Cela facilite le passage d’une couche de transport à une autre – par exemple, pour tirer parti des fonctionnalités spécifiques de fiabilité ou de performance d’une couche de transport particulière – sans impacter votre code d’application.
Installation
Pour commencer à construire des microservices, installez d’abord le paquet requis :
$ npm i --save @nestjs/microservices
Démarrer
Pour instancier un microservice, utilisez la méthode createMicroservice()
de la classe NestFactory
:
import { NestFactory } from '@nestjs/core';import { Transport, MicroserviceOptions } from '@nestjs/microservices';import { AppModule } from './app.module';
async function bootstrap() { const app = await NestFactory.createMicroservice<MicroserviceOptions>( AppModule, { transport: Transport.TCP, }, ); await app.listen();}bootstrap();
Le deuxième argument de la méthode createMicroservice()
est un objet options
. Cet objet peut consister en deux membres :
transport | Spécifie le transporteur (par exemple, Transport.NATS ) |
---|---|
options | Un objet d’options spécifique au transporteur qui détermine le comportement du transporteur. |
L’objet options
est spécifique au transporteur choisi. Le transporteur TCP expose les propriétés décrites ci-dessous. Pour d’autres transporteurs (comme Redis, MQTT, etc.), consultez le chapitre pertinent pour une description des options disponibles.
host | Nom d’hôte de connexion |
---|---|
port | Port de connexion |
retryAttempts | Nombre de tentatives de nouvelle tentative de message (par défaut : 0 ) |
retryDelay | Délai entre les tentatives de nouvelle tentative de message (ms) (par défaut : 0 ) |
serializer | Sérialiseur personnalisé pour les messages sortants |
deserializer | Désérialiseur personnalisé pour les messages entrants |
socketClass | Un Socket personnalisé qui étend TcpSocket (par défaut : JsonSocket ) |
tlsOptions | Options de configuration pour le protocole tls |
Modèles
Les microservices reconnaissent à la fois les messages et les événements par des modèles. Un modèle est une valeur simple, par exemple, un objet littéral ou une chaîne. Les modèles sont automatiquement sérialisés et envoyés via le réseau avec la partie donnée d’un message. De cette manière, les expéditeurs et les consommateurs de messages peuvent coordonner quelles demandes sont consommées par quels gestionnaires.
Demande-réponse
Le style de message demande-réponse est utile lorsque vous devez échanger des messages entre divers services externes. Avec ce paradigme, vous pouvez être certain que le service a effectivement reçu le message (sans avoir à implémenter manuellement un protocole ACK de message). Cependant, le paradigme de demande-réponse n’est pas toujours le meilleur choix. Par exemple, les transporteurs de flux qui utilisent la persistance basée sur les journaux, tels que Kafka ou NATS streaming, sont optimisés pour résoudre une gamme de problèmes différente, plus alignée avec un paradigme de messagerie par événements (voir messagerie basée sur des événements ci-dessous pour plus de détails).
Pour activer le type de message demande-réponse, Nest crée deux canaux logiques - l’un est responsable du transfert des données tandis que l’autre attend les réponses entrantes. Pour certains transports sous-jacents, tels que NATS, ce support de double canal est fourni par défaut. Pour d’autres, Nest compense en créant manuellement des canaux séparés. Cela peut entraîner une surcharge, donc si vous n’avez pas besoin du style de message demande-réponse, vous devriez envisager d’utiliser la méthode basée sur des événements.
Pour créer un gestionnaire de message basé sur le paradigme demande-réponse utilisez le décorateur @MessagePattern()
, qui est importé du package @nestjs/microservices
. Ce décorateur ne doit être utilisé que dans les classes de contrôleur car elles sont les points d’entrée de votre application. Les utiliser à l’intérieur des fournisseurs n’aura aucun effet car elles sont simplement ignorées par le runtime de Nest.
import { Controller } from '@nestjs/common';import { MessagePattern } from '@nestjs/microservices';
@Controller()export class MathController { @MessagePattern({ cmd: 'sum' }) accumulate(data: number[]): number { return (data || []).reduce((a, b) => a + b); }}
Dans le code ci-dessus, le gestionnaire de message accumulate()
écoute les messages qui remplissent le modèle de message { cmd: 'sum' }
. Le gestionnaire de message prend un seul argument, les data
transmises par le client. Dans ce cas, les données sont un tableau de nombres qui doivent être cumulés.
Réponses asynchrones
Les gestionnaires de messages peuvent répondre de manière synchrone ou asynchrone. Par conséquent, les méthodes async
sont prises en charge.
@MessagePattern({ cmd: 'sum' })async accumulate(data: number[]): Promise<number> { return (data || []).reduce((a, b) => a + b);}
Un gestionnaire de message est également capable de retourner un Observable
, auquel cas les valeurs de résultat seront émises jusqu’à ce que le flux soit terminé.
@MessagePattern({ cmd: 'sum' })accumulate(data: number[]): Observable<number> { return from([1, 2, 3]);}
Dans l’exemple ci-dessus, le gestionnaire de message répondra trois fois (avec chaque élément du tableau).
Basés sur des événements
Alors que la méthode demande-réponse est idéale pour échanger des messages entre des services, elle est moins adaptée lorsque votre style de message est basé sur des événements - lorsque vous souhaitez simplement publier des événements sans attendre de réponse. Dans ce cas, vous ne voulez pas la surcharge requise par la demande-réponse pour maintenir deux canaux.
Supposons que vous souhaitiez simplement notifier un autre service qu’une certaine condition s’est produite dans cette partie du système. C’est le cas d’utilisation idéal pour le style de message basé sur des événements.
Pour créer un gestionnaire d’événements, nous utilisons le décorateur @EventPattern()
, qui est importé du package @nestjs/microservices
.
@EventPattern('user_created')async handleUserCreated(data: Record<string, unknown>) { // logique métier}
Le gestionnaire d’événements handleUserCreated()
écoute l’événement 'user_created'
. Le gestionnaire d’événements prend un seul argument, les data
transmises par le client (dans ce cas, une charge utile d’événement qui a été envoyée via le réseau).
Décorateurs
Dans des scénarios plus sophistiqués, vous pouvez vouloir accéder à plus d’informations sur la requête entrante. Par exemple, dans le cas de NATS avec des abonnements génériques, vous souhaiterez peut-être obtenir le sujet d’origine auquel le producteur a envoyé le message. De même, dans Kafka, vous pouvez vouloir accéder aux en-têtes du message. Pour ce faire, vous pouvez utiliser les décorateurs intégrés comme suit :
@MessagePattern('time.us.*')getDate( @Payload() data: number[], @Ctx() context: NatsContext,) { console.log(`Subject: ${context.getSubject()}`); // e.g. "time.us.east" return new Date().toLocaleTimeString();}
Client
Une application cliente Nest peut échanger des messages ou publier des événements à un microservice Nest en utilisant la classe ClientProxy
. Cette classe définit plusieurs méthodes, telles que send()
(pour la messagerie demande-réponse) et emit()
(pour la messagerie événementielle) qui vous permettent de communiquer avec un microservice distant. Obtenez une instance de cette classe de l’une des manières suivantes.
Une technique consiste à importer le ClientsModule
, qui expose la méthode statique register()
. Cette méthode prend un argument qui est un tableau d’objets représentant les transporteurs de microservices. Chaque objet de ce type a une propriété name
, une propriété transport
facultative (par défaut est Transport.TCP
), et une propriété d’options spécifique au transporteur facultative.
La propriété name
sert de token d’injection qui peut être utilisé pour injecter une instance de ClientProxy
là où cela est nécessaire. La valeur de la propriété name
, en tant que token d’injection, peut être une chaîne arbitraire ou un symbole JavaScript, comme décrit ici.
La propriété options
est un objet avec les mêmes propriétés que celles que nous avons vues dans la méthode createMicroservice()
précédemment.
@Module({ imports: [ ClientsModule.register([ { name: 'MATH_SERVICE', transport: Transport.TCP }, ]), ], ...})
Une fois que le module a été importé, nous pouvons injecter une instance de ClientProxy
configurée comme spécifié via les options de transporteur MATH_SERVICE
montrées ci-dessus, en utilisant le décorateur @Inject()
.
constructor( @Inject('MATH_SERVICE') private client: ClientProxy,) {}
Il arrive parfois que nous devions récupérer la configuration du transporteur à partir d’un autre service (disons un ConfigService
), plutôt que de la coder en dur dans notre application cliente. Pour ce faire, nous pouvons enregistrer un fournisseur personnalisé en utilisant la classe ClientProxyFactory
. Cette classe a une méthode statique create()
, qui accepte un objet d’options de transporteur, et retourne une instance de ClientProxy
personnalisée.
@Module({ providers: [ { provide: 'MATH_SERVICE', useFactory: (configService: ConfigService) => { const mathSvcOptions = configService.getMathSvcOptions(); return ClientProxyFactory.create(mathSvcOptions); }, inject: [ConfigService], }, ], ...})
Une autre option est d’utiliser le décorateur de propriété @Client()
.
@Client({ transport: Transport.TCP })client: ClientProxy;
Utiliser le décorateur @Client()
n’est pas la technique préférée, car il est plus difficile à tester et à partager une instance de client.
Le ClientProxy
est paresseux. Il n’initie pas une connexion immédiatement. Au lieu de cela, il sera établi avant le premier appel de microservice, puis réutilisé pour chaque appel subséquent. Cependant, si vous souhaitez retarder le processus de démarrage de l’application jusqu’à ce qu’une connexion soit établie, vous pouvez initier manuellement une connexion en utilisant la méthode connect()
de l’objet ClientProxy
à l’intérieur du hook de cycle de vie OnApplicationBootstrap
.
async onApplicationBootstrap() { await this.client.connect();}
S’il n’est pas possible de créer la connexion, la méthode connect()
rejettera l’objet d’erreur correspondant.
Envoi de messages
Le ClientProxy
expose une méthode send()
. Cette méthode est destinée à appeler le microservice et renvoie un Observable
avec sa réponse. Nous pouvons donc nous abonner facilement aux valeurs émises.
accumulate(): Observable<number> { const pattern = { cmd: 'sum' }; const payload = [1, 2, 3]; return this.client.send<number>(pattern, payload);}
La méthode send()
prend deux arguments, pattern
et payload
. Le pattern
doit correspondre à celui défini dans un décorateur @MessagePattern()
. Le payload
est un message que nous voulons transmettre au microservice distant. Cette méthode retourne un Observable froid, ce qui signifie que vous devez explicitement y souscrire avant que le message ne soit envoyé.
Publication d’événements
Pour envoyer un événement, utilisez la méthode emit()
de l’objet ClientProxy
. Cette méthode publie un événement au courtier de messages.
async publish() { this.client.emit<number>('user_created', new UserCreatedEvent());}
La méthode emit()
prend deux arguments, pattern
et payload
. Le pattern
doit correspondre à un modèle défini dans un décorateur @EventPattern()
. Le payload
est une charge utile d’événement que nous voulons transmettre au microservice distant. Cette méthode retourne un Observable chaud (contrairement à l’Observable froid retourné par send()
), ce qui signifie que, que vous vous abonnez ou non explicitement à l’observable, le proxy essaiera immédiatement de livrer l’événement.
Gestion des délais d’attente
Dans les systèmes distribués, il se peut que parfois, les microservices soient hors service ou non disponibles. Pour éviter d’attendre indéfiniment, vous pouvez utiliser des délais d’attente. Un délai d’attente est un modèle incroyablement utile lors de la communication avec d’autres services. Pour appliquer des délais d’attente à vos appels de microservice, vous pouvez utiliser l’opérateur timeout
de RxJS. Si le microservice ne répond pas à la demande dans un certain temps, une exception est levée, qui peut être interceptée et gérée de manière appropriée.
Pour résoudre ce problème, vous devez utiliser le paquet rxjs
. Utilisez simplement l’opérateur timeout
dans le pipe :
this.client .send<TResult, TInput>(pattern, data) .pipe(timeout(5000));
Après 5 secondes, si le microservice ne répond pas, il lèvera une erreur.