Nest : Le framework Node.js qu'il nous fallait

nestjs



Nest ne vous a probablement pas échappé si vous faites de la veille technique... Ou peut-être que si. Après tout, 0 jour s'est écoulé depuis la sortie d'un nouveau framework JavaScript . Mais alors pourquoi prendrait-on la peine d'en découvrir un de plus ?


Introduction

Avec l'essor fulgurant qu'a connu le développement web ces 10 dernières années, l'environnement frontend a considérablement évolué : l'apparition de frameworks et libs comme Angular, React, ou Vue.js, ont permis aux développeurs d'être plus productifs, tout en construisant des applications plus volumineuses et élégantes, sans que leurs performances ne soient lésées.

Dans l'environnement backend, cependant, malgré un écosystème gigantesque, de puissants outils et une communauté pro-active, un problème persiste : comment architecturer nos applications ?


L'existant

Express est le plus poulaire des frameworks web Node.js. Il est aussi le plus unopinionated (sic), soit celui qui laisse le plus de choix aux développeurs en terme d'architecture applicative. Malheureusement, trop de choix entraîne souvent la prise d'aucune (bonne) décision.

La plupart des faits suivants concernent aussi Hapi, Koa ou Fastify.

Contrôleurs

Dans un projet Express "traditionnel", il est fréquent de voir des contrôleurs ressemblant à cela :

ctrl app = express.Router();

app.get('/companies/:id', (req, res) => {  
  Companies.findById(req.params.id, (err, company) => {
    if (!err) {
      return res.json(company);
    }
  });
});

On en trouve des similaires, parfois par centaines, agrégés dans des arborescences de fichiers construites totalement arbitrairement.

En l'état, ce contrôleur est pénible à tester. On peut extraire son callback dans une class dédiée, puis améliorer sa lisibilité en utilisant async/await et une version promisified de findById :

ctrl app = express.Router();

app.get('/companies/:id', companiesController.getById);

// companies-controller.js
export class CompaniesController {  
  async getById(req, res) {
    const company = await Companies.findById(req.params.id).exec();
    return res.json(company); 
  }
}

La gestion des erreurs est ignorée pour la simplicité des exemples.

C'est mieux ! Le contrôleur est plus facilement testable. On évite aussi le callback hell.
Pour autant, est-ce suffisant ? Du point de vue de la testabilité et du découplage, notre code souffre toujours de la présence de req et res (dans une moindre mesure, app).

Avec Nest, voici à quoi ressemble un contrôleur équivalent :

@Get('/companies/:id')
getById(@Param('id') companyId: string) {  
  return Companies.findById(companyId).exec();
}

Exit le couplage fort à Express (req, res, app) et le mocking qui l'accompagnait dans les tests unitaires. Une simple fonction, facilement testable, indépendante de toute couche de transport (HTTP, WebSocket, ...), retournant une valeur synchrone ou asynchrone (Promise, Observable) .

Middlewares

Si l'on souhaite exécuter avant notre contrôleur des actions assez simples telles que :

  • L'authentification de l’émetteur de la requête,
  • Du logging,
  • La validation des données d'entrée,
  • La transformation des données d'entrée,

La solution de facto dans une application Express est le middleware : une fonction exécutée entre l'étape de routing et le contrôleur.

express-middlewares Dans les faits, un middleware peut interrompre un cycle Requête-Réponse en retournant une réponse.

Ce qui peut se traduire comme ceci :

ctrl app = express.Router();

app.get(  
  '/companies/:id', 
  // router-level middlewares
  [fnAuthenticate, fnLog, fnValidate], 
  // controller : fait partie de la série
  companiesController.createCompany
);

On peut bien sûr en exécuter après un contrôleur. Et il existe aussi des middlewares spéciaux permettant la gestion des erreurs HTTP (404, 500, ...).

Par conséquent, dans bien des projets, on confie aux middlewares : l'authentification, le logging, la transformation et la validation des données d'entrée/sortie, la gestion des erreurs, ...
Tout va bien ! Express est décrit comme un framework de routing/middlewares dont les applications sont essentiellement des séries de Middlewares. Il n'y a donc rien d'anormal à ce que toute l'application soit construite comme telle... Pour autant, est-ce suffisant ?

Quid de l'évolutivité, de la testabilité et de la maintenabilité d'une telle application ? Quelles meilleures pratiques suivre ? Faut-il les définir en interne et s'assurer que chaque nouveau collaborateur les applique ?


NestJS

Et la question fatidique... Qu'apporte réellement Nest en terme de productivité, de maintenabilité, de testabilité et d'architecture ?


Typescript

Conçu avec TypeScript pour des applications TypeScript, Nest exploite et apporte toute la puissance du langage (que j'ai longuement présenté ici), y compris toutes les fonctionnalités d'ES2015, ES2016 et ES2017. De fait, Node.js v8.9+ est recommandé pour un support complet. Sinon, transpilez vers une target plus ancienne.

Bien qu'il soit possible de développer une application Nest avec JavaScript et Babel, il est recommandé d'utiliser TypeScript pour une expérience optimale.
  "Types are priceless" - Kamil Myśliwiec


Modularité

La philosophie du framework est fidèle aux principes de conception SOLID ainsi qu'à la séparation des intérêts. À ce titre, le code d'une application Nest se découpe généralement en Modules constitués de Composants ou providers et éventuellement de Contrôleurs.

@Module({
  imports: [OAuthModule],
  controllers: [AuthController],
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}  

Les développeurs Angular noteront la proximité syntaxique avec @NgModule.

Cette séparation du code par tâche logique (ou métier) en modules encourage l'isolation et la réutilisation des composants de l'application.


Injection de dépendances

Les controllers et providers peuvent injecter (via leur constructeur) d'autres providers grâce au conteneur d'injection de dépendances de Nest.

@Controller('auth')
export class AuthController {  
  // this.authService est assigné dans le constructeur
  constructor(private readonly authService: AuthService) {}
}

Il est aussi possible de définir des providers personnalisés (et asynchrones), afin d'injecter des bibliothèques et modules npm, des valeurs calculées dynamiquement... Par exemple :

// user-service.provider.ts
export const USER_SERVICE = 'UserService';

export const UserServiceProvider = {  
  provide: USER_SERVICE,
  useClass: FirebaseService,
};

Nest injecte les dépendances grâce à un token : on utilise USER_SERVICE pour identifier notre service de gestion des utilisateurs.

@Inject() indique à Nest quoi injecter, quand l'annotation de type ne suffit pas à l'identifier :

// auth.service.ts : Injection + Typage par Interface
constructor(@Inject(USER_SERVICE) private readonly userService: IUserService) {}  

Ici, on admet évidemment que FirebaseService implémente l'interface IUserService et qu'UserServiceProvider a été ajouté aux providers d'AuthModule.
  AuthService est désormais découplé : il dépend d'une implémentation d'IUserService mais ignore laquelle.

Ce n'est pas la façon la plus élégante de régler ce problème, mais elle a le mérite de ne pas trop alourdir l'article. Si le sujet vous intéresse, n'hésitez pas à m'en faire part.


Testabilité

Le test des composants est très largement facilité par :

  • L'injection de dépendance : toute dépendance peut être facilement simulée (mock) avant d'être passée en paramètre d'un constructeur.
  • L'aspect framework agnostic de Nest : il n'y a pas (ou rarement) besoin de mock des objets propres à Express (req, res...) et toute dépendance externe (package npm, etc) peut aussi être injectée à l'aide d'un custom provider.

Le test de l'application (End-to-End) est quelque peu facilité par la modularité de celle-ci. En effet, Nest fournit un TestingModule dans lequel on importe un à plusieurs modules pour lancer notre application.
Grâce (encore) aux custom providers, on peut, au besoin, remplacer certains composants : par exemple, pour tester différentes implémentations d'une interface ILambdaService (AWS Lambda, Google Cloud Functions, ...) ou deux états d'une même implémentation (service disponible et service hors-ligne/en maintenance).


Gestion des erreurs

Autre fonctionnalité, les filtres d'exception permettent la gestion des exceptions lancées par l'application. Dans un contrôleur Nest, aucun return res.status(404).end(), ni même d'import du brave boom. Le framework inclut des classes NotFoundException, ForbiddenException, etc, toutes filles de HttpException.

@Get()
async findOne(@Param('id') userId: string) {  
  const user = await this.userService.findOne({ userId });

  if (!user) {
    throw new NotFoundException();
  }

  return user;
}

Les exception filters sont exécutés en fin de chaîne, pour les types d'exceptions qu'on leur attribue (si aucun type n'est attribué, alors le filtre est exécuté pour chaque exception). C'est dans la méthode catch du filtre que l'on va retourner une réponse au client.

  Les Exception Filters peuvent injecter des dépendances

// filtre pour : NotFoundException et ForbiddenException
@Catch(NotFoundException, ForbiddenException)
export class HttpExceptionFilter implements ExceptionFilter {  
  constructor(private readonly loggerService: LoggerService) {}

  catch(exception: HttpException, host: ArgumentsHost) {
    this.loggerService.warn('Uncaught exception', exception);
    // dans le cas d'un cycle Requête-Réponse via HTTP
    const res = host.switchToHttp().getResponse();
    // par exemple :
    return res.status(exception.getStatus());
  }
}

Pipes

Les Pipes permettent la transformation et/ou la validation d'une donnée d'entrée. Prenons l'exemple d'un contrôleur permettant l'écriture d'un commentaire dans un article :

@Post('article/:articleId/comment')
createComment(  
  @Param('articleId') articleId: string,
  @Body(new ValidationPipe({ whitelist: true })) comment: CreateCommentDto,
) {
  return this.articleService.addComment(comment);
}

Il est facile de créer ses propres Pipes, mais on utilise ici un Pipe intégré : ValidationPipe. Il effectue la validation d'une donnée passée en entrée, en fonction d'un modèle défini en amont (ici, CreateCommentDto), à l'aide du paquet class-validator et de ses précieux décorateurs :

export class CreateCommentDto {  
  @IsString()
  @IsNotEmpty()
  @MinLength(10)
  content: string;

  @IsString()
  @IsNotEmpty()
  @IsEmail()
  author: string;
}

Par défaut, si le corps de la requête (d'où le décorateur @Body()) ne correspond pas au modèle attendu, ValidationPipe interrompt le cycle Requête-Réponse pour lancer une BadRequestException (aka Error 400).

  Les Pipes peuvent injecter des dépendances


Interceptors

Exécutés avant et après un contrôleur, les Interceptors peuvent, entre autres :

  • Transformer la valeur (ou l'exception) retournée par un contrôleur (avant de l'envoyer)
  • Empêcher l'exécution d'un contrôleur (en répondant à sa place) sous certaines conditions

Cette manipulation de flux est possible grâce au puissant RxJS.

Voici un exemple d'intercepteur (tiré de la documentation de Nest) qui interrompt le cycle Requête-Réponse en émettant une erreur si aucune valeur n'est émise sur le flux (nommé ici call$) sous 5 secondes (cf. timeout).

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {  
  intercept(
    context: ExecutionContext,
    call$: Observable<any>,
  ): Observable<any> {
    return call$.pipe(timeout(5000))
  }
}

  Les Interceptors peuvent injecter des dépendances


Guards

Un Guard détermine si le contrôleur gardé doit traiter la requête entrante. Dans le cas contraire, une exception ForbiddenException (aka Error 403) est lancée.
Les guards sont exécutés après les Middlewares et les Pipes, mais avant les Interceptors.

Ils permettent de résumer à un ou deux décorateurs le contrôle d'accès à un contrôleur (ou à toute une classe Controller, ou à toute l'application) :

@Post()
@UseGuards(RolesGuard)
@Roles('admin')
async createCompany(@Body() createCompanyDto: CreateCompanyDto) {  
  this.companyService.create(createCompanyDto);
}

Dans cet exemple, on a créé un décorateur Roles qui ajoute de la metadata à notre méthode createCompany :

import { ReflectMetadata } from '@nestjs/common';  
export const Roles = (...roles: string[]) => ReflectMetadata('roles', roles);  

Le guard RolesGuard a accès à createCompany et peut lire cette metadata. Ainsi, les utilisateurs n'ayant pas le rôle admin se verront refuser le traitement de leur requête :

@Injectable()
export class RolesGuard implements CanActivate {  
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    // ... logique d'autorisation
  }
}

Bien sûr, on peut imaginer toutes sortes de Guards : utiliser une strategy passportjs, dépendre d'une base de données ou d'un service tiers, etc.

  Les Guards peuvent injecter des dépendances et être asynchrones


Outils et intégrations

Parce qu'il est impossible de faire le tour complet de Nest en un seul article, mais qu'ils sont quand même un argument majeur du framework, notez qu'il existe des intégrations TypeScript et outils pour :

Liste non exhaustive


Conclusion

En résumé, Nest est : un cadre structurant encourageant les bonnes pratiques, framework agnostic, portant une architecture modulaire, offrant de l'injection de dépendances et de puissants composants tels que les Pipes et Interceptors, accompagné d'intégrations élégantes, le tout écrit avec/pour TypeScript.

Bien que ça n'ait pas été abordé, Nest permet aussi la création d'application de type CLI, script, etc.