Rails Caching And JavaScript Techniques

While implementing caching in a recent rails project I came across some typical caching issues. In a lot of pages the content is same for all users but certain components in them have user specific actions. As an example, I have a page listing all public messages that users have posted(similar to the public timeline in twitter) but actions on those messages are user specific(eg: only owner or admin can delete a message). Also, most of these actions use ajax and the rails authenticity token in them also gets cached resulting in subsequent failures if the session changes. Another issue was that the timestamps in most pages is fuzzy and they become irrelevant if a page gets cached for too long. I could have created separate caches for each user but if the user base really grows managing the caches would become a nightmare and that would still not solve the authenticity token and the timestamp problem. The simplest solution was to use JavaScript, more specifically jQuery.

I have been a big fan of jQuery since I started using it about a year and a half ago and try to use it in all my projects. There is an excellent Rails plugin called jRails that replaces Prototype with jQuery and provides all the default Rails helpers for JavaScript making jQuery even more tempting to use. The examples I use below all use jQuery.

1. Handling User Specific Components

Taking the delete action as explained in the example above, the code in the cached view looks something like this:

<% if logged_in? %>
  • <%= link_to_remote "Delete", :url => user_message_path(current_user, message) :method => :delete, :confirm => "Are you sure you wish to delete this message?", :html => { :title => "Delete Post" } %>
  • <% end %>

    This will create an li element with class “delete” and “only_kratos_delete_allowed” if the username of the message owner is “kratos”

    In the non-cached part of the view I have the following code:

    <% if logged_in? %>
      <% if current_user.is_admin? %>
        <%= javascript_tag "$(document).ready(function() { MessageView.removeInvalidDeteteButtons('only_#{current_user.login}_delete_allowed', 'true'); });" %>
      <% else %>
        <%= javascript_tag "$(document).ready(function() { MessageView.removeInvalidDeteteButtons('only_#{current_user.login}_delete_allowed'); });" %>
      <% end %>
    <% end %>
    

    If the current user is an admin I pass the JavaScript function an additional parameter.

    In the application.js file I have the following code:

    var MessageView = {
      removeInvalidDeteteButtons: function(element_class, admin) {
        if (admin == undefined)
        {
          $('.delete').each(function() { if (!$(this).hasClass(element_class)) { $(this).remove(); } });
        }
      }
    }
    

    If the second parameter is passed(in the case of an admin) the function does nothing, else it iterates over all li elements with class “delete” and removes all elements that do not also have, in this case, the “only_kratos_delete_allowed” class.

    2. Handling Rails Authenticity Token

    This part describes how to take care of the authenticity token problem:

    I added the following code to the layout:

    <%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? %>
    

    This code creates a JavaScript variable named “AUTH_TOKEN” that contains the current authentication token. Since this section is not cached it always get the correct token.

    Next I added the following code to application.js:

    $(document).ajaxSend(function(event, request, settings) {
      if (settings.type == 'get' || settings.type == 'GET' || typeof(AUTH_TOKEN) == "undefined") return;
      var authTokenRegExp = /authenticity_token=\w{40}/
      settings.data = settings.data || "";
      if (authTokenRegExp.test(settings.data))
      {
        settings.data=settings.data.replace(authTokenRegExp, "authenticity_token=" + encodeURIComponent(AUTH_TOKEN));
      }
      else
      {
        settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN);
      }
    })
    

    This code is a slight modification of the code posted here. ajaxSend is jQuery function that is executed before every ajax request is sent and I use it here to replace or append the authenticity token to the request, unless the request is GET or the AUTH_TOKEN variable is not defined.

    3. Handling Fuzzy Timestamps

    The code for timestamps in the cached view looks like this:

    
      <%= process_message_body_timestamp(message) %>
    
    

    And the helper code is:

    def process_message_body_timestamp(message)
      link_to "#{message.created_at}", show_message_url(message), :class => "timestamps"
    end
    

    I put the following code in the layout(non-cached):

    <%= javascript_tag "$(document).ready(function() { CustomDate.datify(#{get_custom_date_arguments}) });" %>
    

    The get_custom_date_arguments helper is defined in application helper and returns a string containing the current time arguments needed for the JavaScript function:

    def get_custom_date_arguments
      current_time = Time.zone.now
      "#{current_time.year},#{current_time.month-1},#{current_time.day},#{current_time.hour},#{current_time.min},#{current_time.sec}"
    end
    

    I put the following code in application.js:

    var CustomDate = {
      datify: function(current_utc_year,
                          current_utc_month,
                          current_utc_day,
                          current_utc_hour,
                          current_utc_minute,
                          current_utc_second) {
        $('.timestamps').each(function() {
          $(this).html(CustomDate.humane_date($(this).html(),
                                                    current_utc_year,
                                                    current_utc_month,
                                                    current_utc_day,
                                                    current_utc_hour,
                                                    current_utc_minute,
                                                    current_utc_second)).removeClass('timestamps')
        })
      },
    
      humane_date: function(date_str, current_utc_year, current_utc_month, current_utc_day, current_utc_hour, current_utc_minute, current_utc_second) {
        var time_formats = [
                            [60, 'less than a minute ago'],
                            [90, '1 minute'], // 60*1.5
                            [3600, 'minutes', 60], // 60*60, 60
                            [5400, '1 hour'], // 60*60*1.5
                            [86400, 'hours', 3600], // 60*60*24, 60*60
                            [129600, '1 day'], // 60*60*24*1.5
                            [604800, 'days', 86400], // 60*60*24*7, 60*60*24
                            [907200, '1 week'], // 60*60*24*7*1.5
                            [2628000, 'weeks', 604800], // 60*60*24*(365/12), 60*60*24*7
                            [3942000, '1 month'], // 60*60*24*(365/12)*1.5
                            [31536000, 'months', 2628000], // 60*60*24*365, 60*60*24*(365/12)
                            [47304000, '1 year'], // 60*60*24*365*1.5
                            [3153600000, 'years', 31536000], // 60*60*24*365*100, 60*60*24*365
                            [4730400000, '1 century'], // 60*60*24*365*100*1.5
                          ];
    
        var time = ('' + date_str).replace(/-/g,"/").replace(/[TUTC]/g," "),
            dt = new Date
    
        dt.setUTCFullYear(current_utc_year, current_utc_month, current_utc_day)
        dt.setUTCHours(current_utc_hour, current_utc_minute, current_utc_second)
    
        var seconds = ((dt - new Date(time) + (dt.getTimezoneOffset() * 60000)) / 1000),
            token = ' ago',
            prepend = '',
            i = 0,
            format;
    
        if (seconds < 0) {
          seconds = Math.abs(seconds);
          token = '';
          prepend = 'in ';
        }
    
        while (format = time_formats[i++]) {
          if (seconds < format[0]) {
            if (format.length == 2) {
              return (i>1?prepend:'') + format[1] + (i > 1 ? token : ''); // Conditional so we don't return Just Now Ago
            }
            else {
                return prepend + Math.round(seconds / format[2]) + ' ' + format[1] + (i > 1 ? token : '');
            }
          }
        }
    
        // overflow for centuries
        if(seconds > 4730400000) {
          return Math.round(seconds / 4730400000) + ' Centuries' + token;
        }
    
        return date_str;
      }
    }
    

    The datify function iterates over all timestamps(elements with class “timestamps”) and replaces them with fuzzy timestamps. The humane_date function(yanked from here) generates the actual fuzzy timestamps.

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>