このブログでは、アプリケーションのフォルダに保存した画像やスクリプトファイルなどを応答として返すのに HTTP ハンドラを使っています。
F5 キーを押すなどしてページをリロードすると、下の画像のように HTTP 304 Not Modified 応答を返しますが、これを HTTP ハンドラで実現するにはどのようにするかという話をメインに、HTTP ハンドラを利用したキャッシュコントロールについて書きます。
HTTP ハンドラを使わないで、以下のように直接 img 要素の src 属性に画像ファイルの URL を設定した場合は、IIS が ETag, Last-Modified に設定する値を当該ファイルから取得し、それらを応答ヘッダに含めて返してくれます。
<img alt="" src="/images/sample2.jpg" />
クライアントが F5 キーなどでページをリロードすると、再度ブラウザが src 属性に設定された URL の画像を要求しますが、そのとき If-Modified-Since と If-None-Match を要求ヘッダに含めて送ってくれます。
要求を受けた IIS は要求ヘッダに含まれる If-Modified-Since と If-None-Match を見て、条件を満たせば(即ち、画像が変更されてなければ) HTTP 304 Not Modified 応答を返します(ヘッダのみでコンテンツは送信しない。ブラウザのキャッシュを使えという指示と同じ)。
ちなみに、ブラウザのキャッシュを削除すると、画像を要求する際 If-Modified-Since と If-None-Match は要求ヘッダには含まれなくなます。なので、キャッシュを削除してから F5 キーを押せば、IIS は画像のコンテンツを含めて HTTP 200 応答を返します。
src 属性に HTTP ハンドラを指定する(HTTP ハンドラ経由でファイルをダウンロードする)場合は、IIS が自動的に行ってくれる上記の処置を、自力で HTTP ハンドラにコーディングすることになります。
簡単に書くと、(1) ブラウザ送られてくる要求ヘッダの中の If-None-Match, If-Modified-Since の値を取得し、その値に応じて HTTP 200 応答(コンテンツを含む)または HTTP 304 応答(コンテンツ無し)のどちらかを返す。(2) 要求を受けたら毎回、応答ヘッダの ETag, Last-Modified に設定する値を当該ファイルから取得し、それらを応答ヘッダに含めてブラウザへ返す・・・という処置をコーディングすることになります。
さらに、せっかく HTTP ハンドラを使って細かくキャッシュコントロールのためのコードが書けるのですから、上記 (1), (2) だけでなく(ETag, Last Modified の応答ヘッダへの設定だけでなく)、Cache-Control, Expires 等の応答ヘッダへの設定も行って、プロキシやブラウザのキャッシュをきちんとコントロールした方がよさそうです。
また、サーバー側でのキャッシュもコントロールすることが可能になりますので、サーバーのキャッシュが利用できればそれを使わない手はないですよね。
ということで、例として、画像ファイル用とスクリプトファイル用それぞれの HTTP ハンドラを、Web サイトプロジェクトの ジェネリックハンドラ(.ashx ファイル)の形で書いたコードを以下に紹介します。ベースは BlogEngine 1.6.1.0 のものです。
(1) 画像用の HTTP ハンドラ
<%@ WebHandler Language="C#" Class="_0114_ImageHandler" %>
using System;
using System.Web;
using System.IO;
using System.Net;
public class _0114_ImageHandler : IHttpHandler {
public void ProcessRequest (HttpContext context)
{
HttpResponse response = context.Response;
HttpRequest request = context.Request;
// ファイル名はクエリ文字列で指定。
string fileName = request.QueryString["picture"];
// 画像ファイルはアプリケーションルート直下の
// images という名前のフォルダにあることが前提。
string folder = "~/images/";
if (!String.IsNullOrEmpty(fileName))
{
string path =
context.Server.MapPath(folder + fileName);
FileInfo fileInfo = new FileInfo(path);
if (fileInfo.Exists)
{
// BlogEngine では何故か作成日時 (CreationTimeUtc) が
// 使われていたが、IIS が設定するのと同様に、更新日時
// (LastWriteTimeUtc) を使用するように変更。
DateTime lastWriteTime = fileInfo.LastWriteTimeUtc;
// ETag は更新日時のタイマ刻み数。(IIS とは異なる)
string etag = "\"" + lastWriteTime.Ticks + "\"";
string ifNoneMatch = request.Headers["If-None-Match"];
DateTime ifModifiedSince = DateTime.MinValue;
DateTime.TryParse(
request.Headers["If-Modified-Since"],
out ifModifiedSince);
response.Clear();
// ブラウザとプロキシにキャッシュを許可
response.Cache.SetCacheability(HttpCacheability.Public);
// 有効期限を、要求を受けた日時から 1 年に設定。
response.Cache.SetExpires(DateTime.Now.AddYears(1));
// Last-Modified を応答ヘッダに設定。
response.Cache.SetLastModified(lastWriteTime);
// ETag を応答ヘッダに設定。
response.Cache.SetETag(etag);
// 要求ヘッダの If-None-Match, If-Modified-Since と、
// 上のコードで取得した etag, lastWriteTime を比較し、
// どちらかが一致したら HTTP 304 応答を返して終了。
if (ifNoneMatch == etag ||
ifModifiedSince == lastWriteTime)
{
response.StatusCode =
(int)HttpStatusCode.NotModified;
return;
}
int index = fileName.LastIndexOf(".") + 1;
string extension =
fileName.Substring(index).ToLowerInvariant();
// IE は image/jpg を認識しないことへの対応
if (extension == "jpg")
{
context.Response.ContentType = "image/jpeg";
}
else
{
context.Response.ContentType = "image/" + extension;
}
response.TransmitFile(fileInfo.FullName);
}
}
}
public bool IsReusable
{
get
{
return false;
}
}
}
ETag, Last Modified, Expires の違いについては以下の stackoverflow の記事が参考になると思います。
(2) スクリプト用の HTTP ハンドラ
<%@ WebHandler Language="C#" Class="_0114_ScriptHandler" %>
using System;
using System.Web;
using System.IO;
using System.Security;
using System.Web.Caching;
using System.Net;
public class _0114_ScriptHandler : IHttpHandler
{
public void ProcessRequest (HttpContext context)
{
HttpResponse response = context.Response;
HttpRequest request = context.Request;
// ファイル 名はクエリ文字列で指定。
string fileName = request.QueryString["filename"];
// スクリプトファイルはアプリケーションルート直下の
// scripts と言う名前のフォルダにあることが前提。
string folder = "~/scripts/";
if (!String.IsNullOrEmpty(fileName))
{
string script = null;
// まずサーバーのCacheをチェック。なければファイルに
// アクセスしてスクリプトを取得。
if (context.Cache[fileName] == null)
{
if (!fileName.EndsWith(".js",
StringComparison.OrdinalIgnoreCase))
{
throw new SecurityException("アクセス不可");
}
string path =
context.Server.MapPath(folder + fileName);
FileInfo fileInfo = new FileInfo(path);
if (fileInfo.Exists)
{
using (StreamReader reader =
new StreamReader(path))
{
script = reader.ReadToEnd();
// Minify は割愛。
// サーバーのキャッシュに保持。
context.Cache.Insert(
fileName,
script,
new CacheDependency(path));
}
}
}
else
{
script = (string)context.Cache[fileName];
}
if (!string.IsNullOrEmpty(script))
{
response.Clear();
response.ContentType = "text/javascript";
// Vary: Accept-Encoding をヘッダに設定。
response.Cache.VaryByHeaders["Accept-Encoding"] = true;
// 有効期限 (Expires ヘッダ) を 7 日に設定。
response.Cache.SetExpires(
DateTime.Now.ToUniversalTime().AddDays(7));
// Cache-Control ヘッダの max-age を 7 日(604800 秒)
// に設定。
response.Cache.SetMaxAge(new TimeSpan(7, 0, 0, 0));
// Cache-Control ヘッダに must-revalidate を設定。
response.Cache.SetRevalidation(
HttpCacheRevalidation.AllCaches);
int hash = script.GetHashCode();
string etag = "\"" + hash.ToString() + "\"";
string ifNoneMatch = request.Headers["If-None-Match"];
// ETag を応答ヘッダに設定。
response.Cache.SetETag(etag);
// Cache-Control ヘッダに public を設定(ブラウザと
// プロキシにキャッシュを許可)
response.Cache.SetCacheability(HttpCacheability.Public);
// 要求ヘッダの If-None-Match と上のコードで取得した
// etag を比較し一致したら HTTP 304 応答を返して終了。
// サーバーの Cache を利用する関係で Last Modified
// は使用しない。
if (ifNoneMatch == etag)
{
response.StatusCode =
(int)HttpStatusCode.NotModified;
return;
}
response.Write(script);
}
}
}
public bool IsReusable
{
get
{
return false;
}
}
}
ファイルから取得したスクリプトの文字列をサーバーのキャッシュに保持するようにし、2 回目以降の要求ではキャッシュから取得するようにしています。
キャッシュから取得する場合はスクリプトファイルの更新日時を取得できないので、Last Modified は使わず ETag のみを応答ヘッダに含めています。
ETag の値は、スクリプトの文字列から String.GetHashCode メソッドでハッシュコードを取得し、それを設定しています。