IT系おじさんのチラシの裏
2018年10月~
当サイトの記事にはアフィリエイト広告のリンクが含まれる場合があります

Windows版PHPでUTF8なCSVファイルを読むならロケールに注意

PHPのfgetcsvやstr_getcsvなどの関数はロケール設定に依存しており、Windows環境で使う場合はデフォルトのロケールがJapanese_Japan.932になっているので、そのままではUTF-8なCSVは正常に解析できないよー、というだけのお話です。

お急ぎの方は setlocale(LC_ALL, 'C'); というオマジナイだけ覚えて帰って頂ければと思います。

最近ではjsonだのxmlだのyamlだの、便利な書式が増えたおかげで、相対的にCSVを扱う機会が減っており、あまりに久しぶりだとこのへんの仕様、ついつい忘れてしまうんですよね。開発環境も運用環境もLinuxならロケール設定もCかUTF-8にしているから問題ないでしょうし。

今回たまたまWindows上でCSVファイルを解析する必要に迫られ、慣れているPHPでサクサクっと組むか、と思ったらきちんと切り分けてくれず、解決まで半日近く潰してしまったので、備忘録がてらブログに残しておこうと思いました。

PHPでCSVファイルを読み込む基本

CSVファイルの中身

"品番","品名","販売金額"

今回使うのは、ダブルクォートで囲んだ値が3項目、1行だけ入ったファイルです。

PHPサンプル

$fp = fopen('[CSVファイル名]', 'r');
$row = fgetcsv($fp);
print_r($row);

とりあえずテストなのでエラーチェックもしないし、最初の1行だけ読んで配列に格納しています。

結果

Array ( [0] => 品番 [1] => 品名 [2] => 販売金額 )

CSVファイルがShiftJISで保存されている場合はこのとおりきちんと解析できています。

ところが、CSVファイルがUTF-8で保存されていた場合、Windows環境では以下のようになります。
Array ( [0] => 品番 [1] => 品名",販売金額" )

あれあれ~? 品名と販売金額という項目が同じ1つの項目として扱われてしまいました。2個目のカンマ区切りが反映されていません。

最近は文字コードはUTF-8にするのがデファクトスタンダードですし、PHP自体もphp.iniでinternal_encodingをUTF-8に設定していたりするでしょう。

だから、むしろCSVはUTF-8で保存されていたほうが素直に動きそうにも感じてしまいます。

がしかし、php.iniで設定しているのはこんな感じですよね?

mbstring.language = Japanese
mbstring.internal_encoding = UTF-8

この設定が影響するのはその名のとおりmbstring系の関数だけです。mb_strposとかmb_strlenとか、mb_ほにゃららと付いた関数群。

fgetcsvはmbstringの設定なんぞ見ておらず、参照しているのはロケール設定です。

Windows版PHPのデフォルトのロケールはJapanese_Japan.932

ロケールの確認方法は下記のとおり。

echo setlocale(LC_ALL, 0);
LC_COLLATE=C;
LC_CTYPE=Japanese_Japan.932;
LC_MONETARY=C;
LC_NUMERIC=C;
LC_TIME=C

このLC_CTYPEの設定がfgetcsvやstr_getcsv関数に影響するようで、基本的にcp932(=MSが拡張したShiftJIS)じゃないと正常に動作しないようです。

ややこしいのは、微妙にちゃんと動いているように見えてしまうケースもある点。

ロケールがJapanese_Japan.932でも正常動作に見えてしまう例

print_r(str_getcsv('品番,品名,販売金額')); echo '<br>';
print_r(str_getcsv('"品番","品名","販売金額"'));
Array ( [0] => 品番 [1] => 品名 [2] => 販売金額 )
Array ( [0] => 品番 [1] => 品名",販売金額" )

このように、ダブルクォートを付けない「品番,品名,販売金額」だと正常に動作しているように見えてしまうので、より一層混乱する原因になっています。

ダブルクォートを付けなければ読めるのか、と勘違いしそうになりますが、そうではありません。

例えば、こんなコードを書いてみます。

print_r(str_getcsv('あ,かきくけこ')); echo '<br>';
print_r(str_getcsv('あい,かきくけこ')); echo '<br>';
print_r(str_getcsv('あいう,かきくけこ')); echo '<br>';
print_r(str_getcsv('あいうえ,かきくけこ')); echo '<br>';
print_r(str_getcsv('あいうえお,かきくけこ')); echo '<br>';
Array ( [0] => あ,かきくけこ )
Array ( [0] => あい [1] => かきくけこ )
Array ( [0] => あいう,かきくけこ )
Array ( [0] => あいうえ [1] => かきくけこ )
Array ( [0] => あいうえお,かきくけこ )

結果はこのとおり。「あい」「あいうえ」を使った2パターンだけ正常。それ以外は切り分けが出来ていません。

深く調査したわけではないので仮説ですが、UTF-8の3バイト文字の3バイト目が0x81~0x9fあたりだった場合で、かつダブルクォートを付けた場合、もしくは奇数回その文字が入っていた場合に、正常な切り分けが出来ていないように見えます。

つまり、1バイトずつ読み取り、0x81~0x9fだったらShiftJISの漢字コードのはじまりを示すので、2バイト目を読み取ろうとしてバグる、というMS-DOS時代の文字化けあるあるみたいな。

どうせなら全部化けてくれればわかりやすいのに、微妙に正常に表示されて見えるところが腹立た…ごほん…ややこしいです。

UTF-8なPHPで、ShiftJISなCSVを読む例

まずは素直な例から。

$fp = fopen('[CSVファイル名]', 'r');
$row = fgetcsv($fp);
foreach ($row as $key => $value) {
	echo "[".mb_convert_encoding($value, 'UTF-8', 'SJIS-win')."]";
}

デフォルトのロケールがcp932なんだから、そのまま解析させて、表示するときにUTF-8に変換すれば良いじゃない、というケース。

Excelで保存したCSVなど、通常はShiftJISでしょうし、理想はこのやり方なのかなとは思います。

ただ、Windows環境とLinux環境で同じスクリプトを使いまわす場合、今度は逆にWindowsでは正常だけど、LinuxではきちんとCSVを解析できない、なんてこともありえるので、結局環境によってsetlocaleを変更する必要があります。Linuxだと setlocale(LC_ALL, 'ja_JP.Shift_JIS'); あたりかな?

更に言うと、昨今ではUTF-8にしか存在しない文字が入力される例も多いと思います。絵文字なんか代表的な例ですね。そういうデータをCSV化する場合、そもそもShiftJISじゃ保存できません。なので、書いておいて何ですが、この例はWindows環境でShiftJISなCSVしか読まないというレガシーなシステム向けかなぁ。

UTF-8なPHPで、UTF-8なCSVを読む例

setlocale(LC_ALL, 'C');
$fp = fopen('[CSVファイル名]', 'r');
$row = fgetcsv($fp);
print_r($row);

はい、わざわざ記事にするほどかって感じがするくらいシンプルですが、setlocale(LC_ALL, 'C');を入れるだけ。

CロケールはPOSIX標準のうんたらかんたらって説明がされていて、なんのことやらわからんのですが、要するに余計なことしないロケールと認識しています。

どこで見かけたんだったか、たしかGitHubのEC-CUBE関連のスレッドだったかなぁ。

setlocale(LC_ALL, 'English_United States.1252');

という例が書かれていた記憶がありますが、結局同じことでしょう。

3バイト文字(≒漢字)だろうと1バイトずつ読んで処理してるんだと思うけど、カンマ(0x2c)やダブルクォート(0x22)はUTF-8の漢字に含まれないからええやろ、っていう強引なやり方に見えます。正直ちょっと不安を感じなくもない。

まとめ

いよいよとなったら、mb_substr関数で1文字ずつ切り抜いて自前で解析関数作ろうかと思いましたが、一応setlocale(LC_ALL, 'C');付けるだけで問題なく動いてるっぽいので、ひとまずこれでヨシとしました。

PHPではなくJavaScriptですが、前回の記事「cPanelのメールフィルターをエクスポート/インポートする拡張機能を作ってみた」ではダブルクォートや改行も考慮したCSV解析処理を書いているので気になる方はそちらもどうぞ。

そんなに難しい処理じゃないし、昔書いた記憶があるので、HDD探せばPHPの例も出てきそうですが、自前で書くと処理がちょっと遅いんですよね。今回、1万行くらいのCSVを処理したかったので、できればネイティブな関数に任せたかったというのもあります。

ただ、今後PHP8が主流になればネイティブコードと同じくらいスクリプトも早くなるのかなぁ?

ちょっと脱線しましたが、この記事がUTF-8なCSVファイルの解析で悩んでいる人のお役に立てば幸いです。

関連記事

コメント

新しいコメントを投稿する

[新規投稿]
 
TOP