Cycle.js で HTTP ( API ) 通信をやってみる - Cycle.js を学ぼう #8

HTTPDriver とは

Cycle.js 公式が提供する HTTP 通信をするためのドライバです。『Cycle.js の Drivers を理解する – Cycle.js を学ぼう #5』で紹介したようにドライバはアプリケーション開発者が自作することも出来ますが、HTTP 通信DOM 描画といったアプリケーション開発でよく使われるであろう大きな機能は予め公式から提供されているというわけです。

実態は SuperAgent という HTTP 通信に特化したライブラリです。HTTPDriver はこれを薄くラップしただけの非常にシンプルなものですが、アプリケーション開発に必要な機能は充分に備わっているので不足を感じることは無いでしょう。

今回はこの HTTPDriver について詳しくご紹介します。

前提条件

  • Node.js v8.6.0+
  • npm v5.3.0+
  • Yarn v1.1.0+

サンプルコードは GitHub にて公開しておりますので、ぜひ併せてご参照ください。

環境構築

先述の GitHub リポジトリには4つのサンプルがあり、それぞれのディレクトリ内で yarn install を実行します。今回使用する主なライブラリは以下の通り。

"dependencies": {
  "@cycle/dom": "^19.3.0",
  "@cycle/http": "^14.8.0",
  "@cycle/run": "^3.4.0",
  "@cycle/rxjs-run": "7.2.0",
  "rxjs": "^5.5.2",
  "xstream": "^11.0.0"
}
2017年11月6日時点での rxjs-run の最新バージョンは 7.3.0 ですが、httpDriver が動作しなくなるバグ ( ? ) があるため、あえて 7.2.0 を採択しています。

簡単な GET 通信をやってみよう

今回は JSONPlaceholder という RESTful なダミー API サービスを使います。まずは簡単な GET 通信を試してみましょう。 /posts を叩いて100件の投稿データを取得してみます。

import {Observable} from 'rxjs';
import run from '@cycle/rxjs-run';
import {VNode, makeDOMDriver, div, h3, button, pre, p, code} from '@cycle/dom';
import {makeHTTPDriver, RequestInput} from '@cycle/http';
import {DOMSource} from '@cycle/dom/rxjs-typings';
import {HTTPSource} from '@cycle/http/rxjs-typings';
type PageState = {
  response: Object;
}
// DOM ツリーを作成
function render(pageState: PageState): VNode {
  return div('.container-fluid', [
    // render view ...
  ]);
}
function main({DOM, HTTP}) {
  const defaultPageState = {
    response: {}
  };
  const eventClickGet$ = DOM.select('#get-posts').events('click');
  // 1. リクエスト Observable を作成する
  const request$ = Observable.from(eventClickGet$).mapTo({
    url: 'http://jsonplaceholder.typicode.com/posts',
    category: 'api'
  });
  // 3. レスポンス Observable を取得する
  const response$ = HTTP.select('api').switchMap(x => x);
  // 4. レスポンス Observable から値を取り出して DOM 構造を更新する
  const pageState$ = response$.map(response => ({response})).startWith(defaultPageState);
  const dom$ = pageState$.map(pageState => render(pageState));
  return {
    DOM: dom$,
    // 2. リクエスト Observable を httpDriver に Sink する
    HTTP: request$
  };
}
run(main, {
  DOM: makeDOMDriver('#app'),
  HTTP: makeHTTPDriver()
});

DOMDriver 同様、HTTPDriver も Cycle.js 公式ドライバなだけあって、使い方は非常によく似ています。

1. まずはじめにリクエストオブジェクトの Observable を作成します。オブジェクトは RequestInput という Cycle.js の型です。必須パラメータは url: string のみですが、ここでは category: string も指定しています ( ※ 後述 ) 。

2. 作成したリクエスト Observable は HTTPDriver に Sink され、アプリケーション層 ( main関数 ) から隔離されたドライバ層 ( 外の世界 ) で実際の API 通信が行われます。API 通信はいわゆる『副作用を伴う処理』ですが、そのような処理を全てドライバ層に隔離することで、アプリケーション層をリアクティブ・プログラミングのみのクリーンな世界で維持出来るのです。

3. リクエストオブジェクトと対となるレスポンスオブジェクトの Obwervable を HTTPSource から取得します。HTTPSource には、Sink したリクエストで通信した結果が全て含まれています。このとき HTTPSource の select() メソッドでどのリクエストによるレスポンスを取得したいのかを指定するわけですが、ここでリクエストオブジェクトの category パラメータ値を指定することでレスポンスを特定することが出来ます。category が未指定の場合は、select() も未指定にすることでレスポンスを受け取れます。switchMap(x => x) としているのは、レスポンスの型が Observable<Observable<Response> & ResponseStream> と Observable が入れ子となっているためです。これは同じ API を連続で叩いたときに後勝ちにして、最終的に一つのレスポンスだけを受け取れるようにするための仕組みでしょう。

4. レスポンスは Observable なので、map() 等することで実際の値が取り出せます。ここでは値を画面に表示するために render() メソッドに値を渡し、その戻り値を DOMDriver に Sink して DOM 描画を実現させます 。

いかがでしょうか?文章で解説すると複雑に思えるかもしれませんが、実際は DOMDriver による DOM の描画と全く同じ処理の流れを踏んでいるだけであり、一連のサイクルに則った非常に明快な仕組みであることがお分かりいただけたのではないでしょうか。

POST 通信ををやってみよう

GET 通信が理解出来てしまえば、POST 通信もほぼ同じ要領で実現出来ます。JSONPlaceholder の posts API を POST で叩いてみましょう。

type PageState = {
  response: Object;
}
// DOM ツリーを作成
function render(pageState: PageState): VNode {
  return div('.container-fluid', [
    div('.row', [
      div('.col-5', [
        h3(['POST']),
        div('.form-group', [
          input('#post-title.form-control')
        ]),
        div('.form-group', [
          textarea('#post-body.form-control')
        ]),
        button('#post.btn.btn-outline-primary.btn-block', ['POST'])
      ]),
      div('.col-7', [
        div('.card.bg-light', [
          div('.card-body', [
            pre([JSON.stringify(pageState.response, null, 2)])
          ])
        ])
      ])
    ])
  ])
}
function main({DOM, HTTP}) {
  const defaultPageState = {
    response: {}
  };
  // 1. DOM からの入力イベントを取得する
  const eventClickPost$ = DOM.select('#post').events('click');
  const eventInputPostTitle$ = DOM.select('#post-title').events('input');
  const eventInputPostBody$ = DOM.select('#post-body').events('input');
  // 2. 入力イベントをトリガーにしてリクエスト Observable を作成する
  const request$ = Observable.from(eventClickPost$).withLatestFrom(
    eventInputPostTitle$.map((e: CycleDOMEvent) => (e.ownerTarget as HTMLInputElement).value),
    eventInputPostBody$.map((e: CycleDOMEvent) => (e.ownerTarget as HTMLInputElement).value),
    (_, postTitle, postBody) => ({
      url: 'http://jsonplaceholder.typicode.com/posts',
      category: 'api',
      method: 'POST',
      send: {
        id: 1,
        title: postTitle,
        body: postBody
      }
    })
  );
  // 4. レスポンス Observable を取得する
  const response$ = HTTP.select('api').switchMap(x => x);
  // 5. レスポンス Observable から値を取り出して DOM 構造を更新する
  const pageState$ = response$.map(response => ({response})).startWith(defaultPageState);
  const dom$ = pageState$.map(pageState => render(pageState));
  return {
    DOM: dom$,
    // 3. リクエスト Observable を httpDriver に Sink する
    HTTP: request$,
  };
}
run(main, {
  DOM: makeDOMDriver('#app'),
  HTTP: makeHTTPDriver()
});

1. POST する値を DOM ツリーの入力イベントから取得します。DOM ツリー自体は render() メソッドで定義し、その戻り値を DOMDriver に Sink させて画面上に描画します。ユーザが画面上からテキスト入力といったアクションをすると、それが DOM ( DOMSource ) として main 関数に流れ込み、そこから入力イベントを取得するという流れです。

2. 入力イベントを束ねてリクエスト Observable を作成します。GET 通信のときは url と category だけでしたが、POST 通信のときは method と API に送るデータの send オブジェクトを指定します。

後は GET 通信と全く同じです。リクエスト Observable を HTTPDriver に Sink し、POST に成功すると 200 レスポンスが返ってきます ( 4. ) 。

エラーハンドリングをやってみよう

実際のアプリケーション開発においては、API 通信でエラーが発生したときのハンドリングも欠かせません。JSONPlaceholder はダミー API ですが、 GET で不正なエンドポイントを指定することでエラーを起こすことが出来ます。

ここまでご紹介したレスポンス Ovservable のコードは、通信が成功することを前提としたものですが ( 200系 ) 、エラーを想定する場合 ( 400系など ) は次のように記述します。

// 成功する前提
const response$ = HTTP.select('api').switchMap(x => x);
// エラーも想定
const response$ = HTTP.select('api').switchMap(x => x.catch(e => Observable.of(e)));

switchMap で流れてくる値に対して RxJS のcatchオペレータで待ち構えます。エラーが発生するとここでエラーオブジェクトを受け取ることができ、それを再び Observable として rerturn することでエラーオブジェクトも response$ から取得することが出来ます。

つまり、response$ は通信成功時はレスポンスオブジェクトを、失敗時はエラーオブジェクトと 2パターンのレスポンスを受け取ることになります。このまま使っても良いのですが、出来れば通信成功したときと失敗した時とで別々の Observable として受け取れるとコードの見通しが良くなり、何かと扱いやすくなります。次のように書くことで response$ を二分割出来ます。

const response$ = HTTP.select('api').switchMap(x => x.catch(e => Observable.of(e)));
const [success$, error$] = response$.map(response => ({
  status: response.status,
  body: response.body || (response.response ? response.response.body : {})
})).partition(x => 200 <= x.status && x.status < 300);

response$ には Response オブジェクトかエラーオブジェクトのどちらかが流れてきます。ステータスコードはどちらの場合でも response.status で取得することが出来ますが、問題はbody要素です。Response オブジェクトの場合は普通に response.body で取得出来ますが、エラーオブジェクトの場合はresponse.responseという getter メソッド ( ? ) を呼び出す必要があります。こうすることで通信成功時と同じ構造のオブジェクトを取得出来ます。

最後はpartitionオペレータを使って response$ を success$ ( 成功時 ) と error$ ( 失敗時 ) に分割します。

main() メソッドの全体像はこちら。

function main({DOM, HTTP}) {
  const eventClickGet$ = DOM.select('#get-posts').events('click');
  const eventInputPostId$ = DOM.select('#post-id').events('click');
  const request$ = Observable.from(eventClickGet$).withLatestFrom(
    eventInputPostId$.map((e: CycleDOMEvent) => Number((e.ownerTarget as HTMLInputElement).value)),
    (_, postId) => ({
      url: `http://jsonplaceholder.typicode.com/posts/${postId || ''}`,
      category: 'api',
      method: 'GET'
    })
  );
  const response$ = HTTP.select('api').switchMap(x => x.catch(e => Observable.of(e)));
  const [success$, error$] = response$.map(response => ({
    status: response.status || 400,
    body: response.body || (response.response ? response.response.body : {})
  })).partition(x => 200 <= x.status && x.status < 300);
  const defaultPageState = {
    response: {}
  };
  const pageState$ = Observable.merge(
    success$,
    error$
  ).map(response => ({response})).startWith(defaultPageState);
  const dom$ = pageState$.map(pageState => render(pageState));
  return {
    DOM: dom$,
    HTTP: request$
  };
}

締め

以上、HTTPDriver の概要と使い方をご紹介しました。最近ですと fetch を使うことで非常にスマートに HTTP 通信を書くことも出来ますが、Internet Explorer 11 のサポートを考慮するとまだ積極的に採用するのは躊躇われる場面も少なくないでしょう。そういう意味では superagent をラップしたというのは、良い落とし所と言えます。SwitchMap を使うことで連続で通信してもうまい具合に捌けるのも、非常に美しい設計と言えるのではないでしょうか。