方法论系列:一文说透领域驱动设计核心概念
基本概念
什么是领域驱动设计?
领域驱动设计(Domain Driven Design,简称DDD),是一种指导软件系统工程建设的方法论。
「通过抽象提取的手段,将复杂的领域业务细节抽象成精简的业务模型及其实现的匹配。」
可以理解为:针对某个业务领域的,面向对象思维的回归和升华。
在面对一个新领域时,甄别哪些对象对系统有用?哪些对拟建系统无用?如何保证选取的模型对象恰好够用——既不过度设计又能满
足业务要求?
领域对象并不是独立存在的,对象之间有各种千丝万缕的联系(如地铁站、线路和列车之间的关系),正是这种联系造成了系统的复
杂度。
很多时候修改一处变更,则牵一发而动全身,对象的封装机制仅仅只能解决有限的部分问题。
通过DDD,则可以保留对象之间有用的关系而去掉无用的关系,限定变更影响范围来降低系统的复杂度。
DDD要求系统架构师、系统开发工程师等技术人员,与业务领域专家达成业务上的共同认知。
其基本方法则是站在业务角度,从顶层抽象,用上帝视角来看待整体业务。
DDD与传统OO的区别在于,前者是业务视角,后者是技术视角;前者从整体出发建模后设计,后者是各种细节设计后堆叠出一个系统;
前者领域知识丰厚且可传递,后者将系统做成“四不像” ……
DDD要求技术人员将精力投入一部分到业务理解上,而不是单纯的做技术工作。
DDD是一种较为抽象的设计指引,不会涉及具体某个业务领域的设计实践,但国内有参考案例,如很多体量较大的互联网公司早就在实践DDD,特别是做业务中台的时候。
一般设计的时候需要考虑上下文环境,同时要跟业务领域专家深入合作,如首先统一术语,明确需求边界,识别关键对象,理清关键业务对象之间的关系等等。
DDD在Martin Flower等人的传播下,目前已成为微服务架构必不可少的指导思想。
DDD并不是万能的
DDD是OO的一种升华,但同样存在不足,如在安全、权限方面的考虑,与开发框架如何融合等问题。
因此, DDD与OOP, MDD(模型驱动设计)和MDA(模型驱动架构)等理念并不是非此即彼的关系,而是要根据具体场景结合使用。
国内外应用DDD的一线公司及领域
公司:thoughtworks,阿里巴巴集团, 360集团等。
应用DDD的领域:金融、保险、电商等应用。
未应用DDD的领域:区块链、大数据相关、 AI相关、纯技术中间件团
队……
先驱:Eric Evans(概念提出者)、 Martin Flower(推广者)。
构建领域知识/模型
领域模型:与业务领域专家交流,将零散的业务知识建立抽象——在脑海里建立一个业务蓝图,随后不断完善使之越来越清晰。
领域模型不需具备一本百科普及类似的结构,而是经过严格组织并选择性抽象后的知识——只选取对系统有用的领域知识,忽略掉无关的部分。
领域模型贯穿设计和开发的全过程。如何取舍领域对象是设计阶段
的工作,如软件会关注客户的联系方式,但不太会关心客户眼睛的
颜色。
模型是最基础部分,可以简化复杂问题的设计过程。
模型是用来与领域专家、架构师、开发工程师和测试工程师进行交流的核心工具,因此需要做到精确、完整、无二义性。
模型的表现方式可以是图、用例、也可以是文档,甚至是伪代码。
模型也可以用来进行代码设计(非软件设计中所说的架构设计,这里指具体的代码细节,如代码分层,如何分包等)
构建领域知识/模型——简单案例分解
地铁监控系统(假想需求):不考虑完整的地铁网路控制系统,只跟踪有换乘线路的单个地铁站,判断某一条线是否遵照了预定的线路,以及是否有可能发生碰撞。
领域专家:地铁航线管理或监控专家,但不能指望他们完整的描述
这个领域的所有问题。他们可能会描述的内容包括:列车线路号、进站时间、出站时间、上一站、下一站等知识。而我们需要从这些看似杂乱的信息中找到规律。
由此就抽象了三个基本对象:列车,起始站和目的站(可以合并为同一个对象用状态区分)。然后他们之间的关系又会引发出一个新的对象:路线。
于是我们进一步整理(交流->抽象->反馈-进一步交流):
到这一步,是通过列车和上一站与下一站总结出来的结论,但实际上一列列车从起点站到终点站之间,会组成一条完整的曲线,甚至还要区分内圈与外圈等多条曲线。
简单来说,这些曲线是由很多上一站与下一站组成的。
列车的形式并不是由司机决定的,假设都会有列车的出行计划。则我们可以进一步将模型整理成下面这样(现实肯定复杂很多倍,这里仅是一个例子)
通用语言
业务人员满嘴领域专业词汇(行话),开发人员满脑子类、对象、设计模式、继承、多态、抽象等,甚至两类人的思维模型天差地别,交流起来就类似「鸡同鸭讲」 ,所谓隔行如隔山。
DDD核心原则之一:使用基于模型的语言。确保团队使用的语言在所
有的交流形式中保持一致,这种语言成为“通用语言(Ubiquitous Language)”
通用语言成型方式:介于领域术语和开发术语之间的,通过类比和举例两种方式达成理解上的一致后,形成名词集合。(个人实操经验,仅供参考)
当一个领域的通用语言成熟以后,是可以直接由开发人员转化为代码中对象的表示的,甚至单词都可以不用变化。
如此一来,代码的可读性得到提高,模型也完美地呈现了。当模型逐步变大时,技术上的意外可以得到一定程度的控制。
小结
本部分结合一个简单的例子,介绍了DDD相关的基本概念和部分方法。
这属于理论层面的内容,更多的属于在项目前期需求交流和设计阶段的工作。
接下来的部分将介绍如何依赖领域模型进行落地的方法:将领域模型设计的内容转换为具体的代码。
模型驱动设计
模型驱动设计——概述
理论总是完美的,落地是有困难的。技术人员如何经受生产环境的考验?能做到易扩展和易维护吗?
领域可以从不同角度被表现为多种模型,但只能选择容易被轻易和准确转换为代码的模型。
推荐的做法是在模型设计阶段就让开发人员参与讨论,在建模的过程中,同时考虑如何转换为代码的问题,形成良性的反馈机制——问题越早被识别出来,就越容易被解决。
模型驱动设计的基本构成要素
模型驱动设计——分层架构
不分层的代码无论是阅读还是维护都及其困难。
分层的原则与高内聚低耦合的理念不谋而合。
一般分层分为UI、应用、领域和基础设施,其中基础设施一般与业务无关,属于技术组件级别(中间件、开发框架等)。
在这其中,领域部分是本文重点研究的对象。
层 | 释义 |
用户界面/展现层 | 负责向用户展现信息以及解释用户命令/自演示指引/操 作手册…… |
应用层 | 很薄的一层,用来协调应用的活动。 不包含业务逻辑。 不保留业务对象的状态,但保有应用任务的进度状态。 |
领域层 | 包含关于领域的信息。 是业务软件的核心所在。 这里保留业务对象的状态,对业务对象和他们状态的持 久化被委托给了基础设施层。 |
基础设施层 | 作为其它层的支撑库存在。 提供了层间的通信,实现对业务对象的持久化,包含对 用户界面层的支撑库等左右。 包括DB、缓存、 MQ、开发框架等基础组件…… |
模型驱动设计——实体
实体是有标识符的一种对象,在java中一般表现为POJO。标识符一般可以描述为ID,如用户编号、银行卡编号等,对应到数据库中,一般表示业务主键,或者是有唯一索引的字段。
不到万不得已,尽量不使用属性去映射对象(常见的通过姓名和身份证号去映射客户)。
1. 失血模型:模型仅仅包含数据的定义和getter/setter方法,业务逻辑和应用逻辑都放到服务层中。这种类在Java中叫POJO,在.NET中叫POCO。
2. 贫血模型:贫血模型中包含了一些业务逻辑,但不包含依赖持久层的业务逻辑。这部分依赖于持久层的业务逻辑将会放到服务层中。可以看出,贫血模型中的领域对象是不依赖于持久层的。
3. 充血模型:充血模型中包含了所有的业务逻辑,包括依赖于持久层的业务逻辑。所以,使用充血模型的领域层是依赖于持久层,简单表示就是 UI层->服务层->领域层<->持久层。
4. 胀血模型:胀血模型就是把和业务逻辑不想关的其他应用逻辑(如授权、事务等)都放到领域模型中。我感觉胀血模型反而是另外一种的失血模型,因为服务层消失了,领域层干了服务层的事,到头来还是什么都没变。
模型驱动设计——值对象
实体是可以被跟踪的,但跟踪和创建标识符需要很大的成本,特别是涉及DB分片的系统。也可能带来性能问题,因为需要对每个对象产生一个实例。
当只关心一个对象所拥有的属性而不需要其标识符的时候,可以使用另一类对象:值对象(Value Object,简称VO)。也有人称之为数据传输对象(Data Transfer Object,简称DTO)。
值对象相对实体对象而言,是可以轻易创建和丢弃的,一般设计为不可变类。同时VO应该尽量保持简单。
值对象可以包含其他的值对象,甚至可以包含对实体对象的引用。值对象的命名一般是在实体对象名称之后加上VO或DTO字样以示区别,也可以单独分包,但仅通过包名来区分的话,容易引起误会。
VO一般通过构造方法进行初始化,也可以通过builder的方式进行。
模型驱动设计——服务
实体对象和值对象应该尽量减少行为,仅仅包含属性和对属性进行set或get的方法。
比如设计一个账户,当涉及转账操作时,无论是将这个动作放在转出账号上还是转入账号上,都显得很别扭。
类似这样用来形容动作或者行为的,应该单独声明为一个服务。服务用来协调,服务于实体和值对象的相关功能进行分组。
服务担当了一个提供操作的接口。
模型驱动设计——服务三个特征
1、服务执行的操作涉及一个领域概念,这个领域概念通常不属于一个实体或者值对象;
2、被执行的操作涉及到领域中的其他对象;
3、服务的操作应该是无状态的,仅仅表示一个动作或行为。如转账、客户注册、短信发送等……
模型驱动设计——模块
当模型越来越大,就需要模块来组织相关概念和任务,以降低复杂度。
另一个原因提升代码质量。当涉及到功能性内聚和通信性内聚要求时,使用模块。
模块应该由在功能上或逻辑上属于一体的元素构成,同时具有定义好的接口,提供给其他模块访问。
提供的接口应该是封装完整以后,以减少对方调用接口的数量为主要目的。
模型驱动设计——聚合、工厂和资源库
聚合:用来定义对象所有权和边界的领域模式。
工厂和资源库:处理对象的创建和存储问题。
模型驱动设计——聚合
一个模型会包含众多领域对象,领域对象之间会形成一对一、一对多或对多对关联,从而形成了一个复杂的关系网。
1、删除非本质的关联关系:在领域中存在,但在模型中不必要。
2、通过增加约束的方式消减多重性。
3、将双向关联转换成非双向的关联,如汽车拥有引擎,但引擎并不需要拥有汽车。
聚合使用边界将内部和外部的对象划分开,每个聚合有一个根,也就是一个实体对象,并且它是外部可以访问的唯一对象。
外部对其他对象的引用,全部由这个跟对象来进行访问。(类似于企业组织架构之间沟通的意思,总经理只找总监,总监再找下面的
人。)
模型驱动设计——工厂
当实体的属性很多时,通过构造器来创建对象是非常困难的,代码可读性也非常差,也与领域本身所做的事情相冲突。在领域中,某些事物通常是由其他事物组合的(如汽车装配~~)。
这里的工厂概念与设计模式的类似,可以理解成简单工厂模式、抽象工厂模式和工厂方法模式的并称,因需选用对应的模式即可。
模型驱动设计——资源库
资源库:封装所有获取对象引用所需的逻辑。简单来说就是对获取数据的操作进行封装。
重构与一致性
持续重构
字面含义。
凸显关键概念
让隐式的概念显式化。
挖掘模型概念的方式有:使用领域文献;与领域专家深入交谈等。
从代码的例子,如HashMap扩容的约束,判断是否需要扩容时,将判断条件(约束)提取为单独的boolean方法,就是一种手段。
凸现关键概念,在领域建模过程中和设计过程中会交互产生,但并不会表现得很明显,很多时候是在潜移默化的过程中发生的。
保持模型一致性
接下来的章节全部是针对跨团队合作大型项目群中出现的问题所提供的解决思路。
多个团队之间需要合作,同时又是并发开发。比如项目组A定义了一个模型,项目组B在开发时觉得少了一部分东西,于是就增加或修改了部分内容,但项目组B的这位开发人员没有意识到,这个动作实际上是对模型进行了变更。
如此相互修改后,就会导致系统实现上完全背离了当初的设计,从而导致系统出现问题。
想想自己平时校验数据的方法?用isNull还是isNotNull?
保持模型一致性——界定的上下文
每一个模型定义一个上下文,一个独立的模型,上下文是固定的。
定义不同模型间的边界和关系,不能在不同模型间传递任何对象,也不能在没有边界的情况下激活行为。
不同模型的代码不能合并。模型应该足够小,直到只有逻辑相关以及能形成自然概念的因素放在一个模型中为止,同时也要一个小团队能够实现(转换为代码)。
万不得已不同的团队要在同一个模型上工作时,要时刻注意不要踩到别人的脚(各司其职,不越边界)。
保持模型一致性——持续集成
当界定的上下文定义以后,就需要保持模型的完整性和一致性,不能再继续分割模型。
一开始定义模型是不完美的,一般是先创建模型,然后反复持续完善。
使用持续集成,确保新增的部分和模型原有的部分配合得很好,在代码中也能被正确地实现。
对3—7人的独立小团队而言,每日合并代码是比较推荐的做法。
合并的代码需要自动构建并执行自动测试(单元测试、 Mock等)。
保持模型一致性——上下文映射(Context Map)
每个团队在自己的模型下工作,但最好能了解所有的模型。
上下文映射是指抽象出不同界定上下文和它们之间关系的文档,可以是一个图,也可以是其他文档。
保持模型一致性——共享内核
在上下文之间,共享内核(Shared Kernel) 和客户-供应商(Customer-Supplier)是具有高级交互的模式。
隔离通道(Separate Way)是在我们想让上下文高度独立和分开运行时要用到的模式。
还有两个模式处理系统和继承系统或者外部系统之间的交互,它们是开放主机服务(Open Host Service)和防崩溃层(Anticorruption Layer)。
共享内核的目的是减少重复,但是仍保持两个独立的上下文。对于共享内核的开发需要多加小心。
如果团队用的是内核代码的副本,那么要尽可能早地融合(Merge)代码,至少每周一次。还应该使用测试工具,这样每一个针对内核的修改都能快速地被测试。
内核的任何改变都应该通知另一个团队,团队之间密切沟通,使大家都能了解最新的功能。
共享内核的目的是减少重复,但是仍保持两个独立的上下文。对于共享内核的开发需要多加小心。
如果团队用的是内核代码的副本,那么要尽可能早地融合(Merge)代码,至少每周一次。还应该使用测试工具,这样每一个针对内核的修改都能快速地被测试。
内核的任何改变都应该通知另一个团队,团队之间密切沟通,使大家都能了解最新的功能。
保持模型一致性——客户与供应商
经常会遇到两个子系统之间关系特殊的时候:一个严重依赖另一个。
两个子系统所在的上下文是不同的,而且一个系统的处理结果被输入到另外一个。而且没有共享内核。
在两个团队之间确定一个明显的客户/供应商关系。在计划场景里,让客户团队扮演和供应商团队打交道的客户角色。
为客户需求做充分的解释和任务规划,让每个人理解相关的约定和日程表。
联合开发可以验证期望(Expected)接口的自动化验收测试。
保持模型一致性——顺从者
供应商团队并不一定会做得很好。利他精神、自己的deadline等会导致为客户团队服务的力度不够,客户团队大多数情况下比较无助。
如果客户不得不使用供应商团队的模型,而且这个模型做得很好,那么就需要顺从了,而且要完全顺从。
这个模式与共享内核相似,但最大的区别是,客户团队没有任何权限修改模型。
示例:一般称为“核心”系统会作为供应商,“前置”“应用”等为客户。常用做法是提供一个大而全的模型,甚至还会提供一个JSON串用来防止临时需要的发生。
保持模型一致性——防崩溃层
不同团队之间会有模型交互,如接口。
不能忽视和外部模型的交互,但是我们也应该小心地将我们的模型和它隔离开来。方法就是在自己的客户端模型和外部模型之间,建立一个防崩溃层。
实现:一个非常好的方案是将这个层看作从客户端模型来的一个服务。
一般来说是Facade和Adapter的组合。
保持模型一致性——独立方法
独立方法模式适合一个企业应用可由几个较小的应用组成,而且从建模的角度来看彼此之间有很少或者没有相同之处的情况。
创建独立的界定上下文(Bounded Context),并独立建模。
这样做的好处是有选择实现技术的自由。如有些团队使用Java、有些团队使用Node.js、还有些团队使用Go或者Ruby等。
然后再通过一个瘦的GUI或者类似门户的形式,将这样的小应用组合起来,通过按钮点击不同系统结合单点登录的方式来使用。
保持模型一致性——开放主机服务
集成两个子系统时,通常要在它们之间创建一个转换层,用来扮演缓冲的角色。但如果需要集成的系统较多,就是一个灾难。系统难以维护,调整极度困难。
定义一个能以服务的形式访问子系统的协议。开放它,使得所有需要和你集成的人都能获取到。然后优化和扩展这个协议,使其可以处理新的集成需求,但某团队有特殊需求时除外。
特殊的需求在协议之上再建立一个转换层。
这种做法,叫开放主机服务。
保持模型一致性——精炼
一个大的领域会有一个大的模型。即使在重构多次之后,也依然会
很大。
对于这样的情况,就需要精炼。
思路是定义一个代表领域本质的核心域(Core Domain)。精炼过程的副产品将是组合领域中其他部分的普通子域(Generic Subdomain) 。
在之前的例子中,将进站站点和出站站点统一为经纬度坐标的过程,其实就是一次精炼的过程。