Corredor

ウェブ、プログラミングの勉強メモ。

Angular In Memory Web API の実用性を上げるための Tips

以前 Angular In Memory Web API というライブラリを紹介した。Angular アプリ内に API サーバのモックを構築できるライブラリだったが、色々と癖があって扱いが大変だった。

neos21.hatenablog.com

今回はこの In Memory Web API の実用性を向上させるための手法をいくつか紹介する。

複数のリソースを分割して管理する方法

例えば customers (顧客情報) と products (商品情報) というように、複数のリソースを扱う場合、前回の記事で紹介した MockWebApiService クラスのみで構築しようとすると、以下のような作りになる。

import { Injectable } from '@angular/core';
import { InMemoryDbService } from 'angular-in-memory-web-api';

@Injectable()
export class MockWebApiService implements InMemoryDbService {
  /** InMemoryDbService から継承 : モックデータを作成する */
  createDb(): any {
    return {
      // 顧客情報
      customers: [
        { id: 1, name: 'Marty' },
        { id: 2, name: 'Jennifer' }
      ],
      // 商品情報
      products: [
        { id: 1, name: 'DMC-12', price: 50000 },
        { id: 2, name: 'JVC', price: 1000 }
      ]
    };
  }
}

このレベルならまだ良いかもしれないが、リソースの数とダミーデータの数で、段々と this.api が膨らんでいく。さらにリソースごとにカスタムハンドリングをしていくとなると、色々なリソースのビジネスロジックが1クラスにまとまってしまうことになる。

そこで、リソース一つひとつを別々のクラスに分けると管理しやすくなるだろう。

  • ./src/app/fake/mock-web-api.service.ts
import { Injectable } from '@angular/core';
import { InMemoryDbService } from 'angular-in-memory-web-api';

import { CustomersApiService } from './customers-api.service';
import { ProductsApiService } from './products-api.service';

@Injectable()
export class MockWebApiService implements InMemoryDbService {
  /** コンストラクタ : リソース別のクラスを DI する */
  constructor(
    protected customersApiService: CustomersApiService,
    protected productsApiService: ProductsApiService
  ) { }
  
  /**
   * InMemoryDbService から継承 : モックデータを作成する
   *
   * @return モックデータ
   */
  createDb(): any {
    const api = {};
    Object.keys(this)
      .filter((propName) => {
        // BaseApiService を継承したインスタンスのプロパティのみ抽出する
        const member = this[propName];
        return member instanceof BaseApiService;
      })
      .forEach((propName) => {
        // 対象の API サービスクラスの apiName プロパティを DB のリソース名にし、data プロパティの内容をデータとして渡す
        const member = this[propName];
        api[member.apiName] = member.data;
      });
    return api;
  }
  
  /**
   * InMemoryDbService から継承 : レスポンス前に実行される関数
   *
   * @param responseOptions レスポンスオプション
   * @param requestInfo リクエスト情報
   * @return 第1引数の responseOptions を返す
   */
  responseInterceptor(responseOptions: any, requestInfo: any): any {
    // 各 API サービスクラスに同名の関数があれば実行する
    Object.keys(this)
      .filter((propName) => {
        // BaseApiService を継承したインスタンスのプロパティで、リクエスト URL に合致するリソース名で、responseInterceptor() 関数を持っているか
        const member = this[propName];
        return member instanceof BaseApiService
          && requestInfo.collectionName === member.apiName
          && typeof member['responseInterceptor'] === 'function';
      })
      .forEach((propName) => {
        // 対象の API サービスクラスの responseInterceptor() 関数を実行する
        this[propName].responseInterceptor(responseOptions, requestInfo);
      });
    });
    return responseOptions;
  }
}

2018-11-20 追記:responseInterceptor() は特に return しなくて良いと書いていましたが、return responseOptions が必要でしたので訂正しました。

まずはベースクラスが以上のようになっている。この中で出てくる BaseApiService は以下のようになっている。

  • ./src/app/fake/base-api.service.ts
@Injectable()
export class BaseApiService {
  /** API のリソース名 (URL に使用する文字列) */
  apiName: string;
  /** モックデータ */
  data: any;
}

コンストラクタで DI している CustomersApiServiceProductsApiService は、この BaseApiService を継承 (extends) して作成する。

  • ./src/app/fake/customers-api.service.ts
@Injectable()
export class CustomersApiService extends BaseApiService {
  /** API のリソース名 (URL に使用する文字列) */
  apiName: string = 'customers';
  /** 顧客情報のモックデータ */
  data: any = [
    { id: 1, name: 'Marty' },
    { id: 2, name: 'Jennifer' }
  ];
}
  • ./src/app/fake/products-api.service.ts
@Injectable()
export class ProductssApiService extends BaseApiService {
  /** API のリソース名 (URL に使用する文字列) */
  apiName: string = 'products';
  /** 商品情報のモックデータ */
  data: any = [
    { id: 1, name: 'DMC-12', price: 50000 },
    { id: 2, name: 'JVC', price: 1000 }
  ];
  
  /** 商品情報 API ではレスポンスデータを加工したいのでこの関数を実装しておく */
  responseInterceptor(responseOptions: any, requestInfo: any): any {
    // responseOptions.body を加工したり…
  }
}

InMemoryDbService を implements したクラスを複数作ろうとすると、app.module.ts で最後に DI したクラスのみが有効になってしまう。そこで、createDb() 時に別々のクラスからデータをかき集めて DB を作る、というワケ。

その時に、リソース別の API サービスクラスを特定するために BaseApiService というクラスを作っておき、それを継承する形にすることで、instanceof による判定ができるようにしてある。

レスポンスデータを加工するための responseInterceptor() 関数については、「対象の関数が存在していれば実行」としたが、別途インターフェースを用意したりして強制させても良いかも。

さて、こうして用意したクラスを読み込むには、app.module.tsproviders にて各種 API サービスを登録しておく必要がある。ココについては、次の章で合わせて紹介する。

環境変数ファイルで API モックの利用切替を行う方法

これまで In Memory Web API を利用する際は、app.module.ts で直接 imports していた。

import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';

import { AppComponent } from './app.component';
import { MockWebApiService } from './mock-web-api.service';

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    HttpClientInMemoryWebApiModule.forRoot(MockWebApiService)  // ← コレ
  ],
  providers: [
    MockWebApiService  // ← コレ
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule { }

先程、リソース別に API サービスクラスを作成したので、CustomersApiServiceProductsApiServiceproviders に登録する必要がある。

ところで、この In Memory Web API は、本番利用の際は使わないようにしたいのではないだろうか。現状は AppModule に直接書いているので、どの環境でも利用されてしまう。

そこで、Angular CLI が用意してくれる環境変数ファイルを利用して、「開発中のみモック API を利用する」といった切替ができるようにする。

  • ./src/environments/environment.ts : 開発中に API モックを利用する環境変数ファイル
import { CommonModule } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';

import { MockWebApiService } from '../app/fake/mock-web-api.service';
import { CustomersApiService } from '../app/fake/customers-api.service';
import { ProductsApiService } from '../app/fake/products-api.service';

/** 環境に応じたモジュールを用意する */
@NgModule({
  imports: [
    CommonModule,
    // apiBase の値は以下の environment.apiBaseUrl に記載の値と合わせておく
    HttpClientInMemoryWebApiModule.forRoot(MockWebApiService, { apiBase: 'mock-api/', delay: 10 })
  ]
})
export class EnvironmentModule {
  static forRoot(): ModuleWithProviders {
    return {
      ngModule: EnvironmentModule,
      providers: [
        CustomersApiService,
        ProductsApiService
      ]
    };
  }
}

/** 環境情報 */
export const environment = {
  /** 開発環境 */
  production: false,
  /** モック API を利用する */
  isMock: true,
  /** WebAPI のベース URL */
  apiBaseUrl: 'mock-api'
};
  • ./src/environments/environment.prod.ts : 本番向け、API モックを利用しない環境変数ファイル
import { CommonModule } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core';

/** 環境に応じたモジュールを用意する */
@NgModule({
  imports: [
    CommonModule
  ]
})
export class EnvironmentModule {
  static forRoot(): ModuleWithProviders {
    return {
      ngModule: EnvironmentModule,
      providers: []
    };
  }
}

/** 環境情報 */
export const environment = {
  /** 本番環境 */
  production: true,
  /** モック API を利用しない */
  isMock: false,
  /** WebAPI のベース URL */
  apiBaseUrl: 'http://example.com/api'
};

このように環境変数ファイル別に用意した EnvironmentModule を AppModule で読み込む。

import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';

import { EnvironmentModule } from '../environments/environment';

import { AppComponent } from './app.component';

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    EnvironmentModule.forRoot()  // ← コレ
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule { }

このようにしておくと、ビルド時の環境変数ファイル指定によって、API モックサービスを利用するか否かが決められる。

# 「environment.ts」利用 … モック API を利用してサーバ起動する
$ npm run ng serve

# 「environment.prod.ts」利用 … モック API を利用しないでサーバ起動する
$ npm run ng serve -- --environment=prod

もちろん、開発中でもモックを使いたくないという場合もあると思うので、その際は開発用の環境変数ファイルを別途作成すれば良い (環境変数ファイルの作成方法は割愛)。

今回、environment.apiBaseUrl というプロパティを用意したので、コレを利用して HttpClient から通信するようにしておけば、利用する環境変数ファイルに応じて URL を切り替えられる。

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { environment } from '../environments/environment';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  constructor(private http: HttpClient) {}

  /** 顧客情報を取得する */
  fetchCustomer(id: string): void {
    // 前回は以下のように切り替えていたが、コレを止める
    // const serverUrl = environment.production ? 'http://example.com/api/' : 'mock-api/';
    
    // 以下のように environment からベース URL を取得すれば良い
    this.http.get(`${environment.apiBaseUrl}/customers/${id}`).toPromise()
      .then((response) => {
        // 成功時
      })
      .catch((error) => {
        // 失敗時
      });
  }
}

更新データを差し替えるには (リクエストデータの加工)

モック化する API の仕様によっては、「POST パラメータに含められるデータをそのまま DB に登録しない」作りもあると思う。

例えば、データを新規作成する API があるとして、以下のようなリクエストを投げるとする。

{
  "name": "Doc Brown",
  "createdBy": "Administrator"
}

データを作成したユーザ情報を createdBy プロパティで投げてもらうのだが、API サーバ側ではデータを加工し、以下の形式で保持しているとする。

{
  "name": "Doc Brown",
  "lastUpdatedBy": "Administrator",
  "lastUpdatedAt": "2018-07-01"
}

createdBylasteUpdatedBy になり、さらにサーバ側で登録日を生成して lastUpdatedAt プロパティとして保存している。POST で新規登録した後、対象のリソースを GET で取得した時に、この情報が取得できるようにしたい、とする。

In Memory Web API のデフォルトの挙動だと、HttpClient#post()HttpClient#put() で投げられたリクエストボディをそのまま対象のリソースの DB (createDb() のプロパティ) に追加してしまうので、投げられたリクエストデータがそのまま保持される形となる。

このデータを加工して DB に入れるには、リクエストデータを加工する必要がある。

先程の例を実現するとして、POST されたデータを加工する処理を入れてみる。まずは先程実装した MockWebApiService クラス内に、post() という関数を作る。

  • ./src/app/fake/mock-web-api.service.ts
import { Injectable } from '@angular/core';
import { InMemoryDbService } from 'angular-in-memory-web-api';
import { CustomersApiService } from './customers-api.service';
import { ProductsApiService } from './products-api.service';

@Injectable()
export class MockWebApiService implements InMemoryDbService {
  /** コンストラクタ : リソース別のクラスを DI する */
  constructor(
    protected customersApiService: CustomersApiService,
    protected productsApiService: ProductsApiService
  ) { }
  
  /** InMemoryDbService から継承 : モックデータを作成する */
  createDb(): any { /* 省略 */ }
  
  /** InMemoryDbService から継承 : レスポンス前に実行される関数 */
  responseInterceptor(responseOptions: any, requestInfo: any): any { /* 省略 */ }
  
  /** POST 時にリクエストデータを加工する */
  post(requestInfo: RequestInfo): any {
    // 各 API サービスクラスに同名の関数があれば実行する
    Object.keys(this)
      .filter((propName) => {
        // BaseApiService を継承したインスタンスのプロパティで、リクエスト URL に合致するリソース名で、post() 関数を持っているか
        const member = this[propName];
        return member instanceof BaseApiService
          && requestInfo.collectionName === member.apiName
          && typeof member['post'] === 'function';
      })
      .forEach((propName) => {
        // 対象の API サービスクラスの post() 関数を実行する
        this[propName].post(requestInfo);
      });
    });
  }
}

やっていることは responseInterceptor() 関数を実装した時と同じ。リクエスト URL に対応する API サービスクラスに実装されている post() 関数を実行するようにしている。

今回は CustomersApiService (/customers) の POST 時にデータを変換して登録することとする。

  • ./src/app/fake/customers-api.service.ts
@Injectable()
export class CustomersApiService extends BaseApiService {
  /** API のリソース名 (URL に使用する文字列) */
  apiName: string = 'customers';
  /** 顧客情報のモックデータ : 先程までの例では lastUpdatedBy と lastUpdatedAt がなかったが、話の辻褄を合わせるために書いた */
  data: any = [
    { id: 1, name: 'Marty', lastUpdatedBy: 'Rob', lastUpdatedAt: '2017-01-02' },
    { id: 2, name: 'Jennifer', lastUpdatedBy: 'Rob', lastUpdatedAt: '2017-01-02' }
  ];
  
  /** 顧客情報 API への POST 時にリクエストデータを加工する */
  post(requestInfo: RequestInfo): any {
    // lastUpdatedBy プロパティを生成する : 値はリクエスト中の createdBy プロパティを利用する
    requestInfo.req.body.lastUpdatedBy = requestInfo.req.body.createdBy;
    // DB に保存したくない createdBy プロパティは削除する
    delete requestInfo.req.body.createdBy;
    // 同様に lastUpdatedAt プロパティを生成する : 値は適当にダミーデータを入れる
    requestInfo.req.body.lastUpdatedAt = '2018-06-05';
  }
}

なお、id プロパティだけは Angular In Memory Web API が自動的にインクリメントして生成してくれる。コレで準備完了。

画面から以下のような関数で POST したとする。

this.http.post(`${environment.apiBaseUrl}/customers/`, {
  name: 'Doc Brown',
  createdBy: 'Administrator'
})
  .toPromise()
  .then((response) => {
     // 成功時 : 生成したデータを取得してみる
     return this.http.get(`${environment.apiBaseUrl}/customers/${response.id}`);
  })
  .then((response) => {
    // 成功時 : 生成したデータに createdBy プロパティがなく、lastUpdatedBy・lastUpdatedAt・ついでに id プロパティが付与されている
  })
  .catch((error) => {
    // 失敗時
  });

このようになる。

今回は post() だったが、同様の関数は get()put()patch()delete() と用意されているので、色々と処理を切り替えられるだろう。

レスポンスする HTTP ステータスコードを切り替えたい

削除済みのリソースに対して DELETE メソッドを発行した時に、In Memory Web API のデフォルトでは正常動作として HTTP 204 (No Content) を返すが、今回独自に、HTTP 404 (Not Found) を返すようにしたいとする。

レスポンスする HTTP ステータスコードを切り替えるには、responseInterceptor() 関数のタイミングで、responseOptions.status プロパティの値を書き換えてやれば良い。status プロパティを書き換えると、statusText は自動的に変わってくれるので楽チン。

GET メソッド時にワケあって 404 を返したりしたい時はコレで良いが、DELETE 時はちょっと厄介で、In Memory Web API のデフォルトでは正常扱いになるものを異常扱いにする必要があるので、リクエストデータを先に書き換えてやる。

予め、先程紹介した post() メソッドと同じ要領で、delete() メソッドを API サービスクラスに実装して呼び出せるようしておくこと。

  • ./src/app/fake/customers-api.service.ts
@Injectable()
export class CustomersApiService extends BaseApiService {
  /** API のリソース名 (URL に使用する文字列) */
  apiName: string = 'customers';
  /** 顧客情報のモックデータ
  data: any = [ /* 省略 */ ];
  
  /** 顧客情報 API への POST 時にリクエストデータを加工する */
  post(requestInfo: RequestInfo): any { /* 省略 */ }
  
  /** 削除済みのデータに対する DELETE で 404 を返すようリクエストデータを加工する */
  delete(requestInfo: RequestInfo): any {
    // DELETE 時は requestInfo.id を指定しているはずなので、この ID に合致するデータが this.data に存在するか確認する
    const targetData = this.data.find((item) => {
      return item.id === requestInfo.id;
    });
    
    // もし対象のデータが存在しなければ、既に削除済みなので、HTTP 404 を返すためのフラグを用意しておく
    if(!targetData) {
      requestInfo.isAlreadyDeleted = true;
    }
  }
  
  /** リクエスト情報を加工する */
  responseInterceptor(responseOptions: ResponseOptions, requestInfo: RequestInfo): any {
    if(requestInfo.method.toUpperCase() === 'DELETE') {
      // DELETE メソッド実行時 : 「isAlreadyDeleted」フラグがリクエスト情報に含まれていれば、レスポンスを 404 にする
      if(requestInfo['isAlreadyDeleted']) {
        responseOptions.status = 404;
      }
    }
    else {
      // それ以外のメソッドでのリクエスト時…
    }
  }
}

こんな感じ。なかなか力技だが、Angular In Memory Web API が用意しているデフォルトの挙動を変えられた。


以上。API サーバって結局はゴリゴリ実装するしかないのねん…。

Angularデベロッパーズガイド 高速かつ堅牢に動作するフロントエンドフレームワーク

Angularデベロッパーズガイド 高速かつ堅牢に動作するフロントエンドフレームワーク

  • 作者: 宇野陽太,奥野賢太郎,金井健一,林優一,吉田徹生,稲富駿
  • 出版社/メーカー: インプレス
  • 発売日: 2017/12/15
  • メディア: Kindle版
  • この商品を含むブログを見る