はじめに
- 本記事では Ruby のライブラリである bullet の通知生成の仕組みについて、ソースコードを見ながら追っていきます
- bullet の全体的な説明については こちら の記事で行なっているので、こちらを参考にしてください
内容
- 本項目では、通知の生成について以下4つのパターンに分けて見ていきます
1. Article.all.to_a
2. Article.includes(:comments).to_a
3. Article.all.each do |article|
article.comments.to_a
end
4. Article.includes(:comments).each do |article|
article.comments.to_a
end
前提
- 本記事で説明するコードは ORM として ActiveRecord7.1 系を利用しています
- bullet の通知は
Thread.current[:bullet_notification_collector]
の情報が表示されるため「bullet_notification_collector
スレッドローカル変数に値が代入される」=「通知が生成された」とみなします
- 今回は CounterCache の処理については考慮しません
- Bullet::ActiveRecord モジュールの enable メソッドで ActiveRecord::Base や ActiveRecord::Relation に対して prepend メソッドでパッチが当たっている前提で話を進めていきます
module Bullet
module ActiveRecord
def self.enable
require 'active_record'
::ActiveRecord::Base.extend(
Module.new do
def find_by_sql(sql, binds = [], preparable: nil, &block)
...
end
end
)
::ActiveRecord::Relation.prepend(
Module.new do
def records
...
end
end
)
::ActiveRecord::Associations::CollectionAssociation.prepend(
Module.new do
def load_target
...
end
end
)
...
end
end
end
- 上記について詳しくは こちら の記事を参考にしてください
1. Article.all.to_a
Article.all.to_a
- まずは
Article.all.to_a
パターンです
- このパターンでは Bullet::ActiveRecord モジュールでオーバーライドしたメソッドのうち records メソッドのみ通ります
def records
result = super
if Bullet.start?
if result.first.class.name !~ /^HABTM_/
if result.size > 1
Bullet::Detector::NPlusOneQuery.add_possible_objects(result)
Bullet::Detector::CounterCache.add_possible_objects(result)
elsif result.size == 1
Bullet::Detector::NPlusOneQuery.add_impossible_object(result.first)
Bullet::Detector::CounterCache.add_impossible_object(result.first)
end
end
end
result
end
- records メソッドでは
Bullet::Detector::NPlusOneQuery
クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Article の情報が代入されます
Thread.current[:bullet_possible_objects]
=>
- このパターンでは、
bullet_notification_collector
スレッドローカル変数に値が代入されないので通知は生成されないことがわかります
2. Article.includes(:comments).to_a
Article.includes(:comments).to_a
- 次に
Article.includes(:comments).to_a
パターンです
- 本パターンについては application 側の処理と middleware 側の処理の2つに分けて見ていきます
application
- まずは application 側の処理についてです
- このパターンでは Bullet::ActiveRecord モジュールでオーバーライドしたメソッドのうち records → call → preloads_for_reflection → inversed_from → records メソッドを通ります
- まず records メソッドが呼び出されます
- records メソッド内では super メソッドの処理で call メソッドが呼ばれます
- call メソッドでは
Bullet::Detector::Association
クラスの add_object_associations
メソッドによって以下のように bullet_object_associations スレッドローカル変数に各 Article の Association の情報が代入されます
Thread.current[:bullet_object_associations]
=>
- 今回の場合は、各記事にコメントが紐づいているという情報が格納されています
- また、
Bullet::Detector::UnusedEagerLoading
クラスの add_eager_loadings
メソッドによって bullet_eager_loadings に Article のイーガーローディングの情報が代入されます
Thread.current[:bullet_eager_loadings]
=>
- 次に call メソッドの super メソッド内の処理で preloaders_for_reflection メソッドが呼ばれます
- preloaders_for_reflection メソッドでは
Bullet::Detector::Association
クラスの add_object_associations
メソッドによって以下のように bullet_object_associations スレッドローカル変数に Association の情報が代入されます
- また、
Bullet::Detector::UnusedEagerLoading
クラスの add_eager_loadings
メソッドによって bullet_eager_loadings にイーガーローディングの情報が代入されます(ここは特に何もしていなそう)
Thread.current[:bullet_object_associations]
=>
Thread.current[:bullet_eager_loadings]
=>
- 次に call メソッドの super メソッドの後続処理で inversed_from メソッドが呼ばれます
- inversed_from メソッドでは
Bullet::Detector::NPlusOneQuery
クラスの add_inversed_object メソッドによって以下のように bullet_inversed_objects スレッドローカル変数に belongs_to 関連の情報が代入されます
Thread.current[:bullet_inversed_objects]
=>
@registry=
{"Comment:1"=>
"Comment:2"=>
"Comment:3"=>
"Comment:4"=>
"Comment:5"=>
"Comment:6"=>
- その後 records メソッドが呼ばれます
- この records メソッドは、最初に呼ばれた records メソッドの super メソッド内で呼ばれたもので、最初の records メソッドとは別物です
- ここでは
Bullet::Detector::NPlusOneQuery
クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Comment の情報が代入されます
Thread.current[:bullet_possible_objects]
=>
@registry={"Comment"=>
- ここまでが 最初に呼ばれた records メソッドの super メソッドの処理です
- ここからは後続の処理が行われます
- 後続の処理では
Bullet::Detector::NPlusOneQuery
クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Article の情報が追加されます
Thread.current[:bullet_possible_objects]
=>
@registry=
{"Comment"=>
- 以上で、application 側の処理は終了して middleware 側に処理が戻ります
- 現時点では bullet_notification_collector スレッドローカル変数は空のため、通知は作成されていないことがわかります
Thread.current[:bullet_notification_collector]
=>
middleware
- ここまでは application 側の処理を見てきましたが、ここからは middleware 側の処理を見ていきます
- 処理を見ていく前に application 側の処理で変化のあったスレッドローカル変数をまとめました
Thread.current[:bullet_object_associations]
=>
Thread.current[:bullet_possible_objects]
=>
@registry=
{"Comment"=>
Thread.current[:bullet_inversed_objects]
=>
@registry=
{"Comment:1"=>
"Comment:2"=>
"Comment:3"=>
"Comment:4"=>
Thread.current[:bullet_eager_loadings]
=>
- 上記のスレッドローカル変数を踏まえた上で middleware 側の処理を見ていきます
- middleware 側は主に通知の表示部分を担っていますが、HTML の body に通知内容をインサートする前にいくつかの処理を経由します
- その中の一つに Bullet クラスの notification? メソッドがあります
def notification?
...
Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations
notification_collector.notifications_present?
end
- Bullet クラスの notification? メソッドでは
Bullet::Detector::UnusedEagerLoading
クラスの check_unused_preload_associations メソッドを実行しています
def check_unused_preload_associations
...
object_associations.each do |bullet_key, associations|
object_association_diff = diff_object_associations bullet_key, associations
next if object_association_diff.empty?
Bullet.debug('detect unused preload', "object: #{bullet_key}, associations: #{object_association_diff}")
create_notification(caller_in_project(bullet_key), bullet_key.bullet_class_name, object_association_diff)
end
end
Bullet::Detector::UnusedEagerLoading
クラスの check_unused_preload_associations メソッドの処理を見てみると、まず object_associations をループしていることがわかります
- object_associations は bullet_object_associations スレッドローカル 変数の情報が参照されており、以下の情報がループされます
Thread.current[:bullet_object_associations]
=>
- ループの中では diff_object_associations メソッドを実行しており、結果が空では無い場合は create_notification メソッドで通知が生成されていることがわかります
def diff_object_associations(bullet_key, associations)
potential_associations = associations - call_associations(bullet_key, associations)
potential_associations.reject { |a| a.is_a?(Hash) }
end
- diff_object_associations メソッド内では associations から call_associations メソッドの結果を引いてハッシュでは無いものをリターンしています
def call_associations(bullet_key, associations)
all = Set.new
eager_loadings.similarly_associated(bullet_key, associations).each do |related_bullet_key|
coa = call_object_associations[related_bullet_key]
next if coa.nil?
all.merge coa
end
all.to_a
end
- call_associations メソッド内では bullet_eager_loadings スレッドローカル変数の similarly_associated メソッドの結果の
["Article:1", "Article:2"]
をループして、bullet_call_object_associations スレッドローカル変数に各キーが存在するかどうかを判定しています
Thread.current[:bullet_eager_loadings]
=>
@registry.select { |key, value| key.include?(base) && value == associations }.collect(&:first).flatten
=> ["Article:1", "Article:2"]
Thread.current[:bullet_call_object_associations]
=>
- bullet_call_object_associations スレッドローカル変数にキーが存在する場合は all 変数にマージして最後に配列に変換しています
- 今回の場合は、bullet_call_object_associations スレッドローカル変数は空なので call_associations メソッドの戻り値は空配列になります
- その結果、diff_object_associations メソッド内の potential_associations には
[:comments]
という配列が残ります
def diff_object_associations(bullet_key, associations)
potential_associations = associations - call_associations(bullet_key, associations)
potential_associations.reject { |a| a.is_a?(Hash) }
end
- check_unused_preload_associations に戻ると object_association_diff が空配列では無いので、create_notification メソッドで通知が生成されます
- create_notification メソッドでは、bullet_notification_collector スレッドローカル変数に未使用のイーガーローディングの情報が格納されます
Thread.current[:bullet_notification_collector]
=>
@collection=
{
@associations=[:comments],
@base_class="Article",
@callers=["/Users/horinoyuutarou/reading/AppForReading/app/controllers/articles_controller.rb:5:in `index'"],
@path=nil>}>>
- この処理の中で bullet_notification_collector に値が代入されるので、このパターンでは通知が生成されることがわかります
3. Article.all.each … article.comments.to_a
Article.all.each do |article|
article.comments.to_a
end
- 次に
Article.all.each
でループしてそれぞれの記事のコメントを取得するパターンです
- これは皆さんもお分かりの通り N+1 が発生するパターンです
- このパターンでは Bullet::ActiveRecord モジュールでオーバーライドしたメソッドのうち records → load_target → find_by_sql → inversed_from メソッドを通ります
- records メソッドでは
Bullet::Detector::NPlusOneQuery
クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Article の情報が代入されます
Thread.current[:bullet_possible_objects]
=>
- inversed_from メソッドでは
Bullet::Detector::NPlusOneQuery
クラスの add_inversed_object メソッドによって以下のように bullet_inversed_objects スレッドローカル変数に belongs_to 関連の情報が代入されます
Thread.current[:bullet_inversed_objects]
=>
@registry={"Comment:1"=>
- find_by_sql メソッドでは
Bullet::Detector::NPlusOneQuery
クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Association 先の Comment の情報が追加されます
Thread.current[:bullet_possible_objects]
=>
@registry={"Article"=>
- load_target メソッドでは、まず
Bullet::Detector::NPlusOneQuery
クラスの call_association メソッドによって以下のように bullet_call_object_associations スレッドローカル変数に Association の情報が代入されます
Thread.current[:bullet_call_object_associations]
=>
- その後、以下の conditions_met? メソッドが true の場合に create_notification メソッドで通知が生成される仕組みになっています
def conditions_met?(object, associations)
possible?(object) && !impossible?(object) && !association?(object, associations)
end
- create_notification メソッドでは bullet_notification_collector スレッドローカル変数に通知で使用する情報が代入されます
Thread.current[:bullet_notification_collector]
=>
@collection=
{
@associations=[:comments],
@base_class="Article",
@callers=
["/app/controllers/articles_controller.rb:11:in `block in index'",
"/app/controllers/articles_controller.rb:9:in `index'"],
@path=nil>}>>
- この処理の中で bullet_notification_collector に値が代入されるので、このパターンでも通知が生成されることがわかります
4. Article.includes(:comments).each … article.comments.to_a
Article.includes(:comments).each do |article|
article.comments.to_a
end
- 次に
Article.includes(:comments).each … article.comments.to_a
パターンです
- 本パターンでは処理を2つに分割することができます
- 1つ目が
Article.includes(:comments).each
の処理、2つ目がループ内の article.comments.to_a
の処理です
- 本パターンについては application 側の処理と middleware 側の処理の2つに分け、application 側の処理についてはさらに上記の2つの処理に分割して見ていきます
application
- まずは application 側の処理についてです
- このパターンでは Bullet::ActiveRecord モジュールでオーバーライドしたメソッドのうち records → call → preloads_for_reflection → inversed_from → records → load_target メソッドを通ります
- まず records メソッドが呼び出されます
- records メソッド内では super メソッドの処理で call メソッドが呼ばれます
- call メソッドでは
Bullet::Detector::Association
クラスの add_object_associations
メソッドによって以下のように bullet_object_associations スレッドローカル変数に各 Article の Association の情報が代入されます
- 今回の場合は、各記事にコメントが紐づいているという情報が格納されています
- また、
Bullet::Detector::UnusedEagerLoading
クラスの add_eager_loadings
メソッドによって bullet_eager_loadings に Article のイーガーローディングの情報が代入されます
Thread.current[:bullet_object_associations]
=>
Thread.current[:bullet_eager_loadings]
=>
- 次に call メソッドの super メソッド内の処理で preloaders_for_reflection メソッドが呼ばれます
- preloaders_for_reflection メソッドでは
Bullet::Detector::Association
クラスの add_object_associations
メソッドによって以下のように bullet_object_associations スレッドローカル変数に Association の情報が代入されます
- また、
Bullet::Detector::UnusedEagerLoading
クラスの add_eager_loadings
メソッドによって bullet_eager_loadings にイーガーローディングの情報が代入されます(ここは特に何もしていなそう)
Thread.current[:bullet_object_associations]
=>
Thread.current[:bullet_eager_loadings]
=>
- 次に call メソッドの super メソッドの後続処理で inversed_from メソッドが呼ばれます
- inversed_from メソッドでは
Bullet::Detector::NPlusOneQuery
クラスの add_inversed_object メソッドによって以下のように bullet_inversed_objects スレッドローカル変数に belongs_to 関連の情報が代入されます
Thread.current[:bullet_inversed_objects]
=>
@registry=
{"Comment:1"=>
"Comment:2"=>
"Comment:3"=>
"Comment:4"=>
"Comment:5"=>
"Comment:6"=>
- その後 records メソッドが呼ばれます
- この records メソッドは、最初に呼ばれた records メソッドの super メソッド内で呼ばれたもので、最初の records メソッドとは別物です
- ここでは
Bullet::Detector::NPlusOneQuery
クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Comment の情報が代入されます
Thread.current[:bullet_possible_objects]
=>
@registry={"Comment"=>
- ここまでが 最初に呼ばれた records メソッドの super メソッドの処理です
- ここからは後続の処理が行われます
- 後続の処理では
Bullet::Detector::NPlusOneQuery
クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Article の情報が追加されます
Thread.current[:bullet_possible_objects]
=>
@registry=
{"Comment"=>
- ここまでは
Article.includes(:comments).to_a
パターンと全く同じ状態です
- 本パターンではここからさらに load_target の処理を通ります
- load_target メソッドでは、まず
Bullet::Detector::NPlusOneQuery
クラスの call_association メソッドによって以下のように bullet_call_object_associations スレッドローカル変数に Association の情報が代入されます
Thread.current[:bullet_call_object_associations]
=>
- その後、以下の conditions_met? メソッドが true の場合に create_notification メソッドで通知が生成される仕組みになっています
def conditions_met?(object, associations)
possible?(object) && !impossible?(object) && !association?(object, associations)
end
- association? メソッドを見てみると、
bullet_object_associations
スレッドローカル変数の対象のキーの値を取り出してループして associations 変数の値と一致するかどうかを判定しています
def association?(object, associations)
value = object_associations[object.bullet_key]
value&.each do |v|
result = v.is_a?(Hash) ? v.key?(associations) : associations == v
return true if result
end
false
end
object_associations
=>
- 今回の場合は v 変数も associations 変数も値が
:comments
になるため戻り値は true になり、conditions_met? メソッドが false になるため、 create_notification メソッドが呼ばれず通知は作成されないことになります
- この処理をループの回数分繰り返します
- 以上で、application 側の処理は終了して middleware 側に処理が戻ってきます
- 現時点では bullet_notification_collector スレッドローカル変数は空になっており通知は作成されていない状態です
Thread.current[:bullet_notification_collector]
=>
middleware
- ここまでは application 側の処理を見てきましたが、ここからは middleware 側の処理を見ていきます
- 処理を見ていく前に application 側の処理で変化のあったスレッドローカル変数をまとめました
Thread.current[:bullet_object_associations]
=>
Thread.current[:bullet_call_object_associations]
=>
Thread.current[:bullet_possible_objects]
=>
@registry=
{"Comment"=>
Thread.current[:bullet_inversed_objects]
=>
@registry=
{"Comment:1"=>
"Comment:2"=>
"Comment:3"=>
"Comment:4"=>
Thread.current[:bullet_eager_loadings]
=>
- 上記のスレッドローカル変数を踏まえた上で middleware 側の処理を見ていきます
- middleware 側は主に通知の表示部分を担っていますが、HTML の body に通知内容をインサートする前にいくつかの処理を経由します
- その中の一つに Bullet クラスの notification? メソッドがあります
def notification?
...
Bullet::Detector::UnusedEagerLoading.check_unused_preload_associations
notification_collector.notifications_present?
end
- Bullet クラスの notification? メソッドでは
Bullet::Detector::UnusedEagerLoading
クラスの check_unused_preload_associations メソッドを実行しています
def check_unused_preload_associations
...
object_associations.each do |bullet_key, associations|
object_association_diff = diff_object_associations bullet_key, associations
next if object_association_diff.empty?
Bullet.debug('detect unused preload', "object: #{bullet_key}, associations: #{object_association_diff}")
create_notification(caller_in_project(bullet_key), bullet_key.bullet_class_name, object_association_diff)
end
end
Bullet::Detector::UnusedEagerLoading
クラスの check_unused_preload_associations メソッドの処理を見てみると、まず object_associations をループしていることがわかります
- object_associations は bullet_object_associations スレッドローカル 変数の情報が参照されており、以下の情報がループされます
Thread.current[:bullet_object_associations]
=>
- ループの中では diff_object_associations メソッドを実行しており、結果が空では無い場合は create_notification メソッドで通知が生成されていることがわかります
def diff_object_associations(bullet_key, associations)
potential_associations = associations - call_associations(bullet_key, associations)
potential_associations.reject { |a| a.is_a?(Hash) }
end
- diff_object_associations メソッド内では associations から call_associations メソッドの結果を引いてハッシュでは無いものをリターンしています
def call_associations(bullet_key, associations)
all = Set.new
eager_loadings.similarly_associated(bullet_key, associations).each do |related_bullet_key|
coa = call_object_associations[related_bullet_key]
next if coa.nil?
all.merge coa
end
all.to_a
end
- call_associations メソッド内では bullet_eager_loadings スレッドローカル変数の similarly_associated メソッドの結果の
["Article:1", "Article:2"]
をループして、bullet_call_object_associations スレッドローカル変数に各キーが存在するかどうかを判定しています
Thread.current[:bullet_eager_loadings]
=>
@registry.select { |key, value| key.include?(base) && value == associations }.collect(&:first).flatten
=> ["Article:1", "Article:2"]
Thread.current[:bullet_call_object_associations]
=>
- bullet_call_object_associations スレッドローカル変数にキーが存在する場合は all 変数にマージして最後に配列に変換しています
- 今回の場合は、bullet_call_object_associations スレッドローカル変数には Association の情報が格納されているため call_associations メソッドの戻り値は
[:comments]
になります
- その結果、diff_object_associations メソッド内の potential_associations は nil になります
def diff_object_associations(bullet_key, associations)
potential_associations = associations - call_associations(bullet_key, associations)
potential_associations.reject { |a| a.is_a?(Hash) }
end
- check_unused_preload_associations に戻ると object_association_diff は空配列になるので、create_notification メソッドはスキップされます
- ここまでの処理で bullet_notification_collector スレッドローカル変数は空であることが確定するため、本パターンでは通知は生成されないことがわかります
Thread.current[:bullet_notification_collector]
=>
まとめ
- bullet では appication の ORM のメソッドをオーバーライドして、スレッドローカル変数に値を入れて、その値を比較しながら通知の生成を行っていることがわかりました
- 今回はソースコードを見る目的だったので分かりづらいまとめになってしまいましたが、別でもっと噛み砕いて分かりやすく説明した記事も作成しようかなと思っています