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

領域事件在 Rails 中的呈現形式

最近在實作一個會員集點功能的時候意外發現很適合作為「領域事件(Domain Event)」的例子,在很多情況下其實不容易去描述這個概念,然而這個例子倒是很好的反應這個概念,以及 Rails 的特性所產生的變化。

會員集點

會員集點算是很多電商會有的功能大家可能都不陌生,然而實際上會有哪些資訊存在其中,以我的設計來看大致上有這些項目。

模型(Model)說明
Reward發送獎勵的紀錄(發放者、數量)
LoyaltyCard會員卡,用來區別持有者
LoyaltyEvent收到點數的紀錄

大致上來看,可以看出這是一個有著 Event Sourcing(事件溯源)的模型設計,然而我們不一定要採取這樣的方式,因為不在討論範圍因此就不細講。

如果用事件風暴(Event Storming)分析,大概可以看出這樣的過程

  • 發放獎勵(Command
  • 確認獎勵(System,儲存到資料庫)
  • 獎勵產生(LoyaltyPointRewarded

LoyaltyPointRewarded 發生時,就要後續去產生 LoyaltyEvent 並且附加到某 LoyaltyCard 上,因為 LoyaltyCardLoyaltyEvent 的聚合(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 這類背景作業的機制來解決,像是 ActionControllerActionMailer以及 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 的形式,除此之外像是 WebhookPubSub 等等都可以用來作為領域事件發佈的一種形式,也因此我們在思考「領域事件」的時候會需要事件風暴的結果來輔助,會更能清晰的思考「事件傳遞」是怎樣的形式。

另外,要注意的是領域事件的對應物件需要是 DTO(Data Transfer Object,資料傳輸物件)是不帶有脈絡的,在 Rails 中利用 GlobalID 這種封裝,將 Reward 抽離脈絡變成 gid://app/Reward/1 這樣的形式,在回到 ActiveJob 時又以 Reward.find(1) 的方式還原回來,才讓我們省下了尋找 Entity 的處理,這也是 Rails 容易入門卻經常設計不佳的問題之一,因為太多語法糖的存在。

文章的例子是以我正在實作的系統作為例子,他的需求、規格造成了這樣的實現,並不一定代表所有的系統都是這樣運作,也許你正在維護的產品並不需要這樣處理,那就會有不一樣的實作出現。