とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

Angular のひとつのサービスクラスから生成できる異なるインスタンスをそれぞれ注入できるようにする

はじめに

Angular アプリケーションで、ある class A で別の class B をインスタンス化して使いたい場合、class B に @Injectable() のデコレータをつけて注入可能にする。 オプションに providedIn: 'root' をつけることでどこからでもインスタンスを要求することができるようになりとても便利である。

しかし、このままでは class B をインスタンス化する際にコンストラクタ引数を渡すことができない。

Angular の DI の仕組みがわかれば、インスタンス化する際の引数指定もできるようになるため、今回はそれについて説明する。

Angular の DI の仕組み

コンシューマーとプロバイダーの 2 つの役割が存在する。名前のままだが、コンシューマーが要求する側でプロバイダーが提供する側である。 この 2 つをやり取りする層として、Angular にはインジェクターというものが存在する。つまり class A から class B を DI の仕組みでインスタンス化して使う場合には class B をインジェクターに登録できればよい、ということになる。

インジェクターには環境インジェクターと要素インジェクターの 2 種類があるが、今回は環境インジェクターを使って説明をする。 余談だが DI の仕組みを使って作られたインスタンスインジェクターの階層に対してシングルトンとなる。シングルトンである範囲をコントロールするには、目的に合わせて要素インジェクターも使う必要がある。

プロバイダーの設定

@Injectable({ providedIn: 'root' }) を使う

サンプルとしてコンシューマーに AppComponent をプロバイダーに Logger を用意する。

// logger.ts

@Injectable({ providedIn: 'root' })
export class Logger {
  log(message: string) {
    console.log('LOG: ' + message);
  }
}
// app.component.ts

@Component({ selector: 'app-root', standalone: true, template: `<div>Hello world</div>` })
export class AppComponent implements OnInit {
  #logger = inject(Logger);

  ngOnInit() {
    this.#logger.log('----- AppComponent initialized -----');
  }
}

Logger class には @Injectable() をつけている。providedIn: 'root' を指定することで暗黙的に環境インジェクターに登録されることになる。

クラスプロバイダー useClass を使う

providedIn: 'root' を指定せず、自分で環境インジェクターに登録する場合は次のようになる。

// logger.ts

@Injectable()
export class Logger {
  log(message: string) {
    console.log('LOG: ' + message);
  }
}
// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [Logger],
};

上記のように bootstrapApplication() の第 2 引数となる appConfig に自分で登録する必要がある。 またこれはシンタックスシュガーとなっており、次のように解釈される。

// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [{ provide: Logger, useClass: Logger }],
};

useClass を使うと依存オブジェクトが要求されたときに指定した class をインスタンス化してくれる。 このとき new 演算子を使ったインスタンス化がデフォルト挙動である。

ファクトリープロバイダー useFactory を使う

useClass を使った場合、インスタンス化は Angular が暗黙的におこなっている。そのため、useClass ではコンストラクタ引数を渡すことはできない。 インスタンスを作る方法を明示したい場合は、useFactory を使う。書き換えると次のようになる。

// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [{ provide: Logger, useFactory: () => new Logger() }],
};

これでインスタンス化する方法もコントロールすることができる。

InjectionToken を用いて 1 つの class からコンストラクタ引数違いの 2 つのインスタンスを作る

ここでコンストラクタ引数により、インスタンスの振る舞いが変化する場合を考える。 たとえば上記の Logger class がコンストラクタ引数で渡した log level によって出力できる console の種類が変わるとする。

// logger.ts

type LogLevel = 'LOG' | 'ERROR';
export class Logger {
  logLevel: LogLevel = 'LOG';

  constructor(level: LogLevel) {
    console.log('Logger initialized with log level: ' + level);
    this.logLevel = level;
  }

  log(message: string) {
    if (['LOG'].includes(this.logLevel)) {
      console.log('LOG: ' + message);
    }
  }

  error(message: string) {
    if (['LOG', 'ERROR'].includes(this.logLevel)) {
      console.error('ERROR: ' + message);
    }
  }
}

この Logger class のコンストラクタ引数違いのインスタンスをそれぞれインジェクターに登録する。 まずそれぞれの InjectionToken 型のトークンを用意する。

// app.config.ts

export const LOG_LOGGER = new InjectionToken<Logger>('Log Level が Log の Logger');
export const ERROR_LOGGER = new InjectionToken<Logger>('Log Level が ERROR の Logger');

そしてこれらのトークンに対応するファクトリー関数をそれぞれインジェクターに登録する。

// app.config.ts

...
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: LOG_LOGGER, useFactory: () => new Logger('LOG') },
    { provide: ERROR_LOGGER, useFactory: () => new Logger('ERROR') },
  ],
};

これにより同じ class の異なるインスタンスをコンシューマーが要求できるようになる。

まとめ

今回のようにひとつのクラスから複数のインスタンスを登録したいケースは稀かもしれない。 一方でプロバイダーに登録する際に環境ごとに違うコンストラクタ引数を渡したくなることはあるかもしれない。

InjectionToken の生成方法や useClass 以外のプロバイダー定義方法を知っておくのは有用だと思われる。

参考