Page caching is a vital strategy to enhance the performance and responsiveness of web applications by serving cached HTML pages directly to users. In this blog post, we’ll explore how to implement page caching in a Rails application using Rails middleware and Redis as the caching backend.
I. Understanding Rails Middleware and Redis
Rails Middleware: Middleware in Rails sits between the web server and the Rails application, allowing you to intercept and manipulate requests and responses. It provides a flexible way to add functionality to the request-response cycle.
Redis: Redis is an open-source, in-memory data structure store known for its speed and versatility. It serves as an excellent caching backend due to its ability to store and retrieve data quickly.
II. Step-by-Step Implementation:
1. Setup Redis and Include Redis Gem
Begin by adding the redis-rails
gem to your Gemfile:
gem 'redis-rails'
gem 'redis-rack-cache'
Then, run bundle install
to install the gem and set up Redis as the caching backend.
2. Create Custom Middleware for Page Caching
Generate a new middleware using Rails generators:
rails generate middleware PageCaching
This will create a new middleware file app/middleware/page_caching.rb
. Implement the page caching logic within this middleware:
# app/middleware/rake_cache_csrf.rb
require 'nokogiri'
module Middleware
class RakeCacheCsrf
CSRF_PATH = '/site/csrf_meta'.freeze
SET_COOKIE_HEADER = 'Set-Cookie'.freeze
CONTENT_LENGTH_HEADER = 'Content-Length'.freeze
RACK_CACHE_HEADER = 'X-Rack-Cache'.freeze
REQUEST_METHOD = 'REQUEST_METHOD'.freeze
NON_CACHE_HEADER = 'max-age=0, private, must-revalidate'.freeze
REMOVE_HEADERS = %w(X-Rack-Cache Age).freeze
LOG_FORMAT = %{%s - %s [%s] "%s %s?%s %s" %s %s %0.4f: %s\n}
def initialize(app)
@app = app
@began_at = Time.now
end
# The Rack call interface. The receiver acts as a prototype and runs
# each request in a dup object unless the +rack.run_once+ variable is
# set in the environment.
def call(env)
if env['rack.run_once'] && !env['rack.multithread']
_call env
else
dup._call env
end
end
def _call(env)
csrf_app = @app.dup
original_env = env.dup
status, headers, body = @app.call(env)
headers['Cache-Control'] = NON_CACHE_HEADER if change_header?(env, status, headers)
rake_cache_header = headers[RACK_CACHE_HEADER]
REMOVE_HEADERS.each { |header| headers.delete(header) }
return [status, headers, body] unless change_token?(rake_cache_header)
headers.delete(SET_COOKIE_HEADER)
headers['Cache-Control'] = NON_CACHE_HEADER if status == 304
csrf_status, csrf_headers, csrf_body = fetch_token(csrf_app, original_env)
if csrf_status != 200 || csrf_body.blank?
log(env, status, headers, 'Load CSRF FAIL!')
rails_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
if rails_env == 'development'
return [csrf_status, csrf_headers, csrf_body]
else
return [status, headers, body]
end
end
token = read_body(csrf_body)
if csrf_headers.key?('Set-Cookie')
headers['Set-Cookie'] = csrf_headers['Set-Cookie']
end
original_body = body
body_length, new_body = inject_token(original_body, token)
body = Rack::BodyProxy.new(new_body) do
original_body.close if original_body.respond_to?(:close)
end
# Rack::Response has a Content-Length header set, ActionDispatch::Response doesn't
if headers[CONTENT_LENGTH_HEADER]
headers[CONTENT_LENGTH_HEADER] = body_length.to_s
end
return [status, headers, body]
rescue => e
log(env, status, headers, e.message)
csrf_body.close if defined?(csrf_body) && csrf_body.respond_to?(:close)
original_body.close if defined?(original_body) && original_body.respond_to?(:close)
return [status, headers, body]
end
private
def content_type?(headers)
headers.key?('Content-Type') \
&& headers['Content-Type'].include?('text/html')
end
def xhr?(env)
env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
end
def log(env, status, headers, message)
time_now = Time.now
req_logger.error LOG_FORMAT % [
env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-',
env['REMOTE_USER'] || '-',
time_now.strftime("%d/%b/%Y %H:%M:%S"),
env[REQUEST_METHOD],
env['PATH_INFO'],
env['QUERY_STRING'].empty? ? '-' : env['QUERY_STRING'],
env['HTTP_VERSION'],
status.to_s[0..3],
headers.present? ? (headers[CONTENT_LENGTH_HEADER].presence || '-') : '-',
time_now - @began_at,
message
]
end
def req_logger
@req_logger ||= ::Logger.new('log/caching_with_csrf.log', 'weekly')
end
def read_body(enumerable, buffer = '')
enumerable.each { |str| buffer << str }
buffer
end
def change_token?(cache_status)
return true if cache_status.include?('fresh')
false
end
def change_header?(env, status, headers)
(content_type?(headers) && !xhr?(env)) \
|| (status == 304 && headers[RACK_CACHE_HEADER].include?('miss'))
end
def inject_token(body, token)
body_length = 0
new_body = []
body.each do |part|
node_token = Nokogiri::HTML::Document.parse(part).at('[name="csrf-token"]')
if node_token
new_part = part.gsub(node_token.attr('content'), token)
new_body << new_part
body_length += new_part.bytesize
else
new_body << part
body_length += part.bytesize
end
end
[body_length, new_body]
end
def fetch_token(app_csrf, env)
request_url = ActionDispatch::Request.new(env).url
csrf_env = env.merge({
'PATH_INFO' => CSRF_PATH,
'REQUEST_PATH' => CSRF_PATH,
'REQUEST_URI' => CSRF_PATH,
'SCRIPT_NAME' => '',
'QUERY_STRING' => '',
'HTTP_X_SOURCE_URI' => env['REQUEST_URI'],
REQUEST_METHOD => 'GET'
})
csrf_env.delete('HTTP_ACCEPT_ENCODING')
_, _, csrf_body = response = app_csrf.call(csrf_env)
csrf_body.close if csrf_body.respond_to? :close # Ensure close connection
response
end
end
end
class SiteController < ActionController::Base
...
def csrf_meta
request_url = request.env['HTTP_X_SOURCE_URI']
http_ref = URI.parse(request_url)
http_params = parse_params(http_ref)
setup_cookie_session(request_url, http_params)
disable_cache_headers
render plain: form_authenticity_token
end
end
3. Configure Middleware in Rails Application
In your Rails application’s configuration (config/application.rb
), include the newly created middleware:
# app/config/application.rb
config.middleware.insert_before ::Rack::Cache, ::Middleware::RakeCacheCsrf
# evironment/production.rb
config.action_dispatch.rack_cache = {
default_ttl: 6.hours,
metastore: ENV['REDIS_METASTORE'],
entitystore: ENV['REDIS_ENTITYSTORE'],
use_native_ttl: true,
allow_reload: true,
ignore_headers: [], # Remove default ['Set-Cookie'] for setting on CachingHeaders
cache_key: lambda { |request|
::CacheHelper::BuildKey.new(request).generate
}
}
4. Start Redis Server and Run Rails Application
Before running your Rails application, ensure the Redis server is running:
redis-server
Then, start your Rails server:
rails server
IV. Conclusion
By leveraging Rails middleware and Redis, we’ve implemented a robust page caching solution for our Rails application. The middleware intercepts requests, checks if the page is cached in Redis, and serves it directly if available. Otherwise, it forwards the request to the Rails application for dynamic generation and caching. This approach significantly improves application performance and responsiveness, providing users with faster page load times and an enhanced browsing experience. Page caching with Rails middleware and Redis is a valuable technique for optimizing web applications and should be considered in performance optimization strategies.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.