Railsのフラグメントキャッシュを分解調査する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

なお、本記事はRubyWeekly #405でも紹介されています。

Railsのフラグメントキャッシュを分解調査する(翻訳)

本記事は「Rails分解調査」シリーズの第一弾です。このシリーズのねらいは、Railsのコンポーネントを使うときにそれぞれの機能(今回のフラグメントキャッシュなど)が互いにどう絡み合うかという点について一般的な概念を示すことです。何ぶんこの種の記事を書くのは初めてですので、何かお気づきの点がありましたらぜひ元記事までコメントをどうぞ。


Railsのフラグメントキャッシュはほとんどの方がご存知か使ったことがあると思いますが、そのしくみについてはあまり知られていないのではないでしょうか。そこで本記事では、Railsの背後のフラグメントキャッシュのしくみについて簡単にご紹介したいと思います。

まず、フラグメントキャッシュの実行に関連する以下のコンポーネント(クラスやモジュール)を見ていきましょう。

もちろんここでは一般的な概念を示すにとどめますので、すべてのクラスやモジュールまではリストアップしていません。

ユーザーインターフェイス

フラグメントキャッシュの使い方はいたってシンプルです。次のようにcacheを呼ぶだけで完了します。

<% cache project do %> 
  <b>All the topics on this project</b>
  <%= render project.topics %>
<% end %>

このcacheメソッドがあるのはActionView::Helpers::CacheHelperというビューヘルパーです。

# File actionview/lib/action_view/helpers/cache_helper.rb, line 165
module ActionView
  module Helpers 
    module CacheHelper
      def cache(name = {}, options = {}, &block)
        if controller.respond_to?(:perform_caching) && controller.perform_caching
          name_options = options.slice(:skip_digest, :virtual_path)
          safe_concat(fragment_for(cache_fragment_name(name, name_options), options, &block))
        else
          yield
        end

        nil
      end
    end
  end
end

ここでは主に次の2つを行っています。

  • cache_fragment_nameでキャッシュのキーを生成する
  • fragment_forでこのブロックをキャッシュする

fragment_forの動作を見てみましょう(訳注: これはprivateメソッドです)。

# actionview/lib/action_view/helpers/cache_helper.rb
def fragment_for(name = {}, options = nil, &block)
  if content = read_fragment_for(name, options)
    ......
    content
  else
    ......
    write_fragment_for(name, options, &block)
  end
end

この動作も非常にシンプルで、キャッシュが存在する場合は内容を読み出し、なければコンテンツを書き込んでいるだけです。キャッシュの内容の読み書き以外に、以下のようにテンプレートのレンダラーにキャッシュのヒットの有無も通知します。

# actionview/lib/action_view/helpers/cache_helper.rb
def fragment_for(name = {}, options = nil, &block)
  if content = read_fragment_for(name, options)
    @view_renderer.cache_hits[@virtual_path] = :hit if defined?(@view_renderer)
    ......
  else
    @view_renderer.cache_hits[@virtual_path] = :miss if defined?(@view_renderer)
  ......
  end
end

Railsはキャッシュヒットの有無をこのようにして認識します。そしてfragment_forメソッドはキャッシュの内容の読み書きのためにread_fragment_forwrite_fragment_forを使っています(訳注: これらもprivateメソッドです)。

# actionview/lib/action_view/helpers/cache_helper.rb
def read_fragment_for(name, options)
  controller.read_fragment(name, options)
end

def write_fragment_for(name, options)
  pos = output_buffer.length
  yield
  ......
  fragment = output_buffer.slice!(pos..-1)
  ......
  controller.write_fragment(name, fragment, options)
end

ご覧のように、最終的にcontrollerという名前の変数に対してread_fragmentwrite_fragmentが呼び出されています。ご推察のとおり、このcontrollerとは現在のリクエストを処理しているRailsのコントローラなのです。つまりここから主体がビューからコントローラに移ることになります。controller.write_fragmentのソースを追ってみると、AbstractController::Caching::Fragmentsにあるのがわかります。

メインのロジック:  AbstractController::Caching::Fragments

このモジュールが提供する本質的なキャッシュ操作はread_fragmentwrite_fragmentexpire_fragmentの3つです。

  # actionpack/lib/abstract_controller/caching/fragments.rb
  # (instrumentation関連のコードは省略)
  def write_fragment(key, content, options = nil)

    ......

    content = content.to_str
    cache_store.write(key, content, options) # <-ここでキャッシュの内容をキャッシュストアに書き込む
    content
  end

  def read_fragment(key, options = nil)
    ......
    result = cache_store.read(key, options) # <- ここでキャッシュの内容をキャッシュストアから読み出す
    result.respond_to?(:html_safe) ? result.html_safe : result
  end

  def expire_fragment(key, options = nil)
    ......
    if key.is_a?(Regexp)
      cache_store.delete_matched(key, options) # <- ここでキャッシュストアから複数のキャッシュコンテンツを削除する
    else
      cache_store.delete(key, options) # <- ここでキャッシュストアからキャッシュコンテンツを削除
    end 
  end

このモジュールは、ユーザーインターフェイス(cacheメソッド)とキャッシュストレージの中間に位置する抽象化レイヤと見なすこともできます。このモジュールがActionController::Baseによってincludeされることで、コントローラ内でこれらのメソッドをすべて呼び出せるようになります。

根幹の部分: ActiveSupport::Cache::Store

次はActiveSupport::Cache::Storeというコンポーネントです。ここでは、redismemcachefileなどさまざまなキャッシュストレージの汎用的なインターフェイスを定義しています。以下を実行してみるとわかります。

# Rails 5.2.0
ActiveSupport::Cache::Store.descendants
#=> [ActiveSupport::Cache::Strategy::LocalCache::LocalStore,
# ActiveSupport::Cache::FileStore,
# ActiveSupport::Cache::MemoryStore,
# ActiveSupport::Cache::RedisStore]

なお一部のクラスについては、対応するgemがインストールされていないとRailsで読み込めないため表示されません(例: ActiveSupport::Cache::MemCacheStoreの読み込みにはdalli gemのインストールが必要)。

readwritefetchdeleteといったインターフェイスがよく使われますが、すべてを解説すると煩雑なので、ここではreadメソッドのみを例示するにとどめます。

  # activesupport/lib/active_support/cache.rb
  # (instrumentation関連のコードは省略)
  def read(name, options = nil)
    options = merged_options(options)
    key     = normalize_key(name, options)
    version = normalize_version(name, options)

      entry = read_entry(key, options)

      if entry
        if entry.expired?
          delete_entry(key, options)
          nil
        elsif entry.mismatched?(version)
          nil
        else
          entry.value
        end
      else
        nil
      end
    end
  end

上のread_entrydelete_entry(他にwrite_entry)メソッドは、各キャッシュストレージクラスでの実装に必要なインターフェイスです。

# activesupport/lib/active_support/cache.rb
def read_entry(key, options)
  raise NotImplementedError.new
end

def write_entry(key, entry, options)
  raise NotImplementedError.new
end

まとめ

本記事でコードを追ったことで、Railsを支えるフラグメントキャッシュの基本的な理解にお役に立てばと思います。お気づきの点やご意見がありましたらぜひ原文にコメントをお寄せください。このような記事をもっとお読みになりたい方もぜひお知らせください。がんばって書きますので。

関連記事

インタビュー: 超高速リアルタイム検索APIサービス「Algolia」の作者が語る高速化の秘訣(翻訳)

Rails: RedisキャッシュとRackミドルウェアでパフォーマンスを改善(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ