Zend Framework で大きいファイルをダウンロード

表題の通り、Zend Framework 上で大きいファイルをダウンロードするページを作成する手順について考えてみた。

ここでいう「大きいファイル」とは、PHP の出力バッファのサイズを超えるもので、そういう大きいデータを単純に出力しようとすると途中までしか出力されない。

生の PHP だと、そういう場合には出力バッファを切るとか、適当に制御するとかすれば良いのだが、Zend Framework 上で、となると、当然ながら Zend Framework の作法に則った操作が必要となる。

調べてみると、大きく2通りの方針がありそうだ。

一つは、そういう出力を行う Zend Framework に対応したクラスを作成すること。もう一つは、Zend Framework の通常のフローによる出力をキャンセルして、自前でコントロールすることだ。

前者は、アクションコントローラの中でちょっと出力データをダウンロードさせたい、という用途には、やや大げさな気がしたので、比較的簡単そうな後者の方針を取ることにした。

ポイントは、次の通り。

  • 通常のフローによる出力を停止する。
  • ブラウザのダウンロードダイアログが表示されるよう、適切な HTTP レスポンスヘッダを出力する。
  • PHP の出力バッファを停止し、直接データを出力する。
<?php
class PublishController extends Zend_Controller_Action
{
(略)
public function releaseAction()
{
(略)
// Response::sendResponse() が FrontController から
// 呼び出されないようにする
$this->getFrontController()->returnResponse(true);
// Response オブジェクトの取得
$response = $this->getResponse();
// HTTP レスポンスヘッダの出力
$response->clearAllHeaders()
->setHeader('Content-Description', 'File Transfer')
->setHeader('Content-Type', 'application/octet-stream')
->setHeader('Content-Disposition',
sprintf('attachment; filename="%s"', $filename))
->setHeader('Content-Transfer-Encoding', 'binary')
->setHeader('Expires', '0')
->setHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
->setHeader('Pragma', 'public')
->sendHeaders();
// 以前に何かが出力されようとしていた場合に備えて消しておく
$response->clearBody();
// PHP のバッファをクリアし、バッファを無効にする
while (ob_get_level() > 0) {
ob_end_clean();
}
// データの出力
$h = fopen('ファイル', 'rb');
while (!feof($h)) {
$c = fread($h, 4096);  // 4KB毎に入出力する。最適なサイズは?
$response->setBody($c);
$response->outputBody();
}
fclose($h);
}
}

HTTP レスポンスヘッダは PHP の readfile() のマニュアル を参考にした。

上の例では、Content-Length を出力していないが、外部コマンドの出力などのように事前にサイズが取得できない場合のケースを考えていたため。静的なファイルだったら、ファイルサイズを Content-Length に出力する方が当然良い。

他所でよく見られる例では、アクションコントローラ内で

$this->_helper->viewRenderer->setNoRender();

を実行して、

$this->getFrontController()->returnResponse(true);

を実行していない。
しかし、後者を実行しないと、通常のフローとしてフロントコントローラの dispatch 後に Response::sendResponse() が呼び出されることになる。
ここで HTTP レスポンスヘッダを出力しようとするが、アクションコントローラですでに HTTP レスポンスヘッダを出力しているので、例外が発生し、例外メッセージが(データはすでに出力しているので)データの末尾に付加されてしまう。正しいデータサイズに対応した Content-Length をつけていれば無視されるのかもしれないが、この例のように Content-Length を何らかの理由でつけていないと、データの末尾にゴミが付いてしまうことになる。

(2011/11/06 追記)

最初に書いていたコードでは PHP の出力バッファを完全にはキャンセルできていなかったので修正。

なんでかよくわからないが、出力バッファが2段 (ob_get_level() == 2) となっていたので、すべての出力バッファを無効にするように変更した。どこで ob_start() が2回呼ばれているのだろう?