Tokens d’injection NestJS : pourquoi useClass peut dupliquer silencieusement des singletons
Comprendre les types, les tokens et les fournisseurs dans le DI de NestJS, pourquoi les interfaces ne peuvent pas être injectées, et comment useExisting évite deux instances du même service à état.
L’injection de dépendances dans NestJS semble simple au premier abord : il suffit de déclarer un paramètre dans le constructeur, et le framework fournit une instance prête à l’emploi. Cette facilité cache en réalité une correspondance entre les types TypeScript, les tokens de temps de exécution et les définitions des fournisseurs ; si cette correspondance n’est pas bien comprise, cela entraîne des bugs difficiles à détecter. On peut ainsi rencontrer des caches dupliqués, des pools de bases de données supplémentaires ou des mocks qui ne se résolvent jamais. Cet article propose un modèle mental précis de la manière dont NestJS résout les dépendances, explique pourquoi les interfaces ne peuvent pas servir de clés d’injection, et montre comment une seule option de fournisseur détermine si un service disposera d’une instance ou de deux.
De l’interconnexion automatique aux tokens explicites
La première rencontre typique avec l’injection de dépendances dans NestJS concerne un constructeur comme celui-ci :
constructor(
private readonly emailService: EmailService,
) {}
Pour une dépendance de classe, NestJS lit les métadonnées émises par TypeScript concernant les types des paramètres de constructeur (activé par emitDecoratorMetadata) et utilise la classe elle-même pour trouver le fournisseur correspondant. Aucune annotation supplémentaire n’est nécessaire.
Les bases de code plus grandes ont souvent un aspect différent :
constructor(
@Inject('EMAIL_SERVICE')
private readonly emailService: EmailService,
) {}
Si le paramètre est déjà typé, qu’ajoute @Inject() et pourquoi le passage de useClass à useExisting peut-il faire varier le nombre d’instances obtenues (une ou deux) ? Pour y répondre, il faut distinguer trois concepts qui portent souvent le même nom.
Le type, le token et le fournisseur sont trois choses différentes
Les débutants les considèrent souvent comme un seul concept car, dans les cas simples, ils partagent un identifiant :
- Type : ce que le compilateur TypeScript utilise pour vérifier votre code. Il n’existe qu’à l’heure de la compilation.
Avec l’injection basée uniquement sur des classes, une même classe remplit à la fois le rôle du type TypeScript et celui du token NestJS :
CustomLoggerService
↓
NestJS Token
+
TypeScript Type
Un token personnalisé sépare ces rôles. Dans le constructeur suivant, la chaîne de caractères et l’annotation de type servent à des fins complètement différentes :
constructor(
@Inject('logger')
private readonly logger: CustomLoggerService,
)
Décomposées, les responsabilités sont les suivantes :
@Inject('logger')
↓
NestJS Token (Finds the provider in memory)
: CustomLoggerService
↓
TypeScript Type (Gives you IDE autocomplete)
Le token est ce que NestJS utilise pour trouver l’instance en temps de exécution. L’annotation de type ne sert qu’au compilateur et à votre éditeur pour connaître sa structure, aux fins de vérification de type et d’autocomplétion. Une façon concise de le voir : le token localise la boîte, tandis que le type décrit son contenu. Notez que NestJS ne vérifie pas si les deux correspondent ; si le fournisseur derrière 'logger' renvoie quelque chose d’autre, TypeScript ne le détectera pas.
Pourquoi on ne peut pas injecter une interface
Une demande fréquente de la part des développeurs novices en NestJS est d’utiliser une interface afin de maintenir le code découplé, par exemple en spécifiant un paramètre sous la forme de IMailService. La première tentative naturelle échoue :
// Won't work at runtime
constructor(
private readonly mailService: IMailService,
) {}
Lors du démarrage, NestJS affiche Nest can't resolve dependencies of the UserService (?). Le point d’interrogation indique le paramètre qu’il n’a pas pu identifier.
La raison en est que les interfaces n’existent pas en JavaScript. Lorsque tsc compile votre code, chaque interface ainsi que toute annotation de type pure est supprimée. NestJS dépend de métadonnées qui doivent persister dans le programme en cours d’exécution, or une interface ne laisse rien derrière elle que ce dernier puisse lire ; les métadonnées générées pour ce paramètre se réduisent au type générique Object, qui ne correspond à aucun fournisseur.
Les classes, en revanche, sont différentes : elles se compilent en véritables fonctions constructeurs JavaScript, ce qui leur permet de persister en temps de exécution et de servir de clés.
class CustomLoggerService
↓
exists at runtime
↓
can be used as a DI token
interface IMailer
↓
erased during compilation
↓
cannot be used as a runtime DI token
Lorsque vous programmez en utilisant une abstraction, vous devez fournir explicitement un jeton de temps d’exécution, comme @Inject('MAIL_SERVICE'), et enregistrer un fournisseur sous ce même jeton. Une classe abstraite constitue une alternative à connaître : puisqu’elle se compile en une fonction réelle, elle peut servir à la fois de type et de jeton sans @Inject().
Le piège du singleton dupliqué : useClass versus useExisting
Lorsque des jetons personnalisés sont utilisés, chaque module doit indiquer à NestJS comment les résoudre, et c’est là que des erreurs subtiles apparaissent fréquemment. Examinons ce module :
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useClass: CustomLoggerService, // The trap
},
],
})
export class CommonModule {}
À première vue, il semble que 'APP_LOGGER' ne soit qu’un autre nom pour CustomLoggerService. Ce n’est pas le cas. Le module contient désormais deux enregistrements de fournisseurs indépendants, et avec la portée singleton par défaut, chacun d’eux obtient sa propre instance :
- Le token de classe
CustomLoggerServiceest résolu en construisant la classe, ce qui produit l’instance A. - Le token de chaîne
'APP_LOGGER'est résolu en construisant à nouveau la classe, ce qui produit l’instance B.
Le problème ne se manifeste que avec l’état. Si le journaliseur conserve un buffer en mémoire, gère une file d’attente, compte les requêtes pour la limitation de vitesse ou possède une connexion, les consommateurs qui injectent la classe et ceux qui injectent le token de chaîne communiquent avec des objets différents qui ne voient jamais les données de l’autre.
La solution : un alias avec useExisting
Lorsque l’intention est d’obtenir un nom supplémentaire pour un fournisseur déjà enregistré, utilisez useExisting. Cela indique à NestJS de ne pas créer quoi que ce soit de nouveau et de résoudre le token vers l’instance existante à la place :
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useExisting: CustomLoggerService, // Points to the existing singleton
},
],
})
export class CommonModule {}
Une façon simple d’imaginer la différence, c’est avec des boîtes et des étiquettes :
useClasscrée une deuxième boîte. Le jeton de classe étiquette la boîte A, tandis que le jeton de chaîne étiquette la boîte B.useExistingcrée une seule boîte et y attache les deux étiquettes de nom.
Avec cet alias en place, un consommateur qui injecte CustomLoggerService et un autre qui injecte 'APP_LOGGER' reçoivent le même objet, de sorte qu’une vérification d’égalité stricte entre eux donne true.
useClass reste le choix approprié lorsque l’on souhaite réellement une instance distincte, ou lorsque le jeton est le seul moyen de déclarer la classe, comme expliqué dans la section suivante.
Les jetons en tant que jointure architecturale
Pour un petit service CRUD, l’utilisation de tokens personnalisés peut sembler superflue. Leur utilité se révèle lorsque l’implémentation est susceptible de changer. Supposons que des dizaines de contrôleurs utilisent un service basé sur Winston pour enregistrer les logs. Plutôt que d’importer WinstonLoggerService dans chacun d’eux, les contrôleurs dépendent uniquement d’un token et d’une interface :
constructor(
@Inject('LOGGER') private readonly logger: LoggerInterface
) {}
Le module décide quelle implémentation se trouve derrière ce token :
{
provide: 'LOGGER',
useClass: WinstonLoggerService,
}
Passer à un système de journalisation en cloud, comme Google Cloud Logging ou AWS CloudWatch, ne nécessite alors qu’un changement sur une seule ligne dans le module, sans aucune modification des composants qui consomment les logs :
{
provide: 'LOGGER',
useClass: CloudLoggerService,
}
Cette même architecture facilite les tests, car un module de test peut associer 'LOGGER' à une simulation sans avoir à modifier le code à tester.
Une amélioration pratique : les chaînes de caractères simples sont facilement à saisir incorrectement et peuvent entrer en conflit entre modules. En définissant les tokens une fois comme des constantes exportées, ou comme des valeurs Symbol, on assure leur cohérence et permet au compilateur de détecter les fautes d’orthographe.
Les modèles de fournisseurs en bref
Les quatre types de fournisseurs que vous rencontrerez le plus souvent, ainsi que les situations où chacun est approprié :
- Fournisseur de classe (
useClass) : instancie une classe pour un token. Utilisez-le pour lier un token d’abstraction à une implémentation concrète, ou pour changer les implémentations selon l’environnement. Chaque inscription crée sa propre instance. - Fournisseur d’alias (
useExisting) : fait pointer un token vers un fournisseur déjà existant. Utilisez-le pour exposer une seule instance sous plusieurs noms sans dupliquer l’état.
useValue) : retournez une valeur fixe, telle qu’un objet de configuration, une constante ou un mock dans les tests.useFactory) : calculez la valeur à l’aide d’une fonction, éventuellement avec des dépendances injectées listées dans inject. Utilisez-le lorsque la création dépend de la configuration ou est asynchrone, comme c’est le cas pour les connexions à une base de données.N’oubliez pas les exports
Un obstacle classique est un token correctement configuré dans CommonModule que UserModule ne parvient toujours pas à résoudre. L’array providers contrôle ce qu’un module enregistre pour son propre usage. L’array exports contrôle ce qu’il met à disposition des modules qui l’importent. Un token personnalisé comme 'LOGGER' doit figurer dans exports (et le module qui l’utilise doit importer CommonModule) avant que quiconque à l’extérieur ne puisse le injecter. Lorsque vous exportez un alias créé avec useExisting, assurez-vous que les modules consommateurs peuvent également accéder au fournisseur vers lequel il pointe, ou exportez les deux tokens.
Points clés
- Un type n’existe qu’en temps de compilation, un token est la clé en temps d’exécution, et un fournisseur définit comment cette clé est satisfaite ; gardez ces trois éléments distincts lors de la lecture du code DI.
@Inject(), ou une classe abstraite présente en temps de exécution.useClass crée deux instances uniques ; utilisez useExisting lorsque vous souhaitez uniquement un alias.exports et imports.