依赖注入实用介绍
已发表: 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;
- 和更多。
敬请关注!