モジュール版PHPで「If-Modified-Since」に対応する

ページ情報
制作日
2004-01-25
最終更新日
2004-08-24
参照用URI
http://www.arielworks.net/articles/2004/0125a
分野

CGI版PHPではApacheがうまく計算してHTTPリクエストヘッダの「If-Modified-Since」に対応してくれるらしいが、モジュール版PHPでは自力で実装しない限り常に「200 OK」が返される。はてなアンテナなどのアンテナ類のなかには「If-Modified-Since」を元に更新の判定を行っているものがあるが、PHPで生成しているページでは上記の理由で結局全体をGETすることになってしまう。そこで今回はこれに対応するための関数を作ってみる。

Last-Modifiedヘッダを送信するだけならば、以下の手順は必要ない。『PHPで「Last-Modified」を送信する』を参照のこと。

日付の解析

まず、リクエストヘッダの「If-Modified-Since」はHTTP/1.1では以下の書式で定められている。

If-Modified-Since = "If-Modified-Since" ":" HTTP-date

ここで「HTTP-date」とは

HTTP-date = rfc1123-date | rfc850-date | asctime-date

を意味する。訳が分からないかもしれないが、書式が3つあると考えてもらえばよい。例えば以下のような感じだ。

  1. If-Modified-Since: Sun, 06 Nov 1994 08:49:37 GMT
  2. If-Modified-Since: Sunday, 06-Nov-94 08:49:37 GMT
  3. If-Modified-Since: Sun Nov  6 08:49:37 1994

リストは上から「RFC 822, updated by RFC 1123」、「RFC 850, obsoleted by RFC 1036」、「ANSI C's asctime() format」になる。

1番のRFC1123以外は後方互換のための書式なのだが、HTTP/1.1 clients and servers that parse the date value MUST accept all three formatsと仕様書に書いてあるので対応しなければならない。

まずはこの3つの書式を解析する関数を作る。

注:以下のコードは2004-08-24T06:21:07Zに修正済み。修正前$define_monthのキーがint型だったが、0から始まる数値は8進法になってしまうため、string型に変更された。

//-------------------------------------------------------------------------
// array parse_http_date( string Date )
// DateはRFC1123、RFC 850、ANSI C's asctime() formatのいずれか。
//-------------------------------------------------------------------------
function parse_http_date( $string_date ) {

    // 月の名前と数字を定義
    $define_month = array(
        "01" => "Jan", "02" => "Feb", "03" => "Mar",
        "04" => "Apr", "05" => "May", "06" => "Jun",
        "07" => "Jul", "08" => "Aug", "09" => "Sep",
        "10" => "Oct", "11" => "Nov", "12" => "Dec"
    );

    if( preg_match( "/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-3][0-9]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-9]{4}) ([0-2][0-9]):([0-5][0-9]):([0-5][0-9]) GMT$/", $string_date, $temp_date ) ) {

        $date["hour"] = $temp_date[5];
        $date["minute"] = $temp_date[6];
        $date["second"] = $temp_date[7];
        // 定義済みの月の名前を数字に変換する
        $date["month"] = array_search( $temp_date[3], $define_month );
        $date["day"] = $temp_date[2];
        $date["year"] = $temp_date[4];


    } elseif( preg_match( "/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), ([0-3][0-9])-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-([0-9]{2}) ([0-2][0-9]):([0-5][0-9]):([0-5][0-9]) GMT$/", $string_date, $temp_date ) ) {

        $date["hour"] = $temp_date[5];
        $date["minute"] = $temp_date[6];
        $date["second"] = $temp_date[7];
        // 定義済みの月の名前を数字に変換する
        $date["month"] = array_search( $temp_date[3], $define_month );
        // 年が2桁しかないので1900を足して4桁に
        $date["day"] = $temp_date[2];
        $date["year"] = 1900 + $temp_date[4];


    } elseif( preg_match( "/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-3 ][0-9]) ([0-2][0-9]):([0-5][0-9]):([0-5][0-9]) ([0-9]{4})$/", $string_date, $temp_date ) ) {
        $date["hour"] = $temp_date[4];
        $date["minute"] = $temp_date[5];
        $date["second"] = $temp_date[6];
        $date["month"] = array_search( $temp_date[2], $define_month );
        // 日が1桁の場合先、半角スペースを0に置換
        $date["day"] = str_replace( " ", 0, $temp_date[3] );
        // 定義済みの月の名前を数字に変換する
        $date["year"] = $temp_date[7];


    } else {
        return FALSE;
    }

    // UNIXタイムスタンプを生成GMTなのに注意
    $date["timestamp"] = gmmktime( $date["hour"], $date["minute"], $date["second"], $date["month"], $date["day"], $date["year"] );

    return $date;

}

正規表現でそれぞれの書式に一致するかを調べて最終的にUNIXタイムスタンプも制作する。それぞれ書式によって0で桁取りされたりされていなかったりするのを適当に補正する必要がある。また、どの書式も月が名前で与えられるのであらかじめリストを作っておき一致するものを調べている。

注意するべきなのは「HTTP-date」は必ずタイムゾーンがGMTなのでmktime()ではなく、gmmktime()を使う点である。また、正規表現の一致条件を少し厳しくしてある。過去の経験から中途半端に緩くすると後で痛い目を見ることが分かっているので「i」オプションなども付けていない。

parse_http_date( "Sun, 06 Nov 1994 08:49:37 GMT" )で以下のような結果が得られる。

Array
(
    [hour] => 08
    [minute] => 49
    [second] => 37
    [month] => 11
    [day] => 06
    [year] => 1994
    [timestamp] => 784111777
)

ヘッダの取得と応答

PHPがApacheのモジュールとして動いているならapache_request_headers()でブラウザが送信したリクエストヘッダが取得できる。PHP4.3より前のバージョンではgetallheaders()という名前なので注意。

「If-Modified-Since」は必ず送られるわけではないのでもしある場合にのみ、構成ファイルの内最も新しい物のタイムスタンプ「$time_newest」と比較する。

$request_headers = apache_request_headers();
$etag = md5( $_SERVER["REQUEST_URI"] . $time_newest );

if( $request_headers["If-Modified-Since"] ) {
    $since = parse_http_date( $request_headers["If-Modified-Since"] );
    if( $since["timestamp"] >= $time_newest ) {
        header( "HTTP/1.1 304 Not Modified" );
        header( "Etag: \"$etag\"" );
        exit();
    }
}
header( "Etag: \"$etag\"" );

通常両者が一致しなければ更新されたと判断しても良いのだが(キャッシュを制作した時の「Last-Modified」と一致しなければ更新されているはずなので)、一応「$time_newest」が小さい場合も未更新としている。これはキャッシュ制作時の「Last-Modified」ではなくてその時の日時を送信してくるUAがあるかもしれないからだ。「前回訪問したのはこの時間だが、それ以降に更新はあるか」というリクエストも考えられる。

更新がないならば「304 Not Modified」とEtagを送信して処理を終了する。ここで304を送信する場合は本文を送信してはいけないのでexit()で明示的に処理を終了させる必要がある。

Etagを送信しているのはETag and/or Content-Location, if the header would have been sent in a 200 response to the same requestと書いてある為だ。Etagの生成方法はHTTP/1.1では定義されていないので適当にmd5を使ってリクエストされたURIと更新日を元に作っている。参考までに標準設定のApacheではファイルのinode番号、最終修正時刻、中身のバイト数をつかって生成している。なお、更新されていて「200 OK」を返すときもEtagを送信するようにしないとクライアントが比較が出来ないので注意。

なお、前回のリクエスト時に「Last-Modified」を受信した場合にのみ、「If-Modified-Since」を送信するUAもあるので、「304 Not Modified」を送信しない場合、つまり「200 OK」と共に本文を送信する際には『PHPで「Last-Modified」を送信する』を参考に、「Last-Modified」をあらかじめ送信しておくこと。

以上で「If-Modified-Since」に対応することが出来た。なお、一部のUAには上記の3種類以外の書式で日付を送信してくる物も有るが、これは無視して良いと思う。

関連情報

連絡先、リンク、転載や複製などについては「サイト案内」をご覧ください。Powered by HIMMEL