Repository - 重新思考 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_id
和 name
來作為主鍵,因為從前面的功能判斷這兩個組合是不會重疊的情境,因此適合用於這個狀況。
假設一開始就要決定,我們可能就會採用
id
搭配shipment_id
和route_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 的處理,增加一個封裝避免實體直接暴露到外面。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 軟體架構的挑戰 - 重新思考 Rails 架構
- 資料驅動設計 - 重新思考 Rails 架構
- 複雜的操作 - 重新思考 Rails 架構
- 時區換算 - 重新思考 Rails 架構
- 報表機制 - 重新思考 Rails 架構
- 通用化功能 - 重新思考 Rails 架構
- ActiveRecord 的限制 - 重新思考 Rails 架構
- 領域驅動設計 - 重新思考 Rails 架構
- 從架構到設計 - 重新思考 Rails 架構
- 重復使用的反思 - 重新思考 Rails 架構
- 釐清脈絡 - 重新思考 Rails 架構
- 劃分邊界 - 重新思考 Rails 架構
- 職責劃分 - 重新思考 Rails 架構
- 架構規劃 - 重新思考 Rails 架構
- 驗收測試 - 重新思考 Rails 架構
- Controller - 重新思考 Rails 架構
- Form - 重新思考 Rails 架構
- Use Case - 重新思考 Rails 架構
- Entity - 重新思考 Rails 架構
- Repository - 重新思考 Rails 架構