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

Repository - 重新思考 Rails 架構

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

當我們將運送模組(取 module Shipments 的方式稱呼)的實體(Entity)確立下來後,就可以來處理倉庫(Repository)也就是我們的資料如何保存的議題。在某些軟體開發的最佳實踐中,會建議推遲資料庫的設計,就是因為一但確定後就難以修改。

依照這次的流程,我們在接近功能完成時才處理,能省去不少前期就確定資料表結構的問題。

Migration

透過前面的實作,我們基本上確定只需要有 Shipment Route 的資訊就足以構成現階段的 Shipment 實體,因此只需要加入一張 shipment_routes 資料表即可。

使用 rails generate 命令產生一個 ShipmentRoute Model 來提供這個機制。

 1class CreateShipmentRoutes < ActiveRecord::Migration[7.1]
 2  def change
 3    create_table :shipment_routes, primary_key: [:shipment_id, :name]do |t|
 4      t.bigint :shipment_id
 5      t.string :name
 6      t.datetime :delivered_at
 7
 8      t.timestamps
 9    end
10  end
11end

跟我們習慣直接使用 id 當作 Primary Key 不同的地方在於,我們使用 shipment_idname 來作為主鍵,因為從前面的功能判斷這兩個組合是不會重疊的情境,因此適合用於這個狀況。

假設一開始就要決定,我們可能就會採用 id 搭配 shipment_idroute_id 的設計,在維護上可能就會變得更複雜一些。

Repository

我們產生的 ShipmentRoute 基本上可以直接當作 Repository 使用,因此可以像這樣實作。

 1class ShipmentRoute < ApplicationRecord
 2  scope :by_shipment_id, ->(shipment_id) { where(shipment_id: shipment_id).order(:delivered_at) }
 3
 4  def self.save(shipment)
 5    routes = by_shipment_id(shipment.id)
 6
 7    transaction do
 8      shipment.routes.each do |route|
 9        routes.find_or_create_by(name: route.id).tap do |shipment_route|
10          shipment_route.update!(delivered_at: route.delivered_at)
11        end
12      end
13    end
14  end
15
16  def self.find_shipment(shipment_id)
17    routes = by_shipment_id(shipment_id)
18    Shipments::Shipment.new(id: shipment_id).tap do |shipment|
19      routes.each do |route|
20        shipment.add_route(route_id: route.name, delivered_at: route.delivered_at.utc)
21      end
22    end
23  end
24end

這裡做了幾個處理,首先是用 by_shipment_id 的 Scope 來定義查詢的行為,接下來設計了 .save.find_shipment 兩個方法,用於產生 Shipment 物件以及儲存修改。

在這個部分會跟以往我們認知到的 Model 差異很多,基本上不會去定義實例方法反而是 Class Method 更多,然而這個做法不一定能很好對應更複雜的情境,因此還可以繼續切分成獨立的 Repository 物件類型放到 app/repositories 中來維護。

這裡是用於相對簡單情境的案例,實際的專案還是會推薦切分出 Repository 會更好一些。

調整 Use Case

有了 Repository 後,原本我們在 Use Case 上的處理就會變得更加明確,也能很快地理解意圖。

 1module Shipments
 2  module Command
 3    class AddRoute
 4      # ...
 5
 6      attr_reader :timezone_repository, :shipment_repository
 7
 8      def initialize(timezone_repository:, shipment_repository:)
 9        @timezone_repository = timezone_repository
10        @shipment_repository = shipment_repository
11      end
12
13      def execute(shipment_id:, route_id:, date:, time:)
14        shipment = shipment_repository.find_shipment(shipment_id)
15
16        timezone = to_timezone(route_id)
17        delivered_at = timezone.parse("#{date} #{time}")
18        shipment.add_route(route_id:, delivered_at:)
19
20        shipment_repository.save(shipment)
21        shipment
22      end
23
24      # ...
25    end
26  end
27end

上面的調整可以看到 Shipment 會透過具備 #find_shipment 方法的 Repository 來找生成對應的物件,在處理完畢後,呼叫 #save 方法來保存,這樣就不會依賴 Rails 的 ActiveRecord 只要能夠提供這兩個方法就可以運作。

因為回傳改變,我們也先對 Controller 做調整,因為 Shipment 物件不適合被 Controller 依賴,因此後續會再做調整。

 1module Shipments
 2  class RoutesController < ApplicationController
 3    # ...
 4
 5    def create
 6      input = CreateRouteInput.new(*route_params)
 7      command = Shipments::Command::AddRoute.new(
 8        timezone_repository: ActiveSupport::TimeZone,
 9        shipment_repository: ShipmentRoute,
10      )
11      shipment = command.execute(**input)
12
13      render json: {
14        id: shipment.id,
15        state: shipment.state,
16        routes: shipment.routes.map do |route|
17          {
18            route_id: route.id,
19            shipment_id: shipment.id,
20            delivered_at: route.delivered_at.utc,
21          }
22        end
23      }
24    end
25
26    # ...
27  end
28end

更新測試

現在我們的測試會需要對應真實資料,是時候把一開始撰寫測試跳過的步驟定義(Step Definition)實作進去。

 1# ...
 2
 3Given('there have some shipment route') do |table|
 4  table.hashes.each do |row|
 5    row['name'] = row.delete('route_id')
 6    ShipmentRoute.create(row)
 7  end
 8end
 9
10# ...

因為我們在 Cucumber 是使用 route_id 來作為定義,因此需要針對這筆資料替換成儲存到資料庫的 name 就能順利運行。

也可以調整測試的描述,直接使用 name 就可以減少額外的處理,大多數情境我也會偏好這種方式讓 Cucumber 的步驟定義更簡單一些。

接下來就是針對 Shipment 物件回傳給 Controller 的處理,增加一個封裝避免實體直接暴露到外面。