Ruby on Rails Advanced Page Caching
Posted by Max Dunn Sat, 16 Sep 2006 20:02:00 GMT
Ruby on Rails (RoR or Rails) has some cool caching mechanisms already built in. You can cache parts of pages (fragment caching) or whole pages (action caching) while still going into your Rails code to check for permissions or do other things. However, for the highest performance, you can use page caching which serves the web page without even starting Rails, making it incredibly fast. For instance, on my slow computer, doing action caching was 2 times faster than normal, but doing page caching was 30 times faster! (See Ruby on Rails Caching Benchmarks).
However, the problem I ran into was that when you are logged into my wiki as an editor, you see an “Edit” link on each section of the page, and if you are logged in as an admin, you see an “Admin” choice on the menu. So while the Rails action caching would work fine for this, I couldn’t use the Rails out-of-the-box page caching since if I was logged in as an Editor and browsed through the pages, the next person that looked at the pages would still see all the “Edit” links, even if they were not logged in!
After some thought, I came up with two solutions for solving this problem. The first was based on rewriting the URL depending on what role the user is logged in as. I then came up with a better method that relies on setting a cookie with the current role and then using Javascript to modify the page appropriately. Here are the details on both methods.
URL Rewriting
The first solution for Rails advanced page caching is based on rewriting the URL depending on what role the user was logged in with.
The basic steps in this method are:
- Add a route in routes.rb to include the role in the URL
- Pass the role to all url_for calls
- In a before_filter in application.rb, redirect to the correct page based on the current role
- When expiring a page, expire all the roles for that page
- Turn on page caching and sweeping
To be able to cache pages that are different based on what role the use is logged in as, the routing rules are rewritten so that the role is included in the URL. For instance, here is how the URLs will look:
http://www.maxdunn.com/Ruby+on+Rails – For public accesshttp://www.maxdunn.com/_role/editor/Ruby+on+Rails – For editor roleshttp://www.maxdunn.com/_role/admin/Ruby+on+Rails – For admin roles
First, you need to add a “_role” route to routes.rb, something like this:
DEFAULT_WEB = 'wiki' unless defined?(DEFAULT_WEB)
ActionController::Routing::Routes.draw do |map|
map.connect 'rss_with_headlines', :controller => 'wiki',
:web => DEFAULT_WEB, :action => 'rss_with_headlines'
map.connect 'rss_with_content', :controller => 'wiki',
:web => DEFAULT_WEB, :action => 'rss_with_content'
map.connect '_role/:role/:id', :controller => 'wiki',
:web => DEFAULT_WEB, :action => 'show'
map.connect '', :controller => 'wiki',
:web => DEFAULT_WEB, :action => 'show', :id => 'HomePage'
map.connect 'index.htm', :controller => 'wiki',
:web => DEFAULT_WEB, :action => 'show', :id => 'HomePage'
map.connect ':id', :controller => 'wiki',
:web => DEFAULT_WEB, :action => 'show'
map.connect '_edit/:id', :controller => 'wiki',
:web => DEFAULT_WEB, :action => 'edit'
map.connect '_action/:controller/:action/:id',
:web => DEFAULT_WEB
end
(For more information about this routing table, see Ruby on Rails Better Wiki URLs
Simple enough? Now the trick is that we want this route to be used if there is a role set, but otherwise use the other routes. So we need to pass in a role name to all procedures that create URLs, something like this:
href = @controller.url_for :controller => 'wiki', :web => web_address,
:action => 'show', :id => name, :role => @controller.session_role
%{<a class="existingWikiWord" href="#{href}">#{text}</a>}
Next we need to make sure that the correct pages are appearing for the users current role. This is done in a before_filter in application.rb with code like this:
if (params[:action] == 'show') and (session_role != params[:role])
redirect_to :role => session_role, :id => params[:id]
end
Then when a page changes, we need to expire that page in all roles:
def expire_page_in_all_roles(web, page_name)
([nil] + MY_CONFIG[:roles]).each do |role|
expire_one_page(web, page_name, role)
end
end
Finally, turn on page caching and sweeping in your wiki controller:
caches_page :show, :published, :authors, :recently_revised, :list
cache_sweeper :revision_sweeper
There are several limitations with this method:
- It is not secure. A user could change the URL to access a cached page under a different role. (For my application, this is not an issue since this would just show links and if they clicked on the link, they would still have to login.)
- If a user bookmarks a page when they are logged in, if they log out and then use the bookmark to go back to the site, the wrong page will be shown
- The URLS are longer than normal if you are logged in
- Caching is not as efficient since a new page needs to be generated for each role
I have left out quite a few details so that this post wouldn’t get too long. However, if anyone is interested in this method and would like more information, please feel free to contact me.
Javascript and Cookies
The previous method worked fine, but I didn’t like the long URLs. I wanted a method where the page URL would be the same for all roles, but where I could still use Rails page caching.
Here are the basic steps for this method:
- Create a cookie with the current role
- In each web page, include all information for all roles but wrap them in DIVs
- Use Javascript to read the role cookie and enable the corresponding DIVs
- Turn on page caching and sweeping
First, in a before_filter in application.rb set the cookie like this:
cookies['role'] = { :value => session_role, :expires => Time.utc(2030) }
Also call this whenever a user logins in or out.
Next, wrap the role specific code in DIVs like this:
<div class="role-editor" style="display:none">
<%= link_to('Edit',
{:web => @web.address, :controller => 'wiki', :action => 'edit', :id => edit_page.name},
{:class => 'edit-link', :name => 'edit'}) %>
</div>
In your main layout, add Javascript code like this in the HEAD section:
<script type="text/javascript">
window.onload = function() {
if (readCookie('role') == 'admin') {
showClass('role-admin')
showClass('role-editor')
} else if (readCookie('role') == 'editor') {
showClass('role-editor')
} else {
showClass('role-public')
}
}
</script>
And in application.js add this:
function readCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(';');
for(var i=0;i < ca.length;i++) {
var c = ca[i];
while (c.charAt(0)==' ') c = c.substring(1,c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
}
return null;
}
function showClass(name) {
allNodes = document.getElementsByClassName(name);
for(i = 0; i < allNodes.length; i++) {
Element.show(allNodes[i]);
}
}
Finally, turn on page caching and sweeping in your wiki controller:
caches_page :show, :published, :authors, :recently_revised, :list
cache_sweeper :revision_sweeper
There are several limitations with this method:
- Like this previous method, it is not secure since a user could look at the HTML code to see the links for different roles.
- There can sometimes be a flash as the role specific sections are shown
Overall, I like this method much better since it uses the same URL no matter what role the user currently has.
Hi, I found this interesting because I have done some work on conditional page cache use in one of my projects.
My method is similar to your second one, but it uses the cookie at the webserver level rather than in javascript, so public users get the cached page and logged in users get a Rails rendered page.
On ror have a post about caching web pages.
we use ajax to handle this. That way, you can page cache the main page, and use rails for the customized portions that need rails and access to the cookies. All you need to do is add a onload hook to the body page to load your customized bits
thanks
On a side note, it’s always a good idea to cache and minify your javascript!
Check out minify_cache to get a nice monkey patch to rails javascript_include_tag.