Bulletを導入してN+1を検知する
はじめに
RailsアプリケーションのN+1クエリ問題を検出し、通知してくれるBulletの導入と使い方までの紹介です。
動作環境
RSpecの導入と使い方で構築した環境を使います。
Ruby 3.2.2
Rails 7.0.8
MySQL 8.0
Bulletとは
Bulletは、Ruby on Railsアプリケーションのパフォーマンスを向上させるために、クエリ数を減らすことを目的とした便利なGemです。開発中にクエリを監視し、N+1クエリ問題が発生している場合には、eager loadingを追加するよう通知します。また、不要なeager loadingの使用や、カウンターキャッシュの利用が推奨される場合にも通知します。
N+1クエリ問題とは、データベースからデータを取得する際に、1つのクエリで多くのデータを取得し、その後、個別のデータごとにさらにクエリを実行するという非効率なパターンのことです。これにより、データベースへのアクセスが増え、アプリケーションのパフォーマンスが低下することがあります。
インストールと設定
Gemfileに追加
テストでも検出させるようにするので、:testにも追加します
group :development, :test do
gem "rspec-rails"
gem "factory_bot_rails"
+ gem "bullet"
end
Bundleインストール
docker compose run --rm api bundle install
docker compose build api # イメージの再構築
設定ファイルの準備
docker compose run --rm api bundle exec rails g bullet:install
# 実行結果
Enabled bullet in config/environments/development.rb
Would you like to enable bullet in test environment? (y/n) y
Enabled bullet in config/environments/test.rb
config/environments/test.rbに以下が追加されます
Rails.application.configure do
+ config.after_initialize do
+ Bullet.enable = true
+ Bullet.alert = true
+ Bullet.bullet_logger = true
+ Bullet.console = true
+ Bullet.rails_logger = true
+ Bullet.add_footer = true
+ end
...
end
Rails.application.configure do
+ config.after_initialize do
+ Bullet.enable = true
+ Bullet.bullet_logger = true
+ Bullet.raise = true # raise an error if n+1 query occurs
+ end
...
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.add_footer
: ページの左下隅に詳細を追加します。
Bullet.raise
: エラーを発生させます。
その他設定は Bullet Configuration で確認してください
RSpec.configure do |config|
if Bullet.enable?
config.before(:each) do
Bullet.start_request
end
config.after(:each) do
Bullet.perform_out_of_channel_notifications if Bullet.notification?
Bullet.end_request
end
end
...
end
実施
スキーマファイルの準備
touch api/db/schemas/authors.schema.rb
touch api/db/schemas/books.schema.rb
# api/db/schemas/authors.schema.rb
create_table :authors, force: :cascade, charset: 'utf8mb4', collation: 'utf8mb4_bin', options: 'ENGINE=InnoDB ROW_FORMAT=DYNAMIC' do |t|
t.string "name", null: false
end
# api/db/schemas/books.schema.rb
create_table :books, force: :cascade, charset: 'utf8mb4', collation: 'utf8mb4_bin', options: 'ENGINE=InnoDB ROW_FORMAT=DYNAMIC' do |t|
t.string "title", null: false
t.references :author, null: false
end
require 'users.schema.rb'
+ require 'authors.schema.rb'
+ require 'books.schema.rb'
マイグレーション
docker compose run --rm api bundle exec rails db_migration:apply
# 実行結果
=== run db migration... ===
[Running] bundle exec ridgepole --config config/database.yml --env development --file db/schemas/Schemafile --apply
Apply `db/schemas/Schemafile`
-- create_table("authors", {:charset=>"utf8mb4", :collation=>"utf8mb4_bin", :options=>"ENGINE=InnoDB ROW_FORMAT=DYNAMIC"})
-> 0.0820s
-- create_table("books", {:charset=>"utf8mb4", :collation=>"utf8mb4_bin", :options=>"ENGINE=InnoDB ROW_FORMAT=DYNAMIC"})
-> 0.0415s
-- add_index("books", ["author_id"])
-> 0.0388s
Model
# api/app/models/author.rb
class Author < ApplicationRecord
has_many :books
end
# api/app/models/book.rb
class Book < ApplicationRecord
belongs_to :author
end
Routes
Rails.application.routes.draw do
namespace 'api' do
namespace 'v1' do
resources :authors
end
end
end
Controller
class Api::V1::AuthorsController < ApplicationController
def index
authors = Author.all
render json: authors.map { |author|
{
id: author.id,
name: author.name,
books: author.books.map { |book|
{
id: book.id,
title: book.title
}
}
}
}
end
end
デモデータの用意
# Clear existing data
Author.destroy_all
Book.destroy_all
# Create authors with books
author1 = Author.create(name: "Author 1")
author2 = Author.create(name: "Author 2")
author1.books.create([
{ title: "Book 1 by Author 1" },
{ title: "Book 2 by Author 1" }
])
author2.books.create([
{ title: "Book 1 by Author 2" },
{ title: "Book 2 by Author 2" },
{ title: "Book 3 by Author 2" }
])
シードデータの投入
docker compose run --rm api bundle exec rails db:seed
確認
# Rails起動
docker compose up -d api
curl -X GET http://localhost:3000/api/v1/authors
{"data":
[
{"id":1,
"name":"Author 1",
"books":[
{"id":1,"title":"Book 1 by Author 1"},
{"id":2,"title":"Book 2 by Author 1"}
]
},
{"id":2,
"name":"Author 2",
"books":[
{"id":3,"title":"Book 1 by Author 2"},
{"id":4,"title":"Book 2 by Author 2"},
{"id":5,"title":"Book 3 by Author 2"}
]
}
]
}
ログの確認
2024-01-01 12:00:00[WARN] user: root
GET /api/v1/authors
USE eager loading detected
Author => [:books]
Add to your query: .includes([:books])
Call stack
/api/app/controllers/api/v1/authors_controller.rb:8:in `map'
/api/app/controllers/api/v1/authors_controller.rb:8:in `block in index'
/api/app/controllers/api/v1/authors_controller.rb:4:in `map'
/api/app/controllers/api/v1/authors_controller.rb:4:in `index'
RSpecで確認する
FactoryBotのファクトリを作成
# api/spec/factories/authors.rb
FactoryBot.define do
factory :author do
name { "Author #{rand(1000)}" }
trait :with_books do
after(:create) do |author|
create_list(:book, 3, author: author)
end
end
end
end
# api/spec/factories/books.rb
FactoryBot.define do
factory :book do
title { "Book #{rand(1000)}" }
author
end
end
RSpecのテストを作成
require 'rails_helper'
RSpec.describe "Api::V1::Authors", type: :request do
describe "GET /api/v1/authors" do
before do
create(:author, :with_books)
create(:author, :with_books)
end
it "returns a list of authors with their books" do
get api_v1_authors_path
expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json.length).to eq(2)
json.each do |author|
expect(author).to have_key("id")
expect(author).to have_key("name")
expect(author).to have_key("books")
author["books"].each do |book|
expect(book).to have_key("id")
expect(book).to have_key("title")
end
end
end
end
end
テストの実行
# テスト用のDBのマイグレーション
docker compose run --rm api bundle exec rails db_migration:apply RAILS_ENV=test
# テスト実行
docker compose run --rm api bundle exec rspec
# 実行結果
.F
Failures:
1) Api::V1::Authors GET /api/v1/authors returns a list of authors with their books
Failure/Error: get api_v1_authors_path
Bullet::Notification::UnoptimizedQueryError:
user: root
GET /api/v1/authors
USE eager loading detected
Author => [:books]
Add to your query: .includes([:books])
Call stack
/api/app/controllers/api/v1/authors_controller.rb:8:in `map'
/api/app/controllers/api/v1/authors_controller.rb:8:in `block in index'
/api/app/controllers/api/v1/authors_controller.rb:4:in `map'
/api/app/controllers/api/v1/authors_controller.rb:4:in `index'
/api/spec/requests/api/v1/authors_spec.rb:11:in `block (3 levels) in <main>'
Finished in 0.21614 seconds (files took 1.35 seconds to load)
2 examples, 1 failure
Failed examples:
rspec ./spec/requests/api/v1/authors_spec.rb:10 # Api::V1::Authors GET /api/v1/authors returns a list of authors with their books
Github Actionsで確認
N+1の解消
class Api::V1::AuthorsController < ApplicationController
def index
- authors = Author.all
+ authors = Author.includes(:books).all
render json: authors.map { |author|
{
id: author.id,
name: author.name,
books: author.books.map { |book|
{
id: book.id,
title: book.title
}
}
}
}
end
end
Discussion