Multilingual page caching in Ruby on Rails

There is plenty of stuff written about caching in Ruby on Rails. You can easily cache anything from objects returned by a query to fragments or even whole pages. Especially storing full pages (known as page caching) in a directory when Apache1 can grab them is really efficient. And there are quite decent tutorials about it.

Sadly, most of the tutorials assume that you live in a small, cozy, speak-english-or-die type of world and your only contact with foreign languages is when you order “pommes frites” at MacDonald’s.2 The rest of us, working on multilingual applications, might have a lot of problems with implementing page caching. Fortunately, it’s not that difficult. Read on to see for yourself.

The problem

The main problem is that default Rails caching mechanism tries to map page URL directly to a path on the disk. This works in most of the cases, of course, and the rewrite rules in Apache configuration are simple. But if your application might provide different content for the same URL, depending on other data (like session settings), you’re in trouble.

There are many things normally stored in session that can alter what you see on a given page, even if at first glance you might think that the page content is fairly static. Things like whether you are logged in or not, what is your role in the application (admin/regular user), what language you prefer to see the page in and so on.

Tutorials on Rails caching will tell you that in this case you can forget about page caching. If you cannot guarantee that the URL in question will always provide the same content, page caching might be a problem.

The solution

To solve it, you have to do two things:

  1. Make sure that every piece of information that can alter the page content is included in cached path. That is, instead of cache/index.html, your page should probably be stored under something like cache/en_GB/not_logged_in/msie/index.html.
  2. Make sure that every piece of said information is retrievable by Apache or it won’t be able to find the cached page. Retrievable by Apache means URL or cookie. URL can’t change (else we wouldn’t have this problem), so our only option is a cookie.

In the following examples I’m going to present a simple recipe on how to implement a “language-aware” caching. I assume that you know how to cache and expire pages in Rails and you know what to cache. If that’s not the case, please at least read “Ruby on Rails Caching Tutorial” first (link below). I also assume that you run a configuration where Apache is serving static content and proxying dynamic content to a cluster of mongrels.

Move your page cache

This has nothing to do with multiple languages but it’s a good practice anyway and all the following examples depend on it, so I’d recommend that you do this. By default Rails stores cached pages right inside your application’s public directory. This is not the best choice IMHO because it’s hard to differentiate real static pages from cached dynamic pages, which makes it difficult to purge the whole cache. Add following line in your environment.rb.

config.action_controller.page_cache_directory = 
  RAILS_ROOT + "/public/cache/"

Store language in a cookie

First, we must guarantee that the language chosen by user is stored in a cookie. How exactly you implement the choosing part is up to you. In our application, there’s a before_filter that checks many things like session, domain, subdomain, ACCEPT_LANGUAGE header etc. When the language is determined, we set a cookie like this:

class ApplicationController < ActionController::Base
  before_filter :set_language
  ...
private
  def set_language
    lang = ...
    cookies[:lang] = lang.locale_code
  end
end

Now, every request that makes it to Rails, will have a cookie with the language set. Be careful about the “makes it to Rails” part. If your link for changing language points to a cached page (I have been bitten by this), the change will not work, because the page will be served by Apache and you application server will not even see it.

Also note that even if the user prefers e.g. Italian, but your application has no Italian version and you serve them English version instead, you should save the real language of the content in the cookie, or else you will have duplicate pages in cache. This implies, obviously, that your code should be aware what languages your application is localized in.

Add language to cached page path

Next thing we have to do is to add language information to cache path. We can do this by overriding cache_page and expire_page class methods of ApplicationController like this:

class ApplicationController < ActionController::Base
  ...
  class << self
    def cache_page content, path
      super content, GetText.locale.to_s + path 
    end
    def expire_page path
      Language.all_localized.each do |lang|
        super lang.locale_code + path
      end
    end
  end
end

I tried to use our freshly baked cookie[:lang] in cache_page method, but this didn’t seem to work well with tests, so I took the easiest around it with GetText.locale.to_s. If you use some other library in your application, you’ll have to find its own way to determine the locale (it shouldn’t be to hard).

In expire_page method we have to clean all versions of saved pages, not only the current one. Language.all_localized returns a list of all languages that we have localized our application in (all three of them), you probably have something similar in your code.

Configure Apache to find the cached pages

The last part is to setup Apache rewriting rules so it can find the cached pages using the language saved in our cookie. In the Apache configuration file for you application (something like /etc/apache2/sites-enabled/YourAppName), add:

RewriteEngine On
RewriteCond %{HTTP_COOKIE} lang=(en_GB|de_DE|pl_PL) [NC]
RewriteRule ^([^.]+)$ /cache/%1/$1.html [QSA]
RewriteCond %{DOCUMENT_ROOT}/%{REQUEST_FILENAME} !-f
RewriteRule ^/(.*)$ balancer://mogrel_cluster%{REQUEST_URI} [P,QSA,L]
<Proxy balancer://mogrel_cluster>
  BalancerMember http://127.0.0.1:3000
  ...
</Proxy>

Basically, these rules tell Apache to read cookie lang and use it in cache lookup. The crucial part is /cache/%1/$1.html, where $1 is the URL requested and %1 is the value of the cookie.

Now you’re ready to restart Apache and enjoy your multilingual application at much higher speed!

Troubleshooting

So, you’ve done everything exactly as above and nothing happens? Or even worse, Apache or Mongrel crashes? Here are some problems I have encountered and solved:

  • Check if you have really deployed your application with all the changes and restarted Mongrels and Apache. Yeah, I know you couldn’t have possibly miss it, but please check anyway. I have forgotten to deploy this or restart that a couple of times myself.
  • Make sure that your browser lets you see the response headers so you can check whether the page was served by Mongrel (dynamically) or Apache (cached) and page loading time. For Opera I recommend Developer Console and there’s Firebug for Firefox.
  • If you would like to experiment in development mode3, don’t forget to turn caching on in development.rb with:

    config.action_controller.perform_caching = true
  • Make sure you have set your application’s DocumentRoot correctly in Apache configuration file for you application. It should look something like:

    DocumentRoot /var/proj/YourAppName/current/public

    Also see next bullet.

  • You have set DocumentRoot and now you’re getting a 500 error? Don’t panic. Check your /var/log/apache2/error.log file. If you see the following error: Option FollowSymLinks not allowed here (or similar, or something about symbolic links), add following lines above the DocumentRoot setting:

    <Directory /var/proj/YourAppName>
           Options FollowSymLinks
           AllowOverride All
    </Directory>
        

If you have any other problems, don’t hesitate to ask. I’ll try to help if only my limited knowledge of Apache configuration allows me to. If anything is problematic or can be done better, I’d be very glad to hear it. If you use something other than Apache/Mongrel Cluster/GetText and managed to implement this solution, please share.

Resources

For Google-impaired, here is a short list of resources. There are of course many more of them, but the ones below contain all the information you need to setup what I present here. If you had read them earlier, you wouldn’t have to read this article :)


1 Or any other fast HTTP server you happen to be using, of course. But the examples are for Apache only, sorry.

2 This is supposed to be a joke. If you had to look here, it’s probably not that funny.

3 Unfortunately, if you followed my recommendation and changed default cache placement, it won’t work with Mongrel. The cached pages are stored where they should be but not read back by Mongrel, because it ignores this setting. This seems to be a known problem but I couldn’t find a fix for it. You’ll get the information about caching and expiring pages in development.log and this should be sufficient in most cases. You can also try to setup an Apache server for your development use.

About these ads

7 responses to “Multilingual page caching in Ruby on Rails

  • wordpress htaccess

    Whoah! This is an awesome writeup szeryf! I will be coming back to read it again in a bit!

    ~ AskApache

  • jm

    Great stuff ! Thank you very much for this !

    Actually i use subdomains to store language and have the same kind of configuration for nginx like this :

    set $lang fr;
    if ($host ~* "^en\.yaka") {
    set $lang en;
    }
    if ($host ~* "^es\.yaka") {
    set $lang es;
    }
    if ($host ~* "^nl\.yaka") {
    set $lang nl;
    }

    and then i use $lang in the redirection stuff…

  • Valery

    For ngnix it would be like
    server {

    set $locale bg;
    if ($http_cookie ~ ‘locale=(en|bg)’) {
    set $locale $1;
    }

    location / {

    try_files /maintenance.html /cache/$locale/$uri.html /cache/$locale/$uri/index.html /cache/$locale/$uri $uri $uri/index.html $uri.html @modrails; $


    }

  • Jan

    Works really well. It took me forever to get localization based on subdomains working. And boy was I mad when I realized that it destroys my page caching. very, very glad I found this page. Thanks a lot!

    I use de.foo.bar and en.foo.bar in my project with de and en as locale names, so I didn’t have to use cookies, but used:

    RewriteCond %{HTTP_HOST} ^(.+?)\.
    RewriteRule ^([^.]+)$ cache/%1/$1.html [QSA]

    Also I noticed that my root isn’t cached as locale/index.html but as locale.html so I went for:

    RewriteCond %{HTTP_HOST} ^(.+?)\.
    RewriteRule ^$ cache/%1.html [QSA]

    With built-in rails 2.3 localization:

    def cache_page content, path
    super content, I18n.locale + path
    end

  • omari

    This is an awesome piece, very useful on various projects :)

  • Om

    This is a great little piece. I’m surprised that RoR doesn’t come with better support for i18n with caching. Either way, this is very useful

  • Miro

    nicely written and exactly what i was looking for…
    any idea how would apache config look in passenger setup?

    i guess just last line should be different

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Follow

Get every new post delivered to your Inbox.

%d bloggers like this: