前回はショッピングカートを作成しつつ、Ember.jsの汎用的な機能を解説しました。今回は、Ember.jsの永続化層を扱うためのライブラリであるEmber Dataを解説します。
Ember DataはEmber.jsとは別のライブラリですが、Ember.jsのコアチームによって開発されている公式ライブラリです。ただ、APIがまだ安定しきっておらず今後変更される可能性があるため本稿執筆時点ではベータ版という位置付けです。
まだ発展途上のライブラリですが、サーバのデータをクライアントサイトで変更して保存する場合や既存のAPIに対してのクライアントをEmber.jsで作成する場合に大活躍するため、本連載でも取り扱うことにします[1]。
環境準備
まずは環境準備です。前回の記事を参考に必要なファイルを準備してください。また、今回はこれに加えてEmber Dataを読み込む必要があります。Ember.js公式サイトのベータ版のダウンロードページからEmber Dataをダウンロードして、HTMLに読み込み用のscriptタグをを追加します。
本稿での対象バージョンはこちらです。
準備ができたらHTMLをブラウザで表示してください。ブラウザの開発者コンソールにEmber Dataのバージョンが表示されていれば準備完了です。
それでは実際にEmber Dataに触れてみましょう。
データを扱う
Ember Dataでデータを扱う際はDS.Model
を継承したクラスを作成します。まずはブログ記事を表すPost
と、記事に対してのコメントを表すComment
を作成しましょう。
データとモデルの対応付け
データと対応させたいプロパティにはDS.attr()
を指定します。こうすることで、データとモデルのプロパティがマッピングが必要であることをEmber Dataに知らせています。
型変換
DS.attr()
の引数に形名を指定すると、JSONとして渡ってくるデータをモデルにマッピングする際に自動で型変換が行われます。利用可能な値は次の4つです[2]。
string
number
boolean
date
特にJSONで表現できない日付(Date
)型を扱いたい場合はdate
を指定してください。データの型変換が不要な場合(特に、配列やオブジェクトを扱いたい場合)には引数は省略できます。引数を省略した場合、型変換は行われずデータとして渡された値がそのままモデルのプロパティに設定されます。
データの形式
この例では、データは次のような形式であることを想定しています。
このデータのプロパティのうち"id"
は自動でマッピングされるため、それ以外のプロパティにDS.attr()
を指定します。
また、モデルを作成する際にはモデル同士の関連を指定できます。今回の例ではDS.hasMany()
とDS.belongsTo()
を使って一対多の関連を定義しました。DS.hasMany()
/DS.belongsTo()
の引数には、モデル名を与えます。モデル名はモデルのクラスを小文字にしてハイフン区切りにしたものです。
クラス
|
モデル名
|
Post
|
post
|
Comment
|
comment
|
TopicCategory
|
topic-category
|
このモデル名は、関連の指定以外にもあらゆる場面で利用します。
次はデータを読み込む部分を解説します。
Ember Dataでは、REST APIなどから取得したデータはモデルにマッピングされDS.Store
によって管理されます。DS.Store
はクライアントサイドのデータベースのようなもので、取得したデータはこのストアにキャッシュされます。
外部サーバからデータを取得する方法は後述するので、まずここでは手元でデータを用意する方法を解説します[3]。
では、DS.Store
のインスタンスにアクセスする方法を説明します。Ember DataはController
とRoute
にstore
というプロパティを定義し、ここにDS.Store
のインスタンスを設定します。これにより、Controller
とRoute
の中ではthis.store
でストアにアクセスできます。
そしてストアにデータを読み込むにはストアのpush()
メソッドを利用します。push()
メソッドの第一引数はモデル名、第二引数はデータの本体です。
データが関連を持っている場合、関連名をキーとして関連先データのIDを指定します。
さて、これで準備完了です。画面に表示して動作を確認してみましょう。
ルーティングを作成します。
ここではストア経由でモデルを取得しています。モデルを取得するにはいくつかの方法があります。
DS.Store#all()
ストアに読み込み済みのモデル全件を取得します。第一引数にはモデル名を指定します。
DS.Store#find()
モデル名とIDを指定してモデルを取得します。もし該当するモデルがストアに存在しなければ、ストアはサーバに対してHTTPリクエストを発行してデータを取得します[4]。ここまでのサンプルではサーバを用意していないのでリクエストは失敗します。
次はテンプレートを作成します。
この状態でブラウザを表示すると、コメントがブログ記事に関連付けられているのが確認できます。
REST APIを扱う
ここまででストアに読み込んだデータを扱うことができるようになりました。ここからはREST API経由でデータの取得とデータの保存を解説します。
今までデータを準備していたApp.ApplicationRoute
は不要になるので、クラスごと削除してください。
そして、PostsRoute
もmodel()
メソッドではall()
メソッドの代わりにfind()
メソッドを利用します。
これで、必要に応じてデータを取得するように設定されました。
しかし、まだデータを保存するサーバを準備していません。サーバの実装も解説したいところですが、それは「実践入門 Ember.js」の枠をはるかに超えてしまうため今回は割愛させてください。動作するサンプルのAPIサーバを用意したので、今回の記事ではこのサーバを使うよう設定します[5]。
APIサーバのURLは次のとおりです。
それでは、引き続きこのAPIサーバを使うための設定を解説します。
Ember Dataでデータの取得元を指定するためにはDS.Adapter
を利用します。Ember DataにはDS.Adapter
のサブクラスがいくつか用意されていて、デフォルトではREST APIをデータの取得元として扱うDS.RESTAdapter
が利用されます[6]。
DS.RESTAdapter
は、以下の規則に沿ってHTTPリクエストを発行します。
メソッド
|
URL
|
store.find('post');
|
/posts
|
store.find('post', 1);
|
/posts/1
|
store.find('post', {page: 2});
|
/posts?page=2
|
この規則はカスタマイズ可能ですが、なるべくこの規則に沿ってAPIを実装するほうが手間が少なくなります。
では先ほど紹介したAPIサーバを利用するための設定を行います。
すべてのモデルに共通するアダプタはApplicationAdapter
という名前で定義します。もしPost
やComment
専用のアダプタを作成したい場合は、PostAdapter
やCommentAdapter
といった「モデルのクラス名+Adapter」というクラス名のアダプタを定義してください。
ではここで登場した設定項目を説明します。
host
APIサーバのスキームとホスト名を指定します。指定しない場合は現在のURL(window.location.origin
)が利用されます。
namespace
APIの名前空間を指定します。例えばnamespace: 'v1'
が設定されていると、/posts/1
というURLの代わりに/v1/posts/1
に対してリクエストが発行されます。今回利用するAPIサーバではいろいろな形式のJSONを扱う例を紹介したいので、各セクション毎に別のnamespace
を用意しました。
これで、サンプルサーバを利用する設定が完了しました。
次はAPIサーバが返すべきJSONの形について考えてみましょう。
JSONに含まれるデータとモデルのマッピングにはDS.Serializer
を使います。デフォルトでは、DS.RESTSerializer
というREST APIに特化したシリアライザが利用されます。
このDS.RESTSerializer
をカスタマイズするとクライアントサイドで自由なマッピングを指定できますが、まずはカスタマイズをせずにこのシリアライザが期待する形のJSONを準備することにします。
ブログ記事一覧で返すべきJSONは次のような形になります。
ポイントは次の2点です。
- JSONのルートのキーはモデル名を使う(オブジェクトが複数あればモデル名の複数形を指定する)
- 関連するデータがあればレスポンスに含める(今回の例では
/posts
へのリクエストに対してcomments
をレスポンスに含めている)
これを満たすことで、DS.RESTSerializer
がレスポンスのJSONをモデルにマッピングしてくれます。
また、記事詳細で返すべきJSONの形は以下のとおりです。
記事一覧のJSONとの違いは、全件の記事ではなく一件の記事を対象としたものになったことです。JSONのルートのキーも"posts"から"post"になっています。
postとcommentsのような関連するリソースを扱う場合、サーバでこの形式のJSONを出力するのが困難な場合があります。例えば、次のような場合です。
- JSON生成に利用するライブラリの都合上、"post"と"comments"を並列に並べるのが困難である
- commentsの数が膨大で全部をpostのレスポンスに含めるとパフォーマンスが劣化する
こういった場合に対応するため、ここからはこの形式以外のJSONを扱う方法を紹介します。
子リソースを親リソースに埋め込む
ここでは、次のようにpostsの中にcommentsが埋め込まれている場合のJSONの扱い方を解説します。
まずはアダプタの設定を変更しましょう。
この形のJSONから子リソースを取得したい場合、DS.EmbeddedRecordsMixin
を利用します。
attrs
attrs
はDS.JSONSerializer
(DS.RESTSerializer
の親クラス)で提供されているプロパティです。ここではデータとモデルのプロパティの対応を設定できます。
embedded
DS.EmbeddedRecordsMixin
を使うと、attrs
でデータの取得時と送信時での子リソースの扱い方を指定きます(データ送信時の振る舞いについては後述)。ここで指定している{embedded: 'always'}
というのは、「データ送信時・取得時ともに子リソースを埋め込む」ということを意味します。
非同期で子リソースを取得する(ID参照)
さて、次は子リソースを必要になったタイミングで取得する方法を解説します。JSONには子リソースのIDが含まれますが、実体は含まれていません。
まずはアダプタの設定を変更しましょう。
「子リソースを親リソースの埋め込む」で作成したPostSerializer
は不要なので削除します。
そして、関連に非同期であることを示すフラグを設定します。
では、この状態で実際に動かしてみましょう。
記事詳細を表示すると、少し遅れてコメントが表示されるのが確認できます。
実際にどのタイミングでコメントが取得されているのかというと、post
テンプレート中でコメントが参照されたタイミングです。
ではここで、開発者ツールを利用してこのときのHTTPリクエストを確認してみましょう。
コメントの数だけHTTPリクエストが発行されています。もしコメントが大量に存在する場合コメントの数だけHTTPリクエストが発行されるため、表示までに時間がかる場合があります。これを避けるためには、以下のオプションを設定して一度に必要なコメントすべてを取得するようにします。
コメントを取得するURLに、ids
というパラメータ付きでリクエストが発生するようになりました。このAPIサーバがこの形式に対応していればこちらの方がリクエスト数を減らせます。
非同期で子リソースを取得する(URL参照)
先ほどと同じく、子リソースが必要になったタイミングで取得するという方法ですが、先ほどのID指定の方法とは違って子リソースを表すURLを指定する方法を解説します。
links
というプロパティを使うと、子リソースを取得するURLを指定できます。JSONは以下の形になります。
ではこのJSONを扱うためにアダプタを設定しましょう。
この状態で、どのようなリクエストが発行されているか開発者ツールで確認してみましょう。
links
プロパティで指定したURLに対してリクエストが発行されています。
子リソースを扱ういくつかの方法を紹介しましたが、どの方法が良い/悪いというのはありません。アプリケーション毎に適切だと考えられる方法を選択してください。
CRUD を扱う
ここまではデータを取得方法を紹介してきましたが、このセクションではデータの作成と削除を紹介します。
まずはアダプタを設定します[7]。
JSONの形式はv1と同じものを使っているため、関連の非同期フラグは外します。
次は記事詳細画面からコメントの投稿/削除のUIを準備します。
記事詳細画面に対応するコントローラを作成して、コメントの投稿/削除に対応します。
新しく登場した記述を解説します。
createRecord()
モデルのオブジェクトを生成するメソッドです。ここではpost
の関連であるcomments
に対してcreateRecord()
メソッドを呼び出しているため、記事への関連を保持した状態のコメントを生成します。この状態ではまだサーバに保存はされません。関連を元にせずオブジェクトを生成したい場合はstore.createRecord()
を利用します。この場合、第一引数はモデル名、第二引数は初期値を指定します。
save()
モデルをサーバに保存します。クライアントサイドで生成されたモデルであれば新規保存され、サーバから取得してきたモデルであれば更新されます。サーバに送信されるデータはシリアライザによって決定されます。関連するリソースも一緒にサーバに送信したいという場合は前述のDS.EmbeddedRecordsMixin
を使います。サーバに送信されるデータをあらかじめ確認したい場合は、モデルのserialize()
メソッドを利用してデータを取得します。また、save()
メソッドはPromiseを返します。保存に成否を待ってから実行したい処理がある場合、Promiseのコールバックを利用してください。
unloadRecord()
クライアントサイドのモデルを破棄します。クライアントでモデルが不要になった際に呼び出します。
destroyRecord()
モデルをサーバから削除します。似たような機能を持つdeleteRecord()
メソッドもあります。前者はすぐにサーバのデータも削除されるのに対し、後者はsave()
メソッドを呼ぶまでサーバのデータは削除されないという違いがあります。
これらのメソッドは、HTTPリクエストを発行してサーバのリソースを更新します。意図した通り動作させるためには、対応するAPIをサーバでサポートしている必要があります。
メソッドとAPIの対応は次のとおりです。
メソッド
|
URL
|
リクエストメソッド
|
comment.save() (新規)
|
/comments/
|
POST
|
comment.save() (更新)
|
/comments/1
|
PUT
|
comment.destroyRecord()
|
/comments/1
|
DELETE
|
動作するサンプルはこちらです。
ここまでは、Ember DataのDS.RESTAdapter
/DS.RESTSerializer
が期待する形のAPIサーバを前提として解説をしてきました。APIサーバを実装しつつアプリケーションを開発する際にはここまでで紹介した方法で十分でしょう。
外部のAPIを扱う
せっかくなので、既存のAPIサーバに対してEmber Dataを使ってクライアントアプリを作る例も紹介します。
サンプルとして「GitHubのユーザ名を検索すると、そのユーザが最近starをつけたリポジトリを見られる」アプリを作ってみましょう。完成イメージは次のとおりです。
GitHubでのログイン名を入力してエンターキーを押します。
ユーザの最近のお気に入り一覧が表示されます。
今回対象とするAPIはGitHub APIです。
まずはこのAPIを使うようアダプタを設定します。
今回はユーザとリポジトリさえあれば十分なので、以下のドキュメントを参考にモデルを定義します。
次は、GitHub APIのレスポンスを上手くモデルにマッピングするために、シリアライザを定義します。
細かく見ていきましょう。
primaryKey
プライマリーキーとして扱うプロパティを指定します。デフォルトはid
です。GitHub APIではユーザのJSONにid
が含まれているのですが、今回のアプリケーションではログイン名をキーにしてユーザを取得するため、モデルを簡単に扱うためにlogin
プロパティをIDとみなすことにします。
normalizePayload
APIから取得したJSONをEmber Dataで扱う形に変換するためのメソッドです。引数にはサーバから取得したJSONがそのまま渡ってくるので、先ほど紹介した形に加工して戻り値に指定します。デフォルトの実装では引数で渡されたJSONをそのまま返すようになっています。
ここまででGitHub APIを扱う準備が完成しました。あとはルーティングとテンプレートを作成して実際に動かしてみましょう。
ルーティングは以下のとおりです。
テンプレートは次のとおりです。
このように、モデルを定義した上でシリアライザを適切に設定すると、既存のAPIに対するクライアントを簡単に作成できます。
まとめ
今回はEmber Dataについて解説しました。Ember Dataには今回解説したもの以外にもいつくかの部品が存在します。さらに詳しい説明は公式ドキュメントを参照してください。
次回は、開発およびデプロイには欠かせないビルドツールであるEmber CLIとember-railsを解説する予定です。