Skip to content

Instantly share code, notes, and snippets.

@yano3nora
Last active July 7, 2023 04:30
Show Gist options
  • Save yano3nora/311418ace3978039cb0e6dfc6ca84fd9 to your computer and use it in GitHub Desktop.
Save yano3nora/311418ace3978039cb0e6dfc6ca84fd9 to your computer and use it in GitHub Desktop.
[php: File upload & download] Good practice of file upload & download on PHP. #php #cakephp

DOWNLOAD ON PHP

PHPでCSVを生成する (ただしメモリ・CPUを抑えて)

$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 のファイルダウンロードについてネット検索すると大体上記のような ...

  1. ファイルシステム上にファイルを作成
  2. ダウンロード用のヘッダを吐く
  3. readfile() でファイルの中身を標準出力

... みたいな実装がヒットする。しかしこのやり方だと CSV に限らず大量の DB データや大容量ファイルをダウンロードするような場面で サーバ障害をサクっと引き起こしてしまうため危険

  • ファイルシステム上のファイルに全データを一旦書き込むため サーバのディスク I/O がビジーになりパフォーマンスが低下する
    • パフォーマンス低下により Apache や PHP の実行時間制限やブラウザタイムアウトにひっかかる
    • 処理中はサーバの CPU 使用率が 100% 近くなるため 他ユーザがレスポンスをもらえなくなる 可能性がある
  • readfile() 自体は メモリセーフ だが PHP の標準出力バッファ output_buffering巨大な出力をメモリ上に抱えきれずコケる 可能性がある

どうしろと

解決策はいくつかあるようで、大体以下のような感じ。

$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);

readfile とバッファリング無効化で頑張る

readfile - php.net
readfileは巨大ファイルを扱える
Readfile とラージファイル

// 出力バッファを無効化
while (ob_get_level()) {
  ob_end_flush();
}
flush();

その他ダウンロード実装の注意点

  • 重たいファイルに限らず、基本的にバイナリを返すときはストリーミングにしちゃったほうがいい
    • ブラウザのタイムアウト ( レスポンスが全く帰らない状態で数分経過 ) 問題の回避
    • メモリの割り当て不足 ( PHP メモリ上に巨大なバイナリ展開して爆死 ) 回避
    • 実行時間 ( max_time_execution ) をかなり長めにとっても大丈夫なつくりになる
      • ストリーミングにしないと CPU / メモリ負荷でサーバが悲鳴を上げる時間が延々続く ...
  • HTML では target="_blank"download 属性を利用しないと Chrome でコンソールエラー吐かれる
    • 通常の POST や GET で Document が返るのを期待してたのにバイナリ来たぞこらってことみたい
    • リソースのダウンロードは基本的にブラウザの別タブに逃がすほうが無難
  • 他の回避策を考慮する
    • プログラムを介さないダウンロード ( HTML からのリンク ) 対応ではダメか?
    • 日時系のデータなら夜間バッチなどで対応できないか?
    • 条件をフィルタリングしないとダウンロードできない仕様にできないか?
      • 大量のデータはどうせ後でエクセルとかでフィルタリングするはず

On CakePHP3

CakePHP3 でダウンロードをするやりかたは以下 2 パターン。

  • Cake\Http\Response::withFile() を使って一括で落とす
    • 内部的には readfile() と同じかな?
    • 重たい処理・データのとき即死してしまうが実装は楽
  • Cake\Http\Response::withDownload() で添付ファイルダウンロードしてね状態にして Body を返す
    • ::withBody() にぶち込んだコンテンツを withType() で指定した MIME タイプ形式でレスポンスする仕様
    • ストリーミング形式にしたい場合はこちらを選択

CallbackStream

上記ストリーミングダウンロードを実装するためのクラスちゃん。出力処理をコールバックにして Response オブジェクトに withBody() することで return $this->response したときに自動的にストリーミングになる ... みたいだよ?

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

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;

UPLOAD ON PHP

ファイルアップロードの処理 - php.net

ファイルアップロード処理において、大きく注意すべきは大体以下のような感じ。

  • 攻撃に対するバリデート
    • ファイルサイズ
    • ファイル拡張子
  • ストレージへの保存 ( + DB 登録 )
  • 時間/容量制限の設定
    • PHP memory_limit ( default: 128 MB )
    • PHP post_max_size ( default: 8 MB )
    • PHP upload_max_filesize ( default: 2 MB )
    • PHP max_execution_time ( default: 30 sec )
    • Apache LimitRequestBody ( default: 0 = 無制限 → 32 bit 版なら 2 GB )
    • Apache Timeout ( default: 300 sec )
    • ブラウザタイムアウト ( ブラウザにより 60 ~ 300 sec 程度 ? )
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment