【Rails入門】saveがすべて成功したことを保証する(transaction)

みなさんは、トランザクション(transactioni)を知っていますか?

データベース界隈でよく聞くトランザクションですが、データベースを扱うRuby on Rails(以降、Rails)でも当然のようにトランザクションを利用できます。

この記事では、トランザクションを利用する方法を説明しながら、以下のような疑問に答えます。


トランザクションって聞いたことはあるけど
もう一度説明して欲しい

Railsでもトランザクションを利用できるの?
transactionメソッドの使用例をみたい!
saveメソッドとsave!メソッドの使い分けを知りたい


それでは、始めましょう。

目次

トランザクションとは

トランザクションとは、複数の処理をまとめて大きな1つの処理として扱うための機能です。

よく言われる例ですが、まりこさんがさとるくんに、銀行のATMで6000円送金する場合を考えてみましょう。送金の手順を単純化すると、2つの処理と考えられます。

(1)まりこさんがATMに6000円を入れる
(2)さとるくんの口座に6000円が入る

ただし、この2つの処理は、送金という大きな1つの処理と考えるべきでしょう。なぜなら、2つの処理を分割して考えてしまうと、まりこさんがATMに6000円を入れた後にシステムトラブルが発生したら、以下のようになってしまうためです。

(1)まりこさんはATMに6000円入れた
(2)さとるくんの口座にお金が入らない

このケースでの正しい対処方法は、2つ考えられます。

(A)まりこさんに6000円返金する
(B)(システムトラブルが解決した後)さとるくんの口座に6000円が入る

トランザクションは、(A)の方法で対処する場合に使います。

transactionメソッドを使ってみよう

動作を理解するためにWebアプリを作成する

Railsでトランザクションを使うには、transactionメソッドを使います。transactionメソッドの使い方を理解するために、RailsをインストールしてWebアプリを作りましょう。

(1)先ずはRailsをインストールします

私は、以下の記事を参考に、VirtualBoxで作成した仮想パソコンにインストールしたLinux Mintに、Railsの開発環境を作成しました。

基本的には記事の手順に従って操作しますが、app/samurai/sample1ディレクトリを作成する代わりに、app/samurai/transaction-demoディレクトリを作成しました。また、Railsを起動して、ブラウザで画面が表示されることを確認したら、いったんRailsを終了してから次に進みます。

Linux Mintのインストールについては、以下の記事で詳しく説明しています。

(2)端末で以下のコマンドを1行ずつ順番に入力します

bin/rails generate scaffold Item name:string price:integer
bin/rails generate controller Trans example
bin/rails db:migrate
bin/rails server

priceは整数でなくてはならないという制約をつけましょう。制約を守らなかった場合に例外が発生しますので、トランザクションのテスト用にはうってつけです。

(3)app/models/item.rbを編集します。

変更前:

class Item < ApplicationRecord
end

変更後:

class Item < ApplicationRecord
    validates :price, numericality: { only_integer: true }
end

transactionメソッドの基本構文

transactionメソッドの基本的な書きかたは以下のとおりです。とてもシンプルな構造になっていますので、コメント以外の説明は必要ないでしょう。

モデル名.transaction do
  # 例外が発生する可能性のある処理
end
  # 正常に動作した場合の処理
rescue => e
  # 例外が発生した場合の処理

なお、トランザクションはデータベースにデータを保存する処理を正しく管理することが目的のため、モデル名とは別のモデルをtransactionメソッド内で使用しても問題ありません。

save!メソッドでがすべて成功した場合

Railsでのトランザクションは、複数のsave!メソッド(またはsaveメソッド)を実行するときに使います。

まずは、2つのsave!メソッドが無事に終了する例を紹介しましょう。

(1)app/controllers/trans_controller.rbを編集します。

変更前:

class TransController < ApplicationController
  def example
  end
end

変更後:

class TransController < ApplicationController def example Item.transaction do item1 = Item.new({name:"腕時計", price:23000}) item1.save! item2 = Item.new({name:"オルガン", price:53000}) item2.save! end render plain:'保存に成功しました。' rescue => e
     render plain: e.message
  end
end

(2)ブラウザで「http://localhost:3000/trans/example」にアクセスします。

以下のように表示されます。

(3)ブラウザで「http://localhost:3000/items」にアクセスします。

以下のように、腕時計とオルガンのデータが登録されたことが確認できます。

save!メソッドの一部でバリデーションエラーが発生した場合

次は、バリデーションエラーが発生した場合の動作を確認しましょう。

(1)app/controllers/trans_controller.rbを編集します。

変更前:

class TransController < ApplicationController def example Item.transaction do item1 = Item.new({name:"腕時計", price:23000}) item1.save! item2 = Item.new({name:"オルガン", price:53000}) item2.save! end render plain:'保存に成功しました。' rescue => e
     render plain: e.message
  end
end

変更後:

class TransController < ApplicationController def example Item.transaction do item1 = Item.new({name:"鏡", price:5400}) item1.save! item2 = Item.new({name:"まくら", price:2400.5}) item2.save! end render plain:'保存に成功しました。' rescue => e
     render plain: e.message
  end
end

まくらの金額に小数(2400.5)を設定しました。app/models/item.rbで、整数のみという制約を付けましたので、これはバリデーションエラーが発生するはずです。

(2)ブラウザで「http://localhost:3000/trans/example」にアクセスします。

以下のように表示され、バリデーションエラーが発生していることがわかります。

鏡とまくらのデータは登録されているでしょうか。

(3)ブラウザで「http://localhost:3000/items」にアクセスします。

以下のように、腕時計とオルガンのデータだけが登録されたままで、まくらはもちろん、鏡のデータも登録されていないことが確認できます。

ログも確認しておきましょう。

(4)新しい「端末」を起動して、以下のコマンドを1行ずつ順番に入力します。

cd app/samurai/transaction-demo/log
tail --lines=20 development.log

実行結果:

Started GET "/trans/example" for 127.0.0.1 at 2018-07-11 11:43:18 +0900
Processing by TransController#example as HTML
   (0.1ms)  begin transaction
  SQL (0.4ms)  INSERT INTO "items" ("name", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "鏡"], ["price", 5400], ["created_at", "2018-07-11 02:43:18.565431"], ["updated_at", "2018-07-11 02:43:18.565431"]]
   (3.1ms)  rollback transaction
  Rendering text template
  Rendered text template (0.0ms)
Completed 200 OK in 40ms (Views: 2.5ms | ActiveRecord: 5.1ms)


Started GET "/items" for 127.0.0.1 at 2018-07-11 11:44:28 +0900
Processing by ItemsController#index as HTML
  Rendering items/index.html.erb within layouts/application
  Item Load (0.2ms)  SELECT "items".* FROM "items"
  Rendered items/index.html.erb within layouts/application (1.8ms)
Completed 200 OK in 18ms (Views: 16.9ms | ActiveRecord: 0.2ms)

「SQL (0.4ms) INSERT INTO(省略)」(鏡のデータが追加されたログ)の後に「(3.1ms) rollback transaction」と表示されています。これは、ロールバック(巻き戻し)が実行されたことを表しているのです。

saveメソッドでバリデーションを無視して保存するには

バリデーションエラーが発生するようなデータでも無理矢理保存するには、save(validate: false)を使用します。せっかく設定したバリデーションを無視することになりますので、使用する場合はよくよく検討しましょう。

また、使用しているメソッドが、save!メソッドではなく、saveメソッドであることに注意してください。save!メソッドはバリデーションを行い、バリデーションエラーが発生した場合に、例外を発生させます。

saveメソッドはバリデーションを行いますが、バリデーションエラーが発生した場合に、falseを返します(例外を発生させません)。

transactionメソッドは、例外に反応する仕組みですから、トランザクションではsave!メソッドを使うほうが読みやすいでしょう。

(1)app/controllers/trans_controller.rbを編集します。

変更前:

class TransController < ApplicationController def example Item.transaction do item1 = Item.new({name:"鏡", price:5400}) item1.save! item2 = Item.new({name:"まくら", price:2400.5}) item2.save! end render plain:'保存に成功しました。' rescue => e
     render plain: e.message
  end
end

変更後:

class TransController < ApplicationController def example Item.transaction do item1 = Item.new({name:"鏡", price:5400}) item1.save(validate: false) item2 = Item.new({name:"まくら", price:2400.5}) item2.save(validate: false) end render plain:'保存に成功しました。' rescue => e
     render plain: e.message
  end
end

まくらの金額に小数(2400.5)を設定したまま、save!()の代わりにsave(validate: false)を使いました。

このコードを実行すると、先ほどと同様のバリデーションエラーが発生するはずですが、save(validate: false)に変更しているため、バリデーションエラーが発生せずにデータが登録されます。

(2)ブラウザで「http://localhost:3000/trans/example」にアクセスします。

以下のように表示されます。

小数のはずですが、保存に成功したようです。

(3)ブラウザで「http://localhost:3000/items」にアクセスします。

以下のように、今度は鏡もまくらも登録されていることが確認できます。「2400.5」は「2400」で登録されていますね。

ログも確認しておきましょう。

(4)新しい「端末」を起動して、以下のコマンドを1行ずつ順番に入力します。

cd app/samurai/transaction-demo/log
cat development.log

実行結果:

Started GET "/trans/example" for 127.0.0.1 at 2018-07-11 11:55:34 +0900
Processing by TransController#example as HTML
   (0.0ms)  begin transaction
  SQL (0.6ms)  INSERT INTO "items" ("name", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "鏡"], ["price", 5400], ["created_at", "2018-07-11 02:55:34.107924"], ["updated_at", "2018-07-11 02:55:34.107924"]]
  SQL (0.1ms)  INSERT INTO "items" ("name", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "まくら"], ["price", 2400], ["created_at", "2018-07-11 02:55:34.110136"], ["updated_at", "2018-07-11 02:55:34.110136"]]
   (15.5ms)  commit transaction
  Rendering text template
  Rendered text template (0.0ms)
Completed 200 OK in 31ms (Views: 1.5ms | ActiveRecord: 18.9ms)


Started GET "/items" for 127.0.0.1 at 2018-07-11 11:55:56 +0900
Processing by ItemsController#index as HTML
  Rendering items/index.html.erb within layouts/application
  Item Load (0.2ms)  SELECT "items".* FROM "items"
  Rendered items/index.html.erb within layouts/application (7.5ms)
Completed 200 OK in 36ms (Views: 35.3ms | ActiveRecord: 0.2ms)

SQLを発行する時点で、まくらのデータが「2400.5」から「2400」に変更されていることがわかります。

分離レベルを知ろう

分離レベルに対応するデータベースを使用している場合は、トランザクションで分離レベルを指定できます。

分離レベルは、複数のトランザクションを同時に実行した場合にどの程度データに一貫性を持たせるのかを指定するためのものです。分離レベルが高いほど、データの一貫性を保ちやすくなりますが、代わりに複数のユーザーがデータに同時にアクセスできなくなります。

少し具体例を説明しましょう。先ほどのデータのロールバック(巻き戻し)が発生した例を思い出してください。

説明のために、ログを再掲します。

   (0.1ms)  begin transaction
  SQL (0.4ms)  INSERT INTO "items" ("name", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "鏡"], ["price", 5400], ["created_at", "2018-07-11 02:43:18.565431"], ["updated_at", "2018-07-11 02:43:18.565431"]]
   (3.1ms)  rollback transaction

「INSERT INTO」から「rollback transaction」が表示されるまでのほんの一瞬(3.1ms)ですが、データベース(テーブル)に鏡のデータが追加されていますね。

この一瞬に、他のユーザーが鏡のデータを読み出してしまうと、本来存在してはいけないデータにアクセスできていることになり、何かしらの問題が発生するでしょう。

このような問題を回避するために、分離レベルを適切に設定します。Railsで用意されている分離レベルは、レベルの低い順に以下の4つです。

:read_uncommitted(最も分離レベルが低い)
:read_committed
:repeatable_read
:serialization(最も分離レベルが高い)

先ほど紹介した問題は、:read_committed、:repeatable_read、:serializationのいずれかを設定すると、回避できます。

分離レベルは以下のように設定します。

変更前:

    Item.transaction() do

変更後:

    Item.transaction(isolation: :read_committed) do

まとめ

この記事では、トランザクション処理を説明しました。

トランザクションは複数の処理をまとめて大きな1つの処理として扱うための機能です。そして、処理の1つで例外(問題)が発生したら、複数の処理をまとめてロールバック(巻き戻す)ことができます。

予期せぬバグを未然に防げるので、使えそうな場面では一連の処理にトランザクションの導入を検討するとよいでしょう。

分離レベルにも気をつけてくださいね。

この記事を書いた人

侍エンジニア塾は「人生を変えるプログラミング学習」をコンセンプトに、過去多くのフリーランスエンジニアを輩出したプログラミングスクールです。侍テック編集部では技術系コンテンツを中心に有用な情報を発信していきます。

目次