エラーコードに縛られずシンプルかつ有用なエラー設計をする


エラーコードやエラークラスの設計ってめんどうですよね。つまらないし・・・今までも度々エラーコードをどういう単位で宣言すべきかと頭を悩ませる事があったんですが、エラーコードに縛られすぎてないか?もっとシンプルに考えればいいんじゃないか?という結論に達しました。

まず大きな気づきは、エラーコードで条件分岐する場面はそう多くないということです。そしてエラーが起きた時にすることと言えば、たいていの場合、呼び出し元まで戻るか、その場で、

  • ユーザーにエラー表示する
  • ログに出力する

をすることである。

条件分岐しないなら文字列だけでいい

条件分岐しないエラーをエラーコードで分離する意味はあまりないと思う。ユーザーにエラー表示するかログ出力するだけなら文字列だけエラーの情報としてあればいい。エラーが発生した時点でログ出力したいなら、その場で出力すれば良いだけだ。

エラーを呼び出し元でログ出力、ユーザーへ表示したい場合は、汎用エラークラス(AnyErrorとか)に文字列だけを内包させて、呼び出し元へ投げればいい。

そこで今さら思ったのだが、rubyってこの辺を考慮して例外を設計しているように思う。

begin
  raise "Hoge"
rescue => e
  puts e.message
end

と、

begin
  raise RuntimeError.new("Hoge")
rescue => e
  puts e.message
end

は、実質同じコードである。つまり前者はRumtimeError.newする手間を減らしてくれているにすぎない。条件分岐が必要ないなら文字列だけ詰めて投げればいいんじゃない?ということを示唆した設計になっているように思える。

エラーコードを一次元的に宣言しない方が良い

よくありがちなのはエラーコードを以下のように全部並べて宣言すること。

#
# データベース関連
#
DB_CONNECTION_ERROR = 1000
DB_ACCESS_ERROR = 1001
#
# ネットワーク関連
#
SERVER_CONNECTION_ERROR = 2000
INVALID_SERVER_RESPONSE_ERROR = 2001
SERVER_INTERNAL_ERROR = 20002
.
.
.

これは良くない。条件分岐したい場合のエラーは、種類ごとにエラークラスを分け、各種エラークラスに分岐の際に必要な情報がきちんと入っていることが大切である。

むしろエラーコードという概念に縛られてはいけないと思う。

エラーには分岐に必要な情報が入っているべき

分岐の際に判断するのに必要な情報は、エラーの種類によって異なる。ただエラーコードがあれば良いだけの場合もあるし、複数の情報が必要な場合もある。

例えばHTTPエラーは、ステータスコードとエラーメッセージがあれば良いだろう。rubyで宣言するならこのようになる。

class HTTPError
  attr_reader :status_code

  def initialize(status_code, message)
    @status_code = status_code
    super(message)
  end
end

使用する際はこのようになる。

begin
  # raise HTTPError.new(404, "Not found the page.")
  # とか
  # raise HTTPError.new(500, "Internal server error.")
  # が投げられるコード
rescue HTTPError => e
  # 詳細メッセージをログを出力
  logger.error e.message
  # status_codeに応じユーザーへのエラー表示を変更する
  case e.status_code
  when 404
    render "error_404.html"
  when 500
    render "error_500.html"
  else
    render "error_unknown.html"
  end
end

エラーの種類に応じて情報の種類が変わり、かつ、情報が複数あるものの例として、支払いに関するエラーを宣言してみる。

# 支払いエラー基本クラス
class PaymentError < RuntimeError
end

# サーバー内部エラーによるエラー
class PaymentServerInternalError < PaymentError
  attr_reader :error_code # エラーコード

  def initialize(error_code, message)
    @error_code = error_code
    super(message)
  end
end

# 所持ポイント不足によるエラー
class PaymentLackOfPointError < PaymentError
  attr_reader :user_points, # ユーザーの所持ポイント
              :item_points  # 商品購入に必要なポイント

  def initialize(user_points, item_points, message)
    @user_points = user_points
    @item_points = item_points
    super(message)
  end
end

後者の場合は、エラーコードはなくユーザーの所持ポイントと商品購入に必要なポイントが入っています。このようにエラーの種類によっては、必要な情報がエラーコードだけとは限らないし、エラーコードが必要ない場合もあると思います。

エラーコードありきのNSError

エラー設計をエラーコードの設計から入るという思い込みがあると、苦しむことになりかねないと思います。今思うとiOSのObjective-C時代に使われていたNSErrorは、まさにそのような苦しみを誘導していたように思う。

NSErrorは以下の3つの基本要素からなります。

  • domain: エラーの種類を区別する文字列
  • code: エラーコード。domainが異なる者同士での重複を許容する
  • userInfo: 追加的な情報

コンストラクタからして、それを使うことが前提のようになっている。

init(domain: String, code: Int, userInfo: [String : Any]? = nil)

エラー分岐が必要な際は、まずdomainで分岐し、次にcodeで分岐することを前提とした作りである。そして追加的な情報はuserInfoを参照する。この設計に引きずられて苦しんできた思いがある。

Swiftの柔軟なエラー設計

現在のiOSの開発ではSwiftを使うことができる。Swiftはエラーコードありきで考えないエラー設計になっている。Errorプロトコルを継承したenumでエラー宣言すると、このようにスマートに書くことができる。

enum PaymentError: Error {
    case serverInternalError(code: Int, message: String)
    case lackOfPoint(userPoints: Int, needsPoints: Int, message: String)
}

エラーを投げたり、受け取る時は以下のように書く。

do {
  # 以下のようにエラーを投げる
  # throw PaymentError.lackOfPoint(
          userPoints: 300, 
          needsPoints: 500, 
          message: "ポイントが不足しています")
} catch PaymentError.serverInternalError(let code, let message) {
  // Do something
} catch PaymentError.lackOfPoint(let userPoints, let itemPoints, let message) {
  // Do something
} catch {
  // Do something
}

NSErrorの悪夢から目が覚めるようである。

まとめ

  • エラーの受け手が分岐を必要としないなら、エラーの情報は文字列だけでいい
  • 分岐が必要になるエラーは、分岐に必要な情報を詰めるよう設計することが大事
  • エラーコードに考えを縛られないこと