《实现领域驱动设计》读书笔记


第1章、DDD入门

1、什么是DDD

领域驱动设计(DDD)作为一种软件开发方法,它可以帮助我们设计高质量的软件模型。DDD不是关于技术的,而是关于讨论、聆听、理解、发现和业务价值的,而这些都是为了将知识集中起来。

2、什么是领域模型

领域模型是关于某个特定业务领域的软件模型。通常,领域模型通过对象模型来实现,这些对象同时包含了数据和行为,并且表达了准确的业务含义。

3、战略设计和战术设计

战略设计帮助我们理解那些投入是最重要的;哪些既有软件资产是可以重新拿来使用的;哪些人应该被加入团队中。

战术设计则帮助我们创建DDD模型中各个部件。

4、难以捉摸的业务价值

在开发过程中,最大的鸿沟之一便存在于领域专家和开发者之间。领域专家关注交付业务价值,而开发者更关注技术实现,他们之间的协作只是在开发的软件中产生了一种映射,并不能完全反映出领域专家的思维模型,随着时间的推移这种鸿沟将增加软件的开发成本,随着开发者的离去驻留在软件中的领域知识也丢失了。

领域专家之间也存在分歧,这将导致相互矛盾的软件模型。

软件技术实现可能错误地改变软件的业务规则。

5、处理领域的复杂性

DDD是应用在最重要的业务场景下,必须要区分哪些是核心域、哪些是支撑子域。

6、如何判断是否该使用DDD(具体打分可参考书中p9-p10的计分卡)

1)感觉到了业务复杂性和业务变化所带来的痛苦;

2)系统中有30个以上用户故事和用例流;

3)软件功能在接下来的几年将不断的变化且无法预期是简单的变化;

4)你和你的团队都对要处理的领域不是很了解。

6、贫血症和失忆症

贫血对象的特点:

1)领域对象中主要是些共有的getter和setter,几乎没有业务逻辑;

2)几乎所有的业务逻辑都是写在service里面,对象只是数据持有器POJO。

这样的系统中,开发者将很多时间花在对象和数据存储之间的映射上,而不是真正有价值的对象行为。

一个例子:

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
public void updateUser(String userId, String firstName, String lastName, String mobile,String address, Integer sex, String avator, ...) {
        User user = new User();
        if(userId != null) {
            user.setUserId(userId);
        }
        if(firstName != null){
            user.setFirstName(firstName);
        }
        if(lastName != null){
            user.setLastName(lastName);
        }
        if(mobile != null){
            user.setMobile(mobile);
        }
        if(address != null){
            user.setAddress(address);
        }
        if(sex != null){
            user.setSex(sex);
        }
        if(avator != null){
            user.setAvator(avator);
        }
        ...
        userDao.updateUser(user);
    }

1)业务意图不明确

2)方法的实现本身增加了潜在的复杂性

3)User只是一个数据持有器(data holder)

7、如何DDD

通用语言和界限上下文同时构成了DDD的两大支柱。

8、使用DDD的业务价值

1)你获得了一个非常有用的领域模型(不过度建模,主要关注业务核心域)

2)你的业务得到了更准确的定义和理解

3)领域专家可以为软件设计做出贡献

4)更好的用户体验(用户体验是按照领域专家心中的模型来设计,就不需要业务人员来提供培训,软件即实现)

5)清晰的模型边界(技术团队要更好地理解界限上下文)

6)更好的企业架构

7)敏捷、迭代式和持续建模(通过DDD创建出来的模型便是可工作的软件,团队会对模型做持续的改进)

8)使用战略和战术新工具

9、实施DDD所面临的的挑战

1)为创造通用语言腾出时间和精力

2)持续地将领域专家引入项目

3)改变开发者对领域的思考方式

10、数据驱动和领域驱动的编码方式

数据驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    public class BacklogItem extends Entity {
        private SprintId sprintId;
        private BacklogItemStatusType status;
        ...
        public void setSprintId(SprintId sprintId) {
            this.sprintId = sprintId;
        }

        public void setStatus(BacklogItemStatusType status) {
            this.status = status;
        }
    }
   

    // Service层调用
    backlogItem.setSprintId(sprintId);
    backlogItem.setStatus(BacklogItemStatusType.COMMITTED);

领域驱动,表达了领域中的通用语言:

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
public class BacklogItem extends Entity {
        private SprintId sprintId;
        private BacklogItemStatusType status;
        ...
        public void commitTo(Sprint aSprint) {
            if (!this.isSchedulForRelease()) {
                throw new IllegalStateException("Must be scheduled for release to commit to sprint.")
            }
            if(this.isCommittedToSprint()) {
                if(!aSprint.sprintId().equals(this.sprintId())) {
                    this.uncommitFromSprint();
                }
            }
            this.elevateStatusWith(BacklogItemStatus.COMMITTED);
            this.setSprintId(aSprint.sprintId());
            DomainEventPublisher
                    .instance()
                    .publish(new BacklogItemCommitted(
                            this.tenant(),
                            this.backlogItemId(),
                            this.sprintId()
                    ));
        }
        ...
    }

    // service层
    backlogItem.commitTo(sprint);

第一个例子中客户(service层)代码必须知道如何正确地讲一个待定项提交到冲刺中,实际上就客户是需要关心数据属性,而不是对象行为。

第二个例子只是将领域对象的行为暴露给客户,行为方法名字清除地表明了业务含义,客户不需要知道提交BacklogItem的实现细节。

第2章 领域、子域和限界上下文

1、领域、子域和限界上下文概念

在DDD中,一个领域被分为若干子域,在开发一个领域模型时,我们关注的只是这个业务系统的某个方面。

限界上下文是一个显式边界,领域模型便存在于边界之内。在边界内,通用语言中的所有属于和词组都有特定的含义,而模型需要准确地反映通用语言。

2、为何要划分子域

不管你向系统中添加多少功能,你都无法满足每一个潜在客户的需求(tip:即系统是在不断变化的);

如果不通过子域对软件模型进行划分,事情将更加繁琐,因为系统中的各个部分都是紧密联系在一起的(tip:即缺失限界上下文的划分会带来的系统和代码的混乱)。

在一个好的限界上下文中,一个术语应仅表示一种领域概念。

3、核心域、通用子域和支撑子域

核心域:顾名思义;

支撑子域:业务的重要方面,但不是核心;

通用子域:一个子域被用于整个业务系统。

4、限界上下文不仅仅只包含模型

限界上下文主要用来封装通用语言和领域对象,但同时它也包含了那些为领域模型提供交互手段和辅助功能的内容。模块、聚合、领域事件、领域服务。

5、问题空间和解决方案空间

问题空间:对问题空间的开发将产生一个新的核心域,对于问题空间的评估应该同时考虑已有子域和额外所需的子域。在问题空间中,我们思考的是业务所面临的挑战。

解决方案空间:解决方案空间包括一个或多个限界上下文,即一组特定的软件模型。在解决方案空间中,我们思考如何实现软件以解决这些业务挑战

第3章 上下文映射图

1、上下文映射图

上下文映射图并不是一种企业架构,也不是系统拓扑图。但是,他可以用于高层次的架构分析,之处注入继承瓶颈之类的架构不足。上下文映射图展现了一种组织动态能力,它可以帮助我们识别出有碍项目进展的一些管理问题。

这些框图可以贴在墙上一个显著的位置,wiki是葬送信息的地方。

2、组织、集成模式

合作关系、共享内核、客户方-供应方关系、遵奉者、防腐层、开放主机服务、发布语言、另谋他路、大泥球。

3、OHS/PL(开放主机服务/发布语言)和ACL(防腐层)

上游系统采用OHS/PL,下游采用ACL

OHS:远程调用或消息机制实现

PL:REST服务中常用XML或JSON

ACL:转换成下游模型概念

第4章 架构

1、分层

DDD所使用的传统分层架构

用户界面层->应用层->领域层->基础设施层

用户界面层:只用于处理用户的显示和用户请求,他不应包含领域或业务逻辑。

应用层:应用服务很轻量,它主要用于协调对领域对象的操作,同时是表达用例和用户故事的主要手段。

领域层:领域模型用于发布领域事件,领域模型本身只需要关注自己的核心逻辑。

基础设施层:持久化。

2、依赖倒置原则

将基础设施层放在最底层是存在缺点的,它违背了分层架构的基本原则,再者,很难为这样的实现编写测试。

采用依赖倒置,让低层服务(基础设施层)依赖于高层服务(用户界面层、应用层、领域层)所提供的接口(此处可参考:DDD及CQRS模式的落地实现)。

3、六边形架构(端口与适配器)

六边形架构中分为了外部区域和内部区域。

内部区域:从外到内分别是:适配器->应用程序->领域模型。

外部区域接收来自客户的提交输入,六边形的边代表端口,用于处理输入(HTTP、AMQP等)和输出(关系型数据库、文档型数据库、缓存、本地内存等)。

六边形架构的好处在于:整个应用程序和领域模型可以再没有客户和存储机制的条件下进行设计开发,测试时可以完全不依赖用户界面和数据库资源。

4、面向服务架构(SOA)

服务设计原则:服务契约、松耦合、服务抽象、服务重用性、服务自治性、服务无状态性、服务可发现性、服务组合型。

结合六边形架构,此时服务边界位于最左侧,消费方可以通过REST、SOAP和消息机制等获取服务。

5、REST和DDD

不建议将领域模型直接暴露给外界,这样会使系统接口变得非常脆弱。两种方法:

第一种:为系统接口层单独创建一个限界上下文,通过资源抽象将系统功能暴露给外界(即重新抽象一个服务),常用于专属系统;

第二种:共享内核或者发布语言,常用于通用系统。

6、CQRS

命令声明void,查询声明返回的数据类型

资源库只保留add()和save()命令,以及fromId()查询命令。同时建立命令模型(Command Model)和查询模型(Query Model)。

7、事件驱动架构

这不是简单的事件通知,他们显式地对业务过程进行建模,还可以进行同步式的扩展,以使其同时处理多个任务。

8、设计长时处理过程的不同方法

1)任务组合:使用一个执行组件对任务进行跟踪;

2)聚合:聚合实体充当执行组件维护整个处理过程;

3)无状态的处理过程:将处理过程的状态放在消息中。

在实际的领域中,一个长时处理过程的执行器将创建一个新的类似聚合的状态对象来跟踪事件的完成情况。

关注点:单次消费、处理时长,在跟踪器中定义isCompleted(),hasTimeOut()

9、事件源

事件源的核心是跟踪每个实体、每个聚合层面上的变化。

每一个领域事件都将被保存到事件存储中,每次从资源库中获取某个聚合时,我们将根据发生在该聚合上的历史事件来重建改聚合实例。随着聚合实例上的事件越来越多,我们可以通过聚合状态“快照”的方式来进行优化,此快照反映了聚合在事件存储历史中某个事件发生后的状态。快照可以根据实际情况创建一个阈值。

事件源的优势:

1)通过向事件存储打补丁可以修复bug带来的系统问题;

2)重放事件来重做、撤销对模型的修改;

3)可以通过重放模拟虚拟的业务场景。

10、数据网织

我的理解其本质是对聚合的查询结果进行缓存,从而减少数据库访问的压力。(可以参考:DDD及CQRS模式的落地实现)

第5章 实体

1、什么是实体

一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续的变化。唯一标识和可变性是实体与值对象的最大区别。

2、建模

发现实体及其本质特征->挖掘实体的关键行为->发现对象的角色和职责->创建实体

3、验证

三个层次:

1)验证属性:自封装,即setter中进行验证;

2)验证整体对象:建立validator抽象类以及validatorHandler;

3)验证对象组合:建立一个领域服务。

第6章 值对象

1、值对象的特征

1)度量或描述:值对象只是用于度量或描述领域中就某件东西的一个概念;

2)不变性:一个值对象在创建之后便不能改变了;

3)概念整体:一个值对象也可以处理一组关联的属性;

4)可替换性:不是修改而是对整个值对象进行替换;

5)值对象相等性:如果两个对象的类型和属性都相等,那么这两个对象也是相等的。进而,让两个值对象实例相等,我们便可以用一个实例代替另一个;

6)无副作用行为:对于不变的值对象而言,所有的方法都必须是无副作用函数;

第7章 领域服务

1、什么是领域服务

当某个操作不适合放在聚合和值对象上时,最好的方法便是使用领域服务了。

领域服务可以:

1)执行一个显著的业务的操作过程;

2)对领域对象进行转换;

3)以多个领域对象作为输入进行计算,结果产生一个值对象。

一个案例:

1)Product实体中维护了一个BacklogItem实例的集合,里面有一个方法(businessPriorityTotals)用于遍历BacklogItem集合并计算出总的业务优先级;

2)由于业务发展,Product对象过于庞大,BacklogItem本身应该成为一个聚合;

3)由于Product不再包含BacklogItem集合,如何实现businessPriorityTotals?

a. 在Product中直接使用资源库来获取BacklogItem实例——不可取,要避免在实例中使用资源库;

b. businessPriorityTotals方法传入BacklogItem实例的集合——该方法只使用了BacklogItem对象,放在BacklogItem似乎更合适;

c. 但是这里计算所得的业务价值却是属于Product的,而不是BacklogItem。

怎么办?——领域服务

2、建模领域服务

1)作者推荐领域服务不使用接口,因为不会有多个实现,通过依赖注入或者工厂同样可以实现客户端与具体实现之间的解耦;

2)将资源库放在领域服务中是一种好的做法;

3)决不能将与领域相关的业务逻辑放到应用层,应用层只是作为领域服务的客户端,不能具有领域知识。

4)不要滥用领域服务,这将会导致贫血领域模型这种反模式。

第8章 领域事件

1、何时/为什么使用领域事件

线索:

“当......”

“如果发生......”

“当......的时候,请通知我”

“发生......时”

有时领域事件需要发布到外部系统中,这样的事件由订阅方处理,将对本地和远程的上下文产生深远的影响。

当领域事件到达目的地之后——无论是本地系统还是外部系统——我们通常都将领域事件用于维护事件的一致性。这样可以消除两阶段提交(全局事务),还可以支持聚合原则(在单个事务中,只允许对一个聚合实例进行修改)。

2、建模领域事件

1)根据限界上下文中的通用语言来命名事件及其属性;

2)考虑是谁导致了领域事件的产生,包括产生该领域事件的聚合和其他参与操作的聚合;

3)在该事件发布时,本地上下文的订阅方可以用该事件来通知相应的服务。

4)将事务处理委派给应用服务。

最容易识别的场景是当一个聚合依赖于另一个聚合的时候,此时我们需要保证它们之间的最终一致性。

3、创建具有聚合特征的领域事件

有时,领域事件并不是由聚合中的命令方法产生,而是直接由客户方所发出的请求产生。此时,领域事件可以建模成一个聚合,并且可以用有自己的资源库。但是,又由于领域事件表示的是发生在过去的事情,因此资源库是不能对事件进行删除的。

1)这种领域事件是模型结构的一部分;

2)拥有唯一标识,当事件发布到外部限界上下文时,需要对消息进行去重;

3)客户方可以通过调用领域服务来创建事件,然后将其添加到资源库中,再通过消息设施进行发布,此时资源库和消息设施必须使用相同的数据源或者全局事务,以此来保证事件的成功提交。

4、从领域模型中发布领域事件

  • 发送方:

聚合创建了一个事件,然后将其发布出去。

发送方位于模块,它向聚合中添加了一个简单的服务用于通知订阅方所发生的领域事件。

1)每一个用户请求都将在单独的线程中予以处理;

2)不同的请求可能重用同一个线程,系统接收到一个新的用户请求,需要reset先前的订阅方;

3)为了避免线程同步问题,只有当发送方没有进行发布操作时,我们才能注册订阅方;发布不允许嵌套,因此执行发布时,也要检查线程发布状态;

4)基类DomainEventPublisher执行publish()方法时,它将依次遍历所有注册的订阅方,订阅方执行handleEvent()处理事件。

  • 订阅方:

订阅方可以是应用服务,也可以是领域服务,只要它和发布事件是位于同一个线程,并且在发布之前完成注册。

通过消息设施转发事件可以异步地将事件发送到不同的订阅方,每一个订阅方都可以在各自单独的事务中修改额外的聚合实例。

5、消息设施的一致性

1)领域模型和消息设施共享持久化存储,优:性能高,缺:有限制;

2)全局事务,优:可以分开存储,缺:性能差;

3)事件存储,优:模型修改和事件提交可以位于单个本地事务,缺:定制开发一个消息转发组件。

6、事件存储

DDD中将事件存储作为一个消息队列来使用,作用是将所有领域事件通过消息设施发布出去,或者是基于REST的消息通知(形式不同而已);顺序地将事件序列化到事件存储中。

第9章 模块

1、领域模型的命名规范

领域:com.<>.<>.domain

领域模型:com.<>.<>.domain.model

2、先考虑模块,再是限界上下文

使用模块的目的在于组织那些内聚在一起的领域对象。

第10章 聚合

1、聚合的定义

聚合是将实体和值对象在一致性边界之内。

2、原则:在一致性边界之内建模真正的不变条件

在一个事务内只修改一个聚合实例。聚合表达了事务一致性边界相同的意思,边界之外的任何东西与该聚合都是不相关的。

3、原则:设计小聚合

使用根实体来表示聚合,其中只包含最小数量的属性或者值类型属性。

根实体所需的属性是那些必须与其他属性保持一致的属性。

4、原则:通过唯一标识引用其他聚合

即聚合内引用其他聚合时,只包含id,而不是将整个聚合类型包含进来。

在调用聚合行为方法之前,在应用服务中使用资源库或者领域服务来获取所需的对象,而不是在聚合中使用资源库来定位其他聚合。

5、原则:在边界之外使用最终一致性

在DDD中,通过一个聚合的命令方法所发布的领域事件及时地发送给一步的订阅方,订阅方在接收到事件之后都会获取自己的聚合实例完成相应的操作,并且在单独的事务中进行操作。

当订阅方更新发生了失败,就不会发回成功确认信号,消息会重发并重新触发更新,直到一致性得到满足或者达到重试上限。

6、打破原则的理由

方便用户界面、缺乏技术机制、全局事务、查询性能。

7、使用迪米特法则和“告诉而非询问”原则

客户端对象应该通过调用服务对象的公共接口的方式来“告诉”服务对象所要执行的操作,而不是获取到对象后再调用对象的方法。

迪米特法则限制只允许客户端通过聚合根进行访问;“告诉而非询问”允许客户端访问聚合根内部,但是对聚合状态的修改应该属于聚合本身。

第11章 工厂

1、领域模型中的工厂

一个含有工厂方法的聚合根的主要职责是完成它的聚合行为。

2、聚合根中的工厂方法

好处:有效地表达限界上下文中的通用语言;减轻客户端在创建新聚合实例时的负担;确保所创建的实例处于正确的状态。

3、领域服务中的工厂

在翻译器中完成了本地对象的创建,参考集成限界上下文(13)

第12章 资源库

1、面向集合资源库

一个资源库应该模拟一个Set集合。不应该允许多次添加同一个聚合实例,修改时,只需要从集合中获取对象引用,在该对象上直接执行行为方法。

2、面向持久化资源库

持久化机制不支持对对象变化的跟踪,可以考虑使用面向持久化资源库。这种方式模拟了Map集合,在保存新建对象或修改既有对象时,我们都必须显示地调用put()方法,以新值替换原值。

3、资源库vs数据访问对象(DAO)

DAO主要是从数据表角度来看待问题,并提供CRUD操作;

资源库和数据映射器(Data Mapper)则更加偏向于对象,在设计资源库时应该采用面向集合的方式。

第13章 集成限界上下文

1、开放主机服务

当一个限界上下文以URI的方式提供了大量的REST资源时,我们便可称其为开放主机服务。

2、防腐层

使用防腐层实现REST客户端,用于集成两个服务的限界上下文。

第14章 应用程序

1、什么是应用程序

DDD中的应用程序是指那些支撑核心域模型的组件,通常包括领域模型本身、用户界面、内部使用的应用服务和基础设施组件等。

2、使用调停者发布聚合的内部状态

调停者模式(即双分派和回调)可以解决客户端和领域模型之间的耦合问题。

聚合通过调停者接口来发布内部状态。客户端将实现调停者接口,然后把实现对象的引用作为参数传给聚合。

附录 聚合与事件源:A+ES

1、A+ES概念

事件源通过事件来表示一个聚合的完整状态,通过按照产生式的顺序重放这些事件,我们可以重建聚合的状态。

只能通过追加的方式向事件流中加入事件。

通常来说,每个聚合所对应的事件流都将被持久化到事件存储中,各个事件流之间通过跟实体的唯一标识彼此分离。

2、A+ES优缺点

优:

事件源确保每次聚合改变的原因都不会丢失。

只追加特性使事件源具有很高的性能。

使开发者的关注点集中于通用语言。

缺:

需要对业务领域有很深的了解,只有复杂模型才值得使用;

缺少工具支持和一个一致的知识体系;

需要有经验的开发者;

需要CQRS,很难对事件流进行查询。

3、基本执行流程

1)客户端调用应用服务中的某个方法;

2)获取所需领域服务以执行业务操作;

3)根据客户端传来的聚合实例的唯一标识,获取到相应的事件流;

4)根据事件流中的所有事件重建聚合实例;

5)在聚合上执行业务操作;

6)聚合可能双分派给领域服务,或者是其他聚合实例,然后发布新的事件作为操作的输出;

7)将所有新建事件追加到事件流中,此时通过事件流的版本号来防止并发冲突;

8)将新追加的事件通过消息设施发布到订阅方。