Ruby
RubyOnRails

Money Forwardの検索ロジック歴史

内容

はじめまして。
MoneyForward で Ruby on Rails のエンジニアをしています辻です。現在学生で内定者インターンとしてお仕事しています :smiley:

今回業務で検索機能を実装する機会があり、検索ロジックはどういう設計にするのが良いのかチームで議論してきました。
MoneyForward ではこれまで検索ロジックに関して様々な設計が採用されていたので、その背景について軽く話しながら今回採用した検索ロジックについて説明します。

はじめに

MoneyForwardでこれまで検索ロジックとして採用されていた設計を歴史といった時系列単位で区切ります。
歴史は1〜4まであり、それぞれ検索に関する設計について説明していきます。

またサンプルコードがあるので、良ければこちらを参照して下さい。
歴史1に関するロジックは、sample1_users_controller といった形で歴史の番号と対応したサンプルコードになっています。
https://github.com/ShuheiTsuji/search_history_sample

歴史1:form_tag + controllerべた書きスタイル

まずは最も愚直に実装した場合です。
実際にマネーフォワードの古くから存在する社内ツールの一部には、このようなコードが残っていました。
検索フォームでは、対応するモデルクラスが存在しないと判断し、 form_tag を採用しています。

app/views/sample1_users/show.html.slim
= 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'
app/controllers/sample1_users_controller.rb
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 の恩恵に預かれない、また記述が煩雑になりメンテナンスしにくいため、このような実装は改善していきたいですね :smiley:
実際に1年程前、マネーフォワードの社内ツールでこのような記述を発見し、PRを出しました!

こういった記述を見つけた時は、PRのチャンスです!
是非自分が携わっているプロジェクトでこのような記述があればPRを出して見ましょう。

次の歴史2では、自分のPRを元に歴史1と比較してどのように改善したかを説明していきます。

歴史2:form object 利用スタイル

歴史1では、コントローラーに検索のビジネスロジックが書かれてしまい良くないです。またテストも記述しにくい状態です。
これを改善するために、 form object といったパターンを導入しました。

form_object 参考資料

view での記述は form_tag を form_for へ変換した程度です。

app/views/sample2_users/show.html.slim
= render partial: 'users/search', locals: { user: @sample2_user_search, sample_users_path: sample2_users_path }
app/views/users/_search.html.slim
.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 メソッドを呼び出すだけにします。

sample2_users_controller.rb
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 といった機能を使用することが出来ます。

app/forms/user_search_form.rb
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 )

アルパカ隊長さんが作成した素晴らしい記事があるのでそちらを参考にして下さい :tada:

参考記事
- https://qiita.com/alpaca_taichou/items/bebace92f06af3f32898

zip に関しては、108-0022 1080022 108_0022 といった郵便番号の記入も考えられるので、独自で定義してみました。

app/lib/active_model_type/zip.rb
 module ActiveModelType
   class Zip < ActiveModel::Type::String
     def cast_value(value)
       super(super&.remove(/[\-_]/))
     end
   end
 end
config/initializers/type.rb
ActiveModel::Type.register(:zip, ActiveModelType::Zip)

歴史3:service層・models/searcher利用スタイル

歴史2では、 入力値のcastとvalidation 検索ロジックをcontrollerからform objectに移譲 を行いました。
検索ロジックが複雑になった場合や、再利用できる可能性がある場合など、検索ロジック自体の切り出しを行う場合もあるかもしれません。

before

sample2_user_search_form.rb
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

sample3_user_search_form.rb
def search
  search_user_service = Search::UserService.new(self)

  search_user_service.relation
end

この比較から分かるように、後者では検索ロジックを Search::UserService として、Service層に役割を委譲しています。
Service層では、 form object のインスタンスを受け取りその form object が保持している属性値を元に検索ロジックが動作します。

app/services/search/user_service.rb
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を渡しているので、依存が生まれている

次が最後の歴史なのでもう少しお付き合い下さい :eyes:

歴史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 の使用方法に関しては、是非参考資料がわかりやすいので一読して下さい :smile:

app/models/user.rb
class User < ApplicationRecord
  scope :search, Users::SearchQuery
end
app/forms/sample4_user_search_form.rb
class Sample4UserSearchForm
    ・・・・・・・

  def search
    User.search(params_for_search)
  end
end

app/queries/model名/search_query.rb として、ここに query object を定義します。検索に関するビジネスロジックは全て query object が責務を担います。

app/queries/users/search_query.rb
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 を定義して継承するようにしました。

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

spec/forms/sample4_user_search_form_spec.rb
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の条件が返却されるか確認します。

spec/queries/users/search_query_spec.rb
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 という設計方針を採用することが出来ました。
検索ロジックを切り出すことにより、テストコードも書きやすくなります :tada:

ちなみに今回 query object をscope化したことで、管理画面やユーザ画面でも同じ検索ロジックを再利用することが出来ます。アソシエーションで関連するモデルが存在する場合、以下の記述が可能になり、非常に便利です!

# 社内管理画面
Post.search

# ユーザ側画面では
current_user.posts.search

そして私が実装した query object の設計も無事マージされました :tada:

最後に

検索機能といったよくある機能ですが、ネット上の情報は、歴史1・2のパターンが多いように感じます。
この記事によって、form objectやquery objectといった検索機能の設計の存在を知っていただき、是非実装の際参考にしていただけると嬉しいです。

  • マネーフォワードエンジニアブログ

https://moneyforward.com/engineers_blog/2018/01/31/history-of-search/#more-7206