Modules dynamiques
Le chapitre Modules couvre les bases des modules Nest et inclut une brève introduction aux modules dynamiques. Ce chapitre approfondit le sujet des modules dynamiques. À la fin, vous devriez avoir une bonne compréhension de ce que sont ces modules et comment et quand les utiliser.
Introduction
La plupart des exemples de code d’application dans la section Vue d’ensemble de la documentation utilisent des modules réguliers ou statiques. Les modules définissent des groupes de composants comme les fournisseurs et les contrôleurs qui s’intègrent en tant que partie modulaire d’une application globale. Ils fournissent un contexte d’exécution ou une portée pour ces composants. Par exemple, les fournisseurs définis dans un module sont visibles aux autres membres du module sans avoir besoin de les exporter. Lorsqu’un fournisseur doit être visible en dehors d’un module, il est d’abord exporté depuis son module hôte, puis importé dans son module consommateur.
Voyons un exemple familier.
D’abord, nous allons définir un UsersModule
pour fournir et exporter un UsersService
. UsersModule
est le module hôte pour UsersService
.
import { Module } from '@nestjs/common';import { UsersService } from './users.service';
@Module({ providers: [UsersService], exports: [UsersService],})export class UsersModule {}
Ensuite, nous allons définir un AuthModule
, qui importe UsersModule
, rendant les fournisseurs exportés de UsersModule
disponibles à l’intérieur de AuthModule
:
import { Module } from '@nestjs/common';import { AuthService } from './auth.service';import { UsersModule } from '../users/users.module';
@Module({ imports: [UsersModule], providers: [AuthService], exports: [AuthService],})export class AuthModule {}
Ces constructions nous permettent d’injecter UsersService
dans, par exemple, le AuthService
qui est hébergé dans AuthModule
:
import { Injectable } from '@nestjs/common';import { UsersService } from '../users/users.service';
@Injectable()export class AuthService { constructor(private usersService: UsersService) {}
/* Implémentation qui utilise this.usersService */}
Nous appellerons cela la liaison de module statique. Toutes les informations dont Nest a besoin pour relier les modules ont déjà été déclarées dans les modules hôtes et consommateurs. Décomposons ce qui se passe durant ce processus. Nest rend UsersService
disponible à l’intérieur de AuthModule
en :
- Instanciant
UsersModule
, y compris en important transitivement d’autres modules queUsersModule
consomme et en résolvant de manière transitive toutes les dépendances (voir Fournisseurs personnalisés). - Instanciant
AuthModule
, et rendant les fournisseurs exportés deUsersModule
disponibles aux composants deAuthModule
(comme s’ils avaient été déclarés dansAuthModule
). - Injectant une instance de
UsersService
dansAuthService
.
Cas d’utilisation des modules dynamiques
Avec la liaison de module statique, il n’y a aucune opportunité pour le module consommateur d’influencer la manière dont les fournisseurs du module hôte sont configurés. Pourquoi cela est-il important ? Considérons le cas où nous avons un module polyvalent qui doit se comporter différemment selon les cas d’utilisation. Cela est analogue au concept de “plugin” dans de nombreux systèmes, où un dispositif générique nécessite une certaine configuration avant de pouvoir être utilisé par un consommateur.
Un bon exemple avec Nest est un module de configuration. De nombreuses applications trouvent utile d’externaliser les détails de configuration en utilisant un module de configuration. Cela permet de changer dynamiquement les paramètres de l’application dans différentes déployements : par exemple, une base de données de développement pour les développeurs, une base de données de mise en scène pour l’environnement de test, etc. En délégant la gestion des paramètres de configuration à un module de configuration, le code source de l’application reste indépendant des paramètres de configuration.
Le défi est que le module de configuration lui-même, puisqu’il est générique (similaire à un “plugin”), doit être personnalisé par son module consommateur. C’est là que les modules dynamiques entrent en jeu. En utilisant les fonctionnalités des modules dynamiques, nous pouvons rendre notre module de configuration dynamique afin que le module consommateur puisse utiliser une API pour contrôler comment le module de configuration est personnalisé au moment de son importation.
En d’autres termes, les modules dynamiques fournissent une API pour importer un module dans un autre et personnaliser les propriétés et le comportement de ce module lors de son importation, par opposition à l’utilisation des liaisons statiques que nous avons vues jusqu’à présent.
:::tipPour un module dynamique, toutes les propriétés de l'objet options de module sont optionnelles **sauf** `module`.:::
Exemple de module de configuration
Nous allons utiliser la version de base du code d’exemple du chapitre de configuration pour cette section. La version complétée à la fin de ce chapitre est disponible en tant qu’exemple fonctionnel ici.
Notre exigence est de faire en sorte que ConfigModule
accepte un objet options
pour le personnaliser. Voici la fonctionnalité que nous souhaitons prendre en charge. L’exemple de base codifie en dur l’emplacement du fichier .env
pour qu’il soit dans le dossier racine du projet. Supposons que nous souhaitions le rendre configurable, afin que vous puissiez gérer vos fichiers .env
dans n’importe quel dossier de votre choix. Par exemple, imaginez que vous souhaitez stocker vos différents fichiers .env
dans un dossier sous la racine du projet appelé config
(c’est-à-dire un dossier voisin de src
). Vous souhaitez pouvoir choisir différents dossiers lors de l’utilisation du ConfigModule
dans différents projets.
Les modules dynamiques nous donnent la possibilité de passer des paramètres dans le module à importer afin que nous puissions changer son comportement. Voyons comment cela fonctionne. Il est utile de commencer par l’objectif final de la manière dont cela pourrait ressembler du point de vue du module consommateur, puis de travailler à rebours. Tout d’abord, examinons rapidement l’exemple d’importation statiquement du ConfigModule
(c’est-à-dire une approche qui n’a aucune capacité à influencer le comportement du module importé). Faites attention au tableau imports
dans le décorateur @Module()
:
import { Module } from '@nestjs/common';import { AppController } from './app.controller';import { AppService } from './app.service';import { ConfigModule } from './config/config.module';
@Module({ imports: [ConfigModule], controllers: [AppController], providers: [AppService],})export class AppModule {}
Regardons ce à quoi une importation de module dynamique pourrait ressembler, où nous passons un objet de configuration. Comparez la différence dans le tableau imports
entre ces deux exemples :
import { Module } from '@nestjs/common';import { AppController } from './app.controller';import { AppService } from './app.service';import { ConfigModule } from './config/config.module';
@Module({ imports: [ConfigModule.register({ folder: './config' })], controllers: [AppController], providers: [AppService],})export class AppModule {}
Voyons ce qui se passe dans l’exemple dynamique ci-dessus. Quels sont les éléments en mouvement ?
ConfigModule
est une classe normale, nous pouvons donc en déduire qu’elle doit avoir une méthode statique appeléeregister()
. Nous savons qu’elle est statique car nous l’appelons sur la classeConfigModule
, pas sur une instance de la classe. Remarque : cette méthode, que nous créerons bientôt, peut avoir n’importe quel nom arbitraire, mais par convention, nous devrions l’appeler soitforRoot()
, soitregister()
.- La méthode
register()
est définie par nous, donc nous pouvons accepter n’importe quel argument d’entrée que nous voulons. Dans ce cas, nous allons accepter un simple objetoptions
avec des propriétés appropriées, ce qui est le cas typique. - Nous pouvons en déduire que la méthode
register()
doit retourner quelque chose semblable à unmodule
puisque sa valeur de retour apparaît dans la familleimports
, qui, comme nous l’avons vu jusqu’à présent, inclut une liste de modules.
En fait, ce que notre méthode register()
retournera est un DynamicModule
. Un module dynamique n’est rien d’autre qu’un module créé à l’exécution, avec exactement les mêmes propriétés qu’un module statique, plus une propriété supplémentaire appelée module
. Jetons rapidement un coup d’œil à un exemple de déclaration de module statique, en prêtant une attention particulière aux options du module passées dans le décorateur :
@Module({ imports: [DogsModule], controllers: [CatsController], providers: [CatsService], exports: [CatsService],})export class CatsModule {}
Les modules dynamiques doivent renvoyer un objet avec exactement la même interface, plus une propriété supplémentaire appelée module
. La propriété module
sert de nom au module, et doit être identique au nom de la classe du module, comme indiqué dans l’exemple ci-dessous.
:::tipPour un module dynamique, toutes les propriétés de l'objet options de module sont optionnelles **sauf** `module`.:::
Configuration de module
La solution évidente pour personnaliser le comportement du ConfigModule
est de lui passer un objet options
dans la méthode statique register()
, comme nous l’avons deviné ci-dessus. Examinons à nouveau la propriété imports
de notre module consommateur :
import { Module } from '@nestjs/common';import { AppController } from './app.controller';import { AppService } from './app.service';import { ConfigModule } from './config/config.module';
@Module({ imports: [ConfigModule.register({ folder: './config' })], controllers: [AppController], providers: [AppService],})export class AppModule {}
Cela gère parfaitement le passage d’un objet options
à notre module dynamique. Comment utilisons-nous ensuite cet objet options
dans le ConfigModule
? Réfléchissons-y un moment. Nous savons que notre ConfigModule
est essentiellement un hôte pour fournir et exporter un service injectable - le ConfigService
- pour être utilisé par d’autres fournisseurs. En fait, c’est notre ConfigService
qui doit lire l’objet options
pour personnaliser son comportement. Supposons un instant que nous savons comment amener de quelque manière que ce soit les options
de la méthode register()
dans le ConfigService
. Avec cette hypothèse, nous pouvons apporter quelques changements au service pour personnaliser son comportement en fonction des propriétés de l’objet options
. (Remarque : pour le moment, comme nous n’avons pas réellement déterminé comment le passer, nous allons juste coder en dur options
. Nous corrigerons cela dans une minute).
import { Injectable } from '@nestjs/common';import * as dotenv from 'dotenv';import * as fs from 'fs';import * as path from 'path';import { EnvConfig } from './interfaces';
@Injectable()export class ConfigService { private readonly envConfig: EnvConfig;
constructor() { const options = { folder: './config' };
const filePath = `${process.env.NODE_ENV || 'development'}.env`; const envFile = path.resolve(__dirname, '../../', options.folder, filePath); this.envConfig = dotenv.parse(fs.readFileSync(envFile)); }
get(key: string): string { return this.envConfig[key]; }}
Maintenant, notre ConfigService
sait comment trouver le fichier .env
dans le dossier que nous avons spécifié dans options
.
Notre tâche restante est d’injecter d’une manière ou d’une autre l’objet options
à partir de l’étape register()
dans notre ConfigService
. Et bien sûr, nous utiliserons l’injection de dépendance pour le faire. C’est un point clé, donc assurez-vous de le comprendre. Notre ConfigModule
fournit ConfigService
. ConfigService
dépend à son tour de l’objet options
qui n’est fourni qu’à l’exécution. Ainsi, à l’exécution, nous devrons d’abord lier l’objet options
au conteneur IoC de Nest, puis faire en sorte que Nest l’injecte dans notre ConfigService
. Rappelez-vous du chapitre Fournisseurs personnalisés que les fournisseurs peuvent inclure n’importe quelle valeur et pas seulement des services, donc nous sommes bons à utiliser l’injection de dépendance pour gérer un simple objet options
.
Regardons d’abord le liage de l’objet qui passera au conteneur IoC. Nous faisons cela dans notre méthode statique register()
. Rappelez-vous que nous construisons de manière dynamique un module, et l’une des propriétés d’un module est sa liste de fournisseurs. Ce que nous devons faire est de définir notre objet options
comme un fournisseur. Cela le rendra injectable dans le ConfigService
, dont nous allons tirer parti à l’étape suivante. Dans le code ci-dessous, faites attention au tableau providers
:
import { DynamicModule, Module } from '@nestjs/common';import { ConfigService } from './config.service';
@Module({})export class ConfigModule { static register(options: Record<string, any>): DynamicModule { return { module: ConfigModule, providers: [ { provide: 'CONFIG_OPTIONS', useValue: options, }, ConfigService, ], exports: [ConfigService], }; }}
Maintenant, nous pouvons terminer le processus en injectant le fournisseur 'CONFIG_OPTIONS'
dans le ConfigService
. Rappelez-vous qu’en définissant un fournisseur à l’aide d’un jeton non-classe, nous devons utiliser le décorateur @Inject()
comme décrit ici.
import * as dotenv from 'dotenv';import * as fs from 'fs';import * as path from 'path';import { Injectable, Inject } from '@nestjs/common';import { EnvConfig } from './interfaces';
@Injectable()export class ConfigService { private readonly envConfig: EnvConfig;
constructor( @Inject('CONFIG_OPTIONS') private options: Record<string, any>, ) { const filePath = `${process.env.NODE_ENV || 'development'}.env`; const envFile = path.resolve(__dirname, '../../', options.folder, filePath); this.envConfig = dotenv.parse(fs.readFileSync(envFile)); }
get(key: string): string { return this.envConfig[key]; }}
Une dernière note : pour plus de simplicité, nous avons utilisé un jeton d’injection basé sur une chaîne ('CONFIG_OPTIONS'
) ci-dessus, mais la meilleure pratique consiste à le définir comme une constante (ou un Symbol
) dans un fichier séparé et à importer ce fichier. Par exemple :
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
Exemple
Un exemple complet du code dans ce chapitre peut être trouvé ici.
Directives de la communauté
Vous avez peut-être vu l’utilisation de méthodes comme forRoot
, register
, et forFeature
dans certains des paquets @nestjs/
et vous vous demandez quelle est la différence entre toutes ces méthodes. Il n’y a pas de règle stricte à ce sujet, mais les paquets @nestjs/
essaient de suivre ces directives :
Lors de la création d’un module avec :
-
register
, vous vous attendez à configurer un module dynamique avec une configuration spécifique uniquement pour le module appelant. Par exemple, avecHttpModule
de Nest :HttpModule.register({ baseUrl: 'someUrl' })
. Si, dans un autre module, vous utilisezHttpModule.register({ baseUrl: 'somewhere else' })
, il aura une configuration différente. Vous pouvez faire cela pour autant de modules que vous voulez. -
forRoot
, vous vous attendez à configurer un module dynamique une fois et à réutiliser cette configuration à plusieurs endroits (même si cela pourrait se faire sans que cela soit dû à l’abstraction). C’est pourquoi vous avez unGraphQLModule.forRoot()
, unTypeOrmModule.forRoot()
, etc. -
forFeature
, vous vous attendez à utiliser la configuration de la méthodeforRoot
d’un module dynamique mais à devoir modifier certaines configurations spécifiques aux besoins du module appelant (c’est-à-dire, quel dépôt ce module devrait avoir accès ou le contexte que doit utiliser un logger).
Toutes ces méthodes ont généralement leurs contreparties async
, registerAsync
, forRootAsync
, et forFeatureAsync
, qui signifient la même chose, mais utilisent l’injection de dépendance de Nest pour la configuration également.
Constructeur de module configurable
Comme créer manuellement des modules dynamiques hautement configurables exposant des méthodes async
(registerAsync
, forRootAsync
, etc.) est assez compliqué, surtout pour les débutants, Nest expose la classe ConfigurableModuleBuilder
qui facilite ce processus et vous permet de construire un “plan” de module en seulement quelques lignes de code.
Par exemple, prenons l’exemple que nous avons utilisé ci-dessus (ConfigModule
) et convertissons-le pour utiliser le ConfigurableModuleBuilder
.Avant de commencer, assurons-nous de créer une interface dédiée qui représente quelles options notre ConfigModule
prend en charge.
export interface ConfigModuleOptions { folder: string;}
Avec cela en place, créez un nouveau fichier dédié (à côté du fichier existant config.module.ts
) et nommez-le config.module-definition.ts
. Dans ce fichier, utilisons le ConfigurableModuleBuilder
pour construire la définition de ConfigModule
.
import { ConfigurableModuleBuilder } from '@nestjs/common';import { ConfigModuleOptions } from './interfaces/config-module-options.interface';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ConfigModuleOptions>().build();
Maintenant, ouvrons le fichier config.module.ts
et modifions son implémentation pour tirer parti de la classe ConfigurableModuleClass
générée automatiquement :
import { Module } from '@nestjs/common';import { ConfigService } from './config.service';import { ConfigurableModuleClass } from './config.module-definition';
@Module({ providers: [ConfigService], exports: [ConfigService],})export class ConfigModule extends ConfigurableModuleClass {}
Élargir la classe ConfigurableModuleClass
signifie que ConfigModule
fournit maintenant non seulement la méthode register
(comme auparavant avec l’implémentation personnalisée), mais également la méthode registerAsync
qui permet aux consommateurs de configurer le module de manière asynchrone, par exemple, en fournissant des fabriques asynchrones :
@Module({ imports: [ ConfigModule.register({ folder: './config' }), // ou alternativement : // ConfigModule.registerAsync({ // useFactory: () => { // return { // folder: './config', // }; // }, // inject: [...toutes dépendances supplémentaires...], // }), ],})export class AppModule {}
Enfin, mettons à jour la classe ConfigService
pour injecter le fournisseur d’options du module généré à la place de 'CONFIG_OPTIONS'
que nous avons utilisé jusqu’à présent.
import { Injectable, Inject } from '@nestjs/common';import { ConfigModuleOptions } from './interfaces/config-module-options.interface';
@Injectable()export class ConfigService { constructor( @Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions, ) { // Utilisez this.options ici }}
Clé de méthode personnalisée
ConfigurableModuleClass
fournit par défaut les méthodes register
et registerAsync
. Pour utiliser un nom de méthode différent, utilisez la méthode ConfigurableModuleBuilder#setClassMethodName
, comme suit :
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ConfigModuleOptions>() .setClassMethodName('forRoot') .build();
Cette construction donnera pour instruction à ConfigurableModuleBuilder
de générer une classe qui expose forRoot
et forRootAsync
à la place. Exemple :
@Module({ imports: [ ConfigModule.forRoot({ folder: './config' }), // ou alternativement : // ConfigModule.forRootAsync({ // useFactory: () => { // return { // folder: './config', // }; // }, // inject: [...toutes dépendances supplémentaires...], // }), ],})export class AppModule {}
Classe d’usine d’options personnalisée
La méthode registerAsync
(ou forRootAsync
ou tout autre nom, selon la configuration) permet au consommateur de passer une définition de fournisseur qui résout à la configuration du module, un consommateur de bibliothèque pourrait potentiellement fournir une classe à utiliser pour construire l’objet de configuration.
@Module({ imports: [ ConfigModule.registerAsync({ useClass: ConfigModuleOptionsFactory, }), ],})export class AppModule {}
Cette classe, par défaut, doit fournir la méthode create()
qui retourne un objet de configuration de module. Cependant, si votre bibliothèque suit une convention de nommage différente, vous pouvez modifier ce comportement et indiquer à ConfigurableModuleBuilder
d’attendre un méthode différente, par exemple createConfigOptions
, en utilisant la méthode ConfigurableModuleBuilder#setFactoryMethodName
.
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ConfigModuleOptions>() .setFactoryMethodName('createConfigOptions') .build();
Maintenant, la classe ConfigModuleOptionsFactory
doit exposer la méthode createConfigOptions
(au lieu de create
) :
export class ConfigModuleOptionsFactory { createConfigOptions(): ConfigModuleOptions { return { folder: './config', }; }}
Options supplémentaires
Il existe des cas limites lorsque votre module peut nécessiter des options supplémentaires qui déterminent son comportement (un bel exemple de cette option est le drapeau isGlobal
- ou simplement global
) qui, en même temps, ne devraient pas être inclus dans le fournisseur MODULE_OPTIONS_TOKEN
(car elles sont sans rapport avec les services/fournisseurs enregistrés dans ce module, par exemple, ConfigService
n’a pas besoin de savoir si son module hôte est enregistré comme un module global).
Dans de tels cas, la méthode ConfigurableModuleBuilder#setExtras
peut être utilisée. Voir l’exemple suivant :
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ConfigModuleOptions>() .setExtras( { isGlobal: true, }, (definition, extras) => ({ ...definition, global: extras.isGlobal, }), ) .build();
Dans l’exemple ci-dessus, le premier argument passé à la méthode setExtras
est un objet contenant des valeurs par défaut pour les propriétés “supplémentaires”. Le deuxième argument est une fonction qui prend une définition de module auto-générée (avec provider
, exports
, etc.) et un objet extras
qui représente des propriétés supplémentaires (soit spécifiées par le consommateur soit des valeurs par défaut). La valeur retournée de cette fonction est une définition de module modifiée. Dans cet exemple en particulier, nous prenons la propriété extras.isGlobal
et l’assignons à la propriété global
de la définition du module (ce qui détermine à son tour si un module est global ou non, lisez plus ici).
Maintenant, lors de la consommation de ce module, le drapeau isGlobal
supplémentaire peut être passé, comme suit :
@Module({ imports: [ ConfigModule.register({ isGlobal: true, folder: './config', }), ],})export class AppModule {}
Cependant, comme isGlobal
est déclaré comme une propriété “supplémentaire”, il ne sera pas disponible dans le fournisseur MODULE_OPTIONS_TOKEN
:
@Injectable()export class ConfigService { constructor( @Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions, ) { // "options" n'aura pas la propriété "isGlobal" // ... }}
Extension des méthodes auto-générées
Les méthodes statiques auto-générées (register
, registerAsync
, etc.) peuvent être étendues si nécessaire, comme suit :
import { Module } from '@nestjs/common';import { ConfigService } from './config.service';import { ConfigurableModuleClass, ASYNC_OPTIONS_TYPE, OPTIONS_TYPE } from './config.module-definition';
@Module({ providers: [ConfigService], exports: [ConfigService],})export class ConfigModule extends ConfigurableModuleClass { static register(options: typeof OPTIONS_TYPE): DynamicModule { return { // votre logique personnalisée ici ...super.register(options), }; }
static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { return { // votre logique personnalisée ici ...super.registerAsync(options), }; }}
Notez l’utilisation des types OPTIONS_TYPE
et ASYNC_OPTIONS_TYPE
qui doivent être exportés du fichier de définition de module.