PHPでCSVエクスポート時の改行されてしまう問題解消

今さらな感じですが、よく忘れてしまうので備忘録程度になります。
phpでエクスポートすると、文字コードやら改行コードを気にしなきゃいけなくて何かとめんどくさい。
ライブラリーを使った方が確実ですが、メモリやCPUに負担がかかるのを避けたいので、
オレオレコードでエクスポートしています。

表題とずれてしまいますが、まずはメモリ問題を抑える方法を書いておきます。

# phpの消費メモリを抑える
あくまでもphpのメモリを抑えるためのコードなので、サーバーのバッファに使っているメモリは
ここでは気にしないことにします。

CSVを出力するときはいろんな方法があると思います。foreachで変数等に入れて、最後に出力したり、
ファイルに直接1行ずつ書き込んだり、ストリームに出力したり。
変数等にデータを貯めこむとメモリは使ってしまうし、ファイルへの出力は書き込みが遅いかったり、
出力時にfopenで開くと結局はファイルの容量分だけメモリを消費したりと、
この辺のやり方は使えなさそうですね。

なので、今回はphpのストームを使います。ストリームにも種類がありますが、出力バッファに
直接書き込んでくれるphp://outputを使おうと思います。

簡単に書くとこんな感じですね。ポイントは、ヘッダー情報をfopenより前に行うことですね。

```php:sample-export.php
<?php 
// 区切り文字と囲み文字を指定
define('CSV_DELIMITER', ',');
define('CSV_ENCLOSURE', '"');

// 念のために出力をキレイにする
ob_clean();

// ヘッダー送信
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=sample-". strtotime("now") .".csv");

// ストリームを開く
$fp = fopen('php://output','w');

// $dataにDBから取得した情報が入っているとして
foreach( $data as $row )
{
	// ストリームにCSVになったものを書き込んでいく
	fputcsv($fp, $row, CSV_DELIMITER, CSV_ENCLOSURE);
}

// ストリームを閉めて、出力を終える（必須ではないが、念のため）
fclose($fp); exit;
```

シンプルですね。テストはしていないので、間違っていたらすみません…
これでメモリ問題は最善を尽くした感じにはなると思います。

しかし、デメリットが一つあります。ヘッダーを早い段階で送信しているので、
ファイルサイズが不明です。なので、ブラウザで『残りX秒』的な表示やプログレスバーが
表示されない形になってしまいます。重要ではないが、気にする人は気にするので…

# 文字コードを修正する
DBの文字コードはUTF-8が主流なので、このままだとExcel等で文字化けが起こってしまいます。
文字コードの修正方法も色々ありますね。$rowを更にforeachで回してmb_convert_encoding等で
変換したり、一旦溜めたものをテキストにして変換したりなど。
しかし、どれもメモリを食うので、出力中になんどかならないかと考えてみる。

ストリームにもフィルター機能があるので、それをうまく活用しましょう。

```php:sample-export.php
<?php 
// 区切り文字と囲み文字を指定
define('CSV_DELIMITER', ',');
define('CSV_ENCLOSURE', '"');

// 念のために出力をキレイにする
ob_clean();

// ヘッダー送信
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=sample-". strtotime("now") .".csv");

// ストリームを開く
$fp = fopen('php://output','w');

// UTF-8 を Shift_JIS に変換
stream_filter_append($fp, 'convert.iconv.UTF-8/CP932//TRANSLIT', STREAM_FILTER_WRITE);

// $dataにDBから取得した情報が入っているとして
foreach( $data as $row )
{
	// ストリームにCSVになったものを書き込んでいく
	fputcsv($fp, $row, CSV_DELIMITER, CSV_ENCLOSURE);
}

// ストリームを閉めて、出力を終える
fclose($fp); exit;
```

stream_filter_appendを活用して、UTF-8からShift_JISに変換しています。
CP932はShift_JISのことらしいです。また、iconv関数が使えない環境にあると
使えないコードになってしまうのでご注意ください。

ここでポイントはTRANSLITという記述です。変換ができない文字が含まれていた場合に
文字化けが起こってしまうので、これで強制的に変換になります。恐らく『?』に置き換わるけど
化けるよりはマシという判断ですね。完璧にしたい方は、CP932を調整すると良いかもしれません。

これで完璧だ！と思っていた時期がありましたが、MySQLのエスケープ問題が残ってましたね。

# データベースのエスケープに対応する
例えば、書き込みデータの中に下記のような文字が入ってたとします。

`テストテスト\"テスト\"テスト\r\nテスト\r\nテスト\r\nテスト`

これはデータベースに入れる前にエスケープ処理を加えたことで
クオートの前にバックスラッシュがついたものですね。
どうなるんでしょうか？MacのExcelで開くと下記のように何故か改行されてしまう。

![スクリーンショット 2015-12-17 11.25.23.jpg](https://qiita-image-store.s3.amazonaws.com/0/74723/e65b17e7-50d0-5514-1e99-8c7f20efe29b.jpeg)

なので、エスケープされたものをキレイに表示したいと思います。

```php:sample-export.php
<?php 
// 区切り文字と囲み文字を指定
define('CSV_DELIMITER', ',');
define('CSV_ENCLOSURE', '"');


class php_fix_escaped_quotes_filter extends php_user_filter {

	function filter( $in, $out, &$consumed, $closing )
	{
		while($bucket = stream_bucket_make_writeable($in))
		{
			$bucket->data = str_replace('\\' . CSV_ENCLOSURE, CSV_ENCLOSURE . CSV_ENCLOSURE, $bucket->data);
			$consumed += $bucket->datalen;
			stream_bucket_append($out, $bucket);
		}

		return PSFS_PASS_ON;
	}

}

// 念のために出力をキレイにする
ob_clean();

// ヘッダー送信
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=sample-". strtotime("now") .".csv");

// ストリームを開く
$fp = fopen('php://output','w');

// エスケープされたものを変換
stream_filter_register("fix_escaped_quotes", "php_fix_escaped_quotes_filter");
stream_filter_append($fp, 'fix_escaped_quotes', STREAM_FILTER_WRITE);

// UTF-8 を Shift_JIS に変換
stream_filter_append($fp, 'convert.iconv.UTF-8/CP932//TRANSLIT', STREAM_FILTER_WRITE);

// $dataにDBから取得した情報が入っているとして
foreach( $data as $row )
{
	// ストリームにCSVになったものを書き込んでいく
	fputcsv($fp, $row, CSV_DELIMITER, CSV_ENCLOSURE);
}

// ストリームを閉めて、出力を終える
fclose($fp); exit;
```

これでエスケープされたものもキレイに表示されるはずです。
記載されているコードは未テストなので、細かいミスがあったらご指摘ください。

他にも方法が沢山あると思いますが、今回はメモリとCPUの消費量を抑えるというのが目的です。
CPUに関しては、ループ中にsleepさせるとさらに軽減できると思います。
処理がタイムアウトするようでしたら、set_time_limit(0) 入れると良いかもしれません。

いまさらながら良く忘れてしまうので、とりあえず載せておきます。