fighters48's Tech Blog

仕事で Ruby を使っている Dオタ/ファイターズファン のアウトプット用 Blog

【GAS】GASのトリガーをスプレッドシートで設定できるようにする

概要

  • Google Apps Script(以下 GAS)はトリガーを使うことでスクリプトを定期実行することができます
  • しかし、GAS のトリガーにはいくつか課題があります
  • そんな課題に対応するために自作ライブラリを作成し、GAS のトリガーをスプレッドシートで設定できるようにしました
  • ライブラリを使いスプレッドシートでトリガー設定することで以下のことが可能になります
    • トリガーを設定した本人以外もトリガーの内容について確認できる
    • トリガーの発火時刻を正確な時間で設定できる

課題

  • いつどのタイミングでトリガーを設定しているかコードを書いた本人以外わからない
    • トリガーは作成したユーザーに紐付くため他のメンバーが詳細を見ることができない
  • トリガーで正確な時間設定ができない
    • 日次、週次、月次など定期的に走るトリガーは○時〜○時など1時間単位でしか指定できず、いつ発火するか正確な時間がわからない

解決策

  • GAS のトリガーを スプレッドシートで設定できるようにする GAS ライブラリを作成しました
  • 定時実行したい GAS に本ライブラリを導入した後、スプレッドシートで関数と日時を設定し初回のみ手動で実行したらその後は希望の日時で定期実行されるようになります
  • 詳細については以下で説明します

設定方法

1. スプレッドシートを作成する

  • トリガーの定期実行を管理するスプレッドシートを作成してください
  • その際、以下のルールに従ってシートを作成してください
    • シート名は「trigger」にする
    • A列は「関数名」、B列は「頻度」、C列は「曜日/日付」、D列は「時」、E列は「分」を記入してください(※ヘッダーはあってもなくてもOK)
    • A列には定期実行したい GAS の関数名を指定する
    • B列には日次であれば「everyDay」週次であれば「everyWeek」月次であれば「everyMonth」を指定する
    • C列には日次であれば未記入、週次であれば曜日を、月次であれば月を数字で指定する(※入力方法は添付の画像を参考にしてください)
    • D列、E列には定期実行したい時間と分を指定する

2. 対象の GAS にライブラリを導入する

  • 対象の GAS に以下のライブラリを導入してください
  • その際、名前は Trigger で設定してください
// スクリプトID
1zlDXTn3wploxPLKKBskw9XZYCSBVXa3ubw1bSEhjmilLSf2dv15LKNx5
  • もし自分でライブラリ用の GAS を作成したい場合は以下を参考に作成してください
// // --------------トリガー用スニペット---------------
// Trigger.set(arguments.callee.name, ScriptApp, SheetId);
// // -------------------------------------------

function set(funcName, scriptApp, sheetId) {
  // 定数
  SHEET_ID = sheetId;
  SHEET_NAME = 'trigger';
  COLUMN_A = 1;

  // 既存のトリガーを削除する
  deleteSetTrigger_(funcName, scriptApp);

  const sheet = getSheet_(SHEET_ID, SHEET_NAME);
  const row = findRow_(sheet, funcName, COLUMN_A);

  const frequency = sheet.getRange(`B${row}`).getValue();
  const dateOrDay = sheet.getRange(`C${row}`).getValue();
  const hour = sheet.getRange(`D${row}`).getValue();
  const min = sheet.getRange(`E${row}`).getValue();

  let date = new Date();
  // 毎日設定するトリガーの場合
  if (frequency === 'everyDay') {
    date.setDate(date.getDate() + 1); // 翌日のトリガーを設定する
    date.setHours(hour);
    date.setMinutes(min);
    scriptApp.newTrigger(`${funcName}`).timeBased().at(date).create();
  };

  // 毎週設定するトリガーの場合
  if (frequency === 'everyWeek') {
    const dayNumber = getDayNumber_(dateOrDay);
    date.setDate(date.getDate() + 7 - date.getDay() + dayNumber);
    date.setHours(hour);
    date.setMinutes(min);
    scriptApp.newTrigger(`${funcName}`).timeBased().at(date).create();
  }

  // 毎月設定するトリガーの場合
  if (frequency === 'everyMonth') {
    date.setMonth(date.getMonth() + 1);
    date.setDate(dateOrDay);
    date.setHours(hour);
    date.setMinutes(min);
    scriptApp.newTrigger(`${funcName}`).timeBased().at(date).create();
  }
}

function deleteSetTrigger_(funcName, scriptApp) {
  const triggers = scriptApp.getProjectTriggers();
  for(let i=0; i<triggers.length; i++){
    if(triggers[i].getHandlerFunction() === `${funcName}`){
      scriptApp.deleteTrigger(triggers[i]);
    }
  }
}

function findRow_(sheet,val,col) {
  const data = sheet.getDataRange().getValues();

  for(let i=1; i<data.length; i++){
    if(data[i][col-1] === val){
      return i+1;
    }
  }
  return 0; 
}

function getSheet_(sheetId, sheetName) {
  const spreadSheet = SpreadsheetApp.openById(sheetId);
  const sheet = spreadSheet.getSheetByName(sheetName);

  return sheet;
}

// 曜日の数値を返却する(日曜日:0、土曜日:6)
function getDayNumber_(day) {
  const dayNumbers = {
    'Sunday': 0,
    'Monday': 1,
    'Tuesday': 2,
    'Wednesday': 3,
    'Thursday': 4,
    'Friday': 5,
    'Saturday': 6,
  };

  return dayNumbers[day];
}

3. GAS にスニペットを貼り付ける

  • ライブラリを導入したら対象の GAS 関数の最後の方に以下のスニペットを貼り付けてください
  • 第3引数の sheetId には手順1で作成したスプレッドシートのシートIDを記入してください
// --------------トリガー用スニペット---------------
sheetId = 'xxxxxxxxxxxxxxxxxxxxxxx';
Trigger.set(arguments.callee.name, ScriptApp, sheetId);
// -------------------------------------------
  • 初回のみ手動で実行したらその後はスプレッドシートで設定した日時で定期実行されるようになります

成果

  • 実際に GAS トリガーをスプレッドシートで管理することで以下の成果を得ることができました
    • ブラックボックス化していたトリガーの内容をチーム間で共有できるようになった
    • トリガーの発火時刻を正確な時間で設定できるようになった
    • トリガーを設定した本人以外も発火時間の編集ができるようになり、属人化を防ぐことができるようになった

おわりに

  • GAS トリガーの管理に課題を抱えている方のお役に立てたら幸いです!

【MySQL】Using temporary と Using filesort が出るクエリについて

はじめに

  • MySQL にてクエリを EXPLAIN すると Using temporary と Using filesort が同時に表示されることがある
  • 調べてみるとこの2つが表示されたらクエリ改善が必須と出てくるが、そもそもなぜ表示されるのか?どういう時に表示されるのか?
  • 今回はこちらについて調査した

先に結論

  • Using temporary と Using filesort は内部表でソートをした場合に表示され、インデックスを用いた改善は難しい
  • 駆動表を使ったソートで Using filesort が表示された場合はインデックスで改善できる可能性がある

内容

Using temporary / Using filesort とは

  • まず、そもそも Using temporary と Using filesort とは何か
  • MySQL のドキュメントにはそれぞれ以下のように記載されている

・Using filesort (JSON プロパティ): using_filesort )
MySQL はソート順で行を取得する方法を見つけるために、追加のパスを実行する必要があります。 ソートは、結合型に従ってすべての行を進み、ソートキーと WHERE 句に一致するすべての行について行へのポインタを格納して実行されます。 次にキーがソートされ、ソート順で行が取得されます。 セクション8.2.1.16「ORDER BY の最適化」を参照してください。

・Using temporary (JSON プロパティ): using_temporary_table )
クエリーを解決するために、MySQL は結果を保持する一時テーブルを作成する必要があります。 これは一般に、クエリーに、カラムを異なって一覧表示する GROUP BY 句と ORDER BY 句が含まれる場合に発生します。

引用元:https://dev.mysql.com/doc/refman/8.0/ja/explain-output.html

  • どうやら Using filesort は、インデックスを使用して ORDER BY 句を満たすことができず、テーブルの行を読み取ってソートする追加操作をした際に表示されるっぽい
  • Using temporary は、結果を保持するために一時テーブルを作成する必要がある場合に表示されるっぽい
  • 説明だけ見てもわかりづらいので、ここからは Using filesort と Using temporary について具体例を用いながら見ていく

NLJの仕組み

  • 具体例を見ていく前に MySQL の結合処理である NLJ について軽く触れておく
  • NLJ は Nested Loop Join の略で、MySQL では結合処理は基本的に NLJ しか実装されていない
  • 例えば、以下のようなクエリの場合
SELECT
  *
FROM
  table1, table2
WHERE
  table1.column1 = 1
  AND table1.column2 = table2.column3;
  • NLJ が結果を返す流れは以下のようになる
for record1 in fetch(table1, { "column1": 1 }) # ①
  for record2 in fetch(table2, { "column3": record1.column2 }) # ②
    return (record1 + record2) # ③

① 最初の for 文で table1から条件に合致するレコードを1レコード取得

② 次の for 文で ①の結果を元に条件に合致するレコードをtable2から1レコード取得

③ 結果を返す

  • 要は Nested Loop という名前の通り、ループの中でさらにループをしながら結合処理を行っている
  • この時、外側でループしている table1 を駆動表、内側でループしている table2 を内部表という

駆動表でソートするパターン

  • ここからは上記を踏まえた上で、以下のテーブル構成の DB に問い合わせをするクエリを見ながら内部でどのような処理が行われているのかを見ていく

  • まず以下のクエリを見てみる
SELECT
    `answers` .*
FROM
    `answers`
INNER JOIN `questions` `question` ON
    `question`.`id` = `answers`.`question_id`
WHERE
    `question`.`user_id` = 100
  • このクエリはユーザーID 100のユーザーが投稿した質問に対する回答を取得している
  • このクエリの実行計画を確認すると、rows も小さく filtered も 100% で インデックスが適切に使われており、特段問題がないクエリであることがわかる
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE question ref PRIMARY,user_id user_id 5 const 1 100 Using index
1 SIMPLE answers ref question_id question_id 5 question.id 3 100
  • では、次に以下のクエリを見てみる
SELECT
    `answers` .*
FROM
    `answers`
INNER JOIN `questions` `question` ON
    `question`.`id` = `answers`.`question_id`
WHERE
    `question`.`user_id` = 100
ORDER BY
    `question`.`created` DESC
  • このクエリはユーザーID 100のユーザーが投稿した質問に対する回答を「質問投稿日時の降順で」取得している
  • こんな要件はほとんどなさそうだが、説明の都合上このクエリで見ていく
  • このクエリの実行計画を確認すると、Extra の項目に Using filesort が表示されており改善が必要であることがわかる
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE question ref PRIMARY,user_id user_id 5 const 3 100 Using filesort
1 SIMPLE answers ref question_id question_id 5 question.id 3 100
  • 今回の実行計画をコードに落とし込むと以下のようになる
for question in sort(fetch(questions, { "user_id": 100 }))
  for answer in fetch(answers, { "question_id": question.id })
    return(answer)
  • 1行目の sort の処理が クイックソートの処理にあたり、インデックスを使用して ORDER BY 句を満たすことができていないため、filesort の処理を行っていることがわかる
  • そのため実行計画の Extra の項目に Using filesort が表示されてしまっている
  • このケースについてはインデックスを使うことで filesort の処理が必要なくなるため、Using filesort の表示を消すことができそう
  • 実際に questions テーブルに、user_id と created の複合インデックス(user_id_created)を貼って確かめてみる
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE question ref PRIMARY,user_id,user_id_created user_id_created 5 const 3 100 Backward index scan; Using index
1 SIMPLE answers ref question_id question_id 5 question.id 3 100
  • key の項目を確認すると user_id_created のインデックスが使われており、Extra の項目からも Using filesort が消えていることがわかる
  • 今回の実行計画をコードに落とし込むと以下のようになる
for question in fetch(questions, { "user_id": 100 })
  for answer in fetch(answers, { "question_id": question.id })
    return(answer)
  • インデックスを貼っているため、あらかじめ質問を user_id ごとにグルーピングし、その中で created 順で並べ替えた状態でフェッチできるため filesort 処理をしていないことがわかる
  • このパターンでは駆動表(questions)のカラムでソート処理を行っているため、インデックスを使ってクエリを、改善することができた

内部表でソートするパターン

  • 次に以下のクエリを見ていく
SELECT
    `answers` .*
FROM
    `answers`
INNER JOIN `questions` `question` ON
    `question`.`id` = `answers`.`question_id`
WHERE
    `question`.`user_id` = 100
ORDER BY
    `answers`.`created` DESC
  • このクエリはユーザーIDが100のユーザーが投稿した質問に対する回答を「回答日時の降順で」取得している
  • 先に結論を言うと、このクエリはどんなインデックスを使っても改善できない可能性が高い
  • 理由は ORDER BY に内部表(answers)を指定しているから
  • 実際にこのクエリの実行計画を確認すると、Extra の項目に Using temporary と Using filesort が表示されていることがわかる
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE question ref PRIMARY,user_id user_id 5 const 3 100 Using index; Using temporary; Using filesort
1 SIMPLE answers ref question_id question_id 5 question.id 3 100
  • 今回の実行計画をコードに落とし込むと以下のようになる
temporary_table = []

for question in fetch(questions, { "user_id": 100 })
  for answer in fetch(answers, { "question_id": question.id })
    temporary_table.push([question, answer])

# temporary_table内でanswers.createdの降順に並び替えを行う
for question, answer in sort(temporary_table)
  return(answer)
  • まず条件に合う質問と回答をテンポラリテーブルに入れ、そのテンポラリテーブルのレコードをソートして結果をリターンしている
  • この最初のステップのテンポラリテーブルへの push が Using temporary で、次のステップのソート処理が Using filesort にあたる
  • このように内部表を使って ORDER BY を行っているクエリでは一時テーブルにデータを展開した後 filesort を実行してデータのソートを行ってから結果を返すため、実行されるクエリは遅くなる傾向にある
  • 例えば、上記の例でユーザーID 100のユーザーが100件の質問をしており、各質問に対して100件の回答がついていた場合、レコード数1万件(100 × 100)のテンポラリテーブルを作った上で、さらにそのテーブルをソートする処理が走ることになる
  • そう考えると Using temporary と Using filesort のクエリ改善が必要である理由がわかるかもしれない

おわりに

  • 今回は 内部表で ORDER BY をすると Using temporary, Using filesort になってしまうことを確認したが、Using temporary, Using filesort が表示されるパターンは他にもありそう(こちらが参考になりそう)
  • 他のパターンに出会った際は別記事でアウトプットしたい

【Rails】子がいる親だけをフィルタリングする処理で N+1 が発生していたので改善する

はじめに

  • 下記のようなアソシエーション、スコープ、メソッドを持った Rails アプリケーションがあったとする
class Question < ApplicationRecord
  has_many :answers
  has_many :popular_answers, -> { popular }, class_name: 'Answer'
  has_one :best_answer
  
  scope :published, -> { where(is_published: true) }
  
  def has_best_answer?
    BestAnswer.exists?(question_id: id)
  end
end

class Answer < ApplicationRecord
  belongs_to :question
  
  scope :popular, -> { where('like_count > 2').order(like_count: :desc) }
end

class BestAnswer < ApplicationRecord
  belongs_to :answer
  belongs_to :question
end
  • このアプリケーションで下記の条件を満たす質問5件を取得したい
    • いいね数が2件以上の回答が存在する
    • ベストアンサーが存在する
    • 公開済み
  • その際に filter メソッド内でクエリが発行されており N+1 が発生してしまっていた
Question.published.filter { |question| question.popular_answers.exists? & question.has_best_answer? }.take(5)
SELECT `questions`.* FROM `questions` WHERE `questions`.`is_published` = TRUE ORDER BY `questions`.`posted` DESC;

SELECT 1 AS one FROM `answers` WHERE `answers`.`question_id` = 1 AND (like_count > 2) LIMIT 1; 
SELECT 1 AS one FROM `best_answers` WHERE `best_answers`.`question_id` = 1 LIMIT 1;
SELECT 1 AS one FROM `answers` WHERE `answers`.`question_id` = 2 AND (like_count > 2) LIMIT 1; 
SELECT 1 AS one FROM `best_answers` WHERE `best_answers`.`question_id` = 2 LIMIT 1;
SELECT 1 AS one FROM `answers` WHERE `answers`.`question_id` = 3 AND (like_count > 2) LIMIT 1; 
SELECT 1 AS one FROM `best_answers` WHERE `best_answers`.`question_id` = 3 LIMIT 1;
SELECT 1 AS one FROM `answers` WHERE `answers`.`question_id` = 4 AND (like_count > 2) LIMIT 1; 
SELECT 1 AS one FROM `best_answers` WHERE `best_answers`.`question_id` = 4 LIMIT 1;
SELECT 1 AS one FROM `answers` WHERE `answers`.`question_id` = 5 AND (like_count > 2) LIMIT 1; 
SELECT 1 AS one FROM `best_answers` WHERE `best_answers`.`question_id` = 5 LIMIT 1; 
  • 今回はこの N+1 を解消する方法を考えたい

先に結論

  • 子レコードが存在する親レコードだけをフィルタリングする際は INNER JOIN × DISTINCT ではなく EXISTS を使ったセミジョインで絞り込むのが良さそう

内容

実行環境

INNER JOIN × DISTINCT

  • まず最初に試したのがこの方法
Question.published.joins(:popular_answers, :best_answer).distinct.take(5)
  • この処理では以下のようなクエリが発行される
SELECT
    DISTINCT `questions` .*
FROM
    `questions`
INNER JOIN `answers` ON
    `answers`.`question_id` = `questions`.`id`
    AND (like_count > 2)
INNER JOIN `best_answers` ON
    `best_answers`.`question_id` = `questions`.`id`
WHERE
    `questions`.`is_published` = TRUE
LIMIT 5
  • この実装では has_many アソシエーションの answers テーブルを JOIN すると questions テーブルで大量の重複行が発生してしまうため distinct で重複行を排除しているが、このクエリでは Using temporary が発生しておりパフォーマンスが悪化してしまう恐れがある
id select_type table partitions type possible_keys key key_len ref rows filteres Extra
1 SIMPLE best_answers index question_id question_id 4 3282 100 Using index; Using temporary
1 SIMPLE questions eq_ref PRIMARY PRIMARY 4 best_answers.question_id 1 10 Using where
1 SIMPLE answers ref question_id question_id 5 best_answers.question_id 3 33.33 Using where; Distinct
  • rack-lineprof を見ると先ほどの filter の処理よりは改善できていることが確認できた

EXISTS によるセミジョイン

  • 次に試したのがこの方法
Question.published.exists_popular_answers.exists_best_answer.take(5)

class Question < ApplicationRecord
  scope :exists_popular_answers, -> do
    where(
      'EXISTS (:answers)',
      answers: Answer.where(
        'answers.question_id = questions.id AND answers.like_count > 2'
      )
    )
  end

  scope :exists_best_answer, -> do
    where(
      'EXISTS (:best_answer)',
      best_answer: BestAnswer.where(
        'best_answers.question_id = questions.id'
      )
    )
  end
end
  • この処理では以下のようなクエリが発行される
SELECT
    `questions` .*
FROM
    `questions`
WHERE
    `questions`.`is_published` = TRUE
    AND (EXISTS (
    SELECT
        `answers` .*
    FROM
        `answers`
    WHERE
        (answers.question_id = questions.id
            AND answers.like_count > 2)))
    AND (EXISTS (
    SELECT
        `best_answers` .*
    FROM
        `best_answers`
    WHERE
        (best_answers.question_id = questions.id)))
LIMIT 5;
  • この処理にすると questions レコードの重複行が発生しなくなり、DISTINCT の処理が不要になるため Using temporary も発生しない
  • この場合はセミジョイン (FirstMatch(questions)) の処理になる
id select_type table partitions type possible_keys key key_len ref rows filteres Extra
1 SIMPLE best_answers index question_id question_id 4 3282 100 Using index
1 SIMPLE questions eq_ref PRIMARY PRIMARY 4 best_answers.question_id 1 10 Using where
1 SIMPLE answers ref question_id question_id 5 best_answers.question_id 3 33.33 Using where; FirstMatch(questions)
  • ちなみにセミジョインの FirstMatch だと、questions を駆動表としてサブクエリ側のループを回しているときに1行でもマッチするものが見つかったら即座にサブクエリ側のループを打ち切って駆動表のループの次の周回に進むことができるため効率が良くなる
  • rack-lineprof を見ると先ほどの INNER JOIN × DISTINCT の処理よりは改善できていることが確認できた

まとめ

  • 子レコードが存在する親レコードだけをフィルタリングする際は INNER JOIN × DISTINCT ではなく EXISTS を使ったセミジョインで絞り込むのが良さそう

【Rails】bullet 通知生成の仕組み 〜ソースコードリーディング〜

はじめに

  • 本記事では Ruby のライブラリである bullet の通知生成の仕組みについて、ソースコードを見ながら追っていきます
  • bullet の全体的な説明については こちら の記事で行なっているので、こちらを参考にしてください

内容

  • 本項目では、通知の生成について以下4つのパターンに分けて見ていきます
1. Article.all.to_a

2. Article.includes(:comments).to_a # 不要な includes パターン

3. Article.all.each do |article| # N+1 発生パターン
     article.comments.to_a
   end

4. Article.includes(:comments).each do |article|
     article.comments.to_a
   end
   
   
# Article は2件でそれぞれの Article に3件のコメントが紐づいている

# class Article < ApplicationRecord
#   has_many :comments
# end
#

# class Comment < ApplicationRecord
#   belongs_to :article
# 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::Registry::Object:0x000000010e020260 @registry={"Article"=>#<Set: {"Article:1", "Article:2"}>}>
  • このパターンでは、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::Registry::Base:0x000000010ee767d8 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>
  • 今回の場合は、各記事にコメントが紐づいているという情報が格納されています
  • また、Bullet::Detector::UnusedEagerLoading クラスの add_eager_loadings メソッドによって bullet_eager_loadings に Article のイーガーローディングの情報が代入されます
Thread.current[:bullet_eager_loadings]
=> #<Bullet::Registry::Association:0x000000010ee76468 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>
  • 次に 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]
=> #<Bullet::Registry::Base:0x000000010ee767d8 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>

Thread.current[:bullet_eager_loadings]
=> #<Bullet::Registry::Association:0x000000010ee76468 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>
  • 次に call メソッドの super メソッドの後続処理で inversed_from メソッドが呼ばれます
  • inversed_from メソッドでは Bullet::Detector::NPlusOneQuery クラスの add_inversed_object メソッドによって以下のように bullet_inversed_objects スレッドローカル変数に belongs_to 関連の情報が代入されます
Thread.current[:bullet_inversed_objects]
=> #<Bullet::Registry::Base:0x000000010ee76558
 @registry=
  {"Comment:1"=>#<Set: {:article}>,
   "Comment:2"=>#<Set: {:article}>,
   "Comment:3"=>#<Set: {:article}>,
   "Comment:4"=>#<Set: {:article}>,
   "Comment:5"=>#<Set: {:article}>,
   "Comment:6"=>#<Set: {:article}>}>
  • その後 records メソッドが呼ばれます
  • この records メソッドは、最初に呼ばれた records メソッドの super メソッド内で呼ばれたもので、最初の records メソッドとは別物です
  • ここでは Bullet::Detector::NPlusOneQuery クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Comment の情報が代入されます
Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010ed5fe80
 @registry={"Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3", "Comment:4", "Comment:5", "Comment:6"}>}>
  • ここまでが 最初に呼ばれた records メソッドの super メソッドの処理です
  • ここからは後続の処理が行われます
  • 後続の処理では Bullet::Detector::NPlusOneQuery クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Article の情報が追加されます
Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010ed5fe80
 @registry=
  {"Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3", "Comment:4", "Comment:5", "Comment:6"}>, "Article"=>#<Set: {"Article:1", "Article:2"}>}>
  • 以上で、application 側の処理は終了して middleware 側に処理が戻ります
  • 現時点では bullet_notification_collector スレッドローカル変数は空のため、通知は作成されていないことがわかります
Thread.current[:bullet_notification_collector]
=> #<Bullet::NotificationCollector:0x000000010ecf03c8 @collection=#<Set: {}>>

middleware

  • ここまでは application 側の処理を見てきましたが、ここからは middleware 側の処理を見ていきます
  • 処理を見ていく前に application 側の処理で変化のあったスレッドローカル変数をまとめました
Thread.current[:bullet_object_associations]
=> #<Bullet::Registry::Base:0x000000010ecca600 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>

Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010ed5fe80
 @registry=
  {"Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3", "Comment:4", "Comment:5", "Comment:6"}>, "Article"=>#<Set: {"Article:1", "Article:2"}>}>
  
Thread.current[:bullet_inversed_objects]
=> #<Bullet::Registry::Base:0x000000010ed54710
 @registry=
  {"Comment:1"=>#<Set: {:article}>,
   "Comment:2"=>#<Set: {:article}>,
   "Comment:3"=>#<Set: {:article}>,
   "Comment:4"=>#<Set: {:article}>}>
 
Thread.current[:bullet_eager_loadings]
=> #<Bullet::Registry::Association:0x000000010ed4cad8 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>
  • 上記のスレッドローカル変数を踏まえた上で 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]
=> #<Bullet::Registry::Base:0x000000010ecca600 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>
  • ループの中では 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]
=> #<Bullet::Registry::Association:0x000000010f025f98 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>

@registry.select { |key, value| key.include?(base) && value == associations }.collect(&:first).flatten
=> ["Article:1", "Article:2"]

Thread.current[:bullet_call_object_associations]
=> #<Bullet::Registry::Base:0x000000010e598eb8 @registry={}>
  • 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]
=> #<Bullet::NotificationCollector:0x000000010e598f80
 @collection=
  #<Set:
   {#<Bullet::Notification::UnusedEagerLoading:0x000000010f054f28
     @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]
=> #<Bullet::Registry::Object:0x000000010e020260 @registry={"Article"=>#<Set: {"Article:1", "Article:2"}>}>
  • inversed_from メソッドでは Bullet::Detector::NPlusOneQuery クラスの add_inversed_object メソッドによって以下のように bullet_inversed_objects スレッドローカル変数に belongs_to 関連の情報が代入されます
Thread.current[:bullet_inversed_objects]
=> #<Bullet::Registry::Base:0x000000010e36d0a8
 @registry={"Comment:1"=>#<Set: {:article}>, "Comment:2"=>#<Set: {:article}>, "Comment:3"=>#<Set: {:article}>}>
  • find_by_sql メソッドでは Bullet::Detector::NPlusOneQuery クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Association 先の Comment の情報が追加されます
Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010e36d1e8
 @registry={"Article"=>#<Set: {"Article:1", "Article:2"}>, "Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3"}>}>
  • load_target メソッドでは、まず Bullet::Detector::NPlusOneQuery クラスの call_association メソッドによって以下のように bullet_call_object_associations スレッドローカル変数に Association の情報が代入されます
Thread.current[:bullet_call_object_associations]
=> #<Bullet::Registry::Base:0x000000010e346660 @registry={"Article:1"=>#<Set: {:comments}>}>
  • その後、以下の conditions_met? メソッドが true の場合に create_notification メソッドで通知が生成される仕組みになっています
def conditions_met?(object, associations)
  possible?(object) && !impossible?(object) && !association?(object, associations)
end
# object
# => Articleインスタンス(id: 1)
# asssociations
# => :comments
  • create_notification メソッドでは bullet_notification_collector スレッドローカル変数に通知で使用する情報が代入されます
Thread.current[:bullet_notification_collector]
=> #<Bullet::NotificationCollector:0x0000000106f08be0
 @collection=
  #<Set:
   {#<Bullet::Notification::NPlusOneQuery:0x00000001064cb740
     @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]
=> #<Bullet::Registry::Base:0x000000010ee767d8 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>

Thread.current[:bullet_eager_loadings]
=> #<Bullet::Registry::Association:0x000000010ee76468 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>
  • 次に 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]
=> #<Bullet::Registry::Base:0x000000010ee767d8 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>

Thread.current[:bullet_eager_loadings]
=> #<Bullet::Registry::Association:0x000000010ee76468 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>
  • 次に call メソッドの super メソッドの後続処理で inversed_from メソッドが呼ばれます
  • inversed_from メソッドでは Bullet::Detector::NPlusOneQuery クラスの add_inversed_object メソッドによって以下のように bullet_inversed_objects スレッドローカル変数に belongs_to 関連の情報が代入されます
Thread.current[:bullet_inversed_objects]
=> #<Bullet::Registry::Base:0x000000010ee76558
 @registry=
  {"Comment:1"=>#<Set: {:article}>,
   "Comment:2"=>#<Set: {:article}>,
   "Comment:3"=>#<Set: {:article}>,
   "Comment:4"=>#<Set: {:article}>,
   "Comment:5"=>#<Set: {:article}>,
   "Comment:6"=>#<Set: {:article}>}>
  • その後 records メソッドが呼ばれます
  • この records メソッドは、最初に呼ばれた records メソッドの super メソッド内で呼ばれたもので、最初の records メソッドとは別物です
  • ここでは Bullet::Detector::NPlusOneQuery クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Comment の情報が代入されます
Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010ed5fe80
 @registry={"Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3", "Comment:4"}>}>
  • ここまでが 最初に呼ばれた records メソッドの super メソッドの処理です
  • ここからは後続の処理が行われます
  • 後続の処理では Bullet::Detector::NPlusOneQuery クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Article の情報が追加されます
Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010ed5fe80
 @registry=
  {"Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3", "Comment:4", "Comment:5", "Comment:6"}>, "Article"=>#<Set: {"Article:1", "Article:2"}>}>
  • ここまでは 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]
=> #<Bullet::Registry::Base:0x000000010e346660 @registry={"Article:1"=>#<Set: {:comments}>}>
  • その後、以下の conditions_met? メソッドが true の場合に create_notification メソッドで通知が生成される仕組みになっています
def conditions_met?(object, associations)
  possible?(object) && !impossible?(object) && !association?(object, associations)
end
# object
# => Articleインスタンス(id: 1)
# asssociations
# => :comments
  • 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
=> #<Bullet::Registry::Base:0x000000010ade37e8 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>
  • 今回の場合は v 変数も associations 変数も値が :comments になるため戻り値は true になり、conditions_met? メソッドが false になるため、 create_notification メソッドが呼ばれず通知は作成されないことになります
  • この処理をループの回数分繰り返します
  • 以上で、application 側の処理は終了して middleware 側に処理が戻ってきます
  • 現時点では bullet_notification_collector スレッドローカル変数は空になっており通知は作成されていない状態です
Thread.current[:bullet_notification_collector]
=> #<Bullet::NotificationCollector:0x000000010ecf03c8 @collection=#<Set: {}>>

middleware

  • ここまでは application 側の処理を見てきましたが、ここからは middleware 側の処理を見ていきます
  • 処理を見ていく前に application 側の処理で変化のあったスレッドローカル変数をまとめました
Thread.current[:bullet_object_associations]
=> #<Bullet::Registry::Base:0x000000010ecca600 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>

Thread.current[:bullet_call_object_associations]
=> #<Bullet::Registry::Base:0x000000010ade3720 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>

Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010ed5fe80
 @registry=
  {"Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3", "Comment:4"}>, "Article"=>#<Set: {"Article:1", "Article:2"}>}>
  
Thread.current[:bullet_inversed_objects]
=> #<Bullet::Registry::Base:0x000000010ed54710
 @registry=
  {"Comment:1"=>#<Set: {:article}>,
   "Comment:2"=>#<Set: {:article}>,
   "Comment:3"=>#<Set: {:article}>,
   "Comment:4"=>#<Set: {:article}>}>
 
Thread.current[:bullet_eager_loadings]
=> #<Bullet::Registry::Association:0x000000010ed4cad8 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>
  • 上記のスレッドローカル変数を踏まえた上で 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]
=> #<Bullet::Registry::Base:0x000000010ecca600 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>
  • ループの中では 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]
=> #<Bullet::Registry::Association:0x000000010f025f98 @registry={["Article:1", "Article:2"]=>#<Set: {:comments}>}>

@registry.select { |key, value| key.include?(base) && value == associations }.collect(&:first).flatten
=> ["Article:1", "Article:2"]

Thread.current[:bullet_call_object_associations]
=> #<Bullet::Registry::Base:0x000000010ade3720 @registry={"Article:1"=>#<Set: {:comments}>, "Article:2"=>#<Set: {:comments}>}>
  • 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::NotificationCollector:0x000000010ecf03c8 @collection=#<Set: {}>>

まとめ

  • bullet では appication の ORM のメソッドをオーバーライドして、スレッドローカル変数に値を入れて、その値を比較しながら通知の生成を行っていることがわかりました
  • 今回はソースコードを見る目的だったので分かりづらいまとめになってしまいましたが、別でもっと噛み砕いて分かりやすく説明した記事も作成しようかなと思っています

【Rails】bulletの仕組み 〜ソースコードリーディング〜

bulletとは

https://github.com/flyerhzm/bullet

The Bullet gem is designed to help you increase your application's performance by reducing the number of queries it makes. It will watch your queries while you develop your application and notify you when you should add eager loading (N+1 queries), when you're using eager loading that isn't necessary and when you should use counter cache.

  • Bullet は、アプリケーションの開発中にクエリを監視し、いつイーガーローディングを追加すべきか、いつ不要なイーガーローディングを使用しているか、いつカウンターキャッシュを使用すべきかを通知してくれる gem です

パフォーマンスの改善点について教えてくれるライブラリです

機能について

  • N+1が発生しているページを開くと、以下のようなポップアップ警告を表示してくれます

  • N+1が発生しているページを開くと、ページの左下部に警告の詳細を表示してくれます

  • 表示してくれる警告は以下の3種類があります
# N+1クエリ通知パターン
USE eager loading detected:
    Article => [:commentsAdd to your query: .includes([:comments])
  
# 未使用のイーガーローディング通知パターン
AVOID eager loading detected
  Article => [:commentsRemove from your query: .includes([:comments])
  
# カウンターキャッシュ通知パターン
Need Counter Cache
  Article => [:comments]

設定方法について

  • gemをインストールした後に、config/environments/development.rbに以下のコードを追加することで通知システムを有効化できます
config.after_initialize do
  Bullet.enable = true
  Bullet.sentry = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.console = true
  Bullet.xmpp = { :account  => 'bullets_account@jabber.org',
                  :password => 'bullets_password_for_jabber',
                  :receiver => 'your_account@jabber.org',
                  :show_online_status => true }
  Bullet.rails_logger = true
  Bullet.honeybadger = true
  Bullet.bugsnag = true
  Bullet.appsignal = true
  Bullet.airbrake = true
  Bullet.rollbar = true
  Bullet.add_footer = true
  Bullet.skip_html_injection = false
  Bullet.stacktrace_includes = [ 'your_gem', 'your_middleware' ]
  Bullet.stacktrace_excludes = [ 'their_gem', 'their_middleware', ['my_file.rb', 'my_method'], ['my_file.rb', 16..20] ]
  Bullet.slack = { webhook_url: 'http://some.slack.url', channel: '#default', username: 'notifier' }
end
  • それぞれ以下のような設定になっています(※主要なものだけ抜粋)
    • Bullet.enable : Bullet gemを有効化する
    • Bullet.alert : ブラウザにJavaScriptのアラートをポップアップする
    • Bullet.bullet_logger : Bulletのログファイル(Rails.root/log/bullet.log)にログを記録する
    • Bullet.console : ブラウザのconsole.logに警告ログを出力する
    • Bullet.rails_logger : Railsのログファイルに直接警告を追加する
    • Bullet.sentry : sentryに通知を追加する
    • Bullet.add_footer : ページの左下に詳細を追加する
    • Bullet.slack:slackに通知を追加する
    • Bullet.raise : エラーを発生させる。最適化されたクエリでない限り仕様を失敗させるのに便利
    • Bullet.always_append_html_body:notificationが存在しない場合でも、常にhtmlボディを追加する(SPAで、最初のページロード時に通知がない場合に便利)
  • 今回は以下のような設定で Bullet のコードを見ていきます
config.after_initialize do
  Bullet.enable        = true
  Bullet.alert         = false
  Bullet.bullet_logger = true
  Bullet.console       = true
  Bullet.rails_logger  = true
  Bullet.add_footer    = true
end

どのように実現しているのか?

ここからはどのように機能を実現しているのかソースコードを見ていきます

概要

  • 処理の概要は以下のようになっています
    1. Rack application に Bullet::Rack middleware を追加する
    2. Rack application で使用している ORM(今回の場合は ActiveRecord 7.1系)のメソッドをオーバーライドすることで、 ORM の機能に Bullet の処理をフックする
    3. オーバーライドしたメソッドで N+1 クエリを集計して、その結果を Bullet::Rack middleware でレスポンスに追加する

詳細

  • 今回は以下のコードをベースに bullet の処理を見ていきます
# ruby 3.1.4
# rails 7.1.0

# Article は 2 件でそれぞれの Article に 3 件のコメントが紐づいている
Article.all.each do |article|
  article.comments.to_a
end

# class Article < ApplicationRecord
#   has_many :comments
# end
#

# class Comment < ApplicationRecord
#   belongs_to :article
# end
  • まずプロジェクト内で bullet gem の lib ディレクトリ配下の bullet.rb が読み込まれます
    • 詳細については Bundler.require(*Rails.groups) などで調べると出てくるので割愛します
  • その際、bullet.rb の処理で Rails::Railtie クラスが存在している場合は Bullet::BulletRailtie クラスが定義され、initializer で Bullet::Rack middleware が Rack application の middleware として追加されます
module Bullet
  if defined?(Rails::Railtie)
    class BulletRailtie < Rails::Railtie
      initializer 'bullet.configure_rails_initialization' do |app|
        if defined?(ActionDispatch::ContentSecurityPolicy::Middleware) && Rails.application.config.content_security_policy
          app.middleware.insert_before ActionDispatch::ContentSecurityPolicy::Middleware, Bullet::Rack
        else
          app.middleware.use Bullet::Rack
        end
      end
    end
  end
end

ORM のオーバーライド

  • 次に ORM のオーバーライドについて見ていきます
  • lib/bullet.rb の読み込み時に ActiveRecord クラスが定義されている場合、active_record? の戻り値が ‘constant’ になり、ActiveRecord のバージョンに合わせたファイルが autoload されます
  • 今回は Rails7.1 を使っているので bullet/active_record71.rb がロードされます
# /lib/bullet.rb
module Bullet
  autoload :ActiveRecord, "bullet/#{active_record_version}" if active_record?
end

# /lib/bullet/dependency.rb
module Bullet
  module Dependency
    def active_record?
      @active_record ||= defined?(::ActiveRecord)
    end
  end
end
  • Rack application の development.rb で書いた Bullet.enable = true の処理を経て、先ほど autoload した Bullet::ActiveRecord モジュールの enable メソッドが呼ばれます
module Bullet
  def enable=(enable)
    @enable = @n_plus_one_query_enable = @unused_eager_loading_enable = @counter_cache_enable = enable

    if enable?
      reset_safelist
      unless orm_patches_applied
        self.orm_patches_applied = true
        Bullet::Mongoid.enable if mongoid?
        Bullet::ActiveRecord.enable if active_record?
      end
    end
  end

  def enable?
    !!@enable
  end
end
  • Bullet::ActiveRecord モジュールの enable メソッドを見てみると ActiveRecord::Base や ActiveRecord::Relation に対して prepend メソッドでパッチを当てています(find_by_sql のみ extend で拡張している)
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
  • prepend メソッドで ORM のメソッドに対してオーバーライドを行い、Bullet の処理を噛ませた上で super メソッドの処理を行うことで N+1 関連の集計を実現しています

Rack middleware

  • ここからは 先ほど middleware として追加した Bullet::Rack を見ていきます
  • その前に少しだけ Rack middleware について触れます
  • Rack middleware は渡された env の情報を加工して、次の middleware または application に処理の受け渡しを行う役割を担っています
  • Rack middleware は以下の条件を満たしている必要があります
    1. class として実装されていること
    2. initialize で app を受け取ること
    3. call メソッドを実装して、レスポンスとして status, headers, body を返すこと
class SampleRackMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)

    sample_body = body + ["Add Sample Rack Middleware!\n"]

    [status, headers, sample_body]
  end
end

use SampleRackMiddleware
  • 上記のように Rack middleware を class として実装し、useメソッドを呼び出すことで Rack application に middleware を追加できます

Bullet::Rack

  • 上記を踏まえた上で Bullet::Rack を見ていきます
module Bullet
  class Rack
    def initialize(app)
      @app = app
    end

    def call(env)
      return @app.call(env) unless Bullet.enable?

      Bullet.start_request
      status, headers, response = @app.call(env)

      response_body = nil

      if Bullet.notification? || Bullet.always_append_html_body
        if Bullet.inject_into_page? && !file?(headers) && !sse?(headers) && !empty?(response) && status == 200
          if html_request?(headers, response)
            response_body = response_body(response)

            with_security_policy_nonce(headers) do |nonce|
              response_body = append_to_html_body(response_body, footer_note) if Bullet.add_footer
              response_body = append_to_html_body(response_body, Bullet.gather_inline_notifications)
              if Bullet.add_footer && !Bullet.skip_http_headers
                response_body = append_to_html_body(response_body, xhr_script(nonce))
              end
            end

            headers['Content-Length'] = response_body.bytesize.to_s
          elsif !Bullet.skip_http_headers
            set_header(headers, 'X-bullet-footer-text', Bullet.footer_info.uniq) if Bullet.add_footer
            set_header(headers, 'X-bullet-console-text', Bullet.text_notifications) if Bullet.console_enabled?
          end
        end
        Bullet.perform_out_of_channel_notifications(env)
      end
      [status, headers, response_body ? [response_body] : response]
    ensure
      Bullet.end_request
    end
  end
end
  • まず、大まかな流れを確認すると以下のようになります

    1. Bullet.enable? が false の場合は、 @app.call を行い次の middleware または application に処理を受け渡し、戻り値をそのままリターンする
    2. Bullet.enable? が true の場合は、Bullet.start_request した上で @app.call を行い次の middleware または application に処理を受け渡す
    3. application の処理で N+1 の集計が行われる
    4. Bullet.notification? が true の場合は、@app.call の戻り値の body に bullet の通知をインサートしてリターンする
    5. 最後にBullet.end_requestを呼び出す
  • 次項からは上記の処理を部分的に拾いながら詳細に見ていきます

Bullet.start_request / end_request

  • まずは Bullet クラスの start_request メソッド と end_request メソッドについてです
  • Bullet クラスの start_request メソッドは、 Thread を使ってグローバル変数として利用したい情報をスレッドローカル変数として格納しています
  • ここで定義したスレッドローカル変数は、 Bullet の通知や Association の情報を保持する重要な役割を担っています
module Bullet
  def start_request
    Thread.current[:bullet_start] = true
    Thread.current[:bullet_notification_collector] = Bullet::NotificationCollector.new

    Thread.current[:bullet_object_associations] = Bullet::Registry::Base.new
    Thread.current[:bullet_call_object_associations] = Bullet::Registry::Base.new
    Thread.current[:bullet_possible_objects] = Bullet::Registry::Object.new
    Thread.current[:bullet_impossible_objects] = Bullet::Registry::Object.new
    Thread.current[:bullet_inversed_objects] = Bullet::Registry::Base.new
    Thread.current[:bullet_eager_loadings] = Bullet::Registry::Association.new
    Thread.current[:bullet_call_stacks] = Bullet::Registry::CallStack.new

    Thread.current[:bullet_counter_possible_objects] ||= Bullet::Registry::Object.new
    Thread.current[:bullet_counter_impossible_objects] ||= Bullet::Registry::Object.new
  end
end
  • 一通りの処理が終わったら Bullet クラスの end_request メソッドを呼び出して、Thread で定義した値を nil でリセットしています
  • Puma のような一度作成したスレッドを再利用するようなアプリケーションサーバーを使用している場合、スレッドローカル変数が再利用前提のスレッドに紐づいてしまうため、明示的にリセットしないと過去のリクエストで定義した変数が別のリクエストで参照できてしまいます
  • このような事態を回避するために end_request メソッドでリセットを行なっています
module Bullet
  def end_request
    Thread.current[:bullet_start] = nil
    Thread.current[:bullet_notification_collector] = nil

    Thread.current[:bullet_object_associations] = nil
    Thread.current[:bullet_call_object_associations] = nil
    Thread.current[:bullet_possible_objects] = nil
    Thread.current[:bullet_impossible_objects] = nil
    Thread.current[:bullet_inversed_objects] = nil
    Thread.current[:bullet_eager_loadings] = nil

    Thread.current[:bullet_counter_possible_objects] = nil
    Thread.current[:bullet_counter_impossible_objects] = nil
  end
end

通知の生成

  • 次に Bullet 通知の生成部分について見ていきます
Article.all.each do |article| # N+1 発生パターン
   article.comments.to_a
end
  • 今回は Article.all.each でループしてそれぞれの Article のコメントを取得するパターンについて見ていきます
  • これは皆さんもお分かりの通り 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]
=> #<Bullet::Registry::Object:0x000000010e020260 @registry={"Article"=>#<Set: {"Article:1", "Article:2"}>}>
  • inversed_from メソッドでは Bullet::Detector::NPlusOneQuery クラスの add_inversed_object メソッドによって以下のように bullet_inversed_objects スレッドローカル変数に belongs_to 関連の情報が代入されます
Thread.current[:bullet_inversed_objects]
=> #<Bullet::Registry::Base:0x000000010e36d0a8
 @registry={"Comment:1"=>#<Set: {:article}>, "Comment:2"=>#<Set: {:article}>, "Comment:3"=>#<Set: {:article}>}>
  • find_by_sql メソッドでは Bullet::Detector::NPlusOneQuery クラスの add メソッドによって以下のように bullet_possible_objects スレッドローカル変数に Association 先の Comment の情報が追加されます
Thread.current[:bullet_possible_objects]
=> #<Bullet::Registry::Object:0x000000010e36d1e8
 @registry={"Article"=>#<Set: {"Article:1", "Article:2"}>, "Comment"=>#<Set: {"Comment:1", "Comment:2", "Comment:3"}>}>
  • load_target メソッドでは、まず Bullet::Detector::NPlusOneQuery クラスの call_association メソッドによって以下のように bullet_call_object_associations スレッドローカル変数に Association の情報が代入されます
Thread.current[:bullet_call_object_associations]
=> #<Bullet::Registry::Base:0x000000010e346660 @registry={"Article:1"=>#<Set: {:comments}>}>
  • その後、以下の conditions_met? メソッドが true の場合に create_notification メソッドで通知が生成される仕組みになっています
def conditions_met?(object, associations)
  possible?(object) && !impossible?(object) && !association?(object, associations)
end
# object
# => Articleインスタンス(id: 1)
# asssociations
# => :comments
  • create_notification メソッドでは bullet_notification_collector スレッドローカル変数に通知で使用する情報が代入されます
Thread.current[:bullet_notification_collector]
=> #<Bullet::NotificationCollector:0x0000000106f08be0
 @collection=
  #<Set:
   {#<Bullet::Notification::NPlusOneQuery:0x00000001064cb740
     @associations=[:comments],
     @base_class="Article",
     @callers=
      ["/Users/xxxxxxx/xxxxxxx/xxxxxxx/app/controllers/articles_controller.rb:11:in `block in index'",
       "/Users/xxxxxxx/xxxxxxx/xxxxxxx/app/controllers/articles_controller.rb:9:in `index'"],
     @path=nil>}>>
  • 次項の「通知の表示」では、この bullet_notification_collector スレッドローカル変数に代入された情報を元に通知を表示していきます

通知の表示

  • ここからは Bullet 通知の表示部分について見ていきます
  • 上述の通りで通知は Bullet::Rack middleware を通して、application の HTML body に追加されます
  • 今回は add_footer (サイトの左下に表示される赤いやつ) に絞って処理を見ていきます
module Bullet
  class Rack
    def call(env)
      Bullet.start_request
      status, headers, response = @app.call(env)

      response_body = nil

      if Bullet.notification? || Bullet.always_append_html_body
        if Bullet.inject_into_page? && !file?(headers) && !sse?(headers) && !empty?(response) && status == 200
          if html_request?(headers, response)
★           response_body = response_body(response)

            with_security_policy_nonce(headers) do |nonce|
★             response_body = append_to_html_body(response_body, footer_note) if Bullet.add_footer
              ...
            end
            ...
          end
        end
      end
★     [status, headers, response_body ? [response_body] : response]
    ensure
      Bullet.end_request
    end
  end
end
  • Bullet::Rack を見てみると @app.call の戻り値の body (response 変数) を response_body という変数に置き換えていることがわかります
  • そのため response → response_body で Bullet の通知用 HTML が追加されているのではないかと予測できます
  • そのため response_body に何が代入されているのかを見ていきます
  • まず response_body = response_body(response) は response 変数の body (HTML の文字列) を代入しています
  • この時点では Bullet の通知は追加されていません
  • 次に response_body = append_to_html_body(response_body, footer_note) を見ていきます
  • 第二引数に渡された footer_note は以下のようなメソッドになっており、通知用の HTML で構成されています
def footer_note
  "<details #{details_attributes}><summary #{summary_attributes}>Bullet Warnings</summary><div #{footer_content_attributes}>#{Bullet.footer_info.uniq.join('<br>')}#{footer_console_message}</div></details>"
end
  • この HTML の #{Bullet.footer_info.uniq.join('<br>')} という部分が div タグの内容になっているため、この部分が通知の本体であると予測できます
  • Bullet.footer_info を見てみると notification_collector.collection を each で info 配列に代入しています
def footer_info
  info = []
  notification_collector.collection.each { |notification| info << notification.short_notice }
  info
end
  • notification_collector は何か確認すると、先ほど通知の情報を代入した bullet_notification_collector スレッドローカル変数であることがわかります
def notification_collector
  Thread.current[:bullet_notification_collector]
end
  • info に代入している notification.short_notice を確認すると以下のようなメソッドになっており、デバッグしてみると以下のような見慣れた文字列が生成されていることがわかります
def short_notice
  parts = []
  parts << whoami.presence unless Bullet.skip_user_in_notification
  parts << url
  parts << title
  parts << body

  parts.compact.join('  ')
end

=> "user: horinoyuutarou  USE eager loading detected    Article => [:comments]\n  Add to your query: .includes([:comments])"
  • ここで生成した通知を append_to_html_body メソッドで response_body にインサートしていることがわかります
def append_to_html_body(response_body, content)
  body = response_body.dup
  content = content.html_safe if content.respond_to?(:html_safe)
  if body.include?('</body>')
    position = body.rindex('</body>')
    body.insert(position, content)
  else
    body << content
  end
end
  • この response_body を Rack レスポンスとしてリターンすることで Bullet 通知の表示を実現しています

まとめ

  1. Rack application に Bullet::Rack middleware を追加する
  2. Rack application で使用している ORM(JobQの場合は ActiveRecord 7.1系)のメソッドをオーバーライドすることで、 ORM の機能に Bullet の処理をフックする

~ リクエスト ~

  1. Bullet.enable? が false の場合は、 @app.call を行い次の middleware または application に処理を受け渡し、戻り値をそのままリターンする
  2. Bullet.enable? が true の場合は、Bullet.start_request した上で @app.call を行い次の middleware または application に処理を受け渡す
  3. application の処理で N+1 の集計が行われ、bullet_notification_collector スレッドローカル変数に通知情報が代入される
  4. Bullet.notification? が true の場合は、@app.call の戻り値の body に bullet の通知をインサートしてリターンする
  5. 最後にBullet.end_requestを呼び出してスレッドローカル変数の情報をリセットする

【Ruby】Module#prependとは

Module#prependとは

  • Module#prependとは、Rubyの組み込みライブラリに含まれるModuleクラスのインスタンスメソッド
  • 引数で指定したモジュールを self の継承チェーンの先頭に追加することで self の定義 (メソッド、定数) を上書きする
  • prependの引数として渡したモジュールのインスタンスメソッドでsuperを呼ぶことで self のモジュール/クラスのメソッドを呼び出すことができる
  • 引数で指定したモジュールは後ろから順に処理されるため、 modules の先頭が最も優先される
module M1
  def foo
    puts "M1#foo(1)"
    super
    puts "M1#foo(2)"
  end
end

class C1
  prepend M1

  def foo
    puts "C1#foo"
  end
end

# prependの引数として渡したモジュールのインスタンスメソッドでsuperを呼ぶことで self のモジュール/クラスのメソッドを呼び出すことができる
C1.new.foo
# => M1#foo(1)
# C1#foo
# M1#foo(2)
module M2
  def foo
    puts "M2#foo(1)"
    super
    puts "M2#foo(2)"
  end
end

class C2
  prepend M1, M2
  def foo
    puts "C2#foo"
  end
end

# 引数で指定したモジュールは後ろから順に処理されるため、 modules の先頭が最も優先される
C2.new.foo
# => M1#foo(1)
# M2#foo(1)
# C2#foo
# M1#foo(2)
# M2#foo(2)

引数について

  • 引数は可変長引数なので配列として受け取られるため複数モジュール指定可能

prepend(*modules) -> self

  • 引数に複数のモジュールを指定した場合、最後の引数から順に prepend する

Module#prependの実態

  • Module#prependの実態はModuleクラスのprepend_featuresインスタンスメソッド
  • prependメソッドはRubyで書くと以下のように定義できる
    • Module#prepend_features → 引数で指定したモジュール(またはクラス)の継承チェーンの先頭に self を追加する
    • Module#prepend → self が prepend されたときに対象のクラスまたはモジュールを引数にしてインタプリタがこのメソッドを呼び出す(フックメソッド)
def include(*modules)
  modules.reverse_each do |mod|
    # prepend_featuresやprepend はプライベートメソッドなので
    # 直接 mod.prepend_features(self) とは書けない
    mod.__send__(:prepend_features, self)
    mod.__send__(:prepended, self)
  end
end

参考:https://docs.ruby-lang.org/ja/latest/method/Module/i/prepend_features.html

  • Module#prependedメソッドはフックメソッドのため通常はメソッドの中身はなく、オーバーライドして使うもの
  • 一方で、Module#prepend_featuresはメソッドは、実際に継承チェーンの先頭にモジュールを追加する

【Ruby】Object#extendとは

Object#extendとは

  • Object#extendとは、Rubyの組み込みライブラリに含まれるObjectクラスのインスタンスメソッド
  • 引数で指定したモジュールのインスタンスメソッドを self の特異メソッドとして追加する
  • Module#includeは、クラス(のインスタンス)に機能を追加するが、extend は、ある特定のオブジェクトだけにモジュールの機能を追加したいときに使用する
module M1
  def hoge
    puts 'M1#hoge'
  end
end

module M2
  def fuga
    puts 'M2.fuga'
  end
end

class C1
  include M1
  extend M2
end

C1.new.hoge
# => M1#hoge

# 引数で指定したモジュールのインスタンスメソッドを self の特異メソッドとして追加する
C1.fuga
# => M2.fuga

引数について

  • 引数は可変長引数なので配列として受け取られるため複数モジュール指定可能

extend(*modules) -> self

  • 引数に複数のモジュールを指定した場合、最後の引数から逆順に extend を行う

Object#extendの実態

  • Object#extendの実態はModuleクラスのextend_objectインスタンスメソッド
  • extendメソッドはRubyで書くと以下のように定義できる
    • Module#extend_object → モジュール(またはクラス)に self の機能を追加する
    • Module#extended → self が extend されたときに対象のクラスまたはモジュールを引数にインタプリタが呼び出すメソッド(フックメソッド)
def extend(*modules)
  modules.reverse_each do |mod|
    # extend_object や extended はプライベートメソッドなので
    # 直接 mod.extend_object(self) などとは書けない
    mod.__send__(:extend_object, self)
    mod.__send__(:extended, self)
  end
end

参考:https://docs.ruby-lang.org/ja/latest/method/Module/i/extend_object.html