new takyam();

Qiitaぽい話はQiitaに書いていくことにする気がする http://qiita.com/takyam

Backbone.Syncをオーバーライドして幸せAjax生活

業務でBackboneをガッツリ使ったり、プライベートでも何か作る時はだいたいBackboneを使うようになりましたので、Tipsを公開。

Backboneを使うメリットはいくつかあるんですが、
ひとつ大きなメリットとして、APIの設計を統一できる点だと考えてます。

BackboneのAjax関連のメソッド

Backboneでは、Ajaxで通信するためのメソッドとして以下を持ってます。

  • Model
    • save() : 保存 (CREATE / UPDATE)
    • fetch() : 取得 (GET)
    • destroy() : 削除 (DELETE)
  • Collection
    • fetch() : 取得 (GET)

それぞれリクエストのフォーマットは決まっていて、
それに則ったかたちでAPIを作成すれば良いので、
API設計で悩んだり、変なかたちにならなくて良いのです。

BackboneのAjax関連のメソッドのリクエストフォーマット

簡単に説明すると以下のような感じです。

Model.save()

Model.save() は、ModelがIDを持っているかどうかでリクエスト内容が変わります。

IDが無い場合 (新規作成の場合)

  • リクエスト先: modelのurlRootに指定されているURL
  • メソッド: POST
  • パラメータ:モデルのattributes
  • 期待されるレスポンス:モデルのattributesと同じ形式のObject

IDが設定されている場合 (更新の場合)

  • リクエスト先:modelのurlRootに指定されているURL + ModelのID
  • メソッド: PUT
  • パラメータ:モデルのattributes
  • 期待されるレスポンス:モデルのattributesと同じ形式のObject

Model.fetch()

Model.fetch()は、ModelがIDを持っている必要があります。
まぁ無くてもリクエストは出来ると思うんですが、
付けないと何を返せばいいのかサーバーサイドが判断できないと思うんで。

  • リクエスト先:modelのurlRootに指定されているURL + ModelのID
  • メソッド:GET
  • パラメータ:無し
  • 期待されるレスポンス:モデルのattributesと同じ形式のObject

Model.destroy()

Model.destroy()も、モデルのIDの有無で挙動が変わります。

IDが無いばあい

リクエストが発生しません。モデルが消えるだけです。

IDが設定されているばあい

  • リクエスト先:modelのurlRootに指定されているURL + ModelのID
  • メソッド:DELETE
  • パラメータ:無し
  • 期待されるレスポンス:特に無し。200が返ればOK。

Collection.fetch()

  • リクエスト先:collectionのurlに指定されているURL
  • メソッド:GET
  • パラメータ:無し
  • 期待されるレスポンス:モデルのattributesと同じ形式のObjectが複数入った(Array)

ポイントは「配列」なことで「オブジェクト」を返すとパースしてくれないので注意。
PHPとかで配列をjson_encode()で返そうとした時に、配列のキーが0から始まる連番じゃないと配列じゃなくてオブジェクトになってしまうので気をつけてね。

<?php
$models = array(
    1 => array(
        'id' => 1,
        'name' => 'taro',
    ),
    2 => array(
        'id' => 2,
        'name' => 'taro',
    ),
);
echo json_encode($models);
//=> {"1":{"id":1,"name":"taro"},"2":{"id":2,"name":"taro"}}
// 配列じゃなくてObjectになっちゃった!

echo json_encode(array_merge($models));
//=> [{"id":1,"name":"taro"},{"id":2,"name":"taro"}]
// array_merge()でキーをリセットするといい感じに配列になる

これらのリクエストのフォーマットも上書きしたりして変更する事は出来るんですが、標準で提供されてるのは上記のような感じです。

Backbone.sync

で、これらのメソッドの中身なんですが、各メソッドから、Model/Collectionそれぞれのsync()メソッドを経由して、最終的に、Backbone.sync()関数で処理されます。

Backbone.sync()は、Backboneの中でも特別な関数で、上書きされる事を特に強く想定しており、ドキュメントには以下のように書いてあります。

Backbone.sync is the function that Backbone calls every time it attempts to read or save a model to the server. By default, it uses jQuery.ajax to make a RESTful JSON request and returns a jqXHR. You can override it in order to use a different persistence strategy, such as WebSockets, XML transport, or Local Storage.

The method signature of Backbone.sync is sync(method, model, [options])

超意訳すると

Backbone.sync()は、サーバーに読み込みや保存のためにリクエストを送ることをやろうとする時に、Backboneから毎回呼ばれます。標準ではjQuery.ajaxを利用してRESTfulなJSONリクエストをなげ、jqXHRを返します。Websocketによる永続的接続や、XMLでのやりとりや、ローカルストレージを利用したい時には上書きする事ができます
メソッドの形式は sync(method, mode, [options]) です

というわけで、何かったらすぐにオーバーライドすればOKっす。

どういう時にオーバーライドする?

例にも書いてあるように、Websocketを利用する時なんかは、jQuery.ajaxを利用できないので、必然的に上書きしてあげる必要があります。

その他によく有りそうなパターンとしては、save()を叩いた時にサーバーサイドでバリデーションを実行して、バリデーションの結果エラーがあった場合なんかは、レスポンスのJSON内にエラー内容を含めて返したかったりすると思うんですが、デフォルトだと特に対応してないので、それを受け取れるようにする、とかいった時に使います。

それぞれ簡単なサンプルをあげます。

サンプルその1: Websocketの場合

CoffeeScript+SocketStreamの例なのでわかりにくいですが、以下のように上書きします。

#特に使うわけでは無いんですがとりあえず元のsyncをとっておきます
Backbone._sync = Backbone.sync

#Backbone.syncをオーバーライドします
Backbone.sync = (method, model, options)=>
  #Websocketの場合、リクエスト先のURLというのは存在しませんが、options.urlをリクエスト内容を識別するキーとして利用します  
  url = if options?.url? then _.result(options, 'url') else _.result(model, 'url')
  
  #モデルが引数に渡ってきてる場合(Model.save()/.fetch()/.destroy()の場合)
  if model instanceof Backbone.Model  
    url = url.replace(/\.$/, '') + '.' + method
    data = model.attributes
  else
    data = {}
  
  #Websocketでサーバーに送るデータを適当に整形します
  data = _.extend(data, options.data) if options?.data?
  
  #SocketStreamの関数にss.rpcってのがあって、これでWebsocketでサーバー側にリクエストできます
  #Websocketのライブラリに合わせて良い感じの関数を使ってみてください
  ss.rpc url, data, (error, res)=>
    #レスポンスでエラーがあって、かつ、options.errorが設定されていれば実行する
    if error isnt null
      options.error(error, res) if options?.error? and typeof(options.error) is 'function'
    #レスポンスにエラーがなく、options.successが設定されてれば実行する
    else
      options.success(res) if options?.success? and typeof(options.success) is 'function'

    #レスポンスの成否に関わらず、options.completeが設定されていれば実行する
    options.complete(error, model, res) if options?.complete? and typeof(options.complete) is 'function'
  return true

だいたいこんな感じ。

fetch()とかsave()の中で、
options.successが自動的に定義されたりするんで、
かならずレスポンス時にoptions.success()を実行するようにする必要があります。

サンプルその2:エラーをJSONからObjectに変換してxhrを返す

希望しては、以下の例のようにerror: (xhr, options)内で、エラー内容をObjectとして取得できるようにしたい感じです。レスポンスコードが200番代じゃなかったらerror()が実行されるので、レスポンスコードを409 Conflictとかで返すのがイケてるとかイケてないとか。

model.save({},{
  error: (xhr)=>
    if xhr?.response?.errors?
      for error, message of response.errors
        console.log(error, message)
})

ようするに、元々存在しないxhr.responseに、レスポンスとして受け取ったJSONをObjectに変換して突っ込んじゃおうって話です。xhr.responseTextってのに、レスポンスとして受け取ったJSON文字列が格納されているので、これをObjectに変換するだけの簡単なお仕事です。

なので、毎回error()の中でパースしちゃえばいいはなしなんですが、そんなのキモいよね!ってことで。

Backbone._sync = Backbone.sync #元のBackbone.syncを保持しておきます
Backbone.sync = (method, model, options)=>
  #ex_options として受け取った options.error を拡張します
  ex_options = _(options).clone()
  #options.errorが設定されている場合は上書きしにいく
  if options?.error? and typeof(options.error) is 'function'
    ex_options.error = (xhr)->
      #JSON以外は無視
      if xhr?.getResponseHeader? and xhr.getResponseHeader('content-type').match(/^(application\/json|text\/javascript)/)
        #parseJSON()する時に死ぬ事あるんでtry-catchしておきます
        try
          if !(xhr.response?)
            if Backbone.$?.parseJSON? #jQuery.parseJSON()があればソレで
              xhr.response = Backbone.$.parseJSON(xhr.responseText)
            else if JSON?.parse? #JSON.parse()があればソレで
              xhr.response = JSON.parse(xhr.responseText)
        catch error
      #.responseを付与したxhrで元のerror()を実行
      options.error(xhr, ex_options)

  #元のBackbone.syncを、optionsを拡張したex_optionsに変えて実行します
  return Backbone._sync(method, model, ex_options)

まとめ

すごい標準的な拡張としては2番めのエラーの拡張のような感じで、第3引数のoptionsを拡張してあげる感じが簡単なんじゃないかなと思います。

どのモデルでもレスポンス時に共通で処理したいもの何かがあれば、Backbone.syncを上書きしにいくのが、一番楽チンではないかなと思います。

その他にもBackbone.Model.prototype.toJSONBackbone.Model.prototype.destroyを拡張して、CSRF対策用のワンタイムトークンをリクエストパラメータに自動で挿入したりするような拡張もオススメです。

Backboneはよく言われる事ですが、ソースが非常に簡潔で量も少ないので、拡張しようと思ったらソース読むのが一番早いです。

当然ながら、拡張になるので、Backbone自体のアップデートで、それまでの拡張が動作しなくなる事もありますので、その点だけは注意してください。