Catégories
Plugin et site web

Une introduction pratique à l'injection de dépendances – Smashing Magazine

A propos de l'auteur

Jamie est un développeur de logiciels de 18 ans situé au Texas. Il a un intérêt particulier pour l'architecture d'entreprise (DDD / CQRS / ES), l'écriture élégante et testable…
Plus à propos
Jamie

Cet article fournira une introduction pratique à l'injection de dépendances d'une manière qui vous permettra immédiatement de réaliser ses nombreux avantages sans être gêné par la théorie.

Le 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 à côté des concepts plus théoriques d'Inversion de Contrôle, d'Inversion de Dépendance, des Principes SOLID, et ainsi de suite. Pour vous faciliter au maximum la mise en œuvre de l'injection de dépendances et commencer à en récolter les avantages, cet article restera très axé sur le côté pratique de l'histoire, illustrant des exemples qui montrent précisément les avantages de son utilisation, d'une manière principalement divorcé 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 l'essentiel de cette explication sera réservé 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 autres exemples concrets, puis discuter de quelques informations générales. Un autre article (pour suivre celui-ci) expliquera comment l'injection de dépendances s'intègre dans l'écosystème global de l'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, certaines questions judicieuses et raisonnables seraient «comment définissez-vous la« dépendance »?», «Que signifie le fait qu’une dépendance soit« injectée »?», «Pouvez-vous injecter des dépendances de différentes manières?» et "pourquoi est-ce utile?" Vous pourriez ne pas croire qu'un terme tel que «Injection de dépendance» peut être expliqué en deux extraits de code et en quelques mots, mais hélas, c'est possible.

La manière la plus simple d'expliquer le concept est de vous montrer.

Ceci, par exemple, est ne pas 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 ça est 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. Cool. La fin.

Qu'est ce qui a changé? Plutôt que de permettre Car classe à instancier Engine (comme dans le premier exemple), dans le deuxième exemple, Car avait une instance de Engine passé – ou injecté in – d'un niveau de contrôle supérieur à son constructeur. C'est ça. À la base, tout cela est l'injection de dépendances – l'acte d'injecter (passer) une dépendance dans une autre classe ou fonction. Tout autre élément impliquant la notion d'injection de dépendances n'est qu'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, puis 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 du Engine classe, 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épendances est largement utilisée pour prendre en charge de nombreux cas d'utilisation, mais la plus flagrante des utilisations est peut-être de permettre des tests plus faciles. Dans le premier exemple, nous ne pouvons pas nous moquer facilement engine parce que le Car class l'instancie. Le vrai moteur est toujours utilisé. Mais, dans ce dernier cas, nous contrôlons le Engine qui est utilisé, ce qui signifie que dans un test, nous pouvons sous-classer Engine et outrepasser ses méthodes.

Par exemple, si nous voulions voir ce que Car.startEngine() fait si engine.fireCylinders() jette une erreur, nous pourrions simplement créer un FakeEngine classe, faites-le prolonger Engine classe, puis remplacer fireCylinders pour le faire jeter une erreur. Dans le test, nous pouvons injecter cela FakeEngine objet dans le constructeur pour Car. Depuis FakeEngine est un Engine par implication d'héritage, le système de type TypeScript est satisfait.

Je veux qu'il soit très, très clair que ce que vous voyez ci-dessus est la notion fondamentale d'injection de dépendances. UNE Car, en soi, n'est pas assez intelligent pour savoir de quel moteur il a besoin. Seuls les ingénieurs qui construction la voiture comprend les exigences de ses moteurs et de ses roues. Ainsi, il est logique que les personnes qui construction la voiture fournit le moteur spécifique requis, plutôt que de laisser un Car choisit lui-même le moteur qu'il 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 être tournés en toute sécurité au régime maximal que le moteur peut produire? Pour toutes ces raisons et plus encore, il devrait être logique, peut-être intuitivement, que Car ne devrait rien avoir à voir avec la décision Engine et quoi Wheels il utilise. Ils devraient être fournis à partir d'un niveau de contrôle plus élevé.

Dans le dernier exemple illustrant l'injection de dépendances en action, si vous imaginez Engine pour être 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 des fonctionnalités de base, mais comment ce moteur est géré et quelle en est l'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 du monde réel

Nous allons examiner quelques autres exemples pratiques qui, espérons-le, aideront à expliquer, encore une fois intuitivement, pourquoi l'injection de dépendances est utile. Espérons qu'en ne vous inspirant pas de la théorie et en passant directement aux concepts applicables, vous pourrez voir plus pleinement les avantages qu'apporte l'injection de dépendance et les difficultés de la vie sans elle. Nous reviendrons à un traitement légèrement plus «académique» du sujet plus tard.

Nous allons commencer par construire notre application normalement, d'une manière hautement couplée, sans utiliser d'injection de dépendances ou d'abstractions, de sorte que nous venons à voir les inconvénients de cette approche et la difficulté qu'elle ajoute aux tests. En cours de route, nous refactoriserons progressivement 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 certains 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 {
        // ... dbDriver.save(...)
    }

    public async findUserById(id: string): Promise {
        // ... dbDriver.query(...)
    }
    
    public async existsByEmail(email: string): Promise {
        // ... 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, dans la logique métier, votre système de stockage de données soit traité comme un simple 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 à ce que les choses fonctionnent normalement, et dbDriver est codé en dur dans le fichier.

Dans ton UserService, vous importez la classe, l'instanciez et commencez à 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 {
        // 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 {
        // 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 lors de son déplacement entre deux systèmes externes ou deux couches d'une application. Pour en savoir plus sur les DTO, consultez 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'identifiant 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, le findUserById la fonction doit mapper le User objecter à une réponse DTO, mais j'ai négligé cela par souci de brièveté. Enfin, dans le monde réel, je n’aurais pas de User le modèle de domaine contient un fromDto méthode. Ce n’est pas bon pour la pureté du domaine. Encore une fois, son but est ici la brièveté.

Ensuite, vous souhaitez gérer l'envoi d'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 {
        // ... 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 {
        // 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 {
        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 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 programme embêtants ne le font pas. t fixé 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 unitaire – allons-nous utiliser la vraie base de données dans les tests? Pire encore, allons-nous 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 des classes de test unitaire sous cette configuration sont lourdes de complexité et de sur-ingénierie. Les gens apportent des bibliothèques entières simplement pour fournir des fonctionnalités de stubbing, qui ajoutent toutes sortes de couches d'indirection, et, pire encore, peuvent directement coupler les tests à la mise en œuvre 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 nous efforcerons d'atténuer ces problèmes en discutant de la responsabilité réelle de UserService est et applique de nouvelles techniques d'injection de dépendances.

Considérez, pendant un moment, quel UserService Est-ce que. Le point entier 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. Il est recommandé que les classes et les fonctions n'aient qu'une seule responsabilité (SRP – le principe de responsabilité unique), et la responsabilité de UserService consiste à gérer les opérations liées à l'utilisateur. Pourquoi, alors, est UserService responsable du contrôle de la durée de vie UserRepository et SendGridEmailProvider dans cet exemple?

Imaginez si nous avions une autre classe utilisée par UserService qui a ouvert une connexion de longue date. Devrait UserService être responsable de l'élimination de cette connexion aussi? Bien sûr que non. Toutes ces dépendances ont une durée de vie qui leur est associée – elles pourraient être des singletons, elles pourraient être transitoires et étendues à 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 allons injecter toutes les dépendances, comme nous l'avons vu auparavant.

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 {
        // 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 {
        return this.userRepository.findUserById(id);
    }
}

Génial! Maintenant UserService reçoit des objets pré-instanciés et 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 et jusqu'à 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 y a encore quelques problèmes du point de vue de la conception, qui une fois corrigés, serviront à rendre notre utilisation de l'injection de dépendances encore plus puissante.

Premièrement, pourquoi UserService savez-vous que nous utilisons SendGrid pour les e-mails? Deuxièmement, les deux dépendances sont sur des classes concrètes – le béton UserRepository et le béton SendGridEmailProvider. Cette relation est trop rigide – nous sommes obligés de passer dans un objet qui est un UserRepository et est un SendGridEmailProvider.

Ce n’est pas génial parce que nous voulons UserService d'être totalement indépendant de l'implémentation de ses dépendances. En ayant UserService être aveugle de cette manière, nous pouvons échanger les implémentations sans affecter le service du tout – cela signifie que si nous décidons de migrer loin 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 des tests, nous pouvons le faire aussi.

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 ayant UserService être indépendant des détails de mise en œuvre. En d'autres termes, nous devons forcer UserService de ne dépendre que d’une abstraction de ses dépendances, et non de véritables dépendances concrètes. Nous pouvons le faire grâce à des interfaces.

Commencez par définir une interface pour le UserRepository et implémentez-le:

// UserRepository.ts

import { dbDriver } from 'pg-driver';

export interface IUserRepository {
    addUser(user: User): Promise;
    findUserById(id: string): Promise;
    existsByEmail(email: string): Promise;
}

export class UserRepository implements IUserRepository {
    public async addUser(user: User): Promise {
        // ... dbDriver.save(...)
    }

    public async findUserById(id: string): Promise {
        // ... dbDriver.query(...)
    }

    public async existsByEmail(email: string): Promise {
        // ... 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;
}

// SendGridEmailProvider.ts
import { sendMail } from 'sendgrid';
import { IEmailProvider } from './IEmailProvider';

export class SendGridEmailProvider implements IEmailProvider {
    public async sendWelcomeEmail(to: string): Promise {
        // ... await sendMail(...);
    }
}

Remarque: Il s'agit du modèle d'adaptateur du groupe de quatre modèles de conception.

Maintenant, 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 {
        // 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 {
        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 pourrait également être nouveau pour vous. Pensez aux prises murales. Vous pouvez brancher n'importe quel appareil sur n'importe quelle prise à condition que la fiche s'adapte à la prise. C’est un couplage lâche en action. Votre grille-pain n'est pas câblé dans le mur, car si c'était le cas et que vous décidiez de le mettre à niveau, 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 maximale de courant, la fréquence CA, etc., vous vous souciez simplement de savoir si la fiche s'insère 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 venir de la ville ou de vos propres panneaux solaires, et encore une fois, vous ne vous en souciez pas tant que vous pouvez encore brancher 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é s'apparentent aux dépendances et votre grille-pain s'apparente au UserService (il dépend de l'électricité) – la source d'électricité peut changer et le grille-pain fonctionne toujours correctement et n'a pas besoin d'être touché, car la prise, agissant comme 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 contre des interfaces (abstractions) et non des 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 à le faire. changer tant que l'interface ne change jamais. Rappelles toi, UserService a seulement besoin de savoir quelle fonctionnalité est offerte par ses dépendances, pas comment cette fonctionnalité est prise en charge dans les coulisses. 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 un logiciel faiblement couplé et résout tous les problèmes que nous avons rencontrés ci-dessus.

Si nous décidons demain que nous voulons nous fier à Mailchimp pour les e-mails, nous créons simplement une nouvelle classe Mailchimp qui honore le IEmailProvider interface et injectez-le au lieu de SendGrid. L'actuel UserService la classe n'a jamais à changer, même si nous venons de faire un énorme changement dans 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, les maintenant découplés de manière appropriée.

De plus, lorsqu'il s'agit de tests, nous pouvons créer des faux 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 {
        this.users.push(user);
    }

    public async findUserById(id: string): Promise {
        const userOrNone = this.users.find(u => u.id === id);

        return userOrNone
            ? Promise.resolve(userOrNone)
            : Promise.reject(new NotFoundError());
    }

    public async existsByEmail(email: string): Promise {
        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 {
        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 personne ne sera plus sage; il les utilisera comme s’ils étaient la vraie affaire. La raison pour laquelle il peut le faire est parce qu'il sait que toutes les méthodes et propriétés qu'il souhaite utiliser sur ses dépendances existent bien et sont effectivement accessibles (car elles implémentent les interfaces), ce qui est tout UserService a besoin de savoir (c'est-à-dire pas comment fonctionnent les dépendances).

Nous allons injecter 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 de moquage et de stubbing excessives, en travaillant avec le propre interne de Jest. outillage, ou essayer de singe-patch.

Voici des tests réels utilisant les faux:

// 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 {
    return {
        id: 'someId',
        email: 'example@domain.com',
        ...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 = 'john.doe@live.com';
    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 manuscrites sont très simples. Il n’ya pas de complexité avec les 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 / await dans les tests même si tout le comportement est synchrone parce que je pense qu'il correspond plus étroitement à la façon dont je m'attendrais à ce que les opérations fonctionnent dans le monde réel et parce qu'en ajoutant async / await, je peux exécuter cette même suite de tests contre les implémentations réelles en plus des faux, il est donc nécessaire de gérer l'asynchronisme de manière appropriée. En fait, dans la vraie vie, je ne me soucierais probablement même pas de me moquer de la base de données et 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 me moquer de cela pour les performances. Je pourrais alors exécuter les tests DB en mémoire après chaque changement et réserver les vrais tests DB locaux pour juste avant de valider les modifications et pour le serveur de construction dans le pipeline CI / CD.

Dans le premier test, dans la section «organiser», nous créons simplement le DTO. Dans la section «act», nous appelons le système testé et exécutons son comportement. Les choses deviennent un peu plus complexes lors 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 à quoi nous nous attendons d'un utilisateur persistant, puis nous appelons le faux référentiel et lui demandons un utilisateur avec l'ID que nous attendons. Si la UserService n'a pas persisté l'utilisateur correctement, cela lancera un 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 affirmations avec Jest et cela conclut le test. Il est expressif et se lit comme le fonctionnement réel du système. Il n’ya pas d’indirection des bibliothèques moqueuses et il n’ya pas de couplage avec la mise en œuvre 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 conserver 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 repo plus tôt.

Ces exemples sont relativement simples, mais lorsque les choses deviennent plus complexes, pouvoir compter sur l'injection de dépendances et les interfaces de cette manière maintient 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. De nombreuses personnes, à tort, affirment qu'une «unité» dans un «test unitaire» est une fonction ou une classe. Cela ne pouvait 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 sauf si ils atteignent l'extérieur de la limite 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 me moquer d'eux. 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 véritable 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 toujours un test unitaire tant que ce n'est pas trop lent. En général, 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 des conteneurs Docker. Chaque fois que je me moque, il est très peu probable que j'utilise un cadre moqueur ou que j'essaie d'affirmer le nombre d'appels ou de paramètres passés sauf dans de très rares cas, car cela couplerait des tests à la mise en œuvre du système testé, et ils devrait être indépendant de ces détails.

Effectuer une injection de dépendances 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 profiter des avantages de l’injection de dépendances en utilisant des arguments de fonction. Par exemple, notre UserService la classe 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 fabrique qui reçoit les dépendances et construit l’objet de service. Nous pouvons également injecter des dépendances dans des fonctions d'ordre supérieur. Un exemple typique serait de créer une fonction Express Middleware qui obtient un UserRepository Et un ILogger injecté:

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 en déduira automatiquement les types. De même, si j'avais défini une signature de fonction pour le Middleware Express 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) => { ... }
    );
}

Notez que nous récupérons les dépendances dont nous avons besoin, comme une connexion à une base de données ou des fonctions de bibliothèque tierces, puis nous utilisons des usines pour créer nos dépendances propriétaires en utilisant les dépendances tierces. Nous les passons ensuite dans le code dépendant. Puisque tout est codé contre des abstractions, je peux échanger soit userRepository ou emailProvider être n'importe quelle fonction ou classe différente avec n'importe quelle implémentation que je veux (qui implémente toujours l'interface correctement) et UserService va simplement l'utiliser sans aucun changement nécessaire, ce qui, encore une fois, est dû au fait que UserService se soucie de rien d'autre que de l'interface publique des dépendances, pas du fonctionnement des dépendances.

En guise d'avertissement, je tiens à souligner quelques points. Comme indiqué précédemment, cette démonstration a été optimisée pour montrer comment l'injection de dépendances facilite la vie, et n'a donc pas été optimisée en termes de meilleures pratiques de conception de système dans la mesure où les modèles entourant la façon dont les référentiels et les DTO devraient être techniquement utilisés. Dans la vraie vie, il faut gérer les transactions entre les référentiels et le DTO ne doit généralement pas être passé en méthodes de service, mais plutôt mappé dans le contrôleur pour permettre à la couche de présentation d'évoluer séparément de la couche application. le userSerivce.findById Ici, la méthode néglige également de mapper l'objet de domaine utilisateur à un DTO, ce qu'elle devrait faire dans la vie réelle. Cependant, rien de tout cela n'affecte la mise en œuvre de la DI, je voulais simplement me concentrer sur les avantages de la DI elle-même, pas sur la conception du référentiel, la gestion des unités de travail ou les DTO. Enfin, bien que cela puisse ressembler un peu au cadre NestJS en termes de manière de faire les choses, ce n’est pas le cas, et je décourage activement les gens d’utiliser NestJS pour des raisons qui sortent du cadre de cet article.

Un bref aperçu théorique

Toutes les applications sont constituées de composants collaboratifs, et la manière dont ces collaborateurs collaborent et sont gérés déterminera dans quelle mesure l'application résistera au refactoring, résistera au changement et résistera aux tests. L'injection de dépendances mélangée au codage par rapport aux interfaces est une méthode principale (entre autres) pour réduire le couplage des collaborateurs au sein des systèmes et les rendre facilement interchangeables. C'est la marque d'une conception hautement cohérente et faiblement couplée.

Les composants individuels qui composent les applications dans les systèmes non triviaux doivent être découplés si nous voulons que le système soit maintenable, et la façon dont nous atteignons ce niveau de découplage, comme indiqué ci-dessus, consiste à dépendre d'abstractions, dans ce cas, d'interfaces, plutôt que des implémentations concrètes et en utilisant l'injection de dépendances. Cela fournit un couplage lâche et nous donne la liberté de permuter les implémentations sans avoir à apporter de modifications du côté du composant / collaborateur dépendant et résout le problème que le code dépendant n'a aucune entreprise à gérer la durée de vie de ses dépendances et ne devrait pas savoir comment les créer ou les éliminer.

Malgré la simplicité de ce que nous avons vu jusqu'à présent, l'injection de dépendances est beaucoup plus complexe.

L'injection de dépendances peut prendre de nombreuses formes. L'injection de constructeur est ce que nous utilisons ici depuis que les dépendances sont injectées dans un constructeur. Il existe également l'injection de poseur et l'injection d'interface. Dans le cas du premier, le composant dépendant exposera une méthode setter qui sera utilisée pour injecter la dépendance – c'est-à-dire qu'il pourrait exposer une méthode comme setUserRepository(userRepository: UserRepository). Dans le dernier cas, nous pouvons définir des interfaces à travers lesquelles effectuer l'injection, mais j'omettrai ici l'explication de la dernière technique par souci de brièveté car nous passerons plus de temps à en discuter et plus dans le deuxième article de cette série.

Étant donné que le câblage manuel des dépendances peut être difficile, il existe divers cadres et conteneurs IoC. Ces conteneurs stockent vos dépendances et résolvent les bonnes au moment de l'exécution, souvent via Reflection dans des langages tels que C # ou Java, exposant diverses options de configuration pour la durée de vie des dépendances. Malgré les avantages qu'offrent les conteneurs IoC, il y a des raisons de s'en éloigner et de ne résoudre les dépendances que manuellement. Pour en savoir plus, consultez la présentation des 8 lignes de code de Greg Young.

De plus, les cadres DI et les conteneurs IoC peuvent fournir trop d'options, et beaucoup comptent sur des décorateurs ou des attributs pour exécuter des techniques telles que l'injection de setter ou de champ. Je méprise ce type d'approche car, si vous y réfléchissez intuitivement, le point de l'injection de dépendances est d'obtenir un couplage lâche, mais si vous commencez à saupoudrer des décorateurs spécifiques aux conteneurs IoC dans toute votre logique métier, alors que vous avez peut-être atteint découplage de la dépendance, vous vous êtes couplé par inadvertance au conteneur IoC. Les conteneurs IoC comme Awilix résolvent ce problème car ils restent séparés de la logique métier de votre application.

Conclusion

Cet article a servi à ne décrire qu'un exemple très pratique d'injection de dépendances en cours d'utilisation et a négligé principalement les attributs théoriques. Je l'ai fait de cette façon afin de faciliter la compréhension de ce qu'est l'injection de dépendances au cœur d'une manière séparée du reste de la complexité que les gens associent habituellement au concept.

Dans le deuxième article de cette série, nous examinerons beaucoup, beaucoup plus en profondeur, notamment:

  • La différence entre l'injection de dépendance et l'inversion de dépendance et l'inversion de contrôle;
  • Anti-modèles d'injection de dépendance;
  • Anti-modèles de conteneurs IoC;
  • Le rôle des conteneurs IoC;
  • Les différents types de durée de vie des dépendances;
  • Comment les conteneurs IoC sont conçus;
  • Injection de dépendance avec React;
  • Scénarios de test avancés;
  • Et plus.

Restez à l'écoute!

Éditorial fracassant(ra, yk, il)

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *