Railsの「HTTPキャッシングとFaraday」の世界を冒険しながら学ぶ(翻訳)
はじめに
本記事ではこんなおとぎ話をします。私はOctokitに自動キャッシュ機構を追加したのですが、まったくうまくいきませんでした。しかしそのときの冒険が素晴らしかったので、それを元にHTTPキャッシュとFaradayミドルウェアのしくみ、それらを活用してAPI呼び出しを自動キャッシュする方法について本記事で解説します。
私は土手でパソコンの前に腰掛けてAPIレスポンスの手動キャッシュを繰り返しているうちに、何だかひどく疲れを感じ始めました。RFC 7234を1度2度眺めてみたものの、そこには挿絵も会話も書かれていないのです。"絵もおしゃべりもない本なんて何の意味もないわよね"と思いました。
しかし実を言うと、ある顧客のStackBlitzプロジェクトがすべての始まりだったのです。StackBlitzはブラウザで動くIDEで、(Octokit gemの助けを借りて)GitHub APIを多用しています。
Octokitは、まれにGitHub APIのレート制限に引っかかってエラーになることがありました。これは既知の問題で、OctokitのREADMEにはこんなことも書かれています。"パフォーマンスを上げたい場合、APIのレート制約を広げたい場合、またはハイパーメディア税を払いたくない場合は、Faraday Http Cacheを利用できます↓"
stack = Faraday::RackBuilder.new do |builder|
builder.use Faraday::HttpCache, serializer: Marshal, shared_cache: false
builder.use Octokit::Response::RaiseError
builder.adapter Faraday.default_adapter
end
Octokit.middleware = stack
"まあ、こんなにスッキリ書けるなんて!"と私は思いました。
その場でOctokit gemをプロジェクトに追加し、たちまち私はウサギの穴に転がり落ちたのです。そこから抜け出す方法など考えたこともなかったような世界でした。
それからしばらくして、ある朝私はキャッシュの様子をチェックしてみようと思いました。APM(アプリケーションパフォーマンス管理)コンソールを開いてみると、パフォーマンスは高くなるどころか真逆の結果になっていました。リクエストのパフォーマンスが極端に低下していたのです。
そこで、キャッシュをオンにする前のリクエストがどのように動いていたかを詳しく調べることにしました。どうかアロケーションがこれ以上増えませんようにとお祈りしながら。
祈っても手遅れでした。アロケーションは私が見ている間もひたすら増大し続けて、たちまちサーバーはノロノロ運転になってしまいました。
下へ、下へ、下へ。このまま果てしなく落ち続けるのでしょうか。とうとう私は水たまりに落ちてずぶ濡れになり、そこに浮かんでいた"Rubyのメモリ肥大化"というメモをひっつかんで読んでみました。
"涙の池"
いえいえ、泣くほどのことではありませんでしたが、キャッシュをオフにしてリクエストタイムを正常復帰させておきました。
それにしても一体何が起きていたのでしょうか。公式に提案されている方法どおりにAPI制約の問題を解決しようとしたのに、どこからどう見てもメモリが肥大化しているという結果になってしまいました。
謎は深まるばかりです。何が起きていたのかを理解するために、Octokitの仕組みと、コードを数行追加するだけでキャッシュを追加できた理由について少し調べてみることにしましょう。
"Octokitのクロッケー競技場"
OctokitはFaradayというHTTPクライアントライブラリの上に構築されていて、基本的にエンドユーザーによるリクエスト処理のカスタマイズを可能にしています。たとえば、EM-HTTP-Requestなどの非同期クライアントを使いたければ、Faradayのアダプタのおかげで完全に利用可能になります。
リクエストはミドルウェア経由でもカスタマイズできます。また、Faradayは「認証」「cookie」「リトライ」「ログ出力」などさまざまな機能も備えています。Rackミドルウェアのことを知っていれば、やり方はご存知でしょう。Faradayは、以下のようにAPIクライアントとAPIサーバーの間にミドルウェアスタックを挿入します。
Faradayミドルウェアは1個のクラスで、特殊なEnv
オブジェクトを処理して次のミドルウェアに渡します。ミドルウェアがすべて実行されたらFaradayがリクエストを作成し、続いてレスポンスを処理するためにすべてのミドルウェアが呼び出されます。
Faradayにミドルウェアを追加するには、使いたいクラスで#use
メソッドを以下のようなオプションで呼び出す必要があります。
stack = Faraday::RackBuilder.new do |builder|
builder.use Faraday::HttpCache, serializer: Marshal, shared_cache: false
builder.use Octokit::Response::RaiseError
end
独自のミドルウェアを書くのはかなり簡単です。必要なのは、リクエストを処理する#on_request
メソッドと、レスポンスを処理する#on_complete
メソッドをそれぞれ実装することだけです。
module Faraday
module Gardener
class Middleware < Faraday::Middleware
# このメソッドはリクエストの準備中に呼び出される。
# @param env [Faraday::Env]は、処理されるリクエストの環境変数。
def on_request(env)
Gardener.plant_rose!(env)
end
# このメソッドはレスポンスの準備中に呼び出される。
# @param env [Faraday::Env]は、処理されるレスポンスの環境変数。
def on_complete(env)
Gardener.paint_red!(env)
end
end
end
end
これで出来上がりです。
カスタムミドルウェアについて詳しくは、Faradayの公式ドキュメントとfaraday-middleware-templateリポジトリを参照してください。
これで、Octokitの振る舞いをカスタムのキャッシュミドルウェアで手軽に変更できることがわかりました。しかし、ミドルウェアはレスポンスをキャッシュするタイミングをどのようにして知るのでしょうか。一体どんな黒魔術を使っているのでしょうか。
"あの者たちのヘッダーを切れ!"1
Webで指定時間内にどれほど大量のデータが流れるかということは、つい忘れられがちです。たとえるなら、お気に入りの技術ブログで記事を配信するのにどれだけ時間がかかるかという話です😜。Webではスピードアップとスループット短縮のためにいつでもキャッシュを利用できますが、ここで少々厄介な点があります。あるリソースはたちまち陳腐化するかもしれませんし、別のリソースは事実上静的であるかもしれません。どのリソースがどちらなのかを区別するにはどうしたらよいでしょうか。
もうおわかりですか?リソースが有効かどうかを知るメカニズムは既にあります。それがHTTPキャッシングです。
HTTPキャッシングは、HTTPレスポンスをキャッシュすることで同じリクエストの繰り返しを最適化し、HTTPヘッダーでキャッシュの振る舞いを制御します。標準のRFC 7234には、publicとprivateという2種類のキャッシュについて記述されています。
publicキャッシュは、複数ユーザー向けのパーソナライズされていないレスポンスを保存する「共用キャッシュ」のことで、CDNのようにクライアントとサーバーの間に配置されるサービスです。
privateキャッシュは、特定のユーザー1人のみを対象とする専用のキャッシュで、すべてのモダンなブラウザで実装されています。
何らかのトラブルシュートで「ブラウザのキャッシュをクリアしてみてください」とユーザーに指示した経験はありますか?この種の問題は、おそらくHTTPキャッシュヘッダーの誤用が原因です。
Cache-Control
まずはCache-Control
ヘッダーから見ていきましょう。このヘッダーは、さまざまなキャッシュディレクティブのリストを渡すのに使われます。ここでは最も重要ないくつかのディレクティブについて説明します。
private
/ public
すべてのレスポンスが平等とは限りません。特定ユーザーだけが使うレスポンス(プロフィール設定など)にはprivate
ディレクティブを指定するべきです。
Cache-Control: private
それ以外のリクエストは、多くのユーザーに見られても構わないものでしょう(ブログ記事など)。このようなリクエストは共用キャッシュに保存できるので、public
ディレクティブを使うべきです。
Cache-Control: public
なお、max-age
を指定した場合はpublic
ディレクティブを省略できます。
max-age
max-age
は、キャッシュがstaleした(古くなった、陳腐化した)とみなすまでの期間を秒数で指定します。
Cache-Control: max-age=3600
Expires
というヘッダーも一応あり、依然として有効ではあるものの流行らなくなっています。現在はmax-age
で完全に置き換えできます2。
must-revalidate
must-revalidate
ディレクティブは、リクエストのたびに強制的にキャッシュを再検証します。
Cache-Control: must-revalidate
no-cache
/ no-store
少し紛らわしいのですが、no-cache
ディレクティブを指定してもレスポンスがストレージに保存されなくなるわけではありません。no-cache
が実際に行うのは、どのリクエストでもキャッシュを強制的に再検証することです。
Cache-Control: no-cache
レスポンスを実際にストレージに保存しないようにするには、no-store
を使います。
Cache-Control: no-store
no-cache
とno-store
は、実はクライアント側でも使えます。たとえば、キャッシュ後の再検証を強制したい場合は、リクエストにCache-Control: no-cache
ヘッダーを追加できます。
基本的にはこれだけです。しかし保存済みのレスポンスがstaleしたらどうなるのでしょうか?レスポンスを検証する指示は出ていたでしょうか?
staleしたレスポンスの検証
キャッシュに保存されたレスポンスがまだ有効かどうかをチェックするには、リクエストでIf-Modified-Since
ヘッダーかIf-None-Match
ヘッダーを利用できます。
If-Modified-Since
とLast-Modified
時刻ベースで検証する場合は、まずstaleしたレスポンスのLast-Modified
ヘッダーの値を調べます。
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: max-age=3600
Date: Sun, 19 Jun 2022 19:06:00 GMT
Last-Modified: Thu, 27 Feb 2022 18:06:05 GMT
<...>
続いて、その値をIf-Modified-Since
ヘッダーの値としてリクエストを送信します。
GET /chronicles HTTP/1.1
Host: evilmartians.com
Accept: text/html
If-Modified-Since: Thu, 27 Feb 2022 18:06:05 GMT
指定の時刻以降にコンテンツが更新されていない場合は、bodyなしの304 Not Modified
レスポンスが返されます。
HTTP/1.1 304 Not Modified
Content-Type: text/html
Cache-Control: max-age=3600
Date: Sun, 19 Jun 2022 20:06:01 GMT
Last-Modified: Thu, 27 Feb 2022 18:06:05 GMT
<...>
この場合、そのキャッシュをfreshであるとマーキングして1時間再利用できるようにし、そのキャッシュをクライアントに返します。
If-None-Match
とEtag
時刻を文字列で扱うと面倒になるので、もっとよい方法があります。キャッシュされたレスポンスに以下のようにEtag
ヘッダーが含まれているとしましょう。
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: max-age=3600
Date: Sun, 19 Jun 2022 19:06:00 GMT
ETag: "5dc9f959df262d184fb5968d80611e33"
<...>
この場合、ブラウザは以下のようにリクエストのIf-None-Match
ヘッダーでその値を指定できます。
GET /chronicles HTTP/1.1
Host: evilmartians.com
Accept: text/html
If-None-Match: "5dc9f959df262d184fb5968d80611e33"
Etag
ヘッダーの値が一致すれば、サーバーが304 Not Modified
を返します。
これで、どんな場合にレスポンスを保存するか、そしてレスポンスをどのように検証するかがわかりました。しかし、あるレスポンスはどのように別のレスポンスと区別されるのでしょうか?URLで区別するだけで万事めでたしでしょうか?
Vary
残念ながら、ここではURLだけを当てにするわけにはいきません。レスポンスのコンテンツは、リクエストのAccept
ヘッダーやAccept-Language
ヘッダーなどの値によっても区別されます。
たとえば、あるページが多言語化されていて、そこでAccept-Language
ヘッダーが使われているとします。そして Accept
ヘッダーの値に応じてHTMLまたはJSONを返せるようになっているとします。言語ごとのレスポンスのキャッシュを個別に保存するには、以下のようにVary
ヘッダーを利用できます。
Vary: Accept, Accept-Language
キャッシュサーバーはこれによって、URLとAccept
ヘッダー値とAccept-Language
ヘッダー値の組み合わせで言語ごとのレスポンスを区別するようになります。
HTTPキャッシングについて詳しくは、MDNのHTTPキャッシング記事をどうぞ。いっそRFC 7234でもよいでしょう。このRFCは冗談抜きでとても読みやすく書かれています!
ここでちょっと未来を覗いてみることにしましょう。
拡大鏡で未来を見る
ここで、キャッシングヘッダーについて新たに提案されている2つのRFCをご紹介したいと思います。どちらも、マルチレイヤキャッシュをより手軽に設定するためのものです。
Cache-Status
ヘッダー
このヘッダーは、受け取ったデータの種別(レスポンスがキャッシュから取り出されたかどうか、レスポンスが通信路の途中でキャッシュされていたかどうか、現在のTTL値など)を開発者がより深く理解するのに有用です。これらのチェックはどれもキャッシュのレイヤ(Nginx、CDN、そしてブラウザ自身も)ごとに行えます。
シンプルな例を以下に示します。
Cache-Status: OriginCache; hit; ttl=1100,
"CDN Company Here"; hit; ttl=545
利用可能なオプションについて詳しくは、RFC 9211を読むことをおすすめします。
"Targeted" Cache-Control
ヘッダー
このヘッダーの書式はCache-Control
ヘッダーと同じで、キャッシュルールの適用対象を指定するのに有用です。たとえばCDN-Cache-Control
ヘッダーを指定するとすべてのCDNを対象にします(Faraday-Gem-Cache-Control
ヘッダーならFaraday gemだけを対象にするという具合なのでしょう)。
RFC 9213は量もそんなに多くないので一度ご覧ください。
"マッドHTTPパーティ"3: Railsのキャッシング
RailsのHTTPキャッシングといえば、サーバー側でHTTPヘッダーを設定するのが普通です。これは、Railsならこれ以上ないくらい手軽に書けます。
def show
@tea_schedule = TeaSchedule.find(params[:id])
if stale?(
etag: @tea_schedule,
last_modified: @tea_schedule.updated_at,
public: true,
cache_control: { no_cache: true }
)
# ビジネスロジック...
# respond...
end
end
stale?
で利用できる属性はRailsのAPIドキュメントに記載されています。また、Jeff PosnickおよびIlya Grigorik謹製の美しいフローチャートはどのCache Controlヘッダーを選ぶかを決めるときに便利です。
話を戻しますが、とにかく私たちのRailsアプリケーションが(クライアントではなく)単なるサーバーだった時代とはかなり異なるシナリオに陥っていることがようやく判明しました。しかしこれで情報がすべて揃ったので、いよいよ例の奇妙なメモリ肥大化の本当の原因について説明できる段階になりました。
"誰がタルトを盗んだの?"
FaradayのHTTP Cacheでは、キャッシュキーの生成にURLを使っています。以下は、Faraday gemのコードの中からこの問題に関連している部分だけを取り出したものです。
# Internal: Computes the cache key for a specific request, taking in
# account the current serializer to avoid cross serialization issues.
#
# url - The request URL.
#
# Returns a String.
def cache_key_for(url)
prefix = (@serializer.is_a?(Module) ? @serializer : @serializer.class).name
Digest::SHA1.hexdigest("#{prefix}#{url}")
end
新しいリクエストが発生するたびに、Faradayのキャッシュミドルウェアはキャッシュからレスポンス/リクエストのペアの配列をフェッチしますが、このときにURLから生成されたキーを利用しています。このミドルウェアは配列をmap
でシリアライズしますが、キャッシュされたレスポンスの探索を試みるのはシリアライズした後だけです。キャッシュされたレスポンスと最新のレスポンスを照合するために、FaradayのミドルウェアはキャッシュされたレスポンスからVary
ヘッダーを読み込みます。それからVary
ヘッダーにあるヘッダー値リストを用いて、キャッシュされたリクエストと最新のリクエストを照合します。
# Internal: Retrieve a response Hash from the list of entries that match
# the given request.
#
# request - A Faraday::HttpCache::::Request instance of the incoming HTTP
# request.
# entries - An Array of pairs of Hashes (request, response).
#
# Returns a Hash or nil.
def lookup_response(request, entries)
if entries
entries = entries.map { |entry| deserialize_entry(*entry) }
_, response = entries.find { |req, res| response_matches?(request, req, res) }
response
end
end
場合によっては、URLには違いがほぼまったく生じず、Vary
で多数の違いが生じることもあります。その場合、URLをキーに用いるデフォルトの実装は最適化からほど遠いものになってしまいます。
そして、これはあくまでprivateなリクエストで動作する私たちのサーバーでの話です。たとえば私たちのサーバーではhttps://api.github.com/user
というURLはどのユーザーでも共通です。
要するに、StackBlitzのユーザーから送信されたあらゆるAPIリクエストが、毎回同じキャッシュキーに新しい値を追加していたのが原因でした。以後のあらゆるリクエストは、それらのレスポンスをすべてフェッチして、メモリ上にあるものすべてについてキャッシュ済みの値がフレッシュかどうかを1個ずつチェックしていたというわけです。🤯
この問題を解決するために、faraday-http-cacheに新しい概念とストラテジーを追加しなければなりませんでした(#130)。これで、使いたい戦略を:strategy
オプションでミドルウェアに指定できるようになりました。
stack = Faraday::RackBuilder.new do |builder|
builder.use :http_cache, store: Rails.cache, strategy: Faraday::HttpCache::Strategies::ByUrl
end
次のステップとしてByVary
ストラテジーを実装しました。このストラテジーはVary
ヘッダーの値を用いてキャッシュキーを生成します。
# Computes the cache key for the response.
#
# @param [Faraday::HttpCache::Request] request - instance of the executed HTTP request.
# @param [String] vary - the Vary header value.
#
# @return [String]
def response_cache_key_for(request, vary)
method = request.method.to_s
headers = vary.split(/[\s,]+/).map { |header| request.headers[header] }
Digest::SHA1.hexdigest("by_vary#{@cache_salt}#{method}#{request.url}#{headers.join}")
end
しかし何事にも代償はつきものです。現時点では、キャッシュされた値をフェッチしても最新のリクエストの値しか取れません。ここにはVary
ヘッダーがありません。
元のストラテジーでは、URLを用いてリクエストやレスポンスをフェッチし、キャッシュ済みレスポンスのVary
ヘッダーを読み取ってから、そのヘッダーを最新のリクエストとキャッシュ済みリクエストの比較に利用します。
新しいストラテジーでは、キャッシュキーとしてヘッダーのリストが使われるので、ヘッダーのリストを事前に把握しておく必要があります。これを実現するために、キャッシュストアから各URLのVary
ヘッダーをフェッチするためのインデックスを新たに導入しました。
その結果、古いリクエストを保存しなくなりましたが、Vary
ヘッダーを保存するようになってキャッシュの読み取り回数が増えました(cache.read
は常に2回呼び出す必要があるため)。
"何事にも道理はあるのよ、ただしそれを見つけられればの話だけど"
Faradayのしくみ、HTTPキャッシュとは何か、そしてHTTPキャッシュをサーバー視点とクライアント視点の両面からプロジェクトで活用する方法について少しばかり説明いたしました。本記事は、コミュニティに貢献してお返しするのは難しくないという話でもあります。皆さんもどうか謎を楽しむ気持ちを失わず、勇気を出してウサギの穴に飛び込んでみましょう。
ここしばらくリリースが途絶えていたfaraday-http-cacheを生き返らせてくれたGustavo Araujoに特に感謝いたします🙏
バックエンドやフロントエンドなどでお困りの方へ: Evil Martiansのメンバーがお助けに参上いたします。私たちが問題の検出と分析を行って、存在する問題をズバッと消し去ります(何かが消えてお困りでしたら、私たちがズバッと復活させます)。詳しくは元記事のフォームまでご相談をお寄せください。
概要
元サイトの許諾を得て翻訳・公開いたします。
RailsのHTTPキャッシングのしくみを学ぶのにもよい記事です。
日本語タイトルは内容に即したものにしました。