みなさんこんにちは。カルテットコミュニケーションズでフロントエンド開発をしています、松岡です。

松岡やよい | 社員インタビュー記事

弊社の開発環境では、ステージングサーバー・本番サーバーとデプロイを2段階に分けています。ステージングサーバーは本番に近い設定で、バックエンド・フロントエンドのつなぎこみの結合テストなどを行う場です。ステージングサーバーでの検証が終わると同じ内容が本番サーバーにデプロイされる訳ですが、リリース日の都合などで本番サーバーデプロイ待ちのタイムラグが発生し、その間次のリリースに向けて開発している機能は検証を終えたばかりのステージングサーバーにはデプロイできなくなってしまいます。

ユーザーに全く見えない新規画面の開発であればデプロイに問題はないのですが「既存画面にボタンを追加する」「ルーティングを追加するとサイトマップに勝手に出てきてしまう」というような、画面と深く関わるフロントエンドならではの困りごとが出てきます。

ローカル開発環境・ステージングサーバー・本番サーバーと環境ごとに追加機能の有効無効を切り替えられないか、その方法を考えてみたので紹介したいと思います。

環境

  • @angular/cli v10.1
  • node v14.8

追記

【2020/10/07 記事修正のお知らせ】2020/10/05 に記事を公開しましたが「ステージング/本番サーバーでコンポーネントを隠蔽」について @NgModule でのコンポーネント入れ替えは子コンポーネントまで隠蔽できず NO_ERRORS_SCHEMA などを駆使しないといけない事が分かりました。また fileReplacements を使うともっと簡単に隠蔽できる事が分かったので、この方法を使うように加筆修正させていただきました。

サンプルアプリ

簡単なサンプルアプリを例として説明していきます。

このアプリにはナビゲーションバーに Staging Production というリンクボタンがあります。 クリックすると /staging /production に遷移し <router-outlet>(濃い青のボックス部分) にコンテンツを展開します。

リンクは「デプロイしたくない」先のサーバーの名前を示しています。

  • /staging ステージングサーバーにデプロイしたくない(ローカル開発中)
  • /production 本番サーバーにデプロイしたくない(ステージング検証中)

急いで本番サーバーにリリースしたいホットフィックスが発生したら困ってしまいますね。リンクボタンを CSS で隠しておく手もありますが、コンポーネントやサービスのロジックは実行されてしまいます。まだ不完全な HTTP リクエストを実行してしまったり、開発中のサービスクラス内部でエラーが発生するかもしれません。

このサンプルを下記のように環境ごとに機能を制限するアプリに変更していきたいと思います。

環境 /staging リンク /production リンク
ローカル 利用できる 利用できる
ステージング - 利用できる
本番 - -

environment ファイル

Angular には環境ごとに変数を切り替えるための environment.ts というファイルがあります。これを使って、ローカル開発環境・ステージングサーバー・本番サーバーの3つを判別できるように設定を書き足します。

// src/environments/environment.ts

export const environment = {
  local: true,
  staging: false,
  production: false,
};
// src/environments/environment.staging.ts
// ※このファイルは存在しないので新規作成する

export const environment = {
  local: false,
  staging: true,
  production: false,
};
// src/environments/environment.prod.ts

export const environment = {
  local: false,
  staging: false,
  production: true,
};

angular.json の fileReplacements

Angular の CLI コマンドには ng serve --prod のようにプロダクションビルドを示すオプションがあります。 --prod が渡されると fileReplacements によって environment.ts の中身が environment.prod.ts からコピーされ置き換わるようなイメージです。(ビルド中の処理イメージでファイルシステムへの影響はありません)

公式ドキュメント
Angularアプリのビルドとサーブ > ターゲット固有ファイルの置換の設定

ng serve --staging は存在しません。そこで先ほど用意した environment.staging.ts ファイルの設定を angular.json に書き加える必要があります。

// angular.json

"architect": {
  "build": {
    // architect > build > configurations
    "configurations": {
      "production": {
        "fileReplacements": [
          {
            "replace": "src/environments/environment.ts",
            "with": "src/environments/environment.prod.ts"
          }
          ...
      },
      // ① staging を書き足す
      "staging": {
        "fileReplacements": [
          {
            "replace": "src/environments/environment.ts",
            "with": "src/environments/environment.staging.ts"
          }
        ]
      }
    }
  },
  ...
  // architect > serve
  "serve": {
    "builder": ...,
    "configurations": {
      "production": {
        "browserTarget": "app:build:production"
      },
      // ② staging を書き足す
      "staging": {
        "browserTarget": "app:build:staging"
      }
    }
  },
}

書き足した staging という環境は下記のコマンドで呼び出す事ができます。

# ビルトインサーバーを staging 環境で起動
$ ng serve -c staging

# staging 環境のビルド
$ ng build -c staging

angular.json の設定次第で「本番サーバーではコードを難読化する」「ステージング環境ではソースマップを出力する」などの環境ごとのビルドが調整できます。またコンポーネントから environment ファイルを参照すると、それぞれの環境ごとに違った変数の値を得る事ができます。

ステージング/本番サーバーでコンポーネントを隠蔽

サンプルアプリの Staging リンクボタンは「ステージング環境では見せたくない」機能です。 environment で実行環境が判別できるようになったので、ステージング環境でこのリンクを隠蔽しましょう。

リンクボタンは Anchor を表示するだけの下記のようなコンポーネントとします。

// src/app/links/staging-link.component.ts

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

@Component({
  selector: 'app-staging-link',
  template: `<a routerLink="/staging">Staging</a>`,
})
export class StagingLinkComponent {}

このコンポーネントとインターフェースが全く同じである、影武者コンポーネントを作ります。

// src/app/fakes/fake-staging-link.component.ts
// ① ファイルを新規作成する

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

@Component({
  // ② 本物と同じセレクタ
  selector: 'app-staging-link',
  template: ``,
})
export class FakeStagingLinkComponent {
  // ③ 本物に @Input() があれば同じように記述
}

Production リンクボタンも同じように影武者コンポーネントを作ります。

// src/app/fakes/fake-prod-link.component.ts
// ① ファイルを新規作成する

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

@Component({
  // ② 本物と同じセレクタ
  selector: 'app-prod-link',
  template: ``,
})
export class FakeProdLinkComponent {
  // ③ 本物に @Input() があれば同じように記述
}

Angular は selector が同一の複数コンポーネントをひとつのモジュールに登録する事はできません。 仮に StagingLinkComponent(本物)FakeStagingLinkComponent(影武者) を両方とも AppModule に登録すると怒られてしまいます。

そこで fileReplacements を使うと、環境によって本物と影武者を入れ替える事ができるようになります。

// angular.json

"architect": {
  "build": {
    "configurations": {
      // architect > build > configurations > production
      "production": {
        "fileReplacements": [
          ...
          // ① 設定を書き足す
          {
            "replace": "src/app/links/prod-link.component.ts",
            "with": "src/app/fakes/fake-prod-link.component.ts"
          },
          {
            "replace": "src/app/links/staging-link.component.ts",
            "with": "src/app/fakes/fake-staging-link.component.ts"
          }
      },
      // architect > build > configurations > staging
      "staging": {
        "fileReplacements": [
          ...
          // ② 設定を書き足す
          {
            "replace": "src/app/links/staging-link.component.ts",
            "with": "src/app/fakes/fake-staging-link.component.ts"
          }
        ]
      }
    }
  },

これで @NgModule から辿るコンポーネントは環境に応じて影武者に入れ替わるようになります。

// src/app/app.module.ts

import { StagingLinkComponent } from "src/app/links/staging-link.component";
import { ProdLinkComponent } from "src/app/links/prod-link.component";

@NgModule({
  declarations: [
    ...
    StagingLinkComponent,
    ProdLinkComponent,
  ],
  imports: [ ... ],
})
export class AppModule { }

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

ローカル PC ですがビルトインサーバーは「ステージング」環境として起動しています。

  • ステージングサーバーで見せたくない /staging へのリンクは存在しない
  • 本番サーバーで見せたくない /production へのリンクは存在している

次に production 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c production
# ng serve --prod でも同じ

今度はビルトインサーバーは「本番」環境として起動しています。

  • ステージングサーバーで見せたくない /staging へのリンクは存在しない
  • 本番サーバーで見せたくない /production へのリンクは存在しない

開発中の機能がきれいさっぱり隠蔽できましたね!よしよしこれでデプロイのタイミングを気にしなくても大丈夫!

…ではありません。ブラウザのブックマークなどから URL をダイレクトに指定されると /staging/production のページが開いてしまいます。

CanActivate guard

Angular には guard という機能があります。いくつか種類があるのですが、その中の CanActivate を利用するとページ遷移をブロックする事ができます。

公式ドキュメント(未翻訳・英語)
https://angular.io/api/router/CanActivate

実装はとてもシンプルです。ステージングサーバーで見せたくない機能をガードするため、ローカル環境のみ true を返すメソッドを実装しています。

// src/app/guards/staging-guard.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { environment } from 'src/environments/environment';

@Injectable()
export class StagingGuard implements CanActivate {
  canActivate(): boolean {
    return environment.local;
  }
}

本番サーバー用のガードも同じように実装しておきましょう。

// src/app/guards/prod-guard.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { environment } from 'src/environments/environment';

@Injectable()
export class ProdGuard implements CanActivate {
  canActivate(): boolean {
    return environment.local || environment.staging;
  }
}

実装したガードをルーティングに設定します。

// src/app/app-routing.module.ts

import { ProdGuard } from 'src/app/guards/prod-guard';
import { StagingGuard } from 'src/app/guards/staging-guard';

const routes: Routes = [
  {
    path: '',
    children: [
      {
        path: '',
        pathMatch: 'full',
        component: HomePageComponent,
      },
      {
        path: 'staging',
        component: StagingPageComponent,
        // ① ガードを追加
        canActivate: [StagingGuard],
      },
      {
        path: 'production',
        component: ProductionPageComponent,
        // ② ガードを追加
        canActivate: [ProdGuard]
      },
    ],
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

これで URL をダイレクトに入力しても /staging に遷移せずトップページにリダイレクトするようになりました。

ステージング/本番サーバーでサービスを隠蔽

隠蔽したいのはコンポーネントだけではありません。例えばユーザープロファイルを取得するような HTTP クライアントを利用したサービスがあるとします。

// src/app/services/profile-repository.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import * as Rx from 'rxjs';

@Injectable()
export class ProfileRepository {
  constructor(private http: HttpClient) {}

  get(): Rx.Observable<Profile> {
    return this.http.get<Profile>(`/api/profile`);
  }
}

export interface Profile {
  name: string;
  email: string;
}

ローカル開発環境では angular-in-memory-web-api などを利用してフェイクデータで開発していて、ステージングサーバーや本番サーバーでは RestAPI を呼び出すものとします。そして RestAPI の実装は未完了でレスポンスは常に HTTP 404: Not Found になる、という想定です。

公式ドキュメント
angular-in-memory-web-api サーバーからデータの取得 > データサーバーをシミュレートする

ユーザープロファイルサービスは AppComponent で利用し画面上部にユーザー名を表示します。

// src/app/app.component.ts

import { Profile, ProfileRepository } from 'src/app/services/profile-repository';

@Component({
  selector: 'app-root',
  // プロファイルを表示している
  template: `
    <p>ようこそ {{profile.name}} さん</p>
    ...
  `,
})
export class AppComponent implements OnInit {
  profile: Profile;

  // サービスに依存している
  constructor(
    private repository: ProfileRepository
  ) {}

  ngOnInit(): void {
    // サービスを利用してプロファイルを取得している
    this.repository.get().subscribe(profile =>
      this.profile = profile
    );
  }
}

ここでも影武者を登場させます。

// src/app/fakes/fake-profile-repository.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { Profile } from 'src/app/services/profile-repository';
import * as Rx from 'rxjs';

@Injectable()
export class FakeProfileRepository {
  get(): Rx.Observable<Profile> {
    return Rx.of({
      name: '影武者',
      email: 'dummy@example.com',
    });
  }
}

一部のエンドポイントだけ実装が完了していないのであれば、処理を部分的に委譲する事もできます。

// src/app/fakes/fake-profile-repository.ts

@Injectable()
export class FakeProfileRepository {
  // ① 本物をインジェクト
  constructor(
    private delegate: ProfileRepository
  ) {}

  // ② 実装完了のエンドポイントは本物に委譲
  delete = () => this.delegate.delete();
  update = (profile: Profile) => this.delegate.update(profile);

  // ③ 未実装のエンドポイントはダミーデータを返す
  get(): Rx.Observable<string> {
    return Rx.of('おいら影武者ッス');
  }
}

サービスは Angular の DI を使って影武者に置き換えます。モジュールの providers で DI コンテナを操作すれば、あちこちのコンポーネントからサービスが利用されていても、その全てが影武者に置き換わります。

// src/app/app.module.ts

@NgModule({
  declarations: [...],
  imports: [...],
  providers: [
    // ① 設定を追加する
    {
      provide: ProfileRepository,
      useFactory: (http) => {
        // ローカル開発環境は本物のサービス
        if (environment.local) {
          return new ProfileRepository(http);
        }

        // ステージング・本番サーバーは影武者サービス
        return new FakeProfileRepository();
      },
      deps: [HttpClient],
    },
  ],
  ...
})
export class AppModule { }

補足 Angular の DI はとても柔軟で、コンポーネント単位での providers 指定も可能です。 その場合は「全てが影武者に置き換わる」が当てはまらないので providers ごとに設定を追加する必要があります。

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

HTTP リクエストを実行しない影武者が登場しましたね。これでステージングサーバーにデプロイしても HTTP 404: Not Found で処理がストップする事はなくなりました。

※ このまま本番サーバーにデプロイすると ようこそ影武者さん が見えてしまうので、先程の「ステージング/本番サーバーでコンポーネントを隠蔽」の手順で隠してください

package.json にスクリプトを追加

$ ng serve -c staging

ステージング環境としてビルトインサーバーを立ち上げるために何度か登場したこのコマンドですが、明日になったら忘れるので package.json に追加しておきましょう。

// package.json
"scripts": {
  // ① ローカル PC でそれぞれの環境をデバッグするためのコマンド
  "start": "ng serve",
  "start:staging": "ng serve -c staging",
  "start:prod": "ng serve -c production",

  // ② それぞれの環境ごとにビルドファイルを出力するためのコマンド
  "build": "ng build",
  "build:staging": "ng build -c staging",
  "build:prod": "ng build -c prod",
  ...
}

ステージングサーバーには build:staging で出力したファイル群を、本番サーバーには build:prod で出力したファイルをデプロイします。

環境判定のバッドパターン

コンポーネントやサービスクラスの中で if (window.env === "staging") したり HTML で *ngIf="env === 'local'" のように記述するのは、できる限り避けたいところです。安易に分岐させたコードは除去する時にコードを探すのも大変ですし、誤って必要なコードを一緒に削除するなど事故を起こしがちです。

@Component({
  selector: 'app-root',
  template: `
    <p *ngIf="env.isLocal">ようこそ {{profile.fullName}} さん</p>
    ...
    <span class="..." [ngClass]="alertClass">
  `,
})
export class AppComponent implements OnInit {
  profile: Profile;
  isLocal: boolean;
  alertClass: string;

  // 【怖い】 依存のどれを削除していいのか
  constructor(
    private repository: ProfileRepository,
    private envProvider: EnvProvider,
  ) {
    this.isLocal = this.envProvider.isLocal;

    // 【怖い】 インターフェースが本物とは変わってる...
    if (this.isLocal) {
      this.profile = { fullName: "偽武者" } as Profile;
    }
  }

  ngOnInit(): void {
    // 【怖い】 alertClass って変数は残したほうがいいのか?
    this.alertClass = this.isLocal ? 'local' : 'prod';

    if (!this.isLocal) {
      return;
    }
    this.repository.get().subscribe(...);
  }
}

実は私はこの方法をよく使っていたのですが、事故が怖くて if 文だけ削除し変数や依存を残したままにした事がありました。そんな過去の反省から「もっとスマートにできないか」と模索したのが今回の記事で紹介した方法です。

environmentproviders など Angular の機能を利用すれば、コンポーネントやサービスは「どの環境で実行されているのか」を知る必要はなく、除去する時の作業も楽になります。

@NgModule({
  declarations: [...],
  imports: [...],
  providers: [
// 【怖くない】 行単位で削除すれば良い
//   {
//     provide: ProfileRepository,
//     useFactory: (http) => {
//       if (environment.local) {
//         return new ProfileRepository(http);
//       }
//
//       return new FakeProfileRepository();
//     },
//     deps: [HttpClient],
//   },
  ],
  ...
})
// angular.json

"architect": {
  "build": {
    "configurations": {
      "production": {
        "fileReplacements": [
//       【怖くない】 行単位で削除すれば良い
//        ...
//        {
//          "replace": "src/app/links/prod-link.component.ts",
//          "with": "src/app/fakes/fake-prod-link.component.ts"
//        },
//        {
//          "replace": "src/app/links/staging-link.component.ts",
//          "with": "src/app/fakes/fake-staging-link.component.ts"
//        }
      },
      ...

なるべく楽に安全に手を入れられるように、実装するその時から将来の作業を考えつつコードを書いていきたいですね。 最後まで読んでいただき、ありがとうございました。

お詫び

以前 Angular 超入門 というシリーズ記事の第4回を書いたのですが、前回の投稿から半年近く経過してしまいその間にシリーズ第1回の投稿者の金本が Angular実践入門チュートリアル という完全版チュートリアルを別のサイトのエントリとして書いてくれていました。(弊社を退職し現在はフリーで活動しています、Twitterアカウントは ttskch(たつきち) さんです)

チュートリアルを途中で断念していた方には大変申し訳ないのですが、たつきちさんの記事を参考にしていただければと思います….。最初の Angular アプリから Firebase のデプロイ方法までサポートしている、とてもおすすめなコンテンツです!

Angular実践入門チュートリアル @ttskh