Skip to content

Instantly share code, notes, and snippets.

@motchang
Last active April 8, 2017 12:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save motchang/92871cce5c36a780db2ed1760c08e6f1 to your computer and use it in GitHub Desktop.
Save motchang/92871cce5c36a780db2ed1760c08e6f1 to your computer and use it in GitHub Desktop.
#
# USAGE1:
# User.find(1).deepcache.friends.count.fetch
#
# 上記のような呼び出しをすると以下のキーで Redis に問い合わせてなければキャッシュします。
# get deepcache:User:1:friends.count
#
# USAGE2:
# Category.deepcache.co_cats.where(slug: URI.encode(request_manager.params['slug'] || '')).limit(1).to_a.fetch
#
# こうなる
# get deepcache:Category:co_cats:where:{:slug=>"%E8%A1%A3%E9%A1%9E%E5%8F%A4%E7%9D%80%E3%83%BB%E9%9D%B4"}:limit:1:to_a
#
# いい感じにつかえる redis namespaced なキャッシュユーティリティです
# (個人の感想であり効果・効能を示すものではありません。 )
#
# 当システムではカジュアルにキャッシュ全消し運用してるみたいなのでユーザー側のキャッシュが
# ガツガツ消えるのは悲しいなと考えて作りました。
# ref: app/controllers/admins/caches_controller.rb
module Concerns::DeepCache
extend ActiveSupport::Concern
def deepcache
Essence.new(self)
end
module ClassMethods
def deepcache
Essence.new(self)
end
end
def self.extended(obj)
obj.class.include self
end
included do
after_update :self_cleanup_caches, :class_cache_cleanup, :relations_cache_cleanup
after_destroy :class_cache_cleanup, :relations_cache_cleanup
after_create :class_cache_cleanup, :relations_cache_cleanup
end
def self_cleanup_caches
deepcache.cleanup_caches
end
def relations_cache_cleanup
self.class.reflect_on_all_associations.each do |reflection|
case reflection
when ActiveRecord::Reflection::HasManyReflection
# nop, 自分がリレーション上で親の場合は何もしない
when ActiveRecord::Reflection::BelongsToReflection
# 自分がリレーション上で子の場合は親のクラスに紐づくキャッシュを消し込む
DependenciesFinder.new(reflection).dependencies.each do |klass|
klass.include(Concerns::DeepCache) unless klass.class.respond_to?(:deepcache)
klass.deepcache.cleanup_caches
end
end
end
end
def class_cache_cleanup
self.class.include(Concerns::DeepCache) unless self.class.respond_to?(:deepcache)
self.class.deepcache.cleanup_caches
end
class BlackholeClient
def method_missing(method, *args)
nil
end
def respond_to_missing?(symbol, include_private)
false
end
end
# cache fetcher / deleter
class Essence
include ActiveSupport::Configurable
config_accessor :redis_client do
BlackholeClient.new
end
PREFIX = "deepcache".freeze
def method_missing(method, *args)
@chain.push({ method: method, args: args })
self
end
def respond_to_missing?(symbol, include_private)
false
end
def fetch(expires_in = nil)
key = key_name
value = client.get(key)
return Marshal.load(value) unless value.nil?
value = @chain.inject(@root) do |memo, c|
memo&.send(c[:method], *c[:args])
end
return nil if value.blank?
ttl = expires_in || default_ttl
client.setex(key, ttl, Marshal.dump(value))
value
end
def initialize(root)
if root.class != Class && (!root.respond_to?(:id) || root.id.nil?)
raise ArgumentError
end
@chain = []
@root = root
end
def cleanup_caches
delete_keys = client.keys(key_prefix + '*')
return if delete_keys.blank?
client.del(*delete_keys)
end
private
def default_ttl
if @root.respond_to?(:id)
# when @root is instance
(60 * 60 * 24)
else
# when @root is constant
60
end
end
def key_name
@chain.inject(key_prefix) { |memo, c|
[memo, c[:method].to_s, *c[:args].flatten].join(':')
}
end
def key_prefix
if @root.respond_to?(:id)
# when @root is instance
"#{@root.class}:#{@root.id}"
else
# when @root is constant
@root.to_s
end
end
def client
config.redis_client
end
end
# https://www.viget.com/articles/identifying-foreign-key-dependencies-from-activerecordbase-classes
class DependenciesFinder < Struct.new(:association)
delegate :foreign_key, to: :association
def klass
association.klass unless polymorphic?
end
def name
association.options[:class_name] || association.name
end
def polymorphic?
!!association.options[:polymorphic]
end
def polymorphic_dependencies
return [] unless polymorphic?
@polymorphic_dependencies ||= ActiveRecord::Base.subclasses.select { |model| polymorphic_match? model }
end
def polymorphic_match?(model)
model.reflect_on_all_associations(:has_many).any? do |has_many_association|
has_many_association.options[:as] == association.name
end
end
def dependencies
polymorphic? ? polymorphic_dependencies : Array(klass)
end
def polymorphic_type
association.foreign_type if polymorphic?
end
end
end
require 'rails_helper'
RSpec.describe Concerns::DeepCache do
before :each do
Concerns::DeepCache::Essence.configure do |config|
config.redis_client = Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX)
end
end
after :each do
Concerns::DeepCache::Essence.configure do |config|
config.redis_client = Concerns::DeepCache::BlackholeClient.new
end
end
subject { Item.new }
it { respond_to(:deepcache) }
describe '#deepcache' do
context 'root is normal AR table instance' do
context 'persisted' do
let(:item) { create(:item, :published) }
it "returns instance of Concerns::DeepCache::Essence" do
expect(item.deepcache).to be_instance_of Concerns::DeepCache::Essence
end
end
context 'not persisted' do
let(:item) { build(:item, :published) }
it "raise error" do
expect { item.deepcache }.to raise_error(ArgumentError)
end
end
end
context 'root is normal AR table class' do
let(:item) { create(:item, :published) }
it "returns instance of Concerns::DeepCache::Essence" do
expect(item.deepcache).to be_instance_of Concerns::DeepCache::Essence
end
end
end
describe '#fetch' do
context 'instances cache' do
let(:item) { create(:item_with_category, :published) }
it 'returns query result' do
expected = item.categories.first.name
expect(item.deepcache.categories.first.name.fetch).to eq expected
key = "Item:#{item.id}:categories:first:name"
expect(Marshal.load(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key))).to eq expected
end
end
context 'classes cache' do
let(:co_cat) { create(:category, code: 1) }
it 'returns query result' do
Category.include Concerns::DeepCache
expected = Category.co_cats.where(slug: co_cat.slug).limit(1).to_a
expect(Category.deepcache.co_cats.where(slug: co_cat.slug).limit(1).to_a.fetch).to eq expected
key = "Category:co_cats:where:{:slug=>\"#{co_cat.slug}\"}:limit:1:to_a"
expect(Marshal.load(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key))).to eq expected
end
end
end
describe '#self_cleanup_caches' do
context 'instance updated' do
let!(:item) { create(:item_with_category, :published) }
it 'delete cache child of instance' do
expected = item.categories.first.name
expect(item.deepcache.categories.first.name.fetch).to eq expected
key = "Item:#{item.id}:categories:first:name"
expect(Marshal.load(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key))).to eq expected
item.highest_price = 100
item.save
expect(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key)).to be_nil
end
it 'delete cache child of class' do
expected = Item.all.to_a
expect(Item.deepcache.all.to_a.fetch).to eq expected
key = "Item:all:to_a"
expect(Marshal.load(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key))).to eq expected
item.highest_price = 100
item.save
expect(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key)).to be_nil
end
end
end
describe "#relations_cache_cleanup" do
let(:store) { create(:store, :published) }
let!(:shop) { create(:shop, store: store) }
it 'delete parent class cache' do
expected = Store.all.to_a
expect(Store.deepcache.all.to_a.fetch).to eq expected
key = "Store:all:to_a"
expect(Marshal.load(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key))).to eq expected
shop.name = 'hoge'
shop.save
expect(Redis::Namespace.new(Concerns::DeepCache::Essence::PREFIX).get(key)).to be_nil
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment