#89 Page Caching (revised)
- Download:
- source codeProject Files in Zip (91.4 KB)
- mp4Full Size H.264 Video (21.1 MB)
- m4vSmaller H.264 Video (11 MB)
- webmFull Size VP8 Video (13.9 MB)
- ogvFull Size Theora Video (24.5 MB)
Page caching is one of the most efficient forms of caching in a Rails app. Once a page has been cached future requests don’t need to hit the application at all as the entire content of the page is cached into a file which is then served by a front-end web server. To demonstrate this we’re going to add page caching to a page that displays a list of products.
Caching is turned off by default in the development environment so if we want to experiment with it we first need to enable it in the development config file by setting perform_caching
to true
.
config.action_controller.perform_caching = true
We’ll need to restart the server for this change to take effect.
Now we can add page caching to the actions we want to cache. This is easy to do, we just need to modify the ProductsController
so that it calls caches_page
and specify the actions that we want to cache, in this case index
and show
.
class ProductsController < ApplicationController caches_page :index, :show def index @products = Product.page(params[:page]).per_page(10) end def show @product = Product.find(params[:id]) end # Other actions omitted. end
The first time we reload the products page now it will be served by the app and also written to a file. Each subsequent reload the page will be served from the cache file and this also applies to the page for a single product.
By default the files are cached in the application’s /public
directory. If we look in there we’ll see a products.html
file that contains the full response for the /products
page while in a products
directory we’ll find a file called 1.html
which contains the response for the single product we’ve viewed.
This public directory is set up to serve static files by a front-end web server such as Apache or Nginx so these responses will be nice and fast.
Handling Parameters
One issue we may have when using caching like this is that any URL parameters we pass to the page are ignored. We have pagination on our index
page but if we try to visit any other page of products but the first we’ll see the first page again, even though the page parameter is now set to 2
.
To get around this we’ll need to include the page as part of the URL path instead of as a query parameter so that the URL for these pages is of the form http://localhost:3000/products/page/<page_number>
. We’ll make this change by adding a new route at the top of the routes file so that all routes that match this URL path are routed to the ProductsController
’s index
action.
Store::Application.routes.draw do get 'products/page/:page', to: 'products#index' root to: 'products#index' resources :products end
It’s important that this route is above resources :products
so that it takes precedence for these URLs. We’ll also need to remove the public/products.html
file so that a new one is generated with the proper URLs embedded in it for the other pages. When we visit the products page now and click one of the pagination links the correct page is shown and each individual page is cached separately.
Cache Expiration
If the dynamic content of the cache changes, let’s say that we rename one of the products, we’ll need to flush the cache other wise this change won’t be shown. One way to expire a cache is to do it in the controller when the product changes. We’ll modify the update
action so that the cache is expired when the product is saved. We do this by calling expire_page
and we can pass this either a hash of options, like we would to url_for
, or a path. We’ll use a path.
def update @product = Product.find(params[:id]) if @product.update_attributes(params[:product]) expire_page products_path expire_page product_path(@product) redirect_to products_url, notice: "Successfully updated product." else render :edit end end
This will expire both the index and show pages. If we edit “Settlers of Catan” and change it to “Settlers of Catan 2” the cache will be expired and we’ll see the new name on the index page.
The page for that product will also be updated when we visit it so both caches have been successfully flushed. We still have a few issues with this, however. If we refresh the index page the flash message is still visible as it’s included in the new version of the cached page. No matter how many times we reload this page this message will still show. We’ll discuss a way of fixing this later. Another issue is that there are a few caches lying around that weren’t flushed, for example the pagination pages. If we visit the first page of products with the page id specified in the URL we’ll still see the product’s old name as the URL doesn’t match the simple /products
URL whose cache we have expired.
The root URL for this application also points to the products index page and this page won’t have been flushed either. We need to be careful to expire the cache for every URL that can show a product. For the pagination pages this can be difficult as we can’t know for sure which page a product will be on and these pages are all cached separately. In this case its easier to remove every file in the directory and we can use FileUtils.rm_rf
to do this. We use the page_cache_directory
variable to determine where the cache is as this directory is configurable.
def update @product = Product.find(params[:id]) if @product.update_attributes(params[:product]) expire_page products_path expire_page product_path(@product) expire_page "/" FileUtils.rm_rf "#{page_cache_directory}/products/page" redirect_to products_url, notice: "Successfully updated product." else render :edit end end
Now when we update a product all the relevant pages’ caches are flushed.
Expiring Caches via a Sweeper
We need to flush the cache in the create
and destroy
actions as these also change products. Instead of scattering the same few lines throughout the controller it’s better to move this code out into a sweeper. We’ll create a new sweeper and place it in a new /app/sweepers
directory.
class ProductSweeper < ActionController::Caching::Sweeper observe Product def after_update(product) expire_page products_path expire_page product_path(product) expire_page "/" FileUtils.rm_rf "#{page_cache_directory}/products/page" end end
A sweeper class needs to inherit from ActionController::Caching::Sweeper
. To use it we observe
a particular model and override various callback methods which are fired when an instance of that model changes. We’ve used after_update
here and we sweep the cache in it. The code inside this method is the same as we’ve used in the method with just the @product
instance variable being replaced with the product variable that’s passed to the method. We want this behaviour to be applied when a product is created or destroyed, too so we’ll rename our method to sweep and use alias_method
so that it’s fired when a product is created, updated or destroyed.
class ProductSweeper < ActionController::Caching::Sweeper observe Product def sweep(product) expire_page products_path expire_page product_path(product) expire_page "/" FileUtils.rm_rf "#{page_cache_directory}/products/page" end alias_method :after_update, :sweep alias_method :after_create, :sweep alias_method :after_destroy, :sweep end
We’ll need to call cache_sweeper
in the controller so that our sweeper is fired when a product is created, updated or destroyed. We can now also remove the cache expiry code from the controller.
class ProductsController < ApplicationController caches_page :index, :show cache_sweeper :product_sweeper # Other methods omitted. def update @product = Product.find(params[:id]) if @product.update_attributes(params[:product]) redirect_to products_url, notice: "Successfully updated product." else render :edit end end end
We’ll have to restart the server for the sweeper to be picked up. Once we have we can update a product and all of the relevant pages will have their caches expired.
Fixing The Flash Mesage
There’s one more issue we still need to fix. When we update a product the flash message is stored in the cached version of the index page as so it’s shown every time we visit the products page afterwards, whether we’re updating a product or not. The flash messages are created in the application’s layout file and we need a way to hide these when we’re caching the page.
<!DOCTYPE html> <html> <head> <title>Store</title> <%= stylesheet_link_tag "application", media: "all" %> <%= javascript_include_tag "application" %> <%= csrf_meta_tag %> </head> <body> <div id="container"> <% flash.each do |name, msg| %> <%= content_tag :div, msg, id: "flash_#{name}" %> <% end %> <%= yield %> <div class="clear"></div> </div> </body> </html>
Unfortunately there seems to be no way to detect whether or not page caching is enabled so what we’ll do is add a before_filter
to the ProductsController
that sets an instance variable for the actions that cache their output so that we can detect this in the layout file.
before_filter(only: [:index, :show]) { @page_caching = true }
We’ll use this variable to hide the flash message (and also the csrf_meta_tag
) from the page when its value is true
.
<!DOCTYPE html> <html> <head> <title>Store</title> <%= stylesheet_link_tag "application", media: "all" %> <%= javascript_include_tag "application" %> <%= csrf_meta_tag unless @page_caching %> </head> <body> <div id="container"> <% unless @page_caching %> <% flash.each do |name, msg| %> <%= content_tag :div, msg, id: "flash_#{name}" %> <% end %> <% end %> <%= yield %> <div class="clear"></div> </div> </body> </html>
If we update a product now no flash message is shown and there’s no csrf_meta_tag
in the source. There are now both successfully hidden on pages that are cached.
There may be times when we do want to include some dynamic user-specific content on a page that’s cached, such as a flash message or a logged-in user’s status. This is possible with JavaScript and there’s more information about this in episode 169, although this episode may be revised soon.