TL;DR
先に結論を書くと、NetworkInterceptorはCacheの後ろに居るからちゃんと理解してInterceptorを設定しないと思いもよらない結果になるよという話。
もっというと、この話題はYukiの枝折: OkHttp Interceptorに図付きで分かりやすく解説されているのでそちらの方が分かりよい。
自分で痛い目をみると人間は学習する
じゃあなんでこの記事を書いたかというと、僕は前述の分かりやすいエントリを拝読していたにもかかわらずちゃんと理解しておらず、手痛いバグを入れてしまったからだ。
まず、僕のところで起きていた問題は
- 開発の便宜上HttpLoggingInterceptorを使ってHTTP通信をロギング
- OkHttpは特に指定がない場合 "Accept-Encoding: gzip" を付けてリクエストする
- gzipはLogcatに出ないから開発時だけはPlainTextで出したいので次のようなInterceptorをNetworkInterceptorとして追加した
- この状態でリクエストするとキャッシュがあるはずなのにも関わらずEtagを利用するための "If-None-Match" ヘッダが使われない
public class DisableGzipInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
if (!BuildConfig.DEBUG) {
return chain.proceed(request);
}
Request newRequest = request.newBuilder()
.header("Accept-Encoding", "identity")
.build();
return chain.proceed(newRequest);
}
}
client = new OkHttpClient.Builder()
.addNetworkInterceptor(new HttpLoggingInterceptor())
.addNetworkInterceptor(new DisableGzipInterceptor())
.cache(getCache(getApplicationContext()))
.build();
このコードのどこが悪いか一瞬で分かる方は以降の記事は一切読む必要がない。
そう、これは
.addInterceptor(new DisableGzipInterceptor())
としないといけないのだ。なぜか?
OkHttpのCacheとVaryヘッダ
OkHttpに限らずHTTPのキャッシュ戦略を考えた場合、Varyヘッダの存在は重要である。Varyヘッダはレスポンスの生成に列挙する内容を考慮したかもしれないということを示す。
たとえば "Vary: accept-encoding, accept-language" と指定されていると、コンテンツは「エンコーディングの種類」や「言語設定」によって違うかもしれないという意味だ。もしキャッシュのキーとしてHTTPメソッド+パスを単純に用いるとこれらの状況に対応できないため、Varyヘッダは必ず考慮する必要があるというわけだ。
さて、OkHttpに話を戻そう。
OkHttpはInterceptor, NetworkInterceptorをどのようにチェインしてリクエスト/レスポンスを作るかというと、ずばり RealCall#getResponseWithInterceptorChain という部分である。抜粋する。
List<Interceptor> interceptors = new ArrayList<>();
interceptors.addAll(client.interceptors());
interceptors.add(retryAndFollowUpInterceptor);
interceptors.add(new BridgeInterceptor(client.cookieJar()));
interceptors.add(new CacheInterceptor(client.internalCache()));
interceptors.add(new ConnectInterceptor(client));
if (!forWebSocket) {
interceptors.addAll(client.networkInterceptors());
}
interceptors.add(new CallServerInterceptor(forWebSocket));
Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
originalRequest, this, eventListener, client.connectTimeoutMillis(),
client.readTimeoutMillis(), client.writeTimeoutMillis());
return chain.proceed(originalRequest);
- client.interceptors() でユーザのInterceptorを全て登録
- 内部のInterceptorであるBridgeInterceptorを登録
- 内部のInterceptorであるCacheInterceptorを登録
- しかる後にユーザのNetworkInterceptorを全て登録
そしてこの順にチェインされるのである。
注目すべきは BridgeInterceptor で、
boolean transparentGzip = false;
if (userRequest.header("Accept-Encoding") == null && userRequest.header("Range") == null) {
transparentGzip = true;
requestBuilder.header("Accept-Encoding", "gzip");
}
ここに到達するまでに "Accept-Encoding" が設定されていない場合は gzip を勝手に追加する。
次に注目すべきは CacheInterceptor で、これは BridgeInterceptor のあとにチェインされる点を覚えておいて欲しい。
CacheInterceptor
OkHttpのキャッシュを一手に担うInterceptorである。詳しくは別エントリに譲るが、注目すべきは Cache#get である。
if (!entry.matches(request, response)) {
Util.closeQuietly(response.body());
return null;
}
DiskLruCacheには単純にリクエストURLをキーにキャッシュしているが、entry.matches(request, response) でVaryヘッダをチェックしている。
public boolean matches(Request request, Response response) {
return url.equals(request.url().toString())
&& requestMethod.equals(request.method())
&& HttpHeaders.varyMatches(response, varyHeaders, request);
}
せっかくキャッシュエントリにヒットしても、Varyヘッダが異なると別コンテンツとみなしてそのままnullが返され、ネットワークリクエストが継続されるという寸法だ。
流れを整理すると
- DisableGzipInterceptorをNetworkInterceptorとしてセットしてしまったので、最終的にネットワークリクエストされるときに "Accept-Encoding: identity" に書き換わり、それがキャッシュされる
- BridgeInterceptor が次回リクエスト時に透過的に gzip ヘッダを追加
- CacheInterceptor がキャッシュエントリをチェックするが、レスポンスとリクエストでVaryヘッダが異なるのでキャッシュがいつまでも使われない
というのが本件の顛末である。
まとめ
InterceptorとNetworkInterceptorの関係はOkHttpのドキュメントにも書いてあるが、Cacheとの関係はドキュメントに明示がない。
Stethoでリクエストとレスポンスを監視しても「なぜか "If-None-Match" ヘッダが付いてないのでキャッシュヒットしてなさそうだ」というのはすぐに分かるのだが「なぜキャッシュヒットしないのか」はソースを読むまでよく分からなかった。
ただ今回のきっかけでOkHttpのCache周りの実装にひととおり目を通すことができて非常に勉強になった。オープンソースの素晴らしいところはソースがオープンであるところだ(プ並感)
なお、今回調査して抜粋したコードはすべてOkHttp 3.9.1である。注意されたい。