検索の設計、プログラミングは難しい!

Posted by SpaceAgent Tech Blog スペテク on Monday, March 11, 2019

LGTM 画像探しに余念がない @kaiba です。
今回は検索周りのプログラムの設計について書いてみます。

検索は難しく、ひどいコードになりがち

僕は様々なプロジェクトに関われる受託開発を 10 年と、スタートアップ数社に関わってきまして、 様々な検索のプログラムを見てきました。
僕も心苦しいコードを書いてきましたし、心苦しいコードをメンテしてきました。
はっきりいって検索のプログラミングをきれいに書くのは難しいんです。
どうしてこうなってしまうのか、例と共に考えて見ます。

注意: 私自身このようなコードを書いてきましたし、特定の会社のコードを悪く言っているわけではありません。

最初はキーワード検索だけだった

スペースエージェントでは不動産物件を扱っています。不動産物件を探すサイトを例にして、考えてみます。

1
Item.where(keyword: params[:keyword])

ごくごくシンプルです。見通しもいいですね。
これがコントローラのコードにあっても、まあ良いかな、と思えます。
Ruby っぽいコードですがサンプルです。
擬似コードのつもりで見てください。

賃貸物件、売買物件で絞り込みたい

2
Item
  .where(keyword: params[:keyword])
  .where(type: params[:type])

ちょっと嫌な予感がしてきました。

価格、駅徒歩分で絞り込みたい

3
Item
  .where(keyword: params[:keyword])
  .where(type: params[:type])
  .where("price < ?", params[:price])
  .where("walking_minute < ?", params[:walking_minute])

むむ…。

検索結果に検索中の情報を反映したい

検索結果から更に絞り込みたい場合、今入力している内容がフォームに反映されていて欲しいですよね。

@params = params # view に渡して表示に使う
<input type="radio" name="type" value="1"
<%= params[:type] == Type::ForRent ? "selected" : "" %> >
<input type="radio" name="type" value="2"
<%= params[:type] == Type::ForSale ? "selected" : "" %> >
価格:<input type="text" name="price"
 value="<%= params[:price] %>" >円以下
駅徒歩:<input type="text" name="walking_minute"
 value="<%= params[:walking_minute] %>" >分以下

view に三項演算子…。
これはビジネスロジックにすべき…という疑念が湧いてきます。

検索条件が増えてきて使いづらい。「おすすめ」検索を用意したい。

4

おすすめとは、最も頻繁に検索されていた、10 万以下、駅徒歩 10 分以下の条件とします。

if params[:recommend]
  params[:price] = 10
  params[:walking_minute] = 10
end

僕は冷や汗がでてきました。しかし、時間もありません。

クレーム来た

  • おすすめを選んだら検索結果画面では 10 万以下、駅徒歩 10 分以下 が選択された状態になった
  • おすすめの検索結果に対して、9 万以下を足したけど、無視された

パラメータを弄ったら画面にまで反映されてしまったり、どこか直すとどこか壊れる、そんな状態になってきました…。

問題点

僕は以下の問題点があったと感じています。

  • パラメータが増えてきた時点で設計を見直すべきだったのにそのまま進めてしまった
  • params をいじくり回している
  • params を view に渡している
  • その結果、params とその他が密結合になってしまい、どこか直すとどこかが壊れる
  • おすすめの条件と衝突する条件の扱いの仕様が不明確

スペースエージェントの解決策

オブジェクト指向っぽく設計していきます。
今回はパラメータの構築のみ扱い、 view への反映はまた別の機会に…。

  • 無数に増え続けるパラメータを抽象化したい
  • 共通のインタフェースは「URL パラメータを受け取り、検索条件を構築する」
class
class BaseParam
  # 何かしら URL パラメータを受け取り、何かしら検索条件を作る抽象メソッド
  def toQuery(params)
  end
end

パラメータを検索条件に変換する抽象クラスです。
「何をしてるか知らないけど検索条件を返す」という抽象化です。

class KeywordParam : BaseParam
  def toQuery(params)
    [keyword: params[:keyword]]
  end
end

シンプルなのは愚直に。

class RecommendParam : BaseParam
  def toQuery(params)
    [
      ["price < ?", 10],
      ["walking_minute < ?", 10]
    ]
  end
end

おすすめの検索条件はこんな感じでしょうか。

class Search
  def initialize(params)
    @params = params
  end

  def execute
    items = Item
    %w(keyword price walking_minute recommend).each do |key|
      param = xxx # 省略: key に対する BaseParam のインスタンスを生成する
      items = items.where(param.toQuery(self.params))
    end
    items
  end
end
# controller
@items = Search.new(params).execute

終わり

ずいぶん、見やすいコードになった気がします。
「前こうなっていたから」で継ぎ足して行くととんでもない設計になりがちです。
勇気がいるところですが再設計をすることも考えましょう。早いほうがしんどくないです。
2歩目を踏み出す勇気、絞り出していきましょう。