《领域驱动设计(Thoughtworks洞见)》读书摘记

时间:2023-03-03 07:57:29


小结

领域驱动设计DDD的文章很多,书也很多。这本书是TW公司的一群人的一篇篇文章拼凑构成,所以感觉有点琐碎的感觉。不过相对于DDD的书本而已,这本书很多东西描述的比较接地气,很贴近日常开发中遇到的一些场景,而且很多实战的案例。

————书籍摘要————

◆ 什么是架构设计?

  • 通过组件化完成关注点分离从而降低局部复杂度
  • 所以设计其次是要建立团队协作沟通的共识
  • 软件架构设计的实质是让系统能够更快地响应外界业务的变化,并且使得系统能够持续演进

◆ 面向业务变化而架构

  • 让我们的组件划分尽量靠近变化的原点,对于互联网来说就是用户和业务,这样的划分能够让我们将变化“隔离”在一定的范围(组件)内,从而帮助我们有效减少改变点。
  • 组件之间能够互相调用,但彼此之间不应该有强依赖,组件在业务上是鼓励复用的。
  • 从业务出发、面向业务变化是我们现代架构设计成功的关键。架构设计的核心实质是保证面对业务变化时我们能够有足够快的响应能力。
  • 面向业务变化而架构就要求首先理解业务的核心问题,即有针对性地进行关注点分离来找到相对内聚的业务活动形成子问题域。子问题域内部是相对稳定的,即未来的变化频率不会很高,而子问题边界是很容易变化的

◆ 打造架构响应力的方法

  • 1.让团队中各个角色(从业务到开发测试)都能够采用统一的架构语言,从而避免组件划分过程中的边界错位。
  • 2.让业务架构和系统架构形成绑定关系,从而建立针对业务变化的高响应力架构。
  • 战略层面,DDD非常强调针对业务问题的分析和分解,通过识别核心问题域来降低分析的复杂度。在战术层面,DDD强调通过识别问题域里的不同业务上下文来进行面向业务需求的组件化。最后在实现层面利用成熟的技术模式屏蔽掉技术细节的复杂度。

◆ 分层架构

  • 注意“Resources”是基于RESTful架构的抽象,我们也可以理解为更通用的针对外界的接口Interface。而HTTP Client主要是针对互联网的通信协议,Gateways实际才是交换过程中组装信息的逻辑所在

◆ 依赖关系

  • Domain层是不应该依赖于其它任何一层的
  • Repositories是依赖于Domain的
  • Services是依赖于Domain和Repositories的

◆ 测试实现

  • 一个核心的原则是让用例尽量测试业务需求而不是实现方式本身
  • DDD就整个业务问题域进行了分解,形成子问题域;TDD就业务需求在实现时进行任务分解,从简单场景到复杂场景逐步通过测试驱动出实现。

◆ 关于预先设计

  • 我们仍然反对前期设计的大而全(Big-Design-Up-Front,BDUF)。但我们应该认可前期对核心领域模型的分析和设计,这样能够帮助我们更快地响应后续的业务变化(即在核心模型之上的应用)
  • 检验一个模型是否落地的唯一标准是应用这个模型的团队能否就模型本身达成共识。
  • “模型是用来交流的”的这一核心原则

◆ DDD的终极大招——By Experience

  • 都明示或暗示架构设计最后的终极大招还是By Experience——靠经验吃饭。从战略角度的subdomain(子问题域的划分)到战术建模层面Entity、VO的选择,最终的决策很可能不是完全“理性”,经验这个“感性”的东西发挥着很大的作用
  • DDD作为一种架构方法,最大的突破应该说是非常明确地区分出了问题域和解决方案域。但DDD的核心就在于持续的演进,演进就意味着模型和实现的改变。
  • DDD的终极大招By Experience某种意义上是在持续探索,并要求大家接受在这个探索过程中的不确定性——你的设计有可能在未来被证明是错误的。这可能是未来架构设计最大的挑战,我们必须能够让架构持续演进。

◆ 六边形架构(端口适配器)

  • 业务与基础设施分离; 内部是业务的核心,也就是DDD(Domain Driven Design)中强调的领域模型(其中包含领域服务,对业务概念的建立的模型等);外部则是类似RESTful API,SOAP,AMQP,或者数据库,内存,文件系统,以及自动化测试。这种架构风格被称为六边形架构,也叫端口适配器架构。
  • 业务领域的边界更加清晰
  • 更好的可扩展性
  • 对测试的友好支持
  • 更容易实施DDD
  • 这种架构模式甚至可能影响到团队的组成,对业务有深入理解的业务专家和技术专家一起来完成核心业务领域的建模及编码,而外围的则可以交给新人或者干脆外包出去。
  • 软件的核心复杂度在于业务本身,我们需要对业务本身非常熟悉才可能正确的为业务建模。
  1. 没有一套方法能够打遍天下,具体到采用哪一种方案,仿佛都需要增加一个定语“这取决于……”。
  2. 不管是在DDD原著,还是后续不少专家的书籍中,都暗示、甚至明示架构设计的终极大招还是By Experience—靠经验吃饭。
  3. 从战略角度的子领域划分,到战术建模层面实体、值对象的选择,最终的决策很可能不是完全“理性”的,经验这个“感性”的东西发挥着很大的作用”。
  4. 所以,推动领域驱动设计实践的方向是否应该从介绍方法转变为介绍如何累积经验?

◆ 什么是端口和适配器架构

  • 经典”的三层架构:三层(或多层)架构仍然是目前最普遍的架构,但它也有缺点:
  • 1.架构被过分简化,如果解决方案中包含发送邮件通知,代码应该放置在哪些层里?
  • 2.它虽然提出了业务逻辑隔离,但没有明确的架构元素指导我们如何隔离。
  • 因此,在实际落地时,业务逻辑容易泄漏到展示层中,导致当应用需要一种新的使用方式时(例如开放API),原有的业务逻辑层可能不能快速重用,同样的问题也发生在数据层和业务逻辑层之间。
  • “应用应能平等地被用户、其他程序、自动化测试或脚本驱动,也可以独立于其最终的运行时设备和数据库进行开发和测试”。
  • 主适配器(别名Driving Adapter)代表用户如何使用应用,从技术上来说,它们接收用户输入,调用端口并返回输出。Rest API是目前最常见的应用使用方式,
  • 次适配器(别名Driven Adapter)实现应用的出口端口,向外部工具执行操作,例如向MySQL执行SQL,存储订单。
  • 若将其可视化,Driving Adapter和Driven Adapter基于端口围绕着应用形成左右结构,有别于传统的分层形象,形成一个六边形,因此也会称作六边形架构。

◆ 端口和适配器架构有什么好处

  • 简单(Simple)并不代表着容易(Easy),简单说的是只做一件事(或一种事),而容易是指做一件事的难度
  • 测试金字塔是最常被提及的测试策略,它建议自动化测试集应该由大量单元测试作为基础,它们编写容易、运行速度快,应该只包含少量的用UI驱动的测试,由于需要处理测试数据冲突、外部依赖准备,它们编写困难、运行速度也较慢

◆ 与领域驱动设计的协同增效

  • 通用语言是领域驱动设计的核心精髓,它建议各方(无论是领域专家和还是开发人员)对于同一件事都使用相同的词汇。
  • 端口和适配器虽然不能直接帮助我们找到领域模型或通用语言,但它有助于我们从通用语言中快速剔除技术概念:凡是用于实现适配器的技术细节都应该被排除。
  • 端口和适配器的优势是突出了分层不是重点,技术实现隔离才是关键,让你不再纠结是否允许组件跨层调用

◆ 让“DDD战略设计”指导隔离实施

  • 有一个重要的实践是限界上下文的识别,当存在多个限界上下文的时候,很有可能需要集成,防腐层是常见的集成手段

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

◆ 业务对象的抽象

  • 对于一个业务对象,我们常见的抽象可以是“实体”(Entity)和“值对象”(Value Object)。
    这两个抽象方式在定义上的区别是,实体需要给予一个唯一标识,而值对象不需要(可以通过属性集合标识)。当然另外一个经常引用的区别是,实体应该是有一个连续的生命周期的

◆ 聚合的封装

  • DDD元模型中一个核心概念叫“聚合”(Aggregate)。识别聚合是认知潜在核心业务规则的过程,而定义出来的聚合是在大家共识基础上对核心业务规则的封装。

◆ 领域服务的定义

  • 服务本身就像一个静态方法一样,拥有一定的逻辑但不持有任何的信息,从整个领域来看也不存在不同“版本”的同一个服务。
  • 大多数时候应用服务在领域服务的上层,直接对外部提供接口。如果存在这样的分层,那么领域服务就不应该直接对外,而应该通过应用服务。

◆ Repositories的使用

  • 外部应用直接调取了查询服务(接口)并给出规定的参数,我们就需要一个订单记录的repo来持有跟存储相关的查询逻辑。

◆ 限界上下文的意义

  • 那么DDD和微服务架构的关系是什么呢?很多人会提到限界上下文(Bounded Context)。microservice是偏技术架构,DDD是业务架构
  • 上下文封装了一个相对独立子领域的领域模型和服务。限界上下文地图描述了各个子领域之间的集成调用关系。这个定义
  • 子域subdomain和限界上下文某种意义上是互相印证的,重点在区分问题域和解决方案域,这是落地DDD最困难的地方,也是判断一个架构师能力进阶的分水岭。

◆ 战术建模小结

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

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

  • 通用语言是以限界上下文为边界的。如果一个产品或者项目有多个限界上下文,我们就需要为每个限界上下文定义通用语言
  • 技术人员使用业务人员的用语作为开发词汇;
  • 划分好聚合,将这些词汇关联到聚合上;
  • 技术人员要将这些词汇映射到代码实现中;
  • 这些词汇会随着项目的发展一点点扩展;

◆ 通过添加约束消除歧义

  • 在DDD中,软件的核心是其为客户解决领域相关的问题的能力
  • 领域分为问题域和解决方案域两部分。
  • 为了分解问题域的复杂度,问题域又会被拆解为多个子域,每个子域都要明确待解决的业务问题和业务流程,以及通过解决业务问题为企业带来了什么样的业务价值(
  • 限界上下文在DDD中用来定义模型的适用范围、模型的用途、以及在何处保持一致,限界上下文会让团队明确模型的职责边界是什么
  • 上下文映射则提供了不同限界上下中的通用语言的转换关系

◆ 来解决下前文的问题

  • 因为同名的业务词汇与实际业务关系不清导致的疑惑
  • 因为同名的业务词汇与不同的业务词汇关联导致的疑惑
  • 因为同名的业务词汇之间的关系不清楚导致的疑惑

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

有图

(从问题/解决方案和战略/战术维度分析DDD元模型的元素

◆ 区分Subdomain的必要性

  • 作为产品和服务的实现者,如果都不参与和关注问题本身的划分及核心子问题的认知,那么你很可能在浪费自己的时间,开发出未来被边缘化,甚至淘汰的系统。
  • 借用雷布斯的成名句“不要用战术上的勤奋掩盖战略上的懒惰”!

◆ Subdomain和Bounded Context的对应关系?

  • 多个Bounded Context,即当我们分析出了一个子问题后在针对建模的解决方案进行分解,成为多个Bounded Context。所以Subdomain:Bounded Context应该是1:N的关系。
  • 所以,如果让我来站队Subdomain和Bounded Context的对应关系,我仍然会选择一对多。在准确性和易用性之间寻求一个平衡,并保证大家能够更多的关注问题本身。

◆ 坚持持续认知问题

  • Bounded Context建立一定是针对Subdomain的;而Subdomain的划分又会通过Bounded Context的模型得到持续地验证。

◆ 软件项目的套路

  • 你会发现大部分业务系统其实就是对某种形式的资源进行管理。所谓管理,也无非是增删查改(CRUD)操作。

◆ 复杂的业务

  • 软件真正复杂的部分,往往是业务本身,比如航空公司的超售策略,在超售之后Remove乘客的策略等;比如亚马逊的打折策略,物流策略等。

◆ 层次架构(三明治)

  • 将不同职责的事物分类是人类在处理复杂问题时自然使用的一种方式,将复杂的、庞大的问题分解、降级成可以解决的问题,然后分而治之。
  • 以现代的眼光来看,层次架构的出现似乎理所应当、自然而然,其实它也是经过了很多次的演进而来的:层次架构.
  • 展现层
  • 应用层
  • 数据访问层

◆ 领域事件

  • 领域事件是用特定方式(已发生的时态)表达发生在问题域中的重要事情,是领域通用语言(UL)的一部分。
  • 在DDD建模过程中,以领域事件为线索逐步得到领域模型已经成为了主流的实践,即:事件风暴。
  • 事件风暴是以更专注的方式发现与提取领域事件,并将以领域事件为中心的概念模型逐渐演化成以聚合为中心的领域模型,以快速可落地的方式实现了DDD建模。

◆ 1.组织没有领域专家

  • 领域专家应该对问题域及其中的各种可行方案有更深入的理解。
  • 往往会在事件风暴之前,组织业务愿景和场景分析,与被指派的业务干系人对齐业务愿景,一起分析业务场景背后的问题域,找到问题域的本质后再展开事件风暴。

◆ 2.面向复杂业务系统的事件风暴

  • 在处理复杂问题时,一个有效又好用的方法就是分而治之,对于复杂系统的事件风暴也是同样如此
  • 在业务干系人达到一定规模后,将业务干系人分成多组,组织多轮事件风暴,迭代演进领域模型也是一种不错的选择。
  • 这种情况按每条业务线分组展开事件风暴,然后针对多组产出结果进行统一业务概念抽象,建立系统边界内的统一事件流。

◆ 3.业务代表或领域专家用自己的语言表达业务

  • 通过模式中的关键字转换成领域事件,按时间顺序排序后,基于商业模式与价值定位与领域专家讨论领域事件,以统一的语言与统一的业务视角修正并验证领域事件。高质量的领域事件定义自然是清楚的,是可以找到问题域中的某个actor是关注它的,通过讲述领域事件是可以体现商业价值的。

◆ 4.事件风暴可能识别不出来所有领域事件

  • 所以在事件风暴的过程中,并不需要担心是不是找出所有领域事件,只要真正解决了业务问题就好了。

◆ 认识领域事件

  • 领域事件(DomainEvents)是领域驱动设计(Domain Driven Design,DDD)中的一个概念,用于捕获我们所建模的领域中所发生过的事情。领域事件本身也作为通用语言(Ubiquitous Language)的一部分成为包括领域专家在内的所有项目成员的交流用语。
  • 一个领域事件必须对业务有价值,有助于形成完整的业务闭环,也即一个领域事件将导致进一步的业务操作。
  • 在DDD中有一条原则:一个业务用例对应一个事务,一个事务对应一个聚合根,也即在一次事务中,只能对一个聚合根进行操作
  • 领域事件给我们带来以下好处:
  • 解耦微服务(限界上下文);
  • 帮助我们深入理解领域模型;
  • 提供审计和报告的数据来源;
  • 迈向事件溯源(Event Sourcing)和CQRS等。
  • 值得注意的一点是,此时各个微服务之间不再是强一致性,而是基于事件的最终一致性。

◆ 事件风暴(Event Storming)

  • 事件风暴是一项团队活动,旨在通过领域事件识别出聚合根,进而划分微服务的限界上下文

◆ 发布领域事件

  • 在聚合根方法中直接返回领域事件”,然后在Repository中进行发布。这种方式依然有很好的可测性,并且开发人员不用手动清空先前的事件集合,不过还是得记住在Repository中将事件发布出去。另外,这种方式不适合创建聚合根的场景,因为此时的创建过程既要返回聚合根本身,又要返回领域事件。

◆ 业务操作和事件发布的原子性

  • 虽然在不同聚合根之间我们采用了基于领域事件的最终一致性,但是在业务操作和事件发布之间我们依然需要采用强一致性,也即这两者的发生应该是原子的,要么全部成功,要么全部失败,否则最终一致性根本无从谈起
  • 一种解决方法是将事件的消费方创建成幂等的,即消费方可以多次消费同一个事件而不污染系统数据

◆ 总结

  • 领域事件主要用于解耦微服务,此时各个微服务之间将形成最终一致性。事件风暴活动有助于我们对微服务进行拆分,并且有助于我们深入了解某个领域。领域事件作为已经发生过的历史数据,在建模时应该将其创建为不可变的特殊值对象。存在多种方式用于发布领域事件,其中“在聚合中临时保存领域事件”的方式是值得推崇的。另外,我们需要考虑到聚合更新和事件发布之间的原子性,可以考虑使用XA事务或者采用单独的事件表。为了避免事件重复带来的问题,最好的方式是将事件的消费方创建为幂等的。

◆ 事件通知

  • 当领域内有变化发生时,发送事件消息来通知其它系统。事件通知的一个关键点是源系统并不关心外部系统的响应。通常它根本不期待任何结果,即使有也是间接的。发送事件的逻辑流与响应该事件的逻辑流之间会有显著的隔离。
  • 事件不需要包含太多数据,通常只有一些ID信息和一个指向发送方、可供查询更多信息的链接。

◆ 事件携带的状态转移(Event-Carried State Transfer)

  • 事件携带的状态转移(Event-Carried State Transfer)
    采用此模式时,可以在不需要访问源系统的情况下,更新客户端的信息。

◆ 事件溯源

  • 事件溯源(Event Sourcing)的核心思想是,每当系统状态发生变化时,都将状态更改记录为事件,这样我们就有信心在任何时间都能够通过重新处理事件来重建系统状态

◆ CQRS

  • 命令查询职责分离(CQRS)是指读取和写入分别拥有单独的数据结构。
  • 使用CQRS的理由是,在复杂领域中,使用单一模型处理读取和写入过于复杂,我们可以通过分离模型来简化。

◆ 服务于更高的业务响应力

  • DDD的想法是让我们的软件实现和一个演进的架构模型保持一致,而这个演进的模型来自于我们的业务需求。
  • 最根本的驱动力来自于科技时代对软件系统(数字化)响应力要求的不断提升,而系统的复杂度却随着业务的多元化而与日俱增。

◆ 从业务视角分离复杂度

  • 每个人能够认知的复杂度都是有限的,在面对高复杂度的时候我们会做关注点分离,这是一个最基本的哲学原则
  • 技术维度分离,类似MVC这样的分层思想是我们广泛接受的。
  • 业务维度分离,根据不同的业态划分系统,比如按售前、销售、售后划分。
  • 微服务的架构更强调业务维度的关注点分离来应对高复杂度。
  • 这是显著区别于传统SOA架构的特质之一,比如诞生于传统SOA时代的ESB(工业服务总线)就是一个典型的技术关注点分离出来的中间件。

◆ 业务和技术渐进统一的架构设计

  • 业务架构:根据业务需求设计业务模块及交互关系。
  • 系统架构:根据业务需求设计系统和子系统的模块。
  • 技术架构:根据业务需求决定采用的技术及框架。
  • DDD的核心诉求就是能够让业务架构和系统架构形成绑定关系,从而当我们去响应业务变化调整业务架构时,系统架构的改变是随之自发的。
  • 因为微服务追求的是业务层面的复用,所以设计出来的系统必须是跟业务一致的。第二点更是微服务架构的特质:“去中心化”的治理技术和数据管理。
  • 你们连业务故事都讲不清楚,还有必要继续做架构设计吗?

◆ 问题1:如何将单体结构拆分为服务化架构?

  • 首先需要将客户、体验设计师、业务分析师、技术人员集结在一起对业务需求进行沟通,随后对其进行领域划分,确定限界上下文(Boundary Context),也称战略建模
  • Inception->User Journey|Scenarios,用于梳理业务流程,由粗粒度到细粒度逐一场景分析。
  • 四色建模,用于提取核心概念、关键数据项和业务约束。
  • 领域驱动设计-战略设计,用于划分领域及边界、进行技术验证。
  • Eventstorming,用于提取领域中的业务事件,便于正确建模

有图

Inception与DDD战略设计的对比

  • 一个业务领域或子域是一个企业中的业务范围以及在其中进行的活动,核心子域指业务成功的主要促成因素,是企业的核心竞争力;通用子域不是核心,但被整个业务系统所使用;支撑子域不是核心,不被整个系统使用,该能力可从外部购买。一个业务领域和子域可以包括多个业务能力,一个业务能力对应一个服务。领域的边界即限界上下文,也是服务的边界,它封装了一系列的领域模型。
  • 第一,依据该模型与边界内其他模型或角色关系的紧密程度。比如,是否当该模型变化时,其他模型也需要进行变化;该数据是否通常由当前上下文中的角色在当前活动范围内使用。
  • 第二,服务边界内的业务能力职责应单一,不是完成同一业务能力的模型不放在同一个上下文中。
  • 第三,划分的子域和服务需满足正交原则。领域名字代表的自然语言上下文保持互相独立。
  • 第四,读写分离的原则。例如报表需有单独报表子域。核心子域的划分更多基于来自业务价值的产生方,而非不产生价值的报表系统。
  • 第五,模型在很多业务操作中同时被修改和更新。
  • 第六,组织中业务部分的划分也是一种参考,一个业务部门的存在往往有其独特的业务价值。
  • 领域划分的建议
  1. 一定要按照业务能力来进行领域的划分。
  2. 尽早识别剥离通用领域。
  3. 时刻促成技术人员与客户、业务人员的对话,业务领域的划分离不开对业务意图的真正理解。
  • 领域的拆分方法与策略
  1. 绞杀者模式:指在遗留系统外围,将新功能用新的方式构建为新的服务。随着时间的推移,新的服务逐渐“绞杀”老的遗留系统。对于那些老旧庞大难以更改的遗留系统,推荐采用绞杀者模式。
  2. 修缮者模式:就如修房或修路一样,将老旧待修缮的部分进行隔离,用新的方式对其进行单独修复。修复的同时,需保证与其他部分仍能协同功能。
  • 领域划分的误区和建议
  • 拆分方法需要根据遗留系统的状态,通常分为绞杀者与修缮者两种模式。
  • 旧的不变,新的创建,一步切换,旧的再见

◆ 问题2:拆分后业务变了增加了怎么办?

  • 因此,服务的设计需要满足如下的原则:
  • 服务要有明确的业务边界,以单体开始并不意味着没有边界
  • 服务要有明确清晰的契约设计,即对外提供的业务能力
  • 服务内部要保持高度模块化,才能够容易的被拆分
  • 可测试。

◆ 问题3:如何安全地持续地拆?

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

◆ 总结

  • 系统可由单体结构开始,不断的演进。而团队需要对业务保持敏感,与客户、业务人员进行业务对话,不断修炼领域驱动设计和重构的能力。
    在拆分的路上,我们的经验显示其最大的障碍来自意大利面一样的系统。不管我们是什么样的架构风格,高内聚低耦合的模块化代码内部质量仍然是我们架构演进的基石。具有夯实领域驱动设计和重构功底的团队才可以应对这些挑战,持续演进,保持其生命力。而架构变迁之前需要弄清背后的变迁动因与价值,探索性前进,及时反馈验证,才是正解。那么我们如何保证架构不被破坏呢?这个问题会在后续的文章中持续探讨。
    最后,勿忘初心,且行且演进。
  • 今天我们在做微服务设计时,常常利用领域驱动设计中的Bounded Context来进行服务边界的划分。
  • 微服务的另一个特点在于Product over Project,这需要不同于传统投资组合的预算管理与团队组建。
  • 服务的设计不只聚焦于当下需求,更需要考虑价值定位和产品愿景。工程团队则需要思考如何用有限成本支撑非线性的业务接入增长。

◆ 解耦服务就足够了吗?我们需要去中心化一切!

  • 康威定律告诉我们“设计系统的架构受制于产生这些设计的组织的沟通结构”。

◆ 如何更进一步

  • Technologies come and go, Principles stay forever。好在那些架构和实践背后的原则是经久不变的

◆ 第一步:从写好README开始

  • README应该简明扼要,条理清晰,建议包含以下方面:
    • 项目简介:用一两句话简单描述该项目所实现的业务功能;
    • 技术选型:列出项目的技术栈,包括语言、框架和中间件等;
    • 本地构建:列出本地开发过程中所用到的工具命令;
    • 领域模型:核心的领域概念,比如对于示例电商系统来说有Order、Product等;
    • 测试策略:自动化测试如何分类,哪些必须写测试,哪些没有必要写测试;
    • 技术架构:技术架构图;
    • 部署架构:部署架构图;
    • 外部依赖:项目运行时所依赖的外部集成方,比如订单系统会依赖于会员系统;
    • 环境信息:各个环境的访问方式,数据库连接等;
    • 编码实践:统一的编码实践,比如异常处理原则、分页封装等;
    • FAQ:开发过程中常见问题的解答。
  • 因此也是需要持续更新的。虽然我们知道,软件文档的一个痛点便是无法与项目实际进展保持同步,但是就README这点信息来讲,还是建议开发者们不要吝啬那一点点敲键盘的时间

◆ 基于业务分包

  • 优先进行业务分包,然后对于一些不隶属于任何业务的代码可以单独分包,比如一些util类、公共配置等。比如我们依然可以创建一个common包,下面放置了Spring公共配置、异常处理框架和日志等子包:

◆ 自动化测试分类

• 单元测试:核心的领域模型,包括领域对象(比如Order类),Factory类,领域服务类等;
• 组件测试:不适合写单元测试但是又必须测试的类,比如Repository类,在有些项目中,这种类型测试也被称为集成测试;
• API测试:模拟客户端测试各个API接口,需要启动程序。

◆ 日志处理

  • 日志处理主要需要考虑两个点:
    • 在日志中加入请求标识,便于链路追踪
    • 集中式日志管理

◆ 异常处理

  • 向客户端提供格式统一的异常返回。
    • 异常信息中应该包含足够多的上下文信息,最好是结构化的数据以便于客户端解析。
    • 不同类型的异常应该包含唯一标识,以便客户端精确识别。
    异常处理通常有两种形式,

◆ 统一代码风格

  • 客户端的请求数据类统一使用相同后缀,比如Command。
    • 返回给客户端的数据统一使用相同后缀,比如Represetation。
    • 统一对请求处理的流程框架,比如采用传统的3层架构或者DDD战术模式。
    • 提供一致的异常返回(请参考“异常处理”小节)。

• 提供统一的分页结构类。
• 明确测试分类以及统一的测试基础类(请参考“自动化测试分类”小节)。

◆ 静态代码检查

  • Checkstyle:用于检查代码格式,规范编码风格
  • Spotbugs:Findbugs的继承者
  • Dependency check:OWASP提供的Java类库安全性检查
  • Sonar:用于代码持续改进的跟

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

  • 技术复杂度与业务复杂度相互交错纠缠不清,这种火上浇油的做法成为不少软件项目无法继续往下演进的原因。
  • 领域驱动设计(Domain Driven Design, DDD)尝试通过其自有的原则与套路来解决软件的复杂性问题,它将研发者的目光首先聚焦在业务本身上,使技术架构和代码实现成为软件建模过程中的“副产品”。

◆ DDD总览

  • DDD分为战略设计和战术设计。在战略设计中,我们讲求的是子域和限界上下文(Bounded Context, BC)的划分,以及各个限界上下文之间的上下游关系。
  • 如果说战略设计更偏向于软件架构,那么战术设计便更偏向于编码实现。DDD战术设计的目的是使得业务能够从技术中分离并突显出来,让代码直接表达业务的本身,其中包含了聚合根、应用服务、资源库、工厂等概念。

◆ 实现业务的3种常见方式

  • 这种方式依然是一种面向过程的编程范式,违背了最基本的OO原则。另外的问题在于职责划分模糊不清,使本应该内聚在Order中的业务逻辑泄露到了其他地方(OrderService),导致Order成为一个只是充当数据容器的贫血模型(Anemic Model),而非真正意义上的领域模型。在项目持续演进的过程中,这些业务逻辑会分散在不同的Service类中,最终的结果是代码变得越来越难以理解进而逐渐丧失扩展能力。
  • 所有业务(“检查Order状态”、“修改Product数量”以及“更新Order总价”)都被包含在了Order对象中,这些正是Order应该具有的职责。

◆ 基于业务的分包

  • 在DDD的战略设计中,我们关注于从一个宏观的视角俯视整个软件系统,然后通过一定的原则对系统进行子域和限界上下文的划分。在战术实践中,我们也通过类似的提纲挈领的方法进行整体的代码结构的规划,所采用的原则依然逃离不了“内聚性”和“职责分离”等基本原则
  • 在DDD中,聚合根(下文会讲到)是主要业务逻辑的承载体,也是“内聚性”原则的典型代表,因此通常的做法便是基于聚合根进行顶层包的划分。

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

  • UML中有用例(Use Case)的概念,表示的是软件向外提供业务功能的基本逻辑单元。
  • ApplicationService采用了门面模式,作为领域模型向外提供业务功能的总出入口,就像酒店的前台处理客户的不同需求一样。
  • 自底向上:先设计数据模型,比如关系型数据库的表结构,再实现业务逻辑。我在与不同的程序员结对编程的时候,总会是听到这么一句话:“让我先把数据库表的字段设计出来吧”。这种方式将关注点优先放在了技术性的数据模型上,而不是代表业务的领域模型,是DDD之反。
  • 自顶向下:拿到一个业务需求,先与客户方确定好请求数据格式,再实现Controller和ApplicationService,然后实现领域模型(此时的领域模型通常已经被识别出来),最后实现持久化
  • ApplicationService的实现遵循一个很简单的原则,即一个业务用例对应ApplicationService上的一个业务方法。
  • 本身不应该包含业务逻辑:业务逻辑应该放在领域模型中实现,更准确的说是放在聚合根中实现
  • 与UI或通信协议无关:ApplicationService的定位并不是整个软件系统的门面,而是领域模型的门面,这意味着ApplicationService不应该处理诸如UI交互或者通信协议之类的技术细节。在本例中,Controller作为ApplicationService的调用者负责处理通信协议(HTTP)以及与客户端的直接交互。
  • 这种处理方式使得ApplicationService具有普适性,也即无论最终的调用方是HTTP的客户端,还是RPC的客户端,甚至一个Main函数,最终都统一通过ApplicationService才能访问到领域模型。接受原始数据类型:ApplicationService作为领域模型的调用方,领域模型的实现细节对其来说应该是个黑盒子,因此ApplicationService不应该引用领域模型中的对象。此外,ApplicationService接受的请求对象中的数据仅仅用于描述本次业务请求本身,在能够满足业务需求的条件下应该尽量的简单

有图

Repository时,才被封装为OrderId对象。

◆ 业务的载体——聚合根

  • 聚合根(Aggreate Root, AR)就是软件模型中那些最重要的以名词形式存在的领域对象
  • 聚合根是主要的业务逻辑载体,DDD中所有的战术实现都围绕着聚合根展开
  • 所谓“聚合”,顾名思义,即需要将领域中高度内聚的概念放到一起组成一个整体
  • 在DDD中,业务上的一致性被称为不变条件(Invariants)。
  • 对聚合根的设计需要提防上帝对象(God Object),也即用一个大而全的领域对象来实现所有的业务功能
  • 解决这样的问题依然需要求助于限界上下文,不同限界上下文使用各自的通用语言(Ubiquitous Language),通用语言要求一个业务概念不应该有二义性,在这样的原则下,不同的限界上下文可能都有自己的Product类,虽然名字相同,却体现着不同的业务。
  • 聚合根的实现应该与框架无关:既然DDD讲求业务复杂度和技术复杂度的分离,那么作为业务主要载体的聚合根应该尽量少地引用技术框架级别的设施,最好是POJO。
  • 聚合根之间的引用通过ID完成:在聚合根边界设计合理的情况下,一次业务用例只会更新一个聚合根
  • 聚合根内部的所有变更都必须通过聚合根完成
  • 如果一个事务需要更新多个聚合根,首先思考一下自己的聚合根边界处理是否出了问题,因为在设计合理的情况下通常不会出现一个事务更新多个聚合根的场景。如果这种情况的确是业务所需,那么考虑引入消息机制和事件驱动架构,保证一个事务只更新一个聚合根,然后通过消息机制异步更新其他聚合根。
  • 聚合根不应该引用基础设施。
  • 外界不应该持有聚合根内部的数据结构。
  • 尽量使用小聚合。

◆ 实体vs值对象

  • 关于实体对象的特征:
  • 实体对象表示的是具有一定生命周期并且拥有全局唯一标识(ID)的对象
  • 聚合根一定是实体对象,但是并不是所有实体对象都是聚合根,同时聚合根还可以拥有其他子实体对象
  • 聚合根的ID在整个软件系统中全局唯一,而其下的子实体对象的ID只需在单个聚合根下唯一即- 实体对象的相等性是通过ID来完成的
  • 实体对象和值对象会由于界限上下文的变化而会发生变化
  • 值对象的特征:
  • 值对象表示用于起描述性作用的,没有唯一标识的对象
  • 值对象来说,相等性的判断是通过属性字段来完成的
  • 值对象还有一个特点是不变的(Immutable),也就说一个值对象一旦被创建出来了便不能对其进行变更,如果要变更,必须重新创建一个新的值对象整体替换原有的
  • 值对象的优点:
  • 值对象的不变性使得程序的逻辑变得更加简单,你不用去维护复杂的状态信息,需要的时候创建,不要的时候直接扔掉即可
  • 在DDD建模中,一种受推崇的做法便是将业务概念尽量建模为值对象
  • 实体对象表示的是具有一定生命周期并且拥有全局唯一标识(ID)的对象
  • 而值对象表示用于起描述性作用的,没有唯一标识的对象,比如Address对象。
  • 聚合根一定是实体对象,但是并不是所有实体对象都是聚合根,同时聚合根还可以拥有其他子实体对象。聚合根的ID在整个软件系统中全局唯一,而其下的子实体对象的ID只需在单个聚合根下唯一即可。
  • 区分实体和值对象的一个很重要的原则便是根据相等性来判断,实体对象的相等性是通过ID来完成的,对于两个实体,如果他们的所有属性均相同,但是ID不同,那么他们依然两个不同的实体
  • 值对象还有一个特点是不变的(Immutable),也就说一个值对象一旦被创建出来了便不能对其进行变更,如果要变更,必须重新创建一个新的值对象整体替换原有的。
  • 值对象的不变性使得程序的逻辑变得更加简单,你不用去维护复杂的状态信息,需要的时候创建,不要的时候直接扔掉即可,使得值对象就像程序中的过客一样。在DDD建模中,一种受推崇的做法便是将业务概念尽量建模为值对象
  • 有生命周期的意味,因此本文将OrderItem建模为了实体对象。但是,如果没有这样的业务需求,那么将OrderItem建模为值对象应该更合适一些。
  • 实体和值对象的划分并不是一成不变的,而应该根据所处的限界上下文来界定,相同一个业务名词,在一个限界上下文中可能是实体,在另外的限界上下文中可能是值对象

◆ 聚合根的家——资源库

  • 资源库(Repository)作用就是持久化聚合根。通过应用服务、聚合根、资源库这几个模块,可以组成DDD处理业务需求的最常见最典型的形式:
    ① 应用服务作为总体协调者;
    ② 第一步:应用服务通过资源库获取到聚合根;
    ③ 第二步:应用服务调用聚合根中的业务方;
    ④ 第三步:应用服务再次调用资源库保存聚合根。
  • 资源库(Repository)就是用来持久化聚合根的。从技术上讲,Repository和DAO所扮演的角色相似,不过DAO的设计初衷只是对数据库的一层很薄的封装,而Repository是更偏向于领域模型。另外,在所有的领域对象中,只有聚合根才“配得上”拥有Repository,而DAO没有这种约束。
  • 对于查询功能来说,在Repository中实现查询本无不合理之处,然而项目的演进可能导致Repository中充斥着大量的查询代码“喧宾夺主”似的掩盖了Repository原本的目的。事实上,DDD中读操作和写操作是两种很不一样的过程,笔者的建议是尽量将此二者分开实现,由此查询功能将从Repository中分离出去。
  • 应用服务作为总体协调者,先通过资源库获取到聚合根,然后调用聚合根中的业务方法,最后再次调用资源库保存聚合根。

◆ 创生之柱——工厂

  • 创建聚合根通常通过设计模式中的工厂(Factory)模式完成
  • 聚合根的创建过程可简单可复杂,有时可能直接调用构造函数即可,而有时却存在一个复杂的构造流程,比如需要调用其他系统获取数据等
  • 直接在聚合根中实现Factory方法,常用于简单的创建过程。独立的Factory类,用于有一定复杂度的创建过程,或者创建逻辑不适合放在聚合根上
  • 因此程序中往往存在多个构造函数用于不同的场景,而为了将业务上的创建与技术上的创建区别开来,我们引入了create()方法用于表示业务上的创建过程。

◆ 必要的妥协——领域服务

  • 聚合根是业务逻辑的主要载体,也就是说业务逻辑的实现代码应该尽量地放在聚合根或者聚合根的边界之内。但有时,有些业务逻辑并不适合于放在聚合根上,比如前文的OrderIdGenerator便是如此,在这种“迫不得已”的情况下,我们引入领域服务(Domain Service)。
  • ApplicationService和DomainService是两个很不一样的概念,前者是必须有的DDD组件,而后者只是一种妥协的结果,因此程序中的DomainService应该越少越好。

◆ Command对象

  • DDD中的写操作并不需要向客户端返回数据,在某些情况下(比如新建聚合根)可以返回一个聚合根的ID,这意味着ApplicationService或者聚合根中的写操作方法通常返回void即可
  • Command即命令的意思,也即写操作表示的是外部向领域模型发起的一次命令操作
  • ApplicationService需要接受原始数据类型而不是领域模型中的对象
  • 统一使用Command对象还有个好处是,我们通过查找所有后缀为Command的对象,便可以概览性地了解软件系统向外提供的业务功能。
  • 通过聚合根完成业务请求
  • 通过Factory完成聚合根的创建
  • 通过DomainService完成业务请求
  • 以上3种场景大致上涵盖了DDD完成业务写操作的基本方面,总结下来3句话:创建聚合根通过Factory完成;业务逻辑优先在聚合根边界内完成;聚合根中不合适放置的业务逻辑才考虑放到DomainService中。

◆ DDD中的读操作

  • 在DDD的写操作中,我们需要严格地按照“应用服务->聚合根->资源库”的结构进行编码,而在读操作
  • 基于领域模型的读操作
  • 基于数据模型的读操作
  • CQRS
  • 领域模型中的对象不能直接返回给客户端,因为这样领域模型的内部便暴露给了外界,而对领域模型的修改将直接影响到客户端
  • 基于领域模型的读操作
  • 优点是不用创建新的数据读取机制,直接使用Repository读取数据。
  • 缺点也很明显:
  1. 读操作完全束缚于聚合根的边界划分,这种方式既繁琐又低效
  2. 读操作中通常需要基于不同的查询条件返回数据,导致Repository上处理了太多的查询逻辑,偏离了Repository本应该承担的职责
  • 基于数据模型的读操作
  • 绕开了资源库和聚合,直接从数据库中读取客户端所需要的数据,写操作和读操作共享数据库。
  • 这种方式的优点是读操作不受限于领域模型,一方面简化了整个流程,另一方面大大提升了性能。但是,读操作和写操作共享了数据库,但数据库对应于聚合根的结构创建,读操作会受到写操作的数据模型的牵制
  • CQRS(Command Query Responsibility Segregation),即命令查询职责分离
  • 囿于领域模型,而是基于读操作本身的需求直接获取需要的数据即可,一方面简化了整个流程,另一方面大大提升了性能。但是,由于读操作和写操作共享了数据库,而此时的数据库主要是对应于聚合根的结构创建的,因此读操作依然会受到写操作的数据模型的牵制。不过这种方式是一种很好的折中,微软也提倡过这种方式,更多细节请参考微软官网。
  • 这里的命令可以理解为写操作,而查询可以理解为读操作。与“基于数据模型的读操作”不同的是,在CQRS中写操作和读操作使用了不同的数据库,数据从写模型数据库同步到读模型数据库,通常通过领域事件的形式同步变更信息。

◆ 总结

  • 写操作的3种场景为:
  • 通过聚合根完成业务请求,这是DDD完成业务请求的典型方式。
  • 通过Factory完成聚合根的创建,用于创建聚合根。
  • 通过DomainService完成业务请求,当业务放在聚合根中不合适时才考虑放在DomainService中。
  • 基于领域模型的读操作(读写操作糅合在了一起,不推荐。
  • 基于数据模型的读操作(绕过聚合根和资源库,直接返回数据,推荐。
  • CQRS(读写操作分别使用不同的数据库

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

  • 一个软件系统是否真正可用是通过它所提供的业务价值体现出来。
  • DDD有战略设计和战术设计之分。战略设计主要从高层“俯视”我们的软件系统,帮助我们精准地划分领域以及处理各个领域之间的关系;而战术设计则从技术实现的层面教会我们如何具体地实施DDD。
  • DDD的战略设计主要包括领域/子域、通用语言、限界上下文和架构风格等概念。
  • 领域并不是多么高深的概念,比如,一个保险公司的领域中包含了保险单、理赔和再保险等概念;一个电商网站的领域包含了产品名录、订单、发票、库存和物流的概念。这里,我主要讲讲对领域的划分,即将一个大的领域划分成若干个子域。
  • 但是在DDD中,我们对系统的划分是基于领域的,也即是基于业务的。
  • 首先,哪些概念应该建模在哪些子系统里面?我们可能会发现一个领域概念建模在子系统A中是可以的,而建模在子系统B中似乎也合乎情理。第二个问题是,各个子系统之间的应该如何集成
  • 如何解决?答案是:限界上下文和上下文映射图。
  • 在一个领域/子域中,我们会创建一个概念上的领域边界,在这个边界中,任何领域对象都只表示特定于该边界内部的确切含义。这样边界便称为限界上下文。限界上下文和领域具有一对一的关系
  • 同样是一本书,在出版阶段和出售阶段所表达的概念是不同的,出版阶段我们主要关注的是出版日期,字数,出版社和印刷厂等概念,而在出售阶段我们则主要关心价格,物流和发票等概念。我们应该怎么办呢,将所有这些概念放在单个Book对象中吗?这不是DDD的做法,DDD有限界上下文将这两个不同的概念区分开来。
  • 将一个限界上下文中的所有概念,包括名词、动词和形容词全部集中在一起,我们便为该限界上下文创建了一套通用语言。
  • 通用语言是一个团队所有成员交流时所使用的语言,业务分析人员、编码人员和测试人员都应该直接通过通用语言进行交流
  • 你可以采用传统的三层式架构,也可以采用REST架构和事件驱动架构等。但是在《实现领域驱动设计》中,作者比较推崇事件驱动架构和六边形(Hexagonal)架构。
  • 在六边形架构中,已经不存在分层的概念,所有组件都是平等的。这主要得益于软件抽象的好处,即各个组件的之间的交互完全通过接口完成,而不是具体的实现细节。
  • 他们的作用即以业务用例为单位向外界暴露该系统的业务功能。在DDD中,这样的组件称为应用层(Application Layer)。
  • 应用层中不应该包含有业务逻辑,否则就造成了领域逻辑的泄漏,而应该是很薄的一层,主要起到协调的作用,它所做的只是将业务操作代理给我们的领域模型。同时,如果我们的业务操作有事务需求,那么对于事务的管理应该放在应用层上,因为事务也是以业务用例为单位的
  • 战略设计为我们提供一种高层视野来审视我们的软件系统,而战术设计则将战略设计进行具体化和细节化,它主要关注的是技术层面的实施,也是对我们程序员来得最实在的地方
  • 实体表示那些具有生命周期并且会在其生命周期中发生改变的东西;而值对象则表示起描述性作用的并且可以相互替换的概念
  • 值对象是没有唯一标识的,他的equals()方法(比如在Java语言中)可以用它所包含的描述性属性字段来实现。
  • equals()方法便只能通过唯一标识来实现了,因为即便两个实体所拥有的状态是一样的,他们依然是不同的实体,就像两个人的名字都叫张三,但是他们却是两个不同的人的个体
  • 比如一辆汽车(Car)包含了引擎(Engine)、车轮(Wheel)和油箱(Tank)等组件,缺一不可。一个聚合中可以包含多个实体和值对象,因此聚合也被称为根实体。聚合是持久化的基本单位,它和资源库(请参考下文)具有一一对应的关系。
  • 在一个聚合中直接引用另外一个聚合并不是DDD所鼓励的,但是我们可以通过ID的方式引用另外的聚合,比如在Blog中可以维护一个userId的实例变量。
  • 使用聚合的首要原则为在一次事务中,最多只能更改一个聚合的状态。如果一次业务操作涉及到了对多个聚合状态的更改,那么应该采用发布领域事件(参考下文)的方式通知相应的聚合
  • 值得一提的是,领域服务和上文中提到的应用服务是不同的,领域服务是领域模型的一部分,而应用服务不是。应用服务是领域服务的客户,它将领域模型变成对外界可用的软件系统
  • 领域服务不能滥用,因为如果我们将太多的领域逻辑放在领域服务上,实体和值对象上将变成贫血对象。
  • 库的一层很薄的封装,而资源库则更加具有领域特征。另外,所有的实体都可以有相应的DAO,但并不是所有的实体都有资源库,只有聚合才有相应的资源库
  • DDD的一个重要原则便是一次事务只能更新一个聚合实例。然而,的确存在需要修改多个聚合的业务用例,那么此时我们应该怎么办呢?
  • 领域事件便可以用于处理上述问题,此时最终一致性取代了事务一致性,通过领域事件的方式达到各个组件之间的数据一致性。
  • 既然是领域事件,他们便应该从领域模型中发布。领域事件的最终接收者可以是本限界上下文中的组件,也可以是另一个限界上下文。

◆ 用“四色建模法”进行建模

  • 第一步:寻找要追溯的事件
  • 第二步:识别“时标对象”
  • 按时间发展的先后顺序,用红色所表示的起到“追溯单据”作用的“时标”概念,
  • 寻找时标对象周围的“人、地、物”
  • 在“时标”对象周围的用绿色所表示的“人、地、物”概念,
  • 第四步:抽象“角色”
  • 第五步:补充“描述”信息

◆ 用“限界纸笔建模法”进行建模

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

◆ 限界纸笔建模法的3点优势

  • 划分核心领域有助于“分而治之”:一旦确定了核心领域,限界上下文也就确定了,不同的限界上下文之间通过“翻译器”来彼此沟通并屏蔽干扰,这样就避免了“大泥球”的设计,并有助于演进到微服务架构。
  • 2.“聚集根”有助于数据完整性:每个限界上下文都有一个“聚集根”的概念,外界对其下属概念的访问都必须通过它来进行,这样既方便定位职责,也有助于增强数据的完整性。
  • 3.用“纸和笔”画恰好够用的概念有助于避免过度设计:每个限界上下文中要管理的概念,都是通过“倒退到没有电脑而用纸和笔的时代如何管理”来引导出来的,用纸和笔来记

◆ 总结

  • 四色建模法最大的亮点是按时间发展的先后顺序,识别起“追溯单据”作用的“时标”概念,从而能把握业务核心数据,简便有效。
  • 限界纸笔建模法,使用了DDD中的”限界上下文”与“聚集”的概念以及“纸和笔来管理”方法,来实现“分而治之,增强数据的完整性,避免过度设计。

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

  • 第一是事件驱动可能是客观世界的运作方式,但不是人的自然思考问题的方式;第二是事件驱动架构在给软件带来好处的同时,又会增加额外的复杂性,比如调试的困难性,又比如并不直观的最终一致性
  • 我们需要考虑业务的建模、领域事件的设计、DDD的约束、限界上下文的边界以及更多技术方面的因素,这一个系统工程应该如何从头到尾的落地,是需要经过思考和推敲的

◆ 第一部分:领域事件的建模

  • 领域事件是DDD中的一个概念,表示的是在一个领域中所发生的一次对业务有价值的事情,落到技术层面就是在一个业务实体对象(通常来说是聚合根)的状态发生了变化之后需要发出一个领域事件。虽然事件驱动架构中的“事件”不一定指“领域事件”
  • 聚合根状态的更新而产生,另外,在事件的消费方,有时我们希望监听发生在某个聚合根下的所有事件,为此笔者建议为每一个聚合根对象创建相应的事件基类
  • 领域事件本身应该是不变的(Immutable);
  • 领域事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据
  • 发布领域事件有多种方式,比如可以在应用服务(ApplicationService)中发布,也可以在资源库(Repository)中发布,还可以引入事件表的方式
  1. 在更新业务表的同时,将领域事件一并保存到数据库的事件表中,此时业务表和事件表在同一个本地事务中,即保证了原子性,又保证了效率。
  2. 在后台开启一个任务,将事件表中的事件发布到消息队列中,发送成功之后删除掉事件。在第2步中,我们如何保证发布事件和删除事件之间的原子性呢?答案是:我们不用保证它们的原子性,我们需要保证的是“至少一次投递”,并且保证消费方幂等。此时的大致场景如下:
  3. 写入业务表;
  4. 写入事件表,事件表和业务表的更新在同一个本地数据库事务中;
  5. 事务完成后,即时触发事件的发送(比如可以通过Spring AOP的方式完成,也可以定时扫描事件表,还可以借助诸如MySQL的binlog之类的机制);
  6. 后台任务读取事件表;
  7. 后台任务发送事件到消息队列;
  8. 发送成功后删除事件。
  • 1.消费方的幂等性
  • 2.消费方有可能进一步产生事件
  • 1.事件通知
  • 2.事件携带状态转移(Event-Carried State Transfer)
  • 发布方发布事件
  • 2.消费方接收事件并处理
  • 3.消费方调用发布方的API以获取事件相关数据
  • 4.消费方更新自身状态
  • 种风格的好处是,事件可以设计得非常简单,通常只需要携带聚合根的ID即可,由此进一步降低了事件驱动系统中的耦合度。然而,消费方需要的数据依然需要额外的API调用从发布方获取,这又从另一个角度增加了系统之间的耦合性。此外,如果源系统宕机,消费方也无法完成后续操作,因此可用性会受到影响
  • 在“事件携带状态转移”中,消费方所需要的数据直接从事件中获取,因此不需要额外的API请求:
  • 这种风格的好处在于,即便发布方系统不可用,消费方依然可以完成对事件的处理。
  • 对于发布方来说,作为一种数据提供者的“自我修养”,事件应该包含足够多的上下文数据,而对于消费方来讲,可以根据自身的实际情况确定具体采用哪种风格
  • 事件驱动还存在第3种风格,即事件溯源

◆ CQRS实现模式概览

  • 查询模型(Query Model)也被表达为读模型(Read Model);命令模型(Command Model)也被表达为写模型(Write Model)。
  • CQRS的文章都将CQRS与Event Sourcing(事件溯源)结合起来使用,这容易让人觉得采用CQRS就一定需要同时使用Event Sourcing,事实上这是一种误解。CQRS究其本意只是要求“读写模型的分离”,并未要求使用Event Sourcing;再者,Event Sourcing会极大地增加软件的复杂度
  • 读写模型的分离并不一定意味着数据存储的分离,不过在实际应用中,数据存储分离是一种常见的CQRS实践模式,在这种模式中,写模型的数据会同步到读模型数据存储中,同步过程通常通过消息机制完成,在DDD场景下,消息通常承载的是领域事件(Domain Event)。
  • 共享存储/共享模型:读写模型共享数据存储(即同一个数据库),同时也共享代码模型,数查询据通过模型转换后返回给调用方,事实上这不能算CQRS,但是对于很多中小型项目而言已经足够
  • 共享存储/分离模型:共享数据存储,代码中分别建立写模型和读模型,读模型通过最适合于查询的方式进行建模
  • 分离存储/分离模型:数据存储和代码模型都是分离的,这种方式通常用于需要聚合查询多个子系统的情况,比如微服务系统。
  • 一般来讲,如果join次数达到了3次及其以上,建议考虑采用分离存储的形式
  • 先前的join操作太复杂或者太低效了,需要采用专门的数据库来简化查询提升效率。
  • 单独的查询服务用于CQRS的读操作,查询所需数据通常通过事件机制从不同的其他业务服务中同步而来
  • 读模型和写模型之间不再是强事务一致性,而是最终一致性。
  • 从用户体验上讲,用户发起操作之后将不再立即返回结果数据,此时要么需要调用方(比如前端)进行轮询查询,要么需要在用户体验上做些权衡,比如使用确认页面延迟用户对查询数据的获取。
  • 都提醒到需要慎用CQRS,因为它会带来额外的复杂性;而另有人(比如Gabriel Schenker)却提到,当前很多软件逻辑复杂性能低下恰恰是因为没有选择CQRS造成的
  • CQRS并不像人们想象中的那么难,通过适当的设计与选择,CQRS可以在很大程度上将程序架构变得更加的有条理,进而使软件项目在CQRS上的付出变成一件值得做的事情

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

  • 那些精妙的方案之所以落不了地,是因为没有在设计上兼容人类的愚蠢
  • 从上到下依次是系统System、容器Container、组件Component和代码Code。

◆ 四张核心图·系统上下文图

  • 关系——带箭头的线、元素——方块和角色、关系描述——线上的文字、元素的描述——方块和角色里的文字、元素的标记——方块和角色的颜色、虚线框(在C4里面虚线框的表达力被极大的限制了,我觉得可以给虚线框更大的扩展空间)。
  • 那么在系统上下文图里,方块指代的是软件系统,蓝色表示我们聚焦的系统,也就是我开发的系统(也可能是我分析的系统,取决于我是谁),灰色表示我们直接依赖的系统,虚线框表示的是企业的边界。
  • 在思维的世界里,我们都是盲人,很多东西我们以为自己知道,实际上画出来之后,才发现很多东西没想到,或者想的是乱的,同时别人也才可以给我们反馈。

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

  • 上文说过,C4说穿了就是几个东西:关系-线、元素-方块和角色(角色不过是图形不同的方块)、关系表述-线上的文字、元素的描述-方块里的文字,虚线框(如前文所说,在C4里面虚线框的表达力被极大的限制了)
  • 一般会有几个迹象表明我们有可视化的坏味道:
  • 一张图上过分密密麻麻的线。
  • 一张图上太过多元素(也就是方块)。
  • 一张图上太少的元素,比如角色特别少。
  • 每个图上文字表达不契合,有的太泛泛,有的太细节。
  • 无限制的画更多张图,基本上也就失去了使用图形化表达的意义。

◆ 合成更大的元素

  • 我们意识到有密密麻麻的线、太多的元素,闻到这个味道的时候,可以考虑是不是该把里面的一些元素合成更大的元素了。

◆ 画一些共识图来忽略掉一些通用的元素

  • 所以毫无疑问,做好共识管理,可以大幅简化我们的架构图。
  • 所以在我们做架构可视化的时候,经常会先画一个技术共识图,

◆ 通过制定主题,限制文字的抽象层次

  • 只是用于技术方面,如果用于业务方面,我们可以用一些抽象的名词或动词来代替一类业务,

◆ 只画重要的图,剩下的交流的时候再画

  • 不要试图在一张图上给他足够的信息。同时也不要试图把所有的信息都表达出来。
    绝大多数的图可能只在交流具体业务的时候才画,推荐使用动态图。

◆ 技术债治理

  • 为了解决短期的资金压力,获得短期收益,个人或企业向银行或他人借款,从而产生债务,这种债务需要付出的额外代价是利息
  • 如果短期商业的投资所带来的收益大于利息,这也许是一种明智的做法,但如果入不敷出,收益不及债务产生的利息就会导致资产受损。虽然长期来看这种投资仍然有可能扭亏转盈,但是整个过程风险很大,随时会导致个人或企业破产。
    如果把技术债的产生也看做一种投资,那么获得的短期收益可能是快速上线带来的商业利益,比如新的功能吸引了更多的付费用户,解决了短期之内的资金缺口问题;赶在竞争对手之前上线了杀手级应用,并快速地抢占了市场。
  • 技术债的存在的确有很多积极的意义,但是我们经常会过度关注积极的因素,而忽略了技术债长期存在所导致的“利息”问题。

◆ 技术债全景图

  • 可维护性(Maintainability)、可演进性(Evolvability),同时结合问题的可见性(Visibility)分析技术债对于软件开发过程的影响
  • 可维护性(Maintainability)主要指的是狭义上的代码问题,即代码本身可读性如何、是否容易被他人所理解、是否有明显的代码坏味道、是否容易扩展和增强。
  • 可演进性(Evolvability)指的是系统适应变化的能力。在生物学中它指的是种群产生适应性的遗传多样性,从而通过自然选择进化的能力
  • 可见性的分析可以依赖于外部视角:对于最终用户来说,软件功能、设计和用户体验等方面的缺陷,导致用户无法顺利完成既定的业务流程,那么对于用户不可见的代码问题就升级为了可见的质量问题;对于需求提供方来说,臃肿的技术架构、散落各处的业务逻辑导致产品无法快速响应需求变化,导致交付延期,那么对于无技术背景的业务人员来说,难以理解的、不可见的架构问题就升级为了可见的软件交付风险。

◆ 技术债治理的困境

  • 1.团队对于技术改进缺少战略思考
  • 2.代码可维护性问题很难说服客户买单
  • 3.效果不明显,客户信心不足

◆ 技术债治理的四条原则

  • 1.核心领域优于其他子域: 需要遵循“核心域优先、其他子域次之”的原则来选择技术债。也许我们可以把这种评估技术债优先级的方式叫做“Technical Debt Mapping”。
  • 2.可演进性优于可维护性:演进性问题可能会直接导致开发速度滞后,功能无法按期交付,使项目出现重大的交付风险。
  • 3.明确清晰的责任定义优于松散无序的任务分配:理想的情况下每个小组应该是一个价值趋向(Outcome Oridented)的团队,负责一个或者多个业务能力,原则上每个业务能力应该有且仅有一个团队负责。所以我们采用了一种比较折中的方案——服务责任人制度(Service Owner),一个小组负责一个或者多个微服务,每个微服务只由一个小组负责,在分配特性功能、技术债务和线上问题时,需要把服务责任人制度作为首要遵循的原则。
  • 4.主动预防优于被动响应:作为技术人员或者技术领导者,不仅要有前瞻性的技术洞察力、锐意变革的魄力,还需要以“旁观者”视角,置身事外地观察自己所处的环境,思考技术改进究竟对于自己、他人、团队、公司和客户究竟产生了什么价值。