This is the first post of the Disassembling Rails series. What I want to do is to give you a general idea about how Rails' components interact with each other when you use its functionalities (like fragment caching). However, this is the first time I write this kind of article, so please leave a comment if you have any advice about it.


I believe most of you know or have used Rails’ fragment caching. But you probably don’t know how it works. So today I’m going to give you a short introduction about how does fragment caching work underneath (on Rails 5.2).

Let’s first see what components (classes/modules) are involved when performing fragment caching:

  • ActionView::Helpers::CacheHelper
  • AbstractController::Caching::Fragments
  • ActiveSupport::Cache::Store

And of course I didn’t list all modules/classes here, just to give you a general idea.

User Interface

Fragment caching’s usage is very simple, just call the cache method like:

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

And this cache method is from a view helper called ActionView::Helpers::CacheHelper

# actionview/lib/action_view/helpers/cache_helper.rb
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
endendend

It mainly does two things

  • generate a caching key with cache_fragment_name
  • use fragment_for to cache the block

Let’s see what fragment_for does

# 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

It’s job is very simple: read cache content if it there’s any, otherwise write the content. And besides read/write cache content, it also reports the cache hit result to template renderer:

# 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

This is how Rails know if the cache is hit or not. And in order to read/write the cache content, fragment_for uses read_fragment_for and write_fragment_for to do the job:

# 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

As you can see they eventually calls read_fragment and write_fragment on a variable named controller. And your guess is right, the controller is our Rails controller that serves current request. So now the subject becomes the controller instead of view. If you trace the source of controller.write_fragment then you'll find it's located in AbstractController::Caching::Fragments.

Main Logic — AbstractController::Caching::Fragments

This module provides three essential actions for dealing with cache: read, write and expire

  # actionpack/lib/abstract_controller/caching/fragments.rb
  # instrumentation code are removed to reduce the content
def write_fragment(key, content, options = nil)

......

content = content.to_str
cache_store.write(key, content, options) # <- write cache content to cache store
content
end

def read_fragment(key, options = nil)
    ......
    result = cache_store.read(key, options) # <- read cache content from cache store
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) # <- delete multiple cache contents from cache store
else
cache_store.delete(key, options) # <- delete cache content from cache store
end
end

We can think this module as an abstraction layer between user interface (the cache method) and the cache storage. It'll be included by ActionController::Base so you can call all these methods inside your controllers.

Fundamental Part — ActiveSupport::Cache::Store

Next component I’m going to talk about is ActiveSupport::Cache::Store. It defines the universal interface for different cache storage like redis, memcache, file..etc. You can see them by running:

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

Note that it won’t list some classes unless you installed corresponding gem because Rails won’t be able to load the them (i.e. you need to install dalli to load ActiveSupport::Cache::MemCacheStore)

Some of the commonly used interfaces are read, write, fetch and delete. But I am not going to show each of them's implementation cause it'll be too messy. Instead I'm going to use read method as example:

  # activesupport/lib/active_support/cache.rb
  # instrumentation code are removed to reduce the content
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

The read_entry, delete_entry (and write_entry) methods here are the interface that each cache storage class needs to implement

# 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

Summary

I hope above walk through gives a basic understanding about how the fragment caching works underneath. Please leave a comment if you have any advice or feedback. And if you want to see more post like this one, please let me know as well, so I won’t have any excuse to be lazy.