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

前面我們主要著重在寫入的處理,如果是單純寫入的處理,我們可以使用 Query 來處理。基本上 Command 和 Query 都可以看作 Use Case(使用案例)的一種,可以根據複雜程度決定是否需要細分,當我們實踐到這個階段,也接近有讀寫分離的系統所呈現的狀態。

Query

Query 的實作跟 Command 基本上是相同的,只是我們更明確的將職責「讀取」和「寫入」區分開來,因此只需要在 lib/shipments/query 目錄下增加一個 find_shipment.rb 即可。

 1module Shipments
 2  module Query
 3    class FindShipment
 4      attr_reader :shipment_repository
 5
 6      def initialize(shipment_repository:)
 7        @shipment_repository = shipment_repository
 8      end
 9
10      def execute(shipment_id)
11        shipment_repository.find_shipment(shipment_id.to_i)
12      end
13    end
14  end
15end

我們在「取得 Shipment」的機制上跟 Command 是同樣的邏輯,這表示我們能用相同的 Repository 來處理,因此就不需要重新處理 Repository 的實作,直接沿用現有的行為。

Query 也很適合用於處理先前提到的報表情境,因為我們已經將行為限定在報表相關的處理,因此可以集中精神在報表的查詢上,這種情境時,能在 Repository 直接撰寫 SQL 會比 ORM 更加高效,這也是為什麼 Repository 不直接等同於 Rails 的 ActiveRecord。

測試

撰寫測試是一個好習慣,能確保我們在新增或者修改時不會影響現有的行為,也能更直觀反應每個功能的邏輯。在原本的 features/shipment.feature 可以加入新的描述來反應這次新增的功能。

 1Feature: Shipment
 2  # ...
 3  Scenario: I can get the shipment
 4    Given there have some shipment route
 5      | route_id | shipment_id | delivered_at         |
 6      | OKA      | 1           | 2024-03-19T00:00:00Z |
 7    When I make a GET request to "/shipments/1"
 8    Then the response should be
 9      """
10      {
11        "id": 1,
12        "state": "shipping",
13        "routes": [
14          {
15            "route_id": "OKA",
16            "shipment_id": 1,
17            "delivered_at": "2024-03-19T00:00:00.000Z"
18          }
19        ]
20      }
21      """

因為有新的步驟,還會需要在 Step Definition 中,針對 GET 的情境增加新的定義。

1When('I make a GET request to {string}') do |path|
2  @response = get path
3end

完善功能

最後,我們就可以把前面沒有提到的部分加入到 Rails 中。先針對 config/routes.rb 調整將預設值設定為 JSON 格式。

1# ...
2  resources :shipments, module: :shipments, defaults: { format: :json } do
3    resources :routes, only: [:create], defaults: { format: :json }
4  end

接著加入 app/shipments/shipments_controller.rb 呼叫這次增加的 Query 物件。

1module Shipments
2  class ShipmentsController < ApplicationController
3    def show
4      query = Shipments::Query::FindShipment.new(shipment_repository: ShipmentRoute)
5      @shipment = query.execute(params[:id])
6    end
7  end
8end

跟增加 View 的方式相同,這次加入 app/shipments/shipments/show.json.jbuilder 來顯示內容。

1json.id @shipment.id
2json.state @shipment.state
3json.routes @shipment.routes do |route|
4  json.route_id route.id
5  json.delivered_at route.delivered_at.utc
6  json.shipment_id @shipment.id
7end

基本上跟 Command 是差不多的,在這裡我們不一定要追求「消除重複」的處理,因為查詢單個 Shipment 和增加 Route 後的回傳,有可能在未來改變,可以依照當下的狀況處理。

即使遇到要重複使用,也可以考慮採用 Partial 的機制來維護,可以更好的根據使用情境調整。

這一系列的實作,可以看到我們基本上是圍繞在名為 Shipments 的模組進行,大部分的實作都被整合在這個模組之下,讓我們可以更加專注在單一功能上,又不會跟框架、其他模組有耦合的關係,以長期的角度來看整個專案是更加容易維護的。