What Does Counter Culture Do

Posted by Anthony Chao on 2022-05-19

Outline

Intro

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

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

Usage

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

1
2
3
4
5
6
7
8
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 也支援這種情況

1
2
3
4
5
6
7
8
9
10
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 的第一層就找到他了

1
2
3
4
# 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 這邊的原始碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 最後就有這行:

1
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 的部分拿掉比較好看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 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 長得很簡單:

1
2
3
4
5
6
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 怎麼寫,這部分有點多,我直接把自己的註解放進去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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

其中最重要的在最後一行

1
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 是怎麼寫的,來加強自己功力!





prevent_hack