Posted at

Phalcon\Http\Responseでファイルを返す時の処理

More than 3 years have passed since last update.

動作を確認した環境は以下の通りです。


  • Windows 7 (32bit)

  • PHP 5.6.9 ビルトインWebサーバ

  • Phalcon 1.3.4


Phalcon\Http\Response::setFileToSend() の仕様

Phalcon\Http\Response にはファイルをレスポンスで返すための setFileToSend() メソッドが用意されています。

メソッドの仕様は以下の通り。

public Phalcon\Http\ResponseInterface setFileToSend (string $filePath, [string $attachmentName], [boolean $attachment])


  • 第1引数…レスポンスで返すファイルのパス

  • 第2引数…Content-Dispositionヘッダで返すファイル名

  • 第3引数…Content-Dispositionヘッダにattachmentを付けるかどうか(ブラウザにダウンロードダイアログを表示させるかどうか)

このメソッドでは以下のようなレスポンスヘッダが自動でセットされます。 (第3引数を省略またはtrueを指定した場合の例)

Content-Description: File Transfer

Content-Disposition: attachment; filename=ファイル名
Content-Transfer-Encoding: binary

第2引数を省略した場合は、Content-Dispositionヘッダには第1引数で指定したファイルのファイル名が自動でセットされます。


注意事項


  • 【重要】Content-Type, Content-Lengthヘッダはセットしてくれない

自分でセットしましょう!


  • 第2引数にマルチバイトのファイル名を指定しても素のまま返される

Content-Dispositionヘッダでマルチバイトのファイル名を返す際、各ブラウザに対応するための(バッド)ノウハウが色々とありますが、そういうのは考慮されていないようです。

Content-Disposition: attachment; filename=ダウンロード

素のまま返してくれます。UTF-8で指定しても、SJISで指定しても同じでした…。

そのため、マルチバイトのファイル名を返したい場合、Response::setFileToSend() を呼んでから、自分で Response::setHeader('Content-Disposition', ...) とする必要があります。


  • 第3引数 $attachment に false を指定すると…

第3引数にfalseを指定すると、なぜか Content-Description, Content-Disposition, Content-Transfer-Encoding ともにセットされなくなります。どういうことでしょうか。

いずれにせよ、If-None-MatchやIf-Modified-Sinceリクエストヘッダを使ったHTTPキャッシュ制御はフレームワークレベルでサポートされていないようなので、ファイル送信にまつわる処理は、 Response::setFileToSend() を使ってもそれほど手抜きできないでしょう。


ファイルレスポンスを返す上でやっていること(あまりPhalconには限定しない話)

これだけだとあまり参考になる情報がないので、実際にどんな処理を行っているかを Phalcon\Mvc\Micro を継承したApplicationクラスから抜粋して紹介します。


Application::fileResponse()

/**

* ファイルをレスポンスオブジェクトにセットして返します。
*
* @param SplFileInfo ファイル
* @param array オプション
* filename: ファイル名
* download: ダウンロードするかどうか
* no_cache: クライアントキャッシュを禁止するかどうか
* @return \Phalcon\Http\Response
*/

public function fileResponse(\SplFileInfo $file, $options = array())
{
if (!$file->isFile()) {
throw new \InvalidArgumentException('File is not file.');
}
if (!$file->isReadable()) {
throw new \InvalidArgumentException('File is not readable.');
}
$this->setFileToSend($file, $options);
return $this->response;
}


Application::setFileToSend()

中身はこれです。継ぎ足し継ぎ足した秘伝のタレ的な。

アプリケーションの要件次第な部分も大きいし、マルチバイト対応とか不要なユーザーも多いでしょうから、フレームワークレベルでの対応は期待しない方がいいのかもしれません。

private function setFileToSend(\SplFileInfo $file, $options = array())

{
$filename = (isset($options['filename'])) ? $options['filename'] : $file->getBasename();
$download = (isset($options['download']) && $options['download']);
$no_cache = (isset($options['no_cache']) && $options['no_cache']);

if ($no_cache) {
$this->response->setHeader('Cache-Control', 'must-revalidate, no-cache, no-store, private');
} else {
$this->response->setHeader('Cache-Control', 'public');
}

$this->response->setHeader('Last-Modified',
$this->buildHttpDate(new \DateTime(sprintf('@%d', $file->getMtime())))
);

$this->response->setHeader('ETag',
sprintf('"%s"', hash_file('sha256', $file->__toString()))
);

if (!$no_cache && $this->isNotModified()) {
$this->notModified();
return;
}

$this->response->setFileToSend($file->__toString());

// ファイルの内容からMIMEタイプを判定
$mimeType = new \finfo(FILEINFO_MIME_TYPE);
$contentType = $mimeType->file($file->__toString());

$this->response->setHeader('Content-Type', ($contentType !== false) ? $contentType : 'application/octet-stream');
$this->response->setHeader('Content-Length', $file->getSize());

// ファイル名にマルチバイト文字を含まない場合はそのまま出力、含む場合はパーセントエンコード
$disposition = ($download) ? 'attachment' : 'inline';
if (mb_strlen($filename, 'UTF-8') === strlen($filename)) {
$disposition .= sprintf('; filename="%s"', str_replace('"', '\\"', $filename));
} else {
$disposition .= sprintf("; filename=%s; filename*=utf-8''%s", rawurlencode($filename), rawurlencode($filename));
}
$this->response->setHeader('Content-Disposition', $disposition);

// IE8+用
if ($download) {
$this->response->setHeader('X-Download-Options', 'noopen');
}

$currentHttpDate = $this->buildHttpDate(new \DateTime());
$this->response->setHeader('Date', $currentHttpDate);

if ($no_cache) {
$this->response->setHeader('Expires', $currentHttpDate);
}

return $this->response;

}

MIMEタイプ判定に finfo を使うのは速度的に良くないようですが、社内とか特定ユーザー向けアプリケーションが主なためか、今のところ問題にはなっていません。

昔はIE対策としてダウンロード用の処理がかなりめんどくさい、ユーザーエージェント毎の分岐が必要なレベルでしたが、IE7以下を切り捨ててからは Content-Disposition ヘッダは対象ブラウザ共通、ダウンロードダイアログの制御のための X-Download-Options: noopen のみになりました。

あと、全レスポンスに強制的にセットしてるのでこのコードには含まれていませんが、レスポンスヘッダに X-Content-Type-Options: nosniff も必須ですね。


Application::isNotModified()

setFileToSend() 内でレスポンスヘッダにセットした Etag, Last-Modified の値とリクエストヘッダの If-None-Match, If-Modified-Since の値を比較して、更新されているかどうかを返すメソッドです。

ファイルを返す場合に限ったものではありませんが、関連する処理ではあるので併せて載せておきます。

private function isNotModified()

{

$notModified = false;

$etag = $this->response->getHeaders()->get('ETag');
$lastModified = $this->response->getHeaders()->get('Last-Modified');

$ifNoneMatch = $this->request->getHeader('HTTP_IF_NONE_MATCH');
$ifModifiedSince = $this->request->getHeader('HTTP_IF_MODIFIED_SINCE');

$requestEtags = ($ifNoneMatch !== null)
? preg_split('/\s*,\s*/', $ifNoneMatch, null, PREG_SPLIT_NO_EMPTY)
: null;

if ($etag !== null && $requestEtags !== null && (in_array($etag, $requestEtags) || in_array('*', $requestEtags))) {
$notModified = true;
}

if ($lastModified !== null && $ifModifiedSince !== null) {
return ((strtotime($ifModifiedSince) >= strtotime($lastModified)) && ($requestEtags === null || $notModified));
}

return $notModified;
}

条件を簡単にまとめるとこんな感じ。


  • If-None-Match が指定されていれば、ETag の値と比較し、一致する場合は更新されていないものとする

  • If-Modified-Since が指定されていれば、Last-Modified の日時と比較し、同一または If-Modified-Since の方が新しい場合は更新されていないものとする

  • If-None-Match と If-Modified-Since の両方が指定されていれば、両方の条件を満たしている場合のみ更新されていないものとする


Application::notModified()

304 NotModifiedレスポンスを返すためのメソッドです。こちらもファイルを返す場合に限ったものではありませんが、同上で。

/**

* NotModifiedレスポンス
*
* @return \Phalcon\Http\Response
*/

public function notModified()
{
$headers = $this->response->getHeaders();
$this->response->setStatusCode(304, $this->reasonPhraseOf(304));
$this->response->setContent(null);
foreach (self::$entityHeaders as $header) {
if (null !== $this->response->getHeaders()->get($header)) {
$this->response->getHeaders()->remove($header);
}
}
$this->response->setHeader('Date', $this->buildHttpDate(new \DateTime()));
return $this->response;
}

レスポンスオブジェクトを不要なヘッダや内容を空にして返してるだけです。

Dateヘッダをどの時点でセットするべきかは悩みどころですが、そのままユーザーコードから返される可能性のあるメソッドでは逐一セットしています。

その他呼ばれているプライベートメソッドやプロパティは、だいたい想像できると思うので省略します。