依赖注入(后面统一叫:DI)是一种非常重要的设计模式,Angular有自己的依赖注入框架。本章节会告知如何使用DI以及为什么需要DI?

一、为什么需要DI?

首先先看一段代码:

export class Car {
  public engine: Engine;
  public tires: Tires;
  public description = 'No DI';
  constructor() {
    this.engine = new Engine();
    this.tires = new Tires();
  }
  // Method using the engine and tires
  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders and ${this.tires.make} tires.`
  }
}

类构造函数里实例所有需要东西,但有什么问题吗?

问题就是 Car 类非常脆弱、缺乏灵活性、难于测试。

类直接在构造函数里创建 EngineTires 实例;但假如 Engine 被修改成需要参数时,怎么办?是不是需要把实例改成 this.engine = new Engine(theNewParameter)。其实我们真不应该关心 Engine 的构造参数,会倒置 Car 也跟着变动。这样会让 Car 变得非常脆弱。

其次 tires 受困于 Tires 类型,变得缺乏灵活性。

而为 Car 类写测试代码时,很难摆脱那些隐藏的依赖关系,比如你怎么确认在测试环境下能够正常创建出 Engine 实例?又或 Engine 也有依赖关系呢?如果 Engine 会发起异步操作呢?当代码变得难于控制时,也就变成难于测试。

那么如何创建健壮、灵活、可测试的 Car 类呢?

以下是一个带DI版本的 Car 类:

public description = 'DI';

constructor(public engine: Engine, public tires: Tires) { }

纳尼!!Car 类所依赖的engine和tires属性,变成构造函数参数定义,实例化交由DI在创建类时提供参数所需的实例。并且参数允许接受所有符合 EngineTires 接口的类。

比如:

var car = new Car(new Engine(), new Tires());

如果要扩展 Engine 类,那么就跟 Car 没有任何关系,只需在消费 Car 时做一下变动,例如:

class Engine2 {
  constructor(public cylinders: number) { }
}
// Super car with 12 cylinders and Flintstone tires.
var bigCylinders = 12;
var car = new Car(new Engine2(bigCylinders), new Tires());

Car 类也变得非常容易测试,因为我们完全控制依赖关系。可以直接向构造函数模拟数据,比如:

class MockEngine extends Engine { cylinders = 8; }
class MockTires  extends Tires  { make = "YokoGoodStone"; }

// Test car with 8 cylinders and YokoGoodStone tires.
var car = new Car(new MockEngine(), new MockTires());

一个类从外部接收依赖而非内部创建,这就是依赖注入(DI)。

但可怜的消费者,需要知道如何创建 CarEngineTiresCar 类,所以还需要一个东西来组装他们,消费者最好只需要说我要 Car 实例就好,不然消费都要疯了:

import {Engine, Tires, Car} from './car';
// BAD pattern!
export class CarFactory {
  createCar() {
    let car = new Car(this.createEngine(), this.createTires());
    car.description = 'Factory';
    return car;
  }
  createEngine() {
    return new Engine();
  }
  createTires() {
    return new Tires();
  }
}

当然看起来足够傻,而且如果应用程序很大时,那这个类得多大呀!

如果只列出我们需要创建的东西,而不用管定义它应该注入哪些依赖类,那不是爽歪歪了!

这时就是依赖注入框架发挥作用了,假设框架有个 injector,并把类都注册到里面,它会知道怎样创建这些类。

比如当我们需要 Car,我们只需要向 injector 要就好了,比如:

var car = injector.get(Car);

不要太爽了,Car 压根就不用知道如何创建 EngineTires;也没有一个很傻的工厂类。这就是依赖注入框架

既然知道,接者看看Angular如果实现的呢?

二、Angular依赖注入

Angular提供自己的DI框架,还可以把这个框架独立运用到其他系统中。

当我们构建一个组件时Angular是如何工作的呢?先从一个简化版的 HeroesComponent 组件开始。

heroes.component.ts 根组件,包含一个列表组件

import { Component }          from 'angular2/core';
import { HeroListComponent }  from './hero-list.component';
@Component({
  selector: 'my-heroes',
  template: `
  <h2>Heroes</h2>
  <hero-list></hero-list>
  `,
  directives:[HeroListComponent]
})
export class HeroesComponent { }

hero-list.component.ts 列表组件

import { Component }   from 'angular2/core';
import { Hero }        from './hero';
import { HEROES }      from './mock-heroes';
@Component({
  selector: 'hero-list',
  template: `
  <div *ngFor="#hero of heroes">
    {{hero.id}} - {{hero.name}}
  </div>
  `,
})
export class HeroListComponent {
  heroes = HEROES;
}

hero.ts 实体对象

export class Hero {
  id: number;
  name: string;
  isSecret = false;
}

mock-heroes.ts 模拟数据

import { Hero } from './hero';
export var HEROES: Hero[] = [
  { "id": 11, isSecret: false, "name": "Mr. Nice" },
  { "id": 12, isSecret: false, "name": "Narco" },
  { "id": 13, isSecret: false, "name": "Bombasto" },
  { "id": 14, isSecret: false, "name": "Celeritas" },
  { "id": 15, isSecret: false, "name": "Magneta" },
  { "id": 16, isSecret: false, "name": "RubberMan" },
  { "id": 17, isSecret: false, "name": "Dynama" },
  { "id": 18, isSecret: true,  "name": "Dr IQ" },
  { "id": 19, isSecret: true,  "name": "Magma" },
  { "id": 20, isSecret: true,  "name": "Tornado" }
];

HeroesComponet 是功能的根组件,用来管理所有子组件,目前只有一个 HeroListComponent 组件(显示 hero 列表)。

现在 HeroListComponentHEROES 获取数据,数据存放在另一个文件里定义着。如果想对组件进行测试,或将数据获取改成从远端获取,那么就需要修改 heroes 的实现和 HEROES 模拟数据的实现。所以创建一个 Service 服务来隐藏获取数据的细节。

import {Hero}   from './hero';
import {HEROES} from './mock-heroes';
export class HeroService {
  getHeroes() { return HEROES;  }
}

HeroService 提供一个 getHeroes 方法,消费者只需要知道从这个方法获取数据。其实最好返回一个 Promise 对象,这样不管我们未来的数据源是内存或服务端,都不会影响消费者。

HeroService 目前还是一个普通的类,我们还需要把类注册到 injector

1、配置 injector

我们不需要创建 injector,Angular在启动时会自动创建 injector

bootstrap(AppComponent);

此时只需对 provider 参数注册实例。

bootstrap(AppComponent,
         [HeroService]); // DISCOURAGED (but works)

这样 injector 就知道有 HeroService 这么个东西,整体应用程序也知道如何去创建他。

当然我们不禁要问,要是有很多这样的类都放在这里不得疯了。

在组件里注册类

对于在启动Angular程序时,用来注册一些配置信息、路由更合理。

而对于 HeroService 他所需用到最顶层的组件也只是 HeroesComponent,所以最适合的应该在根组件注册类。

import { Component }          from 'angular2/core';
import { HeroListComponent }  from './hero-list.component';
import { HeroService }        from './hero.service';
@Component({
  selector: 'my-heroes',
  template: `
  <h2>Heroes</h2>
  <hero-list></hero-list>
  `,
  providers:[HeroService],
  directives:[HeroListComponent]
})
export class HeroesComponent { }

注意瞧 @Component 装饰器的 providers 部分:

providers:[HeroService],

这样 HeroesComponent 及其所有子组件都能够访问 HeroService 实例。目前 HeroesComponent 并不需要 HeroService,但其子组件需要。

2、从 injector 获取实例

DI会根据构造函数所要求先创建依赖的实例,最后返回 HeroService 实例。这样可以利用实例,调用 getHeroes()

import { Component }   from 'angular2/core';
import { Hero }        from './hero';
import { HeroService } from './hero.service';
@Component({
  selector: 'hero-list',
  template: `
  <div *ngFor="#hero of heroes">
    {{hero.id}} - {{hero.name}}
  </div>
  `,
})
export class HeroListComponent {
  heroes: Hero[];
  constructor(heroService: HeroService) {
    this.heroes = heroService.getHeroes();
  }
}

隐式创建 injector

上面并没有任何有关从 injector 获取实例代码,这是因为Angular会自动帮我做。那他是如何创建的呢?

injector = Injector.resolveAndCreate([Car, Engine, Tires, Logger]);
var car = injector.get(Car);

3、单例实例

injector 默认提供都是单例实例,所以 HeroesComponentHeroListComponent 会共享同一实例。

不过,Angular DI是一个分层的注入系统,其实他如同组件树一样,每个节点都有属于自己的 injector,需要依赖的实例会从当前节点一直找到根节点直到未找到。

4、有DI时是如何测试组件

前面强调的是,设计一个依赖注入的类更容易测试,构造函数参数列表表示类所有的依赖关系,我们只需要在测试程序里模拟这部分数据就行了。

let expectedHeroes = [{name: 'A'}, {name: 'B'}]
let mockService = <HeroService> {getHeroes: () => expectedHeroes }

it("should have heroes when HeroListComponent created", () => {
  let hlc = new HeroListComponent(mockService);
  expect(hlc.heroes.length).toEqual(expectedHeroes.length);
})

5、如果Service存在依赖

目前 HeroService 非常简单,并无任何其他依赖关系。

如果它也有依赖怎么办?比如依赖一个有来记录日志服务。我们用同样的方式,利用构造函数添加一个 Logger 类型参数即可。

import {Injectable} from 'angular2/core';
import {Hero}       from './hero';
import {HEROES}     from './mock-heroes';
import {Logger}     from '../logger.service';
@Injectable()
export class HeroService {
  constructor(private _logger: Logger) {  }
  getHeroes() {
    this._logger.log('Getting heroes ...')
    return HEROES;
  }
}

6、关于 @Injectable

注意到使用了 @Injectable() 装饰器,之前并没有 @Injectable() 呀,程序也未出错,为什么现在需要呢?

因为现在 HeroService 需要注入依赖 Logger

但等等,这又有什么关系呢?因为要想被注入除了类本身外,还需要元数据。所以 @Injectable() 是为了让TypeScript在编译时产生类的元数据。

Angular建议,所有Service类都加上 @Injectable(),理由:

  • 未来打样:需要依赖时,忘记加上 @Injectable()
  • 一致性:统一遵循一个规则,多好呀,谁出错就直接扣钱好了。

另外为什么 HeroesComponent 不需要 @Injectable()呢?因为已经有 @Component,但凡有装饰器存在,就可以生成元数据。

7、完整示例:创建和注册Logger服务

HeroService 注入logger服务分两步:

  1. 创建logger服务。
  2. 注册到应用组件里。

logger服务类:

import {Injectable} from 'angular2/core';
@Injectable()
export class Logger {
  logs:string[] = []; // capture logs for testing
  log(message: string){
    this.logs.push(message);
    console.log(message);
  }
}

日志服务的应用是无处不在的,所以我们最好直接在根组件里注册,我们在 AppComponent 组件服务:

providers: [Logger]

8、可选依赖

当前 HeroService 需要 Logger 服务,如果我们希望有Logger服务时才使用,否则就忽略。那要怎么做呢?

首先加载 @Optional() 装饰器。

import {Optional} from 'angular2/core';

接着构造函数参数前加上 @Optional() 装饰器,告诉注入器 _logger 是可选的。

log:string;
constructor(@Optional() private _logger:Logger) {  }

当未注册 Logger 服务时,会传入一个 null 值。所以还需要考虑当传入 null 值时的处理:

// No logger? Make one!
if (!this._logger) {
  this._logger = {
    log: (msg:string)=> this._logger.logs.push(msg),
    logs: []
  }
}

9、注册注入类

所有servier都必须先在 injector 注册才能被实例,前面都是直接在 providers 数组里添加项。

providers: [Logger]

providers 还支持server类的 Provider 实例,接着看看几种不同写法的区分(这些写法结果是一样的,只不过提供了一些更为灵活的注入方式)。

Provider类和provide函数

[Logger]

以上是最短的表达方式,他相当于:

[new Provider(Logger, {useClass: Logger})]

或者相对于友好点的方式:

[provide(Logger, {useClass: Logger})]

Provider 类和 provide 函数都支持两个参数。

  1. 用于充当依赖值和注册时的标识token。
  2. provider 定义对象。

10、注入替换类

假如我们当前日志服务是用 console.log() 浏览器输出,现在希望用另一个日志服务把日志传到服务端上。

[provide(Logger, {useClass: BetterLogger})]

其实我们只需要标识token(这里是 Logger)不变,可以替换成任何符合接口的其它类。

注入类相互依赖

假如希望在日志服务里显示操作者名字,首先创建一个 EvenBetterLogger 新类并注入 UserService

@Injectable()
class EvenBetterLogger {
  logs:string[] = [];
  constructor(private _userService: UserService) { }
  log(message:string){
    message = `Message to ${this._userService.user.name}: ${message}.`;
    console.log(message);
    this.logs.push(message);
  }
}

同时,更改注册注入配置为 EvenBetterLogger

[ UserService,
  provide(Logger, {useClass: EvenBetterLogger}) ]

10、使用别名注入类

比如老组件依赖 OldLogger,现有个 NewLogger 它一样接口,但某些原因,无法把老组件 OldLogger 替换成 NewLogger

现在希望老组件在使用 OldLogger 时,是调用 NewLogger 实例。换言之就是无论新或旧Logger,都希望用 NewLogger 实例。

很容易嘛,只需要把 useClass 换成 NewLogger 不就好了。

[ NewLogger,
  // Not aliased! Creates two instances of `NewLogger`
  provide(OldLogger, {useClass:NewLogger}) ]

但是,这样做的话 OldLoggerNewLogger 实例是不是同时都在我们系统中存在呢?肯定不希望这样,对吧!解决办法是使用 useExisting 选项:

[ NewLogger,
  // Alias OldLogger w/ reference to NewLogger
  provide(OldLogger, {useExisting: NewLogger}) ]

11、注入值对象

有时希望用现成值对象,而不是告知 injector 创建一个类实例。

// An object in the shape of the logger service
let silentLogger = {
  logs: ['Silent logger says "Shhhhh!". Provided via "useValue"'],
  log: () => {}
}

注册时要使用 useValue 选项:

[provide(Logger, {useValue: silentLogger})]

12、注入工厂类型

有时需要创建动态值,这些动态值可能是随着浏览器会话的变动会有所变性,或注入的服务没有访问资源权限等情况。这种情况就需要注入工厂类型。

现我们增加一个新业务来说明:HeroService 里对普通用户而言不显示 secret 的英雄,只有授权用户才能看到。

像前面 EvenBetterLogger 一样,HeroService 需要知道用户信息,来判断当前用户是否有这个权限。

当然我们这里并不是像 EvenBetterLogger 那样注入一个 UserService。原因是前面我们讨论过,如何写一个类更具有可测试性非常重要。

相反,我们给 HeroService 构造函数增加一个布尔类型的参数,用来判断是否有权限。

constructor(
  private _logger: Logger,
  private _isAuthorized: boolean) { }

getHeroes() {
  let auth = this._isAuthorized ? 'authorized ': 'unauthorized';
  this._logger.log(`Getting heroes for ${auth} user.`);
  return HEROES.filter(hero => this._isAuthorized || !hero.isSecret);
}

Logger 之前已经讲过,可以被Angular DI自动注入实例;但 isAuthorized 类型就没办法了,这个时候借助另一个工厂类,来帮助我们创建实例。

let heroServiceFactory = (logger: Logger, userService: UserService) => {
  return new HeroService(logger, userService.user.isAuthorized);
}

接着同时注入 LoggerUserService 及其工厂类,这里用到另一个选项 useFactory

export let heroServiceProvider =
  provide(HeroService, {
    useFactory: heroServiceFactory,
    deps: [Logger, UserService]
  });

useFactory 选项告诉Angular这是一个工厂类注入,具体实现是 heroServiceFactorydeps 属性是一个provider token数组,用于告知工厂类的参数依赖。

最后还需要把 HeroesComponent 里的 previous 替换成 heroServiceProvider

import { Component }          from 'angular2/core';
import { HeroListComponent }  from './hero-list.component';
import { heroServiceProvider} from './hero.service.provider';
@Component({
  selector: 'my-heroes',
  template: `
  <h2>Heroes</h2>
  <hero-list></hero-list>
  `,
  providers:[heroServiceProvider],
  directives:[HeroListComponent]
})
export class HeroesComponent { }

13、关于依赖注入token

如前所述,当我们向 injector 注册时,第一个参数就是表示 token,injector 内部提供一个 token 与 provider 的映射表。

前面所有示例,注入值都是一个类实例,用类类型作为token key,获取时像这样:

heroService:HeroService = this._injector.get(HeroService);

同时构造函数里的参数类型使用相同的token key,Angular 就知道应该使用哪个实例进行传参了。

14、注入非类实例

依赖注入不一定总类,有时希望是一个字符、函数或对象。

通常我们应用程序会有一些配置信息,这些信息都是以哈希值形式存在:

export interface Config {
  apiEndpoint: string,
  title: string
}

export const CONFIG:Config = {
  apiEndpoint: 'api.heroes.com',
  title: 'Dependency Injection'
};

现在想注入哈希值对象,其实先前我们已经提到 useValue 选项,但是问题来了,token怎么办?因为他不是一个类,并没有类类型。

不过你会想不是有个接口类型嘛,难道用它不行吗?真不行,TypeScript不支持把接口当作一个token。

那怎么办?

OpaqueToken(不透明token???)

解决办法是使用 OpaqueToken,先给哈希值做一层包裹,看起来像这样:

import {OpaqueToken} from 'angular2/core';

export let APP_CONFIG = new OpaqueToken('app.config');

其实用包裹类来注册注入类:

providers:[provide(APP_CONFIG, {useValue: CONFIG})]

还要对构造函数修改, @Inject 装饰器为了帮助 Angular 找到配置依赖值。

constructor(@Inject(APP_CONFIG) private _config: Config){ }

三、总结

Angular依赖注入非常强大,上面的内容只是非常皮毛,但是已经涉及到大部分用法。

注意:Angular建议我们(更或者说必须)每个类一个文件,其实当对DI有深入理解后,会发现,装饰器与类之间存在这不可分隔的关系。为了不吃这种亏一定有一个类一个文件的习惯。

此外,文章还有一段组件直接与 injector 打交道的代码。

@Component({
  selector: 'my-injectors',
  template: `
  <h2>Other Injections</h2>
  <div id="car">{{car.drive()}}</div>
  <div id="hero">{{hero.name}}</div>
  <div id="rodent">{{rodent}}</div>
  `,
  providers: [Car, Engine, Tires,
              heroServiceProvider, Logger]
})
export class InjectorComponent {
  constructor(private _injector: Injector) { }
  car:Car = this._injector.get(Car);
  heroService:HeroService = this._injector.get(HeroService);
  hero = this.heroService.getHeroes()[0];
  get rodent() {
    let rous = this._injector.getOptional(ROUS);
    if (rous) {
      throw new Error('Aaaargh!')
    }
    return "R.O.U.S.'s? I don't think they exist!";
  }
}

不管怎么看,Angular DI框架做了很多细节上的封装,如果对它感兴趣可以自行阅读源代码