PHPで返す304 Not Modified(1)

 普段なら、Apacheがファイル更新日を確認して適切にクライアントに返してくれるはずの304 Not Modified。これを「PHPを介してファイル内容を表示しよう」と思った瞬間*1、単純に実装するとリクエストの度にファイルコンテンツを全て送る事になって、特に画像やら動画では転送量が大変なことになりがちです(ならないよ、って方にはこの記事は必要ないはずですので、次の次の記事にでも進んで下さい)。
 これは、PHPは自動的にはLast-ModifiedやIf-Modified-Since*2の情報を利用しないため、自前でこれらを処理してやらねばならないためです。

 そこで試しに、Last-Modified/If-Modified-Sinceへの対応や、各種HTTPレスポンスヘッダーの追加処理と共にreadfile()を行なう、respondcontents()関数を作ってみました。
 なおrespondcontents()は、特定のURLが特定のファイルに結びつけられていることを想定しています。あるURLが複数のファイルのうちいずれかを返す、という場合は次回。

※ If-Modified-Sinceの処理については、次の記事を参考にしています → モジュール版PHPで「If-Modified-Since」に対応する
※ Content-Dispositionの処理については、次の記事を参考にしています → http://anond.hatelabo.jp/20130219210226

<?php
/**
 * ファイル内容をHTTPレスポンスヘッダーと標準出力に書き出します。
 * クライアントから渡されたIf-Modified-Sinceリクエストヘッダーを確認し、ファイルがそれ以降変更されていなければHTTPステータス304を、
 * 変更されていればLast-Modifiedレスポンスヘッダー、Content-Lengthレスポンスヘッダー、Content-Typeレスポンスヘッダーを出力します。
 * ファイルが存在しなければHTTPステータス404を出力します。
 *
 * @param string $filename 読み込もうとするファイルの名前。
 * @param string $mime ファイルのMIMEタイプ。自動で確認するならNULL、Content-Typeレスポンスヘッダーが不要ならFALSE。
 * @param string $download_as ファイルを「名前をつけて保存」する場合、ファイル名。ブラウザでの閲覧用の場合FALSE。
 * @param boolean $use_include_path readfile()の$use_include_path引数と同じ。 
 * @param resource $context コンテキストストリームリソース。
 * @return int HTTPステータスを返します。新しいコンテンツを返す場合は200、ファイルに変更がなければ304、ファイルが存在しなければ404を返します。
 */
function respondcontents($filename, $mime = null, $download_as = false, $use_include_path = false, $context = null)
{
    if (!is_file($filename)) {
        // ファイルが見つからない
        header('HTTP/1.0 404 Not Found') ;
        return 404 ;
    }

    $stat = @stat($filename) ;
    if (false !== $stat) {
        $ims = get_if_modified_since() ;
        if (false !== $ims && $stat[9] <= $ims) {
            // 更新されていない
            header('HTTP/1.1 304 Not Modified') ;
            return 304 ;
        }

        // Last-Modified、Content-Lengthを設定
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $stat[9]) . ' GMT') ;
        header('Content-Length: ' . $stat[7]) ;
    }

    if (false !== $mime) {
        if (null === $mime) {
            $finfo = @finfo_open(FILEINFO_MIME) ;
            $mime = @finfo_file($finfo, $filename) ;
            @finfo_close($finfo) ;
            unset($finfo) ;
        }
        header('Content-Type: ' . $mime) ;
    }

    if (false !== $download_as) {
        set_content_disposition($download_as) ;
    }

    $result = @readfile($filename) ;
    if (false === $result) {
        // ファイル内容を取得できない
        header('HTTP/1.0 404 Not Found') ;
        return 404 ;
    }

    return 200 ;
}

/**
 * If-Modified-Sinceリクエストヘッダーの値を、UNIX時刻で取得します。
 *
 * @return int UNIX時刻。If-Modified-SinceリクエストヘッダーがなければFALSE。
 */
function get_if_modified_since()
{
    static $MONTHS = array(
        'Jan' => '01', 'Feb' => '02', 'Mar' => '03',
        'Apr' => '04', 'May' => '05', 'Jun' => '06',
        'Jul' => '07', 'Aug' => '08', 'Sep' => '09',
        'Oct' => '10', 'Nov' => '11', 'Dec' => '12',
    );

    // リクエストヘッダーの値は実行中不変なので、get_if_modified_since()の値は一度だけ評価する
    static $unixtime = null ;
    if (null !== $unixtime) return $unixtime ;

    $rh = apache_request_headers() ;
    if (!isset($rh['If-Modified-Since'])) {
        $unixtime = false ;
        return false ; // If-Modified-Sinceがない
    }

    $rh = $rh['If-Modified-Since'] ;
    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$/', $rh, $match)) {
        $hour = $match[5] ;
        $minute = $match[6] ;
        $second = $match[7] ;
        $month = $MONTHS[$match[3]] ;
        $day = $match[2] ;
        $year = $match[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$/', $rh, $match)) {
        $hour = $match[5] ;
        $minute = $match[6] ;
        $second = $match[7] ;
        $month = $MONTHS[$match[3]] ;
        $day = $match[2] ;
        $year = 1900 + $match[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})$/', $rh, $match)) {
        $hour = $match[4] ;
        $minute = $match[5] ;
        $second = $match[6] ;
        $month = $MONTHS[$match[2]] ;
        $day = str_replace(' ', 0, $match[3]) ;
        $year = $match[7] ;
    }
    else {
        $unixtime = false ;
        return false ;
    }

    // unixtimeに変換
    $unixtime = gmmktime($hour, $minute, $second, $month, $day, $year) ;
    return $unixtime ;
}

/**
 * Content-Dispositionヘッダーを設定します。
 *
 * @param string $download_as ファイルを「名前をつけて保存」する場合の、デフォルトファイル名。
 * @return void
 */
function set_content_disposition($download_as)
{
    $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '' ;
    if (false !== strpos($ua, 'MSIE')) {
        // IEではそのままUTF-8エンコード+#を%23にエンコード
        $download_as = str_replace('#', '%23', urlencode(utf8_encode($download_as))) ;
        header('Content-Disposition: attachment; filename="' . $download_as . '"');
    }
    elseif (false !== strpos($ua, 'Safari') && false === strpos($ua, 'Chrome') && false === strpos($ua, 'Android')) {
        // Safari(Google Chrome・Android標準ブラウザを除く)では生UTF-8
        $download_as = utf8_encode($download_as) ;
        header('Content-Disposition: attachment; filename="' . $download_as . '"');
    }
    else {
        // その他ではRFC 2231に対応
        $download_as = "UTF-8''" + str_replace('#', '%23', urlencode(utf8_encode($download_as))) ;
        header('Content-Disposition: attachment; filename*=' . $download_as);
    }
}
?>

 本当はここにRangeやIf-Rangeやらへの対応が加わるとよいのでしょうが、本記事では割愛します。必要に応じて、それらの処理も追加してみてもよいかもしれません。
 なお、PHPにHTTP拡張モジュールが導入されている場合、もう少し簡単になるはずです……が、こちらも割愛。

プログラミングPHP 第2版

プログラミングPHP 第2版

*1:ユーザーログインやら閲覧パスワードやらが必要なことにすると、大体こうなる

*2:サーバーがレスポンスヘッダーに追加してコンテンツの最終更新日を指示するのがLast-Modified、クライアントがリクエストヘッダーに追加してその時点からのコンテンツの変更をサーバーに確認させるのがIf-Modified-Sinceです