2010/10/04

Rails3で汎用検索機能を作る

業務アプリでは CRUD 以外で必ずと言ってもいいほど実装する機能があります。検索です。
検索機能って、たいていはページの上部に検索フィールドがあって、入力して検索ボタンを押すとページの下部に検索結果が表示される。
こんなのばっかりです。
それを毎回ページ全体をゴリゴリ作るって疲れませんか?
Rails で scaffold すると index は一覧表示になりますが、ここが検索になる画面のほうが圧倒的に多くないですか?

というわけで、汎用検索機能(というか簡易フレームワーク)を作ろうってのが今回の趣旨です。
大体、検索機能で検索ごとに違うのは大まかには下記の三つでしょう。
・検索フィールド
・検索ロジック
・結果表示(一行分)
極力この三点に注力できるようにしたい。
具体的には、検索条件は汎用的なコンテナクラスに格納する。
コンテナクラスは、検索条件だけを格納できるように form_for で利用できるようにしたい。
そうすれば、他にどんなパラメータがあっても params[:form] みたいにして検索条件だけをいつでも取得できる。
検索の通信は AJAX で単一のコントローラに飛ばすことにする。
そして検索を実行するロジッククラスだけ実装すればいいようにする。
各コントローラに検索アクションを作るとなると、たとえ共通化していてもどうしてもお約束的なものを書かざるをえない。
何検索を行うかは、クエリ文字列にモードとして埋め込むことにする。

まず、汎用検索条件格納クラスをつくる。
検索条件というカテゴリのパラメータだけ格納したいので、form_for で使えるモデルクラスを作る。
# -*- coding: utf-8 -*-
# 検索パラメータを格納する汎用クラス
class Q
  # model_name を実装してくれる。これがないと form_for できない
  extend ActiveModel::Naming
  # to_key などを実装してくれる。これがないと form_for できない
  include ActiveModel::Conversion

  # オブジェクトを初期化する
  # 内部にパラメータ用のハッシュを持つ
  def initialize(params = {})
    @params = params || {}
  end

  # 指定のキーで格納している値を取得する
  # キーは文字列でもシンボルでもよいが、内部ではシンボルで保持している
  def [](key)
    @params[key.to_sym]
  end

  # 指定のキーで指定の値を格納する
  # キーは文字列でもシンボルでもよいが、内部ではシンボルで保持している
  def []=(key, value)
    @params[key.to_sym] = value
  end

  # ActiveModel::Conversion を include するために必要
  def persisted?
    false
  end

  # メソッドが存在しなくてもいいようにオーバーライドする
  def respond_to?(name, priv=false)
    true
  end

  # メソッドでアクセスされた場合、ハッシュから値を取得する
  # 代入メソッドの場合、ハッシュに値を格納する
  def method_missing(name, *args)
    if name.to_s.ends_with?('=')
      @params[name.to_s.chomp('=').to_sym] = *args[0]
    else
      @params[name.to_sym]
    end
  end

  # to_s をオーバーライド
  # 内部に格納しているパラメータをハッシュ形式で出力する
  def to_s
    @params.to_s
  end

  # 内部に格納しているパラメータをクエリ文字列形式で出力する
  # ただし、内部のハッシュが空の場合は空文字を出力する
  def to_query_string
    kvs = @params.map do |k, v|
      "q[#{URI.encode(k.to_s)}]=#{URI.encode(v.to_s)}"
    end
    if @params.length == 0
      ''
    else
      '?' + kvs.join('&')
    end
  end

  # 指定のキーの値をキーごと削除する
  def delete!(key)
    @params.delete key.to_sym
  end
end
なんでこんなクラスを作ったかというと、毎度毎度検索フォームモデルクラスを作るのが嫌だったから。
もちろんWEB+DB PRESS vol.58で作られているような、検索フォームクラスを作ってもいいんですが、それだと検索機能の数だけモデルを作らなければならない。
また、フォームクラスの属性定義と、form_for でのフィールド定義で二重の定義を行わなければならない。
検索条件はフォーム主体でいいと思うので、こんなクラスを作った。
クラス名はクエリ文字列にしたときにできるだけ短くしたかったので、一文字で。q なら Google などでも使われているし、クエリということが解りやすいだろうから。
ほんとは delete メソッドを作りたくなかった。
というのもどのようなパラメータ名で値を飛ばしても大丈夫なように極力特別なメソッドは実装したくなかった。
しかし、ページングやソート機能を実装しているとどうしても削除が必要になった。
! 付けたパラメータはまず使わないだろうと思ったのと、キー削除してるし破壊的だよなということで ! をつけた。

次は検索ロジッククラスのジェネレータを作ろう。
% rails g generator finder

lib/generators/finder/finder_generator.rb
# -*- coding: utf-8 -*-
# 検索ロジッククラスを作るジェネレータクラス
class FinderGenerator < Rails::Generators::NamedBase
  source_root File.expand_path('../templates', __FILE__)

  # 各種ファイルを作る
  def create_finder_files
    template 'finder.rb', File.join('app/finders', class_path, "#{file_name}_finder.rb")
    template 'form.html.erb', File.join("app/views/search/#{file_name}", class_path, '_form.html.erb')
    template 'result.html.erb', File.join("app/views/search/#{file_name}", class_path, '_result.html.erb')
  end
end
lib/generators/finder/templates/finder.rb
# -*- coding: utf-8 -*-
class <%= class_name %>Finder
  # 検索を実行する
  def find(q)
    return nil unless q
  end
end
lib/generators/finder/templates/form.rb
# からっぽ
lib/generators/finder/templates/result.rb
<table id="<%%= q[:mode] %>_result" class="grid">
  <thead>
    <tr>
      <th></th>
      <th></th>
      <th></th>
    </tr>
  </thead>
  <tbody>
  <%% result.each do |record| %>
    <tr>
      <td></td>
      <td></td>
      <td></td>
    </tr>
  <%% end %>
  </tbody>
</table>
正直、result.rb も適当である。どうせデータによって変わるので。 これでこんな感じで、そのモード専用の検索ロジッククラスと検索条件フォームと検索結果表示パーシャルファイルができるようになる。
% rails g finder mode
次は検索コントローラを作ろう。
class SearchController < ApplicationController
  def index
    @q = Q.new(params[:q])
    finder = get_finder(@q.mode)
    @search_result = finder.find(@q)
  end
end
application_controller.rb に get_finder メソッドを実装する。
protected
  def get_finder(mode)
    eval("#{mode.classify}Finder.new")
  end
次は app/views/search/index.rjs を作る。 検索結果の表示は search_result という div の中を書き換えるだけだ。
if @search_result.nil? || @search_result.length == 0
  page[:search_result].replace_html :partial => 'search/not_found'
else
  page[:search_result].replace_html :partial => "search/#{@q[:mode]}/result", :locals => { :q => @q, :result => @search_result }
end
app/views/search/_not_found.html.erb はとりあえず「見つかりませんでした」とだけいれておけばいい。 jQuery を使っている場合はこれだとうまくいかなくて、別途対応を入れる必要がある。 それはまた別のエントリで。 検索画面自体は、ほとんどが上部に検索フィールド、下部に結果表示なのでその枠組みも用意しておいたほうが楽かもしれない。 app/views/search/_base.html.erb を作ろう。
<!-- 検索フォーム -->
<%= form_for @q, :url => '/search', :html => { :method => :get }, :remote => true do |f| %>
  <%= f.hidden_field :mode %>
  <%= render "search/#{@q[:mode]}/form", :f => f %>
  <%= f.submit '検索' %>
<% end %>

<!-- 検索結果 -->
<div id="search_result">
</div>
おっと、ルーティングの設定を忘れていたね。/search だけでアクセスできるようにしたい。
match '/search' => 'search#index'
おおむねこんな感じでokだろう。 たとえばユーザ検索画面を作りたい場合。 app/controllers/users_controller.rb
def index
  @q = params[:q] ? Q.new(params[:q]) : Q.new(:mode => 'user')
end
app/views/users/index.html/erb
<%= render :partial => 'search/base', :mode => 'user' %>
これだけで枠組みはオーケー、あとは rails g finder user で作成されたファイルだけ実装すればいい。

今後はこれに、ソート機能とページングの機能を共通化として追加してみるよ。
正直スマートな方法じゃないかもしれないし、RESTful じゃないかもしれないけど、検索を集中化するって結構便利だと思う。
のちのち様々な直接ダウンロード機能を実装する場合などには管理しやすくなるかも。

0 件のコメント :

コメントを投稿