AngularにおけるDIについて

Spring Frameworkを触っていたりすると、DIコンテナとか@Autowiredとか@Beanとか見慣れないものが登場しすぎて、 コード側でもDIの機構について理解しておかないとちゃんとコードが書けない!ってなるのですが、
Angularの場合は本当に簡単にDIが実現できてしまうので、そう言えばAngularのDIについてちゃんと知らない!と思ったので、公式ドキュメントを読みながら記事を書いてみることにしました。

1.DI(依存性の注入)とは

ここでは例としてインスタンスを生成の場合について考えます。

class ClassA {
    b :ClassB;

    constructor(){
        this.b = new ClassB();
    }

    doFuga() {
        this.b.doSomethingGreat();
    }
}

class ClassB {

    constructor() { }

    doSomethingGreat(){
        // 何かすごいことをやる
    }
}

上のコード(テキトーですが)のように、あるClassAの中で、ClassBのインスタンスが生成され、使用されている場合、
ClassAは既にClassBが実装されていないととクラスAを動かすことはできません。
また、ClassAはClassBの生成方法を知っていないといけません。
このような状態のことをClassAがClassBに依存しているといいます。

class ClassA {
    b :ClassB;

    constructor(b: ClassB){
        this.b = b
    }

    doFuga() {
        this.b.doSomethingGreat();
    }
}

しかしここで、クラスAの中でクラスBのインスタンスを直接生成するのではなく、外部から生成されたクラスBのインスタンスを渡してもらうことにしたらどうでしょうか?
クラスAがクラスBのインスタンスの生成方法を知らなくて良くなり、クラスAは渡されたクラスBのインスタンスを使用することだけを考えるだけで良くなります。
このデザインパターンがDIと呼ばれています。
DIによってまだクラスBが作られていなくても、同じインターフェイスを実装したクラスのインスタンスをクラスAに渡してあげることが可能なため、 テスト時はモックを使うなどが簡単に可能にできるようになります!

2.AngularのDIの基本

# Service側
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class HeroService {
  constructor() { }
}
# Component側
import { Component }   from '@angular/core';
import { Hero }        from './hero';
import { HeroService } from './hero.service';

@Component({
  selector: 'app-hero-list',
  template: ``
})
export class HeroListComponent {
  heroes: Hero[];
 
  // heroService: HeroServiceと書くだけで勝手にHeroServiceが注入される!
  constructor(heroService: HeroService) {
    this.heroes = heroService.getHeroes();
  }
}

AngularではServiceに@Injectable()デコレーターをつけることによって注入可能なServiceをマークすることができます。
そしてServiceを要求するComponentはコンストラクターの引数にheroService: HeroServiceと書くだけで、起動時にHeroServiceクラスのインスタンスを一つだけ生成し、勝手にComponent側に注入してくれて、heroServiceを使用することが可能になります。 (めちゃめちゃ簡単ですね。。。)

3.Injectorについて

実は2の@Injectable()デコレータをServiceにつけるだけでは注入可能なServiceとして認識されるだけで本当はAngularは何もしてくれません。
2では、{providedIn: 'root'}というprovidedIn メタデータオプションによってServiceをroot Injectorに登録することで、このroot InjectorがServiceインスタンスをComponentに注入してくれています。
root InjectorがDIをしてくれている張本人になります。

root Injectorの他にもDOMごとやModuleごとにInjectorが存在します。それぞれElement InjectorModule Injectorと呼ばれています。
各Injectorはトークンプロバイダーマップというものを持っていて、トークン(マップのキー・DIトークンとも呼ばれている)が保持されています。
2の例ではHeroServiceというトークンがroot Injectorのトークンプロバイダーマップに保持されていて、起動時にHeroServiceクラスのインスタンスが一つだけ生成されます。
また、Injectorは階層構造になっていて、トークンがどのInjectorにあるのか探索を行います。Element Injectorに欲しいトークンが登録されていなかったら、Module Injectorの中を探しに行き、なかったらどんどん上の階層のModule Injectorを探索しにいくことになります。そして最上位の階層のInjectorにもいない場合root Injectorが呼ばれ参照されます。
それでもなかったら最後にNull Injectorというものが呼ばれNullInjectorErrorが吐かれます。(結構登録し忘れててNullInjectorError見たことある人いるのでは?!)

4.injectorの設定方法

@Injectable()も含め、以下3つのInjectorに設定する方法があります。
- @Injectable()のデコレータ内での設定
- @NgModule()のprovidersオプションでの設定
- @Component() デコレーターの中での設定
の3つです。順に説明していきます。

@Injectable()のデコレータ内での設定

root Injectorでなく任意のモジュールのInjectorに設定したい場合は、モジュールクラス名を指定することによって可能です。
下の例ではAuthModuleのModule InjectorにSessionServiceトークンが登録されます。

@Injectable({
  providedIn: AuthModule
})
class SessionService {
     constructor(private http: HttpClient) {}
     login() {
      // ログイン
     }
}

@NgModule()のprovidersオプションでの設定

NgModuleのprovidersに登録することで設定可能です。この場合もModule InjectorにServiceが登録されます。
SessionServiceは省略記法でproviderオブジェクトリテラル{ provide: SessionService, useClass: SessionService }に展開されます。 useClass等のオプションについては5で解説します。

@NgModule({
  providers: [
    // 省略記法で登録
    SessionService
  ],
})
export class AuthModule { }

// 注入
@Component({...})
class LoginComponent {
  constructor(
    service :SessionService,
  ) {}
}

@Component() デコレーターの中での設定

@Component() デコレーターの中でproviderを設定すると、Element Injectorにproviderが登録されます。

@Component({
  ...
  providers: [{ provide: ItemService, useValue: { name: 'lamp' } }]
})
export class TestComponent

Element Injector自体、そこまで多用することは多くないと思いますが、このようなユースケースがあるようです。

providerのオプション設定について

ここでは上で紹介したuseClassのような、providerでのオプションについて解説します。

Angular公式のコードが分かり易かったので拝借して説明したいと思います。

import { Component, Inject } from '@angular/core';

import { DateLoggerService } from './date-logger.service';
import { Hero }              from './hero';
import { HeroService }       from './hero.service';
import { LoggerService }     from './logger.service';
import { MinimalLogger }     from './minimal-logger.service';
import { RUNNERS_UP, runnersUpFactory }  from './runners-up';

const someHero = new Hero(42, 'Magma', 'Had a great month!', '555-555-5555');

@Component({
  selector: 'app-hero-of-the-month',
  templateUrl: './hero-of-the-month.component.html',
  providers: [
    { provide: Hero,          useValue:    someHero },
    { provide: TITLE,         useValue:   'Hero of the Month' },
    { provide: HeroService,   useClass:    HeroService },
    { provide: LoggerService, useClass:    DateLoggerService },
    { provide: MinimalLogger, useExisting: LoggerService },
    { provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }
  ]
})
export class HeroOfTheMonthComponent {
  logs: string[] = [];

  constructor(
      logger: MinimalLogger,
      public heroOfTheMonth: Hero,
      @Inject(RUNNERS_UP) public runnersUp: string,
      @Inject(TITLE) public title: string)
  {
    this.logs = logger.logs;
    logger.logInfo('starting up');
  }
}

Angular 日本語ドキュメンテーション

useClass

useClassプロバイダーキーを使用すると、指定したクラスの新しいインスタンスを1つ作成して注入することができます。
このuseClassを使用することで、クラスを代替クラスのインスタンスで置き換えることができます。 これにより元のクラスと異なる挙動で実装したり、クラスを拡張したり、テストでServiceをmockにできます。 上の実装例では{ provide: LoggerService, useClass: DateLoggerService },で使用されており、LoggerServiceトークンにDateLoggerServiceのインスタンスが紐づけられ、LoggerServiceの代わりにDateLoggerServiceが使用可能になります。

またテスト時には以下のように使用し、ServiceをMockServiceに変えることができます。

// テストの時の使用例 spec.ts
class MockUserService {
  isLoggedIn = true;
  user = { name: 'Test User'};
};

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  comp = TestBed.inject(WelcomeComponent);
  userService = TestBed.inject(UserService);
});

useValue

useValue キーを使用すると、固定値をトークンに関連付けることができます。
これによりServiceの代わりに、単体テスト内でモックデータを提供することなどができます。
上の実装例では{ provide: Hero, useValue: someHero }ではHeroトークンにHeroクラスの既存のインスタンスであるsomeHeroを紐づけています。(一見何してるのか分かりませんが、下のuseFactoryで再度登場します。)
{ provide: TITLE, useValue: 'Hero of the Month' },ではuseValueに文字列リテラルを指定します。
provide: TITLEのTITLEはもはやクラスでもなんでもないですが、これは代わりに InjectionTokenオブジェクトと呼ばれるものを使用しています。これによりクラス以外の文字列、関数、またはオブジェクトをDIすることが可能です。詳しくはこちら
@Inject(TITLE) public title: string)でtitleに値を注入しています。

またテストの場合は以下のように使用されます。

let userServiceStub: Partial<UserService>;

beforeEach(() => {
  // 作成したmock
  userServiceStub = {
    isLoggedIn: true,
    user: { name: 'Test User' },
  };

  TestBed.configureTestingModule({
     declarations: [ WelcomeComponent ],
     
     providers: [ { provide: UserService, useValue: userServiceStub } ],
  });

  fixture = TestBed.createComponent(WelcomeComponent);
  comp    = fixture.componentInstance;
  userService = TestBed.inject(UserService);
});

useFactory

useFactoryを使用すると、ファクトリー関数を呼び出してオブジェクトを作成し注入することができます。
上の{ provide: RUNNERS_UP, useFactory: runnersUpFactory(2), deps: [Hero, HeroService] }では runnersUpFactory()がファクトリー関数です。

export function runnersUpFactory(take: number) {
  return (winner: Hero, heroService: HeroService): string => {
    /* ... */
  };
};

このようにファクトリー関数を実装することができ、runnersUpFactory()(winner: Hero, heroService: HeroService): string => { };というプロバイダーファクトリー関数を返しています。 上のuseValueでHeroトークンに注入したインスタンスやHeroServiceをプロバイダーファクトリー関数の引数として使用することが可能でdeps: [Hero, HeroService]で使用するものが 定義されています。

useExisting(エイリアスプロバイダー)

useExistingを使用すると、あるトークンを別のトークンにマッピングできます。つまり、useExisting は他のトークンのエイリアスとして動作します。

{ provide: LoggerService, useClass: DateLoggerService },
{ provide: MinimalLogger, useExisting: LoggerService },

上の{ provide: LoggerService, useClass: DateLoggerService },LoggerServiceトークンに対応するDateLoggerServiceインスタンスは生成されているため、MinimalLoggerにはLoggerServiceが参照しているDateLoggerServiceインスタンスが注入されます。 またこの使用例では

export abstract class MinimalLogger {
  logs: string[];
  logInfo: (msg: string) => void;
}

を定義しておくことによって、LoggerServiceがMinimalLoggerよりも遥かに多いプロパティやメソッド を持ってる場合、MinimalLoggerに定義されているプロパティとメソッドだけを使用できるように機能を制限することができます。

multi(おまけ)

一つ前の記事でInterceptorを扱いましたが、その時、app.moduleのproviders[]に{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }を登録しました。
このmultiというのはtrue を指定すると同じトークンで複数の依存オブジェクトを扱う事ができるようになるもので、HTTP_INTERCEPTORSトークンに対応する、既存のInterceptor処理のインスタンスが入った配列にTokenInterceptorのインスタンスをpushしていることになります。

終わりに

今回はAngularのDIについて、実際にある程度使ったり、理解に役立つ範囲で紹介しました。 この他にもInjector階層の探索のオプション(@Optional, @Skip)、バンドルサイズに関わるツリーシェイキングの話があるのですが、話が長くなりすぎてしまうので、今回は割愛することにします。

参考

Angular 日本語ドキュメンテーション