📌

Ruby ライブラリの例外設計を考える

2022/01/15に公開

はじめに

唐突ですが、皆さんはライブラリを作ったことがありますか?
「ライブラリ利用者が使いやすいように、複雑な仕様を簡単に表現する」ことは、意外と難しいものです。

この記事が、皆さんのライブラリ設計の役に立てば幸いです。

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

まとめ

  • ライブラリの設計は常に「どう実装すれば利用者にとって便利か?」を考える必要があり、単に「アプリの一部の機能を切り出して共有する」とは違った観点での設計が必要となります。
  • この記事では、その中でも特に「例外設計」について考えるべき観点と、特定のユースケースを取り上げて「更に便利な実装にするためには」を解説しました。
脚注
  1. 私の参画しているPJでは retriable gem を模倣して作った独自のリトライ実装があり、この実装ではリトライすべきか?の判定に「例外の属性を使う」ことができませんでした。 ↩︎

Discussion