はじめに
こんにちは、tomaです。
現在、TDEE(1日の消費カロリー)とアレルギー条件から、献立を自動生成するWebアプリを個人開発しています。
今回、献立をDBに保存する処理を実装していて、
「献立自体は保存されたのに、中の料理が一部だけ保存されなかったらどうなるんだろう?」
ということが気になりました。
例えば、
-
Menuは保存された - 朝食の
Mealは保存された - 昼食の
Mealも保存された - 夕食の
Mealの保存でエラーが起きた
という状態です。
これだと、DBには「途中までしか保存されていない献立」が残ってしまいます。
そこで、Menu と Meal の保存を transaction(トランザクション) でまとめることにしました。
さらに、
「本当にロールバックされるの?」
というところまでRSpecで意図的にエラーを起こして確認しました。
今回は、そのときに学んだことをまとめます。
1. そもそも transaction とは?
一言でいうと、transaction は
複数のDB操作を「全部成功」か「全部なかったことにする」かの単位で扱う仕組み
です。
例えば、次のような処理があったとします。
ActiveRecord::Base.transaction do
# DBへの処理①
# DBへの処理②
# DBへの処理③
end
この中の処理がすべて成功すれば、そのままDBに反映されます。
一方、途中で例外が発生すると、そこまでに行ったDBへの変更がロールバックされます。
つまり、
全部成功
↓
コミット
↓
DBに反映
または、
途中で例外
↓
ロールバック
↓
transaction開始前の状態に戻る
というイメージです。
銀行振込に置き換えて考えるとイメージしやすかったので、ここでは銀行振り込みを例にして説明していきます。
例えば、
Aさんから1,000円を引き出して、Bさんに1,000円を入金する
という処理を考えてみます。
from_account.decrement!(:balance, 1000)
to_account.increment!(:balance, 1000)
もしAさんから1,000円を引き出したあとに、Bさんへの入金でエラーが起きたらどうでしょうか?
transactionがなければ、
Aさん
-1,000円
Bさん
+0円
という、とても困った状態になる可能性があります。
「Aさんからは引かれたのに、Bさんには入っていない」という状態ですね💦
そこでtransactionを使います。
ActiveRecord::Base.transaction do
from_account.decrement!(:balance, 1000)
to_account.increment!(:balance, 1000)
end
この場合、2つ目の処理で例外が発生すると、1つ目の処理もロールバックされます。
① Aさんから1,000円引き出す
↓
② Bさんに1,000円入金
↓
エラー!
↓
①もなかったことにする
このように、途中まで成功してしまった状態を残さないためにtransactionを使います。
2. 自分のアプリでは、どこで必要だったのか?
今回transactionを使ったのは、献立を保存する処理です。
私のアプリでは、1つの献立Menuに対して複数の料理Mealが紐づきます。
イメージとしては、
Menu(1件)
├─ Meal(朝食)
├─ Meal(昼食)
└─ Meal(夕食)
という構造です。
例えば1日の献立を保存するとき、
Menuを作成
↓
朝食のMealを保存
↓
昼食のMealを保存
↓
夕食のMealを保存
という処理になります。
ここで、もし夕食の Meal を保存するところでエラーが起きたらどうなるでしょうか?
transactionを使っていなければ、
Menu → 保存されている
朝食 Meal → 保存されている
昼食 Meal → 保存されている
夕食 Meal → 保存されていない
という状態になる可能性があります。
これでは、ユーザーから見ると
「献立は保存したはずなのに、料理が1品足りない」
という状態になってしまいます。
これは避けたい!
そこで、Menu と Meal の保存をまとめてtransactionの中に入れました。
3. 実際の実装
実際の保存処理は、このような形です。
class MenuSaveService
def initialize(user, menu_hash, date: Date.today)
@user = user
@menu_hash = menu_hash
@date = date
end
def save!
ApplicationRecord.transaction do
menu = Menu.create!(
user: @user,
date: @date,
total_calories: total_calories
)
save_meals!(menu)
menu
end
end
private
def total_calories
@menu_hash.values.flatten.sum(&:calories)
end
def save_meals!(menu)
@menu_hash.each do |meal_timing, selected_meals|
selected_meals.each do |selected|
Meal.create!(
menu: menu,
meal_master: selected.meal_master,
meal_timing: meal_timing,
portion_scale: selected.portion_scale,
calories: selected.calories
)
end
end
end
end
ここで重要なのが、
ApplicationRecord.transaction do
です。
この中で、
menu = Menu.create!(...)
を実行し、その後、
Meal.create!(...)
を複数回実行しています。
つまり、
transaction
│
├─ Menuを保存
│
├─ Mealを保存
│
├─ Mealを保存
│
└─ Mealを保存
という1つのまとまりとして扱っています。
もし最後の Meal 保存で例外が発生したら、それまでに保存した Menu や Meal もロールバックされます。
4. transactionがないと何が困るのか?
実際にtransactionがなかった場合を考えてみます。
Menu作成
↓
✅ 成功
朝食Meal作成
↓
✅ 成功
昼食Meal作成
↓
✅ 成功
夕食Meal作成
↓
❌ エラー!
この時点で処理が終了したとします。
transactionがなければ、すでにDBへ保存されたものはそのまま残ります。
DB
Menu ✅
朝食 Meal ✅
昼食 Meal ✅
夕食 Meal ❌
すると、
「保存処理は失敗したのに、途中までのデータだけ残っている」
という状態になります。
このような途中までのデータが残ってしまうと、後からデータを扱うときにも困ります。
例えばユーザーがもう一度「献立を保存」した場合、
1回目
→ 壊れた献立が残る
2回目
→ 正常な献立が保存される
となり、同じ日付の献立が複数存在するような問題につながる可能性もあります。
「エラーになったなら、最初から何も保存されていない状態にしたい」
と考えました。
そこでtransactionを使っています。
5. transactionを使えば、本当に全部戻るの?
ここで最初は私も、
「transactionで囲めば勝手に全部戻してくれるんだ!」
くらいの理解でした。
でも調べてみると、もう少し正確に理解する必要がありました。
transactionがロールバックするきっかけになるのは、基本的にtransactionの中で例外が発生することです。
そのため、今回の実装では create ではなく create! を使っています。
6. create と create! の違い
Railsには、
create
と
create!
があります。
大きな違いは、バリデーションに失敗したときの挙動です。
create
user = User.create(name: nil)
バリデーションに失敗しても、基本的には例外を発生させず、保存に失敗したことを返します。
create!
user = User.create!(name: nil)
バリデーションに失敗すると、ActiveRecord::RecordInvalid という例外が発生します。
今回のように、
ApplicationRecord.transaction do
Menu.create!(...)
Meal.create!(...)
end
としておけば、Meal.create! でバリデーションエラーが発生したとき、その例外によってtransactionがロールバックされます。
つまり、
Menu.create! → 成功
Meal.create! → 成功
Meal.create! → エラー!
↓
例外が発生
↓
transactionがロールバック
↓
MenuもMealも元に戻る
という流れです。
ただし、ここは少し注意
「transactionには必ず create! が必要」という意味ではありません。
大事なのは、
transaction内で失敗したときに、適切に例外を発生させてロールバックさせること
です。
create を使っていても、自分で失敗を検知して例外を発生させればロールバックできます。
ただ、今回のように「保存に失敗したら処理全体を失敗させたい」というケースでは、create! を使うとRailsの例外処理に任せられるので分かりやすいです。
7. 「本当にロールバックされるの?」をRSpecで確認した
ここまで理解して、
「理屈では分かったけど、本当にMenuも消えるの?」
と思いました。
そこで、RSpecでわざと Meal の保存を失敗させてみました。
今回確認したかったのは、
Menuを作成
↓
Mealの保存でエラー
↓
Menuもロールバックされる
という動きです。
テストでは、Meal.create! が呼ばれたら意図的に ActiveRecord::RecordInvalid を発生させます。
context '保存に失敗した場合' do
before do
allow(Meal).to receive(:create!)
.and_raise(ActiveRecord::RecordInvalid.new(Meal.new))
end
it 'Menuも作成されずロールバックされる' do
expect { service.save! }
.to raise_error(ActiveRecord::RecordInvalid)
expect(Menu.count).to eq(0)
end
end
ここで少し分かりづらかったのが、この部分です。
allow(Meal).to receive(:create!)
これは、
「テスト中だけ、
Meal.create!が呼ばれたら……」
という指定です。
そして、
.and_raise(...)
によって、
「必ずエラーを発生させて!」
としています。
つまり、わざとこんな状況を作っています。
Menu.create!
↓
✅ 成功
Meal.create!
↓
💥 わざとエラー!
そして、
expect { service.save! }
.to raise_error(ActiveRecord::RecordInvalid)
で、
「ちゃんと
ActiveRecord::RecordInvalidが発生したよね?」
と確認しています。
さらに重要なのが、
expect(Menu.count).to eq(0)
です。
エラーが発生する前には Menu.create! が実行されています。
それなのに、最終的に Menu.count が 0 なら、
「Menuまでロールバックされて、DBに残っていない」
ことを確認できます。
8. テストを書いてみて分かったこと
今回、実際にテストを書いてみて、
「transactionを書いたから大丈夫」
で終わらせるのではなく、
「実際に途中で失敗させたら、期待どおりに戻るのか?」
まで確認することが大切だと感じました。
特にDBを扱う処理は、画面上では正常に動いているように見えても、裏側のDBが想定外の状態になっている可能性があります。
今回のテストでは、
Menu作成
↓
Meal保存で意図的にエラー
↓
Menuが残っていないことを確認
というシンプルなテストですが、
「失敗したときに中途半端なデータを残さない」
という、この処理で一番守りたかったことを確認できました。
個人開発だと、つい「正常に動くか」のテストに意識が向きがちでしたが、今回、
「失敗したときにどうなるか」
をテストすることもかなり重要なんだな、と勉強になりました。
9. すべての保存処理にtransactionが必要なわけではない
ここまでtransactionについて書きましたが、
「じゃあDBに保存するときは、とりあえず全部transactionで囲めばいいの?」
というと、そういうわけではありません。
例えば、
User.create!(
name: "太郎",
email: "taro@example.com"
)
のように、単独のレコードを保存するだけなら、必ずしもtransactionを自分で書く必要はありません。
今回のように、
Menu
↓
Meal
↓
Meal
↓
Meal
という複数の保存処理があり、
「Menuだけ残ってMealが一部しかない」
という状態になると困る場合にtransactionが活きてきます。
私の場合は、
「献立」と「その中身の料理」はセットで初めて意味がある
と考えました。
だから、同じtransactionの中で保存しています。
判断するときは、
「途中で処理が失敗して、一部だけDBに残ったら困るか?」
と考えると分かりやすいと思います。
10. 今回学んだこと
今回transactionを実装してみて、単純に「DBを安全にする機能」というだけではなく、
「1つの意味のある処理を、途中でバラバラにしないための仕組み」
なんだと理解できました。
今回の献立保存なら、
Menu
+
Meal
+
Meal
+
Meal
で「1つの献立」です。
だから、
Menuだけ成功
Mealは一部だけ成功
では困ります。
そこで、
ApplicationRecord.transaction do
Menu.create!(...)
Meal.create!(...)
Meal.create!(...)
Meal.create!(...)
end
として、
全部成功したら保存する。
途中で失敗したら全部戻すというルールにしました。
まとめ
今回の個人開発を通して学んだことをまとめると、
- transactionは複数のDB操作を1つのまとまりとして扱える
- すべて成功すればコミットされる
- transaction内で例外が発生するとロールバックされる
- 「途中まで保存されてしまうと困る」処理にtransactionが向いている
-
create!は保存に失敗したとき例外を発生させられる - 「transactionを書いたから大丈夫」で終わらず、RSpecで実際に失敗させて確認することができる
- 正常系だけではなく、失敗したときDBがどうなるかをテストすることも大切
最初は「transactionってDBを安全にするためのもの」というくらいの理解でしたが、実際に自分のアプリで、
「もし夕食の保存だけ失敗したら?」
と考えてみたことで、かなりイメージしやすくなりました。
個人開発では、エラーが起きなかった正常なケースだけを見るのではなく、
「途中で失敗したら、DBはどういう状態になるんだろう?」
と考えてみることが大事なんだな、と今回の実装を通して学びました。
同じようにRailsを勉強している方の参考になれば嬉しいです!