领域驱动模型DDD(二)——领域事件的订阅/发布实践
前言
凭良心来说,《微服务架构设计模式》此书什么都好,就是选用的业务过于庞大而导致代码连贯性太差,我作为读者来说对于其中采用的自研框架看起来味同嚼蜡,需要花费的学习成本实在是难以想象,不仅要对书中的内容进行了解,还要去学习作者框架用法,最可恶的是官方文档还写得十分简洁。
不要跟我说《微服务架构设计模式》是一本概念性著作,对于我这水平一般的"实用派"来说在理解概念的雏形后最希望的就是通过书中的实现案例确认自己的理解是否正确,最后再巩固记忆尝试使用。
如果你读了第一章可以看到,在该章节中我一直强调万不可在阅读文章时带入“正进行编程的思维”——即如何具体到代码块的编写或是相关技术的采用。
这个劝诫与我此章采用代码讲解并不冲突,我举例的代码坚持从简:一是我水平和时间不足以让我做出像书本一样专业的企业案例;二是我认为过度繁琐的业务会让读者深陷代码解读而非吸取思想理念的困境中。从利用现实生活中的相似物比喻引导读者粗略理解概念,到简略的代码框架明了地让读者知道“某种思维”的大致应用方向,这一切始终围绕着本博客撰写的核心观点——“架构的设计”的阅读过程是思维视角的抽象传播,而非细致到可以“一招鲜吃遍天”的具体代码。
贫血模型、充血模型
本着说话就要说完的原则,我还是决定在此章加入这一小节。主要是因为领域驱动模型中重要的宏观架构到“领域的划分”、“限界上下文”,细微业务逻辑到“事件”、“聚合”、“命令”等。然而在通常开发过程中Java的Spring框架、Golang的Beggo框架,大多在有意无意暗示开发者使用贫血模式,在平常项目后端我们一般使用三层架构进行业务开发:Repository + Entity、Service + BO(Business Object)、Controller + VO(View Object),Entity类和Repository类负责数据访问, Bo类和Service类负责处理业务逻辑, Vo类和Controller类属于接口层。(估计某些简单项目甚至连到这一步细分都没有)
贫血模式其最直观的表现就在于:领域对象里中只有GET和SET方法,至于业务逻辑都塞入Service类中,对象BO类中的所有属性就只是用来做数据库和真实世界的数据之间的传递介质。
@Data
public class TicketBO {
//单个订单的商品形成的Set集合,参考淘宝购物车勾选多个商品后合计支付的订单
private Set<Order> orders;
//所有价格
private BigDecimal realAmount;
//优惠
private BigDecimal discounts;
//订单创建时间
private LocalDateTime orderCreateTime;
//修改订单
private LocalDateTime orderChangeTime;
//订单完成时间
private LocalDateTime finishCreateTime;
}
而在充血模式下数据和对应的业务逻辑被封装到对象类中,Service层仅负责业务逻辑与存储之间的流程编排(从DB中获取数据,传递数据,最后存储数据),并不参与任何的业务逻辑。在以下代码里,我将用户未优惠下的支付金额与实际支付金额的业务计算直接置入类中,形成典型的面向对象编程风格。
@Data
public class TicketBO {
//单个订单的商品形成的Set集合,参考淘宝购物车勾选多个商品后合计支付的订单
private Set<Order> orders;
//所有价格
private BigDecimal realAmount;
//优惠扣减价格
private BigDecimal discounts;
//订单创建时间
private LocalDateTime orderCreateTime;
//修改订单
private LocalDateTime orderChangeTime;
//订单完成时间
private LocalDateTime finishCreateTime;
//未使用优惠券情况下应付应付总额
public BigDecimal getAllAmount(){
return orders.stream().map(Order::getPrice).reduce(BigDecimal.ZERO,BigDecimal::add);
}
//使用优惠券后实际应付总额
public BigDecimal getRealAmount(){
BigDecimal allAmount = getAllAmount();
return allAmount.subtract(discounts);
}
}
读者可能会产生疑问,这两种开发模式落实到代码层面,区别不就是一个将业务逻辑放到 Service 类中,一个将业务逻辑放到 Domain 领域模型(上面的BO类)中吗?为什么基于贫血模型的传统开发模式,就不能应对复杂业务系统的开发?
实际上,除了我们能看到的代码层面的区别之外(一个业务逻辑放到 Service 层,一个放到领域模型中),还有一个非常重要的区别,那就是两种不同的开发模式会导致不同的开发流程。
传统的代码业务流程开发中,很多程序员最终面向的不过是CRUD编程,即在Service类中将数据处理好后存储到数据库中。拿以上图代码为例进行假设:我们要让用户支付的金额包括邮费,如果采用的时贫血模式那我们可以直接在BO中添加一个邮费属性。但是到修改Service类时,我们就得对先前每一个涉及金额的计算代码块(秒杀节日订单、正常购买订单、预约购买订单)重写里面的的金额计算方式,这不仅降低了复用性,后期随着业务拓展也更容易陷入“牵一发动全身”的泥沼中。
而采用基于DDD的充血模式时,因为预先定义好了领域模型所包含的属性和方法,此后的领域模型相当于可复用的业务中间层。当出现新功能需求的开发,都基于之前定义好的这些领域模型来完成。还是新增邮费属性,我们可以直接在BO类中编写计算出添加邮费后的实际应付总额,而所涉及金额的Service类基本可以不做任何变更。
领域事件的订阅/发布
上一小节已经对贫血模式、充血模式做了举例说明,下面我将会以充血模式为基础编写一个很基础的下单的业务逻辑。
何为领域事件?
既然被称之为事件,那事件的发生必定是存在触发条件的:因为A的产生所以需要变更B。这个概念希望各位能够切记,以免与后面才讲的命令混淆。
发布领域事件并非任何时候都是必要的,一是在发生重大变更或时才应该发布领域事件,二是在聚合在被创建时才应该发布领域事件。
如何判断是否属于重大变更,拿以下两个例子对比:
①餐馆菜单菜品变更导致食材库需要更换购买的食材;
②餐馆菜单价格涨价导致用户购买时需要多支付金额;
对于①来说菜单与食材库是两个独立的聚合,而食材库无法自动感知菜单菜品是否发生变化(你修改菜单菜品数据库数据是无法影响食材库的数据库数据),所以在不主动通知的情况下,食材库就会一直按照原本的食材单购买原材料。因此当菜单菜品发生变更,从业务上来说会出现 Menu Change 事件,而 Menu Change 事件发生过程中一定需要创建新的 Menu 对象,此外业务上影响了食材 Ingredient 聚合,所以需要发布领域事件。
对于②来说菜单价格的降涨会同步影响到订单应支付金额,不需要手动再去提醒订单“菜单涨价了,你也要跟着再计算一次价格哦”,因为对于订单金额来说只要根据用户下单的菜品查询数据库的价格后求和便可,根本不必时刻关注价格是否变动。
那价格增降什么时候需要事件发布呢?各位可以参考京东或淘宝的“降价短信提醒”功能。因为商品与短信功能时两个独立的聚合,短信模块在数据层面上无法自动感知商品价格的变动,所以就需要商品价格主动发布领域事件告诉短信模块你要发送通知短信告诉用户订阅的商品降价了。从业务上来说就出现了 Commodity Change 事件,而 Commodity Change 的发生才会触发短信的发送。
综上,我用了大量的篇幅对何时需要领域事件发布进行解释,主要希望各位不要错以为任何发生变动的情况都要进行发布,希望各位能够谅解我的啰嗦。
接下来我将会以“用户预约时间对商品进行下单支付,当预约时间到了后通知订单自动创建”为例,编写一段简陋的事件订阅/发布代码让各位能够进一步理解事件订阅/发布的过程。
具体代码
注意:领域事件遵从的是订阅/发布而不是发布/接收。曾经我在阅读理解这一块知识的时候很想当然地误解了字面含义,它们最大的区别是顺序问题,前者是先订阅再发布,而后者是先发布再接收。我并非是为了咬文嚼字,这其中细微的差距希望各位能够多读几遍并结合代码才能领悟得到。
编写事件订阅代码:
public interface DomainEventSubscriber<T> {
//如何处理事件
void handleEvent(final T aDomainEvent);
//订阅事件类型
Class<T> subscribedToEventType();
}
编写事件发布代码:
public class DomainEventPublisher {
private static final ThreadLocal<DomainEventPublisher> instance = new ThreadLocal<DomainEventPublisher>() {
@Override
protected DomainEventPublisher initialValue() {
return new DomainEventPublisher();
}
};
//做一个判断是否正在发布
private boolean publishing;
//订阅方列表
@SuppressWarnings("rawtypes")
private List subscribers;
public static DomainEventPublisher instance() {
DomainEventPublisher domainEventPublisher = instance.get();
return domainEventPublisher;
}
@SuppressWarnings("rawtypes")
private List subscribers() {
return this.subscribers;
}
//设置发布状态
private void setPublishing(boolean flag) {
this.publishing = flag;
}
@SuppressWarnings("rawtypes")
private void setSubscribers(List subscriberList) {
this.subscribers = subscriberList;
}
//查看当前是否有订阅集合
@SuppressWarnings("rawtypes")
public boolean hasSubscribers() {
return subscribers() != null;
}
//如果当前订阅集合为空则创建一个新的集合
@SuppressWarnings("rawtypes")
private void ensureSubscribersList() {
if (!this.hasSubscribers()) {
this.setSubscribers(new ArrayList());
}
}
//如果当前没有在进行发布,则进行订阅集合判断后将新的订阅者加入集合列表
@SuppressWarnings("unchecked")
public <T> void subscribe(DomainEventSubscriber<T> aSubscriber) {
if (!this.publishing) {
this.ensureSubscribersList();
this.subscribers().add(aSubscriber);
}
}
//此处的<T> 表示传入参数有泛型,<T>存在的作用,是为了保证参数中能够出现T这种数据类型
public <T> void publish(T useDomainEvent) {
//如果没有正在发布消息同时候订阅列表不为空
if (!this.publishing && hasSubscribers()) {
try {
this.setPublishing(true);
//获取当前正在发布消息的领域事件类名
Class<?> publishClass = useDomainEvent.getClass();
//获取当前所有订阅者
List<DomainEventSubscriber<T>> allSubscribers = this.subscribers();
//遍历所有订阅者列表
for (DomainEventSubscriber<T> subscriber : allSubscribers) {
//返回对应的领域事件类
Class<T> subscribedToType = subscriber.subscribedToEventType();
//如果发布的领域事件类型与订阅列表的类型匹配上,则将事件交给对应的处理器进行处理
if (subscribedToType.toString().equals(publishClass.toString()) ) {
subscriber.handleEvent(useDomainEvent);
}
}
}finally {
//处理完后告知发布消息事件已经结束
this.setPublishing(false);
}
}
}
}
领域事件一般包括元数据,例如事件ID和时间戳,为了便于拓展先简单订阅领域事件的接口:
//定义领域事件接口
public interface DomainEvent {
String id();
Date occurredOn();
default Date getCreatEventTime(){
return occurredOn();
}
default String type(){
return getClass().getSimpleName();
}
default String getType(){
return type();
}
}
根据聚合编写与聚合相关的事件:
@Data
public abstract class TicketDomainEvent implements DomainEvent{
//补充到聚合根信息中
private String orderId;
private Date occurredOn;
@Override
public String id() {
return this.orderId;
}
@Override
public Date occurredOn() {
return this.occurredOn;
}
}
根据具体业务的需求和上下文环境定制特定的事件:
//在订单创建时可以根据现实需求补充上下文信息,如果没有为空就可以
@Data
public class CustomerTicketCreateEvent extends TicketDomainEvent {
//基础订单信息
private Ticket ticket;
//面向客户的订单信息,额外添加收获地址
private String address;
public CustomerTicketCreateEvent(Ticket ticket, String address){
this.address = address;
this.ticket = ticket;
}
}
充血模式下的对象实体类:
@Data
public class Ticket {
//实际应支付价格
private BigDecimal realAmount;
//优惠
private BigDecimal discounts;
private Set<Order> orders;
//订单创建时间
private LocalDateTime orderCreateTime;
//修改订单
private LocalDateTime orderChangeTime;
//订单完成时间
private LocalDateTime finishCreateTime;
//未优惠情况下应付应付总额
public BigDecimal getAllAmount(){
return orders.stream().map(Order::getPrice).reduce(BigDecimal.ZERO,BigDecimal::add);
}
//价格优惠后实际应付总额
public BigDecimal getRealAmount(){
BigDecimal allAmount = getAllAmount();
return allAmount.subtract(discounts);
}
}
我们在这里做一个业务假设,例如预约购买,一旦某件商品上线,就通知我们自动创建订单进行支付,模拟Service的伪代码:
//模拟MVC三层模型中的Service
public class BuyService {
//预定服务,定时任务到了,发布创建订单的事件
public void reservation(Ticket ticket){
Ticket ticket = new Ticket();
//优惠价格
ticket.setDiscounts(new BigDecimal(20));
//收获地址
String address = "M78星云光之国成化大道消防队对面";
DomainEventPublisher.instance().publish(new CustomerTicketCreateEvent(ticket,address));
}
}
订单自动创建模拟Service的伪代码:
public class TicketService {
public void saveTicket(){
DomainEventPublisher.instance().subscribe(new DomainEventSubscriber<CustomerTicketCreateEvent>() {
@Override
public void handleEvent(CustomerTicketCreateEvent domainEvent) {
System.out.println("收货地址是:"+ domainEvent.getAddress());
System.out.println("goods was purchased");
//将顶到保存到数据库,可以增加与db数据库的增删查改操作
System.out.println("db has save!!");
}
@Override
public Class<CustomerTicketCreateEvent> subscribedToEventType() {
return CustomerTicketCreateEvent.class;
}
});
}
}
可以看到上面的TicketService类中并未对数据进行更多的逻辑处理,只负责业务逻辑与存储之间的流程编排,充血模式使得各个层级负责的业务分明。(至于BuyService因为要模拟发布方,所以就没有遵从)。
最后还记得我上面说的,先订阅再发布吗?以下就很明显地体现出来:
@SpringBootTest
class DrivenApplicationTests {
@Test
void contextLoads() {
//先注册订阅列表,再进行发布。顺序颠倒控制台显示为空
TicketService ticketService = new TicketService();
ticketService.saveTicket();
BuyService buyService = new BuyService();
buyService.reservation();
}
}
GitHub源码:https://github.com/1148973713/Domain-Driven
结语
本博主编程水平一般,不能在闲余时间编写特别复杂的业务流程向各位一一详解领域事件发布/订阅的流程,我希望通过简单明了的案例让各位不陷入代码理解困难的情况下窥得进入领域驱动设计的门槛,如果有什么意见或建议希望各位能够在评论区指出。后续应该会更新Saga事务一致性相关的内容,希望各位持续关注并耐心等待。