Angular 4.3で追加されたHttpClientModuleについてのメモ

はじめに

先日Angular 4.3がリリースされました。予定されているリリーススケジュール通りであれば、
Angular4系では最後のマイナーバージョンリリースになります。
(これから9月のバージョン5のリリースまでパッチリリースのみの予定です。)

リリーススケジュールが若干遅れ、代わりに4.xのマイナーバージョンアップとして4.4がリリースされました。
5.0のリリースは2017/10/4現在、10/23の予定です。

Angularのリリーススケジュール

このAngular 4.3では、大きな機能追加の一つとして 新しいHttpクライアントモジュールが追加されています。
新しい、とはいえこれまでのHttpモジュールと全く違うものというわけではなく、既存のHttpモジュールの改良版という位置付けのようです。
既存のHttpモジュールも使いやすく気に入っていたのですが、今回追加されたHttpクライアントではさらなる改善が加えられています。

以下、公式の解説に従いながら新機能を解説していきます。

Angular.io - HTTP

※サンプルではAngular CLIの利用を前提に記載しています。

インストール

新しいHttpクライアントモジュールは@angular/common/httpとして、これまでの@angular/httpとは別のモジュールとして提供されています。
既存のHttpモジュールと新しいHttpクライアントモジュールでは基本的な使い方は似ていますが、一部互換性のない構文が含まれています。そのためいきなり@angular/httpを置き換えるのではなく別モジュールとすることで、徐々に移行できるようにしようという意図があるようです。

まずはプロジェクトを作成しましょう。@angular/common/httpを使う場合でも特に追加設定は不要です。
(この時Angular 4.3以上でないと@angular/common/httpは提供されていないので、4.3以上がインストールされていることを確認してください。)

ng new

続いて、モジュールに新しいHttpクライアントモジュールを追加します。

app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

// HttpClientModuleをインポート
import { HttpClientModule } from '@angular/common/http';

@NgModule({
    imports: [
        BrowserModule,
        // HttpClientModuleを追加
        HttpClientModule,
    ],
})
export class AppModule {}

これでAppModule内のクラスに対してHttpClientのDIが可能になります。
Httpモジュール同様、コンストラクタでhttp: HttpClientと記載しておけばインスタンスの生成と同時に初期化されます。

sample.service.ts

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

@Injectable()
export class SampleService {

    // コンストラクタの引数に指定してDI
    constructor(private http: HttpClient) {}
}

改善ポイント

それでは早速、HttpClientの機能を見ていきます。

JSON形式のパースがデフォルトに

これまでのgetメソッドはObservable<Response>を返しており、レスポンスとして返されたJSONを処理するためには以下のように記述する必要がありました。

// @angular/http
this.http.get(url).map(response => response.json()).subscribe(json => ...);

しかし近年ではREST APIなどJSONによるデータのやり取りが一般的になっており、リクエストのたびに上のように書くのは冗長です。そうした流れを受けて、HttpClientではJSON形式のパースをデフォルトで行うように変更されました。
getなどのメソッドは特に型の指定がない場合、これまでのObservable<Response>ではなくObservable<Object>が戻り値になります。

// @angular/common/http
// ここでjsonはResponseではなくObject
this.http.get(url).subscribe(json => ... );

とはいえ、必ずしもすべての通信がJSONで行われるわけではないと思います。そういった場合に開発者が任意にレスポンスのフォーマットを指定するにはresponseTypeオプションを指定します。

http.get(url, { responseType: 'text' })
    // レスポンスはテキストとしてsubscribeに渡される
    .subscribe(text => console.log(text));

レスポンスの型指定

上記サンプルのようにresponse.json()でJSONをパースすると、subscribeで受け取る型はおのずとobjectになります。JavaScriptであればobjectで受け取れればあとはよしなに処理すれば良いのですが、TypeScriptではobject内のプロパティは宣言の時点で明示されているもの以外はobj.foo形式でアクセスできないという制約があります。

// JavaScript
this.http.get(url)
    .subscribe(response => {
        console.log(response.foo);     // OK
        console.log(response['foo']);  // OK
    });
// TypeScript
// この書き方だとresponseオブジェクト内部のプロパティが定義されていない 
this.http.get(url)
    .subscribe(response => {
        console.log(response.foo);     // NG
        console.log(response['foo']);  // OK
    });

JavaScriptとTypeScriptで全く同じコードなのにresponse.fooの呼び出しがエラーになってしまいました。これを回避するためには、interfaceを用いて内部のプロパティを定義する必要があります。

import 'rxjs/add/operator/map';

// プロパティ: fooを持つinterfaceを定義
interface FooResponse {
    foo: string;
}

// パターン1:mapでキャスト
this.http.get(url)
    // FooResponseにキャスト
    .map(response => response as FooResponse)
    .subscribe(response => {
        console.log(response.foo);     // OK 
        console.log(response['foo']);  // OK
    });

// パターン2:subscribe内でキャスト
this.http.get(url)
    .subscribe(response => {
        const fooResponse = response as FooResponse;
        console.log(fooResponse.foo);     // OK
        console.log(fooResponse['foo']);  // OK
    });

上記のコードでプロパティ呼び出しは実現できましたが、せっかくなくせたと思っていたmapメソッドが復活していたり、わざわざasでキャストしていたりと冗長になってしまいました。
新しいHttpクライアントモジュールでは、各メソッドに戻り値の型が型パラメータとして渡せるようになっています。

// プロパティ: fooを持つinterfaceを定義
interface FooResponse {
    foo: string;
}

// getメソッドの型パラメータでレスポンスの型を指定
this.http.get<FooResponse>(url)
    // subscribeの時点でFooResponseとして受け取れる
    .subscribe(response => {
        console.log(response.foo);     // OK
        console.log(response['foo']);  // OK
    });

mapメソッドもasによるキャストもなくなり、コードがすっきりしました。

完全なレスポンスの取得

これまでの書き方では、subscribeは直接レスポンスボディの内容を受け取っていました。しかしAPIの内容によってはカスタムヘッダが含まれているなど、レスポンスヘッダを参照したいことがあるかもしれません。
そのような場合はメソッドにobserveオプションを設定することでヘッダなどを含めた完全なレスポンスを受け取ることができます。

// プロパティ: fooを持つinterfaceを定義
interface FooResponse {
    foo: string;
}

// getメソッドにobserveオプションを指定
this.http.get<FooResponse>(url, { observe: 'response' })
    .subscribe(response => {
        // ヘッダ情報はresponse.headersに格納。getメソッドで取得。
        console.log(response.headers.get('X-My-Header'));

        // レスポンスボディはresponse.bodyに格納。型指定も有効。
        console.log(response.body.foo);
    });

Interceptor

HttpClientの目玉の一つです。
例えば、認証が必要なREST APIにリクエストを送信する際、ヘッダにAuthorization: Bearer XXXXXXXXXXを設定したり、レスポンスから不要な値を削除したりといった共通的な処理を挟み込みたい場合、これまではHttpモジュールをラップするサービスを作って処理を記述する他ありませんでした。
Interceptorを利用すれば、これまでのようにHttpClientのラッパを作らずとも共通処理が実現できます。

以下はリクエストをそのまま転送しレスポンスをそのまま返却するだけのInterceptorです。

import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class SimpleInterceptor implements HttpInterceptor {

    // リクエストの変換処理。ここに共通処理を記述。
    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        return next.handle(request);
    }
}

InterceptorはHttpInterceptorインタフェースを実装し、interceptメソッドを持つクラスとして定義します。

  • interceptメソッドは受け取ったHttpRequestObservable<HttpEvent>に変換して返すメソッドとして定義します。
  • request: HttpRequest<>が処理対象となるリクエストデータです。ヘッダやボディなどを含みます。
  • next.handleinterceptメソッド同様にHttpRequestを受け取りObservable<HttpEvent>を返すメソッドで、Interceptorが複数ある場合は後続のInterceptorを呼び出し、後続の処理がなければリクエストを送信します。基本的にinterceptメソッドの処理完了時の決まり文句と捉えて貰えれば良いと思います。

作成したInterceptorを利用する場合はサービスと同様にproviderに設定する必要があります。(複数のInterceptorを利用する場合、ここで指定した順序通りに処理が実行されます。)

import { NgModule } from '@angular/core';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { SimpleInterceptor } from './simple-interceptor.service';

@NgModule({
    providers: [
        {
            provide: HTTP_INTERCEPTORS,
            useClass: SimpleInterceptor,
            // 必須:HTTP_INTERCEPTORSが配列であることを示す
            multi: true
        }
    ]
})
export class AppModule {}

リクエストの処理

リクエストを処理するには、interceptメソッドに渡されたHttpRequestオブジェクトを利用して処理を行います。
リクエストを加工する場合も同様にHttpRequestオブジェクトを利用しますが、HttpRequestオブジェクトは基本的にimmutableになっており、そのままではリクエストを加工することができません。値を加工したい場合、HttpRequestに定義されているcloneメソッドを利用します。cloneメソッドは引数なしで実行するとそのオブジェクトのコピーを返しますが、オブジェクトを引数に渡すと、指定された値を上書きしたオブジェクトを返します。これを利用して値の書き換えを行います。

// そのまま複製するサンプル
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const req = request.clone();

    return next.handle(req);
}

// fooの値を書き換える場合
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const req = request.clone({ foo: 'Foo' });

    return next.handle(req);
}

// 複数の値も可
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const req = request.clone({ foo: 'Foo', bar: 'Bar' });

    return next.handle(req);
}

リクエストヘッダの書き換えも上記と同じ要領で行うことができます。書き換えるプロパティはheadersです。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = 'Bearer XXXXXXXXXX';
    const req = request.clone({ headers: request.headers.set('Authorization', token) });

    return next.handle(req);
}

上記サンプルではrequest.headers.set('Authorization', token)が使われています。request.headersがオリジナルのリクエストヘッダです。headersは複数値のプロパティなので、そのままheaders: [token]としてしまうと必要なリクエストヘッダの情報が欠落してしまいます。ヘッダに限らず、複数値のプロパティでは注意してください。

ヘッダに関しては、上記の処理についてショートハンドが提供されています。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = 'Bearer XXXXXXXXXX';
    const req = request.clone({ setHeaders: { Authorization: token }});

    return next.handle(req);
}

レスポンスの処理

レスポンスの値を利用して処理を行う場合、HttpHandler.handle(これまでのサンプルにおけるnext.handle)の戻り値を利用します。先述したとおりhandleメソッドはObservable<HttpEvent>を返しますので、RxJSのメソッドを利用して加工して貰えればOKです。
以下はレスポンスの値をWebStorageに格納するサンプルです。

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const storage = window.sessionStorage;

    return next.handle(request)
        .do(event => {
            if (event instanceof HttpResponse) {
                storage.setItem('cache', event.body);
            }
        });
}

Progress Events

大容量のファイルアップロードやダウンロードを行う場合など、処理の進捗(ex. 今どこまでアップロードが進んでいるのか)
を確認したい場合があります。HttpClientでは、そのようなユースケースに対応するため、処理の進捗を示すイベントの実行をサポートしています。
処理の進捗をイベントで受け取る場合は、HttpRequestオブジェクトを生成して、reportProgressオプションを設定する必要があります。

import { HttpRequest } from '@angular/common/http';

const request = new HttpRequest('POST', '/upload/files', file, {
  reportProgress: true,
});

HttpRequestオブジェクトをPOST用に生成したので、これをHttpClient.requestメソッドでPOSTします。
subscribeで取得されるオブジェクトは進捗を示すイベント、もしくはレスポンスになりますので、オブジェクトの型を頼りに処理を分岐します。

import { HttpEventType, HttpResponse, HttpEventType } from '@angular/common/http';

this.http.request(request).subscribe(event => {

    if (event.type === HttpEventType.UploadProgress) {
        // 進捗状況の出力
        const percentDone = Math.round(100 * event.loaded / event.total);
        console.log(`File is ${percentDone}% uploaded.`);
    } else if (event instanceof HttpResponse) {
        // HttpResponseを取得した場合は処理完了
        console.log('File is completely uploaded!');
    }
});

XSRF対策

上で紹介した Interceptor を利用した機能として、XSRF 対策がサポートされています。

クッキーに XSRF-TOKEN が設定されている場合、その値をリクエストヘッダ X-XSRF-TOKEN に設定して通信します。

この Intercepter は、HttpClient を使用した通信のうち、

  • リクエストメソッドが GET, HEAD 以外
  • リクエスト先 URL が相対パス

であるリクエストに適用されます。

Cookie 名 / ヘッダ名を指定する。

デフォルトでは、クッキー名に XSRF-TOKEN を、ヘッダ名に X-XSRF-TOKEN を使用します。これらの名前を変更するには、@NgModule にインポート HttpClientXsrfModule.withConfig を追加します。

imports: [
  HttpClientModule,
  HttpClientXsrfModule.withConfig({
    cookieName: 'My-Xsrf-Cookie', // クッキー名を指定
    headerName: 'My-Xsrf-Header', // ヘッダ名を指定
  })
]

その他機能

上記の他

  • HTTP リクエストのテスト機能

も追加されています。
(これについても時間があれば追記したいと思います。)

終わりに

新しいHttpクライアントモジュールについて、大まかに説明してみました。
個人的にInterceptorのサポートは非常に嬉しいポイントです。
既存の資産については互換性のない変更もありますので、既存のHttpモジュールを利用している資産を今すぐ置き換えるものではないですが、これから開発するものについては活用していきたいと思います。

※ 間違いなどあればご指摘いただけると助かります!