Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsの「HTTPキャッシングとFaraday」の世界を冒険しながら学ぶ(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

RailsのHTTPキャッシングのしくみを学ぶのにもよい記事です。
日本語タイトルは内容に即したものにしました。

Railsの「HTTPキャッシングとFaraday」の世界を冒険しながら学ぶ(翻訳)

はじめに

本記事ではこんなおとぎ話をします。私はOctokitに自動キャッシュ機構を追加したのですが、まったくうまくいきませんでした。しかしそのときの冒険が素晴らしかったので、それを元にHTTPキャッシュとFaradayミドルウェアのしくみ、それらを活用してAPI呼び出しを自動キャッシュする方法について本記事で解説します。

私は土手でパソコンの前に腰掛けてAPIレスポンスの手動キャッシュを繰り返しているうちに、何だかひどく疲れを感じ始めました。RFC 7234を1度2度眺めてみたものの、そこには挿絵も会話も書かれていないのです。"絵もおしゃべりもない本なんて何の意味もないわよね"と思いました。

しかし実を言うと、ある顧客のStackBlitzプロジェクトがすべての始まりだったのです。StackBlitzはブラウザで動くIDEで、(Octokit gemの助けを借りて)GitHub APIを多用しています。

octokit/octokit.rb - GitHub

Octokitは、まれにGitHub APIのレート制限に引っかかってエラーになることがありました。これは既知の問題で、OctokitのREADMEにはこんなことも書かれています。"パフォーマンスを上げたい場合、APIのレート制約を広げたい場合、またはハイパーメディア税を払いたくない場合は、Faraday Http Cacheを利用できます↓"

sourcelevel/faraday-http-cache - GitHub

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のクロッケー競技場"

lostisland/faraday - GitHub

OctokitはFaradayというHTTPクライアントライブラリの上に構築されていて、基本的にエンドユーザーによるリクエスト処理のカスタマイズを可能にしています。たとえば、EM-HTTP-Requestなどの非同期クライアントを使いたければ、Faradayのアダプタのおかげで完全に利用可能になります。

リクエストはミドルウェア経由でもカスタマイズできます。また、Faradayは「認証」「cookie」「リトライ」「ログ出力」などさまざまな機能も備えています。Rackミドルウェアのことを知っていれば、やり方はご存知でしょう。Faradayは、以下のようにAPIクライアントとAPIサーバーの間にミドルウェアスタックを挿入します。

Faradayのミドルウェアスタック

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-cacheno-storeは、実はクライアント側でも使えます。たとえば、キャッシュ後の再検証を強制したい場合は、リクエストにCache-Control: no-cacheヘッダーを追加できます。

基本的にはこれだけです。しかし保存済みのレスポンスがstaleしたらどうなるのでしょうか?レスポンスを検証する指示は出ていたでしょうか?

staleしたレスポンスの検証

キャッシュに保存されたレスポンスがまだ有効かどうかをチェックするには、リクエストでIf-Modified-SinceヘッダーかIf-None-Matchヘッダーを利用できます。

If-Modified-SinceLast-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-MatchEtag

時刻を文字列で扱うと面倒になるので、もっとよい方法があります。キャッシュされたレスポンスに以下のように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 6のB面に隠れている地味にうれしい機能たち(翻訳)

アプリのUIを完全にmacOSネイティブらしく見せるコツ(翻訳)


  1. 訳注: これは不思議の国のアリスに登場するハートの女王の“Off with their heads!”(あの者たちの首を切れ!)のもじりです。 
  2. 訳注: 原文のobsoleteについて問い合わせしたところ、技術用語ではなく一般的な「廃れた」というニュアンスでした。問い合わせの回答に基づいた文にしました。 
  3. 訳注: これも不思議の国のアリスの"A Mad Tea-Party"のもじりです。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。