依赖注入和反转的解释 | Node.js w/ TypeScript

525 阅读9分钟

我们在编程中最先学会的事情之一是将大问题分解成小部分。这种分而治之的方法可以帮助我们把任务分配给其他人,通过一次只关注一件事来减少焦虑,并提高我们设计的模块化程度。

但总有一天,事情会准备好被连接起来。

这就是大多数开发者走错路的地方。

大多数开发者还没有了解到坚实的原则或软件组合,就开始写一些不应该耦合的模块和类,导致代码很难改变,也很难测试

在这篇文章中,我们要学习的是

  • 组件和软件组合
  • 如何不把组件挂起来
  • 如何以及为什么使用依赖注入来注入依赖关系
  • 如何应用依赖反转并编写可测试的代码
  • 使用控制反转容器的考虑因素

术语

在我们继续之前,让我们确保理解关于连接依赖的术语。

组件

我将会经常使用组件这个术语。这个术语可能会引起React.js或Angular开发者的共鸣,但它可以在Web、Angular或React的范围之外使用。

一个组件只是一个应用程序的一部分。它是任何一组软件,旨在成为一个更大系统的一部分。

这个想法是将一个大型的应用程序分解成几个模块化的组件,可以独立开发和组装。

你对软件了解得越多,你就越能意识到,好的软件设计都是关于组件的组成

如果不能做到这一点,就会导致无法测试的笨重的代码。

依赖性注入

最终,我们将需要以某种方式把组件连接起来。让我们看看一个微不足道的(非理想的)方法,我们可以把两个组件连接在一起。

在下面的例子中,我们想把一个UserController 挂起来,这样当有人向/api/users 发出 HTTP GET 请求时,它就能从UserRepo (仓库)中检索到所有的User[]s。

repos/userRepo.ts

/**
 * @class UserRepo
 * @desc Responsible for pulling users from persistence.
 **/

export class UserRepo {
  constructor () {}

  getUsers (): Promise<User[]> {
    // Use Sequelize or TypeORM to retrieve the users from
    // a database.
  }
}

而控制器...

controllers/userController.ts

import { UserRepo } from '../repos' // Bad

/**
 * @class UserController
 * @desc Responsible for handling API requests for the
 * /user route.
 **/

class UserController {
  private userRepo: UserRepo;

  constructor () {
    this.userRepo = new UserRepo(); // Also bad, read on for why
  }

  async handleGetUsers (req, res): Promise<void> {
    const users = await this.userRepo.getUsers();
    return res.status(200).json({ users });
  }
}

在这个例子中,我们通过在UserController 类中引用 UserRepo 类的名称,将一个UserRepo 直接连接到一个UserController

这并不理想。当我们这样做的时候,我们创造了一个源代码的依赖性

源代码依赖性。当当前组件(类、模块等)依赖至少一个其他组件才能被编译时。源代码依赖性应该是有限的。

问题是,每当我们想要旋转UserController ,我们需要确保UserRepo ,这样代码才能被编译。

UserController类直接依赖于UserRepo类。

什么时候你可能想启动一个孤立的UserController

在测试期间。

在测试过程中,模拟伪造 当前被测模块的依赖关系是一种常见的做法,以便隔离和测试不同的行为。

注意到我们是如何a)将具体的 UserRepo 类导入文件中,b)在UserController 构造函数中创建一个实例的吗?

这使得这段代码无法测试。或者至少,如果UserRepo 连接到一个真实运行的数据库,我们就必须把整个数据库连接带在身边来运行我们的测试,使其非常缓慢......


依赖性注入是一种技术,可以提高我们代码的可测试性。

它的工作原理是通过传递(通常是通过构造函数)你的模块需要运行的依赖关系。

UserRepo 如果我们改变一下从UserController ,注入的方式,我们可以稍微改善一下。

controllers/userController.ts

import { UserRepo } from '../repos' // Still bad

/**
 * @class UserController
 * @desc Responsible for handling API requests for the
 * /user route.
 **/

class UserController {
  private userRepo: UserRepo;

  constructor (userRepo: UserRepo) { // Better, inject via constructor
    this.userRepo = userRepo; 
  }

  async handleGetUsers (req, res): Promise<void> {
    const users = await this.userRepo.getUsers();
    return res.status(200).json({ users });
  }
}

尽管我们使用了依赖注入,但仍有一个问题。

UserController 仍然直接依赖 。UserRepo

这种依赖关系仍然成立。

即使如此,如果我们想模拟出我们的UserRepo ,连接到一个真正的SQL数据库的模拟内存库,目前还不能实现。

UserController 需要一个 ,特别是。UserRepo

controllers/userRepo.spec.ts

let userController: UserController;

beforeEach(() => {
  userController = new UserController(
    new UserRepo() // Slows down tests, needs a db running
  )
});

那么......我们该怎么做呢?

介绍一下依赖反转原则!

依赖反转

依赖反转是一种技术,它允许我们将组件相互解耦。看看这个吧。

依赖关系的流向现在是什么方向?

从左到右。UserController 依赖于UserRepo

好的。准备好了吗?

看看当我们在这两个组件之间打上一个接口,让UserRepo 实现一个IUserRepo 接口,然后将UserController 指向这个接口,而不是UserRepo 具体的类,会发生什么。

repos/userRepo.ts

/**
 * @interface IUserRepo
 * @desc Responsible for pulling users from persistence.
 **/

export interface IUserRepo {          // Exported
  getUsers (): Promise<User[]>
}

class UserRepo implements IUserRepo { // Not exported
  constructor () {}

  getUsers (): Promise<User[]> {
    ...
  }
}

并更新控制器以引用IUserRepo 接口不是UserRepo 具体类。

controllers/userController.ts

import { IUserRepo } from '../repos' // Good!

/**
 * @class UserController
 * @desc Responsible for handling API requests for the
 * /user route.
 **/

class UserController {
  private userRepo: IUserRepo; // like here

  constructor (userRepo: IUserRepo) { // and here
    this.userRepo = userRepo;
  }

  async handleGetUsers (req, res): Promise<void> {
    const users = await this.userRepo.getUsers();
    return res.status(200).json({ users });
  }
}

现在看一下依赖关系的流动方向。

你看到我们刚刚做了什么吗?通过将所有的引用从具体类改为接口,我们只是翻转了依赖关系图,在两个组件之间创建了一个架构边界

设计原则。针对接口编程,而不是实现。

也许你并不像我一样对此感到兴奋。让我来告诉你为什么这有这么大的好处。

还记得我说过,我们希望能够在UserController ,而不需要传入UserRepo ,完全是因为这样会使测试变慢(UserRepo 需要数据库连接才能运行)?

那么,现在我们可以写一个MockUserRepo,它实现了IUserRepo 和接口上的所有方法,而不是使用一个依赖缓慢的数据库连接的类,而是使用一个包含User[]的内部数组的类(快多了!⚡)。

这就是我们要传递给UserController ,而不是。

使用MockUserRepo 来模拟出我们的UserController

repos/mocks/mockUserRepo.ts

import { IUserRepo } from '../repos';

class MockUserRepo implements IUserRepo {
  private users: User[] = [];

  constructor () {}

  async getUsers (): Promise<User[]> { 
    return this.users;
  }
}

提示。将 "async "添加到一个方法中会自动将其包裹在一个Promise中,这样就可以很容易地伪造异步活动。

我们可以使用Jest这样的测试框架来写一个测试。

controllers/userRepo.spec.ts

import { MockUserRepo } from '../repos/mock/mockUserRepo';

let userController: UserController;

const mockResponse = () => {
  const res = {};
  res.status = jest.fn().mockReturnValue(res);
  res.json = jest.fn().mockReturnValue(res);
  return res;
};

beforeEach(() => {
  userController = new UserController(
    new MockUserRepo() // Speedy! And valid since it inherits IUserRepo.
  )
});

test ("Should 200 with an empty array of users", async () => {
  let res = mockResponse();
  await userController.handleGetUsers(null, res);
  expect(res.status).toHaveBeenCalledWith(200);
  expect(res.json).toHaveBeenCalledWith({ users: [] });
})

祝贺你。你(或多或少)刚刚学会了如何编写可测试的代码!

DI的主要胜利

这种解耦不仅使你的代码可测试,而且还能改善你的代码的以下特点。

  • 可测试性。在测试过程中,我们可以用昂贵的基础设施组件替代模拟组件。
  • 可替代性。如果我们针对一个接口编程,我们就会启用一个遵守Liskov替代原则插件架构,这使得我们可以非常容易地交换出有效的插件,并针对尚不存在的代码编程。因为接口定义了依赖的形状,我们要替代当前的依赖所需要做的就是创建一个新的,遵守接口定义的契约。请看这篇文章,深入了解一下。
  • 灵活性。遵循开放封闭原则,一个系统应该是开放的,可以扩展,但封闭的,可以修改。这意味着如果我们想扩展系统,我们只需要创建一个新的插件,以扩展当前的行为。
  • 授权。反转控制(Inversion of Control)是我们观察到的一种现象,当我们把行为委托给别人来实现,但又提供挂钩/插件/回馈来实现时。我们设计当前的组件,控制权反转给另一个组件。很多网络框架都是基于这个原则建立的。

控制反转和IoC容器

应用程序变得比只有两个组件大得多。

我们不仅需要确保我们所指的是接口而不是具体的实现,而且我们还需要处理在运行时手动注入依赖实例的过程。

如果你的应用程序相对较小,或者你的团队有一个连接依赖关系的风格指南,你可以手动完成这个工作。

如果你有一个巨大的应用程序,而且你没有计划如何在你的应用程序中完成依赖注入,那么它就有可能失去控制。

正是由于这个原因,反转控制(IoC)容器才得以存在。

它们的作用是要求你。

  1. 创建一个容器(它将容纳你的应用程序的所有依赖性)
  2. 让容器知道该依赖性(指定它是可注入的)。
  3. 通过要求容器注入你需要的依赖关系来解决这些依赖关系。

一些针对JavaScript/TypeScript的比较流行的软件是AwilixInversifyJS

就我个人而言,我不太喜欢它们以及它们散布在我的代码库中的额外的基础设施特定框架逻辑

如果你像我一样,不喜欢容器生活,我有自己的注入依赖的风格指南,我在solidbook.io中谈到过。我还在制作一些视频内容,敬请期待。

反转控制。传统的程序控制流是指程序只做我们告诉它要做的事情(今天)。当我们开发框架或只参考插件架构的代码区域时,控制流的反转就会发生,这些区域可以被钩住。在这些领域,我们*可能不知道(今天)*我们希望它如何被使用,或者我们希望让开发人员增加额外的功能。这意味着React.js或Angular中的每一个生命周期钩子都是实践中反转控制的好例子。