This article was contributed by Vincent Spehner
Vincent Spehner is a technology addict working for Tquila interested in software architecture and development patterns. He is currently writing a book explaining best practices for the integration of Heroku and Salesforce apps.
Rails を使用した Ruby での HTTP キャッシング
この記事の英語版に更新があります。ご覧の翻訳には含まれていない変更点があるかもしれません。
最終更新日 2021年10月05日(火)
Table of Contents
Rails 3 では、静的なページやアセットのためのシンプルな HTTP キャッシング設定が最初から提供されています。この設定でもアプリケーションにはメリットがありますが、動的なものを含めたすべてのリクエストに対して適切なキャッシュヘッダーを指定することで、応答時間、ユーザーエクスペリエンス、アプリケーションの動作に必要なリソースが桁違いに改善されます。
この記事では、Rails 3 アプリケーションで HTTP キャッシュヘッダーを利用することで、最小限の変更で応答時間を改善できるいくつかのユースケースについて説明します。
この記事のリファレンスアプリケーションのソースコードは GitHub で 公開されており、実際の動作は https://http-caching-rails.herokuapp.com で確認できます。
Rails 3 のデフォルトの HTTP キャッシング
Rails 3 アプリのデフォルト設定には、最も基本的なシナリオでの HTTP キャッシュヘッダーの使用が含まれています。さらに、静的アセットの配信効率を高めるアセットパイプラインと、控えめなキャッシングメカニズムとして機能する Rack::Cache および Rack::ETag ミドルウェアが設定されています。
アセットパイプライン
Rails 3.1 以降では、アセットパイプラインの概念が導入されています。JS および CSS アセットの連結と圧縮に加えて、Rails では、複数のリクエストをまたいだ同一アセットの再取得を防ぐための HTTP キャッシュヘッダーが追加されました。アセットのリクエストには、アセットをローカルでどのように保存するかを定義する複数のヘッダーが含まれます。
Age
ヘッダーは、リソースがキャッシュから取得されてからの推定経過時間を示しています。Cache-Control
は、このアセットがpublic
(中間のプロキシに保存可能) であり、max-age
の値が 31,536,000 秒 (365 日) であることを示しています。Etag
は、応答本体のダイジェストに基づいて Rack ミドルウェアによって計算されます。Last-Modified
は、ファイル内の情報に基づいた最新の変更日を示しています。
ほとんどのアプリケーションでは、これらのデフォルト値で十分であり、アセットパイプラインの変更は必要ありません。
Rack::Cache
Rack::Cache は使用しないでください。代わりに CDN を使用してください。
Rails 3 では、Rack::Cache がネイティブのプロキシキャッシュとして導入されました。本番モードでは、public
キャッシュヘッダーがあるすべてのページが、中間プロキシとして機能する Rack::Cache に保存されます。その結果、キャッシュされたリソースに対するこれらのリクエストでは Rails スタックがバイパスされます。
Rack::Cache では、デフォルトでインメモリストレージが使用されます。Heroku などの高度分散環境では、共有キャッシュリソースを使用することをお勧めします。パフォーマンスの高い HTTP ヘッダーベースのリソースキャッシングを Heroku で実現するには、Rack::Cache と Memcached アドオンを使用してください。
Rack::ETag
Cache-Control: private
ヘッダーの副作用は、(Rack::Cache を含めた) リバースプロキシキャッシュにはこれらのリソースが保存されないことです。
Rack::ETag は、すべての応答に自動的に ETag
ヘッダーと Cache-Control: private
を割り当てることによって、条件付きリクエストのサポートを提供します。
ビューがレンダリングされた後に完全な形式の応答文字列をハッシュ化することにより、アプリケーションの詳細を知らなくてもこれを行うことができます。
このアプローチはアプリケーションに対して透過的ですが、それでもアプリケーションでは、リクエストを完全に処理して応答本体をハッシュ化する必要があります。完全な応答の代わりに空の応答が 304 Not Modified
応答ステータスと共に送信されるため、ネットワーク経由でエンドクライアントに完全な応答を送り返すコストが節約されるだけです。
パフォーマンス最大化のためにキャッシュヘッダーを設定するのがアプリケーション開発者の責任であることに変わりはありません。
時間ベースのキャッシュヘッダー
Rails では、時間ベースのリソースのキャッシングを Expires
HTTP ヘッダーを介して指定するためのコントローラーメソッドとして、expires_in
と expires_now
の 2 つを提供しています。
expires_in
Cache-Control
ヘッダーの max-age
の値は、(サンプルアプリ)の show
アクションで使用される) expires_in
コントローラーメソッド を使用して設定されます。
def show
@company = Company.find(params[:id])
expires_in 3.minutes, :public => true
# ...
end
会社のリソースに対してリクエストが行われるときは、Cache-Control
ヘッダーが適切に設定されます。
max-age
の値で指定された時間が経過するまで、クライアントはリソースをリクエストしません。これは、キャッシングに対する粒度の粗いアプローチとして機能し、変更頻度が低く、変更されてもすぐに伝播する必要のないコンテンツに適しています。
Rack::Cache のリクエストと組み合わせて使用する場合、これらのリソースに対するリクエストは、指定された期間中、コントローラーに 1 回しかヒットしません。
Started GET "/companies/2" for 127.0.0.1 at 2012-09-26 14:07:28 +0100
Processing by CompaniesController#show as HTML
Parameters: {"id"=>"2"}
Rendered companies/show.html.erb within layouts/application (9.0ms)
Completed 200 OK in 141ms (Views: 63.8ms | ActiveRecord: 14.4ms)
Started GET "/companies/2" for 127.0.0.1 at 2012-09-26 14:11:10 +0100
Processing by CompaniesController#show as HTML
Parameters: {"id"=>"2"}
Completed 304 Not Modified in 2ms (ActiveRecord: 0.3ms)
最初のリクエストはレンダリングを表示するために完全に実行される一方、2 番目のリクエストはただちに 304 Not Modified
を返すことに注意してください。
expires_now
expires_now
コントローラーメソッドを使用して、リソースを強制的に期限切れにすることができます。このメソッドは、Cache-Control
ヘッダーを no-cache
に設定して、ブラウザまたは中間キャッシュによるキャッシングが行われないようにします。
def show
@person = Person.find(params[:id])
# Set Cache-Control header no-cache for this one person
# (just as an example)
expires_now if params[:id] == '1'
end
Cache-Control
ヘッダーがゼロで埋められ、リソースは強制的に期限切れになります。
expires_now
は、コントローラーアクションを呼び出すリクエストにしか実行されません。以前に expires_in
でヘッダーが設定されたリソースは、有効期限が過ぎるまで、更新されたリソースをすぐにリクエストしません。開発/デバッグ時はこのことに注意してください。
条件付きキャッシュヘッダー
条件付き GET
リクエストでは、ブラウザがリクエストを開始する必要があります。一方でサーバーは、共有メタデータ (ETag
ハッシュまたは Last-Modified
タイムスタンプ) に基づいて、キャッシュされた応答を返すか、処理を完全にバイパスすることができます。
Rails では、stale?
および fresh_when
メソッドを使用して適切な条件付き動作を指定します。
stale?
stale?
コントローラーメソッドは、適切な ETag
および Last-Modified-Since
ヘッダーを設定します。また、現在のリクエストが古くなっている (完全に処理する必要がある) か、それともまだ新しい (Web クライアントはリクエストのキャッシュされたコンテンツを使用できる) かを判定します。
パブリックリクエストの場合は、追加されたリバースプロキシキャッシングのために :public => true
を指定します。
def show
@company = Company.find(params[:id])
# ...
if stale?(etag: @company, last_modified: @company.updated_at)
respond_to do |format|
format.html # show.html.erb
format.json { render json: @company }
end
end
end
respond_to
を stale?
ブロック内にネストすると、そのビューのレンダリングが保証されます。これは多くの場合、リクエストの中で最もコストが高い部分であり、必要時にしか実行されません。
ActiveRecord ドメインオブジェクトと、最終変更時刻としてその updated_at
タイムスタンプを使用して stale?
を呼び出すパターンが一般的です。Rails では、オブジェクト自体を唯一の引数として許可することでこれをサポートしています。この例は stale?(@company)
のように実装できます。
if stale?(@company)
respond_to do |format|
# ...
end
end
この設定では、Companies#show
の最初のリクエストではリクエストスタック全体が呼び出されます (パフォーマンスは向上しません)。
しかし、それ以降のリクエストではビューのレンダリングをスキップして 304 Not modified
を返し、リクエストのうち最もコストが高い部分を回避します。
応答の裏付けとなるコアオブジェクトが古くなっていないことが確認できればリクエスト全体の処理をバイパスできるため、304
応答ステータスにより、ブラウザのロードの観点から高速化されるだけでなくサーバー側での効率も高まります。
fresh_when
stale?
メソッドはブール値を返すので、リクエストがまだ新しいかどうかに応じて異なるパスを実行できます。一方、fresh_when
は ETag
および Last-Modified-Since
応答ヘッダーを設定するだけであり、リクエストがまだ新しい場合は 304 Not Modified
応答ステータスも設定します。カスタムの実行処理が不要な (デフォルト実装の) コントローラーアクションには fresh_when
を使用することをお勧めします。
def index
@people = Person.scoped
fresh_when last_modified: @people.maximum(:updated_at), public: true
end
リソースの遅延ロード
ここで説明する HTTP ヘッダーのキャッシングアプローチを使用すれば、リクエスト処理のうちビューのレンダリング部分をバイパスできます。したがって、できるだけ多くの処理をビューに先送りすると有利です。通常の実行では、Person.all
のコントローラーアクション呼び出しによって、すべての Person
レコード (モデルの関連付けによっては、これに加えてすべての子オブジェクト) がデータベースから取得およびロードされます。
Started GET "/people" for 127.0.0.1 at 2012-09-26 15:08:15 +0100
Processing by PeopleController#index as HTML
Person Load (0.2ms) SELECT "people".* FROM "people"
Company Load (0.4ms) SELECT "companies".* FROM "companies" WHERE "companies"."id" = 1 LIMIT 1
Company Load (0.4ms) SELECT "companies".* FROM "companies" WHERE "companies"."id" = 2 LIMIT 1
Rendered people/index.html.erb within layouts/application (2023.8ms)
Completed 200 OK in 2030ms (Views: 2023.7ms | ActiveRecord: 5.2ms)
一方、コントローラーで ActiveRelation スコープを使用する場合、データベースからのオブジェクトのロードは、ビューでオブジェクトが必要になる時点まで先送りされます。
def index
@people = Person.scoped
fresh_when last_modified: @people.maximum(:updated_at)
end
HTTP キャッシングによってビューの処理が回避されれば、必要なデータベース呼び出しが減り、多大な追加効果が得られます。
Started GET "/people" for 127.0.0.1 at 2012-09-26 15:09:43 +0100
Processing by PeopleController#index as HTML
(0.4ms) SELECT MAX("people"."updated_at") AS max_id FROM "people"
Completed 304 Not Modified in 1ms (ActiveRecord: 0.4ms)
コントローラーアクションで、名前付きスコープ、または ActiveRecord クエリメソッドのいずれかをまだ使用していない場合、匿名スコープメソッド scoped
を使用して、all
ファインダーメソッドと同等のスコープを作成します。
パブリックリクエスト
パブリック応答には機密データが含まれず、中間プロキシキャッシュによって保存できます。キャッシングメソッドで public: true
を使用してパブリックリソースを識別します。
def show
@company = Company.find(params[:id])
expires_in(3.minutes, public: true)
if stale?(@company, public: true)
# …
end
end
プライベートコンテンツ
デフォルトでは、Cache-Control
はすべてのリクエストに対してプライベートに設定されます。ただし、一部のキャッシュ設定によってデフォルト動作が上書きされる可能性があるため、プライベートリソースを明示的に指定することをお勧めします。
expires_in(1000.seconds, public: false)
キャッシュ不可能なコンテンツ
コンテンツのキャッシュを回避するためのグローバルなアプローチは before_filter
の使用です。これは、コントローラーの継承ツリーで定義することも、明示的なプライベート設定を使用してコントローラーごとに定義することもできます。
before_filter :set_as_private
def set_as_private
expires_now
end
Rails では、静的アセットのための基本レベルの HTTP キャッシングがデフォルトで提供されます。ただし、エクスペリエンスを真に最適化するには、Rails の豊富なリクエストキャッシング機能のいずれかを使用して、アプリケーション全体で HTTP キャッシングヘッダーを明示的に定義することをお勧めします。