Passer au contenu

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.

Image illustrative

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 :

Fenêtre de terminal
$ npm i --save @nestjs/microservices

Démarrer

Pour instancier un microservice, utilisez la méthode createMicroservice() de la classe NestFactory :

Code pour instancier un microservice
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 :

transportSpécifie le transporteur (par exemple, Transport.NATS)
optionsUn 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.

hostNom d’hôte de connexion
portPort de connexion
retryAttemptsNombre de tentatives de nouvelle tentative de message (par défaut : 0)
retryDelayDélai entre les tentatives de nouvelle tentative de message (ms) (par défaut : 0)
serializerSérialiseur personnalisé pour les messages sortants
deserializerDésérialiseur personnalisé pour les messages entrants
socketClassUn Socket personnalisé qui étend TcpSocket (par défaut : JsonSocket)
tlsOptionsOptions 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.

Code d'un gestionnaire de message
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.

Gestionnaire de message asynchrone
@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é.

Gestionnaire de message avec Observable
@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.

Gestionnaire d'événements
@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 :

Décorateur pour obtenir des informations sur le message
@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 Client
@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().

Injection de ClientProxy
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 avec ClientProxy personnalisé
@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().

Utilisation de @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.

Démarrer la connexion dans 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.

Envoi de messages avec ClientProxy
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.

Publication d'un événement
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 :

Gestion des délais d'attente dans ClientProxy
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.