Un'introduzione pratica all'iniezione di dipendenza
Pubblicato: 2022-03-10Il concetto di Dependency Injection è, al suo interno, una nozione fondamentalmente semplice. Tuttavia, è comunemente presentato in un modo insieme ai concetti più teorici di Inversion of Control, Dependency Inversion, SOLID Principles e così via. Per rendere il più semplice possibile iniziare a utilizzare Dependency Injection e iniziare a trarne i benefici, questo articolo rimarrà molto sul lato pratico della storia, illustrando esempi che mostrano precisamente i vantaggi del suo utilizzo, in un modo principalmente separato dalla teoria associata.
Passeremo solo una piccola quantità di tempo a discutere i concetti accademici che circondano l'iniezione di dipendenza qui, poiché la maggior parte di quella spiegazione sarà riservata al secondo articolo di questa serie. In effetti, possono essere e sono stati scritti interi libri che forniscono un trattamento più approfondito e rigoroso dei concetti.
Qui inizieremo con una semplice spiegazione, passeremo ad alcuni altri esempi del mondo reale e quindi discuteremo alcune informazioni di base. Un altro articolo (per seguire questo) discuterà di come Dependency Injection si inserisce nell'ecosistema generale dell'applicazione di modelli architettonici di best practice.
Una semplice spiegazione
"Iniezione di dipendenza" è un termine eccessivamente complesso per un concetto estremamente semplice. A questo punto, alcune domande sagge e ragionevoli sarebbero "come si definisce 'dipendenza'?", "cosa significa per una dipendenza essere 'iniettata'?", "puoi iniettare dipendenze in modi diversi?" e "perché è utile?" Potresti non credere che un termine come "Iniezione di dipendenza" possa essere spiegato in due frammenti di codice e un paio di parole, ma purtroppo è possibile.
Il modo più semplice per spiegare il concetto è mostrartelo.
Questo, ad esempio, non è un'iniezione di dipendenza:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Ma questa è l'iniezione di dipendenza:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Fatto. Questo è tutto. Freddo. La fine.
Cosa è cambiato? Invece di consentire alla classe Car
di creare un'istanza di Engine
(come ha fatto nel primo esempio), nel secondo esempio, Car
ha avuto un'istanza di Engine
passata - o iniettata - da un livello di controllo superiore al suo costruttore. Questo è tutto. In sostanza, questa è tutta l'iniezione di dipendenza: l'atto di iniettare (passare) una dipendenza in un'altra classe o funzione. Qualsiasi altra cosa che coinvolga la nozione di iniezione di dipendenza è semplicemente una variazione su questo concetto fondamentale e semplice. In parole povere, l'iniezione di dipendenza è una tecnica in base alla quale un oggetto riceve altri oggetti da cui dipende, chiamati dipendenze, anziché crearli da solo.
In generale, per definire cos'è una "dipendenza", se una classe A
utilizza la funzionalità di una classe B
, allora B
è una dipendenza per A
, o, in altre parole, A
ha una dipendenza da B
. Naturalmente, questo non è limitato alle classi e vale anche per le funzioni. In questo caso, la classe Car
ha una dipendenza dalla classe Engine
oppure Engine
è una dipendenza da Car
. Le dipendenze sono semplicemente variabili, proprio come la maggior parte delle cose nella programmazione.
L'iniezione di dipendenza è ampiamente utilizzata per supportare molti casi d'uso, ma forse l'uso più palese è consentire test più semplici. Nel primo esempio, non possiamo facilmente deridere il engine
perché la classe Car
lo istanzia. Il vero motore è sempre in uso. Ma, in quest'ultimo caso, abbiamo il controllo Engine
utilizzato, il che significa che, in un test, possiamo sottoclassare l' Engine
e sovrascriverne i metodi.
Ad esempio, se volessimo vedere cosa Car.startEngine()
se engine.fireCylinders()
genera un errore, potremmo semplicemente creare una classe FakeEngine
, farla estendere la classe Engine
e quindi sovrascrivere fireCylinders
per fare in modo che generi un errore . Nel test, possiamo iniettare quell'oggetto FakeEngine
nel costruttore per Car
. Poiché FakeEngine
è un Engine
per implicazione dell'ereditarietà, il sistema di tipi TypeScript è soddisfatto. L'uso dell'ereditarietà e dell'override del metodo non sarebbe necessariamente il modo migliore per farlo, come vedremo in seguito, ma è sicuramente un'opzione.
Voglio chiarire molto, molto chiaramente che ciò che vedi sopra è la nozione fondamentale di iniezione di dipendenza. Car
, di per sé, non è abbastanza intelligente da sapere di quale motore ha bisogno. Solo gli ingegneri che costruiscono l'auto comprendono i requisiti per i suoi motori e ruote. Pertanto, ha senso che le persone che costruiscono l'auto forniscano il motore specifico richiesto, piuttosto che lasciare che Car
stessa scelga il motore che desidera utilizzare.
Uso la parola "costruisci" specificamente perché costruisci l'auto chiamando il costruttore, che è il luogo in cui vengono iniettate le dipendenze. Se l'auto ha creato anche le proprie gomme oltre al motore, come facciamo a sapere che le gomme utilizzate possono essere fatte girare in sicurezza al numero di giri massimo che il motore può produrre? Per tutti questi motivi e altro, dovrebbe avere senso, forse intuitivamente, che l' Car
non abbia nulla a che fare con il decidere quale Engine
e quali Wheels
utilizzare. Dovrebbero essere forniti da un livello di controllo più elevato.
Nell'ultimo esempio raffigurante l'iniezione di dipendenza in azione, se immagini Engine
come una classe astratta piuttosto che concreta, questo dovrebbe avere ancora più senso: l'auto sa che ha bisogno di un motore e sa che il motore deve avere alcune funzionalità di base , ma come viene gestito quel motore e quale sia la sua specifica implementazione è riservato per essere deciso e fornito dal pezzo di codice che crea (costruisce) l'auto.
Un esempio del mondo reale
Esamineremo alcuni esempi più pratici che si spera aiutino a spiegare, ancora in modo intuitivo, perché l'iniezione di dipendenza è utile. Si spera che, non insistendo sul teorico e passando invece direttamente ai concetti applicabili, tu possa vedere più pienamente i vantaggi offerti dall'iniezione di dipendenza e le difficoltà della vita senza di essa. Torneremo a un trattamento leggermente più "accademico" dell'argomento in seguito.
Inizieremo costruendo la nostra applicazione normalmente, in un modo altamente accoppiato, senza utilizzare l'iniezione di dipendenze o astrazioni, in modo da arrivare a vedere gli aspetti negativi di questo approccio e la difficoltà che aggiunge al test. Lungo la strada, faremo gradualmente il refactoring fino a correggere tutti i problemi.
Per iniziare, supponiamo che ti sia stato assegnato il compito di creare due classi: un provider di posta elettronica e una classe per un livello di accesso ai dati che deve essere utilizzato da alcuni UserService
. Inizieremo con l'accesso ai dati, ma entrambi sono facilmente definibili:
// 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(...) } }
Nota: il nome "Repository" qui deriva da "Repository Pattern", un metodo per disaccoppiare il database dalla logica aziendale. Puoi saperne di più sul Repository Pattern, ma ai fini di questo articolo puoi semplicemente considerarlo come una classe che incapsula il tuo database in modo che, secondo la logica aziendale, il tuo sistema di archiviazione dati sia trattato semplicemente come un in-memory collezione. Spiegare completamente il Repository Pattern non rientra nell'ambito di questo articolo.
Questo è il modo in cui normalmente ci aspettiamo che le cose funzionino e dbDriver
è codificato all'interno del file.
Nel tuo UserService
, importerai la classe, la istanziarai e inizierai a usarla:
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); } }
Ancora una volta, tutto rimane normale.
Una breve parentesi: un DTO è un Data Transfer Object — è un oggetto che funge da contenitore di proprietà per definire una forma di dati standardizzata mentre si sposta tra due sistemi esterni o due livelli di un'applicazione. Puoi saperne di più sui DTO dall'articolo di Martin Fowler sull'argomento, qui. In questo caso, IRegisterUserDto
definisce un contratto per quale dovrebbe essere la forma dei dati così come escono dal client. Ho solo due proprietà: id
e email
. Potresti pensare che sia strano che il DTO che ci aspettiamo dal client per creare un nuovo utente contenga l'ID dell'utente anche se non abbiamo ancora creato un utente. L'ID è un UUID e consento al client di generarlo per una serie di motivi, che non rientrano nell'ambito di questo articolo. Inoltre, la funzione findUserById
dovrebbe mappare l'oggetto User
su un DTO di risposta, ma l'ho trascurato per brevità. Infine, nel mondo reale, non avrei un modello di dominio User
contenente un metodo fromDto
. Non va bene per la purezza del dominio. Ancora una volta, il suo scopo è qui la brevità.
Successivamente, vuoi gestire l'invio di e-mail. Ancora una volta, come di consueto, puoi semplicemente creare una classe di provider di posta elettronica e importarla nel tuo UserService
.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
All'interno 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); } }
Ora abbiamo una classe completamente operaia e in un mondo in cui non ci interessa la testabilità o la scrittura di codice pulito in alcun modo, e in un mondo in cui il debito tecnico è inesistente e i fastidiosi gestori di programmi non lo fanno t fissare delle scadenze, va benissimo. Sfortunatamente, non è un mondo in cui abbiamo il vantaggio di vivere.
Cosa succede quando decidiamo che dobbiamo migrare da SendGrid per le e-mail e utilizzare invece MailChimp? Allo stesso modo, cosa succede quando vogliamo testare i nostri metodi: useremo il database reale nei test? Peggio ancora, invieremo effettivamente e-mail reali a indirizzi e-mail potenzialmente reali e pagheremo anche per questo?
Nell'ecosistema JavaScript tradizionale, i metodi delle classi di unit test in questa configurazione sono carichi di complessità e ingegneria eccessiva. Le persone portano intere librerie semplicemente per fornire funzionalità di stubbing, che aggiunge tutti i tipi di livelli di indirizzamento e, peggio ancora, possono accoppiare direttamente i test all'implementazione del sistema in prova, quando, in realtà, i test non dovrebbero mai sapere come il sistema reale funziona (questo è noto come test della scatola nera). Lavoreremo per mitigare questi problemi mentre discutiamo di quale sia la responsabilità effettiva di UserService
e applichiamo nuove tecniche di iniezione delle dipendenze.
Considera, per un momento, cosa fa un UserService
. Il punto centrale dell'esistenza di UserService
è di eseguire casi d'uso specifici che coinvolgono gli utenti — registrarli, leggerli, aggiornarli, ecc. È una buona pratica per classi e funzioni avere una sola responsabilità (SRP — il principio di responsabilità unica), e la responsabilità di UserService
è di gestire le operazioni relative all'utente. Perché, quindi, UserService
è responsabile del controllo della durata di UserRepository
e SendGridEmailProvider
in questo esempio?
Immagina se avessimo un'altra classe utilizzata da UserService
che ha aperto una connessione di lunga durata. UserService
dovrebbe essere responsabile dell'eliminazione di quella connessione? Ovviamente no. Tutte queste dipendenze hanno una durata associata ad esse: potrebbero essere singleton, potrebbero essere transitorie e nell'ambito di una specifica richiesta HTTP, ecc. Il controllo di queste durate è ben al di fuori dell'ambito di UserService
. Quindi, per risolvere questi problemi, inietteremo tutte le dipendenze, proprio come abbiamo visto prima.
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); } }
Grande! Ora UserService
riceve oggetti pre-istanziati e qualsiasi parte di codice chiama e crea un nuovo UserService
è la parte di codice responsabile del controllo della durata delle dipendenze. Abbiamo invertito il controllo da UserService
e fino a un livello superiore. Se volessi solo mostrare come possiamo iniettare dipendenze attraverso il costruttore per spiegare il tenant di base dell'inserimento delle dipendenze, potrei fermarmi qui. Ci sono ancora alcuni problemi dal punto di vista del design, tuttavia, che una volta risolti, serviranno a rendere il nostro uso dell'iniezione di dipendenza ancora più potente.
Innanzitutto, perché UserService
sa che stiamo usando SendGrid per le e-mail? In secondo luogo, entrambe le dipendenze sono su classi concrete: la concreta UserRepository
e la concreta SendGridEmailProvider
. Questa relazione è troppo rigida: siamo bloccati nel dover passare un oggetto che è un UserRepository
ed è un SendGridEmailProvider
.
Questo non è eccezionale perché vogliamo che UserService
sia completamente indipendente dall'implementazione delle sue dipendenze. Facendo in modo che UserService
sia cieco in questo modo, possiamo sostituire le implementazioni senza influire affatto sul servizio: ciò significa che se decidiamo di migrare da SendGrid e utilizzare invece MailChimp, possiamo farlo. Significa anche che se vogliamo falsificare il provider di posta elettronica per i test, possiamo farlo anche noi.
Sarebbe utile se potessimo definire un'interfaccia pubblica e forzare che le dipendenze in entrata rispettino quell'interfaccia, pur mantenendo UserService
dai dettagli di implementazione. In altre parole, dobbiamo forzare UserService
a dipendere solo da un'astrazione delle sue dipendenze e non dalle sue effettive dipendenze concrete. Possiamo farlo attraverso, beh, le interfacce.
Inizia definendo un'interfaccia per UserRepository
e implementala:
// 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(...) } }
E definiscine uno per il provider di posta elettronica, implementandolo anche:
// 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(...); } }
Nota: questo è il modello adattatore dalla banda dei quattro modelli di progettazione.
Ora, il nostro UserService
può dipendere dalle interfacce piuttosto che dalle implementazioni concrete delle dipendenze:
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); } }
Se le interfacce sono nuove per te, questo potrebbe sembrare molto, molto complesso. In effetti, anche per te potrebbe essere nuovo il concetto di creare software ad accoppiamento libero. Pensa ai contenitori da parete. Puoi collegare qualsiasi dispositivo a qualsiasi presa purché la spina si adatti alla presa. Questo è un accoppiamento allentato in azione. Il tuo tostapane non è cablato nel muro, perché se lo fosse e decidi di aggiornare il tuo tostapane, sei sfortunato. Al contrario, vengono utilizzati i punti vendita e l'uscita definisce l'interfaccia. Allo stesso modo, quando colleghi un dispositivo elettronico alla presa a muro, non ti preoccupi del potenziale di tensione, dell'assorbimento di corrente massimo, della frequenza CA, ecc., ti interessa solo se la spina si adatta alla presa. Potresti far entrare un elettricista e cambiare tutti i cavi dietro quella presa, e non avrai problemi a collegare il tuo tostapane, a patto che quella presa non cambi. Inoltre, la tua fonte di elettricità potrebbe essere commutata per provenire dalla città o dai tuoi pannelli solari e, ancora una volta, non ti interessa finché puoi ancora collegarti a quella presa.
L'interfaccia è la presa, fornendo funzionalità "plug-and-play". In questo esempio, il cablaggio nel muro e la fonte di elettricità sono simili alle dipendenze e il tuo tostapane è simile a UserService
(ha una dipendenza dall'elettricità) — la fonte di elettricità può cambiare e il tostapane funziona ancora bene e non è necessario essere toccati, perché la presa, fungendo da interfaccia, definisce il mezzo standard per entrambi per comunicare. Infatti si potrebbe dire che la presa funge da “astrazione” dei cablaggi a parete, degli interruttori, della sorgente elettrica, ecc.
È un principio comune e ben considerato della progettazione del software, per le ragioni sopra, codificare contro interfacce (astrazioni) e non implementazioni, che è ciò che abbiamo fatto qui. In tal modo, ci viene data la libertà di sostituire le implementazioni a nostro piacimento, poiché tali implementazioni sono nascoste dietro l'interfaccia (proprio come il cablaggio a muro è nascosto dietro la presa), quindi la logica aziendale che utilizza la dipendenza non deve mai cambia fintanto che l'interfaccia non cambia mai. Ricorda, UserService
deve solo sapere quale funzionalità è offerta dalle sue dipendenze , non come tale funzionalità è supportata dietro le quinte . Ecco perché l'utilizzo delle interfacce funziona.
Queste due semplici modifiche all'utilizzo delle interfacce e all'inserimento delle dipendenze fanno la differenza nel mondo quando si tratta di creare software ad accoppiamento libero e risolvono tutti i problemi che abbiamo riscontrato sopra.
Se decidiamo domani che vogliamo affidarci a Mailchimp per le e-mail, creiamo semplicemente una nuova classe Mailchimp che onora l'interfaccia IEmailProvider
e la inseriamo al posto di SendGrid. L'attuale classe UserService
non deve mai cambiare anche se abbiamo appena apportato una modifica enorme al nostro sistema passando a un nuovo provider di posta elettronica. La bellezza di questi modelli è che UserService
rimane beatamente inconsapevole di come le dipendenze che utilizza funzionano dietro le quinte. L'interfaccia funge da confine architettonico tra i due componenti, mantenendoli opportunamente disaccoppiati.
Inoltre, quando si tratta di test, possiamo creare falsi che rispettano le interfacce e invece li iniettano. Qui puoi vedere un falso repository e un falso provider di posta elettronica.
// 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)); }
Si noti che entrambi i fake implementano le stesse interfacce che UserService
si aspetta che le sue dipendenze rispettino. Ora, possiamo passare questi falsi in UserService
invece che nelle classi reali e UserService
non sarà più saggio; li userà proprio come se fossero un vero affare. Il motivo per cui può farlo è perché sa che tutti i metodi e le proprietà che vuole usare nelle sue dipendenze esistono effettivamente e sono effettivamente accessibili (perché implementano le interfacce), che è tutto ciò che UserService
deve sapere (cioè, non come funzionano le dipendenze).
Inietteremo questi due durante i test e renderà il processo di test molto più semplice e molto più diretto di quello a cui potresti essere abituato quando hai a che fare con librerie di mocking e stubbing esagerate, lavorando con le librerie interne di Jest utensili o cercando di patchare le scimmie.
Ecco i test effettivi che utilizzano i falsi:
// 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); });
Noterai alcune cose qui: i falsi scritti a mano sono molto semplici. Non c'è complessità da framework beffardi che servono solo a offuscare. Tutto è arrotolato a mano e ciò significa che non c'è magia nella base di codice. Il comportamento asincrono è simulato per corrispondere alle interfacce. Uso async/await nei test anche se tutto il comportamento è sincrono perché ritengo che corrisponda più da vicino a come mi aspetterei che le operazioni funzionino nel mondo reale e perché aggiungendo async/await, posso eseguire questa stessa suite di test anche contro le implementazioni reali oltre ai falsi, quindi è necessaria l'asincronia in modo appropriato. In effetti, nella vita reale, molto probabilmente non mi preoccuperei nemmeno di prendere in giro il database e userei invece un DB locale in un contenitore Docker fino a quando non ci fossero così tanti test che dovevo prenderlo in giro per le prestazioni. Potrei quindi eseguire i test DB in memoria dopo ogni singola modifica e riservare i test DB locali reali per il diritto prima di eseguire il commit delle modifiche e per il server di build nella pipeline CI/CD.
Nel primo test, nella sezione “arrange”, creiamo semplicemente il DTO. Nella sezione "agire", chiamiamo il sistema sottoposto a test ed eseguiamo il suo comportamento. Le cose diventano leggermente più complesse quando si fanno affermazioni. Ricorda, a questo punto del test, non sappiamo nemmeno se l'utente è stato salvato correttamente. Quindi, definiamo come ci aspettiamo che assomigli a un utente persistente, quindi chiamiamo il repository falso e gli chiediamo un utente con l'ID che ci aspettiamo. Se UserService
non persiste correttamente l'utente, verrà generato un NotFoundError
e il test avrà esito negativo, altrimenti ci restituirà l'utente. Successivamente, chiamiamo il falso provider di posta elettronica e gli chiediamo se ha registrato l'invio di un'e-mail a quell'utente. Infine, facciamo le affermazioni con Jest e questo conclude il test. È espressivo e si legge proprio come il sistema sta effettivamente funzionando. Non ci sono indicazioni indirette da librerie fittizie e non c'è accoppiamento con l'implementazione di UserService
.
Nel secondo test, creiamo un utente esistente e lo aggiungiamo al repository, quindi proviamo a chiamare di nuovo il servizio utilizzando un DTO che è già stato utilizzato per creare e rendere persistente un utente e ci aspettiamo che fallisca. Affermiamo inoltre che nessun nuovo dato è stato aggiunto al repository.
Per il terzo test, la sezione "disponi" ora consiste nel creare un utente e nel mantenerlo nel falso Repository. Quindi, chiamiamo il SUT e, infine, controlliamo se l'utente che torna è quello che abbiamo salvato in precedenza nel repository.
Questi esempi sono relativamente semplici, ma quando le cose diventano più complesse, essere in grado di fare affidamento sull'iniezione delle dipendenze e sulle interfacce in questo modo mantiene il codice pulito e rende la scrittura di test un piacere.
Una breve parentesi sui test: in generale, non è necessario deridere ogni dipendenza utilizzata dal codice. Molte persone, erroneamente, affermano che un'"unità" in un "test unitario" è una funzione o una classe. Non potrebbe essere più scorretto. L'“unità” è definita come l'“unità di funzionalità” o l'“unità di comportamento”, non una funzione o una classe. Quindi, se un'unità di comportamento utilizza 5 classi diverse, non è necessario deridere tutte quelle classi a meno che non raggiungano il limite del modulo. In questo caso, ho preso in giro il database e ho preso in giro il provider di posta elettronica perché non ho scelta. Se non voglio utilizzare un database reale e non voglio inviare un'e-mail, devo prenderlo in giro. Ma se avessi un sacco di classi in più che non hanno fatto nulla attraverso la rete, non le prenderei in giro perché sono dettagli di implementazione dell'unità di comportamento. Potrei anche decidere di non prendere in giro il database e le e-mail e creare un vero database locale e un vero server SMTP, entrambi in contenitori Docker. Sul primo punto, non ho problemi a utilizzare un database reale e a chiamarlo ancora unit test purché non sia troppo lento. In generale, userei prima il vero DB fino a quando non diventasse troppo lento e dovessi deridere, come discusso sopra. Ma, qualunque cosa tu faccia, devi essere pragmatico: l'invio di e-mail di benvenuto non è un'operazione mission-critical, quindi non abbiamo bisogno di andare così lontano in termini di server SMTP nei container Docker. Ogni volta che faccio beffe, sarebbe molto improbabile utilizzare un framework beffardo o tentare di asserire sul numero di volte chiamate o parametri passati tranne in casi molto rari, perché ciò abbinerebbe i test all'implementazione del sistema in test, e loro dovrebbe essere indipendente da quei dettagli.
Esecuzione dell'iniezione di dipendenza senza classi e costruttori
Finora, in tutto l'articolo, abbiamo lavorato esclusivamente con le classi e inserito le dipendenze tramite il costruttore. Se stai adottando un approccio funzionale allo sviluppo e desideri non utilizzare le classi, puoi comunque ottenere i vantaggi dell'iniezione delle dipendenze usando argomenti di funzione. Ad esempio, la nostra classe UserService
sopra potrebbe essere rifattorizzato in:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
È una factory che riceve le dipendenze e costruisce l'oggetto servizio. Possiamo anche iniettare dipendenze in funzioni di ordine superiore. Un tipico esempio potrebbe essere la creazione di una funzione Express Middleware che ottiene un UserRepository
e un ILogger
iniettati:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
Nel primo esempio, non ho definito il tipo di dto
e id
perché se definiamo un'interfaccia chiamata IUserService
contenente le firme del metodo per il servizio, il compilatore TS dedurrà automaticamente i tipi. Allo stesso modo, se avessi definito una firma di funzione per Express Middleware come tipo restituito di authProvider
, non avrei nemmeno dovuto dichiarare i tipi di argomento lì.
Se considerassimo funzionali anche il provider di posta elettronica e il repository e se inserissimo anche le loro dipendenze specifiche invece di codificarle, la radice dell'applicazione potrebbe apparire così:
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.
Conclusione
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;
- E altro ancora.
Rimani sintonizzato!