読者です 読者をやめる 読者になる 読者になる

k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

HttpFoundationで画像の条件付きGETを実装してみる (Symfony Advent Calendar JP 2012 - Day 22)

Symfony Advent Calendar JP 2012 22日目の記事です。

まずはじめにお断りしておきますが、この記事はSymfony未経験者向けです。

(飛び入り参加なのに低レベルな内容ですみません…)

HTTP/1.1 には If-Modified-Since, If-Unmodified-Since, If-Match, If-None-Match, If-Range ヘッダによる「Conditional GET Request」、いわゆる条件付きGETリクエストの仕様があります。

これまでは熟成された秘伝のUtilクラス(という名前の $SERVER とか $REQUEST にアクセスする処理が無造作に詰め込まれたくそ関数集)で行っていたんですが、Symfonyコンポーネントの HttpFoundation を使って If-Modified-Since, If-Match への対応を実装してみました。

キャッシュを禁止する (Pragma, Expires, Cache-Control)

PHPでは、Session機能を有効にした場合、session.cache_limiter および session.cache_expires の設定に合わせて Cache-Control ヘッダの内容が自動的に変更されます。

初期設定の "session.cache_limiter = nocache" で出力されるヘッダは以下のようなものになります。

Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache

php-src/ext/session/session.c at master

CACHE_LIMITER_FUNC(nocache) /* {{{ */
{
    ADD_HEADER("Expires: Thu, 19 Nov 1981 08:52:00 GMT");

    /* For HTTP/1.1 conforming clients and the rest (MSIE 5) */
    ADD_HEADER("Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0");

    /* For HTTP/1.0 conforming clients */
    ADD_HEADER("Pragma: no-cache");
}

とにかくキャッシュさせたくない場合の例として、まずはこの内容を手がかりに調べてみました。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.21 Expires より

レスポンスが "期限切れ" という事を表すためには、オリジンサーバは Date ヘッダの値と同じである Expires の日付を送る

Expiresヘッダの値について、仕様ではこのように書かれていますが、PHPでは固定の過去日付が送られています。

これを疑問に感じる人もいるようで、検索してみるとこのような質問がありました。

epoch date -- Expires: Thu, 19 Nov 1981 08:52:00 - PHPより

Why is the date: Thu, 19 Nov 1981 08:52:00 GMT used as the value for anti-caching Expires: headers?

Why not use the UNIX epoch of Thu, 01 Jan 1970 00:00:00 GMT?

これに対する回答

It was added in this revision:

http://cvs.php.net/viewvc.cgi/php-sr...1=1.80&r2=1.81

... by CVS user "sas", who is Sascha Schumann. The date appears to be Sascha's

birthday:

http://www.phpbuilder.com/lists/php3...99911/3159.php

上記のSession拡張のソースを書いた Sascha Schumann さんの誕生日という話ですが、リンク先が消失しているため真偽のほどは分かりません。

HTTP/1.1向けの Cache-Control ヘッダの値はどのような仕様にもとづいて設定されているのでしょうか。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.9 Cache-Controlより

no-store 指示子の目的は、取り扱いが慎重な (例えばバックアップテープ上の) 情報の不注意な漏洩や保留を防ぐ事である。 no-store 指示子はメッセージ全体に適用され、レスポンスかリクエストのどちらかで送る事ができる。 リクエストで送られる場合、キャッシュはそのリクエストやそれへのいかなるレスポンスの一部分を保存してはならない。 レスポンスで送られる場合、キャッシュはそのレスポンスやそれを引き起こしたリクエストの一部分を保存してはならない。

もし no-cache 指示子がフィールド名を指定していなければ、オリジンサーバへの再検証が成功するまで、以降のリクエストを満足させるためにそのレスポンスを使ってはならない。 これによって、オリジンサーバはリクエストするクライアント古くなったレスポンスを返すよう設定されているキャッシュによるキャッシングさえ行えなくする事ができる。

もしオリジンサーバがすべての HTTP/1.1 キャッシュを、それがどのように設定されているかに関わらず、すべてのリクエストで強制的に検証させたい場合、"must-revalidate" cache-control 指示子 (section 14.9 参照) を使うべきである。

ちょっと分かりづらいですが、no-storeはこのレスポンスを保存するなという意味、no-cacheはキャッシュを保持している場合でもオリジンサーバへの再検証に成功しない限りこのレスポンスを再利用するなという意味、must-revalidateはキャッシュを保持している場合でもオリジンサーバへの検証を必ず行えという意味のようです。

これらのディレクティブを指定することで、クライアントおよび全ての経路上のサーバに対して、該当URLへのリクエストに対するレスポンスの保存と再利用の禁止を指示しています。

post-check および pre-check については明確な仕様は発見できなかったのですが、どうやらMicrosoftがIE5から独自で対応している仕組みのようで、以下のページの「Use Cache-Control Extensions」に記載があります。

Building High Performance HTML Pages (Internet Explorer).aspx)より

When building a Web site, pages will change with varying frequency. Some pages will change daily, while others will never change once they are posted. To allow the Web site manager to indicate to a client browser how frequently an HTTP server should be queried for changes to a resource, Internet Explorer 5 introduces support for two extensions to the cache-control HTTP response header: pre-check and post-check.

By supporting these extensions, Internet Explorer reduces network traffic by sending fewer requests to the server. In addition, Internet Explorer improves the user experience by rendering resources from the cache and by fetching updates in the background after a specified interval.

The post-check and pre-check cache-control extensions are defined as follows:

  • post-check -- Defines an interval in seconds after which an entity must be checked for freshness.

The check may happen after the user is shown the resource but ensures that on the next roundtrip the cached copy will be up-to-date.

  • pre-check -- Defines an interval in seconds after which an entity must be checked for freshness

prior to showing the user the resource.

日本語文による解説は以下のPDFにありました。

無線WANでCitrixテクノロジーを活用する方法 ベストプラクティス P.20-21「IISでCache-Control HTTPヘッダーを使用する」より

Internet Explorer Version 5.0以降は、Cache-Control HTTPヘッダーをサポートしています。 このヘッダーは、キャッシュされたオブジェクトに関するブラウザの動作を定めます。 Cache-Controlヘッダーは、post-checkとpre-checkの2つのパラメータからなります。

post-checkは、エンティティの新しさをチェックする時間間隔です(秒単位で指定)。 このチェックはそのエンティティをユーザーに対して表示した後に行われます。 このチェックにより、次回のラウンドトリップ時に最新のコピーがユーザーに対して表示されるようになります(キャッシュオブジェクトは表示「後(post)」にチェックできることに注意)。

pre-checkは、エンティティをユーザーに対して表示する「前」にエンティティの新しさをチェックする時間間隔(秒単位)です(キャッシュオブジェクトは表示「前(pre)」にチェックされることに注意)。

post-checkとpre-checkを適切に設定することで、公開アプリケーションおよびWeb Interfaceのイメージのキャッシュコピーがユーザーに対して表示されます。この処理は高速に行われます。 post-checkタイムアウト値が経過すると、ブラウザはバックグラウンドでチェックを行い、イメージが更新されているかどうかを確認します。 ブラウザは、pre-checkタイムアウト値が経過したときにのみ(さらにその後post-checkタイムアウトフェーズ中にキャッシュオブジェクトが更新されなかった場合にのみ)、キャッシュオブジェクトの更新状況をチェックします。

要は、"post-check=0, pre-check=0" によって、エンティティの表示前後に時間経過によるキャッシュの有効性をチェックするという、IE専用のキャッシュ制御機能を無効にしているということでしょうか。

前置きが長くなりましたが、レスポンスのキャッシュ禁止は以下のように設定しました。

<?php
namespace Acme;

$loader = include __DIR__ . '/../../../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$request = Request::createFromGlobals();
$response = new Response();

// HTTP/1.0向けに Pragma ヘッダをセット
$response->headers->set('Pragma', 'no-cache');

// HTTP/1.1向けに Cache-Control ヘッダをセット
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private, post-check=0, pre-check=0');

$content = <<<'HTML'
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>TEST</title>
</head>
<body>
    <h1>TEST</h1>
</body>
</html>
HTML;

$response->headers->set('Content-Length', strlen($content));
$response->setContent($content);

// レスポンスが期限切れということを示すために、 Date と Expires ヘッダに同じ値をセット
$currentDate = new \DateTime(null, new \DateTimeZone('UTC'));
$response->setDate($currentDate)->setExpires($currentDate)->prepare($request)->send();

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/no-cache.php

Cache-Control ヘッダにカンマ区切りで複数のディレクティブを設定していますが、HeaderBag には Cache-Controlヘッダ値の解析が実装されているため、こういう書式の値もちゃんと処理してくれます。

(処理内容は HeaderBag::parseCacheControl() を参照)

ResponseHeaderBag では Cache-Control, ETag Last-Modified, Expires ヘッダがセットされた時際、自動的に Cache-Control 値が適切になるようセットし直されます。

具体的には ETag Last-Modified, Expires のいずれかが設定されており、Cache-Control ヘッダのディレクティブが何一つ設定されていない場合は private, must-revalidate ディレクティブが設定され、private または public ディレクティブが設定されておらず s-maxage ディレクティブも設定されていない場合は private ディレクティブを自動的に追記するという複雑な処理が行われます。

(処理内容は ResponseHeaderBag::computeCacheControlValue() を参照)

Response クラスにはレスポンスヘッダの設定を行うためのメソッドや、リクエストを引数に取る便利メソッドが用意されています。

Response::setDate() と Response::setExpires() もそのひとつで、DateTimeオブジェクトを渡すことで、RFC 1123形式に変換してヘッダにセットしてくれます。

Response クラスではインスタンスの生成時に自動的に現在時刻が Date ヘッダに設定されますので、少々面倒ではありますがこのように記述しました。

また、Response::prepare() ではリクエストメソッドやヘッダに合わせて以下のようにレスポンスを自動設定してくれます。

  • 要求されたフォーマットとcharsetに合わせた Content-Type ヘッダを設定
  • Transfer-Encoding ヘッダ値が設定されている場合は Content-Length ヘッダをクリア
  • リクエストメソッドが HEAD の場合はボディをクリア(Content-Length ヘッダのみ返す)
  • サーバ変数に合わせたプロトコルバージョンを設定
  • プロトコルバージョンがHTTP/1.0かつCache-Control ヘッダに no-cache が設定されている場合は Pragma, Expires ヘッダを設定

Transfer-Encoding と Content-Length については [Studying HTTP] HTTP Header Fields #Content-Length のまとめによると

  • 転送コーディングが施されている場合、Content-Length ヘッダは送られてはならないし、仮に送られてもこれを無視しなければならない
  • 転送コーディングが施されていない場合、Content-Length ヘッダは送られなければならないが、これはメッセージボディ中のオクテット数と正確に一致しなければならない

とのことです。

最近はWebサーバ側の設定で圧縮転送されていることも多いと思いますが、たとえばApacheの mod_deflate モジュールのドキュメントには、圧縮機能が実装されている DEFLATE フィルタについてこのような記載があります。

mod_deflate - Apache HTTP サーバ #圧縮を有効にするより

DEFLATE フィルタは必ず、PHP や SSI といった RESOURCE フィルタの後になります。 DEFLATE フィルタは内部的なサブリクエストを関知しません。

サンプルコードの動作確認に使っている Gehirn RS2 でもテキストの圧縮が設定されているようで、リクエストヘッダの Accept-Encoding: gzip, deflate を受け、PHPから出力するHTMLレスポンスに対して明示的に設定した Content-Length が自動的に削除されて、 Content-Encoding: gzip が設定されていました。

ただキャッシュを禁止するだけでも、色々と気をつけなければいけないところがあって、HttpFoundation にもそれらの知見が凝縮されていることが分かりました。

画像ファイルを返すレスポンスを更新日時と属性を元にキャッシュさせる (Last-Modified と If-Modified-Since, ETag と If-None-Match)

こちらが今回の本題です。

HTTPキャッシュへの対応については、静的コンテンツへのリクエストの場合、Webサーバの機能(Apache の場合は mod_expires モジュールや、コアモジュールの FileETag ディレクティブ)で行えます。

しかし実際のところ、Webアプリケーションにおいてはシステムで管理しているファイルを直接公開せず、アプリケーション経由でレスポンスを返すことが多いと思います。

そういった場合にも、仕様に従って適切にレスポンスヘッダを返すことで、キャッシュの利用を促そうというのが今回の内容です。

Last-Modified と If-Modified-Since ヘッダを利用する

Last-Modified レスポンスヘッダ と If-Modified-Since リクエストヘッダは、リソースの最終更新日時をもとにキャッシュの同一性を検証するために利用されます。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.25 If-Modified-Sinceより

If-Modified-Since ヘッダを持っていて且つ Range ヘッダを持たない GET メソッドは、If-Modified-Since ヘッダによって与えられる時刻以降に更新された場合のみ指定したエンティティを転送する事を要求する。 これを決定するためのアルゴリズムには、以下のようなものを含む。

  • リクエストが 200 (OK) ステータス以外の結果を返すか、あるいは渡された If-Modified-Since の日付が不正なものであるような場合、レスポンスは通常の GET 時と全く同じものとなる。サーバの現在時刻より未来の時刻は無効である。
  • バリアントが If-Modified-Since にある時刻以降に更新されている場合、レスポンスは通常の GET 時と全く同じものとなる。
  • バリアントが有効な If-Modified-Since にある時刻以降に更新されていない場合、サーバは 304 (not modified) レスポンスを返すべきである。

この機能は、通信処理の負荷を最小量にするようにキャッシュされた情報を能率的に更新する事を目的にしている。

静的ファイルを返すレスポンスをキャッシュさせる場合、リソースの最終更新日時としてファイルの日付をそのまま使えるため、少しの手間を加えるだけで対応できます。

<?php
namespace Acme;

$loader = include __DIR__ . '/../../../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$request = Request::createFromGlobals();
$response = new Response();

$file = new \SplFileInfo(__DIR__ . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'sunset.jpg');

// ファイルの更新日時を Last-Modified ヘッダにセット
$lastModified = new \DateTime();
$lastModified->setTimestamp($file->getMTime());
$response->setLastModified($lastModified);

// リクエストヘッダの値を利用して同一性を検証
// Response::isNotModified() で検証に成功した場合はステータス 304 がセットされるので、そのまま返せばOK
if ($response->isNotModified($request)) {
    $response->prepare($request)->send();
    exit;
}

$response->setPublic();
$response->setContent(file_get_contents($file->getRealPath()));

// ファイルの情報を元にレスポンスヘッダをセット
$mimeType = new \Finfo(FILEINFO_MIME_TYPE);

$response->headers->set('Content-Type', $mimeType->file($file->getRealPath()));

$response->headers->set('Content-Disposition',
    $response->headers->makeDisposition('inline', $file->getBasename())
);

$response->headers->set('Content-Length', $file->getSize());

$currentDate = new \DateTime(null, new \DateTimeZone('UTC'));
$response->setDate($currentDate)->prepare($request)->send();

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/caching-by-last-modified.php

Response クラスにはリクエストヘッダの値を元にキャッシュの同一性を検証し、同一であればステータス304をセットする Response::isNotModified() メソッドがありますので、これを利用しています。

Response::setLastModified(\DateTime $date = null) と Response::isNotModified(Request $request) のコードを読んでいて気になったんですが、 [Studying HTTP] HTTP Header Fields #HTTP日付 に解説されている 「RFC 822, updated by RFC 1123」形式でセットされた値(これは仕様通りです)と If-Modified-Since リクエストヘッダから取得した値が単純に比較されています。

RFC 850, obsoleted by RFC 1036」や「ANSI C's asctime() format」でリクエストされた値だと常に FALSE が返されてしまうようですが、これは問題にならないんでしょうか。

  • HTTP/1.1アプリケーションは、HTTPに関するあらゆる日付フォーマットを「RFC 1123形式」にて生成しなければならない。
  • また、その他に「RFC 850 形式」、「ANSI Cのasctime()」形式も理解できなければならない。

それ以外の日付形式に関しては必ずしも理解できる必要はないが、ある程度有名な日付形式であれば理解できることが望まれ、その場合には適切な形式に書き換えるべきである。

現行のUAでそういったものがあるのか分かりませんが、忠実に実装するのであれば Response::isNotModified() は使わないようにするか、Requestオブジェクトの If-Modified-Since ヘッダの値を「RFC 1123形式」に変換してセットし直してから使う必要があるかと思います。

(あと蛇足かもしれませんが、熟成された秘伝のUtilクラスでは「;区切りのRFC違反ヘッダに対応」なんてこともしているので、多分コード書いた当時にはそういう日付を送信するUAもあったのではないかと…)

なお上記サンプルでは Last-Modified ヘッダのみレスポンスにセットしていますが、Response::isNotModified() メソッドは後述するエンティティタグの検証にも利用できます。

ETag と If-None-Match ヘッダを利用する

ETag レスポンスヘッダと If-None-Match リクエストヘッダは、リソースのエンティティを比較するための「エンティティタグ」を使ったキャッシュの同一性検証に利用されます。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.26 If-None-Matchより

If-None-Match リクエストヘッダフィールドは、メソッドを条件付きにする場合に使われる。 以前にそのリソースから一つ以上のエンティティを取得しているクライアントは、If-None-Match ヘッダフィールド中にそれに対応するエンティティタグのリストを含める事によって、それらのエンティティの中に現在使用できるものがないかどうかを確かめる事ができる。 この機能は、通信処理の負荷を最小量にするようにキャッシュされた情報を能率的に更新する事を目的にしている。 また (例えば PUT 等の) メソッドを使う場合に、既にあるリソースをクライアントがそのリソースが無いと考えている場合に不注意に更新してしまわないようにするためにも使われる。

エンティティタグについては、プロトコルパラメータのひとつとして独立した説明があります。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #3.11 エンティティタグより

エンティティタグは、同一の要求リソースからの二つ以上のエンティティを比較するために使用される。 HTTP/1.1 では、ETag (section 14.19), If-Match (section 14.24), If-None-Match (section 14.26), If-Range (section 14.27) 各ヘッダフィールドで、エンティティタグを使う。 それらがキャッシュバリディタとして、どのよう使われ、比較されるかの定義は、section 13.3.3 にある。 エンティティタグは、それ自体は読んでも意味のわからない{opaque} 引用符で括られた文字列から成り、weakness インジケータが前方に付く場合もある。

entity-tag = [ weak ] opaque-tag

weak = "W/"

opaque-tag = quoted-string

"W/" プレフィクスによって示される "weak entity tag" では、エンティティが等価であり、意味論においてそれぞれ重要な変更がなく互いを代わりに使う事が出来る場合のみ、リソースの2つのエンティティが共有できる。 weak エンティティタグは、弱い比較の時のみ使用される。

値は示されている通り引用符で囲まれた文字列で、「弱い比較」を行いたい場合のみ W/ プレフィクスが付与されます。

また、仕様ではエンティティタグの一意性について以下のように説明されています。

エンティティタグは、特有のリソースと関連付けられた全てのエンティティの全てのバージョンの中で一意{unique} でなければならない。 与えられたエンティティタグの値は、異なる URI へのリクエストから得られたエンティティのために使う事ができる。 異なる URI へのリクエストから得られたエンティティに同じエンティティタグの値を使っているからといって、それらのエンティティの同等性を暗に意味するものではない。

あくまで同一のリソースにおけるエンティティのバージョンを示すためのものであって、ホスト内で一意である必要はないということです。

静的ファイルの属性を元にエンティティタグを生成するにあたっては、対象のファイルパスとURIが1対1で関連付けられているのであれば、ファイルパスを考慮しなくてもよいことになります。

ただし、同じURIでも別のファイルが返されるケース(例えば月替わりで異なる画像が返される場合など)では、エンティティタグの生成にファイルパスも利用する必要がありますね。

ちなみにApacheでETag レスポンスヘッダを生成するための FileETag ディレクティブでは、エンティティタグ生成に利用するファイルの属性として、inode 番号 / 最終更新日時 / バイト数 の3つを任意で設定できるようになっています。

(inode 番号については分散環境など同じ内容のファイルでも設置先サーバが異なる場合に問題となったり、脆弱性になりうるという指摘もあって、設定しないことが多いようです)

以下はこれらを踏まえて、ファイルパス、サイズ、更新日時を元にエンティティタグを生成したサンプルです。

<?php
namespace Acme;

$loader = include __DIR__ . '/../../../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$request = Request::createFromGlobals();
$response = new Response();

$file = new \SplFileInfo(__DIR__ . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'sunset.jpg');

// ファイルの属性を元に生成した Etag をセット
$etag = sha1(serialize(array(
    'file'  => $file->getRealPath(),
    'size'  => $file->getSize(),
    'mtime' => $file->getMTime(),
)));
$response->setEtag($etag);

// リクエストヘッダの値を利用して同一性を検証
// Response::isNotModified() で検証に成功した場合はステータス 304 がセットされるので、そのまま返せばOK
if ($response->isNotModified($request)) {
    $response->prepare($request)->send();
    exit;
}

$response->setPublic();
$response->setContent(file_get_contents($file->getRealPath()));

// ファイルの情報を元にレスポンスヘッダをセット
$mimeType = new \Finfo(FILEINFO_MIME_TYPE);

$response->headers->set('Content-Type', $mimeType->file($file->getRealPath()));

$response->headers->set('Content-Disposition',
    $response->headers->makeDisposition('inline', $file->getBasename())
);

$response->headers->set('Content-Length', $file->getSize());

$currentDate = new \DateTime(null, new \DateTimeZone('UTC'));
$response->setDate($currentDate)->prepare($request)->send();

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/caching-by-etag.php

Last-Modified の場合と同様、Response::setEtag() でエンティティタグをレスポンスヘッダにセットした後、Response::isNotModified() メソッドでリクエストヘッダの値を利用した同一性の検証を行います。

Response::isNotModified() ではリクエストヘッダに If-None-Match があればそちらを優先し、なければ If-Modified-Since が利用されます。

またこのメソッドでは、If-None-Match の特別な値 "*" についても考慮されています。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.26 If-None-Matchより

特別な場合として、"*" という値は、そのリソースの現在のあらゆるエンティティに一致する。

いずれかのエンティティタグがそのリソースにされる類似した (If-None-Match ヘッダが無い) GET リクエストのレスポンスとして返されるエンティティのエンティティタグに一致する場合か、"*" が与えられる場合にそのリソースに現在使用できるエンティティが存在する場合において、サーバはリソースの更新時刻とリクエストの中の If-Modified-Since ヘッダフィールドにて与えられた時刻が一致しなかった場合以外は、リクエストされた動作を行ってはならない。 その代わり、もしリクエストメソッドが GET か HEAD であれば、サーバは 304 (Not Modified) レスポンスを、一致したエンティティのうちの一つのキャッシュに関連するヘッダフィールド (特に ETag) を付けて返すべきである。

つまり、If-None-Match ヘッダの値に * が指定されている場合は、If-Modified-Since ヘッダの値とリソースの更新時刻も合わせて検証するということです。

ETag ヘッダと Last-Modified ヘッダの併用をどのような基準で行うべきかについては、HTTP/1.1のRFCにも記載されています。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #13.3.4 エンティティタグや Last-Modified の日付を使う場合の規定より

我々は様々なバリディタタイプがいつ、何の目的で使用されるべきかに関するオリジンサーバ、クライアント、キャッシュのための規定と推薦のセットを採用する。

HTTP/1.1 オリジンサーバについて

  • エンティティタグバリディタを生成する事が不可能で無いのであれば、それを送るべきである。
  • パフォーマンス考慮が弱いエンティティタグの使用を支持しているか、強いエンティティタグを送る事を不可能であるならば、強いエンティティタグの代わりに弱いエンティティタグを送る事ができる。
  • Last-Modified 値を送る事が可能で、If-Modified-Since ヘッダ中にこの日付の使う事から生じる、意味的な透過性における故障の危険が深刻な問題を引き起さなければ、Last-Modified 値を送るべきである。

言い換えれば、HTTP/1.1 オリジンサーバにとってより望まれる動作とは強いエンティティタグと Last-Modified 値の両方を送る事である。

サンプルでは比較のためそれぞれ個別にレスポンスヘッダをセットしましたが、オリジンサーバにおいては ETag ヘッダも Last-Modified ヘッダも可能な限りセットする方が良いということですね。

Internet Explorerへの対応

先日、Internet Explorer でファイルをダウンロードできないという問題が発生、色々と調査しましたのでメモも兼ねて記載しておきます。

結論としては、IEでのファイルダウンロードに対応する場合、レスポンスヘッダの生成に特別な対策が必要になります。

キャッシュ制限とダウンロード

Content-Disposition: attachemnt と Cache-Control: no-cache によるダウンロードの問題より

Internet Explorer を使用して下記条件を満たすファイルを開いた場合、ファイル名が見つからない内容のエラーが発生し、ファイルを開くことができない場合があります。 - ダウンロード対象となるファイルに Content-Disposition:attachment ヘッダーを付加している - Cache-Control:no-cache ヘッダーなどを使用して、ファイルのキャッシュを行わない設定をしている

この不具合、更新日は 2005年4月26日 となっており、対象製品は IE5.0, 6.0, 6.0 SP1 と記載されていますが、IE8においても同様に発生することを確認しています。

Web サーバーで Content-Disposition に inline を指定する等、Content-Disposition:attachment ヘッダーを使用しない、またはキャッシュを制限しないことにより現象を回避することが可能です。

回避策として上記のような理解不能なものが挙げられており、問題の根深さを伺わせてくれます。

Pragma (HTTP/1.0) や Cache-Control (HTTP/1.1) といったヘッダによるキャッシュ制限と、後述するIE特有の余計なお世話の合わせ技で起きている現象でしょうか。

MIME-Sniffingとファイル保存ダイアログの制御

また、Content-Type ヘッダの指定を無視してファイルの拡張子や内容で判断し、関連付けられたアプリケーションを起動するという余計なお世話(MIME-Sniffing と呼ばれているようです)にも、気をつける必要があります。

参考 教科書に載らないWebアプリケーションセキュリティ(2):[無視できない]IEのContent-Type無視 (1/2) - @IT

IE8から、これらの問題への対策として X-Download-Options, X-Content-Type-Options という2つの拡張ヘッダが提供されています。

X-Download-Options ヘッダは "noopen" を指定することでダウンロードダイアログから開かせない、"nosave" を指定することでダウンロードダイアログから保存させない、といった動作の制限を設定できるというものです。

IE8 Security Part V: Comprehensive Protection - IEBlog - Site Home - MSDN Blogsより

When the new X-Download-Options header is present with the value noopen, the user is prevented from opening a file download directly; instead, they must first save the file locally.

X-Content-Type-Options: nosniff はMIME-Sniffing による意図しないブラウザの振舞いを防止するためのもので、特にユーザーによる任意のファイルアップロードを許可するWebアプリケーションの場合は必須となるでしょう。

MIME-Handling Change: X-Content-Type-Options: nosniff (Windows).aspx)より

サーバーが応答ヘッダー X-Content-Type-Options: nosniff を送信する場合、SCRIPT 要素と STYLESHEET 要素は、誤った MIME タイプを拒否します。これは、MIME タイプの問題を悪用した攻撃を防ぐためのセキュリティ機能です。

参考 1分でわかる「X-ナントカ」HTTPレスポンスヘッダ - 葉っぱ日記

多くのアプリケーションフレームワークでは、当然ながら、このような特定ブラウザのためのバッドノウハウについては本体のコードには含んでいないと思います。

レスポンスヘッダの生成がブラックボックス化されているようなフレームワークでも、こういった対策の組み込み方については、きちんと把握しておく必要がありますね。

キャッシュの効果を実験

最後に、これまでの内容を確認するために、Silex と HttpFoundation でHTMLからキャッシュを有効にした画像を読み込ませてみました。 65.7KBの同じ画像を3枚表示して、1つはキャッシュなし、1つは Last-Modified と If-Modified-Since によるキャッシュを有効に、1つは ETag と If-None-Match によるキャッシュを有効にしています。

これを Opera Dragonfly を使って、クライアントキャッシュのない初回リクエストと、2回目のリクエストのレスポンス速度を比較してみます。

初回リクエスト

favicon.icoも含めて全てのリクエストへのレスポンスにステータス 200 が返ってきています。

一覧右端の「グラフ」の黄緑色の部分が、レスポンスヘッダの読み込み+レスポンス本文の読み込み+レスポンス処理の時間です。

favicon.icoは画像サイズが小さいためあまり影響ありませんが、3枚の画像はそれなりの大きさのため結構な割合を占めています。

2回目のリクエスト

favicon.icoも含めて、キャッシュされた画像にはステータス 304 が返ってきています。

「グラフ」の黄緑色の部分は、favicon.icoは画像サイズが小さいためほとんど変わっていませんが、キャッシュされた2枚の画像についてはレスポンス時間がそれなりに短くなっていることが分かります。

上記の実験に利用したサンプルコードはこちらです。

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/

と、ここまで書いてから、 HttpFoundation には BinaryFileResponse というバイナリファイルをレスポンスで返すためのクラスがあることに気付いてしまいました。

Last-Modified と ETag のほか、Range にも対応しているようです。

他に、拡張子やMIMEタイプをいい具合に扱ってくれる File クラスもあるようで…仕様を調べたことは勉強になりましたが…今回書いたサンプルは何の役にも立たないですね…。orz

参考記事

[Studying HTTP] HTTP Caching

言わずと知れたHTTP学習サイト Studying HTTP の「キャッシュ期限モデル」と「キャッシュ検証モデル」についてのコンテンツです。

オリジンサーバ、キャッシュサーバ、クライアントの3者において、キャッシュの正当性がどのような手続きを経て検証されるべきか、といった仕様が解説されています。

ハイパーテキスト転送プロトコル -- HTTP/1.1

[Studying HTTP] で翻訳された RFC 2616 の日本語訳です。

Qt での HTTP におけるキャッシュについて | Qt Japanese Blog

QtというフレームワークにおけるHTTPキャッシュについての記事ですが、HTTPキャッシュの仕様そのものが分かりやすく解説されていました。

max-age, s-maxage, must-revalidateディレクティブがどのように扱われるかが参考になりました。