PHPのmb_detect_encoding()の精度を上げようとしてハマった話
2026/09/18
はじめに
弊社で運用しているとあるWeb APIは、外部のシステムから送られてくるリクエストを受け取って処理しています。連携先のシステムは複数あり、送信元によって文字コードがUTF-8だったりShift-JISだったりとまちまちだったため、受け取ったデータの文字コードを自動判定してUTF-8に変換する処理を実装していました。
先日、この「文字コード自動判定」の処理に、特定の条件下で無限ループに陥ってしまう不具合があることに気づきました。原因を調べていくと、PHPのmb_detect_encoding()の「地味だけど知らないとハマる仕様」に行き当たったので、本記事ではその調査の記録を残しておこうと思います。
同じようにレガシーな文字コード変換処理を抱えているシステムの方の参考になれば幸いです。
背景・目的
該当のAPIには、こんな処理がありました(実際のコードを単純化・一般化したものです)。
function convertToUtf8(string $input): string
{
$targetEncodings = ['UTF-8', 'Shift-JIS', 'EUC-JP', 'ISO-2022-JP'];
$detecting = true;
$encoding = 'Unknown';
while ($detecting)
{
$encoding = mb_detect_encoding($input, $targetEncodings, true);
if ($encoding !== false)
{
$converted = mb_convert_encoding($input, 'UTF-8', $encoding);
// 変換前のバイト列に戻して比較し、本当にその文字コードで合っているか検証する
if (mb_convert_encoding($converted, $encoding, 'UTF-8') === $input)
{
$detecting = false; // 一致したので確定
}
else
{
// 変換に失敗した候補は次回から外す
$targetEncodings = array_diff($targetEncodings, [$encoding]);
}
}
if ($targetEncodings === [] || $encoding === false)
{
$encoding = 'Unknown';
$detecting = false;
}
}
return ($encoding !== 'Unknown') ? mb_convert_encoding($input, 'UTF-8', $encoding) : $input;
}
mb_detect_encoding()は候補として複数の文字コードを渡せますが、判定を誤ることがあります。特に日本語の文字コード判定は「たまたまそう見えるだけ」の誤判定が起きやすく、単純にmb_detect_encoding()を1回呼んで終わり、という実装だと事故ります。
そこで、「判定→変換→逆変換して元に戻るか検証→ダメなら候補から外して再判定」を繰り返すことで、精度を上げようとしていました。狙いとしては悪くない発想だったと思っています。実際、これでほとんどのケースは正しく判定できていました。
無限ループに気づいた経緯
ある時、特定のリクエストだけ処理がなかなか終わらないことに気づきました。他のリクエストと比べて明らかに応答が遅く、調べてみるとその間ずっと処理が返ってこない状態になっていました。
疑わしいのは、まさに上記の文字コード判定処理でした。実際にログを追ってみると、mb_detect_encoding()を呼んでいる箇所からずっと処理が先に進んでおらず、このwhileループが終わっていないことが分かりました。
原因1: mb_detect_encoding()は「渡した名前」をそのまま返すとは限らない
まず疑ったのは、array_diff()による候補の除外がうまく機能していないのではないか、ということでした。実際に検証してみると、予想通りの挙動が見つかりました。
var_dump(mb_detect_encoding('あいうえお', ['Shift-JIS'], true));
// あいうえおをShift-JISのバイト列にしてから渡すと...
// => string(4) "SJIS"
‘Shift-JIS’という名前を候補として渡しているのに、判定に成功すると’SJIS’という別の文字列が返ってきます。mb_encoding_aliases()で確認すると、これは想定された挙動でした。
var_dump(mb_encoding_aliases('SJIS'));
// array(2) {
// [0]=> string(6) "x-sjis"
// [1]=> string(9) "SHIFT-JIS"
// }
mbstringの内部では’SJIS’が正規名(canonical name)で、’Shift-JIS'(‘SHIFT-JIS’)はその別名(エイリアス)として登録されています。mb_detect_encoding()は判定に成功すると、渡された候補の表記ではなく、この正規名を返してくる仕様のようでした。
教訓: mb_detect_encoding($str, $candidates, true)の戻り値は、$candidatesに渡した文字列と同じ表記とは限らない。戻り値を$candidatesの要素と文字列比較する処理を書くときは要注意。
原因2: だから候補除外が空振りすることがある
これが分かると、array_diff()の呼び出しが機能しないケースがあることに気づきます。
$targetEncodings = ['UTF-8', 'Shift-JIS', 'EUC-JP', 'ISO-2022-JP'];
$encoding = 'SJIS'; // mb_detect_encoding()が返してきた値
$targetEncodings = array_diff($targetEncodings, [$encoding]);
var_dump($targetEncodings);
// array(4) {
// [0]=> string(5) "UTF-8"
// [1]=> string(9) "Shift-JIS" ← 除外されない!
// [2]=> string(6) "EUC-JP"
// [3]=> string(11) "ISO-2022-JP"
// }
array_diff()は文字列としての完全一致で除外するため、’SJIS’という値を渡しても’Shift-JIS’という要素は消えません。つまりこの状態で往復変換チェックに失敗し続けると、候補配列がいつまでも変化しないことになります。
ループの終了条件は「候補が空になる」か「mb_detect_encoding()がfalseを返す」の2つだけでした。しかし上記のケースでは候補は1件残ったまま(空にならない)、mb_detect_encoding()も判定自体には成功している(falseを返さない)ため、どちらの終了条件も満たされず、ループが永遠に回り続けます。
教訓: 「配列から要素を除外し続けて、空になったら終わる」というループを書くときは、除外条件が本当に成立するケースだけでなく、除外が空振りし続けるケースも想定しておく必要がある。
原因3: 実際に踏むデータは意外と身近にある
「往復変換で元に戻らない」ケースが具体的にどんなデータで起きるのか気になったので調べてみました。答えは、Shift-JIS(JIS X0208)の範囲にはないが、Windows-31J(CP932)には存在する文字でした。丸囲み数字(①②③…)や、「株式会社」を1文字で表す合字(㈱)などが代表例です。
$bytes = mb_convert_encoding('①㈱', 'SJIS-win', 'UTF-8'); // Windows由来のバイト列を用意
var_dump(mb_detect_encoding($bytes, ['Shift-JIS'], true));
// => string(4) "SJIS" (構造的には妥当と判定されてしまう)
$roundTrip = mb_convert_encoding(
mb_convert_encoding($bytes, 'UTF-8', 'Shift-JIS'),
'Shift-JIS',
'UTF-8'
);
var_dump($roundTrip === $bytes);
// => false (①も㈱も基本のShift-JISテーブルには無い文字なので、変換すると"?"になってしまう)
①や㈱は、WindowsやWeb上のフォームでは当たり前のように入力できてしまう文字です。実際に手元の開発環境で、この手のデータを含むリクエストを送ってみたところ、想定通り処理が終わらなくなることを確認できました。特別な異常データでなくても、ごく普通の日本語の入力で踏んでしまう類のバグだった、というのが今回いちばん反省した点です。
対応:2段構えで直す
対策1: 候補集合が変化しなくなったらループを止める(構造的な安全策)
まず、そもそも無限ループに陥らないようにループの終了条件を追加しました。
else
{
$previousTargetEncodings = $targetEncodings;
$targetEncodings = array_diff($targetEncodings, [$encoding]);
// 除外前後で候補集合が変化しない場合、これ以上絞り込めないので打ち切る
if ($previousTargetEncodings === $targetEncodings)
{
$encoding = 'Unknown';
$detecting = false;
}
}
これは今回見つかったShift-JIS/SJISのケースに限らず、「候補を渡した文字列と、返ってくる正規名が食い違う」パターン全般に対する安全策になります。似たようなエイリアス関係は他の文字コードにも存在するため、個別に対処するより構造的に塞いでおくほうが安全だと判断しました。
対策2: そもそも精度の高い候補を先に評価する
安全策だけだと、①や㈱を含むデータは「文字コード不明」として変換されずに素通りしてしまいます。せっかくなので、これらの文字を正しく扱えるSJIS-win(Windows-31J)を、Shift-JISより先に判定するよう候補の並び順を変更しました。
$targetEncodings = ['UTF-8', 'SJIS-win', 'Shift-JIS', 'EUC-JP', 'ISO-2022-JP'];
mb_detect_encoding()は候補リストの先頭から見て最初に構造的に妥当と判定したものを返す挙動だったため、SJIS-winを先に置くことで、①や㈱を含むデータも文字化けせず正しくUTF-8に変換できるようになりました。SJIS-winは基本のShift-JIS(JIS X0208)の範囲を包含する上位互換の文字コードなので、通常のShift-JISデータへの影響もありません。
2つの修正を入れたあと、実際に同じリクエストを送って確認したところ、以前は終わらなかった処理が一瞬で正常応答するようになり、保存された値も文字化けせず正しく変換されていることを確認できました。
まとめ
- mb_detect_encoding($str, $candidates, true)の戻り値は、$candidatesに渡した表記そのものとは限らない。’Shift-JIS’を渡しても’SJIS’が返ってくることがある
- 「候補配列から要素を除外していき、空になったら終わる」ようなループは、除外が一度も成功しないまま回り続けるケースを必ず考慮する。除外前後で配列が変化しなければ打ち切る、といった安全策を入れておくと安心
- 判定候補を増やして総当たりすれば精度が上がる、とは限らない。似たようなエイリアスを複数候補に含めると、かえって思わぬ落とし穴になることがある
- 丸囲み数字や㈱のような、Windows環境ではごく普通に入力される文字が、こうしたエンコーディング判定処理の思わぬトリガーになり得る。特別な異常データではなく日常的な入力で発生し得るという点は、原因調査の初期段階では見落としがちだった。
おわりに
「文字コードの自動判定」は、レガシーな連携が絡むシステムだとどうしても避けて通れない処理ですが、今回のように「精度を上げようとした工夫」自体が思わぬ落とし穴になることもあると実感しました。mb_detect_encoding()を複数候補で使っている実装がある方は、一度この観点で見直してみることをおすすめします。
▶ お問い合わせ
Claudeでのやり取りから本記事を作成し、加筆・修正しております。