symfonyでアプリケーションを作成していた際に、文字コード絡みで面白い事象に遭遇したので記事にすることにしたw
携帯用のWebアプリケーションを作っていたのだが、
Webサーバ側での出力データの文字コードをShift_JISに統一するため、
以下のように全ての文字コードをShift_JIS/cp932に統一して実験してみた。
MySQL(my.cnf)
[client] default-character-set=cp932 [mysqld] default-character-set=cp932
現在の設定状況を確認。
mysql> show variables like 'character_set%'; +--------------------------+----------------------------+ | Variable_name | Value | +--------------------------+----------------------------+ | character_set_client | cp932 | | character_set_connection | cp932 | | character_set_database | cp932 | | character_set_filesystem | binary | | character_set_results | cp932 | | character_set_server | cp932 | | character_set_system | utf8 | | character_sets_dir | /usr/share/mysql/charsets/ | +--------------------------+----------------------------+
とここで、、、DBドライバmysqliはmy.cnfを読み込む設定が可能だが、DBドライバmysqlは無理ぽい。。。
要するに、クライアント側(symfony/phpアプリケーション)からはmy.cnfは読み込まれない。
php(php.ini)
[mbstring] mbstring.language = Japanese mbstring.internal_encoding = SJIS-WIN mbstring.http_output = SJIS-WIN
symfony
# config/settings.yml(symfony本体、アプリケーション共に) charset: Shift_JIS # databases.yml encoding: cp932
以上の設定を施したが、0×5c問題が気になったので以下のテストを行った。
symfonyで作成したテスト用アプリケーションで「ソ」一文字を登録。
結果は失敗。
ということでsymfonyが使用しているO/Rマッパや
データベースドライバについて調べてみた。
symfonyはデフォルトではPropelというO/Rマッパを用い、
CreoleというDB接続モジュールでDBドライバに”mysql”を利用している。
ということで、CreoleのDBドライバ:mysqlにおける MySQL接続クラス(MySQLConnection.php)を確認してみた。
if ($encoding) {
$this->executeUpdate("SET NAMES " . $encoding);
}
むむ、発見。
ここでdatabases.ymlに設定した文字コードを指定している。
SET NAMESクエリを発行している。
試しにここをphpの標準関数mysql_set_charset()に置き換えてみた。
if ($encoding) {
mysql_set_charset($encoding);
}
再度「ソ」一文字登録テスト。
お!成功!!
しかし何故??
ということでMySQLのクエリログを確認
# 失敗: $this->executeUpdate("SET NAMES " . $encoding);
081114 19:30:21 22 Connect db_test@localhost on
22 Init DB db_test
22 Init DB db_test
22 Query SET NAMES cp932
22 Query SET AUTOCOMMIT=0
22 Query BEGIN
22 Init DB db_test
22 Query INSERT INTO table_test (TITLE,CREATED_AT,UPDATED_AT) VALUES ('ソ\','2008-11-14 20:27:42','2008-11-14 20:27:42')
22 Init DB db_test
22 Query ROLLBACK
22 Query SET AUTOCOMMIT=1
22 Quit
# 成功: mysql_set_charset($encoding);
081114 19:30:48 23 Connect db_test@localhost on
23 Init DB db_test
23 Query SET NAMES cp932
23 Query SET AUTOCOMMIT=0
23 Query BEGIN
23 Init DB db_test
23 Query INSERT INTO table_test (TITLE,CREATED_AT,UPDATED_AT) VALUES ('ソ','2008-11-14 20:28:09','2008-11-14 20:28:09')
23 Init DB db_test
23 Query COMMIT
23 Query SET AUTOCOMMIT=1
# 失敗: mysql_query("SET NAMES " . $encoding);
081114 19:39:12 27 Connect db_test@localhost on
27 Init DB db_test
27 Query SET NAMES cp932
27 Query SET AUTOCOMMIT=0
27 Query BEGIN
27 Init DB db_test
27 Query INSERT INTO table_test (TITLE,CREATED_AT,UPDATED_AT) VALUES ('ソ\','2008-11-14 20:36:33','2008-11-14 20:36:33')
27 Init DB db_test
27 Query ROLLBACK
27 Query SET AUTOCOMMIT=1
ン?
成功/失敗時のクエリを確認すると、文字コード指定部分は全く同じである。
なんと、違いはMySQLサーバがデータを受け取った時点で異なることがわかった。
では、php標準関数mysql_set_charset()では何をしているのか?
phpのソースコード(php_mysql.c)を追ってみた。
PHP_FUNCTION(mysql_set_charset)
{
zval *mysql_link = NULL;
char *csname;
int id = -1, csname_len;
php_mysql_conn *mysql;
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s|r", &csname, &csname_len, &mysql_link) == FAILURE) {
return;
}
if (ZEND_NUM_ARGS() == 1) {
id = php_mysql_get_default_link(INTERNAL_FUNCTION_PARAM_PASSTHRU);
CHECK_LINK(id);
}
ZEND_FETCH_RESOURCE2(mysql, php_mysql_conn *, &mysql_link, id, "MySQL-Link", le_link, le_plink);
if (!mysql_set_character_set(&mysql->conn, csname)) {
RETURN_TRUE;
} else {
RETURN_FALSE;
}
}
おっと、mysql_set_character_set()関数を使用している。
これはMySQLモジュール、libmysqlの関数である。
では、このmysql_set_character_set()では何をしているのか?
MySQLのソースコードに内包されているlibmysqldのclient.cを確認
int STDCALL mysql_set_character_set(MYSQL *mysql, const char *cs_name)
{
struct charset_info_st *cs;
const char *save_csdir= charsets_dir;
if (mysql->options.charset_dir)
charsets_dir= mysql->options.charset_dir;
if (strlen(cs_name) < MY_CS_NAME_SIZE &&
(cs= get_charset_by_csname(cs_name, MY_CS_PRIMARY, MYF(0))))
{
char buff[MY_CS_NAME_SIZE + 10];
charsets_dir= save_csdir;
/* Skip execution of "SET NAMES" for pre-4.1 servers */
if (mysql_get_server_version(mysql) < 40100)
return 0;
sprintf(buff, "SET NAMES %s", cs_name);
if (!mysql_real_query(mysql, buff, strlen(buff)))
{
mysql->charset= cs;
}
}
else
{
char cs_dir_name[FN_REFLEN];
get_charsets_dir(cs_dir_name);
mysql->net.last_errno= CR_CANT_READ_CHARSET;
strmov(mysql->net.sqlstate, unknown_sqlstate);
my_snprintf(mysql->net.last_error, sizeof(mysql->net.last_error) - 1,
ER(mysql->net.last_errno), cs_name, cs_dir_name);
}
charsets_dir= save_csdir;
return mysql->net.last_errno;
}
ぉお!
sprintf(buff, "SET NAMES %s", cs_name);
if (!mysql_real_query(mysql, buff, strlen(buff)))
{
mysql->charset= cs;
}
これだ!
SET NAMESクエリを発行した後で
クライアント側のmysqlコネクションオブジェクトに文字コードをセットしている。
ただ単にSET NAMESクエリを発行するのとphpのmysql_set_charset()にて文字コードを指定するのとでは
この違いがあった。
いくらsymfonyやphpの設定をShift_JISにしても、MySQLクライアント(DBドライバmysql)がShift_JISで無ければ0×5cの2バイト目の5c(\)を2バイト目の5cと認識できず、単にエスケープ文字としてエスケープしてしまう。しかし、MySQLサーバ側はShift_JIS(cp932)で設定されているため2バイト目の5cを2バイト目と認識し、クライアントが付加した5cが余分なエスケープとなってクエリがシンタックスエラーとなる。
Shift_JISの「ソ」を「835c」と表現して以下に当問題の一連の流れを書く。
■ symfony/php
insert into table values '835c';
■ mysql(MySQLクライアント)
# 835cの2バイト目をエスケープ(5c付加)してしまう
# 83 + ここに5cを付加 + 5c ⇒ 835c5c
insert into table values '835c5c';
■ MySQLサーバ
# 835cまでを「ソ」と認識し、残りの5cが余計なエスケープとなり、
# 閉じクオートがエスケープされる。('ソ\')
insert into table values '835c5c';
mysqliなら、my.cnfを読み込むことも可能なのでこの様な事象に遭遇することは少ないのかもしれない。
ん~phpでDBドライバにmysqlを使用する、またはそのような環境においてShift_JISを利用したい場合は
mysql_set_charset()で文字コードを設定するべきですね。
November 20th, 2008 at 8:31 pm
たいへん参考になりました。ありがとうございます。
今回の5c問題ですが、creloe のコード MySQLPreparedStatement#escape 内で呼ばれている mysql_real_escape_string が原因とみてよいでしょうか。
つまり、databases.yml で encoding: cp932 と設定してもデフォでは SET NAMES 発行するだけで mysql_real_escape_string 的に接続時の文字コード(mysql_client_encoding で返る値)は cp932 でなかったというわけですかね。
それが、件の場所を mysql_set_charset($encoding); におき代えることで解決した と。
November 21st, 2008 at 6:47 pm
も さんコメントありがとうございます!
そのとおりです。
“835cの2バイト目をエスケープ(5c付加)してしまう”
のは、mysql_real_escape_string()がエスケープしているためです。
mysql_real_escape_string()が適切なエスケープをしないといけません。
mysqlコネクション構造体に現在使用している文字コードをセットすることでこれを満たしています。
それをしているのが、libmysqlのmysql_set_character_set()であり、
これを呼び出しているのがphpのmysql_set_charset()という訳です。