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.rbmodule 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.rbdef 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.rbdef 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.rbdef 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.rbdef 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.