Cet article a été publié dans le magazine Programmez n°250 paru le 7 janvier 2022. Merci encore à eux et à Sfeir pour cette opportunité !
De nos jours, l’informatique est un milieu qui évolue si rapidement que le time-to-market (la durée entre la conception d’une idée et le moment où le produit fini arrive entre les mains des utilisateurs) est devenu un indicateur primordial. Pour produire rapidement des fonctionnalités, les développeurs se basent depuis longtemps sur des frameworks, qui sont conçus pour augmenter la productivité en prenant en charge une partie de la complexité du développement.
NestJS (Nest) est un framework open source conçu pour développer des applications sur la plateforme Node.js. Il est écrit en Typescript qu’il supporte nativement, bien qu’il permette aussi de développer son application en Javascript. Le véritable avantage de Nest est d’accélérer le démarrage du projet, en proposant une architecture inspirée d’Angular qui permet aux équipes de développer des applications facilement testables, scalables et maintenables dans le temps. Il compte 1,3 million de téléchargements hebdomadaires sur npm en avril 2022. Son fonctionnement peut être comparé à celui de Spring pour Java, avec un système d’annotations et d’injection de dépendances.
Nest a une documentation extrêmement claire, fournie et détaillée, avec un grand nombre d’exemples d’utilisations, ce qui en fait un framework facile à prendre en main, et sa communauté est très active.
Dans cet article, nous allons voir ensemble un exemple d’application écrite avec Nest : la gestion d’une liste personnelle de séries avec notes et commentaires. Cette application permet de lister les avis sur des séries et de créer un nouvel avis à l’aide d’un formulaire.
Le code qui a servi d’exemple est disponible sur github : CeliaDoolaeghe/my-list-of-series.
Premiers pas et configuration
En tant que framework, Nest a fait des choix en amont pour que les développeurs n’aient pas à faire la configuration du projet eux-mêmes, ce qui est une étape souvent longue à mettre en place et assez pénible mais qui n’apporte aucune valeur métier. Nest fournit donc une CLI qui va créer rapidement et facilement une application de base, déjà configurée et prête à l’emploi, avec l’arborescence suivante :
Le projet généré marche immédiatement, il suffit de le lancer avec npm start
, et nous avons déjà une application qui tourne sur localhost:3000
, même si elle ne fait qu'afficher “Hello World” dans le navigateur.
Nest fournit nativement la configuration de Typescript, Eslint et Prettier, qui s’occupent respectivement du typage de Javascript, de la vérification des conventions de code et du formatage. Ces configurations restent modifiables si besoin, et même supprimables comme n’importe quelle autre dépendance. Ce sont des outils qui sont largement utilisés par la communauté des développeurs Javascript car ils facilitent la gestion du projet et surtout sa maintenabilité dans le temps.
Dans le package.json, un certain nombre de scripts sont déjà définis, notamment les scripts nécessaires pour démarrer l’application (avec hot reload pour la phase de développement), faire tourner eslint et prettier, ou encore lancer les tests. Nest installe et configure par défaut le framework de test Jest, le plus répandu sur les applications Javascript. Si on lance le script npm test
, nous avons déjà 1 test qui passe, qui est là pour l’exemple. Des tests end-to-end sont également présents dans le dossier test. Nous pouvons bien sûr installer en plus n’importe quelle dépendance souhaitée, comme dans n’importe quel projet Node.js.
Performances
Par défaut, Nest est construit au-dessus d’Express, le framework Node.js open-source le plus répandu. Mais si la performance est au cœur de vos préoccupations, Nest est également compatible avec Fastify, un autre framework open-source centré sur la performance.
Les modules
La première complexité dans un projet, c’est l’architecture : pour assurer la maintenabilité du projet dans le temps, il faut une structure claire et scalable. Il faut limiter au maximum l’entropie, c’est-à-dire la tendance naturelle des projets informatiques à se complexifier avec le temps, avec un impact sur la productivité dans le développement de nouvelles fonctionnalités.
Nest a fait le choix d’une architecture modulaire : chaque fonctionnalité sera vue comme un Module. Un module se compose d’abord d’un ou plusieurs controllers, qui exposent des routes. Un module contient des providers, qui sont des classes à comportement (métier, base de données, etc). Un module peut exporter des classes et être importé dans d’autres modules. Chaque module contient tout ce qui est nécessaire à son fonctionnement.
Prenons par exemple une fonctionnalité qui servirait juste à créer un avis sur une série. Nous créons un module CreateReviewModule qui expose une route permettant de noter une série en laissant un commentaire :
@Module({
controllers: [CreateReviewController],
imports: [
MongooseModule.forFeature([
{ name: SeriesReview.name, schema: SeriesReviewSchema },
]),
],
providers: [CreateReviewRepository, CommentChecker],
})
export class CreateReviewModule {}
Ici, on voit que notre module expose un controller CreateReviewController qui contient la route. Il importe le module Mongoose, un ORM qui gère pour nous le mapping entre nos entités et la base de données MongoDB dans laquelle nous allons stocker les notes et commentaires des séries (l'ORM n'est pas obligatoire, à vous de choisir, pour un exemple comme ici c'est plus simple). Enfin, nous voyons dans les providers deux classes CreateReviewRepository, qui est chargé de la sauvegarde en base de données, et CommentChecker, qui sera chargé de vérifier que le contenu du commentaire est autorisé (par exemple, pour éviter de sauvegarder un commentaire avec du langage injurieux).
Toutes les classes qui sont répertoriées dans les providers peuvent ensuite être injectées dans les controllers ou les autres providers. Les classes exportées par les modules que nous importons peuvent également être injectées dans les classes de notre module.
Dans cet exemple, on voit facilement le périmètre de notre fonctionnalité : toutes les dépendances de notre controller sont listées dans ce module. Lorsqu’on parle de maintenabilité dans le temps, la capacité à anticiper les impacts de changements dans notre code compte beaucoup, et l’architecture préconisée par Nest permet de plus facilement prédire les impacts de nos changements.
Cette architecture est également scalable, car l’ajout de nouveaux modules n’impactent pas ceux qui sont déjà présents, chaque nouvelle fonctionnalité vient juste s’ajouter dans le root module, c’est-à-dire celui qui va ensuite importer tous les autres modules. La complexité locale dans les modules reste liée à la complexité métier, et non à la taille du projet.
Par exemple, dans notre projet, nous pouvons imaginer deux modules : un pour lister les avis existants et un autre pour créer un nouvel avis. Les deux modules se servent du même module Mongoose pour la base de données, mais peuvent aussi avoir besoin d’autres modules propres, par exemple pour récupérer les affiches des séries dans la liste des avis. Chaque module n’importe que ce dont il a besoin dans un souci de responsabilité limitée.
Injection de dépendances
Avant d’aller plus loin, faisons un petit aparté sur l’injection de dépendances. A la base, c’est le cinquième des principes SOLID de la Programmation Orientée Objet (D pour Dependency Inversion). L’idée est qu’une classe de “haut niveau” (gestion des règles métier) ne soit pas directement liée à une classe de “bas niveau” (gestion de l’infrastructure). Par exemple, on crée une interface avec des fonctions de lecture en base de données, et on injecte dans les classes métier une classe qui implémente cette interface.
Ce qui est intéressant ici, c’est que notre classe métier n’a pas la responsabilité d’instancier la classe qui lit en base de données, elle s’attend à avoir une classe qui respecte la bonne interface et peut donc appeler ses fonctions sans se soucier de l’implémentation. Notre classe métier n’a pas besoin de savoir que cette implémentation est en MongoDB ou PostgreSQL, ou encore un mock pour les tests unitaires (nous y reviendrons dans le paragraphe sur les tests). On sépare bien les responsabilités de chaque classe.
En tout cas, c’est sur ce principe que se base Nest : en déclarant une classe en tant que provider dans un module, elle devient injectable dans les autres classes du module. Maintenant, nous allons voir concrètement comment construire le code autour de ce principe.
Controller et validation
Créons maintenant une route pour donner son avis sur une série. Il s'agit d'une route POST puisque nous créons un nouvel avis. Un avis contient le titre de la série, une note comprise entre 0 et 5 et un commentaire optionnel.
La première chose à faire (en dehors des tests si vous faites du TDD, ici nous y reviendrons après) est de créer la route d’ajout de commentaire. C’est le rôle du Controller qui va répondre lors d’un appel à la route. Nest fournit les annotations nécessaires pour créer une route Post, récupérer le body et retourner automatiquement un statut "201 Created" si aucune exception n’est renvoyée.
Il ne reste donc au développeur qu’à implémenter le vrai code métier, à savoir vérifier que si un commentaire est présent, alors il doit être valide (sans contenu injurieux), puis sauvegarder cet avis en base de données.
@Controller()
export class CreateReviewController {
constructor(
private commentChecker: CommentChecker,
private createReviewRepository: CreateReviewRepository,
) {}
@Post('/series/reviews')
async grade(@Body() gradeRequest: ReviewRequest): Promise<void> {
if (gradeRequest.comment) {
const isValidComment = this.commentChecker.check(gradeRequest.comment);
if (!isValidComment) {
throw new BadRequestException({
message: 'This comment is not acceptable',
});
}
}
await this.createReviewRepository.save(gradeRequest);
}
}
Comme on peut le voir ici, les classes CommentChecker et CreateReviewRepository sont des dépendances injectées par le constructeur, qui est géré par Nest grâce au module que nous avons déclaré plus tôt.
L’annotation @Post()
est suffisante pour déclarer la route à Nest. L’annotation @Body()
permet de récupérer le body qui est envoyé dans le Post, qu’on peut directement typer. On renvoie ici Promise<void>
car Nest s’occupe de renvoyer un statut 201 par défaut sur les routes Post, bien qu’on puisse surcharger ce comportement si besoin.
Finalement, en plus des annotations, nous n’avons écrit que les règles métier de gestion des avis, et c’est ce qui compte : passer du temps sur la valeur métier apportée par notre code, et non la forme pour le faire fonctionner, qui est gérée par le framework. Il ne reste qu’à implémenter les fonctions dans les classes CommentChecker et CreateReviewRepository et nous avons là une route opérationnelle.
A noter ici que si le commentaire est invalide, nous renvoyons une exception de type BadRequestException, qui contient le statut "400 Bad Request" et dans lequel nous passons juste un message explicatif.
Validation du body
Quand on soumet une requête, il faut d’abord valider que le body soumis répond à nos spécifications : tous les champs obligatoires doivent être présents, la note doit être numérique, etc. Il existe deux dépendances class-validator et class-transformer qui permettent d’assurer cette validation à travers des annotations sur la classe du body. Ici, nous appliquons des règles de validation sur la classe ReviewRequest :
export class ReviewRequest {
@ApiProperty({ description: 'Title of the series' })
@IsNotEmpty()
title: string;
@ApiProperty({ description: 'Grade between 0 and 5' })
@IsNumber()
@Min(0)
@Max(5)
grade: number;
@ApiPropertyOptional({ description: 'A comment on the series' })
@IsOptional()
@IsNotEmpty()
comment?: string;
constructor(title: string, grade: number, comment?: string) {
this.title = title;
this.grade = grade;
this.comment = comment;
}
}
Chaque champ se voit associer ses règles de validation. Le titre ne doit pas être vide. La note doit être numérique et sa valeur doit être comprise entre 0 et 5. Le commentaire est optionnel, mais s’il est présent, il ne doit pas être vide. Les annotations sont très explicites ici, et permettent de mettre en place les règles de validation les plus simples.
Si la validation du body échoue, alors Nest renvoie un statut "400 Bad Request" avec un message qui indique quel champ est en erreur et pour quelle raison.
Il est aussi possible de faire des validations sur des tableaux, vérifier qu’il n’est pas vide, que chaque élément du tableau correspond aux règles énoncées, etc. Les annotations disponibles sont très riches.
Et si ma validation est plus complexe ?
Parfois, nous avons besoin d’exprimer des règles qui ne font pas partie des annotations de validation proposées par défaut. Dans ce cas, il est d’abord possible de créer ses propres annotations pour exécuter une vérification spécifique sur un champ. Par exemple, on peut vérifier qu’une chaîne de caractères commence bien par un mot spécifique si c’est notre besoin.
Mais on peut aussi imaginer une validation qui nécessiterait de lire plusieurs champs. Par exemple, dans notre cas, si la note associée à un avis est basse, on peut exiger que le commentaire soit obligatoire pour justifier cette note, tout en le laissant optionnel sinon. Comment gérer ce cas ?
On peut créer un Pipe de validation. C’est une classe dont le comportement s’exécute avant que le controller ne récupère le body. Elle a accès à l’objet en entrée en entier et laisse le soin au développeur d’écrire les règles de validation. Nous pouvons donc implémenter de cette manière n’importe quelle règle de validation d’un objet pour s’assurer qu’il soit bien valide lorsqu’il arrive dans le controller. Dans notre exemple, si la note est inférieure à 3 et qu’il n’y a pas de commentaire, alors nous renvoyons une BadRequestException, sinon l’objet est valide.
@Injectable()
export class MandatoryCommentOnBadGradePipe implements PipeTransform {
transform(value: unknown): ReviewRequest {
const reviewRequest = plainToClass(ReviewRequest, value);
if (reviewRequest.grade < 3 && !reviewRequest.comment) {
throw new BadRequestException(
'Comment is mandatory when grade is lower than 3',
);
}
return reviewRequest;
}
}
Swagger
Les plus attentifs l’auront remarqué : à quoi servent les annotations @ApiProperty()
?
Une fois que notre route est en place, nous avons envie de la tester. Bien sûr, nous pouvons utiliser curl, Postman ou n’importe quel autre outil permettant de faire des appels d’API. Mais l’éco-système autour de Nest fournit des dépendances permettant de générer dynamiquement la documentation Swagger à partir des annotations.
La mise en place est très simple, il suffit de quelques lignes dans le fichier main.ts pour que cette documentation soit déployée sur une route de notre application.
Pour notre route de création d’un avis, le rendu donnerait :
Le schéma du body est directement généré par les annotations @ApiProperty()
et @ApiPropertyOptional()
et la description qu’ils contiennent. On obtient une documentation standard, facile à partager car directement hébergée sur notre application, et facile à utiliser grâce à l’option “Try it out” (nous reviendrons sur l’authentification par la suite).
Tests unitaires
Chose promise chose due, nous allons parler maintenant des tests unitaires. Pour qu’une application reste maintenable dans le temps, il ne suffit pas que l'architecture nous aide à comprendre les fonctionnalités impactées par nos changements, il faut aussi que des tests (unitaires et/ou end-to-end) soient présents pour assurer que nos changements ne créent pas de régressions dans les règles métier déjà existantes.
Grâce à l’injection de dépendances évoquée plus tôt, les classes implémentées sont facilement testables unitairement car les dépendances peuvent être mockées, c’est-à-dire remplacées par des fausses instances où nous contrôlons le comportement et les retours.
Pour tester un controller, Nest fournit les outils pour créer des modules de test, où l’on peut injecter nos dépendances mockées :
let app: INestApplication;
let commentCheckerMock: CommentChecker;
let createReviewRepository: CreateReviewRepository;
beforeEach(async () => {
commentCheckerMock = {} as CommentChecker;
commentCheckerMock.check = jest.fn().mockReturnValue(true);
createReviewRepository = {} as CreateReviewRepository;
createReviewRepository.save = jest.fn();
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [CreateReviewController],
providers: [CommentChecker, CreateReviewRepository],
})
.overrideGuard(AuthGuard)
.useValue({})
.overrideProvider(CommentChecker)
.useValue(commentCheckerMock)
.overrideProvider(CreateReviewRepository)
.useValue(createReviewRepository)
.compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
});
it('201 valid review with no comment', () => {
return request(app.getHttpServer())
.post('/series/reviews')
.send({
title: 'Test',
grade: 3,
})
.expect(201);
});
Ici, nous créons une fausse instance de CommentChecker et de CreateReviewRepository, nous utilisons Jest pour la fausse implémentation des fonctions de ces deux classes, et nous les fournissons en providers au module de test. Ensuite, il ne reste dans le test qu’à appeler la route et vérifier le retour.
Nous pouvons ensuite créer des tests pour tous les cas gérés par notre code: renvoyer une erreur si l’un des champs obligatoires est manquant, si la note n’est pas comprise entre 0 et 5, si le commentaire est injurieux, etc.
Bien sûr, les tests peuvent parfaitement être écrits avant l’implémentation, comme préconisé par le TDD (Test Driven Development).
Sécurité et Authentification
La plupart des applications ne sont pas accessibles librement au grand public et nécessitent donc d’être sécurisées. Les préconisations classiques, telles que l’installation de la dépendance helmet pour pré-configurer les headers HTTP par exemple, sont toujours de mise et ne doivent pas être oubliées. Elle fait d’ailleurs partie des recommandations de sécurité de Nest.
Pour gérer l’authentification, dans une application Node.js en express par exemple, nous pourrions utiliser un middleware spécifique, c’est-à-dire une fonction qu’on applique sur des routes et qui s’exécutent avant que les controllers ne soient appelés. Dans Nest, les middlewares existent aussi, ils ont la même définition, mais ne sont pas la solution idéale préconisée.
Les guards marchent sur le même modèle mais ont l’avantage de connaître le contexte dans lequel ils sont appelés : ils savent quelle route est appelée mais aussi quel controller sera exécuté si la validation passe. Un guard peut se faire injecter une dépendance, par exemple un service qui gère la vérification d’un token.
Ici, nous avons un exemple de guard qui protège les routes à l’aide d’une authentification de type Basic, c’est-à-dire que les requêtes HTTP ont un header authorization qui contient username et password encodés en base 64. On vérifie ensuite que l’utilisateur est bien reconnu par l’application :
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
if (!request.headers.authorization) {
throw new UnauthorizedException();
}
const [basic, token] = request.headers.authorization.split(' ');
const isValidToken = await this.authService.validateBasicToken(token);
if (basic !== 'Basic' || !isValidToken) {
throw new UnauthorizedException();
}
return true;
}
}
L’authentification Basic n’est pas la méthode la plus sûre, mais ce modèle est compatible avec d’autres techniques d’authentification comme JWT.
Pour appliquer ce guard, il suffit d’ajouter à nos controllers l’annotation @UseGuard(AuthGuard)
. Nous aurions aussi pu définir ce guard en global dans le module AppModule. Nos routes sont désormais sécurisées, et le SwaggerModule peut prendre une option qui permet de saisir l’authentification basique directement depuis le swagger.
Interface avec Nest MVC
Nous avons maintenant une route pour donner un avis sur une série, mais le swagger n’est pas vraiment adapté pour la plupart des utilisateurs non développeurs… L’idéal serait de créer un petit formulaire qui soumet l’avis à notre API.
On peut bien sûr brancher une interface externe à nos apis. Nest est compatible avec toutes les dépendances npm, comme cors par exemple, qui autorise les appels cross-origin entre un frontend et un backend qui ne seraient pas hébergés sur le même domaine.
Sinon, Nest permet d’implémenter toutes les facettes du MVC (Model-View-Controller) : nous avons déjà vu les parties Model et Controller précédemment, mais nous pouvons aussi implémenter la partie View directement. Il s’agit ici de faire des vues simples avec un langage de templating (type handlebars ou ejs) pour faire du SSR (Server-Side Rendering). Pour des interfaces complexes ou hautement dynamiques, cela ne sera peut-être pas suffisant, mais pour notre formulaire, ce sera parfait.
Tout d’abord, il faut écrire le fichier handlebars qui contiendra notre formulaire. Il s’agit là d’une page de html classique avec du templating de type mustache, dans lequel on peut ajouter du css pour le design et du js pour les comportements, par exemple pour vérifier les valeurs des champs obligatoires avant la soumission du formulaire.
Du point de vue de Nest, notre interface est un module comme les autres, qu’il faut donc importer dans AppModule. Notre controller fait simplement le lien entre le fichier create-review.hbs et la route /interface
dans le navigateur :
@Controller()
export class CreateReviewFormController {
@Get('/interface')
@ApiExcludeEndpoint()
@Render('create-review')
createReviewForm(): void {
// Rendering form
}
}
Si nous avons besoin d’injecter des valeurs dans la page à l’aide du templating, il suffit que le controller renvoie un objet contenant les valeurs à afficher. Ici, nous n’en avons pas besoin. L’annotation @ApiExcludeEndpoint
évitera à cette route propre à l’interface de se retrouver dans le swagger.
Lorsque nous entrons l’url http://localhost:3000/interface
dans le navigateur, nous pouvons désormais voir notre formulaire :
Le design est très simple dans cet exemple, mais l’important est d’avoir une interface qui permet à des utilisateurs ne maîtrisant pas swagger d’utiliser notre application. On peut bien sûr faire des interfaces bien plus jolies que celle-ci !
On pourrait d’ailleurs l’étendre avec une autre page permettant de lister les séries pour lesquelles un avis a été posté, afficher dans un encart la série ayant obtenu la meilleure moyenne, etc. Chaque écran supplémentaire sera simplement un module de plus à ajouter dans l’application.
Les points forts et points faibles de NestJS
Nest possède de nombreux avantages lorsqu’il s’agit de démarrer une nouvelle application. D’abord, la CLI permet d’avoir immédiatement un projet opérationnel. L’architecture modulaire préconisée permet l’évolutivité et la maintenabilité dans le temps, en gardant la maîtrise de la complexité. Nest permet l’utilisation de n’importe quelle dépendance externe et ne se ferme pas à de nouveaux usages. La communauté est très réactive et de nombreux cas d’usage sont documentés.
En revanche, le framework est très riche et complexe, et on peut facilement se perdre dans la documentation lorsqu’on bloque sur un point très précis. D’ailleurs, il n’est pas rare de devoir rechercher sur Google comment faire une chose précise (par exemple, injecter un service dans un guard) plutôt que de se baser sur la documentation. D’ailleurs, cette documentation manque parfois de conseils sur des bonnes pratiques pour justement garantir la maintenabilité du projet.
Pour aller plus loin
Nest propose encore beaucoup d’extensions qui permettent d’enrichir son projet et que je n’ai pas présenté ici, mais qu’il peut être intéressant de découvrir. On peut citer par exemple des préconisations pour la mise en place de CQRS ou de health checks, ou encore l’outil de génération de documentation Compodoc.
Conclusion
Nest est un framework sur lequel je travaille personnellement au quotidien et qui tourne en production d’un site e-commerce connu. Il facilite grandement mon travail de développeuse car il apporte des réponses prêtes à l’emploi à des questions que tout projet soulève à un moment donné : évolutivité et maintenabilité dans le temps, sécurité, authentification, etc. Le framework est très riche, et ce qu’il ne fait pas peut être géré par des outils externes car il n’est pas fermé à l’extension à travers d’autres dépendances.
Il fait ce qu’on attend principalement d’un framework, c’est-à-dire qu’il nous soulage de la complexité de l’architecture du projet et nous laisse nous concentrer sur la complexité métier de notre application, celle qui apporte de la valeur à nos utilisateurs.
Top comments (0)