關於 Rails 中的 Form Object / Presenter 這些物件該怎麼用(一)

前陣子在 Review 新專案中同事的程式碼時,發現同事對像是 Service Object / Form Object 這類物件不太有概念。不過這個新專案因為是接手其他公司的專案,所以有不少地方要微調。至少那個值得吐槽的「因為 Controller 程式碼太長不知道放哪裡,就都丟去 Service Object 好了!」的神奇用法,完全沒有幫助改善程式碼。

也因為這個機會,我用了一點時間跟專案的同事分享了一下我對這些物件的看法。畢竟當出我也是搞不太懂,不過隨著了解物件導向和 Ruby 的語言特性,從這些角度切入後,就比較能理解該怎麼使用。

我想,在能夠應用 Rails 開發網站後,撇除 Controller / Model 之外,要在加入其他的物件類型,就很難去「區分」該如何使用了!所以,我們應該要先來看「物件向語言」之所以要製作成物件的理由。

物件導向

剛開始,我們寫程式就是直接把想要做的事情描述出來,類似這樣

1
puts '[INFO] Server started'

不過隨著行為重複的使用,每次都要複製同樣的程式碼,我們通常會包裝成一個「方法」

1
2
3
def log(level, message)
puts "[#{level.upcase}] #{message}"
end

隨著這些行為變得複雜,我們可能會有一些希望共用的情境跟邏輯,那就會製作成一個「物件」

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Logger
def initialize(output)
@output = output
end

def info(message)
log('info', message)
end

# ...

def log(level, message)
@output.write "[#{level.upcase}] #{message}"
end
end

我們依照這個脈絡來看,其實我們都是在做「輸出紀錄」的功能,只是輸出記錄這件事情有越來越多行為,所以我們一步一步的把它封裝起來。

其實這也是重構的基本思考方式,我們該如何將散落的「相似程式」整理歸納,變成一個統一的行為跟功能。

有了這樣的概念,我們再去看隨著學習 Rails (或是任何框架)不斷出現的新物件類型,就能夠抓到這樣應用的理由。

Form Object

一開始我要講的是 Form Object,通常是用來處理不同畫面上不同輸入的需求。依照前面的脈絡,物件導向語言的設計大多遵從 SOLID 的原則,不過我們先關注 S(單一功能)這件事情就好了。

既然叫做 Form Object 也就表示 Form Object 著重在處理使用者填寫表單的輸入,一開始你可能會覺得直接放到 Model 不就好了嗎?

1
2
3
4
@post = Post.new(post_params)
return redirect_to posts_path if @post.save

render :new

大多數時候我們都會這樣寫,不過這建立在的前提就是每次輸入的欄位都相同,假設新增跟編輯可以輸入的欄位有所不同,要怎麼控制?

也許你會想到可以用 Validator 的 on 選定要在 #update 還是 #create 處理,這確實是一個方法,但是如果要判定的基準相對複雜,就不一定這麼方便。

所以我們就會用 Form Object 來輔助。

1
2
3
4
5
6
7
8
9
10
11
12
13
class PostUpdateForm
include ActiveModel::Model

validate :content, presence: true

def save
@post = Post.find(@id)

attrs = attributes.dup
attrs.delete(:id)
@post.update(attrs)
end
end
1
2
3
4
@form = PostUpdateForm.new(create_params)
return render :new unless @form.valid?

@form.save

至少像這樣,我們就可以視情況單獨對某個表單做處理,而且 Model 就從要處理資料的角色,變成單純處理從資料庫進行讀寫以及建立資料關聯的角色。

至少在某種意義上,Model 的工作更簡單、更單純。

Context Object

搭配 Form Object 來講一下在我目前負責的其中一個專案,我們拿來處理「查詢資料」的物件類型。如果是在 PHP 中的 Laravel 的話,類似的物件應該會叫做 Repository。

不過,為什麼會需要 Context Object 呢?在一些比較複雜的系統,我們要處理的查詢條件是非常繁複的,一般的專案可能像是這樣就可以解決。

1
@posts = Post.where(category: 'Ruby')

不過如果是一些大的系統,要的查詢條件可能會是跨兩三個資料表同時做 JOIN 查詢,再篩選出結果這樣可能就得變成像這樣有大量的判斷。

1
2
3
@posts = Post.all
@posts = @posts.where(category: @categories) if @categories
# ...

一方面是看起來不這麼好看,另一方面是可能需要先去從其他資料表篩選出一些資訊才能夠查詢,都塞進去的話 Controller 大概就爆炸了!

所以我們就會像這樣做改寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PostContext
def initialize(params)
@params = params
@relation = Post
end

def perform
scope_by_categories
# ...
@relation
end

def scope_by_categories
return if @params[:categories].blank?

categories = Category.where(name: @params[:categories])
@relation = @relation.where(category: caregories)
end
end

如此一來搭配查詢的時候就會像這樣應用

1
2
@context = PostContext.new(params)
@posts = @context.perform

那麼我們就可以將不同的查詢條件處理實作在 Context 裡面,就不需要在 Controller 裡面實作了!(有點類似 Ransack 的感覺)

有些人會把這類問題都用 Service Object 解決,其實最近處理專案的經驗看起來,如果分不出來該怎麼做,通常就會被當作 Service Object 去做,但是如果類似的邏輯已經很多了,那麼獨立出來成一類有統一行為的物件會是更好的。

不過即使這樣實作,我們還是會遇到兩類問題:

  1. params 的參數不是 Context 想要的
  2. 查詢條件太過複雜

我們先討論第一點,假設我們只希望使用者查詢一年內的資料,要怎麼限制呢?其實這時候 Form Object 就會再度出場,畢竟他的職責就是處理「使用者的輸入」

1
2
3
4
5
6
7
8
9
10
11
12
class PostSearchForm
# 其實這行應該放在 BaseForm
include ActiveModel::Model

validate :search_ranges

def search_ranges
return if (@end_at - @start_at).to_i.abs > 365

errors.add(:end_at, I18n.t('.invalid_search_ranges')
end
end

如此一來,我們就可以像這樣使用

1
2
3
4
5
@form = PostSearchForm.new(params)
return render :index unless @form.valid?

@context = PostContext.new(@form)
@posts = @context.perform

看起來就會精簡很多,不過 Context 似乎出錯了,因為 Form Object 並沒有 @form[:categories] 這樣行為可以使用,要怎麼解決呢?

對 Ruby 來說,其實不外乎就是 Duck Typing 的問題,而其他語言來看,其實就是物件有沒有相同的 Interface 而已,我們只需要對 Form Object 做擴充即可。

1
2
3
4
5
6
7
8
9
10
11
module Form
module HasHashAccessor
extend ActiveSupport::Concern

included do
def [](attribute)
instance_variable_get("@#{attribute}")
end
end
end
end
1
2
3
4
5
class PostSearchForm
# ...
include Form::HasHashAccessor
# ...
end

如此一來,就可以用非常漂亮的方式讓 Form / Context / Model 三種物件串連起來一起運作,並且都只處理一種類型的行為。

HasHashAccessor 的命名方式是我從之前一個日本客戶的專案學來的,他可以很直觀的告訴我們這個物件上擴充了什麼行為,對 Ruby 語言特性來說會是個比 HashAccessor 這樣單純的命名更加容易理解。

大家可能會疑惑為什麼要在這個時候用 Module 呢?一方面是剛剛有提到,這是一個通用的 Interface 對 Context 來說,拿到的是 Form Object 或者 Params 其實都沒有關係,只要可以用 [] 方法存取他要用來查詢的參數就好了。

但是 ActionController::Parameters 物件其實不能滿足我們驗證查詢或者預處理之類的行為,那麼交給 Form Object 來做看起來是更恰當的,那麼要滿足 Context 的使用條件,提供他 [] 行為就是合理的,而且這個行為應該只提供給 SearchForm 而非所有的 Form Object。

簡單說對 Ruby 來說就是 Duck Typing 只在意有沒有 [] 方法,其他語言可能就是有沒有實作 [] 行為了!

小結

在我目前的專案其實還有像是 Transformer、Calculator 等等各種類型物件的變化,不過如果每一種都討論的話大概是一篇文章無法討論完全的。

下一篇就來討論跟呈現資料有關係的 Presenter 和 Decorator 這兩個物件,還有 Context 還可以怎樣做拆分。

要注意的是,不管他是哪類型的物件,其實想要使用都可以使用。只不過這些物件都是在過去經驗總結中,經常會重複使用所以分類出來的物件。照這樣的邏輯來看,只有 Service Object 通常會有 PhotoUploadService 和 PhotoUploader 兩種方式來命名,後者會用這樣的命名邏輯,也許就是當這類物件增加之後,直接分出一類會是更加適合的。

Buy me a CoffeeBuy me a Coffee

留言