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

自然地在 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)來描述這樣的關係,在這個例子中我們就是賦予了 WalletAccount 具備「儲值對象」這一個角色,並且給予了 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 在內。