OkHttpのInterceptorとNetworkInterceptorとCacheの関係
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である。注意されたい。
ひほうをよこせ!おれはかみになるんだ!
リビングで物音を感じた私は不審者がいると確信して寝室を出たがエアコンのメンテナンスモードだった。意を決してブログを開設することにする。
思えばブログの続かぬ人生であった。ブログの続かなさに関しては東洋太平洋チャンピオンである。これだけ続かぬので肩肘張らぬ意味合いを込めてMedium等でなくて読者として愛するはてなブログを選んだ。
ブログを書くと決めた私は我が家の論理冷蔵庫であるところのセブンイレブンまで歩きサッポロ黒ラベルとベビースターラーメンいきなりステーキ味を買ったのである。ベビースターラーメンいきなりステーキ味は、あのいきなりステーキを出たあと髪の毛にまで染み込んだいきなりソースの香りがして大変良い。
セブンイレブンといえばこの程セブンプレミアムワッフルコーンチョコミントをリニューアルしたが、妻がわざわざTwitterで「チョコミント売ってねえ」と2回つぶやくほど気にしていたので午前4時にビールを買いに行く後ろめたさを感じていた私がこのアイスを供物として求めたのは自然な流れと言えるが果たして売っておらない。
未練がましくアイス売り場を徘徊していると、ふと雪見だいふくの下からあのチョコミントを示す警戒色であるところの青緑色が目に飛び込んでくるではないか。なんとセブンプレミアムワッフルコーンチョコミントはアイス売り場上段からは物理的に届かない下段に隠されていたのである。元コンビニ店員の私としては、これは発注担当がどうしてもバイト上がりに品薄が予想されるセブンプレミアムワッフルコーンチョコミントを買い占めてメルカリで転売したいがさりとて客に買われたくはない、かと言って発注したアリバイは本部に残したいという苦肉の策だと察するに余りある。
勝利を確信した私はセブンプレミアムワッフルコーンチョコミントを4つ放り込み、カゴに入れたワンカップ大関を上撰にアップグレードしてから帰路についた。そしてこのブログを書いているのである。
ブログのタイトル「怠惰を求めて勤勉に行き着く」というのは昔ジャンプで連載していた坊や哲という麻雀漫画に出てくる房州さんという博打打ちがバーのママに「あんたら博打打ちは楽して儲けようっていう連中だろ。サイコロ振りの練習なんて妙に勉強熱心じゃないか」と指摘されて「『怠惰を求めて勤勉に行き着く』か、こりゃ一本取られた」みたいな感じで発する台詞である。20年ぐらい読んでないうろ覚えなので細部はだいぶ間違っているかも知れないが許して欲しい。とにかく流れ流れてプログラマになった私はこの台詞を生涯の目標にしようと決めたのだ。プログラマは楽をするためならどんな苦労も厭わない存在でなくてはならない。
丁度酒がなくなったのでここで筆を置くことにする。
リビングに置いてあるドジョウの水槽にいるタニシがお腹を空かせているのではないかと心配した私はインターネットの叡智を信じ乾燥昆布を投入したが、一晩経って水槽には茶色く出汁がでてタニシは壁面からこぼれ落ちていた。これからその水換えをせねばならない。それではまたいつの日か。