hiyoko-programingの日記

プログラミングを勉強したてのひよっ子。   エンジニア目指して勉強中。

例外処理

Ruby on Railsにおける例外処理について。

・例外処理を使った実装ができるようになる
・rescueを使った処理を記述できるようになる
トランザクション処理を記述することができるようになる

事前準備

 サンプルアプリケーションをクローンする

ターミナル
1

 gemを導入する

ターミナル
1
2
3
$ cd exception_sample
$ rm Gemfile.lock #bunlderのバージョンが古い可能性があるため、一度Gemfile.lokを削除しておきます
$ bundle install

bundle installが完了したら、データベースの作成と初期設定を行う。

 データベースの設定をする

ターミナル
1
2
3
4
5
6
# データベースの作成
$ rails db:create
# マイグレーションの実行
$ rails db:migrate
# 初期データの投入
$ rails db:seed

rails db:seedコマンドは、db/seeds.rbの記述に基づいて、データベースにレコードを作成するコマンド。
クローン直後に投入したい初期データがある場合に利用する。

db/seeds.rb
1
2
3
4
5
6
7
8
users = []
10000.times do |i|
 # usersに10000件新規ユーザーの情報を格納する
  users << User.new(name: "dummy-#{i+1}", ticket_count: 0)
end
# importメソッドの引数に配列を渡して、まとめてレコードを作成する
User.import users
User.find(500).update(ticket_count: 2147483647)

今回は初期データの作成に、activerecord-importというgemを利用している。
これは、通常通り10000回User.createするよりも、高速に大量のレコードを作成できるからである。
詳細は下記リンク参照。

https://qiita.com/xhnagata/items/79184ded56158ea1b97a

サンプルアプリケーションの仕様確認

サンプルアプリケーションは「開発途中のオンラインゲーム」。
現在、ユーザーの情報を格納しているUsersテーブルのみ作成済みで、テーブル構造は次のようになっている。

保持している情報 カラム名 制約
ユーザーの名前 name string null: false
所持しているチケットの枚数 ticket_count integer なし

サーバー側の実装も、フロント側の実装も全く始まっていませんが、次の仕様だけは決まっている。

  • ユーザーは、チケットを所持している
  • チケットはゲームをプレイする度に1つ消費する
  • アプリケーションに障害が発生してサービスが止まってしまった場合、障害解消後に全ユーザーに補償のチケットを10枚発行する

全ユーザーにチケットを10枚発行する処理は、lib/tasks/distribute_ticket.rakeに定義されている。

lib/tasks/distribute_ticket.rake
1
2
3
4
5
6
7
8
namespace :distribute_ticket do
  desc "全ユーザーのticket_countを10増加させる"
  task execute: :environment do
    User.find_each do |user|
      user.increment!(:ticket_count, 10)
    end
  end
end

incrementメソッドは、カラム名と数字を引数に取り、引数の数値分カラムの値を増加させる。今回は、ticket_countカラムの値を10増加させるという意味。

ターミナルから、rake distribute_ticket:executeとコマンドを実行することで、
Usersテーブルの全てのレコードのticket_countの値が10増加する。

一見問題なく見える処理だが、現在の実装には大きな問題がある。

現在の実装の問題点

例えば、rake distribute_ticket:executeコマンドを実行中に、何らかのエラーが発生してしまうと、そこで各ユーザーのticket_countを10ずつ増加させる処理は止まってしまう。つまり、コマンドを実行した結果、チケットを受け取れたユーザーと、受け取れなかったユーザーに分かれてしまう。

また、上記のコードでは、どのユーザーに対してチケットを発行する処理を行っている際にエラーが起こってしまったのか、分からない。
途中から処理を再開しようにも、どこから再開すればいいのか分からないため、チケットを受け取れなかったユーザーは泣き寝入りすることになってしまう。

そこで登場するのが例外処理である。例外処理をうまく活用することによって、次のような実装が可能になる。
- 特定のレコードの更新に失敗したら、どのレコードで更新に失敗したかをログに記録し、そのまま全レコードへの更新を続行する
- 特定のレコードの更新に失敗したら、どのレコードで更新に失敗したかをログに記録し、全てのレコードへの更新をなかったことにして処理を中止する

例外

例外とは、ある処理を実行した際の結果が、期待されるものと異なる状況を指す。 RubyおよびRuby on Railsでは、Exceptionというクラスを継承する形で様々な例外が定義されている。ここまでよく見てきた NoMethodErrorSyntaxError などは、全てExceptionクラスの子孫クラスであり、例外である。例外が発生すると、それ以降の処理は中止され、実行されなくなってしまう。

実は、今回rails db:seedを実行して投入した初期データには、意図的に例外を発生させる値が投入されているものがある。
seeds.rbの6, 7行目を確認すると

db/seeds.rb
1
2
# ticket_countをint型で許容できる最大の値にする
User.find(500).update(ticket_count: 2147483647)

idが500のユーザーのticket_countを、2147483647に変更している。これは、SQLのinteger型が許容できる最大の値であり、もし2147483647より大きな値を保存しようとすると、RangeErrorという例外が発生し、保存に失敗する。ターミナルからrake distribute_ticket:executeを実行してみると、例外の発生するidが500までのユーザーについては、ticeket_countが10増加し、idが500番目以降のユーザーについては、例外が発生してしまったために、処理が中止され、ticket_countが増えていないことが分かる。

例外処理

例外処理とは、例外が発生した場合に実行する処理のことを指す。Ruby on Railsの開発を行う際に、よく利用するのがrescueである。

上記のrubyプログラムを実行すると、「1 / 0」の処理を行った際に、ZeroDivisionErrorという例外が発生する。これは、数字を0で割ろうとすると発生する例外。
beginブロックの内部で例外が発生したため、rescue以下の処理が実行される。

rescueを用いた実装

rescue

rescueとは、発生した例外を捕捉し、例外が起こった際に呼び出される条件節。
例外が発生しそうな部分をbeginから始まるブロックで囲み、ブロックの内部にrescueを記述して使用する。

サンプル
1
2
3
4
5
6
7
begin
# 数字を0で割ろうとすると、ZeroDivisionError例外が発生する
  1 / 0
rescue
# 例外が発生した時にrescue以下の処理が呼ばれる
  puts '0で割ることはできません'
end

通常、each, times, whileなどの繰り返しの最中に例外が発生した場合、繰り返し処理は途中で中止され、次のループが実行されることはない。ただし、繰り返し処理の内部にrescueを記述していた場合、例外が発生したとしても、次のループに移ることができる。

大量のレコードを扱う処理で、例外が発生しても繰り返しを続行したい場合には、rescueを使うようにする。

rescueを用いたrakeタスクを実装

rails distribute_ticket:executeタスクを、rescueを使って実装し直す。
今回はrescueを使った実装を行うので、タスク名はrescueとする。

lib/tasks/distribute_ticket.rake
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
namespace :distribute_ticket do
  desc "全ユーザーのticket_countをrescueしながら10増加させる"
  task rescue: :environment do
    User.find_each do |user|
      begin
        user.increment!(:ticket_count, 10)
      rescue => e
        Rails.logger.debug e.message
      end
    end
  end
end

通常、レコードを操作するメソッド(save create update destroyなど)は、実行に成功したらtrue、失敗したらfalseを返すため、例外を生じることはない。
例外を意図的に発生させたい場合、元々のメソッド名に「!」をつけると、実行に失敗した際にfalseを返すのではなく例外を発生させるようになる。これまで挙げたメソッドで言うと、save! create! destroy!が例外を発生させるメソッド。

全てのメソッドが「!」を付けると例外を返すようになる訳ではない。また、saveとsave!など、真偽値を返すメソッドと例外を発生させるメソッドは、正確には同一のメソッドではなく別々のメソッドであるので注意。

rescue => eという記述があるが、これは発生した例外をrescue節以下で'e'として扱うという意味。
続くRails.logger.debug e.messageで、発生した例外をログに記録している。

作成したrakeタスクを実行してみる

ターミナル
1
$ rake distribute_ticket:rescue

コマンドを実行後、Usersテーブルのticket_countの値がどうなったか確認してみる。
idが500以降のレコードについても、ticket_countが増加していれば成功。

https://tech-master.s3.amazonaws.com/uploads/curriculums//2acd0df4e9a78cfee5c0ffec636e5d37.png

トランザクションを用いた実装

今までとは逆に、例外が発生したら全ての処理をなかったことにしたい場合はどうすれば良いのか? distribute_ticket:executeコマンドを実行すると、idが499のユーザーまでについてはticket_countを増加させ、500以降のユーザーについては一切追加されないようになっている。もしもチケット補填の処理の途中で、1件でも補填に失敗した場合、全ユーザーへの補填をなかったことにする処理は、rescueでは表現できない。そこで役に立つのがトランザクションである。

トランザクション

トランザクションとは、複数のレコードの更新を1つにまとめて行うことを指す。トランザクションを用いることで、「全て実行されるか、それとも全て実行されないか」という1か0の状況を作ることができる。

サンプル
1
2
3
4
5
# ECサイトの購入処理を模した架空のコード
ActiveRecord::Base.transaction do 
  current_user.pay!(@product.price)
  current_user.confirm_purchase!(@product)
end

上記は、ECサイトでの購入処理を模した架空のコードである。pay!メソッドで商品の料金を払い、confirm_purchase!メソッドで商品の注文を確定させている。料金の支払いと、商品の購入の確定は、常に1つのまとまりとして考えられるべき。トランザクションを用いることによって、「料金の支払いに失敗したけど、商品は購入できた」「料金は支払ったけど、注文の確定に失敗し商品が届かなかった」といった事態を防ぐことができる。

トランザクションを用いてチケットの補填機能をもう一度実装してみる。

lib/tasks/distribute_ticket.rake
1
2
3
4
5
6
7
8
9
# トランザクションのおかげで、例外が起こった際に何も変更されない
  desc "全ユーザーのticket_countをトランザクションで10増加させる"
  task transact: :environment do
    ActiveRecord::Base.transaction do
      User.find_each do |user|
        user.increment!(:ticket_count, 10)
      end
    end
  end

作成したrakeタスクを実行

rescueの際と同様に、ターミナルから作成したrakeタスクを実行。

ターミナル
1
$ rake distribute_ticket:transact

実行後に、Usersテーブルを確認して、どのレコードも値が変化していなければ成功。

https://tech-master.s3.amazonaws.com/uploads/curriculums//48eee3b23a33807e5edc0b985610d05d.png

参考リンク

  • Ruby Exceptionクラス ドキュメント

例外と呼んでいたものは、全てこのExceptionクラスを継承している。

https://docs.ruby-lang.org/ja/latest/class/Exception.html

https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html