戏说领域驱动设计(廿七)——Saga设计模型
上一节我们讲解了常用的事务,也提及了Saga,这是在分布式环境下被经常使用的一种处理复杂业务和分布式事务的设计模式。本章我们的主要目标是编写一个简单版本的Saga处理器,不同于Seata框架中那种可独立部署的事务服务,我们所编写的Saga和业务集成在一起也不支持通过手画流程的方式实现,因为我们的目标是将Saga作为一种设计模式(不是框架)来使用,类似于您经常使用的“工厂”、“策略”等,重点学习它的思想,在真实项目中使用肯定是需要根据需求做二次加工的。而且,简单版本的优势就是足够简单,投入虽然不多但能从中获取的收益却很大。在代码演示后我们还会重点描述一下如何解决Saga事务的隔离性问题。
一、Saga种类说明
常用的Saga包含两类:协同式和编排式。前者把流程的走向与协调全盘由事务的参考者来完成,比如最简单的场景:下单同时对库存进行扣减,订单服务本地事务完成后就把事件消息发送给库存服务,库存服务如果本地事务处理失败则由它将回滚的消息发送给订单服务。虽然整个流程当中订单服务与库存服务并没有产生耦合,但由于没有一个总的事务协调者,一旦服务参与者多起来那业务流程的可理解性就非常差,出了问题也不好定位。第二类为编排型事务也是我们本章要主要介绍的,通过把Saga的执行顺序交由一个集中的Saga编排器,由它指挥并决策业务的流向,相对于协同式整个流程要清晰很多。除非您使用的是足够成熟的第三方的框架,要不然集中式Saga也可能会存在事务参与者不清晰的问题,比如本文我们要介绍的Saga就会有类似的问题,毕竟以个人的精力很多事情的确无法做到极致,有牺牲也很正常。基于消息式的Saga很多时候你并不知道谁订阅了消息,所有的消费情况都体现在代码中,非常不方便后续的业务扩展以及代码阅读。个人在使用的时候偷了一个懒:通过图形文档的方式说明整个事务的走向包括会由谁发送命令,由谁来订阅事件,也就是把流程的逻辑定义与代码进行了分离,好的方式当然是把这些信息作为元数据来用,谁都喜欢好的但付出的成本也很高的。况且我是在2015年开始使用Saga,我倒是想用Seata呢,没有啊。
二、基于编排式的设计思想
基于编排的模式设计思路我们在前文中已经大概介绍,那要如何实现呢?可参考如下图所示。这里使用了四个设计原则:1)方便起见Saga会和全局事务发起的一起部署,这样就不用花费精力考虑如何独立部署Saga服务;2)Saga所有的服务遵循了这样的一个模式:发送命令,接受事件。也就是说Saga只向外发布领域命令,事件参与者执行完本地事务后发送领域事件并由Saga进行订阅,各服务间使用消息队列进行解耦;3)Saga的实现也被分为应用服务和领域实体。所有的命令其实都是由Saga领域实体在处理事件后生成的,在应用服务中调用“RabbitMQ”的客户端进行发送;4)只存储命令消息,不存储事件。
三、代码实现
根据上述的模式说明您会发现发布命令与事件并不需要进行代码的说明,通过RabbitMQ的客户端即可搞定。由于我们并不会将Saga实现为一个框架,所以也不涉及到框架内部复杂的逻辑代码,那么唯一需要介绍的是Saga如何与业务集成,这也是为什么我为本章所起的标题是“Saga设计模式”。
1、命令分发与处理
我们把发送命令和事件的组件称之为“命令总线”或“事件总线”,这样的封装在使用的时候不用使用者(一般是工程师)考虑消息队列使用的各种细节也可以实现组件的复用,毕竟您使用Saga的场景可能不只一个。
熟悉RabbitMQ的朋友都知道想要让消费者正确的收到消息我们可以使用“Topic”模式,相当于给消息一个路由的策略,Exchange会根据Topic的名称把消息投递到某个队列中,而消费者通过这与个队列进行绑定就可以进行消费了。针对命令总线我们仍然这用这个模式,我们把Topic命名为“command.bus”。由于命令的特性是只有一个消费者,所以不论消费者启动了多少个实例,反正只要有一个能消费就行。这样做以后……这样做就什么都做不了了,这个思路是错误的。以上图为例,因为所有命令型的消息的Topic都是“command.bus”,很有可能这些命令全被“事务参与者2”消费了,他处理“命令1”自然没问题,“命令2”他可真搞不定了。所以,很可惜,“Topic”模式根本不适用。
让我们再翻翻其它的模式,“Direct”貌似也差点意思,搞不定!那只能使用“Fanout”做点文章了。所以正确的方式应该是使用这个模式,消息被发出去后让所有的事务参与者都去订阅,然后在每个事务参考者内部维护一个类似路由表的东西,可以完成“通过某个命令的名称便可知道应该哪段代码来处理这个命令”的需求,正好是一个键值对:键是命令的名称,值是命令处理器。当根据键找不到对应的处理器时则把消息直接扔掉,因为事件参与者使用的是广播模式,但是其只关心自己能够处理的命令。至此我们已经大致把方案确认了,那就让我们一点点搞起来,music……
首先我们先说命令对象的构成。由于有“命令路由表”的存在且这个表是一个键值对的形式,其中键是命令的名称,程序会根据这个名称去寻找对应的命令处理器。所以我们就得给每个命令一个独一无二的名称(如果全称重复,那就出现Bug了),在我给出的实现中使用的是这个命令的类全名。此外,命令的路由过程我们直接写在消息的消费者里,这样我们在获得命令名称后就可以第一时间找到对应的处理器了,参考代码如下所示。之所以引入了一个“Message”的类是因为对事件的处理和命令是类似的,所以它实际上是事件与命令共同的父类。Command中的方法“from”用于将消息反序列为命令对象,其返回的结果是真实的命令对象而不是“Command”这个父类。友情提示一下:清注意“Message”是从什么对象继承的。
public abstract class Message extends EntityModel {
private String name; public Message() {
this.name = this.getClass().getName();
}
} public class Command extends Message {
public static Command from(String json) {
JsonNode jsonNode = objectMapper.readValue(json, JsonNode.class);
String className = jsonNode.get("name").asText();
Command command = (Command)objectMapper.readValue(json, Class.forName(className));
}
}
我们再展示一下消息监听的代码,如下所示。其实很简单就两行:反序列化命令同时使用“CommandDispatcher”进行命令的分发处理,分发过程其实就是根据命令的名称找到对应的命令处理器。这个类就是我们前方中提及的“命令路由器”
@RabbitListener(queues = “command.bus”)
public void listenCommand(String message, Message message1, Channel channel) {
Command command = Command.from(message);
CommandDispatcher.INSTANCE.dispatch(command);
}
按一般的习惯我们应该开始介绍“CommandDispatcher”,但我们一般都喜欢不走寻常路,所以请移动您的尊驾我们先看看命令处理器要如何搞,代码如下所示 。“CommandHandlerUnite”是一个抽象类,所有的命令处理器都需要从它继承。“process”方法是通过反射的形式调用到实际接收这个命令的方法,这句好是不是很不好理解?那我们细说一下:1)通过前面的代码我们知道命令的消息虽然可以被成功的反序列化成真实的命令对象,但声明它的类型仍然是抽象类型“Command”;2)一个应用服务中可能会有多个命令的处理方法,比如下文中的“AccountService ”所示。那么我要如何根据一个声明为抽象类型的对象调用到能够以这个对象的实际类型为参数的方法呢?答案就是反射。
public abstract class CommandHandlerUnite { private static final String COMMAND_HANDLER_METHOD_NAME = "process"; @Override
public void process(Command command) {
if (command == null) {
return;
}
Class clazz = Command.from(command.getName());
Method handler = this.getClass().getMethod(COMMAND_HANDLER_METHOD_NAME, new Class[]{clazz});
handler.invoke(this, command);
}
} @Service
public class AccountService extends CommandHandlerUnite {
public void handle(IncreaseRewardPoints command) { } public void handle(DecreaseRewardPoints command) { }
}
到这里相信聪明的您应该还可以跟得上节奏,那咱们再把前面的坑埋上,也就是“CommandDispatcher”,这是一个命令路由表对象,代码好下所示。“dispatch”用于分发命令,调用的是“CommandHandlerUnite”中声明的方法“process”;“register”用于注册命令处理器,也就是把命令处理器放到HashMap中。
public class CommandDispatcher { //命令处理器对象列表
private Map<String, CommandHandlerUnite> commandHandlers = new HashMap<>(); /**
* 命令分发器实例
*/
public final static CommandDispatcher INSTANCE = new CommandDispatcher(); /**
* 分发消息方法
*/
public void dispatch(Command command) {
CommandHandler commandHandler = this.commandHandlers.get(command.getName());
if (commandHandler != null) {
commandHandler.process(command);
}
} /**
* 注册处理分发器
*
*/
public void register(String commandName, CommandHandlerUnite commandHandler) {
this.commandHandlers.put(commandName, commandHandler);
}
}
到此,命令相关的处理已经讲完了,虽然没有说到Saga但离我们的目标已经不远了。下面我们再讲一下事件的处理。
2、事件分发与处理
事件的分发与处理其实和命令的处理是一样的,唯一的区别是一个事件可以被多个消费者同时消费 。如果事件的消费者分布在多个服务中,在使用了“Fanout”模式后消息可以被同时分发至对应的服务;如果是分布在同一个服务中则仍然使用类似上面的路由表的形式,只是我们给它一个新的名称“事件路由器”,代码和命令路由器略有不同,所下所示。
public class EventDispatcher { //事件处理器对象列表
private Map<String, List<EventHandlerUnite>> eventHandlers = new HashMap<>(); /**
* 分发消息方法
*/
public void dispatch(Event event) {
List<EventHandler> eventHandlers = this.eventHandlers.get(event.getName());
for (EventHandler eventHandler : eventHandlers) {
if (eventHandler != null) {
eventHandler.process(event);
}
}
} /**
* 注册处理分发器
*/
public void register(String eventName, EventHandlerUnite eventHandler) {
List<EventHandlerUnite> eventHandlers = this.eventHandlers.get(eventName);
if (eventHandlers == null) {
eventHandlers = new ArrayList<>();
}
eventHandlers.add(eventHandler);
this.eventHandlers.put(eventName, eventHandlers);
}
}
3、Saga
我们的主角开始上场,不过讲Saga还得有案例才行,所以还得使用我们前面介绍过的那个一句话业务“订单支付后用户积分加10”。其实屏幕前的朋友不要觉得案例简单,我们学习的是模式不是表面的代码。书归正文,Saga要分成两个层次:应用服务和领域模型。在应用服务中我们注册自己为事件处理器并编写用于处理事件的方法,代码如下所示。再强调一下它的工作模式:发送命令——接收事件。
@Service
public class OrderProcessSagaService extends EventHandlerUnite { OrderProcessSaga {
EventDispatcher.INSTANCE.register(OrderPaid.getClass().getName(), this);
EventDispatcher.INSTANCE.register(RewardPointsIncreased.getClass().getName(), this);
} public void handle(OrderPaid event) {
OrderProcessSaga saga = this.orderProcessSagaRepository.findBy(event.getOrderId());
if (saga == null) {
saga = new OrderProcessSaga();
}
List<Command> commands = saga.handle(event);
//命令和Saga对象一起进行保存
this.orderProcessSagaRepository.updateOrSave(saga);
this.commandBus.post(commands);
} public void handle(RewardPointsIncreased event) {
OrderProcessSaga saga = this.orderProcessSagaRepository.findBy(event.getOrderId());
sava.handle(event);
//命令和Saga对象一起进行保存
this.orderProcessSagaRepository.update(saga);
this.commandBus.post(commands);
}
}
上面代码中的“OrderProcessSaga”其实就是Saga的领域模型,所有的事件处理都在它里面进行处理,贴一段代码供参考。两个“handle”方法分别应对两个不同的事件,在这些事件中您可以按自己的需求决策业务的走向也可以进行业务的补偿。本案例只演示了正向的业务流程,其实反向(业务补偿)的也是一样的,只不过是处理不同的事件。注意一点:业务补偿的代码要写在“OrderProcessSagaService ”中,不可以写在“OrderProcessSaga ”里面,因为它只负责处理Saga的业务(只做业务的调度)不负责处理业务。什么?怎么决策业务的走向?命令啊,您通过发送不同的命令不就实现了业务流程的控制了吗?
public class OrderProcessSaga extends EntityModel {
private String orderId;
private String accountId;
private Status status;
private List<Command> commands = new ArrayList<>(); //处理订单支付事件
public List<Command> handle(OrderPaid event) {
if (this.status == Status.FINISHED || this.status == Status.CLOSED) {
throw new OrderProcessException();
}
this.status = Status.STARTING_REWARD_POINTS_PROCESS;
//发送账户增加10积分的命令
this.commands.add(new IncreaseRewardPoints(10L, this.accountId, this. orderId)); return this.commands;
} //处理积分增加事件
public List<Command> handle(RewardPointsIncreased event) {
if (this.status != Status.STARTING_REWARD_POINTS_PROCESS) {
throw new OrderProcessException();
}
this.status = Status.FINISHED;
return this.commands;
}
}
您知道为什么我每次都会把Saga对象存储起来吗?一是为了流程更好的观察,根据Saga的状态便可知晓每个流程的当前状态,加个页面当然也可以啦;二是流程伴随着命令信息在一个事务内共同存储不仅可以保障消息不丢失,当遇到命令无法被正确发送或发送后消息丢失、消息没有被路由到消费者的情况,我们只需要把命令查询出来再发送一下就解决了,随随便便就实现了断点续传。
总结
本节主要讲了Saga模式的实现方式及相关的代码。本来还想讲一下如何处理Saga的隔离性问题,奈何最近精力有限,我们先发出一部分,后面我再把坑填上。对了,针对上面说过的命令或事件的名称 我使用了类全名其实并不是很好,在分布架构下反而容易泄露内部信息。您其实也可以使用“服务名+命令类名”的方式,反正只要保障名称不重复就行。此外,案例代码仅用于演示,真实环境中还需要做很多的验证处理,请务必注意。
戏说领域驱动设计(廿七)——Saga设计模型的更多相关文章
- DDD领域驱动设计-案例建模设计-Ⅲ
1. 背景 参考<DDD领域驱动设计-案例需求文档>,本文将构建实体,聚合根详述领域驱动中的建模设计.构建实体,聚合根的一些原则或方法,将在后续文章中说明. 2. 建模设计 2.1. 实体 ...
- 领域驱动和MVVM应用于UWP开发的一些思考
领域驱动和MVVM应用于UWP开发的一些思考 0x00 起因 有段时间没写博客了,其实最近本来是根据梳理的MSDN上的资料(UWP开发目录整理)有条不紊的进行UWP学习的.学习中有了心得体会或遇到了问 ...
- 【tornado】系列项目(一)之基于领域驱动模型架构设计的京东用户管理后台
本博文将一步步揭秘京东等大型网站的领域驱动模型,致力于让读者完全掌握这种网络架构中的“高富帅”. 一.预备知识: 1.接口: python中并没有类似java等其它语言中的接口类型,但是python中 ...
- IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)
上一篇:<IDDD 实现领域驱动设计-SOA.REST 和六边形架构> 阅读目录: CQRS-命令查询职责分离 EDA-事件驱动架构 Domin Event-领域事件 Long-Runni ...
- 浅谈我对DDD领域驱动设计的理解
从遇到问题开始 当人们要做一个软件系统时,一般总是因为遇到了什么问题,然后希望通过一个软件系统来解决. 比如,我是一家企业,然后我觉得我现在线下销售自己的产品还不够,我希望能够在线上也能销售自己的产品 ...
- DDD 领域驱动设计-商品建模之路
最近在做电商业务中,有关商品业务改版的一些东西,后端的架构设计采用现在很流行的微服务,有关微服务的简单概念: 微服务是一种架构风格,一个大型复杂软件应用由一个或多个微服务组成.系统中的各个微服务可被独 ...
- DDD领域驱动设计 - 设计文档模板
设计文档模板: 系统背景和定位 业务需求描述 系统用例图 关键业务流程图 领域语言整理,主要是整理领域中的各种术语的定义,名词解释 领域划分(分析出子域.核心域.支撑域) 每个子域的领域模型设计(实体 ...
- 初探领域驱动设计(2)Repository在DDD中的应用
概述 上一篇我们算是粗略的介绍了一下DDD,我们提到了实体.值类型和领域服务,也稍微讲到了DDD中的分层结构.但这只能算是一个很简单的介绍,并且我们在上篇的末尾还留下了一些问题,其中大家讨论比较多的, ...
- [.NET领域驱动设计实战系列]专题二:结合领域驱动设计的面向服务架构来搭建网上书店
一.前言 在前面专题一中,我已经介绍了我写这系列文章的初衷了.由于dax.net中的DDD框架和Byteart Retail案例并没有对其形成过程做一步步分析,而是把整个DDD的实现案例展现给我们,这 ...
随机推荐
- ES6-11学习笔记--对象的扩展
属性简洁表示法 属性名表达式 Objec.is() 扩展运算符 与 Object.assign() in 对象的遍历方式 属性简洁表示法: 如果属性key跟变量名一样,可简写 let name = ...
- python pymysql连接数据库并创建表
之前看菜鸟教程 #!/usr/bin/python3 import pymysql # 打开数据库连接 db = pymysql.connect("localhost"," ...
- mysql8.0.13本地安装忘记密码解决办法
之前一直用图形化界面,加上考研期间也没动,竟然把我的数据库密码给忘了,无地自容....... 找了找教程,问题如下: MySQL从低版本向高版本迭代变化的过程,越来越严谨的安全性是其一大特点之一,在版 ...
- ServletContext介绍和用法总结
ServletContext介绍和用法总结 学习总结 一.ServletContext 介绍 1. 概念 2. 作用 3. 获取 3.1 在实现类中获取 3.2 在 Spring 容器中获取 二.Se ...
- LeetCode刷题知识点总结——二叉树
二叉树 一.二叉树理论基础 1.满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树.通俗话理解:从底层开始到顶部的所有节点都全部填满的二叉树.深 ...
- 02 | 自己动手,实现C++的智能指针
第一步:针对单独类型的模板 为了完成智能指针首先第一步的想法. class shape_wrapper { public: explicit shape_wrapper( shape* ptr = n ...
- 修复tunl0-二进制安装calico
这篇博文很重要,出现这个问题导致pod之间无法通讯,pod无法连接外网. 出现的问题是二进制方式安装了节点之后, tunl0没有显示,通过ifconfig tunl0 up 启动tunl0 没有意义, ...
- 某空间下的令牌访问产生过程--Kubernetes Dashboard(k8s-Dashboard)
在面试中发现,有些运维人员基本的令牌访问方式都不知道,下面介绍下令牌的产生过程 某个空间下的令牌访问产生过程(空间名称为cc) ###创建命名空间[root@vms61 ccadmin]# kubec ...
- JavaScript学习高级1
Doucment(Dom)文档对象,用户控制html文档中的元素, <span id="span" onclick="fun();">1111& ...
- GET sql注入
靶机地址:192.168.43.156 攻击机地址:192.168.43.89 一.AppScan检查靶机sql漏洞 二.使用sqlmap利用SQL注入漏洞 1.sqlmap -u " ht ...