By Splitwise
Cacheable is a gem which adds method caching in Ruby following an aspect-oriented programming (AOP) paradigm. Its core goals are:
- ease of use (method annotation)
- flexibility (simple adaptability for any cache backend)
- portability (plain Ruby for use with any framework)
While using Ruby on Rails is not a requirement, Cacheable was built inside a mature Rails app and later extracted. The current release is designed for drop-in support in Rails, and includes an adapter for an in-memory cache backed by a simple hash. This may be enough for your needs, but it's more likely that additional cache adapters will need to be written for other projects.
See more about Cache Adapters.
Add it to your Gemfile:
gem 'cacheable'
Set your cache adapter
# If you're in a Rails app place the following in config/initializers/cacheable.rb
Cacheable.cache_adapter = Rails.cache
# Otherwise you can specify the name of the adapter anywhere before you use it
Cacheable.cache_adapter = :memory
Cacheable is designed to work seamlessly with your already existing codebase. Consider the following example where we fetch the star count for Cacheable from GitHub's API. Feel free to copy/paste it into your IRB console or use the code in examples/simple_example.rb
.
require 'json'
require 'net/http'
class GitHubApiAdapter
def star_count
puts 'Fetching data from GitHub'
url = 'https://api.github.com/repos/splitwise/cacheable'
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
end
end
To cache this method and its result, simply add the following:
# From examples/simple_example.rb
require 'cacheable' # this may not be necessary depending on your autoloading system
require 'json'
require 'net/http'
class GitHubApiAdapter
include Cacheable
cacheable :star_count
def star_count
puts 'Fetching data from GitHub'
url = 'https://api.github.com/repos/splitwise/cacheable'
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
end
end
That's it! There's some complex Ruby magic going on under the hood but to the end user you can simply call star_count
and the result will be retrieved from the cache, if available, or fetched from the network and placed into the cache. To confirm it is working, fire up an IRB console try the following:
> a = GitHubApiAdapter.new
> a.star_count
Fetching data from GitHub
=> 19
> a.star_count
=> 19
# Notice that "Fetching data from GitHub" was not output the 2nd time the method was invoked.
# The network call and result parsing would also not be performed again.
Cacheable also adds two useful methods to your class.
The cache can intentionally be skipped by appending _without_cache
to the method name. This invocation will neither check the cache nor populate it. It is as if you called the original method and never used Cacheable.
> a = GitHubApiAdapter.new
> a.star_count
Fetching data from GitHub
=> 19
> a.star_count_without_cache
Fetching data from GitHub
=> 19
> a.star_count
=> 19
The cached value can be cleared at any time by calling clear_#{your_method_name}_cache
.
> a = GitHubApiAdapter.new
> a.star_count
Fetching data from GitHub
=> 19
> a.star_count
=> 19
> a.clear_star_count_cache
=> true
> a.star_count
Fetching data from GitHub
=> 19
By default, Cacheable will construct key a key in the format [cache_key || class_name, method_name]
without using method arguments.
If the object responds to cache_key
its return value will be the first element in the array. ActiveRecord
provides cache_key
but it can be added to any Ruby object or overwritten. If the object does not respond to it, the name of the class will be used instead. The second element will be the name of the method as a symbol.
It is up to the cache adapter what to do with this array. For example, Rails will turn [SomeClass, :some_method]
into "SomeClass/some_method"
. For more information see the documentation on Cache Adapters
If (re)defining cache_key
does not provide enough flexibility, you can pass a proc to the key_format:
option of cacheable
.
# From examples/custom_key_example.rb
require 'cacheable'
require 'json'
require 'net/http'
class GitHubApiAdapter
include Cacheable
cacheable :star_count, key_format: ->(target, method_name, method_args) do
[target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/')
end
def star_count(repo)
puts "Fetching data from GitHub for #{repo}"
url = "https://api.github.com/repos/splitwise/#{repo}"
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
end
end
target
is the object the method is being called on (#<GitHubApiAdapter:0x0…0>
)method_name
is the name of the method being cached (:star_count
)method_args
is an array of arguments being passed to the method ([params]
)
Including the method argument(s) allows you to cache different calls to the same method. Without the arguments in the cache key, a call to star_count('cacheable')
would populate the cache and star_count('tokenautocomplete')
would return the number of stars for Cacheable instead of what you want.
In addition, we're including the current date in the cache key so calling this method tomorrow will return an updated value.
> a = GitHubApiAdapter.new
> a.star_count('cacheable')
Fetching data from GitHub for cacheable
=> 19
> a.star_count('cacheable')
=> 19
> a.star_count('tokenautocomplete')
Fetching data from GitHub for tokenautocomplete
=> 1164
> a.star_count('tokenautocomplete')
=> 1164
# In this example the follow cache keys are generated:
# GitHubApiAdapter/star_count/cacheable/2018-09-21
# GitHubApiAdapter/star_count/tokenautocomplete/2018-09-21
You can control if a method should be cached by supplying a proc to the unless:
option which will get the same arguments as key_format:
. This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. Note: When using a symbol, the first argument, target
, will not be passed but will be available as self
.
# From examples/conditional_example.rb
require 'cacheable'
require 'json'
require 'net/http'
class GitHubApiAdapter
include Cacheable
cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do
[target.class, method_name, method_args.first].join('/')
end
def star_count(repo)
puts "Fetching data from GitHub for #{repo}"
url = "https://api.github.com/repos/splitwise/#{repo}"
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
end
def growing_fast?(_method_name, method_args)
method_args.first == 'cacheable'
end
end
Cacheable is new so we don't want to cache the number of stars it has as we expect it to change quickly.
> a = GitHubApiAdapter.new
> a.star_count('tokenautocomplete')
Fetching data from GitHub for tokenautocomplete
=> 1164
a.star_count('tokenautocomplete')
=> 1164
> a.star_count('cacheable')
Fetching data from GitHub for cacheable
=> 19
> a.star_count('cacheable')
Fetching data from GitHub for cacheable
=> 19
If your cache backend supports options, you can pass them as the cache_options:
option. This will be passed through untouched to the cache's fetch
method.
cacheable :with_options, cache_options: {expires_in: 3_600}
You can use the same options with multiple cache methods or limit them only to specific methods:
cacheable :these, :methods, :share, :options, key_format: key_proc, unless: unless_proc
cacheable :this_method_has_its_own_options, unless: unless_proc2
You can cache static (class) methods as well by including Cacheable in your class' eigenclass. This is because all Ruby classes are instances of the Class
class. Understanding how Ruby's class structure works is powerful and useful, however, further explanation is beyond the scope of this README and not necessary to proceed.
Simply put include Cacheable
and the cacheable
directive within a class << self
block as in the example below. The methods you want to cache can be defined in this block or outside using the def self.#{method_name}
syntax.
# From examples/class_method_example.rb
require 'cacheable'
require 'json'
require 'net/http'
class GitHubApiAdapter
class << self
include Cacheable
cacheable :star_count_for_cacheable, :star_count_for_tokenautocomplete
def star_count_for_cacheable
star_count('cacheable')
end
private
def star_count(repo)
puts "Fetching data from GitHub for #{repo}"
url = "https://api.github.com/repos/splitwise/#{repo}"
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
end
end
def self.star_count_for_tokenautocomplete
star_count('tokenautocomplete')
end
end
> GitHubApiAdapter.star_count_for_cacheable
Fetching data from GitHub for cacheable
=> 19
> GitHubApiAdapter.star_count_for_cacheable
=> 19
> GitHubApiAdapter.star_count_for_tokenautocomplete
Fetching data from GitHub for tokenautocomplete
=> 1164
> GitHubApiAdapter.star_count_for_tokenautocomplete
=> 1164
- Q: How does Cacheable handle cache invalidation?
- A: Cacheable takes Rails' cue and sidesteps the difficult problem of cache invalidation in favor of key-based expiration. As DHH mentions in the blog post,
ActiveRecord
'scache_key
uses theupdated_at
timestamp so the cache is recalculated as the object changes. This results in new cache values being calculated, and your cache implementation can be configured to expire least recently used (LRU) values. In other applications, care must be taken to include a mechanism of key-based expiration in thecache_key
method orkey_format
proc or you risk serving stale data. Alternatively the generated cache clearing method can be used to explicitly invalidate the cache.