STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Rubyのカスタム例外をちゃんと使おうと思った話

STORES のバックエンドエンジニアの@zakkyです。

本記事は、hey アドベントカレンダー 2020 の 4 日目の記事です!

アドベントカレンダーに記事を書くのは初めてで、何を書くか悩みました。

技術記事は過去の自分に向けて書くのがちょうど良いという言葉を先日耳にしたので、
「エラーを表現する際は、Rubyカスタム例外を使うよう過去の自分に言いたいな」
と思い、今回のテーマを選びました。

f:id:dimn_zkym:20201201093031p:plain:w300

RuntimeErrorだけで例外処理を済ませていませんか?

過去の私はアプリケーションコードの中で例外を発生させたいときは、以下のように書くことがほとんどでした

raise "許容していないデータがインポートされました。正しいデータでやり直してください"

このように raise の文字列を渡すだけだと、以下のように RuntimeError 例外が渡された文字列とともに発生します。

RuntimeError: 許容していないデータがインポートされました。正しいデータでやり直してください

恥ずかしながら STORES にジョインするまでは、私は上記の書き方ばかりしてきました。

例外によって表現したい情報は全て、引数に渡す文字列に詰め込む書き方をしていたのです。

その結果、例外発生を知らせるSlackチャンネルは以下のように視認性が悪いものになっていました。

RuntimeError: "データの状態に矛盾が生じています。想定していない組み合わせでのクーポン割引が使用されたため、user_id: 12345 のユーザーの決済処理をキャンセルしました"
RuntimeError: "不正な決済を検出しました。決済ID:1234567890abc 決済手段:QRコード決済"
RuntimeError: "想定していない値がCSVファイルから検出されました。正しい値を入力し直してください"
RuntimeError: "エラーが発生しました"
RuntimeError: "何らかの不具合が発生しました。エラーが発生したのはCSVファイルの32行目です"
RuntimeError:  ...  
RuntimeError:  ...  
RuntimeError:  ...  
...  
(以下、無限に続く)  

ひたすら RuntimeError と長い文字列が流れ続けるSlackのエラーチャンネルを業務の合間に見続けるのは、非常に辛かったです…

RuntimeError という表記が特に情報量を増やしていないので、もはやノイズでしかありません。

このように視認性が悪いと、人はエラー通知を見なくなります(そして障害発生へ…)

上記の惨状を軽く見ただけで、以下の問題点が浮かび上がってきます。

  • RuntimeError ばかり並んでいるため、それぞれ全く別物のエラーであるため緊急度と重要度にメリハリが付けられない
  • 文字数が多いためパッと見てどんなエラーなのか分からない。エラー文の解読と対応の必要があるかの判断に時間がかかる
  • ”正しい値を入力し直してください” とあるが、いったいどの機能の処理で起こったエラーか把握できない
  • "何らかの不具合が発生しました" とだけ表示され、最後まで読まないとファイル処理エラーだと判別できない
  • "エラーが発生しました" とだけ出されても何のことか全くわからない(サボったやつですね)

情報量が少なく視認性に欠けるエラー文が常態化していると、プロジェクトの新メンバーがキャッチアップするのは難しいですし、
重大なエラーを見逃すリスクも大きくなります。

こうした視認性の問題は、カスタム例外を定義してやることで解決できるはずです。

カスタム例外とは

カスタム例外とは、Rubyを扱うプログラマーが独自で定義した例外クラスのことを指します。
多くの場合は StandardError を継承したクラスになります。

以下のように定義します。

class InvalidCouponError < StandardError; end

class QrCodeError < StandardError; end

# initializeをオーバーライドしてデフォルトメッセージを設定することも可能
class FileError < StandardError
  def initialize(message = 'ファイルエラーが発生しました')
    super(message)
  end
end

定義したカスタム例外は以下のように使えます。
FileError クラスは initialize をオーバーライドすることで、デフォルトで表示する文字列を定義しています。

raise FileError
=> FileError: ファイルエラーが発生しました

定義したカスタム例外を継承させることもできます。

class EncodingError < FileError; end
class InvalidHeader < FileError; end

# rescue FileError で上記2つの例外を捕捉することもできる


では、カスタム例外のメリットを見ていきましょう。

エラー内容が直感的に把握できるようになる

独自定義したカスタム例外で先ほどのRuntimeErrorたちを置き換えてみましょう

InvalidCouponError: "データの状態に矛盾が生じています。想定していない組み合わせでの割引特典が使用されたため、user_id: 12345 のユーザーの決済処理をキャンセルしました"
QrCodeError: "不正な決済を検出しました。決済ID:1234567890abc"
InvalidHeader: "想定していない値がCSVファイルから検出されました。正しい値を入力し直してください"
EncodingError: "エラーが発生しました"
FileError: "何らかの不具合が発生しました。エラーが発生したのはCSVファイルの32行目です"

どうでしょうか?おそらく最初の例外クラスを見ただけで、おおよそのエラーの雰囲気が掴めたと思います。

InvalidCouponError を定義したことで、発生したエラーが不正なクーポン利用によるものだということがパッと見て分かるようになりました。

また、QrCodeError を定義したことにより、どの決済手段によるエラーかを文字列で表現する必要がなくなり、よりスリムな例外の表現に修正できました。

InvalidHeaderEncodingError を定義したことで、ファイルの処理中に生じたエラーの種類を細かく分類し、どう対処すれば良いかも直感的に理解できます。

エラーの原因が特定できない場合でも、FileError と表現することで少なくともファイルの処理中にエラーが起きたのだとすぐに把握出来るようになりました。

エラー検知ツールとの相性が良い

例外発生時の視認性を上げることは、SentryNewRelicといったエラー検知ツールの効果をより大きくしてくれました。

STORES ではSentryを使用していますが、定期的にSentryに登録されたエラーリストを見て、運用チームを中心としてエラー対応をしています。

(詳しくは、STORES の運用チームについて書いた STORESを支える「運用週」という仕組み - hey Product Blog をご覧ください)

f:id:dimn_zkym:20201127005659p:plain
この画面は他社が公開しているものですが、なんとなくのイメージを掴んでもらえればと思います
出典: How Sentry helps Nextcloud build reliable and secure software – Nextcloud

このSentryの画面に、ひたすらに RuntimeError が並んでいると想像してみてください。

二度とSentryを開きたくなくなると思います(私はそうでした)

エラー通知画面は日々の業務の合間にサッと目を通すことが多いものだと思うので、一覧生と視認性は欠かせません。

カスタム例外クラスを適切に定義することで各種エラーに名付けをすることは、エラーと戦うための大きな資産になるはずです。

おわりに

カスタム例外のメリットについて、私が感じたことをベースに書きました。

サービスの規模が大きくなり大量のエラー通知に素早く反応するためには、カスタム例外+エラー検知ツールの組み合わせは非常に強力な味方だと痛感しました。

導入コストも高くないと思うので、まだ使ってない方はカスタム例外の導入をぜひ検討してみてください!

この記事は hey Advent Calendar 2020の4日目の記事でした。 明日は SRE チームの @wanijiさんによる「MongoDBでnull以外を条件にデータを取得する時にCovered Queryにする方法」です! お楽しみに!