Builder::RailsCache
Provides a convenient with_cache do ... end wrapper around caching Rails responses, and shared_examples for use in your controller tests.
Contents
[[TOC]]
Example controller method:
def index
# Cache the response across all users
json = with_cache do
# this code will only be called
# if the response is already cached,
# and the cache entry has not expired
@records = MyModelClass.where(....)
# The return value of the block will be
# what gets stored in the cache, so it's
# simplest to just return the generated JSON
@records.to_json
end
# render whatever we got back from the cache if it was a hit,
# or whatever the block generated if it was a cache miss
render json: json, status: :ok
end
Or to cache a different response for each user :
def index
# Cache the response separately for each user:
json = with_cache(user_id: current_user.id) do
@records = MyModelClass.where(....)
@records.to_json
end
render json: json, status: :ok
end
Installation
- Add the gem to your Gemfile:
gem 'builder-rails_cache'
- Install the gem
> bundle
NOTE: future versions will automate as much of the following steps as possible - but for now, this must be a manual process
Installing the gem into your application
- Include the module in your
ApplicationController
(this can either be the global BuilderBase::ApplicationController or just for one specific module if you prefer):
class ApplicationController < ActionController::Base
include Builder::RailsCache
end
- Enable caching locally in development mode, if
RAILS_CACHE_STOREis given
In config/environments/development.rb, you should see a block something like this, usually around line 18 or so:
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp', 'caching-dev.txt').exist?
config.cache_store = :memory_store
config.public_file_server.headers = {
'Cache-Control' => "public, max-age=#{2.days.to_i}"
}
else
config.action_controller.perform_caching = false
config.cache_store = :null_store
end
Add another elsif clause to that block, so that it reads:
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp', 'caching-dev.txt').exist?
config.cache_store = :memory_store
config.public_file_server.headers = {
'Cache-Control' => "public, max-age=#{2.days.to_i}"
}
# NEW LINES START HERE ----------------
elsif ENV['RAILS_CACHE_STORE'].present?
config.cache_store = ENV['RAILS_CACHE_STORE'].to_sym
# /END OF NEW LINES -------------------
else
config.action_controller.perform_caching = false
config.cache_store = :null_store
end
- Enable caching in production mode, if
RAILS_CACHE_STOREis given
Note: this code will enable caching in all deployed environments (dev, stage, uat, prod etc), but only if the RAILS_CACHE_STORE environment variable is present - so if you're cautious, you can push the code first, and then do a separate MR to provide the config which actually enables the cache in each environment, making it a simple job to revert it if anything goes wrong.
Add these lines to config/environment/production.rb, anywhere inside the Rails.application.configure do ... end block:
if ENV['RAILS_CACHE_STORE'].present?
config.cache_store = ENV['RAILS_CACHE_STORE'].to_sym
end
- Include the
Builder::RailsCachemodule in your ApplicationController:
module BuilderBase
class ApplicationController
include Builder::RailsCache
end
end
Usage
Adding response-caching to a controller method
For example, assume your controller method looks like this:
def index
# Slow query:
@records = MyModelClass.where(....)
# maybe some other code is here too
# ultimately a response is rendered as JSON:
render json: @records.as_json, status: :ok
end
You can add a with_cache block around everything involved in generating the JSON - constructing
queries, retrieving the data, calling any serializers, etc.
NOTE - The Rails cache ultimately stores strings in Redis (or whatever other cache store you my have configured) So you'll need to split the conversion of the result to JSON, from the rendering of the result back to the caller, like this:
def index
json = with_cache do
@records = MyModelClass.where(....)
@records.to_json
end
render json: json, status: :ok
end
Options / parameters
with_cache accepts the following parameters, all are optional:
:user_id
This value will form the first part of the cache key
Caching a user-specific response for each user
Pass the current users' ID (e.g. current_user.id) to cache a different value for each user.
It's important to do this if anything in the code inside the block (including within any serializers called, etc) references the current user
Re-using the same cached value across all users Pass nil (or just don't supply the parameter, as nil is the default) to cache the same value across all users
:cache_time
Determines expiry / TTL of the cache entry. If an entry is in the cache, but older than this value, it will be treated as if there is no cache entry there at all.
Default
If no value is given, it will use the value of ENV['RAILS_CACHE_TIMEOUT_SECONDS']. If that environment
variable is not defined, or not parseable to an integer, it will default to zero - i.e. effectively disabling
the caching
Specifying a number of seconds If the value is an integer, it will use that as the number of seconds for which the cache entry will be considered valid.
Using a particular environment variable
If the value is a string or symbol, it will append that string (uppercased) to RAILS_CACHE_TIMEOUT_SECONDS and look
for an environment variable with that name to use.
For example, if you pass :short or :long this will use the value of the ENV['RAILS_CACHE_TIMEOUT_SECONDS_SHORT'] or ENV['RAILS_CACHE_TIMEOUT_SECONDS_LONG)'] env vars - letting you have different cache timeouts for different methods.
:headers_to_include
An array of header names, the values of which will be included in the key.
This might be useful if your application uses, for instance, a language header to render different strings
:key
If a value is given, it will use that value as the cache key. If no value is given (default), it will construct a key using a combination of :
- the given
user_id(optional) - the full request URL including all query parameters
- any headers specified in the given
headers_to_include(optional).
If user_id is NOT given, then the same cache key will be used for all users (assuming they pass the same parameters and have the same values of the named headers), so make sure you consider this when introducing caching.
Error handling / returning non-200 status
The contents of the block are not called at all if there's a cache hit - so you may need to put a little thought into how to handle any non-OK status that you might want to return.
For example, this code -
def show
@record = MyClass.find(params[:id])
render json: @record.to_json, status: :ok
end
- has a potential edge case in the
.findcall. It will raise anActiveRecord::RecordNotFoundexception if the ID doesn't exist, which usually gets caught elsewhere and a 404 status returned. For this case, an exception will still bubble out of the block and be handled in the normal way, so this code should not need modifying.
But if your controller method decides on response status without raising an exception, modification may be needed. For example:
def show
@record = MyClass.find(params[:id])
if @record.group_id != current_user.group_id
render json: { errors: ["You don't have permission to read that record"] }.to_json, status: :forbidden
else
render json: @record.to_json, status: :ok
end
end
This code may need a slight alteration to handle this case, something like this:
def show
result = with_cache do
@record = MyClass.find(params[:id])
if @record.group_id != current_user.group_id
'forbidden'
else
@record.to_json
end
end
if result == 'forbidden'
render json: { errors: ["You don't have permission to read that record"] }.to_json, status: :forbidden
else
render json: result, status: :ok
end
end
Testing caching behaviour
Shared examples
The gem provides three shared examples, which you can add to your controller tests as follows:
Assuming your controller method looks like the example above, you can use these shared examples in your specs like this:
describe 'GET :index' do
# define the object & method which your caching block
# surrounds:
let(:object_with_cache_miss_method) { MyModelClass }
let(:method_called_on_cache_miss) { :where }
# two user accounts, so that it can test the caching behaviour
# across different users accessing the same methods
let(:account_1) { ...user account object 1... }
let(:account_2) { ...user account object 2... }
# test the universal cache behaviour
it_behaves_like 'a cached method'
# ...and EITHER
it_behaves_like 'it has a different cache entry for each user'
# OR
it_behaves_like 'it has the same cache entry for all users'
end
That will test for standard caching behaviour, using an expectation like:
expect(object_with_cache_miss_method).to receive(:method_called_on_cache_miss)
to signal a cache miss, and:
expect(object_with_cache_miss_method).not_to receive(:method_called_on_cache_miss)
to signal a cache hit.
You should ultimately get results that look something like this:
behaves like a cached method
caching
when the cache_timeout is more than zero
hitting the url twice for the same user with the same params
does not calls the code surrounded by with_cache the second time
returns the same response each time
after the cache expiry
calls the code surrounded by with_cache both times
hitting the url twice for the same user with different params
calls the code surrounded by with_cache both times
behaves like it has a different cache entry for each user
hitting the url twice for two different users with the same params
calls the code surrounded by with_cache both times
Development
After checking out the repo, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on Builder's GitLab at https://gitlab.builder.ai/cte/alistair-davidson/builder-rails_cache.