ASP.NETにおける、生成コンテンツのクライアントサイドのキャッシングを有効にする方法

By: Tomohiro Takahashi

Abstract: この記事では、ASP.NETとDelphiにおいて動的に生成されるコンテンツのクライアントサイドキャッシングをサポートする方法を解説し、ASP.NETのHttpCachePolicyクラスに関してあまり知られていない問題点を取り上げます。

    はじめに

Webサイトは遅い。とにかく、ネイティブアプリケーションに比べて遅いのが通常です。これはWebアプリケーションの書き方が酷いとか、ブラウザに問題があるからではなく、単にブラウザとWebサーバーが1つのページを表示するだけのために数多くのタスクを処理しなければならないからです。例えば次のようなものです:

  1. ブラウザは、URLを解析し、サーバー名を抽出して、そのアドレスを解決し、そこに接続しなければならない。
  2. ブラウザはHTTPリクエストを送信し、レスポンスを待つ。
  3. サーバーはそのリクエストを解析し、それを処理しようとする。
  4. サーバーはHTTPレスポンスをブラウザに送信する。
  5. ブラウザはそのレスポンスヘッダを解析し、コンテンツを取り出す。
  6. 最後に、ブラウザはそのコンテンツを描画する。もしそのページの中にコンテンツ(画像やスタイルシートのような)が埋め込まれている場合には、これらの手順を要素ごとに繰り返す。

通常、3番目と4番目の手順がもっとも時間がかかります。サーバーは、コンテンツをロードまたは生成し、それからインターネットを介してブラウザに送信しなければなりません。静的なサイトでは、この送信がもっとも遅い箇所であるため、ブラウザはそのデータをローカルにキャッシュしようと試みます。これはWebアプリケーションにとっては少し難しいことなのです。

Webアプリケーションは通常、データベースに格納されている情報を元にコンテンツを生成することによって動作します。実は、このページで見ているほとんど全ての要素はデータベースから取得したものです。コンテンツは動的に生成されるので、Webサーバーはそのコンテンツが変更されているかどうかが分からず、その結果をキャッシュするのに十分な情報をブラウザに提供することができません。そこで、そのような情報を提供するのがアプリケーションの責任となります。

    クライアントサイドのキャッシングはどのように動作するか

キャッシングが動作するためには、ブラウザおよびWebサーバーが HTTP 1.1の仕様で定められた一連の規則に従います。この規則はかなり複雑ですが、基本的な処理は単純です。

  1. サーバーがキャッシュ可能なコンテンツを返す際、そのコンテンツの最終更新時刻や有効期限、誰がそれをキャッシュ可能なのか、といった追加情報を提供する。
  2. ブラウザがコンテンツの問合せを行う際、現在キャッシュにあるコンテンツのバージョンをサーバーに伝える。
  3. もし、そのコンテンツが変更されていなかった場合、サーバーはHTTPのステータスコードに 304 (“Not Modified”) を設定します。そうでない場合には、そのリクエストは新しいコンテンツに対すす通常のリクエストであると見なされ、サーバーはそれを処理しようとする。

キャッシング情報はHTTPリクエス/レスポンスのヘッダに載せられます。そして、ほとんどのWebサーバーは、レスポンスヘッダでキャッシング情報を提供し、静的なコンテンツに対するキャッシングリクエストヘッダを処理します。

    ASP.NETにおけるクライアントサイドのキャッシング

ASP.NETでは、ページは幾つかの方法を利用して、クライアントサイドのキャッシング("出力キャッシング"と呼ばれる)を制御することが可能です。

  • web.configファイルとmachine.configファイルで、OutputCacheSection要素とOutputCacheSettingsSection要素を使用する。
  • ASP.NETのページおよびユーザーコントロールに @OutputCacheディレクティブを設定する。
  • HttpCachePolicyクラスを使用して、明示的にキャッシングポリシーを設定する。

最後の方法は、特に、コード内でコンテンツを生成するASP.NETアプリケーションには便利です。

    HttpCachePolicy

HttpCachePolicyクラスは、"出力キャッシング"を制御するための幾つかのメソッドを含んでおり、次のようなものです:

type
  HttpCachePolicy = class sealed
  public
    procedure AddValidationCallback(handler: HttpCacheValidateHandler;
      data: TObject);
    procedure AppendCacheExtension(extension: string);
    procedure SetAllowResponseInBrowserHistory(allow: boolean);
    procedure SetCacheability(cacheability: HttpCacheability); overload;
    procedure SetCacheability(cacheability: HttpCacheability;
      field: string); overload;
    procedure SetETag(etag: string);
    procedure SetETagFromFileDependencies;
    procedure SetExpires(date: DateTime);
    procedure SetLastModified(date: DateTime);
    procedure SetLastModifiedFromFileDependencies;
    procedure SetMaxAge(delta: TimeSpan);
    procedure SetNoServerCaching;
    procedure SetNoStore;
    procedure SetNoTransforms;
    procedure SetOmitVaryStar(omit: boolean);
    procedure SetProxyMaxAge(delta: TimeSpan);
    procedure SetRevalidation(revalidation: HttpCacheRevalidation);
    procedure SetSlidingExpiration(slide: boolean);
    procedure SetValidUntilExpires(validUntilExpires: boolean);
    procedure SetVaryByCustom(custom: string);
    property VaryByContentEncodings: HttpCacheVaryByContentEncodings;
    property VaryByHeaders: HttpCacheVaryByHeaders;
    property VaryByParams: HttpCacheVaryByParams;
  end;

コードからは、カレントのHttpRequestのCacheプロパティを使用して、HttpCachePolicyクラスにアクセス可能です。

    コードによるクライアントサイドのキャッシングの処理

動的なコンテンツに対するクライアントサイドのキャッシングをサポートするには、ASP.NETのWebアプリケーションは次の2つを行う必要があります:

  1. キャッシュ可能なコンテンツを適切なレスポンスヘッダで修飾する必要がある。
  2. アプリケーションは、リクエストヘッダを処理して、キャッシュされたコンテンツが最後にキャッシュされてから変更されているか否かを判定しなければならない。

キャッシングヘッダはタイムスタンプを利用するので、アプリケーションはコンテンツが最後に更新されたタイムスタンプを保持しなければなりません。

    レスポンスヘッダの設定

    最後に更新された時刻(タイムスタンプ)

HttpCachePolicySetLastModifiedメソッドは、 HTTPの Last-Modifiedヘッダを追加し、指定されたタイムスタンプを正しく符号化します。ブラウザは、次にそのコンテンツを取得しようとする際に、そのタイムスタンプをサーバーに渡します。

  Response.Cache.SetLastModified(ModifyDate);

    エンティティタグ

しかし、クライアントの中には、ETagヘッダも指定しないと条件付きのリクエストを送信しないものがあります。ETagには、一意の「エンティティタグ」 が含まれます。クライアントは。最終更新日付に加えて、代わりにエンティティタグを使用することもできます。ですので、アプリケーションは両方とも提供すべきです。

エンティティタグは、コンテンツの正確なバージョンを識別できなければなりません。そのような一意な識別子を生成する方法の一つは、コンテンツの名前と最終更新日付とを使用するものです。以下のコードは、それらのパラメータを元にエンティティタグを生成します。

function GetFileETag(fileName: string; modifyDate: DateTime): string;
var
  FileString: string;
  StringEncoder: Encoder;
  StringBytes: array of Byte;
  MD5Enc: MD5CryptoServiceProvider;
begin
  { Use file name and modify date as the unique identifier }
  FileString := fileName + modifyDate.ToString;

  { Get string bytes }
  StringEncoder := Encoding.UTF8.GetEncoder;
  SetLength(StringBytes,
    StringEncoder.GetByteCount(
      FileString.ToCharArray, 0, Length(FileString), True));
  StringEncoder.GetBytes(FileString.ToCharArray, 0,
    Length(FileString), StringBytes, 0, True);

  { Hash string using MD5 and return the hex-encoded hash }
  MD5Enc := MD5CryptoServiceProvider.Create;
  Result :=
    '"' + BitConverter.ToString(
      MD5Enc.ComputeHash(StringBytes)).Replace('-', '') + '"';
end;

この関数で追加される2つのダブルクォートは、HTTP 1.1の仕様 がエンティティタグに要求するものです。

HttpCachePolicyクラスのSetETagメソッドは、レスポンスにETagヘッダを追加します。しかし、このメソッドの利用には幾つかの制限があります。

  1. SetETagメソッドは一度しか呼び出せません。再度呼び出すと例外が発生します。
  2. このメソッドは、HTTP 1.1で要求される2つのダブルクォートを追加しません。もし、タグを生成するのに別のアルゴリズムを使用する場合には、この2つのダブルクォートが確実に含まれるようにしてください。
  3. HttpCachePolicyは、「cacheability」がPrivate(これがデフォルト値です)に設定されていると、ETagヘッダを追加しません。違う値でSetCacheabilitメソッドを呼び出さない限り、HttpRequestAppendHeaderメソッドを呼び出してETagヘッダを追加しなければなりません。

私が言える限りでは、最後の制限はドキュメント化されておらず、おそらく不具合でしょう。

    有効期限

SetExpires SetMaxAgeおよびSetSlidingExpirationメソッドを使用してキャシュの有効期限の指定が可能です。ほとんどのブラウザは有効期限が切れるまで、キャッシュされたコンテンツに対するリクエストを送信しません。これは、コンテンツが更新されてしまったかどうかをサーバーがチェックする機会を得られないことを意味します。ですから、特定の期間は変更されないことが分かっているコンテンツに対してのみ有効期限を設定してください。

    キャッシュされたコンテンツに対するリクエストの処理

コンテンツがキャッシュされている場合、ブラウザは、HTTP 1.1の仕様で「条件付きGET」と呼ばれるリクエストを送信します。条件付きGETは、If-Modified-SinceIf-Unmodified-SinceIf-MatchIf-None-Match または If-Range ヘッダフィールドを含んだGETリクエストであり、それは Rangeヘッダフィールドではありません(この場合「部分GET」リクエストと見なされる)。

If-Modified-Sinceリクエストヘッダは、Last-Modifiedレスポンスヘッダに対応し、同じ値を含みます。同様に、If-None-Matchリクエストヘッダは、ETagレスポンスヘッダで渡された値を含みます。

HTTP 1.1の仕様によれば、サーバーがエンティティタグを提供する場合には、ブラウザはキャッシュの条件付きのリクエストに If-Match または If-None-Matchヘッダを渡さなければなりませんが、すべてのブラウザがそうするわけではありません。また仕様では、エンティティタグと最終更新日付の両方が含まれるリクエストを受け取ったサーバーは両方とも処理しなければならず、すべての条件が合致した場合には単にステータスコード304を返してもよい、とも規定しています。

次のコードは、コンテンツが最後にキャッシュされてから更新されたかどうかを判定するものです:

function IsFileModified(fileName: string; modifyDate: DateTime;
  eTag: string; request: HttpRequest): Boolean;
var
  FileDateModified: Boolean;
  ModifiedSince: DateTime;
  ModifyDiff: TimeSpan;
  ETagChanged: Boolean;
begin
  { Assume file has been modified unless we can determine otherwise }
  FileDateModified := True;

  { Check If-Modified-Since request header, if it exists }
  if (Length(request.Headers['If-Modified-Since']) > 0) and
    DateTime.TryParse(request.Headers['If-Modified-Since'], ModifiedSince) then
  begin
    FileDateModified := False;
    if ModifyDate > ModifiedSince then
    begin
      ModifyDiff := ModifyDate - ModifiedSince;
      { Ignore time difference of up to one seconds to compensate for date
        encoding }
      FileDateModified := ModifyDiff > TimeSpan.FromSeconds(1);
    end;
  end;

  { Check the If-None-Match header, if it exists. This header is used by FireFox
    to validate entities based on the ETag response header }
  ETagChanged := False;
  if (Length(request.Headers['If-None-Match']) > 0) then
    ETagChanged := request.Headers['If-None-Match'] <> ETag;

  Result := ETagChanged or FileDateModified;
end;

コンテンツが更新されているかどうかを判定するのに必要なものはリソース名と最終更新日付ですので、データベースからコンテンツを取り出すアプリケーションを、要求されたフィールドをチェックするだけで実際のコンテンツは取り出さないように最適化することが可能です。

なお、時間差が1秒以上であるかのチェックを追加しています:

      ModifyDiff := ModifyDate - ModifiedSince;
      { Ignore time difference of up to one seconds to compensate for date
        encoding }
      FileDateModified := ModifyDiff > TimeSpan.FromSeconds(1);

DateTime型はHTTPの日付/時間の形式よりも高い精度を持っているので、Last-Modifiedヘッダが設定されている場合、1秒より小さな値を失ってしまいます。コンテンツがキャッシュより少なくとも1秒以上新しいことを確認することで、最終更新時刻が丸められた秒を含まない場合にキャッシュが常に無効だと認識される問題を防ぐことができます。

    キャッシュされたコンテンツに対するレスポンスを返す

もしアプリケーションがキャッシュは依然有効だと判断した場合、HTTPのステータスコード304を返すことができます。

  if not IsFileModified(fileName, modifyDate, ETag, request) then
  begin
    { File hasn't changed, so return HTTP 304 without retrieving the data }
    Response.StatusCode := 304;
    Response.StatusDescription := 'Not Modified';
    Response.&End;
    Exit;
  end;

304のレスポンスはメッセージボディを含んではいけません

上のコードは実際にはHTTP 1.1に準拠していません。追加の要件が幾つかあります。

  • 通常のレスポンスがETagヘッダを含むのであれば、304のレスポンスにもそのヘッダを含めなければならない。
  • 前回のレスポンスで送信された値と異なるかもしれない場合には、キャッシュヘッダ (ExpiresCache-Control、および/または Vary) が必要。

今回は、通常のレスポンスに常にETagヘッダを含めているので、304のレスポンスにそれを含めなければなりません。

  if not IsFileModified(fileName, modifyDate, ETag, request) then
  begin
    { File hasn't changed, so return HTTP 304 without retrieving the data }
    response.StatusCode := 304;
    response.StatusDescription := 'Not Modified';
    response.Cache.SetCacheability(HttpCacheability.&Public);
    response.Cache.SetLastModified(ModifyDate);
    response.Cache.SetETag(ETag);
    response.&End;
    Exit;
  end;

このコードにはまだ一つ問題が残っています。それは、実行時にHTTPレスポンスをチェックしなければ明らかにならないものです。つまり、Connectionフィールドがヘッダに自動的に追加され、その値がcloseに設定されています。このことによりブラウザは接続をクローズし、ブラウザは以降のリクエストで新たに接続をオープンしなければならなくなり、パフォーマンスに影響を及ぼすかもしれません。

そのフィールドが追加される理由は、コンテンツが空だからです。ブラウザは期待するコンテンツが何なのかを知らないので、追加のデータを待ってしまいます。この問題は、Content-Lengthヘッダを明示的に追加することで防ぐことができます。何もデータを返していませんので、そのフィールドの値を0に設定しています。

  if not IsFileModified(fileName, modifyDate, ETag, request) then
  begin
    { File hasn't changed, so return HTTP 304 without retrieving the data }
    response.StatusCode := 304;
    response.StatusDescription := 'Not Modified';

    { Explicitly set the Content-Length header so the client doesn't wait for
      content but keeps the connection open for other requests }
    response.AddHeader('Content-Length', '0');

    response.Cache.SetCacheability(HttpCacheability.&Public);
    response.Cache.SetLastModified(ModifyDate);
    response.Cache.SetETag(ETag);
    response.&End;
    Exit;
  end;

    単純なレスポンス

この記事で紹介したコードは、このWebサイトを表示するために使用されているコードを簡略化したバージョンです。以下は、クライアントによるキャッシングが有効な場合におけるHTTPリクエストとレスポンスです:

最初のリクエスト:

GET /article/36897/images/36897/screen-shot-1.jpg HTTP/1.1
Accept: */*
Referer: http://www.codegear.com/products/radstudio
Accept-Language: en-US,he-IL;q=0.5
Accept-Encoding: gzip, deflate
Host: www.codegear.com
Connection: Keep-Alive

レスポンス:

HTTP/1.1 200 OK
Date: Tue, 06 May 2008 17:58:16 GMT
Server: Microsoft-IIS/6.0
X-Powered-By: ASP.NET
X-AspNet-Version: 2.0.50727
Content-disposition: inline; filename=screen-shot-1.jpg
Content-Length: 278054
Cache-Control: public
Last-Modified: Thu, 24 Apr 2008 23:37:27 GMT
ETag: "78BC2A032DBD0B296726BD94B57568F8"
Content-Type: image/jpeg; charset=utf-8

2番目のリクエスト (画像はキャッシュされている):

GET /article/36897/images/36897/screen-shot-1.jpg HTTP/1.1
Accept: */*
Referer: http://www.codegear.com/products/radstudio
Accept-Language: en-US,he-IL;q=0.5
Accept-Encoding: gzip, deflate
If-Modified-Since: Thu, 24 Apr 2008 23:37:27 GMT
If-None-Match: "78BC2A032DBD0B296726BD94B57568F8"
Host: www.codegear.com
Connection: Keep-Alive

レスポンス

HTTP/1.1 304 Not Modified
Date: Tue, 06 May 2008 17:58:52 GMT
Server: Microsoft-IIS/6.0
X-Powered-By: ASP.NET
X-AspNet-Version: 2.0.50727
Content-Length: 0
Cache-Control: public
Last-Modified: Thu, 24 Apr 2008 23:37:27 GMT
ETag: "78BC2A032DBD0B296726BD94B57568F8"
Content-Type: image/jpeg

    追加のリソース

Server Response from: ETNASC02