Want to make your Rails JSON APIs fast? Blisteringly fast? In a project I’ve been working on recently, I reduced requests from 5 seconds (or more!) to at most 0.5 seconds by using
ActiveModel::Serializers and partial object caching with object composition. Before I explain how, I’ll need to explain why.
Imagine a Rails app that stores a handful of locations in a database:
class Location < ActiveRecord::Base has_many :images has_many :categories geocoded_by :address after_validation :geocode end
In this example, the
Location model is using the geocoder gem to automatically geocode the model’s address. To find all locations within 15 miles of Boston:
This returns an
ActiveRecord::Relation with each location decorated with two pieces of additional data: distance and bearing. This data is dependent upon the point of interest searched; if I were to change my query from Boston to Cambridge (just across the Charles River), the distances and bearings would be affected even though the location data from the database would be the same.
ActiveModel::Serializer for this model looks like this:
class LocationSerializer < ActiveModel::Serializer attributes :id, :name, :description attributes :street_one, :city, :state, :postal_code, :country, :latitude, :longitude attributes :distance has_many :images has_many :categories end
ActiveModel::Serializers recently (as of 0.8.0) introduced a caching mechanism on serializers:
class MySerializer < ActiveModel::Serializer cached delegate :cache_key, to: :object end
This allows serializers to use
Rails.cache behind the scenes to cache generated JSON. However, enabling caching on the
LocationSerializer doesn’t make sense because distance changes based on the search term. Finding locations within 15 miles of Boston would cache each relative to that search string; searching in Cambridge would return the previously cached results from Boston meaning most distances would be invalid.
The solution is figuring out how to cache some of the location JSON generated but not all of it (omitting distance, of course). Object composition is the perfect solution; by writing a brand new serializer that combines two other serializers (one which caches the location data from the database and one which adds distance), we can rely on the cache for the heavy lifting of the JSON and the decoration of distance for every search:
class SearchSerializer < ActiveModel::Serializer def serializable_hash location_serializer_hash.merge distance_serializer_hash end private def location_serializer_hash LocationSerializer.new(object, options).serializable_hash end def distance_serializer_hash DistanceSerializer.new(object, options).serializable_hash end end
This composite serializer merges our existing serializer with a new
ActiveModel::Serializer#serializable_hash is the method which contains all the logic for
#to_json so overriding
#serializable_hash on the
SearchSerializer will impact
The modified serializers look like this:
class LocationSerializer < ActiveModel::Serializer cached delegate :cache_key, to: :object attributes :id, :name, :description attributes :street_one, :city, :state, :postal_code, :country, :latitude, :longitude # attributes :distance <= removed has_many :images has_many :categories end class DistanceSerializer < ActiveModel::Serializer attributes :distance end
To use a custom serializer when operating on an array,
ActiveModel::ArraySerializer provides an
ActiveModel::ArraySerializer.new(@locations, each_serializer: SearchSerializer)
Finally, we’ll want to cache all the other
has_many results from the
LocationSerializer by creating serializers for each of the models:
class ImageSerializer < ActiveModel::Serializer cached delegate :cache_key, to: :object attributes :image def image object.url end end class CategorySerializer < ActiveModel::Serializer cached delegate :cache_key, to: :object attributes :display_in_search_results, :display_in_details, :name, :class_name def class_name object.name.parameterize end end
By enabling caching the other related pieces of data, we’re effectively enabling key-based cache expiration due to
ActiveModel::Serializers’s behavior under the covers. For static data, this is incredibly effective because there will rarely be a cache miss after the cache is warmed.
Warming the Cache
While caching location data helps recurring searches (dependent on locations displayed and not by the search point of interest), it doesn’t improve responses for searches where the locations haven’t been cached. Depending on the dataset, warming the cache may be the best option. While this makes plenty of assumptions about usage data, it worked well for the app I worked on:
big_cities = ['New York, NY', 'Boston, MA', 'San Francisco, CA'] # long list of cities big_cities.each do |city| Location.near(city, 50).each do |location| LocationSerializer.new(location).serializable_hash end end
This warms the cache with the latest data and can be thrown in a rake task.
Overall, this resulted in between a 5x and 10x speed improvement without any sort of database query tuning. Warming the cache still takes a long time, but I’ve got some ideas for how to speed up database queries after watching a screencast about improving performance from Joe Ferris.
If you found this useful, you might also enjoy: