@blog.justoneplanet.info

日々勉強

CakePHP 2でRedisにSessionを保持する

Cake2系用のが無かったのでCake1系用のこれをforkして作ってみた。

■導入

iRedis

Vendorに配置する

cakephp-redis-session

app/Model/DataSource/Session/に配置する

core.php

core.phpに以下のように記述する。

Configure::write('Session.handler', array('engine' => 'RedisSession'));
Configure::write('RedisSession.hostname', 'some.host.name');
Configure::write('RedisSession.port', 1337);//port
Configure::write('RedisSession.password', 'your password');// password
Configure::write('RedisSession.database', 0);// database number

■コード

オリジナルのコードはパスワードやDB番号が設定できなかったので改良してある。

App::import('Vendor', 'iRedis/iredis');
App::uses('DatabaseSession', 'Model/Datasource/Session');
/**
 * Redis Session Store Class
 */
class RedisSession extends DatabaseSession implements CakeSessionHandlerInterface {
    private static $store;
    private static $timeout;
    
    public function __construct() {
        parent::__construct();
        $timeout = Configure::read('Session.timeout');
        if (empty($timeout)) {
            $timeout = 60 * 24 * 90;
        }
        self::$timeout = $timeout;
    }

    /**
     * open
     * connect to Redis
     * authorize
     * select database
     */
    public function open() {
        $host = Configure::read('RedisSession.hostname');
        $port = Configure::read('RedisSession.port');
        $password = Configure::read('RedisSession.password');
        $database = Configure::read('RedisSession.database');

        if ($host !== null && $port !== null) {
            $redis = new iRedisForRedisSession(array('hostname' => $host, 'port' => $port));
        }
        else {
            $redis = new iRedisForRedisSession();
        }
        if (!empty($password)) {
            $redis->auth($password);
        }
        if (!empty($database)) {
            $redis->select($database);
        }

        self::$store = $redis;
    }

    /**
     * close
     * disconnect from Redis
     * @return type 
     */
    public function close() {
        self::$store->disconnect();
        return true;
    }

    /**
     * read
     * @param type $id
     * @return type 
     * - Return whatever is stored in key
     */
    public function read($id) {
        return self::$store->get($id);
    }

    /**
     * write
     * @param type $id
     * @param type $data
     * @return type 
     * - SETEX data with timeout calculated in open()
     */
    public function write($id, $data) {
        self::$store->setex($id, self::$timeout, $data);
        return true;
    }

    /**
     * destroy
     * @param type $id
     * @return type 
     * - DEL the key from store
     */
    public function destroy($id) {
        self::$store->del($id);
        return true;
    }

    /**
     * gc
     * @param type $expires
     * @return type 
     * not needed as SETEX automatically removes itself after timeout
     */
    public function gc($expires = null) {
        return true;
    }
}
if (!class_exists('iRedisForRedisSession')) {
    class iRedisForRedisSession extends iRedis {
        function __destruct() {
            // don't disconnect yet
        }
        function disconnect() {
            parent::__destruct();
        }
    }
}

iRedisのコードを読んでみると面白いのだがマジックメソッド__callを使って各コマンドをcallしている。

CakePHPでOAuth Service Providerを実装する

■プロジェクト

以下のコマンドでCakePHPをダウンロードして展開する。

wget https://github.com/cakephp/cakephp/tarball/master
tar xvzf cakephp-cakephp-2.1.3-1-g5270721.tar.gz
cd cakephp-cakephp-5270721

■Pluginの設置

面倒なのでプラグインを使う。

cd app/Plugin
git clone https://github.com/seddonmedia/cakephp-oauth-server.git OAuth
mv OAuth/Controller/OAuthController.php OAuth/Controller/OauthController.php #多分必要

oauth2-phpが必要になるので以下のコマンドを実行する。

cd app/Vendor
git clone https://github.com/quizlet/oauth2-php.git

■Schema

CREATE TABLE IF NOT EXISTS `access_tokens` (
  `oauth_token` varchar(40) NOT NULL,
  `client_id` char(36) NOT NULL,
  `user_id` int(11) unsigned NOT NULL,
  `expires` int(11) NOT NULL,
  `scope` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`oauth_token`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `auth_codes` (
  `code` varchar(40) NOT NULL,
  `client_id` char(36) NOT NULL,
  `user_id` int(11) unsigned NOT NULL,
  `redirect_uri` varchar(200) NOT NULL,
  `expires` int(11) NOT NULL,
  `scope` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`code`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `clients` (
  `client_id` char(20) NOT NULL,
  `client_secret` char(40) NOT NULL,
  `redirect_uri` varchar(255) NOT NULL,
  `user_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `refresh_tokens` (
  `refresh_token` varchar(40) NOT NULL,
  `client_id` char(36) NOT NULL,
  `user_id` int(11) unsigned NOT NULL,
  `expires` int(11) NOT NULL,
  `scope` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`refresh_token`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

■ログイン

アプリケーションによって実装は様々だが、以下の変数にuniqueなユーザidを保持している必要がある。

$this->Auth->user('id');

以降はドキュメントに書いてあるので必要ないが一応。

■ルーティング

app/Config/bootstrap.phpの最終行に以下を追記する。

CakePlugin::loadAll(array(
    'OAuth' => array('routes' => true)
));

■Client IDとClient Secretの取得

var_dump($this->OAuth->Client->add('http://sample.org'));

ここで取得できるのはクライアントID(a)とSecret(b)である。表示されるSecretは実際の値で、DB側に保存されている値はhash化されている。

■Authorization Codeの取得

/oauth/authorize?response_type=code&client_id=xxxx&redirect_url=http%3a%2f%2fsample.org

Authorization Codeが取得できる。(c)

■Access Tokenの取得

/oauth/token?grant_type=authorization_code&code=(c)&client_id=(a)&client_secret=(b)

Access Tokenの取得時に(b)は以下の部分でhash化してfindされる。

    public function checkClientCredentials($client_id, $client_secret = NULL) {
        $conditions = array('client_id' => $client_id);
        if ($client_secret) {
            $conditions = array(
                'client_secret' => self::hash($client_secret),
            );
        }
        $client = $this->Client->find('first', array(
            'conditions' => $conditions,
            'recursive'  => -1
        ));
        if ($client){
            return $client['Client'];
        };
        return false;
    }

CakePHP2系で認証のユーザーモデルを変更する

ログアウト後のログインが上手くできなくてハマったのでメモしておく。

class AppController extends Controller {
    public $components = array(
        'Auth' => array(
            'loginRedirect' => array('controller' => 'hoge', 'action' => 'index'),
            'loginAction'   => array('controller' => 'hoge', 'action' => 'login'),
            'authenticate'  => array(
                'Form' => array(
                    'userModel' => 'Hoge',
                    'fields' => array(
                        'username' => 'email',
                    ),
                    'scope' => array(
                        'Hoge.is_public' => 1,
                    ),
                ),
            ),
        ),
        'Session'
    );

    public function beforeFilter() {
        parent::beforeFilter();
    }
}
class HogeController extends AppController {
    public function beforeFilter() {
        parent::beforeFilter();
        $this->Auth->allow(array('login', 'logout', 'fuga'));// loginとlogoutも記述しておく
    }
    public function login() {
        if ($this->Auth->login()) {
            $this->redirect($this->Auth->redirect());
        }
        else {
            $this->Session->setFlash(__('Invalid username or password, try again'));
        }
    }
    public function logout() {
        $this->redirect($this->Auth->logout());
    }
}

CakePHP 2系でjsonを出力する

1系よりも簡単に以下のようにして、jsonを出力することができる。

<?php
App::uses('AppController', 'Controller');
class SampleController extends AppController {
    public function index() {
        return new CakeResponse(array('body' => json_encode(array('key' => 'value'))));
    }
}
// {"key" : "value"}

CakePHP 2.0.4で多言語対応する

■手順

アプリケーションのrootディレクトリで以下のコマンドを実行する。

php lib/Cake/Console/cake.php i18n

以下のように表示される。

Welcome to CakePHP v2.0.4 Console
---------------------------------------------------------------
App : app
Path: /var/www/domain/app/
---------------------------------------------------------------
I18n Shell
---------------------------------------------------------------
[E]xtract POT file from sources
[I]nitialize i18n database table
[H]elp
[Q]uit
What would you like to do? (E/I/H/Q) 

Eを選択する。

What is the path you would like to extract?
[Q]uit [D]one  
[/var/www/domain/app/] > 

Dを選択する。

What is the path you would like to output?
[Q]uit  
[/var/www/domain/app//Locale] > 
Would you like to merge all domains strings into the default.pot file? (y/n) 

yを選択すると、ファイルを読み込んでデフォルトの言語ファイルが生成される。

  • /var/www/domain/app/Locale/default.pot
  • /var/www/domain/app/Locale/eng

/var/www/domain/app/Locale/engをコピーして/var/www/domain/app/Locale/jpnを作り、/var/www/domain/app/Locale/default.potをコピーして、/var/www/domain/app/Locale/jpn/LC_MESSAGES/default.poを作る。この時、拡張子を変更しないと上手く動作しない。

翻訳ファイル

翻訳ファイルは以下のように記述する。

#: Controller/HogeController.php:65;166;188;214
msgid "Hello world"
msgstr "世界こんにちわ"

CakePHP 2.0でログインフォームを作る

■実装

app/Controller/UsersController.php

App::uses('AppController', 'Controller');
/**
 * Users Controller
 * @property User $User
 */
class UsersController extends AppController {

    public $uses = array('User');
    
    public $components = array(
        'Session',
        'Auth' => array(
            'loginRedirect' => array('controller'  => 'users', 'action' => 'index'),// ログイン後のリダイレクト先
            'logoutRedirect' => array('controller' => 'pages', 'action' => 'display', 'home')// ログアウト後のリダイレクト先
        )
    );
    
    public function beforeFilter() {
        //$this->Auth->allow('index'/*, 'add'*/);// 認証を除外するアクションを指定する
    }

    /**
     * ユーザを追加する画面
     * @return void
     */
    public function add() {
        /*if ($this->request->is('post')) {
            $this->User->create();
            if ($this->User->save($this->request->data)) {
                $this->Session->setFlash(__('The user has been saved'));
                $this->redirect(array('action' => 'index'));
            }
            else {
                $this->Session->setFlash(__('The user could not be saved. Please, try again.'));
            }
        }*/
    }

    /**
     * ログイン画面
     * @return void
     */
    public function admin_login() {
        if ($this->Auth->login()) {
            $this->redirect($this->Auth->redirect());
        }
        else {
            $this->Session->setFlash(__('Invalid username or password, try again'));
        }
    }

    /**
     * ログアウト画面
     * @return void
     */
    public function admin_logout() {
        $this->redirect($this->Auth->logout());
    }
}

app/Model/User.php

App::uses('AppModel', 'Model');

/**
 * User Model
 * ユーザのモデル
 */
class User extends AppModel {
    public function __construct($id = false, $table = null, $ds = null) {
        $this->useDbConfig = Configure::read('Config.environment');
        parent::__construct($id, $table, $ds);
    }

    /**
     * 保存前に実行される
     * @return void
     */
    public function beforeSave() {
        if (isset($this->data[$this->alias]['password'])) {
            $this->data[$this->alias]['password'] = AuthComponent::password($this->data[$this->alias]['password']);
        }
        return true;
    }

}

Cake1系ではpasswordというカラム名が自動的にハッシュ化されていた。Cake2系では上述のように実装する必要がある。

app/View/Users/admin_login.ctp

以下のようにログインフォームを記述する。

<?php echo $this->Session->flash('auth'); ?>
<?php echo $this->Form->create('User');?>
<fieldset>
<legend><?php echo __('Please enter your username and password'); ?></legend>
<?php
echo $this->Form->input('username');
echo $this->Form->input('password');
?>
</fieldset>
<?php echo $this->Form->end(__('Login'));?>

$thisを介してSessionとFormにアクセスするところがCake2系では異なる。

app/View/Users/admin_logout.ctp

<h3>ログアウトしました。</h3>

■参考

殆ど参考文献の簡略化日本語版だな。

crontabとCakePHPでバッチ処理をする

バッチ処理したい時などに使う技である。

■実装

app/vendors/shells/calc.php

以下のようにしてShellクラスを継承する。基本的にはコントローラと同様にモデルなどが使用できるがコンポーネントについては注意が必要である。

date_default_timezone_set('Asia/Tokyo');

//Configure::write('Config.environment', isset($_SERVER['CAKE_ENV']) ? $_SERVER['CAKE_ENV'] : "development");// コマンドラインから叩いている場合、$_SERVERによる環境分岐ができない
Configure::write('Config.environment', "production");

class RankShell extends Shell
{
    public $uses = array(
        'Logs',
        'Ranks'
    );
    
    /**
     * 処理を実行する前に読み込むコンポーネントなどを記述する
     */
    public function initialize()
    {
        parent::initialize();
        //$this->Email = new EmailComponent($this);// コンポーネント名に注意
    }
    
    /**
     * ここに記述した処理が実行される
     */
    public function main()
    {
        $this->out("start");
        if($result = $this->Log->getRank()){
            $this->Ranks->deleteAll();
            if($this->Ranks->saveAll($result)){
                $this->out("success");
            }
            else{
                $this->out("failed to save");
            }
        }
        else{
            $this->out("failed to calc");
        }
    }
}

■実行

以下のようにして実行できるかどうか確認する。

/usr/bin/php /var/www/hogehoge.justoneplanet.info/cake/console/cake.php calc

■crontab

以下のコマンドを実行してcrontabを編集する。

crontab -e

例えば10時のオヤツを忘れないよう10:15に実行するようにするには以下のようにする。

15 10 * * * /usr/bin/php /var/www/hogehoge.justoneplanet.info/cake/console/cake.php calc

15分ごとに処理をするには以下のように記述する。

*/15 * * * * /usr/bin/php /var/www/hogehoge.justoneplanet.info/cake/console/cake.php calc

以上のようにCakePHPでは非常に簡単にバッチ処理を書くことができる。個人的にはZendFrameworkよりも簡単に感じる。

CakePHPのrenderメソッドでViewファイルを指定する

以下のように指定することも多々ある。

$this->render('/ajax/json');

Cake内部でファイルを探索する際、open_basedirの制限に引っかかることがあるので以下のように記述すると良い。

$this->render(null, null, VIEWS . 'ajax' . DS . 'json.ctp');

CakePHPでモデルを作る

前回に引き続き今更ではあるがメモっておく。

■モデルのファイル生成

bakeする

bakeの前に解説用に以下のテーブルを作る。

CREATE TABLE `users` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `email` varchar(255) NOT NULL,
  `created` datetime NOT NULL,
  `modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `is_public` tinyint(1) NOT NULL DEFAULT '1',
  PRIMARY KEY (`id`),
  UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8;

以下のコマンドを実行する。

./cake/console/cake bake

以下のように表示される。

Welcome to CakePHP v1.3.10 Console
---------------------------------------------------------------
App : app
Path: /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app
---------------------------------------------------------------
Interactive Bake Shell
---------------------------------------------------------------
[D]atabase Configuration
[M]odel
[V]iew
[C]ontroller
[P]roject
[F]ixture
[T]est case
[Q]uit
What would you like to Bake? (D/M/V/C/P/F/T/Q) 
> M

Mを選択すると以下の選択肢が表示されるのでdefaultを選択する。

---------------------------------------------------------------
Bake Model
Path: /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/models/
---------------------------------------------------------------
Use Database Config: (default/production) 
[default] > default

この時点でDBにテーブルが存在していないと以下のように表示される。

Your database does not have any tables.

当然だけどデータストアの定義がされていないのにコードを書き始めてはいけない。

テーブルが存在している場合は以下のように表示される。

Possible Models based on your current database:
1. User
Enter a number from the list above,
type in the name of another model, or 'q' to exit 

1番を選択すると以下のように質問される。

Would you like to supply validation criteria 
for the fields in your model? (y/n) 

yを選択すると以下のように表示される。

Field: id
Type: integer
---------------------------------------------------------------
Please select one of the following validation options:
---------------------------------------------------------------
1 - alphanumeric
2 - between
3 - blank
4 - boolean
5 - cc
6 - comparison
7 - custom
8 - date
9 - decimal
10 - email
11 - equalto
12 - extension
13 - inlist
14 - ip
15 - maxlength
16 - minlength
17 - money
18 - multiple
19 - notempty
20 - numeric
21 - phone
22 - postal
23 - range
24 - ssn
25 - time
26 - url
27 - userdefined
28 - uuid
29 - Do not do any validation on this field.

どんな入力値チェックをするかという事ですな。ここで設定した値を元にソースを書きだしてくれる。但し、idはautoincrementで特にinsertするカラムではないので29を選択する。

Field: name
Type: string
---------------------------------------------------------------
Please select one of the following validation options:
---------------------------------------------------------------
1 - alphanumeric
2 - between
3 - blank
4 - boolean
5 - cc
6 - comparison
7 - custom
8 - date
9 - decimal
10 - email
11 - equalto
12 - extension
13 - inlist
14 - ip
15 - maxlength
16 - minlength
17 - money
18 - multiple
19 - notempty
20 - numeric
21 - phone
22 - postal
23 - range
24 - ssn
25 - time
26 - url
27 - userdefined
28 - uuid
29 - Do not do any validation on this field.
... or enter in a valid regex validation string.

次に表示されるのはnameカラムに対するバリデーションだ。必須項目としたいので19を入力すると以下のように表示される。

Would you like to add another validation rule? (y/n) 

1つのカラムに複数のバリデーションを適用できるので必要な場合は番号を入力する。次はパスワードだ。

Field: password
Type: string
---------------------------------------------------------------
Please select one of the following validation options:
---------------------------------------------------------------
1 - alphanumeric
2 - between
3 - blank
4 - boolean
5 - cc
6 - comparison
7 - custom
8 - date
9 - decimal
10 - email
11 - equalto
12 - extension
13 - inlist
14 - ip
15 - maxlength
16 - minlength
17 - money
18 - multiple
19 - notempty
20 - numeric
21 - phone
22 - postal
23 - range
24 - ssn
25 - time
26 - url
27 - userdefined
28 - uuid
29 - Do not do any validation on this field.
... or enter in a valid regex validation string.
  
[19] > 

1と19あたりを設定すると思う。次はemailだ。

Field: email
Type: string
---------------------------------------------------------------
Please select one of the following validation options:
---------------------------------------------------------------
1 - alphanumeric
2 - between
3 - blank
4 - boolean
5 - cc
6 - comparison
7 - custom
8 - date
9 - decimal
10 - email
11 - equalto
12 - extension
13 - inlist
14 - ip
15 - maxlength
16 - minlength
17 - money
18 - multiple
19 - notempty
20 - numeric
21 - phone
22 - postal
23 - range
24 - ssn
25 - time
26 - url
27 - userdefined
28 - uuid
29 - Do not do any validation on this field.
... or enter in a valid regex validation string.
  
[10] > 

なんとカラム名から判断して10番を提示してくれるではないか。Cakeは気がきくのだがこれは時としてお節介にもなる。そんなこんなでバリデーションを設定していくと次に以下のような質問をされる。

Would you like to define model associations
(hasMany, hasOne, belongsTo, etc.)? (y/n) 

関連するテーブルを聞かれているのだが、まだ他のテーブル存在していないのでnを入力する。

---------------------------------------------------------------
The following Model will be created:
---------------------------------------------------------------
Name:       User
DB Table:   `users`
Validation: Array
(
    [name] => Array
        (
            [notempty] => notempty
        )

    [password] => Array
        (
            [notempty] => notempty
        )

    [email] => Array
        (
            [email] => email
        )

    [is_public] => Array
        (
            [boolean] => boolean
        )

)

-------------------------

上述のように生成されるファイルの確認をされるのでyを押す。

ファイルが存在している場合は上書きの確認メッセージが表示される。

Creating file /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/models/user.php
File `/Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/models/user.php` exists, overwrite? (y/n/q) 
[n] >

次に単体テスト用のファイルをbakeするか聞かれる。

SimpleTest is not installed. Do you want to bake unit test files anyway? (y/n)

やっぱちゃんとたりたいのでyを選択する。ちなみにSimpleTestのインストールは後からでも全然構わない。ファイルは「./app/tests/cases/models/user.test.php」に自動的に生成されている。

■生成したファイル

./app/models/user.php

<?php
class User extends AppModel {
    var $name = 'User';
    var $displayField = 'name';
    var $validate = array(
        'name' => array(
            'notempty' => array(
                'rule' => array('notempty'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
        'password' => array(
            'notempty' => array(
                'rule' => array('notempty'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
        'email' => array(
            'email' => array(
                'rule' => array('email'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
        'is_public' => array(
            'boolean' => array(
                'rule' => array('boolean'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
    );
}

コメントアウトされている部分については一番上を参考にする。

./app/tests/cases/models/user.test.php

/* User Test cases generated on: 2011-07-20 02:35:04 : 1311096904*/
App::import('Model', 'User');

class UserTestCase extends CakeTestCase {
	var $fixtures = array('app.user');

	function startTest() {
		$this->User =& ClassRegistry::init('User');
	}

	function endTest() {
		unset($this->User);
		ClassRegistry::flush();
	}

}

しかし、PHP5の文法で書きだして欲しいかつインデントはスペースを使って欲しいものだ。

■SimpleTest

debugモードで動作しているサイトのtest.phpにアクセスするとSimpleTestがダウンロードされていない場合、リンクが表示されるのでそこからダウンロードする。。。のもいいんだが分かりにくいのでコマンドラインから操作する。

cd vendors
wget http://sourceforge.net/projects/simpletest/files/simpletest/simpletest_1.0.1/simpletest_1.0.1.tar.gz/download
tar xvzf simpletest_1.0.1.tar.gz
rm simpletest_1.0.1.tar.gz

上述の操作でSimpleTestが使用できるようになった。テストケースについてはまた別の機会に解説する。

eclipseプラグイン

eclipseプラグインもあるのだがデバッグの構成とかいまいち設定方法が分からん。。。

■メソッドの記述

モデルクラスができたので試しにidからユーザを取得するメソッドgetByIdを書いてみる。

class User extends AppModel {
    public $name = 'User';
    public $displayField = 'name';
    public $validate = array(
        'name' => array(
            'notempty' => array(
                'rule' => array('notempty'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
        'password' => array(
            'notempty' => array(
                'rule' => array('notempty'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
        'email' => array(
            'email' => array(
                'rule' => array('email'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
        'is_public' => array(
            'boolean' => array(
                'rule' => array('boolean'),
                //'message' => 'Your custom message here',
                //'allowEmpty' => false,
                //'required' => false,
                //'last' => false, // Stop validation after this rule
                //'on' => 'create', // Limit validation to 'create' or 'update' operations
            ),
        ),
    );
    
    /**
     * getById
     * 
     * @param numeric $id
     */
    public function getById($id)
    {
        $id = (int) $id;
        $result = $this->find(
            'first',
            array(
                'conditions' => array(
                    'id' => $id
                )
            )
        );
        return $result;
    }
}

SQLは見当たらない。生のSQLを書かないのが流儀である。まぁ、サブクエリとか書くときは普通に生で書いちゃうんだけど・・・少なくとも通常のCRUDはORMを使うべきだと思う。

理由

Symfonyの記事を参考に読んだ。以下は抜粋である。

データベースはリレーショナルです。一方でPHP 5とsymfonyはオブジェクト指向です。オブジェクト指向のコンテキストでもっとも効果的にデータベースにアクセスするには、オブジェクトをリレーショナルなロジックに変換するインターフェイスが求められます。

リレーショナルから取り出すものは単なるデータの集合に過ぎないが、オブジェクトを取り出せるならコードとの相性もいいよね。

$data = "{'name' : 'pochi' , 'age' : 28}";
$data = new Dog('pochi', 28);
$data->cry();// こんな感じ

抽象化レイヤーの主な利点は、移植性です。これによって、プロジェクトの真っ最中でも、別のデータベースに切り替えることができます。

そう!DBに依存した部分の変換(泥臭い仕事)はフレームワークがこなす。しかし、プロジェクトの途中でDBが変わるなんてあるのか・・・?

詳しくはドキュメントを参照するのが良いんだが、まぁとりあえず書いてみる。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * getByAge
     * 年齢でユーザを検索する
     * @param numeric $age
     */
    public function getByAge($age)
    {
        $age = (int) $age;
        $result = $this->find(
            'all',
            array(
                'conditions' => array(
                    'age' => $age
                )
            )
        );
        return $result;
    }
}

前述と違うのは結果が複数あるのでallを使用した。条件が複数ある場合は以下のようにする。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * getByAgeAndGender
     * 年齢と性別でユーザを検索する
     * @param numeric $age
     * @param numeric $gender
     */
    public function getByAgeAndGender($age , $gender)
    {
        $age    = (int) $age;
        $gender = (int) $gender;
        $result = $this->find(
            'all',
            array(
                'conditions' => array(
                    'age'    => $age,
                    'gender' => $gender,
                )
            )
        );
        return $result;
    }
}

上述のように配列の要素を増やすだけで良い。

範囲

日付や数値などで等号ではなく大なり小なりを指定して特定の範囲に含まれるレコードが欲しい場合がある。以下のようにすると実現できる。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * getByAgeRangeAndGender
     * 年齢の範囲と性別でユーザを検索する
     * @param numeric $minAge
     * @param numeric $maxAge
     * @param numeric $gender
     */
    public function getByAgeRangeAndGender($minAge, $maxAge , $gender)
    {
        $minAge = (int) $minAge;
        $maxAge = (int) $maxAge;
        $gender = (int) $gender;
        $result = $this->find(
            'all',
            array(
                'conditions' => array(
                    'gender' => $gender,
                    'age <' => $maxAge,
                    'age >' => $minAge,
                )
            )
        );
        return $result;
    }
}

若干気持ち悪いが連想配列なのでまぁしょうがない。

OR

conditionsに追加すると全てANDで結合した条件となる。ORにする場合は以下のように記述する。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * getByAgeRangeAndGender
     * 年齢の範囲と性別でユーザを検索する
     * @param numeric $minAge
     * @param numeric $maxAge
     * @param numeric $gender
     */
    public function getByAgeAndGender($minAge, $maxAge , $gender)
    {
        $minAge = (int) $minAge;
        $maxAge = (int) $maxAge;
        $gender = (int) $gender;
        $result = $this->find(
            'all',
            array(
                'conditions' => array(
                    'gender' => $gender,
                    'or' => array(
                        'age >' => $maxAge,
                        'age <' => $minAge,
                    )
                )
            )
        );
        return $result;
    }
}
NOT

NOTにする場合は以下のように記述する。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * getTarget
     * 血液型が入力されているユーザをサービスのターゲットとして検索する
     * @param string $type
     */
    public function getTarget($type)
    {
        $result = $this->find(
            'all',
            array(
                'conditions' => array(
                    'not' => array(
                        'blood_type' => null
                    )
                )
            )
        );
        return $result;
    }
}
順序

以下のようにすることでORDER BYと同じになる。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * getByAgeAndGender
     * 年齢と性別でユーザを検索する
     * @param numeric $age
     * @param numeric $gender
     */
    public function getByAgeAndGender($age , $gender)
    {
        $age    = (int) $age;
        $gender = (int) $gender;
        $result = $this->find(
            'all',
            array(
                'conditions' => array(
                    'age'    => $age,
                    'gender' => $gender,
                ),
                'order' => array(
                    'age desc',
                    'gender asc',
                )
            )
        );
        return $result;
    }
}
結合

例えば職業テーブルが存在しユーザが職業IDを持つ場合に結合する必要もあるはずだ。以下のようにして結合する。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * getByOccupation
     * @param string $occupation
     */
    public function getByOccupation($occupation)
    {
        $alias = 'Occupation';
        $result = $this->find(
            'all',
            array(
                'fields' => array(
                    "`{$this->name}`.`id`",
                    "`{$this->name}`.`name`",
                    "`{$alias}`.`name`",
                ),
                'conditions' => array(
                    "`{$this->name}`.`gender`" => $gender,
                ),
                'joins' => array(
                    array(
                        'type'       => 'LEFT',
                        'table'      => 'occupations',
                        'alias'      => $alias,
                        'conditions' => "`{$this->name}`.`occupations_id` = `{$alias}`.`id`",
                    )
                ),
            )
        );
        return $result;
    }
}

fieldsを使用しない場合は結合したテーブルのカラムが取り出せない。結合しないクエリにおいて、fieldsを使用しない場合に発行されるクエリは以下のとおりとなる。

SELECT * FROM `user` WHERE `id` = ?;

fieldsを使用しないとアスタリスクとなり全てのカラムがfetchされるので注意が必要だ。

■保存

CakePHPでは明確にInsertとUpdateが分離されていない。プライマリキーがセットされている場合はUpdateの扱いとなる。

Insert

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * register
     * 登録してIDを返す
     * @param string $name
     * @param numeric $age
     */
    public function register($name, $age)
    {
        $age    = (int) $age;
        $result = $this->save(
            array(
                'name' => $name,
                'age'  => $age,
            )
        );
        return ($result)? $this->getLastInsertID() : $result;
    }
}

Update

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * register
     * 登録してIDを返す
     * @param string $name
     * @param numeric $age
     * @param numeric $id
     */
    public function register($name, $age, $id)
    {
        $id    = (int) $id;
        $result = $this->save(
            array(
                'id'   => $id,
                'name' => $name,
                'age'  => $age,
            )
        );
        return $result;
    }
}

■削除

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * del
     * @param numeric $id
     */
    public function del($id)
    {
        $this->delete((int) $id);
    }
}

第二引数でcascadeも指定できるけど、MySQLで直接定義してるよね・・・。以下のように条件で削除することもできる。

class User extends AppModel {
    public $name = 'User';
    // validationは省略
        
    /**
     * del
     * @param string $name
     */
    public function del($name)
    {
        $this->deleteAll(array(
            'name' => $name
        ));
    }
}

大体モデルはコンナ感じでいいかな。

CakePHPを使う準備をする

今更な感があるがメモとして残しておく。さらに面倒なのでローカルのXAMPPで説明する。

■ダウンロード

以下のようにダウンロードして解凍する。

mkdir cake.sample.justoneplanet.info
cd cake.sample.justoneplanet.info/
wget https://github.com/cakephp/cakephp/tarball/1.3.10
tar xvzf cakephp-cakephp-1.3.10-0-g8671aa3.tar.gz
rm cakephp-cakephp-1.3.10-0-g8671aa3.tar.gz
rm -fr cakephp-cakephp-b0abad1/

以下のような状態になっている。

  • cake.sample.justoneplanet.info/app
  • cake.sample.justoneplanet.info/cake
  • cake.sample.justoneplanet.info/plugins
  • cake.sample.justoneplanet.info/vendors
  • cake.sample.justoneplanet.info/index.php
  • cake.sample.justoneplanet.info/README

■Apacheの設定

まぁ、バーチャルホストの設定を変更する。

vi /Applications/XAMPP/etc/extra/httpd-vhosts.conf
<VirtualHost *:80>
    DocumentRoot "/Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app/webroot"
    ServerName cake.sample.justoneplanet.info
    ServerAlias www.cake.sample.justoneplanet.info
#    SetEnv CAKE_ENV default
</VirtualHost>

hostsファイルを変更しApacheを再起動する。

■パーミッションの設定

起動すると大量のエラー文が表示されるので、以下のコマンドを実行してパーミッションを変更する。

chmod -R 0777 app/tmp/

また、以下のコマンドを実行して「Security.salt」と「Security.cipherSeed」を変更する。

vi app/config/core.php

■データベースの設定

ファイルを直接編集しても良いんだけど、せっかくなのでbakeを使う。

./cake/console/cake bake

以下のように表示されるので順に設定する。

Welcome to CakePHP v1.3.10 Console
---------------------------------------------------------------
App : app
Path: /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app
---------------------------------------------------------------
Your database configuration was not found. Take a moment to create one.
---------------------------------------------------------------
Database Configuration:
---------------------------------------------------------------
Name:
[default] > default
Driver: (db2/firebird/mssql/mysql/mysqli/odbc/oracle/postgres/sqlite/sybase) 
[mysql] > mysql
Persistent Connection? (y/n) 
[n] > n
Database Host:
[localhost] > localhost
Port?
[n] > n
User:
[root] > hogehoge
Password:
> **********
Database Name:
[cake] > sample
Table Prefix?
[n] > n
Table encoding?
[n] > utf8

設定が終わると確認メッセージが表示され他のデータベース設定をするか聞かれる。

Look okay? (y/n) 
[y] > y
Do you wish to add another database configuration?  
[n] > y

今回は以下のようにしてもう一つ設定する。

Welcome to CakePHP v1.3.10 Console
---------------------------------------------------------------
App : app
Path: /Applications/XAMPP/xamppfiles/htdocs/cake.sample.justoneplanet.info/app
---------------------------------------------------------------
Your database configuration was not found. Take a moment to create one.
---------------------------------------------------------------
Database Configuration:
---------------------------------------------------------------
Name:
[default] > production
Driver: (db2/firebird/mssql/mysql/mysqli/odbc/oracle/postgres/sqlite/sybase) 
[mysql] > mysql
Persistent Connection? (y/n) 
[n] > n
Database Host:
[localhost] > localhost
Port?
[n] > n
User:
[root] > hogehoge
Password:
> **********
Database Name:
[cake] > sample
Table Prefix?
[n] > n
Table encoding?
[n] > utf8

開発用と本番用で設定を用意した。開発上は関係ないと思われるが、以前に使用していたdevelopmentという名前だと配置した段階でdefaultという設定名が必要になるようなので、開発環境はdefaultという名前を用いることにした。

app/app_model.php

以下のように記述して本番環境用の設定も読み込めるようにする。

class AppModel extends Model {
    /**
     * __construct
     * @param mixed $id
     * @param mixed $table
     * @param mixed $ds
     */
    public function __construct($id = false, $table = null, $ds = null)
    {
        $this->useDbConfig = Configure::read('Config.environment');
        parent::__construct($id, $table, $ds);
    }
}

■開発環境と本番環境の設定の分離

以下のコマンドを実行する。

vi app/config/bootstrap.php
mkdir app/config/environment

以下の記述を最終行に付加する。

Configure::write('Config.environment', isset($_SERVER['CAKE_ENV']) ? $_SERVER['CAKE_ENV'] : "default");
require_once("environment/" . basename(Configure::read('Config.environment') . ".php"));

新しくできたディレクトリに以下の2ファイルを追加する。

default.php

開発用設定ファイル。

Configure::write('debug', 2);
Configure::write('Cache.disable', true);

エラーメッセージを表示するのと、キャッシュを無効にしておく。

production.php

本番用設定ファイル。

Configure::write('debug', 0);
Configure::write('Cache.disable', false);

エラーメッセージは見せないようにする。

■おまけ

PHP5.3以上を使っていると思うので、core.php以下の部分をコメントアウトし引数を変更する。

date_default_timezone_set('Asia/Tokyo');