Form - 重新思考 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 來實作相關的邏輯。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 軟體架構的挑戰 - 重新思考 Rails 架構
- 資料驅動設計 - 重新思考 Rails 架構
- 複雜的操作 - 重新思考 Rails 架構
- 時區換算 - 重新思考 Rails 架構
- 報表機制 - 重新思考 Rails 架構
- 通用化功能 - 重新思考 Rails 架構
- ActiveRecord 的限制 - 重新思考 Rails 架構
- 領域驅動設計 - 重新思考 Rails 架構
- 從架構到設計 - 重新思考 Rails 架構
- 重復使用的反思 - 重新思考 Rails 架構
- 釐清脈絡 - 重新思考 Rails 架構
- 劃分邊界 - 重新思考 Rails 架構
- 職責劃分 - 重新思考 Rails 架構
- 架構規劃 - 重新思考 Rails 架構
- 驗收測試 - 重新思考 Rails 架構
- Controller - 重新思考 Rails 架構
- Form - 重新思考 Rails 架構
- Use Case - 重新思考 Rails 架構