[PHP] Mac版Excelと互換性のあるCSVファイルを出来るだけ効率よく作成する

# ポイント

[mpyw-yattemita/excel-csv-compatibility-check](https://github.com/mpyw-yattemita/excel-csv-compatibility-check)

上記リポジトリで互換性を確認した結果，一般的には，以下の2つのいずれかの選択肢をとるべきであることが分かりました。

- Shift_JISでCSV形式にして拡張子csvで保存
- UTF-16(LE)で**TSV形式**にして拡張子csvで保存

この記事では，UTF-16(LE)を取り扱うことを前提とします。

### 注意点

- Unicodeを用いる場合，文字セットに応じたBOMが必要である。
  - UTF-8なら `0xEF` `0xBB` `0xBF`
  - **UTF-16(LE) なら `0xFE` `0xFF`**
  - UTF-16(BE) なら `0xFF` `0xFE`
- 「UTF-16(LE)」と「UTF-16LE」は別物なので注意。**「UTF-16LE」にはBOMが付かない。**
- Shift_JISのほうが文字の守備範囲が狭く，絵文字やラテン文字・ハングル文字などに対応できない。文字化けを回避したい場合はUTF-16(LE)を選択すべき。

# 基本

「新規作成`w`」「読み出し`r`」の2パターンを紹介します。

## ファイルポインタリソース版

```php:新規作成
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// ファイルをオープン
$fp = fopen(__DIR__ . '/data.csv', 'wb');

// 書き込み時にUTF-8からUTF-16LEに変換
// (UTF-16にするとBEになってしまうため，UTF-16LEとしてオープンして後からBOMを書き込む)
stream_filter_prepend($fp, 'convert.iconv.utf-8/utf-16le');

// BOMを書き込んだ後，1行ずつファイルに書き込む
fwrite($fp, "\xEF\xBB\xBF");
foreach ($rows as $row) {
    fputcsv($fp, $row, "\t");
}
```

```php:読み出し
<?php

// ファイルをオープン
$fp = fopen(__DIR__ . '/data.csv', 'rb');

// 読み出し時にUTF-16(BE/LE)からUTF-8に変換する
// (自動でBOMは取り除かれる)
stream_filter_prepend($fp, 'convert.iconv.utf-16/utf-8');

// 1行ずつ取り出す
while ($row = fgetcsv($fp, 0, "\t")) {
    if ($row === [null]) continue; // 空行を読み飛ばす
    var_dump($row);
}
```

## SplFileObject版

```php:新規作成
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// 書き込み時にUTF-8からUTF-16LEに変換するようにしてファイルオープン
// (UTF-16にするとBEになってしまうため，UTF-16LEとしてオープンして後からBOMを書き込む)
$filename = __DIR__ . '/data.csv';
$spec = "php://filter/write=convert.iconv.utf-8%2Futf-16le/resource=$filename";
$file = new \SplFileObject($spec, 'wb');
$file->setCsvControl("\t"); // デフォルトの区切り文字をタブに変更する

// BOMを書き込んだ後，1行ずつファイルに書き込む
$file->fwrite("\xEF\xBB\xBF");
foreach ($rows as $row) {
    $file->fputcsv($row);
}
```

```php:読み出し
<?php

// 読み出し時にUTF-16(BE/LE)からUTF-8に変換するようにしてファイルオープン
// (自動でBOMは取り除かれる)
$filename = __DIR__ . '/data.csv';
$spec = "php://filter/read=convert.iconv.utf-16%2Futf-8/resource=$filename";
$file = new \SplFileObject($spec, 'rb');
$file->setFlags(
    \SplFileObject::READ_CSV | // foreachの反復処理をfgetcsvで行う
    \SplFileObject::SKIP_EMPTY | // 空行を読み飛ばす (これが無いと空行を [null] として読み取ってしまう)
    \SplFileObject::READ_AHEAD | // 空行を読み飛ばすためには必須
    \SplFileObject::DROP_NEW_LINE // 空行を読み飛ばすためには必須
);
$file->setCsvControl("\t"); // デフォルトの区切り文字をタブに変更する

// 1行ずつ取り出す
foreach ($file as $row) {
    var_dump($row);
}
```

# 応用

「新規作成or追記→読み出し`a+`」の場合を紹介します。これは掲示板スクリプトなどでの利用を想定しているため，ファイルロック処理も入れています。

## ファイルポインタリソース版

```php:新規作成or追記→読み出し
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// ファイルをオープン
$filename = __DIR__ . '/data.csv';
$fp = fopen($filename, 'a+b');

// 書き込み時にUTF-8からUTF-16LEに変換する
// (UTF-16にするとBEになってしまうため，UTF-16LEとしてオープンして後からBOMを書き込む)
stream_filter_prepend($fp, 'convert.iconv.utf-8/utf-16le', STREAM_FILTER_WRITE);

// 排他ロックと初回のみBOMの書き込みを行った後末尾に移動し，1行ずつファイルに書き込む
flock($fp, LOCK_EX);
if (filesize($filename) < 2) {
    fwrite($fp, "\xEF\xBB\xBF");
    clearstatcache();
}
foreach ($rows as $row) {
    fputcsv($fp, $row, "\t");
}

// 先頭に戻る
rewind($fp);

// 読み出し時にUTF-16(BE/LE)からUTF-8に変換する
// (自動でBOMは取り除かれる)
stream_filter_prepend($fp, 'convert.iconv.utf-16/utf-8', STREAM_FILTER_READ);

// 共有ロックに切り替えた後，1行ずつ取り出す
flock($fp, LOCK_SH);
while ($row = fgetcsv($fp, 0, "\t")) {
    if ($row === [null]) continue; // 空行を読み飛ばす
    var_dump($row);
}
```

## SplFileObject版

```php:新規作成or追記→読み出し
<?php

// 挿入するデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// 書き込み時にUTF-8からUTF-16LE
// 読み出し時にUTF-16LEからUTF-8に変換するようにしてファイルオープン
// (新規作成する可能性がある場合はreadにUTF-16は指定出来ないのでUTF-16LEにする)
$filename = __DIR__ . '/data.csv';
$spec = "php://filter/read=convert.iconv.utf-16le%2Futf-8/write=convert.iconv.utf-8%2Futf-16le/resource=$filename";
$file = new \SplFileObject($spec, 'a+b');
$file->setFlags(
    \SplFileObject::SKIP_EMPTY | // 空行を読み飛ばす (これが無いと空行を [null] として読み取ってしまう)
    \SplFileObject::READ_AHEAD | // 空行を読み飛ばすためには必須
    \SplFileObject::DROP_NEW_LINE // 空行を読み飛ばすためには必須
);
$file->setCsvControl("\t"); // デフォルトの区切り文字をタブに変更する

// 排他ロックと初回のみBOMの書き込みを行った後末尾に移動し，1行ずつファイルに書き込む
$file->flock(LOCK_EX);
if (filesize($filename) < 2) {
    $file->fwrite("\xEF\xBB\xBF");
    clearstatcache();
}
foreach ($rows as $row) {
    $file->fputcsv($row);
}

// 共有ロックに切り替えた後2バイトを除いた先頭に戻り，1行ずつ取り出す
// イテレータは使えないので手動でfgetcsvする
$file->flock(LOCK_SH);
$file->fseek(2);
while ($row = $file->fgetcsv()) {
    var_dump($row);
}
```

**※ `fgets`に対してこのwhileループの書き方は出来ないので注意**

なんかイマイチですね。どうせならクラス継承しちゃいましょうか。

```php:新規作成or追記→読み出し&#32;(SplFileObject継承)
<?php

// 書き込むデータ
$rows = [
    ['佐倉綾音', '保登心愛'],
    ['水瀬いのり', '香風智乃'],
];

// 追記と読み出しが可能なCSVファイルとしてオープン
$file = new ExcelCsvFileObject(__DIR__ . '/data.csv');

// 排他ロックのあと1行ずつファイルに書き込む
$file->flock(LOCK_EX);
foreach ($rows as $row) {
    $file->fputcsv($row);
}

// 共有ロックに切り替えて1行ずつ取り出す
$file->flock(LOCK_SH);
foreach ($file as $row) {
    var_dump($row);
}



/**
 * Excel互換のCSVに特化したSplFileObjectのラッパー
 * 常にa+bモードでオープンする
 */
class ExcelCsvFileObject extends \SplFileObject
{   
    private $position = 0;
    private $current;
    
    /**
     * @param string $filename ファイル名
     * @throws RuntimeException
     */
    public function __construct($filename)
    {
        parent::__construct("php://filter/read=convert.iconv.utf-16le%2Futf-8/write=convert.iconv.utf-8%2Futf-16le/resource=$filename", 'a+b');
        $this->setFlags(
            \SplFileObject::READ_CSV |
            \SplFileObject::SKIP_EMPTY | 
            \SplFileObject::READ_AHEAD | 
            \SplFileObject::DROP_NEW_LINE
        );
        $this->setCsvControl("\t");
        if (filesize($filename) < 2) {
            $this->flock(LOCK_EX);
            $this->fwrite("\xEF\xBB\xBF");
            $this->flock(LOCK_UN);
            clearstatcache();
        }
        $this->rewind();
    }
    
    /**
     * @return string|null
     */
    public function current()
    {
        return $this->current;
    }

    /**
     * @return int
     */
    public function key()
    {
        return $this->position;
    }

    /**
     * @return null
     */
    public function next()
    {
        $this->current = $this->fgetcsv();
        ++$this->position;
    }
    
    /**
     * @return null
     */
    public function rewind()
    {
        $this->fseek(2);
        $this->position = 0;
        $this->current = $this->fgetcsv();
    }
    
    /**
     * @return bool
     */
    public function valid()
    {
        return $this->current !== null;
    }
}
```