Railsのコールバック実行順とトランザクションのタイミング: 実例と解説
こんにちは、M-Yamashitaです。
業務でコールバックがどの順で呼び出されるのか、トランザクションのタイミングがどこだったか忘れてしまい困ったことがありました。そのため、備忘録も兼ねてコールバックの実行順やトランザクションのタイミングを記事として残します。
この記事で伝えたいこと
- DBの保存・更新メソッド実行時におけるコールバックの実行順
- トランザクション開始・終了の実行タイミング
- コールバック・トランザクションの実行コード
想定読者
この記事は、Ruby on Railsを使用している開発者で、コールバックやトランザクションの動作に興味がある方を対象としています。
環境
- Ruby: 3.3.0
- Ruby on Rails: 7.1.3.4
- MySQL: 8.3.0
コールバック実行順とトランザクションのタイミング
オブジェクトの作成・更新でのコールバック一覧
コールバック一覧については、Railsガイドを参考にします。
このオブジェクトの作成・更新でのコールバックをbeforeから順番に並べてまとめると、以下の通りになります。
before_validation
after_validation
before_create
before_save
before_update
around_create
around_save
around_update
after_commit / after_rollback
after_create
after_save
after_update
このコールバックの内、after_rollback
を除くコールバックを今回の確認対象とします。また、after_save_commit
、after_update_commit
についてはafter_commit
とエイリアス関係なので、今回の対象コールバック一覧には含めません。
確認パターン
DBの保存・更新メソッドとしてcreate
, create!
, save
, save!
、update
、update!
があります。
これらの組み合わせによってコールバックがどの順で呼び出されるのか調べるために、確認パターンを作ります。ただ、!
の有無はコールバックに影響しないのでそのメソッドは今回のパターン作成から除きます。そのため保存メソッドのcreate!
, save!
のみ実行する場合と、それらのメソッドの実行後にsave!
、update!
した時のパターンを考えます。
組み合わせたパターン一覧は以下の通りです。
前提 | 実行メソッド |
---|---|
なし | create! |
事前にcreate!メソッドを実行済み | save! |
事前にcreate!メソッドを実行済み | update! |
なし | save! |
事前にsave!メソッドを実行済み | save! |
事前にsave!メソッドを実行済み | update! |
確認に使ったコード
上記「オブジェクトの作成・更新でのコールバック一覧」項目で挙げた各コールバックを持ったモデルのコードは次のとおりです。コールバックが実行されたときにそのコールバック名を画面に表示するシンプルなコードとしています。
class User < ApplicationRecord
before_validation :puts_before_validation
after_validation :puts_after_validation
before_create :puts_before_create
before_save :puts_before_save
before_update :puts_before_update
around_create :puts_around_create
around_save :puts_around_save
around_update :puts_around_update
after_commit :puts_after_commit
after_create :puts_after_create
after_update :puts_after_update
after_save :puts_after_save
def puts_before_validation
puts 'before_validation'
end
def puts_after_validation
puts 'after_validation'
end
def puts_before_create
puts 'before_create'
end
def puts_before_save
puts 'before_save'
end
def puts_before_update
puts 'before_update'
end
def puts_around_create
puts 'around_create'
yield
end
def puts_around_save
puts 'around_save'
yield
end
def puts_around_update
puts 'around_update'
yield
end
def puts_after_commit
puts 'after_commit'
end
def puts_after_create
puts 'after_create'
end
def puts_after_save
puts 'after_save'
end
def puts_after_update
puts 'after_update'
end
end
またそのUser
モデルを使用し、Railsで簡単な画面を作ってボタンごとに各パターンを実行するようにしました。そのときの画面とcontrollerのコードは以下の通りです。
class UsersController < ApplicationController
attr_accessor :name, :name2, :email
def initialize
@name = "test_user"
@email = "example@exmaple.com"
@name2 = "test_user2"
end
def create
puts "---create!---"
User.create!(name:, email:)
redirect_to root_path
end
def create_save
puts '---create!---'
user = User.create!(name:, email:)
puts '---save!---'
user.name = name2
user.save!
redirect_to root_path
end
def create_update
puts '---create!---'
user = User.create!(name:, email:)
puts '---update!---'
user.update!(name: name2)
redirect_to root_path
end
def save
puts '---save!---'
user = User.new(name:, email:)
user.save!
redirect_to root_path
end
def save_save
puts '---save!---'
user = User.new(name:, email:)
user.save!
puts '---save!---'
user.name = name2
user.save!
redirect_to root_path
end
def save_update
puts '---save!---'
user = User.new(name:, email:)
user.save!
puts '---update!---'
user.update!(name: name2)
redirect_to root_path
end
end
加えてトランザクションがどのタイミングで実行されたかわかるように、クエリを表示するようにしておきます。
require "active_support/core_ext/integer/time"
Rails.application.configure do
# ・・・略・・・
config.logger = ActiveSupport::Logger.new(STDOUT)
# ・・・略・・・
end
結果
確認パターンに沿ってそれぞれボタンを押して確認しました。
各パターンにおけるコールバックの実行順番とトランザクションのBEGIN
、COMMIT
の実行タイミングは以下の通りです。
create!のみ
---create!---
before_validation
after_validation
before_save
around_save
before_create
around_create
TRANSACTION (0.1ms) BEGIN
↳ app/models/user.rb:37:in `puts_around_create'
User Create (0.9ms) INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:19:35.354712', '2024-08-16 03:19:35.354712')
↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
TRANSACTION (0.8ms) COMMIT
↳ app/controllers/users_controller.rb:4:in `create'
after_commit
Redirected to http://localhost:3000/
create! → save!
---create!---
before_validation
after_validation
before_save
around_save
before_create
around_create
TRANSACTION (0.2ms) BEGIN
↳ app/models/user.rb:37:in `puts_around_create'
User Create (1.5ms) INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:23:52.754989', '2024-08-16 03:23:52.754989')
↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
TRANSACTION (0.4ms) COMMIT
↳ app/controllers/users_controller.rb:11:in `create_save'
after_commit
---save!---
before_validation
after_validation
before_save
around_save
before_update
around_update
TRANSACTION (0.1ms) BEGIN
↳ app/models/user.rb:47:in `puts_around_update'
User Update (0.4ms) UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:23:52.758843' WHERE `users`.`id` = 2
↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
TRANSACTION (0.6ms) COMMIT
↳ app/controllers/users_controller.rb:14:in `create_save'
after_commit
Redirected to http://localhost:3000/
create! → update!
---create!---
before_validation
after_validation
before_save
around_save
before_create
around_create
TRANSACTION (0.2ms) BEGIN
↳ app/models/user.rb:37:in `puts_around_create'
User Create (1.1ms) INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:24:35.517345', '2024-08-16 03:24:35.517345')
↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
TRANSACTION (0.6ms) COMMIT
↳ app/controllers/users_controller.rb:21:in `create_update'
after_commit
---update!---
before_validation
after_validation
before_save
around_save
before_update
around_update
TRANSACTION (0.2ms) BEGIN
↳ app/models/user.rb:47:in `puts_around_update'
User Update (0.6ms) UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:24:35.521125' WHERE `users`.`id` = 3
↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
TRANSACTION (0.5ms) COMMIT
↳ app/controllers/users_controller.rb:23:in `create_update'
after_commit
Redirected to http://localhost:3000/
save!のみ
---save!---
before_validation
after_validation
before_save
around_save
before_create
around_create
TRANSACTION (0.3ms) BEGIN
↳ app/models/user.rb:37:in `puts_around_create'
User Create (1.3ms) INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:24:54.344819', '2024-08-16 03:24:54.344819')
↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
TRANSACTION (0.6ms) COMMIT
↳ app/controllers/users_controller.rb:31:in `save'
after_commit
Redirected to http://localhost:3000/
save! → save!
---save!---
before_validation
after_validation
before_save
around_save
before_create
around_create
TRANSACTION (0.2ms) BEGIN
↳ app/models/user.rb:37:in `puts_around_create'
User Create (1.1ms) INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:25:07.233447', '2024-08-16 03:25:07.233447')
↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
TRANSACTION (0.4ms) COMMIT
↳ app/controllers/users_controller.rb:39:in `save_save'
after_commit
---save!---
before_validation
after_validation
before_save
around_save
before_update
around_update
TRANSACTION (0.1ms) BEGIN
↳ app/models/user.rb:47:in `puts_around_update'
User Update (0.5ms) UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:25:07.236525' WHERE `users`.`id` = 5
↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
TRANSACTION (0.4ms) COMMIT
↳ app/controllers/users_controller.rb:42:in `save_save'
after_commit
Redirected to http://localhost:3000/
save! → update!
---save!---
before_validation
after_validation
before_save
around_save
before_create
around_create
TRANSACTION (0.2ms) BEGIN
↳ app/models/user.rb:37:in `puts_around_create'
User Create (1.7ms) INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:25:24.064263', '2024-08-16 03:25:24.064263')
↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
TRANSACTION (0.5ms) COMMIT
↳ app/controllers/users_controller.rb:50:in `save_update'
after_commit
---update!---
before_validation
after_validation
before_save
around_save
before_update
around_update
TRANSACTION (0.2ms) BEGIN
↳ app/models/user.rb:47:in `puts_around_update'
User Update (0.4ms) UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:25:24.068754' WHERE `users`.`id` = 6
↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
TRANSACTION (0.5ms) COMMIT
↳ app/controllers/users_controller.rb:52:in `save_update'
after_commit
Redirected to http://localhost:3000/
コールバックの実行コード
validationの場合
レコード作成・保存の場合、以下save!
メソッド内のperform_validations
メソッド内で実行されます。
def save!(**options)
perform_validations(options) ? super : raise_validation_error
end
このメソッドを辿っていくと、以下run_validations!
メソッドが呼び出され、そこで動的に作られた_run_validation_callbacks
メソッドを呼び出し、before_validation
やafter_validation
が実行されます。
def run_validations!
_run_validation_callbacks { super }
end
saveの場合
以下save!
メソッド内のcreate_or_update
メソッド内で実行されます。
def save!(**options, &block)
create_or_update(**options, &block) || raise(RecordNotSaved.new("Failed to save the record", self))
end
このメソッドを辿った先にあるActiveRecord::Callbacks
モジュールのcreate_or_update
メソッドが呼び出されます。ここでも同様に動的に作成された_run_save_callbacks
メソッドでコールバック処理を呼び出します。
なお、before_create
, around_create
のコールバックはクエリ実行前に呼び出されるようですが、after_create
はクエリ実行後の呼び出しとなるようです。
def create_or_update(**)
_run_save_callbacks { super }
end
create、updateの場合
上記save!
メソッドとほぼ同じ呼び出し場所となります。save!
メソッドを持っている以下create!
メソッドでコールバックが実行されます。
def create!(attributes = nil, &block)
if attributes.is_a?(Array)
attributes.collect { |attr| create!(attr, &block) }
else
object = new(attributes, &block)
object.save!
object
end
end
save!
メソッドの先にあるActiveRecord::Callbacks
モジュールの_create_record
で、動的に作成された_run_save_callbacks
メソッドでコールバック処理を呼び出します。
なお、before_create
やaround_create
はクエリ実行前、after_create
はクエリ実行後に呼び出されるようです。
def _create_record
_run_create_callbacks { super }
end
updateに関してもcreateとほぼ同様であり、_update_record
メソッド内でコールバックを呼んでいます。
def _update_record
_run_update_callbacks { record_update_timestamps { super } }
end
commitの場合
後述するトランザクションCOMMITのコードを実行後に、以下のcommitted!
メソッド内にある_run_commit_callbacks
メソッドでコールバックが実行されます。
def committed!(should_run_callbacks: true) # :nodoc:
@_start_transaction_state = nil
if should_run_callbacks
@_committed_already_called = true
_run_commit_callbacks
end
ensure
@_committed_already_called = @_trigger_update_callback = @_trigger_destroy_callback = false
end
トランザクションの実行コード
BEGINについて
Rails 6から、トランザクションを貼った後のクエリ未実行ということを避けるためにトランザクションの遅延対応が入っています。
この結果、クエリ実行直前でBEGIN
が実行されるようです。コードとしてはクエリ実行の処理を持つraw_execute
メソッドにおいて、with_raw_connection
メソッドの先にあるbegin_db_transaction
メソッドでBEGINを実行しています。
def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
log(sql, name, async: async) do
with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|
sync_timezone_changes(conn)
result = conn.query(sql)
verified!
handle_warnings(sql)
result
end
end
end
def begin_db_transaction # :nodoc:
internal_execute("BEGIN", "TRANSACTION", allow_retry: true, materialize_transactions: false)
end
COMMITについて
COMMITについては、save!
や``destroy!メソッドで使用されている
with_transaction_returning_status`メソッドで実行しています。
def save!(**) # :nodoc:
with_transaction_returning_status { super }
end
このメソッド内部にあるtransaction
メソッドを辿った先で、commit_db_transaction
メソッドによりCOMMIT
を実行しています。
def commit_db_transaction # :nodoc:
internal_execute("COMMIT", "TRANSACTION", allow_retry: false, materialize_transactions: true)
end
おわりに
この記事では、コールバックやトランザクションの順番と実行タイミングについてまとめました。その点について再確認できたことに加えて、調べている過程でトランザクションの遅延対応について新しく発見できたことも良かったと感じています。
この記事が誰かのお役に立てれば幸いです。
Discussion