Passer au contenu

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.

Définition du UserModule
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:

Définition du 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:

Utilisation du UsersService dans AuthService
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 :

  1. Instanciant UsersModule, y compris en important transitivement d’autres modules que UsersModule consomme et en résolvant de manière transitive toutes les dépendances (voir Fournisseurs personnalisés).
  2. Instanciant AuthModule, et rendant les fournisseurs exportés de UsersModule disponibles aux composants de AuthModule (comme s’ils avaient été déclarés dans AuthModule).
  3. Injectant une instance de UsersService dans AuthService.

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.

:::tip
Pour 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() :

Exemple d'importation statique
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 :

Exemple d'importation dynamique
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 ?

  1. ConfigModule est une classe normale, nous pouvons donc en déduire qu’elle doit avoir une méthode statique appelée register(). Nous savons qu’elle est statique car nous l’appelons sur la classe ConfigModule, 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 soit forRoot(), soit register().
  2. 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 objet options avec des propriétés appropriées, ce qui est le cas typique.
  3. Nous pouvons en déduire que la méthode register() doit retourner quelque chose semblable à un module puisque sa valeur de retour apparaît dans la famille imports, 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 :

Exemple de module statique
@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.

:::tip
Pour 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 :

Exemple de configuration du 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.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).

Utilisation de l'objet options dans ConfigService
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 :

Exemple de binding des options
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.

Injection des options dans le ConfigService
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 :

Constante de jeton d'options
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, avec HttpModule de Nest : HttpModule.register({ baseUrl: 'someUrl' }). Si, dans un autre module, vous utilisez HttpModule.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 un GraphQLModule.forRoot(), un TypeOrmModule.forRoot(), etc.

  • forFeature, vous vous attendez à utiliser la configuration de la méthode forRoot 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.

Interface d'options du module
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.

Définition du 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 :

Mise à jour du ConfigModule
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 :

Exemple d'utilisation d'une méthode asynchrone
@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.

Nouvelle injection dans ConfigService
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 :

Modification du nom de méthode
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 :

Exemple d'utilisation de forRoot
@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.

Exemple d'utilisation d'une usine d'options
@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.

Exemple de méthode d'options personnalisée
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) :

Exemple d'une usine d'options
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 :

Exemple d'utilisation d'options supplémentaires
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 :

Consommation d'un module global
@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 :

Injection d'options dans ConfigService
@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 :

Extension des méthodes de module
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.