Rubyでファーストクラスコレクションをベースに、検索結果をオブジェクト化する

はじめに

こんにちは、ITANDI株式会社でITANDI BBの開発をしている渡邉(海)です。 新卒4年目で、今年の2月末までは、toC向けのお部屋探しサイトの OHEYAGOを担当していました。 今年の3月からITANDI BBでサーバサイドを中心に開発しています。 ITANDI BBとは、ITANDIが提供している不動産会社向けの業者間サイトのことです。 バックエンドはRails、フロントはReact、インフラはAWS、エラー監視としてSentryを使っています。 趣味はバスケで、月に数回は行っています!

社内利用のAPIの検索結果を、オブジェクト化した際の知見を共有できればと思います。

背景

バックエンドから、社内の物件データ基盤の物件の検索結果を呼び出す必要が有りました。社内の物件データ基盤は、REST APIで、レスポンスはJSON形式で実装されています。こちらのレスポンスをオブジェクト指向に準拠した形でITANDI BBで取り扱うクラスを実装したいです。比較的テスタブルで、拡張性高く読みやすい実装を目指した結果、ファーストクラスコレクションをベースにするとよさそうでした。

ファーストクラスコレクション(コレクションクラス)とは?

デザインパターンの一種で、配列などをラップしたクラスを指します。 配列を扱うコードが、プログラムのあちこちに散らばらないというメリットがあります。 今回の要件では、検索結果は物件の一覧を取り扱うので相性がよさそうです。

class RoomCollection
  def initialize(rooms)
    @rooms = rooms
  end
end

ファーストクラスコレクションとの違い

ファーストクラスコレクションに完全に準拠するだけでは、「検索結果」をうまくオブジェクトとして扱いきれなかったので、以下のような変更を加えました。

pageを要素として持つ

検索結果には、検索に引っかかった物件自体以外にも、「ページネーションで何ページ目か?」なども含みます。コレクションクラスの要素としてPageクラスを実装することで、roomspageを一括のものとして取り扱えてわかりやすそうです。

class RoomCollection
  def initialize(rooms, page)
    @rooms = rooms
    @page = page
  end
end

APIの呼出しをクラス関数で持つ

今回の要件では、APIの呼出し結果をわざわざclass外から流し込んで上げる必要性がなさそうでした。また、「社内の物件データ基盤の物件の検索結果」を取り扱うクラスと捉えれば、APIの呼出し自体もクラス内にあって問題なさそうです。クラス関数の命名をわかりやすいものとして、APIの呼出し自体をクラス関数で実行するようにしました。

class RoomCollection
  class << self
    def search(params)
      # 社内の物件データ基盤の物件の検索結果を取得する処理
      response_body = Product::Client.search_rooms(params)
      new(
         Room.build_array(response_body[:rooms]),
         Page.build(response_body[:page])
       )
    end
  end

  def initialize(rooms, page)
    @rooms = rooms
    @page = page
  end
end

完全コンストラクタパターンを採用する

現段階の要件としては、インスタンスを変更できないようにしておいてよさそうです。極力処理をイミュータブルにしておきます。

class RoomCollection
  attr_reader :rooms
  attr_reader :page

  class << self
    def search(params)
      response_body = Product::Client.search_rooms(params)
      new(
         Room.build_array(response_body[:rooms]),
         Page.build(response_body[:page])
       )
    end
  end

  def initialize(rooms, page)
    @rooms = rooms
    @page = page
  end
end

結局テストしやすくなったの?

.searchのテストとしては、以下の項目を書いてあげると十分かなと思います。

  • 返り値は、RoomCollectionのインスタンスか?
  • roomsは、APIのレスポンスと同数のinstanceを持つか?
  • 内部でAPIの呼出しは1回だけ行われているか?

PageRoomに関することは、それぞれのクラスでテストを書けばよさそうな実装にできたので、比較的テスタブルな実装にできていそうです。

拡張性高く読みやすい実装になっている?

.search自体は比較的自由度の高い検索でしたが、1つの条件のみで検索する(Roomidsのみで検索する)ケースがありました。Roomidsで検索する場合は.fetch_by_idsのようなmethodを足していけば容易に実装できそうで、読みやすさも担保されていそうです。また、idsが空の場合は、API自体呼ばないということにも対応できそうです。

class RoomCollection
  attr_reader :rooms
  attr_reader :page

  class << self
    def search(params)
      response_body = Product::Client.search_rooms(params)
      new(
         Room.build_array(response_body[:rooms]),
         Page.build(response_body[:page])
       )
    end

   def fetch_by_ids(ids)
      return new([], Page.build_empty) if ids.empty?

      response_body = Product::Client.search_rooms({ ids: ids })
      new(
         Room.build_array(response_body[:rooms]),
         Page.build(response_body[:page])
       )
   end
  end

  def initialize(rooms, page)
    @rooms = rooms
    @page = page
  end
end

おわりに

現段階の僕の知識内においては、比較的わかりやすい実装にできてよかったなと思いました。しかし、時の流れとともにこの設計も見直すべき箇所が出てくることと思います。また、僕が知らないだけでもっと良い設計や取り入れたほうがよいrubyの構文もあることでしょう。いつまでもより良い実装を探求し、将来自分の書いたコードを見る人にも、何よりもユーザに価値のあるものを作れるように力をつけていかないとなと改めて思いました。
この記事がだれかの一助になれば幸いです!!