依赖注入(DI)

Posted wpengch1

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了依赖注入(DI)相关的知识,希望对你有一定的参考价值。

注:学习使用,禁止转载

通常,随着应用程序的变大,应用程序的不同部分需要交流,当模块a需要模块b才能运行时,我们说b依赖于a。

获得依赖的最常见的一种方式就是导入一个文件,比如,在我们假设的模块中,我们可能向下面这样做:

// in A.ts
import B from 'B'; // a dependency!

B.foo(); // using B

通常情况下,简单导入其他代码就够了,然而有时候,我们所需要的是一种更复杂的方式提供依赖项。比如:

  • 在测试期间,我们需要实现B代替MockB。
  • 我们想要分享我们B类的单例在我们的app中(比如单例模式)
  • 当每次b被使用时,需要创建一个新的实例

DI可以解决这些问题

DI是这样一个系统,它使我们程序的一部分可以访问其他部分,而且,我们可以配置这些事怎么发生的。

DI这个词是用来描述设计模式的(它使用在很多框架中),angular内建就实现了DI。

使用DI的主要好处是,客户组件不需要了解如何创建依赖,只需要知道怎么交互就可以了。4

DI的例子:PriceService

想象一下,我们有一个Product类,这个Product有一个价格,为了计算这个产品的最终价格,我们依赖于一个服务,它使用输入传递进来。

  • 产品的基本价格
  • 我们销售的状态

下面是不使用DI的样式:

class Product 
 constructor(basePrice: number) 
 this.service = new PriceService();
 this.basePrice = basePrice;
 

 price(state: string) 
 return this.service.calculate(this.basePrice, state);
 

现在,让我们想象一下,我们需要为这个Product编写一个测试,假设PriceService通过查询数据库返回一个折扣,我们写的测试像下面这样:

let product;

 beforeEach(() => 
 product = new Product(11);
 );

describe('price', () => 
 it('is calculated based on the basePrice and the state', () => 
 expect(product.price('FL')).toBe(11.66);
 );
)

即使这个测试可以工作,这种方法有一些缺点,为了测试成功,必须有几个条件得到满足:

  • 数据库必须运行
  • Florida的税收收入必须是我们期望的。

基本上,如果我们增加一个意想不到的依赖,我们的测试会更加脆弱。

如果我们做一点改变,像下面这样:

class Product 
 constructor(service: PriceService, basePrice: number) 
 this.service = service;
 this.basePrice = basePrice;
 

 price(state: string) 
 return this.service.calculate(this.basePrice, state);
 

现在,创建一个Product,客户需要决定使用哪一种价格服务。
我们可以编写一个简单的测试,如下:

class MockPriceService 
 calculate(basePrice: number, state: string) 
 if (state === 'FL') 
 return basePrice * 1.06;
 

 return basePrice;
 

通过这个小小的改变,我们可以调整我们的测试使其更清楚,而且摆脱了数据的依赖。

let product;

 beforeEach(() => 
 const service = new MockPriceService();
 product = new Product(service, 11);
 );

 describe('price', () => 
 it('is calculated based on the basePrice and the state', () => 
 expect(product.price('FL')).toBe(11.66);
 );
)

我们还会得到好处,我们的 Product类是隔离的,也就是说,我们的Product有一个确切的依赖。

Don’t Call Us…”别叫我

DI这个技术依赖于控制反转这种原则。

多年来,这是很常见的的模式,我们为每一个组件注明完整的应用程序上下文,并且设置它的依赖,这看起来是很清晰的,Product类必须知道PriceService类。

这样做的一个缺点是,一旦一个组件需要一个依赖,组件本身变得脆弱而且难以改变。如果我们做出变化,我们的组件依赖于很多其他组件,我们不得不传播这些依赖。换句话说,它使得我们的组件紧耦合。

当我们使用DI的时候,我们朝着松耦合的方向前进,单个应用程序尽量少影响其他的组件。并且,只要这些组件的接口没有改变,我们甚至可以直接改变它们,不需要组件做任何其他的改变。

ng2继承了ng1的一大特点就是控制反转。angular使用DI去解决来自于外部的依赖。

传统上,如果组件a依赖组件b,那么在组件a的内部会创建一个b的对象,这意味着a依赖于b,

angular使用DI改变这个事情,A像依赖注入系统请求B,依赖注入系统会像我们期望一样传递一个B给A。

相对传统方式来说,这个有很多好处。一个好处就是当我们测试A的时候,我们可以mock一个B出来注入到A。

在这本书中,我们使用了很多次DI。比如我们在路由那张创建music应用的时候,为了去和Spotify API进行交互,我们创建了SpotifyService,它被注入到了几个组件中:

code/routes/music/app/ts/components/AlbumComponent.ts

constructor(public routeParams: RouteParams, public spotify: SpotifyService,
 public locationStrategy: LocationStrategy) 
 this.id = routeParams.get('id');

现在,让我们学习怎么创建自己的service和我们注入它们的不同形式。

依赖注入部分

注册一个依赖,我们必须将其绑定到能标识依赖的地方,这个标识被称为依赖令牌(token),比如,我们想要注册一个API的URL,我们可以使用API_URL作为令牌,同理,如果我们注册一个类,我们可以使用类本身作为它的令牌。

在angular中的依赖注入,分为三步部分:

  • 提供者(Provider-通常也称为绑定)映射一个令牌(通常是一个string或者一个类)到一个列表中,它告诉angular,怎么去创建一个对象,并且给定一个令牌。
  • 注入器(Injector),它存储绑定的集合,并在创建对象的时候去解析依赖并且注入它们
  • 依赖(Dependency),将被注入的东西

我们可以通过下面这张图描述它们的职责:

当处理DI的时候,有很多不同的选择,让我们逐一学习它们。

使用DI最通用的场景就是在整个应用程序中提供服务或者值,这个场景占了99%。

这个就是我们想要去做的,接下来,我们会讲解怎样编写一个基本的服务,它会是我们在用户程序中花费时间最多的。

注入器

像上面提到的一样,angular在后台为我们安装一个DI,在我们使用注解注入依赖到我们的组件之前,让我们首先了解一下注入器。

我们创建一个仅仅返回一个string的简单服务。

code/dependency_injection/simple/app/ts/app.ts

class MyService 
    getValue():string
        return 'a value';
    

接下来,我们创建我们的APP组件:

code/dependency_injection/simple/app/ts/app.ts

@Component(
    selector: 'di-sample-app',
    template: `
  <button (click)="invokeApi()">Get a value</button>
  `
)
class DiSampleApp 
    myService: MyService;
    constructor() 
       let injector: any = ReflectiveInjector.resolveAndCreate([MyService]);
       this.myService = injector.get(MyService);
       console.log('Same instance?', this.myService === injector.get(MyService));
    

    invokeApi():void 
        console.log('MyService returned', this.myService.getValue());
    

这个程序很简单,我们定义一个DiSampleApp的组件,它会显示一个button,当button被点击的时候,调用invokeApi。

现在,我们关注一下构造函数,我们使用一个来自于ReflectiveInjector的静态方法resolveAndCreate.这个方法的职责是创建一个新的注入器,参数是一个我们想要这个注入器知道的可注入的(injectable)东西的数组,在这个例子中,我只想要它知道MyService。

ReflectiveInjector是Injector的具体实现,它使用反射去寻找合适的参数类型,相对于其他的注入器,ReflectiveInjector是一个通用的注入器。

一个重要的事情是,它将会注入这个类的单实例(single)

这个可以通过构造函数的最后两行得以确定,

console.log('Same instance?', this.myService === injector.get(MyService));

这句话证明,我们使用injector.get获取的两次实例是相同的。

注意,如果我们使用自己的注入器,我们就不需要告诉应用程序的可注入列表。

既然我们学会了注入器的工作原理,然后我们学习angular的注入框架,接下来学习我们可以注入的其他东西,并且学会怎么注入它们。

Providers(提供者)

接下来需要知道的,在angular的DI系统中,我们可以使用几种注入方式:

  • 注入一个单例
  • 调用任何函数,然后注入这个函数的返回值
  • 注入一个值
  • 创建一个别名

让我们分开看看

使用一个类

注入一个类的单实例可能是最通常做的事情。下面是我们配置它的代码:

provide(MyComponent, useClass: MyComponent)

值得注意的是,provide方法接收两个参数,第一个就是令牌,第二个是怎样注入或者注入什么。所以这里,我们将我们的MyComponent类映射到MyComponent令牌,在这种情况下,类的名字与令牌匹配,这是通常的情形,但是要注意,被注入的东西和令牌不一定要有相同的名字。

就像我们在上面看到的一样,这个方法会创建一个单例对象,并且我们每次注入的时候它都返回这个相同的单例,当然,在第一次注入之前,不会创建这个类的实例,所以第一次注入的时候,会调用该类的构造函数。

如果一个服务的构造函数需要参数怎么办呢?

code/dependency_injection/misc/app/ts/app.ts

class ParamService 
  constructor(private phrase: string) 
    console.log('ParamService is being created with phrase', phrase);
  

  getValue(): string 
    return this.phrase;
  

注意,这个构造器怎么获取参数呢?如果我们使用正常的注入机制:

bootstrap(MyApp, [ParameterService]);

我们会得到一个错误:

这个发生的原因是我们没有给注入器关于构建一个类的足够的信息,为了解决这个问题,我们需要告诉注入器,当创建这个服务的实例时,我们想要它去使用哪些参数。

如果我们希望创建服务的时候传递参数,我们可能需要使用工厂来代替(factory)

工厂(Using a Factory)

当我们使用一个工厂注入器的时候,我们可以编写一个函数,这个函数可以返回任何类型。

 provide(MyComponent, useFactory: () => 
     if (loggedIn) 
         return new MyLoggedComponent();
     
     return new MyComponent();
);

在上面的代码中,我们注入MyComponent令牌,但是这个会检测一个外部变量loggedIn,如果这个参数为真,我们会接受一个MyLoggedComponent的实例,否则返回MyComponent。

工厂也可以有依赖,比如:

provide(MyComponent, useFactory: (user) => 
     if (user.loggedIn()) 
         return new MyLoggedComponent(user);
     
     return new MyComponent();
, deps: [User])

如果我们希望像上面一样使用我们的ParamService,我们可以将他包装在useFactory中,像下面这样:

bootstrap(DiSampleApp, [
  SimpleService,
  provide(ParamService, 
    useFactory: (): ParamService => new ParamService('YOLO')
  )
]).catch((err: any) => console.error(err));

对于创建注入来说,工厂是功能最强大的方式,因为我们可以在函数中做任何事情。

使用一个值

当我们想要一个值,在应用程序的另外一个地方会被重定义的时候,注入一个值是非常有用的。比如:

provide('API_URL', useValue: 'http://my.api.com/v1')

用于定义别名(alias)

我们也可以用于定义一个引用的别名,像下面这样:

provide(NewComponent, useClass: MyComponent)

在应用程序中的依赖注入

在编写应用程序时,为了注入服务,需要执行下面三步:

  1. 创建一个服务类
  2. 在接收组件里面声明一个依赖
  3. 配置注入(使用angular注册一个注入)

我们要做的第一件事情就是创建一个服务类,这个类可以暴露一些我们希望的行为,这个被称为注射,它包含一些我们组件类将要从诸如服务那里获取到的行为。

下面是我们创建的一个服务:

code/dependency_injection/simple/app/ts/services/ApiService.ts

export class ApiService 
  get(): void 
    console.log('Getting resource...');
  

现在,我们有了要注入的类,接下来就是在我们的组件中声明一个依赖,前面我们直接使用Injector类,但是angular为我们提供了两种简便的方式,最经典的方式就是在我们的构造器中声明。

为了做这个事情,首先需要引入服务:

code/dependency_injection/simple/app/ts/app.ts

/*
 * Services
 */
import ApiService from 'services/ApiService';

然后,我们在构造器中声明它:

code/dependency_injection/simple/app/ts/app.ts

class DiSampleApp 
    constructor(private apiService:ApiService) 
    

当我们在构造器中声明之后,angular会通过反射查找要注入的服务,并注入进来。也就是说,angular会通过我们在构造器中声明的对象类型寻找ApiService,然后向DI系统接收一个合适的注入。

有时,我们需要给angular更多的提示,这个时候,我们使用@Inject注解。

class DiSampleApp 
 private apiService: ApiService;
 constructor(@Inject(ApiService) apiService) 
 this.apiService = apiService;

使用依赖注入的最后一件事情就是使用injectable链接我们组件想要的注入的东西。换句话说,我们要告诉angular当一个组件声明它的依赖的时候,哪些东西需要注入。

provide(ApiService, useClass: ApiService)

在上面的代码中,我们使用ApiService这个token去导出ApiService的单实例。

与Injectors工作

我们已经可以使用injectors做一些事情,现在我们讨论,我们什么时候需要显示使用他们

一种情况就是当我们希望控制依赖注入的单实例创建的时候,为了描述这个情况,我们使用ApiService创建另外一个应用程序。

这个服务会使用到其他的两个服务,它是基于浏览器视框。当小于800像素的时候,返回一个叫着SmallService,否则,返回一个LargeService.。

SmallService像下面这样:

code/dependency_injection/complex/app/ts/services/SmallService.ts

export class SmallService 
  run(): void 
    console.log('Small service...');
  

LargeService:像这样:
code/dependency_injection/complex/app/ts/services/LargeService.ts

export class LargeService 
  run(): void 
    console.log('Large service...');
  

然后,我们编写ViewPortService,它选择两者之一:

code/dependency_injection/complex/app/ts/services/ViewPortService.ts

import LargeService from './LargeService';
import SmallService from './SmallService';

export class ViewPortService 
  determineService(): any 
    let w: number = Math.max(document.documentElement.clientWidth,
                             window.innerWidth || 0);

    if (w < 800) 
      return new SmallService();
    
    return new LargeService();
  

现在,让我们创建一个访问我们服务的APP,

code/dependency_injection/complex/app/ts/app.ts

class DiSampleApp 
  constructor(private apiService: ApiService,
              @Inject('ApiServiceAlias') private aliasService: ApiService,
              @Inject('SizeService') private sizeService: any) 
  

这里,我们获取了一个ApiService的实例,但是我们又获取了一个ApiService的服务,并给它取一个别名ApiServiceAlias,然后,我们获取一个SizeService,但是它还没有定义。

为了理解每一个service代表什么,让我们看看bootstrap的代码:

bootstrap(DiSampleApp, [
  ApiService,
  ViewPortService,
  provide('ApiServiceAlias', useExisting: ApiService),
  provide('SizeService', useFactory: (viewport: any) => 
    return viewport.determineService();
  , deps: [ViewPortService])
]).catch((err: any) => console.error(err));

这里,我们希望应用程序注入器知道ApiService和ViewPortService。然后,我们定义了ApiService的另外一个别名ApiServiceAlias。然后,我们定义了另外一个注入叫着SizeService.这个工厂接收一个ViewPortService的实例,将它作为依赖放入列表,然后调用它的determineService,它会根据浏览器的宽度返回SmallService或者LargeService。

当我们在app上点击button的时候,我们希望去做三个调用: 一个是ApiService,一个是ApiServiceAlias,一个是SizeService:。

code/dependency_injection/complex/app/ts/app.ts

invokeApi(): void 
    this.apiService.get();
    this.aliasService.get();
    this.sizeService.run();
  

现在,让我们运行我们的app,如果在小的浏览器上:

如果在大的浏览器上:

可以看到,两次打印的信息不一样,说明获取到了不同的服务。

但是,现在,如果我们缩放浏览器,并点击button,它始终会打印Large。

这是因为,factory只会被调用一次,为了解决这个问题,我们创建我们自己的注入器,它获取适当的服务,像下面这样:

code/dependency_injection/complex/app/ts/app.ts

let injector:any = ReflectiveInjector.resolveAndCreate([
            ViewPortService,
            provide('OtherSizeService', 
                useFactory: (viewport:any) => 
                    return viewport.determineService();
                , deps: [ViewPortService]
            )
        ]);
        let sizeService:any = injector.get('OtherSizeService');
        sizeService.run();

这里,我们创建一个注入器,它知道ViewPortService,和另外的OtherSizeService作为令牌的注入,最后我们使用获取OtherSizeService的实例。现在如果我们缩放窗口,点击button,我们会得到合适的日志,因为每次点击button的时候,都会调用useInjectors一次。

替换值

使用DI的另一个原因就是在运行时替换值。当我们有一个APIService,它执行HTPP请求的时候发生。在我们的单元测试或者继承测试中,我们不希望我们的代码访问数据库,在这种情况下,我们希望编写一个mock service来代替我们的真正实现。

比如,如果我们希望在开发环境和生产环境,我们的APP访问不同的api。

或者是,当我们发布一个开源或者可重用的API服务时,我们希望客户端应用程序定义或者重载我们API URL。

让我们编写一个应用程序,它根据我们是在开发环境还是在生产环境返回不同的值。

code/dependency_injection/value/app/ts/services/ApiService.ts

import  Inject  from '@angular/core';

export const API_URL: string = 'API_URL';

export class ApiService 
  constructor(@Inject(API_URL) private apiUrl: string) 
  

  get(): void 
    console.log(`Calling $this.apiUrl/endpoint...`);
  

我们定义一个常量,它会被作为令牌为API URL的依赖使用。换句话说,angular会使用API URL来存储调用的URL信息,这样,当我们使用@Inject(API_URL)的时候,就会返回合适的值,注意,我们导出了API_URL常量,所以客户端应用程序可以使用。

现在,已经有了service,让我们编写一个使用这个服务的组件,它根据不同的运行环境提供不同的值。

code/dependency_injection/value/app/ts/app.ts

@Component(
  selector: 'di-value-app',
  template: `
  <button (click)="invokeApi()">Invoke API</button>
  `
)
class DiValueApp 
  constructor(private apiService: ApiService) 
  

  invokeApi(): void 
    this.apiService.get();
  

这是组件的代码,在构造器中,我们使用了注入apiService服务,如果想要显示声明,可以使用下面这种方式:

constructor(@Inject(ApiService) private apiService: ApiService)  

在这个组件中有一个button,当我们点击button的时候,调用apiService的get方法,并打印返回值,也就是API_URL的值。

接下来是使用providers配置应用程序。

code/dependency_injection/value/app/ts/app.ts

bootstrap(DiValueApp, [
  provide(ApiService,  useClass: ApiService ),
  provide(API_URL, 
    useValue: isProduction ?
      'https://production-api.sample.com' :
      'http://dev-api.sample.com'
  )
]).catch((err: any) => console.error(err));

首先,我们声明一个常量isProduction,并且设置它为false,我们可以假装我们做一些事情来决定我们是不是在生产环境。这个设置可以是像我们一样硬编码,也可以使用像webpack一样的设置。

最后,我们启动应用程序,并且按照两个提供程序,一个是ApiService,另外一个是API_URL,如果我们是在生产环境中,我们会使用一个值,否则使用另外一个值。

为了测试,可以将isProduction设置为true,并且点击button,会得到下面两种输出。

总结

就像你所看到的,管理应用程序依赖方面,DI是一个功能强大的方式。

以上是关于依赖注入(DI)的主要内容,如果未能解决你的问题,请参考以下文章

详解依赖注入(DI)和Ioc容器

浅析Spring IOC依赖注入(DI)和依赖查找(DL)

IOC和DI的区别详解

依赖注入与服务位置

在 NestJS 中使用依赖注入导入 TypeScript 模块

Spring的AOP面向切面原理,IOC控制反转也叫DI依赖注入原理