浅析控制反转

https://zhuanlan.zhihu.com/p/60995312

介绍

控制反转 (Inversion of control) 并不是一项新的技术,是 Martin Fowler 教授提出的一种软件设计模式。那到底什么被反转了?获得依赖对象的过程被反转了。控制反转 (下文统一简称为 IoC) 把传统模式中需要自己通过 new 实例化构造函数,或者通过工厂模式实例化的任务交给容器。通俗的来理解,就是本来当需要某个类(构造函数)的某个方法时,自己需要主动实例化变为被动,不需要再考虑如何实例化其他依赖的类,只需要依赖注入 (Dependency Injection, 下文统一简称为 DI), DI 是 IoC 的一种实现方式。所谓依赖注入就是由 IoC 容器在运行期间,动态地将某种依赖关系注入到对象之中。所以 IoC 和 DI 是从不同的角度的描述的同一件事情,就是通过引入 IoC 容器,利用依赖注入的方式,实现对象之间的解耦。

那反转控制这种设计模式到底给前端带来了什么价值?这里先给出答案:

  1. 提升开发效率
  2. 提高模块化
  3. 便于单元测试

为什么我们需要它?

先给出一个例子,传统模式下当我们创建汽车 (Car) 这个类的时候,我们需要依赖轮子,发动机。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }
}

在 Car 这个类的构造器中我们装备了这个类中需用到的依赖项,这有什么问题呢?正如你所见,构造器不仅需要把依赖赋值到当前类内部属性上还需要把依赖实例化。比如 Engine 是通过 new 实例化的, 而 Tires 是通过工厂模式创建的。这样的高度耦合的依赖关系大大增加了单元测试难度和后期维护的成本。必然会出现牵一发而动全身的情形。而且在依赖 hard-code 写死在代码中并不符合 SOLID 开发原则中的 “开闭原则”。试想一个程序中,我们有超多种类的 Car,他们都依赖同一个依赖 Engine,但是有一天我想把所有的 Engine 换成 V8Engine 我该怎么做?全局搜索 Engine 修改为 V8Engine,想想都有点麻烦。


每辆车都需要自己控制引擎的创建

然后我们尝试一下 IoC 的版本。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';
import { Container } from 'path/to/container';

const container = new Container();
container.bind('engine', Engine);
container.bind('tires', Tires);

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = container.get('engine');
    this.tires = container.get('tires');
  }
}

现在引擎和轮胎的创建不再直接依赖它们的构造函数,而是通过 IoC 容器 (container) 来创建,使得 Car 类 和 Engine,Tires 没有了强耦合关系。代码中不再依赖于具体,而是依赖于 container 抽象容器,即要针对接口编程,不针对实现编程。过去思维中想要什么依赖,需要自己去 “拉” 改为抽象容器主动 “推” 给你,你只管使用实体就可以了。这是依赖倒转 (DIP) 的一种表现形式。


所有车装有引擎

因为汽车不直接依赖引擎,所以现在我想把所有引擎换成 V8 引擎,只需要把 IoC 容器中的引擎替换掉就可以了。


所有车装有 V8 引擎

原理

首先让我们实现一个最简单的容器来管理依赖,这里省略了大量类型定义,类型判断和异常处理,并不适用于生产环境。

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, constructor) {
    this.constructorPool.set(name, constructor);
  }

  get(name) {
    const target = this.constructorPool.get(name);
    return new target();
  }
  
}

container.register('myClass', DemoClass);
const classInstance = container.get('myClass');

constructorPool 是存放所有依赖的集合, 这是最简单的对象池,池中存储着构造函数和唯一标识符的集合。当调用 get 方法时,根据唯一标识符从对象池中拿到构造函数并返回实例,这只考虑了在注册时如参是构造函数,并且每次 get 的时候都返回新的实例。当我们需要在全局使用单一实例,并且在不同的地方拿到同一个实例,就需要在注册 (register) 的时候添加配置区分是单例模式还是工厂模式

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, definition, dependencies) {
    this.constructorPool.set(name, {
      definition: definition,
      dependencies: dependencies
    });
  }

  get(name) {
    const targetConstructor = this.constructorPool.get(name);
    if (this._isClass(targetConstructor.definition)) {
      return this._createInstance(targetConstructor);
    } else {
      return targetConstructor.definition;
    }
  }
  
  // 递归拿到类的所有依赖集合
  _getResolvedDependencies(target) {
    let classDependencies = [];
    if (target.dependencies) {
      classDependencies = target.dependencies.map(dependency => {
        return this.get(dependency);
      });
    }
    return classDependencies;
  }

  _createInstance(target) {
    return new target.definition(...this._getResolvedDependencies(service));
  }

  // 判断是否为构造函数
  _isClass(definition) {
    return Object.prototype.toString.call(definition) === "[object Function]";
  }
}

而且依赖容器中需要维护一套自己的生命周期去满足连接数据库等需求,这里建议大家读一下 midway 团队出品的 injection ,这里有更完整的解决方案。

可测性

接下来我们用实际开发的例子看一下 IoC 是如何提高代码的可测性。

这里还是使用汽车的例子。

import { Engine } from 'engine/path';
import { Tires } from 'tires/path';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }

  async run() {
    const engineStatus = await this.engine.check();
    const tiresStatus = await this.tires.check();

    if (engineStatus && tiresStatus) {
      return console.log('car running.');
    }
    return console.log('car broken');
  }
}

当我们实例化 Car 之后,执行 run 的时候,我们会调用 engine 和 tires 依赖里的方法,这个方法有可能会有外部依赖,比如从数据库中读数据,或者一次 http 请求。

export class Engine {
  private health = true;
  async check() {
    const result1 = await http.get('demo'); //check 1
    const result2 = await db.find({         //check 2
      id: 'demoId'
    });                                     
    const result3 = this.health;            //check 3

    return result1 && result2 && result3;
  }
}

当生产环境下我们执行 check,我们期望 3 个 check 都是 true 才让引擎发动,但是在测试阶段,我们只想执行 check3,忽略 check1 和 check2,这在传统开发模式下是很难做的,因为在 Car 构造函数中,已经写死了 Engine 的创建。想在测试阶段提供一个永远保持健康状态的引擎只能通过实例化时判断环境变量,赋值不同的实例,或者修改构造函数。

实例化时判断环境。

class Car {
  private engine;
  public running = false;

  constructor() {
    if (process.env === 'test') {
      this.engine = new TestEngine();
    } else {
      this.engine = new Engine();
    }
  }

  async run() {
    const engineStatus = await this.engine.check();

    return this.running = engineStatus;
}

公用类判断环境。

export class Engine {
  private health = true;
  async check() {
    if (process.env === 'test') {
      // test check
    } else {
      // normal check
    }
  }
}

这两种方式都不是优雅的解决方案,这种脏代码不应该在项目中出现。为了单元测试而需要判断执行环境的代码不应该写在具体实现上,而是应该放在公共的地方统一处理。

借由 IoC 容器,我们的业务代码不需要为单元测试作出修改,只需要在测试的时候,把测试的实例注册到 IoC 的容器中就可以了。

class Car {
  private engine;
  public running = false;

  constructor() {
    this.engine = container.get('engine');
  }

  async run() {
    const engineStatus = await this.engine.check();

    if (engineStatus) {
      return this.running = true;
    }
    return this.running = false;
  }
}

通过 IoC 我们可以优雅的处理测试环境下,业务代码中需要的依赖实体。因为当测试开始时,我们可以通过配置创建符合预期的类放到对象池中,业务代码中只需要直接使用就可以了。

以下给出一段对于 Car 的测试代码。

// car.spec.js
const Car = require('./car');

describe('Car', function () {
  it('#car.run', async function () {
    // 注册测试用依赖
    container.register('engine', MockEngine);

    const car = new Car();

    await car.run()

    expect(car.running).to.eql(true);
  });
});

社区最佳实践

在前端领域,反转控制可能被提及的比较少 (Angular 2 发布之前),但是在服务端领域, IoC 有很多实现,比如 Java 的 Spring 框架,PHP 的 Laravel 等等。Angular 的出现让我对前端工程化有了新的见解,Angular 把依赖注入作为应用设计模式,在框架的高度管理所有依赖和帮助开发者获取依赖,Angular 官方自己维护了一套自己的 DI 框架。

想揭开 DI 的神秘面纱需要了解两个东西。

首先是 @Injectable。这是 JavaScript 装饰器 (Decorators) 语法特性,装饰器语法已经进入 TC39 提案 Stage 2,但是还没正式进入 ECMA 语法标准。这个特发特性是使类可被注入的关键。开发者可以使用注解的方式自定义类的行为,方法,和运行时的属性。在 Angular 中使用 @Injectable 注解向 IoC 容器注册。angular/packages/core/src/di/ 在这个命名空间下 Angular 组织了 DI 的逻辑。框架提供了一套解决方案跟踪被注解的所有依赖,当你需要时提供正确的实例。

然后是 reflect-metadata。这个包提供了读取和修改类的源数据的能力,是帮助 Angular 判断被注入方所需实例类型的关键点。当使用这个包时,必须设置在 tsconfig.json 中开启 emitDecoratorMetadata: true 。

通过这两位的帮助,TypeScript 便可在编译时拿到被注解类的原数据,而且这些原属组是在运行时可用的。

总结

因篇幅原因,这里只是简单介绍 IoC 的使用,控制反转设计模式的优点是显而易见的,它有益于编写单元测试。因为依赖的实例化交给了容器,所以减少了实例化模版代码。让程序更易于扩展。去除代码之间的直接依赖关系,降低了耦合度。控制反转离不开依赖注入,现阶段社区中解决方案是通过 reflect-metadata 和装饰器来进行注入。