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.