Introduction
Beaucoup de personnes prétendent avoir une architecture orientée microservices, mais elles manquent souvent de connaissances de base. Diviser simplement son application en plusieurs services ne suffit pas à créer une architecture de microservices, cela peut même conduire à une mauvaise interprétation.
Dans cet article, nous allons nous concentrer sur les concepts fondamentaux de l'architecture microservices, en utilisant NestJs. Nous allons explorer le modèle de microservices et nous intéresser à l'API Gateway qui permet de créer une porte d'entrée à nos microservices.
Dans les applications monolithiques traditionnelles, les clients API consomment toutes les fonctionnalités depuis le même emplacement. Mais avec l'architecture microservices, la philosophie est différente et nous avons besoin de créer une séparation entre les différents services pour éviter la dépendance et les risques de panne.
Microservices
Avec les microservices Nest.js, il est possible d'extraire une partie de la logique métier de notre application et de l'exécuter dans un contexte Nest.js distinct. Cependant, contrairement à ce que son nom peut suggérer, ce nouveau contexte Nest.js ne s'exécute pas dans un nouveau thread ou un nouveau processus, du moins pas par défaut. Par conséquent, si vous utilisez le transport TCP par défaut, les demandes peuvent prendre plus de temps à se terminer.
Malgré cela, il y a des avantages à décharger certaines parties de votre application dans ce nouveau contexte de microservice. Dans cet article, nous allons nous concentrer sur le transport TCP, mais nous allons également explorer des stratégies concrètes permettant d'améliorer les performances des applications avec les microservices Nest.js.
Voici un schéma de ce que nous allons mettre en place dans cet article :
Pour commencer nous allons installer le cli de nestjs :
npm install -g @nestjs/cli
Créons le premier service
Dans une architecture basée sur les microservices, il y a plusieurs services qui peuvent être exécutés sur la même machine ou sur des machines distribuées. Pour commencer, nous allons créer un nouveau service en suivant ces étapes :
-
Créez un nouveau dossier en utilisant la commande suivante : mkdir back
-
Exécutez la commande nest new service-a pour créer un nouveau projet Nest.js avec la dépendance de votre choix (npm, yarn, pnpm).
-
Supprimez le fichier app.service.ts ainsi que les dépendances qui ne sont pas nécessaires pour ce service.
-
Votre arborescence de fichiers devrait maintenant ressembler à cela :
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
constructor() {}
@Get()
getHello(): string {
return "Hello"
}
}
Et pour le module :
// src/app.module.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
@Module({
imports: [],
controllers: [AppController],
providers: []
})
export class AppModule {}
Nous allons transformer notre service en microservice en utilisant le protocole TCP. Pour cela, nous avons besoin d'installer le package @nestjs/microservices
en utilisant la commande npm i --save @nestjs/microservices
. Ensuite, nous devons mettre à jour notre fichier main.ts
en ajoutant createMicroservice
et en configurant notre microservice avec les paramètres nécessaires.
Pour mieux comprendre la différence entre HTTP et TCP, vous pouvez imaginer qu'il s'agit de langages différents. Un serveur HTTP traditionnel parle en anglais, tandis qu'un microservice utilisant TCP parle en espagnol.
Structure :
- Utilisons notre ligne de commande : npm i --save @nestjs/microservices
- Mettons à jour notre fichier main.ts, pour ajouter createMicroservice et ajoutez les paramètres pour configurer notre microservice
import { NestFactory } from "@nestjs/core";
import { Transport } from "@nestjs/microservices";
import { AppModule } from "./app.module";
import { Logger } from "@nestjs/common";
const logger = new Logger();
async function bootstrap() {
const app = await NestFactory.createMicroservice(AppModule, {
transport: Transport.TCP,
options: {
host: "127.0.0.1",
port: 8888
}
});
app.listen();
logger.log("Microservice A is listening")
}
bootstrap();
Parlons de la configuration de Nest.js. La fonction createMicroservice prend des paramètres de configuration qui dépendent du transport que nous utilisons. Un transport est constitué d'un client et d'un serveur qui travaillent ensemble pour transmettre les demandes et les réponses de microservice entre les contextes NestApplication et NestMicroservice. Nest.js propose plusieurs transports intégrés et permet de créer des transports personnalisés, avec des paramètres disponibles spécifiques à chaque transport.
Pour configurer le contexte NestMicroservice, nous pouvons spécifier l'hôte avec le paramètre "host", qui par défaut est localhost, mais peut être modifié si notre NestMicroservice s'exécute sur un hôte différent, tel qu'un pod Kubernetes. Le paramètre "port" spécifie le port sur lequel le contexte NestMicroservice écoute, avec une valeur par défaut de 3000, mais nous pouvons utiliser un port différent si nécessaire. Pour le transport TCP, les paramètres "retryAttempts" et "retryDelay" déterminent le nombre de tentatives de reconnexion en cas de déconnexion du serveur, avec un délai en millisecondes entre chaque tentative.
En utilisant le modèle de message MessagePattern au lieu du décorateur classique, nous pouvons déclencher un ping et recevoir un pong en réponse. Pour utiliser ce modèle, nous devons mettre à jour AppController.
import { Controller } from "@nestjs/common";
import { MessagePattern } from "@nestjs/microservices";
import { of } from "rxjs";
import { delay } from "rxjs/operators";
@Controller()
export class AppController {
@MessagePattern({ cmd: "ping" })
ping(_: any) {
return of("pong").pipe(delay(1000));
}
}
Nest.js utilise un modèle de message pour déterminer le gestionnaire de microservices à exécuter. Ce modèle peut être une chaîne simple ou un objet complexe. Lorsqu'un nouveau message de microservice est envoyé, Nest.js recherche parmi tous les gestionnaires de microservices enregistrés pour trouver le gestionnaire correspondant exactement au modèle de message.
Le gestionnaire de microservice peut exécuter la même logique métier qu'un gestionnaire de contrôleur normal et peut répondre de manière similaire. Cependant, contrairement à un gestionnaire de contrôleur normal, il n'a pas de contexte HTTP et les décorateurs tels que @Get, @Body, @Req n'ont pas de sens et ne doivent pas être utilisés dans un contrôleur de microservice. Pour terminer le traitement d'un message, le gestionnaire peut simplement renvoyer une valeur, une promesse ou un Observable RxJS.
Gateway
Nous avons un microservice défini, la question à se poser est : comment y accéder ? Nous allons créer un nouveau service qui fonctionne comme un serveur HTTP et qui mappera notre demande au bon service. On proposera en quelque sorte un proxy qui :
- Composera les requêtes
- Réduira l'utilisation de la bande passante
Comment faire ?
- Dans le dossier parent de notre microservice : nest new api-gateway.
- Accéder au dossier racine de cette gateway créée : npm i --save @nestjs/microservices.
- Importez notre ServiceA créé dans l'AppModule en l'enregistrant par le biais du ClientModule
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ClientsModule, Transport } from "@nestjs/microservices";
@Module({
imports: [
ClientsModule.register([
{
name: "SERVICE_A",
transport: Transport.TCP,
options: {
host: "127.0.0.1",
port: 8888
}
}
])
],
controllers: [AppController],
providers: [AppService]
})
export class AppModule {}
Nous devons intégrer ce nouveau service dans l'AppService en créant une méthode qui permettra d'interroger le ServiceA. Cette méthode renverra l'heure actuelle en millisecondes en envoyant un message à l'instance du service, puis elle mappera cette réponse à un objet.
import { Injectable, Inject } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { map } from "rxjs/operators";
@Injectable()
export class AppService {
constructor(
@Inject("SERVICE_A") private readonly clientServiceA: ClientProxy
) {}
pingServiceA() {
const startTs = Date.now();
const pattern = { cmd: "ping" };
const payload = {};
return this.clientServiceA
.send<string>(pattern, payload)
.pipe(
map((message: string) => ({ message, duration: Date.now() - startTs }))
);
}
}
Ajoutons nos modifications à notre controller :
import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get("/ping-a")
pingServiceA() {
return this.appService.pingServiceA();
}
}
Une fois que notre gateway est en place, nous pouvons la tester en lançant notre service puis notre gateway avec la commande "npm run start". Pour accéder à l'URL de test, vous pouvez cliquer sur ce lien : "Test my gateway". Notre gateway utilise le service pour interroger notre microservice Service-A et renvoie la réponse "Pong" avec la durée affichée. Nous pourrions faire cela avec un simple proxy, mais les choses se compliquent lorsque nous souhaitons composer des requêtes. Avant de pouvoir le faire, nous devons créer un nouveau service.
Pour illustrer davantage notre gateway, créons un deuxième service. Exécutez la commande "nest new service-b" en utilisant la dépendance de votre choix (npm, yarn, pnpm). Ensuite, supprimez les fichiers "app.service.ts" et les dépendances inutiles. Transformons-le en un microservice en installant "@nestjs/microservices" avec la commande "npm i --save @nestjs/microservices". Notre module écoutera sur le port 8889.
import { NestFactory } from "@nestjs/core";
import { Transport } from "@nestjs/microservices";
import { AppModule } from "./app.module";
import { Logger } from "@nestjs/common";
const logger = new Logger();
async function bootstrap() {
const app = await NestFactory.createMicroservice(AppModule, {
transport: Transport.TCP,
options: {
host: "127.0.0.1",
port: 8889
}
});
app.listen();
logger.log("Microservice B is listening")
}
bootstrap();
Nous allons définir un Controller qui fait sensiblement la même chose que notre ServiceA :
import { Controller } from "@nestjs/common";
import { MessagePattern } from "@nestjs/microservices";
import { of } from "rxjs";
import { delay } from "rxjs/operators";
@Controller()
export class AppController {
@MessagePattern({ cmd: "ping" })
ping(_: any) {
return of("pong 2").pipe(delay(1000));
}
}
Notre Microservice peut maintenant être ajouté à notre gateway pour être appelée comme nous l'avons fait avec les microservice A :
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ClientsModule, Transport } from "@nestjs/microservices";
@Module({
imports: [
ClientsModule.register([
{
name: "SERVICE_A",
transport: Transport.TCP,
options: {
host: "127.0.0.1",
port: 8888
}
},
{
name: "SERVICE_B",
transport: Transport.TCP,
options: {
host: "127.0.0.1",
port: 8889
}
}
])
],
controllers: [AppController],
providers: [AppService]
})
export class AppModule {}
Maintenant que notre microservice est configuré correctement, nous pouvons implémenter notre pingServiceB qui permettra de faire exactement la même chose que pingServiceA. Voici un exemple de code pour créer un pingServiceB :
import { Injectable, Inject } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { map } from "rxjs/operators";
@Injectable()
export class AppService {
constructor(
@Inject("SERVICE_A") private readonly clientServiceA: ClientProxy,
@Inject("SERVICE_B") private readonly clientServiceB: ClientProxy
) {}
pingServiceA() {
const startTs = Date.now();
const pattern = { cmd: "ping" };
const payload = {};
return this.clientServiceA
.send<string>(pattern, payload)
.pipe(
map((message: string) => ({ message, duration: Date.now() - startTs }))
);
}
pingServiceB() {
const startTs = Date.now();
const pattern = { cmd: "ping" };
const payload = {};
return this.clientServiceB
.send<string>(pattern, payload)
.pipe(
map((message: string) => ({ message, duration: Date.now() - startTs }))
);
}
Maintenant nous définissons notre endpoint, mais ajoutons un endpoint permettant d'appeler l'ensemble des MS :
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { zip } from 'rxjs';
import { map } from 'rxjs/operators';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/ping-a')
pingServiceA() {
return this.appService.pingServiceA();
}
@Get('/ping-b')
pingServiceB() {
return this.appService.pingServiceB();
}
@Get('/ping-all')
pingAll() {
return zip(
this.appService.pingServiceA(),
this.appService.pingServiceB(),
).pipe(
map(([pongServiceA, pongServiceB]) => ({
pongServiceA,
pongServiceB,
})),
);
}
}
Résultat de ping-all :
{
pongServiceA: {
message: "pong",
duration: 1022
},
pongServiceB: {
message: "pong 2",
duration: 1005
}
}
Si vous n'êtes pas familier avec RxJS, cela peut sembler mystérieux, mais en réalité, c'est assez simple. Nous souhaitons lancer l'exécution de nos appels asynchrones simultanément et regrouper toutes les réponses en une seule.
Filtre d'exception
Les filtres d'exceptions permettent de transformer les exceptions émises par les gestionnaires de microservices en objets significatifs. Par exemple, la méthode "rpcCreate" peut générer une variété d'erreurs différentes, y compris des erreurs générées par le service ou l'ORM. Actuellement, cette méthode génère une erreur sous forme de chaîne de caractères. Le seul moyen pour la méthode appelante de savoir ce qui s'est passé est d'analyser cette chaîne d'erreur, ce qui n'est pas idéal. Nous devons donc corriger cela.
Commençons par créer une nouvelle classe d'exception. Notez que notre exception de microservice étend la classe RpcException et ne transmet pas de code d'état HTTP dans le constructeur. Ces sont les seules différences entre les exceptions de microservice et les exceptions normales de l'API Nest.js.
export class RpcValidationException extends RpcException {
constructor(public readonly validationErrors:ValidationError[]) {
super('Validation failed');
}
}
Enfin, il est temps de créer un filtre d'exception pour transformer les exceptions en objets significatifs. Les filtres d'exception de microservice diffèrent de ceux de l'API normale car ils étendent la classe RpcExceptionFilter et renvoient un ErrorObservable. Ce filtre interceptera l'exception RpcValidationException que nous avons créée et renverra un objet contenant un code d'erreur spécifique.
@Catch(RpcValidationException)
export class RpcValidationFilter implements RpcExceptionFilter {
public catch(exception: RpcValidationException): ErrorObservable {
return throwError({
error_code: 'VALIDATION_FAILED',
error_message: exception.getError(),
errors:exception.validationErrors
});
}
}
Conclusion
Jusqu'à présent, nous avons couvert tout ce dont vous avez besoin pour configurer et commencer à écrire et à utiliser des microservices dans Nest.js. Cependant, nous avons également décrit certains des inconvénients de l'implémentation de microservices avec Nest.js, en particulier le fait que les microservices ne s'exécutent pas dans un thread ou un processus distinct, ce qui peut limiter les gains de performance.
Cependant, cela ne signifie pas que vous ne pouvez pas bénéficier des avantages des microservices avec Nest.js. Nest.js ne fournit tout simplement pas les outils prêts à l'emploi pour cela. Dans la plupart des documents sur l'exécution d'applications Node.js en production, la seule recommandation généralement donnée est d'utiliser le cluster Node.js.
Cela n'est qu'un avant-goût de ce que les microservices peuvent apporter à votre architecture. Il existe de nombreux autres modèles, tels que l'API Gateway, que vous pouvez explorer. J'espère que cet article vous a montré la facilité d'implémentation de Nest.js et vous a convaincu de son utilité dans vos projets futurs.