Rails 程式碼整理 - Service Object
Ruby on Rails 是 Model-View-Controller(MVC)組成的架構,在開發時非常快速且容易但當專案規模越大就會越來越難管理,很有可能就會在 controller 看到以下這樣的一個 action
class SellersController < ApplicationController
def downgrading
@mashines = @seller.mashines
if params[:new_seller_email].blank? && params[:no_seller].blank?
flash.now[:error] = 'New Seller Email or No Seller is blank'
return render :downgrade
end
if params[:new_seller_email]
new_seller = Seller.find_by(email: params[:new_seller_email])
if new_seller.blank?
flash.now[:error] = I18n.t('messages.seller_not_found')
return render :downgrade
end
if @seller == new_seller
flash.now[:error] = 'Downgrading Seller should not be New Seller'
return render :downgrade
end
end
begin
Seller.transaction do
Machine.transaction do
@reseller.machines.each do |device|
Log.write(current_user, device, 'device_change_reseller', nil, staff_office_id)
device.reseller = params[:no_reseller] == '1' ? new_reseller : nil
device.save!
end
end
if @seller.update(type: 'Customer')
@seller = User.find(@seller.id)
if @seller.subscribe_service_notification?
UserMailer.seller_downgrade_notification(@seller).deliver_later
end
Log.write(current_user, @seller, 'downgrade_seller', nil, staff_office_id)
flash[:success] = I18n.t('messages.downgrade_success')
redirect_to downgrade_my_country_sellers_url
end
end
rescue => e
flash.now[:alert] = e.message
flash.now[:notice] = I18n.t('messages.downgrade_exception')
render :downgrade_confirm
end
end
end
這個大約 50 行的 code 有什麼問題?
- 一般 Fat Controller 被視為一種反模式(anti-pattern),Skinny Controller, Fat Model 可以參考這篇文章
- 可讀性差
- 很難被重用(reuse)
- 不好被測試
一般視情況這樣的 Fat Controller 可能會以這樣的流程來做整理。
Form Object 來處理不同畫面上不同輸入的需求 -> Context Object 處理複雜的資料查詢 -> Service Object 以商業邏輯來對資料進行操作 -> Presenter 來處理畫面顯示資料的邏輯 -> 最終將結果顯示於前端。
這次要來介紹的是處理業務邏輯相關的設計模式,Service Object,舉例來說,金流系統的支付這一行為就是金流的業務邏輯,就有可能會使用到 Service Object 來處理這一行為的邏輯。
Service Object
service object 一般被用來處理商業邏輯,雖然這跟 model 負責的事情似乎重複了,但當有商業邏輯關聯多個 model 時,這個邏輯就不適合放在某個 model。 舉文章一開始的例子,這是一個製作並出售工廠機台的管理系統,downgrading 這個 action 的功能是將某位 User 從銷售員降為一般顧客的功能,儘管邏輯有些複雜,但我們大概可以知道這個行為主要會去更新 Seller 以及 Machine 這兩個 table 的資料,若想將這一段邏輯放進 Seller model,撇除一些對前端顯示的操作,這樣做也行,但放進 Machine model 好像也能達到同樣的效果,其實放進哪個 model 都沒有錯,這取決於寫的方式。 但如果這邊我們可以使用 service object 的設計模式,將這段邏輯封裝進我們建立的 Class,並且讓這個 Class 更單純只負責執行 seller downgrading 這件事情的話,或許就能整理成以下這樣:
class SellersController < ApplicationController
def downgrading
# 使用 Form Object 處理前端傳入的參數
form = Seller::DowngradingForm.new(seller_params)
if form.save
flash[:success] = I18n.t('messages.downgrade_success')
redirect_to downgrade_sellers_url
else
flash.now[:error] = form.error_message
return render :downgrade
end
rescue => e
flash.now[:alert] = e.message
flash.now[:notice] = I18n.t('messages.downgrade_exception')
render :downgrade
end
end
# Form Object
module Seller
class DowngradingForm
#...略...
def save
# 略
process_downgrading
# 略
end
private
def process_downgrading
SellerDowngradeService.new(seller).perform
end
#...略...
end
end
這樣是否看起來更能一眼就明白這個 action 到底在做什麼了呢?原本那段複雜的資料操作邏輯就被封裝進 SellerDowngradeService
這個 Service Object 裡了。但我們依然能從名字理解這邊要進行 seller downgraing 這個操作。
什麼時候該使用 Service Object ?
首先,一般我們先考慮的是能不能將這些 code 整理進 Model 或 Concern,但並不是所有的邏輯或功能都找得到一個合適的 Model 來擺放像是使用第三方服務的情況,在 7 Patterns to Refactor Fat ActiveRecord Models 這篇文章中有提到 Service Object 的使用情況
- 邏輯相當複雜
- 會跨多個 model 進行操作
- 與核心邏輯無關 ex: 定時發信
- 當方法會被重複使用時
- 使用外部服務
Service Object 其實就是一個 Ruby 的類別(Class),並且提供公開方法(public method)供 controller 或 job 等等地方來使用,以下用簡單的例子來說明。 這是一個 controller action 讓使用者領養選取的貓咪,如果貓咪還沒被領養,就讓使用者領養牠並且檢查貓咪的房子有沒有破損,再做接下來的處理。
class CatsController < ApplicationController
...
def adopte
if !@cat.adopted?
@cat.adopte!(@customer)
if @house.any_broken?
@house.supplier.notify!(event: "house_repair")
else
@house.update(need_to_clean: true)
end
render :confirm
else
flash.now[:error] = "This cat was been adopted"
redirect_to cats_path
end
end
...
end
可以看到雖然現在看來感覺其實還好,但之後若又有其他新的功能要加入這個流程呢? 結果就是 controller 會越來越大,如果把這段邏輯放進 model 裡呢? 嗯… 是不是又感覺放在哪個 model 都不太對,因為這段流程使用到的 model 不只 Cat 還有 House 以及 Supplier。 這個時候就可以將這段邏輯封裝進 Service Object 並在 Controller 來使用它
接下來來看看如何實作。
開始實作 Service Object!
- 第一步先在 app 目錄底下建立 services 資料夾(名稱沒有固定主要還是要看團隊習慣)
- 第二步就在 service 資料夾底下新增 cat_adopte_service.rb
- 把 controller 對 Cat, House以及 Supplier 的操作都放進 Service Object 中,比較常見的是建立一個 #perform 方法並且把邏輯放進去。
這邊看到 CarAdopteService 的 perform ,我將原本在 cat 執行 adopte! 後處理 house 的邏輯抽成 check_house 的 method 讓人可以直接從 perform 就可以大概知道這個 Service Object 在做什麼,使可讀性提升。這是我認為滿重要的。
class CatAdopteService
def initialize(customer, cat)
@customer = customer
@cat = cat
end
def perform
return false if @cat.adopted?
@cat.adopte!(customer)
check_house
true
end
private
def check_house
if @house.any_broken?
@house.supplier.notify!(event: "house_repair")
else
@house.update(need_to_clean: true)
end
end
end
- 接著 controller 這邊就可以使用 CarAdopteService Service Object 了~
controller 的部分可以看到,在改變之後原來的邏輯都已經被封裝進 CatAdopteService 裡面了,讓 controller 更單純只做它應該做的事,接收並處理事件再給出回應,也讓這個 adopte action 更加簡潔。
反思
所以只要 controller 開始變複雜我就把邏輯通通塞進 Service Object 裡就好了嗎?
還不夠熟悉 Service Object, Form Object, Presenter 等等設計模式的使用方法,就很容易以為只要把邏輯全丟進 Service Object 就好了。
但要注意前面有提到 Service Object 是用於處理或整理核心業務邏輯的,以分層架構來說的話, Service Object 應該是被歸類在 Domain 層的,即負責業務邏輯規則。
所以在使用 Service Object 之前,還是得要對要修改的程式碼考慮清楚它是不是應該被放進 Service Object,還是其實要用其他方式處理它。
總結
- 當 Controller 裡有需要進行不同 Model 間的資料操作或是有很複雜的邏輯時就可以考慮使用 Service Object 來進行封裝
- 不管是 Service Object 的名稱還是裡面的 public method 都應該注意可讀性(這應該不管在哪都要注意!!)
- 最好一個 Service Object 只負責一個行為(SRP)不要讓他有太多 public method
參考
單一職責原則 (Single Responsibility Principle)