Ruby ライブラリの例外設計を考える
はじめに
唐突ですが、皆さんはライブラリを作ったことがありますか?
「ライブラリ利用者が使いやすいように、複雑な仕様を簡単に表現する」ことは、意外と難しいものです。
この記事が、皆さんのライブラリ設計の役に立てば幸いです。
Ruby ライブラリの例外設計を考える
この記事では、WebAPI のクライアントライブラリを作ることを想定して記事を進めていきます。
常に気をつけるべきこと
依存する外部ライブラリの例外について、想定可能なものはすべて詰め替える
例えば「WebAPI が HTTP 500 Internal Server Error を返した」場合、
あなたの作ったライブラリはどのような例外を raise するべきでしょうか。
以前の記事でも触れましたが、
「あなたが WebAPI クライアントを実装するにあたりどんな gem を利用したか」は、利用者にとっては一切関係のないことであり、「意識させる必要がない」ものです。
-
Net::HTTPResponse#code
を取得して判定しなければならないのか -
Faraday::Error
を rescue しなければならないのか
このようなことをライブラリの利用者に考えさせてはいけません。
ライブラリ固有の例外を定義する際は、StandardError を直接継承しない
# Bad
module MyWebAPI
class Client
class Timeout < ::StandardError; end
class ClientError < ::StandardError; end
class ServerError < ::StandardError; end
end
end
# Good
module MyWebAPI
class Client
class Error < ::StandardError; end
class Timeout < Error; end
class ClientError < Error; end
class ServerError < Error; end
end
end
この2つのコードを比較した際、より「利用者のことを考えている」コードは「後者である」と言ってよいと思います。
それは、後者のほうが「利用者の選択肢が増える」ためです。
「WebAPI へのリクエストで例外が発生した場合、とりあえず処理を中断して画面に表示したい」といったユースケースを考えてみます。
module MyApp
class MyController
# Bad: すべての例外クラスを羅列する必要がある
def index
@res = MyWebAPI::Client.get(...)
rescue MyWebAPI::Client::Timeout, MyWebAPI::Client::ClientError, MyWebAPI::Client::ServerError => e
@res = nil
@error = e
end
# Bad: WebAPI へのリクエスト以外で発生した例外も拾ってしまう
def index
@res = MyWebAPI::Client.get(...)
rescue StandardError => e
@res = nil
@error = e
end
# Good: rescue する例外の範囲を絞りつつ、簡潔に書ける
def index
@res = MyWebAPI::Client.get(...)
rescue MyWebAPI::Client::Error => e
@res = nil
@error = e
end
# Good: 例外クラスによって処理を分けたい場合はもちろん個別に指定できる
def index
@res = MyWebAPI::Client.get(...)
rescue MyWebAPI::Client::ClientError => e
...
rescue MyWebAPI::Client::Timeout, MyWebAPI::Client::ServerError => e
...
end
end
end
後者の定義であれば、「まとめて rescue する」「個別に rescue する」のいずれもが利用者の選択肢となります。
利用者が「より便利に」使えるように意識することは、ライブラリ設計の重要な観点です。
よりよい例外設計を考える
リトライ可能判定を例外の属性として定義する
さて、「WebAPI の一時的なエラー」によって例外が発生した場合をユースケースとして考えてみます。
利用者が「より便利に」使えるようにするためには、「リトライ可能な例外であること」を返してあげる必要があります。
最も簡単な方法は、MyWebAPI::Client::Error#retryable?
といったメソッドを用意してあげることでしょう。
module MyWebAPI
class Client
class Error < ::StandardError
def initialize(message = nil, retryable:)
super(message)
@retryable = !!retryable
end
def retryable?
@retryable
end
end
class Timeout < Error
def initialize
super(retryable: true)
end
end
class ClientError < Error
def initialize(message)
super(message, retryable: false)
end
end
class ServerError < Error
def initialize(message)
super(message, retryable: true)
end
end
end
end
# usage
module MyApp
class MyController
def index
@res = MyWebAPI::Client.get(...)
rescue MyWebAPI::Client::Error => e
retry if e.retryable?
@res = nil
@error = e
end
end
end
これでも十分なのですが、
- rescue 節の中に if 文を書く必要がある
- 「rescue する必要のない例外まで捕捉して、リトライ不可な場合は例外を再度 raise する必要がある」などのユースケースで使い勝手が悪い
- 「特定の例外クラスのインスタンスか?」だけで「リトライ可能か?」を判定したい場面がある[1]
このような場面での使い勝手を考慮し、もう少し改善してみたいと思います。
リトライ可能判定を mixin を使って定義する
module MyWebAPI
class Client
class Error < ::StandardError
module Retryable
def retryable? = true
end
def initialize(message = nil, retryable:)
super(message)
extend Retryable if retryable
end
def retryable? = false
end
...
end
end
#retryable?
の戻り値を、「インスタンス変数」ではなく「モジュール」で管理するようにしてみました。
Object#extend
は「モジュール(クラス)に特異メソッドを追加する」ために使われることが多いメソッドですが、
Module#include
とは異なり Object
class に定義されているため、
「特定のインスタンスに、動的に継承関係と特異メソッドを追加する」ためにも使うことができます。
MyWebAPI::Client::Error.new(retryable: true).is_a? MyWebAPI::Client::Error::Retryable #=> true
MyWebAPI::Client::Error.new(retryable: false).is_a? MyWebAPI::Client::Error::Retryable #=> false
MyWebAPI::Client::Error.new(retryable: true).retryable? #=> true
MyWebAPI::Client::Error.new(retryable: false).retryable? #=> false
module MyApp
class MyController
def index
@res = MyWebAPI::Client.get(...)
rescue MyWebAPI::Client::Error::Retryable
retry
rescue MyWebAPI::Client::Error => e
@res = nil
@error = e
end
end
end
まとめ
- ライブラリの設計は常に「どう実装すれば利用者にとって便利か?」を考える必要があり、単に「アプリの一部の機能を切り出して共有する」とは違った観点での設計が必要となります。
- この記事では、その中でも特に「例外設計」について考えるべき観点と、特定のユースケースを取り上げて「更に便利な実装にするためには」を解説しました。
Discussion