Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Railsのビューキャッシング徹底解説:知っておきたい基本から実践まで

キャッシングとは、コードの実行結果を保存しておき、後で素早く取り出せるようにする仕組み全般を指す用語です。これにより、たとえばほとんど変化しないデータを取得するために、何度もデータベースへアクセスすることを避けられます。キャッシングの基本的な概念はどの種類でも共通していますが、Railsでは「何をキャッシュしたいのか」に応じて、さまざまな便利な機能が提供されています。

Rails開発者にとって一般的なキャッシングの手法には、メモ化、低レベルキャッシング(いずれもこのキャッシングシリーズの過去記事で解説済み)、そして本記事で扱うビューキャッシングがあります。

Ruby on Railsがビューをレンダリングする仕組み

まず、少し紛らわしい用語について整理しておきましょう。Railsコミュニティで「ビュー」と呼ばれるのは、app/viewsディレクトリ内に置かれるファイル群のことです。通常は.html.erbファイルですが、それ以外にも選択肢があります(プレーンな.html.js.erb、あるいはslimやhamlといった他のプリプロセッサを使ったファイルなど)。多くの他のWebフレームワークでは、これらのファイルは「テンプレート」と呼ばれており、その用途を考えるとこちらの方がしっくりくる名前だと思います。

RailsアプリケーションがGETリクエストを受け取ると、リクエストは特定のコントローラアクション(例:UsersController#index)にルーティングされます。アクションは必要な情報をデータベースから集め、それをビュー/テンプレートファイルのレンダリングに渡します。ここで私たちは「ビューレイヤー」に入ることになります。

典型的には、ビュー(テンプレート)はハードコードされたHTMLマークアップと動的なRubyコードが混在したものになります:

#app/views/users/index.html.erb

<div class='user-list'>
  <% @users.each do |user| %>
    <div class='user-name'><%= user.name %></div>
  <% end %>
</div>

ファイル内のRubyコードは、ビューをレンダリングするために実行される必要があります(erbの場合、<% %>タグ内のすべてが対象です)。ページを100回更新すれば、@users.each...も100回実行されます。インクルードされるパーシャル(partial)についても同様で、プロセッサはパーシャルのhtml.erbファイルを読み込み、内部のRubyコードをすべて実行し、その結果を1つのHTMLファイルに結合してリクエスト元へ返します。

ビューが遅くなる原因とは

開発中にページを表示すると、Railsが大量のログ情報を出力することにお気づきでしょう。以下のような出力です:

Processing by PagesController#home as HTML
  Rendering layouts/application.html.erb
  Rendering pages/home.html.erb within layouts/application
  Rendered pages/home.html.erb within layouts/application (Duration: 4.0ms | Allocations: 1169)
  Rendered layouts/application.html.erb (Duration: 35.9ms | Allocations: 8587)
Completed 200 OK in 68ms (Views: 40.0ms | ActiveRecord: 15.7ms | Allocations: 14307)

この段階で最も役立つのは最終行です。時間を左から右へ追っていくと、Railsがブラウザへレスポンスを返すまでにかかった合計時間は68msで、そのうち40msがerbファイルのレンダリングに、15.7msがActiveRecordクエリの処理に使われたことがわかります。

これは些細な例ですが、ビューレイヤーのキャッシングを検討すべき理由も示しています。仮にActiveRecordクエリを魔法のように瞬時にできたとしても、erbのレンダリングにはその2倍以上の時間がかかっているのです。

ビューのレンダリングが遅くなる理由はいくつかあります。たとえば、ビューの中で重いDBクエリを呼び出していたり、ループ内で大量の処理を行っていたりするケースです。筆者がよく目にする最も一般的な状況は、単純に多数のパーシャルをレンダリングしているケース、特に複数階層にネストされている場合です。

メール受信箱を想像してみてください。個々の行を処理するパーシャルがあるかもしれません:

# app/views/emails/_email.html.erb

<li class="email-line">
  <div class="email-sender">
    <%= email.from_address %>
  </div>
  <div class="email-subject">
    <%= email.subject %>
  </div>
</div>

そして、受信箱のメインページでは、メールごとにこのパーシャルをレンダリングします:

# app/views/emails/index.html.erb

...
<% @emails.each do |email| %>
  <%= render email %>
<% end %>

受信箱に100件のメッセージがあれば、_email.html.erbパーシャルを100回レンダリングすることになります。この些細な例であれば大きな問題ではありません。筆者のマシンでは、インデックス全体のレンダリングにわずか15msしかかかりませんでした。もちろん、実際のアプリケーションはもっと複雑で、パーシャルの中にさらに別のパーシャルが含まれていることもあるため、レンダリング時間は容易に増大します。_emailパーシャル1件あたり1〜2msしかかからなくても、コレクション全体では100〜200msかかる計算になるのです。

幸い、Railsにはこの問題を簡単に解決できるキャッシング機能が組み込まれています。_emailパーシャルだけをキャッシュしたいのか、indexページ全体をキャッシュしたいのか、あるいはその両方なのか、柔軟に対応できます。

ビューキャッシングとは

Ruby on Railsにおけるビューキャッシングとは、ビューが生成したHTMLを保存し、後で再利用することです。Railsはこれらをファイルシステムに書き込んだり、メモリ上に保持したりする機能を持っていますが、本番環境での利用には、MemcachedやRedisのような独立したキャッシュサーバーを使うことがほぼ必須となります。Railsのmemory_storeは開発時には便利ですが、プロセス間で共有できません(複数のサーバー/dynoや、unicornのようなフォーク型サーバーなど)。同様に、file_storeもサーバーローカルのものであるため、複数マシン間で共有できず、期限切れのエントリを自動的に削除しないため、サーバーのディスクが満杯にならないよう定期的にRails.cache.clearを呼び出す必要があります。

キャッシュストアの有効化は、環境設定ファイル(例:config/environments/production.rb)で行えます:

  # memory storeは開発中のテストには便利だが、
  # 本番環境では推奨されない
  config.cache_store = :memory_store

デフォルトのインストール状態では、development.rbにすでに設定が用意されており、ローカル環境でキャッシングを簡単に切り替えられます。rails dev:cacheを実行するだけで、キャッシングのオン/オフを切り替えられます。

Railsでのビューキャッシングは驚くほどシンプルです。パフォーマンスの違いを示すために、ここではsleep(5)で人工的な遅延を作ってみます:

<% cache do %>
  <div>
    <p>Hi <%= @user.name %>
    <% sleep(5) %>
  </div>
<% end %>

このビューの初回レンダリングは予想通り5秒かかります。しかし、2回目以降の読み込みは数ミリ秒で完了します。cache doブロック内のすべてがキャッシュから取得されるためです。

実例で学ぶビューキャッシングの追加方法

小さなサンプルビューを使って、キャッシングの選択肢を順番に見ていきましょう。このビューが実際にパフォーマンス問題を引き起こしていると仮定します:

# app/views/user/show.html.erb
<div>
  Hi <%= @user.name %>!
<div>

<div>
  Here's your list of posts,
  you've written
  <%= @user.posts.count %> so far
  <% @user.posts.each do |post|
    <div><%= post.body %></div>
  <% end %>
</div>

<% sleep(5) #artificial delay %>

これが作業用の基本骨格で、人工的な5秒の遅延が含まれています。まず、前述のようにshow.html.erbファイル全体をcache doブロックで囲むことができます。こうすると、キャッシュが温まった後は高速なレンダリングが得られます。しかし、この手法にはすぐに問題が見えてきます。

第一に、ユーザーが名前を変更したらどうなるでしょうか? キャッシュされたページをいつ失効させるべきかをRailsに伝えていないため、ユーザーは更新版を永遠に見られない可能性があります。簡単な解決策は、@userオブジェクトをcacheメソッドに渡すことです:

<% cache(@user) do %>
<div>
  Hi <%= @user.name %>!
</div>
...
<% sleep(5) #artificial delay %>
<% end %>

このシリーズの低レベルキャッシングに関する前回の記事でキャッシュキーの詳細を扱ったので、ここでは繰り返しません。今のところ、モデルをcache()に渡すと、そのモデルのupdated_at属性を使ってキャッシュ検索用のキーが生成されることだけ覚えておけば十分です。つまり、@userが更新されるたびに、このキャッシュされたページは失効し、RailsがHTMLを再レンダリングします。

これでユーザーが名前を変更したケースには対応できましたが、投稿についてはどうでしょうか? 既存の投稿を変更したり新しい投稿を作成したりしても、Userupdated_atタイムスタンプは変わらないため、キャッシュされたページは失効しません。さらに、ユーザーが名前を変更すると、投稿自体が変わっていなくてもすべての投稿が再レンダリングされてしまいます。この両方の問題を解決するのが「ロシアンドールキャッシング」(キャッシュの中にキャッシュを入れる手法)です:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>!
  <div>

  <div>
    Here's your list of posts,
    you've written
    <%= @user.posts.count %> so far<br>
    <% @user.posts.each do |post| %>
      <% cache(post) do %>
        <div><%= post.body %></div>
      <% end %>
    <% end %>
  </div>

  <% sleep(5) #artificial delay %>
<% end %>

これで、個別にレンダリングされる各postがキャッシュされるようになりました(実際のアプリケーションでは、これはおそらくパーシャルになるでしょう)。したがって、@userが更新されても、投稿を再レンダリングする必要はなく、キャッシュされた値をそのまま使えます。ただし、まだ1つ問題が残っています。postが変更されても、@user.updated_atは変わらないため、cache(@user) doブロック内は実行されず、更新内容が反映されないのです。

この問題を修正するには、Postモデルにtouch: trueを追加します:

class Post < ApplicationRecord
  belongs_to :user, touch: true
end

touch: trueを追加することで、「所属する(belongs_to)」ユーザーのupdated_atタイムスタンプも、投稿が更新されるたびに更新するようActiveRecordに指示することになります。

補足として、パーシャルのコレクションをレンダリングするのは非常によくある操作であるため、Railsは専用のヘルパーを提供しています:

  <%= render partial: 'posts/post',
       collection: @posts, cached: true %>

これは以下と機能的に同等です:

<% @posts.each do |post| %>
  <% cache(post) do %>
    <%= render post %>
  <% end %>
<% end %>

render partial: ... cached: trueの形式は冗長さが減るだけでなく、追加の効率性ももたらします。Railsはコレクション内の各アイテムに対して個別にキャッシュストアへアクセスする代わりに、マルチゲット(multiget、1回のラウンドトリップで多数のキー/バリューペアを読み取ること)を発行できるからです。

動的なページコンテンツへの対応

ページの一部に、周囲のコンテンツよりもはるかに速い頻度で変化する「動的」コンテンツが含まれることは珍しくありません。特にホームページやダッシュボードでは、アクティビティフィードやニュースフィードがあることが多いでしょう。こうした要素をキャッシュされたページに含めると、キャッシュを頻繁に無効化する必要が生じ、せっかくのキャッシングのメリットが損なわれてしまいます。

簡単な例として、現在の曜日をビューに追加してみましょう:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>,
    hope you're having a great
    <%= Date.today.strftime("%A") %>!
  <div>

  ...
<% end %>

毎日キャッシュを無効化することもできますが、明らかに現実的ではありません。一つの選択肢は、プレースホルダー値(あるいは空の<span>だけ)を使い、JavaScriptで埋める方法です。この種のアプローチは「JavaScriptスプリンクル(javascript sprinkles)」と呼ばれることが多く、Railsのコアコードが多く開発されているBasecampが好んで採用している手法です。結果は次のようになります:

<% cache(@user) do %>
  <div>
    Hi <%= @user.name %>,
    hope you're having a great
    <span id='greeting-day-name'>Day</span>!
  <div>

  ...
<% end %>

<script>
 // vanilla JS + turbolinks使用を想定
 document.addEventListener(
   "turbolinks:load", function() {
   weekdays = new Array('Sunday', 'Monday',
     'Tuesday', 'Wednesday', 'Thursday',
     'Friday', 'Saturday');
     today = weekdays[new Date().getDay()];
   document.getElementById("greeting-day-name").textContent=today;
 });
</script>

もう一つのアプローチは、ビューの一部だけをキャッシュすることです。この例では、挨拶文がページの上部にあるため、その後ろの部分だけをキャッシュするのは比較的簡単です:

<div>
  Hi <%= @user.name %>,
  hope you're having a great
  <%= Date.today.strftime("%A") %>!
<div>

<% cache(@user) do %>
  ...
<% end %>

もちろん、実際の世界のレイアウトではそう単純にはいかないことが多いため、どこにどのようにキャッシングを適用するかは慎重に検討する必要があります。

注意点

ビューキャッシングを、パフォーマンス問題に対する手軽な万能薬と見なしてしまうのは危険です。確かにRailsは、深くネストされたものを含め、ビューやパーシャルを信じられないほど簡単にキャッシュできます。このシリーズの最初の記事では、システムにキャッシングを導入した際に発生しうる問題を整理しましたが、特にビューレベルのキャッシングではその傾向が顕著です。

その理由は、ビューというものは本質的に、システムの基盤となるデータとのやり取りが多くなりがちだからです。Railsでメモ化や低レベルキャッシングを適用する場合、キャッシュされた値をいつ・なぜ更新すべきかを判断するのに、今いるファイルの外を見る必要はあまりありません。一方ビューでは、複数の異なるモデルが呼ばれる可能性があり、意図的な設計なしには、どのモデルがビューのどの部分をいつ再レンダリングさせるべきかを見極めるのが難しくなります。

低レベルキャッシングと同様、最良のアドバイスは、どこで・いつ使うかを戦略的に考えることです。許容できるパフォーマンスレベルを達成できる範囲で、できるだけ少ない箇所に、できるだけ最小限のキャッシングにとどめましょう。

Railsのデフォルトキャッシング

これまでのキャッシングシリーズでは、手動でキャッシングを行う方法を扱ってきましたが、実は手動の設定を一切しなくても、ActiveRecordはすでに内部でいくつかのキャッシングを行い、クエリを高速化(あるいは完全にスキップ)しています。シリーズ次回の記事では、ActiveRecordが私たちのために何をキャッシュしているのか、そして少しの工夫で「カウンターキャッシュ(counter cache)」を維持させ、thing.children.sizeのような行が最新のカウントを取得するためにデータベースにアクセスする必要すらなくなる仕組みについて見ていきます。

  1. Bluetooth 5とは?知っておきたい新機能と特徴を徹底解説

    Wi-Fiと同様に、Bluetoothはスマートフォンをはじめとするあらゆるモバイルデバイスに欠かせない技術です。ファイルやデータの転送だけでなく、ワイヤレススピーカーやスポーツ用イヤホンなどの周辺機器との接続にも幅広く活用されています。 Bluetoothはコミュニケーションのあり方を変えただけでなく、多くのデバイスを大型からコンパクトへと進化させました。最新バージョンのBluetooth 5では、従来のバージョンでは実現できなかったさまざまなことが可能になっています。劇的な変革とまでは言えませんが、確かに新しい魅力がいくつも追加されています。それでは、Bluetooth 5がもたらす新機能

  2. PBMファイルとは?正体や開き方・変換方法をわかりやすく解説

    パソコンが身近な存在になって以来、テクノロジーとエンターテインメントの分野は目覚ましい成長を遂げてきました。その中でも最も一般的な娯楽のひとつが、写真をはじめとするメディアコンテンツです。現在の技術では、特定のファイルを開くために対応アプリケーションを探す手間はほとんどありません。しかし、画像やメディアファイルにはそれぞれ異なる種類があり、多様な拡張子が存在することを知っておくことは有益です。そのひとつである「PBM」という拡張子は、ここしばらくユーザーの間で注目を集めています。本記事では、PBMファイルの概要とその扱い方についてわかりやすくご紹介します。 PBMファイルとは? PBMは「P