JavaScript
angular
AngularDay 3

HttpInterceptorのユースケース

v4.3で追加されたHttpClientの中で、特にHttpInterceptorについて、ユースケースを挙げていきます。
対象はHttpClientの初級〜中級レベルの想定です。やるよねえからやるかもしれないなあくらいを取り上げたつもりです。また、できないよというのも拾ってみました。

HttpInterceptorについて

HttpInterceptorは、appが発行する全てのhttpリクエストやレスポンスに処理を追加できる仕組みです。

angularJSでもあったようなので古参のかたはお馴染みのようです。自分のような2以降に入った人にとっての説明としては、概念では、javaで言うAOPや、railsのcallback、reduxのmiddlewareなどが近いです。仕組みはそれぞれ違いますが、フックでしょ、フック。

HttpInterceptor、およびHttpClientの基本については、公式ドキュメントを翻訳したのでそちらをご参照ください。
公式ドキュメント(日本語版)「基礎ガイド-HttpClient編」

公式ドキュメントでは、HttpInterceptorのユースケースがいくつかサンプルコードで取り上げられています。

  • ヘッダーにAuthorizationと認証トークンをつける
  • アップロード/ダウンロードの進行状況を計算する
  • レスポンス内容をキャッシュする
  • urlを変える
  • 応答時間を計測する

以下、公式ドキュメントに乗っていないユースケースを数点サンプルを挙げていきます。

リクエストをインターセプトする

ドメインを変える

場面例:dev環境はCORS許可でやっていて、environmentでドメインを切り替えたい

公式ドキュメントのurlをリプレースするサンプルを少し発展させたものです。
environmentにdomainを設定して、HttpApiInterceptorでurlにdomainを追加します。

environment.ts
export const environment = {
  production: false,
  domain: 'http://localhost:3000', // dev環境のAPIサーバ
};
import { environment } from 'environments/environment';

@Injectable()
export class HttpApiInterceptor implements HttpInterceptor {
  // 起動環境毎のAPIサーバを保持する
  readonly domain: string = environment.domain;

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    const req = request.clone({
      url: this.domain + request.url, // ここでServiceで設定されたAPIパスにドメインをくっつける
    });
    return next.handle(req);
  }
}

複数のheader、またはparamsをセットする

場面例:ヘッダーを複数のInterceptorで段階的に追加していきたい

複数のheader、またはparamsをセットするために、一番シンプルな方法はこれです。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

  const req = request.clone({
    setHeaders: {
      test1: 'value1',
      test2: 'value2',
      test3: 'value3'
    },
    setParams: {
      param1: 'value1',
      param2: 'value2',
      param3: 'value3',
    }
  });
  return next.handle(req);
}

以下のように書き方を変えることもできます。本例では全く同じ動きになりますが、Interceptorを複数分けている時に、違いを発揮します。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

  // 引数requestからcloneして追加する
  const headers = request.headers
    .set('test1', 'value1')
    .set('test2', 'value2')
    .set('test3', 'value3');

  // 引数requestからcloneして追加する
  const params = request.params
    .set('param1', 'value1')
    .set('param2', 'value2')
    .set('param3', 'value3');

  const req = request.clone({
    headers: headers,
    params: params,
  });
  return next.handle(req);
}

前提にあるのは、HttpRequestオブジェクトはイミュータブルで扱われることです。このやり方をするときは、new HttpHeaders() | new HttpParams()ではなくrequest.headers | request.paramsから生成するのが無難です。setterは内部的にはcloneしてset()毎に別インスタンスを生成しています。そして、updateフィールドにset毎の値を格納して管理しているため、Interceptorを分けて作っている時などにnew HttpHeaders() | new HttpParams()したインスタンスを放り込むと、前段の内容が上書きされて消えてしまいます。

逆に言うと、以下のように複数のInterceptorを用いてheader | paramsを段階的に追加していくことが可能です。

first.interceptor.ts
// 一つ目のInterceptor
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

  const headers = request.headers
    .set('test1', 'value1');

  const params = request.params
    .set('param1', 'value1');

  const req = request.clone({
    headers: headers,
    params: params,
  });
  return next.handle(req);
}
second.interceptor.ts
// 2つ目のInterceptor
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

  const headers = request.headers
    .set('test2', 'value2')
    .set('test3', 'value3');

  const params = request.params
    .set('param2', 'value2')
    .set('param3', 'value3');

  const req = request.clone({
    headers: headers,
    params: params,
  });
  return next.handle(req);
}

なお、複数のInterceptorを定義する方法は後述しています。

レスポンスをインターセプトする

送信ログを取る

場面例:障害調査でrequestを発信したか担保取りたい。

interceptorがrequestを掴み、next.handleをreturnしますが、next.handle()はHttpEventを断続的に流すストリームになります。

例)HttpSentEvent → HttpProgressEvent → HttpProgressEvent → HttpResponse

HttpEventは5種類定義されていて、HttpSentEventが送信イベントになります。

HttpEventの種類
HttpSentEvent | HttpHeaderResponse | HttpResponse<T> | HttpProgressEvent | HttpUserEvent<T>

よって、handleをdoなりmapなりのRxオペレーターで処理してHttpSentEventをマッチさせれば、発信直後のストリームのスナップショットが取れます。ちなみにHttpResponseはinstanceofでマッチしますが、HttpSentEventはただのハッシュ{type: 0}が実体のため、instanceofではなくtypeを比較します。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  return next.handle(req)
          .do((event: HttpEvent<any>) => {
            // HttpSentEventをマッチする
            if (event.type === HttpEventType.Sent) {
              console.log('sent');
            }
          });
  }
}

また、service側で以下のようにobserveオプションをeventsに変えれば、HttpSentEventをsubscribeすることもできます。

this.httpClient.get(`/api/test`, {
  observe: 'events',
})
.subscribe(event => console.log('event', event)); // SentEvent→Responseが順番に流れてくる

エラーをハンドリングする

場面例:特定のステータスコードは特定の処理を行いたい

以下のようにRxのcatchオペレーターでエラーを捕まえることができます。HttpErrorResponseの担保を取っておくと良いと思います。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  return next.handle(req)
          .catch((error: any) => {
            if (error instanceof HttpErrorResponse) {
              switch (error.status) {
                case 304:
                  console.log('304');
                  return Observable.throw('304');
                case 401:
                  console.log('401');
                  return Observable.throw('401');
              }
            }
            return Observable.throw(error);
          });
  }

複数のInterceptorを定義する

単純にInterceptorを並べる

場面例:記述が膨らんできたので責務毎にInterceptorを分割したい

公式ドキュメントでも文章の説明がありますが、以下のように複数並べて供給するだけです。

providers: [
  { provide: HTTP_INTERCEPTORS, useClass: FirstInterceptor, multi: true },
  { provide: HTTP_INTERCEPTORS, useClass: SecondInterceptor, multi: true }
]

処理の順番は、FirstInterceptor.interceptor() → SecondInterceptor.interceptor() で実行されます。ただし、HttpEventの流れる順番は逆転します。これは、Interceptorが数珠繋ぎになっていると、returnのnextは次のHttpInterceptorへの参照を持ち、順番にスタックに積まれてLIFO(LastIn-FirstOut)になっているためと思われます。最後のInterceptorのnextの実体がHttpXhrBackendになり、ようやくネイティブのxhrオブジェクトが生成されます。

Module毎にInterceptorを分ける

場面例:一つのAPP内で一般公開サイトと管理サイトを作っていて、共通処理を分けたい

module毎にInterceptorを供給すると、DependencyInjectionTreeの解決順番によりInterceptorが並びます。

しかし、LazyLoadingModuleならDependencyInjectionTreeが独立するため、A moduleだけで動くA Interceptorと、B Moduleだけで動くB Interceptorといった定義も可能ではあります。

ただし、基本的な設計思想としてInterceptorは直列で繋いでいくものになっています。そのため、用途を分けたいなら、interceptorメソッド内で分岐を行うことが基本となり、並列で定義するのは道を外れた行為であることを自覚しDependencyInjectionTreeやwebpackのlazyModuleを十分に理解して行うべきと思います。

以下は一つのInterceptorで統合する場合のサンプルです。管理サイトはサブドメインで切られている想定で、サイトを判定して、サイト毎にreturnを分けています。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

  const site = location.hostname;
  // 管理サイトである
  if (site.match(/^admin/)) {
    // 管理サイトだけ認証する
    const req = request.clone({
      setHeaders: { Authorization: 'token' },
    });
    return next.handle(req);
  // 一般公開サイトである
  } else {
    // 一般公開サイトだけ別サービスに実績送信する
    return next.handle(request)
      .do(event => this.sendAnalystics(request.url));
  }
}

できないこと

HEADのレスポンスを判定する

HttpEventにHttpHeaderResponseがあるので、サーバがHEADで返せば取れると思いきや無理でした。response.d.tsのHttpHeaderResponseにはA partial HTTP response which only includes the status and header data,but no response body.(ステータスとヘッダーだけを持ちボディを持っていない部分的なHTTPレスポンス)とコメント書かれているので、取れるはずのようです。

ダメだったコードは以下に格納しています。(サーバレスポンスはexpressのend()で実行。他にもrailsのHEADメソッドで返してもHttpResponseで処理された。)
https://github.com/studioTeaTwo/angular-starter/commit/15d6ceba91df1d079a139a78a5f99fe09d92f05a

なお、HEADのレスポンス自体はHttpClientで以下のようにすれば、subscribeできます。

 this.httpClient.post(`/api/example/header`, {
   observe: 'response',  // ヘッダーを含めるために指定
   responseType: 'text', // bodyのjson.parse()を止めるためにダミーで指定する
 })
  • responseType=jsonになっていると受信後パースが走るため実行時エラーになります。
  • post<T>と型指定するとbodyの型チェックするためコンパイルエラーになります。

最後に

今回検証していたsandboxも上げておきます。
angularは5.0.3でやっていました。
https://github.com/studioTeaTwo/angular-starter/tree/example/http

明日4日目は@MasanobuAkibaさんです。