$path = TMP.'sample.csv';
$data = [
['ID', '氏名'],
[1, '山田 太郎'],
[2, '田中 花子'],
];
mb_convert_variables('SJIS-win', 'UTF-8', $data);
$fp = fopen($path, 'w');
foreach ($data as $line) {
fputcsv($fp, $line);
}
fclose($fp);
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="'.basename($path).'"');
header('Content-Length: '.filesize($path));
readfile($path);
PHP のファイルダウンロードについてネット検索すると大体上記のような ...
- ファイルシステム上にファイルを作成
- ダウンロード用のヘッダを吐く
readfile()
でファイルの中身を標準出力
... みたいな実装がヒットする。しかしこのやり方だと CSV に限らず大量の DB データや大容量ファイルをダウンロードするような場面で サーバ障害をサクっと引き起こしてしまうため危険 。
- ファイルシステム上のファイルに全データを一旦書き込むため サーバのディスク I/O がビジーになりパフォーマンスが低下する
- パフォーマンス低下により Apache や PHP の実行時間制限やブラウザタイムアウトにひっかかる
- 処理中はサーバの CPU 使用率が 100% 近くなるため 他ユーザがレスポンスをもらえなくなる 可能性がある
readfile()
自体は メモリセーフ だが PHP の標準出力バッファoutput_buffering
が 巨大な出力をメモリ上に抱えきれずコケる 可能性がある
解決策はいくつかあるようで、大体以下のような感じ。
- 出力バッファリングで指定バイトずつ ob_flush() する
- php://temp など高速に処理可能なメモリ上にデータ展開してから一括出力する
- php://output で標準出力のストリーミングを行いデータをちょっとずつ作成 → 都度出力する
- PHP fputcsvで改行コードをCR+LFにする
- Win10 の新しい環境では割と無視してもよいかも ...
$path = TMP.'sample.csv';
$data = [
['ID', '氏名'],
[1, '山田 太郎'],
[2, '田中 花子'],
];
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="'.basename($path).'"');
// 標準出力 php://output を fopen でストリームする
$fp = fopen('php://output', 'w');
$filter = 'convert.iconv.utf-8/cp932';
stream_filter_append($fp, $filter, STREAM_FILTER_WRITE); // 一括変換できないのでストリームフィルタを設定
foreach ($data as $line) {
fputcsv($fp, $line);
}
fclose($fp);
// メモリ上 ( php://memory ) に展開して一括出力する
$fp = fopen('php://temp/maxmemory:'.(5*1024*1024),'a'); // 5MB 制限 ( デフォルト 2MB ) を超えると一時ファイルを書き出すため拡張
foreach($data as $line){
fputcsv($fp, $line);
}
rewind($fp);
echo mb_convert_encoding(stream_get_contents($fp), 'SJIS-win', 'UTF8');
fclose($fp);
// 出力バッファを無効化
while (ob_get_level()) {
ob_end_flush();
}
flush();
- 重たいファイルに限らず、基本的にバイナリを返すときはストリーミングにしちゃったほうがいい
- ブラウザのタイムアウト ( レスポンスが全く帰らない状態で数分経過 ) 問題の回避
- メモリの割り当て不足 ( PHP メモリ上に巨大なバイナリ展開して爆死 ) 回避
- 実行時間 ( max_time_execution ) をかなり長めにとっても大丈夫なつくりになる
- ストリーミングにしないと CPU / メモリ負荷でサーバが悲鳴を上げる時間が延々続く ...
- HTML では
target="_blank"
やdownload
属性を利用しないと Chrome でコンソールエラー吐かれる- 通常の POST や GET で Document が返るのを期待してたのにバイナリ来たぞこらってことみたい
- リソースのダウンロードは基本的にブラウザの別タブに逃がすほうが無難
- 他の回避策を考慮する
- プログラムを介さないダウンロード ( HTML からのリンク ) 対応ではダメか?
- 日時系のデータなら夜間バッチなどで対応できないか?
- 条件をフィルタリングしないとダウンロードできない仕様にできないか?
- 大量のデータはどうせ後でエクセルとかでフィルタリングするはず
CakePHP3 でダウンロードをするやりかたは以下 2 パターン。
- Cake\Http\Response::withFile() を使って一括で落とす
- 内部的には
readfile()
と同じかな? - 重たい処理・データのとき即死してしまうが実装は楽
- 内部的には
- Cake\Http\Response::withDownload() で添付ファイルダウンロードしてね状態にして Body を返す
::withBody()
にぶち込んだコンテンツをwithType()
で指定した MIME タイプ形式でレスポンスする仕様- ストリーミング形式にしたい場合はこちらを選択
上記ストリーミングダウンロードを実装するためのクラスちゃん。出力処理をコールバックにして Response オブジェクトに withBody()
することで return $this->response
したときに自動的にストリーミングになる ... みたいだよ?
- ボディーの設定 - book.cakephp.org
- Cakephp3 CallbackStream を利用したCSVダウンロード
- CakePHP3.4でファイルを作成しつつダウンロードを行うとヘッダーエラーが発生する - stackoverflow
use Cake\Http\CallbackStream;
// Print image.
$this->response = $this->response->withType('image/png');
$this->response = $this->response->withDownload('image.png');
$image = imagecreate(100, 100);
$stream = new CallbackStream(function () use ($image) {
imagepng($image);
});
$this->response = $response->withBody($stream);
return $this->response;
// Print csv.
$data = [
['ID', '氏名'],
[1, '山田 太郎'],
[2, '田中 花子'],
];
$this->response = $this->response->withType('text/csv');
$this->response = $this->response->withDownload('sample.csv');
$streamContent = new CallbackStream(function () use ($data) {
mb_convert_variables('SJIS-win', 'UTF-8', $data);
$stdOut = new \SplFileObject('php://output', 'w');
foreach ($data as $line) {
$stdOut->fputcsv($line);
}
});
$this->response = $response->withBody($streamContent);
return $this->response;
Zend\Diactoros\Stream
クラスを利用すると 既にサーバ上に存在するファイルを指定してストリーミング みたいなことがお手軽に実装できるみたい。
ただ、CSV や SQL みたいな DB データの出力は前述した CallbackStream のコールバックで echo fgets($fp)
でちょい吐きを仕込めば良いし、画像系は imagepng() / imagegif() / imagejpeg()
でいけるし、動画や音声は HTML5 の <video>
や <audio>
タグがあるので、使いどころが微妙。
use Zend\Diactoros\Stream;
$this->response = $this->response->withHeader('Content-Length', filesize($filePath));
$this->response = $this->response->withType('application/octet-stream');
$this->response = $this->response->withDownload($fileName);
$streamContent = new Stream($filePath, 'rb');
$this->response = $this->response->withBody($streamContent);
return $this->response;
// CallbackStream で頑張る説
use Cake\Http\CallbackStream;
$this->response = $this->response->withHeader('Content-Length', filesize($filePath));
$this->response = $this->response->withType('application/octet-stream');
$this->response = $this->response->withDownload($fileName);
$streamContent = new CallbackStream(function () use ($filePath) {
$file = new \SplFileObject($filePath, 'r+b');
while (!$file->eof() and (connection_status() == CONNECTION_NORMAL)) {
echo $file->fgets(); // ここで第一引数与えれば分割ストリームできる
}
$file = null; // fclose 的なリソース解放
unlink($filePath); // ここで殺せる
});
$this->response = $this->response->withBody($streamContent);
return $this->response;