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