メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

HTTP API Clientライブラリの自作を手助けするGemを公開しました

こんにちは。サーバーサイドエンジニアの三村(@t_mimura39)です。 育休明け早々猛暑の熱気にやられ部屋に閉じこもっています。

今回はとあるGemを作成したので、そちらの紹介をさせていただきます。

目次

前フリ

Webアプリケーションを開発されている皆さん。 外部のHTTP APIを呼び出すような要件が発生したらどのように実装されますか?

まずはHTTP Clientライブラリ(Gem)の選定からですよね。 無難なところでfaraday、最近?だとhttpなんかも選択肢にありますし、Gemを利用せずにRuby標準の Net::HTTP を直接使うなんてこともありますよね。

これらのいずれかを採用した後はどのような実装をされますか?

お行儀が良い方は以下のような形で、専用のHTTP API Clientクラスを作成しその中にfaradayなどGemの依存を閉じ込めるのではないでしょうか

class BookStoreClient
  class Error < StandardError; end
  class ClientError < Error; end
  class ServerError < Error; end

  def books(limit:, offset: 0)
    connection = Faraday.new("https://example.com") do |builder|
      builder.response :raise_error
    end

    connection.get("/books")
  rescue Faraday::Error::ClientError
    raise ClientError
  rescue Faraday::Error::ServerError
    raise ServerError
  rescue StandardError
    raise Error
  end
end

こうすることのメリットは主に二つあります。

一つ目は 「faradayなどの外部gemの影響範囲を狭めることで、外部gemのアップデート時の対応が容易になる」です。

これは分かりやすいですね。
上記の例で言うとfaradayに関する定数やメソッドの利用箇所がClient内部に限定されるのでfaradayに非互換な変更が発生した場合はここだけを修正すれば良くなります。

二つ目は「複数サービスのAPIを同一箇所で呼び出す際に、Clientクラス単位で例外が分かれているため捕捉制御がしやすくなる」です。

これに関してもHTTP Clientに限定された話ではありませんが、例外が表す意味が大きくならないように適切に捕捉し別の例外に置き換えるべきです。という話です。

別の例を挙げるとActiveRecordの ActiveRecord::RecordNotFound があります。 これは「"何か" のレコードが見つからなかった」としか例外レベルでは表現されていないため、例外を捕捉する際に「何のレコードが見つからなかったのか」を特定するには例外オブジェクトの中身まで参照する必要があります。 faradayの例外に関しても全て Faraday::Error のサブクラスとして定義されているため同種の問題があります。

このような問題に対処するために「ライブラリが発生する汎用的な例外を適切に捕捉し、意味が限定的になるような個別の例外に置き換える」といった制御を私は良くやります。(皆さんもやりますよね?)

こういった実装は私の好みなので、今までも散々実装してきましたし今後も継続して推進しています。

が、しかし、しかしですよ。

HTTP APIを呼び出す処理なんてもう何回も実装して、その内の大半は同じような「例外の定義」「ステータスコードによる例外制御」をコピペ実装していませんか? 私はしてきました。ツライ

と言うことで本題です。

Gemの概要

上記のようなツラミを解消するGemを作成しました。

github.com

詳細はREADMEに記載しているためそちらをご参照ください。 と言いたいところですが、拙い英語でニュアンスが伝わるかも怪しいのでこの場を借りて母国語で解説したいと思います。

まず、本Gemの利用方法からです。

READMEに記載しているコード片をそのまま転記します。

class BookStoreClient
  include Ueki::HttpClient.new('https://example.com')

  # Class Method
  def self.delete_book(id)
    delete("/books/#{id}")
  end

  # Instance Method
  def books(limit:, offset: 0)
    get('/books', params: { limit: limit, offset: offset })
  end

  private

  def _default_headers
    h = super
    h['X-Request-Id'] = Current.request_id if Current.request_id.present?
    h
  end
end

BookStoreClient.new.post('/books', params: { title: 'Programming Ruby' })
#=> { id: 1, title: 'Programming Ruby' }

BookStoreClient.new.books(limit: 5)
#=> { books: [{ id: 1, title: 'Programming Ruby' }] }

BookStoreClient.delete_book(1)
#=> nil

BookStoreClient.get('/books/1')
#=> BookStoreClient::NotFoundError (status: 404, body: { message: 'Not Found' })

お察しの良い皆さまならこれだけで理解できるかと思います。

Ueki::HttpClient.new('https://example.com') で "良い感じ" のmoduleが作成されるので、これを自作のHTTP API Clientクラスにincludeするだけです。
そうすることで、getやpostなどのリクエストメソッドが定義され、レスポンスステータスに応じた例外ハンドリングも勝手に制御してくれます。

以上です!

あれ? 例外ハンドリングはfaradayの RaiseErrorMiddleware で十分と思ったそこのあなた! Uekiのウリはここからです。

Uekiがraiseする例外クラスは以下の階層構造となっています。

BookStoreClient
└── Error
    ├── RequestError
    │   ├── TimeoutError
    │   └── UnexpectedError
    └── UnsuccessfulResponseError
        ├── BadRequestError
        │   ├── UnauthorizedError
        │   ├── ForbiddenError
        │   ├── NotFoundError
        │   ├── RequestTimeoutError
        │   ├── ConflictError
        │   ├── UnprocessableEntityError
        │   └── TooManyRequestsError
        └── ServerError

こういったツリー構造って美しいですよね。

ですが、重要なのはそこではありません! これらのトップレベルには自作のHTTP API Clientクラスである BookStoreClient があります。

はい、もうお分かりの通りUekiは Faraday::Error のような汎用例外クラスではなく、includeしたクラスそれぞれに例外クラスも自動定義します。(これが欲しかった!)

これほど丁寧に例外ハンドリングをしてくれているので本Clientクラスの呼び出し側は安心して rescue BookStoreClient::NotFoundError のように例外を捕捉することができます。

ここまでをまとめると、Uekiは大きく分けて以下の二つの機能を提供します。

  • 例外クラスの自動定義
    • 400系, 500系などを考慮した捕捉制御のしやすい階層構造の例外
  • シンプルなget,postなどのリクエストメソッドの自動定義
    • ステータスコードに応じた例外ハンドリング機能付き

いかがでしょうか? とても薄い機能ですが痒い所に手が届くような機能提供となっているのではないでしょうか。

カスタマイズ性について

本Gemは元々弊社内の特定プロジェクトに依存した内部ライブラリとして開発していました。 そのため完全にfaradayに依存しており、faradayのミドルウェア設定なんかもビジネス要件に合わせたものを設定していました。 今回GemとしてOSS公開するにあたり、この点のカスタマイズ性について検討しました。

そこで私の中で出た結論としては「HTTPリクエスト処理に関してはUekiの責任でないから勝手に改変して良いと言う姿勢にしよう」です。

本件の詳細についてもREADMEに記載していますが、こちらでも解説します。

Uekiではデフォルトでfaradayの Net::HTTP adapter を利用し、ログ出力も デフォルトで有効にしています

例えば「Keep-Aliveを有効にしたい。そしてログ出力は不要だから抑制したい」のようなニーズがあったとします。 その場合は思い切ってメソッドを上書きして対処しましょう。

faradayのadapterを Faraday::NetHttpPersistent に変更し、好きなfaradayミドルウェアを自由に設定することが可能です。

class BookStoreClient
  include Ueki::HttpClient.new('https://example.com')

  private

  def _initialize_faraday_connection(request_options)
    Faraday.new(url: self.class::ENDPOINT, headers: _default_headers, request: request_options) do |builder|
      builder.adapter :net_http_persistent, pool_size: 5 do |http|
        http.idle_timeout = 100
      end
    end
  end
end

「faradayのどのadapterを利用しどのミドルウェアを適用するか」というのはビジネス要件次第で組み合わせが膨大になります。 設定変更用のDSLを提供することも検討しましたが、中途半端なものになってしまいかねないため思い切って「勝手にメソッドを上書きして」を公式の対処法と明言することとしました。

意味のある細かい粒度でメソッドを分割しているため、比較的メソッドの上書きはしやすいのではないかなと考えています。

ここでもう一つのニーズについて考えてみます。 「ウチのプロジェクトではfaradayは利用していない。Gemに頼らずNet::HTTPでリクエストしたい。それに逐一各クラスでメソッドの上書きをするのも面倒だ。」

ありそうな話ですよね。 これについてもUekiは "そこそこ" 対応しています。

Uekiの姿勢は「自作のRequesterモジュールを作成し、それをUekiで利用するように設定しましょう」です。 以下のようなイメージです。

class BookStoreClient
  include Ueki::HttpClient.new('https://example.com', requester: CustomRequester)
end

「最強のHTTP リクエスト処理」をUekiは目指しているわけではありません。 "皆さん" が思う「最強のHTTP リクエスト処理」を各々が実装すれば良いだけの話です。 Uekiは少しだけそこのお手伝いをします。

CustomRequester の作成方法など丁寧なドキュメントはまとめていませんが、おそらくここを自作したいと動く人は勝手にコードを読んで察するだろうと考えています。

また、Uekiがこの「HTTPリクエスト処理」に対しての想いが強くない表れとし、ueki.gemspecのdependencyには「faraday」を "あえて" 定義していません。 「faraday」の利用に関しても完全にオプショナルであるということです。

以上のように非常に柔軟で大胆なアプローチでUekiのカスタマイズ性を実現しています。

正直なところUekiに標準実装されている DefaultRequester に私が満足しているためここのカスタマイズ性に関してのモチベーションが低いです。 より洗練されたI/Fなど思いつく方いましたらIssueやPullRequestをお待ちしております。

まとめ

というわけで、今回は「HTTP API Clientライブラリの自作を手助けするGem」について紹介させていただきました。 このGemを利用せずとも、こういった考え方もあるのだなと思えていただけたら幸いです。

皆様のHTTP API人生に幸あれ

おまけ

class BookStoreClient
  include Ueki::HttpClient.new('https://example.com')

少し特殊なI/Fですよね。 includeできるのはmoduleだけなのに、 Ueki::HttpClient クラスとは何者なのかと。

これは 認証Gemのshrine なんかでも利用されているModule Builderパターンという形で実装しています。

こちらの連載記事が詳しいので最後の紹介しておきます。 techracho.bpsinc.jp

Uekiのコードも少しは整理されているものとなっているので、Rubyビギナーの方なんかは勉強がてら読んでみると面白いかと思います。

github.com


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp