---
title: "Form - 重新思考 Rails 架構"
date: 2024-10-25T00:00:00+08:00
publishDate: 2024-10-25T00:00:00+08:00
lastmod: 2024-06-02T17:03:47+08:00
tags: ["Rails","Domain-Driven Design","設計","Clean Architecture"]
series: "rethink-rails-architecture"
toc: true
permalink: "https://blog.aotoki.me/posts/2024/10/25/rethink-rails-architecture-form/"
language: "zh-tw"
---


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

<!--more-->

## Form Object

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

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

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

> 以前曾經寫過 [Form Object 的使用](https://blog.aotoki.me/posts/2019/05/28/How-to-use-Form-Object-and-others-for-Rails/)來討論，從這個角度來看引入 `ActiveModel` 並不是理想的設計，我們並不需要這麼複雜的機制。

## Input Object

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

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

```ruby
class CreateRouteInput
  # NOTE: refactor to domain model
  TZ = {
    'TPE' => 'Asia/Taipei',
    'OKA' => 'Asia/Tokyo'
  }.freeze

  attr_reader :route_id, :shipment_id

  def initialize(route_id, shipment_id, date, time)
    @route_id = route_id
    @shipment_id = shipment_id.to_i
    @date = date
    @time = time
  end

  def timezone
    TZ[@route_id] || 'UTC'
  end

  # NOTE: Use timezone interface instead `ActiveSuport::TimeZone`
  def delivered_at
    @delivered_at ||= ActiveSupport::TimeZone[timezone].parse("#{@date} #{@time}")
  end
end
```

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

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

## 調整 Controller {#adjust-controller}

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

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

```ruby
module Shipments
  class RoutesController < ApplicationController
    rescue_from ActionController::ParameterMissing, with: :render_bad_request

    def create
      input = CreateRouteInput.new(*route_params)

      render json: {
        id: 1,
        state: 'shipping',
        routes: [
          {
            route_id: 'OKA',
            shipment_id: input.shipment_id,
            delivered_at: Time.utc(2024, 3, 19, 0, 0, 0)
          },
          {
            route_id: input.route_id,
            shipment_id: input.shipment_id,
            delivered_at: input.delivered_at.utc
          }
        ]
      }
    end

    private

    def route_params
      params.require(%i[route_id shipment_id date time])
    end

    def render_bad_request(error)
      render json: { error: "#{error.param} is required" }, status: :bad_request
    end
  end
end
```

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

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

