18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

WebKit(Safari)におけるページキャッシュ(ETag/Last-Modified)の取り扱い

Last updated at Posted at 2017-05-18

前提: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

CurlCacheEntry.cpp-L79
// 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() でキャッシュファイルが有効なキャッシュファイルかをチェックします。

CurlCacheEntry.cpp-L151
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() でレスポンス内容を解析していきます。

CurlCacheEntry.cpp-L272
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-ModifiedETag の値はクロスドメインでは共有できないということがわかりました。

調査は以上です。

18
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?