依賴注入實用介紹
已發表: 2022-03-10依賴注入的概念本質上是一個簡單的概念。 然而,它通常與控制反轉、依賴反轉、SOLID 原則等更具理論性的概念一起呈現。 為了讓您盡可能輕鬆地開始使用依賴注入並開始獲得它的好處,本文將非常注重故事的實際方面,描述的示例準確地展示了使用它的好處,主要是脫離了相關理論。
我們將只花很少的時間在這裡討論圍繞依賴注入的學術概念,因為大部分解釋將保留在本系列的第二篇文章中。 事實上,整本書都可以並且已經被寫成對這些概念提供了更深入和嚴格的處理。
在這裡,我們將從一個簡單的解釋開始,轉向更多真實世界的示例,然後討論一些背景信息。 另一篇文章(緊隨本文之後)將討論依賴注入如何融入應用最佳實踐架構模式的整個生態系統。
一個簡單的解釋
“依賴注入”對於一個極其簡單的概念來說是一個過於復雜的術語。 在這一點上,一些明智和合理的問題將是“你如何定義'依賴'?”,“依賴被'注入'意味著什麼?”,“你能以不同的方式註入依賴嗎?” 和“為什麼這有用?” 你可能不相信像“依賴注入”這樣的術語可以用兩個代碼片段和幾個詞來解釋,但唉,它可以。
解釋這個概念的最簡單方法是向您展示。
例如,這不是依賴注入:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
但這是依賴注入:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
完畢。 而已。 涼爽的。 結束。
發生了什麼變化? 在第二個示例中, Car
沒有允許Car
類實例化Engine
(就像在第一個示例中所做的那樣),而是將一個Engine
的實例從更高級別的控制傳入或註入到其構造函數中。 而已。 從本質上講,這就是所有依賴注入——將依賴注入(傳遞)到另一個類或函數的行為。 任何其他涉及依賴注入概念的東西都只是這個基本和簡單概念的變體。 簡單地說,依賴注入是一種技術,對象接收它所依賴的其他對象,稱為依賴項,而不是自己創建它們。
一般來說,要定義什麼是“依賴”,如果某個類A
使用了類B
的功能,那麼B
就是A
的依賴,或者換句話說, A
依賴於B
當然,這不僅限於類,也適用於函數。 在這種情況下,類Car
依賴於Engine
類,或者Engine
是Car
的依賴項。 依賴關係只是變量,就像編程中的大多數事情一樣。
依賴注入被廣泛用於支持許多用例,但也許最明顯的用途是允許更輕鬆的測試。 在第一個示例中,我們不能輕易模擬engine
,因為Car
類實例化了它。 真正的引擎總是被使用。 但是,在後一種情況下,我們可以控制所使用的Engine
,這意味著,在測試中,我們可以繼承Engine
並覆蓋它的方法。
例如,如果我們想看看Car.startEngine()
在engine.fireCylinders()
拋出錯誤時做了什麼,我們可以簡單地創建一個FakeEngine
類,讓它擴展Engine
類,然後重寫fireCylinders
使其拋出錯誤. 在測試中,我們可以將該FakeEngine
對象注入到Car
的構造函數中。 由於FakeEngine
是繼承暗示的Engine
,因此滿足 TypeScript 類型系統。 正如我們稍後會看到的,使用繼承和方法覆蓋不一定是最好的方法,但它肯定是一種選擇。
我想非常非常清楚地說明,您在上面看到的是依賴注入的核心概念。 Car
本身不夠聰明,無法知道它需要什麼引擎。 只有製造汽車的工程師才能了解其發動機和車輪的要求。 因此,製造汽車的人提供所需的特定引擎是有道理的,而不是讓Car
自己選擇它想使用的任何引擎。
我使用“構造”這個詞是因為你通過調用構造函數來構造汽車,這是注入依賴的地方。 如果汽車除了發動機之外還製造了自己的輪胎,我們怎麼知道正在使用的輪胎在發動機可以輸出的最大轉速下是安全的? 出於所有這些原因以及更多原因,這應該是有道理的,也許直覺上, Car
應該與決定它使用什麼Engine
和Wheels
無關。 它們應該由更高級別的控制提供。
在後一個描述依賴注入的例子中,如果你把Engine
想像成一個抽像類而不是一個具體的類,這應該更有意義——汽車知道它需要一個引擎並且它知道引擎必須具有一些基本功能,但是如何管理該引擎以及它的具體實現是保留的,由創建(構造)汽車的代碼段決定和提供。
一個真實的例子
我們將看一些更實際的例子,希望有助於再次直觀地解釋為什麼依賴注入是有用的。 希望通過不拘泥於理論,而是直接進入適用的概念,您可以更充分地看到依賴注入提供的好處,以及沒有它的生活困難。 稍後我們將恢復到對該主題的稍微“學術”的處理。
我們將從正常構建我們的應用程序開始,以一種高度耦合的方式,不使用依賴注入或抽象,這樣我們就會看到這種方法的缺點以及它給測試帶來的困難。 在此過程中,我們將逐步重構,直到我們糾正所有問題。
首先,假設您的任務是構建兩個類 - 一個電子郵件提供程序和一個用於需要由某些UserService
使用的數據訪問層的類。 我們將從數據訪問開始,但兩者都很容易定義:
// 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(...) } }
注意:這裡的“存儲庫”名稱來自“存儲庫模式”,一種將數據庫與業務邏輯解耦的方法。 您可以了解有關存儲庫模式的更多信息,但就本文而言,您可以簡單地將其視為封裝數據庫的某個類,因此,對於業務邏輯而言,您的數據存儲系統僅被視為內存中的一個收藏。 全面解釋存儲庫模式超出了本文的範圍。
這就是我們通常期望的工作方式,並且dbDriver
在文件中是硬編碼的。
在您的UserService
中,您將導入該類,對其進行實例化,然後開始使用它:
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); } }
再一次,一切都保持正常。
順便說一句: DTO 是一個數據傳輸對象——它是一個充當屬性包的對象,用於在兩個外部系統或應用程序的兩個層之間移動時定義標準化的數據形狀。 您可以在此處從 Martin Fowler 關於該主題的文章中了解有關 DTO 的更多信息。 在這種情況下, IRegisterUserDto
定義了一個契約,用於定義來自客戶端的數據應該是什麼形狀。 我只讓它包含兩個屬性—— id
和email
。 您可能認為我們期望客戶端創建新用戶的 DTO 包含用戶的 ID 很奇怪,即使我們還沒有創建用戶。 ID 是一個 UUID,我允許客戶端出於各種原因生成它,這超出了本文的範圍。 此外, findUserById
函數應該將User
對象映射到響應 DTO,但為簡潔起見,我忽略了這一點。 最後,在現實世界中,我不會讓User
域模型包含fromDto
方法。 這對域純度不利。 再一次,它的目的是簡潔。
接下來,您要處理電子郵件的發送。 再一次,像往常一樣,您可以簡單地創建一個電子郵件提供程序類並將其導入您的UserService
。
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
在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); } }
我們現在有一個完整的工人階級,在一個我們根本不關心可測試性或以任何定義方式編寫乾淨代碼的世界中,在一個技術債務不存在且討厭的項目經理不存在的世界中' t設定最後期限,這很好。 不幸的是,這不是一個我們可以從中受益的世界。
當我們決定需要從 SendGrid 遷移電子郵件並改用 MailChimp 時會發生什麼? 同樣,當我們想要對我們的方法進行單元測試時會發生什麼——我們要在測試中使用真實的數據庫嗎? 更糟糕的是,我們真的會向潛在的真實電子郵件地址發送真實的電子郵件並為此付費嗎?
在傳統的 JavaScript 生態系統中,這種配置下的單元測試類的方法充滿了複雜性和過度工程化。 人們引入整個庫只是為了提供存根功能,這增加了各種間接層,更糟糕的是,可以將測試直接耦合到被測系統的實現,而實際上,測試永遠不知道如何真正的系統工作(這被稱為黑盒測試)。 當我們討論UserService
的實際職責是什麼並應用依賴注入的新技術時,我們將努力緩解這些問題。
考慮一下, UserService
做了什麼。 UserService
存在的全部意義在於執行涉及用戶的特定用例——註冊、閱讀、更新等。類和函數只有一個責任(SRP——單一責任原則)是一種最佳實踐,而UserService
的職責是處理用戶相關的操作。 那麼,在這個例子中,為什麼UserService
負責控制UserRepository
和SendGridEmailProvider
的生命週期呢?
想像一下,如果我們有UserService
使用的其他類打開了一個長時間運行的連接。 UserService
是否也應該負責處理該連接? 當然不是。 所有這些依賴項都有一個與之相關的生命週期——它們可以是單例的,它們可以是瞬態的並且作用於特定的 HTTP 請求等。這些生命週期的控制遠遠超出了UserService
的範圍。 因此,為了解決這些問題,我們將注入所有依賴項,就像我們之前看到的那樣。
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); } }
偉大的! 現在UserService
接收預實例化的對象,任何一段代碼調用並創建一個新的UserService
都是負責控制依賴項生命週期的代碼。 我們已經將控制從UserService
轉移到更高的級別。 如果我只是想展示我們如何通過構造函數注入依賴項來解釋依賴項注入的基本租戶,我可以在這裡停止。 然而,從設計的角度來看仍然存在一些問題,當這些問題得到糾正時,將使我們對依賴注入的使用更加強大。
首先,為什麼UserService
知道我們正在使用 SendGrid 處理電子郵件? 其次,這兩個依賴項都依賴於具體的類——具體的UserRepository
和具體的SendGridEmailProvider
。 這種關係太僵化了——我們不得不傳入一些對象,它是一個UserRepository
並且是一個SendGridEmailProvider
。
這不是很好,因為我們希望UserService
對其依賴項的實現完全不可知。 通過以這種方式讓UserService
成為盲人,我們可以在完全不影響服務的情況下交換實現——這意味著,如果我們決定從 SendGrid 遷移並改用 MailChimp,我們可以這樣做。 這也意味著,如果我們想偽造電子郵件提供商進行測試,我們也可以這樣做。
如果我們可以定義一些公共接口並強制傳入的依賴項遵守該接口,同時仍然讓UserService
對實現細節不可知,這將是有用的。 換句話說,我們需要強制UserService
僅依賴於其依賴的抽象,而不是實際的具體依賴。 我們可以通過接口來做到這一點。
首先為UserRepository
定義一個接口並實現它:
// 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(...) } }
並為電子郵件提供商定義一個,同時實現它:
// 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(...); } }
注意:這是四種設計模式中的適配器模式。
現在,我們的UserService
可以依賴接口而不是依賴項的具體實現:
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); } }
如果接口對您來說是新的,這可能看起來非常非常複雜。 事實上,構建鬆散耦合軟件的概念對您來說可能也是新的。 想想牆上的插座。 只要插頭適合插座,您就可以將任何設備插入任何插座。 這就是鬆散耦合。 你的烤麵包機沒有硬連線到牆上,因為如果是這樣,你決定升級你的烤麵包機,你就不走運了。 相反,使用了出口,出口定義了接口。 同樣,當您將電子設備插入壁式插座時,您並不關心電壓電位、最大電流消耗、交流頻率等,您只關心插頭是否適合插座。 您可以讓電工進來並更換該插座後面的所有電線,只要該插座不改變,插入烤麵包機就不會遇到任何問題。 此外,您的電源可以切換為來自城市或您自己的太陽能電池板,再一次,只要您仍然可以插入該插座,您就不必在意。
接口就是插座,提供“即插即用”功能。 在這個例子中,牆上的佈線和電源類似於依賴關係,你的烤麵包機類似於UserService
(它依賴於電力)——電源可以改變,烤麵包機仍然可以正常工作,不需要被觸及,因為作為接口的出口定義了雙方通信的標準方式。 實際上,您可以說插座充當牆壁佈線、斷路器、電源等的“抽象”。
由於上述原因,針對接口(抽象)而不是實現進行編碼是軟件設計的一個常見且備受推崇的原則,這就是我們在這裡所做的。 這樣做時,我們可以隨意更換實現,因為這些實現隱藏在接口後面(就像牆壁佈線隱藏在插座後面一樣),因此使用依賴項的業務邏輯永遠不必只要界面從不改變,就改變。 請記住, UserService
只需要知道其依賴項提供了哪些功能,而不需要知道該功能在幕後是如何支持的。 這就是使用接口有效的原因。
在構建鬆散耦合軟件時,利用接口和注入依賴項這兩個簡單的變化使世界變得不同,並解決了我們在上面遇到的所有問題。
如果我們明天決定要依賴 Mailchimp 來處理電子郵件,我們只需創建一個新的 Mailchimp 類,該類尊重IEmailProvider
接口並將其註入而不是 SendGrid。 即使我們剛剛通過切換到新的電子郵件提供商對我們的系統進行了巨大的更改,實際的UserService
類也不必更改。 這些模式的美妙之處在於, UserService
仍然幸福地不知道它使用的依賴項是如何在幕後工作的。 接口充當兩個組件之間的架構邊界,使它們保持適當的解耦。

此外,在測試方面,我們可以創建遵守接口的假貨並註入它們。 在這裡,您可以看到一個虛假的存儲庫和一個虛假的電子郵件提供商。
// 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)); }
請注意,這兩個偽造品都實現了UserService
期望其依賴項遵守的相同接口。 現在,我們可以將這些偽造的東西傳遞給UserService
而不是真正的類,而UserService
就不會更聰明了; 它會使用它們,就好像它們是真正的交易一樣。 它可以這樣做的原因是因為它知道它想要在其依賴項上使用的所有方法和屬性確實存在並且確實可以訪問(因為它們實現了接口),這是UserService
需要知道的所有內容(即,不依賴項如何工作)。
我們將在測試期間注入這兩個,這將使測試過程變得更加容易和直接,比您在處理頂級模擬和存根庫時可能習慣的方式,使用 Jest 自己的內部工具,或嘗試猴子補丁。
以下是使用假貨的實際測試:
// 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); });
您會在這裡註意到一些事情: 手寫的贗品非常簡單。 僅用於混淆的模擬框架沒有復雜性。 一切都是手工製作的,這意味著代碼庫中沒有魔法。 偽造異步行為以匹配接口。 儘管所有行為都是同步的,但我在測試中使用 async/await,因為我覺得它更符合我期望操作在現實世界中工作的方式,並且因為通過添加 async/await,我可以運行相同的測試套件除了假貨之外,還針對真實的實現,因此需要適當地處理異步。 事實上,在現實生活中,我很可能甚至不擔心模擬數據庫,而是在 Docker 容器中使用本地數據庫,直到有太多的測試我不得不模擬它以提高性能。 然後,我可以在每次更改後運行內存中的數據庫測試,並在提交更改之前和 CI/CD 管道中的構建服務器上保留真正的本地數據庫測試。
在第一個測試中,在“排列”部分,我們只是創建了 DTO。 在“行為”部分,我們調用被測系統並執行其行為。 做出斷言時,事情會變得稍微複雜一些。 請記住,在測試的這一點上,我們甚至不知道用戶是否被正確保存。 因此,我們定義了我們期望持久用戶的樣子,然後我們調用假存儲庫並要求它提供具有我們期望的 ID 的用戶。 如果UserService
沒有正確持久化用戶,這將拋出NotFoundError
並且測試將失敗,否則,它將返回給我們用戶。 接下來,我們打電話給假電子郵件提供商,詢問它是否記錄了向該用戶發送電子郵件的記錄。 最後,我們使用 Jest 進行斷言並結束測試。 它富有表現力,讀起來就像系統的實際工作方式一樣。 模擬庫沒有間接性,也沒有與UserService
的實現耦合。
在第二個測試中,我們創建了一個現有用戶並將其添加到存儲庫中,然後我們嘗試使用已用於創建和持久化用戶的 DTO 再次調用該服務,我們預計這會失敗。 我們還斷言沒有新數據添加到存儲庫中。
對於第三個測試,“安排”部分現在包括創建用戶並將其持久化到假存儲庫。 然後,我們調用 SUT,最後,檢查返回的用戶是否是我們之前保存在 repo 中的用戶。
這些示例相對簡單,但是當事情變得更複雜時,能夠以這種方式依賴依賴注入和接口可以使您的代碼保持乾淨,並使編寫測試成為一種樂趣。
關於測試的簡短說明:通常,您不需要模擬代碼使用的每個依賴項。 許多人錯誤地聲稱“單元測試”中的“單元”是一個函數或一個類。 那再錯誤不過了。 “單元”被定義為“功能單元”或“行為單元”,而不是一個功能或類。 因此,如果一個行為單元使用 5 個不同的類,則不需要模擬所有這些類,除非它們超出了模塊的邊界。 在這種情況下,我模擬了數據庫並模擬了電子郵件提供商,因為我別無選擇。 如果我不想使用真正的數據庫並且不想發送電子郵件,我必須模擬它們。 但是如果我有更多的類在網絡上沒有做任何事情,我不會模擬它們,因為它們是行為單元的實現細節。 我還可以決定不模擬數據庫和電子郵件,並在 Docker 容器中啟動一個真正的本地數據庫和一個真正的 SMTP 服務器。 在第一點上,我使用真正的數據庫沒有問題,只要它不是太慢,我仍然稱它為單元測試。 一般來說,我會先使用真正的數據庫,直到它變得太慢並且我不得不模擬,如上所述。 但是,無論你做什麼,你都必須務實——發送歡迎電子郵件不是一項關鍵任務操作,因此我們不需要在 Docker 容器中的 SMTP 服務器方面走那麼遠。 每當我進行模擬時,我都不太可能使用模擬框架或嘗試斷言調用的次數或傳遞的參數,除非在極少數情況下,因為這會將測試與被測系統的實現結合起來,並且它們應該對這些細節不可知。
在沒有類和構造函數的情況下執行依賴注入
到目前為止,在整篇文章中,我們只使用類並通過構造函數注入依賴項。 如果您採用函數式開發方法並且不希望使用類,仍然可以使用函數參數獲得依賴注入的好處。 例如,我們上面的UserService
類可以重構為:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
它是一個接收依賴關係並構造服務對象的工廠。 我們還可以將依賴項注入到高階函數中。 一個典型的例子是創建一個 Express Middleware 函數,該函數獲取一個UserRepository
和一個ILogger
注入:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
在第一個示例中,我沒有定義dto
和id
的類型,因為如果我們定義一個名為IUserService
的接口,其中包含服務的方法簽名,那麼 TS 編譯器將自動推斷類型。 同樣,如果我將 Express 中間件的函數簽名定義為authProvider
的返回類型,我也不必在那裡聲明參數類型。
如果我們認為電子郵件提供程序和存儲庫也可以正常工作,並且如果我們也注入它們的特定依賴項而不是硬編碼它們,那麼應用程序的根可能如下所示:
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.
結論
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;
- 和更多。
敬請關注!