我们在进行系统开发的时候,经常会遇到”当发生某事件的时候,执行某个动作”。
比如,在一个问答系统中,当有人回复了提问者的提问的时候,系统就向提问者的邮箱发送通知邮件,如果我们使用事务脚本的方式来实现这个功能,就会用到如下代码所示的伪代码:
void 保存答案(long id,string answer)
{
保存到数据库(id,answer);
string email = 获取提问者邮箱(id);
发送邮件(email,"你的问题被回答了");
}
这样编写的代码有如下几个问题。
问题一:
代码会随着需求的增加而持续膨胀。
比如网站又增加了一个功能”如果用户回复的答案中有疑似违规的内容,则先把答案隐藏,并且通知管理员进行审核”,那么我们就要把”保存答案”方法修改成如下代码所示的形式:
void 保存答案(long id,string answer)
{
long aId = 保存到数据库(id,answer);
if(检查是否疑似违规(answer))
{
隐藏答案(aId);
通知管理员审核();
}
else
{
string email = 获取提问者邮箱(id);
发送邮件(email,"你的问题被回答了");
}
}
随着系统的升级,这个方法的代码页越来越长、越来越复杂,充斥着大量一层嵌套一层的判断语句。
这样的代码警告几任开发人员的接手,可能没有任何一个开发人员能够完全理解这个方法。
当需要为这个方法增加新的功能的时候,开发人员不敢修改前任的代码,他只能”胆战心惊”地找到一个位置插入自己编写的代码,如果代码恰好能够运行,又没有导致原有功能出现bug,就是一件”天大的喜事”。
这样新版本的代码又成为了继任的开发人员不敢动的”祖传代码”。
总之,这样的事务脚本的可读性和可维护性非常差。
问题二:
代码可扩展性低。
在后续版本中,我们可能要把”发送邮件”改成”发送短信”,那么我们就要把上述代码改成与发送短信相关的代码;
如果后续我们又要把逻辑改成”向普通会员发邮件,向VIP会员发短信”,那么我们就要把上述代码改成由多个判断语句组成的代码块。
面向对象设计中有一个原则是”开闭原则“,即”对扩展开放,对修改关闭”,通俗来讲就是”当需要增加新的功能的时候,我们可以通过增加扩展代码来完成,二不需要修改现有的代码”。
很显然,我们这种事务脚本的写法是很难满足开闭原则的。
问题三:
用户体验很差。
这段代码中除了”保存答案”这个核心的业务逻辑之外,掺杂了”检查是否疑似违规””发送邮件”等业务逻辑,这些业务逻辑的执行一般都比较耗时,会拖慢”保存答案”方法的执行速度,造成每次用户单击【保存答案】按钮的时候都要等待很长时间。
问题四:
容错性差。
“检查是否疑似违规”可能需要调用第三方的鉴黄服务,”发送邮件”需要访问邮件服务,这些都需要访问外部系统,这些外部系统并不总是稳定的。
比如,在发送邮件时,邮件服务器的暂时故障可能会造成用户单击【保存答案】按钮后,系统提示”操作失败”,因此用户体验是极差的。
为了解决这些问题,我们可以在保存答案后,发出一个”答案已保存”的通知事件,内容审核模块和邮件发送模块监听这个事件来分别进行各自的处理。
采用事件机制的伪代码如以下代码所示:
void 保存答案(long id,string answer)
{
long aId = 保存到数据库(id,answer);
发布事件("答案已保存",aId,answer);
}
[绑定事件("答案已保存")]
void 审核答案(long aId,string answer)
{
if(检查是否疑似违规(answer))
{
隐藏答案(aId);
发布事件("内容待审核",aId);
}
}
[绑定事件("答案已保存")]
void 发邮件给提问者(long aId,string answer)
{
long qId = 获取问题Id(aId);
string email = 获取提问者邮箱(qId);
发送邮件(email,"你的问题被回答了")
}
采用这样的事件机制的代码有如下优点:
- 关注点分离:3个方法各司其职,各种的业务逻辑没有混杂到一起,代码的可读性、可维护性都非常高。
- 扩展容易:如果我们需要实现”保存答案后,刷新缓存”,只要再增加一个新的方法并且将其绑定到”答案已保存”事件即可,现有的代码不用做任何修改,符合”开闭原则”
- 用户体验好:我们可以把”审核答案””发邮件给提问者”等这些对事件的处理一部运行,这样这些处理就不会影响用户体验。
- 容错性更好:如果外部系统调用失败,我们可以进行失败重试或者服务降级等处理。
在我们对用户需求进行分析的时候,如果发现有”如果发生了某事,则执行某个动作”这样的描述的时候,我们都可以把它们通过事件机制来实现。
DDD中的事件分为两种类型:
领域事件(domain events)和集成事件(integration events)。
聚合内一般不需要通过领域事件进行事件传递,领域事件主要用于在同一个微服务内的聚合之间的事件传递,而集成事件用于跨微服务的事件传递。
比如在问答微服务中,当用户保存答案的时候,审核答案的逻辑我们一般通过领域事件来实现。
如果项目中有专门的邮件发送微服务,则当用户保存答案的时候,发送邮件给提问者的操作就要通过集成事件来实现。
领域事件由于是在同一个进程内进行的,我们通过进程内的通信机制就可以完成;
集成事件由于需要跨微服务进行通信,我们就要引入事件总线(eventbus)来实现事件的传递。
我们一般使用消息队列服务器中的”发布/订阅”模式来实现事件总线。