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を追加します。
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
を段階的に追加していくことが可能です。
// 一つ目の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);
}
// 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さんです。