控制反转、依赖注入与Nestjs

发布于 更新于
尝试用最简单直白的语言来解释我所理解的控制反转和依赖注入,并且在Nest.js和Golang中进行实践体验

背景

最近大半年我一直在琢磨设计模式。背过了八股、看过了书、也看了很多博客和帖子、也结合自己手头的项目做了反思总结和新的尝试,逐渐有了一些明悟,但总感觉还缺一点火候,所以暂时不长篇大论,先揪出一个小点来讲讲。

最近由于某些机缘巧合,我学习了Nest.js这个框架。它几乎将JAVA那套框架完整地复刻到了Typescript的世界中,我在学习之后认为,对前端工程师来说,它是一个非常好的学习设计模式的案例。其中我感触最深的就是控制反转和依赖注入模式。

控制反转-依赖注入

什么是控制反转?

我从一个常见的案例来解释。一个软件系统中往往会有多个代码模块(或者说逻辑单元),多个模块之间会相互依赖,A依赖B、B依赖CD、C依赖AD……等等。

一种典型的写法是直接引用依赖的模块,示例代码如下:

import { B } from '../b';

class A {
  b: B = new B();
}

上述代码,A模块依赖B,就直接在A里面引用(并实例化)B,这就是“控制正转”。

这样的直接引用,在系统代码规模增大以后,很容易形成复杂的交叉循环引用。虽然很多现代语言的模块系统能够处理循环引用,但是这种交叉循环引用所导致的代码逻辑耦合的问题仍然无法消除。举个形象例子,假如某天你想要复用模块A,你把代码复制出去一看,A依赖B、B依赖C、C依赖D,所有的东西都藕断丝连在一起,最后可能顺藤摸瓜把大半个项目的代码都牵连到了。

那所谓“控制反转”,就是不再主动控制依赖的东西、而是转为被动声明,示例代码如下:

interface IB {
  xxx(): void;
}

class A {
  b: IB;
}

A不再直接引用B,而是声明“我需要一个可以做xxx事情的东西”,只要能满足 IB 这个接口,无论给我 B 还是 B2、B3 我都无所谓。这样A、B两个模块之间就实现了解耦。

但是实际的业务逻辑是需要二者合作完成的,那么如何在运行过程中如何将二者关联起来呢?

一种解决方案就是“依赖注入”,即通过一个第三者来管理所有的模块,并在运行时传递,示例代码如下:

// a.ts
interface IB {
  xxxx(): void;
}

class A {
  b: IB;

  constructor(b: IB) {
    this.b = b;
  }
}
// ioc.ts
import A from './a';
import B from './b';

class IOC {
  constructor() {
    this.b = new B();
    this.a = new A(this.b);
  }
}

也就是说,原来的 A-B-C-D-E 之间的复杂依赖关系,现在全部切断并由一个共同的第三者 ioc 来进行组织,依赖关系变成了ioc对所有模块的单向依赖,所有模块互相之间完全解耦了。

依赖注入最大的一个好处是方便替换。典型场景是测试,例如假如我要在本地对A做单元测试,那我可以直接mock一个B传进去:

// a.spec.ts
class MockB {
  xxxx(): void {}
}

test(() => {
  const b = new MockB();
  const a = new A(b);
});

控制反转-注册模式

与前面类似的思想,只不过方向调换一下,第三者不再主动提供,而是被动的注册再由调用方自行获取。示例代码如下:

全局注册树:

interface IA {}
interface IB {}

class Register {
  a: IA;
  b: IB;
}

业务模块依赖注册树:

// a.ts
import { register } from '../register';

class A {
  run() {
    register.b.xxx();
  }
  static register() {
    register.a = new A();
  }
}

这样的话,依赖关系就变成了所有模块依赖注册树(与之相反,依赖注入模式是IOC容器依赖所有模块)。

虽然理论上可行,但实际上仍然需要做与依赖注入模式相同并且更多的准备工作,模块有依赖也降低了灵活性,因此不如依赖注入模式。所以我们平常只考虑依赖注入模式。

Nestjs的依赖注入

Nest.js框架所采用的依赖注入实现,与我上面所说的模型稍有不同。

一方面主要体现在Ioc容器本身并不知道模块的依赖项,而是由模块自己声明、然后在运行时检测并根据声明来一一对应注入,示例伪代码如下:

(注:以下伪代码并非真实Nest.js代码,仅供参考理解,实际中Nest.js主要通过TS装饰器来实现声明能力)

// ioc.ts
import A from './a';
import B from './b';

class IOC {
  imports: [A, B];

  constructor() {
    // 由模块B自己声明自己要什么,Ioc本身并不知道B要什么
    const dependencyModules = B.checkDependencyModules();
    // 实例化,并保存在Ioc容器中
    const dependencyModuleInstances = dependencyModules.map((mod) => new Mod());
    // 依赖注入
    this.b = new B(dependencyModuleInstances);
  }
}

另一方面体现在,考虑到实际工程中的方便,并没有严格要求面向接口编程,因此模块之间存在互相直接引用的情况,没有实现理想的“控制反转”。示例伪代码如下:

(注:以下伪代码并非真实Nest.js代码,仅供参考理解,实际中Nest.js主要通过TS装饰器来实现声明能力)

// a.ts
import B from '../b';

class A {
  b: B; // 直接引用另一个模块,而不是面向接口

  constructor(b: B) {
    this.b = b;
  }
}

因此方便替换模块的好处反倒成为了Nest.js模式的主要好处,实践中通过派生重写来实现替换依赖:

// a.spec.ts
import B from '../b';

class MockB extends B {}

test(() => {
  const b = new MockB();
  const a = new A(b);
});

在Golang中实践

在Nest.js的实践过程中,或者准确的说在将原来的golang项目翻译为Nest.js项目的实践过程中,我感受到了一些痛点,这让我又想起一些Golang的好处。因此我尝试用同样的架构思想去重构我的Golang项目。

我的项目依然只用了轻量级的框架gin,因此我需要自己手动实现简单的Ioc容器。我选用的是前文第二章节所述的标准“控制反转-依赖注入”模式。

一开始的架构如下:

src
├── main.go (顶层Ioc,管理所有的依赖注入)
├── Core
│   ├── db.go
│   ├── config.go

├── AModule
│   ├── AController(视图层,处理用户输入输出)
│   │   ├── controller.go
│   ├── AService(服务层,业务逻辑)
│   │   ├── service.go
│   ├── ADao(数据库层,对接数据库和缓存)
│   │   ├── dao.go

├── BModule
......

然后在实践过程中我添加了一些便利(或者说取舍)的设定:

设定一。由于当模块数量增多以后,最高层级(main.go)的Ioc容器会变得庞大,因此我将Ioc的功能下放到了每个模块中,每个模块在模块层级这个层级实现Ioc能力,这样可以减少Ioc容器的复杂度。

设定二。由于写接口有点麻烦的,因此我觉得在紧密关联的部分之间可以直接引用,即同一个模块内部的Controller、Service、Dao及其相关测试代码之间可以直接引用;但是在模块之间进行引用的时候,还是要面向接口编程,并且统一在模块Ioc上进行注入。

调整后的架构如下:

src
├── main.go (顶层Ioc,只引用各模块Ioc,不关心模块内部是怎么注入的)
├── Core
│   ├── db.go
│   ├── config.go

├── AModule
│   ├── module.go(模块Ioc,负责模块内的依赖注入)
│   ├── AController
│   │   ├── controller.go
│   ├── AService
│   │   ├── service.go
│   ├── ADao
│   │   ├── dao.go

├── BModule
......

殊途同归,经过调整时候我的架构其实已经与Nest.js的架构非常相似了,可以说是“英雄所见略同”。

设计模式的其他应用

带着新的思想,我回顾了我之前写过的一个日志库alog。我发现了一些设计得不够优雅的地方,可以用控制反转和依赖注入的思想来进行重构。

(这里感慨一下时间过得好快,这已经是我三年前写的代码了,差点我就看不懂了……)

改着改着我感觉有点眼熟,原来类似的写法我曾经在阅读其他一些开源库的时候曾经见过,但是当时不明白,甚至还有点奇怪为什么他们要写得弯弯绕绕的,现在终于明白了这种写法背后的设计思想。正如诗云:“初闻不知曲中意,再听已是曲中人”。

这种思想也可以运用在前端项目上。典型例子已经有了,就是Angular。不过以我现在的技术审美来看,我觉得Angualr还是有些过于笨重了,我依然坚持使用React,只不过可以引入控制反转和依赖注入的思想来优化架构。

几个月前我跟前辈讨论过有关“MVVM”的话题,我按自己的想法在项目中做了尝试,算是原型,现在回头看,我这个MVVM原型已经非常接近于成品了,因此可以发现MVVM思想与控制反转和依赖注入的思想其实是有很高的相关性的。

之后我会再花一些时间来继续完善这个原型,如果踩的坑多的话,我会另写一篇文章来分享。

Nestjs的取舍

在前面的章节中我提到了三种实践方式,分别是基于:Nest.js,Golang(gin) 和 React 。

其中 Next.js 我是第一次尝试。我对它的印象如何?简单来说,有利有弊。

首先是框架层面的。Nest.js 是功能较多、模式较“重”的框架,类似的有 Angular.js、Django.py 等,与之相反的轻型框架有 React.js、gin.go、Flask.py、Express.js 等。老生常谈:“重型框架的优点是功能全面,有很多现成的功能模块可以直接使用,缺点是学习成本高,上手慢,而且有时候功能过于复杂,不易维护;轻型框架的优点是学习成本低,上手快,功能简单,易于维护,缺点是功能不全,需要自己实现很多功能模块。” 我个人更倾向于轻型框架,主要关注的是更可控。

第二是语言层面的。Nest.js 是基于 Typescript 的,必须承认的是TS的类型表达系统是鹤立鸡群般的强大,在我的认知中没有任何其他语言可以与之相比。但是底层的 JS 由于本质上是动态类型语言,因此在处理运行时类型的时候还是存在痛点的,在这一方面以go为代表的静态类型语言扳回一城。除此之外,TS的构建运维也有痛点,而Go在异常处理和业务灵活性上也存在不足。我个人在反复切换尝试之后、甚至在最近两三年绝大部分时间都在写TS的情况下、最后仍然更倾向于Go(来实现后端服务),主要关注的是安全和稳定。

我有时会想:会不会有朝一日,我也变得不再关心技术细节,只想着快速弄出业务代码交差,混口饭吃就好(或者是让手下的笨蛋们能顺利完成任务就好)?——我不确定以后会不会,但至少不是现在。

事件驱动与观察者模式

要实现“控制反转”的目的,也就是为了实现模块之间的解耦,除了通过“依赖注入”的方法,依然还有许多其他方法,例如“事件驱动”。

举一个简单的例子来理解“事件驱动”:

假如我们在开发一个浏览器应用,进程本身是一个App,这个父进程下面控制着多个浏览器窗口Window,每个窗口中可以有多个页面Tab,这是一个典型的爷父孙三级关系。

需求来了:当我们关闭一个页面Tab的时候,如果这是最后一个页面,那么这个窗口Window也应该关闭;如果这是最后一个窗口,那么这个应用App也应该关闭,也就是说,下级对象的行为会影响到上级对象的行为。

最好不要直接在下级对象中尝试去控制上级(这就是典型的“依赖正转”),而是通过事件驱动的方式,让下级对象对自己发出一个事件,上级对象监听这个事件并做出相应的反应。也就是说,下级对象可以完全不知道上级对象的存在,二者之间仍然是单向的控制关系,这样就实现了“控制反转”。

当然,用依赖注入模式依然可以完成同样的功能。或者换个思路来理解,上级对象通过“注册事件处理函数”的方式,也实现了对下级对象的“依赖注入”——这样理解的话,事件驱动模式和依赖注入模式就是同一种思想的两种实现方式。

事件驱动模式最重要的好处是能适应一对多(或多对多)的复杂场景。但它也有明显缺点,第一,抛出事件之后就切断了与事件的联系,无法收到返回值;第二,多层级的场景下,事件可能需要在中间层进行转发,使得代码理解和调试变得困难。在抽象程度上,“控制正转” < “注册树” < “依赖注入” < “事件驱动”,越后者越难以理解和调试。

“观察者模式”也是与“事件驱动模式”几乎相同,或者说前者是后者的超集。其中的区别这里不深究,可以划上约等于。