領域事件在 Rails 中的呈現形式
最近在實作一個會員集點功能的時候意外發現很適合作為「領域事件(Domain Event)」的例子,在很多情況下其實不容易去描述這個概念,然而這個例子倒是很好的反應這個概念,以及 Rails 的特性所產生的變化。
會員集點
會員集點算是很多電商會有的功能大家可能都不陌生,然而實際上會有哪些資訊存在其中,以我的設計來看大致上有這些項目。
模型(Model) | 說明 |
---|---|
Reward | 發送獎勵的紀錄(發放者、數量) |
LoyaltyCard | 會員卡,用來區別持有者 |
LoyaltyEvent | 收到點數的紀錄 |
大致上來看,可以看出這是一個有著 Event Sourcing(事件溯源)的模型設計,然而我們不一定要採取這樣的方式,因為不在討論範圍因此就不細講。
如果用事件風暴(Event Storming)分析,大概可以看出這樣的過程
- 發放獎勵(
Command
) - 確認獎勵(
System
,儲存到資料庫) - 獎勵產生(
LoyaltyPointRewarded
)
當 LoyaltyPointRewarded
發生時,就要後續去產生 LoyaltyEvent
並且附加到某 LoyaltyCard
上,因為 LoyaltyCard
是 LoyaltyEvent
的聚合(Aggregate)所以會由 LoyaltyCard
來製作。
直接呼叫
在我的認知中,最直接的領域事件其實是直接呼叫對應的方法,如果只有一個物件關注事件是最直覺的。
因此,我們可以像這樣處理獎勵事件
1def create
2 # Command (1)
3 @form = RewardForm.new(params)
4
5 # System (1)
6 @reward = Reward.new(
7 user: @form.user,
8 merchant: @form.merchant,
9 amount: @form.amount
10 )
11
12 # Domain Event (1)
13 @reward.save!
14
15 # Command (2)
16 loyalty_card = LoyaltyCard.find_or_create!(user: @reward.user)
17
18 # System (2)
19 service = LoyaltyPointService.new(@reward.merchant)
20 service.verify_balance!(@reward.amount)
21 service.reward_to(loyalty_card, @reward.amount)
22 # reward_to: (card, amount)-> { card.events.build(amount: amount) }
23
24 # Domain Event (2)
25 reward.transaction do
26 reward.completed!
27 loyalty_card.save!
28 end
29end
然而,這樣的呼叫其實有一些問題存在,第一個是 Reward
(獎勵產生)跟 LoyaltyCard
會員卡的紀錄不一定是綁定的,因此這邊沒有用 Transaction
(交易)機制綁定,如果後續的步驟失敗就會造成資料的不一致。
另一方面,在 #create
方法會有非常冗長的實作,因為我們同時處理了兩種脈絡問題,一個是「預定發放點數」另一個是「記錄發放點數」如此一來可讀性就會下降。
ActiveJob
這類問題,我們可以用 ActiveJob
這類背景作業的機制來解決,像是 ActionController
、ActionMailer
以及 ActiveJob
剛好就是對應 Domin-Driven Design 的應用層(Application Layer)或者 Clean Architecture 的使用案例(Use Case)的機制。
那麼,在直接呼叫版本中的「預定發放」跟「記錄發放」正好是 ActionController
(使用者操作)和 ActiveJob
(領域事件觸發)兩個不同使用案例的情境。
經過重構後,我們可以得到像這樣的版本:
1# app/controllers/rewards_controller.rb
2def create
3 @reward = Reward.new(reward_attributes)
4 @reward.save!
5end
6
7def reward_attributes
8 params.permit(:user_id, :merchant_id, :amount)
9end
首先,我們只需要處理「預定發放」的機制因此只需要簡單的儲存即可,甚至不需要 Form Object 輔助直接利用 ActiveRecord
內建的 Validation 來檢驗。
接下來利用 Callback
(回呼)機制,作為觸發事件的方式。
1# app/models/reward.rb
2
3after_commit :commit_loyalty_point, on: :create
4
5def commit_loyalty_point
6 CommitLoyaltyPointJob.perform_later(self)
7end
雖然我們可以直接在 #commit_loyalty_point
方法實作「記錄發放」的機制,然而這就破壞了 Entity(實體)類型的特性,也就是只管理自身狀態,同時也會讓我們的實作有副作用(Side Effect)產生,那麼去呼叫另一個「使用案例」來處理似乎更加合適。
1# app/jobs/commit_loyalty_point_job.rb
2
3def perform(reward)
4 return if reward.completed?
5
6 loyalty_card = LoyaltyCard.find_or_create!(user: reward.user)
7
8 service = LoyaltyPointService.new(reward.merchant)
9 service.verify_balance!(reward.amount)
10 service.reward_to(loyalty_card, reward.amount)
11
12 reward.transaction do
13 reward.completed!
14 loyalty_card.save!
15 end
16end
如此一來我們就分離了兩個不同脈絡下的使用案例,而且因為 ActiveJob
的特性,我們還得以讓「發放點數」這件事情能夠被重試,也能夠透過增加更多的後設資料(Metadata)去關聯發放點數的事件來讓我們可以追蹤更多資訊。
領域事件
在討論領域模型(Domain Model)的時候很難把領域事件放進去討論,畢竟他是一個抽象的概念,正因如此領域事件其實可以有很多種表現的形式。
以上面的例子,他會是一個 Callback
的形式,除此之外像是 Webhook
、PubSub
等等都可以用來作為領域事件發佈的一種形式,也因此我們在思考「領域事件」的時候會需要事件風暴的結果來輔助,會更能清晰的思考「事件傳遞」是怎樣的形式。
另外,要注意的是領域事件的對應物件需要是 DTO(Data Transfer Object,資料傳輸物件)是不帶有脈絡的,在 Rails 中利用 GlobalID 這種封裝,將 Reward
抽離脈絡變成 gid://app/Reward/1
這樣的形式,在回到 ActiveJob
時又以 Reward.find(1)
的方式還原回來,才讓我們省下了尋找 Entity 的處理,這也是 Rails 容易入門卻經常設計不佳的問題之一,因為太多語法糖的存在。
文章的例子是以我正在實作的系統作為例子,他的需求、規格造成了這樣的實現,並不一定代表所有的系統都是這樣運作,也許你正在維護的產品並不需要這樣處理,那就會有不一樣的實作出現。