前提:HTTPにおけるページキャッシュについて
HTTPリクエストヘッダにはあるページに再訪する際、リクエストを行う端末内に存在するページキャッシュがサーバーで配布するページ(ファイル)と同一かどうかをサーバー側で確認するため次の値が送信されることがあります。
リクエストヘッダ If-None-Match
HTTP通信ではリクエストを送った際、サーバー側から ETag
というレスポンスヘッダを送出します。ETag
はサーバー側でレスポンス内容を何かしらの方法(Ex: ハッシュ関数)で文字列でレスポンス内容を一意に表したものです。ETagの仕様はRFC7232のSection-2.3を参照して下さい。
実装例としてはRuby on RailsのRackミドルウェアRack::ETag
を見ると生成の過程がわかると思います。 Rack::ETag
ではレスポンスボディをSHA256でハッシュしています。
サーバーからレスポンスヘッダ ETag
が送付されたら、受け取ったブラウザは同時に送付されたレスポンスヘッダ Cache-Control
にしたがってキャッシュを行います。キャッシュの方法はブラウザ毎に実装が異なります。(今回の記事はこのブラウザ毎のキャッシュ方法の実装の違いのうち、WebKit(Safari)の方法を調査するものです。)
キャッシュがブラウザに存在する場合、同じリクエストを行った時、前回リクエストのレスポンスヘッダ ETag
の値が今回のリクエストのリクエストヘッダ If-None-Match
に入ります。ただし、 If-None-Match
がリクエストヘッダに入るのは前回リクエストのレスポンスヘッダ Cache-Control
または Expires
の条件次第です。ここでもブラウザ毎の実装の差異が発生します。
サーバー側ではリクエストヘッダにある If-None-Match
と、レスポンス予定の ETag
が一致した場合、 304 Not Modified
をレスポンスします。一致しない場合、 通常通り 200 OK
等をサーバーはレスポンスします。
リクエストヘッダ If-Modified-Since
ETag
同様、サーバーからはそのコンテンツが最後に更新された日時を記録する Last-Modified
がレスポンスヘッダとしてブラウザに送信されます。そのレスポンスをキャッシュするかどうかは ETag
の時と同様、レスポンスヘッダ Cache-Control
にしたがいます。
キャッシュがブラウザに存在する場合、同じリクエストを行った時、前回リクエストのレスポンスヘッダ Last-Modified
の値が今回のリクエストのリクエストヘッダ If-Modified-Since
に入ります。
サーバー側ではコンテンツの最終変更日 Last−Modified
がリクエストヘッダ If-Modified-Since
より新しければ 200 OK
をレスポンスします。
WebKitにおける If-None-Match
/ If-Modified-Since
の送出条件
本題のWebKitにおけるキャッシュコントロールを見ていきましょう。WebKitはオープンソースであるため、コードを見ながら行きたいと思います。
WebKitにおけるキャッシュの存在・有効判定は CurlCacheEntry::isCached()
によって行われています。
WebKit GIT trees - WebKit.git/blob - Source/WebCore/platform/network/curl/CurlCacheEntry.cpp
// Cache manager should invalidate the entry on false
bool CurlCacheEntry::isCached()
{
if (!fileExists(m_contentFilename) || !fileExists(m_headerFilename))
return false;
if (!m_headerParsed) {
if (!loadResponseHeaders())
return false;
}
if (m_expireDate < currentTimeMS()) {
m_headerParsed = false;
return false;
}
if (!entrySize())
return false;
return true;
}
まず、 !fileExists(m_contentFilename) || !fileExists(m_headerFilename)
でキャッシュファイルが存在するかどうかをチェックしています。キャッシュファイルが存在したら、loadResponseHeaders()
でキャッシュファイルが有効なキャッシュファイルかをチェックします。
bool CurlCacheEntry::loadResponseHeaders()
{
Vector<char> buffer;
if (!loadFileToBuffer(m_headerFilename, buffer))
return false;
String headerContent = String(buffer.data(), buffer.size());
Vector<String> headerFields;
headerContent.split('\n', headerFields);
Vector<String>::const_iterator it = headerFields.begin();
Vector<String>::const_iterator end = headerFields.end();
while (it != end) {
size_t splitPosition = it->find(":");
if (splitPosition != notFound)
m_cachedResponse.setHTTPHeaderField(it->left(splitPosition), it->substring(splitPosition+1).stripWhiteSpace());
++it;
}
return parseResponseHeaders(m_cachedResponse);
}
なんかゴニョゴニョやってますが、対象のキャッシュファイルが存在していて、ちゃんと中身のあるものであることをチェックし、キャッシュレスポンスを一時的にロードしているようです。ロードが完了したら parseResponseHeaders()
でレスポンス内容を解析していきます。
bool CurlCacheEntry::parseResponseHeaders(const ResourceResponse& response)
{
using namespace std::chrono;
if (response.cacheControlContainsNoCache() || response.cacheControlContainsNoStore() || !response.hasCacheValidatorFields())
return false;
double fileTime;
time_t fileModificationDate;
if (getFileModificationTime(m_headerFilename, fileModificationDate))
fileTime = difftime(fileModificationDate, 0) * 1000.0;
else
fileTime = currentTimeMS(); // GMT
auto maxAge = response.cacheControlMaxAge();
auto lastModificationDate = response.lastModified();
auto responseDate = response.date();
auto expirationDate = response.expires();
if (maxAge && !response.cacheControlContainsMustRevalidate()) {
// When both the cache entry and the response contain max-age, the lesser one takes priority
auto maxAgeMS = duration_cast<milliseconds>(maxAge.value()).count();
double expires = fileTime + maxAgeMS;
if (m_expireDate == -1 || m_expireDate > expires)
m_expireDate = expires;
} else if (responseDate && expirationDate) {
auto expirationDateMS = duration_cast<milliseconds>(expirationDate.value().time_since_epoch()).count();
auto responseDateMS = duration_cast<milliseconds>(responseDate.value().time_since_epoch()).count();
if (expirationDateMS >= responseDateMS)
m_expireDate = fileTime + (expirationDateMS - responseDateMS);
}
// If there is no lifetime information
if (m_expireDate == -1) {
if (lastModificationDate) {
auto lastModificationDateMS = duration_cast<milliseconds>(lastModificationDate.value().time_since_epoch()).count();
m_expireDate = fileTime + (fileTime - lastModificationDateMS) * 0.1;
}
else
m_expireDate = 0;
}
String etag = response.httpHeaderField(HTTPHeaderName::ETag);
if (!etag.isNull())
m_requestHeaders.set(HTTPHeaderName::IfNoneMatch, etag);
String lastModified = response.httpHeaderField(HTTPHeaderName::LastModified);
if (!lastModified.isNull())
m_requestHeaders.set(HTTPHeaderName::IfModifiedSince, lastModified);
if (etag.isNull() && lastModified.isNull())
return false;
m_headerParsed = true;
return true;
}
WebKitにおける If-None-Match
/ If-Modified-Since
の送出条件はここ parseResponseHeaders()
が肝になっているようです。今までのを合わせて、リクエストヘッダ If-None-Match
/ If-Modified-Since
の送出するための条件はざっくり書くと以下のようです。
- キャッシュファイルが存在すること。
- 以前のレスポンスヘッダ
Cache-Control
に以下が 存在しない事no-cache
no-store
- 以前のレスポンスヘッダで次の値のいずれかが存在すること(
ResourceResponseBase::hasCacheValidatorFields()
に定義)Last-Modified
ETag
このあと CurlCacheEntry::isCached()
にもどり、 CurlCacheEntry::parseResponseHeaders()
内でメンバー変数 m_expireDate
で生存しているかどうかをチェックしています。生存期間内であれば、そもそもサーバーにリクエストを出しません。
WebKitにおけるページキャッシュの作成単位
さて、ウェブページにはクロスドメインで画像を取ってきたりすることは通常有り得ることです。例えば https://www.foo.com/index.html
の中に
<img src="https://www.bar.com/image.gif" />
が記載されているようなことです。
このような場合、どのようにキャッシュファイルが実体としてストレージに格納されているのでしょうか?シンプルに考えれば、 https://www.bar.com/image.gif
のキャッシュはreferer(記載されてるhtmlドキュメント)が https://www.foo.com/index.html
でも https://www.buz.com/index.html
でも同一のキャッシュファイルが利用されると考えます。つまり以下のようなディレクトリ構造です。
-
cache/
-
https://www.foo.com/index.html
のキャッシュファイル -
https://www.buz.com/index.html
のキャッシュファイル -
https://www.bar.com/image.gif
のキャッシュファイル
-
ですが、WebKitでのキャッシュファイルの作成単位は以下のように構成されて居ることが確認されました(実際Safariのキャッシュディレクトリを見た状況です)。
-
foo.com/
-
index.html
のキャッシュファイル(ハッシュ値のようなファイル名) -
https://www.bar.com/image.gif
のキャッシュファイル(ハッシュ値のようなファイル名)
-
-
buz.com/
-
index.html
のキャッシュファイル(ハッシュ値のようなファイル名) -
https://www.bar.com/image.gif
のキャッシュファイル(ハッシュ値のようなファイル名)
-
つまり、サブドメインを含まないドメインレベルでまずディレクトリが分けられ、そのドメインの中で使われた全てのファイルをキャッシュしています。
このことからわかるように、WebKitではキャッシュファイルは同じソースURLであっても、呼び出し元によってキャッシュディレクトリが分けれられる様になっています。つまり、 Last-Modified
や ETag
の値はクロスドメインでは共有できないということがわかりました。
調査は以上です。