《领域驱动设计——洞见》

引:读完《实现领域驱动设计》,对于实践还是有些模糊,那就再通过《领域驱动设计——洞见系列》加加餐!

综述

DDD战略篇:架构设计的影响力

系统健壮(能适应各种不确定性) 》 稳定

软件设计共识:通过组件化完成关注点分离从而降低局部复杂度

软件架构设计的实质:让系统能够更快得响应外接业务的变化,并且使得系统能够持续演进

面向业务变化的架构:要求首先理解业务的核心问题,即由针对性的进行关注点分离来找到相对内聚的业务活动形成子问题域

打造架构响应力的方法:DDD通过下面两个方法(模式)解决:

  1. 让团队中各个角色(从业务到开发测试)都能够采用统一的架构语言,从而避免组件划分过程中的边界错误
  2. 让业务架构和系统架构形成绑定关系,从而建立针对业务变化的高响应力架构。(业务架构(描述功能)和系统(技术)架构的区别)

DDD战术篇:领域模型的应用

DDD构建的元模型元素脑图(可以想象的)提供了一个元模型体系,通过这个元模型我们会对战略建模过程冲识别出来的问题子域进行抽象,而通过抽象来知道最后的落地实现。

业务对象的抽象: 通过对业务问题的子域划分,我们找到了一些关键的业务对象,在开始抽象前一个必须的步骤就是讲故事!故事是关于这个子域的业务问题或者提供的业务能力的故事。故事必须有清晰的业务场景和业务对象之间的交互。只有当我们能够讲清楚业务场景的时候,才应该开始抽象的的步骤。对于一个业务对象,常见的抽象可以是“实体(Entity)”和“值对象(Value Object)”。这两个抽象方式在定义上的区别是。实体需要给予一个唯一标识,值对象不需要。当更常用的区别是实体应该使用有一个连续的生命周期。然后在生命周期保持实体状态的一致性。 所以会发现实体的成本会很大,值对象的成本会低一些,所以会优先考虑值对象建模。

聚合的封装: 聚合可以让我们把多个实体和值对象的业务封装封装起来。识别聚合是认知潜在核心业务规则的过程。而定义出来的聚合是在大家共识基础上对核心业务规则的封装。

领域服务的定义: 例子(转账、订单消息通知)

Repositories的使用: 更多的是粗颗粒度的对象。在DDD这个方法里可以认为映射对象是我们的聚合。然后针对聚合里的实体创建出对应的DAO(可能和ORM框架有关)。这里要注意:不是有一个查询就一定又一个repo与之对应,未尝不可以让服务直接对数据存储实现。记住我们抽象的目标是让建模更加简单,抽象的过程中应该保持灵活。

界限上下文的意义: DDD通过对子问题域的划分依据进行了对业务能力的分解。而界限上下文在解决方案域中完成了进一步分解。我们不能认为子问题域和界限上下文有严格的一对一的关系,但大多数情况下一个子问题域会被设计成一个或多个界限上下文。我们的重点在区分子问题域和解决方案域。这是落地DDD最困难的地方,也是判断一个架构师能力进阶的分水岭。

战略上要藐视敌人,战术上要重视敌人

业务需求是我们的朋友,战略上要重视朋友,战术上要简化建模

DDD实战篇:分层架构的代码结构

领域驱动设计DDD,提出了从业务设计到代码实现一致性的要求,不再对分析模型和实现模型进行区分。

在整个DDD的建模过程中,我们更多的是关注核心领域模型的建立,我们认为完成业务需求就是在领域模型上的一些列操作(应用)。这些包括了对核心实体状态的改变、领域事件的存储,领域服务的调用等。

分层架构:

Service Layer -> Domain -> Repositories

当我们谈论代码结构的时候,针对的是一个经过DDD建模后的子问题域,这是明确的组件化边界。

1
2
3
4
5
domain
gateways
interface
repositores
services

领域模型一定要可测试,测试的核心原则是让用例尽量测试业务需求而不是实现方式本身。满足业务需求是我们的目标,实现方式可能有多种。不要让持续重构的代码影响到测试用例。

DDD从更高的视角,TDD从更低的视角。

我们反对前期设计的大而全,但是我们认可前期对核心领域模型的分析和设计,这样能够帮助我们更快的响应后续业务的业务变化(即核心模型之上的应用),以为核心的模型变化的频率较外部应用会低很多。如果核心领域模型变化剧烈,那么可能我们业务发生了根本性的变化,需要建立新的模型。

DDD的终极大招——By Experience

架构设计的最后终极大招还是By Exprience——考经验吃饭。从战略角度的subdomain(子问题域的分化)到战术建模层面的Entity和VO的选择,最终的决策很可能不是完全理性,经验这个感性的东西发挥这很大的作用。

通用语言、领域、界限上下文

如何说好一门通用语言

在DDD中,通用语言是以界限上下文为边界的。如果一个产品或者项目有个多个界限上下文,我们就需要为每个界限上细问定义通用语言。界限上下文提供了一个语义边界,来保持通用语言和领域概念的意义对应关系。

通过添加约束消除通用语言的歧义。在构建通用语言时,有两个额外的约束条件:子域和界限上下文。

在DDD中,软件的核心是其为客户解决领域相关的问题的能力。

为了分解问题域的复杂度,问题域又会被拆解为多个子域,每个子域都要明确待解决的业务问题和业务流程,以及通过解决业务问题为企业带来了什么样的业务价值。

对于通用语言而言,子域解释了通用语言和现实业务活动的关系;界限上下文提供了一个语义边界,来保持通用语言和领域模型的一一对应关系;上下文映射则提供了不同界限上下文中的通用语言的转换关系。

当Subdomain遇见Bound Context

《领域驱动设计精粹》

区分问题和解决方案是一个老大难问题

区分SubDomain的重要性,比如淘宝专注解决吸引消费者,京东专注解决物流速度。

雷布斯:不要用战术上的勤奋掩盖战略上的懒惰

Subdomain和Bound Context的对应关系

一对多的映射可能是最优的选择。

架构

从三明治到六边形

现实世界的软件开发时复杂的,复杂性并不体现在具体的技术栈上。软件真正复杂的部分往往在是业务本身。

三明治:展现层、应用层(服务层)、数据访问层

六边形(端口和适配器):内部是业务的核心,即DDD中强调的领域模型;外部则是类似RestFul API、SOAP、AMQP、数据库、内存、文件系统、以及自动化测试

端口和适配器架构——DDD好帮手

如何快速获取经验?无非是多练,但是练了要讨论和总结

架构的定义: 应用应能平等地被用户、其他程序、自动化测试或脚本驱动,也可以独立于其最终的运行时设备和数据进行开发和测试

端口和适配器架构由端口和适配器组成,端口是指应用的入口和出口(入口可以理解为usecase,出口可以理解为Repository吧)。而适配器分为两种,主适配器(Driving Adapter)代表用户如何使用应用,即接受用户输入;次适配器(Driven Adapter)实现应用功能的出口端口,向外部工具执行操作。

img

img

端口和适配器的优势是突出了分层不是重点,技术实现隔离才是关键

img

领域事件

识别领域事件

领域事件是用特定方式(已发生的事态)表达发生在问题域中的重要事情,是领域通用语言的一部分。

在DDD建模过程中,以领域事件为线索逐步得到领域模型已经成为了主流的时间,即:事件风暴

事件风暴是以更专注的方式发现与提取领域事件,并将以领域事件为中心的概念模型组件演化成以聚合为中心的领域模型。

不同场景产生领域事件的方式不一样

在微服务中使用领域事件

在DDD中有一条原则:一个业务用例对应一个事务,一个事务对应一个聚合根,也即在一次事务中,只能对一个聚合根进行操作。但是对于一个用例操作两个聚合根的时候就容易违背上面的原则。通过引入领域事件,我们可以很好解决上诉问题。总的来说,领域事件给我们带来了以下好处:

  • 解耦微服务(界限上下文)
  • 帮助我们深入理解领域模型
  • 提供审计和报告的数据来源
  • 迈向事件溯源ES和CQRS等

事件风暴是一项团队活动,旨在通过领域事件识别出聚合根,进而划分微服务的界限上下文。在活动中,团队先通过头脑风暴罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合。然后对于每一个事件,标注出导致该事件的命令(Command),在然后为每个事件标注出命令发起方的角色,命令可以是用户发起,也可以是第三方调用或者定时器触发等。最后对事件进行分类整理出聚合根以及界限上下文。

发布领域事件

在使用领域事件时,我们通常采用”发布-订阅“的方式来集成不同的模块或系统。在单个微服务的内部,我们可以使领域事件来集成不同的功能组件。

通常,领域事件产生与领域对象中,或者更准确的说是产出与聚合根中,在具体编码实现时,有多种方式可用于发布领域事件,一种直接的方式是在聚合根中直接调用发布事件的service对象。另一种方式是使用EventPublisher的静态方法来发布领域事件。还有一种是采用在聚合根中临时保存领域事件。在Repository发布事件,并及时清楚events集合。还有一种是在聚合根方法中直接返回领域事件

业务操作和时间发布的原子性

虽然在不同的聚合根之间我们采用了基于领域事件的最终一致性,我是在业务操作和事件发布之间我们依然采用强一致性。我们可以使用本地事件表来保证。当然最重要的还是将事件的消费方创建为幂等的。

事件驱动是什么?

在领域内有变化发生时,发送事件消息来通知其他系统。时间通知的一个关键点是源系统并不关心外部系统的响应。通常它根本不期待任何结果,及时有也是间接的。发送事件的逻辑流与响应该事件的逻辑流之间会有显著的而格力。

事件不需要包含太多数据,通常只有一些ID信息和一个指向发送方、可供查询更多信息的链接。接收方知道他已经变化,并且接受到关于变化的最少信息,随后向发送方发出请求,已决定下一步该做什么。

CQRS 命令查询职责分离是指读取和写入分别拥有单独的数据结构。和事件没有关系

微服务

DDD & MicroServices

Microservices(微服务架构)和DDD(领域驱动设计)是时下最炙手可热的两个技术词汇。它们之间的关系是什么呢?

DDD是Eric Evans于2003年出版的书名,同时也是这个架构设计方法名的起源。DDD的想法是让我们的软件实现和一个演进的架构模型保持一致,而这个演进的模型来自于我们的业务需求。

每个人能够认知的复杂度都是有限的,在面对高复杂度的时候我们会做关注点分离,这是一个最基本的哲学原则。显然在针对复杂业务场景进行建模时,我们也会应用此原则。这个时候去分离关注点一般可以从两个维度出发:

  • 技术维度分离,类似MVC这样的分层思想是我们广泛接受的。
  • 业务维度分离,根据不同的业态划分系统,比如按售前、销售、售后划分。

从本质上作为一种架构设计方法的DDD和作为一种架构风格的Microservices都是为着追求高响应力目标而从业务视角去分离复杂度的手段。

为了解释清楚这个问题让我们极简化架构设计为以下三个层面工作

  • 业务架构:根据业务需求设计业务模块及交互关系。
  • 系统架构:根据业务需求设计系统和子系统的模块。
  • 技术架构:根据业务需求决定采用的技术及框架。

img

DDD的核心诉求就是能够让业务架构和系统架构形成绑定关系,从而当我们去响应业务变化调整业务架构时,系统架构的改变是随之自发的。

这个变化的结果有两个:

  • 业务架构的梳理和系统架构的梳理是同步渐进的,其结果是划分出的业务上下文和系统模块结构是绑定的。
  • 技术架构是解耦的,可以根据划分出来的业务上下文的系统架构选择最合适的实现技术。

值得一提的是采用DDD这种架构设计方法并不一定就产生Mircoservices这种架构风格,往往会推荐用大颗粒度的服务来包含业务分析过程中发现的不确定点,以避免拆分后变化过度频繁带来的双向修改成本。

DDD成功运用的基础就是创造让业务和系统这两种不同认知模型逐步统一的环境。

成功的DDD方法运用是贯穿系统的整个生命周期的,这个过程中业务和技术的协作是持续发生的。

服务拆分与架构演进

企业想要实施微服务架构,经常问到的第一个问题是,怎么拆?如何从单体到服务化的结构?第二个问题是拆完后业务变了增加了怎么办?另外,我们想要改变的系统往往已经成功上线,并有着活跃的用户。那么对其拆分还需要考虑现有的系统运行,如何以安全最快最低成本的方式拆分也是在这个过程中需要回答的问题。

架构演进路上,我们遇到的主要挑战如下:

  • 如何拆?即如何正确理解业务,将单体结构拆分为服务化架构?

    首先需要将客户、体验设计师、业务分析师、技术人员集结在一起对业务需求进行沟通,随后对其进行领域划分,确定限界上下文 (Boundary Context),也称战略建模

    img

    一个业务领域或子域是一个企业中的业务范围以及在其中进行的活动,核心子域指业务成功的主要促成因素,是企业的核心竞争力;通用子域不是核心,但被整个业务系统所使用;支撑子域不是核心,不被整个系统使用,该能力可从外部购买。一个业务领域和子域可以包括多个业务能力,一个业务能力对应一个服务。领域的边界即限界上下文,也是服务的边界,它封装了一系列的领域模型

    一个业务流程代表了企业的一个业务领域,业务流程所涉及的数据或角色或是通用子域,或是支撑子域,由其在企业的核心竞争力的角色所决定。比如企业有统一身份认证,决策不同部门负责不同的流程任务,那么身份认证子域并不产生业务价值,不是业务成功的促成因素,但是所有流程的入口,因而为通用子域,可为单独服务;而部门负责的业务则为核心子域

    “旧的不变,新的创建,一步切换,旧的再见”

    通过识别内部的被拆模块,对其增加接口层,将旧的引用改为新接口调用;随后将接口封装为 API,并将对接口的引用改为本地 API 调用;最后将新服务部署为新进程,调用改为真正的服务 API 调用。

    在拆分步骤上我们更多的推荐数据库先行,通过重复schema 同步数据,对数据库的读写操作分别进行迁移。如下图所示:

    img

  • 拆完后业务变了增加了怎么办?即在业务需求不断发展变化的前提下,如何持续快速地演进?

    客户的业务是在变化的,我们对业务的认知也是逐渐的过程,所以 Martin Fowler 在他的文章中提出,系统的初期建议以单体结构开始,随业务发展决定其是否被拆分或合并。那么这也意味着这样构建的服务在它的生命周期中必然会持续被拆分或合并。那么为了实现这样一个目标,使系统拥有快速的响应力,也要求这样的拆分必然是高效的低成本的。

    因此,服务的设计需要满足如下的原则:

    • 服务要有明确的业务边界,以单体开始并不意味着没有边界。 服务要有边界,即使以单体开始也要定义单体时期的边界
    • 服务要有明确清晰的契约设计,即对外提供的业务能力。(提供各种版本)
    • 服务内部要保持高度模块化,才能够容易的被拆分。
    • 可测试。
  • 如何安全地持续地拆?即如何在不影响当下系统运行状态的前提下,持续安全地演进?

    一场架构层次的重构,在这样的路上同样需要:

    • 坏味道驱动,架构的坏味道是代码坏味道在更高层次的展现,也就意味着架构的混乱程度同样反映了该系统代码层的质量问题。
    • 安全小步的重构。
    • 有足够的测试进行保护 - 契约测试。
    • 持续验证演进的方向。
  • 如何保证拆对了?

    拆分不能没有目标,尤其在具有风险的架构层次拆分更需谨慎。

    其实要回答这个问题,还是要回到拆分之初:为什么而拆?,可能是因为政治原因、业务发展需要,系统集成驱动

  • 拆完了怎么保证不被破坏?

最后,勿忘初心、且行且演进

微服务案例回顾

架构的关键在于构造合理的封装抽象。

分布式系统中我们关注组件、组件间的通信以及伴随的工程实践。微服务在企业应用的上下文中就技术约束和业务价值间达成了更好的平衡

服务的设计不只聚焦于当下需求,更需要考虑价值定位和产品愿景。

如今我们对服务的定义已经超越了技术组件,领先的组织已经在尝试将design thinking, business operating model应用到微服务设计中。业务逻辑

服务间的集成应该依赖封装好的显示接口,而不是数据库这种实现细节。我们应该在兼顾数据一致性的情况下,为每个微服务分配独立的db schema甚至db instance。

Technologies come and go, Principles stay forever。

案例

开发者的第0个迭代

  1. 从写好Readme开始

    • 项目简介:用一两句话简单描述该项目实现的业务功能
    • 技术选型:列出项目技术栈、包括语言和中间件等
    • 本地构建:列出本地开发过程中所用到的工具命令
    • 领域模型:核心领域模型概念,比如电商系统来说有Order、Product等
    • 测试策略:自动化测试如何分类,哪些必须写测试,哪些没有必要写测试
    • 技术架构:技术架构图
    • 部署架构:部署架构图
    • 外部依赖:项目运行时所依赖的外部集成方,比如订单系统会依赖会员系统
    • 环境信息:各个环境的访问方式,数据库连接等
    • 编码实践:统一的编码实践,比如一次处理原则、分页封装等
    • FAQ:开发过程中常见问题的解答
  2. 一键式本地构建

    • 生成IDE工程:idea.sh,生成IntelliJ工程问题并自动打开InteliJ
    • 本地运行:run.sh,本地启动项目,自动 启动本地数据库,监听调试端口5005
    • 本地构建:local-build.sh,有本地构建成功才能提交代码
  3. 目录结构

    1
    2
    3
    4
    5
    gradle
    src
    idea.sh
    local-build.sh
    run.sh
  4. 基于业务分包

    1
    2
    3
    order
    model
    common
  5. 自动化测试分类

    • 单元测试:核心的领域模型,包括领域对象、Factory类、领域服务类
    • 组件测试(集成测试):不适合写单元测试但是又必须测试的类,如Repository
    • API测试:模拟客户端测试各种API接口,需要启动程序
  6. 日志处理

    • 在日志中加入请求标识,便于链路追踪,如logback的MDC功能
    • 集中式日志管理,在多节点部署的场景下,各个节点的日志是分散的,为此可以引入ELK、Graylog之类的工具将日志统一输出到ElasticSearch中
  7. 异常处理

    • 向客户端提供格式统一的异常返回
    • 异常信息中应该包含足够多的上下文信息,最好是结构化的数据以便与客户端解析
    • 不同类型的异常应该包含唯一标识,以便客户端精确识别
    • 异常处理通常有两种方式,一种是层级式,即每种具体的异常都对应了一个异常类,这个类最终继承自某个父类;另一种是单一式,即整个程序中只有一个异常类,再以一个字段拿来区分不同的异常场景。
  8. 后台任务与分布式锁

  9. 统一代码风格

    • 客户端的请求数据类统一使用相同后缀,比如Command
    • 返回给客户端的数据统一使用相同后缀,比如Representation
    • 统一对请求处理的流程框架,比如采用传统的3层架构或者DDD战术模式
    • 提供一直的异常返回
    • 提供统一的分页结构类
    • 明确测试分类以及统一的测试基础类
  10. 静态代码检查

  11. 健康检查

  12. API文档 swagger

  13. 数据库迁移 ddl文件

  14. 多环境构建

    • local
    • ci
    • dev
    • qa
    • uat
    • prod
    • cors 跨域

领域驱动设计(DDD)编码实践

战略设计更偏向于软件架构,战术设计更偏向于编码实践

实现业务的三种常见方式

  1. 基于”Service + 贫血模型”的实现

    特点:存在一个贫血的领域对象,业务逻辑通过一个Service类实现,然后通过setter方法更新领域对象、最后通过DAO保存到数据库中 (业务逻辑泄漏)

  2. 基于事务脚本的实现

    领域对象都没有存在的必要,但是增加了更多的DAO方法,此时DAO不再是对持久化的封装,而且也会包含业务逻辑

  3. 基于领域对象的实现

基于业务的分包

业务分包即通过软件锁实现的业务功能进行模块划分,而不是从技术角度划分(比如划分出service和infrastructure)。在战术实践中,所采用的原则逃离不了“内聚性”和“职责分离”等基本原则。

在DDD中,聚合根是主要业务逻辑的承载体,也是内聚性原则的典型代表,因此通常的做法便是基于聚合根进行顶层包的划分。比如电商项目的两个聚合根对象Order和Product。

领域模型的门面——应用服务

在DDD实践中,自然应该采用自顶向下的实现方式。ApplicationService实现遵循一个很简单的原则,即一个业务用例对应ApplicaitonSerivce上一个业务方法。在应用服务上实现事务

Application需要遵循以下原则:

  • 业务方法与业务用例一一对应
  • 也无法方法与事务一一对应:每一个业务方法均构成独立的事务边界
  • 本身不应该包含业务逻辑
  • 与UI和通信协议无关

业务的载体——聚合根

除了内聚性和一致性,聚合根还有以下特征:

  1. 聚合根的实现应该与框架无关
  2. 聚合根之间的应用通过ID完成,一次业务用例只会更新一个聚合根,所以在聚合根去引用其他聚合根的整合没有任何好处
  3. 聚合根内部的所有变更都必须通过聚合根改成,为了保证聚合根的一致性,同时避免聚合根内部逻辑向外泄露。
  4. 如果一个事务要更新多个聚合根,要考虑引用消息机制和事件驱动架构,异步更新其他聚合根
  5. 聚合根不应该引用基础设施
  6. 外接不应该只有聚合根内部的数据结构
  7. 尽量使用小聚合

实体vs值对象

实体:ID唯一,具有变化的生命周期

值对象:值是不可变的

聚合根的家——资源库

在所有对象对象中,只有聚合根才配得上拥有Repository,而DAO没有这种约束

有的DDD实践者,任务一个纯粹的Repository值应该包含这两个方法(Save和ByID)

创生之柱——工厂

创建聚合根通常通过设计模式的工厂模式完成

聚合根的创建可简单可复杂,有时可能直接调用构造函数即可,而有时却存在一个复杂的构造流程,比如需要调用其他系统获取数据,通过来说,Factory有两种实现方式:

  1. 直接在聚合根中实现Factory方法,常用于简单创建过程
  2. 独立的Factory类,用于一定复杂度的创建过程,或者创建逻辑不适合放在聚合根上(比如IDGenerator)

必要的妥协——领域服务

DomainService用来处理聚合根不能处理的业务方法,越少越好。

Command对象

从技术上将,Command对象是一种类型的DTO对象,在controller中所接受写操作都需要通过Command进行包装,在Command比较简单(比如只有一两个字段)的情况下Controller可以将Command解开,将其中的数据直接传给ApplicationService、而在Command中数据字段比较多的时候,可以将Command对象直接传递给ApplicationService。具体怎么传只是一个编码习惯上的选择

写操作

  1. 通过聚合根完成业务请求
  2. 通过Factory完成聚合根的创建
  3. 通过DomainService完成业务请求

DDD实现软件"写操作"的3种场景

创建聚合根通过Factory完成,业务逻辑优先在聚合根边界内完成,聚合根中不合适放置的业务逻辑才考虑放在DomainService中。

读操作

在DDD的写操作中,我们需要严格按照应用服务->聚合根->资源库的接口进行编码,而在读操作往往采用下面的方式:

  1. 基于领域模型的读操作

    优点:直接使用Repository读取数据即可

    缺点:读操作完全束缚与聚合根的边界划分,繁琐低效;基于不同的查询条件返回数据,导致Repository处理太多查询逻辑,偏离Repository应该承担的责任

  2. 基于数据模型的读操作

    绕开资源库和聚合,直接从数据库中读取客户端所需要的数据,此时写操作和读操作共享的只是数据库。通过一个专门的XXRepresentationService直接从数据库中读取数据。(用这种吧)

  3. CQRS

    与“基于数据模型的读操作”不同的是,在CRQS中写操作和读操作使用不同的数据库,数据从写模型数据库同步到读模型数据库,通常通过领域事件的形式同步变更信息

    CQRS架构

    这样依赖,读操作便可以根据自身所需独立设计数据结构,而不用受写模型数据结构的牵制。

    DDD读操作的3种实现方式

无论哪种读操作,都需要遵循一个原则:领域模型中的对象不能直接返回给客户端。

在读操作汇总,我们通过Represention进行展现数据的统一

事件驱动架构(EDA)编码实践

领域事件的建模

领域事件是DDD中的一个概念,表示的是在一个领域中所发生的一次对业务有价值的事情,落到技术层面就是在一个业务实体对象(通常来说是聚合根)的状态发生了变化之后需要发出一个领域事件。

创建领域事件

在建模领域事件时,首先需要记录事件的一些通用信息,比如唯一标识ID和创建时间等

在DDD场景下,领域事件一般随着聚合根状态的更新而产生,另外,在事件的消费方,有时我们希望监听发生在某个聚合根下的所有事件,为此笔者建议为每一个聚合根对象创建相应的事件基类,其中包含聚合根的ID,比如对于订单(Order)类

在创建领域事件时,需要注意2点:

  • 领域事件本身应该是不变的(Immutable);
  • 领域事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据

发布领域事件

发布领域事件有多种方式,比如可以在应用服务(ApplicationService)中发布,也可以在资源库(Repository)中发布,还可以引入事件表的方式。

当前,一种比较受推崇的方式是引入事件表,其流程大致如下:

  1. 在更新业务表的同时,将领域事件一并保存到数据库的事件表中,此时业务表和事件表在同一个本地事务中,即保证了原子性,又保证了效率。
  2. 在后台开启一个任务,将事件表中的事件发布到消息队列中,发送成功之后删除掉事件。

在事件表场景下,一种常见的做法是将领域事件保存到聚合根中,然后在Repository保存聚合根的时候,将事件保存到事件表中。这种方式对于所有的Repository/聚合根都采用的方式处理,因此可以创建对应的抽象基类。

消费领域事件

在事件消费时,除了完成基本的消费逻辑外,我们需要重点关注以下两点:

  1. 消费方的幂等性
  2. 消费方有可能进一步产生事件

对于“消费方的幂等性”,在上文中我们讲到事件的发送机制保证的是“至少一次投递”,为了能够正确地处理重复消息,要求消费方是幂等的,即多次消费事件与单次消费该事件的效果相同。为此,在消费方创建一个事件记录表,用于记录已经消费过的事件,在处理事件时,首先检查该事件是否已经被消费过,如果是则不做任何消费处理。

在消费领域事件的过程中,程序需要更新业务表、事件记录表以及事件发送表,这3个操作过程属于同一个本地事务。

事件驱动架构的3种风格

  1. 事件通知

    \1. 发布方发布事件
    \2. 消费方接收事件并处理
    \3. 消费方调用发布方的API以获取事件相关数据
    \4. 消费方更新自身状态

  2. 事件携带状态转移(Event-Carried State Transfer)

  3. 事件溯源

对于发布方来说,作为一种数据提供者的“自我修养”,事件应该包含足够多的上下文数据,而对于消费方来讲,可以根据自身的实际情况确定具体采用哪种风格。在同一个系统中,同时采用2种风格是可以接受的。比如,对于基于事件的CQRS而言,可以采用“事件通知”,此时的事件只是一个“触发器”,一个聚合下的所有事件所触发的结果是一样的,即都是告知消费方需要从源系统中同步数据,(因此此时的消费方可以对聚合下的所有事件一并处理,而不用为每一种事件单独开发处理逻辑。)???

实例项目

该电商系统包含3个微服务,分别是:
- 订单(Order)服务:用于用户下单
- 产品(Product)服务:用于管理/展示产品信息
- 库存(Inventory)服务:用于管理产品对应的库存

整个系统中涉及到的领域事件如下:

img

其中:

  • Order服务自己消费了自己产生的所有OrderEvent用于CQRS同步读写模型;
  • Inventory服务消费了Order服务的OrderCreatedEvent事件,用于在下单之后即时扣减库存;
  • Inventory服务消费了Product服务的ProductCreatedEventProductNameChangedEvent事件,用于同步产品信息;
  • Product服务消费了Inventory服务的InventoryChangedEvent用于更新产品库存。

简单可用的CQRS编码实践

软件模型中存在读模型和写模型之分,CQRS便为此而生

20多年前,Bertrand Meyer在他的《Object-Oriented Software Construction》一书中提出了CQS(Command Query Seperation,命令查询分离)的概念,指出:

Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一个方法要么作为一个“命令”执行一个操作,要么作为一次“查询”向调用方返回数据,但两者不能共存。)这里的“命令”可以理解为更新软件状态的写操作,Martin Fowler将此称为“Modifier”;而“查询”即为读操作,是无副作用的。

后来,Greg Young在此基础上提出了CQRS(Command Query Resposibility Segregation,命令查询职责分离),将CQS的概念从方法层面提升到了模型层面,即“命令”和“查询”分别使用不同的对象模型来表示。

采用CQRS的驱动力除了从CQS那里继承来的好处之外,还旨在解决软件中日益复杂的查询问题,比如有时我们希望从不同的维度查询数据,或者需要将各种数据进行组合后返回给调用方。此时,将查询逻辑与业务逻辑糅合在一起会使软件迅速腐化,诸如逻辑混乱、可读性变差以及可扩展性降低等等一些列问题。

对于Command侧,主要的讲究是将业务用例建模成对应的Command对象,然后在对Command的处理流程中应用核心的业务逻辑,其中最重要的是领域模型的建模。下面着重介绍Query侧的编码实践。

CQRS究其本意只是要求“读写模型的分离”,并未要求使用Event Sourcing;再者,Event Sourcing会极大地增加软件的复杂度,而本文追求的是“简单可用的CQRS”

另外需要指出的是,读写模型的分离并不一定意味着数据存储的分离,不过在实际应用中,数据存储分离是一种常见的CQRS实践模式,在这种模式中,写模型的数据会同步到读模型数据存储中,同步过程通常通过消息机制完成,在DDD场景下,消息通常承载的是领域事件(Domain Event)。

查询模型的数据来源

所读数据的来源形式大致分为以下几种:

  • 所读数据来源于同一个进程空间的单个实体(后文简称“单进程单实体”),这里的进程空间指某个单体应用或者单个微服务;
  • 所读数据来源于同一个进程空间中的多个实体(后文简称“单进程跨实体”);
  • 所读数据来源于不同进程空间中的多个实体(后文简称“跨进程跨实体”)。

读写模型的分离形式

CQRS中的读写分离存在2个层次,一层是代码中的模型是否需要分离另一层是数据存储是否需要分离,总结下来有以下几种:

  • 共享存储/共享模型:读写模型共享数据存储(即同一个数据库),同时也共享代码模型,数查询据通过模型转换后返回给调用方,事实上这不能算CQRS,但是对于很多中小型项目而言已经足够;
  • 共享存储/分离模型:共享数据存储,代码中分别建立写模型和读模型,读模型通过最适合于查询的方式进行建模;
  • 分离存储/分离模型:数据存储和代码模型都是分离的,这种方式通常用于需要聚合查询多个子系统的情况,比如微服务系统。

将以上“查询模型的数据来源”与“读写模型的分离形式”相组合,我们可以得到以下不同的CQRS模式及其适用范围,有以下几种常见做法:

  • 单进程单实体 + 共享存储/共享模型
  • 单进程单实体 + 共享存储/分离模型
  • 单进程跨实体 + 共享存储/分离模型
  • 单进程跨实体 + 分离存储/分离模型
  • 跨进程跨实体 + 分离存储/分离模型

可以为查询单独起一个服务,通过接受领域事件来构建读模型

不管在架构层面还是编码层面,采用CQRS的都会增加程序的复杂度和代码量,不过,这种复杂性可以在很大程度上被其所带来的“条理性”所抵消,“有条理的多”恰恰是为了简单。

用DDD实现打卡系统

可以参考

扩展阅读

DDD该如何学

  1. 《领域驱动设计》
  2. 《企业应用架构》
  3. 《实现领域驱动设计》
  4. 《领域驱动设计模式、原理与实践》
  5. 《领域驱动设计精粹》

相信我,光学习理论是没有用的,你必须将其应用于实践,在自己的真实项目里演练DDD,最后通过By Experience 实践来出真知。

领域驱动设计(DDD)实现之路

一个软件系统是否真正可用是通过它所提供的业务价值体现出来的,因此,与其每天钻在哪些永远也学不完的技术中,何不将我们的关注点向软件系统所提供的业务价值方向思考。这也是DDD所试图解决的问题。

DDD有战略设计和战术设计之分战略设计主要从高层俯视我们的软件系统,帮助我们精准地划分领域以及处理各个领域之间的关系;而战术设计则从技术实现的层面教会我们如何具体得实施DDD。

战略设计

  1. 领域/子域
  2. 通用语言
  3. 界限上下文(概念边界)
  4. 架构风格(使用六边形架构(端口和适配器的组合):抽象不应该依赖于细节,细节应该依赖于抽象)

战术设计

战术设计将战略设计进行具体化和细节化

  1. 实体

    实体表示哪些具有生命周期并且会在生命周期中发生改变的东西,通过唯一标识来判断相等。

  2. 值对象

    起描述性作用并且可以相互替换的概念,通过数据的值来判断相等。

  3. 聚合

    一个聚合可以包含多个实体和值对象,聚合所包含的对象具有密不可分的联系。聚合是持久化的基本单位,他和资源库就有一一对应的关系。聚合之间的引用应该通过ID。使用聚合的首要原则为在一次事务中,最多只能更改一个聚合的状态。如果一次业务操作涉及到了对多个聚合状态的更改,应该采用发布领域事件的方式通知响应的绝活,此时数据的一致性从事务一致性变成了最终一致性。

  4. 领域服务

    当无法把概念放在实体或值对象的时候,就可以放在领域服务上了

  5. 资源库

    资源库用于保存和获取对象。我们要区分Repository和DAO。所有实体都有对应的DAO,只有聚合才有资源库。有两种实现方式:基于集合、基于持久化。

  6. 领域事件

    为了使用聚合的首要原则为在一次事务中,最多只能更改一个聚合的状态问题,我们通过领域事件来保持最终一致性。领域事件的命名为“名词+动词过去分词”格式,表示先前发生过的一件事。同时领域事件还可用于CQRS软件系统中的写模型和读模型之间的数据同步。再进一步是形成Event Sourcing

从四色建模法到界限纸笔建模法

四色建模法

  1. 寻找要追溯的事件

    谁,在什么时候,干了什么

  2. 识别”时标对象”

    按照时间发展的先后顺序,用红色表示起到“追溯单据”作用的“时标”概念

  3. 寻找时标对象周围的“人、地、物”,也标上红色

  4. 在3图里添加表示角色的黄色

  5. 在4图中添加表示描述的蓝色(一般给时标对象)对应了值对象

界限纸笔建模法

利用四色建模法可以按照时间发展的先后顺序,识别出起到“追溯单据”作用的时标概念。这种识别方法直达业务核心数据,简便有效。

此外,界限纸笔建魔法可以继续进行以下三项建模工作:

  1. 划分界限上下文、避免模型发展成“大泥球架构”
  2. 强调“聚合根”的概念,更好保证数据的完整性
  3. 寻找“恰好够用”的概念,避免过度设计,降低所建模型的复杂性

流程:

  1. 根据”追溯单据”的价值识别核心领域
  2. 确定核心领域之间的领域关系
  3. 用纸和笔画表格并写实例
  4. 确认”聚合根”
  5. 以”人以群分”的原则抽取新的聚集

3个优势:

  1. 划分核心领域有助于”分为治之”:一旦确定核心领域,界限上下文也就确定了,不同的界限上下文之间通过”翻译器(反腐层)”来彼此工程并屏蔽干扰。
  2. “聚合根”有助于数据完整性:每个界限上下文都有一个“聚合根”的概念,外界对其下属概念的访问都必须通过它来进行,这样既方便定位职责,也有助于数据的完整性。
  3. 用“纸和笔”画恰好够用的概念有助于避免过度设计。

可视化架构设计——C4介绍

当我们看待真实世界的“架构图”时候,也是要不停的缩放,在每一个层次可以忽略一些细节才能表达好当前抽象层次的信息,所以架构也有四个抽象层次:

系统System Context、容器Container、组件Component、代码Code

图元素:关系-线;元素-方块和角色;关系的表述-线上的文字、元素的描述-方块里的文字,虚线框

架构可视化入门到抽象坏味道

从C4图中如果有下面几个迹象就表示我们有可视化的坏味道

  1. 一张图上过分密码的线:合成更大的元素
  2. 一张图上太过多的元素(方块):合成各大的元素
  3. 一张图上太少的元素,比如角色特别少
  4. 每个图上文字表达不契合,有的太泛泛,有的太细节:通过制定主体,限制文字的抽象层次
  5. 无限制的画更多张图,基本上也失去了使用图形化表达的意义:只画重要的图,剩下的交流的时候再画

技术债治理的四条原则

技术债 用来描述理想中的解决方案和当前解决方案中间的差距所隐含的潜在成本。

这种隐喻和金融债务非常类似,这也是这个隐喻的高明之处:为了解决短期的资金压力,获得短期收益,个人或企业向银行或他人借款,从而产生债务,这种债务需要付出的额外代价是利息。

如果把技术债的产生也看做一种投资,那么获得的短期收益可能是快速上线带来的商业利益,比如新的功能吸引了更多的付费用户,解决了短期之内的资金缺口问题;赶在竞争对手之前上线了杀手级应用,并快速地抢占了市场。

不可否认,技术债的存在的确有很多积极的意义,但是我们经常会过度关注积极的因素,而忽略了技术债长期存在所导致的“利息”问题。

技术负载全景图

img

这个全景图从两个方向来分析技术债对于软件的影响:可维护性(Maintainability)可演进性(Evolvability),同时结合问题的可见性(Visibility)分析技术债对于软件开发过程的影响。

这里的可维护性(Maintainability)主要指的是狭义上的代码问题,即代码本身可读性如何、是否容易被他人所理解、是否有明显的代码坏味道、是否容易扩展和增强。

其中可演进性(Evolvability)指的是系统适应变化的能力。

技术债治理的困境

  1. 团队对于技术改进缺少战略思考

    没有让改进方向和业务的战略方向保持一致

  2. 代码可维护性问题很难说服客户买单

    代码级别的技术债的影响和收益难以衡量

  3. 效果不明显,客户信心不足

    没有解决实质性问题,比如领域模型设计是否合理

技术债治理的四条原则

  1. 核心领域由于其他子域
  2. 可演进性优于可维护性
  3. 明确清晰的责任定义由于松散无序的任务分配
  4. 主动预防由于被动响应
    • 对于代码可维护性方面,很多比较成熟的静态代码扫描工具都可以自动识别这类问题,同时需要和团队一起自定义扫描规则,并把检查代码扫描报告作为代码审查的一部分,逐步形成一种正向的反馈机制。
    • 可演进性:低耦合