As mentionned in my previous post, I’ve been using conditional requests in a Ruby on Rails app.
The principle of conditional requests is that when providing a resource, the
server will add cache control headers such as an ETag (an identifier of
a version of the resource) in the ETag
header, or the last
modification date of the resource in the Last-Modified
header.
When the client sends its next request, it can send the ETag and the last
modification date in the If-None-Match
header and the
If-Modified-Since
header. The server will then
compare them to the latest values. If they match, the client has fresh data and
thus the server can just send a 304 Not Modified response with no content,
saving bandwidth. If the headers do not match, the server will send a normal
response with updated cache control information.
Note that there are other cache control parameters. Read the HTTP GET method definition to learn more about them.
This post describes how I set up conditional requests in a Rails app, using Redis as a cache to speed up things even more by avoiding a costly hit on my SQL database.
Setting up Redis in a Rails app
# Install on Fedora 17
sudo yum install redis
sudo systemctl enable redis.service
sudo systemctl start redis.service
# Install on OS X 10.8 with MacPorts
sudo port install redis
sudo port load redis
Add the following to your Gemfile and run bundle install
:
# Client library for Redis.
gem 'redis'
# C extension for speed (optional).
gem 'hiredis'
# To namespace keys in Redis (optional).
gem 'redis-namespace'
Here’s an initializer for Redis inspired by the one from Resque. It
will load its configuration from config/redis.yml
:
# File: config/initializers/redis.rb
rails_root = Rails.root || File.dirname(__FILE__) + '/../..'
rails_env = Rails.env || 'development'
config = YAML.load_file "#{rails_root}/config/redis.yml"
config = config[rails_env]
host, port, db = config.split /:/
# The Redis connection.
# You can add "logger: Rails.logger" as an option
# if you want Redis operations logged like Active Record's.
$redis_db = Redis.new(
host: host,
port: port,
db: db.to_i,
driver: :hiredis
)
# Wrap the connection in a namespace (if useful for you).
$redis = Redis::Namespace.new 'myapp', redis: $redis_db
This is how the configuration file looks. Each line represents environment:
host:port:db
.
# File: config/redis.yml
development: localhost:6379:0
test: localhost:6379:1
production: localhost:6379:0
Now you may connect to Redis on the command line with redis-cli
, or use
$redis
in your application. The redis-rb gem pretty much uses the
same commands as Redis.
Caching Contents and Cache Control Parameters
Now let’s say I have a Rails action that looks like this.
class PeopleController < ActionController::Base
def show
person = Person.where(id: params[:id])
.includes(:lots, :of, :associated, :data)
.first
render :json => person.to_json
end
end
The goal is to avoid loading the person and its associated data from the SQL database every time if it hasn’t been modified. We want to store the resulting JSON and the last modification date in Redis so that we can quickly return a cached version. We will also generate an ETag by hashing the JSON.
For the sake of example, I added the caching methods to the Person
class. This
could be generalized for any active record model.
require 'digest/sha2'
class Person
after_save :clear_cache
# Returns the cache for the person with the given ID.
def self.cache id
# Return existing cached data.
cache = load_from_cache id
return cache if cache.present?
# Or load the data, then cache and return it.
person = self.where(id: params[:id])
.includes(:lots, :of, :associated, :data)
.first
person.save_to_cache
end
def save_to_cache
# Build cache data.
json = self.to_json
etag = Digest::SHA2.hexdigest json
# Store it in a Redis hash.
$redis.hmset(
cache_key,
:json, json,
:updated_at, updated_at.to_i,
:etag, etag
)
# For simplicity, we use the same
# return format as load_from_cache.
{
'json' => json,
'updated_at' => updated_at.to_i,
'etag' => etag
}
end
private
def self.load_from_cache id
# Return the Redis hash.
# This will return an empty hash if no data is cached.
$redis.hgetall cache_key(id)
end
def self.cache_key id
"#{self.name.underscore}:#{id}" # => "person:42"
end
def clear_cache
$redis.del cache_key
end
def cache_key
self.class.cache_key id
end
end
Conditional Request
Rails supports conditional requests out of the box with the stale?
method. It
will set the ETag and last modified headers on the response and check them
against the request headers. If the headers don’t match, the response should be
generated from scratch, otherwise Rails will automatically return a 304 Not
Modified.
class PeopleController < ActionController::Base
def show
cache = Person.cache params[:id]
# Parse the cache data.
updated_at = Time.at(cache['updated_at'].to_i).utc
etag = cache['etag']
# Perform the conditional check.
if stale? last_modified: updated_at, etag: etag
# The request ETag or last modified date
# doesn't match what we have. The client
# cache is stale. Send the JSON with updated
# headers.
render :json => cache['json']
end
# The request headers match.
# A 304 Not Modified will be sent.
end
end
jQuery Client
And that’s my periodic call from the browser. On the first request, jQuery will automatically get the ETag and last modified date from the server and send them for the next requests. Nothing to do here.
function pollPerson() {
$.ajax({
url : '/people/42',
dataType : 'json',
// The "ifModified" option makes it so that
// the done callback is only called if the
// response is not a 304 Not Modified.
ifModified : true
}).done(function(response) {
doStuffWith(response);
});
}
// Poll every 30 seconds.
setInterval(pollPerson, 30000);
Cache Auto-Expiration
If you prefer to expire your cache after a certain time rather than with or in
addition to the after_save
callback, you can tell Redis to do that:
# Wrap it in a multi block for faster execution.
$redis.multi do
$redis.hmset(
cache_key,
:json, json,
:updated_at, updated_at.to_i,
:etag, etag
)
$redis.expire cache_key, 1.day.to_i
end
Go cache in peace.