领域驱动设计(DDD)以及落地实践

前言

领域驱动设计是一项艰巨的技术挑战,但它也会带来丰厚的回报,当大多数软件项目开始僵化而成为遗留系统时,它却为你敞开了机会的大门。

现在面临的问题

过度耦合

过度耦合有两方面,一方面是领域之间没有拆分,由于业务初期,我们的功能大都非常简单,普通的CRUD就能满足,此时系统是清晰的。随着迭代的不断演化,业务逻辑变得越来越复杂,我们的系统也越来越冗杂。模块彼此关联,谁都很难说清模块的具体功能意图是啥。修改一个功能时,往往光回溯该功能需要的修改点就需要很长时间,更别提修改带来的不可预知的影响面。

另一方面是业务逻辑和一些胶水适配的逻辑耦合。有时候我们的代码写得不好,往往是在我们代码中依赖了大量的外部服务,而这些服务又往往不是为我们定制的,我们需要写大量的适配。结果就是我们的核心业务逻辑被那些非核心业务逻辑所淹没。比如我们有一个给用户发逾期短信的用例,本来是很简单的一个业务逻辑,跑出所有的逾期分期单并发出逾期事件,监听这些逾期事件去取手机手机,然后调用短信平台的接口发送短信。但是历史原因我们的手机号码保存在不同的地方,我们先取金融手机号码,如果不存在金融手机号码则查询用户开白条时实名手机号码,如果不存在实名手机号码则获取申请分期贷款时填写的手机号码。结果就是我们的发逾期短信的业务逻辑中大部分都是取获取手机号码的逻辑。

贫血症和失忆症

在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。

当我们接到一个项目后,我们很容易就会想到这个项目中涉及的数据的载体,这个载体上应该有什么数据。然后就基于这个数据进行串连流程。

简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。

业务规则泄露

在我们开始接触软件开发时就被告知,代码复用可以极大的提高我们的工作效率。所以大部分后端研发提供的API接口或者方法都是极其开放的,而且我们是直接使用数据库模型,往往一个更新接口可以更新这个模型的所有字段,这样几乎可以适用于所有的更新场景。结果就是所有的调用方都可以修改这个模型的几乎所有属性。我甚至看到过可以修改数据库自增ID的接口。这个是一个极端的例子。

回到业务上来。我们有一个订单表,里面有一个订单的状态。我们提供了一个更新订单的接口,入参就是Order对象。

@Getter

@Setter

class Order {

    private int status;

}

interface OrderMapper {

    int update(Order);

}

结果任何调用方都可以更新这个订单的状态。作为订单的维护人员根本不知道这个订单表的状态是谁修改了,修改成了什么样。这个就相当于业务逻辑泄露了。作为这个订单领域的负责人都已经失去对这个订单的把控了。失控是非常危险的,这种不确定性增加了出问题的机率。

软件核心复杂性应对之道

统一语言

同一对象在不同上下文中的概念可能是不同的。

领域太复杂,只有在分割的上下文内才可能形成统一语言。

比如同一件物品,iPhone12ProMax512G。在购买的上下文中是商品,但是在配送上下文中是货物。在不同的上下文中,关注的属性也是不一样的。

战略设计

战略设计侧重于高层次、宏观上去划分和集成限界上下文。消费金融目前主要支持了白条和金条业务线。在我们进行业务体系化的建设中,把消金的信贷领域划分成了授信域、用户域、用户域、账户域、交易域、营销域、触达域等一级业务域,每个一级业务域下再根据具体分析拆分二级业务域。

领域划分

限界上下文划分

上下文映射


如何识别限界上下文

可以从两个方向识别限界上下文:

纵向:识别用例或者事件,倘若相邻两个事件之间的关系较弱,或者体现了两个非常明显的阶段,就可以对其进行分割。

横向:梳理所有的用例,根据组成用例的名词和动词去发现用例之间的相关性(相同、相似的名称),然后去提炼一个整体的概念。

识别限界上下文遵循的原则

单一抽象层次原则:每个限界上下文从概念上应尽量处于同一个抽象的层次,不能嵌套。

正交原则:限界上下文之间不能互相影响,互相包含。

战术设计

战术设计主要是围绕着领域模型为主。通用业务用例或者故事点分析梳理总结出实体和值对象,然后通过分析它他们之间相关梳理出聚合来。

领域对象划分

无状态和有状态

对象是有状态的,服务是无状态的。由于Spring以及失血模型的流行,我们大量的业务逻辑都是在无状态服务中的。

落地实践

事件风暴

事件风暴是一种快速探索复杂业务领域和对领域建模的实践。事件风暴从领域中关注的业务事件出发,在此过程中团队经过充分讨论,统一语言,最后找到领域模型。

事件风暴是一项团队活动,领域专家与项目团队通过头脑风暴的形式,罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对每一个事件,标注出导致该事件的命令,再为每一个事件标注出命令发起方的角色。

命令可以是用户发起,也可以是第三方系统调用或者定时器触发等,最后对事件进行分类,整理出实体、聚合、聚合根以及限界上下文。

核心概念

事件(Event):事件风暴的核心概念,事件是过去发生的与业务有关的事实。一般使用宾语+动词的过去式,例如:申请单被风控审批通过,订单被支付成功。

命令(Command):命令即动作,命令会改变对象的状态,并产生相应的事件。比如:成功支付订单、取消订单。

用户(User或者Actor):命令是由对象执行的,这称之为用户。用户可以是自然人,也可以是系统,这里一般指自然人。

规则(Policy):当产生事件或者执行命令时,需要进行某些业务相关的规则校验。比如用户参加拼团活动,需要校验活动是否有效等。

执行模型


用户执行了命令,命令生成了事件,事件触发了规则校验。

如何利用事件风暴构建领域模型

事件风暴的参与者

组织者:组织者应当熟悉事件风暴的整个流程,能够组织大家顺利完成事件风暴;

领域专家:领域专家应该是精通业务的人,在事件风暴过程中,要负责澄清一些业务上的概念,思考业务上有没有遗漏的事件;

项目成员:负责开发这个项目的成员,所有角色都可参加,比如BA、QA、UX、DEV。因为事件风暴可以快速让整个团队了解整个项目的业务流程

寻找领域事件

由寻找领域事件开始。领域事件一般用橘色的便利贴表示,书写领域实践的规则是使用被动语态,并按照时间顺序贴在白纸上。

最开始可能很多成员都不知道该怎么写,或者不知道该怎么寻找领域事件。可以由组织者写下领域中发生的第一个事件。其它参与者会迅速的开始模仿,这时我们可以让大家快速的进入状态。

在遇到有疑惑的事件时,不必长时间阻塞在那里讨论,把它作为标记记下来即可,后续再进行重点优化。可以贴一个比较醒目的便签纸(比如紫色)在事件旁边。

随着我们对业务认识的不断加深,可以随时回顾和总结之前添加的内容,对于有问题的描述进行更正,对于表述不清楚的内容可以进行重写。

事件是有相对顺序的。可以把一系列有相对顺序关系的事件放在一行上,从左到右排好。这样有助于梳理领域事件,查看是否有遗漏。

寻找命令和角色

在收集完领域事件后,我们可以在此基础上进一步探索系统核心事件的运行机制。这里我们在之前的领域事件的基础上加入指令和角色的概念。

指令代表系统中用户的意图、动作和决定,一般用蓝色的便利贴表示;角色表一类特定用户,一般用黄色便利贴表示。它们之间的关系是“角色”发送“指令”产生了“领域事件”(指令也可由外部系统触发,外部系统通常用粉色的便利贴表示)。

在寻找命令和角色的过程中,你可能会遇到某些命令会在“特定的条件下”触发。比如:“当用户通过新的设备登入时,系统会发送提醒通知”。通常,我们将这种系统的行为逻辑称为策略,通常记录在紫丁香色的便利贴上,放在命令旁边。

寻找领域模型和聚合

当我们做完了上一个环节,就可以开始寻找系统中的领域模型和聚合了。我们把跟一个概念相同的指令和事件集合到一起,并用黄色的较大的便利贴表示领域模型。

把跟这个领域模型相关的命令放到左边,事件放到右边。需要注意的是,这个时候会去掉“事件的相对顺序”这个概念,因为我们已经不需要了。

可能有些领域模型不能作为一个独立存在的对象。它应该被另一个领域模型持有和使用。那这时候,可以考虑把两个模型合起来,形成一个聚合。在最上面的模型就是这个聚合的聚合根,其之下的模型都是它的实体或值对象。

划分领域和限界上下文

找到领域模型以后,我们应当就可以比较轻松地划分子域和限界上下文了。

在划分限界上下文的时候也可以反过来检验领域模型和通用语言的正确性。如果发现一个模型有歧义,那它就应该是限界上下文边界的地方,我们应该重新思考这个模型,必要时进行拆分。

应用落地

分层架构

分层架构发展

四层架构

传统的四层架构都是限定型松散分层架构,即Infrastructure层的任意上层都可以访问该层(“L”型),而其它层遵守严格分层架构。

四层架构


六边形架构

Alistair Cockburn在2005年提出,解决了传统的分层架构所带来的问题,实际上它也是一种分层架构,只不过不是上下或左右,而是变成了内部和外部。在领域驱动设计(DDD)和微服务架构中都出现了六边形架构的身影,在《实现领域驱动设计》一书中,作者将六边形架构应用到领域驱动设计的实现,六边形的内部代表了application和domain层,而在Chris Richardson对微服务架构模式的定义中,每个微服务使用六边形架构设计,足见六边形架构的重要性。

六边形架构


洋葱架构

2008 年 Jeffrey Palermo 提出了洋葱架构,它在端口和适配器架构的基础上贯彻了将领域放在应用中心,将传达机制(UI)和系统使用的基础设施(ORM、搜索引擎、第三方 API...)放在外围的思路。但是它前进了一步,在其中加入了内部层次。

端口和适配器架构与洋葱架构有着相同的思路,它们都通过编写适配器代码将应用核心从对基础设施的关注中解放出来,避免基础设施代码渗透到应用核心之中。这样应用使用的工具和传达机制都可以轻松地替换,可以一定程度地避免技术、工具或者供应商锁定。

还有,任何一个外部层次都可以直接调用任何一个内部层次,这样既不会破坏耦合的方向,也避免了仅仅为了追求分层模式而创建一些没有任何业务逻辑的代理方法甚至代理类。这和 Martin Flowler 表达的偏好一致。

The Onion Architecture 原文地址

洋葱架构


干净架构

Robert C. Martin在2012年提出了干净架构(Clean Architecture),这是六边形架构的一个变体,通过隔离变化和依赖倒置守护业务代码。

CleanArchitecture


清晰架构

2017年又出现一个在Clean Architecture基础上增加CQRS的Explicit Architecture。

 Explicit Architecture



虽然这些架构在细节上都略有不同,但他们都非常相似。它们都具有相同的目标,那就是分离关注。他们都通过软件分层来实现这种分离。至少有一个层代表业务规则,而另一个层用于接口。

系统特点:

独立的框架,这样的架构并不依赖与应用软件的具体库包,这样可以将框架作为工具,而不必将你的系统都胡乱混合在一起

可测试,业务规则能够在没有UI和数据库 或Web服务器的情况下被测试

数据库的独立性,你能够在MySQL或SQL Server、Mongo之间切换,你的业务规则不会和数据库绑定

独立的外部代理,其实你的业务规则可以对其外面的技术世界毫无所知

依赖倒置原则

依赖倒置原则(Dependency Inversion Principle, DIP),它通过改变不同层之间的依赖关系达到改进目的。

依赖倒置原则由Robert C. Martin提出,正式定义为:

高层模块不应该依赖于底层模块,两者都应该依赖于抽象。

抽象不应该依赖于细节,细节应该依赖于抽象。


经过我们多次的尝试和探讨后,最终我们选择了干净架构作为落地的分层架构,并且结合CQRS架构。在此基础上我们提供了一个Maven脚手架:http://coding.jd.com/com.jd.jr.cf/ddd-archetype/

依赖倒置

以上是我们工程的模块的依赖图。在原干净架构的基础上增加了API和Types、Query三个模块。API主要是一些接口协议,Types封装了领域内一些特殊的值对象Domain Primitives,而Query是基于CQRS的查询端,可以建立独立于Domain的查询模型。

Domain

即上图中的Entites层。这里面主要封装了业务域的核心业务规则,包括了领域模型和领域服务。这一层封装了这个领域最通用和最高层级的业务规则,和业务规则不相关的改变都不应该影响到这一层,这些业务规则不会轻易发生变化。这一层可以被其他的领域引用,这个可以理解为共享内核。

Application

即上图中的Use Cases层。应用层,主要封装了业务用例(UseCase),为了和DomainService区分,在此ApplicationService使用UseCase的概念,实际上二者等同。应用层一般会比较薄,主要对Domain层进行编排。

Adapter

适配器层,Domain和Application为应用核心,应用核心与任何外部系统的交互都通过适配器层。适配器层实际上又分成两大类,主动适配和被动适配。主动适配基本都是应用的入口,比如JSF、MQ、调度器等,被动适配基本都是应用出口,比如访问RPC接口、数据库等。

主动适配器是系统主动适配一些组件

Boot

框架与驱动层,主要包括了SpringBoot的启动类,AOP、系统配置、Spring容器等。Domain和Application层不应该直接依赖Spring容器,这两层的一些对象在Boot层注入Spring容器。

Query

CQRS的查询层,可以单独定义不同于领域模型的查询模型。在CQRS中,查询的数据库模型可以与命令中的数据库模型不一样,查询的模型是针对查询优化的。这一层不是必须的,也可以独立部署。Query可以直接引入DO,将DO转换成DTO对外输出。

Api

外对暴露的接口的协议。

Types

Types模块是保存无状态的逻辑的Domain Primitives的地方。

模块和包说明

|--- adapter                     -- 适配器层 应用与外部应用交互适配

|      |--- controller           -- 控制器层,API中的接口的实现

|      |       |--- assembler    -- 装配器,DTO和领域模型的转换

|      |       |--- impl         -- 协议层中接口的实现

|      |--- repository           -- 仓储层

|      |       |--- assembler    -- 装配器,PO和领域模型的转换

|      |       |--- impl         -- 领域层中仓储接口的实现

|      |--- rpc                  -- RPC层,Domain层中port中依赖的外部的接口实现,调用远程RPC接口

|      |--- task                 -- 任务,主要是调度任务的适配器

|--- api                         -- 应用协议层 应用对外暴露的api接口

|--- boot                        -- 启动层 应用框架、驱动等

|      |--- aop                  -- 切面

|      |--- config               -- 配置

|      |--- Application          -- 启动类

|--- app                         -- 应用层

|      |--- cases                -- 应用服务

|--- domain                      -- 领域层

|      |--- model                -- 领域对象

|      |       |--- aggregate    -- 聚合

|      |       |--- entities     -- 实休

|      |       |--- vo           -- 值对象

|      |--- service              -- 域服务

|      |--- factory              -- 工厂,针对一些复杂的Object可以通过工厂来构建

|      |--- port                 -- 端口,即接口

|      |--- event                -- 领域事件

|      |--- exception            -- 异常封装

|      |--- ability              -- 领域能力

|      |--- extension            -- 扩展点

|      |       |--- impl        -- 扩展点实现

|--- query                       -- 查询层,封装读服务

|      |--- model                -- 查询模型

|      |--- service              -- 查询服务

|--- types                       -- 定义Domain Primitive

在落地中遇到的问题

关于服务

到这大家已经发现了应用层也有Service,Domain层也有Service。应用服务和领域服务的划分是一个难题,在干净架构中将应用服务叫做Use Case。

单从字面理解,不管是领域服务还是应用服务,都是服务。而什么是服务?从SOA到微服务,它们所描述的服务都是一个宽泛的概念,我们可以理解为服务是行为的抽象。从前缀来看,它们隶属于不同的层,应用服务属于应用层,领域服务属于领域层。

应用服务

应用服务是用来表达用例(Use Case)和用户故事(User Story)的主要手段。应用服务负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。

领域服务

当领域中的某个操作过程或转换过程不是实体或值对象的职责时,我们便应该将该操作放在一个单独的接口中,即领域服务。请确保该服务和通用语言时是一致的,并且保证它是无状态的。

两个凡事

上面的定义比较抽象。那哪些逻辑应该放在Use Case,哪些该放在Domain Service中呢?在此我们引用了两个凡事的原则。

1.凡是可以移动到领域模型中的逻辑都不应该出现在领域服务中

2.凡是可以移动到领域层中的逻辑都不应该出现在应用服务中

另外DomainService也不是必须的,一些简单的UseCase是可以直接构造领域模型然后调用其方法。避免出现无业务逻辑的DomainService

关于事务

事务不是业务逻辑,就像存储不是业务逻辑一样。事务是一个技术实现或者细节,所以应该隐藏在Adapter的Repository中。但是有时候是无法在Repository中实现,可以在DomainService中实现。

聚合内事务

聚合是一个一致性的事务单元,所以对于一个聚合内的事务应该在对应的Aggregate的Repository中。

跨聚合事务

如果出现跨Aggregate的事务应该在ApplicationService层中,ApplicationService来编排这两个Aggregate的Repository来保证事务一致性。

跨域事务

如果出现跨域的,也就是分布式事务,优先考虑领域事件的方式,在另外一个域监听领域事件处理。

关于模型分类

开始我们并没有对模型进行分类规范,发现落地的过程中,有不少同事对这些模型并没有清晰的认识,造成了模型的滥用,超出了它们的边界。因此我们强调了模型的分类,在这里我们将整个应用中涉及的模型分成了三种,分别为DTO、DO和PO。

DTO:Data Transfer Object,数据传输对象,在API模块中定义,在Adapter中使用,不能在Application和Domain中使用

DO:Domain Object,领域对象即领域模型(Domain Model),在Domain中定义,在Adapter中和DTO、PO进行转换,一般在Application中构造生成(如果有DomainService,则在DomainService中生成)。DO主要有三类,聚合(Aggregate)、实体(Entity)、值对象(ValueObject),另外Domain Service也属于领域模型

PO:Persistent Object,持久化对象即数据库模型,在Adapter中定义和使用。(P.S.在阿里的规范中持久化对象又叫Data Object,简称DO,实际上就是PO,二者等值的,此处为了和领域对象区分,使用了PO的定义)

模型分类


模型的转换

DTO和DO、PO的转换,可以通过一些通用工具解决。

如果是字段相同,可以使用CGLIB BeanCopier,Spring带的BeanUtil实际上就是使用CGLIB BeanCopier的。

如果有嵌套的字段映射,可以使用MapStruct,非常方便的进行类型转换。

<dependency>

        <groupId>org.mapstruct</groupId>

        <artifactId>mapstruct</artifactId>

        <version>${org.mapstruct.version}</version>

    </dependency>

关于实体和值对象

在授信域下的准入域落地的过程中。通过对用户准入的用例的分析,我们将用例中的一些名词列出来分析后。将用户、业务身份、校验结果、准入申请记录列为了领域对象。其中用户这个对象的争议比较大,因为有的同事认识用户是一个实体,因为它有唯一标识,用户PIN。在讨论这个问题之前,我们先回顾一下实体和值对象的定义。

实体

实体(Entity), 主要由标识定义的对象。它可以是任何事物,只要满足两个条件即可,一是它在整个生命周期中具有连续性;二是它的区别并不是由那些对用户非常重要的属性决定的。

其实这两点要简化为具有唯一标识和生命周期。唯一标识可以进一步理解为业务编号,比如订单实体中的订单号。生命周期则一般可以理解为持久化,实际上生命周期是标识在实体生命周期内体现出连续性。

值对象

值对象(Value Object),用于描述领域的某个方面而本身没有概念的对象称为值对象,值对象被实例化之后用来表示一些设计元素,对于这些设计元素,我们只关心它们是什么,不关心它是谁。

另外同一个事物(对象)在不同的上下文中可以是实体或者值对象,但是在同一上下文中是确定的。值对象最大的特点是不可变的。

度量或描述领域中的一件东西

可以作为不变对象

将不同的相关属性组合成一个概念整体

当度量或描述改变时,可以使用另一个值对象予以替换

可以与其他值对象进行相等性比较

不会对协作对象造成负面影响

那么用户为什么在准入域中是一个值对象而不是一个实体呢?用户是具有唯一标识Pin的,但是我们无需对这个用户的状态进行维护,也就是在准入上下文中是不需要维护用户的生命周期的。区别值对象的一个更简单的方法是,这个对象是不是当前这个业务域存储的,从外部获取的对象基本可以确定为值对象(在共享内核的情况下可能会有不一样的判定)。在准入上下文中实际上我们是只是关注了用户的部分属性。显而易见用户在用户域是一个实体。为了更好的区分实体和值对象,这里可以引入一个概念,最小化集成原则。

最小化集成原则

在 DDD 项目中通常存在多个限界上下文,意味着我们需要找到合适的方法对这些上下文进行集成,当模型概念从上游上下文流入下游上下文中时,尽量使用值对象来表示这些概念,这样的好处是可以达到最小化集成,既可以最小化下游模型中的属性数目,又可以使用不变的值对象减少职责假设。

研发是喜欢“偷懒”的。所以在落地讨论时,一些同事也提出了我们在准入域为什么不能直接引用用户域的用户实体,这样就可以不需要再在准入域建一个用户对象了。按照最小化集成原则,我们应该在准入域建一个用户值对象。用户域的用户模型是很复杂的,但是在准入域我们只关心用户极少的属性,甚至都不关心用户本身具备的行为能力。如果直接引入用户域的用户实体,也意味着用户域的用户实体的修改时有可能影响到我们的业务。使用值对象的概念也能将准入域和用户域进行很好的隔离,起到防腐层的作用。所以两个域之间建议通过DTO进行传输,避免直接引入其他域的领域模型。

我们应该尽量使用值对象建模而不是实体对象,因为我们可以非常容易地对值对象进行创建、测试、使用、优化和维护

关于值对象的不可变

可以这么说吧,不可变是值对象最显著的特征。

关于值对象的不可变,也让一些同事感到困惑。他们认为值对象还是变的,比如颜色可以改变。其实颜色可以改变描述并不准确,得引入语境。比如衣服的颜色可以改变,是衣服这个实体的颜色的属性改变了,并不是颜色这个值对象本身变了。这改变衣服颜色这个用例中,衣服是实体,颜色是值对象。有位同事就举了在商城下单时的问题,他说在下单时地址是可以改变的。这个问题是复杂的,我们还是从用例开始解析,首先要准确的描述这个用例,特别是准确描述其语境(即上下文)。在下单时修改地址,实际是由两个用例组成的,用户在地址管理页修改地址,在下单页从地址列表中选择一个地址。这两个用例实际上是两个不同的上下文。用户在地址管理页修改地址,在这个语境下,地址就是实体,所以是修改了常用地址的这个实体上的地址(省市区)。在下单页用户修改地址,实际上是从常用地址中选择一个新的地址,这个修改地址实际上是将地址这个值对象用另外一个值对象替换了,常用地址本身并没有修改。说到这里,其实订单的收货地址修改和衣服的颜色改变是相似的。

Domain Primitive

Domain Primitive是一种特殊的值对象,用来描述标准模型,标准模型是用于表示事物类型的描述性对象。

Primitive的定义是:不人任何其他事物发展而来,初级的形成或者生长的早期阶段。

这么说吧,标准模型就是“放之四海而皆准”的模型,这个模型在大部分上下文中都是一致的。比如Money这个对象,Money是由面值和货币类型组成的,在绝大部分语境中我们也只关心这两个属性(在造币厂就需要关心更多的属性了)。

常见的 DP 的使用场景包括:

有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等

有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等

可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)

Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等

复杂的数据结构:比如 Map> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

事例:

@Value

public class ExchangeRate {

    private BigDecimal rate;

    private Currency from;

    private Currency to;


    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {

        this.rate = rate;

        this.from = from;

        this.to = to;

    }


    public Money exchange(Money fromMoney) {

        notNull(fromMoney);

        isTrue(this.from.equals(fromMoney.getCurrency()));

        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);

        return new Money(targetAmount, to);

    }

}

关于Domian Primitive的详细可以参考 阿里技术专家详解 DDD 系列- Domain Primitive

关于领域模型的加载性能

 这个主要是针对聚合的。比如商户聚合,商户聚合由商户实体和门店实体聚合而成,如下

@Getter

public class MerchantAggregate {

    private MerchantEntity merchantEntity;

    private List<StoreEntity> storeEntities;

}

每次重建时都会一次从数据库中将商户信息和其下的门店信息。当门店很少的时候并不存在什么问题,但是当一个商户有5000家门店时。本来我们只是操作一下商户实体的一些基本信息,但却将这5000个门店实体加载到了内存中。这个对性能影响比较大。这个怎么处理呢。接下来我们将对DDD里面的模型做一个总结,在合适的情况选择合适的模型。

失血模型

失血模型中,domain object只有属性的get set方法的纯数据类,所有的业务逻辑完全由Service层来完成的。

service:  肿胀的服务逻辑

model:只包含get set方法

显然失血模型service层负担太重,在DDD中一般不会有这种设计。

贫血模型

贫血模型中,domain ojbect包含了不依赖于持久化的原子领域逻辑,而组合逻辑在Service层。

service :组合服务,也叫事务服务

model:除包含get set方法,还包含原子服务

贫血模型比较常见,其问题在于原子服务往往不能直接拿到关联model,因此可以把这个原子服务变成直接用关联modelRepo拿到关联model,这就是充血模型。

充血模型

充血模型中,绝大多业务逻辑都应该被放在domain object里面,包括持久化逻辑,而Service层是很薄的一层,仅仅封装事务和少量逻辑,不和DAO层打交道。

service :组合服务 也叫事务服务

model:除包含get set方法,还包含原子服务和数据持久化的逻辑

充血模型的问题也很明显,当model中包含了数据持久化的逻辑,实例化的时候可能会有很大麻烦,拿到了太多不一定需要的关联model。

胀血模型

胀血模型取消了Service层,在domain object的domain logic上面封装事务。

在这个商户聚合中,我们可以采用充血模型,也就是在商户聚合中直接持有门店的Repositoy,这样就可以实现懒加载。只有使用门店的时候再通过门店Repository获取门店,这样就避免了一次性加载过多的门店从而影响性能。

@Getter

public class MerchantAggregate {

    private final MerchantEntity merchantEntity;

    private final StoreRepository storeRepository;

    private List<StoreEntity> storeEntities;


    public MerchantAggregate(MerchantEntity merchantEntity, StoreRepository storeRepository) {

        this.merchantEntity = merchantEntity;

        this.storeRepository = storeRepository;

    }


    public List<StoreEntity> getStoreEntities(){

        this.storeEntities = storeRepository.getAll(merchantEntity.getMerchantId());

        return storeEntities;

    }

    private StoreEntity getOne(String storeId){

        return storeRepository.getOne(storeId);

    }

}

这个StoreRepository也可以提供根据门店ID获取门店的方法,这样在商户聚合中就可以使用,尽量避免全部加载门店。其实不推荐使用这种模型,这种模型只有在特定的场景才应该使用。

关于模型共享

模型共享的问题,其实上面已经提到了部分。为什么单独再强调一遍呢?因为很多人都对这块提出了疑问。为什么不能直接引用用户域的用户实体

康威定律

在讲这个模型共享前我们先了解一下康威定律

Conway’s law: Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations. - Melvin Conway(1967)

设计系统的组织其产生的设计等价于组织间的沟通结构。

在限界上下文(Bounded Context)

任何大型项目都会存在多个模型 。而当基于不同模型的代码被组合到一起后,软件就会出现bug,变得不可靠和难以理解。团队成员之间的沟通变得混乱。人们往往弄不清楚 一个模型不应该在哪个上下文中被使用。

明确地定义模型所应用的上下文。根据团队的组织,软件系统的各个部分的用法 以及物理表现(代码和数据库模式等)来设置模型的边界。在这些边界中严格保持模型的一致性,而不要受到边界之外问题的干扰和混淆。

ANTICORRUPTION LAYER(防腐层)

以下是完全基于防腐层设计的服务间通讯。上游系统通过开放公共主机的方式提供服务(主动适配器),下游通过被动适配器来隔离上游系统的服务。

Shared Kernel通常是Core Domain,或者是一组Generic SubDomain,也可能二者兼有,它可以是两个团队都需要的任何一部分模型。不仅仅可以共享Domain,也可以共享相关的Repository。使用共享内核的目的是减少重复(并不是消除重复,因为只有在一个Bounded Context中才能消除重复),并两个系统之间的集成变得相对容易一些。

在消金领域内,我们把账单域又分成了核心账单域(主要处理用户账单的资金科目的变动)、贷款域、还款域、逾期域、退款域,贷款(这里指正向交易)、还款、逾期、退款的上下文中都会对用户的核心账单进行修改。如果使用ACL的模式,集成起来也比较费劲,并且也会增加RPC或者分布式事务的问题。这个时候就可以把核心账单域作为一个共享内核提供给其他四个子域,这个情况下核心账单域也不需要单独部署一个应用,只需要集成到其他四个子域中即可。

关于对象的创建和管理

在干净架构中,内圈是不依赖Spring框架的。很多同事反馈没有Spring怎么管理这些对象呢,甚至有的人开玩笑说没有了Spring都不会编程了。所以在此我们对这个作了说明。

对于有状态的对象可以通过new的方式创建,如果这个对象是全局唯一的共享的,可以设置成单例,系统初始化时创建。

对于无状态的对象可以交由Spring管理。Domian和Application层的对象(比如应用服务和领域服务)同样可以交由Spring容器管理,因为这两层不依赖Spring,可以通过构造方法的方式传入依赖的其他的Spring管理的Bean,在最外层的Boot层由Spring创建管理。

关于构造方法的说明,由于是通过构造方法的方式进行初始化,可能会带来一些坏味道,比如长参数列表。但是这个坏味道是可以接受的,因为构造方法的方式提供了一个很好的检验机制,避免产生必要参数没有遗漏输入的问题。另外当构造一个对象比较麻烦时,可以引入Factory,通过Factory构建对象。

如上所述我们希望在应用和领域层尽量不依赖框架,比如Spring,在最外层的Boot层由Spring创建并管理。这个时候就需要在外层写大量的@bean代码

package com.jd.jr.cf.dawn.example.config;


import com.jd.jr.cf.dawn.example.service.UserServiceImpl;

import com.jd.jr.cf.dawn.example.service.PersonService;

import com.jd.jr.cf.dawn.example.service.PersonServiceImpl;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;


@Configuration

public class ExampleConfigDawnLoader {

    @Bean

    public UserServiceImpl userService(PersonService personService) {

        return new UserServiceImpl(personService);

    }

    @Bean

    public PersonServiceImpl personService() {

        return new PersonServiceImpl();

    }

}

为了我们提供了一个组件,可以自动生成上述的样版代码,只需要声明一下该类即可,组件是在编译时生成的代码,会自动根据构造方法生成相对应的@Bean代码。

package com.jd.jr.cf.dawn.example.config;


import com.jd.jr.cf.dawn.annotation.DawnLoader;

import com.jd.jr.cf.dawn.example.service.PersonServiceImpl;

import com.jd.jr.cf.dawn.example.service.UserServiceImpl;


@DawnLoader

public class ExampleConfig {

    private UserServiceImpl userService;

    private PersonServiceImpl personService;

}

关于并发安全

其实DDD的落地也是有一个套路的,在DomainService中一般都是这样的“DDD八股文”。首先从Factory中新增领域对象或者从Repository加载领域对象,然后调用领域对象的方法,最后调用Repository进行store对象。简单的处理模型就是,创建对象-》调用对象的方法-》保存对象。这样就产生了一个问题了,并发安全问题。读取对象、处理对象和保存对象不是一个原子操作,


比如扣减额度的处理模型,从数据库中加载账户实体,调用实体的扣减额度的方法,持久化账户实体。我们这边传统的做法是,使用SQL语句来操作额度并避免其扣减为负数。

UPDATE cf_userbalance

SET

limitBalance = limitBalance - #{userBalance.limitBalance,jdbcType=DECIMAL}

WHERE limitBalance - #{userBalance.limitBalance,jdbcType=DECIMAL} >= 0;

但是在DDD中,所有的业务逻辑都应该是内聚内账户实体中的,也就是在账户实体中进行操作额度。那怎么保证这个并发安全呢?在此我们引入了乐观锁的机制。其实每次重载对象的时候都带上了版本号,处理完后持久化时,在条件语句中带个版本号。

这个乐观锁在很多ORM框架中都已经支持,使用起来特别方便,只需要增加一个@Version的注解即可,比如Spring Data Jpa和Mybatis Plus。其实JPA很适合DDD,比较推荐使用Spring Data Jpa,可以极大的简化代码。

关于读写分离

我们的领域对象就是基于业务进行建模的,特别是我们在建模时也不怎么关心持久化。

但是作为一个业务系统,「查询」的相关功能也是不可或缺的。在实现各式各样的查询功能时,往往会发现很难用领域模型来实现。假设在用户需要一个订单相关信息的查询功能,展现的是查询结果的列表。列表中的数据来自于「订单」,「商品」,「品类」,「送货地址」等多个领域对象中的某几个字段。这样的场景如果还是通过领域对象来封装就显的很麻烦,其次与领域知识也没有太紧密的关系。

此时 CQRS 作为一种模式可以很好的解决以上的问题。实际上在消金我们已经在很多场景使用了读写分离的了,比如大量使用了预热,账户预热、订单预热。大量的读服务都是通过预热服务。这个已经算是CQRS的雏形了。

CQRS

CQRS — Command Query Responsibility Segregation,故名思义是将 command 与 query 分离的一种模式。

CQRS 将系统中的操作分为两类,即「命令」(Command) 与「查询」(Query)。命令则是对会引起数据发生变化操作的总称,即我们常说的新增,更新,删除这些操作,都是命令。而查询则和字面意思一样,即不会对数据产生变化的操作,只是按照某些条件查找数据。

CQRS 的核心思想是将这两类不同的操作进行分离,然后在两个独立的「服务」中实现。这里的「服务」一般是指两个独立部署的应用。在某些特殊情况下,也可以部署在同一个应用内的不同接口上。

Command 与 Query 对应的数据源也应该是互相独立的,即更新操作在一个数据源,而查询操作在另一个数据源上。

————————————————

版权声明:本文为CSDN博主「vow_」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/qq_30757161/article/details/116485388

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容