ブラウザから使える O/R マッパ、 js-data を使ってみる

ヘルスケア事業部の濱田です。花粉がつらい時期ですが、みなさん楽しく開発してますか?

おいしい健康では、JavaScript(以下 JS)で非同期にサーバ側のリソース操作を行う際に、js-data というライブラリを使っています。Rails ユーザにとってはとっつきやすい便利なライブラリですが、日本語での情報がほとんど見当たらなかったため、簡単にご紹介したいと思います。

js-data とは

JS 製のデータ管理用ライブラリです。RESTful API などを通じて取得できるデータ(リソース)を抽象化して、CRUD 操作を統一したインターフェースで行えるようにしてくれます。 リソースごとにオブジェクト(モデル)を定義したり、find でデータを取ってくるなど、ActiveRecord などの O/R マッパのような使い勝手が特徴。設計等には Ember data の影響を受けています。 以下で、基本的な使い方を見てみましょう。

リソースとデータストアの定義

リソースとは、ActiveRecord で言えばひとつひとつのモデルにあたる JS オブジェクトで、以下のような機能を持っています。

  • リソース名、対応する API のエンドポイントの URL などのメタデータ管理
  • リソースに対する CRUD 用インターフェースの提供

API などを通じて読み込まれたデータは一旦データストアと呼ばれるオンメモリキャッシュに格納されます。 一度読み込んだデータはストア内から呼び出し、Update, Delete などの更新系操作のタイミングでサーバにデータを書き戻すというわけです。

例 - Book リソース

例として、一冊の本を表す Book リソースを定義してみましょう。

// データストアを作成
var store = new JSData.DS();

// 元データの参照元として HTTP (Web API) を指定する
store.registerAdapter('http', new DSHttpAdapter(), { default: true });

// データストア上に Book リソースを定義
var Book = store.defineResource({
  name: 'book',  // リソース名
  endpoint: 'books'  // 対応する API のエンドポイント
});

RESTful API の準備

上記のように定義したリソースオブジェクトを使うには、操作を実際に行うための Web API も用意してやる必要があります。リソース定義には endpoint という API のエンドポイントの情報しか含まれていませんが、その URL に json オブジェクトを返却する RESTful な API を定義しておけば、js-data が自動的に叩いてくれるようになっています。

定義しておく操作

以下のような API が存在することが期待されています。必ずしも全部実装しておく必要はありません。使用したいものだけで OK です。

operation URL HTTP method Description
Create /<resource> POST 新しいアイテムの作成。プロパティ({ title: '眠る盃' })を受け取り、作成したアイテムのデータを返す({ id: 1, name: '眠る盃' })。
Read /<resource>/:id GET 既存のアイテムの取得。 { id: 1, title: "眠る盃" }
Update /<resource>/:id PUT 既存のアイテムの更新。プロパティ({ title: '父の詫び状' }など)を受け取り、更新したアイテムを返す({ id: 1, title: '父の詫び状' })。
Delete /<resource>/:id DELETE 既存のアイテムの削除。
Read All /<resource> GET 指定された条件でリソースを検索し、配列で返す([{ id: 1, title: '父の詫び状' }, { id: 1, title: "眠る盃" }])。検索条件は GET パラメータとして指定。
Update All /<resource> PUT 指定された条件で検索された複数のリソースを一括更新。更新されたリソースの配列を返す。
Delete All /<resource> DELETE 指定された条件で検索された複数のリソースを一括削除。

リソースの CRUD インターフェイス

データストア/リソースの定義、対応する API の実装を終えれば、実際にリソースの操作を行うことができるようになります。

// Read
var book = null;
Book.find(1).then(function (data) {
  book = data;
  book; // { id: 1, title: "眠る盃", publishedIn: "1979" }
});

// Create
var book = null;
Book.create({
 title: "父の詫び状",
 publishedIn: "1988"
}).then(function (data) {
  book = data;
  book; // { id: 2, name: "父の詫び状", publishedIn: "1988" }
});

// Update
Book.update(2, {
  publishedIn: "1978"
}).then(function (data) {
  data; // { id: 2, name: "父の詫び状", publishedIn: "1978" }
});

// Read All
var books = [];
Book.findAll({
  // 検索条件の指定。
  // get パラメータの "?where=..." に JSON 文字列として埋め込まれるので、
  // サーバではこれをパースして配列にした後、検索条件に使用する
  where: {
    publishedIn: "1979"
  }
}).then(function (data) {
  books = data; // 配列が返る。[{ id: 1, title: "眠る盃", publishedIn: "1979" }]
});

ポイント

非同期なメソッドは Promise を返す

API コールを伴う操作は非同期に行われ、Promise パターンを使ってメソッドチェインしながらデータを受け取ることになります。

データの指定をするシンタクスがある

findAlldestroyAll など、複数のデータを操作するメソッドにはデータを指定する独自のクエリシンタクスが用意されています。whereorderBy など、SQL を使ったことがある人なら馴染みやすいものばかりでしょう。ただし、このクエリを受け付けるための実装をサーバに加える必要があるので、使わないという選択肢も可能です。 このシンタクスは、データストアの中にあるデータのみを対象に検索を行う Resource.filter メソッドなどでも共通で使えます。

2度目以降の取得は直接データストアから

一度読み込んだデータはデータストアにキャッシュされています。find を複数回行っても、API コールが発生するのは最初の一度で済むので効率的ですね。何らかの理由で強制的にデータを読み込み直したいときは、以下のようにオプションを渡せば OK です。

book; // { id: 1, title: "眠る盃" }
Book.find(1, {
  bypassCache: true  // このオプション指定でキャッシュを使わずリソースを読み込み直す
}).then(function (data) {
  data; // { id: 1, title: "眠る盃" }
})

リレーションの定義

リソース同士の間に関連を定義して、データストア内でひも付けておくことができます。

例 - Author リソース

例として先ほどの Book に対する Author リソースを定義し、関連を定義してみましょう。

// データストア上に Author リソースを定義
Author = store.defineResource({
  name: 'author',
  endpoint: 'authors',
  relations: {
    hasMany: {
      book: {
        // author.books でアクセス
        localField: 'books',
        // book リソースにおける、author を参照するための外部キー相当のプロパティを指定
        foreignKey: 'authorId'
      }
    }
  }
});

// Books リソースの定義を変更
Book = store.defineResource({
  name: 'book',
  endpoint: 'books',
  relations: {
    belongsTo: {
      author: {
        // book.author でアクセス
        localField: 'author',
        // book リソースにおける、author を参照するための外部キー相当のプロパティを指定
        localKey: 'authorId'
      }
    }
  }
});

defineResource を呼ぶときに、リレーション用のオプションを渡すだけです。データストアに入ったオブジェクトは、localKey/foreignKey をもとに関連付けられ、localField に設定した名前でアクセスできるようになります。

関連リソースの操作

関連を定義したリソースを実際に操作してみましょう。

authorResponse = {
  name: "向田邦子",
  birthDate: "1929-11-28",

  books: [
    { id: 1, authorId: 1, title: "眠る盃", publishedIn: "1979" },
    { id: 2, authorId: 1, title: "父の詫び状", publishedIn: "1978" }
  ]
}

// inject はデータストアへ直接リソースを挿入するメソッド
// (API 経由の create は行われない)
author = Author.inject(authorResponse); // { name: "向田邦子", birthDate: "1929-11-28" }
author.books; // [{ id: 1, authorId: 1, name: "眠る盃", publishedIn: "1979" }, { id: 2, ... }]

book = null;
// すでにデータストアに挿入されているので、API コールは起こらない。 { id: 1, authorId: 1, name: "眠る盃", publishedIn: "1979" }
Book.find(2).then(function (data) { book = data });

Book.create({
  authorId: author.id,
  title: "阿修羅のごとく",
  publishedIn: "1979"
}).then(function(data){
  // 関連リソースを呼び出すと、新しいインスタンスも含まれている
  author.books; // [{ id: 1, ...}, { id: 2, ... }, { id: 3, name: "阿修羅のごとく", ...}]
});

ポイント

API は関連データを一気に返却できる

上記の例でお気づきだと思いますが、レスポンスに関連モデルのデータを含めておけば、自動的にデータストアへの登録も行われます。この場合、author.books のアクセスをした際にも API コールは行われず、データストア内のデータが返ってきます。関連データをネストして返しても js-data のリソースとして扱われるので、おしゃべりな API になるのを防ぎつつ、js-data インタフェイスの恩恵をうけることができます。

関連リソースの変更は自動で反映される

新しい子データの作成/削除などでデータストア内の関連リソースの値が変更されると、すぐに反映されます。上記の例では、Book リソースが作成されたあと Author リソースからは単に .books とアクセスするだけで新しい Book リソースが見えるようになります。

イベント

データの更新を検知するためのイベント通知の仕組みが用意されています。リソースのライフサイクルそれぞれのタイミングで違ったイベントが通知されます。

リスナの登録/解除などのインターフェイスは EventEmitter と同一で、onoffemit が用意されています。

新しいデータがデータストアに挿入されたときに通知を受けて何か処理を実行する例を書いてみましょう。

notifyNewBookTitle = function (resource, instance){
  console.log("新しい本が追加されました:" + instance.title)
}

// DS.afterInject はデータストアに新しいインスタンスが入った時に呼ばれる
Book.on("DS.afterInject", notifyNewBookTitle);

Book.create({
  authorId: 1,
  title: "思い出トランプ",
  publishedIn: "1980"
});

// => '新しい本が追加されました: "思い出トランプ"'

Book.off("DS.afterInject", notifyNewBookTitle);

Book.create({
  authorId: 1,
  title: "あ・うん",
  publishedIn: "1981"
});

// => ''

その他便利な機能

ここでは紹介しきれませんでしたが、以下のような機能も実現されています。

  • リソースのバリデーション
  • Computed property
    • データ更新後一度だけ値を計算して保持しておく機能
  • localStorge 上にあるデータなど、HTTP ベースでないリソースへのアクセス

注意 - 検索性の悪さ

js-data は素晴らしいライブラリですが、少々一般的すぎる名前のせいか、検索エンジンで情報を探すのに苦労します。 ライブラリ名で検索しても、公式ページの Google での検索順位は 4 位。その他にひっかかってくるページもJavaScript で HTML5 の data 属性を操作する方法のページが多かったりと、なかなか思うように情報を集められません。

問題の解決策や情報がほしい時は、公式ドキュメント(とてもよくまとまっています)や Github issuesStack Overflow などを直接検索すると良いと思います。公式の Slack チャンネルもありますので、ここで開発者に直接尋ねてみるのも手です。

おわりに

Redux などの Flux フレームワークを導入するまでもないんだけど、React.js と組み合わせてデータのアクセスの部分だけ抽象化してくれるライブラリはないかな……。ということで調査したのが、js-data を見つけるきっかけでした。 自分以外の特定のフレームワークに依存していないので、React.js で管理されたコンポーネントとそれ以外のコンポーネント、両方でこのライブラリをモデル層として使用することもできます。

名前で損をしている感じがあるのですが、なかなか使いやすいやつです。この機会にぜひ触ってみてください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/