蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Use Case - 重新思考 Rails 架構

這篇文章是 重新思考 Rails 架構 系列的一部分。

處理完使用者的輸入後,就可以來著手實際的處理。通常會將這些實作放到 app/ 目錄下,然而當我們要以一個 Domain(領域)來劃分時,使用 lib/ 來放置這些實作可能更加恰當,未來要轉換框架或者搬移,就只需要移動 lib/ 而不會和 Rails 綁定。

Command Object

就我個人而言,會喜歡將 UseCase 分為 Command(命令)和 Query(查詢)兩種類型,方便區分寫入、讀取兩種不同情境,如果未來有需要實現 CQRS 的設計,也更容易延伸出去完善。

因為 Rails 預設不會載入 lib/ 目錄的內容,我們可以透過調整 config/application.rb 來支援這個行為。

1# config/application.rb
2module Example
3  class Application < Rails::Application
4    # ...
5    config.autoload_lib(ignore: %w[assets tasks generators])
6  end
7end

這個方式雖然方便,然而我們希望以套件的型式存在時,自行加入 require_relative 來處理可能會更加恰當。

接著實作一個 CreateRoute 的 Command 在 lib/shipments/command/create_route.rb

 1module Shipments
 2  module Command
 3    class AddRoute
 4      TIMEZONE_MAPPING = {
 5        'TPE' => 'Asia/Taipei',
 6        'OKA' => 'Asia/Tokyo'
 7      }.freeze
 8
 9      attr_reader :timezone_repository
10
11      def initialize(timezone_repository:)
12        @timezone_repository = timezone_repository
13      end
14
15      def execute(shipment_id:, route_id:, date:, time:)
16        timezone = to_timezone(route_id)
17        delivered_at = timezone.parse("#{date} #{time}")
18
19        [shipment_id, route_id, delivered_at]
20      end
21
22      private
23
24      def to_timezone(route_id)
25        name = TIMEZONE_MAPPING.fetch(route_id)
26        timezone_repository.create(name)
27      end
28    end
29  end
30end

處理時間的部分跟前面實作的 Input Object 基本上是類似的,然而在這裡我們定義了一個 Timezone Repository 的介面,預期會有一個 #create 行為產生我們期望的時區物件,並且提供一個 #parse 方法讓我們針對時間解析。

因為沒有任何實質對某個特定類型(Class)的依賴,即使替換到其他框架,只要能實作對應的方法就能夠繼續使用這段程式碼。

調整 Controller

原本我們是透過 Input Object 來取得新增的 Route 資訊,然而現在有 Command 初步做了一些事情,我們可以讓 Controller 改為依賴這個 Command 處理,也讓 Controller 更接近協調任務的角色。

 1module Shipments
 2  class RoutesController < ApplicationController
 3    # ...
 4
 5    def create
 6      input = CreateRouteInput.new(*route_params)
 7      command = Shipments::Command::AddRoute.new(timezone_repository: ActiveSupport::TimeZone)
 8      shipment_id, route_id, delivered_at = command.execute(**input)
 9
10      render json: {
11        id: 1,
12        state: 'shipping',
13        routes: [
14          {
15            route_id: 'OKA',
16            shipment_id:,
17            delivered_at: Time.utc(2024, 3, 19, 0, 0, 0)
18          },
19          {
20            route_id:,
21            shipment_id:,
22            delivered_at: delivered_at.utc
23          }
24        ]
25      }
26    end
27
28    # ...
29  end
30end

因為 ActiveSupport::TimeZone 剛好具備了 .create 方法,並且回傳的物件實例也有滿足 #parse 的要求,因此剛好符合我們對 Timezone Repository 的介面,就可以直接當作參考傳遞給 Command 使用。

另一方面,我們在 #execute 是使用 Keyword Argument 來接收資訊,因此可以讓 Input Object 直接轉換成 Hash 傳遞進去,最後將回傳結構傳遞給我們暫時性寫死的結果即可。

假設將 Input Object 規劃在 lib/shipments/input 的話,這邊可以改為直接傳遞 Input 的方式,因為 lib/shipments 在架構上會知道這個物件與相應的細節

Input Object

接下來只需要修正我們的 Input Object 去除跟 Domain 相關(站點跟時區對應)的資訊,就可以讓這段程式碼正確運作起來。

 1class CreateRouteInput
 2  attr_reader :route_id, :shipment_id, :date, :time
 3
 4  def initialize(route_id, shipment_id, date, time)
 5    @route_id = route_id
 6    @shipment_id = shipment_id.to_i
 7    @date = date
 8    @time = time
 9  end
10
11  def to_hash
12    {
13      route_id:,
14      shipment_id:,
15      date:,
16      time:
17    }
18  end
19end

目前的版本就更明顯是一個單純的物件,並且支援轉換成 Hash 的 Data Transfer Object(資料傳輸物件)

這樣的實作、開發方式確實相對繁瑣,因此並不是要一開始就這樣發展,可以選擇在後續重構時,慢慢的將 Controller、Model 中相關的邏輯抽離出來。然而,系統一開始就具備一定程度的複雜度時,直接採用這樣的方式會更加恰當。

接下來我們要繼續實作 Entity(實體)來完善一個完整的行為。