現職のままシリコンバレーに赴任する
電撃的な展開により会社からシリコンバレーのラボへの異動を命じられた。現職のまま任期付き(恐らく2年)の赴任となる。
オフィスはパロアルトだが家賃が高く到底住めないのでサンマテオとかその辺りに安アパートを借りることになると思う。何ひとつ分からないのでどうかご指導賜りたい。
それから東京の家を完全に引き払う予定なのでドジョウの里親を探している。もし助けてやっても良いという方は本エントリの一番最後までスクロールしていただければ幸いである。
(14時23分追記:里親見つかりました。本当にありがとうございました。)
経緯
12月も半ばになろうという頃、突然すごいエライ人に呼び出されてパロアルト赴任に興味はないか訊かれた。ちょうど失意のどん底だったので「行きたいです!」と答えた。翌週経営会議で承認されてそのまま内示となった。スピード感!
葛藤
ただここからウジウジとナメクジのように悩むことになる。
ラボといっても技術者の赴任は弊社初とのことだ。当然プログラマは僕しかいない。業務内容は
- 新規事業の発掘およびそれに関わる技術調査、助言
- プロトタイピング
- 現地企業との共同研究、連携
等と聞いている。プログラミングをしている時間はざっと50%以下になるそうだ。必要な英語力や交渉力もプログラマとして求められるそれとは別次元のものになる。
2017年は僕にとっては確変状態みたいな年で信じがたいほど多くのチャンスがあった。
モバイルアプリバブルの最末期。34歳。恐らくキャリアの絶頂。2年後同じような好条件で引き合いのある可能性の方が低いのだ。そんな状況で内資企業の、プログラミングを主としない業務に2年間を費やして良いのだろうか?
ビッグボス
赴任先で二人三脚でやっていく予定のボスが一時帰国したので面談した。次のような失礼千万な問いを率直に投げかけた。
「僕は元々アメリカに行きたかったので2年後任期が切れたときにそのまま別の会社に転職しちゃって帰ってこないかも知れないです。そんな男をわざわざ行かせますか?」
「うーんそんじゃあさ、新規事業がうまくいきすぎて現地にジョイントベンチャーでも設立してそこの社長になって帰れないぐらいのところを目標にしない?君だって2年間ずっと受け身で言われたことだけをやって終わるつもりはないだろう?」
このひと言に震えるほど感動した。必ずこの2年間で会社に利益をもたらすのだと誓った。僕は身勝手な人間だったと反省した。プログラムを書くとか書かないとかではなく、どこに居て何をしようが自分の向き合い方次第で僕自身もきっと成長することができる。
キャリア感
2017年は30代前半最後の年であり自分のキャリアについて深く考えさせられた。僕はスタートがどん底*1だったのでこの10年とにかく這い上がるだけだった。
ようやく「これがプログラミングの世界か」とその入口に立った頃、こりゃ大学からちゃんとビジョンを持って然るべき教育を受けてきた連中と今から素手で殴り合うのは得策じゃないかも知れんとちょっと考え始めた。別に自分のプログラマとしてのキャリアを諦めた訳ではなくステータス振り分けの問題で、要するに三国志で許チョを知力に振るのは無駄な努力ってもんなので、僕はもうわずかにしか残っていないパラメータを僕を最も輝かせる方向に賭けるしかないのである。
そのひとつとしてビジネスデベロップメント力をこの2年で養うのはきっと悪くない選択だと信じたい。どの道止まっている時間はない。いのち短し歩けよ中年!
これ名言だと思う pic.twitter.com/HVVPdyJfPC
— まちゃまー (@masaki01192001) 2017年10月18日
ドジョウの里親を募集しています
東京の家を引き払う予定で色々調べたが米国まで生体を持ち込むのが法的にもロジスティクス的にも著しく困難であることが分かったのでドジョウの引き取り手を探しています。内訳は次の通りです。
- マドジョウ * 2
- シマドジョウ * 1
- ミナミヌマエビ * 3
- ヒメタニシ * 3
- ヒメダカ * 6
水槽、LEDライト、外掛けフィルター、水交換ポンプ、バケツ、予備の川砂、予備の餌等あります。
引き取りはこれらまとめて1セットです。引き取って育ててくださる方は @fushiroyama までDMください。DMはフォロー関係になくとも送受信できるよう解放しています。東京23区内は僕が車で直接お届けできます。もし直接引き取り*2に来てくださった方には技術書が2冊買える程度のAmazonギフト券を寸志として贈らせてください。
(14時23分追記:里親見つかりました。本当にありがとうございました。)
アメリカ就職に失敗したはなし
前口上
アメリカで就職できなかった。華々しい成功譚は見かけるが、夢と散った話はあまり表に出てこない。
なんというか「三振したバッターが相手ピッチャーのことを語る」みたいでまるっきり時間の無駄かもしれないが、もしかしたら参考になる人もいるかも知れないし、実際に就職した人に「お前のアプローチはまったく的外れだ」と言われるかも知れない。僕も何が悪かったのか教えてもらいたい気持ちもあるし、迷ったがこのエントリを公開する。
ちなみにめっっっっちゃ長いので、要点だけ知りたい人は、アメリカで就職するにはとにかく
就労ビザ>技術力>学歴>>>>>>>>>>>>(越えられない壁)>英語力
だというのだけお伝えできればと思う。
アメリカで働くために英語を頑張るぐらいなら、それより大学(院)に入り直してコンピュータサイエンスの学位をとり*1、同時に技術力を磨くほうがよほど近道だと感じた。
それから、現職の同僚はこのエントリをみて微妙な気持ちになると思うので、その点について最後に「現職について」で補足する。
秋風五丈原
じいちゃんが死んだ。大正・昭和・平成の3つの時代を生き、たくさんのひ孫に囲まれて90年超の幸せな人生を脱稿した。しかしそれでもなおこのことは僕にはショックだった。「あー人生ってマジで1回なんだ。知ってたけど知らなかったわー」という感じ。
漠然とした概念としての死が、自分の人生が必ず最後に到達する終着駅だとやっと実感をもって気付いた。
ここで僕は、やりたいことはとにかく何でも試してやりきってから死のうと決めた。そのとき思いついたのが「アメリカでソフトウェア技術者として通用するか試したい」ということだった。僕にはそれに至るちゃんとしたロジックとかはなくて、とにかく自分の目で見て自分の足でアメリカの大地を踏みしめ、自分の鼻でSFの雑踏のマリファナまみれの風を吸い込み、自分の手でソフトウェア技術者としての痕跡を残したかった。
ちょうどその頃堤修一さんのフリーランスを休業して就職しますというエントリを発見したり、身近な何人かのプログラマが渡米したりして僕もあのようになりたいとトランペット少年のように思った。早速Creators Learning English Meetupというイベントに帰国中の堤さんが登壇されるということで、突撃して彼を捕まえて質問攻めにした。
それから具体的な就職活動について考えた。
就職活動
どうしていいかさっぱりわからなかった。どこに応募したらいいのか分からない。
とりあえずこの時はアホだったので、さる世界的な多国籍企業の東京支社にソフトウェア技術者として応募した。なんか、そのうち転籍でもできるんじゃとか思ったのである。いま振り返ると筋が悪すぎる。
ちなみに割と早い段階で早々に落とされた。ただこの経験はしておいてよかったなと後に思う。ここの面接でアメリカ企業というのは基本的にコンピュータサイエンスの基礎力を応募者に強く求めるということがわかったからだ。
それからもしばらくは具体的なアクションが起こせず、うーんどうしたらいいんだ?とかつまらないことを考えていたんだけど、事件が起きる。ある日LinkedInに海外企業のリクルーターから「当社のSenior Android Engineer職に興味はないか?」と直接コンタクトが来たのである。
この会社は事前に課題として、実際にその会社のフラッグシップアプリで問題が起こりやすい部分をスマートに解決するための簡易な実装を提出するように求めてきた。僕は自分が優秀であるとアピールするために、組み込みライブラリを組み合わせればまあサラッとできそうなものをわざわざ自前でドバーッと書いて提出したところ、採用担当者はこれを痛く気に入ってめでたく本面接に進むことができた。
面接はすべてSkypeやGoogle Hangoutsを使ってリモートから英語でなされた。この時も英語なんてカタコトかつ単語の羅列で何の問題もなくて、とにかくコーディングにつぐコーディングだった。ある人は整列や探索の簡単なアルゴリズム*2の実装を求めてきたし、HashMapがどう実装されているかかなり踏み込んだ説明*3を求めてきたりしたし、あるいはわざと罠を仕込まれた作りかけのAndroidアプリを共有されて、画面共有ツールで僕がどうやって正しい実装にたどり着くか監視されたりした。
この会社は2人ぐらい突破したんだけど、その後急にリクルーターが代わって
「ところでビザのステータスは?」
「え?ステータスとは?君たちがサポートしてくれると思ってた」
「うーん残念だけどいまはUSの就労ビザはサポートできないんだ」
と言われてなんとそこですべてのプロセスが終わってしまった。
このときもしかしてビザとはとんでもなく重要な要素なのでは…?と初めて気付いた。
ビザ
それからアメリカの就労ビザについて色々調べた。僕がお世話になる可能性のあるビザは次の3つ*4だ。
- H1B(専門職ビザ)
- L1(駐在員ビザ)
- E2(投資家、またはそれを補佐する専門職ビザ)
H1B
専門職ビザの花形である。米国の企業が発行する。
米国はITの聖地であり、技術者は全然足りていないんだそうで「その足らない技術者を海外の人材で賄うため」というお題目でGoogleとかMicrosoftみたいな世界に冠たるIT企業はH1Bをバンバンサポートしてくれるらしい。
就労する業務に深く関連のある学士以上の学位を持っている必要がある。望むらくは修士以上。博士は優遇される。
L1
日本企業の社員が駐在員として米国支社に転籍するような場合に発行してもらえるビザ。その会社に1年以上在籍している必要がある。
日系企業がアメリカ支社に社員を送り込む際の最も一般的な方法のひとつ。
E2
日本の起業家が自分自身に投資してアメリカで起業したり、それを補佐する専門職者に対して発行される可能性があるビザ。L1と違って最低在籍期間のようなものがなく、すぐに発行することが可能。
他にもトレーニングビザというものがあるらしいが、詳しいことは僕は知らない。
また、Diversity Programといって、アメリカが移民の多様性を推進するために移民の申請が少ない国の人を対象に申込者の中から純粋なるくじ引きを行って当選者には永住権の申請権を与えるという面白いプログラムがある。こちらはまさに運任せなのと当選率が低い*5ので参考程度に。
転機、そして内定
さて、その頃たまたまGoogle I/OでSFに行ったときにDeploy NIKUというイベントでお会いしたDrivemodeのCEOの古賀洋吉さんにどうやったらアメリカでソフトウェア技術者として採用されて就労ビザを発行してもらえるか相談したら、
「片っ端から応募しまくりました?してない?どうして?LinkedInとかで募集している会社に『初年度は給料はこのぐらいで我慢してやるから就労ビザだしてくれ』って100社ぐらいにメール送ったら5社ぐらい返事くれるんじゃないですか?」
と言われた。なるほど、ぼくは全然行動力が足りていなかったのだ。
それからちょっと気が楽になって、少しずつ友人のツテとかを頼ってアメリカの会社に応募するようになった。具体的には
- 僕がアメリカで働くことに興味があること
- 出向ではなくアメリカ企業からの直接雇用を望んでいること
- そのためにたとえば即時解雇のリスクがあることや試験内容が現地基準で難しくなっても問題ないこと
- その代わり待遇は現地基準にして欲しいこと
を伝えた。
また、同時にLinkedInのプロフィールを英語で充実させて、自分が経験豊富なプログラマであり転職の意志があることを明示した。具体的には
- 自分が経験のある言語、フレームワークを年数込みで列挙
- 転職に興味があること
- これらを英語で詳述
した。これはもっとも効果のある施策だった。
LinkedInは転職斡旋リクルーターからのコンタクトばかりくるが、1割ぐらい企業のHRが直に連絡を取ってくれる。リクルーターが「弊社に興味がないか?」と聞いてくるのは「試験を受けないか?」と言ってるだけなのでそれ自体に特に意味はないのだが、僕のように関連学位をもたない人間は書類審査でバンバン落とされるので、少なくとも相手企業のHRが連絡を取ってくれた場合は電話面接を受けることができる。そうすれば実力次第で次のステップにすすむ希望が生まれる。
実際に数ヶ月でヨーロッパのスタートアップから数社、アメリカのスタートアップから数社、国際的な多国籍企業からもいくつかInterviewのお誘いを受けた。ひとまずは友人のツテとこの中から興味のある3社ぐらいに絞って面接を受けた。
面接は相変わらずコンピュータサイエンスの基礎知識とアルゴリズムクイズが中心で、対策のしがいがあった。少なくとも、
- アルゴリズムクイックリファレンス等の本で「整列」「探索」「グラフ」あたりのアルゴリズムとデータ構造を理解しておく
- TopCoderのSRM Div2ぐらいのレベルのコーディングテストはスラスラ解けるようになっておく
- UNIX/Linuxの理解。権限、ユーザ管理、プロセス、スレッド、入出力、システムコールなど。詳解UNIXプログラミングとかふつうのLinuxプログラミングとかはおすすめ。
のような準備が有効だった。
そしてついに、行きたい会社から内定がもらえた。オファーレターを見てその金額や福利厚生に興奮したし、「いかなるときも当社都合で解雇できるものとする」というような内容に戦慄したりした。
挫折
内定はゴールではなかった。ビザがおりないのである。
ビザの手続きは先方の弁護士に任せていたのだが、H1Bが簡単にいかなそうで他に色々利用できる可能性はないか探っているようだった。これはたぶん本当で、向こうも採用という莫大なコストをかけて内定を出した候補者はどうにかして入社までこぎつかせたいようだった。
これは推測にしかすぎないが、自分の学位が足を引っ張ったのではないかと想像する。僕はコンピュータサイエンスの学位を持っていない。数学や物理学といった関連学位も持っていない。
アメリカは日本以上の学歴社会である。学歴がひとつの「免許」として機能している。「おれ無免許だけど車の運転うまいっす」とかいう奴を誰も相手にしないのと同じだ。これはとても健全なことだ。大学というのがきちんと社会の求める役割を果たしているのだ。H1Bが業務に関連する学位を必須条件として定めていることは非常に合理的だが僕にとっては悪いニュースだ。
もしかしたらトランプ政権もタイミングが悪かったのかもしれない。トランプ政権下では移民ビザをとにかく制限する方向に動いている。H1Bもアメリカ人の雇用を守るためにかなり厳しくなるという話だ。
先方の人事とやりとりしつつ半年待ったが「率直に言って、来年4月のビザは絶望的だ」という話を受けて、こちらから正式にお断りの連絡を入れた。こちらは4歳と0歳の子供がおり、僕の身の振り如何で保育園も妻の復職もすべてが左右される。いつまでも宙ぶらりんで居るわけにはいかなかった。無念だ。人生はままならぬ。
こうして僕のアメリカ就職は一旦頓挫することになった。
振り返り
とにかく就労ビザがどれだけ大切か思い知らされた。どれだけ面接でうまくやってもビザがなくては何も始まらないということがよくわかった。
学歴もかなり大切で、僕が安易に「自分が行けそうなところで偏差値が高い大学をなんとなく選ぶ。学部はどこでもいい」というようないい加減な高校生活を送ってしまったことを非常に強く後悔した。
突出した技術力や実績があればこれらは挽回のチャンスがあるが、僕のような凡人が凡人なりに就職するには大学の勉強とそこから必然的に導かれる就職先というストーリーは非常に大切だった。
家族のこともありしばらくはこういったアクションは起こさないが、もうあと5歳でも若ければコンピュータサイエンスの大学院を受けていたかもなーという感じ。
現職について
ここまで書いて、現職を辞めるわけでもないのによくもまあいけしゃあしゃあとこんなエントリを書けるなと呆れる向きもあるかも知れない。これには少しフォローを入れたい。
まず、僕は現職を非常に気に入っているし採ってもらったことを深く感謝している。優れたボスと同僚に恵まれ、プロジェクトはいつも創造意欲を掻き立てられるし、みんなメリハリをつけて働いていて余程のことがない限り残業もない。待遇も日本にいる限りにおいては申し分ない。
それとは別で「会社と従業員は対等だ」という僕のキャリア感がある。
僕は僕にしかなしえない技術力を提供し、会社はそれの対価として報酬を払う。僕は常にプライドを持って問題を解決し、素早くアプリをつくり、会社に貢献してきたつもりだ。そしてその度合というのは「勤続年数」で測られるものではないと思っている。
会社が僕に居て欲しいと思い、僕が会社に居たいと思う。両者の利害が一致して契約が持続される。これがシンプルで良いのではないか。
祖父の死でカッとなって色々足掻いてみたが、結局僕は現職に残ることを選んだ。そして会社はまだ僕に力を貸して欲しいと言ってくれる。もう少しお世話になります。
*1:金銭に余裕があるならアメリカの大学(院)を出るとインターンシップ等で利用できる就労ビザが取得できるようだ
*2:NDAにサインしたので詳しい内容は書けないが、ソートのアルゴリズムを直接実装せよというようなものはなかった。ただ、O(n log n)の代表的な探索アルゴリズムついて基本的なアイディアを問われたことはある。
*3:Arrayとキーのハッシュ値を用いた基本的なアイディアや、コリジョンの解決方法の複数のアプローチ等
*4:これ、僕はど素人なので微妙に間違ってたりするかも知れないし、詳しくは自分で調べるか弁護士に相談して欲しい。
*5:応募総数と当選者から割り出した当選率はたしか公式サイトに公開されていて、それによると0.8%ぐらいだが、実際には当たったひと全員が永住権を申請するわけではないのでもう少し多めに当選を出しているとの噂
エンジニアの英語力
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である。注意されたい。