はじめまして。 Money Forward で Ruby on Rails のエンジニアをしています辻です。 ( ※ CEOの辻とは別の辻です ) 現在学生で内定者インターン中です。
今回業務で検索機能を実装する機会があり、検索ロジックはどういう設計にするのが良いのかチームで議論してきました。
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 )
アルパカ隊長さんが作成した素晴らしい記事があるのでそちらを参考にして下さい。
参考記事 https://qiita.com/alpaca_taichou/items/bebace92f06af3f32898
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でメソッドが一度呼び出されたかどうか確認する記述方法は以下の資料を参考にして下さい。
参考資料 https://qiita.com/am/items/398b0782fa3754ff3878
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といった検索機能の設計の存在を知っていただき、是非実装の際参考にしていただけると嬉しいです。
現在マネーフォワードでは、19卒の新卒エンジニアの募集を行なっています。 私も19新卒のエンジニア内定者なので、同期として一緒に切磋琢磨していく仲間を切望しています! 是非この記事を見て弊社に興味を持った方は、是非一度遊びに来てください ■マネーフォワード (新卒) 採用サイト
【採用サイト】 ■マネーフォワード採用サイト ■Wantedly | マネーフォワード
【マネーフォワードのプロダクト】 自動家計簿・資産管理サービス『マネーフォワード』 ■Web ■iPhone,iPad ■Android
「しら」ずにお金が「たま」る 人生を楽しむ貯金アプリ『しらたま』 ■Web ■iPhone,iPad
ビジネス向けクラウドサービス『MFクラウドシリーズ』 ■バックオフィス業務を効率化『MFクラウド』 ■会計ソフト『MFクラウド会計』 ■確定申告ソフト『MFクラウド確定申告』 ■請求書管理ソフト『MFクラウド請求書』 ■給与計算ソフト『MFクラウド給与』 ■経費精算ソフト『MFクラウド経費』 ■入金消込ソフト『MFクラウド消込』 ■マイナンバー管理ソフト『MFクラウドマイナンバー』 ■資金調達サービス『MFクラウドファイナンス』
メディア ■くらしの経済メディア『MONEY PLUS』