PHPでCSVの文字化け対策を考える(2023)
SJISはもう使わない
関連記事:
WSLとJetbrains GatewayでPHPStormローカルリモート開発
PHP SPLの標準Interfaceを真剣に考える
assertEqualsを今すぐ捨て、assertSameを使うのです
PHPUnitには @testdox を使え
CSVの実装する機会があったのだが、
ちょっと考えさせられることが多かったので記事にする。
CSVっていうフォーマットは古代から存在するが、
JSONが一般化した現在でも消えることはなさそう。
用途は大体Excelで使うか他のツールに取り込むなどだと思うが、
Excel200xの時代の慣習やInternetExploer時代の名残が残ってる印象がある
そこで現在の実装方法をまとめて、
新たな指標にしたい。
ヾ(・ω<)ノ" 三三三● ⅱⅲ コロコロ♪
------------------- ↓ 本題はここから ↓-------------------
エンコードはBom付UTF8
ページの表示はSJIS、バックエンドはEUC-JPなんていう時代もあった
今はページ表示もバックエンドもUTF8でなのが当たり前になっている
実はCSVも同じで
UTF8(Bom付)
を使う。
基本的にエンコードはクライアントOSに対応したものを使用するが、
Windows = SJIS
と思っていたらそれは間違いだ
Excelで開くことを想定しているならBom付UTF8を使う
Excel2010ぐらいからUTF8をサポートしていて、
もうかれこれ15年弱ぐらいは経っている
('ω') Bom無しも対応してくれよ...
Windows10であればメモ帳ですらUTF8に対応している
ちなみにMac向けに出力するときはBom付Unicode(UTF16)にすることもある
バックエンドはデータ作成のみ
昔は特定のAPIにリダイレクトしてCSVダウンロードする手法を使っていたが、
今はフロントエンド側でダウンロードさせるやり方が普通な気がする。
バックエンド側ではJSONで普通にデータを返却して、
フロントエンド側で整形する
または、CSV化したデータをそのまま出力して、
フロントエンド側でダウンロードの実行を行う
ダウンロードはフロント側
JSONまたはCSVの素のデータをフロント側で受け取り、
ダウンロードURLを発行してブラウザに指示する。
URL発行には createObjectURL を使う
仮想DOM上に発行したURLをAタグに貼ってクリックさせることでダウンロードさせている
const blob = "csv data";
const fileName = "filename.csv";
const url = URL.createObjectURL(new Blob([blob], {type: 'text/csv'}));
const a = document.createElement('a');
a.href = url;
a.setAttribute('download', fileName);
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.parentElement?.removeChild(a);
Bomの付与はフロント側で
「エンコードはBom付UTF8」と上記で述べたが、
Bomの付与はダウンロードの直前の方が取り回しがいい。
なので、バックエンド側ではBom無しUTFで出力し、
ダウンロード直前にBomを付与する
const bom = new Uint8Array([0xef, 0xbb, 0xbf]);
const url = URL.createObjectURL(new Blob([bom, blob], {type: 'text/csv'}));
ファイル名はUTF8
Internet Explorerの消滅とともにファイル名もSJISにする必要がなくなった。
なのでフロント側でファイル名を自由に設定すればいい。
日本語ファイル名を使うときはUTF8で生成する
エンコードはJavaScriptで行う
Windows側はUTF8で出力させるが、
Mac側は UTF16 にすることもある。
その際、JavaScript側でエンコードをする。
使用するのはencoding.jsがよい
件数が大きいものはAPIのページングを使う
JSONでデータを取り出す時、
offset値やlimit値を使ってページ単位でデータを取得するのは普通だと思うが、
CSVになると途端に一括で処理しよう考える傾向にある。
CSVもページ単位で取っていき、
フロントで合成すればよい。
ただし、生成するときに順番が重要なのだが、
フロントは基本非同期なので注意が必要
const maxPage = 5; // データ取得のページ数
// 整数分の配列を生成 例: 5 => [0,1,2,3,4]
const iterator = Array.from(Array(maxPage).keys());
await iterator.reduce((promise, current) => {
return promise.then(async () => {
const res = await axios.get('/path/to/api',{
offset: limit * current,
limit,
});
if (!res) return;
// ここで置換合成処理
});
}, Promise.resolve());
バッチは最終手段
データ量が果てしなく多いとか、
計算量が多いなどPHPのリクエストタイム内で処理が収まらない場合も存在する。
その時初めてバッチ処理を検討する
CSV作成指示、キューの管理、バッチ処理、CSVデータ返却など考えることは多いが、
一つ用意して、条件でいつでも使えるようにしておくのは重要。
参考:
------------------- ↓ 後書きはここから ↓-------------------
実はここからが本編。
よほどの事情がない限りこうはならないと思うが、
私がこうなったのだから仕方がない...
どうしてもSJISで出したい場合
SJISで出すとなるとPHPの mbstring.http_output
とバッティングする。
出力の際に指定しているエンコードに自動で変換する機能なのだが、
正直ない方がいいレベルで邪魔な機能。
PHP8.1から非推奨になったとあるが、
設定場所が default_charset
になっただけで、
挙動は何も変わっていない。
その場合バックエンドから添付ファイル(application/octet-stream)としてデータを返却し、
フロント側も添付ファイルとしてデータを受け取る
コードはLaravelだがこんな感じだろうか
$csv = "csv data";
$file_name = "csv_file";
$csv = str_replace(PHP_EOL, "\r\n", $csv);
$csv = mb_convert_encoding($csv, 'sjis-win', 'utf-8');
$url_encoded = rawurlencode($file_name);
$file_size = strlen($csv);
$response = new StreamedResponse(function () use ($csv) {
echo $csv;
}, SymfonyResponse::HTTP_OK);
$response->headers->set('Content-Type', 'application/octet-stream');
$response->headers->set('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $url_encoded);
$response->headers->set('Content-length', $file_size);
return $response;
フロントでは添付ファイルとしてデータを受け取る
responseTypeをblobとしておけば取得後に変な置換が入らなくて済む
export const csvDownload = (options: any): AxiosPromise => {
const url = `/path/to/csv_download`;
return Axios.get(url, options, { responseType: 'blob' });
}
確認用のツール
CSV確認はExcelを使うと思うが、
データの状態を正確に見るのは難しい。
なので、CSV用のツールを活用するのだが、
意外とありそうでない。
たまたま以下のツールを見つけたので、
ちょっと使ってみよう。
Discussion