Sometimes you need to cache a particular value or query result instead of caching view fragments. Rails' caching mechanism works great for storing any serializable information.
The most efficient way to implement low-level caching is using the Rails.cache.fetch method. This method does both reading and writing to the cache. When passed only a single argument, the key is fetched and value from the cache is returned. If a block is passed, that block will be executed in the event of a cache miss. The return value of the block will be written to the cache under the given cache key, and that return value will be returned. In case of cache hit, the cached value will be returned without executing the block.
This is part of Flecto Educa, and we want to share our knowledge with the community and help other developers to improve their skills.
- Ruby
- Rails
- Redis
- rest-client
- hiredis
- Docker
First we can try it in terminal with rails console
$ rails c
Then we can try it with a simple example
Loading development environment (Rails 7.0.8)
3.0.0 :001 > age = 27
=> 27
3.0.0 :002 > Rails.cache.write("alekinho", age)
=> true
3.0.0 :003 > Rails.cache.read("alekinho")
=> nil
What the heck happen here ? Well, we need to set up the cache store, in this case we are going to use the memory store, but you can use redis, memcached, etc.
lowLevelCache git:(main) ✗ rails c
Loading development environment (Rails 7.0.8)
3.0.0 :001 > Rails.cache.class
=> ActiveSupport::Cache::NullStore
3.0.0 :002 > Rails.cache = ActiveSupport::Cache::MemoryStore.new
=> #<ActiveSupport::Cache::MemoryStore entries=0, size=0, options={:compress=>false, :compress_threshold=>1024}>
3.0.0 :003 > Rails.cache.class
=> ActiveSupport::Cache::MemoryStore
3.0.0 :004 > age = 27
=> 27
3.0.0 :005 > Rails.cache.write("alekinho", age)
=> true
3.0.0 :006 > Rails.cache.read("alekinho")
=> 27
We also can check the config/development.rb file and see the cache store
config.cache_store = :memory_store, { size: 64.megabytes }
But not that by default, Rails check the existence of file called: `caching-dev.txt``
if Rails.root.join("tmp/caching-dev.txt").exist?
So we can create that file and check again
➜ lowLevelCache git:(main) ✗ touch tmp/caching-dev.txt
➜ lowLevelCache git:(main) ✗ rails c
Loading development environment (Rails 7.0.8)
3.0.0 :001 > age = 27
=> 27
3.0.0 :002 > Rails.cache.write("alekinho", age)
=> true
3.0.0 :003 > Rails.cache.read("alekinho")
=> 27
We saw how write/read works, what about fetch ?
fetch provides a nice wrapper around reading and writing. You pass it a key and a block, and if a value is present for that key in the cache it will be returned and the block is not executed. If there is no cached value for that key (or it has expired, more on expiration later) it will execute the block and store the result in the cache for next time.
3.0.0 :004 > Rails.cache.fetch("alekinho") { 27 }
=> 27
We can also pass a time to expire the cache
3.0.0 :005 > Rails.cache.fetch("alekinho", expires_in: 1.minute) { 30 }
=> 27
after 1 minute
3.0.0 :006 > Rails.cache.fetch("alekinho", expires_in: 1.minute) { 30 }
=> 30
A great use case for this kind of caching is when you are hitting an external API to get a value that may not change that often. In one client app we had some calculations based on the current futures price of some commodities. Rather than hit the API on every page refresh, we cache the value for a period of time (in our case 10 minutes).
imagine you have some API that returns /products and /products/:id, but you don't change the products that often, so you can cache the result of /products and /products/:id for 10 minutes.
For example, in lowLevelCache
folder, i had created a Product Controller + Product Helper.
First, let me show you the code of the controller in lowLevelCache/app/controllers/products_controller.rb
I will start calling the ProductHelper that will replace our Model, because i dont want to use a database for this example.
class ProductsController < ApplicationController
include ProductsHelper
end
before create helper, let use a simple gem that will help us to make request to an external API
Go to Gemfile and add
gem 'rest-client'
then run
bundle install
Let's create 2 functions to simulate index and show
module ProductsHelper
def fake_get_from_db
response = RestClient.get 'https://swapi.dev/api/people'
JSON.parse(response.body)
end
def fake_show_from_db(id)
response = RestClient.get "https://swapi.dev/api/people/#{id}"
JSON.parse(response.body)
end
end
Now we can create the index
...
def index
@products = Rails.cache.fetch('products', expires_in: 10.minutes) do
# Fake database call to get all products
# This method will call the Star Wars API and return all people
fake_get_from_db
end
# return the products array
render json: @products
end
...
As we saw before, we are using fetch to get the products in cache, if the products are not in the cache, we will call the fake_get_from_db method, that will call the Star Wars API and return all people.
Same thing we can do with show
def show
# Get the product from the cache, or if it doesn't exist, get it from the fake database
@product = Rails.cache.fetch("product/#{params[:id]}", expires_in: 10.minutes) do
# Fake database call to get the product
# This is a call for a single product, so we need to pass the id
# to the fake_show_from_db method
# This method will call the Star Wars API and return the person with the id
fake_show_from_db(params[:id])
end
# return the product object
render json: @product
end
Sorry about the confusion between people
and products
, ignore this and focus on the code.
So...
Now, imagine you need to update the "product", but we have cache enabled, so we need to clear the cache.
def update
# Ignore the find and update, because we are not using a database
# @product = Product.find(params[:id])
# @product.update(product_params)
#
# Remove the cache for the updated product
Rails.cache.delete("product/#{params[:id]}")
# Return the updated product
render json: @product
end
We can also update the cache when we update, but lets update in the next request, just YAGNI.
Now we can start the server:
cd lowLevelCache
rails s
and we can get response time with the products.sh
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 3.310715 seconds
The products API take: 0.851138 seconds
So, in the second request this is faster, because we are using cache.
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 0.010398 seconds
The products API take: 0.008920 seconds
Remember: This caching that we are using here are in memory, so if you restart the server, the cache will be cleared.
So, how to use Redis ?
To be fast, lets create a docker-compose.yml
file
now, we can run the command
docker-compose up -d
after install, lets add more one gem
In the first moment, i tried to install redis-rails and redis-store, but i got some errors, so i decided to use the hiredis gem Official Recommendation: https://guides.rubyonrails.org/caching_with_rails.html#cache-stores
# FlectoEduca packages
# for requests
gem 'rest-client'
# for redis usage
gem 'redis', '~> 5.0', '>= 5.0.7'
gem 'hiredis'
and run
bundle install
Now, we need to change the config/environments/development.rb
file
config.cache_store = :redis_cache_store, { url: "redis://localhost:6379/0" }
OBS: you can also add a expire time, but read the documentation to understand how it works. OBS2: links in the references section.
Remember that we check the tmp/caching-dev.txt
, lets remove this file.
rm tmp/caching-dev.txt
Now, we can start the server
➜ lowLevelCache git:(main) ✗ rails s
=> Booting Puma
=> Rails 7.0.8 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.7 (ruby 3.0.0-p0) ("Birdie's Version")
* Min threads: 5
* Max threads: 5
* Environment: development
* PID: 88458
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop
and we can get response time with the products.sh
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 3.750448 seconds
The products API take: 1.049783 seconds
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 0.012662 seconds
The products API take: 0.010791 seconds
WOW, kinda FASTER !
How do we know that we are using Redis ?
docker compose exec cache redis-cli
show all keys
127.0.0.1:6379> KEYS *
1) "product/1"
2) "products"
127.0.0.1:6379>
Now, we can stop the server, and run rails again
➜ lowLevelCache git:(main) ✗ rails s
And run the products.sh
again
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 0.218349 seconds
The products API take: 0.008394 seconds
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 0.021605 seconds
The products API take: 0.008456 seconds
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 0.013654 seconds
The products API take: 0.010118 seconds
➜ research-low-level-caching git:(main) ✗ ./products.sh
The products API take: 0.011347 seconds
The products API take: 0.009350 seconds
If you want to use a single API to store in memory, instead redis you can set this in Fetch
first let's create a route for details:
get '/products/:id/details', to: 'products#details'
add this on top of class
class ProductsController < ApplicationController
include ProductsHelper
# add this line
MEMORY = ActiveSupport::Cache::MemoryStore.new
.....
Now, use the memory store
def details
@product = MEMORY.fetch("product/#{params[:id]}/details", expires_in: 10.minutes) do
fake_show_from_db(params[:id])
end
end
Now, we will let the Controller more clean, and cache the whole request, Instead only the response.
This will be useful, because if you change the params of the request, the cache will be different.
Let's remove the hiredis gem
We dont need any gem, since Rails v5 support Built-in Cache
We can see a Redis front of application in lib/http_cache.rb
To use it, lets modify the development.rb and add:
require "http_cache"
config.middleware.use HttpCache
You can check if is used:
➜ lowLevelCache git:(feat/rack-middleware-rails) rake middleware
....
use HttpCache
run LowLevelCache::Application.routes
Now, all request will be cached on front of app:
==================================================
Started GET "/products/1" for 127.0.0.1 at 2023-11-20 01:39:38 +0000
==================================================
Request method: GET
Request path: /products/1
Request params: {}
Request headers: []
Cache hit for key: http_cache-GET/products/1{}[]
Started GET "/products/1" for 127.0.0.1 at 2023-11-20 01:39:39 +0000
==================================================
Request method: GET
Request path: /products/1
Request params: {}
Request headers: []
Cache hit for key: http_cache-GET/products/1{}[]
I also rename current products_controller.rb to products_controller_old.rb
the products_controller.rb will only have the methods.
https://guides.rubyonrails.org/caching_with_rails.html
https://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html
https://reinteractive.com/articles/rails-low-level-caching-tips
https://www.honeybadger.io/blog/rails-low-level-caching/
http://redis-store.org/redis-rails