fighters48's Tech Blog

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

【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を呼び出してスレッドローカル変数の情報をリセットする