RailsでFat ControllerになったらForm Classを作れ,という記事はあちこちで見るのですが,今一つ参考になるような実装があまり見つからなかったので記事にしてみました.
Rails 3.2.13,Ruby 2.0.0系で動作確認済みです.
Fat Controller問題
Railsで特に何も考えずに検索機能を作っていると,検索ロジックでcontrollerが膨らんできてしまうと思います(Fat Controller問題).
例えば,検索フォームの内容として最初は名前だけで検索するとのことで,
HogeController < ApplicationController
def index
@hoges = Hoge.where("name LIKE ?", "%#{params[:name]}%")
end
end
と書いていたのが,そのうちメールアドレスや住所でも絞り込みしたくなり,
HogeController < ApplicationController
def index
@hoges = Hoge.where("name LIKE ?", "%#{params[:name]}%")
@hoges = @hoge.where("email LIKE ?", "%#{params[:email]}%")
@hoges = @hoge.where("address LIKE ?", "%#{params[:address]}%")
end
end
と,どんどん内容が増えていきます.ちなみに,当該パラメータが空の場合に絞り込みたくなければ,
HogeController < ApplicationController
def index
@hoges = Hoge.scoped
@hoges = @hoge.where("name LIKE ?", "%#{params[:name]}%") if params[:name].present?
@hoges = @hoge.where("email LIKE ?", "%#{params[:email]}%") if params[:email].present?
@hoges = @hoge.where("address LIKE ?", "%#{params[:address]}%") if params[:address].present?
end
end
と,scopedと後置ifを使って書くと少しはマシに見えますが,検索フィールド数が増えたり,パラメータを前処理してからwhereに渡したりしたくなってくるとどんどんcontrollerが太っていきます.
また,この方式の場合,検索結果ページにも検索フォームを表示したい,といったときには検索パラメータをViewに渡す必要があり,その辺りも含めて実装していくと,さらに太ります.
フォームクラスを作る
こんな時,検索フォーム自体をクラスとして実装するフォームクラスを検討せよ,というのが良く言われる話です.
フォームクラス自体はWebアプリケーションフレームワークでは割合一般的で,PHPでも古くはPEAR::HTML_Quickformから最近ではSymfony 1.x系のsfFormなど,フォームを1クラスとして実装する概念は一般的です.
RailsではCode Climate Blogの7 Patterns to Refactor Fat ActiveRecord Modelsでフォームクラスに関する言及がありますし,RailsCastsでもForm Objectに関する配信があります(これはPro向け配信なので,有償登録しないと観られません).
Code Climate Blogの例では,solnic/virtusというgemを利用するやり方が書いてあり,実際に上記の例で実装してみると,以下の様になると思います.
Form Class
# app/models/hoge_form.rb
class HogeForm
include Virtus
extend ActiveModel::Naming
include ActiveModel::Conversion
include ActiveModel::Validations
attribute :name, String
attribute :email, String
attribute :address, String
# validationが必要ならここにvalidatesを書ける
# validates :name, presence: true
def search
scoped = Hoge.scoped
scoped = Hoge.where("name LIKE ?", "%#{name}%") if name.present?
scoped = Hoge.where("email LIKE ?", "%#{email}%") if email.present?
scoped = Hoge.where("address LIKE ?", "%#{address}%") if address.present?
scoped
end
end
Controller
# app/controllers/hoge_controller.rb
HogeController < ApplicationController
def index
@hoge_form = HogeForm.new params[:hoge_form]
@hoges = @hoge_form.search
end
end
View
<!-- app/views/hoges/index.html -->
<h1>Hoge search</h1>
<div class="search_form">
<%= form_for @hoge_form, url: hoges_path, method: :get do |f| %>
<%= f.text_field :name %>
<%= f.text_field :email %>
<%= f.text_field :address %>
<%= f.submit %>
<% end %>
</div>
<div class="result">
<table>
<tr>
<th>名前</th>
<th>メールアドレス</th>
<th>住所</th>
</tr>
<% @hoges.each do |hoge| %>
<tr>
<td><%= hoge.name %></td>
<td><%= hoge.email %></td>
<td><%= hoge.address %></td>
</tr>
<% end %>
</table>
</div>
Controllerにあった検索ロジックがフォームクラスに移ったのが分かると思います.
また,もしパラメータの前処理がしたければ,フォームクラス側のvalidatorなどで実装すれば良いわけですね.
フォームクラスを一般化する
ここまでに上げたフォームクラスの実装方法により,Controllerに実装されていったロジックがフォームクラスに移り,見通しが良くなりました.
しかし,こういった特定Modelに対する検索機能は非常によく行う処理のため,いちいちScaffoldごとに書いているとしんどいです.
そこで,一般的な検索に使うフォームクラスを作成するのに便利なクラスを作ってみました.内部でActiveAttrを使っているので,利用する場合はGemfileにactive_attrを追加してbundle installして使って下さい.
# 検索フォームのための汎用モデル
class SearchForm
include ActiveAttr::Model
class_attribute :_search_model
class_attribute :_like_attributes
class_attribute :_equal_attributes
class_attribute :_join_tables
self._like_attributes = []
self._equal_attributes = []
self._join_tables = []
class << self
private
def inherited(child)
child._search_model = child.name.gsub('SearchForm', '').constantize
end
def define_attribute(*attrs)
attrs.each do |attr|
if attr.respond_to?(:each)
attr.each do |attr2|
__send__(:attribute, attr2) unless attributes.include?(attr2)
end
else
__send__(:attribute, attr) unless attributes.include?(attr)
end
end
end
def search_model(attr)
self._search_model = attr
end
def like_attributes(*attrs)
define_attribute attrs
if attrs.respond_to?(:each)
attrs.each do |attr|
self._like_attributes += [attr]
end
else
self._like_attributes += [attrs[0]]
end
end
def equal_attributes(*attrs)
define_attribute attrs
if attrs.respond_to?(:each)
attrs.each do |attr|
self._equal_attributes += [attr]
end
else
self._equal_attributes += [attrs[0]]
end
end
def join_tables(*attrs)
define_attribute attrs
if attrs.respond_to?(:each)
attrs.each do |attr|
self._join_tables += [attr]
end
else
self._join_tables += [attrs[0]]
end
end
end
def search
scoped = _search_model.scoped
_like_attributes.each do |attr|
scoped = scoped.where _search_model.arel_table[attr].matches("%#{send(attr)}%") if send(attr).present?
end
_equal_attributes.each do |attr|
scoped = scoped.where _search_model.arel_table[attr].eq(send(attr)) if send(attr).present?
end
_join_tables.each do |model|
scoped = scoped.includes model
end
scoped
end
end
このSearchFormを使うと,先ほど書いた例は以下の様に書けます.
Form Class
class HogeSearchForm < SearchForm
like_attributes :name, :email, :address
end
Controller
# app/controllers/hoge_controller.rb
HogeController < ApplicationController
def index
@hoge_search_form = HogeSearchForm.new params[:hoge_search_form]
@hoges = @hoge_search_form.search
end
end
View
<!-- app/views/hoges/index.html -->
<h1>Hoge search</h1>
<div class="search_form">
<%= form_for @hoge_search_form, url: hoges_path, method: :get do |f| %>
<%= f.text_field :name %>
<%= f.text_field :email %>
<%= f.text_field :address %>
<%= f.submit %>
<% end %>
</div>
<div class="result">
<table>
<tr>
<th>名前</th>
<th>メールアドレス</th>
<th>住所</th>
</tr>
<% @hoges.each do |hoge| %>
<tr>
<td><%= hoge.name %></td>
<td><%= hoge.email %></td>
<td><%= hoge.address %></td>
</tr>
<% end %>
</table>
</div>
フォームオブジェクト(HogeSearchForm)の実装がDSLを使って非常に単純に書けるようになりました.この状態だとLIKEとEQUAL検索しかできませんが,SearchFormを拡張していくことで他の検索条件にも耐えられるようになるかと思います.
まだまだブラッシュアップしたい所はたくさんありますが,とりあえず動くので公開しておきます.もっと良いものになったらまたアップデートして,また記事にしようと思います.
ではでは,いつも通りご利用は自己責任でお願いします.