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

メモ化でRailsを高速化する:基礎から実践テクニックまで徹底解説

アプリケーション開発では、処理が遅いメソッドによく出会います。データベースへのクエリ発行や外部サービスへのアクセスが必要なメソッドは、どうしてもパフォーマンスが低下しがちです。必要になるたびに毎回メソッドを呼び出してオーバーヘッドを受け入れることもできますが、パフォーマンスが重要な要件であるなら、いくつかの選択肢があります。

ひとつは、データを変数に代入して再利用する方法です。これにより処理は高速化しますが、変数を手動で管理するのはすぐに面倒になります。

そこで、「遅い処理」を行うメソッド自身がその変数を管理してくれたらどうでしょうか?呼び出し方は従来どおり同じまま、メソッドが内部でデータを保存・再利用してくれる。まさにこれこそがメモ化(memoization)です。

シンプルに言えば、メモ化とはメソッドの戻り値を保存しておき、毎回再計算しなくて済むようにする仕組みのことです。すべてのキャッシュと同様、これは実質的に「メモリと時間のトレード」です。つまり、値を保持するためのメモリを犠牲にする代わりに、メソッドの処理にかかる時間を節約できるのです。

値をメモ化する方法

Rubyには、or-equals演算子 ||= を使った非常に美しいメモ化のイディオムが用意されています。これは左辺と右辺の値に対して論理OR(||)を適用し、その結果を左辺の変数に代入するものです。実際のコードを見てみましょう。

value ||= expensive_method(123)

# 論理的には以下と等価
value = (value || expensive_method(123))

メモ化の仕組み

この動作を理解するには、2つの概念——「真偽値としての扱い(truthy/falsey)」と「遅延評価」——を掴む必要があります。まずtruthy/falseyから見ていきましょう。

Truthy と Falsey

Ruby(ほぼすべての言語と同様)にはブーリアンの truefalse を表す組み込みキーワードがあります。期待どおりに動作します。

if true
  # 常にこのブロックが実行される
end

if false
  # このブロックは決して実行されない
end

しかしRuby(および多くの他の言語)には「truthy」「falsey」という概念もあります。これは、値があたかも true または false であるかのように扱われるという意味です。Rubyでは nilfalse のみがfalseyであり、それ以外のすべての値(ゼロを含む)は true として扱われます(注:他の言語では異なる判断をする場合があります。たとえばC言語ではゼロは false として扱われます)。先ほどの例を書き換えると次のようになります。

value = "abc123" # 文字列
if value
  # 常にこのブロックが実行される
end

value = nil
if value
  # このブロックは決して実行されない
end

遅延評価

遅延評価はプログラミング言語で非常によく使われる最適化手法の一つで、不要な操作をスキップすることを可能にします。

論理OR演算子(||)は、左辺または右辺のどちらかがtrueであればtrueを返します。つまり、左辺がtrueなら右辺を評価する意味はありません。結果がすでに確定しているからです。自分で実装するとしたら、次のようなコードになるでしょう。

def logical_or(lhs, rhs)
  return lhs if lhs

  rhs
end

lhsrhs が関数(ラムダなど)だった場合、rhs が実行されるのは lhs がfalseyなときだけだということが分かりますね。

Or-Equals 演算子

truthy/falseyの概念と遅延評価という2つの概念を組み合わせると、||= 演算子が何をしているのかが見えてきます。

value # デフォルトはnil
value ||= "test"
value ||= "blah"
puts value
=> test

最初、value は初期化されていないため nil です。次に最初の ||= 演算子に出会います。この時点で value はfalseyなので、右辺("test")が評価され、その結果が value に代入されます。 さらに2番目の ||= 演算子に到達しますが、今度は value の中身が "test" になっているためtruthyです。ここで右辺の評価はスキップされ、value はそのままの状態で処理が続行されます。

メモ化を使うべきかどうかの判断基準

メモ化を利用する際には、いくつか自問すべき点があります。その値にはどのくらいの頻度でアクセスされるのか?何が原因で値は変わるのか?そしてどのくらいの頻度で変わるのか?

値へのアクセスが一度きりであればキャッシュしてもあまり意味がなく、アクセス頻度が高いほどキャッシュによる恩恵は大きくなります。

「何が値を変えるのか」という観点では、メソッド内で使われている値を確認する必要があります。引数を受け取るのでしょうか?受け取る場合、メモ化もそのことを考慮に入れる必要があるでしょう。個人的には、引数の対応も自動的に処理してくれる memoist gem を好んで使っています。

最後に、値がどのくらいの頻度で変わるかを検討しましょう。変化の引き金となるインスタンス変数は存在するのか?それらが変化したときにキャッシュをクリアする必要があるのか?値はオブジェクトレベルでキャッシュすべきか、クラスレベルか?

これらの問いに答えるために、簡単な例を使って判断プロセスを一歩ずつ見ていきましょう。

class ProfitLossReport
  def initialize(title, expenses, invoices)
    @expenses = expenses
    @invoices = invoices
    @title = title
  end

  def title
    "#{@title} #{Time.current}"
  end

  def cost
    @expenses.sum(:amount)
  end

  def revenue
    @invoices.sum(:amount)
  end

  def profit
    revenue - cost
  end

  def average_profit(months)
    profit / months.to_f
  end
end

呼び出し側のコードは省略していますが、title メソッドはおそらく1回しか呼ばれないでしょう。しかも Time.current を使っているため、メモ化してしまうと値が即座に古くなってしまう可能性があります。

一方、revenuecost メソッドはこのクラス内でも何度も参照されます。どちらもデータベースアクセスを伴うため、パフォーマンスが問題になった場合はメモ化の有力候補です。これらをメモ化する前提なら、profit までメモ化する必要はありません。さもないと、ほとんど効果のない「キャッシュの上にさらにキャッシュ」を重ねることになってしまいます。

最後は average_profit です。この値は引数に依存しているため、メモ化にもその考慮が必要です。revenue のような単純なケースなら次のように書けます。

def revenue
  @revenue ||= @invoices.sum(:amount)
end

しかし average_profit では、渡された引数ごとに異なる値を保持する必要があります。memoistを使うこともできますが、ここでは理解を深めるために独自の解決策を実装してみましょう。

def average_profit(months)
  @average_profit ||= {}
  @average_profit[months] ||= profit / months.to_f
end

ここではハッシュを使って計算済みの値を追跡しています。まず @average_profit が初期化されていることを確認し、そのうえで渡された引数をハッシュのキーとして使用します。

クラスレベルとインスタンスレベルのメモ化

多くの場合、メモ化はインスタンスレベルで行われます。つまり計算済みの値をインスタンス変数で保持するということです。この場合、新しいオブジェクトのインスタンスを作成するたびに、「キャッシュ済み」の値を引き継ぐことはできません。非常に単純な例で確認してみましょう。

class MemoizedDemo
  def value
    @value ||= computed_value
  end

  def computed_value
    puts "Crunching Numbers"
    rand(100)
  end
end

このオブジェクトを使うと、結果は次のようになります。

demo = MemoizedDemo.new
=> #<MemoizedDemo:0x00007f95e5d9d398>

demo.value
Crunching Numbers
=> 19

demo.value
=> 19

MemoizedDemo.new.value
Crunching Numbers
=> 93

メモ化する値をクラス変数(@@)に変更すれば、この挙動を変えられます。

  def value
    @@value ||= computed_value
  end

結果はこうなります。

demo = MemoizedDemo.new
=> #<MemoizedDemo:0x00007f95e5d9d398>
demo.value
Crunching Numbers
=> 60
demo.value
=> 60
MemoizedDemo.new.value
=> 60

クラスレベルのメモ化が必要になる場面はあまり多くないかもしれませんが、選択肢としては存在します。ただし、このレベルで値をキャッシュしたいのであれば、Redisやmemcachedのような外部ストアでキャッシュすることを検討する価値があるでしょう。

Railsアプリケーションにおける代表的なメモ化のユースケース

Railsアプリケーションにおいて私がよく目にするメモ化のユースケースは、データベースへのアクセス回数を減らすことです。特に、1つのリクエスト内で値が変化しない場合に有効です。コントローラ内でレコードを検索する「ファインダー」メソッドは、この種のデータベースアクセスの良い例です。

  def current_user
    @current_user ||= User.find(params[:user_id])
  end

もう一つよくあるのが、ビューの描画にデコレーター/プレゼンター/ビューモデル的なアーキテクチャを使っている場合です。これらのオブジェクトのメソッドはメモ化の有力候補になりやすいのです。理由は、オブジェクトの寿命がリクエスト期間と一致すること、データは通常変更されないこと、そしてビュー描画時に一部のメソッドが複数回呼び出されることが多いからです。

メモ化の落とし穴

最大の落とし穴の一つは、本当に必要のないものまでメモ化してしまうことです。文字列補間などは手軽なメモ化対象に見えますが、実際にはサイトのパフォーマンスに顕著な影響を与えることはまずありません(もちろん、極端に大きな文字列を扱ったり大量の文字列操作を行ったりしている場合を除きます)。例を挙げます。

  def title
    # ここでメモ化しても、パフォーマンスへの影響はほとんどない
    @title ||= "#{@object.published_at} - #{@object.title}"
  end

もう一つ注意すべきは、お馴染みの「キャッシュ無効化」の問題です。特にメモ化した値がオブジェクトの状態に依存している場合は要注意です。これを防ぐ有効な手段の一つは、可能な限り低いレベルでキャッシュすることです。a + b を計算するメソッドを丸ごとキャッシュする代わりに、ab の各メソッドを個別にキャッシュしたほうがよいかもしれません。

  # 避けるべき書き方
  def profit
    # 'revenue'や'losses'を直接呼ぶ他の箇所は、ここでのキャッシュ恩恵を受けられない
    # また'revenue'や'losses'の値が変わったら、profitの更新を覚えていられるだろうか?
    @profit ||= (revenue - losses)
  end

  # 推奨される書き方
  def profit
    # キャッシュされていないが、減算は高速な計算
    revenue - losses
  end

  def revenue
    @revenue ||= Invoice.all.sum(:amount)
  end

  def losses
    @losses ||= Purchase.all.sum(:amount)
  end

最後の落とし穴は、遅延評価の仕組みに起因するものです。falseyな値(nilfalse)をメモ化したい場合は、少し工夫が必要になります。||= のイディオムは、保存された値がfalseyだと常に右辺を実行してしまうからです。筆者の経験上、これらの値をキャッシュする必要性はそれほど高くありませんが、必要になった場合は「計算済みかどうか」を示すブーリアンフラグを追加するか、別のキャッシュ機構を使うことになります。

  def last_post
    # ユーザーが投稿を持たない場合、このメソッドが呼ばれるたびにDBへアクセスしてしまう
    @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first
  end

  # 簡単な回避策の例
  def last_post
    return @last_post if @last_post_checked

    @last_post_checked = true
    @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first
  end

メモ化だけでは不十分なケース

メモ化はアプリケーションのパフォーマンスを改善する安価かつ効果的な手段ですが、欠点がないわけではありません。大きな問題の一つが永続性です。一般的なインスタンスレベルのメモ化では、値はその特定のオブジェクトに対してのみ保存されます。そのため、Webリクエストのライフサイクルの間だけ値を保持する用途には最適ですが、複数のリクエストで共通して使える値が毎回再計算されているようなケースでは、キャッシュの恩恵をフルに受けられません。

クラスレベルのメモ化はこの問題の助けになる可能性がありますが、キャッシュ無効化の管理はより難しくなります。さらにサーバーが再起動すればキャッシュされた値は失われ、複数のWebサーバー間で共有することもできません。

本シリーズのキャッシュに関する次回の記事では、これらの問題に対するRailsのソリューションである「低レベルキャッシング」を取り上げます。外部ストアに値をキャッシュして複数のサーバー間で共有できるだけでなく、有効期限のタイムアウトや動的なキャッシュキーによってキャッシュ無効化を管理できるようになります。

  1. RailsでTailwind CSSを使う方法|導入から実践的なスタイリングまで徹底解説

    CSSは魔法のような存在ですが、同時に時間のかかる作業でもあります。美しく、機能的で、アクセシブルなサイトは使っていて心地よいものですが、自分でCSSを一から書くのは骨の折れる仕事です。近年はBootstrapをはじめとする多くのCSSフレームワークが登場し、その中でもTailwind CSSは特に注目を集めています。 RailsにはTailwindが標準搭載されていませんが、この記事では新しいRuby on RailsプロジェクトにTailwind CSSを追加する方法を解説します。これにより、デザイン実装にかかる時間を大幅に節約できるでしょう。さらに、Tailwindのユーティリティクラス

  2. Rails5でのAngularの使用

    あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた