自然地在 Rails 中應用 Data Context Interaction
之前在查資料時,在 Twitter 上看到 DHH 對 DCI (Data Context Interaction) 的看法同時上週的我對 Domain-Driven Design 的理解(2022 年)這篇文章也提到了 DDD 跟 DCI 不互斥,而且能夠改善某些設計上的問題。
MVC 的限制
現今大多數的網路服務都是基於 Model-View-Controller 這樣的架構下去設計的,同時框架本身提供了 ORM(Object–relational mapping)的機制,也因此我們會很直覺的認為資料表跟模型(Model)物件是一個直接的映射關係。
也因此,在使用像是 Rails 這類框架時,我們大多會像這樣使用。
1class WalletController < ApplicationController
2 # ...
3 def deposit
4 @wallet = current_user.wallets.find_by(currency: params[:currency])
5 @wallet.balance += params[:amount].to_d
6 @wallet.save
7
8 redirect_to @wallet
9 end
10end
單就這段程式碼來看,其實沒有什麼大問題,不過有經驗的人其實很清楚現實狀況非常複雜,因此不太可能這麼單純。
DCI 的目的
簡單來說 DCI 是針對 MVC 的補充,用來更清晰的釐清當下的情境,也因此是一個關於資料(Data)在怎樣的情境(Context)能做出怎樣的互動(Interaction)的使用場景。
我們回到上一個段落的 deposit
(儲值)的情境來看,整個情境是這樣的:
- Data - 某個貨幣的錢包餘額
- Context - 儲值的狀況
- Interaction - 儲值
把他們跟 MVC 關聯起來,則會是:
- Data -
Wallet
Model 的資料 - Context -
Wallet
Controller - Interaction -
deposit
的處理
在這裡會發現一個有點「奇怪」的狀況,那就是我們的情境為什麼是 Wallet
呢?是不是應該有個 DepositContext
來處理比較合理?
就這件事情來看,我認爲沒有「正確」的答案,假設 Wallet
相關的處理都很單純,那麼我們直接把他視為一個 Context 來看待是完全沒有問題的,或者說理想上能保持這樣的狀態我們的應用會更單純。
如果過於複雜,以往我們會拆分 Service Object 來解決,就這點來說也沒有問題,因為他可以是 Domain Service 而且 Service 的層級上來說比 Context 還小一點的。
還能做什麼?
照上面的解說來看,我們似乎沒有事情可以做了?這樣子 DCI 的用意在哪裡,當我們使用 MVC 的時候不就已經符合條件了嗎?
我們回到前面仔細觀察這兩行,就會發現似乎有哪裡不對勁:
1@wallet.balance += params[:amount].to_d
2@wallet.save
在這兩行裡面,我們並沒有描述「互動」是怎樣的,也就是說這個 Wallet
物件的互動是直接把 balance
屬性拿出來修改,並沒有給定「互動」的定義。
因此,更合理的方式應該是:
1@wallet.deposit(params[:amount].to_d)
我們需要明確的描述這個物件「能做的事情」也就是 #deposit
方法,也因此這個 DCI 的敘述是「有一個 Wallet 在處理 Wallet 的情況下可以被儲值(Deposit)」進而構成了對情境的完整敘述,那麼我們在 Ruby 最常使用的 BDD(行為驅動,Behavior-Driven Development)測試框架 RSpec 就能夠自然的「對應」這件事情。
1RSpec.describe WalletController, type: :controller do
2 describe 'POST /deposit' do
3 # ...
4 context 'when currency is USD' do
5 # ...
6 it { expect { deposit }.to change(wallet, :balance) }
7 it { is_expected.to redirect_to wallet_path(wallet) }
8 end
9 end
10end
像這樣子,我們可以用 describe
描述一個場景,接下來就可以針對這個情境用 context
給予不同的資訊,來驗證這個場景下所發生的各種事情,進而很自然的就能將 Controller 和測試對應起來。
Concern 與角色
當我們將大量的方法集中到一個 Model 上,構成充血模型(Rich Model)後就面臨了另一個問題——物件過於巨大難以管理,這也是以往 Rails 提倡 Fat Model 後面臨的問題,也因此才會有許多專案選擇用 Service Object 來抽離這些邏輯。
然而,一個物件的狀態(或稱屬性)應該要由物件本身自己來管理,也就是所謂的「高內聚」的狀態,當我們將一部分的行為抽離出來後反而讓這個狀態流失,甚至要為了維持狀態操作正常,而將一些本應該是私有的行為曝露出來。
然而在 Ruby 語言本身就具備了 Mixin(混和)的性質,在這樣的前提下 Rails 採用了 Concern(相關)的機制來解決這個問題。
假設我們的模型中有數種類似 Wallet
而且可以被「儲值」的情況,我們就可以將它獨立成一個模組。
1module CanDeposit
2 extend ActiveSupport::Concern
3
4 included do
5 # DSL ...
6 end
7
8 def deposit(amount)
9 self.balance += amount
10 end
11end
12
13class Wallet
14 include CanDeposit
15end
16
17class Account
18 include CanDeposit
19end
如此一來,我們利用了 Mixin 的特性將「可以儲值」的性質混合到數種不同的物件上,在 DCI 的觀點中,有一些文章會用角色(Role)來描述這樣的關係,在這個例子中我們就是賦予了 Wallet
和 Account
具備「儲值對象」這一個角色,並且給予了 deposit
(儲值)這一個互動選項。
那麼回到我們在 Rails 負責的 Context 的 Controller 上,這段程式具備的意涵就完全不同。
1class WalletController < ApplicationController
2 # ...
3 def deposit
4 @wallet = current_user.wallets.find_by(currency: params[:currency])
5 @wallet.deposit(params[:amount].to_d)
6 @wallet.save
7
8 redirect_to @wallet
9 end
10end
我們可以明確的知道在「Wallet 的情境下要進行 deposit
時,會有一個 Wallet 的資料參與(扮演 CanDeposit
的角色)因此可以對其執行 deposit
的操作」
如果我們還想更近一步,加入 DDD 在建模上以「領域(Domain)」為基礎的方式實作,讓整個場景更佳「完善」則可以加入像是 Value Object(值物件)的實作來更近一步的描述現實世界的情境。
1class WalletController < ApplicationController
2 # ...
3 def deposit
4 @amount = Money.from_cents(params[:amount], params[:currency])
5 @wallet = Wallet.from_user(current_user).find_by(currency: @amount.currency)
6 @wallet.deposit(@amount)
7 @wallet.save
8
9 redirect_to @wallet
10 end
11end
樣我們就可以明確地表達出這個情境中的資訊有金錢的單位、幣種(Money.from_cents
)以及錢包(Wallet.from_user.find_by
)互動是儲值(deposit
)等等資訊,就自然而然的構成了一個不需要註解也能清晰描述情境的實作。
Rails 的 Concern 機制是方便使用 Rails 的 DSL 所設計,假設沒有需要使用 DSL 的話並不一定要加入
extend ActiveSupport::Concern
在內。