為 Gopher 打造 DDD 系列:領(lǐng)域模型-領(lǐng)域事件
前言:?在DDD中,一個業(yè)務(wù)用例對應(yīng)一個事務(wù),一個事務(wù)對應(yīng)一個聚合根,在一次事務(wù)中,只能對一個聚合根進(jìn)行操作。那么在復(fù)雜的業(yè)務(wù)場景涉及多個聚合根的修改,特別是許多聚合根處于不同的限界上下文中時,我們可以選擇使用領(lǐng)域事件對其進(jìn)行修改。
使用freedom,兩行代碼就讓你輕松搞定領(lǐng)域事件!
一、DomainEvent
什么是領(lǐng)域事件
領(lǐng)域事件是領(lǐng)域驅(qū)動設(shè)計中的一個重要概念,我們使用領(lǐng)域事件來捕獲領(lǐng)域中發(fā)生的一些事情。只有那些對業(yè)務(wù)有價值,能夠有助于形成完整的業(yè)務(wù)閉環(huán),能夠推動下一步業(yè)務(wù)發(fā)展的事情,才能被當(dāng)做領(lǐng)域事件。
這里舉一個例子,當(dāng)用戶在訂單服務(wù)下單付款后,倉儲物流團(tuán)隊就可以開始進(jìn)行發(fā)貨流程發(fā)貨了。當(dāng)用戶確認(rèn)收貨后,發(fā)票服務(wù)的會計團(tuán)隊就能開始做賬報稅開票了。這一系列的動作都是屬于事件,屬于各自領(lǐng)域的事件,并且明顯的能夠推動業(yè)務(wù)的發(fā)展!
隨著微服務(wù)的興起,拆分服務(wù)和事件風(fēng)暴在不斷的演進(jìn),領(lǐng)域事件扮演著靈魂角色。在事件風(fēng)暴中,發(fā)現(xiàn)并提取領(lǐng)域事件,將以領(lǐng)域事件為中心的業(yè)務(wù)模型,演化成以聚合為中心的領(lǐng)域模型,是DDD落地實踐的一種重要手段。
領(lǐng)域事件是屬于領(lǐng)域模型的,前面我們已經(jīng)介紹過聚合和實體都是領(lǐng)域模型。在領(lǐng)域事件建模時,我們應(yīng)該關(guān)注這一點。用戶實體修改密碼?訂單聚合支付?訂單聚合發(fā)貨?它們完成命令后是否應(yīng)該發(fā)布通知?
二、事件的存儲
在數(shù)據(jù)的最終一致性的問題上,我們至少要保證該限界上下文內(nèi)領(lǐng)域模型的持久化和領(lǐng)域事件的發(fā)布是一致的。
如果訂單狀態(tài)修改為已付款,然后在使用MQ基礎(chǔ)設(shè)施發(fā)布失敗,那么是不一致的。那么如果是先使用MQ基礎(chǔ)設(shè)施發(fā)布成功,然后在修改訂單狀態(tài)為已付款失敗,也是不一致的。
我們需要一張事件表和使用本地事務(wù)。在領(lǐng)域模型持久化的同時并插入一條領(lǐng)域事件的記錄,它們依賴本地事務(wù)來保持?jǐn)?shù)據(jù)的一致性。
當(dāng)事務(wù)成功后再使用MQ基礎(chǔ)設(shè)施發(fā)布通知,如果失敗了,定時器掃描后繼續(xù)發(fā)送直至發(fā)送成功。
//事務(wù)偽代碼beginupdate order set status = PAIDinsert into domain_event_publishcommit//事件發(fā)布表CREATE TABLE `domain_event_publish` (`id` int(11) NOT NULL AUTO_INCREMENT,`topic` varchar(255) NOT NULL,`content` varchar(2000) NOT NULL,PRIMARY KEY (`id`));//訂單表CREATE TABLE `order` (`id` int(11) unsigned NOT NULL AUTO_INCREMENT,`order_no` varchar(65) NOT NULL DEFAULT '',`user_id` int(11) NOT NULL COMMENT '用戶id',`status` enum('PAID','NON_PAYMENT','SHIPMENT','DONE') NOT NULL DEFAULT 'NON_PAYMENT' COMMENT '支付,未支付,發(fā)貨,完成',)//事件消費表CREATE TABLE `domain_event_subscribe` (`id` int(11) NOT NULL AUTO_INCREMENT,`publish_id` int(11) NOT NULL,`topic` varchar(255) NOT NULL,`content` varchar(2000) NOT NULL,PRIMARY KEY (`id`),UNIQUE KEY `publish_id` (`publish_id`))
看到這里你可能覺得會很麻煩和復(fù)雜,其實只要代碼設(shè)計的漂亮,這不過是幾行代碼的事情。
freedom還有一個額外的優(yōu)點,當(dāng)我們事務(wù)成功后,并不一定要用MQ這種基礎(chǔ)設(shè)施去發(fā)布通知,也可以選擇用**HTTP API、RPC,甚至是Golang的Channel、Cond。
發(fā)布方

訂閱方

三、領(lǐng)域事件的注意事項
領(lǐng)域事件不僅僅只有Topic?,而且還需要使用唯一身份標(biāo)識符,這樣該事件才是一個冪等的事件。
通常發(fā)布方用重試用唯一身份標(biāo)識符來識別,訂閱方的冪等消費也用唯一身份標(biāo)識符來識別。上述示例使用了自增的唯一ID,實際生產(chǎn)中建議使用UUID。
雖然事務(wù)成功立刻發(fā)布消息,定時器只是重試失敗的發(fā)布,但依然會存在發(fā)布和消費的延遲。我們應(yīng)該細(xì)心的評估這種影響。是否可以接受?如果不能接受并且要加入中間狀態(tài)(例如‘訂單確認(rèn)中’),是否產(chǎn)生依賴和耦合?
領(lǐng)域事件可以解耦微服務(wù),使限界上下文更清晰,并且達(dá)到最終一致。通常聚合根可以引用其他限界上下文Query來的值對象,然后聚合根發(fā)布事件來觸發(fā)其他限界上下文的變更。聚合根是不能組合其他限界上下文的實體,不論是否在一個服務(wù)內(nèi)。
領(lǐng)域事件也會有陷阱,最常見的是被動操控型命令?!兑?、什么是領(lǐng)域事件》已經(jīng)簡單介紹過命令和事件。
這里還是舉個例子,當(dāng)用戶為訂單付款后,訂單發(fā)布事件的Topic如果是訂單已支付,這是屬于該訂單領(lǐng)域模型自身發(fā)布的領(lǐng)域事件,這個是正確的。如果Topic是開發(fā)票,訂單這個領(lǐng)域模型讓誰開發(fā)票???這是錯誤的,這就是被動操控型命令(Passive-aggressive command).
四、代碼實戰(zhàn)
創(chuàng)建一個修改密碼的領(lǐng)域事件
package eventimport "encoding/json"// ChangePassword 修改密碼事件type ChangePassword struct {ID int `json:"id"`prototypes map[string]interface{}UserID int `json:"userID"`NewPassword string `json:"newPassword"`OldPassword string `json:"oldPassword"`}// Topic 返回該事件的Topic.func (password *ChangePassword) Topic() string {return "ChangePassword"}// Marshal 序列化.func (password *ChangePassword) Marshal() []byte {data, _ := json.Marshal(password)return data}// Identity 返回唯一標(biāo)識.func (password *ChangePassword) Identity() interface{} {return password.ID}// SetIdentity 設(shè)置唯一標(biāo)識,通常是事件管理器統(tǒng)一設(shè)置.func (password *ChangePassword) SetIdentity(identity interface{}) {password.ID = identity.(int)}
在實體修改密碼的行為中使用領(lǐng)域事件
package entityimport ("github.com/8treenet/freedom""github.com/8treenet/freedom/example/fshop/domain/event""github.com/8treenet/freedom/example/fshop/domain/po")type User struct {freedom.Entity //實體的基類,繼承后可以使用事件集合的方法。po.User}// Identity 返回實體的唯一標(biāo)識func (u *User) Identity() string {return strconv.Itoa(u.ID)}// ChangePassword 修改密碼func (u *User) ChangePassword(newPassword, oldPassword string) error {if u.Password != oldPassword {return errors.New("Password error")}u.SetPassword(newPassword)//為實體加入修改密碼事件u.AddPubEvent(&event.ChangePassword{UserID: u.User.ID,NewPassword: u.Password,OldPassword: oldPassword,})return nil}
領(lǐng)域服務(wù)的處理
import ("github.com/8treenet/freedom/example/fshop/domain/dependency""github.com/8treenet/freedom/example/fshop/domain/entity")type UserService struct {UserRepo dependency.UserRepo //依賴倒置用戶資源庫Transaction *domainevent.EventTransaction //依賴注入事務(wù)組件}// ChangePassword 修改密碼func (user *UserService) ChangePassword(userID int, newPassword, oldPassword string) (e error) {//使用資源庫讀取用戶實體var userEntity *entity.UseruserEntity, e = user.UserRepo.Get(userID)if e != nil {return}//修改密碼if e = userEntity.ChangePassword(newPassword, oldPassword); e != nil {return}//使用事務(wù)組件保證一致性 1.修改密碼屬性, 2.事件表增加記錄//Execute 如果返回錯誤 會觸發(fā)回滾。成功會調(diào)用infra/domainevent/EventManager.pushe = user.Transaction.Execute(func() error {return user.UserRepo.Save(userEntity)})return}
資源庫的處理
package repositoryimport ("github.com/8treenet/freedom/example/fshop/domain/entity""github.com/8treenet/freedom/example/fshop/infra/domainevent""github.com/8treenet/freedom")type UserRepository struct {freedom.RepositoryEventRepository *domainevent.EventManager //領(lǐng)域事件組件}// Save .func (repo *UserRepository) Save(entity *entity.User) error {//持久化實體_, e := saveUser(repo, &entity.User)if e != nil {return e}//持久化事件return repo.EventRepository.Save(&repo.Repository, entity)}
領(lǐng)域事件組件介紹
//領(lǐng)域事件基礎(chǔ)設(shè)施包package domainevent// EventManager 實現(xiàn)了通用的Pub/Sub處理,資源庫引入后直接使用Save方法.type EventManager struct {}//EventTransaction 配合EventManager的事務(wù)組件.type EventTransaction struct {}
更多細(xì)節(jié),請參考代碼。https://github.com/8treenet/freedom/blob/master/example/fshop
目錄
golang領(lǐng)域模型-開篇 golang領(lǐng)域模型-六邊形架構(gòu) golang領(lǐng)域模型-實體 golang領(lǐng)域模型-資源庫 golang領(lǐng)域模型-依賴倒置 golang領(lǐng)域模型-聚合根 golang領(lǐng)域模型-CQRS golang領(lǐng)域模型-領(lǐng)域事件
推薦閱讀
