领域驱动模型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事务一致性相关的内容,希望各位持续关注并耐心等待。
领域驱动模型DDD(二)——领域事件的订阅/发布实践的更多相关文章
- 领域驱动模型DDD(一)——服务拆分策略
前言 领域驱动模型设计在业界也喊了几年口号了,但是对于很多"务实"的程序员来说,纸上谈"术"远比敲代码难得太多太多.本人能力有限,在拜读相关作品时既要隐忍书中晦 ...
- 领域驱动模型DDD(三)——使用Saga管理事务
前言 虽然一直说想写一篇关于Saga模式,在多次尝试后不得不承认这玩意儿的仿制代码真不是我一个菜鸟就能完成的,所以还是妥协般地引用现成的Eventuate Tram Saga框架(虽然我对它一直很反感 ...
- 【tornado】系列项目(二)基于领域驱动模型的区域后台管理+前端easyui实现
本项目是一个系列项目,最终的目的是开发出一个类似京东商城的网站.本文主要介绍后台管理中的区域管理,以及前端基于easyui插件的使用.本次增删改查因数据量少,因此采用模态对话框方式进行,关于数据量大采 ...
- 基于领域驱动设计(DDD)超轻量级快速开发架构(二)动态linq查询的实现方式
-之动态查询,查询逻辑封装复用 基于领域驱动设计(DDD)超轻量级快速开发架构详细介绍请看 https://www.cnblogs.com/neozhu/p/13174234.html 需求 配合Ea ...
- Java开发架构篇《初识领域驱动设计DDD落地》
作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 DDD(Domain-Driven Design 领域驱动设计)是由Eric Eva ...
- 领域驱动设计(DDD)
领域驱动设计(DDD)实现之路 2004年,当Eric Evans的那本<领域驱动设计——软件核心复杂性应对之道>(后文简称<领域驱动设计>)出版时,我还在念高中,接触到领域驱 ...
- python 全栈开发,Day116(可迭代对象,type创建动态类,偏函数,面向对象的封装,获取外键数据,组合搜索,领域驱动设计(DDD))
昨日内容回顾 1. 三个类 ChangeList,封装列表页面需要的所有数据. StarkConfig,生成URL和视图对应关系 + 默认配置 AdminSite,用于保存 数据库类 和 处理该类的对 ...
- 【tornado】系列项目(一)之基于领域驱动模型架构设计的京东用户管理后台
本博文将一步步揭秘京东等大型网站的领域驱动模型,致力于让读者完全掌握这种网络架构中的“高富帅”. 一.预备知识: 1.接口: python中并没有类似java等其它语言中的接口类型,但是python中 ...
- 领域驱动设计(DDD:Domain-Driven Design)
领域驱动设计(DDD:Domain-Driven Design) Eric Evans的"Domain-Driven Design领域驱动设计"简称DDD,Evans DDD是一套 ...
随机推荐
- quartz框架(十)-QuartzShedulerThread
QuartzSchedulerThread 本篇博文,博主将介绍QuartzSchedulerThread的相关内容.话不多说,直接进入正题. 什么是QuartzSchedulerThread? 从源 ...
- hive 操作
show databases ;use default;show tables ;create table student(id int, name string) ROW FORMAT DELIMI ...
- 4月28日 python学习总结 线程与协程
一. 异步与回调机制 问题: 1.任务的返回值不能得到及时的处理,必须等到所有任务都运行完毕才能统一进行处理 2.解析的过程是串行执行的,如果解析一次需要花费2s,解析9次则需要花费18s 解决一: ...
- python练习册 每天一个小程序 第0009题
1 ''' 2 题目描述: 3 找出一个html文件中所有的url 4 5 思路 : 6 利用正则表达式进行匹配 7 8 ''' 9 10 11 import re 12 13 14 with ope ...
- 统计分析— 1.SPSS数据编辑窗口 输出窗口 语法窗口
第一课-SPSS窗口 一 数据编辑窗口(Data Editor) 二 输出窗口(Output Viewer ) 三 语法窗口(Syntax Editor):针对中高级用户,有些操作可以通过输入代码的方 ...
- Flutter入门教程(二)开发环境搭建
学习Flutter,首先需要搭建好Flutter的开发环境,下面我将一步步带领大家搭建开发环境并且成功运行flutter项目. Flutter环境配置主要有这几点: 系统配置要求 Java环境 Flu ...
- 什么是 Callable 和 Future?
Callable 接口类似于 Runnable,从名字就可以看出来了,但是 Runnable 不会返 回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执 行后,可以返回 ...
- Redis 常见性能问题和解决方案?
1.Master 最好不要写内存快照,如果 Master 写内存快照,save 命令调度 rdbSave 函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性 暂停服务 2.如果数据 ...
- volatile 类型变量提供什么保证?
volatile 变量提供顺序和可见性保证,例如,JVM 或者 JIT 为了获得更好的性能 会对语句重排序,但是 volatile 类型变量即使在没有同步块的情况下赋值也不会 与其他语句重排序. vo ...
- 一个注解@Recover搞定丑陋的循环重试代码
使用背景 在实际项目中其中一部分逻辑可能会因为调用了外部服务或者等待锁等情况下出现不可预料的异常,在这个时候我们可能需要对调用这部分逻辑进行重试,代码里面主要就是使用for循环写一大坨重试的逻辑,各种 ...