蒼時弦也
蒼時弦也
資深軟體工程師
發表於

職責劃分 - 重新思考 Rails 架構

這篇文章是 重新思考 Rails 架構 系列的一部分。

透過釐清脈絡、邊界後,我們大致上就能對整個系統的全貌有一定程度的理解。接下來會需要根據當下系統的狀況,釐清我們需要有哪些類型的物件,以及擔任怎樣的職責,來確保單一職責(Single-responsibility principle)

職責

Rails 作為一個 MVC 框架,對於物件的職責還是相當容易區分。然而這只限於比較簡單的情境,當我們的系統變複雜後,通常會需要繼續細分任務才足以使用,比較主流的就是 Clean Architecture 的方式。

單純從 MVC 的角度看,我們會有這樣的責任分配

  • Model - 保存狀態、商業邏輯
  • View - 呈現資訊、處理算繪(Render)
  • Controller - 接收操作並且協調 Model & View

這種情境通常表示輸入的資料跟狀態通常非常接近或者一致,並且也沒有太複雜的處理邏輯。當使用者填寫的表單跟資料庫有差異時,Controller 就需要做大量的處理,並且 Model 上的驗證機制很可能無法發揮作用。

此時 Form Object 的概念就會被加入進來,把 Controller 和 Model 的職責抽離一部分出來,變成新的類型。

  • Model - 保存狀態、商業邏輯
  • View - 呈現資訊、處理算繪(Render)
  • Controller - 協調 Form 和 Model 並且回傳 View 產生的內容
  • Form - 根據輸入轉換成 Model 可以使用格式

然而,當我們希望 Model 的狀態不該被輕易改變,就會需要依靠實作一些方法來協助處理。此時就會從 Model 直接以 #assign_attributes 方法讀取 Form 的內容,轉變為依靠 Service Object 來操作的形式。

  • Model - 保存狀態、商業邏輯
  • View - 呈現資訊、處理算繪(Render)
  • Controller - 協調 Form 和 Service 協作回傳 View 產生的內容
  • Form - 根據輸入轉換成 Service 的參數
  • Service - 根據參數操作 Model 進行處理

上述這種擴充的過程是比較接近 Clean Architecture 的方式,然而在實作上如果沒有恰當的設計,就會很難擴充成這樣的架構,因此在開發初期仍需要耗費一定成本釐清某些介面(Interface)的存在。

案例

上述的說明仍是相對抽象,我們繼續以物流系統的案例進行說明。在開發初期,我們通常會直接採取 Rails 常見的的方式實作,大致上如下。

 1class ShipmentController < ApplicationController
 2  def update
 3    @shipment.update(shipment_params)
 4    # ...
 5  end
 6
 7  private
 8
 9  def shipment_params
10    params.require(:shipment).permit(
11      items_attributes: %i[shippable_type shippable_id]
12    )
13  end
14end

當我們需要針對不同角色給予權限,或者一性次修改所有運送的品項時,在 Shipment 上實作驗證就會相當複雜,此時拆分為 Form Object 就會是個好方法。

 1class ShipmentController < ApplicationController
 2  def update
 3    @form = Customer::ShipmentForm.new(shipment_params)
 4    @form.valid!
 5
 6    @shipment.update(@form.attributes)
 7    # ...
 8  end
 9
10  # ...
11end
1class Customer::ShipmentForm < BaseForm
2  # ...
3
4  def attributes
5    {
6      items_attributes: formated_items
7    }
8  end
9end

像這樣子,我們可以在 Form 裡面針對 items_attributes 額外做一些處理,雖然也可以用 items 直接對應物件,不過會造成有額外的職責,因此需要避免。

然而,這樣依舊很難針對運送操作進行細部的處理,因此會繼續擴充出 Service Object 來協助我們處理。

 1class ShipmentController < ApplicationController
 2  def update
 3    @form = Customer::ShipmentForm.new(shipment_params)
 4    @form.valid!
 5
 6	@service = Customer::UpdateShipmentService.new
 7	@service.call(
 8	  @shipment,
 9	  items: @form.items
10	)
11
12    # ...
13  end
14
15  # ...
16end
 1class Customer::UpdateShipmentService < BaseService
 2  # ...
 3
 4  def call(shipment, items:)
 5    items.each do |item|
 6        state = item.delete(:state)
 7		case state
 8		when :remove then shipment.remove_item(item[:id])
 9		when :update then shipment.update_item(item[:id], item)
10		when :add then shipment.add_item(item)
11		end
12	end
13  end
14end

如上述的例子,我們被允許在使用者輸入的資料加入一些額外的屬性,這些屬性不用對應 ShipmentShipmentItem 的資料表欄位,我們可以透過 Form Object 先檢查過這些輸入的內容是否正確,並且轉交給 Service Object 根據提供的內容進行對應的動作。

上述仍是單純的 CRUD 情境,如果是有搭配一些 API 呼叫的情境,使用 Service Object 來進行協調這些處理等等,就更能夠將不同類型的職責區分出來。