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 Injector
とModule 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'); } }
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)、バンドルサイズに関わるツリーシェイキングの話があるのですが、話が長くなりすぎてしまうので、今回は割愛することにします。