PHP7.1~7.4でSJIS-winの文字列をmb_splitに渡すと失敗する現象について

2021年11月30日

とあるライブラリーがPHP7.1~7.4でうまく動かない現象に出くわしたので調べてみました。
どういう状況かというと、SJIS-win(CP932)にエンコードした文字列をmb_split関数に渡して配列に変換する際に
PHP5.3 → うまく配列に変換できる。
PHP7.3 → falseが返ってきてうまく行かない。
PHP8.0 → うまく配列に変換できる。
という事が起こりました。

mb_split関数はマニュアルによると

https://www.php.net/manual/ja/function.mb-split.php
注意:
mb_regex_encoding() で指定した文字エンコーディングを、 この関数の文字エンコーディングとして使用します。

とのことだったのでmb_regex_encodingで「SJIS-win」を設定しましたがうまく行きませんでした。
SJIS-winで拡張された範囲の文字(髙、﨑など)を含んでいない場合はうまく配列に変換できます。

再現コード

<?php

$value = "山a髙a﨑a川";

$encoding_list = array(
	"UTF-8",
	"SJIS",
	"SJIS-win",
);

echo "分割する文字列 = " . $value . "\r\n\r\n";

foreach ($encoding_list as $encoding) {
	echo "■" . $encoding . "の場合\r\n";
	$value_encoding = mb_convert_encoding($value, $encoding, "UTF-8");
	
	$mb_regex_encoding_result = mb_regex_encoding($encoding);
	if ($mb_regex_encoding_result === false) {
		echo "mb_regex_encoding失敗\r\n";
		continue;
	}
	echo "mb_regex_encodingの値 = " . mb_regex_encoding() . "\r\n";
	echo "mb_check_encodingの結果 = " . (mb_check_encoding($value_encoding, mb_regex_encoding()) ? "true" : "false") . "\r\n";
echo "mb_detect_encodingの結果 = " . mb_detect_encoding($value_encoding, $encoding_list) . "\r\n"; $list = mb_split("a", $value_encoding); echo "mb_splitの結果\r\n"; if ($list === false) { echo "false\r\n"; } else { foreach ($list as $k => $v) { $list[$k] = mb_convert_encoding($v, "UTF-8", $encoding); } echo print_r($list, true) . "\r\n"; } }

実行結果

PHP 5.3 & 7.0の場合

分割する文字列 = 山a髙a﨑a川

■UTF-8の場合
mb_regex_encodingの値 = UTF-8
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = UTF-8
mb_splitの結果
Array
(
    [0] => 山
    [1] => 髙
    [2] => 﨑
    [3] => 川
)

■SJISの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = SJIS
mb_splitの結果
Array
(
    [0] => 山
    [1] => ?  ←SJISに含まれてない文字なので「?」になってOK
    [2] => ?  ←SJISに含まれてない文字なので「?」になってOK
    [3] => 川
)

■SJIS-winの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = false
mb_detect_encodingの結果 = SJIS-win
mb_splitの結果
Array     ←うまく変換できている。
(
    [0] => 山
    [1] => 髙
    [2] => 﨑
    [3] => 川
)

PHP 7.1 & 7.4の場合

分割する文字列 = 山a髙a﨑a川

■UTF-8の場合
mb_regex_encodingの値 = UTF-8
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = UTF-8
mb_splitの結果
Array
(
    [0] => 山
    [1] => 髙
    [2] => 﨑
    [3] => 川
)

■SJISの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = SJIS
mb_splitの結果
Array
(
    [0] => 山
    [1] => ?
    [2] => ?
    [3] => 川
)

■SJIS-winの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = false
mb_detect_encodingの結果 = SJIS-win
mb_splitの結果
false  ←falseが返ってきてしまって配列に変換できていない!

PHP 8.0 & 8.1の場合

分割する文字列 = 山a髙a﨑a川

■UTF-8の場合
mb_regex_encodingの値 = UTF-8
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = UTF-8
mb_splitの結果
Array
(
    [0] => 山
    [1] => 髙
    [2] => 﨑
    [3] => 川
)

■SJISの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = SJIS
mb_splitの結果
Array
(
    [0] => 山
    [1] => ?
    [2] => ?
    [3] => 川
)

■SJIS-winの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = false
mb_detect_encodingの結果 = SJIS-win   ←8.0の場合は「SJIS-win」で、8.1の場合は「CP932」になる。
mb_splitの結果
Array       ←うまく変換できている。
(
    [0] => 山
    [1] => 髙
    [2] => 﨑
    [3] => 川
)

情報収集

なぜこのような挙動になるのかネットで調べてみましたが、イマイチ情報が出てきませんでした。
なのでソースを調べてみることにしました。

PHPの公式サイトから7.1のソースを落としてきて解凍し、
php-{バージョン}\ext\mbstring\php_mbregex.c
の1094行目にmb_split関数が居ました。

mb_splitの中身を見てみると、

/* {{{ proto array mb_split(string pattern, string string [, int limit])
split multibyte string into array by regular expression */
PHP_FUNCTION(mb_split)
{
char *arg_pattern;
size_t arg_pattern_len;
php_mb_regex_t *re;
OnigRegion *regs = NULL;
char *string;
OnigUChar *pos, *chunk_pos;
size_t string_len;

int n, err;
zend_long count = -1;

if (zend_parse_parameters(ZEND_NUM_ARGS(), "ss|l", &arg_pattern, &arg_pattern_len, &string, &string_len, &count) == FAILURE) {
RETURN_FALSE;
}

if (count > 0) {
count--;
}

if (!php_mb_check_encoding(string, string_len,
_php_mb_regex_mbctype2name(MBREX(current_mbctype)))) {
RETURN_FALSE;
}

/* create regex pattern buffer */
if ((re = php_mbregex_compile_pattern(arg_pattern, arg_pattern_len, MBREX(regex_default_options), MBREX(current_mbctype), MBREX(regex_default_syntax))) == NULL) {
RETURN_FALSE;
}

array_init(return_value);
/* ・・・略・・・ */
}

となっており、最初の方でおかしな値が渡されてきていないかチェックしています。
7.0のソースと比べてみると

if (!php_mb_check_encoding(string, string_len,
_php_mb_regex_mbctype2name(MBREX(current_mbctype)))) {
RETURN_FALSE;
}

という処理が増えているようなので、この処理を追ってみました。

まず、php_mb_check_encoding関数はmb_check_encoding関数を指しており、
第一引数で渡されているstring変数の文字コードと、
第三引数で渡されている「_php_mb_regex_mbctype2name(MBREX(current_mbctype))」の文字コードが一致しているかチェックしているようです。
string変数にはmb_split関数を呼び出したときの第二引数の文字列、今回の場合はSJIS-winにエンコードした文字列が入ってきます。

次は_php_mb_regex_mbctype2name関数ですが、これはmb_split関数と同じくphp_mbregex.cに定義されており、390行目に居ます。

/* {{{ php_mb_regex_mbctype2name */
static const char *_php_mb_regex_mbctype2name(OnigEncoding mbctype)
{
php_mb_regex_enc_name_map_t *mapping;

for (mapping = enc_name_map; mapping->names != NULL; mapping++) {
if (mapping->code == mbctype) {
return mapping->names;
}
}

return NULL;
}
/* }}} */

引数で渡されたmbctype変数と同じcodeを持つ構造体をenc_name_map変数から探し出し、namesを返しています。
enc_name_map変数に文字コードの一覧が入っているので、その中から「UTF-8」や「SJIS」などの文字コードの名前を取得する処理のようです。

enc_name_map変数も同じくphp_mbregex.cに定義されており、183行目に居ます。

/* {{{ encoding name map */
typedef struct _php_mb_regex_enc_name_map_t {
const char *names;
OnigEncoding code;
} php_mb_regex_enc_name_map_t;

php_mb_regex_enc_name_map_t enc_name_map[] = {
#ifdef ONIG_ENCODING_EUC_JP
{
"EUC-JP\0EUCJP\0X-EUC-JP\0UJIS\0EUCJP\0EUCJP-WIN\0",
ONIG_ENCODING_EUC_JP
},
#endif
#ifdef ONIG_ENCODING_UTF8
{
"UTF-8\0UTF8\0",
ONIG_ENCODING_UTF8
},
#endif
/* ・・・略・・・ */
#ifdef ONIG_ENCODING_SJIS
{
"SJIS\0CP932\0MS932\0SHIFT_JIS\0SJIS-WIN\0WINDOWS-31J\0",
ONIG_ENCODING_SJIS
},
#endif
/* ・・・略・・・ */
{ NULL, ONIG_ENCODING_UNDEF }
};
/* }}} */

namesの中身はSJIS関係の文字コードの名前が「\0」のヌル文字区切りで格納されています。
なぜ「\0」で区切られているのかは後ほど調べていくことにします。

これで_php_mb_regex_mbctype2name関数の中身は大体わかったので、関数の呼び出し元に戻ります。
_php_mb_regex_mbctype2name関数を呼び出す際に第一引数に「MBREX(current_mbctype))」と指定しています。
MBREXは以下のようなマクロです。

#define MBREX(g) (MBSTRG(mb_regex_globals)->g)

この処理はちょっと追ってませんが、文字コード関係の設定値を格納した構造体から引数に渡された設定項目名の値を取り出すような処理かと思います。
MBREXを呼び出す際に指定されているcurrent_mbctypeは↑の構造体のメンバーの名前です。
「MBREX(current_mbctype)」は「mb_regex_globals->current_mbctype」という感じですね。
そうなると気になるのが「mb_regex_globals->current_mbctype」がどうやって設定されるのかですが、mb_regex_encoding関数です。
mb_regex_encoding関数の定義も同じファイルに書かれており、660行目に居ます。

/* {{{ proto string mb_regex_encoding([string encoding])
Returns the current encoding for regex as a string. */
PHP_FUNCTION(mb_regex_encoding)
{
char *encoding = NULL;
size_t encoding_len;
OnigEncoding mbctype;

if (zend_parse_parameters(ZEND_NUM_ARGS(), "|s", &encoding, &encoding_len) == FAILURE) {
return;
}

if (!encoding) {
const char *retval = _php_mb_regex_mbctype2name(MBREX(current_mbctype));

if (retval == NULL) {
RETURN_FALSE;
}

RETURN_STRING((char *)retval);
} else {
mbctype = _php_mb_regex_name2mbctype(encoding);

if (mbctype == ONIG_ENCODING_UNDEF) {
php_error_docref(NULL, E_WARNING, "Unknown encoding \"%s\"", encoding);
RETURN_FALSE;
}

MBREX(current_mbctype) = mbctype;
RETURN_TRUE;
}
}
/* }}} */

引数で渡された文字コードの名前(SJISやUTF-8など)を_php_mb_regex_name2mbctype関数に渡し、
文字コードが特定できたら「MBREX(current_mbctype)」によって「mb_regex_globals->current_mbctype」へ代入しています。
PHPのマニュアルに
「mb_regex_encoding() で指定した文字エンコーディングを、 この関数の文字エンコーディングとして使用します。」
と書かれていたのはこのことを指していたようですね。
ちなみに引数が渡されなかった場合は「_php_mb_regex_mbctype2name(MBREX(current_mbctype))」という処理で現在設定されている文字コードの名前を返しています。


最後に_php_mb_regex_name2mbctype関数を追ってみることにします。
この関数も同じくphp_mbregex.cに定義されており、368行目に居ます。

/* {{{ php_mb_regex_name2mbctype */
static OnigEncoding _php_mb_regex_name2mbctype(const char *pname)
{
const char *p;
php_mb_regex_enc_name_map_t *mapping;

if (pname == NULL || !*pname) {
return ONIG_ENCODING_UNDEF;
}

for (mapping = enc_name_map; mapping->names != NULL; mapping++) {
for (p = mapping->names; *p != '\0'; p += (strlen(p) + 1)) {
if (strcasecmp(p, pname) == 0) {
return mapping->code;
}
}
}

return ONIG_ENCODING_UNDEF;
}
/* }}} */

enc_name_mapは先ほど出てきた文字コードの一覧が格納されている配列です。
この配列をぐるぐる回し、p変数のポインターをずらすことによってnamesの中身を「\0」区切りでぐるぐる回しています。

SJIS関連の文字コードの場合は「SJIS\0CP932\0MS932\0SHIFT_JIS\0SJIS-WIN\0WINDOWS-31J\0」を「\0」区切りにするので、
大文字小文字関係なく
・SJIS
・CP932
・MS932
・SHIFT_JIS
・SJIS-WIN
・WINDOWS-31J
のどれかであればenc_name_mapのcodeメンバーに設定されている「ONIG_ENCODING_SJIS」の値を返す感じですね。
これで先ほどの「\0」区切りの謎が分かりました。

情報収集はこれくらいで良さそうなので分析してみることにします。

なぜこのような挙動になるのか?

まずは再現コードの実行結果を見ることにします。
mb_regex_encodingに「SJIS-win」を指定しても、設定できた値を取得すると「SJIS」という文字列が返ってきています。

■実行結果抜粋
mb_regex_encodingの値 = SJIS

これはPHPのバージョンに関係なく同様の動きです。
mb_regex_encoding関数のソースを読んだ際に引数を指定しないと「_php_mb_regex_mbctype2name(MBREX(current_mbctype))」にて
文字コードの名前を返却しているようなので、
「_php_mb_regex_mbctype2name(MBREX(current_mbctype))」 = 「SJIS」という文字列を返す
となりそうです。

また、さらに実行結果を見ると

■実行結果抜粋
mb_detect_encodingの結果 = SJIS-win

mb_detect_encoding関数でmb_split関数に渡している文字列を判定すると「SJIS-win」と判定されていました。
これはそのままストレートな挙動ですが、今回の推理のワンポイントです。


次はここまでわかったことをまとめてみます。

・PHP7.1からmb_split関数の最初の方に
 「第二引数に指定された文字列の文字コードがmb_regex_encoding関数で設定された文字コードと一致しているか?」
 というチェック処理が追加された。

・SJIS関連の文字コードの名前を「SJIS\0CP932\0MS932\0SHIFT_JIS\0SJIS-WIN\0WINDOWS-31J\0」というように「\0」区切りで持っている。

・mb_regex_encoding関数で文字コードを設定した際に、SJISを指定してもSJIS-winを指定しても内部的には「ONIG_ENCODING_SJIS」という一つの値で保持している。

・「_php_mb_regex_mbctype2name(MBREX(current_mbctype))」 = 「SJIS」という文字列を返す。
 なぜなら内部的にSJISもSJIS-winも「ONIG_ENCODING_SJIS」という一つの値で管理しているのと、
 文字コードの名前を「\0」区切りで持っていて、1番目に登場する「SJIS」を返しているから。

・mb_detect_encoding関数でmb_splitに渡している文字列の文字コードを判定すると「SJIS-win」になる。


最後にここまでわかった情報から推理していきます。
mb_splitの最初の方に追加されていた

if (!php_mb_check_encoding(string, string_len,
    _php_mb_regex_mbctype2name(MBREX(current_mbctype)))) {
    RETURN_FALSE;
}

という処理ですが、赤字の部分は実行結果から「SJIS」という文字列になることが分かっています。
青字のphp_mb_check_encoding関数はmb_check_encoding関数の実体で、
内部的にはmb_detect_encoding関数と同じようなことを行い、第一引数に指定されているstring変数と「SJIS」を比べているんだと思います。
(php_mb_check_encoding関数はソースを追ってないので推測ですが。)
そうするとstring変数からmb_detect_encoding関数と同じような処理で割り出した「SJIS-win」という文字コードの名前と、
「_php_mb_regex_mbctype2name(MBREX(current_mbctype)))」で取得した「SJIS」という文字コードの名前を比べることになり、
「別の文字コードでしょ」という判定になってしまい、上記のチェック処理で弾かれてしまっているんだと思われます。

試しに上記のコードをコメントアウトしてコンパイルしてみると、PHP7.1でもPHP7.0以下と同じ挙動になりました。
おそらくセキュリティ絡みで必要なチェック処理なんだと思いますが、このチェック処理によってうまく行かなくなっていることがわかりました。

今回ソースを追っかけた「php_mbregex.c」の頭の方を見てみると、「Onig」「oniguruma」などの単語が書かれていました。
鬼車という正規表現ライブラリーなようですね。
うまくSJIS-winを処理できているmb_detect_encoding関数はmbstring.cの中に定義されているので、
ここら辺の実装者の違いによってmbstringの中でも挙動の違う関数が出てきているような気がしますね。

そうなると7.1~7.4の場合はphp_mbregex.cに書かれている
・mb_regex_encoding
・mb_ereg
・mb_eregi
・mb_ereg_replace
・mb_eregi_replace
・mb_ereg_replace_callback
・mb_split
・mb_ereg_match
・mb_ereg_search
・mb_ereg_search_pos
・mb_ereg_search_regs
・mb_ereg_search_init
・mb_ereg_search_getregs
・mb_ereg_search_getpos
・mb_ereg_search_setpos
・mb_regex_set_options
で「SJIS」ではなく「SJIS-win」を扱う場合にはうまく動かないのかもしれませんね。

回避策

mb_splitではなくpreg_splitを使いましょう。
mb_splitとほぼ感じで使えます。

<?php
$value = "山a髙a﨑a川";

$encoding_list = array(
"UTF-8",
"SJIS",
"SJIS-win",
);

echo "分割する文字列 = " . $value . "\r\n\r\n";

foreach ($encoding_list as $encoding) {
echo "■" . $encoding . "の場合\r\n";
$value_encoding = mb_convert_encoding($value, $encoding, "UTF-8");

$mb_regex_encoding_result = mb_regex_encoding($encoding);
if ($mb_regex_encoding_result === false) {
echo "mb_regex_encoding失敗\r\n";
continue;
}
echo "mb_regex_encodingの値 = " . mb_regex_encoding() . "\r\n";
echo "mb_check_encodingの結果 = " . (mb_check_encoding($value_encoding, mb_regex_encoding()) ? "true" : "false") . "\r\n";
echo "mb_detect_encodingの結果 = " . mb_detect_encoding($value_encoding, $encoding_list) . "\r\n";

// $list = mb_split("a", $value_encoding);
$list = preg_split("/a/", $value_encoding);   ←preg_splitに変えました。
echo "mb_splitの結果\r\n";
if ($list === false) {
echo "false\r\n";
} else {
foreach ($list as $k => $v) {
$list[$k] = mb_convert_encoding($v, "UTF-8", $encoding);
}
echo print_r($list, true) . "\r\n";
}
}

実行結果(PHP7.1~7.4)

分割する文字列 = 山a髙a﨑a川

■UTF-8の場合
mb_regex_encodingの値 = UTF-8
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = UTF-8
mb_splitの結果
Array
(
    [0] => 山
    [1] => 髙
    [2] => 﨑
    [3] => 川
)

■SJISの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = true
mb_detect_encodingの結果 = SJIS
mb_splitの結果
Array
(
    [0] => 山
    [1] => ?
    [2] => ?
    [3] => 川
)

■SJIS-winの場合
mb_regex_encodingの値 = SJIS
mb_check_encodingの結果 = false
mb_detect_encodingの結果 = SJIS-win
mb_splitの結果
Array     ←うまく変換できている!!!
(
    [0] => 山
    [1] => 髙
    [2] => 﨑
    [3] => 川
)

これにてめでたしめでたしですね。

おまけ:PHP8だとどうなっているのか。

PHP 8.1のソースを見てみました。
mb_regex_globalsの構造体に「current_mbctype_mbfl_encoding」という文字コードの名前を保持するメンバーが増えていました。
mb_regex_encoding関数で文字コードをセットした場合に↑のメンバーに名前が保持されます。
ただし、おそらく下位互換のためなのかmb_regex_encoding関数を引数なしで呼んだ場合は以前と変わらず「SJIS」が返ってくるようになっていました。
そしてmb_splitの問題のチェック処理は

if (!php_mb_check_encoding(string, string_len, php_mb_regex_get_mbctype_encoding())) {
RETURN_FALSE;
}

というようになっており、php_mb_regex_get_mbctype_encoding関数にて新しく増えた「current_mbctype_mbfl_encoding」というメンバー変数を参照するようになっていました。
PHP8.1の実行結果を見るとmb_detect_encodingの戻り値が「CP932」になっているので、
php_mb_check_encoding関数で判定した際に「CP932 対 SJIS-win」になってうまく動かなさそうですが、
おそらくCP932とSJIS-winを同じものとして判定してくれてるのかもしれません。
とりあえずPHP 8だとうまく動くようになってそうですね。

書いた人:木本
コメント一覧
コメントはまだありません。
コメントを投稿する
お名前
E-Mail
[必須]コメント
Top