[PHP][Yii] モデルでタイムスタンプを自動更新する

Yii 1.1 のモデル(CModel)で自動更新する方法のメモ。

参考: Yii 1.1: How to automate timestamps in ActiveRecord models

CActiveRecord::rules()で設定する

/**
 * @return array validation rules for model attributes.
 */
public function rules()
{
    return array(
        // 略
        array('modified','default',
              'value'=>new CDbExpression('NOW()'),  // PHPのタイムスタンプを使うならdate('Y-m-d H:i:s')など
              'setOnEmpty'=>false,'on'=>'update'),
        array('created,modified','default',
              'value'=>new CDbExpression('NOW()'),
              'setOnEmpty'=>false,'on'=>'insert')
    );
}

CActiveRecord::beforeSave()で更新する

public function beforeSave() {
    if ($this->isNewRecord)
        $this->created = new CDbExpression('NOW()');

<pre><code>$this-&gt;modified = new CDbExpression('NOW()');

return parent::beforeSave();
</code></pre>

}

CTimestampBehaviorを使う

    // CActiveRecord::behaviors()
    public function behaviors(){
        return array(
            'CTimestampBehavior' => array(
                'class' => 'zii.behaviors.CTimestampBehavior',
                'createAttribute' => 'created', // 作成日時のカラム名(デフォルト: create_time)
                'updateAttribute' => 'modified', // 更新日時のカラム名(デフォルト: update_time)
                'setUpdateOnCreate' => true,    // 作成時にupdateカラムも更新するか(デフォルト: false)
            )
        );
    }

どれを使うかはお好みですね。

PHPのCache_Lite で lifeTime を場所によって変える時の注意点

PEAR の CacheLite を、短期キャッシュと長期キャッシュに分けて運用する場合、普通に automaticCleaningFactor を使っていると、思った通りに動作しなくなります。
 
automaticCleaningFactor は、現在設定されているlifeTimeを基準に、cache_dirの中の全ての古いファイルを掃除してしまいます。
 
save() で指定した group_id の古いファイル限定で掃除してくれれば融通がき くのですが、そもそも clean() の実装がそうはなっていない様子。
 
PEAR Bugにもこの問題が報告されていますが、現在のAPIを変えずに実装するのは困難として対応打ち切りになっています。
その代わり、解決案としてキャッシュの寿命別にフォルダ分けをする方法が紹介されていました。
 

Best practice then would appear to be that you create directories for each of your caches if you want to be using this autocleanfactor.
ここでの最良の習慣は、autocleanfactorを使う場合には(用途,寿命の違う)それぞれのキャッシュごとにディレクトリを分けて作る事です。

 
参考:
non-specific automaticCleaningFactor(PEAR Bug)
Cache_Lite(PEARマニュアル)

PHPでDBレコードをソートする

レコード…連想配列の配列…は、MDB::fetchAll() や DB::getAll() の結果の書式で見られるように一般的なデータ構造書式です。
たまにはコードを上げてみよう、ということで汎用関数としてレコードソート関数を作ってみました。実装コードおよびサンプルは下のほうにあります。
特に著作権等は主張しませんのでご自由にお使いください。
 
書式:

int sort_records(&$records, $key, $descent = false);


実装コード:

/**
 * DBなどのレコードを任意のキーでソートする。
 * PHP 4.4.9, PHP 5.2.8 で動作確認。
 *
 * @author ecoop.net
 * @access public
 * @param array &$records DBなどレコードの配列
 * @param string $key ソートの基準にするカラムの名前。省略時は第一カラムでソート。
 * @param boolean $descent true なら降順で結果を返す。省略時は false
 * @return bool 成功なら true を返す
 */
function sort_records(&$records, $key = null, $descent = false){
  if(!$records || !is_array($records)){ return false; }
   
  // $key が省略された場合は第一レコードの第一カラムを名前を取得
  if(is_null($key)){
    $t = array_values($records);
    $t = array_keys($t[0]);
    if(!$t){ return false; }
    $key = $t[0];
  }
  // —-
  $map = array();
  foreach($records as $i => $v){
    if(!is_array($v) || !isset($v[$key])){ return false; }
    $map[$v[$key] .’_’. $i] = $v;
  }
  
  if($descent){
    $res = krsort($map);
  }else{
    $res = ksort($map);
  }
  $records = array_values($map);
  return $res;
}

利用例:

// 元データ
$records = array(
array(‘age’ => 24, ‘name’=>’john’),
        array(‘age’ => 33, ‘name’=>’george’),
        array(‘age’ => 56, ‘name’=>’bill’),
        array(‘age’ => 50, ‘name’=>’steven’),
);
// name カラムでソート
sort_records($records, ‘name’);
print_r($records);

結果:

Array
(
    [0] => Array
        (
            [age] => 56
            [name] => bill
        )
    [1] => Array
        (
            [age] => 33
            [name] => george
        )
    [2] => Array
        (
            [age] => 24
            [name] => john
        )
    [3] => Array
        (
            [age] => 50
            [name] => steven
        )
)

PHPのsession_regeneration_id とセッションフィクシエーション対策

CSRF 対策のうち、セッションフィクセーションを避けるために使われる
session_regenerate_id() 関数について、ITProで間違い(になる場合がある)記事を見つけたのでメモ。
 
関数の知られざる引数(print_r関数、session_regenerate_id関数)[より

同じように、PHP 5.1からはsession_regenerate_id関数にも第1引数が指定されました。第1引数をtrueに指定すると、古くなったセッションは削除されるようになります。
 
もともとセッション固定攻撃を防ぐために用意された関数ではなく、PHP 5.1 までの同関数には古いセッションファイルを削除する機能が無かったため、セッション固定攻撃への対策としては不十分でした。
 
今後は、session_regenerate_id関数の引数に必ずtrueを指定することを忘れないようにしましょう。また、それまでのPHPで使う場合は、以下のコードを使うことで、セッション固定攻撃を防ぐことができます。

のように、session_regenerate_id() の第一引数に true を常に指定すべきと書かれていますが、session_regenerate_id(true) はむやみに使ってはなりません。
 
ショッピングカートなど限定的な用途でセッションを使っている場合は問題ないのですが、常にログインが必要なサイトなどで非同期で複数のリクエストを実行する可能性がある場合、古いセッションが削除されると問題になりことがあります。
 
たとえば同じ HTML 内に認証が必要な画像などの埋め込みコンテンツが複数あったり AJAX で認証が必要なデータにアクセスする場合、一つのセッションIDで複数のリクエストを行うため、2リクエスト目以降も古いセッションで認証することになるからです。
 
代替案として、session_regenerate_id(false) と session_write_close() を組み合わせて古いセッションのみ有効期間を設定してやるとよい、とsession_regenrate_idのコメント欄に書かれていました。
 
実装例:

// ユーザがリクエストしたセッションIDからセッション情報をロードする。
session_start();
// —————————————————–
// 以下セッションフィクセーション対策
if(isset($_SESSION[‘expire’]) && $_SESSION[‘expire’] < time()){
    // 過去に session_regenerate_id() で置き換えられた古いセッションによるアクセス。
    // 有効期限を過ぎていればセッション情報をクリアする。
    $_SESSION = array();
}
 
// 古いセッションは2分間で消えるように設定。
$_SESSION[‘expire’] = time() + (2*60);
// 有効期限を記録するため、一旦明示的にセッションを閉じ、$_SESSION の中身を古いセッションIDで保存する。
session_write_close();
 
// 閉じたセッションを開きなおす。
session_start();
// セッションIDを更新し、$_SESSION を古いセッションIDから切り離す。
session_regenerate_id();
// 新しいセッションID は有効期限なし。
unset($_SESSION[‘expire’]);
// ——————————————-
// セッションフィクセーション対策ここまで。
// あとは通常通り。
$_SESSION[‘user_id’] = “1234”;

 
また、session_regenerate_id() だけではセッション所有者のリクエストによる XSRF は防ぐことはできないため、こちら(開発者のための正しいCSRF対策)を参考にワンタイムトークン等も適宜組み合わせましょう。

PHPで任意のファイルデスクリプタを開く

PHP には fdopen がないので、子プロセスについては /dev/fd/<FD番号> で代用してます。
PHP の機能ではありませんが。。

<?php
// child.php: 子プロセス(parent.php から実行される)
$fd = 4; // 親プロセスとのやり取りに使うファイルデスクリプタ(0:stdin, 1:stdout, 2:stderr)
$fdfile = “/dev/fd/$fd”;
if(!file_exists($fdfile)){
   die(“ファイルデスクリプタが開かれていません”);
}
 
$pipe = fopen($fdfile, “r”);
while(!feof($pipe)){
  echo fread($pipe, 1024);
}
fclose($pipe);

 

<?php
// parent.php: 親プロセス
$fd = 4; // 子プロセスとの通信に使うファイルデスクリプタ
$fp = proc_open(“php child.php”, array(
  $fd=>array(‘pipe’, r),
),$pipes);
 
fwrite($pipes[$fd], “Hello world!!”);
fclose($pipes[$fd]);
 
proc_close($fp);

 
FreeBSD の場合、標準では /dev/fd に 0,1,2 しかないため動作しません。
次のようにしてfdescfs を /dev/fd にマウントすればプロセスごとのファイルデスクリプタを自動作成してくれるようになります。

# echo “fdescfs /dev/fd fdescfs rw 0 0” >> /etc/fstab
# mount /dev/fd

または /dev/fd を使わない実装に置き換える事でも動作します。
この場合、ファイルデスクリプタが開いているかの確認には fstat [-p PID] を使うといいかもしれません。

// child.php: FreeBSD で devfs を使っている場合の代替実装。
$fd = 4;
$pipe = popen(‘cat < &'.$fd, 'r');
echo fread($pipe, 1024)
pclose();

PHPのproc_open の落とし穴

http://d.hatena.ne.jp/ichii386/20071116/1195153132
 
proc_open()の説明に書いてある例通りやると問題があるので [[stream_select()|]http://jp.php.net/manual/ja/function.stream-select.php] をあわせて使いましょう、という話。
一つのストリームを読み書きしてる間に他のストリームからの入力がたまるとバッファがいっぱいになってしまい、そこで書き出し側の処理がブロックしてしまう可能性がある。
stream_select を使うと、複数のストリームの中から入出力要求がきているストリームのみを選択して読み書きすることが出来ると。
 

// http://d.hatena.ne.jp/ichii386/20071116/1195153132 から引用:
$stdout = $stderr = ”;
while (feof($pipes[1])= false || feof($pipes[2]) = false) {
    $ret = stream_select(
        $read = array($pipes[1], $pipes[2]),
        $write = null,
        $except = null,
        $timeout = 1
    );
    if ($ret= false) {
        echo “error\n”;
        break;
    } else if ($ret
= 0) {
        echo “timeout\n”;
        continue;
    } else {
        foreach ($read as $sock) {
            if ($sock= $pipes[1]) {
                $stdout .= fread($sock, 4096);
            } else if ($sock
= $pipes[2]) {
                $stderr .= fread($sock, 4096);
            }
        }
    }
}

error_reporting=E_ALL のススメ

初歩ではありますが、error_reporting = E_ALL ^ E_PHP_NOTICE な環境に慣れている人のためのコーディングルールを探してもすぐに見つけられなかったため、書いておきます。
 
□変数は代入してから使うこと
定義済みの(値が入っている)変数のみ参照するべきです。
もしもグローバル変数など、値が入っているかわからない場合は isset() や empty() で存在を確認しましょう。接頭文字 @ を使ってもいいですが、 isset() や empty() のほうが望ましいです。
なお、null を代入した変数も定義済みの変数です。

<?php
// —————–
// 未定義の変数参照
// —————–
// NG
echo $foo; // PHP Notice: Undefined variable: foo
// ——–
// OK
$foo = “abc”;
echo $foo;

 
□配列、連想配列の要素も存在するもののみ参照すること
配列の要素の一つ一つについても、変数同様に定義済みのもののみ参照しましょう。
リクエスト値など、値が入っているかわからない場合は isset() や empty() で存在を確認しましょう。

<?php
// —————–
// 未定義の配列要素/連想配列要素の参照
// —————–
// NG
$id = $_GET[‘id’]; // PHP Notice: Undefined index: id (引数省略リクエストの場合)
// ——–
// OK ※省略不可能なパラメータの場合
if(empty($_GET[‘id’])){ // または if( !isset($_GET[‘id’]) )
// エラー処理
exit;
}
$id = $_GET[‘id’];
// ——–
// OK-2 ※省略可能なパラメータの場合
$id = isset($_GET[‘id’])? $_GET[‘id’]: null;

 
□定数の定義方法に注意しましょう。
定数の定義関数は

define(定数名文字列, 値)

です。
第一引数はシンボルではなく文字列です。’〜’ や “〜” で括ることを忘れないようにしましょう。

<?php
// —————–
// 未定義の定数参照(定義時)
// —————–
// NG
define(FOO, 123); // PHP Notice: Use of undefined constant FOO – assumed ‘FOO’
// ——–
// OK
define(“FOO”, 123);

 
□文字列中の連想配列のキー指定時もクォーテーションにも注意

“{$assoc[key]}”

は key が定数値とみなされます。
連想配列のキーをダブルクォーテーションまたはシングルクォーテーションで囲むようにしましょう。

<?php
// —————–
// 未定義の定数参照(文字列内での連想配列参照)
// —————–
// NG
$foo=array();
$foo[“name”] = ”John”;
echo “{$foo[name]}\n”; // Use of undefined constant name – assumed ‘name’
// ——–
// OK
$foo=array();
$foo[“name”] = ”John”;
echo “{$foo[‘name’]}\n”;

.htaccess で PHP の表示言語を切り替える

php_value, php_flag を使って自動文字コード変換をする tips です。
  
.htaccess:

# ソースの文字コード(SJIS, EUC-JP, UTF-8 など mb_convert_encoding()で指定する書式)
php_value mbstring.internal_encoding UTF-8
# ヘッダで表明する出力文字コード(Shift_JIS, EUC-JP, UTF-8 など Content-Type で指定する書式)
php_value default_charset Shift_JIS
# 変換に使う出力文字コード(mb_convert_encoding()で指定する書式)
php_value mbstring.http_output SJIS
# 変換文字コードが属する言語
php_value mbstring.language Japanese
# フォームなどの入力文字コードは自動判別させる
php_value mbstring.http_input auto
# 入力の自動変換を有効化(Off で無効化)
php_flag mbstring.encoding_translation On
# 出力の自動変換を有効化(空値で無効化)
php_value output_handler mb_output_handler

なお、動作には

1.CGI モードではなく PHP-CLIモードで動いていること
2. httpd.conf で AllowOverride Options または AllowOverride All など Options の変更ができるように設定されていること。

が必要です。


ちなみに余談ですが、同じことを .htaccess なしでやろうとした場合は次のようになります。

<?php
// 自動変換処理
mb_language(“Japanese”);
// ソース文字コード
mb_internal_encoding(“UTF-8”);
// 出力文字コード
mb_http_output(“SJIS”);
header(“Content-Type:text/html;charset=Shift_JIS”);
 
// mb_output_handler で出力をフィルタリング
ob_start(“mb_output_handler”);
register_shutdown_function(‘ob_end_flush’);
 
// 入力をフィルタリング
// (mb_convert_encoding($value, mb_internal_encoding(), “auto, UTF-8”);
// を使い、 $_REQUEST, $_GET, $_POST, $_COOKIE など入力変数を変換すればエミュレートできるが
// 長くなるので省略)
 
// —————————–
// あとは普通の処理
 
echo “はろーわーるど\n”;

参考:
AllowOverride ディレクティブ(Apache 2.2 リファレンス)
PHP 出力制御関数(php.net)

PHP の 5.2.5 が mysql 5.1.23-rc と非互換な件

先日 PHP 5.2.5 をビルドしようとしたところ

/usr/src/php-5.2.5/ext/mysqli/mysqli_api.c: In function
‘zif_mysqli_change_user’:
/usr/src/php-5.2.5/ext/mysqli/mysqli_api.c:420: error: ‘NET’ has no
member named ‘last_errno’
/usr/src/php-5.2.5/ext/mysqli/mysqli_api.c:420: error: ‘NET’ has no
member named ‘last_errno’
/usr/src/php-5.2.5/ext/mysqli/mysqli_api.c:420: error: ‘NET’ has no
member named ‘last_error’
/usr/src/php-5.2.5/ext/mysqli/mysqli_api.c: In function
‘zif_mysqli_kill’:
 
以下略

というエラーが出てコンパイルができなかった。
調べたら php bug に投稿がありました。
 
http://bugs.php.net/bug.php?id=44137
 
5.1.22-rc ならいけるのに MySQL 5.1.23-rc だとダメらしい。
そして開発チームの回答。

[18 Feb 7:41pm UTC] uw@php.net

A little more info: we are discussing this internally at MySQL. Its
possible that the change which has caused this (unintended) problem will
be reverted soon.
 
[24 Feb 1:01am UTC] jani@php.net
 
And it’s not PHP problem but Mysql problem since they broke the BC in
their libs/headers. 🙂
 
[24 Feb 1:03am UTC] jani@php.net
 
And please, in the future don’t report bugs caused by some 3rd party
library here which isn’t even released as stable yet.

曰く、
「mysql が悪い。こんな(不本意な)問題を引き起こしてくれる変更は mysql 開発側がじきに元に戻してくれるかもしれないからそれを待て。
そもそもサードパーティのライブラリに安定リリース版じゃないのを使ってるのはサポート外だから報告しないでね」
っていうことだそうで。
 
…とはいっても、FreeBSD ports の databases/mysql51-client (実体は mysql51-server) が 5.1.23-rc なのが悩ましいところです。