1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails の transaction で「献立は保存されたのに料理が1品欠けている」を防いだ話

1
Posted at

はじめに

こんにちは、tomaです。

現在、TDEE(1日の消費カロリー)とアレルギー条件から、献立を自動生成するWebアプリを個人開発しています。

今回、献立をDBに保存する処理を実装していて、

「献立自体は保存されたのに、中の料理が一部だけ保存されなかったらどうなるんだろう?」

ということが気になりました。

例えば、

  • Menu は保存された
  • 朝食の Meal は保存された
  • 昼食の Meal も保存された
  • 夕食の Meal の保存でエラーが起きた

という状態です。

これだと、DBには「途中までしか保存されていない献立」が残ってしまいます。

そこで、MenuMeal の保存を 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品足りない」

という状態になってしまいます。

これは避けたい!

そこで、MenuMeal の保存をまとめて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 保存で例外が発生したら、それまでに保存した MenuMeal もロールバックされます。


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. createcreate! の違い

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.count0 なら、

「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を勉強している方の参考になれば嬉しいです!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?