蒼時弦也
蒼時弦也
資深軟體工程師
發表於
這篇文章是 重新思考 Rails 架構 系列的一部分。

實際對運送狀態進行處理之前,我們需要將 Rails 的 ActionController::Parameters 轉換成可以使用的結構,這個結構通常可以看作是 Form Object。

Form Object

比起叫做 Form Object 我更偏好用 Input Object 來稱呼更為恰當,大多數情境中這個物件扮演了將使用者的輸入處理後,轉換成我們系統中可以實際使用的物件,扮演著輸入(Input)的角色。

舉例來說,我們的系統可以接收 XML、JSON 等格式,假設每種格式都要個別解析,未來就可能會在每個使用案例(Use Case)中充滿相關的處理,而讓物件負責過多不相關的事情。

至於 Form Object 則是在 Rails 中,我們希望將 Params(參數)直接傳遞給 Model 使用,但是不同的情境會有不一樣的欄位和條件,才衍伸出這樣的設計,在比較新版本的 Rails 中 Strong Parameters 的機制使用 #required#permit 就足以滿足這樣的需求。

以前曾經寫過 Form Object 的使用來討論,從這個角度來看引入 ActiveModel 並不是理想的設計,我們並不需要這麼複雜的機制。

Input Object

Input Object 是一種用於傳遞資訊的物件(Data Transfer Object)同時在定位上,屬於使用案例的一部分,因此要避免跟框架有所耦合,才能夠在為來替換框架或者改寫時,減少相應的影響。

要滿足現有的測試,我們可以追加一個 app/inputs/create_route_input.rb 加入以下的實作

 1class CreateRouteInput
 2  # NOTE: refactor to domain model
 3  TZ = {
 4    'TPE' => 'Asia/Taipei',
 5    'OKA' => 'Asia/Tokyo'
 6  }.freeze
 7
 8  attr_reader :route_id, :shipment_id
 9
10  def initialize(route_id, shipment_id, date, time)
11    @route_id = route_id
12    @shipment_id = shipment_id.to_i
13    @date = date
14    @time = time
15  end
16
17  def timezone
18    TZ[@route_id] || 'UTC'
19  end
20
21  # NOTE: Use timezone interface instead `ActiveSuport::TimeZone`
22  def delivered_at
23    @delivered_at ||= ActiveSupport::TimeZone[timezone].parse("#{@date} #{@time}")
24  end
25end

基本上 Input Object 是一個很單純的物件,只負責用於記錄可以使用的參數,以 Struct 或者 Ruby 3 的 Data 物件來實作都很適合。

上述的案例中,為了讓 Controller 對於 Domain 相關的知識減少,因此先將 Route 和時區的對應,以及轉換時區的處理放到裡面,在開發初期可以暫時性的這樣實作,後續完成功能時則不應該出現這樣的狀況。

調整 Controller

加入 Input Object 是為了讓我們的核心功能不和 ActiveController::Parameters 耦合再一起有依賴關係,接下來調整 Controller 並且確認功能運作正常。

使用 params 時,ActiveController::Parameters 實作了 Hash 的介面,並不會有耦合的問題,使否需要 Input Object 可以視需求判斷調整,但是要盡量統一做法。

 1module Shipments
 2  class RoutesController < ApplicationController
 3    rescue_from ActionController::ParameterMissing, with: :render_bad_request
 4
 5    def create
 6      input = CreateRouteInput.new(*route_params)
 7
 8      render json: {
 9        id: 1,
10        state: 'shipping',
11        routes: [
12          {
13            route_id: 'OKA',
14            shipment_id: input.shipment_id,
15            delivered_at: Time.utc(2024, 3, 19, 0, 0, 0)
16          },
17          {
18            route_id: input.route_id,
19            shipment_id: input.shipment_id,
20            delivered_at: input.delivered_at.utc
21          }
22        ]
23      }
24    end
25
26    private
27
28    def route_params
29      params.require(%i[route_id shipment_id date time])
30    end
31
32    def render_bad_request(error)
33      render json: { error: "#{error.param} is required" }, status: :bad_request
34    end
35  end
36end

經過調整後,假設我們沒有正確的填入必要欄位會得到錯誤訊息,同時原本的輸入也可以從 Input Object 解析出來,並且提供給我們回傳內容使用。

這個階段處理完畢後,我們就可以實際進入 Use Case 來實作相關的邏輯。