Anthony Chao

What Does Counter Culture Do

#Rails

Outline

Intro

在 ActiveRecord 模組裡面已經有原生的 counter cache 機制,但如果要用到比較複雜的 cache 機制可以使用 counter_culture 這個 gem

剛好最近有個 bug 跟他有關,因此去了解消化一下 source code 內容

Usage

如果 A has_many B,使用 counter_culture 要寫在 B 身上

class A < ActiveRecord::Base
  has_many :bs
end

class B < ActiveRecord::Base
  belongs_to :a
  counter_culture :a, column_name: "bs_count"
end

厲害的地方在於,如果 has_many 這一邊還有 parent 身上也有 count cache 要更新, counter_culture 也支援這種情況

class A < ActiveRecord::Base
	has_many :children, class_name: 'A', foreign_key: 'parent_id', optional: true
	belongs_to :parent, class_name: 'A', foreign_key: 'parent_id'
  has_many :bs
end

class B < ActiveRecord::Base
  belongs_to :a
  counter_culture :a, column_name: "bs_count", foreign_key_values: proc { |a_id| [a_id, A.find_by_id(a_id).try(:parent).try(:id)] }
end

其他更詳細的使用方式就看看 counter_culture 的 文件 吧!

Analyzing

Entrypoint

首先要先找到這個 gem 的入口在哪裡,通常會寫在跟 gem 名字很像的檔案上面

果然在 lib 的第一層就找到他了

# lib/counter_culture.rb
ActiveSupport.on_load(:active_record) do
  include CounterCulture::Extensions
end

他這邊利用了 Active Support 的 lazy load hooks,讓 ActiveRecord 完整 load 完之後 include CounterCulture::Extensions 這個 module

這個 on_load 的用法可以參考這篇 文章,同時來看看 ActiveSupport 這邊的原始碼:

module ActiveSupport
  module LazyLoadHooks
	# ...
    def on_load(name, options = {}, &block)
      @loaded[name].each do |base|
        execute_hook(name, base, options, block)
      end

      @load_hooks[name] << [block, options]
    end

    def run_load_hooks(name, base = Object)
      @loaded[name] << base
      @load_hooks[name].each do |hook, options|
        execute_hook(name, base, options, hook)
      end
    end
	#...
end

on_load 的時候會先把前面已經註冊的 hook 都先執行過,然後把這次新註冊的再加到 @load_hooks 這個 instance_variable 裏面,但要使用這個方法的前提是用 on_load 註冊的 class 需要在 code 的最後用 run_load_hooks 去執行所有還沒執行的 hook,他們是需要搭配使用的

這個 gem 可以這樣在 ActiveRecord 身上裝 hook 是因為 ActiveRecord 最後就有這行:

ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base)

counter_culture instance method

接著就來看看 counter_culture 這個 gem 最重要的 instance method counter_culture 從哪裡來的,前面利用 hook 讓 ActiveRecord include CounterCulture::Extensions 這個模組,所以一定是放在裡面的

我把一些相容其他 gem 跟 error check 的部分拿掉比較好看

# counter_culture/extentions.rb

def counter_culture(relation, options = {})
	unless @after_commit_counter_cache
		# initialize callbacks only once
		after_create :_update_counts_after_create

		before_destroy :_update_counts_after_destroy, unless: :destroyed_for_counter_culture?

		after_update :_update_counts_after_update, unless: :destroyed_for_counter_culture?

		# we keep a list of all counter caches we must maintain
		@after_commit_counter_cache = []
	end
	column_names_valid = (
		!options[:column_names] ||
		options[:column_names].is_a?(Hash) ||
		(
			options[:column_names].is_a?(Proc) &&
			options[:column_names].call.is_a?(Hash)
		)
	)

	# add the counter to our collection
	@after_commit_counter_cache << Counter.new(self, relation, options)
end

從上面的原始碼看到,他還是利用了 ActiveRecord 的 after_create / before_destroy / after_update 這些 callback,因為他想要指註冊一次 callback,所以會先去檢查 @after_commit_counter_cache 這個 instance_variable 是不是空值,否則如果某個 model 使用了兩次 counter_culture 就會執行兩次

而綁定在這些 callback 身上的 method 長得很簡單:

def _update_counts_after_create
	self.class.after_commit_counter_cache.each do |counter|
		# increment counter cache
		counter.change_counter_cache(self, :increment => true)
	end
end

只是把 instance_variable 裡面的東西拿出來讓 counter 去改他們的 counter_cache,可以知道實作的細節是靠 Counter 這個 class 去實現的

所以就來看看 Counter 主要的進入點 change_counter_cache 怎麼寫,這部分有點多,我直接把自己的註解放進去

def change_counter_cache(obj, options)
	# counter cache 記錄在哪個欄位
	change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }

	# 要改的資料有哪些,如果 parent 也需要更新,這邊會一起撈到
	id_to_change = foreign_key_value(obj, relation, options[:was])
	id_to_change = foreign_key_values.call(id_to_change) if foreign_key_values

	if id_to_change && change_counter_column
		# delta_magnitude 是 counter 要增減的數量
		delta_magnitude = if delta_column
												(options[:was] ? attribute_was(obj, delta_column) : obj.public_send(delta_column)) || 0
											else
												counter_delta_magnitude_for(obj)
											end
		# 如果 destroy 是 - create 就是 +
		operator = options[:increment] ? '+' : '-'
		# 拿到需要調整的 class
		klass = relation_klass(relation, source: obj, was: options[:was])

		quoted_column = "#{model.connection.quote_column_name(change_counter_column)}"

		column_type = klass.type_for_attribute(change_counter_column).type

		# updates 最後長相:["\"bs_count\" = COALESCE(\"bs_count\", 0) + 1"]
		updates = []
		updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"

		primary_key = relation_primary_key(relation, source: obj, was: options[:was]primary_key = relation_primary_key(relation, source: obj, was: options[:was]))

		# 最重要的一行,在這行執行
		execute_now_or_after_commit(obj) do
			klass.where(primary_key => id_to_change).update_all updates.join(', ')
		end
	end
end

其中最重要的在最後一行

klass.where(primary_key => id_to_change).update_all updates.join(', ')

他會把所有需要改變的紀錄用 where 一次撈出來,然後直接去更新這些紀錄的 counter 欄位,要注意 update_all 是不會 trigger callback 的一個 method,所以如果要更新紀錄的時間,在這個 gem 裡面有另外實作

Conclusion

counter_culture 這個 gem 的原始碼不多,但也是一個有 1000 多個星星的專案,所以真的實用的 gem 真的不用太多行,打到大家的痛處就好

因為最近常常遇到不知道怎麼寫比較好的情況,覺得除了平常 review PR 之外,多看看 source code 也是個學習快速的方式,所以最近應該會嘗試多看看各種 gem 是怎麼寫的,來加強自己功力!