📘

PHPでCSVの文字化け対策を考える(2023)

2023/09/11に公開

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に対応している

https://learn.microsoft.com/ja-jp/windows-insider/archive/new-in-19h1#notepad-updates

ちなみにMac向けに出力するときはBom付Unicode(UTF16)にすることもある

バックエンドはデータ作成のみ

昔は特定のAPIにリダイレクトしてCSVダウンロードする手法を使っていたが、
今はフロントエンド側でダウンロードさせるやり方が普通な気がする。

バックエンド側ではJSONで普通にデータを返却して、
フロントエンド側で整形する
または、CSV化したデータをそのまま出力して、
フロントエンド側でダウンロードの実行を行う

ダウンロードはフロント側

JSONまたはCSVの素のデータをフロント側で受け取り、
ダウンロードURLを発行してブラウザに指示する。
URL発行には createObjectURL を使う
仮想DOM上に発行したURLをAタグに貼ってクリックさせることでダウンロードさせている

const blob = "csv data";
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データ返却など考えることは多いが、
一つ用意して、条件でいつでも使えるようにしておくのは重要。

参考:
https://atmarkit.itmedia.co.jp/ait/articles/2112/20/news026.html

------------------- ↓ 後書きはここから ↓-------------------

実はここからが本編。

よほどの事情がない限りこうはならないと思うが、
私がこうなったのだから仕方がない...

どうしても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用のツールを活用するのだが、
意外とありそうでない。

たまたま以下のツールを見つけたので、
ちょっと使ってみよう。

https://www.moderncsv.com/

Discussion