CakePHP + Backbone.jsで作るJavaScript Web Application

Monday, March 26th, 2012

CakePHPでWebサービスを公開して、Backbone.jsでアクセスするという、今話題のJavaScript Web Applicationを試してみたのですが、いろいろと苦労したのでまとめてみました。
ソースコードはGitHubに置いてあります。

更新: 本記事ではCakePHPとBackbone.jsを連携させる部分に焦点を当てているために、Backbone.jsのViewを使っていません。View使った場合については「CakePHP + Backbone.jsで作るJavaScript Web Application ~ Viewの利用 ~」を参照してください。

更新2: 本記事ではCakePHPに合わせるようにBackbone.js側のモデルにカスタムparseメソッドを実装しています。逆にCakePHP側の出力するJSONフォーマットを変更したものが「CakePHP + Backbone.jsで作るJavaScript Web Application ~ JSONフォーマットの変更 ~」にあります。

1.Webアプリの構成

Webアプリは、CakePHP側とBackbone.js側とを別のアプリ、つまり別のフォルダに構成することにします。CakePHP側は”cakephp_service”、Backbone.js側は”backbone_client”という名前のフォルダに置くこととします。作成するアプリの内容としては、DB上の顧客(Customer)情報にアクセスできるWebサービスをCakePHPで提供し、そのWebサービスにBackbone.jsでアクセスすることとします。以下が簡単なアプリケーションの構成図です。

2.環境

今回は以下の環境を使いました。

3.MySQLのセットアップ

XAMPPからphpMyAdminを起動して、以下のデータベースとユーザを作成します。

  • データベース: test_service
  • ユーザ/パスワード: test/test

test_serviceデータベースに、以下のようにcustomersテーブルを作成します。

CREATE TABLE customers (
  id CHAR(36) NOT NULL,
  name VARCHAR(64) NOT NULL,
  address VARCHAR(255) NOT NULL,
  created datetime NOT NULL,
  modified datetime NOT NULL,
  PRIMARY KEY (id)
);

このテーブルはCakePHPの規約に沿って、作成しています。

4.CakePHPの基本的なセットアップ

XAMPPのhtdocs内に”cakephp_service”と”backbone_client”フォルダを作成します。

cd xampphtdocs
mkdir cakephp_service
mkdir backbone_client

CakePHP 2.1のzipファイルを展開し、その中身をすべてcakephp_serviceにコピーします。XAMPPのApacheが起動していなければ起動します。ブラウザで”http://localhost/cakephp_service/“にアクセスすることでCakePHPの動作が確認できます。
ここからは、CakePHPの基本的な設定を行います。詳しくはCookbookのInstallを見てください。

  1. app/Config/core.phpのSecurity.saltの値を変更する。
  2. app/Config/core.phpのSecurity.cipherSeedの値を変更する。
  3. app/Config/database.php.defaultをdatabase.phpという名前でコピーする。
  4. database.phpを編集する。

database.phpの内容は以下の通りにしました。

class DATABASE_CONFIG {
  public $default = array(
    'datasource' => 'Database/Mysql',
    'persistent' => false,
    'host' => 'localhost',
    'login' => 'test',
    'password' => 'test',
    'database' => 'test_service',
    'prefix' => '',
    'encoding' => 'utf8',
  );

public $test = array(
    'datasource' => 'Database/Mysql',
    'persistent' => false,
    'host' => 'localhost',
    'login' => 'test',
    'password' => 'test',
    'database' => 'test_service',
    'prefix' => '',
    'encoding' => 'utf8',
  );
}

ブラウザで”http://localhost/cakephp_service/“にアクセスして、エラーや警告が出ていないことを確認してください。

5.CakePHPのWebサービスの設定

CakePHPをWebサービスに対応させるためには、3つのことが必要となります。

  1. CakePHPでJson形式のレスポンスを返せるようにする – JsonViewの設定をします
  2. CakePHPでJson形式のリクエストを受け取れるようにする – CakePHPがJsonをパースするので、コントローラでそのデータにアクセスするようにします
  3. CakePHPのアクションとWebサービスのHTTPメソッドをマッピングする – routes.phpを編集します

最後のHTTPメソッドですが、Backbone.jsやSpine.jsは、サーバとの通信に以下のようなHTTPメソッドとURLの組み合わせでアクセスしてきます。そして、CakePHPでは、設定を変更することで、それぞれを以下のようにControllerのアクションにマッピングする機能を持っています。

機能 HTTPメソッド URL コントローラのアクション
一覧の取得 GET /customers CustomersController::index()
IDを持つ値の取得 GET /customers/id CustomersController::view(id)
追加 POST /customers CustomersController::add()
編集 POSTもしくはPUT /customers/id CustomersController::edit(id)
削除 DELETE /customers/id CustomersController::delete(id)

特筆すべきは、JsonViewを有効にすることで、CakePHPのModelとControllerを作成するだけで、つまり、Viewを作成することなく、Jsonのレスポンスを返せるようになります。
Webサービスに関する設定は、CakePHP Cookbookに書かれている以下の情報を参考にしています。

5.1 AppControllerの編集

“app/Controller/AppController.php”をテキストエディタで開き、以下のように編集します。

App::uses('Controller', 'Controller');

class AppController extends Controller {
  public $viewClass = 'Json';
  public $components = array('RequestHandler');
}

“$viewClass = ‘Json'”により、JsonViewをViewクラスとして使うことを指定しています。”$components = array(‘RequestHandler’)”により、RequestHandlerが有効となり、コンテンツタイプにより自動的にViewクラスが変更されるようになります。

5.2 routes.phpの編集

“app/Config/routes.php”をテキストエディタで開き、以下のように編集します。

Router::mapResources('customers');
Router::parseExtensions('json');

Router::connect('/',
  array('controller' => 'pages', 'action' => 'display', 'home'));
...

“Router::parseExtensions(‘json’)”により、Accept Headerが”application/json”の場合にJsonViewに切り替わるようになります。
そして、”Router::mapResources(‘customers’)”により、CustomersControllerに対するリクエストはWebサービス特有のHTTPメソッドにマッピングするようになります。注意として、今後Webサービスとして公開するコントローラが増えた場合、routes.phpに”Router::mapResources(コントローラ名)”を追加していく必要があります。

6.モデルの作成

モデルは、通常のCakePHPのモデルと変わりません。今回のアプリケーションではCustomer.phpを”app/Model”に以下の内容で作成します。

<?php
class Customer extends AppModel {
    public $name = 'Customer';
}
?>

7.コントローラの作成

まず、”app/Controller”にCustomersController.phpを以下の内容で作成します。

<?php
class CustomersController extends AppController {
    public $name = 'Customers';
}
?>

ここに、アクションを一つずつ追加していきます。その前に、Backbone.js側の準備をします。

8. Backbone.js側の準備

8.1 環境

backbone_clientフォルダ内にlibフォルダを作成し、その中に以下のファイルをコピーします。

  1. backbone.js
  2. jquery-1.7.2.js
  3. json2.js
  4. underscore.js

アプリケーションはindex.htmlとapplication.jsだけとなります。index.htmlには以下の内容を記述します。

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Backbone.js Client</title>
    <script src="lib/jquery-1.7.2.js" type="text/javascript"></script>
    <script src="lib/underscore.js" type="text/javascript"></script>
    <script src="lib/json2.js" type="text/javascript"></script>
    <script src="lib/backbone.js" type="text/javascript"></script>
    <script src="application.js" type="text/javascript"></script>
  </head>
  <body>
  </body>
</html>

8.2 モデルの定義

Backbone.jsにはいろいろな機能が含まれていますが、ここで使うのはモデルの定義と、モデルに含まれるデータのAjaxによるサーバへの永続化です。定義するモデルはCustomerモデルと、そのコレクションであるCustomersコレクションです。
まず、Customerモデルを定義しますので、application.jsの先頭に以下を記述します。なお、モデルが持つデータをBackbone.jsでは属性(attribute)と読んでいますので、その用語をここでも使います。ちなみに、CakePHPのモデルではプロパティという用語を使いますが、JavaScriptでのプロパティはJavaScriptオブジェクトの持つプロパティとなり、モデルの持つデータとは異なる意味になるので注意が必要です。

var Customer = Backbone.Model.extend({
  defaults : {
    "name" : "",
    "address" : ""
  },
  urlRoot : "/cakephp_service/customers",
  parse : function(response) {
    if (response.Customer != undefined) {
      return response.Customer;
    }
    return response;
  }
});

Backbone.jsではBackbone.Model.extend({})を使って、モデルクラスを定義します。引数でオプションを指定できますが、ここではdefaults、urlRoot、parseという3つのオプションを指定しています。

“defaults”は、その名の通り、Customerモデルが持つ属性のデフォルト値を指定しています。”defaults”は無くても問題はないのですが、nameとaddressが存在しないという状態を作りたくなかったので設定しました。注意として、idという属性は”defaults”に指定してはなりません。Backbone.jsでは、idという属性が存在しているかどうかで、そのインスタンスがサーバに永続されているかを判断するために使います。つまり、属性idが割り当てられていないインスタンスは、新しいインスタンスと判断され、サーバに永続される際にaddアクションが呼び出され、DBへのINSERTが発生します。

“urlRoot”は、このモデルがサーバに永続される際に、サーバへAjaxリクエストを送る際のURLルートを指定します。今回のアプリケーションでは”/cakephp_service”の”/customers”にアクセスするように指定します。

“parse”は、サーバが返したJSONを、どのようにパースしてモデルのインスタンスを生成するかを定義する関数を記述します。デフォルトでは、受け取ったJSONがそのままモデルのインスタンスとなることを仮定しています(つまり”return response;”)。ですので、CakePHP側が生成するJSONのフォーマットと合うように設計する必要があります。加えて、この後で説明するコレクションのパースの場合でも正しく動作する必要があります。ここでは、responseにCustomerというプロパティが定義されていたらその値をモデルのインスタンスとし、それ以外の場合は、そのままresponseをモデルのインスタンスとして返すようにしています。

8.3 コレクションの定義

Backbone.jsでは、検索結果のように複数件のモデルが含まれる結果が返る場合は、それをコレクションに格納することになっています。ですので、複数のCustomerモデルが含まれるコレクションとしてCustomersコレクションを次に定義します。

var Customers = Backbone.Collection.extend({
  model : Customer,
  url : "/cakephp_service/customers"
});

Backbone.Collection.extend({})で、コレクションクラスを定義します。ここでは、オプションとしてmodelとurlを指定しています。modelはこのコレクションが格納するモデルを指定します。urlは、コレクションをサーバから取得する際のURLを指定します。ちなみに、コレクションでもparseを指定し、JSONのパースの仕方を変えることができます。また、コレクションのparseは、内部でモデルのparseを呼び出して、最終的なモデルのインスタンスを取得しています。

9 アクションの定義

9.1 Customerの追加(addアクション)

Customerを新しく追加するため、nameとaddressを入力し、Addボタンを押すことができるWebページを作成します。

HTMLとしては以下のようになります。

<table id="add-table">
  <tbody>
    <tr>
      <th>name</th>
      <td><input id="add-name" size="32"/></td>
    </tr>
    <tr>
      <th>address</th>
      <td><input id="add-address" size="32"/></td>
    </tr>
  </tbody>
</table>
<input id="add-button" type="button" value="Add"/>

Addボタンが押された場合に、Customerモデルをサーバに永続化する処理は以下となります。

$(function() {
  $("#add-button").click(function(e) {
    var customer = new Customer({
      name : $('#add-name').val(),
      address : $('#add-address').val()
      });
    customer.save();
  });
});

“new Cutomer({})”とすることで、新しいCustomerのインスタンスが生成できます。この際、設定したい属性を引数に指定できます。ここでは、nameとaddress属性を、inputタグの値を取得して設定しています。
生成したcustomerインスタンスを永続化するにはsave()を呼び出します。これで、非同期でサーバへの永続化が実行されます。
nameに”test”、addressに”tokyo”と指定した場合に、Backbone.jsから送られるHTTPリクエストの内容は以下の通りとなります。

POST /cakephp_service/customers HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:11.0) Gecko/20100101 Firefox/11.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: ja,en-us;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Type: application/json; charset=UTF-8
X-Requested-With: XMLHttpRequest
Referer: http://localhost/backbone_client/
Content-Length: 33
Pragma: no-cache
Cache-Control: no-cache

{"name":"test","address":"tokyo"}

さて、CakePHPのCustomersControllerに、このリクエストを受けて、データベースにCustomer格納するするアクションaddを追加します。

class CustomersController extends AppController {
  public $name = 'Customers';
  function add() {
    $customer['Customer']['name'] = $this->params['data']['name'];
    $customer['Customer']['address']  = $this->params['data']['address'];
    $this->Customer->create();
    $this->Customer->save($customer);
    $customer['Customer']['id'] = $this->Customer->id;
    $this->set('customer', $customer);
    $this->set('_serialize', 'customer');
  }
}

まず、Backbone.jsはデータをJSON形式でPOSTしています。通常のFORMからのPOSTならば$this->params[‘form’]でパラメータを取得できますが、JSONの場合$this->[‘data’]を使う必要があります。CakePHPのモデルを用いたデータベースの永続化は、今まで通りです。最後に、登録された内容をJSON形式でクライアントに返しています。JsonViewは”_serialize”という名前で配列(array)を設定すると、それをJSONとして出力します。
実際にaddアクションのHTTPレスポンスは以下のような内容となります。

HTTP/1.1 200 OK
Server: Apache/2.2.21 (Win32) mod_ssl/2.2.21 OpenSSL/1.0.0e PHP/5.3.8 mod_perl/2.0.4 Perl/v5.10.1
X-Powered-By: PHP/5.3.8
Content-Length: 46
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json

{"Customer":{"name":"test","address":"tokyo"}}

9.2 Customerの一覧の取得(indexアクション)

次に、Customerの一覧を取得するindexアクションを追加します。CustomersControllerに以下のようにindexアクションを追加します。

class CustomersController extends AppController {
  public $name = 'Customers';
    function index() {
      $customers = $this->Customer->find('all');
      $this->set('customers', $customers);
      $this->set('_serialize', 'customers');
  }
}

こちらは、非常にシンプルです。find(‘all’)でCustomerの一覧を取得し、それを’_serialize’に設定しています。
Backbone.js側は以下のようになります。

$(function() {
  $('#list-button').click(function(e){
    var customers = new Customers;
    customers.fetch({
      success : function(customers, response) {
        var tr = $('#list-table>tbody>tr');
        if(tr.length > 0) {
          tr.remove();
        }
        var tbody = $('#list-table>tbody');
        var i;
        for(i = 0; i < customers.length; ++i) {
          var customer = customers.at(i);
          tbody.append('<tr><td>' + customer.get('id') +
              '</td><td>' + customer.get('name') +
              '</td><td>' + customer.get('address') +
              '</td></tr>');
        }
      },
      error : function(customers, response) {
        alert('error: ' + response);
      }
    });
  });
});

table要素に結果を追加しているところまで示しているのでちょっと長いですが、その部分を削ると、実質Backbone.jsで行っているのは以下の処理だけです。

$(function() {
  $('#list-button').click(function(e){
    var customers = new Customers;
    customers.fetch({
      success : function(customers, response) {
        // 取得に成功した場合の処理
      },
      error : function(customers, response) {
        // エラーが発生した場合の処理
      }
    });
  });
});

まず、”new Customers”でCustomersコレクションのインスタンスを生成します。
次に、fetch()関数を呼び出して、サーバに一覧取得のリクエストを送ります。処理は非同期で行われるので、コールバック関数としてsuccessとerrorを指定します。successの第一引数に取得できたコレクションのインスタンスが渡されるので、そこからモデルのインスタンスを取得し、表示処理を行います。

9.3 指定したIDのCustomerの取得(viewアクション)

既存のCustomerを取得するviewアクションの場合、Backbone.jsは”/cakephp_service/customers/(id)”というパスでサーバにアクセスしてきます。そして、CakePHPは最後のidをviewアクションの引数に自動的に渡してくれます。ですので、viewアクション内では渡されたidでデータベースを検索し、取得されたCustomerをJSON形式で返すようにします。

class CustomersController extends AppController {
  public $name = 'Customers';
  function view($id) {
    $customer = $this->Customer->find('first', array(
        'conditions' => array('Customer.id' => $id)
    ));
    $this->set('customer', $customer);
    $this->set('_serialize', 'customer');
  }
}

Backbone.jsでは、idだけを指定したCustomerのインスタンスを作成して、fetch()を呼び出します。

$(function() {
  $('#update-read_button').click(function(e){
    var customer = new Customer({
      id : $('#update-read_id').val()
    });
    customer.fetch({
      success : function(customer, response) {
        $('#update-name').val(customer.get('name'));
        $('#update-address').val(customer.get('address'));
      },
      error : function(customers, response) {
        alert('error: ' + response);
      }
    });
  });
});

実際に送られたHTTPリクエストです。

GET /cakephp_service/customers/4f6f3d11-27d8-4a52-b68d-1b7c4e257404 HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:11.0) Gecko/20100101 Firefox/11.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: ja,en-us;q=0.7,en;q=0.3
Accept-Encoding: gzip, deflate
Connection: keep-alive
X-Requested-With: XMLHttpRequest
Referer: http://localhost/backbone_client/

HTTPレスポンスです。

HTTP/1.1 200 OK
Server: Apache/2.2.21 (Win32) mod_ssl/2.2.21 OpenSSL/1.0.0e PHP/5.3.8 mod_perl/2.0.4 Perl/v5.10.1
X-Powered-By: PHP/5.3.8
Content-Length: 155
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json

{"Customer":{"id":"4f6f3d11-27d8-4a52-b68d-1b7c4e257404","name":"test","address":"tokyo","created":"2012-01-01 00:00:00","modified":"2012-01-01 00:00:00"}}

なぜCustomerモデルのparseメソッドをわざわざ定義したのかの理由がこのレスポンスにあります。レスポンスを見るとCustomerの中に属性が含まれています。このため、パースされたJSONは、”response.Customer”という形式になります。Backbone.jsは、デフォルトではresponse = Customerを期待しているので、以下のようにparseの処理を変更する必要があったのです。

parse : function(response) {
  if (response.Customer != undefined) {
    return response.Customer;
  }
  return response;
}

最後に

更新と削除の説明は省略しますので、直接、ソースコードを見てください。

開発をする際の注意として、CakePHPとBackbone.jsは同じドメインに置かなければなりません。JavaScriptはクロスドメインのリクエストが基本的に送れないので、当然といえば当然なのですが、つい、普通のWebサービスのつもりで手元でBackbone.jsのWebアプリを起動すると、CakePHPのサービスにアクセスできなくなります。(この場合OPTIONSというHTTPメソッドでリクエストが送られます)

まだまだ情報がない分野だけに、ここまでたどり着くのは結構苦労させられました。でも、作成してみた後にソースを眺めてみると、すっきりしていて、JavaScriptを中心としたアプリを作るならば、Backbone.jsやSpine.jsなどを使うことをおすすめします。