Speedy Rails JSON serialization with built-in caching.
There are a lot of Rails serializers out there, but there seem to be very few these days that are well maintained and performant. The ones that are, tend to lock you into a specific standard for how to format your JSON responses. And the idea of introducing breaking API changes across the board to a mature Rails app is daunting, to say the least.
In addition, incorporating a caching layer (for performance reasons) into your serializers can be difficult unless you do it at a Rails view layer. And the serialization gems that work at the view layer tend to be slow in comparison to others. So it tends to be a one step forward one step back sort of solution.
In light of all that, this gem was built with these goals in mind:
- Be fast
- Support caching in as simple a way as we can
- Support rollout without causing breaking API changes
- Avoid the bloat that can lead to slowness and maintenance difficulties
- Ruby 2.4–2.6 (others will likely work but are untested)
- Rails 5 or 6 (others may work but are untested)
- Fast even without caching
- Flexible lets you serialize data any way you want it
- Built-in Caching (documentation coming soon)
- ETags for easy HTTP caching
- Simple, Readable DSL
CacheCrispies.configure do |conf|
conf.etags = true
endetags is set to false by default.
CacheCrispies.configure do |conf|
conf.cache_store = ActiveSupport::Cache::DalliStore.new('localhost')
endcache_store must be set to something that quacks like a ActiveSupport::Cache::Store.
cache_store is set to Rails.cache by default, or ActiveSupport::Cache::NullStore.new if Rails.cache is nil.
CacheCrispies.configure do |conf|
conf.cache_key_method = :custom_cache_key_method_name
endcache_key_method must be set to the name of the method the model responds to and returns a string value.
cache_key_method is set to :cache_key by default.
class CerealSerializer < CacheCrispies::Base
serialize :name, :brand
end class CerealSerializer < CacheCrispies::Base
key :food
collection_key :food
do_caching true
cache_key_addons { |options| options[:be_trendy] }
dependency_key 'V3'
serialize :uid, from: :id, to: String
serialize :name, :company
serialize :copyright, through: :legal_info
serialize :spiel do |cereal, _options|
'Made with whole grains!' if cereal.ingredients[:whole_grains] > 0.000001
end
merge :itself, with: MarketingBsSerializer
nest_in :about do
nest_in :nutritional_information do
serialize :calories
serialize :ingredients, with: IngredientSerializer, optional: true
end
end
show_if ->(_model, options) { options[:be_trendy] } do
nest_in :health do
serialize :organic
show_if ->(model) { model.organic } do
serialize :certification
end
end
end
def certification
'Totally Not A Scam Certifiers Inc'
end
endPut serializer files in app/serializers/. For instance this file should be at app/serializers/cereal_serializer.rb.
class CerealsController
include CacheCrispies::Controller
def index
cereals = Cereal.all
cache_render CerealSerializer, cereals, custom_option: true
end
endCerealSerializer.new(Cereal.first, be_trendy: true, include: :ingredients).as_json{
"uid": "42",
"name": "Eyeholes",
"company": "Needful Things",
"copyright": "© Need Things 2019",
"spiel": "Made with whole grains!",
"tagline": "Part of a balanced breakfast",
"small_print": "This doesn't mean jack-squat",
"about": {
"nutritional_information": {
"calories": 1000,
"ingredients": [
{
"name": "Sugar"
},
{
"name": "Other Kind of Sugar"
}
]
}
},
"health": {
"organic": false
}
}Turning on caching is as simple as adding do_caching true to your serialzer. But if you're not familiar with how Rails caching, or caching in general works you could wind up with some real messy caching bugs.
At the very least, you should know that Cache Crispies bases most of it's caching on the cache_key method provided by Rails Active Record models. Knowing how cache_key works in Rails, along with touch, will get you a long way. I'd recommend taking a look at the Caching with Rails guide if you're looking for a place to start.
For those looking for more specifics, here is the code that generates a cache key for a serializer instance:
[
CACHE_KEY_PREFIX, # "cache-crispies"
serializer.cache_key_base, # an MD5 hash of the contest of the serializer file and all nested serializer files
serializer.dependency_key, # an optional static key
addons_key, # an optional runtime-generated key
cacheable.cache_key # typically ActiveRecord::Base#cache_key
].flatten.compact.join(CACHE_KEY_SEPARATOR) # + is used as the separator- Caching is completely optional and disabled in serializers by default
- If an object you're serializing doesn't have a
cache_keymethod, it won't be cached - If you want to cache a model, it should have an
updated_atcolumn - Editing an
app/serializers/____serializer.rbfile will bust all caches generated by that serializer - Editing an
app/serializers/____serializer.rbfile will bust all caches generated by other serialiers that nest that serializer - Changing the
dependency_keywill bust all caches from that serializer - Not setting the appropriate value in
cache_key_addonswhen the same model + serializer pair could produce different output, depending on options or other factors, will result in stale data - Data will be cached in the
Rails.cachestore by default - If the serializer is implemented in a Rails Engine instead of the base Rails application, set the engine class in the serializer:
engine MyEngine(inherited in subclasses)
serialize :is_organic, from: :organic?serialize :copyright, through: :legal_infoIf the legal_info method returns nil, copyright will also be nil.
serialize :ingredients, with: IngredientSerializermerge :legal_info, with: LegalInfoSerializermerge :prices, with: PricesSerializer, collection: falseserialize :id, to: StringSupported data type arguments are
StringIntegerFloatBigDecimalArrayHash:bool,:boolean,TrueClass, orFalseClass
nest_in :health_info do
serialize :non_gmo
endYou can nest nest_in blocks as deeply as you want.
show_if (model, options) => { model.low_carb? || options[:trendy] } do
serialize :keto_certified
endYou can nest show_if blocks as deeply as you want.
serialize :fine_print do |model, options|
model.fine_print || options[:fine_print] || '*Contents may contain lots and lots of sugar'
endor
serialize :fine_print
def fine_print
model.fine_print || options[:fine_print] || '*Contents may contain lots and lots of sugar'
endclass CerealSerializer < CacheCrispies::Base
serialize :page
def page
options[:page]
end
end
CerealSerializer.new(cereal, page: 42).as_json
# or
cache_render CerealSerializer, cereal, page: 42 cache_render CerealSerializer, meta: { page: 42 }This would render
{
"meta": { "page": 42 },
"cereal": {
...
}
}Note that metadata is not cached.
The default metadata key is meta, but it can be changed with the meta_key option.
cache_render CerealSerializer, meta: { page: 42 }, meta_key: :paginationThis would render
{
"pagination": { "page": 42 },
"cereal": {
...
}
}class CerealSerializer < CacheCrispies::Base
key :breakfast_cereal
collection_key :breakfast_cereals
endNote that collection_key is the plural of key by default.
By default Cache Crispies will look at whether or not the object you're serializing responds to #each in order to determine whether to render it as a collection, where every item in the Enumerable object is individually passed to the serializer and returned as an Array. Or as a non-collection where the single object is serialized and returned.
But you can override this default behavior by passing collection: true or collection: false to the cache_render method.
This can be useful for things like wrappers around collections that contain metadata about the collection.
class CerealListSerializer < CacheCrispies::Base
nest_in :meta do
serialize :length
end
serialize :cereals, from: :itself, with: CerealSerializer
end
cache_render CerealSerializer, cereals, collection: falseCerealSerializer.new(Cereal.first, trendy: true).as_jsondo_caching true cache_key_addons do |options|
options[:current_user].id
endBy default the model's cache_key is the primary thing determining how something will be cached. But sometimes, you need to take other things into consideration to prevent returning stale cache data. This is espcially common when you pass in options that change what's rendered.
Here's an example:
class UserSerializer < CacheCrispies::Base
serialize :display_name
def display_name
if options[:current_user] == model
model.full_name
else
model.initials
end
end
endIn this scenario, you should include options[:current_user].id in the cache_key_addons. Otherwise, the user's full name could get cached, and users, who shouldn't see it, would.
It is also possible to configure the method CacheCrispies calls on the model via the config.cacheable_cache_key
configuration option.
dependency_key 'V2'Cache Crispies does it's best to be aware of changes to your data and your serializers. Even tracking nested serializers. But, realistically, it can't track everything.
For instance, let's say you have a couple Rails models that have email fields. These fields are stored in the database as mixed case strings. But you want them lowercased in your JSON. So you decide to do something like this.
module HasEmail
def email
model.email.downcase
end
end
class UserSerializer < CacheCrispies::Base
include HasEmail
do_caching true
serialize :email
endAs your app is used, keys are generated and stored with downcased emails. But then you realize that you have trailing whitespace in your emails. So you change your mixin to do model.email.downcase.strip. Now you've changed your data, without changing your database, or your serializer. So Cache Crispies doesn't know your data has changed and continues to render the emails with trailing whitespace.
The best solution for this problem is to do something like this:
module HasEmail
CACHE_KEY = 'HasEmail-V2'
def email
model.email.downcase
end
end
class UserSerializer < CacheCrispies::Base
include HasEmail
do_caching true
dependency_key HasEmail::CACHE_KEY
serialize :email
endNow anytime you change HasEmail in a way that should bust the cache, just change the CACHE_KEY and you're good.
See rubydoc.info/gems/cache_crispies
See github.com/codenoble/cache-crispies-performance-comparison
To delete all cache entries in Redis:
redis-cli --scan --pattern "*cache-crispies*" | xargs redis-cli unlink
We use Appraisal to run tests against multiple Rails versions.
bundle exec appraisal install
bundle exec appraisal rspecFeel free to contribute by opening a Pull Request. But before you do, please be sure to follow the steps below.
- Run
bundle exec appraisal installto update all of the appropriate gemfiles. - Run
bundle exec appraisal rspecto ensure all tests are passing. - Check the
rspecoutput around test coverage. Try to maintainLOC (100.0%) covered, if at all possible. - After pushing up your pull request, check the status from CircleCI and Codacy to ensure they pass.
MIT