Une introduction pratique à l'injection de dépendance
Publié: 2022-03-10Le concept d'injection de dépendance est, à la base, une notion fondamentalement simple. Cependant, il est généralement présenté d'une manière parallèle aux concepts plus théoriques d'inversion de contrôle, d'inversion de dépendance, des principes SOLID, etc. Pour vous faciliter le plus possible le démarrage de l'utilisation de l'injection de dépendance et commencer à en récolter les bénéfices, cet article restera essentiellement du côté pratique de l'histoire, illustrant des exemples qui montrent précisément les avantages de son utilisation, d'une manière principalement séparé de la théorie associée.
Nous ne passerons que très peu de temps à discuter des concepts académiques qui entourent l'injection de dépendances ici, car la majeure partie de cette explication sera réservée au deuxième article de cette série. En effet, des livres entiers peuvent être et ont été écrits qui fournissent un traitement plus approfondi et rigoureux des concepts.
Ici, nous allons commencer par une explication simple, passer à quelques exemples concrets supplémentaires, puis discuter de quelques informations de base. Un autre article (pour suivre celui-ci) discutera de la façon dont l'injection de dépendance s'intègre dans l'écosystème global d'application des modèles architecturaux des meilleures pratiques.
Une explication simple
« Injection de dépendance » est un terme trop complexe pour un concept extrêmement simple. À ce stade, des questions sages et raisonnables seraient "comment définissez-vous la 'dépendance' ?", "qu'est-ce que cela signifie pour une dépendance d'être 'injectée' ?", "pouvez-vous injecter des dépendances de différentes manières ?" et "pourquoi est-ce utile?" Vous ne croirez peut-être pas qu'un terme tel que "Injection de dépendance" peut être expliqué en deux extraits de code et quelques mots, mais hélas, c'est possible.
La façon la plus simple d'expliquer le concept est de vous montrer.
Ceci, par exemple, n'est pas une injection de dépendance :
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Mais c'est une injection de dépendance :
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Terminé. C'est ça. Frais. La fin.
Qu'est ce qui a changé? Plutôt que de permettre à la classe Car
d'instancier Engine
(comme dans le premier exemple), dans le deuxième exemple, Car
avait une instance de Engine
transmise - ou injectée - d'un niveau de contrôle supérieur à son constructeur. C'est ça. À la base, c'est tout ce qu'est l'injection de dépendance - l'acte d'injecter (passer) une dépendance dans une autre classe ou fonction. Tout ce qui implique la notion d'injection de dépendance est simplement une variation de ce concept fondamental et simple. En termes simples, l'injection de dépendances est une technique par laquelle un objet reçoit d'autres objets dont il dépend, appelés dépendances, plutôt que de les créer lui-même.
En général, pour définir ce qu'est une "dépendance", si une classe A
utilise la fonctionnalité d'une classe B
, alors B
est une dépendance pour A
, ou, en d'autres termes, A
a une dépendance sur B
. Bien sûr, cela ne se limite pas aux classes et vaut également pour les fonctions. Dans ce cas, la classe Car
dépend de la classe Engine
ou Engine
est une dépendance de Car
. Les dépendances sont simplement des variables, comme la plupart des choses en programmation.
L'injection de dépendance est largement utilisée pour prendre en charge de nombreux cas d'utilisation, mais l'utilisation la plus flagrante consiste peut-être à permettre des tests plus faciles. Dans le premier exemple, nous ne pouvons pas facilement modéliser le engine
car la classe Car
l'instancie. Le vrai moteur est toujours utilisé. Mais, dans ce dernier cas, nous avons le contrôle sur le Engine
utilisé, ce qui signifie que, dans un test, nous pouvons sous-classer Engine
et remplacer ses méthodes.
Par exemple, si nous voulions voir ce que Car.startEngine()
si engine.fireCylinders()
génère une erreur, nous pourrions simplement créer une classe FakeEngine
, lui faire étendre la classe Engine
, puis remplacer fireCylinders
pour qu'il génère une erreur. . Dans le test, nous pouvons injecter cet objet FakeEngine
dans le constructeur de Car
. Étant donné que FakeEngine
est un Engine
par implication d'héritage, le système de type TypeScript est satisfait. L'utilisation de l'héritage et de la substitution de méthode ne serait pas nécessairement la meilleure façon de le faire, comme nous le verrons plus tard, mais c'est certainement une option.
Je veux qu'il soit très, très clair que ce que vous voyez ci-dessus est la notion centrale d'injection de dépendance. Une Car
, en elle-même, n'est pas assez intelligente pour savoir de quel moteur elle a besoin. Seuls les ingénieurs qui construisent la voiture comprennent les exigences de ses moteurs et de ses roues. Ainsi, il est logique que les personnes qui construisent la voiture fournissent le moteur spécifique requis, plutôt que de laisser une Car
elle-même choisir le moteur qu'elle souhaite utiliser.
J'utilise le mot "construire" spécifiquement parce que vous construisez la voiture en appelant le constructeur, qui est l'endroit où les dépendances sont injectées. Si la voiture a également créé ses propres pneus en plus du moteur, comment savons-nous que les pneus utilisés peuvent tourner en toute sécurité au régime maximal que le moteur peut produire ? Pour toutes ces raisons et bien d'autres, il devrait être logique, peut-être intuitivement, que Car
n'ait rien à voir avec le choix du Engine
et des Wheels
qu'elle utilise. Ils devraient être fournis à partir d'un niveau de contrôle supérieur.
Dans ce dernier exemple illustrant l'injection de dépendances en action, si vous imaginez que Engine
est une classe abstraite plutôt qu'une classe concrète, cela devrait avoir encore plus de sens - la voiture sait qu'elle a besoin d'un moteur et elle sait que le moteur doit avoir certaines fonctionnalités de base , mais comment ce moteur est géré et quelle est son implémentation spécifique est réservé pour être décidé et fourni par le morceau de code qui crée (construit) la voiture.
Un exemple concret
Nous allons examiner quelques exemples pratiques supplémentaires qui, espérons-le, aideront à expliquer, encore une fois intuitivement, pourquoi l'injection de dépendance est utile. Espérons qu'en ne revenant pas sur la théorie et en passant directement aux concepts applicables, vous pourrez mieux voir les avantages que procure l'injection de dépendance et les difficultés de la vie sans elle. Nous reviendrons plus tard sur un traitement un peu plus « académique » du sujet.
Nous commencerons par construire notre application normalement, de manière hautement couplée, sans utiliser d'injection de dépendances ou d'abstractions, de sorte que nous verrons les inconvénients de cette approche et la difficulté qu'elle ajoute aux tests. En cours de route, nous allons progressivement refactoriser jusqu'à ce que nous corrigions tous les problèmes.
Pour commencer, supposons que vous ayez été chargé de créer deux classes - un fournisseur de messagerie et une classe pour une couche d'accès aux données qui doit être utilisée par un UserService
. Nous allons commencer par l'accès aux données, mais les deux sont faciles à définir :
// UserRepository.ts import { dbDriver } from 'pg-driver'; export class UserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
Remarque : Le nom « Repository » vient ici du « Repository Pattern », une méthode de découplage de votre base de données de votre logique métier. Vous pouvez en savoir plus sur le modèle de référentiel, mais pour les besoins de cet article, vous pouvez simplement le considérer comme une classe qui encapsule votre base de données afin que, selon la logique métier, votre système de stockage de données soit traité comme un simple système en mémoire. collection. L'explication complète du modèle de référentiel n'entre pas dans le cadre de cet article.
C'est ainsi que nous nous attendons normalement à ce que les choses fonctionnent, et dbDriver
est codé en dur dans le fichier.
Dans votre UserService
, vous importeriez la classe, l'instancieriez et commenceriez à l'utiliser :
import { UserRepository } from './UserRepository.ts'; class UserService { private readonly userRepository: UserRepository; public constructor () { // Not dependency injection. this.userRepository = new UserRepository(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send a welcome email // ... } public async findUserById(id: string): Promise<User> { // No need for await here, the promise will be unwrapped by the caller. return this.userRepository.findUserById(id); } }
Encore une fois, tout reste normal.
Un bref aparté : un DTO est un objet de transfert de données - c'est un objet qui agit comme un sac de propriétés pour définir une forme de données normalisée lorsqu'il se déplace entre deux systèmes externes ou deux couches d'une application. Vous pouvez en savoir plus sur les DTO dans l'article de Martin Fowler sur le sujet, ici. Dans ce cas, IRegisterUserDto
définit un contrat pour la forme des données telles qu'elles proviennent du client. Je n'ai que deux propriétés - id
et email
. Vous pourriez penser qu'il est étrange que le DTO que nous attendons du client pour créer un nouvel utilisateur contienne l'ID de l'utilisateur même si nous n'avons pas encore créé d'utilisateur. L'ID est un UUID et j'autorise le client à le générer pour diverses raisons, qui sortent du cadre de cet article. De plus, la fonction findUserById
doit mapper l'objet User
à un DTO de réponse, mais j'ai négligé cela par souci de brièveté. Enfin, dans le monde réel, je n'aurais pas un modèle de domaine User
contenant une méthode fromDto
. Ce n'est pas bon pour la pureté du domaine. Encore une fois, son but est ici de brièveté.
Ensuite, vous souhaitez gérer l'envoi des e-mails. Encore une fois, comme d'habitude, vous pouvez simplement créer une classe de fournisseur de messagerie et l'importer dans votre UserService
.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Dans UserService
:
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor () { // Still not doing dependency injection. this.userRepository = new UserRepository(); this.sendGridEmailProvider = new SendGridEmailProvider(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Nous avons maintenant une classe ouvrière à part entière, et dans un monde où nous ne nous soucions pas du tout de la testabilité ou de l'écriture de code propre, quelle que soit la définition, et dans un monde où la dette technique est inexistante et les gestionnaires de programmes embêtants ne le font pas. t fixer des délais, c'est parfaitement bien. Malheureusement, ce n'est pas un monde dans lequel nous avons l'avantage de vivre.
Que se passe-t-il lorsque nous décidons que nous devons migrer de SendGrid pour les e-mails et utiliser MailChimp à la place ? De même, que se passe-t-il lorsque nous voulons tester nos méthodes à l'unité ? Allons-nous utiliser la vraie base de données dans les tests ? Pire encore, allons-nous réellement envoyer de vrais e-mails à des adresses e-mail potentiellement réelles et payer pour cela aussi ?
Dans l'écosystème JavaScript traditionnel, les méthodes de classes de tests unitaires sous cette configuration sont lourdes de complexité et de sur-ingénierie. Les gens apportent des bibliothèques entières simplement pour fournir une fonctionnalité de stub, qui ajoute toutes sortes de couches d'indirection, et, pire encore, peut coupler directement les tests à l'implémentation du système testé, alors qu'en réalité, les tests ne devraient jamais savoir comment le vrai système fonctionne (c'est ce qu'on appelle le test de la boîte noire). Nous travaillerons pour atténuer ces problèmes en discutant de la responsabilité réelle de UserService
et en appliquant de nouvelles techniques d'injection de dépendances.
Considérez un instant ce que fait un UserService
. L'intérêt de l'existence de UserService
est d'exécuter des cas d'utilisation spécifiques impliquant des utilisateurs - les enregistrer, les lire, les mettre à jour, etc. C'est une bonne pratique pour les classes et les fonctions d'avoir une seule responsabilité (SRP - le principe de responsabilité unique), et la responsabilité de UserService
est de gérer les opérations liées à l'utilisateur. Pourquoi, alors, UserService
est-il responsable du contrôle de la durée de vie de UserRepository
et SendGridEmailProvider
dans cet exemple ?
Imaginez si nous avions une autre classe utilisée par UserService
qui ouvrait une connexion de longue durée. UserService
devrait-il également être responsable de l'élimination de cette connexion ? Bien sûr que non. Toutes ces dépendances ont une durée de vie qui leur est associée - elles peuvent être des singletons, elles peuvent être transitoires et limitées à une requête HTTP spécifique, etc. Le contrôle de ces durées de vie est bien en dehors de la compétence de UserService
. Donc, pour résoudre ces problèmes, nous injecterons toutes les dépendances, comme nous l'avons vu précédemment.
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor ( userRepository: UserRepository, sendGridEmailProvider: SendGridEmailProvider ) { // Yay! Dependencies are injected. this.userRepository = userRepository; this.sendGridEmailProvider = sendGridEmailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Génial! Désormais, UserService
reçoit des objets pré-instanciés, et quel que soit le morceau de code qui appelle et crée un nouveau UserService
est le morceau de code chargé de contrôler la durée de vie des dépendances. Nous avons inversé le contrôle de UserService
vers un niveau supérieur. Si je voulais seulement montrer comment nous pourrions injecter des dépendances via le constructeur pour expliquer le locataire de base de l'injection de dépendances, je pourrais m'arrêter ici. Cependant, il reste encore quelques problèmes du point de vue de la conception qui, une fois corrigés, rendront notre utilisation de l'injection de dépendances d'autant plus puissante.
Premièrement, pourquoi UserService
sait-il que nous utilisons SendGrid pour les e-mails ? Deuxièmement, les deux dépendances sont sur des classes concrètes - le UserRepository
concret et le SendGridEmailProvider
concret. Cette relation est trop rigide - nous sommes obligés de transmettre un objet qui est un UserRepository
et un SendGridEmailProvider
.
Ce n'est pas génial car nous voulons que UserService
soit complètement indépendant de l'implémentation de ses dépendances. En faisant en sorte que UserService
soit aveugle de cette manière, nous pouvons échanger les implémentations sans affecter du tout le service - cela signifie que si nous décidons de migrer de SendGrid et d'utiliser MailChimp à la place, nous pouvons le faire. Cela signifie également que si nous voulons simuler le fournisseur de messagerie pour les tests, nous pouvons également le faire.
Ce qui serait utile, c'est si nous pouvions définir une interface publique et forcer les dépendances entrantes à respecter cette interface, tout en UserService
être agnostique aux détails d'implémentation. Autrement dit, nous devons forcer UserService
à ne dépendre que d'une abstraction de ses dépendances, et non de ses dépendances concrètes réelles. Nous pouvons le faire via, eh bien, des interfaces.
Commencez par définir une interface pour le UserRepository
et implémentez-la :
// UserRepository.ts import { dbDriver } from 'pg-driver'; export interface IUserRepository { addUser(user: User): Promise<void>; findUserById(id: string): Promise<User>; existsByEmail(email: string): Promise<boolean>; } export class UserRepository implements IUserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
Et définissez-en un pour le fournisseur de messagerie, en l'implémentant également :
// IEmailProvider.ts export interface IEmailProvider { sendWelcomeEmail(to: string): Promise<void>; } // SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; import { IEmailProvider } from './IEmailProvider'; export class SendGridEmailProvider implements IEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Remarque : il s'agit du modèle d'adaptateur du gang des quatre modèles de conception.
Désormais, notre UserService
peut dépendre des interfaces plutôt que des implémentations concrètes des dépendances :
import { IUserRepository } from './UserRepository.ts'; import { IEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: IUserRepository; private readonly emailProvider: IEmailProvider; public constructor ( userRepository: IUserRepository, emailProvider: IEmailProvider ) { // Double yay! Injecting dependencies and coding against interfaces. this.userRepository = userRepository; this.emailProvider = emailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.emailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Si les interfaces sont nouvelles pour vous, cela peut sembler très, très complexe. En effet, le concept de création de logiciels faiblement couplés peut également être nouveau pour vous. Pensez aux prises murales. Vous pouvez brancher n'importe quel appareil dans n'importe quelle prise tant que la fiche correspond à la prise. C'est le couplage lâche en action. Votre grille-pain n'est pas câblé dans le mur, car s'il l'était et que vous décidez de mettre à niveau votre grille-pain, vous n'avez pas de chance. Au lieu de cela, des prises sont utilisées et la prise définit l'interface. De même, lorsque vous branchez un appareil électronique dans votre prise murale, vous n'êtes pas concerné par le potentiel de tension, la consommation de courant maximale, la fréquence AC, etc., vous vous souciez simplement de savoir si la fiche rentre dans la prise. Vous pourriez faire venir un électricien et changer tous les fils derrière cette prise, et vous n'aurez aucun problème à brancher votre grille-pain, tant que cette prise ne change pas. De plus, votre source d'électricité pourrait être commutée pour provenir de la ville ou de vos propres panneaux solaires, et encore une fois, vous ne vous en souciez pas tant que vous pouvez toujours vous brancher sur cette prise.
L'interface est la prise, offrant une fonctionnalité "plug-and-play". Dans cet exemple, le câblage dans le mur et la source d'électricité sont similaires aux dépendances et votre grille-pain est similaire au UserService
(il dépend de l'électricité) - la source d'électricité peut changer et le grille-pain fonctionne toujours bien et n'a pas besoin être touché, car le point de vente, agissant comme l'interface, définit les moyens standard pour les deux de communiquer. En fait, on pourrait dire que la prise agit comme une "abstraction" du câblage mural, des disjoncteurs, de la source électrique, etc.
C'est un principe courant et bien considéré de la conception de logiciels, pour les raisons ci-dessus, de coder par rapport aux interfaces (abstractions) et non aux implémentations, ce que nous avons fait ici. Ce faisant, nous avons la liberté d'échanger les implémentations à notre guise, car ces implémentations sont cachées derrière l'interface (tout comme le câblage mural est caché derrière la prise), et donc la logique métier qui utilise la dépendance n'a jamais à changer tant que l'interface ne change jamais. N'oubliez pas que UserService
seulement besoin de savoir quelle fonctionnalité est offerte par ses dépendances , et non comment cette fonctionnalité est prise en charge en arrière-plan . C'est pourquoi l'utilisation d'interfaces fonctionne.
Ces deux changements simples d'utilisation d'interfaces et d'injection de dépendances font toute la différence dans le monde lorsqu'il s'agit de créer des logiciels faiblement couplés et résolvent tous les problèmes que nous avons rencontrés ci-dessus.
Si nous décidons demain que nous voulons compter sur Mailchimp pour les e-mails, nous créons simplement une nouvelle classe Mailchimp qui honore l'interface IEmailProvider
et l'injectons à la place de SendGrid. La classe UserService
réelle n'a jamais à changer même si nous venons d'apporter une modification gigantesque à notre système en passant à un nouveau fournisseur de messagerie. La beauté de ces modèles est que UserService
reste parfaitement inconscient de la façon dont les dépendances qu'il utilise fonctionnent dans les coulisses. L'interface sert de frontière architecturale entre les deux composants, en les maintenant correctement découplés.
De plus, en ce qui concerne les tests, nous pouvons créer des contrefaçons qui respectent les interfaces et les injecter à la place. Ici, vous pouvez voir un faux référentiel et un faux fournisseur de messagerie.
// Both fakes: class FakeUserRepository implements IUserRepository { private readonly users: User[] = []; public async addUser(user: User): Promise<void> { this.users.push(user); } public async findUserById(id: string): Promise<User> { const userOrNone = this.users.find(u => u.id === id); return userOrNone ? Promise.resolve(userOrNone) : Promise.reject(new NotFoundError()); } public async existsByEmail(email: string): Promise<boolean> { return Boolean(this.users.find(u => u.email === email)); } public getPersistedUserCount = () => this.users.length; } class FakeEmailProvider implements IEmailProvider { private readonly emailRecipients: string[] = []; public async sendWelcomeEmail(to: string): Promise<void> { this.emailRecipients.push(to); } public wasEmailSentToRecipient = (recipient: string) => Boolean(this.emailRecipients.find(r => r === recipient)); }
Notez que les deux faux implémentent les mêmes interfaces que UserService
s'attend à ce que ses dépendances honorent. Maintenant, nous pouvons passer ces faux dans UserService
au lieu des vraies classes et UserService
n'en sera pas plus sage ; il les utilisera comme s'il s'agissait de la vraie affaire. La raison pour laquelle il peut le faire est qu'il sait que toutes les méthodes et propriétés qu'il souhaite utiliser sur ses dépendances existent bel et bien et sont effectivement accessibles (car elles implémentent les interfaces), ce qui est tout ce que UserService
doit savoir (c'est-à-dire pas fonctionnement des dépendances).
Nous injecterons ces deux pendant les tests, et cela rendra le processus de test tellement plus facile et tellement plus simple que ce à quoi vous pourriez être habitué lorsque vous traitez avec des bibliothèques moqueuses et stubbing exagérées, en travaillant avec le propre interne de Jest l'outillage, ou essayer de singe-patch.
Voici des tests réels utilisant les contrefaçons :
// Fakes let fakeUserRepository: FakeUserRepository; let fakeEmailProvider: FakeEmailProvider; // SUT let userService: UserService; // We want to clean out the internal arrays of both fakes // before each test. beforeEach(() => { fakeUserRepository = new FakeUserRepository(); fakeEmailProvider = new FakeEmailProvider(); userService = new UserService(fakeUserRepository, fakeEmailProvider); }); // A factory to easily create DTOs. // Here, we have the optional choice of overriding the defaults // thanks to the built in `Partial` utility type of TypeScript. function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto { return { id: 'someId', email: '[email protected]', ...opts }; } test('should correctly persist a user and send an email', async () => { // Arrange const dto = createSeedRegisterUserDto(); // Act await userService.registerUser(dto); // Assert const expectedUser = User.fromDto(dto); const persistedUser = await fakeUserRepository.findUserById(dto.id); const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email); expect(persistedUser).toEqual(expectedUser); expect(wasEmailSent).toBe(true); }); test('should reject with a DuplicateEmailError if an email already exists', async () => { // Arrange const existingEmail = '[email protected]'; const dto = createSeedRegisterUserDto({ email: existingEmail }); const existingUser = User.fromDto(dto); await fakeUserRepository.addUser(existingUser); // Act, Assert await expect(userService.registerUser(dto)) .rejects.toBeInstanceOf(DuplicateEmailError); expect(fakeUserRepository.getPersistedUserCount()).toBe(1); }); test('should correctly return a user', async () => { // Arrange const user = User.fromDto(createSeedRegisterUserDto()); await fakeUserRepository.addUser(user); // Act const receivedUser = await userService.findUserById(user.id); // Assert expect(receivedUser).toEqual(user); });
Vous remarquerez quelques petites choses ici : Les contrefaçons écrites à la main sont très simples. Il n'y a aucune complexité à partir de frameworks moqueurs qui ne servent qu'à obscurcir. Tout est roulé à la main et cela signifie qu'il n'y a pas de magie dans la base de code. Le comportement asynchrone est simulé pour correspondre aux interfaces. J'utilise async/wait dans les tests même si tous les comportements sont synchrones parce que je pense que cela correspond plus étroitement à la façon dont je m'attends à ce que les opérations fonctionnent dans le monde réel et parce qu'en ajoutant async/wait, je peux exécuter cette même suite de tests contre les implémentations réelles aussi en plus des contrefaçons, il est donc nécessaire de gérer l'asynchronisme de manière appropriée. En fait, dans la vraie vie, je ne m'inquiéterais probablement même pas de me moquer de la base de données et j'utiliserais plutôt une base de données locale dans un conteneur Docker jusqu'à ce qu'il y ait tellement de tests que je devais m'en moquer pour des raisons de performances. Je pourrais ensuite exécuter les tests de base de données en mémoire après chaque modification et réserver les vrais tests de base de données locaux juste avant de valider les modifications et sur le serveur de construction dans le pipeline CI/CD.
Dans le premier test, dans la section "arranger", nous créons simplement le DTO. Dans la section "acte", nous appelons le système testé et exécutons son comportement. Les choses deviennent un peu plus complexes lorsque vous faites des affirmations. N'oubliez pas qu'à ce stade du test, nous ne savons même pas si l'utilisateur a été enregistré correctement. Donc, nous définissons ce à quoi nous nous attendons à ce qu'un utilisateur persistant ressemble, puis nous appelons le faux référentiel et lui demandons un utilisateur avec l'ID que nous attendons. Si le UserService
n'a pas conservé l'utilisateur correctement, cela lancera une NotFoundError
et le test échouera, sinon, il nous rendra l'utilisateur. Ensuite, nous appelons le faux fournisseur de messagerie et lui demandons s'il a enregistré l'envoi d'un e-mail à cet utilisateur. Enfin, nous faisons les assertions avec Jest et cela conclut le test. C'est expressif et se lit exactement comme le fonctionnement réel du système. Il n'y a pas d'indirection des bibliothèques fictives et il n'y a pas de couplage avec l'implémentation de UserService
.
Dans le deuxième test, nous créons un utilisateur existant et l'ajoutons au référentiel, puis nous essayons d'appeler à nouveau le service en utilisant un DTO qui a déjà été utilisé pour créer et conserver un utilisateur, et nous nous attendons à ce que cela échoue. Nous affirmons également qu'aucune nouvelle donnée n'a été ajoutée au référentiel.
Pour le troisième test, la section « organiser » consiste maintenant à créer un utilisateur et à le persister dans le faux Repository. Ensuite, nous appelons le SUT, et enfin, vérifions si l'utilisateur qui revient est celui que nous avons enregistré dans le dépôt plus tôt.
Ces exemples sont relativement simples, mais lorsque les choses deviennent plus complexes, pouvoir s'appuyer sur l'injection de dépendances et les interfaces de cette manière permet de garder votre code propre et rend l'écriture de tests un plaisir.
Un bref aparté sur les tests : en général, vous n'avez pas besoin de simuler toutes les dépendances utilisées par le code. Beaucoup de gens prétendent à tort qu'une "unité" dans un "test unitaire" est une fonction ou une classe. Cela ne pourrait pas être plus incorrect. L'« unité » est définie comme « l'unité de fonctionnalité » ou « l'unité de comportement », et non comme une fonction ou une classe. Donc, si une unité de comportement utilise 5 classes différentes, vous n'avez pas besoin de simuler toutes ces classes à moins qu'elles ne dépassent les limites du module. Dans ce cas, je me suis moqué de la base de données et je me suis moqué du fournisseur de messagerie parce que je n'ai pas le choix. Si je ne veux pas utiliser une vraie base de données et que je ne veux pas envoyer d'e-mail, je dois les simuler. Mais si j'avais un tas de classes supplémentaires qui ne faisaient rien sur le réseau, je ne me moquerais pas d'elles car ce sont des détails d'implémentation de l'unité de comportement. Je pourrais également décider de ne pas me moquer de la base de données et des e-mails et créer une vraie base de données locale et un vrai serveur SMTP, tous deux dans des conteneurs Docker. Sur le premier point, je n'ai aucun problème à utiliser une vraie base de données et à l'appeler un test unitaire tant qu'il n'est pas trop lent. Généralement, j'utilisais d'abord la vraie base de données jusqu'à ce qu'elle devienne trop lente et que je devais me moquer, comme indiqué ci-dessus. Mais, quoi que vous fassiez, vous devez être pragmatique - l'envoi d'e-mails de bienvenue n'est pas une opération critique, nous n'avons donc pas besoin d'aller aussi loin en termes de serveurs SMTP dans les conteneurs Docker. Chaque fois que je me moque, il est très peu probable que j'utilise un cadre de simulation ou que j'essaie d'affirmer le nombre de fois appelé ou les paramètres passés, sauf dans de très rares cas, car cela couplerait les tests à l'implémentation du système testé, et ils devrait être agnostique à ces détails.
Effectuer une injection de dépendance sans classes ni constructeurs
Jusqu'à présent, tout au long de l'article, nous avons travaillé exclusivement avec des classes et injecté les dépendances via le constructeur. Si vous adoptez une approche fonctionnelle du développement et que vous ne souhaitez pas utiliser de classes, vous pouvez toujours obtenir les avantages de l'injection de dépendances en utilisant des arguments de fonction. Par exemple, notre classe UserService
ci-dessus pourrait être refactorisée en :
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
C'est une usine qui reçoit les dépendances et construit l'objet service. Nous pouvons également injecter des dépendances dans des fonctions d'ordre supérieur. Un exemple typique serait la création d'une fonction Express Middleware qui obtient un UserRepository
et un ILogger
injectés :
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
Dans le premier exemple, je n'ai pas défini le type de dto
et id
car si nous définissons une interface appelée IUserService
contenant les signatures de méthode pour le service, le compilateur TS déduira automatiquement les types. De même, si j'avais défini une signature de fonction pour Express Middleware comme étant le type de retour de authProvider
, je n'aurais pas non plus eu à y déclarer les types d'arguments.
Si nous considérions que le fournisseur de messagerie et le référentiel étaient également fonctionnels, et si nous injections également leurs dépendances spécifiques au lieu de les coder en dur, la racine de l'application pourrait ressembler à ceci :
import { sendMail } from 'sendgrid'; async function main() { const app = express(); const dbConnection = await connectToDatabase(); // Change emailProvider to `makeMailChimpEmailProvider` whenever we want // with no changes made to dependent code. const userRepository = makeUserRepository(dbConnection); const emailProvider = makeSendGridEmailProvider(sendMail); const userService = makeUserService(userRepository, emailProvider); // Put this into another file. It's a controller action. app.post('/login', (req, res) => { await userService.registerUser(req.body as IRegisterUserDto); return res.send(); }); // Put this into another file. It's a controller action. app.delete( '/me', authProvider(userRepository, emailProvider), (req, res) => { ... } ); }
Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository
or emailProvider
to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService
will just use it with no changes needed, which, once again, is because UserService
cares about nothing but the public interface of the dependencies, not how the dependencies work.
As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn't optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById
method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it's not, and I actively discourage people from using NestJS for reasons outside the scope of this article.
A Brief Theoretical Overview
All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.
The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn't know how to create them or dispose of them. This doesn't mean that everything should be injected and no collaborators should ever be directly coupled to each other. There are certainly many cases where having that direct coupling is no problem at all, such as with utilities, mappers, models, and more.
Despite the simplicity of what we've seen thus far, there's a lot more complexity that surrounds dependency injection.
Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository)
. In the last case, we can define interfaces through which to perform the injection, but I'll omit the explanation of the last technique here for brevity since we'll spend more time discussing it and more in the second article of this series.
Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young's 8 Lines of Code talk.
Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you've inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix by Jeff Hansen solve this problem since they remain divorced from your application's business logic.
Conclusion
This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.
In the second article of this series, we'll take a much, much more in-depth look, including at:
- The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
- Dependency Injection anti-patterns;
- IoC Container anti-patterns;
- The role of IoC Containers;
- The different types of dependency lifetimes;
- How IoC Containers are designed;
- Dependency Injection with React;
- Advanced testing scenarios;
- Et plus.
Restez à l'écoute!