Rails 有許多組織程式碼的 pattern, 一個經常被提到的例子是 form object.
Form object 主要使用在你需要創造表單, 但該表單不直接與某個特定的 model 有關, 這類的情境有很多, 例如搜尋表單, 你可能不會想把當下搜尋的參數儲存到資料庫, 但你依然想對輸入的資料進行驗證, 如必須輸入關鍵字, 日期必須在一個月內, … 這時候就可以自行創造一個 SearchForm model 專門負責處理搜尋表單的資料驗證.
以前經常使用 Virtus 實作, 但它已經不再更新了, 而它的接班人 dry-rb 又有點… 雖然強大, 太過於複雜了, 而且許多地方的設計與 Rails 格格不入, 想要開心使用的話必須要做很多工, 而且還要說服其他同事接受, 真的有點困難.
其實 Rails 的 ActiveModel 提供了非常多的的小工具, 可以幫助我們設計 form object, 而且可以輕易的與 form helper 接軌, 網路上也有非常多相關的文章. 那我為什麼要寫這篇呢? 因為最近有人正好問到我, 我發現有許多資料都有點舊了 (但依然是值得去閱讀的, 我不打算重複寫太多原理), Rails 6 有著更多內建方便的好東西去把這件事情做更好, 因此我想分享的是一個較新, 並且可簡單驗證與測試的 pattern.
通用 form object module, 可定義欄位並強制轉型, 以及經常使用的 callbacks:
module ApplicationForm
extend ActiveSupport::Concern
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations::Callbacks
included do
# adds before_call, after_call
define_model_callbacks :call
end
def save
validate.tap do |result|
run_callbacks(:call) { call } if result
end
end
def inspect
"#<#{self.class.name} #{attributes}>"
end
end
直接 include 它, 定義需要的東西即可. 以下是搜尋表單範例, 限制關鍵字最短長度, 去除頭尾空白:
class SearchForm
include ApplicationForm
attribute :keyword, :string
attr_reader :result
validates :keyword, length: { minimal: 2 }
before_validation :strip_keyword
def strip_keyword
self.keyword = keyword.try(:strip)
end
def call
@result = Model.search(keyword)
end
end
使用方法:
@form = SearchForm.new(params[:form])
if @form.save
@models = @form.result
else
render :search
end
<%= simple_form_for @form do |f| %>
<%= f.input :keyword %>
<%= f.button :submit %>
<% end %>
單元測試:
require 'rails_helper'
RSpec.describe SearchForm, type: :model do
context 'keyword is too short' do
subject(:form) { SearchForm.new(keyword: 'x') }
# not good if you have many attributes to check
it { is_expected.to be_invalid }
# just check errors for passed attribute
it 'should have error on keyword' do
form.validate
expect(form.errors[:keyword]).to include('is too short (minimum is 2 characters)')
end
# or use rspec-collection_matchers, the recommended way
it { is_expected.to have(1).error_on(:keyword) }
end
context 'keyword is valid' do
subject(:form) { SearchForm.new(keyword: 'ayaya') }
it 'finds models' do
form.save
expect(form.result).to be_present
end
end
end
對於較複雜的搜尋, 我則建議 form object 只進行參數檢查, 檢查完後可將資料交由專門產生查詢的 query object 處理.