Falcor入門の第3回目です。
前回 はブラウザオンリーでFalcorを動かす話でしたが、今回はクライアント - サーバにFalcorを介在させる話を書きたいと思います。
ちなみに過去回はこちら:
Falcorとサーバサイド実装
第1回で解説したように、Falcorはクライアント - サーバ間のMiddlewareです。
FalcorでHTTPリクエストをラップするためには、開発者はFalcorのendpointサーバを実装し、クライアントからのリクエストを受け取り、JSON Grpah Envelopeを返却するようにしなくてはなりません。
現状(2016年2月現在)で、Falcor開発元であるNetflixが提供しているサーバサイドの仕組みとしては下記があります:
- falcor-router: JSON Graphのパスに対する処理をルーティングするための仕組み。ちなみにまだDeveloper Preview(2016年2月現在)
- falcor-express: falcor-routerで作成したRouterをExpressのRouterとして登録できる
上記をNode.jsのWebサーバであるExpressと組み合わせることで、Falcorのendpointサーバを作る事ができます(今回のエントリではこの方法を紹介します)。
ちなみにサーバサイド実装については、Falcorクライアントから特定の形式のHTTP リクエストを処理できればいいだけの話なので、その気になればJavaScriptではなく別言語で実装することもできます。実際、Githubで検索をかけてみた所、下記の実装が見つかりました。
- falcordotnet/falcor.net: C#のサーバ実装.
サンプルコード
さて、ここからは実際にコードを書いていきます。今回はクライアント, サーバ双方のコードを書いていく都合上、以下の用なファイル構成としました:
- (prj root)/
|- built/
|+ front/
|+ server/
| bundle.js
|- src/
|- front/
| index.js
|- server/
| app.js
| TodoRouter.js
| TodoService.js
| index.html
| package.json
src/front
ディレクトリにフロント用のコード(といってもindex.jsだけですが)とsrc/server
ディレクトリにサーバ用のコードを格納しています。
今回のサンプルで必要となるnpm packageは下記となります:
npm -g install browserify babel
npm install falcor falcor-http-datasource falcor-router falcor-express express body-parser --save
npm install babel-preset-es2015 --save-dev
サーバサイド
細かい解説は後述するとして、一旦コードを書ききってしまいます。
まずはExpressの起動コードです:
import * as express from 'express';
import * as bodyParser from 'body-parser';
import {dataSourceRoute} from 'falcor-express';
import {TodoRouter} from './TodoRouter';
const app = express();
// NOTE: bodyParserを忘れるとRouterのsetが動かないので注意
app.use(bodyParser.urlencoded({extended: true}));
app.use('/model.json', dataSourceRoute(() => new TodoRouter()));
app.use(express.static(__dirname + '/../../'));
app.listen(4000);
falcor-expressが登場するのはdataSourceRoute
の部分だけです。
この関数が、下記で作成するFalcor RouterをExpressのRouterとして'/model.json'のエンドポイントに登録してくれます。
続いて、下記が本エントリのメインテーマとなるFalcor Router部分です。
import {createClass} from 'falcor-router';
import {TodoService} from './TodoService';
export class TodoRouter extends createClass([
{
// {keys: ...}で任意のkeyに一致するルールが書ける
route: 'todoByIds[{keys:ids}][{keys:props}]',
get: function getTodoProperty(pathset) {
console.log('get:', pathset);
return Promise.all(pathset.ids.map(id => this.todoService.fetch(id))).then(todos => {
return todos.map(todo => {
var value = {};
pathset.props.forEach(prop => {
value[prop] = todo[prop];
});
return {
path: ['todoByIds', todo.id],
value
};
});
});
},
set: function setTodoProperty(jsonGraph) {
// Model.set, setValue等でセットされたjsonGraphが渡される
console.log('set:', jsonGraph);
var todoByIds = jsonGraph.todoByIds;
var keys = Object.keys(todoByIds);
return Promise.all(keys.map(id => this.todoService.set(id, todoByIds[id]))).then(() => ({jsonGraph}));
}
},
{
route: 'todos.length',
get: function getTodosLength(pathset) {
console.log('get:', pathset);
return this.todoService.fetchList().then(list => {
return {
path: ['todos', 'length'],
value: list.length
};
});
}
},
{
// {integers: ...} で任意のindexに一致するルールが書ける
route: 'todos[{integers:indices}]',
// pathsetにはマッチしたpathを分解したものが格納される
get: function getTodosReferences(pathset) {
console.log('get: ', pathset);
return this.todoService.fetchList().then(list => {
return {
jsonGraph: {
todos: pathset.indices.filter(i => list[i]).map(i => {
return {$type: 'ref', value: ["todoByIds", list[i].id]};
})
}
}
});
}
},
{
route: 'todos.push',
call: function pushNewTodo(callpath, args) {
console.log('call todos.push', callpath, args);
return this.todoService.add(args[0]).then(todo => {
return this.todoService.fetchList().then(list => {
return [
{
path: ['todos', 'length'],
value: list.length
},
{
path: ['todos', list.length - 1],
value: {$type: 'ref', value: ['todoByIds', todo.id]}
}
];
});
});
}
}
]){
constructor() {
super();
this.todoService = new TodoService();
}
}
最後に上記のRouterから呼び出されている TodoService
です。
本来はServer Side MVCのModel相当のコンポーネントで、DBアクセスか別Serviceの呼び出しを担うことになりますが、面倒なので今回は手抜き実装です。
単件取得、全件取得、TODOの追加、単件更新のメソッドを用意しました。
export class TodoService {
constructor() {
this._list = [
{
id: 'todo1',
text: '牛乳を買う',
completedAt: '2016-01-01'
}, {
id: 'todo2',
text: 'ATMでお金を引き出す',
completedAt: null
}
];
this._seq = this._list.length;
}
fetchList() {
return new Promise(res => {
res(this._list);
});
}
fetch(id) {
return new Promise((res, rej) => {
var matched = this._list.filter(t => t.id === id);
if(matched.length === 1) {
res(matched[0]);
}else{
rej();
}
});
}
add(param) {
var todo = {
id: `todo${++this._seq}`,
text: param.text || '',
completedAt: param.completedAt || null
};
this._list.push(todo);
return new Promise(res => res(todo));
}
set(id, param) {
var item = this._list.filter(l => l.id === id)[0];
return new Promise((resolve, reject) => {
if(!item) {
reject();
}else{
if(param.text) item.text = param.text;
if(param.completedAt) item.completedAt = param.completedAt;
resolve(item);
}
});
}
}
クライアントサイド
続いて、 クライアントサイドのコードです。
import * as Falcor from 'falcor/browser';
var out = (res) => {
console.log(res && JSON.stringify(res.json, null, 2));
};
var model = new Falcor.Model({source: new Falcor.HttpDataSource('/model.json') });
model.get('todos[0..9]["text", "completedAt"]').then(out);
前回のコードと殆ど変わりませんね。Falcor.Model作成時の引数がcache
からHttpDataSource
に変わった事ぐらいでしょう。
締めとして、index.htmlを用意してやりましょう。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<script src="built/bundle.js"></script>
</body>
</html>
起動
さて、ようやっと起動の準備が出来ました。
babel --presets es2015 -d built src
node built/server/app.js
browserify -o built/bundle.js built/front/index.js
として、http://localhost:4000/index.html
にアクセスして、開発者ツールからコンソールを立ち上げてみてください。前回と同じく、TODOリストの中身が表示されている筈です。
Routerコードの解説
ここからは上記で作成したTodoRouter.js
に焦点を当てて解説していきます。
Routerの作成方法
まずはFalcor Routerの定義方法から。createClass
でRouterのClassを動的に生成します。引数のRouterDefinitionが命。
import {createClass} from 'falcor-router';
class TodoRouter extends createClass([
/* Falcor Router の定義をゴニョゴニョ書く */
]){
}
createClass
の引数として用意するRouter Definitionsは下記の形式のJSONオブジェクトのリストです。
{
route: /* JSON Graph Path */,
get: function(pathsets) {...},
set: function(jsonGraph) {...},
call: function(callPath, args) {...}
}
リクエストがroute
のパスにマッチした場合に、メソッドに応じた関数が起動します。なお、get/set/callは全て書く必要はなく、必要なものだけ実装すれば充分です。
get
ここからはRouter Definitionのget/set/callを個別に見ていきましょう。
具体例があった方が説明しやすいので、先ほどのmodel.get('todos[0..9]["text", "completedAt"]')
というリクエストが、TodoRouter
にどのように処理されていくかに沿って説明していきます。
このケースでは、route: 'todos[{integers:indices}]'
の get
関数が動作します。 {integers:indices}
の部分は、[0..9]
のような整数範囲指定箇所に対応しており、
get
関数の引数であるpathsets
からpathsets.indices
に、範囲情報が格納されます。
{
route: 'todos[{integers:indices}]',
get: function getTodosReferences(pathset) {
console.log('get: ', pathset);
return this.todoService.fetchList().then(list => {
return {
jsonGraph: {
todos: pathset.indices.filter(i => list[i]).map(i => {
return {$type: 'ref', value: ["todoByIds", list[i].id]};
})
}
}
});
}
}
実データソースとなるModel(またはService相当のコード)から、TODOリストのID一覧が引けたとします(上記コードにおけるtodoService.fetchList()
)。
前回の例と同じく、JSON Graphのtodos
は、todoByIds
に対する参照を返すようにしたいので、ここではget
関数は下記のJSON Grape Envelopeを返却させます。
jsonGraph: {
todos: [
{$type: 'ref', value: ['todoByIds', 'todo1']},
{$type: 'ref', value: ['todoByIds', 'todo2']}
]
}
{$type: 'ref', value: ...}
がJSON Graph上での参照を表すのはもう説明不要ですよね?(何それ!って思うのであれば、第2回の説明を読んでください)
さて、ここからがFalcor Routerが通常のWeb Application FrameworkのRouter(Controller)と異なる点です。
ExpressのRouterもそうですが、一般的なWeb Application FrameworkのRouter(Controller)といえば、1 HTTP リクエスト = 1 メソッドの関係ですが、falcor-routerでは1 HTTPリクエスト = 複数メソッドです。
先ほどの route: 'todos[{interger:indices}]'
のget関数が返却するリストが、参照先の実体であるtodoByIds
のJSON Graphを含んでいないことに注意してください。
ここでFalcor Routerは気を利かせてくれます。 より具体的に言うと、Falcor Routerは「あれ、このままだとクライアントが参照先の値が分かんない状態になっちゃうぞ!」と判断し、続いて route: 'todoByIds[{keys:ids}][{keys:props}]'
のget関数へRoutingします。
あくまでmodel.get('todos[0..9]["text", "completedAt"]')
という1 HTTP リクエストの中で、この処理が行われる訳です。Router君賢い!
route: 'todoByIds[{keys:ids}][{keys:props}]'
は、TODOのidと、"text"
や"completedAt"
の2種類の文字列keyをpathsetsの変数として受け取れるようにしています。
{
route: 'todoByIds[{keys:ids}][{keys:props}]',
get: function getTodoProperty(pathset) {
console.log('get:', pathset);
return Promise.all(pathset.ids.map(id => this.todoService.fetch(id))).then(todos => {
return todos.map(todo => {
var value = {};
pathset.props.forEach(prop => {
value[prop] = todo[prop];
});
return {
path: ['todoByIds', todo.id],
value
};
});
});
},
}
このルーティング関数が完了して初めて、本来のmodel.get('todos[0..9]["text", "completedAt"]')
のJSON Graph レスポンスが完成します。
両方のレスポンスを組み合わせると、HTTPレスポンスの中身は以下のようになります。
{
todoByIds: {
'todo1': {
id: 'todo1',
text: '牛乳を買う',
completedAt: '2016-01-01'
},
'todo2': {
id: 'todo2',
text: 'ATMでお金を引き出す',
completedAt: null
}
},
todos: [
{$type: 'ref', value: ['todoByIds', 'todo1']},
{$type: 'ref', value: ['todoByIds', 'todo2']}
]
}
上記で説明したRouting Flowについては文章で説明されただけだと実感が湧きにくいと思いますので、是非サンプルを手元で動作させた上で開発者ツールのネットワークやNode.jsのコンソールログも眺めながら読んでみてください。
getにおけるRouting Flowの説明は以上なのですが、中まとめとしてFalcorが介在しない世界との比較を考えてみたいと思います。
今回のTODOリストをFalcorを使わずにRESTful Web APIで組んだ場合、サーバサイドのRouterは次のような実装になるでしょう。
-
GET /api/v1/todos
: linkとして個別のTODO idを含める -
GET /api/v1/todos/:todoId
: TODO エンティティが含んでいる全ての値をクライアントに返却する
このようなRESTful Web APIと今回のFalcor版 TodoRouterを比較すると、以下の違いがあることが分かります。
- REST APIではリストに含まれるlinkの処理は、クライアントサイドで別途リクエストを送る必要があったが、JSON GraphとFalcor Routerにより、自動的に1回のHTTPリクエストで処理してくれる
- 「クライアントはViewが必要とする値の項目レベルでリクエストしてくる」というFalcorの思想により、
[{interger:indices}]
や[{key:props}]
が登場する。この結果、ルーティングにおける分解能を細かくする必要がある。
set
続いて、値更新系の処理であるsetについて見ていきます。
...とは言ってみたものの、JSON Graphの仕組みを理解していればsetで語ることは実は殆どありません。
前回のサンプルと同様、model.setValue('todos[1].completedAt', '2016-02-29')
をクライアントコードに記述して実行してみてください。
TODOリストの2番目の要素のcompletedAt
が null
から '2016-02-29'
に変わったことを確認できましたか?
でもよくよくTodoRouter
のコードを見てみてください。どこにもroute: 'todos[{interger:indices}]'
のset関数の記述はありません。
クライアントからのmodel.set(...)
は、以下のようなbodyを伴ったPOSTがリクエストされています。
{
"jsonGraph": {
"todoByIds": {
"todo2": {
"completedAt": "2016-02-29"
}
}
},
"paths": [
["todoByIds", "todo2", "completedAt"]
]
}
そうです、Falcor.Model
(正確にはFalcor.HttpDataSource
)が、{$type: 'ref'}
の参照構造を解決した上で、TODOの実体である'todoByIds.todo2.completedAt'
へのリクエストに変換してくれている為です。
これにより、Falcor Router側では、route: 'todoByIds[{keys:ids}][{keys:props}]'
のset
関数が起動します。
ちなみに set関数は引数/Promiseの戻り値共にJSON Graph Envelopeです。
call
最後にcall について説明します。get/setについては、前回から説明をしてきましたが、callは初登場でしたね。
コードの解説をする前に、callとはどのような処理を実装すべきか、という話から触れておきましょう。
FalcorのDataSource(Http)や、そのデコレータであるFalcor.Modelはget/set/callというJSON Graphへのアクセッサが用意されていますが、その実装ポリシーとして次のような区別があります:
- get: JSON Graphからの値取得. 安全且つ冪等. RESTであればGETメソッド.
- set: JSON Graphの更新. 安全では無いが冪等. RESTであればPUT/PATCH/DELETEメソッド.
- call: JSON Graphに対する任意の関数呼び出し. 安全でも冪等でも無い. RESTであればPOSTメソッド.
「安全」は「0回以上の何度同じ操作をしても状態が変化しない」こと、 「冪等」は「1回以上、何度同じ操作を施しても状態が変化しない」ことを意味します。
今回のTODOリストの例で言うと、「新しいTODOをリストに加える」は安全でも冪等でもありません。
今回のTodoRouter
では、route: 'todos.push'
と call
関数が例として該当します。
{
route: 'todos.push',
call: function pushNewTodo(callpath, args) {
console.log('call todos.push', callpath, args);
return this.todoService.add(args[0]).then(todo => {
return this.todoService.fetchList().then(list => {
return [
{
path: ['todos', 'length'],
value: list.length
},
{
path: ['todos', list.length - 1],
value: {$type: 'ref', value: ['todoByIds', todo.id]}
}
];
});
});
}
}
callはroute
で指定するパス以外に、任意個数の引数を受けとれます。call
関数の第2引数として渡される値です。
call関数は、「この関数呼び出しによって変更をされるJSON Graphの部分集合」をJSON Graph Envelopeとして返却する必要があります。
今回のケースであれば、以下2つのJSON Graph上パスが変化します:
-
todos.length
: TODOリストの長さが変化するため -
todoByIds
への参照: リスト末尾の要素が新しく参照を得るため
作成したtodos.push
という関数をクライアントから呼び出すには以下のように記述します。
model.call('todos.push', [{text: 'サンドイッチを作る'}], ['text', 'completedAt']).then(out)
第1引数, 第2引数はそのまま、callの関数名と関数引数に該当します。
第3引数が若干曲者です。ここにはJSON Graphのパスを指定するのですが、ここで指定したpathと、call関数のレスポンスに含まれるJSON Graph referenceを結合した結果がcallのHTTP レスポンスとしてくれます。
今回の例で言うと、call関数自体はtodoByIds
のrefを返すだけですが、呼び出し側の第3引数に['text', 'completedAt']
が含まれているため、route: 'todoByIds[{keys:ids}][{keys:props}]'
の get
がさらに呼び出され、「リストに新しいTODOが結合された状態のJSON Graph」を返却してくれる、ということになります。
ちなみに、今回のケースでは、第3引数を省略すると、callの戻り値が{$type: 'ref'}
のみとなってしまい、Routerからすると「クライアントがどの値を欲しがっているか分からない」状態となってしまいます。
ここで、前回も説明した「配列全体、オブジェクトに含まれる全ての値のやりとりは許さない」というFalcorのポリシーと照らし合わせると、今回のcall呼び出しでは第3引数を省略するケースはErrorとなってしまうので注意が必要です。
call呼び出しで必ずしも第3引数が省略不可能という訳ではありませんが、気を抜くとエラーになるので気を付けましょう。
Falcor君は結構気難しいのです...
まとめ
第3回の今回は下記について解説しました:
- falcor-express, falcor-router, expressを用いたFalcor endpoint サーバ実装方法の基本
- Falcor RouterのRouting Flow
- get/set/callの実装方法
次回はReactとFalcorを組み合わせる話を書こうかと思います。