Use Case - 重新思考 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(實體)來完善一個完整的行為。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 軟體架構的挑戰 - 重新思考 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 架構
- Output - 重新思考 Rails 架構
- Query - 重新思考 Rails 架構
- 可能性 - 重新思考 Rails 架構