|
【领域驱动设计实践之路】系列往期精彩文章:《领域驱动设计(DDD)实践之路(一)》 主要讲述了战略层面的DDD原则。这是“领域驱动设计实践之路”系列的第二篇文章,分析了如何应用事件来分离软件核心复杂度。探究CQRS为什么广泛应用于DDD项目中,以及如何落地实现CQRS框架。当然我们也要警惕一些失败的教训,利弊分析以后再去抉择正确的应对之道。一、前言:从物流详情开始大家对物流跟踪都不陌生,它详细记录了在什么时间发生了什么,并且数据作为重要凭证是不可变的。我理解其背后的价值有这么几个方面:业务方可以管控每个子过程、知道目前所处的环节;另一方面,当需要追溯时候仅仅通过每一步的记录就可以回放整个历史过程。我在之前的文章中提出过“软件项目也是人类社会生产关系的范畴,只不过我们所创造的劳动成果看不见摸不着而已”。所以我们可以借鉴物流跟踪的思路来开发软件项目,把复杂过程拆解为一个个步骤、子过程、状态,这和我们事件划分是一致的,这就是事件驱动的典型案例。二、领域事件领域事件(Domain Events)是领域驱动设计(Domain Driven Design,DDD)中的一个概念,用于捕获我们所建模的领域中所发生过的事情。领域事件本身也作为通用语言(Ubiquitous Language)的一部分成为包括领域专家在内的所有项目成员的交流用语。比如在前述的跨境物流例子中,货品达到保税仓以后需要分派工作人员进行分拣分包,那么“货品已到达保税仓”便是一个领域事件。首先,从业务逻辑来说该事件关系到整个流程的成功或者失败;同时又将触发后续子流程;而对于业务方来说,该事件也是一个标志性的里程碑,代表自己的货品就快配送到自己手中。所以通常来说,一个领域事件具有以下几个特征:较高的业务价值,有助于形成完整的业务闭环,将导致进一步的业务操作。这里还要强调一点,领域事件具有明确的边界。比如:如果你建模的是餐厅的结账系统,那么此时的“客户已到达”便不是你关心的重点,因为你不可能在客户到达时就立即向对方要钱,而“客户已下单”才是对结账系统有用的事件。1、建模领域事件在建模领域事件时,我们应该根据限界上下文中的通用语言来命名事件及属性。如果事件由聚合上的命令操作产生,那么我们通常根据该操作方法的名字来命名领域事件。对于上面的例子“货品已到达保税仓”,我们将发布与之对应的领域事件GoodsArrivedBondedWarehouseEvent(当然在明确的界限上下文中也可以去掉聚合的名字,直接建模为ArrivedBondedWarehouseEvent,这都是命名方面的习惯)。事件的名字表明了聚合上的命令方法在执行成功之后所发生的事情,换句话说待定项以及不确定的状态是不能作为领域事件的。一个行之有效的方法是画出当前业务的状态流转图,包含前置操作以及引起的状态变更,这里表达的是已经变更完成的状态所以我们不用过去时态表示,比如删除或者取消,即代表已经删除或者已经取消。然后对于其中的节点进行事件建模。如下图是文件云端存储的业务,我们分别对预上传、上传完成确认、删除等环节建模“过去时”事件,PreUploadedEvent、ConfirmUploadedEvent、RemovedEvent。2、领域事件代码解读package domain.event;import java.util.Date;import java.util.UUID;/** * @Description: * @Author: zhangwenbo * @Since: 2019/3/6 */public class DomainEvent { /** * 领域事件还包含了唯一ID, * 但是该ID并不是实体(Entity)层面的ID概念, * 而是主要用于事件追溯和日志。 * 如果是数据库存储,该字段通常为唯一索引。 */ private final String id; /** * 创建时间用于追溯,另一方面不管使用了 * 哪种事件存储都有可能遇到事件延迟, * 我们通过创建时间能够确保其发生顺序。 */ private final Date occurredOn; public DomainEvent() { this.id = String.valueOf(UUID.randomUUID()); this.occurredOn = new Date(); }}(滑动可查看)在创建领域事件时,需要注意2点:领域事件本身应该是不变的(Immutable);领域事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据。例如,在创建订单时可以携带订单的基本信息,而对于用户更新订单收货地址事件AddressUpdatedEvent事件,只需要包含订单、用户以及新的地址等信息即可。publicclassAddressUpdatedEventextendsDomainEvent{ //通过userId+orderId来校验订单的合法性; private String userId; private String orderId; //新的地址 private Address address; //略去具体业务逻辑}(滑动可查看)3、领域事件的存储事件的不可变性与可追溯性都决定了其必须要持久化的原则,我们来看看常见的几种方案。3.1单独的EventStore有的业务场景中会创建一个单独的事件存储中心,可能是Mysql、Redis、Mongo、甚至文件存储等。这里以Mysql举例,business_code、event_code用来区分不同业务的不同事件,具体的命名规则可以根据实际需要。这里需要注意该数据源与业务数据源不一致的场景,我们要确保当业务数据更新以后事件能够准确无误的记录下来,实践中尽量避免使用分布式事务,或者尽量避免其跨库的场景,否则你就得想想如何补偿了。千万要避免,用户更新了收货地址,但是AddressUpdatedEvent事件保存失败。总的原则就是对分布式事务Say No,无论如何,我相信方法总比问题多,在实践中我们总可以想到解决方案,区别在于该方案是否简洁、是否做到了解耦。# 考虑是否需要分表,事件存储建议逻辑简单CREATE TABLE `event_store` ( `event_id` int(11) NOT NULL auto increment, `event_code` varchar(32) NOT NULL, `event_name` varchar(64) NOT NULL, `event_body` varchar(4096) NOT NULL, `occurred_on` datetime NOT NULL, `business_code` varchar(128) NOT NULL, UNIQUE KEY (`event id`)) ENGINE=InnoDB COMMENT '事件存储表';(滑动可查看)3.2 与业务数据一起存储在分布式架构中,每个模块都做的相对比较小,准确的说是“自治”。如果当前业务数据量较小,可以将事件与业务数据一起存储,用相关标识区分是真实的业务数据还是事件记录;或者在当前业务数据库中建立该业务自己的事件存储,但是要考虑到事件存储的量级必然大于真实的业务数据,考虑是否需要分表。这种方案的优势:数据自治;避免分布式事务;不需要额外的事件存储中心。当然其劣势就是不能复用。4、领域事件如何发布4.1 由领域聚合发送领域事件/** 一个关于比赛的充血模型例子* 贫血模型会构造一个MatchService,我们这里通过模型来触发相应的事件* 本例中略去了具体的业务细节*/public class Match { public void start() { //构造Event.... MatchEvent matchStartedEvent = new MatchStartedEvent(); //略去具体业务逻辑 DefaultDomainEventBus.publish(matchStartedEvent); } public void finish() { //构造Event.... MatchEvent matchFinishedEvent = new MatchFinishedEvent(); //略去具体业务逻辑 DefaultDomainEventBus.publish(matchFinishedEvent); } //略去Match对象基本属性}(滑动可查看)4.2 事件总线VS消息中间件微服务内的领域事件可以通过事件总线或利用应用服务实现不同聚合之间的业务协同。即微服务内发生领域事件时,由于大部分事件的集成发生在同一个线程内,不一定需要引入消息中间件。但一个事件如果同时更新多个聚合数据,按照 DDD“一个事务只更新一个聚合根”的原则,可以考虑引入消息中间件,通过异步化的方式,对微服务内不同的聚合根采用不同的事务三、Saga分布式事务1、Saga概要我们看看如何使用 Saga 模式维护数据一致性?Saga 是一种在微服务架构中维护数据一致性的机制,它可以避免分布式事务所带来的问题。一个 Saga 表示需要更新的多个服务中的一个,即Saga由一连串的本地事务组成。每一个本地事务负责更新它所在服务的私有数据库,这些操作仍旧依赖于我们所熟悉的ACID事务框架和函数库。模式:Saga通过使用异步消息来协调一系列本地事务,从而维护多个服务之间的数据一致性。请参阅(强烈建议):https://microservices.io/patterns/data/saga.htmlSaga与TCC相比少了一步Try的操作,TCC无论最终事务成功失败都需要与事务参与方交互两次。而Saga在事务成功的情况下只需要与事务参与方交互一次, 如果事务失败,需要额外进行补偿回滚。每个Saga由一系列sub-transaction Ti 组成;每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果;可以看到,和TCC相比,Saga没有“预留”动作,它的Ti就是直接提交到库。Saga的执行顺序有两种:success:T1, T2, T3, ..., Tn ;failure:T1, T2, ..., Tj, Cj,..., C2, C1,其中0 > handlerMap = new HashMap>(); private static DomainRegistry instance; private DomainRegistry() { } public static DomainRegistry getInstance() { if (instance == null) { instance = new DomainRegistry(); } return instance; } public Map> getHandlerMap() { return handlerMap; } public List find(String name) { if (name == null) { return null; } return handlerMap.get(name); } //事件注册与维护,register分多少个场景根据业务拆分, //这里是业务流的核心。如果多个事件需要维护前后依赖关系, //可以维护一个priority逻辑 public void register(Class domainEvent, DomainEventHandler handler) { if (domainEvent == null) { return; } if (handlerMap.get(domainEvent.getName()) == null) { handlerMap.put(domainEvent.getName(), new ArrayList()); } handlerMap.get(domainEvent.getName()).add(handler); //按照优先级进行事件处理器排序 。。。 }}(滑动可查看)文件上传完毕事件的例子。package domain.handler.event;import domain.DomainRegistry;import domain.StateDispatcher;import domain.entity.meta.MetaActionEnums;import domain.event.DomainEvent;import domain.event.MetaEvent;import domain.repository.meta.MetaRepository;import org.springframework.stereotype.Component;import javax.annotation.PostConstruct;import javax.annotation.Resource;/** * @Description:一个事件操作的处理器 * 我们混合使用了Saga的两种模式,外层事件交互; * 对于单个复杂的事件内部采取状态流转实现。 * @Author: zhangwenbo * @Since: 2019/3/6 */@Componentpublic class MetaConfirmUploadedHandler implements DomainEventHandler { @Resource private MetaRepository metaRepository; public void handle(DomainEvent event) { //1.我们在当前的上下文中定义个ThreadLocal变量 //用于存放事件影响的聚合根信息(线程共享) //2.当然如果有需要额外的信息,可以基于event所 //携带的信息构造Specification从repository获取 // 代码示例 // metaRepository.queryBySpecification(SpecificationFactory.build(event)); DomainEvent domainEvent = metaRepository.load(); //此处是我们的逻辑 。。。。 //对于单个操作比较复杂的,可以使用状态流转进一步拆分 domainEvent.setStatus(nextState); //在事件触发之后,仍需要一个状态跟踪器来解决大事务问题 //Saga编排式 StateDispatcher.dispatch(); } @PostConstruct public void autoRegister() { //此处可以更加细分,注册在哪一类场景中,这也是事件驱动的强大、灵活之处。 //避免了if...else判断。我们可以有这样的意识,一旦你的逻辑里面充斥了大量 //switch、if的时候来看看自己注册的场景是否可以继续细分 DomainRegistry.getInstance().register(MetaEvent.class, this); } public String getAction() { return MetaActionEnums.CONFIRM_UPLOADED.name(); } //适用于前后依赖的事件,通过优先级指定执行顺序 public Integer getPriority() { return PriorityEnums.FIRST.getValue(); }}(滑动可查看)事件总线逻辑package domain;import domain.event.DomainEvent;import domain.handler.event.DomainEventHandler;import java.util.List;/** * @Description: * @Author: zhangwenbo * @Since: 2019/3/6 */public class DefaultDomainEventBus { public static void publish(DomainEvent event, String action, EventCallback callback) { List handlers = DomainRegistry.getInstance(). find(event.getClass().getName()); handlers.stream().forEach(handler -> { if (action != null & action.equals(handler.getAction())) { Exception e = null; boolean result = true; try { handler.handle(event); } catch (Exception ex) { e = ex; result = false; //自定义异常处理 。。。 } finally { //write into event store saveEvent(event); } //根据实际业务处理回调场景,DefaultEventCallback可以返回 if (callback != null) { callback.callback(event, action, result, e); } } }); }}(滑动可查看)五、自治服务和系统DDD中强调限界上下文的自治特性,事实上,从更小的粒度来看,对象仍然需要具备自治的这四个特性,即:最小完备、自我履行、稳定空间、独立进化。其中自我履行是重点,因为不强依赖外部所以稳定、因为稳定才可能独立进化。这就是六边形架构在DDD中较为普遍的原因。(图片来源于网络)六、结语本文所讲述的事件、Saga、CQRS的方案均可以单独使用,可以应用到你的某个method、或者你的整个package。项目中我们并不一定要实践一整套CQRS,只要其中的某些思想解决了我们项目中的某个问题就足够了。也许你现在已经磨刀霍霍,准备在项目中实践一下这些技巧。不过我们要明白“每一个硬币都有两面性”,我们不仅看到高扩展、解耦的、易编排的优点以外,仍然要明白其所带来的问题。利弊分析以后再去决定如何实现才是正确的应对之道。这类编程模式有一定的学习曲线;基于消息传递的应用程序的复杂性;处理事件的演化有一定难度;删除数据存在一定难度;查询事件存储库非常有挑战性。不过我们还是要认识到在其适合的场景中,六边形架构以及DDD战术将加速我们的领域建模过程,也迫使我们从严格的通用语言角度来解释一个领域,而不是一个个需求。任何更强调核心域而不是技术实现的方式都可以增加业务价值,并使我们获得更大的竞争优势。附:参考文献Pattern: Saga分布式事务:Saga模式书籍:《微服务架构设计模式》
|
|