“解耦神器”之SpringEvents領(lǐng)域事件
大家好,我是Jensen。一個想和大家一起打怪升級的程序員朋友。
在DDD項目的落地過程中,除了聚合、模型等等重要概念,領(lǐng)域事件在其中扮演了一個非常重要的角色,它不僅能解耦領(lǐng)域?qū)优c其他層,作為“跳出”領(lǐng)域?qū)拥奶澹€是一種策略模式的高級用法。即便你的項目沒有DDD,領(lǐng)域事件在傳統(tǒng)的MVC分層架構(gòu)也大有妙用。
下面我們一起來解鎖這個“解耦神器”。
1.什么是領(lǐng)域事件
領(lǐng)域事件是一種用于表示領(lǐng)域模型中發(fā)生的重要事件的機制。它們用于通知其他相關(guān)的聚合或服務(wù),以便它們可以采取相應(yīng)的行動。
領(lǐng)域事件通常由聚合根( Aggregate Root)發(fā)布。當聚合根內(nèi)部發(fā)生重要的狀態(tài)更改時,它會發(fā)布一個領(lǐng)域事件。其他聚合或服務(wù)可以訂閱這些事件,并在事件發(fā)生時采取相應(yīng)的行動。
以下是使用領(lǐng)域事件的四大步:
- 定義領(lǐng)域事件:領(lǐng)域事件是一個簡單的對象,它包含事件的名稱、發(fā)生時間和相關(guān)的數(shù)據(jù)。例如,一個訂單已完成的領(lǐng)域事件可能包含訂單的 ID 和完成時間。
- 發(fā)布領(lǐng)域事件:當聚合根內(nèi)部發(fā)生重要的狀態(tài)更改時,它會發(fā)布一個領(lǐng)域事件。例如,當訂單完成時,訂單聚合根會發(fā)布一個 OrderCompletedEvent 事件。
- 訂閱領(lǐng)域事件:其他聚合或服務(wù)可以訂閱領(lǐng)域事件,并在事件發(fā)生時采取相應(yīng)的行動。例如,一個訂單跟蹤服務(wù)可以訂閱 OrderCompletedEvent 事件,并在訂單完成時發(fā)送通知給客戶。
- 處理領(lǐng)域事件:當領(lǐng)域事件被發(fā)布時,訂閱者會收到通知,并可以根據(jù)事件的數(shù)據(jù)采取相應(yīng)的行動。例如,訂單跟蹤服務(wù)可以在收到 OrderCompletedEvent 事件時發(fā)送通知給客戶。
領(lǐng)域事件的使用可以幫助保持領(lǐng)域模型的解耦和一致性。通過使用領(lǐng)域事件,不同的聚合或服務(wù)可以獨立地處理事件,而不需要直接相互依賴。這有助于提高系統(tǒng)的可維護性和靈活性。
(以上內(nèi)容由豆包AI生成,描述還是蠻契合的,理由我就不過多掩飾了)
2.領(lǐng)域事件的定義、發(fā)布與訂閱
在DDD工程中,領(lǐng)域事件定義在領(lǐng)域?qū)樱唧w來說是放在領(lǐng)域契約下面,如:domain.contract.event,它不屬于某個聚合私有,由該系統(tǒng)下的所有聚合共享。
為什么要這樣劃分呢?
我認為,領(lǐng)域事件不僅能在領(lǐng)域?qū)影l(fā)布,也可能在應(yīng)用層發(fā)布,甚至在接入層發(fā)布,而在領(lǐng)域聚合之外發(fā)布的事件,必然會存在跨聚合的事件屬性。
我舉個預(yù)約的場景:
工單中臺下的預(yù)約業(yè)務(wù)需要設(shè)計一個支付回調(diào)接口,由商城系統(tǒng)支付成功后進行回調(diào),此時商城系統(tǒng)傳入的回調(diào)命令參數(shù)在處理完核心業(yè)務(wù)后(如設(shè)置預(yù)約單狀態(tài)為待服務(wù)),再發(fā)布支付回調(diào)成功事件,以執(zhí)行后續(xù)的非核心業(yè)務(wù)邏輯(比如提醒服務(wù)店員需要聯(lián)系客戶到店等等)。
工單中臺和商城系統(tǒng)已然進行了服務(wù)拆分,工單中臺本身并不包含支付業(yè)務(wù),領(lǐng)域?qū)樱ㄈ珙I(lǐng)域服務(wù))并沒有發(fā)布這個支付回調(diào)成功的事件的入口,那么,發(fā)布領(lǐng)域事件的最佳位置是在應(yīng)用層。
至此,事件的定義、事件的發(fā)布已經(jīng)確定好了位置,但事件在哪里訂閱也有講究。
我在DDD落地過程中,曾多次調(diào)整領(lǐng)域事件訂閱的位置,有試過放在領(lǐng)域?qū)泳酆舷旅妫灿性囘^抽取到SDK工程里,最終在前段時間確定下來了,事件訂閱就放在應(yīng)用層的listener包下面,意為事件監(jiān)聽器。
至于命名規(guī)則,需要看系統(tǒng)的復(fù)雜度,一般小而美的微服務(wù),以聚合Listener或以外部系統(tǒng)Listener命名足以,如工單中臺(WorkOrder)下的預(yù)約領(lǐng)域聚合(Appointment),其監(jiān)聽器以AppointmentListener命名,訂單領(lǐng)域聚合(Order)是商城系統(tǒng)(如Mall)外部聚合,其監(jiān)聽器以MallListener命名而非OrderListener。
特別強調(diào)一點,在高內(nèi)聚的架構(gòu)設(shè)計中,外部系統(tǒng)的調(diào)用不會設(shè)計特別多,如果存在大量的跨系統(tǒng)交互,我們該反思一下是不是微服務(wù)拆分得太細了,大量的外部系統(tǒng)調(diào)用會存在跨線程的分布式事務(wù)等問題等。
當然,隨著業(yè)務(wù)快速發(fā)展,系統(tǒng)復(fù)雜度隨之上升,事件監(jiān)聽listener也可能跟著拆分,這時候我們的原則還是往大了拆,不宜拆得太細。
對于非DDD工程,可以考慮在根目錄定義一個event包,包括entity和listener:entity下定義領(lǐng)域事件,listener下定義領(lǐng)域事件監(jiān)聽器,這樣一來我們寫代碼就更加簡單清晰。
3.領(lǐng)域事件解耦實戰(zhàn)
下圖是我在DDD工程落地的案例,我們要先約定好代碼放哪里才能更好地規(guī)劃后續(xù)的編碼工作。
上面所說的領(lǐng)域事件,一直停留在概念層面,事件的發(fā)布訂閱只是設(shè)計模式,那具體要怎么實現(xiàn),才是核心技術(shù)。
發(fā)布訂閱有很多種實現(xiàn)方式,如Java自帶的觀察者模型java.util.Observer,事件驅(qū)動模型java.util.EventListener,還有基于第三方跨線程的消息隊列模型(如Kafka、RabbitMQ、RocketMQ、Redis等),以及Spring的發(fā)布訂閱模型SpringEvents。
在這里,我認為領(lǐng)域事件在工程內(nèi)部解耦即可,用不上第三方跨線程的MQ模型,所以我選了SpringEvents作為發(fā)布訂閱的底層實現(xiàn),而且Spring事件有個好處,它可以在Idea工具中鏈接消息發(fā)布和訂閱,對于編程還是非常友好的。
在系統(tǒng)內(nèi)部事件滿天飛的情況下,解耦完還能保證代碼可讀性,可謂是錦上添花。
SpringEvents的常規(guī)打開方式:
- 定義事件:定義一個事件類,該類應(yīng)該繼承自ApplicationEvent類。你可以在事件類中添加任何需要的數(shù)據(jù),這些數(shù)據(jù)將在事件發(fā)布時傳遞給訂閱者。
- 發(fā)布事件:使用ApplicationEventPublisher發(fā)布事件。你可以通過ApplicationContext獲取ApplicationEventPublisher實例,并使用其publishEvent方法發(fā)布事件。
- 訂閱事件:使用@EventListener注解來訂閱事件。將@EventListener注解應(yīng)用于一個方法上,并指定要訂閱的事件類型。該方法將在事件發(fā)布時被調(diào)用,并接收事件對象作為參數(shù)。
領(lǐng)域事件還要解決一個問題,如果我們通過@Async+@EventListener實現(xiàn)異步監(jiān)聽,需要跨線程傳遞信息,那我們就要對領(lǐng)域事件做一層小小的封裝了。
首先,寫一個領(lǐng)域事件抽象類,該類由其他事件繼承:
public abstract class DomainEvent extends ApplicationEvent {
// 本地線程變量池,用于存儲跨線程信息
private final Map<String, Object> THREAD_LOCALS = ThreadContext.getValues();
/**
* 領(lǐng)域事件構(gòu)造器
*
* @param source 事件內(nèi)容
* @param <T> 任意類型
*/
public <T> DomainEvent(T source) {
super(source);
}
/**
* 獲取事件內(nèi)容
*
* @param <T> 任意類型
* @return 事件內(nèi)容
*/
public <T> T get() {
ThreadContext.setValues(THREAD_LOCALS);
return (T) super.getSource();
}
/**
* 租戶判斷
* 使用方式:監(jiān)聽方法標注@EventListener(condition = "#event.tenantIn('xxx', 'xxx')")
*
* @param tenantIds 指定租戶ID才能訂閱
* @return 該租戶能否監(jiān)聽
*/
public boolean tenantIn(String... tenantIds) {
ThreadContext.setValues(THREAD_LOCALS);
String tenantId = ThreadContext.getOrDefault("tenant-id", "");
return Arrays.asList(tenantIds).contains(tenantId);
}
}
以上代碼,把本地線程變量存進了領(lǐng)域事件內(nèi),在監(jiān)聽器獲取事件內(nèi)容時,把本地線程變量塞到另一個線程里。
細心的同學(xué)發(fā)現(xiàn),該類封裝的tenantIn方法有什么作用?
這是為了控制指定的租戶才能監(jiān)聽到該事件,比如某個租戶需要監(jiān)聽下單完成后,推到他自己的ERP系統(tǒng),但是其他租戶并沒有這個需求,那么我們就可以使用這種方式控制不同租戶的行為,這樣解耦也不會對業(yè)務(wù)主流程產(chǎn)生太大影響。
除了SaaS系統(tǒng)的租戶隔離監(jiān)聽,我們也可以利用這一特性做些別的策略。
以上代碼我們再抽象一輪:
/**
* 領(lǐng)域事件
* 1. 異步事件透傳線程變量
* 2. 租戶策略
* 3. 條件策略
*/
public abstract class DomainEvent extends ApplicationEvent {
// 本地線程變量池,用于存儲跨線程信息
private final Map<String, Object> THREAD_LOCALS = ThreadContext.getValues();
/**
* 領(lǐng)域事件構(gòu)造器
*
* @param source 事件內(nèi)容
* @param <T> 任意類型
*/
public <T> DomainEvent(T source) {
super(source);
}
/**
* 獲取事件內(nèi)容
*
* @param <T> 任意類型
* @return 事件內(nèi)容
*/
public <T> T get() {
ThreadContext.setValues(THREAD_LOCALS);
return (T) super.getSource();
}
/**
* 租戶判斷
* 使用方式:監(jiān)聽方法標注@EventListener(condition = "#event.tenantIn('xxx', 'xxx')")
*
* @param tenantIds 指定租戶ID才能訂閱
* @return 該租戶能否監(jiān)聽
*/
public boolean tenantIn(String... tenantIds) {
ThreadContext.setValues(THREAD_LOCALS);
String tenantId = ThreadContext.getOrDefault("tenant-id", "");
return Arrays.asList(tenantIds).contains(tenantId);
}
// 監(jiān)聽者能否執(zhí)行的條件,用于控制事件監(jiān)聽器能否執(zhí)行(策略模式)
private Collection supports;
/**
* 領(lǐng)域事件構(gòu)造器
*
* @param source 事件內(nèi)容
* @param supports 支持執(zhí)行的條件,配合supports方法使用
* @param <T> 任意類型
*/
public <T> DomainEvent(T source, Collection supports) {
super(source);
this.supports = supports;
}
/**
* 條件判斷(策略模式)
* 使用方式:監(jiān)聽方法標注@EventListener(condition = "#event.supports('xxx', 'xxx')")
*
* @param supports 支持的類型
* @param <T> 任意類型
* @return 該條件下能否監(jiān)聽
*/
public <T> boolean supports(T... supports) {
if (this.supports == null) return false;
ThreadContext.setValues(THREAD_LOCALS);
List<T> supportList = Arrays.asList(supports);
for (Object support : this.supports) {
if (supportList.contains(support)) {
return true;
}
}
return false;
}
/**
* 發(fā)布事件,方便但降低代碼可讀性
* 建議使用原生的SpringContext.getApplicationContext().publishEvent()方法
*/
public void publish() {
SpringContext.getApplicationContext().publishEvent(this);
}
}
我們加入了新的成員變量supports,有什么作用呢?來看一個消息中心的例子就一目了然。
業(yè)務(wù)需求是:消息中心需要寫一個事件發(fā)布的接口,聚合站內(nèi)信、極光推送、小程序訂閱消息、公眾號模板消息、郵件、短信功能等等,并且后續(xù)支持擴展。
首先設(shè)計一下整個消息中心,DDD領(lǐng)域圖如下:
對應(yīng)的領(lǐng)域事件定義和監(jiān)聽器:
領(lǐng)域事件定義:
public class PublishEventMessageEvent extends DomainEvent {
public PublishEventMessageEvent(EventMessage eventMessage) {
super(eventMessage, Collections.singleton(eventMessage.getPushChannel()));
}
}
發(fā)布事件的核心代碼:
// 存儲事件消息
EventMessage eventMessage = EventMessage.builder().eventCode(messageDefine.getEventCode()).notify(messageDefine.getNotify())
.pushChannel(pushChannel).content(contentCopy).target(targetCopy)
.categoryCode(messageDefine.getCategoryCode()).categoryName(messageDefine.getCategoryName())
.pushConfig(messageDefine.getPushConfig())
.build();
eventMessage.save();
// 發(fā)布事件消息事件
SpringContext.getApplicationContext().publishEvent(new PublishEventMessageEvent(eventMessage));
事件消息事件監(jiān)聽器:
/**
* 極光推送監(jiān)聽器
*/
@Component
public class JPushListener {
/**
* 發(fā)送極光消息
*
* @param event
*/
@EventListener(condition = "#event.supports('jpush')")
public void sendJPushMessage(PublishEventMessageEvent event) {
EventMessage eventMessage = event.get();
// 下面是核心的推送邏輯
}
}
上面以極光推送監(jiān)聽器為例,其他監(jiān)聽器也是同樣的實現(xiàn)方式,后續(xù)如果還有別的推送實現(xiàn),再寫一個推送監(jiān)聽器即可,消息定義里把對應(yīng)的推送通道pushChannel給加上。
需要注意的是,使用事件作為策略模式,一般是單向的通知,不宜接收監(jiān)聽器的返回結(jié)果做后續(xù)處理。你可能會說,那可以在事件的數(shù)據(jù)里定義返回值啊,方法層傳遞引用對象就行了,但再細想一下,如果在推送監(jiān)聽器上做了異步處理,那由事件發(fā)布者處理這個結(jié)果就變得不可控了。
4.寫在最后
基于SpringEvents實現(xiàn)的領(lǐng)域事件作為一種跨層解耦的手段,可以讓我們的代碼可讀性變得更高,擴展性更強,無論新老項目都是使用即見效的舉措。
上述領(lǐng)域事件DomainEvent已集成到我的D3Boot開源基礎(chǔ)框架,大家需要可以移步Gitee抄作業(yè)。
Gitee源碼地址:
https://gitee.com/jensvn/d3boot(例行賒Star)
D3boot基礎(chǔ)框架具體的使用方式見源碼的README.md文件,這里不再贅述。