Praktyczne wprowadzenie do wstrzykiwania zależności
Opublikowany: 2022-03-10Koncepcja Dependency Injection jest w swej istocie zasadniczo prostym pojęciem. Jest jednak powszechnie przedstawiany w sposób obok bardziej teoretycznych koncepcji odwrócenia kontroli, odwrócenia zależności, zasad SOLID i tak dalej. Aby maksymalnie ułatwić Ci rozpoczęcie korzystania z Dependency Injection i czerpanie z niego korzyści, ten artykuł pozostanie w dużej mierze po praktycznej stronie historii, przedstawiając przykłady, które dokładnie pokazują korzyści z jego stosowania, głównie w sposób oddzielona od powiązanej teorii.
Poświęcimy tylko bardzo mało czasu na omawianie koncepcji akademickich, które otaczają wstrzykiwanie zależności, ponieważ większość tego wyjaśnienia będzie zarezerwowana dla drugiego artykułu z tej serii. Rzeczywiście, mogą być i zostały napisane całe książki, które zapewniają bardziej dogłębne i rygorystyczne traktowanie pojęć.
Tutaj zaczniemy od prostego wyjaśnienia, przejdziemy do kilku bardziej rzeczywistych przykładów, a następnie omówimy kilka podstawowych informacji. W innym artykule (poniżej) omówimy, w jaki sposób Dependency Injection wpisuje się w ogólny ekosystem stosowania wzorców architektonicznych opartych na najlepszych praktykach.
Proste wyjaśnienie
„Dependency Injection” to nadmiernie skomplikowane określenie na niezwykle prostą koncepcję. W tym momencie niektóre mądre i rozsądne pytania brzmią „jak definiujesz „zależność”?”, „co to znaczy, że zależność jest „wstrzykiwana”?”, „czy możesz wstrzykiwać zależności na różne sposoby?” i „dlaczego to jest przydatne?” Możesz nie wierzyć, że termin taki jak „Wstrzyknięcie zależności” można wyjaśnić za pomocą dwóch fragmentów kodu i kilku słów, ale niestety tak.
Najprostszym sposobem wyjaśnienia tej koncepcji jest pokazanie.
To na przykład nie jest wstrzykiwanie zależności:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Ale to jest wstrzykiwanie zależności:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Gotowy. Otóż to. Fajny. Koniec.
Co się zmieniło? Zamiast zezwalać klasie Car
na tworzenie instancji Engine
(tak jak w pierwszym przykładzie), w drugim przykładzie Car
miał instancję Engine
przekazaną — lub wstrzykniętą — z wyższego poziomu kontroli do jego konstruktora. Otóż to. W swej istocie to wszystko jest wstrzykiwaniem zależności — aktem wstrzykiwania (przekazywania) zależności do innej klasy lub funkcji. Wszystko, co wiąże się z pojęciem wstrzykiwania zależności, jest po prostu odmianą tej podstawowej i prostej koncepcji. Mówiąc trywialnie, wstrzykiwanie zależności to technika, dzięki której obiekt otrzymuje inne obiekty, od których zależy, zwane zależnościami, zamiast tworzyć je samodzielnie.
Ogólnie rzecz biorąc, aby zdefiniować, czym jest „zależność”, jeśli jakaś klasa A
korzysta z funkcjonalności klasy B
, to B
jest zależnością dla A
, czyli innymi słowy, A
ma zależność od B
. Oczywiście nie ogranicza się to również do klas i funkcji. W tym przypadku klasa Car
jest zależna od klasy Engine
lub Engine
jest zależnością klasy Car
. Zależności to po prostu zmienne, tak jak większość rzeczy w programowaniu.
Dependency Injection jest szeroko stosowany do obsługi wielu przypadków użycia, ale być może najbardziej rażącym z zastosowań jest umożliwienie łatwiejszego testowania. W pierwszym przykładzie nie możemy łatwo wykpić engine
ponieważ tworzy go klasa Car
. Zawsze używany jest prawdziwy silnik. Ale w tym drugim przypadku mamy kontrolę nad używanym Engine
, co oznacza, że w teście możemy podklasy Engine
i nadpisać jego metody.
Na przykład, gdybyśmy chcieli zobaczyć, co Car.startEngine()
, jeśli engine.fireCylinders()
zgłosi błąd, moglibyśmy po prostu utworzyć klasę FakeEngine
, rozszerzyć ją na klasę Engine
, a następnie nadpisać fireCylinders
, aby powodowała błąd . W teście możemy wstrzyknąć ten obiekt FakeEngine
do konstruktora Car
. Ponieważ FakeEngine
jest Engine
przez implikację dziedziczenia, system typów TypeScript jest spełniony. Korzystanie z dziedziczenia i zastępowania metod niekoniecznie byłoby najlepszym sposobem na zrobienie tego, jak zobaczymy później, ale z pewnością jest to opcja.
Chcę, aby było bardzo, bardzo jasne, że to, co widzisz powyżej, jest podstawowym pojęciem wstrzykiwania zależności. Samo w sobie Car
nie jest wystarczająco inteligentny, aby wiedzieć, jakiego silnika potrzebuje. Tylko inżynierowie, którzy konstruują samochód, rozumieją wymagania dotyczące jego silników i kół. Dlatego ma sens, aby ludzie, którzy konstruują samochód, dostarczali konkretny wymagany silnik, zamiast pozwolić, aby Car
sam wybrał ten silnik, którego chce użyć.
Używam słowa „konstrukcja” w szczególności, ponieważ konstruujesz samochód, wywołując konstruktor, który jest miejscem wstrzykiwania zależności. Jeśli samochód stworzył również własne opony oprócz silnika, skąd mamy wiedzieć, że używane opony można bezpiecznie obracać przy maksymalnych obrotach silnika? Z tych wszystkich powodów i nie tylko, powinno mieć sens, być może intuicyjnie, że Car
nie powinien mieć nic wspólnego z decydowaniem, jakiego Engine
i jakich Wheels
używa. Powinny być zapewniane z jakiegoś wyższego poziomu kontroli.
W tym ostatnim przykładzie przedstawiającym wstrzykiwanie zależności w działaniu, jeśli wyobrazisz sobie, że Engine
jest klasą abstrakcyjną, a nie konkretną, powinno to mieć jeszcze większy sens — samochód wie, że potrzebuje silnika i wie, że silnik musi mieć jakąś podstawową funkcjonalność , ale jak ten silnik jest zarządzany i jaka jest jego konkretna implementacja, jest zarezerwowane do ustalenia i dostarczone przez fragment kodu, który tworzy (konstruuje) samochód.
Przykład z prawdziwego świata
Przyjrzymy się kilku bardziej praktycznym przykładom, które, miejmy nadzieję, pomogą wyjaśnić, ponownie intuicyjnie, dlaczego wstrzykiwanie zależności jest przydatne. Miejmy nadzieję, że nie wdając się w teorię, a zamiast tego przechodząc od razu do odpowiednich pojęć, możesz pełniej zobaczyć korzyści, jakie zapewnia wstrzykiwanie zależności oraz trudności życia bez niego. Później powrócimy do nieco bardziej „akademickiego” podejścia do tematu.
Zaczniemy od konstruowania naszej aplikacji w normalny sposób, w sposób wysoce powiązany, bez wykorzystywania wstrzykiwania zależności lub abstrakcji, tak abyśmy mogli zobaczyć wady tego podejścia i trudności, jakie dodaje do testowania. Po drodze będziemy stopniowo dokonywać refaktoryzacji, aż naprawimy wszystkie problemy.
Na początek załóżmy, że masz za zadanie zbudować dwie klasy — dostawcę poczty e-mail i klasę warstwy dostępu do danych, która musi być używana przez jakąś UserService
. Zaczniemy od dostępu do danych, ale oba są łatwe do zdefiniowania:
// 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(...) } }
Uwaga: nazwa „Repozytorium” pochodzi od „Wzorzec repozytorium”, metody oddzielania bazy danych od logiki biznesowej. Możesz dowiedzieć się więcej o wzorcu repozytorium, ale na potrzeby tego artykułu możesz po prostu uznać go za klasę, która hermetyzuje twoją bazę danych, dzięki czemu, zgodnie z logiką biznesową, twój system przechowywania danych jest traktowany jedynie jako in-memory kolekcja. Pełne wyjaśnienie wzorca repozytorium wykracza poza zakres tego artykułu.
W ten sposób zwykle oczekujemy, że wszystko będzie działać, a dbDriver
jest zakodowany w pliku.
W swoim UserService
, zaimportujesz klasę, utworzysz jej instancję i zaczniesz jej używać:
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); } }
Po raz kolejny wszystko pozostaje w normie.
Pokrótce na marginesie: DTO to obiekt transferu danych — jest to obiekt, który działa jak worek właściwości, aby zdefiniować ustandaryzowany kształt danych podczas przemieszczania się między dwoma systemami zewnętrznymi lub dwiema warstwami aplikacji. Możesz dowiedzieć się więcej o DTO z artykułu Martina Fowlera na ten temat, tutaj. W tym przypadku IRegisterUserDto
definiuje kontrakt, jaki powinien mieć kształt danych, jakie mają pochodzić od klienta. Mam tylko dwie właściwości — id
i email
. Możesz pomyśleć, że to dziwne, że DTO, którego oczekujemy od klienta, aby utworzyć nowego użytkownika, zawiera identyfikator użytkownika, nawet jeśli jeszcze go nie utworzyliśmy. Identyfikator to UUID i pozwalam klientowi go wygenerować z różnych powodów, które wykraczają poza zakres tego artykułu. Dodatkowo funkcja findUserById
powinna mapować obiekt User
na DTO odpowiedzi, ale zaniedbałem to dla zwięzłości. Wreszcie, w prawdziwym świecie nie miałbym, aby model domeny User
zawierał metodę fromDto
. To nie jest dobre dla czystości domeny. Po raz kolejny jego celem jest tutaj zwięzłość.
Następnie chcesz obsłużyć wysyłanie e-maili. Ponownie, jak zwykle, możesz po prostu utworzyć klasę dostawcy poczty e-mail i zaimportować ją do usługi UserService
.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
W ramach 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); } }
Mamy teraz w pełni klasę robotniczą i to w świecie, w którym nie zależy nam na testowalności ani pisaniu czystego kodu według jakiejkolwiek definicji, a także w świecie, w którym dług techniczny nie istnieje, a nieznośni menedżerowie programów t ustalać terminy, to jest w porządku. Niestety, nie jest to świat, w którym żyjemy z korzyścią.
Co się stanie, gdy zdecydujemy, że musimy migrować z SendGrid do obsługi e-maili i zamiast tego używać MailChimp? Podobnie, co się dzieje, gdy chcemy przetestować nasze metody jednostkowe — czy w testach będziemy używać prawdziwej bazy danych? Co gorsza, czy rzeczywiście zamierzamy wysyłać prawdziwe e-maile na potencjalnie prawdziwe adresy e-mail i też za to płacić?
W tradycyjnym ekosystemie JavaScript metody klas testów jednostkowych w tej konfiguracji są obarczone złożonością i nadmierną inżynierią. Ludzie sprowadzają całe biblioteki po prostu po to, aby zapewnić funkcjonalność opóźniania, która dodaje wszelkiego rodzaju warstwy pośrednie, a co gorsza, może bezpośrednio sprzęgać testy z implementacją testowanego systemu, podczas gdy w rzeczywistości testy nigdy nie powinny wiedzieć, jak prawdziwy system działa (jest to znane jako testowanie w czarnej skrzynce). Będziemy pracować nad złagodzeniem tych problemów, omawiając rzeczywistą odpowiedzialność UserService
i stosując nowe techniki wstrzykiwania zależności.
Zastanów się przez chwilę, co robi UserService
. Sednem istnienia UserService
jest realizacja konkretnych przypadków użycia z udziałem użytkowników — ich rejestrowanie, odczytywanie, aktualizowanie itp. Dobrą praktyką jest, aby klasy i funkcje miały tylko jedną odpowiedzialność (SRP — zasada pojedynczej odpowiedzialności), a obowiązkiem UserService
jest obsługa operacji związanych z użytkownikiem. Dlaczego zatem UserService
odpowiada za kontrolowanie okresu istnienia UserRepository
i SendGridEmailProvider
w tym przykładzie?
Wyobraź sobie, że mamy inną klasę używaną przez UserService
, która otwiera długotrwałe połączenie. Czy UserService
powinien być również odpowiedzialny za usunięcie tego połączenia? Oczywiście nie. Wszystkie te zależności są powiązane z okresem istnienia — mogą być pojedynczymi elementami, mogą być przejściowe i ograniczone do określonego żądania HTTP itp. Kontrolowanie tych okresów istnienia jest daleko poza zakresem UserService
. Aby rozwiązać te problemy, wstrzykniemy wszystkie zależności, tak jak widzieliśmy wcześniej.
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); } }
Świetnie! Teraz UserService
odbiera obiekty wstępnie utworzone, a dowolny fragment kodu wywołuje i tworzy nowy UserService
jest fragmentem kodu odpowiedzialnym za kontrolowanie okresu istnienia zależności. Odwróciliśmy kontrolę od UserService
na wyższy poziom. Gdybym chciał tylko pokazać, jak możemy wstrzykiwać zależności za pomocą konstruktora, aby wyjaśnić podstawową dzierżawę wstrzykiwania zależności, mógłbym na tym poprzestać. Jednak nadal istnieją pewne problemy z punktu widzenia projektowania, które po naprawieniu sprawią, że nasze wykorzystanie wstrzykiwania zależności będzie jeszcze potężniejsze.
Po pierwsze, dlaczego UserService
wie, że używamy SendGrid do e-maili? Po drugie, obie zależności dotyczą konkretnych klas — konkretnego UserRepository
i konkretnego SendGridEmailProvider
. Ta relacja jest zbyt sztywna — utknęliśmy, musimy przekazać jakiś obiekt, który jest UserRepository
i jest SendGridEmailProvider
.
To nie jest wspaniałe, ponieważ chcemy, aby UserService
była całkowicie niezależna od implementacji swoich zależności. Dzięki temu, że UserService
jest ślepy w ten sposób, możemy wymieniać implementacje bez wpływu na usługę — oznacza to, że jeśli zdecydujemy się na migrację z SendGrid i zamiast tego użyjemy MailChimp, możemy to zrobić. Oznacza to również, że jeśli chcemy sfałszować dostawcę poczty e-mail do testów, również możemy to zrobić.
Przydatne byłoby, gdybyśmy mogli zdefiniować jakiś publiczny interfejs i wymusić, aby przychodzące zależności były zgodne z tym interfejsem, a jednocześnie UserService
był niezależny od szczegółów implementacji. Innymi słowy, musimy zmusić UserService
do polegania tylko na abstrakcji jego zależności, a nie na rzeczywistych konkretnych zależnościach. Możemy to zrobić przez, cóż, interfejsy.
Zacznij od zdefiniowania interfejsu dla UserRepository
i zaimplementuj go:
// 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 zdefiniuj jeden dla dostawcy poczty e-mail, również go wdrażając:
// 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(...); } }
Uwaga: to jest wzorzec adaptera z grupy czterech wzorców projektowych.
Teraz nasza UserService
może zależeć od interfejsów, a nie od konkretnych implementacji zależności:
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); } }
Jeśli interfejsy są dla Ciebie nowe, może to wyglądać na bardzo, bardzo złożone. Rzeczywiście, koncepcja tworzenia luźno powiązanego oprogramowania może być dla Ciebie również nowa. Pomyśl o pojemnikach ściennych. Możesz podłączyć dowolne urządzenie do dowolnego gniazdka, o ile wtyczka pasuje do gniazdka. To luźne sprzężenie w akcji. Twój toster nie jest podłączony na stałe do ściany, ponieważ jeśli tak, i zdecydujesz się ulepszyć toster, nie masz szczęścia. Zamiast tego używane są gniazda, a gniazdo definiuje interfejs. Podobnie, kiedy podłączasz urządzenie elektroniczne do gniazdka ściennego, nie przejmujesz się potencjałem napięcia, maksymalnym poborem prądu, częstotliwością prądu przemiennego itp., Obchodzi Cię tylko to, czy wtyczka pasuje do gniazdka. Możesz mieć elektryka, który wejdzie i zmieni wszystkie przewody za tym gniazdkiem, i nie będziesz miał żadnych problemów z podłączeniem tostera, o ile to gniazdko się nie zmieni. Co więcej, twoje źródło energii elektrycznej może zostać przełączone na miasto lub własne panele słoneczne, i po raz kolejny nie przejmujesz się tym, dopóki nadal możesz podłączyć do tego gniazdka.
Interfejs jest gniazdem, zapewniającym funkcjonalność „plug-and-play”. W tym przykładzie okablowanie w ścianie i źródło prądu są zbliżone do zależności, a Twój toster jest zbliżony do usługi UserService
(jest uzależniony od energii elektrycznej) — źródło prądu może się zmienić, a toster nadal działa dobrze i nie musi być dotykane, ponieważ wylot, działając jako interfejs, określa standardowe środki komunikacji dla obu stron. W rzeczywistości można powiedzieć, że gniazdko działa jako „abstrakcja” okablowania ściennego, wyłączników, źródła elektrycznego itp.
Jest to powszechna i powszechnie uznawana zasada projektowania oprogramowania, z powyższych powodów, kodowanie przeciwko interfejsom (abstrakcje), a nie implementacjom, co tutaj zrobiliśmy. W ten sposób mamy swobodę wymiany implementacji, jak nam się podoba, ponieważ te implementacje są ukryte za interfejsem (tak jak okablowanie ścienne jest ukryte za gniazdem), więc logika biznesowa, która wykorzystuje tę zależność, nigdy nie musi zmieniać tak długo, jak interfejs nigdy się nie zmienia. Pamiętaj, że UserService
musi tylko wiedzieć, jakie funkcje oferują jego zależności , a nie jak ta funkcja jest obsługiwana za kulisami . Dlatego korzystanie z interfejsów działa.
Te dwie proste zmiany dotyczące korzystania z interfejsów i wstrzykiwania zależności robią ogromną różnicę w świecie, jeśli chodzi o budowanie luźno powiązanego oprogramowania i rozwiązują wszystkie problemy, które napotkaliśmy powyżej.
Jeśli jutro zdecydujemy, że będziemy polegać na Mailchimp w przypadku wiadomości e-mail, po prostu utworzymy nową klasę Mailchimp, która honoruje interfejs IEmailProvider
i wstrzykniemy ją zamiast SendGrid. Rzeczywista klasa UserService
nigdy nie musi się zmieniać, mimo że właśnie dokonaliśmy ogromnej zmiany w naszym systemie, przełączając się na nowego dostawcę poczty e-mail. Piękno tych wzorców polega na tym, że UserService
pozostaje w błogiej nieświadomości tego, w jaki sposób zależności, których używa, działają za kulisami. Interfejs służy jako architektoniczna granica między obydwoma komponentami, utrzymując je odpowiednio oddzielone.
Dodatkowo, jeśli chodzi o testowanie, możemy tworzyć podróbki, które trzymają się interfejsów i zamiast tego je wstrzykiwać. Tutaj możesz zobaczyć fałszywe repozytorium i fałszywego dostawcę poczty e-mail.
// 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)); }
Zauważ, że oba podróbki implementują te same interfejsy, które UserService
oczekuje od swoich zależności. Teraz możemy przekazać te podróbki do UserService
zamiast prawdziwych klas, a UserService
nie będzie mądrzejszy; użyje ich tak, jakby były prawdziwą okazją. Powodem, dla którego może to zrobić, jest to, że wie, że wszystkie metody i właściwości, których chce użyć w swoich zależnościach, rzeczywiście istnieją i są rzeczywiście dostępne (ponieważ implementują interfejsy), co jest wszystkim, co musi wiedzieć UserService
(tj. jak działają zależności).
Wprowadzimy te dwie rzeczy podczas testów, a to sprawi, że proces testowania będzie o wiele łatwiejszy i prostszy niż to, do czego możesz być przyzwyczajony, gdy masz do czynienia z przesadnymi bibliotekami do mockowania i skrótu, pracując z wewnętrznymi bibliotekami Jest oprzyrządowanie lub próba łatania małp.
Oto rzeczywiste testy z wykorzystaniem podróbek:
// 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); });
Zauważysz tutaj kilka rzeczy: odręczne podróbki są bardzo proste. Nie ma złożoności z szyderczych frameworków, które służą tylko do zaciemniania. Wszystko jest robione ręcznie, a to oznacza, że w kodzie nie ma magii. Zachowanie asynchroniczne jest fałszowane, aby pasowało do interfejsów. Używam async/await w testach, mimo że wszystkie zachowania są synchroniczne, ponieważ czuję, że bardziej pasuje do tego, jak oczekiwałbym, że operacje będą działać w prawdziwym świecie, a dodając async/await, mogę uruchomić ten sam zestaw testów przeciwko prawdziwym wdrożeniom oprócz fałszywych, dlatego wymagane jest odpowiednie przekazywanie asynchronii. W rzeczywistości najprawdopodobniej nawet nie przejmowałbym się wyśmiewaniem bazy danych i zamiast tego używałbym lokalnej bazy danych w kontenerze Dockera, dopóki nie było tak wielu testów, że musiałbym ją wyśmiewać pod kątem wydajności. Mogłem wtedy uruchomić testy bazy danych w pamięci po każdej zmianie i zarezerwować prawdziwe lokalne testy bazy danych bezpośrednio przed wprowadzeniem zmian i na serwerze kompilacji w potoku CI/CD.
W pierwszym teście, w sekcji „rozmieść”, po prostu tworzymy DTO. W sekcji „act” wywołujemy testowany system i wykonujemy jego zachowanie. Sprawy stają się nieco bardziej złożone podczas tworzenia asercji. Pamiętaj, w tym momencie testu nie wiemy nawet, czy użytkownik został poprawnie zapisany. Definiujemy więc, jak oczekujemy, że będzie wyglądać utrwalony użytkownik, a następnie wywołujemy fałszywe repozytorium i pytamy o użytkownika o oczekiwanym przez nas identyfikatorze. Jeśli UserService
nie zachowała użytkownika poprawnie, zgłosi to NotFoundError
i test się nie powiedzie, w przeciwnym razie zwróci nam użytkownika. Następnie dzwonimy do dostawcy fałszywej poczty e-mail i pytamy, czy zarejestrował wysłanie wiadomości e-mail do tego użytkownika. Na koniec wykonujemy asercje za pomocą Jest i na tym kończy się test. Jest wyrazisty i czyta się dokładnie tak, jak faktycznie działa system. Nie ma żadnych pośrednich od podrabiania bibliotek i nie ma sprzężenia z implementacją UserService
.
W drugim teście tworzymy istniejącego użytkownika i dodajemy go do repozytorium, następnie próbujemy ponownie wywołać usługę za pomocą DTO, które zostało już użyte do utworzenia i utrwalenia użytkownika i spodziewamy się, że to się nie powiedzie. Zapewniamy również, że do repozytorium nie zostały dodane żadne nowe dane.
W trzecim teście sekcja „uporządkuj” składa się teraz z utworzenia użytkownika i utrwalenia go w fałszywym repozytorium. Następnie wywołujemy SUT, a na koniec sprawdzamy, czy powracający użytkownik jest tym, którego wcześniej zapisaliśmy w repozytorium.
Te przykłady są stosunkowo proste, ale gdy sprawy stają się bardziej złożone, możliwość polegania w ten sposób na wstrzykiwaniu zależności i interfejsach utrzymuje kod w czystości i sprawia, że pisanie testów staje się przyjemnością.
Krótka uwaga na temat testowania: Ogólnie rzecz biorąc, nie musisz wyśmiewać każdej zależności używanej w kodzie. Wiele osób błędnie twierdzi, że „jednostka” w „teście jednostkowym” to jedna funkcja lub jedna klasa. To nie mogło być bardziej błędne. „Jednostka” jest definiowana jako „jednostka funkcjonalności” lub „jednostka zachowania”, a nie jedna funkcja lub klasa. Jeśli więc jednostka zachowania używa 5 różnych klas, nie musisz wyśmiewać wszystkich tych klas , chyba że wykraczają poza granice modułu. W tym przypadku wykpiłem bazę danych i wykpiłem dostawcę poczty, ponieważ nie mam wyboru. Jeśli nie chcę korzystać z prawdziwej bazy danych i nie chcę wysyłać e-maili, muszę je wykpić. Ale gdybym miał więcej klas, które nic nie robiły w sieci, nie szydziłbym z nich, ponieważ są to szczegóły implementacji jednostki zachowania. Mógłbym też zrezygnować z podszycia bazy danych i e-maili i uruchomić prawdziwą lokalną bazę danych oraz prawdziwy serwer SMTP, oba w kontenerach Dockera. Po pierwsze, nie mam problemu z używaniem prawdziwej bazy danych i nadal nazywam ją testem jednostkowym, o ile nie jest to zbyt wolne. Generalnie używałbym najpierw prawdziwego DB, dopóki nie stał się zbyt wolny i musiałbym kpić, jak omówiono powyżej. Ale bez względu na to, co robisz, musisz być pragmatyczny — wysyłanie e-maili powitalnych nie jest operacją o znaczeniu krytycznym, dlatego nie musimy posuwać się tak daleko, jeśli chodzi o serwery SMTP w kontenerach Docker. Za każdym razem, gdy robię mock, jest bardzo mało prawdopodobne, abym użył frameworka do mockowania lub próbował potwierdzić liczbę wywołań lub przekazanych parametrów, z wyjątkiem bardzo rzadkich przypadków, ponieważ wiązałoby się to z parowaniem testów z implementacją testowanego systemu, a oni powinien być agnostyczny wobec tych szczegółów.
Wykonywanie wstrzykiwania zależności bez klas i konstruktorów
Jak dotąd w całym artykule pracowaliśmy wyłącznie z klasami i wstrzykiwaliśmy zależności za pomocą konstruktora. Jeśli stosujesz funkcjonalne podejście do programowania i nie chcesz używać klas, nadal możesz uzyskać korzyści z wstrzykiwania zależności za pomocą argumentów funkcji. Na przykład powyższa klasa UserService
może zostać zrefaktorowana na:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
To fabryka, która otrzymuje zależności i konstruuje obiekt usługi. Możemy również wstrzykiwać zależności do funkcji wyższego rzędu. Typowym przykładem może być utworzenie funkcji Express Middleware, która pobiera UserRepository
i wstrzyknięty ILogger
:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
W pierwszym przykładzie nie zdefiniowałem typu dto
i id
, ponieważ jeśli zdefiniujemy interfejs o nazwie IUserService
zawierający sygnatury metod dla usługi, to kompilator TS automatycznie wywnioskuje typy. Podobnie, gdybym zdefiniował sygnaturę funkcji dla Express Middleware jako typ zwracany przez authProvider
, nie musiałbym również deklarować tam typów argumentów.
Gdybyśmy uznali dostawcę poczty e-mail i repozytorium również za funkcjonalne, a także wstrzyknęliśmy ich specyficzne zależności zamiast na stałe je kodować, root aplikacji mógłby wyglądać tak:
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.
Wniosek
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 więcej.
Bądźcie czujni!