環境: CakePHP 1.3
特定のフィールドの値をシリアライズしてから保存する Serializable ビヘイビアを作りました。配列などの構造を持つデータを、ひとつのフィールドに保存することができます。
serializable.php – gist
(いずれ正式なリポジトリに移すかもしれない)
fields オプションに指定したフィールドがsave時にJSONでエンコードして保存され、find時には自動的にデコードされます(PHP標準の serialize ではなくJSONを使用するのは互換性を重視しているため)。
var $actsAs = array('Serializable'
=> array('fields' => array('field1', 'field2')));
// 保存する前に自動的にJSONエンコードされる
$Model->save(array(
'field1' => array(1,2,3),
'field2' => array('assoc' => 'data')
));
// find時に自動でデコードされる
$values = $Model->find('first');
// field1 == array(1,2,3)
// field2 == array('assoc' => 'data')
この種のフィールド・シリアライズには様々なバリエーションが考えられます:
- JSON以外の形式でエンコードする
- 複数のフィールドの値をひとつにまとめて保存する
- シリアライズしたフィールドを別のモデルにマップして、hasOne関連のように扱えるようにする
このbehaviorは最も単純な作りになっているので、適当なメソッドを上書きすれば上のような動作も実現できるのではないかと思います。
環境: CakePHP 1.3
モデル用のコールバック関数を動的に登録/解除できるようにする Observable Behaviorを作りました。コールバックを使用するためだけにサブクラスを作ったりビヘイビアを作ったりする必要がなくなります。
observable.php – gist
(いずれ正式なリポジトリに移すかもしれない)
callable なオブジェクトなら何でもコールバックとして使用できますが、PHP 5.3のクロージャと組み合わせると特に便利です。
$actsAs = array('Observable');
// addObserver メソッドで登録。戻り値は一意なid
$oid = $Model->addObserver('beforeSave', function($model) {
// 第1引数はモデル、それ以後は通常のコールバック引数
$model->data[$model->alias] = ....;
// false を返すとコールバックの実行が停止する
// 何も返さなかった場合(null)は true を返したのと同じことになる
//return false;
});
// 不要になったら取り除く
$Model->removeObserver($oid);
コメント中にもある通り、明示的に引数を返さなかった場合は true と見なされるので、 beforeSave や beforeValidate で戻り値を忘れてその後の処理が実行されない……といったことはありません。
5.2以前でも使用できるmcryptについてはこちら。
PHP 5.3でopensslモジュールの機能が拡張され、共通鍵暗号による暗号化が利用可能になった。同時に待望の疑似乱数生成器も用意された。これで今までmcryptが使用できなかったWindowsでも、暗号学的強度を持った乱数が生成できる(実際に試していないが、OpenSSLはWindowsのCryptAPIから乱数を得ている)。
まだマニュアルにも詳しい情報が載っていないので、ソースを読みながら使い方を探ってみた。以下はphp-5.3.3のソースに基づく。ただしコミットログを見る限り仕様も機能もまだ安定しているとは言いがたいので、実際に使用するにはもう少し様子を見た方が良いかもしれない。
暗号化する際に決めておくべきこと:
- 鍵(以下 KEY)… マニュアルでは
$password と表記されているが、いわゆる「パスワード」ではない。暗号化に使用する鍵であり、十分にランダムでなければならない。
- 暗号アルゴリズム+モード(以下 CIPHER)… 利用可能な一覧は openssl_get_cipher_methods から得られる。
暗号化/復号を行う関数 encrypt/decrypt は次のようになる。
//実際の暗号化/復号を行う補助関数
function _callOpenSSL($func, $msg, $iv) {
$opensslFunc = "openssl_{$func}";
return $opensslFunc($msg, CIPHER, KEY, true, $iv);
};
/**
* @param string 暗号化したいメッセージ
* @return array (暗号文, 初期化ベクトル) の配列
*/
function encrypt($msg)
{
//初期化ベクトルを生成
$ivSize = openssl_cipher_iv_length(CIPHER);
$iv = openssl_random_pseudo_bytes($ivSize, $cryptStrong);
if($iv === false || !$cryptStrong) {
// 乱数生成失敗
return false;
}
//ダミーの初期化ベクトル
$dummyIV = str_repeat("x", $ivSize);
//メッセージの暗号化
$cryptMsg = _callOpenSSL('encrypt', $msg, $iv);
//初期化ベクトルの暗号化
$cryptIV = _callOpenSSL('encrypt', $iv, $dummyIV);
return array($cryptMsg, $cryptIV);
}
/**
* @param string 暗号文
* @param string 初期化ベクトル
* @return string 平文
*/
function decrypt($cryptMsg, $cryptIV)
{
//ダミーの初期化ベクトルを生成
$ivSize = openssl_cipher_iv_length(CIPHER);
$dummyIV = str_repeat("x", $ivSize);
//初期化ベクトルの復号
$iv = _callOpenSSL('decrypt', $cryptIV, $dummyIV);
//メッセージの復号
$msg = _callOpenSSL('decrypt', $cryptMsg, $iv);
return $msg;
}
実行:
define('CIPHER', 'aes-128-cbc');
define('KEY', '...暗号化のキー...');
$msg = "めっせーじ";
// 暗号化
$crypt = encrypt($msg);
var_dump(bin2hex($crypt[0]));
// 復号
$plain = decrypt($crypt[0], $crypt[1]);
var_dump($plain);
mcrytと違ってブロック長に合わせたパディング(PKCS#5パディング)はopensslが自動でやってくれるので、自前でbase64エンコードしたりする必要はない。
openssl_random_pseudo_bytes の戻り値は、乱数の生成に失敗した場合は真偽値の false になる。また十分なエントロピーが得られなかった場合は第二引数に指定した変数が false になる(ただ滅多に起こらないはずである:OpenSSL FAQ参照)。
ちなみに鍵の生成も openssl_random_pseudo_bytes で行えばいい。
#コマンドラインにて16バイト=128ビットの乱数を生成
$ php -r 'echo base64_encode(openssl_random_pseudo_bytes(16));
bONq0KiSNIO5ww1ggwdFdQ==
//定数KEYの定義
define('KEY', base64_decode('bONq0KiSNIO5ww1ggwdFdQ=='));
パスワードから鍵を生成する必要がある場合は、自分でPBKDFを実装する必要がある。『PHP PBKDF2』で検索するといくらか実装例が見つかるようだ。またRubyの pkcs5_keyivgen(openssl の EVP_BytesToKey)互換の機能が必要な場合はOpenSSL::Cipher::Cipher#pkcs5_keyivgen の中身などを参照のこと。
openssl_random_pseudo_bytes の中身
実体はOpenSSLの RAND_pseudo_bytes(rand_lib.c) -> ssleay_rand_pseudo_bytes(md_rand.c) -> ssleay_rand_bytes(md_rand.c) で、そこから先は『暗号乱数インフラの初期化処理』を参照のこと。
ssleay_rand_pseudo_bytes と ssleay_rand_bytes の違いは RAND_R_PRNG_NOT_SEEDED を見逃すか否か。エラーがあったこと自体は戻り値で判定できるので、$crypto_strong ではこれを利用している。
環境: CakePHP 1.3
CakePHPはURLパラメータのエンコードを一切行わないため、特殊な文字がパラメータに入り込むと容易にルーティングが破綻する。ここで言う「URLパラメータ」とはRoute中に埋め込まれたパラメータ、namedパラメータ、passパラメータのことを指す。
例えば以下のようなRouteがあったとする。
/controller/action/:keyword
ここでパラメータkeywordに特殊な文字を与えると…
keyword => '%'
URL: /controller/action/%
結果: URLとして不正な形式なので"400 Bad Request"になる
keyword => '?foo'
URL: /controller/action/?foo
結果: ?以降はQueryStringと見なされてルーティングから除外され、Routeにマッチしなくなる
keyword => 'a/b/c'
URL: /controller/action/a/b/c
結果: /がそのままパラメータ区切りになるのでRouteにマッチしなくなる
この問題は単純に rawurlencode/rawurldecode を使うだけでは回避できない。またCakeの設計上の問題なので、簡単な修正方法もない。よって任意の文字が含まれる可能性のあるパラメータをURLに埋め込んではいけない。最も簡単な回避策は、URLパラメータを諦めてQueryStringを使うことである。
Router::url(array('?' => array('keyword' => $any_characters)));
問題の原因と回避策の考察
なぜ rawurlencode で回避できないかというと、CakePHPが mod_rewrite 経由で $_GET['url'] から元のURLを取得しているからだ。
RewriteRule ^(.*)$ index.php?url=$1 [QSA,L]
RewriteRuleの後方参照($1の部分)では%エンコーディングのデコードが行われる。またQueryStringから $_GET に格納される際にもPHPによってデコードが行われる。つまりCakeのルーティングが実行される前に、既に2回のデコードが行われている。よってもしこの問題を rawurlencode で回避するなら、3重に rawurlencode した上で rawurldecode しなければならない、ということになる。
ちなみに RewriteRule の[B]オプションを指定することで後方参照のデコードを回避することができる。この場合は rawurlencode は2回で済む。しかしそれでもURLが汚くなることは変わりないし、検索エンジンのロボット等もURL中の文字列を認識してくれないだろう。
RewriteRule ^(.*)$ index.php?url=$1 [QSA,L,B]
別の考え方として、%エンコーディング以外の安全なエンコーディングを用いる方法もある。例えば16進数のバイナリ表現に変換すれば、[a-f0-9]しか現れないので確実に安全である。
$encoded = bin2hex($data);
//[0-9a-f]しか含まれないことを事前にチェックしておくこと
$decoded = pack('H*', $encoded);
ただしこの方法だと長さは元の文字列の2倍になるし、可読性も全く損なわれる。ここまでするくらいならやはりQueryStringを使った方が良いと思う。
CakePHP 1.3 + SimpleTestでグローバル関数をMock化するためのラッパークラスを作ってみました。libs/ に置いて使用します。
global_function.php – gist
次のように任意の関数を呼び出せます。
//bootstrap.php 等で読み込んでおく
App::import('Lib', 'GlobalFunction');
GlobalFunction()->env('REMOTE_ADDR');
GlobalFunction()->gethostbyname('example.com');
テストケースでMockを使用したいとき、GlobalFunction::registerMock を呼び出します。戻り値はMockオブジェクトです。
function testRegisterMock() {
//使用する関数名を引数に渡す
$mock = GlobalFunction::registerMock('env', 'gethostbyname');
//通常のmockと同様に使用できる
$returnValue = '192.168.0.1';
$mock->expectOnce('env', array('REMOTE_ADDR'));
$mock->setReturnValue('env', $returnValue, array('REMOTE_ADDR'));
$mock->expectNever('gethostbyname');
$this->assertEqual($returnValue, GlobalFunction()->env('REMOTE_ADDR'));
//ClassRegistry::flush を呼び出せば元に戻る
ClassRegistry::flush();
//本物の関数envが呼ばれる
$addr = GlobalFunction()->env('REMOTE_ADDR');
}
注意:参照を引数に取る関数(array_push など)は使用できません。
単に field パラメータに指定するだけではうまく動作しないので、一旦 find(‘all’) で結果を取得してから Set::combine を使って同等の構造を得る。
// user_id => 件数
$tmp = $Post->find('all',
array('fields' => array('user_id', 'COUNT(*) AS posts_count'),
'group' => 'user_id'));
return Set::combine($tmp, '{n}.Post.user_id', '{n}.0.posts_count');
Set::combine は恐ろしく多機能なので、find(‘list’) をそのまま使うよりずっと多くの事ができる。例えば次のように、複数カラムで GROUP BY した結果をフラットな配列にまとめることができる。
// "user_id:category_id" => 件数
$tmp = $Post->find('all',
array('fields' => array('user_id', 'category_id', 'COUNT(*) AS posts_count'),
'group' => array('user_id', 'category_id')));
return Set::combine($tmp,
array('{0}:{1}', '{n}.Post.user_id', '{n}.Post.category_id'),
'{n}.0.posts_count');
値を連想配列にする事もできる。
// user_id => array("created_max" => 最大値, "created_min" => 最小値)
$tmp = $Post->find('all',
array('fields' => array('user_id',
'MAX(created) AS created_max',
'MIN(created) AS created_min'),
'group' => 'user_id'));
return Set::combine($tmp, '{n}.Post.user_id', '{n}.0');
CakePHP1.3で動的にコンポーネントを読み込むためのコンポーネント DynamicComponent を作成しました。
cakephp-dynamic-component
注意: Cake本来の機能を超える機能を提供するため、Cakeコアのprivateなメソッドやプロパティにアクセスしています。Cakeのバージョンが上がると使用できなくなる可能性があります。十分ご注意ください。
このコンポーネントを使うと次のようなことが可能になります:
- admin プレフィクスが付いているときだけ Auth コンポーネントを読み込む
- debug > 0 のときだけ DebugKit を読み込む
読み込みはコントローラの _initialize メソッドで loadComponents メソッドを使って行います。
class AppController extends Controller
{
var $components = array('DynamicComponent.Dynamic');
function _initialize() {
if(!empty($this->params["prefix"]) && $this->params["prefix"] == 'admin') {
$this->Dynamic->loadComponents('Auth');
}
if(Configure::read('debug') > 0) {
$this->Dynamic->loadComponents('DebugKit.Toolbar');
}
}
}
この方法で読み込んだコンポーネントの initialize メソッドからは、さらに別のコンポーネントを読み込むことができます。そのためコントローラから直接 Auth や DebugKit を読み込むよりは、状況に合わせた専用のコンポーネントを用意して、その中で Auth や DebugKit の読み込み・初期化を行うことをお勧めします。以下、Admin と Debug という2つのコンポーネントを作っています。
// app/controllers/components/admin.php
class AdminComponent extends Object
{
function initialize($controller) {
$controller->Dynamic->loadComponents('Auth');
//Authコンポーネントのセットアップ
$controller->Auth->loginAction = array(/*...*/);
}
}
// app/controllers/components/debug.php
class DebugComponent extends Object
{
function initialize($controller) {
$controller->Dynamic->loadComponents('DebugKit.Toolbar');
//その他debugに有用な設定など
$controller->set(...);
}
}
また prefix 名に応じた読み込みを行うオプションがあらかじめ用意されているので、コントローラの prefix 判定コードは省略できます。最終的に _initialize メソッドは次のようになります。
class AppController extends Controller
{
var $components = array('DynamicComponent.Dynamic'
=> array('prefix' => true));
function _initialize() {
if(Configure::read('debug') > 0) {
$this->Dynamic->loadComponents('Debug');
}
}
}
PHP 5.3.0 ではマジックメソッド __callStatic が追加された。グローバルに使用されるユーティリティクラスで、動的にメソッド名を生成したいときに便利。
class FooUtil {
static protected $_inc = array('test1' => 1,
'test2' => 2);
public static function __callStatic($method, $args) {
return isset(static::$_inc[$method])
? static::$_inc[$method] + $args[0] : $args[0];
}
}
echo FooUtil::test1(100); // => 101
echo FooUtil::test2(100); // => 102
echo FooUtil::testxxx(100); // => 100
同等の機能を PHP 5.2 より前の環境で再現したい場合、シングルトンパターンと __call を組み合わせるのが最も普通のやり方だと思う。
class FooUtil2 {
protected $_inc = array('test1' => 1,
'test2' => 2);
static private $__singleton = null;
public static function getInstance() {
if(self::$__singleton === null) {
self::$__singleton = new self;
}
return self::$__singleton;
}
public function __call($method, $args) {
return isset($this->_inc[$method])
? $this->_inc[$method] + $args[0] : $args[0];
}
}
echo FooUtil2::getInstance()->test1(100); // => 101
echo FooUtil2::getInstance()->test2(100); // => 102
echo FooUtil2::getInstance()->testxxx(100); // => 100
この方法の欠点は「呼び出し時のコードが長い」ことである。そこで個人的にはクラス名と同じ名前のグローバル関数を使う方法を提案したい。
function FooUtil3() {
static $singleton = null;
if($singleton === null) {
$singleton = new FooUtil3();
}
return $singleton;
}
class FooUtil3 {
protected $_inc = array('test1' => 1,
'test2' => 2);
public function __call($method, $args) {
return isset($this->_inc[$method])
? $this->_inc[$method] + $args[0] : $args[0];
}
}
echo FooUtil3()->test1(100); // => 101
echo FooUtil3()->test2(100); // => 102
echo FooUtil3()->testxxx(100); // => 100
この方法ならば __callStatic を使うのと比べて2文字長いだけだ。しかも他2つの方法よりもカプセル化の面では優れている。関数とクラスは「名前が同じ」というごく緩やかなつながりを持っているだけなので、静的な意味でも動的な意味でも、あとから自由に実装を切り替えられる。staticメソッドでなければならない、シングルトンでなければならない、といった制約はない。
これはグローバル変数や単純なレジストリ(CakePHPにおけるConfigureのような)を使うのとも違う。関数なので、どんな処理でも差し挟むことができる。関数の方がパラメータを持ってもいい。
FooUtil4($context)->test($parameters);
グローバル関数というと問題になるのは名前の衝突だが、関数名とクラス名では名前の傾向が異なるので、クラス名が衝突しないのであれば同じ名前の関数も衝突しない可能性が高いと考えられる。
あまり公のプロジェクトで採用する気にはならないが、内々に使うにはそれなりに便利なテクニックだと思う。
せっかく私のpatchが採用されたので、ブログにも書いておきます。
CakePHP 1.3からはプラグイン内の DataSource が使えるようになりますが、1.3RC1からは DataSource の driver もプラグインから読み込めるようになりました。
たとえばGithubのCakePHP datasources pluginでは、Cakeコアに含まれない様々なデータベースに対するDboドライバが提供されていますが、RC1ではこれを次のようにして読み込むことができます。
class DATABASE_CONFIG {
var $sqlite3 = array('driver' => 'Datasources.DboSqlite3',
...);
}
また driver を使用する DataSource をプラグインとして提供することも可能です。拙作のKeyValueSourceは次のように使用できます。
class DATABASE_CONFIG {
var $memcache = array('datasource' => 'KeyValueStore.KeyValueSource',
'driver' => 'KeyValueStore.KeyValueMemcache',
...);
}
プラグインが読み込む driver を app/models/datasources 内に作ることもできます。たとえば memcache ドライバを拡張した my_memcache ドライバを作った場合、次のように読み込みます。
// app/models/datasources/key_value/key_value_my_memcache.php
class DATABASE_CONFIG {
var $memcache = array('datasource' => 'KeyValueStore.KeyValueSource',
'driver' => 'my_memcache',
...);
}
(CakePHP 1.2.5)
ビヘイビアに定義されたメソッドは、モデル自身のメソッドであるかのように呼び出すことができます。
class FooBehavior extends ModelBehavior {
function foo($model){ /* ... */ }
}
class Bar extends AppModel {
var $actsAs = array('Foo');
}
// in Controllers
$this->Bar->foo();
しかしこの方法では Dynamic Finder のように動的に名前が変化するメソッドは実装できません。
ModelBehavior クラスの $mapMethods プロパティを使用すると、この制約を乗り越えることができます。$mapMethods は配列で、次のような構造をしています:
array( '/メソッド名に対する正規表現/' => '実際に呼び出されるメソッド' )
例えば '/^list/' => '_genericList' という設定を行った場合、listから始まる全てのメソッド呼び出しが、ビヘイビアの _genericList メソッドの呼び出しに変換されます。このとき _genericList メソッドの引数には元のメソッド名と引数が渡されます。ちょうど __call によるメソッドのオーバーロードと同じです。
この機能を活用して、あるカラムの値を配列として取得するための listColumn メソッドを作ってみます(Column はカラム名)。このメソッドは ListingBehavior に実装することにしましょう。
class ListingBehavior extends ModelBehavior {
var $mapMethods = array('/^list[a-zA-z0-9]+$/' => '_listColumn');
function _listColumn($model, $methodName, $query=array()) {
$column = substr($methodName, 4);
$query = am($query, array('fields' => array($model->alias.".".$column)));
$arr = $model->find('all', $query);
if(is_array($arr)) {
return Set::extract("/{$model->alias}/{$column}", $arr);
}
return $arr;
}
}
モデルにこのビヘイビアを組み込むと、任意のカラムに対して listXXX メソッドを呼び出すことができるようになります。
class User extends AppModel {
var $actsAs = array('Listing');
}
// in Controllers
$this->User->listId();
$this->User->listUsername(array('conditions' => 'active = 1'));
$this->User->listGroup_id();
メソッド名は小文字に変換されて渡されるので(これが仕様なのかバグなのか微妙なところです)、残念ながら Inflector::underscore を使うことはできません。