Domain-Driven Design
领域模型对所有软件从业者来讲都不是一个陌生的名词,一个软件产品的内在质量好坏可能被领域模型清晰与否所决定,好的领域模型可以让产品结构清楚,修改更方便,演进成本更低。大家都熟悉的设计模式未必是最好的设计模式,引入新的思想,并借鉴应用到自己的设计中,才是正道。
领域模型设计有两种维度可以入手:基于对象(Data Modeling,通过数据抽象系统关系,也就是数据库设计)VS 基于数据库(Object Modeling,通过面向对象方式抽象系统关系,也就是面向对象设计)。
大部分架构师都是从data modeling开始设计软件系统,少部分人通过object modeling方式开始设计软件系统。这两种建模方式并不互相冲突,都很重要,但从哪个方向开始设计,对系统最终形态有很大的区别。
Data Modeling
我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。
简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。
在传统的Action/Service/DAO分层模式项目中,领域模型就是数据库设计。架构师们在需求讨论的过程中不停地演进更新这个数据库设计。传统项目中,架构师交给开发的一般是一本厚厚的概要设计文档,里面除了密密麻麻的文字就是分好了域的数据库表设计。言下之意:数据库设计是根本,一切开发围绕着这本数据字典展开,形成类似于如下的架构图:
我再次强调一下,这种架构没什么不好的,简单的业务系统采用这种贫血模型和过程化设计是没有问题的,部分稍复杂的管理后台用这种架构也是合适的。在Oracle为王的传统行业,通过外键约束、触发器、储存过程等来实现领域模型依然可以接受。但是在互联网场景下,在以性能为王的年代,在业务快速迭代的场景下,数据库层的领域设计已经跟不上业务的调整速度。
Object Modeling
2004年,Eric Evans 发表了Domain-Driven Design(领域驱动设计)书里对领域驱动做了开创性的理论阐述,可以参考我的另一篇文章领域驱动设计理论基础。
领域驱动设计中不考虑持久化实现。领域模型就要基于程序本身来设计了,热爱设计模式的同学们可以在这里大显身手,这里推荐我另一篇文章设计模式总结。在面向过程,面向函数,面向对象的编程语言中,面向对象无疑是领域建模最佳方式。类与表有点像(不少人认为表和类就是对应的,行row和对象object就是对应的),我个人强烈地不认同这种等同关系,这种认知直接导致了软件设计变得没有意义。类和表有以下几个显著区别,这些区别对领域建模的表达丰富度有显著的差别,有了封装、继承、多态,我们对领域模型的表达要生动得多。数据是“死的”,但是对象是活的,对象能够表达很多数据没法表达的信息。
【引用】关系数据库表表示多对多的关系是第三张表来实现,这个领域模型表示不需要具体对象化, 应为业务同学不需要知道这张表的存在。
【封装】类可以设计方法,数据并不能完整地表达领域模型,数据表可以知道一个人三维,并不知道“一个人是可以跑的”。
【继承、多态】类可以多态,数据上无法识别人与猪除了三维数据还有行为的区别,数据表不知道“一个人跑起来和一头猪跑起来是不一样的”。
通过在面向对象的世界里设计栩栩如生的领域模型,service层就可以基于这些模型做各种业务操作(service层变薄了,很多动作交给了domain objects去处理):领域模型并不完成业务,每个domain object都是完成属于自己应有的行为(single responsibility)。哈哈,领域模型可以理解为我们公司正在推的“中台”。通过引入领域层,上面的架构形成了类似于如下的架构图:
我接手的问诊系统是业务散落在4个系统中,每个系统都有用户侧和医生侧的业务,传统的Action/Service/DAO架构,但是问诊的业务域越来越复杂,从内部的问诊支撑走向平台化,达到支撑集团乃至开放给医疗行业的目的,现有架构已经暴露了它的局限性。这次借助DDD的思想,将问诊的业务整理成如下的领域架构:
问诊系统领域设计四合一,然后一拆二,医生侧的问诊系统(主要支撑微医生app等医生侧需求)、用户侧的问诊系统(支撑微医app等用户侧系统需求)。
CQRS实践
命令查询的责任分离Command Query Responsibility Segregation (简称CQRS)模式是一种属于领域驱动设计(DDD,Domain Driven Design)的架构体系模式。在客户端就将数据的新增修改删除等动作和查询进行分离,前者称为Command,走Command bus进入Domain对模型进行操作,而查询则从另外一条路径直接对数据进行操作。CQRS原始的架构如下:
image.png
命令的目的是在系统上执行一些业务操作。在实践中,我们注意到命令是否需要更改一个或多个实体。出于架构原因,如何处理多个实体更改非常重要。
在这种情况下,实体意味着某个逻辑单元。在高度规范化的表中,实体可能包含父对象和一对多关系的任何子对象。在DDD术语中,你可以将其称为聚合。在事件溯源中,这是一个事件流。结合到我们具体的业务,我整理了如下流程:
由于领域对象操作和数据库持久化存储这两个动作分离,数据表结构和领域对象就成松耦合的了(领域对象和数据表不再是一对一对应)。当一个Command进来时,从仓储Repository加载一个聚合Aggregate对象群,会激发聚合对象群产生一个事件,然后执行其方法和行为。同时分发给Event Bus事件总线,比如JavaEE的消息总线等等(我们可以考虑使用MQ来实现)。事件总线将再次激活所有监听本事件的处理者。当然一些处理者会执行其他聚合对象群的操作,包括数据库的更新。
引入DDD思想后的新分层架构如下图所示:
image
事件
事件驱动了领域模型的状态改变,如果你记录这些事件 ,将可以将一些用户操作进行回放,从而找到重要状态改变的轨迹,而不是单纯只能依靠数据表字段显示当前状态,至于这些当前状态怎么来的,你无法得知。
虽然这种架构有些复杂,但是好处却很多,主要的是实现透明的分布式处理(Transparent distributed processing),当使用事件作为状态改变的引擎时,你可以通过实现多任务并发处理,事件能够很容易序列化,并在多个服务器之间传送。如果这个过程中涉及到分布事务问题,可以参考我另一篇文章Rocketmq原理&最佳实践。
事件代表过去发生的事件,事件既是技术架构概念,也是业务概念。以事件为驱动的编程模型称为事件驱动架构EDA(Event Driven Architecture)。事件本身是不可变的值对象。事件在技术架构上应用能提供无堵塞的高并发性能,结合DDD的实现是CQRS。在业务上将事件和领域驱动设计DDD结合在一起,可以形成统一语言DSL,事件是触发状态变化的根源。领域事件是领域中发生的事件,领域事件将领域模型的改变显式化,突出暴露出来。一个命令被设计为一个同步交互,一个事件被设计为一个异步交互。采用事件的优势如下:
事件实现解耦
一个事件的生产者发布事件,零或多个的消费者订阅它。这个同mq的解耦功能,这里不再累述。
事件作为记录
一旦我们将实体的所有有趣的状态转换表示为事件,我们就可以使用这些事件来记录该实体发生了什么以及在何时发生了什么。当我们想要回头看看发生了什么时,这些记录都是非常有价值的。
我们总是可以简单地通过播放事件来重建当前状态。这是一个非常聪明的想法,人们已经想到了 - 它被称为“事件溯源EventSourcing”(Event sourcing事件溯源是借鉴数据库事务日志的一种数据持久方式,在事务日志中记录导致状态变化的一系列领域事件。通过持久化记录改变状态的事件,通过重新播放获得状态改变的历史。 事件回放可以返回系统到任何状态)。事件溯源原本的做法是只保留事件,而不是在数据库持久存储当前的状态,这种做法在现有业务状态查询时会存在严重性能问题。我们利用“事件溯源EventSourcing”来解决我们日常异常排查问题,在必要的时候通过回放数据达到修复数据的目的。
事件是真实世界的工作方式
如果习惯于构建经典的三层架构,那么可能很难从事件的角度思考。但如果系统的某个操作会影响内部系统多个并行操作,甚至多个其他系统,这种场景下传统三层架构就不能很好满足需求。如果你可以把你的问题看成是一个工作流程,那么你已经完成了一半以上的工作。不能因为系统的一个部分与另一个部分之间存在“不一致”而认为这个想法有点愚蠢。因为复杂的真实场景下就是存在“不一致”,短暂的“不一致”并不一定会有影响。
事件建模更加通用
我们甚至可以使用领域事件直接对业务需求进行事件建模,通过事件功能的发现挖掘需求中深层次的概念。业务专家包括我们的产品对需求的理解也是建立在事件之上。动态流的事件模型加上结合DDD的聚合实体、状态以及上下文场景,我们实际上就统一了需求分析和软件设计两个阶段的语言。使用这套统一语言分析需求以后,能够直接落地为代码,如下图是总结了在Jdon多位牛人的思想后的Jdon分析法:
该图表达了用户操作者和被操作者事物之间的本质关系,以用户和购物车为案例,从购物车这个事物角度看:领域聚合实体表达的是购物车这个事物的分析设计方法,以一种静态结构性来表达事物;从用户购买者这个角度看:用户将选购的商品放入购物车,删除购物车已有商品,这些都是用户的操作行为,每一个操作行为相当于发出一个个命令command,在一定场景中转化为事件,事件会改变购物车状态,这是一种以动态行为(面向函数)来表达与人有关的需求。
一个命令代表一个意图,而事件表示一个事实,已经发生的事情。一个事件可能是接收指示当前飞机位置的雷达轨迹,系统很难拒绝这样的事件,因为它已经发生了。另一方面,想要修改飞行轨迹的飞行控制指令是命令。这是一个用户意图,不同于之前的事实,它必须由我们的应用程序验证是否可以执行。大多数情况下,一个命令被设计为一个同步交互,一个事件被设计为一个异步交互。
事件的落地实现可以兼顾内外部系统交互
将需要发送事件消息的行为按照领域对象进行划分,分出相应的领域操作对象,相应的Process实现CommandBus。CommandBus调用MQ将消息事件发送出去,内外部应用都可以通过监听该消息事件。
public interface CommandBus{
void Send(Command command);
}
命令通过MQ传送到处理器,命令接收方接收到命令消息,接着调用对应的处理器处理相关命令。所有的命令处理逻辑写在handle方法中,这样整个命令交互就非常清晰。
public abstract class CommandHandler<T extends Command>{
public void handle(Command command){
//事件处理
}
}
CQRS实施注意事项
在实施CQRS API时,需要注意以下点(我暂时想到的,应该不全):
返回错误与返回数据不同
一个常见的误解是命令应该什么都不返回。实际是命令应该返回一些东西的。它们返回有关操作本身的元信息(无论是成功还是失败以及为什么)
命令可以在不进行更改的情况下成功
“进行更改”是命令的目的,而不是所需的结果。因此,对于成功运行的命令应该返回成功结果,即使这条命令不会导致任何数据更改。
命令处理代码可以调用查询
这似乎违反了CQRS原则。命令的内部除了“进行更改”之外,它没有任何意思。
因此,可以在命令中运行查询,获取命令所需的一些信息,但是要小心一点。
命令相应操作要保证幂等
由于我们采用RocketMq(RocketMQ使用的消息原语是At Least Once,所以consumer可能多次收到同一个消息)作为事件总线,事件的Handle需要保证在多次接受同一条消息时,处理结果是一致的。
自动递增ID不应是主键ID
如果自动增量字段是业务唯一的ID,类似下单等场景下应用无法知道请求是否成功。对这种情况的补救措施通常取决于用户的意识和参与。如果用户再次点击提交(非常可能),但是先前的请求如果确实创建了实体,尽管超时,那么现在有两个具有不同ID的相同实体。要正确清理,用户现在应该搜索重复项并删除冗余实体(极不可能)。
网友评论