蒼時弦也
蒼時弦也
資深軟體工程師
發表於
這篇文章是 重新思考 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/ 的物件,就違反原則)