Output - 重新思考 Rails 架構
在 Form Object 的階段有提過 Input Object 的概念,與之相對的則是 Output Object 的機制,這兩個物件也正好對應 Clean Architecture 中所提到的 Input Port 和 Output Port 的應用。
Output Object
Output Object 從定義上來說,可以很直覺的理解為 Use Case(使用案例)的回傳,即使我們像加入 Repository 後將 Entity 直接曝露給 Controller 也沒有問題。那麼為什麼還會有 Output Object 的需求存在呢?
現今大部分的軟體開發情境中,我們都會使用開發框架來協助開發,假設未來有需要更換框架(或者語言)就會遇到有大量的依賴(Dependency)在框架上的問題。為了對應這樣的狀況,合理的做法就是從 Use Case 作為基準跟框架切開,那麼 Use Case 就必須知道如何回傳結果,相比 Input Object 可以利用介面的方式定義,Output Object 就必定需要跟 Use Case 共存。
舉例來說,假設我們像 Form 的設計一樣使用了 include ActiveModel::Model
那麼在替換到其他框架(如:Hanami.rb)就必須要加入 ActiveModel 的 Gem 才能使用,然而以 PORO(Plain Old Ruby Object)就能自由的替換,而保有原有的功能。
一般來說 Input / Output 兩種物件都是 Read-only 和 Serializable(可序列化)的特性。
1# lib/shipments/data/shipment.rb
2module Shipments
3 module Data
4 Shipment = ::Data.define(:id, :state, :routes) do
5 def eql?(other)
6 id == other.id
7 end
8 end
9 end
10end
11
12# lib/shipments/data/route.rb
13module Shipments
14 module Data
15 Route = ::Data.define(:id, :delivered_at) do
16 def eql?(other)
17 id == other.id
18 end
19 end
20 end
21end
舉例來說,在 Ruby 3.2 就增加了 Data
類型物件,剛好可以滿足這樣的要求,或者我們直接使用 Ruby 的 Hash
也可以符合條件。
至於 Use Case 則針對實作調整,將原本的 Entity(實體)做一次轉換
1module Shipments
2 module Command
3 class AddRoute
4 # ...
5
6 def execute(shipment_id:, route_id:, date:, time:)
7 # ...
8 build_shipment_output(shipment)
9 end
10
11 private
12
13 # ...
14
15 def build_shipment_output(shipment)
16 routes = shipment.routes.map do |route|
17 Data::Route.new(id: route.id, delivered_at: route.delivered_at)
18 end
19
20 Data::Shipment.new(id: shipment.id, state: shipment.state, routes:)
21 end
22 end
23 end
24end
雖然這樣的設計相當繁瑣,然而也能夠避免在傳遞到 Controller 還被修改或者操作的可能性,就能將相關的邏輯盡可能的集中在 Use Case 上,讓設計更明確。
Rendering
在 Controller 收到這個物件後,我們可直接的呈現出來,然而會發現測試是失敗的。
1module Shipments
2 class RoutesController < ApplicationController
3 # ...
4
5 def create
6 # ...
7 render json: shipment
8 end
9
10 # ...
11 end
12end
這是因為我們預期的呈現跟實際的呈現有差異,這類情況通常就會由 Presenter(表現)類型的物件處理,至於 Rails 使用 View 的機制來實現基本上就非常足夠。
將原本的 render json: shipment
先替換為 @shipment = command.execute(**input)
的版本,讓 Controller 可以把 @shipment
傳遞給 View。
因為這次預設是 JSON 的內容,將 config/routes.rb
的內容也做調整。
1Rails.application.routes.draw do
2 # ...
3 resources :shipments, module: :shipments do
4 resources :routes, only: [:create], defaults: { format: :json }
5 end
6end
最後,就可以利用 jbuilder
來做呈現上的處理。
1# app/view/shipments/routes/create.json.jbuilder
2json.id @shipment.id
3json.state @shipment.state
4json.routes @shipment.routes do |route|
5 json.route_id route.id
6 json.delivered_at route.delivered_at.utc
7 json.shipment_id @shipment.id
8end
跟直接對 Output 做 Serialize 處理不同的地方在 routes
中是使用 route_id
以及多了一個 shipment_id
欄位,假設實務上沒有這樣的需求存在,可以統一後會更加簡單。
這一系列的實作可以觀察到,假設整體系統不複雜的情境下,是沒有必樣做出這樣的拆分,然而隨系統變得複雜後,將職責跟任務明確劃分就會自然出現這類物件,並不一定要刻意進行或者一次性的實踐,只要避免低階元件(Low-Level Component)被高階元件(High-Level Component)依賴的情況(如 lib/
下的實作使用了 app/
的物件,就違反原則)
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 軟體架構的挑戰 - 重新思考 Rails 架構
- 資料驅動設計 - 重新思考 Rails 架構
- 複雜的操作 - 重新思考 Rails 架構
- 時區換算 - 重新思考 Rails 架構
- 報表機制 - 重新思考 Rails 架構
- 通用化功能 - 重新思考 Rails 架構
- ActiveRecord 的限制 - 重新思考 Rails 架構
- 領域驅動設計 - 重新思考 Rails 架構
- 從架構到設計 - 重新思考 Rails 架構
- 重復使用的反思 - 重新思考 Rails 架構
- 釐清脈絡 - 重新思考 Rails 架構
- 劃分邊界 - 重新思考 Rails 架構
- 職責劃分 - 重新思考 Rails 架構
- 架構規劃 - 重新思考 Rails 架構
- 驗收測試 - 重新思考 Rails 架構
- Controller - 重新思考 Rails 架構
- Form - 重新思考 Rails 架構
- Use Case - 重新思考 Rails 架構
- Entity - 重新思考 Rails 架構
- Repository - 重新思考 Rails 架構
- Output - 重新思考 Rails 架構
- Query - 重新思考 Rails 架構
- 可能性 - 重新思考 Rails 架構