エンジニアの英語力
TL; DR
- どれだけ努力しても"ネイティブ並"は無理なので諦めが肝心
- エンジニアは英語ができなくても話を聞いてもらえるので「伝える意思」と「分かったか分かってないかを絶対に曖昧にしない」こと
謝辞
このエントリは弊Android Projectのビルド待ち時間を使って書かれています。Android Studioさんに感謝します。
ビルド待ち時間にブログを書かれたくない場合は弊社は僕に全部盛りiMacを買ってください。
前口上
英語力に関するエントリは盛り上がりやすく荒れやすい気がするんだけど、それはやっぱりみんな英語は出来たほうがいいに決まってるしさりとて英語を身につけるのは難しいよねってことが分かってるからだと思う。
僕はエンジニアの中では比較的英語が得意な部類に入ると思うけど、それでも全然充分だとは思わない。ただ、これでやっていけないか?というと全然そんなことはないので、一番重要なのはエンジニアには何が期待されているのかというのを改めて認識しなおすことで、手持ちの英語力でも幸せに生きていこうではないかということが言いたい。
僕について
僕はもう10年以上プログラマをしている。職種はソフトウェアエンジニアである。
旅行を除いて1週間以上海外にいたことがなく、外資系企業での勤務歴もない。
英語力はTOEIC最高点が890。ここはサバを読んでTOEIC900マンとしてくれ、頼む。英検も準1級を持っている。
ここまで書いて「英語強者だ。解散!」とか「出た、ただの自虐風自慢」というのは簡単だけど、このエントリには僕がTOEIC900に至るまでの道のりと、実はそれは不要であることの両方を記した。良かったら読み進めて欲しい。
エンジニアの英語力について
まず、一番最初にエンジニアに必要な英語力について書いておきたい。
エンジニアにとって英語力はあるに越したことはないが、なくても別に死なない、ソフトクリームのあのカラフルなつぶつぶチョコレートぐらいのもんだということだ。
で、最初に言い切ってしまうと、エンジニアはエンジニアリングができるというただ一点においてすでに価値があるので、エンジニアが英語ができなくても相手は辛抱強くコミュニケーションを取ってくれるという事実だ。
弊社にはイギリス人、アメリカ人、ニュージーランド人といった英語を母国語とする同僚が多数いるが
- エンジニア同士の場合、コードは英語より雄弁なコミュニケーション手段となる
- 相手が非エンジニアの場合も、作るのはこっちなので無碍にはしてこない
ことが分かっている。
従って僕はエンジニアは
- 英語の一次情報(ライブラリやRFC等)をいかなる手段を用いても構わないので読めること
- 相手とどうしても英語を話す必要があるときはいかなる手段を用いても構わないのでゴールについて合意できること
が達成できればなんだって構わないと考える。
いかなる手段を用いても構わないので、前者はGoogle翻訳を使えばいいし、後者は同僚に通訳を頼んでもいい。僕はふざけてなどいない。エンジニアとのコミュニケーションミスで本来とは違うものを作ってしまう損失は通訳のそれよりゼロ2桁ぐらい大きい。
肝心なのは、君にコミュニケーションの意思があることと、いま訊かれており合意しなければならないことが分かったかどうか確実にすることだ。
相手が日本人ですら「ちょっとそこ仕様がフワフワしててわかんないっす」とか確認するじゃないですか。相手が違う言語を話してたら尚更だ。「よくわかんないけどウンって言ってきた」って、文字で書くとアホなの?と思うけど実際には多くの人がやっちゃう。
「わかんなかった」
「あ、そこはわかった、後半わかんない」
「こういう理解でいい?」
ってしつこく確認すること。これは英語力の問題ではなくコミュニケーション意思の問題だ。そしてそれが何より大事だと思う。
僕は海外企業で働いたことがないけど、実は海外企業に内定をもらった*1ことがあって、その時の面接で「英語で困る」なんてことはまったくなかった。
もちろん流暢にはしゃべれるわけないんだけど、別に単語の羅列でも相手が求めているアルゴリズムをホワイトボードにスラスラ書けることの方が肝心だ。
僕は周りのものすごいエンジニアたちが英語ができないという思い込みで海外に挑戦しない例を見てきて「なんてラッキーなんだ!エンジニアの価値はエンジニアリング力にあるのに、そんなことで諦めてくれるなんて!ライバルが減った!」ぐらいに思っている。
このエントリで書いちゃったけど、このエントリを読んで海外企業受けたよありがとうと言ってもらえる方がもっと嬉しいかな。
どうやって実践的な英語力を身につけるか
とはいえ英語は出来たほうが有利だ。ここでは僕がいかにしてTOEIC900の英語力を身に着けたか紹介する。
僕のアプローチはただひとつ。「理解できる英語をひたすら繰り返す」だ。
僕はスピードラーニングの完全否定者である。英語は聞き流すだけでは絶対にできるようにならないが持論だ。想像してみてよ、なんでも良い、例えばアラビア語の音声を10年間毎日聞いたとして、アラビア語が理解できるようになると思うか?
僕が考える良い英語の教材は次の条件を備えていることだ。
- 英語母国語話者が英語で解説する
- 内容はゆっくり平易で、辞書なしで9割理解できる
- 1レッスンに2〜3個わからない単語が出てくる
英語母国語話者による英語の解説
これが非常に肝要で、英語を読み上げて日本語で解説するタイプの教材は日本語の解説を聞いた時点で満足しちゃって記憶に残らない。
英語を英語で解説することがどれだけ大事かというと、これは知らない単語を別の平易な説明で言い換えることができるということだ。これはいざ自分が直接知らない単語をどうにか相手に説明しないといけない場面で役に立つ。
内容はゆっくり平易で辞書なしで理解できる
これはある程度意味がわかった状態で「単語の字面」と「ネイティブな音」とを結びつける必要があることを意味する。実は知っている単語が、ネイティブの発音ではそれと認識できなかったという経験はしばしばすることになる。
単語と音が結びついてくると、今度は未知の単語を「聞いただけで」だいたいのつづりが想像できるようになる。あるいは、けったいなつづりでも辞書に向かって音声入力で聴いたとおりしゃべりかけるとスペルを教えてくれるようになる。
いきなりABC Newsを聞くようなトレーニングは速すぎて取りこぼしの方が多いと個人的に考える。平易でゆっくり過ぎると中級者は「ちょっとこれは簡単過ぎるのでは…」と不安になるかも知れない。
人間は9割わかると前後から内容を類推することができるのでほとんど理解できたような気になるが、逆に言うと1割分かっていないということは各レッスンで1割ずつ未知の内容を習得できるということだ。
僕のオススメはESLPodである。
サイトが分かりにくいのが難点だが、僕は15ドル/月の有料会員で、Select Englishというコースから15本好きなプログラムをDLできるので「Daily English」という、日常でよくあるシチュエーションの会話を通じて単語や表現を学ぶコースから10本、「Cultural English」という、アメリカの歴史や偉人といったアメリカの文化を学ぶコースから5本それぞれ毎月DLしてスマホに入れてウォーキングしながら聴いている。
僕はこのサービスが有料化する前からのユーザで、実に7年間このレッスンを聴いている。人間、7年間も続けたら大抵のことはできるようになるのだ。
月15本で1本20分ぐらいなので、毎日聴いていると10日ぐらいでやりきってしまう。その場合は繰り返し聞く。何度でも繰り返し聞く。そうすることで記憶が強固になる。イディオムが勝手に口をついて出るようになる。
僕がこれまでにした英語のトレーニングにはESLPodただひとつだけだ。他に何一つしていない。それだけでTOEIC900取れる。TOEICなんてTOEICの点数を上げるための対策すらしていない。そんなことは無意味だ。TOEICの点を上げることそれ自体には何の価値もない。
シャドウィングと英語思考
前述の英語リスニングに慣れてくると、今度はそれに合わせて気になる表現や単語をブツブツとシャドウィングしてみよう。聞くのと話すのはやっぱり違う。聞こえたのと同じように言えるようになるまで何回でもブツブツしゃべる。英語には日本語にない音がいくつもあるので、最初はうまく口が動かない。冗談みたいだけど口が攣ったりする。練習あるのみである。
ゆっくりしゃべってくれるのに合わせて自分でも言ってみるのを繰り返していると、対面で英語話者とスピーキングのトレーニングをしていないのに不思議としゃべれるようになる。とにかく重要なのは
- 言葉のシャワー(理解できるものじゃないとダメよ!)を浴びまくって、イディオムが脳内でスッと出てくるようになる
- 何回もシャドウィングして口が慣れてくる
これを繰り返すとしゃべるほうも結構イケるようになってくる。
僕のオススメは日常生活で「これ英語で何ていうかな」と妄想しながら生きるトレーニングだ。普段からそういうことを考えていると、いざ英語話者を前にしたときに自然な流れで言いたいことが言える。
「まずは日本語で考えてそれを英訳する」ステージから「脳内で英語で直接考える」ステージに移行できればしめたものだ。あとは少しずつ教材の難易度を上げていくだけだ。グッドラック!!!
TOEIC900について
さて、これまで散々言ってきたTOEIC900ってどのぐらいの英語力なんだろうかと思った時に、自分の例で言うと
- 英語母国語話者と知的な会話は無理
- ジョークを言い合うなんてとても不可能
- ニュースや新聞も満足に視聴できない
レベルである。
たとえば、ABC NewsやBBC NewsはPodcastで無料で視聴できるのでいますぐ聴いてみて欲しい。TOEIC900程度ではたぶん7割ぐらいしか分からない。
新聞も、例えばFinancial Timesを買ってみて欲しい。辞書なしに1コラム満足に読むことができないと思う。
TOEIC900ってこれっぽっちの英語力なのだ。英語ができるという物差しにしては短すぎる。
ニュース、新聞は日本でもそうだと思うけど、格式張った知的な単語や言い回しが頻出する。そしてその新聞やニュースの内容を議論するようなビジネスマンが、知的な単語を使ってないわけがないのである。同僚のイギリス人同士が政策について議論しているような場では、まるっきり何をしゃべっているのかチンプンカンプンだ。
同僚の英語母国語話者が久しぶりに同郷の同僚と話していて「いや〜、久しぶりに"Intellectual(=知的)"な会話をしたよ^^」って彼に言ってるのを聞いてちょっと傷ついたものだ。
ただ、ことエンジニアと仕事をする上では、彼らはちゃんと「第2外国語としての英語話者」に向けて平易な単語や表現をわざわざ選んでしゃべってくれるし、こちらが分からなくてもある程度譲歩してもらえる。これは僥倖だ。我々エンジニアは恵まれた世界線にいるのだ。
まとめ
最後にまとめると、やっぱり英語は極めようとするには難しすぎるのである程度妥協しないとキリがないということかな。
英語ってたぶんエンジニアにとって目的ではなくて手段であるはずで、目的というのはエンジニアとして幸せに仕事したいとかそういうことだと思うから、手段にこだわりすぎるあまり目的がおろそかになるのはやっぱり不幸なことだと思うので、エンジニアリングの方にフォーカスしていきましょうということです。
最後とっ散らかっちまった…
ビルドしつつちょこちょこ書いたのでつながりも悪いかもしれないけどまあいいや。じゃあの。
*1:この会社はビザがおりず、半年待ってついに諦めることになった。この悔しさとその時の経験はいつか別エントリにする。
Android 4系(API16-19)のTLS1.1, 1.2対応
TL; DR
- API16-19はデフォルトでTLS1.1, 1.2が有効になってないので適宜ONにしてやる
- TLS1.1, 12を有効にしたとて、強いCipher suitesが使えるかは別問題
知ってる人は知っている。知らない人は覚えてね。
前口上
さて、iOS 9からTLS1.2が必須になったのは記憶に新しい。このタイミングで社内のAPIサーバの設定が変わって芋づる式に対応に追われたAndroiderも少なくないはずだ。
本件、僕自身もすぐ忘れるのでAndroid 4系(API16-19)のTLS1.1, 1.2対応について改めてまとめておきたい。
Default configuration for different Android versions に書いてあることが全てなんだけど、AndroidのAPIレベルとSSL/TLSの関係は次のとおりだ。
- SSLv3…使うな
- TLSv1…API 1からサポート、API 1から有効
- TLSv1.1…API 16からサポート、API 20から有効
- TLSv1.2…API 16からサポート、API 20から有効
TLS 1.0で検索すると、各社サポートを打ち切る方針を打ち出していることが分かる。
このタイミングで最低でもサポートするAPIレベルを16を下限としたいところだ。
サーバの設定を確認
さて、TLS 1.0を切ると決めたらApacheなりNginxなりでサポートするTLSのバージョンを設定するはずだ。ここではそれについては解説しない。ここではcurlコマンドでちゃんと設定が正しくなされているか確認してみる。
MacOSデフォルトのcurlはOpenSSLが組み込まれてないので再インストールする。
% brew install --with-openssl curl
% cat<<'EOS'>>~/.zshrc
export CURL_HOME="/usr/local/opt/curl"
export PATH="$CURL_HOME/bin:$PATH"
EOS
% source ~/.zshrc
% curl --version | grep -i openssl
curl 7.57.0 (x86_64-apple-darwin16.7.0) libcurl/7.57.0 OpenSSL/1.0.2n zlib/1.2.8
これでOK。
curl -I --tlsv1.2 -s -v "https://your_server"
こんな感じで --tlsv1.1, --tlsv1.10 でそれぞれ試してみよう。
サポートしていないバージョンで
error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure
みたいになればOK。
Android側でどうするか
で、Android側でどうするかが関心事だと思う。
ここではOkHttpをHTTPクライアントに使った場合に絞る。
まず、SSLSocketFactoryを拡張して、TLS 1.1, 1.2を有効にする以外はほとんど処理を元のクラスにdelegateするだけのファクトリクラスを作る。
private static class TLSSocketFactory extends SSLSocketFactory {
private SSLSocketFactory internalSSLSocketFactory;
public TLSSocketFactory() throws KeyManagementException, NoSuchAlgorithmException {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, null, null);
internalSSLSocketFactory = context.getSocketFactory();
}
@Override
public String[] getDefaultCipherSuites() {
return internalSSLSocketFactory.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return internalSSLSocketFactory.getSupportedCipherSuites();
}
@Override
public Socket createSocket() throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket());
}
@Override
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose));
}
@Override
public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port));
}
@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort));
}
@Override
public Socket createSocket(InetAddress host, int port) throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port));
}
@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort));
}
private Socket enableTLSOnSocket(Socket socket) {
if (socket != null && (socket instanceof SSLSocket)) {
((SSLSocket) socket).setEnabledProtocols(new String[]{"TLSv1.1", "TLSv1.2"});
}
return socket;
}
}
次に、X509TrustManagerを取得する処理をコピペする。
private X509TrustManager getTrustManager() throws NoSuchAlgorithmException, KeyStoreException {
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init((KeyStore) null);
TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers));
}
X509TrustManager trustManager = (X509TrustManager) trustManagers[0];
return trustManager;
}
あとはいつもどおり。
ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS).build();
OkHttpClient okHttpClient;
try {
okHttpClient = new OkHttpClient.Builder()
.connectionSpecs(Collections.singletonList(spec))
.sslSocketFactory(new TLSSocketFactory(), getTrustManager())
.build();
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
throw new RuntimeException(e);
}
return okHttpClient;
これでこのOkHttpClientを使ったHTTPリクエスト/レスポンスはTLS1.1, 1.2を使って行われる。
なお、Builder#sslSocketFactory に
Most applications should not call this method, and instead use the system defaults. Those classes include special optimizations that can be lost if the implementations are decorated.
と明記されているので、この部分を自前で書き換える場合はパフォーマンス上の不利に留意しつつ、 Build.VERSION.SDK_INT で条件式でくくったりする方が良さそうだ。
Cipher suites
冒頭で書いたように、TLS 1.1, 1.2に対応することと、充分に強度のあるCipher suitesを使えることは別問題である。
サーバ側で低APIレベルでは対応していないCipher suitesしか許容しない設定にした場合、本記事の内容を適用してもSSL Handshakeに(当然ながら)失敗する。
SSLSocket | Android Developers
を見て、どのCipher suitesを許容するかAPIチームと相談しつつ、最低APIレベルをどこで妥協するか決めるのが望ましそうだ。以上。
参考リンク
- https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
- https://blog.dev-area.net/2015/08/13/android-4-1-enable-tls-1-1-and-tls-1-2/
- https://github.com/square/okhttp/issues/2372
- https://github.com/square/okhttp/issues/1934
「書き直した方が早い」は9割のケースで間違いだった
はじめに、本エントリは特定の企業、チーム、個人を指して書いたものでは一切ない。100%僕の個人的な経験から来ている。
さて、職業プログラミングに従事していると一度は「これ書き直した方が早いっす」とか言ったことある気がする。自分の場合、多くは歴史のあるレガシーコードを読んだときだ。思い返せば、自分がこう思ったときはほとんどそれは間違いであった。
「なんだこのコード…」
「これ何書いてあるか分かんないっす」
「うーんこれもう書き直した方が早くないっすか?」
この流れは非常に危険だ。
なぜならプログラムというのは本質的に書いてある通りにしか動かないからだ。ちゃんと読めば絶対に何を書いてあるかは分かる。
ここで安易に選んだ書き直しという選択は、自分が慣れ親しんだやり方でその部分をそっくり置き換えるというだけで、それは他人にとってあたらしい「これ何書いてあるかあるか分かんない」を生む結果にしかならない。
ここで取るべき行動は、注意深く辛抱強くコードを読み、解きほぐし、チームメンバやテックリードに相談し、リファクタリングすることだ。安易に「書き直す」なんて言ってはならない。少なくともそこに「何書いてあるか分かるまで」書き直すなんて言えないはずだ。
うわー読みづらいなー、つらいなー、書き直したいなーと思ったら、その行動が実装、テスト、QA含めて前のコードと同等の品質に持っていけるか熟考と見積もりを重ねて、経営陣に納得してもらえるだけの説明がつくか、その覚悟はあるか、思い返したいところだ。
自戒を強くこめて。
Date, Last-ModifiedヘッダがありCache-Controlが不適切な場合OkHttpが思いもよらないCacheをするので注意
(お詫びと訂正)
本件、「思いもよらないCache」ではなく、RFCに示された通りの実装でした。以下に追記します。
RFC7234 4.2.2 Calculating Heuristic Freshness にすべて書いてあるので詳しくはそちらを参照して欲しいですが、サーバが文書の失効に関する明示的な情報を何も返さない場合、キャッシュは他のヘッダを用いてコンテンツの鮮度をヒューリスティック*1に設定するかもしれないとのことです。
RFC7234の当該項目にたとえば代わりに"Last-Modified"を用いることやヒューリスティックな計算結果は最大10%にすることなど何もかも説明がありました。つまりOkHttpがこのようなキャッシュ戦略を取ることは思いもよらないでも何でもなくRFCの勧告通りということです。お詫びして訂正します。
Twitterにて指摘してくださった @hydrakecat さんありがとうございました🙇
以下、それを踏まえてお読みください。追記ここまで。
TL;DR
随分長いタイトルになったが、次の条件を満たす場合にOkHttpのCacheが思いもよらない挙動をする場合がある。
- HTTPレスポンスに"Date", "Last-Modified"ヘッダが両方指定されている
- HTTPレスポンスの"Cache-Control"ヘッダにno-cache, no-store, max-age=0*2 のいずれもが未指定
- HTTPレスポンスの"Expires"ヘッダが未指定
- "Date" - "Last-Modified" が長い期間(たとえば3日間)
このとき、端的に言うと、思いがけず長い期間Cacheが破棄されずネットワークリクエストすら送られずローカルのCacheを見続ける。
な…何を言っているのかわからねーと思うが、俺も何をされたのかわからなかったので順番に書いていく。
事の発端
そもそもこれに気付いたきっかけが、弊社アプリでCloudFrontに置いた小さな設定ファイルを見に行っている部分がファイルが更新されてもいつまで経っても再DLされず、よく観察してみるとリクエストすら飛んでないことが発覚したためだ。
ファイルにcurl -Iして見ると、たしかに"Cache-Control"ヘッダは設定が漏れている。
しかしOkHttpで"Cache-Control"ヘッダがない場合のキャッシュ戦略がどうなっているか、ソースを読むより方法がない。
CacheStrategy
OkHttpのキャッシュはCacheInterceptorという内部Interceptorによって制御されており、その名の通りCacheというクラスによってリクエストURLをキーにしたDiskLruCacheとして実装されている。*3
そしてCacheの動きを決める最も重要なクラスがCacheStrategyである。
CacheStrategyはこれからネットワークに向かっていくリクエストとヒットしたキャッシュエントリを受け取って色んなヘッダを見て、クラス名が表すとおりキャッシュ戦略を決定する。
まず、CacheStrategy.Factory がレスポンスヘッダをフィールドにマップしていく。
this.sentRequestMillis = cacheResponse.sentRequestAtMillis();
this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis();
Headers headers = cacheResponse.headers();
for (int i = 0, size = headers.size(); i < size; i++) {
String fieldName = headers.name(i);
String value = headers.value(i);
if ("Date".equalsIgnoreCase(fieldName)) {
servedDate = HttpDate.parse(value);
servedDateString = value;
} else if ("Expires".equalsIgnoreCase(fieldName)) {
expires = HttpDate.parse(value);
} else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
lastModified = HttpDate.parse(value);
lastModifiedString = value;
} else if ("ETag".equalsIgnoreCase(fieldName)) {
etag = value;
} else if ("Age".equalsIgnoreCase(fieldName)) {
ageSeconds = HttpHeaders.parseSeconds(value, -1);
}
}
"Date"ヘッダが servedDate に、"Last-Modified"ヘッダが lastModifled に、"Expires"ヘッダが expires にマップされたのを覚えておいて欲しい。
次のCacheStrategy#get() とそこから呼ばれる getCandidate() がフィールドにマップされた値を元にキャッシュ戦略を組み立てる心臓部である。
long ageMillis = cacheResponseAge();
long freshMillis = computeFreshnessLifetime();
if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
Response.Builder builder = cacheResponse.newBuilder();
if (ageMillis + minFreshMillis >= freshMillis) {
builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
}
long oneDayMillis = 24 * 60 * 60 * 1000L;
if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
}
return new CacheStrategy(null, builder.build());
}
ここが問題のコードだ。
理解のために先に答えを書くと、
- ageMillis…レスポンスがどのくらい古くなったか
- freshMillis…キャッシュはどのくらいの期間新鮮か
- そしてキャッシュを使ってよく、age < fresh ならキャッシュは新鮮だとみなし、ネットワークリクエストすら行わない。
return new CacheStrategy(null, builder.build());
この第一引数がネットワークリクエストだが、これがnullだとCacheInterceptorがネットワークリクエストを一切行わない。
僕が疑問を持っているのがこの計算方法である。
cacheResponseAge()
レスポンスのageの計算をしているメソッドだ。この計算方法はRFC 2616 13.2.3 Age Calculationsに明示してある。*4
long apparentReceivedAge = servedDate != null
? Math.max(0, receivedResponseMillis - servedDate.getTime())
: 0;
long receivedAge = ageSeconds != -1
? Math.max(apparentReceivedAge, SECONDS.toMillis(ageSeconds))
: apparentReceivedAge;
long responseDuration = receivedResponseMillis - sentRequestMillis;
long residentDuration = nowMillis - receivedResponseMillis;
return receivedAge + responseDuration + residentDuration;
コンテンツが発行されてから実際に受信に要した時間を計算し、現時点までの経過時間を加算するなどしてageを計算している。*5
ここはいいだろう。
computeFreshnessLifetime()
キャッシュが新鮮だと考えられる期間を計算する。コメントを読むと
Returns the number of milliseconds that the response was fresh for, starting from the served date.
とのことで「コンテンツが発行されたときから考えて、レスポンスが新鮮だとみなされるミリ秒」という感じか。
CacheControl responseCaching = cacheResponse.cacheControl();
if (responseCaching.maxAgeSeconds() != -1) {
return SECONDS.toMillis(responseCaching.maxAgeSeconds());
} else if (expires != null) {
long servedMillis = servedDate != null
? servedDate.getTime()
: receivedResponseMillis;
long delta = expires.getTime() - servedMillis;
return delta > 0 ? delta : 0;
} else if (lastModified != null
&& cacheResponse.request().url().query() == null) {
// As recommended by the HTTP RFC and implemented in Firefox, the
// max age of a document should be defaulted to 10% of the
// document's age at the time it was served. Default expiration
// dates aren't used for URIs containing a query.
long servedMillis = servedDate != null
? servedDate.getTime()
: sentRequestMillis;
long delta = servedMillis - lastModified.getTime();
return delta > 0 ? (delta / 10) : 0;
}
return 0;
冒頭の「特定条件」を見直していただければ分かるが、ここでは
} else if (lastModified != null
&& cacheResponse.request().url().query() == null) {
のブロックに入る。
なんとここでは、servedDate(要するにDateヘッダ)から lastModified(Last-Modifiedヘッダ)を引き、10で割って返している。10で割る…
当然だが、"Date"ヘッダと"Last-Modified"が非常に長い期間空いていると(いくら10%とはいえ)非常に長い期間になり、結果的にageを上回ってしまい、この期間ローカルのキャッシュしか見ない状態に陥ってしまう。
謎のRFC
コメントには次のようにある
As recommended by the HTTP RFC and implemented in Firefox, the
max age of a document should be defaulted to 10% of the
document's age at the time it was served. Default expiration
dates aren't used for URIs containing a query.
HTTP RFCが勧告しFirefoxの実装が従っているように、ある文書のmax ageは文書が返されてから経過した時間の10%をデフォルトとすべきだ。
このRFCが何番の何項か分からないので、原文にあたりようがない。したがって現時点でこの仕様と実装が妥当なのか判断がつかない。
これについて何かご存知のかた、是非情報ください。
Cache-Controlヘッダが適切に存在する場合
"Cache-Control"ヘッダに
- max-age=0(というかageより小さければなんでも)
- no-cache, no-store
がある場合は、いずれもこの謎の計算式に到達せず、キャッシュは古いものとしてネットワークリクエストが送られる。
max-ageが計算したageより小さい場合とno-cacheの場合はETag(のための"If-None-Match")ヘッダも正しく使われるし、no-storeの場合はヘッダに従ってキャッシュエントリの保存自体が行われない。
この辺はこれまでに挙げたクラスのソースコードを実際に見て欲しい。行数も少なく、シンプルに書かれているので読みやすい。
Date, Last-Modifiedヘッダ
この挙動に触れるまで、"Date"ヘッダと"Last-Modified"ヘッダについてきちんとRFCで読んだことがなかったので改めて読んでみた。
RFC2616 14.18 Date
要約すると、メッセージが生成された日時ということだ。(これはそのファイルが置かれた日時という意味ではないので注意)
ざっくりと、このHTTPレスポンスのボディが生成されてクライアントに向けて送出する直前ぐらいまでの時間のようだ。
サーバエラー等やNTPサーバが利用できないような状況を除いて、基本的に全てのHTTPレスポンスに付けなければならない。日本語訳はこちら。
RFC7232 2.2 Last-Modified
これは提供するコンテンツが最後に更新された日時だ。
更新されたという定義はコンテキストによるようで、それがファイルであればファイルの更新タイムスタンプかもしれないし、XMLやJSONの一部ならツリーの一部コンテンツ以下の更新を意味するかもしれない。
条件付きリクエストやキャッシュ鮮度によってネットワークトラフィックを軽減できるため、サーバはLast-Modifiedヘッダを返すべきである。
An origin server SHOULD obtain the Last-Modified value of the
representation as close as possible to the time that it generates the
Date field value for its response.
そしてここは個人的に重要だと思ったので引用したが、Last-Modifiedは可能な限りDateに近づけるようにすべきだと書いてある。正しく運用することでより正確なキャッシュ運用が可能だからだろう。日本語訳はこちら。
まとめ
まとめと言っても難しいな…特定のヘッダの組み合わせでOkHttpが思いもよらないキャッシュを持つというのを誰かに共有したかった。
敢えて教訓を挙げるならば、キャッシュして欲しくないコンテンツは正しく"Cache-Control"ヘッダを設定しようということだ。
弊社ではCloudFrontに置いたファイルを更新してもアプリがそれをいつまで経っても読みに行ってくれず、一体何が起こってるんだ!?とソースコードを読んでこのような挙動を発見した。
同じような運用をしている人は一度そのファイルにcurl -Iしてレスポンスヘッダを確認してみて欲しい。
最終的に少し発散してしまったが、computeFreshnessLifetime() の計算式がいかなる根拠によるものか未だに興味があるので、何かご存知のかたは教えてください。宜しくお願い申し上げます。
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年ぐらい読んでないうろ覚えなので細部はだいぶ間違っているかも知れないが許して欲しい。とにかく流れ流れてプログラマになった私はこの台詞を生涯の目標にしようと決めたのだ。プログラマは楽をするためならどんな苦労も厭わない存在でなくてはならない。
丁度酒がなくなったのでここで筆を置くことにする。
リビングに置いてあるドジョウの水槽にいるタニシがお腹を空かせているのではないかと心配した私はインターネットの叡智を信じ乾燥昆布を投入したが、一晩経って水槽には茶色く出汁がでてタニシは壁面からこぼれ落ちていた。これからその水換えをせねばならない。それではまたいつの日か。