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

# 前提：HTTPにおけるページキャッシュについて

HTTPリクエストヘッダにはあるページに再訪する際、リクエストを行う端末内に存在するページキャッシュがサーバーで配布するページ（ファイル）と同一かどうかをサーバー側で確認するため次の値が送信されることがあります。

## リクエストヘッダ `If-None-Match`

HTTP通信ではリクエストを送った際、サーバー側から `ETag` というレスポンスヘッダを送出します。`ETag` はサーバー側でレスポンス内容を何かしらの方法（Ex: ハッシュ関数）で文字列でレスポンス内容を一意に表したものです。ETagの仕様は[RFC7232のSection-2.3](https://tools.ietf.org/html/rfc7232#section-2.3)を参照して下さい。

実装例としてはRuby on RailsのRackミドルウェア[`Rack::ETag`](https://github.com/rack/rack/blob/master/lib/rack/etag.rb#L68)を見ると生成の過程がわかると思います。 `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](https://git.webkit.org/?p=WebKit.git;a=blob;f=Source/WebCore/platform/network/curl/CurlCacheEntry.cpp;hb=54ac3c1a6042a05be6a07a1e00274c9924d7b7a4#l125)

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

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

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

調査は以上です。