CakePHPでRESTful APIを作って、Backbone.jsのデータの永続化をサーバサイドで行う
CakePHPでRESTful APIを作って、Backbone.jsのデータの永続化をサーバサイドで行う方法をメモしておきます。RESTful APIは、FuelPHP等、その他のPHPフレームワーク、Ruby on Rails等でも簡単に作成することができますので、各々好きなものを選択してください。ここでは、CakePHPを使った例を示します。
ここで制作したアプリケーションは、以下よりダウンロードできます。
Backbone ToDos with CakePHP RESTful API – GitHub
開発環境
開発環境は以下の通りです。それぞれ執筆時点での最新バージョンを用いました。
ソフトウェア | バージョン |
---|---|
Apache | 2.2.25 |
PHP | 5.4.19 |
MySQL | 5.5.33 |
CakePHP | 2.3.9 |
Backbone.js | 1.0.0 |
基本方針
目的を単純化するため、Backbone.jsのアプリケーションは、自作せずに、パッケージされているToDoアプリを利用します。サンプルアプリケーションでは、localStorageを利用してデータの永続化を行っていますが、これを変更して、サーバサイドでデータの永続化を行うようにします。
また、CakePHPで、Backboneアプリケーションを出力する等も、ここでは行いません。CakePHPは、あくまでRESTful APIを構築することのみに利用します。
ベースアプリのソースコード
jashkenas/backbone/examples – GitHub
ベースアプリのデモ
Backbone.jsの経験が浅い方は、以下に勉強のロードマップを提案しておりますので、参考にしてみてください。
Backbone.js入門 – 初学者の為のロードマップ
構成
以下の通りにファイルを配置しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 | /backbone /backbone.js /index.html /jquery.js /todos.js /underscore.js ... /cakephp /app /lib /plugins /vendors ... |
Backbone ToDosへは、 http://localhost/backbone/でアクセスできるものとし、RESTful APIへは、 http://localhost/cakephp/todosでアクセスできるものとします。
CakePHPでRESTful APIを作る
CakePHPでは、RESTful APIを簡単に作ることができます。その方法は、Cookbookにも示されています。
REST — CakePHP Cookbook v2.x documentation
それでは早速見ていきましょう。
スキーマ
スキーマは以下の通りです。テーブル1つのシンプルな構成です。
1 2 3 4 5 6 7 8 9 | CREATE TABLE `todos` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `title` VARCHAR(45) NOT NULL, `order` INT(11) NOT NULL, `done` TINYINT(1) NOT NULL, `created` DATETIME NOT NULL, `modified` DATETIME NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; |
ルーター
app/Config/routes.phpに以下を追記します。routesをオーバーライドする他の設定より前に呼び出す必要があるため、先頭に記述するのが良いでしょう。
25 26 | Router::mapResources('todos'); Router::parseExtensions('json'); |
Router::mapResources('todos');を設定することで、以下の表のように、HTTPリクエストメソッドとリクエストURLの組み合わせによってコントローラアクションが呼ばれるようになります。
HTTP format | URL.format | 対応するコントローラアクション |
---|---|---|
GET | /todos.format | TodosController::index() |
GET | /todos/123.format | TodosController::view(123) |
POST | /todos.format | TodosController::add() |
PUT | /todos/123.format | TodosController::edit(123) |
DELETE | /todos/123.format | TodosController::delete(123) |
POST | /todos/123.format | TodosController::edit(123) |
REST #簡単なセットアップ — CakePHP Cookbook v2.x documentationより
例えば、同じ /todos.jsonがリクエストされたとしても、 GETであった場合は、Todoコントローラのindexアクションが呼ばれ、 POSTであった場合は、Todoコントローラのaddアクションが呼ばれるといった具合です。
これらのルーティングは変更可能ですが、ここでは割愛します。詳しくは、REST — CakePHP Cookbook v2.x documentationを参照してください。
また、 Router::parseExtensions('json');を設定することで、JSONフォーマットのリクエスト&レスポンスを適切に処理してくれるようになります。
モデル
cake bake model等としたもので構いません。バリデーション等は、必要に応じて行ってください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | class Todo extends AppModel { public $validate = array( 'title' => array( 'notempty' => array( 'rule' => array('notempty'), ), ), 'order' => array( 'numeric' => array( 'rule' => array('numeric'), ), ), 'done' => array( 'boolean' => array( 'rule' => array('boolean'), ), ), ); } |
コントローラ
コントローラアクションを実装します。以下では、Cookbookのサンプルをベースに、シンプルな処理しか行っておりませんので、必要に応じて追加してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | <?php App::uses('AppController', 'Controller'); /** * Todos Controller * * @property Todo $Todo */ class TodosController extends AppController { public $components = array('RequestHandler'); public function index() { $todos = $this->Todo->find('all'); $this->set(array( 'todos' => $todos, '_serialize' => array('todos') )); } // 今回のアプリケーションではToDo個別表示はしないのでviewはいらないが例として表示 public function view($id = null) { $todo = $this->Todo->findById($id); $this->set(array( 'todo' => $todo, '_serialize' => array('todo') )); } public function add() { $this->Todo->create(); if ($this->Todo->save($this->request->data)) { $message = 'Saved'; } else { $message = 'Error'; } $this->set(array( 'message' => $message, '_serialize' => array('message') )); } public function edit($id = null) { $this->Todo->id = $id; if ($this->Todo->save($this->request->data)) { $message = 'Saved'; } else { $message = 'Error'; } $this->set(array( 'message' => $message, '_serialize' => array('message') )); } public function delete($id = null) { if ($this->Todo->delete($id)) { $message = 'Deleted'; } else { $message = 'Error'; } $this->set(array( 'message' => $message, '_serialize' => array('message') )); } } |
9行目。
RequestHandlerComponentを有効にしています。このコンポーネントには、たくさんの機能があるのですが、ここでは、クライアントからJSONデータをコントローラへPOSTした場合に、自動的に解析され $this->request->dataの配列に割り当ててくれる機能等を有効利用しています。
他にも、 Router::parseExtensions()と組み合わせて使われることで、 /todos.jsonといったリクエストを受けたとき、また、 /todosといった拡張子なしのリクエストに対しても、ヘッダが application/jsonであった場合は、JSONをレスポンスしてくれるようになります。
詳しくは以下のCookbookを参照してください。
リクエストハンドリング — CakePHP Cookbook v2.x documentation
それぞれのコントローラアクションの $this->set()で、 _serializeキーを指定して、データをシリアライズしています。これにより、JSONでレスポンスが行われます。
以下のようなデータが登録されていた場合、
1 2 3 | INSERT INTO `todos` VALUE (1, 'ブログを書く', 1, 0, NOW(), NOW()), (2, 'サンプルを作る', 2, 0, NOW(), NOW()); |
indexアクションでは、以下のようなJSONを返します。
1 | {"todos":[{"Todo":{"id":"1","title":"\u30d6\u30ed\u30b0\u3092\u66f8\u304f","order":"1","done":false,"created":"2013-08-20 23:17:55","modified":"2013-08-20 23:17:55"}},{"Todo":{"id":"2","title":"\u30b5\u30f3\u30d7\u30eb\u3092\u4f5c\u308b","order":"2","done":false,"created":"2013-08-20 23:18:09","modified":"2013-08-20 23:18:09"}}]} |
ビュー
RequestHandlerComponentによって、ビューファイルの定義を省略できるため、ビューファイルは作っていません。レスポンスデータを加工したい場合は、ビューファイルを使うようにしましょう。以下のCookbookに詳しいです。
REST — CakePHP Cookbook v2.x documentation
JSONとXMLビュー — CakePHP Cookbook v2.x documentation
ビューファイルを使ったレスポンスデータ加工の例
以下に、ビューファイルを使ったレスポンスデータ加工の例を示します。
まずは、コントローラです。 $this->set()の _serializeキーの指定を削除します。
1 2 3 4 5 6 7 8 9 10 | class TodosController extends AppController { public $components = array('RequestHandler'); public function index() { $todos = $this->Todo->find('all'); $this->set(array( 'todos' => $todos )); } } |
ビューファイルで、 created, modifiedフィールドを削除しています。(Cookbookに倣って例を示しています。セレクトのときにフィールドを指定すればいいやんって言うツッコミはなしで。)
1 2 3 4 5 6 | <?php foreach ($todos as &$todo) { unset($todo['Todo']['created']); unset($todo['Todo']['modified']); } echo json_encode(compact('todos')); |
ステップアップ
これまでに、とりあえず動かすためのRESTful APIの構築について見てきました。現実的に使えるものとする為に、いくつか処理を加えましょう。
出力されるJSONの型を最適化する
既に気付かれた方もいるかもしれませんが、上記に例を示した通り、CakePHPのRESTful APIから返されたJSONは、MySQL上の型がintegerのものもstringになっています。(id, order)
これは、あまりイケてない状態です。例えば、以下は今回使っているToDoアプリ内で行われている処理ですが、型が違うことによって結果が変わってしまいます。
1 2 3 4 5 6 7 8 9 | // this.last().get('order') == 1 console.log(this.last().get('order') + 1); // order === integer // -> 2 // order === string // -> 11 // 文字列結合になってしまう! |
JavaScriptアプリケーション側で正すのも一つの方法ですが、API側で正しておいた方が、より良いでしょうから、修正してみましょう。
初めはCakePHPでJSON出力するときに起こっていることかと思いましたが、これはPHPでMySQLからセレクトしたときに起きる現象だということがわかりました。調べている経過をtweetしていたら、@cakephperさんから助け舟があり、その後、調べていくうちに、対処法がわかりました。 PDO::ATTR_EMULATE_PREPARESを falseに設定しましょう。
※ tinyint(1)がbooleanになるのは、CakePHPの仕様です。
CakePHPでのやり方は、いくつかありますが、例えば、コントローラアクションで以下のように設定します。
1 2 3 4 5 6 7 8 9 10 | public function index() { $pdo = $this->Todo->getDatasource()->getConnection(); $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); $todos = $this->Todo->find('all'); $this->set(array( 'todos' => $todos, '_serialize' => array('todos') )); } |
これらに関しては、@cakephperさんが、ブログにまとめてくれているので、詳しくはそちらを参照してください。
CakePHPのfind()で取得したデータが全てstring型になるのを、DBのカラムの型に合わせてint型で値を取得する方法(mysql) – cakephperの日記(CakePHP, MongoDB)
また、関連ツイートを@zuborawkaさんがまとめてくれていますので、あわせて参照してもらえると経緯が分かりやすいと思います。
CakePHP+MySQLで、数値型カラムの結果を文字列ではなく数値で取得する – Togetter
セキュリティ対策
以下の記事にある通り、JSONを返すAPIを構築する際、いくつか対応しておくべき事柄があります。
HPのイタい入門書を読んでAjaxのXSSについて検討した(3)~JSON等の想定外読み出しによる攻撃~ – ockeghem(徳丸浩)の日記
記事から対策の要点を引用します。
- 対策の基本はX-Requested-Withヘッダのチェック
- Content-Typeは正しく application/json; charset=UTF-8 と指定
- IEに顔を立てて、X-Content-Type-Options: nosniff を指定(あるいは不要か?)
- JSON生成ライブラリで設定できる最大限のエスケープ
これらを受けて、コントローラに beforeFilterを追加し、以下の対策を施しました。
- Ajaxのみ受け付けるようにする
- X-Content-Type-Options: nosniffを付与する
1 2 3 4 5 6 | public function beforeFilter() { parent::beforeFilter(); if (!$this->request->is('ajax')) throw new BadRequestException('Ajax以外でのアクセスは許可されていません。'); $this->response->header('X-Content-Type-Options', 'nosniff'); } |
あわせて、ビューファイルを追加し、JSON生成時のエスケープ対象を追加しました。
1 2 3 4 5 6 7 8 9 | public function index() { $pdo = $this->Todo->getDatasource()->getConnection(); $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); $todos = $this->Todo->find('all'); $this->set(array( 'todos' => $todos )); } |
1 | echo json_encode(compact('todos'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); |
探してみましたが、コンポーネント等で指定する方法は分かりませんでした。
なお、 Content-Typeを application/json; charset=UTF-8にする設定は、 RequestHandlerComponentが行ってくれます。
CakePHP側の設定は以上です。
Backbone.js ToDoアプリを修正する
データの永続化をサーバサイドで行うための修正をします。
コレクション
まずはコレクションです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | var TodoList = Backbone.Collection.extend({ // Reference to this collection's model. model: Todo, // Save all of the todo items under the `"todos-backbone"` namespace. // localStorage: new Backbone.LocalStorage("todos-backbone"), url: '/cakephp/todos', parse: function(response) { return response.todos != undefined ? response.todos : response; }, // Filter down the list of all todo items that are finished. done: function() { return this.where({done: true}); }, // Filter down the list to only todo items that are still not finished. remaining: function() { return this.where({done: false}); }, // We keep the Todos in sequential order, despite being saved by unordered // GUID in the database. This generates the next order number for new items. nextOrder: function() { if (!this.length) return 1; return this.last().get('order') + 1; }, // Todos are sorted by their original insertion order. comparator: 'order' }); |
7行目。
localStorageは、利用しないので、コメントアウトするか削除しておきましょう。
9行目。
urlは、fetchメソッドでの通信先となるURLを設定します。CakePHPで作ったAPIを指定しましょう。拡張子を含める必要はありません。
11行目。
parseを定義し、受け取ったJSONを加工します。
BackboneのCollection#fetchでは、 [{"id":1,"title":"foo"},{"id":2,"title":"bar"}]という形でデータを受け取ることを期待しています。CakePHPのRESTful APIが返すJSONの例として見た通り、そのようにはなっていませんので、ここでフォーマットしています。
一般的なWebAPIでも、 {"meta": {"xxx": "xxx"},"results": [{...},{...}...]}と言った形式で返されることは、よくあることなので、覚えておきましょう。
モデル
続いて、モデルを修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | var Todo = Backbone.Model.extend({ // Default attributes for the todo item. defaults: function() { return { title: "empty todo...", order: Todos.nextOrder(), done: false }; }, urlRoot: '/cakephp/todos', parse: function(response) { return response.Todo != undefined ? response.Todo : response; }, // Toggle the `done` state of this todo item. toggle: function() { this.save({done: !this.get("done")}); } }); |
12行目の urlRootは、モデルの urlメソッドの内部で利用されます。更新・削除等で、idが設定されているモデルのURLは、”[urlRoot]/id”となります。
14行目。
コレクションの時と同じく、 parseを定義し、受け取ったJSONを加工します。
Modelでは、 {"id":1,"title":"foo"}という形でデータを受け取ることを期待しています。
まとめと注意点
以上で完了です。
ToDoの登録や更新・削除等を行って、データが更新されていることを確認してみてください。
注意点として、サンプルのToDoアプリでは、サーバサイドでの実行結果に関わらず、DOMを更新しているということが挙げられます。アプリケーションの性質によっては、レスポンスを受けてDOMを更新するように変更する等の対処が必要でしょう。
今回は、APIの更新系のレスポンスについては、簡易的な実装しか行っておりませんので、作り込みが必要です。その際、エラーであった場合は、ステータスコードも指定しておいた方が丁寧でしょう。
CakePHPとBackboneを密に絡ませた開発については、以下の記事が参考になると思います。
最近の案件でのJavaScript開発周辺について、書いてみる | be-hase.com
以上です。
2013年PHPの話題を一挙に振り返るまとめ | Engine Yard Blog JP
2013年12月25日 @ 3:03 PM
[…] CakePHPでRESTful APIを作って、Backbone.jsのデータの永続化をサーバサイドで行う | mawatari.jp […]