avoidnote

PHP で If-Modified-Since に対応してみる

Published at 2005-07-05 (Tue) 19:03 in PHP

If-Modified-Since とは HTTP リクエストヘッダのフィールドの1つで更新の有無を調べる場合に使われる。例えば以下のようなフィールドを含むリクエストがクライアント側からサーバーに送信された場合、この日付以降に更新があった場合はその内容を、ない場合は 304 Not Modified というステータスを返し、クライアント側はキャッシュしていたデータを表示する。ムダなトラフィックを減らすには有効な仕組みです。

If-Modified-Since: Mon Jul  4 01:37:32 2005

たいていのブラウザは標準で送信しています。ブラウザ以外にも検索エンジンとか各種クローラーもこれを使ってリクエストを送信するようなのでこのリクエストに対応しておかないとキャッシュ済みのリソースを何度も取得することになるので迷惑でもあります。静的ファイルにであれば Apache が処理してくれるので問題ないのだが、PHPファイルの場合自前で実装する必要があります。

これがどうもうまく機能していない場合があるようなので調べてみると、日付の書式は 3 種類あって、サーバ側から送信した Last-Modified フィールドの書式、値でリクエストしてくるとは限らないようです。どうやら、この日付の書式を決めうちして判定、処理をしていたことが誤動作の原因のようだ。

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

[ RFC 2616 14.25 If-Modified-Since ]

Sun, 06 Nov 1994 08:49:37 GMT  ; RFC 822, updated by RFC 1123
Sunday, 06-Nov-94 08:49:37 GMT ; RFC 850, obsoleted by RFC 1036
Sun Nov  6 08:49:37 1994       ; ANSI C's asctime() format

中略

HTTP-date    = rfc1123-date | rfc850-date | asctime-date
rfc1123-date = wkday "," SP date1 SP time SP "GMT"
rfc850-date  = weekday "," SP date2 SP time SP "GMT"
asctime-date = wkday SP date3 SP time SP 4DIGIT
date1        = 2DIGIT SP month SP 4DIGIT
              ; day month year (e.g., 02 Jun 1982)
date2        = 2DIGIT "-" month "-" 2DIGIT
              ; day-month-year (e.g., 02-Jun-82)
date3        = month SP ( 2DIGIT | ( SP 1DIGIT ))
              ; month day (e.g., Jun  2)
time         = 2DIGIT ":" 2DIGIT ":" 2DIGIT
              ; 00:00:00 - 23:59:59
wkday        = "Mon" | "Tue" | "Wed"
            | "Thu" | "Fri" | "Sat" | "Sun"
weekday      = "Monday" | "Tuesday" | "Wednesday"
            | "Thursday" | "Friday" | "Saturday" | "Sunday"
month        = "Jan" | "Feb" | "Mar" | "Apr"
            | "May" | "Jun" | "Jul" | "Aug"
            | "Sep" | "Oct" | "Nov" | "Dec"

[ RFC 2616 3.3.1 Full Date ]

そこで、これら3つの日付書式とファイルの最終更新時刻を UNIX タイムスタンプ(数値)に変換してから比較して、最終更新時刻の方が古いか、同じならば更新されていないことになるので、304 Not Modified を返して終了するというロジックを考えてみました。Perl にはこれらの3種類の日付書式をまとめて面倒見てくれる HTTP::Datestr2time()というのがあります。PHP で同様の関数を定義してみました。

function str2time($str) {
    /* 
    * Convert a HTTP-date string to Unix time
    * 念のためセミコロン以降を削除してから処理する
    * http://bakera.jp/hatomaru.aspx/ebi/topic/586 を参照
    */
    $str = preg_replace( '/;.*/', '', $str);
    if (!preg_match("/GMT/", $str)) $str .= ' GMT';
    return strtotime($str);
}

さらに、「If-Modified-Since の書式」[ 水無月ばけらのえび日記 ] によるとセミコロンを含む不正な値をフィールドにつけてくるクライアントもあるとの情報があります。RFC 違反の不正な値(書式)なので無視しても構わないのですが、一応対処してみました。

「条件付きGET」のススメ[ Ogawa::Memoranda ]で紹介されている Simon Willison さんのコードに組み込んでみました。

<?php

$ts = getlastmod();
doConditionalGet($ts);

function str2time($str) {
    /* 
    * Convert a HTTP-date string to Unix time
    * 念のためセミコロン以降を削除してから処理する
    * http://bakera.jp/hatomaru.aspx/ebi/topic/586 を参照
    */
    $str = preg_replace( '/;.*/', '', $str);
    if (!preg_match("/GMT/", $str)) $str .= ' GMT';
    return strtotime($str);
}

function doConditionalGet($timestamp) {

    /*
    * A PHP implementation of conditional get, see 
    * http://fishbowl.pastiche.org/archives/001132.html
    * http://as-is.net/blog/archives/000956.html
    */

    // Convert to GMT format
    $last_modified = gmdate('D, d M Y H:i:s T', $timestamp);

    // Create ETag
    $etag = '"'.md5($last_modified).'"';

    // Send the headers
    header("Last-Modified: $last_modified");
    header("ETag: $etag");

    // See if the client has provided the required headers
    $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ?
        // UNIX タイムスタンプに変換
        str2time( stripslashes( $_SERVER['HTTP_IF_MODIFIED_SINCE'])) : false;

    $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ?
        stripslashes($_SERVER['HTTP_IF_NONE_MATCH']) : false;

    if (!$if_modified_since && !$if_none_match)
        return;

    // At least one of the headers is there - check them
    if ($if_none_match && $if_none_match != $etag)
        return; // etag is there but doesn't match

    if ($if_modified_since && $if_modified_since < $timestamp)
        return; // Unix タイムスタンプ(int)で比較する

    // Nothing has changed since their last request - serve a 304 and exit
    header('HTTP/1.1 304 Not Modified');
    exit;
}

?>

更新がされたかどうかの判定基準を「文字列の一致、不一致」ではなく、最終更新日時のタイムスタンプ(数値の大小)との比較にしていますので、本来の Apache の仕様に近いと思います。試しに現在時刻を If-Modified-Since フィールドの値としてリクエストを送信してみると、ちゃんと 304 Not Modified が返ってきました。

このスクリプは、単に HTTP ヘッダを出力するだけですが、ヘッダ以降のテンプレートのなかで最終更新日を変数として使い回せるように関数の返り値が Last-Modified になるように修正したものが LightPress で紹介されていますので興味のある方はどうぞ。

Copyright © 2005 atz. All rights reserved.
Creative Commons License Movable Type Valid XHTML 1.0 Strict W3C CSS