O introducere practică la injectarea dependenței
Publicat: 2022-03-10Conceptul de injectare a dependenței este, în esență, o noțiune fundamental simplă. Este, totuși, prezentat în mod obișnuit într-o manieră alături de conceptele mai teoretice de inversare a controlului, inversare a dependenței, principiile SOLIDE și așa mai departe. Pentru a vă face cât mai ușor posibil să începeți să utilizați Dependency Injection și să începeți să culegeți beneficiile acesteia, acest articol va rămâne foarte mult pe partea practică a poveștii, prezentând exemple care arată exact beneficiile utilizării sale, într-un mod în principal divorțat de teoria asociată.
Vom petrece doar o perioadă foarte mică de timp discutând conceptele academice care înconjoară injectarea dependenței aici, pentru că cea mai mare parte a acestei explicații va fi rezervată pentru al doilea articol al acestei serii. Într-adevăr, cărți întregi pot fi și au fost scrise care oferă o tratare mai aprofundată și mai riguroasă a conceptelor.
Aici, vom începe cu o explicație simplă, vom trece la câteva exemple din lumea reală și apoi vom discuta câteva informații de fundal. Un alt articol (care îl urmează pe acesta) va discuta despre modul în care Dependency Injection se încadrează în ecosistemul general de aplicare a modelelor arhitecturale de cele mai bune practici.
O explicație simplă
„Injecția de dependență” este un termen prea complex pentru un concept extrem de simplu. În acest moment, câteva întrebări înțelepte și rezonabile ar fi „cum definiți „dependența”?”, „ce înseamnă ca o dependență să fie „injectată”?”, „puteți injecta dependențe în moduri diferite?” și „de ce este util acest lucru?” S-ar putea să nu credeți că un termen precum „Injecția de dependență” poate fi explicat în două fragmente de cod și câteva cuvinte, dar, din păcate, poate.
Cel mai simplu mod de a explica conceptul este să îți arăți.
Aceasta, de exemplu, nu este o injecție de dependență:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Dar aceasta este injecția de dependență:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Terminat. Asta e. Misto. Sfarsit.
Ce sa schimbat? În loc să permită clasei Car
să instanțieze Engine
(așa cum a făcut în primul exemplu), în al doilea exemplu, Car
a avut o instanță de Engine
transmisă - sau injectată - de la un nivel superior de control către constructorul său. Asta e. În esență, aceasta este tot ceea ce este injecția de dependență - actul de a injecta (trece) o dependență într-o altă clasă sau funcție. Orice altceva care implică noțiunea de injectare a dependenței este pur și simplu o variație a acestui concept fundamental și simplu. În mod trivial, injecția de dependență este o tehnică prin care un obiect primește alte obiecte de care depinde, numite dependențe, mai degrabă decât să le creeze el însuși.
În general, pentru a defini ce este o „dependență”, dacă o clasă A
folosește funcționalitatea unei clase B
, atunci B
este o dependență pentru A
sau, cu alte cuvinte, A
are o dependență de B
Desigur, acest lucru nu se limitează la clase și este valabil și pentru funcții. În acest caz, clasa Car
are o dependență de clasa Engine
sau Engine
este o dependență de Car
. Dependențele sunt pur și simplu variabile, la fel ca majoritatea lucrurilor din programare.
Dependency Injection este utilizat pe scară largă pentru a sprijini multe cazuri de utilizare, dar poate cea mai flagrantă dintre utilizări este aceea de a permite testarea mai ușoară. În primul exemplu, nu putem ridica ușor engine
deoarece clasa Car
îl instanțează. Motorul adevărat este întotdeauna folosit. Dar, în acest ultim caz, avem control asupra Engine
care este utilizat, ceea ce înseamnă că, într-un test, putem subclasa Engine
și suprascrie metodele acestuia.
De exemplu, dacă dorim să vedem ce Car.startEngine()
dacă engine.fireCylinders()
afișează o eroare, am putea pur și simplu să creăm o clasă FakeEngine
, să-i extindem clasa Engine
și apoi să suprascriem fireCylinders
pentru a-l face să arunce o eroare. . În test, putem injecta acel obiect FakeEngine
în constructorul Car
. Deoarece FakeEngine
este un Engine
prin implicare a moștenirii, sistemul de tip TypeScript este satisfăcut. Folosirea moștenirii și suprascrierii metodei nu ar fi neapărat cea mai bună modalitate de a face acest lucru, așa cum vom vedea mai târziu, dar este cu siguranță o opțiune.
Vreau să fie foarte, foarte clar că ceea ce vedeți mai sus este noțiunea de bază a injecției de dependență. O Car
, în sine, nu este suficient de inteligentă pentru a ști ce motor are nevoie. Doar inginerii care construiesc mașina înțeleg cerințele pentru motoarele și roțile acesteia. Astfel, este logic ca oamenii care construiesc mașina să furnizeze motorul specific necesar, mai degrabă decât să lase Car
să aleagă orice motor dorește să folosească.
Folosesc cuvântul „construiți” în mod special pentru că construiți mașina apelând constructorul, care este locul unde sunt injectate dependențele. Dacă mașina și-a creat și propriile anvelope în plus față de motor, de unde știm că anvelopele utilizate sunt sigure pentru a fi rotite la turația maximă pe care o poate produce motorul? Din toate aceste motive și multe altele, ar trebui să aibă sens, poate intuitiv, ca Car
să nu aibă nimic de-a face cu a decide ce Engine
și ce Wheels
folosește. Acestea ar trebui să fie furnizate de la un nivel superior de control.
În cel din urmă exemplu care prezintă injecția de dependență în acțiune, dacă vă imaginați că Engine
este o clasă abstractă mai degrabă decât una concretă, acest lucru ar trebui să aibă și mai mult sens - mașina știe că are nevoie de un motor și știe că motorul trebuie să aibă unele funcționalități de bază. , dar modul în care este gestionat acel motor și care este implementarea specifică a acestuia este rezervat pentru a fi decis și furnizat de fragmentul de cod care creează (construie) mașina.
Un exemplu din lumea reală
Vom analiza mai multe exemple practice care, sperăm, ajută la explicarea, din nou intuitiv, de ce este utilă injecția de dependență. Să sperăm că, fără să recurgi la teoretic și, în schimb, trecând direct la concepte aplicabile, poți vedea mai pe deplin beneficiile pe care le oferă injecția de dependență și dificultățile vieții fără ea. Vom reveni la o tratare ceva mai „academică” a subiectului mai târziu.
Vom începe prin a ne construi aplicația în mod normal, într-un mod puternic cuplat, fără a utiliza injecția de dependență sau abstracții, astfel încât să ajungem să vedem dezavantajele acestei abordări și dificultatea pe care o adaugă testării. Pe parcurs, vom refactoriza treptat până când remediam toate problemele.
Pentru început, să presupunem că ați fost însărcinat să construiți două clase - un furnizor de e-mail și o clasă pentru un strat de acces la date care trebuie utilizat de către un anumit UserService
. Vom începe cu accesul la date, dar ambele sunt ușor de definit:
// 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(...) } }
Notă: Denumirea „Repository” aici provine de la „Repository Pattern”, o metodă de decuplare a bazei de date de logica afacerii dumneavoastră. Puteți afla mai multe despre modelul de depozit, dar în scopul acestui articol, puteți considera pur și simplu că este o clasă care încapsulează baza de date, astfel încât, în logica de afaceri, sistemul dvs. de stocare a datelor să fie tratat doar ca un simplu în memorie. Colectie. Explicarea completă a modelului de depozit nu este de competența acestui articol.
În mod normal, așa ne așteptăm să funcționeze, iar dbDriver
este codificat în fișier.
În UserService
, ai importa clasa, ai instanția și ai începe să o folosești:
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); } }
Încă o dată, totul rămâne normal.
O scurtă deosebire: un DTO este un obiect de transfer de date — este un obiect care acționează ca un sac de proprietăți pentru a defini o formă de date standardizată în timp ce se deplasează între două sisteme externe sau două straturi ale unei aplicații. Puteți afla mai multe despre DTO din articolul lui Martin Fowler pe această temă, aici. În acest caz, IRegisterUserDto
definește un contract pentru care ar trebui să fie forma datelor pe măsură ce provin de la client. Am doar două proprietăți — id
și e- email
. S-ar putea să credeți că este ciudat că DTO pe care îl așteptăm de la client pentru a crea un utilizator nou conține ID-ul utilizatorului, chiar dacă nu am creat încă un utilizator. ID-ul este un UUID și permit clientului să îl genereze dintr-o varietate de motive, care sunt în afara domeniului de aplicare al acestui articol. În plus, funcția findUserById
ar trebui să mapați obiectul User
la un răspuns DTO, dar am neglijat asta pentru concizie. În cele din urmă, în lumea reală, nu aș avea ca un model de domeniu de User
să conțină o metodă fromDto
. Asta nu este bun pentru puritatea domeniului. Încă o dată, scopul său este concizia aici.
Apoi, doriți să vă ocupați de trimiterea de e-mailuri. Încă o dată, ca de obicei, puteți să creați pur și simplu o clasă de furnizor de e-mail și să o importați în UserService
.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
În cadrul 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); } }
Acum avem o clasă muncitoare pe deplin și într-o lume în care nu ne pasă de testabilitate sau de scrierea de coduri curate, după orice fel de definiție, și într-o lume în care datoria tehnică este inexistentă și managerii de programe deranjanți. nu stabilesc termene limită, este perfect. Din păcate, aceasta nu este o lume în care avem avantajul să trăim.
Ce se întâmplă atunci când decidem că trebuie să migrăm de la SendGrid pentru e-mailuri și să folosim în schimb MailChimp? În mod similar, ce se întâmplă atunci când dorim să testăm metodele noastre – vom folosi baza de date reală în teste? Mai rău, vom trimite de fapt e-mailuri reale către adrese de e-mail potențial reale și vom plăti și pentru asta?
În ecosistemul tradițional JavaScript, metodele de testare unitară a claselor în această configurație sunt pline de complexitate și suprainginerie. Oamenii aduc biblioteci întregi, pur și simplu pentru a oferi funcționalitate stubbing, care adaugă tot felul de straturi de indirect și, chiar mai rău, pot cupla direct testele cu implementarea sistemului testat, când, în realitate, testele nu ar trebui să știe niciodată cum. sistemul real funcționează (aceasta este cunoscută sub numele de testare cutie neagră). Vom lucra pentru a atenua aceste probleme pe măsură ce discutăm care este responsabilitatea reală a UserService
și vom aplica noi tehnici de injectare a dependenței.
Luați în considerare, pentru un moment, ce face un UserService
. Scopul existenței UserService
este de a executa cazuri de utilizare specifice care implică utilizatori — înregistrarea acestora, citirea lor, actualizarea lor etc. Este o bună practică ca clasele și funcțiile să aibă o singură responsabilitate (SRP — Principiul responsabilității unice), iar responsabilitatea UserService
este de a gestiona operațiunile legate de utilizator. De ce, atunci, UserService
este responsabil pentru controlul duratei de viață a UserRepository
și SendGridEmailProvider
în acest exemplu?
Imaginați-vă dacă am avea o altă clasă folosită de UserService
care ar deschide o conexiune de lungă durată. Ar trebui UserService
să fie responsabil și pentru eliminarea acelei conexiuni? Desigur că nu. Toate aceste dependențe au o durată de viață asociată cu ele - ar putea fi singleton-uri, ar putea fi tranzitorii și vizate la o anumită solicitare HTTP, etc. Controlul acestor durate de viață este cu mult în afara domeniului UserService
. Deci, pentru a rezolva aceste probleme, vom injecta toate dependențele, așa cum am văzut înainte.
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); } }
Grozav! Acum UserService
primește obiecte pre-instanțiate și orice bucată de cod apelează și creează un nou UserService
este fragmentul de cod responsabil de controlul duratei de viață a dependențelor. Am inversat controlul de la UserService
și la un nivel superior. Dacă aș vrea doar să arăt cum am putea injecta dependențe prin constructor pentru a explica chiriașul de bază al injectării dependenței, m-aș opri aici. Totuși, există încă unele probleme din perspectiva designului, care, atunci când sunt remediate, vor servi pentru a face utilizarea injecției de dependență și mai puternică.
În primul rând, de ce știe UserService
că folosim SendGrid pentru e-mailuri? În al doilea rând, ambele dependențe sunt de clase concrete - UserRepository
concret și SendGridEmailProvider
concret. Această relație este prea rigidă - suntem blocați să trecem un obiect care este un UserRepository
și este un SendGridEmailProvider
.
Acest lucru nu este grozav deoarece dorim ca UserService
să fie complet agnostic la implementarea dependențelor sale. Făcând ca UserService
să fie orb în acest fel, putem schimba implementările fără a afecta deloc serviciul - asta înseamnă că, dacă decidem să migram de la SendGrid și să folosim MailChimp în schimb, putem face acest lucru. De asemenea, înseamnă că dacă vrem să falsificăm furnizorul de e-mail pentru teste, putem face și asta.
Ceea ce ar fi util este dacă am putea defini o interfață publică și am putea forța ca dependențele primite să respecte acea interfață, în timp ce UserService
să fie agnostic la detaliile implementării. Cu alte cuvinte, trebuie să forțăm UserService
să depindă doar de o abstractizare a dependențelor sale, și nu de dependențe concrete reale. Putem face asta prin, ei bine, interfețe.
Începeți prin a defini o interfață pentru UserRepository
și implementați-o:
// 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(...) } }
Și definiți unul pentru furnizorul de e-mail, implementându-l și:
// 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(...); } }
Notă: Acesta este modelul adaptorului din Gang of Four Design Patterns.
Acum, UserService
-ul nostru poate depinde mai degrabă de interfețe decât de implementările concrete ale dependențelor:
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); } }
Dacă interfețele sunt noi pentru dvs., acest lucru ar putea părea foarte, foarte complex. Într-adevăr, conceptul de a construi software cuplat liber ar putea fi nou și pentru tine. Gândiți-vă la prizele de perete. Puteți conecta orice dispozitiv la orice priză, atâta timp cât ștecherul se potrivește la priză. Acesta este un cuplaj liber în acțiune. Pâinea de pâine nu este conectată în perete, pentru că dacă a fost și decideți să vă actualizați prăjitorul, nu aveți noroc. În schimb, sunt folosite prize, iar priza definește interfața. În mod similar, atunci când conectați un dispozitiv electronic la priza de perete, nu vă interesează potențialul de tensiune, consumul maxim de curent, frecvența AC etc., vă pasă doar dacă ștecherul se potrivește în priză. Ai putea să vină un electrician și să schimbe toate firele din spatele acelei prize și nu vei avea probleme la conectarea prăjitorului de pâine, atâta timp cât acea priză nu se schimbă. Mai mult, sursa ta de electricitate ar putea fi schimbată pentru a veni din oraș sau din propriile panouri solare și, din nou, nu-ți pasă atâta timp cât poți încă să te conectezi la acea priză.
Interfața este priza, oferind funcționalitate „plug-and-play”. În acest exemplu, cablajul din perete și sursa de electricitate este asemănător cu dependențele, iar prăjitorul dvs. este asemănător cu UserService
(are o dependență de electricitate) - sursa de electricitate se poate schimba, iar prăjitorul încă funcționează bine și nu este necesar. fi atins, deoarece priza, acționând ca interfață, definește mijloacele standard pentru comunicarea ambilor. De fapt, ai putea spune că priza acționează ca o „abstracție” a cablajului de perete, a întrerupătoarelor, a sursei electrice etc.
Este un principiu comun și bine privit al designului software, din motivele de mai sus, codificarea împotriva interfețelor (abstracții) și nu a implementărilor, ceea ce am făcut aici. Făcând acest lucru, ni se oferă libertatea de a schimba implementările după bunul plac, deoarece acele implementări sunt ascunse în spatele interfeței (la fel cum cablarea de perete este ascunsă în spatele prizei), și astfel logica de afaceri care utilizează dependența nu trebuie niciodată să schimbați atâta timp cât interfața nu se schimbă niciodată. Amintiți-vă, UserService
trebuie să știe doar ce funcționalitate este oferită de dependențele sale , nu cum acea funcționalitate este acceptată în culise . De aceea, folosirea interfețelor funcționează.
Aceste două modificări simple de utilizare a interfețelor și injectarea dependențelor fac toată diferența în lume atunci când vine vorba de construirea de software cuplate slab și rezolvă toate problemele cu care am întâlnit mai sus.
Dacă decidem mâine că vrem să ne bazăm pe Mailchimp pentru e-mailuri, pur și simplu creăm o nouă clasă Mailchimp care onorează interfața IEmailProvider
și o injectăm în loc de SendGrid. Clasa UserService
nu trebuie să se schimbe niciodată, chiar dacă tocmai am făcut o schimbare majoră în sistemul nostru prin trecerea la un nou furnizor de e-mail. Frumusețea acestor modele constă în faptul că UserService
rămâne fericitor inconștient de modul în care dependențele pe care le folosește funcționează în spatele scenei. Interfața servește drept graniță arhitecturală între ambele componente, menținându-le decuplate corespunzător.
În plus, atunci când vine vorba de testare, putem crea falsuri care respectă interfețele și le putem injecta în schimb. Aici, puteți vedea un depozit fals și un furnizor de e-mail fals.
// 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)); }
Observați că ambele falsuri implementează aceleași interfețe pe care UserService
se așteaptă să le respecte dependențele. Acum, putem trece aceste falsuri în UserService
în loc de clasele reale, iar UserService
nu va fi deloc mai înțelept; le va folosi ca și cum ar fi adevărata afacere. Motivul pentru care poate face asta este pentru că știe că toate metodele și proprietățile pe care dorește să le folosească pentru dependențele sale într-adevăr există și sunt într-adevăr accesibile (deoarece implementează interfețele), ceea ce este tot ce trebuie să știe UserService
(adică, nu cum funcționează dependențele).
Le vom injecta pe acestea două în timpul testelor și va face procesul de testare mult mai ușor și mult mai simplu decât cu ce ați putea fi obișnuit atunci când aveți de-a face cu biblioteci exagerate de batjocură și stubbing, lucrând cu propria internă a lui Jest. scule, sau încercarea de maimuță-petice.
Iată testele reale folosind falsurile:
// 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); });
Veți observa câteva lucruri aici: falsurile scrise de mână sunt foarte simple. Nu există nicio complexitate din cadrele batjocoritoare care servesc doar la ofuscarea. Totul este rulat manual și asta înseamnă că nu există magie în baza de cod. Comportamentul asincron este falsificat pentru a se potrivi cu interfețele. Folosesc async/wait în teste, chiar dacă tot comportamentul este sincron, deoarece simt că se potrivește mai bine cu modul în care m-aș aștepta ca operațiunile să funcționeze în lumea reală și pentru că, adăugând async/wait, pot rula aceeași suită de teste. și împotriva implementărilor reale, în plus față de falsuri, astfel încât asincronizarea în mod corespunzător este necesară. De fapt, în viața reală, cel mai probabil nu mi-aș face griji să bat joc de baza de date și aș folosi în schimb o bază de date locală într-un container Docker până când vor exista atât de multe teste încât a trebuit să-l batjocoresc pentru performanță. Aș putea apoi să rulez testele DB în memorie după fiecare modificare și să rezerv testele DB locale reale chiar înainte de a efectua modificări și pentru serverul de compilare din conducta CI/CD.
În primul test, în secțiunea „aranja”, pur și simplu creăm DTO. În secțiunea „act”, apelăm sistemul testat și executăm comportamentul acestuia. Lucrurile devin puțin mai complexe atunci când se fac afirmații. Amintiți-vă, în acest moment al testului, nici măcar nu știm dacă utilizatorul a fost salvat corect. Deci, definim cum ne așteptăm să arate un utilizator persistent, apoi apelăm depozitul fals și îi cerem un utilizator cu ID-ul pe care îl așteptăm. Dacă UserService
nu a persistat corect utilizatorul, aceasta va genera o NotFoundError
și testul va eșua, în caz contrar, ne va returna utilizatorul. Apoi, sunăm furnizorul de e-mail fals și îl întrebăm dacă a înregistrat trimiterea unui e-mail către acel utilizator. În cele din urmă, facem afirmațiile cu Jest și asta încheie testul. Este expresiv și se citește la fel cum funcționează de fapt sistemul. Nu există nicio indirectă din bibliotecile batjocoritoare și nu există nicio legătură cu implementarea UserService
.
În al doilea test, creăm un utilizator existent și îl adăugăm la depozit, apoi încercăm să apelăm din nou serviciul folosind un DTO care a fost deja folosit pentru a crea și a persista un utilizator și ne așteptăm să eșueze. De asemenea, afirmăm că nu au fost adăugate date noi în depozit.
Pentru cel de-al treilea test, secțiunea „aranjare” constă acum în crearea unui utilizator și menținerea acestuia în depozitul fals. Apoi, apelăm SUT și, în final, verificăm dacă utilizatorul care revine este cel pe care l-am salvat în repo mai devreme.
Aceste exemple sunt relativ simple, dar atunci când lucrurile devin mai complexe, posibilitatea de a te baza pe injecția de dependență și pe interfețe în acest fel menține codul curat și face scrierea testelor o bucurie.
O scurtă deosebire despre testare: în general, nu trebuie să ridicați joc de fiecare dependență pe care o folosește codul. Mulți oameni, în mod eronat, susțin că o „unitate” dintr-un „test unitar” este o funcție sau o clasă. Asta nu poate fi mai incorect. „Unitatea” este definită ca „unitatea de funcționalitate” sau „unitatea de comportament”, nu o funcție sau clasă. Deci, dacă o unitate de comportament folosește 5 clase diferite, nu trebuie să le batjocorești toate acele clase decât dacă ajung în afara limitei modulului. In acest caz, am batjocorit baza de date si am batjocorit furnizorul de email pentru ca nu am de ales. Dacă nu vreau să folosesc o bază de date reală și nu vreau să trimit un e-mail, trebuie să le batjocoresc. Dar dacă aș avea mai multe clase care nu au făcut nimic în rețea, nu mi-aș bate joc de ele pentru că sunt detalii de implementare ale unității de comportament. De asemenea, aș putea decide să nu bat joc de baza de date și de e-mailuri și să creez o bază de date locală reală și un server SMTP real, ambele în containere Docker. La primul punct, nu am nicio problemă să folosesc o bază de date reală și să o numesc în continuare test unitar atâta timp cât nu este prea lent. În general, aș folosi mai întâi DB real până când a devenit prea lent și a trebuit să bat joc, așa cum am discutat mai sus. Dar, indiferent de ceea ce faci, trebuie să fii pragmatic - trimiterea de e-mailuri de bun venit nu este o operațiune esențială, așa că nu trebuie să mergem atât de departe în ceea ce privește serverele SMTP din containerele Docker. Ori de câte ori batjocoresc, este foarte puțin probabil să folosesc un cadru batjocoritor sau să încerc să afirm numărul de apeluri sau parametrii trecuți, cu excepția cazurilor foarte rare, deoarece asta ar cupla testele cu implementarea sistemului testat și ei. ar trebui să fie agnostic la aceste detalii.
Efectuarea injecției de dependență fără clase și constructori
Până acum, pe parcursul articolului, am lucrat exclusiv cu clase și am injectat dependențele prin constructor. Dacă adoptați o abordare funcțională a dezvoltării și doriți să nu utilizați clase, puteți obține în continuare beneficiile injectării dependenței folosind argumente ale funcției. De exemplu, clasa noastră UserService
de mai sus ar putea fi refactorizată în:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
Este o fabrică care primește dependențele și construiește obiectul de serviciu. De asemenea, putem injecta dependențe în funcțiile de ordin superior. Un exemplu tipic ar fi crearea unei funcții Express Middleware care primește un UserRepository
și un ILogger
injectat:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
În primul exemplu, nu am definit tipul de dto
și id
, deoarece dacă definim o interfață numită IUserService
care conține semnăturile metodei pentru serviciu, atunci compilatorul TS va deduce tipurile automat. În mod similar, dacă aș fi definit o semnătură de funcție pentru Express Middleware să fie tipul de returnare al authProvider
, nici nu ar fi trebuit să declar tipurile de argument acolo.
Dacă am considerat că furnizorul de e-mail și depozitul sunt și ele funcționale și dacă am injectat și dependențele lor specifice în loc să le codificăm greu, rădăcina aplicației ar putea arăta astfel:
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.
Concluzie
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;
- Și altele.
Rămâneți aproape!