DDD领域驱动设计

背景和来源

导读

一文看懂DDD

本文章侧重于DDD的实践和落地,对于理论概念涉及交浅,建议配合《领域驱动设计 软件核心复杂性应对之道》这本书加深理解

领域:从事一种专门活动或事业的范围、部类或部门

背景

  • 实体经济和ToB业务会成为未来的主战场
  • 深化数字化转型
  • 简单的系统已经被开发得差不多了(开箱即用的脚手架)

随着技术的发展,以后软件系统的开发方向有可能是挑战历史做不到的事情,这类系统往往庞大而复杂。而面对复杂的系统我们传统的设计模式便会显得捉襟见肘,亟需要新的理论来帮助我们设计复杂系统模型,而领域驱动设计便是一套指导复杂系统分析和设计的方法论

DDD的起源

DDD其实早在2003年便诞生了,刚诞生时并不火。直到微服务架构的流行,发现DDD能够解决微服务架构那些亟需解决的问题,包括项目复杂度的控制和服务的划分

核心思想和解决的痛点

模型和建模

模型是对现实的抽象和模拟

建模是针对特定问题建立领域的合理模型

总结同样的事物在面对不同领域(问题)的时候,它的模型是不一样的。建模一定是针对领域问题来建模。上图中的两个系统中,进销存中的商品条码对超市来说比较重要。而在电商系统中的商品属性,标签,图片对电商系统特别重要

软件系统复杂性来源

  • 建模阶段:把领域知识和业务需求转换成领域模型
  • 设计阶段:把模型转化成代码,需要考虑的是技术实现

业务复杂导致模型复杂

由上图可知,同样的事物在不同的领域(财务、印钞、防伪)的复杂度是不一样的

假设我们现在要为铸币厂设计一个内部系统,我们把上图的三个领域都放在一个模型中进行系统设计,那么我们将得到一个复杂的系统模型。这其实就是传统的面向对象思想存在的一个问题,在DDD出现之前,这一直是个痛点问题。当业务变得越来越复杂后我们依然采用面向对象思想在一个单一的模型里描述一个系统,这个系统的复杂度将不可控制!

解决方案:模型分解

技术实现引入额外复杂性

假设开发一个电商平台系统,系统中有两个团队进行开发,第一个团队专门负责数据接入开发,第二个团队负责商品服务开发

第一个团队:业务逻辑简单,当商家建立商品数据、门店时,只需要针对商品、门店建立实体写入表中

第二个团队:假设在商品搜索服务时,需要加入门店信息的搜索时,为了搜索的实时性,可能要求第一团队生成一张门店商品表(引入复杂性)

门店商品:商品服务需要实现根据门店距离实现进行商品搜索,那这个时候商品表和门店表的数据同步如何保持一致,无论哪张表延迟同步都有可能导致商品搜索不出来,因此又引入门店商品表,搜索时使用门店商品表进行搜索(门店商品表数据是在商家系统数据接入时生成的,同步给商品服务)

解决痛点

领域驱动设计(英语:domain driven design,缩写 DDD)是一种用于指导软件设计的方法论,也是一种设计思维方式,用于解决软件复杂性问题,旨在加速那些必须处理复杂领域的软件项目的开发

解决痛点:解决了面向对象思想无法解决的软件设计的复杂性问题(复杂领域)

核心思想

模型分解

对于一个复杂的系统,可以对模型进行分解,划分出不同的子领域,然后再针对不同的子领域进行建模,面向不同的子领域解决不同的问题,分而治之思想

DDD提供两个工具对模型进行分解

  • 领域划分:面向问题空间
  • 限界上下文:面向解决方案空间

模型驱动设计

Model Driven Design,通过分层架构隔离领域层、仔细选择模型和设计方案等措施保持实现与模型的一致

OOAD敏捷和DDD的区别与联系

在DDD诞生之前,在软件开发领域已经诞生一些思想和理论,其中的佼佼者就是面向对象程序设计敏捷开发

DDD与OOAD

  • OOAD:面向对象程序设计其实和DDD属于同一层面,因为它们都属于设计层级目的都是将问题(领域)和需求转换成模型,再将模型转换成方案
  • OOP:DDD的落地需要依赖于面向对象编程语言的实现

OOAD的不足:OOAD在面向大型系统设计时其实是有局限性的。原因是OOAD体系其实没有一个对系统有效划分的方法论,面向大规模复杂的系统,它会将多个子领域的问题放到一个模型中去考虑,得出的模型不管是对象本身的复杂性或对象和对象之间关系的复杂性都会变得异常复杂

DDD的优势战略设计的部分,它会面对系统遇到的领域进行拆分,最后得到一个拆分后的模型,是一种分而治之的方案,最后系统复杂性也得到控制

DDD与敏捷开发

在上个时代,每个软件工程的主体都是放在一个版本里开发的,软件工程会把这个版本划分为几个阶段,有细化阶段,需求分析阶段,实现阶段,测试阶段,部署阶段等。每个阶段的输出是下一个阶段的输入数据,顺序不会逆转回来,等项目上线后,一切尘埃落定。这种模式有一个明显的弊端,如果是前期的一些错误可能到项目的后期才会被发现,比如需求分析阶段,假设到了测试阶段才发现需求分析阶段的问题,那么纠正分析阶段的问题成本将异常的高,软件失败的风险也非常高。在这种缺陷下,敏捷开发应运而生。

敏捷开发主要包含两个方面的内容

  • 开发流程:把一个大的目标分成多个迭代去完成,每个迭代为一个冲刺(传统开发好比马拉松,敏捷是把这个马拉松划分为若干个百米冲刺,敏捷开发不提倡开发前期的整体设计)
  • 开发文化敏捷宣言

战略设计

智慧零售项目介绍

背景:线下零售依然占据GDP一定的比重,线上销售面临流量枯竭

智慧零售,自动售货机线下新零售。智慧零售的主要解决的问题

团队背景

  • A公司:SaaS公司,主要面向零售企业客户
  • SmartRM产品团队:一位资深产品经理,几位产品策划
  • SmartRM研发团队:一位架构师,10+开发,资深开发3至4位

客户背景

  • B集团: 零售行业某头部商家
  • 全国各地有大量商超和便利店,有成熟的内部系统和供应链
  • 基于自动售卖机的零售业务是新业务

案例优势

  • 复杂度可覆盖DDD大部分知识点并体现其价值
  • 智慧零售市场庞大且场景接近日常生活
  • 有数据分析需求,可结合大数据分析场景

建模和设计的流程

目的:对DDD和在项目中的应用有一个全局的认识

重点

  • 软件系统从需求到最终技术方案包含的环节

  • 各环节的目标和概要

  • 常用建模方法

所谓的建模和设计就是把领域知识和需求转换成代码的过程,它可以分为四个阶段

  • 挖掘用户故事
  • 建立通用语言
  • 战略设计
  • 战术设计

这四个阶段的开始时间有先后,但并不是上一个阶段结束下一个阶段才开始,而是重叠的。对用户故事的挖掘和建立通用语言是最先开始的,可能会贯穿整个建模设计的过程。同样,战术设计的开始也不意味着战略设计的结束,在战术设计的过程中我们有可能发现战略设计的不合理从而回过头对战略设计进行优化。同时在战略设计中,我们也不断会对通用语言进行丰富,甚至发现业务流程中的优化点,从而回过头重新研究用户故事(建模涡流思想)

建模参与人员

  • 领域专家:非常重要的角色,指的是在某个领域有深度经验的专业人士(比如医疗系统,医生就是领域专家)

  • 产品团队:现实中产品团队经常扮演领域专家角色,在有必要的情况下还是需要邀请领域专家进行讨论

  • 研发团队:负责将建立起来的模型以编码形式落地

用户故事

  • 什么角色
  • 希望做什么
  • 最终达到什么目的

用户故事更多的是对问题的描述,而不是解决方案,通过从问题出发,通过整个团队的讨论认证(领域专家,产品经理,研发人员,讨论过程中固化其图文)最终达到一个接近的解决方案

售卖机用户故事:作为一个消费者,我希望通过手机支付从售卖机中购买商品,从而能够更方便购物

给出用户故事后团队就可以开始对故事进行讨论,一边讨论一边画图,通过图文可快速把讨论结果固化

通过这一讨论过程,会发现慢慢从问题空间进入方法空间(消费者怎么通过售卖机购买商品)

通用语言

目的是拉齐各方人员的领域认知

  • 在讨论模型和定义模型时,团队使用的同一种语言
  • 领域知识需要在团队内部高效流转,模型需要描述
  • 通用语言要体现在代码里(类名 属性名等)

在对用户故事进行挖掘的时候,就已经实在建立通用语言了,在讨论的时候,大部分时间是领域专家进行讲解。其他人要做的是理解领域专家提到的各种概念,及时提问同时进行知识固化,对讨论中存在歧义的关键词汇建立统一认知,甚至固化下来形成文档

战略设计

  • DDD中对问题空间和解决方案空间进行分解的过程
  • 目的是分解模型以控制复杂性
  • 是DDD与传统建模和设计方法的核心区别之一

包括内容

  • 领域划分
  • 寻找限界上下文(BC)
  • 确定上下文映射:上下文之间的关系,确定上下文映射的过程还会加入防腐层(领域自我保护)

战术设计

  • 对各个限界上下文的细节设计过程
  • 限界上下文内部的模型结构与完整技术方案

内容包含

  • 实体
  • 值对象
  • 服务
  • 模块
  • 聚合
  • 工厂
  • 资料库

这些元素确定下来后代码基本也就确定下来了,战术设计包括编码环节(Eric Evans认为设计人员或者架构师是需要深度参与编码的)

建模方法

从用户故事到通用语言

用户故事和建立通用语言和战略设计的关系

因为建模一定要从问题空间出发,而且通用语言也要尽早开始建立,因为无论是描述模型还是团队沟通都需要通用语言。所以在正式开始战略设计之前,从用户故事开始对领域进行探索,可以使得开发人员迅速迅速学习领域知识并且在团队内部初步建立通用语言,为后面的领域划分寻找限界上下文等工作做好铺垫

什么是用户故事

在软件开发中,用户故事是一种对软件系统特性的非正式的自然语言描述,是敏捷软件开发中从终端用户的角度对软件系统特性进行捕捉的一种方式。用户故事描述了不同类型的用户需要什么以及为什么需要,它可以帮助我们创建需求的简单描述。

总结:用户故事就是对问题的描述

在软件开发的需求阶段,就让产品经理对软件的功能进行详细的设计(可能设计并不能落地),不仅是时间上的浪费,也会让团队过早陷入细节。用户故事就提供了一种恰到好处的粒度,直接对需求(问题)进行描述

用户故事的构建

  1. 简单描述用户需求;
  2. 围绕简单描述进行讨论;
  3. 明确如何验证

分别对应用户故事的三个元素,也就是3C: Card(卡片)Conversation(谈话)Confirmation(验证)

Card (卡片)

卡片就是指对用户故事的简述(传统上人们通过便利贴在白板上构建用户故事),一个好的用户故事卡片包括三
个要素:

  1. 谁:谁需要这个功能;
  2. 需要什么:想通过系统完成什么事情;
  3. 为什么:为什么需要这个功能,这个功能带来什么样的价值;

Conversation (谈话)

谈话是指用户领域专家产品经理研发之间围绕用户故事进行的讨论,谈话是明确需求细节的必要环节。可以用文字对谈话进行简要记录,此外,也可以基于图形或其他工具进行讨论。(比如使用 domain storytelling 挖掘用户故事)

Confirmation (验证)

验证代表了验收测试,描述了客户或者产品owner怎样确定用户故事已经被实现,且能够满足需求。一般可以用如
下模板写Confirmation:

1
2
3
假设我是<角色>,在xxx情况下,
当我<操作>
那么<结果>

用户故事必须是可以验证的

SmartRM通用语言文档

场景模拟

售卖机扫码支付购物

  • 卡片

    作为用户,

    我希望在售卖机上通过手机扫码支付购买商品,

    以便快速便捷地购买商品。

  • 谈话

    P:用户在设备屏幕上选择商品后,设备展示支付二维码,用户使用微信扫描二维码,完成支付后,设备完成出货,交易结束,设备屏幕上回到商品列表界面

    D:这里的设备是指自动售卖机吗?

    P:是的。

    D:那么我们以后统一用”售卖机“这个词吧? 英文用Vending Machine。

    P:没问题。

    D: 如果用户支付失败,会怎么样?

    P:售卖机会等待一段时间,然后取消交易,回到商品列表界面

    D:售卖机出货会失败吗?

    P:有可能,不过我们还是要找懂这套售卖机的人了解下

    D: 是的,我把运营人员O拉进来聊下。

    P:0,我们在对SmartRM系统进行建模,想咨询一些售卖机相关的问题。售卖机出货会失败吗?

    0:我们应用的售卖机主要包括自动称重式柜门机弹货道售卖机蛇形货道售卖机。其中,称重式柜门机特点是售卖的商品类型多,不会卡货,主要用于办公楼、商超、小区等室内外公共场所的饮料、零食、生鲜等商品的售卖,弹簧货道售卖机的特点是成本低,售卖商品类型多,卡货概率高,主要用于室内外等公共场所的饮料、零食的售卖,蛇形货道售卖机的特点是体积一般较大、库存容量大、卡货概率低、省电,但是只能用于饮料的售卖,主要放在体育场、工厂、学校、公园等室内或室外公共场所用于饮料售卖。目前我们有一部分存量弹簧货道售卖机,后面大部分室内的会替换成自动称重柜门机,但是仍然会保留一部分。我们的售卖机可靠性很优秀,但是还是会有几种出货失败的情况。比较常见的出货失败的情况有以下几种: 1) 卡货,2)售卖机网络问题,3) 库存错误

    意义:用户故事的讨论能够让我们快速的明确流程,熟悉领域知识同时建立通用语言,同时让团队之间的理解达成一致

  • 验收标准

    假设我是一名用户,货道售卖机屏幕的商品列表上有商品A,B,C当我在售卖机屏幕上选择了商品A,并扫描展示的二维码完成支付后那么商品A就会从售卖机中弹出,我可以拿到商品A

    • 研发人员:单元测试的依据
    • 测试人员:测试计划的依据
    • 产品人员:验收的依据

Domain Storytelling

通过上面的谈话我们能够发现其实是比较松散的,可以借助 领域故事陈述 Domain Storytelling 将关键细节记录起来,建立通用语言,形成简要文档

domain story modeler 领域故事陈述工具

通过领域故事陈述建立起的模型可以将不同角色的认知进行对齐,并且图例的关键词的统一其实就是建立通用语言的过程

通用语言

  • 一种描述模型且基于模型的语言
  • 团队在进行所有交流时都使用它
  • 代码中也要体现

包含的内容

  • 类和操作的名称
  • 施加于模型之上的规则和约束(如商品补充的库存不能超过容量)
  • 应用于领域模型的模式(工厂,资料库…)

我们对用户故事进行storytelling,其实就是建立领域通用语言的过程,storytelling的输出结果 (上述的storytelling图)也就包含了领域通用语言的完整语句,对象、角色、活动、以及体现它们相互作用的完整语句在图中都可以一览无余。这里我们可以更进一步,将通用语言中的词汇提炼出来,将其中英文都列在通用语言词汇表中,这些词汇将会贯穿整个建模和设计过程,最终也会体现在代码中,因此团队中所有成员,都需要明确理解其含义,并且在相关讨论、模型、以及代码中使用它们

领域划分和子域

内容

  • 什么是领域划分和子域
  • 为什么要进行领域划分
  • 基于用户故事分解的领域划分方法

什么是领域划分

  • 领域:DDD会按规则细分业务领域,细分到一定程度,会将问题范围限定在特定边界,在该边界内建立领域模型,进而用代码实现该领域模型,解决相应业务问题,领域就是该边界内要解决的业务问题域(对业务进行边界划分)

  • 子域:领域可进步划分为子领域。划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或业务范围

  • 领域划分是以分离关注点为原则对问题空间的划分

  • 子域是领域中某个方面的问题和解决它所涉及的一切

为什么进行领域划分

领域划分是一个分而治之的思想,核心是将一个大而复杂的问题拆解成一个个较小的问题

问题点和领域知识重叠:图中的两个研发都需要关注三个问题点,存在重复劳动,并且工作上可能还出现重叠

模型重叠:抽象出的商品,订单,售卖机的模型设计工作由谁展开?

不同子域聚焦不同问题:职责明确

工作效率低 职责冲突

smartRM项目的六大顶级用户故事

  • 售卖机扫码支付购物
  • 柜门机免密购物
  • 售卖机投放
  • 补货
  • 售卖机撤销
  • 经营分析

初步领域划分

按照面向的用户不同,可以把六大用户故事初步划分为交易域和运营域

怎样进行领域划分

基于故事分解的领域划分

模拟交易域的用户故事分解

然后再根据拆分的用户故事进行归类,最终得到下面的图

最终根据团队的不断优化,一个系统被拆分为以下的领域

核心域和精炼

子域的类型

  • 核心域:决定产品和公司核心竞争力的子域是核心域,它是业务成功的主要因素和公司的核心竞争力。
  • 通用域:没有太多个性化的诉求,同时被多个子域使用的通用功能子域是通用域。
  • 支撑域:还有一种功能子域是必需的,但既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域,它就是支撑域。

核心域和研发力分配

DDD认为,对于项目和产品来说,最核心人员应该把精力投入到核心域的设计和开发里来,尽量减少团队在其他部分的投入,这样才能提升产品和业务的核心竞争力

精炼

精炼就是不断提炼和压缩的过程

通过精炼,分理出领域普通的部分,最终得到一个核心域。精炼可以让团队用尽可能小的代价换取最大的成功概率

案例中的精炼

  • 战略设计要明确核心域,团队尽量减少非核心域投入
  • 核心域的建立总是伴随着精炼,精炼有两种方法

限界上下文

限界上下文是一种语义上的上下文边界。意思是在这个边界里的软件模型组件都有它特定的含义并且做特定的事。一个限界上下文内的组件都是上下文特定的并且语义明确的

前文已经提到,同一个事物在面向不同的场景、问题或领域(或限界上下文)时,表现的复杂度和语义是不一样的

同一台售卖机在运营眼里,用户眼里是不一样的。在运营眼里,它是一台库存不断消耗的又需要及时补充库存的商品容器,在用户眼里它就是一个购物平台。所以在运营和用户(或者叫上下文)眼里提到的售卖机其实是有差别的。我们要让这个词具备单一的含义,单一的作用那就需要明确指出来,它在哪个上下文。或者换句话说它的限界上下文是什么,这就是限界上下文本质的含义

为什么需要限界上下文

  • 自然语言具有模糊性(同一个词在不场合在不同人眼里通常具有不同的意义)
  • 同一个事物面向不同场景有不同模型(上文的售卖机)
  • 软件系统需要分解模型以控制复杂性(需要通过限界上下文来分解复杂的模型)
  • 限界上下文是分工的单位(高内聚低耦合特性,一个限界上下文可以交给一个研发或者团队负责)

如何划分限界上下文

Domain Storytelling (领域故事陈述法)

这里识别边界主要利用了概念之间的语义差别

Domain storytelling中边界特征

  • 单向联系(库存计划到配送订单的箭头)
  • 语义区别
  • 活动的触发方式不一样(比如图中售卖机的投放和库存计划就是不同的触发方式,没有划分边界主要是考虑到售卖机的投放和库存计划的制订是紧密相关的,本质都是解决库存问题,设计前期的原则是宁缺毋滥)

Event Storming (事件风暴法 推荐)

目前最热门的方法,DDD中最重要的建模方法,后续会讲

基于子域概念提取

通过分别从各子领域的用户故事中提取关键概念,审视它们之间的关系,以及它们与外部系统之间的关系,我们可以梳理出系统中的限界上下文。如下图所示

把子域和限界上下文的划分结果整合

限界上下文和微服务

DDD和微服务

微服务是限界上下文的实现方式,一般一个限界上下文对应一个服务

上下文映射模式

限界上下文并不是孤立的,它们之间需要协作才能完成系统的功能。划分之后的限界上下文以及团队之间如何进行协作,怎样去理解限界上下文之间的关系,这就引出了上下文映射的概念

上下文映射(Context Mapping)

  • 上下文映射是指限界上下文之间的模型映射关系
  • 描述团队之间的协作关系以及上下文之间的集成关系
  • 决定上下文之间如何集成以及如何设置防腐层(代理层,将其他模型转换本上下文模型)

上图的上下文中存在概念重叠的地方,比如支付二维码和商品,这种现象是正常的,这就是限界上下文的意义所在。但是在系统实现的过程中需要处理一个问题,当我们集成两个概念重叠的上下文需要怎么处理?是将其中一个转换成另一个或多个?还是其中一个向另外一个看齐?其实这就是模型和模型之间的映射关系。当需要集成另一个上下文,就需要看到这个上下文暴露出来的概念

上下文映射模式

模式名称备注
Partenership合伙人
Shared Kernel共享内核
Customer/Supplier客户/供应商
Conformist顺从者
Anticorruption Layer防腐层
Separate Ways分道扬镰(独立方法)
Open Host Service开放主机服务
Published Language公开语言
Big Ball Of Mud大泥球

开放主机服务

  • 服务提供方为所有消费方提供一套公共的API
  • 针对通用的功能和模型

微信支付是支付上下文的上游,微信支付提供通用的支付API。通用API服务因为难以定制化,其隐含了另一种模式顺从者

顺从者

  • 没有模型到模型的转换
  • 一个上下文沿用另一个上下文的部分模型

顺从者模式隐藏一个风险,当你顺从一个上下文的时候,其实也就表明了你允许其对你的侵入。如果有多个支付渠道,那么也就意味着需要引入多个上下文模型,如果不采用特殊方法处理,当前上下文可能会变得混乱可能会变成大泥球

大泥球

  • 由混杂的模型构成的糟糕系统,模型不稳定且难于维护
  • 与大泥球合作的上下文要确保自身不被污染,设置防腐层

防腐层

  • 把上游上下文的模型转换成自己上下文的模型
  • 是下游上下文中访问外部模型的一个代理层

共享内核

  • 两个上下文共享部分模型

  • 比如把核心源码封装为一个jar或者starter甚至是数据表等

  • 慎用,仅当团队紧密合作且共享部分稳定,合作紧密的团队又隐含另一模式

合伙人

  • 技术无关,是一种团队协作关系
  • 两个团队之间可以随时互通有无,协同变更

客户/供应商

  • 下游上下文可以向上游上下文提需求

  • 一般用于核心域与非核心域之间的协作

  • 核心域(下游)会向非核心域提需求(现实中支撑子域可能由外包团队负责)

分道扬镳(独立方法)

  • 两个上下文无协作,各自独立
  • 当两个上下文之间的集成成本过高

比较常见的是新旧系统的集成,原本旧系统如果就是一个大泥球,旧系统的集成就会相对困难。新系统可能会放弃集成,自己实现

公开语言

  • 标准化与协议化的模型
  • 所有上下文都可以与公开语言中的模型进行转换
  • 对接了公开语言的上下文之间可以实现组件化对接

例子

  • 蓝牙协议、tcp/ip
  • Java生态的jdbc、jvm标准等
  • SQL

分层架构

  • 严格按照领域模型来编写代码(通用语言命名)
  • 建模和实现中都有破坏该原则的因素
  • 架构分层能够避免模型在实现过程中被省略或者污染

建立分层架构的目标是实现领域驱动设计中的一个重要原则,模型驱动设计。模型驱动设计简而言之就是我们需要遵循模型来编写代码,使得代码的设计不受到其他因素的干扰,代码和模型实现一致。但现实中这种原则很容易受到干扰,比如出于数据库性能考虑,我们代码会创造出领域不存在的实体,因此分层架构用于避免模型在实现过程中被省略或者污染

传统分层

传统分层围绕数据结构编码

传统架构分层的入口是controller,处理业务会调用service,如果处理的业务逻辑有远程调用则会调用client,最后会查询或保存数据调用dao层,返回数据用到entityvo层。这样的分层架构看上去中规中矩,其实就是我们要尽量避免的反模式。因为在entity包中可能存在大量非领域实体的实体,在这种分层架构下,其实我们一直是围绕数据在编写代码,领域模型其实已经被我们忽略掉了,这个模型其实属于贫血模型。在这种分层架构下我们的设计其实都是在围绕这数据在设计(或者说围绕数据表结构设计,先设计表结构然后开始编码),这种模式最后会演变为业务代码被存储层绑架,业务逻辑和技术实现混杂在一起,领域模型最终也被技术方案绑架,传统的架构模型存在以下问题

  • 领域模型易被省略,变成贫血模型
  • 容易演变成基于数据的设计,一切从表结构开始
  • 领域模型与技术实现混杂,易被技术实现绑架

四层架构

目的:让各层之间形成一个良性的单向依赖

四层架构是DDD中经典的架构,把架构分为

分层英文描述
表现层User Interface用户界面层,或者表现层,负责向用户显示解释用户命令
应用层Application Layer定义软件要完成的任务,并且指挥协调领域对象进行不同的操作还包含事务的控制。该层不包含业务领域知识。
领域层Domain Layer或称为模型层,系统的核心,负责表达业务概念,业务状态信息以及业务规则。即包含了该领域(问题域)所有复杂的业务知识抽象和规则定义。该层主要精力要放在领域对象分析上,可以从实体,值对象,聚合(聚合根),领域服务,领域事件,仓储,工厂等方面入手
基础设施层Infrastructure Layer主要有2方面内容,一是为领域模型提供持久化机制,当软件需要持久化能力时候才需要进行规划;一是对其他层提供通用的技术支持能力,如消息通信,通用工具,配置等的实现;

四层架构落地

接口层(表现层)

领域层

应用层

应用服务不在访问数据层(直接操作dao类),应用服务会去协调领域层的元素,实体,资源库,领域服务等让它们配合完成请求。具体的业务逻辑被放到了领域层。通过四层架构,这里的设计和编码已经不围绕表结构去进行了(资料库是实体和表结构的解耦),领域模型就可以变得相对独立(模型不受表结构影响),不再被技术方案所绑架,这样设计人员才能把精力聚焦到领域模型的建立和实现上

基础设施层

四层架构解决的问题

  • 分离关注点
  • 让领域模型层更独立
  • 单向依赖

缺陷

  • 领域层对基础设施层仍然有感知,领域模型和技术实现耦合(因为资料库的实现依赖于dao类)

六边形架构

洋葱架构,六边形架构

  • 保持领域层的纯粹性,不受其他因素干扰
  • 便于践行模型驱动设计,代码跟随模型
  • 便于把团队精力集中到领域模型

四层架构演化而来的,核心思想是把领域模型放到了核心层,领域层变得存粹和独立,本文参考了六边形架构和洋葱架构落地,将洋葱架构的User Interface层改为Adapter

应用层

和上文没有太大区别

领域层

资源库的实现类被移到了适配器层,领域层只保留了资料库的接口。因为资源库本质完成的是领域模型和存储层之间的数据交换

基础设施层

把原先基础设施层远程服务调用拆分成两部分,接口放到了领域层,实现放到了适配器层。因为调用其他上下文的服务其实就是对上下文映射的一种实现,是领域模型之间的数据交换。需要在adapter层中完成其他模型到本模型数据的转换,防腐层可以在适配器层中实现

适配器层

实现消息的收发(controller)

六边形架构的特点是让领域模型变得非常干净,领域模型非常独立,不再受到业务无关因素的影响

初涉战术设计

环境准备,约定

项目 smartrm-monolith

语言Java
IDEIntelliJ IDEA
JdkJava SE Development Kit 8
依赖管理和构建应Maven 3
应用开发框架Spring boot 2.5.4
关系型数据库访问ORMMybatis 3.5.6
通用工具库Guava 30.1.1-jre
定时任务调度Quratz 2.3.2

命名约定

对象含义所处层业内常用命阿里规范采用命名
视图层对象接口 (适配) 层VOVOVO (能省则省)
数据传输对象应用层DTODTODTO
数据存储对象基础设施层PO(Persistent Object)DO(Data Object)DO
领域对象领域层DO(Domain Object)BO(business object)根据领域通用语言命名

前期先使用单体架构实现DDD,后续重构为微服务架构

项目结构

包结构

交易域准备工作

  • 交易域业务流程熟悉
  • 针对交易域进行战术设计分析
  • 核心域上下文的依赖准备工作

货道售卖机操作流程

柜门售卖机操作流程

在战略设计时(场景模拟),我们通过用户故事使用 Domain Storytelling(领域故事陈述法)建立起一个模型。在这个图中我们能够看到重要的角色和活动(标记序号的是活动),角色有可能是领域中的某种用户或者某一种系统,活动就是从角色触发的领域中的某一种行为。在途中我们能看到角色和活动以及活动发生的顺序 ,但是我们看不到系统中的重要对象他们之间的关系。因为在战略设计的用户故事建模中我们围绕的就是对角色和活动进行分析,基本上没有机会去探讨对象之间的关系。所以通过这个图我们还是不知道系统中关键对象的特性是什么,对象之间又是怎样进行协作的,以至于我们不知道如何编写代码

因此我们必须针对战术设计进行分析,需要分析出对象之间的关联

对象间关系

  • 一个对象为另一个对象的状态变更提供数据

    比如售卖机商品列表,就需要商品库存提供信息过滤商品,也需要商品信息提供商品明细进行展示

  • 一个对象的状态变更导致另一个对象的状态变更

    订单状态的变化影响支付,支付状态的变化影响订单状态

战术设计阶段就是要尽量挖掘对象之间的关系,和战略设计分析的思路不同,重点不再围绕用户和其他领域中的角色以及从这些角色出发的活动进行分析。而转变到挖掘领域中的对象和对象之间的关联

domain-story-modeler 是以用户,角色,活动为核心,只有从角色出发的箭头才有标号(设备上下文到交易上下文无法放置标号),因此需要借用UML建模里的时序图

整合

将战术故事的领域故事陈述结合时序图后,就得到上下文交互图。箭头的标注我们能够看到上下文提供的接口

实体和值对象

参考 实体和值对象

  • 实体:是指描述了领域中唯一的且可持续变化的抽象模型,有ID标识,有生命周期,有状态(用值对象来描述状态),实体通过ID进行区分;其二是要跟踪状态的变化;
  • 值对象:值对象的核心本质是值,与是否有复杂类型无关,值对象没有生命周期,通过两个值对象的值是否相同区分是否是同一个值对象

特征

实体

  • ID相等性

  • 要跟踪状态变化

    比如身份证上的身份证号,头像

值对象

  • 属性相等性

  • 可互换

  • 不变性

    比如身份证上的出生地址

区分的原因

  • 值对象往往更轻量级
  • 尽可能用值对象而不是实体
  • 值对象不用跟踪变化
  • 实体和值对象在领域中扮演的角色不一样

实体可以作为一个聚合根,而值对象不可以

区分

  • 通过特征区分
  • 是否只读
  • 生命周期是否跨越活动

以身份证举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 实体
public class IdCard {
// id
private Long id;

// 身份证号
private String IdNumber;

// 头像
private String avatar;

// 地址
private Address address;

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getIdNumber() {
return IdNumber;
}

public void setIdNumber(String idNumber) {
IdNumber = idNumber;
}

public String getAvatar() {
return avatar;
}

public void setAvatar(String avatar) {
this.avatar = avatar;
}

public Address getAddress() {
return address;
}

public void setAddress(Address address) {
this.address = address;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 值对象
public class Address {
public Address(String province, String city, String address, String detail) {
this.province = province;
this.city = city;
this.address = address;
this.detail = detail;
}

// 省份
private String province;

// 城市
private String city;

// 区
private String address;

// 详细地址
private String detail;

public String getProvince() {
return province;
}

public String getCity() {
return city;
}

public String getAddress() {
return address;
}

public String getDetail() {
return detail;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;

Address address1 = (Address) o;

if (province != null ? !province.equals(address1.province) : address1.province != null) return false;
if (city != null ? !city.equals(address1.city) : address1.city != null) return false;
if (address != null ? !address.equals(address1.address) : address1.address != null) return false;
return detail != null ? detail.equals(address1.detail) : address1.detail == null;
}

@Override
public int hashCode() {
int result = province != null ? province.hashCode() : 0;
result = 31 * result + (city != null ? city.hashCode() : 0);
result = 31 * result + (address != null ? address.hashCode() : 0);
result = 31 * result + (detail != null ? detail.hashCode() : 0);
return result;
}
}

领域对象的构造

Order

对象构造是谁的职责,如何确保相关对象的一致性

使用工厂模式解决领域对象的构造。一个聚合的领域对象一般是由聚合根提供构造方法的,而聚合根的构造一般在领域服务或者应用层被构造

  • 工厂方法模式
  • 抽象工厂模式

如何兼顾对象构造的简便性和对象的封装性

  • 建造者模式

实体ID应该如何生成

  • 基于已有信息的拼接
  • 基于数据库表自增ID
  • 基于独立的ID生成器

资源库与持久化

OrderRepository

OrderRepositoryImpl

什么是资源库

  • 为每种需要全局访问的对象类型创建一个对象,这个对象相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的全局接口来提供访问
  • 带必要管理功能的领域对象容器,与技术实现无关

资源库的意义

  • 提供一个管理领域对象的简单模型
  • 使领域模型和持久化技术解耦,它可以屏蔽存储层的技术细节

在领域层提供一个资源库接口暴露给上层使用,大多数情况下,资源库底层也是使用Dao类实现,主要完成表对象和领域对象的转换和解耦

资源库的实现

领域实体通常也会使用充血模式,实体除包含get set方法,还包含对象的持久化操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface OrderRepository {

// 根据订单id获取订单
Order getOrderById(long orderId);

// 新增订单
void addOrder(Order order);

// 更新订单信息
void updateOrder(Order order);

// 新增或修改订单信息
void addOrUpdate(Order order);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Repository
public class OrderRepositoryImpl implements OrderRepository {

@Autowired
OrderMapper orderMapper;

@Autowired
DomainEventBus eventBus;

@Override
public Order getOrderById(long orderId) {
// 表实体
OrderDo orderDo = orderMapper.selectOne(orderId);
try {
// 字符串转对象
StockedCommodityDo[] commodityDos = new ObjectMapper()
.readValue(orderDo.getCommodities(), StockedCommodityDo[].class);
// 表实体转领域实体,实现领域模型和底层存储的解耦
return Order.Builder()
.orderId(orderId)
.paymentId(orderDo.getPaymentId())
.type(OrderType.of(orderDo.getType()))
.machineId(orderDo.getMachineId())
.state(OrderState.of(orderDo.getState()))
.commodities(Arrays.stream(commodityDos).map(
d -> new StockedCommodity(d.getCommodityId(),
d.getName(),
d.getImageUrl(),
d.getPrice(),
d.getCount())).collect(
Collectors.toList()))
.eventBus(eventBus).build();
} catch (JsonProcessingException e) {
throw new DomainException(CommonError.PersistentDataError);
}
}
...

聚合

聚合根提供外部访问聚合内部对象接口

限界上下文和聚合:一个限界上下文可能包含多个聚合

聚合就是一组相关对象的集合,我们把它作为数据修改的单元。每个聚合都有一个根和一个边界。聚合根是聚合所包含的一个特定实体。对聚合而言,外部对象只可以引用聚合根,而边界内部的对象之间则可以互相引用

聚合是拥有事务一致性 (强一致性)的领域对象组合

  • 聚合内的实体适用事务一致性
  • 聚合之间适用最终一致性
  • 不脱离聚合根修改聚合内部对象

聚合根有全局唯一标识,聚合内部实体只有局部标识

聚合根可以从资源库获取,聚合内部实体不能

比如一辆汽车,汽车就是聚合根,而引擎就是一个和汽车相关的对象。比如汽车引擎的维修必须通过汽车对象,首先必须把汽车拖进维修厂

聚合解决什么问题

  • 优雅地实现一致性
  • 聚合是限界上下文粒度的下限

聚合的识别

实体是否在所有活动中都协同变更

在本项目中,货道售卖机,订单,支付三个实体,是否属于同一个聚合?货道售卖机和订单不管是用户在选择商品之后或者是用户超时取消订单之后或扫码支付之后它们的状态是协同变更的,要么货道售卖机售卖机处于交易状态,订单处于开始状态;要么货道售卖机处于就绪状态,订单处于成功或取消状态。我们可以认为货道售卖机和订单属于同一个聚合,判断聚合根需要两个因素

  • 直接面向用户请求
  • 谁的生命周期更长

项目中货道售卖机是直接面向用户请求的,而且货道售卖机在处理一个订单后可以处理下一个新的订单,货道售卖机的生命周期会比订单长。所以货道售卖机是聚合根。支付这个实体和货道售卖机不属于同一个聚合,因为支付属于外部系统,无法保证一致性,因此支付和货道售卖机不属于同一个聚合

实现聚合

应用层服务:AppTradeService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 用户选择商品
*/
@Transactional
public PaymentQrCode selectCommodity(SelectCommodityCmdDto cmd) {
// 获取聚合根
SlotVendingMachine machine = machineRepository.getSlotVendingMachineById(cmd.getMachineId());
if (machine == null) {
LOGGER.warn("vending machine not found:{}", cmd.getMachineId());
throw new DomainException(TradeError.VendingMachineNotFound);
}

// 校验商品
CommodityInfo commodityInfo = commodityService.getCommodityDetail(cmd.getCommodityId());
if (commodityInfo == null) {
LOGGER.warn("commodity not exist:{}", cmd.getCommodityId());
throw new DomainException(TradeError.CommodityNotExist);
}

// 构建库存商品
StockedCommodity commodity = new StockedCommodity(
commodityInfo.getCommodityId(),
commodityInfo.getCommodityName(),
commodityInfo.getImageUrl(),
commodityInfo.getPrice(),
1
);

// 远程调用,开始支付,获取二维码
// 支付和货到售卖机不属于同一个聚合
PaymentQrCode code = machine
.selectCommodity(Lists.newArrayList(commodity), deviceService, payService,
cmd.getPlatformType());
Map<String, Object> params = Maps.newHashMap();
params.put("orderId", machine.getCurOrder().getOrderId());
params.put("machineId", machine.getMachineId());
// 超时支付设置
scheduler.scheduleRetry(TradeExpireExecutor.class, params, 30 * 1000, 1000);
return code;
}

聚合根:SlotVendingMachine

聚合根的聚合对象的持久化交给聚合根的资料库操作,聚合根负责和外部对象交互,被依赖的对象只能通过聚合根和外部对象交互

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 选择商品
public PaymentQrCode selectCommodity(Collection<StockedCommodity> commodities,
TradeDeviceService deviceService, TradePayService payService, PlatformType platformType) {
//校验设备状态
if (state != SlotVendingMachineState.Ready) {
throw new DomainException(TradeError.VendingMachineStateNotRight);
}

//校验库存
if (!checkInventory(commodities, deviceService)) {
throw new DomainException(TradeError.InventoryCheckFail);
}

// 生成订单
curOrder = this.generateOrder(commodities);
emitEvent(new OrderCreatedEvent(this.machineId, curOrder));
state = SlotVendingMachineState.Trading;

// 远程调用,开始支付,获取支付二维码
PaymentQrCode ret = payService.startQrCodePayForOrder(platformType, curOrder);
// AOP切面,更新订单
curOrder.setPaymentId(ret.getPaymentId());
// 乐观锁机制
incVersion();
return ret;
}

// 订单和货道售卖机是聚合关系,售卖机可以看作是订单的聚合根
private Order generateOrder(Collection<StockedCommodity> commodities) {
return Order.Builder().commodities(commodities)
.orderId(UniqueIdGeneratorUtil.instance().nextId())
.state(OrderState.Start)
.type(OrderType.SlotQrScanePaid)
.machineId(this.machineId)
.eventBus(eventBus)
.build();
}

// 完成订单
public void finishOrder(long orderId, TradeDeviceService deviceService) throws Exception {
if (this.curOrder == null || this.curOrder.getOrderId() != orderId) {
LOGGER.warn("order finished when slot vending machine has release it:{},{}", machineId,
orderId);
return;
}
//弹出商品
if (curOrder.getCommodities().size() > 1) {
throw new DomainException(CommonError.UnExpected)
.withMsg("slot vending machine only support one commodity order");
}
for (StockedCommodity commodity : curOrder.getCommodities()) {
deviceService
.popCommodity(curOrder.getMachineId(), commodity.getCommodityId(), curOrder.getOrderId());
}
this.curOrder.succeed();
this.state = SlotVendingMachineState.Popping;
incVersion();
}

// 取消订单
public void cancelOrder() {
if (curOrder == null || curOrder.getState() == OrderState.Canceled) {
LOGGER.warn("cancel order state not right.");
return;
}
curOrder.cancel();
state = SlotVendingMachineState.Ready;
//curOrder = null;
// 乐观锁机制
incVersion();
}

聚合根资料库:VendingMachineRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void updateSlotVendingMachine(SlotVendingMachine machine) {
if (!machine.isVersionInc()) {
//版本号未改变时直接跳过
return;
}
TradeSlotVendingMachineDo machineDo = new TradeSlotVendingMachineDo();
machineDo.setMachineId(machine.getMachineId());
machineDo.setState(machine.getState().code());
machineDo.setCurOrderId(machine.getCurOrder() != null ? machine.getCurOrder().getOrderId() : 0);
machineDo.setVersion(machine.getVersion());

// 聚合根资料库持久化聚合对象
if (machine.getCurOrder() != null) {
orderRepository.addOrUpdate(machine.getCurOrder());
}
int updated = slotVendingMachineMapper.update(machineDo);
if (updated == 0) {
LOGGER.error("fail to update slot machine, version:" + machineDo.getVersion());
throw new DomainException(CommonError.ConcurrencyConflict);
}
LOGGER.info("update slot machine, version:" + machineDo.getVersion());
}

这样聚合根就完成了聚合中的所有对象的操作,收敛了所有聚合对象的操作(只能通过聚合根操作)

领域服务

TradeCommodityService

TradeCommodityServiceImpl,实现在适配层,因为需要进行模型转换

当领域中的某个重要的过程或转换操作不是实体或值对象的自然职责时应该在模型中添加一个作为独立接口的操作,并将其声明为领域服务定义接口时要使用模型语言,并确保操作名称是通用语言中的术语。此外应该使领域服务成为无状态的,领域服务只包含业务逻辑

场景:比如货道售卖机此时开展了运营活动,如果是新用户第一次购买商品时打八折,由于运营活动不是实体和值对象,因此可以使用领域服务声明一个ActivityService,用于计算新用户商品总额

应用层

定义软件要完成的任务,并且指挥表达领域概念的对象来解决问题。这一层所负责的工作对业务来说意义重大,也是与其他系统的应用层进行交互的必要渠道。应用层要尽量简单,不包含业务规则或者知识,而只为下一层中的领域对象协调任务,分配工作,使它们互相协作

比如在一个汽车模型中,汽车的领域层包括,开门,关门,启动,刹车,转弯,倒车。而应用层是“从家里开车到公司”,那么应用层要实现汽车从家里开到公司只能是协调汽车的开门,关门,启动等操作后,最终才能完成家到公司的需求

应用层的职责

应用层服务:AppTradeService

  • 事务控制
  • 身份认证和访问权限
  • 定时任务调度
  • 事件订阅
    • 事件监听 (适配层,可能包含事件对象转换)
      事件处理(应用层)

深入战术设计

领域事件为什么重要

在DDD中,聚合根的读写操作,出于对性能的考虑,其读模型和写模型可能是相对独立的。写模型落地到数据库后通过领域事件通知读模型进行数据同步,读模型对于性能的考量,采取性能更优秀的NoSql存储,比如Redis

建模工具

事件风暴建模法

  • 便利贴:线下开会
  • Miro:线上工具

领域事件

订单支付使用领域事件:Order

DomainEvent 领域事件,单机模式使用spring自定义事件实现

什么是领域事件

  • 领域中发生的任何领域专家感兴趣的事情
  • 领域事件一般由聚合产生
  • 领域事件不是技术概念

事件命名和基本属性

  • 命名方法:动词+名词 finishOrder
  • 事件ID:全局唯一
  • 产生时间

发布订阅方式

  • 外部系统
    • API定向通知,比如支付的回调
    • API定时拉取,提供公开API
    • 消息队列
  • 内部系统
    • 观察者模式,单体架构
    • 数据库流水(binlog)
    • 消息队列

事件存储

  • 直接使用消息中间件的存储(内存或文件)
  • 基于数据库(mongodb就是不错的选择)

事件处理的要求

  • 顺序性
    • 聚合ID
    • 存储分片
    • 消费分组
  • 幂等性:(用幂等性代替分布式事务,最终一致性保证)

领域事件和大数据分析

事件风暴建模法

事件风暴

事件风暴讲解

事件风暴讲解2

  • 一种协作式的对复杂业务领域进行探索的讨论形式
  • 一种灵活易调整的的轻量级的适用于DDD的建模方法

应用场景

  • 评估已有业务线的健康度并发现优化点
  • 探索一个新业务模型的可行性
  • 设想为各个参与方能带来最大利益的新服务
  • 设计整洁的可维护的软件以支持快速推进的业务

事件风暴核心

  • 领域事件
  • 聚台
  • 决策命令
  • 角色
  • 读模型
  • 策略
  • 外部系统
  • 问题/热点

参与角色

4 ~ 8人规模,人太多沟通效率太低

  • 研发人员

  • 产品经理

  • 领域专家

列出主要领域事件

  • 橙色便利贴
  • 动词过去式
  • 和领域专家相关

收集关注点和问题

  • 紫色便利贴

  • 问题

  • 风险/关注点

  • 假设

  • 讨论点

通过命令深入领域

  • 蓝色代表命令
  • 黄色代表角色

找到聚合

从领域事件反向驱动出命令后就要找到聚合,聚合链接了领域事件和决策命令

  • 处理领域逻辑
  • 处理命令
  • 产生领域事件

找出读模型

  • 帮助用户做出决策
  • 数据查询

策略

  • 响应式逻辑
  • 响应领域事件
  • 触发命令

外部系统

  • 第三方服务

  • 对当前领域来说是外部

  • 比如微信支付

事件风暴的几个任务

  • Big Picture(描绘出全景图)
  • 业务处理流程
  • 软件设计

高效事件风暴的注意事项

  • 首先关注学习和倾听
  • 谈话和例子很关键
  • 锚定到具体的业务用例
  • 澄清模糊概念

正向驱动

反向驱动

运营域事件风暴建模

防腐层构建

图中的运营上下文和ERP系统上下文间就存在一个防腐层,使用ACL标记。防腐层就是上下文之间的一个转换层,它的作用就是防止上游上下文的复杂和混乱扩散到下游上下文(上下文的自我保护层)

在案例中,当运营人员决定在某个地方投放安装售卖机的时候,需要在系统中下投放订单。最终提交到客户(第三方)的ERP系统内部,由客户的采购和运营人员最终执行,这里的ERP系统是一个业务复杂度非常高的系统,和我们的领域区别也比较大。我们的系统和客户ERP系统交互的过程中,如果没有任何隔离措施,那么ERP内部系统的复杂性,一些系统概念就会扩散到我们系统

上图为ERP的对接文档,这些字段在我们的上下文中不好理解,在领域上下文应该只要关注运营相关的概念就可以了,不需要关注外部系统的概念,否则外部系统概念入侵到运营域,会增加运营域的复杂度

防腐层落地

将运营上下文的投放订单和客户ERP的采购订单进行转换

投放订单

VendingMachineInstallOrder

ERP采购订单对象放在适配层

投放订单服务

DevicePurchaseService

投放订单服务实现放到设配层,因为需要对投放订单和ERP的采购订单进行转换

DevicePurchaseServiceImpl:实现了领域值对象和外部系统对象的转换,将外部系统的业务逻辑隔离在适配层,方式外部系统的业务入侵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/**
* @description: erp防腐层示例,调用采购服务申请安装售卖机
*/
@Service
public class DevicePurchaseServiceImpl implements DevicePurchaseService {

private static String ERP_BUSSINESS_TYPE = ""; //业务类型
private static String ERP_BILL_TYPE = ""; //单据类型

// 售卖机型号Mapper
@Autowired
private VendingMachineModelMapper machineModelMapper;

private K3CloudApi client;

@PostConstruct
public void init() {
client = new K3CloudApi();
}

@Override
public void placeInstallOrder(VendingMachineInstallOrder order) {
// 获取售卖机物料信息
VendingMachineModelDo modelData = machineModelMapper
.selectByCode(order.getDeviceModel().code());

//售卖机投放订单 -> ERP系统采购订单
FPOOrderEntry orderEntry = new FPOOrderEntry();
orderEntry.setFEntryID(0);
//物料信息
orderEntry.setFMaterialId(new ERPNumberId(modelData.getMaterialId()));
orderEntry.setFMaterialDesc(modelData.getMaterialDesc());
orderEntry.setFProductType(modelData.getProductType());
orderEntry.setFProcesser(new ERPNumberId(modelData.getProcessor()));
orderEntry.setFBomId(new ERPNumberId(modelData.getBomId()));
//数量
orderEntry.setFStockQty(order.getCount());
//TODO: 填充订单项目更多字段

//创建采购订单
ERPPurchaseOrder purchaseOrder = new ERPPurchaseOrder();
purchaseOrder.setFBusinessType(ERP_BUSSINESS_TYPE);
purchaseOrder.setFBillTypeID(new ERPNumberId(ERP_BILL_TYPE));
purchaseOrder.setFPurchaseOrgId(new ERPNumberId(modelData.getPurchaseOrgId()));
purchaseOrder.setFPurchaseDeptId(new ERPNumberId(modelData.getPurchaseDeptId()));
purchaseOrder.setFPurchaserId(new ERPNumberId(modelData.getPurchaserId()));
purchaseOrder.setFDate(order.getCreatedTime().format(DateTimeFormatter.BASIC_ISO_DATE));
purchaseOrder.addEntry(orderEntry);
//TODO: 填充采购订单更多字段

// 提交采购订单
try {
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(purchaseOrder);
String resultString = client.save("PUR_PurchaseOrder", json);
JsonNode resultJson = objectMapper.readTree(resultString);
if (!resultJson.get("Result").get("ResponseStatus").get("IsSuccess").asBoolean()) {
throw new DomainException(OperationError.ERPError);
} else {
String id = resultJson.get("Result").get("ID").asText();
String number = resultJson.get("Result").get("Number").asText();
order.setOrderId(new InstallOrderId(id, number));
//提交订单
ObjectNode submitRequest = objectMapper.createObjectNode();
submitRequest.putArray("Ids").add(id);
submitRequest.putArray("Numbers").add(number);
String submitResult = client
.submit("PUR_PurchaseOrder", objectMapper.writeValueAsString(submitRequest));
resultJson = objectMapper.readTree(submitResult);
if (!resultJson.get("Result").get("ResponseStatus").get("IsSuccess").asBoolean()) {
throw new DomainException(OperationError.ERPError);
}
}
} catch (DomainException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

大数据服务实现经营数据分析

准备

使用JMeter 或 Locust 压测框架生成测试数据

kafka数据同步到OSS

阿里巴巴大数据治理平台 DataWorks

  • 使用压测工具模拟请求
  • 交易上下文发布领域事件到Kafka
  • 使用Datax消费Kafka数据,并同步到OSS对象存储
  • 使用阿里巴巴 DataWorks 大数据解决方案,OSS作为数据输入源,配置数据形成大数据报表

DDD和微服务

单体架构开发虽然简单,但是多个上下文都在同一个项目中集成部署,上下文中遇到性能瓶颈非常难以排查。多个上下文共享同一个DB,当业务越来越庞大后,DB面临的读写压力也会越来越大

微服务架构的好处

  • 技术异构性:不同的服务可以使用不同的技术栈,甚至使用不同的语言开发
  • 容错性:服务之间的性能问题和故障不会相互影响
  • 灵活扩展:不同服务可以根据业务动态扩缩容
  • 简化部署:大服务拆分成模块化部署
  • 与组织结构匹配:多个开发团队可以同时开发不同的微服务模块
  • 可组合性:多个服务可以组合成新的应用
  • 方便替代和升级:当需要替换或重构某个服务的时候不会牵连其他服务(保证暴露接口的一致性即可)

微服务的基础

  • 服务注册和发现
  • 服务监控
  • 熔断降级(高峰期服务节点分配给核心业务,周边服务节点让出资源)
  • 流量控制
  • 安全性
  • 配置管理

微服务的问题和DDD的答案

服务划分

如何划分限界上下文

服务划分一直是微服务落地一个比较具有争议性的问题,服务划分的边界和粒度按照不同的理解有不同的划分手段,DDD给出的答案是根据限界上下文划分微服务,每个限界上下文都是一个独立的服务,上下文之间互不影响

微服务框架基础设施

微服务架构的落地需要解决服务治理问题,而服务治理依赖良好的底层方案。当前,微服务的底层方案总的来说可以分为两种:微服务SDK务框架)和服务网格

微服务SDK

应用程序通过接入SDK来实现服务治理,SDK运行在应用程序的上下文(相同进程),构建后成为应用程序的一部分,常见有实现方式有

  • Spring Cloud OpenFeign 打包 jar包暴露服务API
  • Dubbo 将服务Interface打包成 jar包暴露服务

SDK的方式通常伴随代码入侵,当SDK升级不向下兼容时,下游的服务不管是否有业务变更也得被迫升级

服务网格

通过Sidecar模式,用单独的代理进程接管应用程序的网络流量,从而实现服务治理,借助代理进程,可以实现服务的流量控制(访问权限控制流、熔断等等)、服务发现、负载均衡等等服务治理相关功能

Istio 服务网格从逻辑上分为数据平面控制平面

  • 数据平面 由一组智能代理(Envoy)组成,被部署为 Sidecar。这些代理负责协调和控制微服务之间的所有网络通信。它们还收集和报告所有网格流量的遥测数据。
  • 控制平面 管理并配置代理来进行流量路由

Istio

Istio组件

网格可视化

微服务框架的选择

服务发现对比

SpringCloud

Istio

Istio 服务发现与负载

基于DDD思想进行服务拆分

smartrm-micro-services

使用限界上下文拆分服务

领域事件改造

DomainEvent:领域事件接口,微服务不再使用Spring自定义事件

DomainEventBus:时间总线接口,用于发布事件, 队列名称就是事件类名

SimpleEventBusImpl:消息总线实现,底层使用Kafka发布事件

DomainEventHandler:事件处理器接口

DomainEventListener:领域事件监听器,其底层主要是kafkaConsumer,利用构造函数传入的事件类型和Handler监听和处理事件

DomainEventListenerAppRunner:Kafka配置

交易上下文微服务改造

因为交易上下文和其他上下文交互最多,因此选择交易上下文作为例子

涉及到跨领域调用的需要在交易服务添加一个共享层。共享模型可以考虑抽取公共jar包或者放在共享内核(弊端是可能会导致共享内核频繁升级)

AppTradeService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Transactional
public PaymentQrCode selectCommodity(SelectCommodityCmdDto cmd) {
SlotVendingMachine machine = machineRepository.getSlotVendingMachineById(cmd.getMachineId());
if (machine == null) {
LOGGER.warn("vending machine not found:{}", cmd.getMachineId());
throw new DomainException(TradeError.VendingMachineNotFound);
}

// 远程服务调用,接口订单在领域层,实现在适配层,因为需要进行Dto到本领域实体的转换
CommodityInfo commodityInfo = commodityService.getCommodityDetail(cmd.getCommodityId());
if (commodityInfo == null) {
LOGGER.warn("commodity not exist:{}", cmd.getCommodityId());
throw new DomainException(TradeError.CommodityNotExist);
}
StockedCommodity commodity = new StockedCommodity(
commodityInfo.getCommodityId(),
commodityInfo.getCommodityName(),
commodityInfo.getImageUrl(),
commodityInfo.getPrice(),
1
);
PaymentQrCode code = machine
.selectCommodity(Lists.newArrayList(commodity), deviceService, payService,
cmd.getPlatformType());
Map<String, Object> params = Maps.newHashMap();
params.put("orderId", machine.getCurOrder().getOrderId());
params.put("machineId", machine.getMachineId());
scheduler.scheduleRetry(TradeExpireExecutor.class, params, 30 * 1000, 1000);
return code;
}

应用层使用远程服务调用商品信息,远程服务调用底层使用的是RestTemplate

RestTemplateConfig:RestTemplate配置类

TradeCommodityServiceImpl:商品远程服务调用实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Autowired
private RestTemplate commodityRestTemplate;

@Override
public CommodityInfo getCommodityDetail(String commodityId) {
String path = "/detail/" + commodityId;
ParameterizedTypeReference<CommonResponse<CommodityInfoDto>> reference = new ParameterizedTypeReference<CommonResponse<CommodityInfoDto>>() {
};
ResponseEntity<CommonResponse<CommodityInfoDto>> response = commodityRestTemplate
.exchange(path, HttpMethod.GET, null, reference);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new DomainException(CommonError.UnExpected)
.withMsg(response.getStatusCode().toString());
} else if (response.getBody().getCode() != CommonError.NoError.getCode()) {
throw new DomainException(CommonError.UnExpected).withMsg(response.getBody().getMsg());
}
// Dto和领域实体转换
CommodityInfoDto dto = response.getBody().getData();
return new CommodityInfo(dto.getCommodityId(), dto.getCommodityName(), dto.getImageUrl(),
dto.getPrice());
}

改造好后的微服务架构图

k8s容器编排

  • Kubernetes是容器集群管理系统,是一个开源的平台
  • 硬件资源管理调度、应用部署的事实标准
  • 业务架构师需要懂运维架构

K8S架构原理

K8S 服务类型

Kubernetes集群由一个Master节点(为了高可用也可以使用多Master互为备份)和多个Worker节点构成

​ Master节点中主要包含三个组件:API Server、Scheduler、Controller。其中API Server负责对集群内外提供集群信息的restful接口,从etcd读据;Controller是集群的管理控制组件,负责感知和调整集群状态,根据用户的请求控制集群,Controller包含多种不同的Controller,如ReplicationController,Node Controller等等,分别执行集群中不同方面的管理工作;Scheduler是调度器,负责为应用的Pod分配部署的Worker节点

​ Worker节点主要由两部分构成:kubelet 和 kube-proxy。kubelet主要负责Worker节点上的容器管理,它会向Master汇报当前节点的状态信息,从Master节点获取和执行指令,对节点上Pod的生命周期进行管理,使节点的状态向目标状态靠拢;kube-proxy则负责节点上的网络管理,负责消息的路由转发(确切说是负责相应路由规则的配置)

服务网格

Istio核心组件

  • Trace:整个调用链
  • Span:某个服务调用

链路调用跟踪的核心是在每个请求中生成一个TraceID,当A服务调用B服务调用C服务将TraceID向下传递(其实就是埋点),最终达到全链路跟踪目的

常用开源的组件有 zipkinSkyWalking

实践中的问题和关键

持续集成

什么是CI/CD

  • CI(持续集成)
    通过自动化流程持续把各个开发者的工作集成到一起避免过大的集成成本
  • CD(持续交付)
    通过自动化测试和部署流程使软件系统随时处于可发布状态

CI/CD也是微服务的重要基础

持续集成工具对比:Flow vs Jenkins

CI/CD核心

  • 单元测试:代码覆盖率(尽量代码覆盖)
  • 集成测试:上下文边界
  • 功能测试
  • 回归测试

所有的测试工作尽可能通过代码自动进行

领域沟通和建模避免漏掉重要细节

深层模型

若开发人员识别出设计中隐含的某个概念或是在讨论中受到启发而发现一个概念时,就会对领域模型和相应的代码进行许多转换,在模型中加入一个或多个对象或关系,从而将此概念显式地表达出来。有时这种从隐式概念到显式概念的转换可能是一次突破,使我们得到一个深层模型

在之前的篇章中,我们和领域专家在谈话的过程中使用领域叙事构建了上图柜门及免密购物的模型,在整个谈话的过程中,我们都是围绕用户的行为在做一系列讨论,整个过程很顺畅,没有什么问题,但是和下图我们最终建立起的模型后发现有很大的区别

我们漏掉了一个很重要的实体账号,导致漏掉了整个用户上下文,在前期和领域专家的谈话过程中,我们根本就没有发现用户这个实体,因为在领域专家或业务专家的眼里是看不到用户这个概念的,他们只能看到用户拿着手机在购物,以及用户和微信的交互,他们可能不清楚免密支付是需要用户签署支付协议,下单支付时需要依赖用户实体的(技术层面)。所以在谈话过程中就没有出现账号这个词,但是在我们的系统实现过程中,是离不开用户这个对象的。首先遇到的问题就是判断用户是否要打开柜门这个权限,并且其他子域中可能对用户上下文也有依赖,比如运营域要对用户进行深入分析…

原因

  • 以活动作为建模的核心,模型过于偏向业务
  • 漏掉重要分支
  • 复杂系统难免漏掉细节

应对方法

  • 不要单纯以角色的行为(活动)为中心进行沟通和建模
  • 领域沟通过程中,研发人员发挥主动性
  • 场景走查

建模是一个不断完善自我修复的过程,建模完成后不是一成不变的

DDD的争论和局限性

资源库与领域服务的区别

  • 资源库:负责实体发的读写逻辑
  • 领域服务:负责处理业务逻辑

DDD是否过度设计

“按照DDD写代码的话似乎对编码的要求与系统理解更复杂了,一个业务是由多个领域对象同时分担处理,要是此时项目紧急加人进来做新业务,完全无法了解这些领域对象究竟具体提供了哪些服务,有一种系统被过度设计的感觉。”

  • DDD落地到业务逻辑简单,性能要求高的系统
  • 模型问题,DDD就是根据需求建立通用语言后设计的模型,领域专家和技术专家应该能够理解领域对象

DDD的出现只是辅助复杂系统的分析和设计,并不是解决所有代码结构问题

战略设计和战术设计

DDD就是一套方法论,一个作用是用于拉齐项目中各个角色对于需求的认知,高效率的让项目中涉及的”知识”在团队内流转,这个是战略设计的作用,另一个作用就是将战略设计划分的领域模型,通过使用战术设计的各种“武器”,比如实体,值对象,仓储层,防腐层等等,将领域模型落地成高度抽象且领域层稳定的代码

DDD是否被神化

《领域驱动设计 软件核心复杂性应对之道》书名已经给出了答案,觉得DDD被神化之前,首先需要有一个概念,什么事复杂系统?一个由几个微服务组成的系统算复杂系统吗? 个人认为,觉得DDD被神化可能有一个原因是拿DDD和目前正在进行的项目进行模拟落地对比。要知道,DDD不是一套通用的方法论,他提供的是一套面向复杂系统指导和设计的方法论。我们可以向淘宝,京东看齐,他们的电商系统有多少个类?面对这样庞大的复杂系统,DDD能够做的是指导架构设计合理化,以便架构能够适应业务的落地和变化,降低架构设计失误的风险(前几年有多少借着微服务的风口重构了不适应业务变化的系统?),这才是大厂核心部门要推行DDD的原因。所以觉得DDD被神化需要看到的它对复杂系统架构的指导意义,不要盲目拿DDD和现有系统进行落地比较

DDD是一套不完善的方法论

DDD其实是一套由工程师梳理出来的方法论和模式(聚合,聚合根,值对象),它指导了复杂系统的分析和设计,贴合业务建模是它的侧重点(建立通用语言,战略设计)。但是它在对理论落地的性能方面并没有相关指导,比如聚合根的读写效率(一个庞大的聚合根的读写可能最后会是压垮DB的最后一根稻草)。因此DDD寻找的是一种贴合业务(对象使用领域实体命名),便于业务理解的建模和代码落地方法论。落地DDD性能调优也将会是一个大的挑战


DDD领域驱动设计
https://wugengfeng.cn/2022/12/21/DDD领域驱动设计/
作者
wugengfeng
发布于
2022年12月21日
许可协议