JavaScript
Node.js

yahoo/fetchrを使う

https://github.com/yahoo/fetchr

先日参加した、isomorphic tokyo meetupで紹介されていたfetchrが良さそうだったので、使ってみた。


fetchrが解決する課題

React+Fluxでサーバサイドレンダリング時に、superagentやaxios、isomorphic-fetchなどのIsomorphicなHTTPクライアントを使ってデータをフェッチするが、そのまま使うと下記のような課題にぶつかる。


  • ブラウザから直接BEのAPIにアクセスできない(またはさせたくない、外部のAPIでキー・シークレットをブラウザ側で持ちたくない)場合、そのAPIとProxyするエントリポイントを作る必要がある。

  • フェッチ部分のロジックが共有されるので、ブラウザではWeb API経由でフェッチして、サーバサイドレンダリング時は直接DBにクエリを叩きたい、みたいなのを自分で実装すると面倒。

fetchrはBEや外部のAPIからデータをフェッチするProxyを簡単に作れる。


使い方&サンプル

Yahooの郵便番号検索APIを使って、郵便番号から住所を検索するだけのWebアプリを作る。


0. インストール

$ npm install --save fetchr

// サンプルに必要なパッケージを入れておく
$ npm install --save express body-parser superagent
$ npm install --save-dev browserify babelify babel

今回、サンプルは全てES6で書くのでbabelでコンパイルする。

// srcにあるjsをコンパイルしてlib以下に出力

$ babel src -d lib


1. データサービスを定義・登録する

データサービスとは、サーバーサイドでデータに関する処理をする部分のことで、今回の例だと「APIにリクエストを投げて住所を取得する部分」をデータサービスに記述する。データサービスはサーバサイドで実行されるので、Isomorphicは意識しなくてもよい。

nameは必須項目で、呼び出す時に使う。データの作成・取得・更新・削除の処理をそれぞれ、create, read, update, deleteの4つで定義する。

今回はデータ取得なのでread関数を定義する。


services/address.js

'use strict';

import qs from 'querystring';
import request from 'superagent';

// 郵便番号検索API
// http://developer.yahoo.co.jp/webapi/map/openlocalplatform/v1/zipcodesearch.html
const ZIPCODE_SEARCH_API = "http://search.olp.yahooapis.jp/OpenLocalPlatform/V1/zipCodeSearch";
// リクエストにAppIDが必要なのでブラウザから直接叩けない
const APP_ID = "xxxxxxxxxxxxxxxxxxxxxxxxxxxx";

export default {
// nameは必須。呼び出す時に使う
name: 'address',

/**
* @param req expressのreq
* @param resource よくわからん(追記しました)
* @param params 呼び出す時に渡す
* @param config  呼び出す時に渡す
* @param callback コールバック
*/

read (req, resource, params, config, callback) {

let query = qs.stringify({
appid: APP_ID,
query: params.zipcode,
output: 'json'
});

// APIリクエスト
request
.get(`${ZIPCODE_SEARCH_API}?${query}`)
.end(function (err, res) {
if (err) return callback(err, null);

let result = res.body;
let address = false;

if (result.ResultInfo && result.ResultInfo.Count == 1) {
address = result.Feature[0];
}

callback(null, { address: address });
});
}

// 今回は使わないがCRUDで定義する
//create: function(req, resource, params, body, config, callback) {},
//update: function(req, resource, params, body, config, callback) {},
//delete: function(req, resource, params, config, callback) {}
}


定義したデータサービスをFetchrに登録するには、Fetcher.registerServiceを使う。

// 作った郵便番号検索のデータサービスをFetcherを登録

import addressService from './services/address';
Fetcher.registerFetcher(addressFetcher);


2. middlewareを使う

fetchrはexpress middlewareを提供しているので他のパスと被らないように設定する。今回は/proxy以下に設定。


server.js

'use strict';

import express from 'express';
import bodyParser from 'body-parser';
import Fetcher from 'fetchr';

// 作った郵便番号検索のデータサービスをFetcherを登録
import addressService from './services/address';
Fetcher.registerFetcher(addressFetcher);

let app = express();

// JSONでデータのやり取りをするので必要
app.use(bodyParser.json());

// `/proxy`以下にmiddlewareを設定
app.use('/proxy', Fetcher.middleware());



3. サーバサイドで使う

Fetcherをインスタンス化する際に、middlewareを設定したパスとreqを渡す(new Fetcher({ xhrPath: '/proxy', req }))。reqに関しては渡さなくても動作するが、渡さないとデータサービス側でreqが取得できなくなるので注意。

1で作ったfetcherのread関数を呼び出すには第1引数に'addresss'を渡す。


server.js

app.get('/', function (req, res) {

let zipcode = req.query.zipcode || '';

let renderHtml = (err, data) => {
// 検索フォームがあるだけのページ
let html = `
<!DOCTYPE html>
<body>
<h1>郵便番号検索</h1>
<form>
<input name="zipcode" type="text" value="
${ zipcode }" />
<button type="submit">検索</button>
</form>
<h2>結果</h2>
<pre>
${ data ? JSON.stringify(data, null, 2) : ''}</pre>
</body>
`
;

res.set('Content-Type', 'text/html');
res.send(html);
}

// queryにzipcodeが設定されている場合は検索
if (zipcode) {
// ②でmiddlewareを設定したパスを渡す
let fetcher = new Fetcher({
xhrPath: '/proxy' // middlewareを設定したパス,
req // データサービス側でreqが参照できるようになる
});

// ①の手順で定義したfetcherのread関数を呼び出す
//
return fetcher.read('address', { zipcode: req.query.zipcode }, {}, renderHtml);
}

renderHtml();
});

app.listen(3000);


node server.jsで起動してhttp://localhost:3000にアクセスすると、郵便番号検索するだけのWebアプリが表示されているはず。まだクライアントサイドを実装してないので、検索ボタンを押すたびにリロードして検索結果を表示する。


4. クライアントサイドで使う

クライアントサイドを実装し、初期レンダーはサーバサイドで、2回目以降の検索はfetchr経由でデータを取得するように改修する。

fetchrの使い方はサーバサイドと全く一緒で、インスタンス化する時にxhrPathを渡して、read関数を呼ぶ。


client.js

'use strict';

import Fetcher from 'fetchr';

let fetcher = new Fetcher({
xhrPath: '/proxy'
});

let form = document.querySelector('form');
let input = document.querySelector('input[type=text]');
let result = document.querySelector('pre');

// submitをキャンセルして、fetchr経由でデータを取得する
form.addEventListener('submit', function (e) {
e.preventDefault();
result.innerText = '';

// インターフェースはサーバサイドと同じ
fetcher.read('address', { zipcode: input.value }, {}, function (err, data) {
if (!err) {
result.innerText = JSON.stringify(data, null, 2);

// URLも書き換える(これであってるか不安)
history.pushState({}, document.title, `/?zipcode=${input.value}`);
}
});
}, false);


browserifyでビルドする(webpackでも可)


terminal

$ mkdir -p build

$ browserify -t babelify client.js -o build/client.bundle.js

server側でclient.bundle.jsを返すようにし、HTMLに組み込む。


server.js

// client.bundle.jsを静的アセットとして返す

app.use(express.static(__dirname + '/build'));

// 省略

app.get('/', function (req, res) {

// 省略

let renderHtml = (err, data) => {
// 検索フォームがあるだけのページ
let html = `
<!DOCTYPE html>
<body>
<h1>郵便番号検索</h1>
<form>
<input name="zipcode" type="text" value="
${ zipcode }" />
<button type="submit">検索</button>
</form>

<h2>結果</h2>
<pre>${ data ? JSON.stringify(data, null, 2) : ''}</pre>

<!-- 追加 -->
<script src="/client.bundle.js"></script>
<!-- 追加 -->
</body>
`;

res.set('Content-Type', 'text/html');
res.send(html);
}

// 省略
});


server.jsを再起動すると動作は前と変わらないが、chrome dev toolのnetworkタブ等で見ると、XHR経由で/proxy/address;zipcode=入力した郵便番号にリクエストしているのがわかる。

kobito.1430808586.414557.png

また、リロードしても真っ白にならずに住所が表示される!!


サンプルプロジェクト

https://github.com/pirosikick/fetchr-example


まとめ


  • fetchrはFE(クライアントサイド、サーバサイドのJS)とBE(DBや外部API)をつなぐ層を簡単に作れるからおすすめや!

  • また、csurfを組み合わせて使うこともできるらしい


追記:resourceについて

データサービスのread, create, update, deleteの第2引数にやってくるresourceについて、追記。

read (req, resource, params, config, callback)

create (req, resource, params, body, config, callback)
update (req, resource, params, body, config, callback)
delete (req, resource, params, config, callback)

↑の方のサンプルコードでresourceについて「ようわからん」と書いたが、fetchr.(read|create|update|delete)を呼び出し時のデータサービス名が渡ってくる。

上記の郵便番号検索の場合、fetchr.read('address', { ... })を実行すると、services/address.jsread関数のresourceには"address"が入る。

// 呼び出し側

const fetchr = new Fetchr()
fetchr.read("address", { zipcode: '...' });

// データサービス側
// services/address.js
export default {
name: "address",
read (req, resource, params, config, callback) {
console.log(resource); // "address"

「これ、何に使うんだ?」という疑問が浮かんだのでfetchrのソースを読んでみると、「resource.(ドット)でsplitして得られた配列の先頭の要素」で登録されたデータサービスの中からリクエストに該当するものを探しているみたい。

// https://github.com/yahoo/fetchr/blob/master/libs/fetcher.js#L320-L329

/**
* Returns true if service with name has been registered
* @method isRegistered
* @memberof Fetcher
* @param {String} name of service
* @returns {Boolean} true if service with name was registered
*/

Fetcher.isRegistered = function (name) {
return name && Fetcher.services[name.split('.')[0]];
};

要するに、fetcher.read("address.something", { ... })でもservices/address.jsread関数を呼び出すことができる。

自分がこれまで担当したプロジェクト(もしくは現在担当しているプロジェクト)では、1ページ1データサービスという作りにしていることが多々ある。その場合に、ページの一部分のデータだけ再読込したかったり、ファーストビューに必要なデータだけ先に取得して残りは非同期でという実装に後から変えたいというケースが発生することがある。そういう場合に別のデータサービスを追加してもよいが、fetcher.read("pageA.some-part")fetcher.read("pageA.firstview")のようにresourceを使ってレスポンスの量をコントロールできるようにするとうまく実装できることがあるので、覚えておくと役に立つかも!(追記終わり)