Angularにおける動的な環境変数の実現
はじめに
通常、Angularでは環境変数を environment フォルダ下の environment.ts からimportして利用する。環境によって環境変数を切り替えたい場合は、その環境に応じた enviroment.dev.ts や enviroment.prod.ts を定義して、angular.json で fileReplacements を定義することになる。しかし、ここで問題になるのが、この環境変数はAngularのビルド時にビルドファイルに埋め込まれてしまうため、あとから上書きすることができない、ということである。例えばステージング環境と本番環境で環境変数が違う場合、ステージング環境と本番環境ではAnuglarのビルドを別々に行う必要が生じ、完全に同一のビルドファイルをステージング環境から本番環境へ持っていくことができない。
この記事では主に上記のような状況を解決するため、環境変数を外から切り替えられるようにするための方法を紹介する。最初に結論をまとめておくと、以下の2点がポイントになる。
- main.tsにおいて、動的な環境変数の取得を行い、- envrionment.tsを書き換えて- 'platform'からProvideする。
- 環境変数はファイルから直接参照させず、DIさせる
実装について
main.tsにおける環境変数の書き換え
main.ts というのはAngularプロジェクトの scr 下に必ず存在しているファイルで、Angularアプリの起動の起点となる場所である。通常、この main.ts が実行され、AppModule をbootstrapし、AppModule が AppComponent をbootstrapする、という流れでAngularアプリは起動する。
main.ts を開いてみると、実際に main 関数が定義され実行されていること、そして main 関数の中では platformBrowserDynamic() とそれに続いて bootstrapModule() という関数が実行されていることがわかる。後者の bootstrapModule() は先程述べた AppModule をbootstrapする関数であるが、前者の方、platformBrowserDynamic() で、実は 'platform' から値をProvideすることができる。
一般的に、AngularのアプリではAppModuleのInjectorがroot Injectorで、その上はNull Injectorになるが、platformBrowserDynamic() で値をProvideすると、rootの一つ上の階層である 'platform' においてInjectorを生成することができる。今回はmain.ts で(AppModuleを起動する前に)環境変数の書き換え処理を実行し、ここで環境変数をProvideする。'platform' については公式ドキュメントや以下の動画が参考になる。
コードは以下のようになる。
main.tsimport { enableProdMode, InjectionToken } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { overrideEnv } from './override-env';
if (environment.production) {
  enableProdMode();
}
/**
 * ここでは便宜上main.tsに記述しているが、InjectionTokenの定義は別ファイルにまとめたほうが良いと思う
 */
export const ENVIRONMENT = new InjectionToken<Record<string, unknown>>(
  'environment'
);
const main = async () => {
  overrideEnv(environment).then((dynamicEnv) => {
    platformBrowserDynamic([{ provide: ENVIRONMENT, useValue: dynamicEnv }])
      .bootstrapModule(AppModule)
      .catch((err) => console.error(err));
  });
};
main();main 関数の中で最初に実行している overrideEnv は次のようなイメージで実装する。
override-env.tsexport const overrideEnv = async <T>(env: T): Promise<T> => {
  try {
    const response = await fetch('https://sample.com/api/client-env');
    const dynamicEnv: T = await response.json();
    for (const key in dynamicEnv) {
      if (Object.prototype.hasOwnProperty.call(dynamicEnv, key)) {
        const targetProp = env[key];
        if (targetProp !== undefined) {
          env[key] = dynamicEnv[key];
        }
      }
    }
  } catch (e) {}
  return env;
};この overrideEnv() は環境変数を引数に取り、サーバーから取得した環境変数で一致するプロパティがあればその値を書き換えた上で、書き換え後の環境変数を返している。関数内部の処理(動的な環境変数をどう取得するか、取得した環境変数をどうマージするか)については、個々のプロジェクト事情に応じて実装する必要がある。
環境変数の使用
以上で環境変数の用意はできた。この環境変数を使用するためには、先程定義した InjectionToken を使用してDIする。
sample-component.tsimport { Component, Inject, OnInit } from '@angular/core';
import { ENVIRONMENT } from 'src/main';
@Component({
  selector: 'app-sample',
  templateUrl: './sample.component.html',
  styleUrls: ['./sample.component.scss'],
})
export class SampleComponent implements OnInit {
  constructor(@Inject(ENVIRONMENT) env: Record<string, unknown>) {}
  ngOnInit(): void {}
  handleClick(): void {
    console.log(this.env);
  }
}当然ではあるが、環境変数は先程 'platform' に対してProvideした値を利用する必要があるので、environment.ts から直接環境変数を参照するのは禁止する必要がある。この記事の例ではそうなっていないが、environment.ts で、environment オブジェクト自体にTSDoc/JSDocで @deprecated を明記しておく、などの工夫はできると思う。
さいごに
環境変数を動的に取り扱うために、main.ts で環境変数を上書きしてProvideする方法を紹介した。ひとつ余談として、今回は main.ts を利用してAngularアプリの起動前に処理を差し込んだが、Angularには APP_INITIALIZER という InjectionToken が用意されており、これを使うともう少しあと、AppModule が AppComponent をbootstrapする前に処理を差し込むことができる。こちらは基本的にServiceの初期化のための仕掛けのようで、値のProvideには適さない(恐らくできない)が、知っておくと役に立つことがある。今回想定した要件でも、環境変数を常にServiceから取得する、みたいな設計を取るのであれば、その環境変数Serviceを APP_INITIALIZER で初期化するという方法が考えられる。