PHPでmysqlを適切に扱う方法

PHP データベース 2010-07-18 01:24:36
以前のエントリでSET NAMES問題に対して「MYSQLをやめる」ことを結論として書いた。
しかし、識者の人たちが「SET NAMESは禁止」とか「PDOは禁止」のような表現をしている事が、
この問題の理解をややこしくしているのではないかと思い直し、より実践的なまとめを書く事にした。

この記事を書き終える寸前にいいサイトを見つけてしまった。
http://d.hatena.ne.jp/jrofbyr/20081228/p1
ここの内容がほぼ結論じゃないかと思う。

というわけで、シチュエーション別にベストと思われる解決策を考えたつもりだが、以下は蛇足。
「PDOは禁止」と言われて困っている人がPDOとmysqliを比較するためのネタとして使ってくれれば大変光栄です。

データベースエンジン選定前

mysqlを使うかどうかも確定していない時は、mysqlを導入すべきかを検討しなおすと良い。
WebサーバとDBサーバを分けたい、堅牢性を求めたい時は、postgresqlが良い選択肢になる。
レプリケーションをしたい時は、Tokyo|Kyoto Tyrantが選択肢に入るかも知れない。

単純な速度比較でもpostgresqlは劣っていないし、手動vacuumも過去のものであるから、
選択可能な状況なら、迷わずpostgresqlを選択したほうが良い。

データベースエンジン選定後導入前

mysqlを使う事に決定してしまった。
phpもmysqlも「最新の安定版」を入れる事に決まったが具体的なバージョンには言及されていない。
あなたはバージョン決定に意見する事が出来るか、具体的なインストール作業を任されている。

この時あなたがするべきことは、php 5.2の出来る限り最新版を入れることだ。5.3が許されるなら尚良い。
そしてmysqlは5.1系(現在の最新安定版)を選ぶべきだ。
そして、php.iniとmy.cnfを編集し、SET NAMESを使わなくても文字化けしないような状態に設定すれば良い。

データベースエンジン導入後

phpもmysqlもバージョンが既に決まっており、インストールが済んでいる。
たとえば既存サイト上に新しいアプリを追加する時がこれに当てはまる。
アップグレードや既存システムに影響しそうな設定の変更は認められない。
しかし、スクリプト自体はこれから書く。

まず、mysqld < 5.0.7の場合は、SET NAMES問題を解決する方法は今のところ無い。
SET NAMES対象の文字コード以外がSQL文字列に一切含まれないように対策を取る必要がある。
もしもmysqld < 4.1.3なら、サーバサイドプリペアードステートメントすら使えないので、状況は絶望的である。

mysqld >= 5.0.7かつphp >= 5.1なら、PDOとサーバサイドプリペアードステートメントを使う手がある。
ただしこの時にSET NAMESと非プリペアードなSQLを併用する場合は、上記と同様の対策が必要になる。
さもなくば、PDOを使ってmy.cnfを読み込ませる手があるらしいが、正しい対処法なのかはテストしていない。
$pdo = new PDO('mysql:host=' . $host . ';dbname=' . $dbname, $user, $password, array(
    PDO::MYSQL_ATTR_READ_DEFAULT_FILE => '/var/www/my.cnf',
));
※MYSQL_ATTR_READ_DEFAULT_FILEは、Windows版だとうまく指定できないらしい?
[client]
default-character-set=sjis
mysqld >= 5.0.7かつphp >= 5.2.3なら、mysql拡張でmysql_set_charsetを使える。
この場合は逆にプリペアードステートメントが使えなくなる。

mysqld >= 5.0.7かつphp >= 5.0.5なら、mysqli拡張でmysqli_set_charsetを使える。
この場合はプリペアードステートメントも使えてSET NAMES問題も起こらない。
ただし最大の弱点として、利用者が少ないため、ノウハウの共有が困難かも知れない。
たとえば、レンタルサーバとかではmysqliを導入していない所が結構多いと思う。
開発自体は継続されているので、charset指定出来ないからPDO全否定、みたいな人はベターなのかも。
http://www.php.net/manual/ja/mysqli.overview.php

PEAR::DBを使うメリットは何も無い。
エミュレートプリペアードステートメントはSET NAMES問題の特効薬にはならないからだ。
もちろん、ある種のライブラリやラッパーがこの問題を解決する機能を提供してくれるかも知れない。
そういう観点では、PEAR::MDB2も同じだ。
しかし、MDB2には、mysqliが使えて、これを使いこなすための機能も整っているという利点がある。

「PDOをやめてmysqli(とMDB2)を使いましょう」と言われた場合に抵抗するための良い方法は思いつかない。
mysqliのラッパークラスをPDOに似せてしまうというのは如何か。
少なくとも、PDOベースのライブラリを捨ててまで、マイナーなMDB2を新規に導入する意義は無いと思う。

スクリプト実装後

既存システムのセキュリティ脆弱性を解決したい、という場合。

PDOを使っているなら、PDO::ATTR_EMULATE_PREPARES を false にする。
mysql < 5.1ではサーバサイドプリペアードステートメントを使うとクエリキャッシュが効かなくなるらしいので要注意。

PDOとSET NAMESを使っているなら、my.cnfを読み込ませる方法を確認するのが最善手かも知れない。

ORMなどを使っており、SQLを直書きで実行したい時に呼び出すメソッドが統一されているのであれば、
それが直接呼び出された時だけmysql関数を使う、という実装を考えたが、けっこう不毛な気もする。
コネクションを2本はる事になるのが良いことかどうかも悩ましい。まあアイデアの一つとして。

既存システムの改修をする際に、せめて改修対象の部分だけでもまともにしておこう、と思うならば、
とりあえずプリペアードステートメントを使うように心がけておくのが一般論としては最善手だと思う。

改善を待つべきか?

PDO_MYSQLがcharsetを適切に取り扱えるようになった時点で、PDOが最善手になる。
MDB2やmysqliを選択する事のつらさがそこにはある。php >= 5.3以降はPDOが積極的に開発される。

しかし、PDO_MYSQLには改善の兆しが無い。PHP 5.3.3RC3のソースコードを見ても対応されていない。
ひょっとするとDSNでcharsetを指定させたくないポリシーでもあるのかも知れない。
「APIの仕事を増やすなんてけしからん。pgsqlみたいに SET CLIENT_ENCODING したら動くようにしろ」って思っているとか。だとしたら反論の余地は無い。個人的にもmysqlが修正されるのが最善の方法だと思う。
(まあ、本当にそう思ってるなら、emulate_prepareも0にしたまえよ、と思うけど)
「mysql >= x.xを使えばセキュリティもパフォーマンスもサロゲートペアも全部マシになりますよ」という答えのほうが必要である。mysqlの今後の開発は、あまり期待できる状況では無いのだが・・・。

というわけで一万歩譲って「mysql関数で対応してるんだからPDOも対応しろ」という主張をすると仮定しても、
年内リリースと噂されるRHEL 6には搭載が間に合わない事が予測される。
RHEL 6のbeta2に搭載されたPHPのバージョンは5.3.2(最新版)。RHEL 5が5.1.6で、RHEL 4が4.3.9である。

最悪のケースを「mysqliの無いRHEL5」と仮定すると、
打てそうな手は結局 MYSQL_ATTR_READ_DEFAULT_FILE しか無い。

まとめ

PDOで良い。
$dbh = new PDO('mysql:host=' . $host . ';dbname=' . $dbname, $user, $password,
               array(
                     PDO::MYSQL_ATTR_READ_DEFAULT_FILE => '/etc/my.cnf',
                     PDO::MYSQL_ATTR_READ_DEFAULT_GROUP => 'pdo',
                     PDO::MYSQL_ATTR_DIRECT_QUERY => true
               ));

[pdo]
default-character-set=utf8
接続時に PDO::MYSQL_ATTR_DIRECT_QUERY を true にしていれば、
わざわざ後付けで PDO::ATTR_EMULATE_PREPARES を false にする必要は無い。

2010/07/20追記

[php-5.3.3RC3/ext/pdo_mysql/mysql_driver.c]
static int pdo_mysql_set_attribute(pdo_dbh_t *dbh, long attr, zval *val TSRMLS_DC)
中略
        case PDO_MYSQL_ATTR_DIRECT_QUERY:
        case PDO_ATTR_EMULATE_PREPARES:
            ((pdo_mysql_db_handle *)dbh->driver_data)->emulate_prepare = Z_BVAL_P(val);
って書いてあるけど、これ本当にPDO::MYSQL_ATTR_DIRECT_QUERYにtrueを指定して正しく動くのか?
static int pdo_mysql_get_attribute(pdo_dbh_t *dbh, long attr, zval *return_value TSRMLS_DC)
中略
        case PDO_MYSQL_ATTR_DIRECT_QUERY:
            ZVAL_LONG(return_value, H->emulate_prepare);
            break;
 
static int pdo_mysql_handle_factory(pdo_dbh_t *dbh, zval *driver_options TSRMLS_DC) /* {{{ */
中略
        H->emulate_prepare = pdo_attr_lval(driver_options,
            PDO_MYSQL_ATTR_DIRECT_QUERY, H->emulate_prepare TSRMLS_CC);
        H->emulate_prepare = pdo_attr_lval(driver_options,
            PDO_ATTR_EMULATE_PREPARES, H->emulate_prepare TSRMLS_CC);
なんか非常に不安なので、
PDO::MYSQL_ATTR_DIRECT_QUERY をいじるのはやめて、
PDO::ATTR_EMULATE_PREPARES を false にする方法を採用しようかと思う。

実際に動作確認を綿密にしたほうがいいな・・・。