Speedy Rails JSON serialization with built-in caching.
Why?
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
Requirements
- Ruby 2.4–2.6 (others will likely work but are untested)
- Rails 5 or 6 (others may work but are untested)
Features
- 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
Configuration
ETags
CacheCrispies.configure do |conf|
conf.etags = true
end
etags
is set to false
by default.
Custom Cache Store
CacheCrispies.configure do |conf|
conf.cache_store = ActiveSupport::Cache::DalliStore.new('localhost')
end
cache_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
.
Custom model cache key
CacheCrispies.configure do |conf|
conf.cache_key_method = :custom_cache_key_method_name
end
cache_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.
Usage
A simple serializer
class CerealSerializer < CacheCrispies::Base
serialize :name, :brand
end
A not-so-simple serializer
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
end
Put serializer files in app/serializers/
. For instance this file should be at app/serializers/cereal_serializer.rb
.
In your Rails controller
class CerealsController
include CacheCrispies::Controller
def index
cereals = Cereal.all
cache_render CerealSerializer, cereals, custom_option: true
end
end
Anywhere else
CerealSerializer.new(Cereal.first, be_trendy: true, include: :ingredients).as_json
Output
{
"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
}
}
A Note About Caching
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
Key Points to Remember
- Caching is completely optional and disabled in serializers by default
- If an object you're serializing doesn't have a
cache_key
method, it won't be cached - If you want to cache a model, it should have an
updated_at
column - Editing an
app/serializers/____serializer.rb
file will bust all caches generated by that serializer - Editing an
app/serializers/____serializer.rb
file will bust all caches generated by other serialiers that nest that serializer - Changing the
dependency_key
will bust all caches from that serializer - Not setting the appropriate value in
cache_key_addons
when 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.cache
store 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)
How To...
Use a different JSON key
serialize :is_organic, from: :organic?
Use an attribute from an associated object
serialize :copyright, through: :legal_info
If the legal_info
method returns nil
, copyright
will also be nil
.
Nest another serializer
serialize :ingredients, with: IngredientSerializer
Merge attributes from another serializer
merge :legal_info, with: LegalInfoSerializer
Force another serializer to be rendered as a single or collection
merge :prices, with: PricesSerializer, collection: false
Coerce to another data type
serialize :id, to: String
Supported data type arguments are
String
Integer
Float
BigDecimal
Array
Hash
-
:bool
,:boolean
,TrueClass
, orFalseClass
Nest attributes
nest_in :health_info do
serialize :non_gmo
end
You can nest nest_in
blocks as deeply as you want.
Conditionally render attributes
show_if (model, options) => { model.low_carb? || options[:trendy] } do
serialize :keto_certified
end
You can nest show_if
blocks as deeply as you want.
Render custom values
serialize :fine_print do |model, options|
model.fine_print || options[:fine_print] || '*Contents may contain lots and lots of sugar'
end
or
serialize :fine_print
def fine_print
model.fine_print || options[:fine_print] || '*Contents may contain lots and lots of sugar'
end
Include other data
class 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
Include metadata
cache_render CerealSerializer, meta: { page: 42 }
This would render
{
"meta": { "page": 42 },
"cereal": {
...
}
}
Note that metadata is not cached.
Change the default metadata key
The default metadata key is meta
, but it can be changed with the meta_key
option.
cache_render CerealSerializer, meta: { page: 42 }, meta_key: :pagination
This would render
{
"pagination": { "page": 42 },
"cereal": {
...
}
}
Set custom JSON keys
class CerealSerializer < CacheCrispies::Base
key :breakfast_cereal
collection_key :breakfast_cereals
end
Note that collection_key
is the plural of key
by default.
Force rendering as a collection or not
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: false
Render a serializer to a Hash
CerealSerializer.new(Cereal.first, trendy: true).as_json
Enable Caching
do_caching true
Customize the Cache Key
cache_key_addons do |options|
options[:current_user].id
end
By 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
end
In 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.
Bust the Cache Key
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
end
As 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
end
Now anytime you change HasEmail
in a way that should bust the cache, just change the CACHE_KEY
and you're good.
Detailed Documentation
See rubydoc.info/gems/cache_crispies
Benchmarks and Example Application
See github.com/codenoble/cache-crispies-performance-comparison
Tips
To delete all cache entries in Redis:
redis-cli --scan --pattern "*cache-crispies*" | xargs redis-cli unlink
Running Tests Locally
We use Appraisal to run tests against multiple Rails versions.
bundle exec appraisal install
bundle exec appraisal rspec
Contributing
Feel free to contribute by opening a Pull Request. But before you do, please be sure to follow the steps below.
- Run
bundle exec appraisal install
to update all of the appropriate gemfiles. - Run
bundle exec appraisal rspec
to ensure all tests are passing. - Check the
rspec
output 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.
License
MIT