内容
はじめまして。
MoneyForward で Ruby on Rails のエンジニアをしています辻です。現在学生で内定者インターンとしてお仕事しています
今回業務で検索機能を実装する機会があり、検索ロジックはどういう設計にするのが良いのかチームで議論してきました。
MoneyForward ではこれまで検索ロジックに関して様々な設計が採用されていたので、その背景について軽く話しながら今回採用した検索ロジックについて説明します。
はじめに
MoneyForwardでこれまで検索ロジックとして採用されていた設計を歴史といった時系列単位で区切ります。
歴史は1〜4まであり、それぞれ検索に関する設計について説明していきます。
またサンプルコードがあるので、良ければこちらを参照して下さい。
歴史1に関するロジックは、sample1_users_controller といった形で歴史の番号と対応したサンプルコードになっています。
https://github.com/ShuheiTsuji/search_history_sample
歴史1:form_tag + controllerべた書きスタイル
まずは最も愚直に実装した場合です。
実際にマネーフォワードの古くから存在する社内ツールの一部には、このようなコードが残っていました。
検索フォームでは、対応するモデルクラスが存在しないと判断し、 form_tag
を採用しています。
= form_tag url: users_path, method: :get do
.form-group.col-md-3
= label :id, 'ID'
= text_field_tag :id, '', class: 'form-control'
.form-group.col-md-3
= label :name, '名前'
= text_field_tag :name, '', class: 'form-control'
.form-group.col-md-4
= label :zip, '郵便番号'
= text_field_tag :zip, '', class: 'form-control'
.form-group.col-md-2
= label :sex, '性別'
= select_tag :sex, options_for_select([['男', 0],['女', 1]]), class: 'form-control'
.form-group
= submit_tag '検索', class: 'btn btn-primary'
def index
@id = params[:id]
@name = params[:name]
@address = params[:address]
@sex = params[:sex]
@users = User.all
@users.where!(id: @id) if @id.present?
@users.where!('name like ?', "%#{@name}%") if @name.present?
@users.where!(address: @address) if @address.present?
@users.where!(sex: @sex) if @sex.present
end
form_for
の恩恵に預かれない、また記述が煩雑になりメンテナンスしにくいため、このような実装は改善していきたいですね
実際に1年程前、マネーフォワードの社内ツールでこのような記述を発見し、PRを出しました!
こういった記述を見つけた時は、PRのチャンスです!
是非自分が携わっているプロジェクトでこのような記述があればPRを出して見ましょう。
次の歴史2では、自分のPRを元に歴史1と比較してどのように改善したかを説明していきます。
歴史2:form object 利用スタイル
歴史1では、コントローラーに検索のビジネスロジックが書かれてしまい良くないです。またテストも記述しにくい状態です。
これを改善するために、 form object
といったパターンを導入しました。
form_object 参考資料
- http://tech.medpeer.co.jp/entry/2017/05/09/070758
- https://techracho.bpsinc.jp/hachi8833/2013_11_19/14738#form-object
view での記述は form_tag を form_for へ変換した程度です。
= render partial: 'users/search', locals: { user: @sample2_user_search, sample_users_path: sample2_users_path }
.container
.col-md-8
p ユーザ検索画面
.wrapper
.container
.panel.panel-default
.panel-body
= form_for user, url: sample_users_path, method: :get do |f|
.form-group.col-md-3
= f.label :id
= f.text_field :id, class: 'form-control'
.form-group.col-md-3
= f.label :name
= f.text_field :name, class: 'form-control'
.form-group.col-md-4
= f.label :zip
= f.text_field :zip, class: 'form-control'
.form-group.col-md-2
= f.label :sex
- collection = User.sexes.keys.map { |value| [User.human_attribute_name("#sex.#{value}"), value] }
= f.select :sex, collection, class: 'form-control', include_blank: true
.form-group
= f.submit '検索', class: 'btn btn-primary'
table.table-hover
colgroup
col width=('200px')
col width=('200px')
col width=('200px')
col width=('200px')
thead
th ID
th 名前
th 郵便番号
tbody
- @users.each do |user|
tr
td
= user.id
td
= user.name
td
= user.zip
controllerの記述では、検索のビジネスロジックを全て form object
に委譲します。
ここでは、form object
のインスタンスを作成し、 search メソッドを呼び出すだけにします。
def index
@user_search =Sample2UserSearchForm.new(user_search_form_params)
unless @user_search.valid?
flash.now[:alert] = @user_search.errors.full_messages
end
@users = @user_search.search
end
private
def user_search_form_params
params.fetch(:user_search_form, {}).permit(
:id,
:name,
:zip,
:sex
)
end
form object
内では、params を受け取って検索ロジックの結果を返します。
ActiveModel::Model を include しているため、モデルと同様に、 Validation Callback といった機能を使用することが出来ます。
class Sample2UserSearchForm
include ActiveModel::Model
include ActiveModelAttributes
attribute :id, :integer
attribute :name, :string
attribute :zip, :zip
attribute :sex, :string
validates :id, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 100 }, allow_blank: true
validates :name, length: { maximum: 100 }
validates :zip, length: { is: 7 }, allow_blank: true
def search
users = User.all
return users unless valid?
users.where!(id: id) if id.present?
users.where!('name like ?', "%#{name}%") if name.present?
users.where!(zip: zip) if zip.present?
users.where!(sex: sex) if sex.present?
users
end
private
def params_for_search
{
id: id,
name: name,
zip: zip,
sex: sex
}
end
end
おまけ ( ActiveModelAttributes )
アルパカ隊長さんが作成した素晴らしい記事があるのでそちらを参考にして下さい
参考記事
zip に関しては、108-0022
1080022
108_0022
といった郵便番号の記入も考えられるので、独自で定義してみました。
module ActiveModelType
class Zip < ActiveModel::Type::String
def cast_value(value)
super(super&.remove(/[\-_]/))
end
end
end
ActiveModel::Type.register(:zip, ActiveModelType::Zip)
歴史3:service層・models/searcher利用スタイル
歴史2では、 入力値のcastとvalidation
検索ロジックをcontrollerからform objectに移譲
を行いました。
検索ロジックが複雑になった場合や、再利用できる可能性がある場合など、検索ロジック自体の切り出しを行う場合もあるかもしれません。
before
def search
users = User.all
return users unless valid?
users.where!(id: id) if id.present?
users.where!('name like ?', "%#{name}%") if name.present?
users.where!(zip: zip) if zip.present?
users.where!(sex: sex) if sex.present?
users
end
after
def search
search_user_service = Search::UserService.new(self)
search_user_service.relation
end
この比較から分かるように、後者では検索ロジックを Search::UserService
として、Service層に役割を委譲しています。
Service層では、 form object
のインスタンスを受け取りその form object
が保持している属性値を元に検索ロジックが動作します。
module Search
class UserService
attr_reader :relation
def initialize(user_form)
@user_form = user_form
@relation = User.all
set_conditions
end
private
def set_conditions
set_id
set_name
set_zip
set_sex
end
def set_id
return if @user_form.id.blank?
@relation.where!(id: @user_form.id)
end
def set_name
return if @user_form.name.blank?
@relation.where!('name like ?', "%#{@user_form.name}%")
end
def set_zip
return if @user_form.zip.blank?
@relation.where!(zip: @user_form.zip)
end
def set_sex
return if @user_form.sex.blank?
@relation.where!(sex: @user_form.sex)
end
end
end
ロジックの切り出しは出来ましたが、まだ改善できそうで、チームで議論をしました。
- このクラスの責務が、 Service層の本来の責務ではないのではないか
- モデル配下に `app/models/users/searcher.rb` として定義したほうがよいのではないか
- そもそも他のパターンで表現できないか
- クラスを分離しても、formのselfを渡しているので、依存が生まれている
次が最後の歴史なのでもう少しお付き合い下さい
歴史4:query object スタイル
議論の中で、検索ロジックはARを特定の条件で絞り込む処理なので、query objectで表現できるのではないか、という案がでてきました。
参考記事
- https://techracho.bpsinc.jp/hachi8833/2013_11_19/14738#query-object
- https://qiita.com/furaji/items/12cef3ec4d092865af88
query object
を scope から呼び出すために、 scope :search, Users::SearchQuery
と記述をします。
query object
の使用方法に関しては、是非参考資料がわかりやすいので一読して下さい
class User < ApplicationRecord
scope :search, Users::SearchQuery
end
class Sample4UserSearchForm
・・・・・・・
def search
User.search(params_for_search)
end
end
app/queries/model名/search_query.rb
として、ここに query object
を定義します。検索に関するビジネスロジックは全て query object
が責務を担います。
module Users
class SearchQuery < BaseScopeQuery
def initialize(relation = User.all)
@relation = relation
end
def call(conditions)
@relation.where!(id: conditions[:id]) if conditions[:id].present?
@relation.where!('name like ?', "%#{conditions[:name]}%") if conditions[:name].present?
@relation.where!(zip: conditions[:zip]) if conditions[:zip].present?
@relation.where!(sex: conditions[:sex]) if conditions[:sex].present?
@relation
end
end
end
scopeで定義したquery objectに引数を渡したい場合の処理は、基底クラス app/queries/base_scope_query.rb
を定義して継承するようにしました。
class BaseScopeQuery
# https://qiita.com/furaji/items/12cef3ec4d092865af88
# 下記と同義
# def self.call
# new.call
# end
class << self
delegate :call, to: :new
end
end
またquery objectを採用した場合のテストコードも追加しておきます。
valid?メソッドのテストについては、validationのテストでよく見る記述なので説明を省略します。
searchメソッドのテストでは、Userモデルに対してsearchメソッドが一度呼び出されたかどうか確認します。
rspecでメソッドが一度呼び出されたかどうか確認する記述方法は以下の資料を参考にして下さい。
参考資料
require 'rails_helper'
RSpec.describe Sample4UserSearchForm, type: :model do
let(:form) { described_class.new(id: id, name: name, zip: zip, sex: sex) }
let(:id) { 1 }
let(:name) { 'tsuji' }
let(:zip) { '1080022' }
let(:sex) { 'man' }
describe '#valid?' do
subject { form.valid? }
it { is_expected.to eq true }
context 'when zip is not 7 length' do
let(:zip) { '12345678' }
it { is_expected.to eq false }
end
context 'when name is grater than 100' do
let(:name) { '1' * 101 }
it { is_expected.to eq false }
end
end
describe '#search' do
subject { form.search }
before do
allow(User).to receive(:search)
end
it 'call search' do
expect { subject }.not_to raise_error
expect(User).to have_received(:search).once
end
end
end
またquery objectには下記のようなテストを追加します。
Users::SearchQueryに対して適切なconditionsが引数で渡った時に、適切なwhereの条件が返却されるか確認します。
require 'rails_helper'
RSpec.describe Users::SearchQuery do
describe '#call' do
let(:instance) { described_class.call(conditions) }
let(:conditions) { {} }
subject { instance.where_values_hash }
let(:default_where_values_hash) { {} }
it { is_expected.to eq default_where_values_hash }
context 'conditiosn included id' do
let(:conditions) { { id: 1 } }
it { is_expected.to eq default_where_values_hash.merge({ 'id' => 1 }) }
end
context 'conditions included name' do
let(:conditions) { { name: 'tsuji' } }
it { expect(instance.to_sql).to include "name like '%tsuji%'" }
end
context 'conditiosn included zip' do
let(:conditions) { { zip: '1234567' } }
it { is_expected.to eq default_where_values_hash.merge({ 'zip' => '1234567' }) }
end
context 'conditiosn included id' do
let(:conditions) { { sex: 'woman' } }
it { is_expected.to eq default_where_values_hash.merge({ 'sex' => 'woman' }) }
end
end
end
このように query object
を使用することによって、検索ロジックを form object
から切り出すことに成功し、基底クラスを継承して呼び出し方も統一しチーム内で共通認識の取れた query object
という設計方針を採用することが出来ました。
検索ロジックを切り出すことにより、テストコードも書きやすくなります
ちなみに今回 query object
をscope化したことで、管理画面やユーザ画面でも同じ検索ロジックを再利用することが出来ます。アソシエーションで関連するモデルが存在する場合、以下の記述が可能になり、非常に便利です!
# 社内管理画面
Post.search
# ユーザ側画面では
current_user.posts.search
そして私が実装した query object
の設計も無事マージされました
最後に
検索機能といったよくある機能ですが、ネット上の情報は、歴史1・2のパターンが多いように感じます。
この記事によって、form objectやquery objectといった検索機能の設計の存在を知っていただき、是非実装の際参考にしていただけると嬉しいです。
- マネーフォワードエンジニアブログ