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

Railsアプリを高速化する:ビューパフォーマンス最適化の実践ガイド

本記事では、Railsのビューパフォーマンスを改善するための実績ある手法を詳しく解説します。特に、データベースの効率化、ビュー操作、そしてキャッシングの3つの観点に焦点を当てます。

「時期尚早な最適化は諸悪の根源である」という言葉は、少し文脈から外れて使われているように思います。コードレビューでシンプルな最適化手法を指摘した際に、開発者がこの言葉を使うのをよく耳にします。「まず動くようにして、後で最適化する」——その後にテスト、デバッグ、再テストと続いていくわけです。

しかし幸いなことに、コードを書き始めた瞬間から活用できる、シンプルかつ効果的なパフォーマンス最適化手法が数多く存在します。

👋 この記事が気に入った方は、Rubyパフォーマンス監視チェックリストにある他のRuby(on Rails)パフォーマンス関連記事もぜひご覧ください。

本記事では、基本的なRailsアプリを題材に、改善を加えながら結果を比較していきます。

サンプルアプリには以下のモデルを使用します。

  • Person(複数のProfileを持つ)
    • name:string
    • votes_count:integer
  • Profile(Personに所属)
    • address:string

サンプルアプリの構成

Personモデルのコードは以下の通りです。

# == Schema Information
#
# Table name: people
#
#  id          :integer          not null, primary key
#  name        :string
#  votes_count :integer
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
#

class Person < ApplicationRecord
  # リレーション
  has_many :profiles

  # バリデーション
  validates_presence_of :name
  validates_uniqueness_of :name

  def vote!
    update votes_count: votes_count + 1
  end
end

Profileモデルのコードはこちらです。

# == Schema Information
#
# Table name: profiles
#
#  id         :integer          not null, primary key
#  address    :text
#  person_id  :integer
#  created_at :datetime         not null
#  updated_at :datetime         not null
#

class Profile < ApplicationRecord
  # リレーション
  belongs_to :person

  # バリデーション
  validates_presence_of :address
end

また、Faker gemを利用して1,000人分のテストデータを簡単に生成するシードファイルも用意しています。

次に、ApplicationControllerに「home」というアクションを作成します。

def home
  @people = Person.all
end

home.html.erbのコードは以下の通りです。

<ul>
  <% @people.each do |person| %>
    <li id="<%= person.id %>"><%= render person %></li>
  <% end %>
</ul>

まずは計測を行い、このページのパフォーマンスを確認してみましょう。ページの読み込みにはなんと1066.7msもかかりました。これは改善すべき値です。

データベースクエリの最適化

高性能なアプリケーションを構築する第一歩は、リソースの利用効率を最大化することです。ほとんどのRailsアプリはデータベースから取得したデータをビューに表示するため、まずはデータベースへのアクセスを最適化しましょう。

このデモンストレーションではMySQLデータベースを使用します。

最初の読み込みにかかった1066msの内訳を見てみましょう。

「controllers/application_controller#home」の実行に414.7ms、「application/home.html.erb」と「people/_person.html.erb」パーシャルのレンダリングに519.2msと132.8msが費やされています。

...
(0.1ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 996]]
Rendered people/_person.html.erb (1.5ms)
(0.2ms)  SELECT "profiles"."address" FROM "profiles" WHERE "profiles"."person_id" = ?  [["person_id", 997]]
Rendered people/_person.html.erb (2.3ms)
...
Rendered application/home.html.erb within layouts/application (890.5ms)

Completed 200 OK in 1066ms (Views: 890.5ms | ActiveRecord: 175.4ms)

何かおかしな点にお気づきでしょうか?

コントローラーでデータベース呼び出しは1回だけ行ったのに、各パーシャルがそれぞれ個別にデータベースへアクセスしています。これが有名なN+1クエリ問題です。

1. N+1クエリ問題

これは非常にポピュラーでシンプルな最適化手法ですが、このミスがあまりにも蔓延しているため、最初に取り上げる価値があります。

「people/_person.html.erb」の中身を見てみましょう。

<ul>
  <li>
    Name: <%= person.name %>
  </li>
  <li>
    Addresses:
      <ul>
        <% person.profiles.each do |profile| %>
          <li><%= profile.address %></li>
        <% end %>
      </ul>
  </li>
</ul>

<%= button_to "Vote #{person.votes_count}", vote_person_path(person) %>

このパーシャルは、その人物のプロフィールを取得するためにデータベースへ問い合わせ、それぞれをレンダリングしています。つまりN回のクエリ(Nは人数)に、コントローラーでの1回のクエリを加えたN+1回のクエリが発生しているのです。

これを最適化するには、MySQLのJOINとRails ActiveRecordのincludesメソッドを活用します。

コントローラーを以下のように変更しましょう。

def home
  @people = Person.all.includes(:profiles)
end

これにより、全ユーザーの取得は1つのMySQLクエリで行われ、それぞれのプロフィールも別の1クエリでまとめてロードされます。N+1クエリがたった2クエリになりました。

パフォーマンスの向上具合を見てみましょう。ページの読み込みはわずか936msになりました。「application_controller#home」アクションが2つのMySQLクエリのみを実行していることがログから確認できます。

Rendered people/_person.html.erb (0.3ms)
Rendered people/_person.html.erb (0.2ms)
...
Rendered application/home.html.erb within layouts/application (936.0ms)
Completed 200 OK in 936ms (Views: 927.1ms | ActiveRecord: 9.3ms)

2. 必要なデータだけを読み込む

ホームページには住所情報だけが必要で、それ以外は不要です。しかし「_person.html.erb」パーシャルではprofileオブジェクト全体を読み込んでいます。ここを改善してみましょう。

<li>
  Addresses:
  <ul>
    <% person.profiles.pluck(:address).each do |address| %>
      <li><%= address %></li>
    <% end %>
  </ul>
</li>

N+1クエリについてより深く知りたい方は、「ActiveRecord performance: the N+1 queries antipattern」も参考にしてください。

ProTip: この処理はスコープとして「models/profile.rb」に定義しておくとよいでしょう。ビューファイル内に生のデータベースクエリを書くのは避けるべきです。

3. データベース呼び出しはコントローラーに集約する

例えば、この架空のアプリケーションの将来のバージョンで、ホームページに総ユーザー数を表示したいとしましょう。

シンプルですね。ビュー内で次のような呼び出しを行えば実現できます。

# of People: <%= @people.count %>

さらに別の要件として、ページの進捗状況を表示するUI要素が必要になったとします。今度はページ上の人数を総数で割ることになります。

Progress: <%= index / @people.count %>

残念ながら、同僚はあなたがすでにこのクエリを実行済みであることを知らず、ビュー内で何度も同じクエリを実行してしまうかもしれません。

もしコントローラーが以下のようになっていれば:

def home
  @people = Person.all.includes(:profiles)
  @people_count = @people.count
end

計算済みの変数を簡単に再利用できます。

この手法はページ読み込み速度を直接的に改善するものではありませんが、さまざまなビューページからの重複したデータベース呼び出しを防ぎ、キャッシングなど後から実施できる最適化への準備にもなります。

4. ページネーションを活用する

必要なデータだけを読み込むのと同様に、必要な情報だけを表示することも重要です。ページネーションを使えば、ビューは情報の一部だけをレンダリングし、残りはオンデマンドで読み込めます。これにより多くのミリ秒を削減できます。will_paginatekaminariといったgemを使えば、数分で導入できます。

ただし、ユーザーが「次のページ」ボタンを何度もクリックしなければならないという不便さが生じます。そこで「無限スクロール」も検討すると、より快適なユーザー体験を提供できます。

HTMLの再読み込みを避ける

従来型のRailsアプリでは、HTMLビューのレンダリングに多くの時間がかかります。幸い、これを軽減する手段がいくつかあります。

1. Turbolinks

Turbolinksは標準のRailsアプリに同梱されています。JavaScriptライブラリであり、あらゆる環境で動作し(Railsなしの静的ページでも利用可能)、未対応ブラウザでは優雅にフォールバックします。

TurbolinksはすべてのリンクをAJAXリクエストに変換し、JavaScriptでページのbody全体を置き換えます。CSS、JS、画像を再読み込みする必要がないため、パフォーマンスが大幅に向上します。

ただし、カスタムJSを書く際は「TurbolinksセーフなJS」を書くために特別な注意が必要です。詳細は公式ドキュメントを参照してください。

2. AJAXリクエストを使う

Turbolinksと同様の発想で、一部のリンクやボタンをAJAXリクエストに変換することもできます。違いは、Turbolinksのようにbody全体ではなく、置き換えるHTMLを自分で制御できる点です。

実際にAJAXを試してみましょう。サンプルアプリには各ユーザーに「Vote」ボタンがあります。このアクションにかかる時間を計測してみます。

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by PeopleController#vote as HTML
  ...
Redirected to https://localhost:3000/
Completed 302 Found in 24ms (ActiveRecord: 7.5ms)

Started GET "/" for 127.0.0.1 at 2020-01-21 14:50:49 +0530
Processing by ApplicationController#home as HTML
  ...
Completed 200 OK in 190ms (Views: 179.0ms | ActiveRecord: 6.8ms)

ページ全体を再読み込みする場合とほぼ同じ時間がかかり、投票処理分の時間がさらに追加されています。

AJAXリクエストに変更してみましょう。「people/_person.html.erb」は次のようになります。

<%= button_to "Vote #{person.votes_count}", vote_person_path(person), remote: true %>

コントローラーアクションはJSレスポンスを返します。

$("#<%= @person.id %>").html("<%= j render(partial: 'person', locals: {person: @person}) %>");

ご覧の通り、必要なコンテンツだけを置き換えています。HTML IDをフックポイントとして指定し、該当部分だけを入れ替える仕組みです。もちろん、ボタンの内容だけを置き換えればさらに最適化できますが、本記事ではパーシャル全体を置き換えることにします。

結果はどうなったでしょうか?

Started POST "/people/1/vote" for 127.0.0.1 at 2020-01-21 14:52:56 +0530
Processing by PeopleController#vote as JS
  ...
Completed 200 OK in 31ms (Views: 14.6ms | ActiveRecord: 2.9ms)

30ms! たったこれだけです。素晴らしい成果ですね。

ProTip: HTML IDやclassを大量に扱って置き換えのタイミングや対象を管理するのが面倒な場合は、render_async gemの利用を検討してください。面倒な処理の多くを自動的に担ってくれます。

3. WebSocketを使う

HTMLの再読み込みの利点の一つは、毎回サーバーから最新のコンテンツを取得できることです。一方、AJAXリクエストでは、その小さな断片についてのみ最新のコンテンツが得られます。

WebSocketは、クライアントが新しい情報を要求するのではなく、サーバーがクライアントへ更新をプッシュできる優れた技術です。

動的なWebページを構築する際に役立ちます。例えば、Webサイトにゲームのスコアを表示する必要がある場合、新しいコンテンツを取得する方法として以下が考えられます。

  • ユーザーにページ全体の再読み込みを促す
  • スコア部分だけを更新する再読み込みボタンを設置する
  • JavaScriptでバックエンドを1秒ごとにポーリングする
    • データに変更がなくてもサーバーにpingを送り続けてしまう
    • 各クライアントが毎秒呼び出しを行うため、サーバーが容易に過負荷になる
  • WebSocketを使う!

WebSocketなら、サーバーが全クライアント(または特定のサブセット)にいつデータをプッシュするかを制御できます。サーバーはデータがいつ変更されたかを把握しているため、変更があったときだけデータをプッシュできます。

Rails 5ではActionCableがリリースされ、WebSocketに関するすべてを管理できるようになりました。クライアントがサーバーを購読するためのJSフレームワークと、サーバーが変更を公開するためのバックエンドフレームワークを提供しています。ActionCableでは、Fayeのような自己管理型のWebSocketサービスでも、Pusherのようなサブスクリプションサービスでも、任意のWebSocketサービスを選択できます。

個人的には、管理すべき項目が減るためサブスクリプションサービスを選びます。

ActionCableのセットアップが完了すると、ビューはサーバーからのJSON入力を受け取れるようになり、受信時に記述しておいたフックアクションが対応するHTMLコンテンツを置き換えます。

Rails公式ドキュメントとPusherには、WebSocketを使った開発に関する優れたチュートリアルがあります。必読です!

キャッシング

読み込み時間の大部分は、ビューのレンダリングに費やされます。CSS、JS、画像の読み込み、ERBファイルからのHTML生成などが含まれます。

読み込み時間を大幅に削減する一つの方法は、一定期間、または特定のイベントが発生するまで静的であることが分かっているアプリケーションの箇所を特定することです。

今回の例では、誰かが投票しない限り、ホームページは全ユーザーにとって実質的に同じ見た目になることは明らかです(現状、ユーザーが住所を編集する機能はありません)。そこで、イベント(投票)が発生するまで「home.html.erb」ページ全体をキャッシュしてみましょう。

Dalli gemを使用します。これはMemcachedを利用して情報の断片を高速に保存・取得します。Memcachedには保存用のデータ型がないため、実質的にどんなデータでも保存できます。

1. ビューのキャッシング

キャッシュなしで2,000件のレコードを読み込むと、3500msもかかります!

「home.html.erb」の内容をすべてキャッシュしましょう。とてもシンプルです。

<% cache do %>
  <ul>
    <% @people.each do |person| %>
      <li id="<%= person.id %>"><%= render person %></li>
    <% end %>
  </ul>
<% end %>

次に、Dalli gemをインストールし、「development.rb」のキャッシュストアを以下のように変更します。

config.cache_store = :dalli_store

MacまたはLinuxをお使いの場合は、Memcachedサービスを次のように起動するだけです。

memcached -vv

さあ、再読み込みしてみましょう!

537ms7倍の高速化です!

また、MySQLクエリの数も大幅に減少していることが分かります。HTML全体がMemcachedに保存され、そこから再度読み込まれるため、データベースに一切アクセスしていないのです。

アプリケーションログを確認すれば、ページ全体がキャッシュから読み込まれていることも確認できます。

この例はビューキャッシングのほんの入り口にすぎません。パーシャルのレンダリングを各personオブジェクトに紐付けてキャッシュすること(フラグメントキャッシング)や、コレクション全体をキャッシュすること(コレクションキャッシング)も可能です。さらに、ネストの深いビューレンダリングに対しては、ロシアンドールキャッシングと呼ばれる手法も使えます。

2. データベースクエリのキャッシング

ビューの速度を改善するもう一つの最適化は、複雑なデータベースクエリのキャッシングです。アプリケーションが統計や分析を表示している場合、各メトリクスの算出に複雑なデータベースクエリを実行している可能性が高いでしょう。その出力結果をMemcachedに保存し、タイムアウトを設定できます。タイムアウト後は計算が再実行され、結果が再びキャッシュに保存されます。

例えば、アプリケーションがユーザーのチームサイズを表示する必要があるとしましょう。直接の部下の人数、外部委託のコンサルタントの数などを含む複雑な計算になり得ます。

同じ計算を何度も繰り返す代わりに、キャッシュしましょう!

def team_size
  Rails.cache.fetch(:team_size, expires_in: 8.hour) do
    analytics_client = AnalyticsClient.query!(self)
    analytics_client.team_size
  end
end

このキャッシュは8時間後に自動的に失効します。失効すると計算が再実行され、最新の値が次の8時間分キャッシュされます。

3. データベースインデックス

インデックスを使えば、クエリを高速化することもできます。ある人物のすべての住所を取得するシンプルなクエリを見てみましょう。

person.addresses

このクエリは、Addressテーブルに対してperson_idカラムがperson.idと一致するすべての住所を返すよう要求します。インデックスがない場合、データベースは各行を個別に調べてperson.idと一致するか確認しなければなりません。しかし、インデックスがあれば、データベースは特定のperson.idに一致する住所のリストをすぐに参照できます。

データベースインデックスについて詳しく学べる優れたリソースが多数公開されているので、ぜひ参考にしてください。

まとめ

本記事では、データベース利用の改善、サードパーティツールやサービスの活用、ユーザーに表示する情報の制限を通じて、Railsアプリのビューパフォーマンスを向上させる方法を探りました。

アプリのパフォーマンスを改善したい場合は、シンプルな手法から始めて、進めながら継続的に計測することが重要です。まずデータベースクエリを整理し、次に可能な限りAJAXリクエストを活用し、最後にできるだけ多くのビューをキャッシュしましょう。その後、WebSocketやデータベースキャッシングに進むとよいでしょう。

ただし注意が必要です——最適化は一歩間違えると泥沼にはまり込みます。私のように中毒になってしまうかもしれません!

P.S. 本番環境でのRailsアプリのパフォーマンス監視には、Ruby開発者によるRuby開発者のために作られたAppSignalのAPMをご検討ください。🚀

  1. Macが重くなったと感じたら?パフォーマンスを向上させる9つの最適化方法

    「iMacを購入した当初は、滑らかで不具合のないパフォーマンスに本当に感動していました。でも最近は動きが少し鈍くなり、レスポンスも昔ほど速く感じません。」 – デイビッド・モリソン 「Macでのインターネット閲覧は、もう以前のような快適な体験ではなくなりました。システムの起動も遅く、使いたいアプリの起動にも時間がかかるようになりました。」 – カミラ・スミス あなたも、Macが使い込むうちにだんだん遅くなっていると感じていませんか? Macのパフォーマンスに満足できていますか? 「買ったばかりの頃の方が断然快適だった」と感じていませんか? では、Macを遅くしている犯人は一体誰なのでしょうか?

  2. YouTubeアナリティクス徹底解説:主要指標の見方と動画パフォーマンスを最大化する最適化術

    自分のYouTubeチャンネルを誰が視聴しているのか気になったことはありませんか?どの動画が最も好評なのかを知りたいと思ったこともあるでしょう。YouTubeアナリティクスを活用すれば、視聴者の所在地、登録者数の増減、コメント、共有数、高評価・低評価、視聴時間など、さまざまなデータを把握できます。これらのデータを理解することで、どんなコンテンツを発信すべきかが明確になり、動画戦略を最適化しながらブランドを効果的に成長させる方法が見えてきます。 YouTubeに標準搭載されているアナリティクス機能では、動画やチャンネルのパフォーマンスを監視するための集計データや各種指標を確認できます。それでは、