在微服务中使用领域事件

 
稍微回想一下计算机硬件的工作原理我们便不难发现,整个计算机的工作过程其实就是一个对事件的处理过程。当你点击鼠标、敲击键盘或者插上U盘时,计算机便以中断的形式处理各种外部事件。在软件开发领域,事件驱动架构(Event Driven Architecture,EDA)早已被开发者用于各种实践,典型的应用场景比如浏览器对用户输入的处理、消息机制以及SOA。最近几年重新进入开发者视野的响应式编程(Reactive Programming)更是将事件作为该编程模型中的一等公民。可见,“事件”这个概念一直在计算机科学领域中扮演着重要的角色。 
 
 
认识领域事件 
领域事件(Domain Events)是领域驱动设计(Domain Driven Design,DDD)中的一个概念,用于捕获我们所建模的领域中所发生过的事情。领域事件本身也作为通用语言(Ubiquitous Language)的一部分成为包括领域专家在内的所有项目成员的交流用语。比如,在用户注册过程中,我们可能会说“当用户注册成功之后,发送一封欢迎邮件给客户。”,此时的“用户已经注册”便是一个领域事件。 
 
当然,并不是所有发生过的事情都可以成为领域事件。一个领域事件必须对业务有价值,有助于形成完整的业务闭环,也即一个领域事件将导致进一步的业务操作。举个咖啡厅建模的例子,当客户来到前台时将产生“客户已到达”的事件,如果你关注的是客户接待,比如需要为客户预留位置等,那么此时的“客户已到达”便是一个典型的领域事件,因为它将用于触发下一步——“预留位置”操作;但是如果你建模的是咖啡结账系统,那么此时的“客户已到达”便没有多大存在的必要——你不可能在用户到达时就立即向客户要钱对吧,而”客户已下单“才是对结账系统有用的事件。 
 
微服务(Microservices)架构实践中,人们大量地借用了DDD中的概念和技术,比如一个微服务应该对应DDD中的一个限界上下文(Bounded Context);在微服务设计中应该首先识别出DDD中的聚合根(Aggregate Root);还有在微服务之间集成时采用DDD中的防腐层(Anti-Corruption Layer, ACL);我们甚至可以说DDD和微服务有着天生的默契。更多有关DDD的内容,请参考笔者的另一篇文章或参考《领域驱动设计》《实现领域驱动设计》。 
 
在DDD中有一条原则:一个业务用例对应一个事务,一个事务对应一个聚合根,也即在一次事务中,只能对一个聚合根进行操作。但是在实际应用中,我们经常发现一个用例需要修改多个聚合根的情况,并且不同的聚合根还处于不同的限界上下文中。比如,当你在电商网站上买了东西之后,你的积分会相应增加。这里的购买行为可能被建模为一个订单(Order)对象,而积分可以建模成账户(Account)对象的某个属性,订单和账户均为聚合根,并且分别属于订单系统和账户系统。显然,我们需要在订单和积分之间维护数据一致性,然而在同一个事务中同时更新两者又违背了DDD设计原则,并且此时需要在两个不同的系统之间采用重量级的分布式事务(Distributed Transactioin,也叫XA事务或者全局事务)。另外,这种方式还在订单系统和账户系统之间产生了强耦合。通过引入领域事件,我们可以很好地解决上述问题。 
 
总的来说,领域事件给我们带来以下好处: 
  1. 解耦微服务(限界上下文)
  1. 帮助我们深入理解领域模型
  1. 提供审计和报告的数据来源
  1. 迈向事件(Event Sourcing)和CQRS
 
还是以上面的电商网站为例,当用户下单之后,订单系统将发出一个“用户已下单”的领域事件,并发布到消息系统中,此时下单便完成了。账户系统订阅了消息系统中的“用户已下单”事件,当事件到达时进行处理,提取事件中的订单信息,再调用自身的积分引擎(也有可能是另一个微服务)计算积分,最后更新用户积分。可以看到,此时的订单系统在发送了事件之后,整个用例操作便结束了,根本不用关心是谁收到了事件或者对事件做了什么处理。事件的消费方可以是账户系统,也可以是任何一个对事件感兴趣的第三方,比如物流系统。由此,各个微服务之间的耦合关系便解开了。值得注意的一点是,此时各个微服务之间不再是强一致性,而是基于事件的最终一致性。 
 
 
 
事件风暴(Event Storming) 
事件风暴是一项团队活动,旨在通过领域事件识别出聚合根,进而划分微服务的限界上下文。在活动中,团队先通过头脑风暴的形式罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对于每一个事件,标注出导致该事件的命令(Command),再然后为每个事件标注出命令发起方的角色,命令可以是用户发起,也可以是第三方系统调用或者是定时器触发等。最后对事件进行分类整理出聚合根以及限界上下文。事件风暴还有一个额外的好处是可以加深参与人员对领域的认识。需要注意的是,在事件风暴活动中,领域专家是必须在场的。更多有关事件风暴的内容,请参考这里。 
 
 
 
 
 
创建领域事件 
领域事件应该回答“什么人什么时候做了什么事情”这样的问题,在实际编码中,可以考虑采用层超类型(Layer Supertype)来包含事件的某些共有属性: 

public abstract class Event {
private final UUID id;
private final DateTime createdTime; public Event() {
this.id = UUID.randomUUID();
this.createdTime = new DateTime();
}
}
可以看到,领域事件还包含了ID,但是该ID并不是实体(Entity)层面的ID概念,而是主要用于事件追溯和日志。另外,由于领域事件描述的是过去发生的事情,我们应该将领域事件建模成不可变的(Immutable)。从DDD概念上讲,领域事件更像一种特殊的值对象(Value Object)。对于上文中提到的咖啡厅例子,创建“客户已到达”事件如下: 
 
public final class CustomerArrivedEvent extends Event {
private final int customerNumber; public CustomerArrivedEvent(int customerNumber) {
super();
this.customerNumber = customerNumber;
}
}
 
在这个CustomerArrivedEvent事件中,除了继承自Event的属性外,还自定义了一个与该事件密切关联的业务属性——客户人数(customerNumber)——这样后续操作便可预留相应数目的座位了。另外,我们将所有属性以及CustomerArrivedEvent本身都声明成了final,并且不向外暴露任何可能修改这些属性的方法,这样便保证了事件的不变性。
 
 
发布领域事件 
在使用领域事件时,我们通常采用“发布-订阅”的方式来集成不同的模块或系统。在单个微服务内部,我们可以使用领域事件来集成不同的功能组件,比如在上文中提到的“用户注册之后向用户发送欢迎邮件”的例子中,注册组件发出一个事件,邮件发送组件接收到该事件后向用户发送邮件。 
 
 
 
在微服务内部使用领域事件时,我们不一定非得引入消息中间件(比如ActiveMQ等)。还是以上面的“注册后发送欢迎邮件”为例,注册行为和发送邮件行为虽然通过领域事件集成,但是他们依然发生在同一个线程中,并且是同步的。另外需要注意的是,在限界上下文之内使用领域事件时,我们依然需要遵循“一个事务只更新一个聚合根”的原则,违反之往往意味着我们对聚合根的拆分是错的。即便确实存在这样的情况,也应该通过异步的方式(此时需要引入消息中间件)对不同的聚合根采用不同的事务,此时可以考虑使用后台任务。
 
除了用于微服务的内部,领域事件更多的是被用于集成不同的微服务,如上文中的“电商订单”例子。 
 
 
 
通常,领域事件产生于领域对象中,或者更准确的说是产生于聚合根中。在具体编码实现时,有多种方式可用于发布领域事件。 
 
一种直接的方式是在聚合根中直接调用发布事件的Service对象。以上文中的“电商订单”为例,当创建订单时,发布“订单已创建”领域事件。此时可以考虑在订单对象的构造函数中发布事件: 
 
public class Order {
public Order(EventPublisher eventPublisher) {
//create order
//…
eventPublisher.publish(new OrderPlacedEvent());
}
}
 
注:为了把焦点集中在事件发布上,我们对Order对象做了简化,Order对象本身在实际编码中不具备参考性。 
 
可以看到,为了发布OrderPlacedEvent事件,我们需要将Service对象EventPublisher传入,这显然是一种API污染,即Order作为一个领域对象只需要关注和业务相关的数据,而不是诸如EventPublisher这样的基础设施对象。 另一种方法是由NServiceBus的创始人Udi Dahan提出来的,即在领域对象中通过调用EventPublisher上的静态方法发布领域事件:
 
public class Order {
public Order() {
//create order
//...
EventPublisher.publish(new OrderPlacedEvent());
}
}
 
这种方法虽然避免了API污染,但是这里的publish()静态方法将产生副作用,对Order对象的测试带来了难处。此时,我们可以采用“在聚合根中临时保存领域事件”的方式予以改进:
 
public class Order {

    private List<Event> events;

    public Order() {
//create order
//...
events.add(new OrderPlacedEvent());
} public List<Event> getEvents() {
return events;
} public void clearEvents() {
events.clear(); }
}
 
在测试Order对象时,我们便你可以通过验证events集合保证Order对象在创建时的确发布了OrderPlacedEvent事件:
 
@Test
public void shouldPublishEventWhenCreateOrder() {
Order order = new Order();
List<Event> events = order.getEvents();
assertEquals(1, events.size());
Event event = events.get(0);
assertTrue(event instanceof OrderPlacedEvent);
}
 
在这种方式中,聚合根对领域事件的保存只能是临时的,在对该聚合根操作完成之后,我们应该将领域事件发布出去并及时清空events集合。可以考虑在持久化聚合根时进行这样的操作,在DDD中即为资源库(Repository):
 
public class OrderRepository {
private EventPublisher eventPublisher; public void save(Order order) {
//save the order
//...
List<Event> events = order.getEvents();
events.forEach(event -> eventPublisher.publish(event));
order.clearEvents();
}
}
 
除此之外,还有一种与“临时保存领域事件”相似的做法是“在聚合根方法中直接返回领域事件”,然后在Repository中进行发布。这种方式依然有很好的可测性,并且开发人员不用手动清空先前的事件集合,不过还是得记住在Repository中将事件发布出去。另外,这种方式不适合创建聚合根的场景,因为此时的创建过程既要返回聚合根本身,又要返回领域事件。
 
 这种方式也有不好的地方,比如它要求开发人员在每次更新聚合根时都必须记得清空events集合,忘记这么做将为程序带来严重的bug。不过虽然如此,这依然是笔者比较推荐的方式。 
 
业务操作和事件发布的原子性 
虽然在不同聚合根之间我们采用了基于领域事件的最终一致性,但是在业务操作和事件发布之间我们依然需要采用强一致性,也即这两者的发生应该是原子的,要么全部成功,要么全部失败,否则最终一致性根本无从谈起。以上文中“订单积分”为例,如果客户下单成功,但是事件发送失败,下游的账户系统便拿不到事件,导致最终客户的积分并不增加。 
 
要保证业务操作和事件发布之间的原子性,最直接的方法便是采用XA事务,比如Java中的JTA,这种方式由于其重量级并不被人们所看好。但是,对于一些对性能要求不那么高的系统,这种方式未尝不是一个选择。一些开发框架已经能够支持独立于应用服务器的XA事务管理器(如Atomikos 和Bitronix),比如Spring Boot作为一个微服务框架便提供了对Atomikos和Bitronix的支持。 
 
如果JTA不是你的选项,那么可以考虑采用事件表的方式。这种方式首先将事件保存到聚合根所在的数据库中,由于事件表和聚合根表同属一个数据库,整个过程只需要一个本地事务就能完成。然后,在一个单独的后台任务中读取事件表中未发布的事件,再将事件发布到消息中间件中。 
 
 
 
这种方式需要注意两个问题,第一个是由于发布了事件之后需要将表中的事件标记成“已发布”状态,即依然涉及到对数据库的操作,因此发布事件和标记“已发布”之间需要原子性。当然,此时依旧可以采用XA事务,但是这违背了采用事件表的初衷。一种解决方法是将事件的消费方创建成幂等的,即消费方可以多次消费同一个事件。这个过程大致为:整个过程中事件发送和数据库更新采用各自的事务管理,此时有可能发生的情况是事件发送成功而数据库更新失败,这样在下一次事件发布操作中,由于先前发布过的事件在数据库中依然是“未发布”状态,该事件将被重新发布到消息系统中,导致事件重复,但由于事件的消费方是幂等的,因此事件重复不会存在问题。 
 
另外一个需要注意的问题是持久化机制的选择。其实对于DDD中的聚合根来说,NoSQL是相比于关系型数据库更合适的选择,比如用MongoDB的Document保存聚合根便是种很自然的方式。但是多数NoSQL是不支持ACID的,也就是说不能保证聚合更新和事件发布之间的原子性。还好,关系型数据库也在向NoSQL方向发展,比如新版本的PostgreSQL(版本9.4)和MySQL(版本5.7)已经能够提供具备NoSQL特征的JSON存储和基于JSON的查询。此时,我们可以考虑将聚合根序列化成JSON格式的数据进行保存,从而避免了使用重量级的ORM工具,又可以在多个数据之间保证ACID,何乐而不为? 
 
总结
领域事件主要用于解耦微服务,此时各个微服务之间将形成最终一致性。事件风暴活动有助于我们对微服务进行拆分,并且有助于我们深入了解某个领域。领域事件作为已经发生过的历史数据,在建模时应该将其创建为不可变的特殊值对象。存在多种方式用于发布领域事件,其中“在聚合中临时保存领域事件”的方式是值得推崇的。另外,我们需要考虑到聚合更新和事件发布之间的原子性,可以考虑使用XA事务或者采用单独的事件表。为了避免事件重复带来的问题,最好的方式是将事件的消费方创建为幂等的。 

Event Driven Architecture的更多相关文章

  1. 【转】Event Driven Programming

    FROM: http://lazyfoo.net/tutorials/SDL/03_event_driven_programming/index.php Event Driven Programmin ...

  2. event driven的一些概念

    1. event :Something that happens during your application that requires a response. 2.event object:Th ...

  3. event driven model

    http://www.jdon.com/eda.html http://blog.csdn.net/gykimo/article/details/9182287 事件代表过去发生的事件,事件既是技术架 ...

  4. Domain Driven Design and Development In Practice--转载

    原文地址:http://www.infoq.com/articles/ddd-in-practice Background Domain Driven Design (DDD) is about ma ...

  5. RabbitMQ 高可用集群搭建及电商平台使用经验总结

    面向EDA(事件驱动架构)的方式来设计你的消息 AMQP routing key的设计 RabbitMQ cluster搭建 Mirror queue policy设置 两个不错的RabbitMQ p ...

  6. 基于“事件”驱动的领域驱动设计(DDD)框架分析

    摘抄自 从去年10月份开始,学了几个月的领域驱动设计(Domain Driven Design,简称DDD).主要是学习领域驱动设计之父Eric Evans的名著:<Domain-driven ...

  7. OO之美

    ㈠ 设计的分寸 对于设计,还有很多看似"惯常"的法则与经验广泛存在于软件系统中,例如除了经典的23种设计设计模式.还有很多模式之外的模式,按照粒度的大小,系统的特点,规模的大小,而 ...

  8. A JavaFX based Game Authoring System

    http://www.mirkosertic.de/doku.php/javastuff/javafxgameauthoring ——————————————————————————————————— ...

  9. SEDA工作笔记(一)

    摘要 在普遍认知中,软件开发实践是一项充满不确定性的工作,这是由于编码工作占据了其绝大部分的工作,而编码本身就是具有极大不确定性的.同样,计算机科学被视作一门门槛低,基于经验,而无理论意义的纯工程类学 ...

随机推荐

  1. Android AsyncTask异步加载WebAPI

    之前做的程序一直存在很多问题,因为需要加载的Activity需要从网络加载数据.并没有完全正确的使用异步的方法去加载! 之前用的虽然是AsyncTask,但是在加载完成的时候还是并没有使用AsyncT ...

  2. 这是关于FastJson的一个使用Demo,在Java环境下验证的

    public class User { private int id; private String name; public int getId() { return id; } public vo ...

  3. 快手、抖音、微视类短视频SDK接入教程,7步就能搞定

    欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由视频咖 发表于云+社区专栏 终端部分 按照如下三步操作,可以用 XCode 或者 Android Studio 编译和调试小视频 Ap ...

  4. 前端渲染模板(一):Thymeleaf

    一.使用 本篇文章将以SpringBoot为框架来介绍Thymeleaf的用法. 1 资源文件的约定目录结构  Maven的资源文件目录:/src/java/resources spring-boot ...

  5. 用m2eclipse创建Maven项目时报错

    Could not calculate build plan: Failure to transfer org.apache.maven.plugins:maven-surefire-plugin:p ...

  6. [javaEE] tomcat内部连接池

    在META-INF的目录下,新建context.xml 在程序中获取数据源,通过jndi,这个jndi必须在Servlet中才能获取,并且需要配置web.xml使servlet一启动就拿到数据源 co ...

  7. UrlRewrite 的配置和使用总结

    UrlRewrite就是我们通常说的地址重写,用户得到的全部都是经过处理后的URL地址.     主要优点 一:提高安全性,可以有效的避免一些参数名.ID等完全暴露在用户面前,如果用户随便乱输的话,不 ...

  8. springboot+mybatis实现动态切换数据源

    前几天有个需求,需要使用不同的数据源,例如某业务要用A数据源,另一个业务要用B数据源.我上网收集了一些资料整合了一下,虽然最后这个需求不了了之了,但是多数据源动态切换还是蛮好用的,所以记录一下,或许以 ...

  9. css之子元素获取(未定义高度)父元素的高度

    你可能碰到过这样的需求,一个高度不固定的区域(内容由用户创造),当鼠标经过该区域或者其神马操作时,需要出现一个与该区域一样大的模版: 我们用一个span来处理这个mask.由于 .sample-1 和 ...

  10. Django Cookie于Session

    一.Cookie与Session由来 因为Http协议的特性,每一次来自用户浏览器的请求都是无状态且独立的,通俗地说,就是无法保存用户状态,后台服务器根本就不知道当前请求和以前及以后请求是否来自同一用 ...