1. 什么是DDD?为什么业务系统要使用领域设计?
领域驱动设计是一种模型驱动的设计方法,通过模型精炼业务知识,使用模型构造的更易维护的软件。
如果我们构造的是业务系统,那么团队中就会引入并不具有开发背景的业务方参与。那么这个时候,与领域无关的数据结构及其关联算法,由于业务方并不了解,那么在他们的头脑中也就无法直观地映射为业务的流程和功能。这种认知上的差异,会造成团队沟通的困难,从而破坏统一语言的形成,加剧知识传递的难度。
对于业务系统而言,领域设计所构建的领域模型有利于代码的构建和业务知识的传递。这种知识传递不仅有利于业务知识在参与方种传递,也让代码更加易于理解。
2. 方法论
2.1 知识消化(迭代式试错建模法)
可以帮助我们去提炼领域模型
知识消化法具体来说有五个步骤,分别是:
两关联一循环
2.1.1 关联模型与软件实现
模型关联的实现方法,也就是被称作“富含知识的模型(Knowledge Rich Model)”,是一种面向对象的编程风格。因此,我们强调模型与实现关联,实际上也就在变相强调面向对象技术在表达领域模型上的优势。接下来我们具体分析。
从贫血模型到富含知识的模型
我们习惯于构造与具体领域无关的模型,举个简单例子,比如极客时间的用户订阅专栏。我们很容易在头脑中建立起它的模型:
代码可能是:
class UserDAO {...public User find(long id) {try(PreparedStatement query = connection.createStatement(...)) {ResultSet result = query.executeQuery(...);if (rs.next)return new User(rs.getLong(1), rs.getString(2), ...);...} catch(SQLException e) {...}}}class SubscriptionDAO {...// 根据用户Id寻找其所订阅的专栏public List<Subscription> findSubscriptionsByUserId(long userId) {...}// 根据用户Id,计算其所订阅的专栏的总价public double calculateTotalSubscriptionFee(long userId) {...}}
这样的实现方式就是被称作“贫血对象模型”(Anemic Model)的实现风格,即:对象仅仅对简单的数据进行封装,而关联关系和业务计算都散落在对象的范围之外。也就是它们之间的关联关系是人为的实现的,而不是显示的展现在程序设计当中。这种方式实际上是在沿用过程式的风格组织逻辑,而没有发挥面向对象技术的优势。
与之相对的则是“充血模型”,也就是与某个概念相关的主要行为与逻辑,都被封装到了对应的领域对象中。“充血模型”也就是 DDD 中强调的“富含知识的模型”。
class User {// 获取用户订阅的所有专栏public List<Subscription> getSubscription() {...}// 计算所订阅的专栏的总价public double getTotalSubscriptionFee() {...}}class UserRepository {...public User findById(long id) {...}}
从这段代码很容易就可以看出:User(用户)是聚合根(Aggregation Root);Subscription(订阅)是无法独立于用户存在的,而是被聚合到用户对象中。
这里就是通过聚合关系表达业务概念。通过关联关系连接的一组对象,也可以表示业务概念,而一部分业务逻辑也只对这样的一组对象起效。
关联模型与软件实现,最终的目的是为了达到这样一种状态:修改模型就是修改代码;修改代码就是修改模型。
2.1.2基于模型提取统一语言
统一语言(Ubiquitous Language)是一种业务方与技术方共同使用的共同语言(Common Language),业务方与技术方通过共同语言描述业务规则与需求变动。
一旦业务方接受了统一语言,实际上就是放弃了对业务 100% 的控制权,也意味着统一语言在业务上能够赋予开发人员更大的控制权。这或许是出于 Eric 的故意设计,抑或是源于 Eric 对敏捷价值观的深刻认同,在有限的几次接触中,我并没有跟他求证过。但可以肯定的是,统一语言实际上赋予了技术人员定义业务的权利。
统一语言本身的形式并不重要,或者说统一语言并没有统一的形式,它甚至可以是任意一种形式。但是,当且仅当,统一语言与领域模型关联,且多方认可并承认对统一语言的集体所有权时,统一语言才能成为真正的统一语言。
统一语言可以包含以下内容:
源自领域模型的概念与逻辑;
界限上下文(Bounded Context);
系统隐喻;
职责的分层;
模式(patterns)与惯用法。
2.1.3 一循环
开发富含知识的模型;
精炼模型;
头脑风暴与试验。
提炼知识的循环大致是这样一个流程:
首先,通过统一语言讨论需求;
而后,发现模型中的缺失或者不恰当的概念,然后精炼模型,以反映业务的实际情况;
接着,对模型的修改会引发统一语言的改变,再以试验和头脑风暴的态度,使用新的语言以验证模型的准确。
提炼知识的循环最终会反映到具体的软件实现上,那么业务方实际也就更深入地参与到了研发流程中。如果说统一语言与模型的关联赋予了技术方定义业务的权利,那么提炼知识的循环也就赋予了业务方影响软件实现的权利。
能否真正地变成统一语言,则需要团队接纳并逐步在工作中使用它。这是行为改变,需要变革管理去推动。而知识消化希望通过头脑风暴与试验的方法,简化这种变革
2.2 落地的障碍1-分页
2.2.1 延迟加载引发的经典的性能瓶颈 N + 1 问题
这里接着上面所使用的代码例子:
class User {private List<Subscription> subscriptions;// 获取用户订阅的所有专栏public List<Subscription> getSubscriptions() {...}// 计算所订阅的专栏的总价public double getTotalSubscriptionFee() {...}}class UserRepository {...public User findById(long id) {...}
按照面向对象和领域驱动设计提倡的做法,User 作为聚合根,需要管控其对应的 Subscription。我们会发现,这里的Subscription所暗含的意思,是这个User的全部Subscription,那要是这个User很喜欢学习,买了成千上万个专栏呢?此时将所有订阅过的专栏都读取到内存里,这就意味着会有巨大的网络 I/O 开销和内存占用。
当然,这里你可能会说,JPA/Hibernate 等 ORM 提供了延迟加载啊。是的,但这又会引入经典的性能瓶颈 N + 1 问题。因为随着延迟加载集合的遍历,其中的 Subscription 对象会被依次加载。
延迟加载的实现流程是这样的:
- 先执行一条查询获取集合的概况。比如总共有多少条记录之类的信息。
- 然后根据概况信息,生成一个集合对象。这时候集合对象基本上是空的,不占用什么内存空间。
- 随后,当我们需要集合内的具体信息的时候,它再根据我们需要访问的对象,按需从数据库中读取。
理论上讲,这是为了避免一次性读入大量数据带来的性能问题,而提出的解决办法。
然而,如果需要获取所有的数据,那么我们总共就会有 N+1 次数据库访问:1 次是指第一次获取概况的访问,N 次指而后集合中 N 个对象每个一次。而每一次加载,都伴随着对数据库的访问,自然就会带来 I/O 与数据库的开销。特别是频繁地对数据库访问,可能会阻塞其他人,从而造成性能瓶颈。
在这种情况下,我们其实没有什么好的选择:要么是一次性读入全部数据,避免 N + 1 问题;要么是引入 N+1 问题,变成钝刀子割肉。
2.2.2 为解决N+1问题,常用的设计为何不符合领域设计
这里我根据文章总结下领域设计应该遵循的规范:
- 避免逻辑泄露
在这里的例子中,Subscription 被 User 聚合,那么 User 所拥有的 Subscription 的集合逻辑应该被封装在 User 中,这样才能保证 User 是“逻辑丰富的模型”。
- 分离技术实现和逻辑领域
2.2.2.1 Spring推荐的方式:构造一个独立的 Repository 对象
这种做法是为订阅(Subscription)构造一个独立的 Repository 对象,将逻辑放在里面(也是 Spring 推荐的做法)
interface SubscriptionRepository extends ... {public Page<Subscription> findPaginated(int from, int size);}
这种做法的问题就是会导致逻辑泄露。Subscription 被 User 聚合,那么 User 所拥有的 Subscription 的集合逻辑应该被封装在 User 中,这样才能保证 User 是“逻辑丰富的模型”。
我们复习下“充血模型”:
与某个概念相关的主要行为与逻辑,都被封装到了对应的领域对象中
2.2.2.2 在User中实现分页
如:
public class User {public List<Subscription> getSubscriptions() {// db操作}public List<Subscription> getSubscriptions(int from, int size) {return db.executeQuery(....);}}
这也是不行的,因为这么做会将技术实现细节引入领域逻辑中,而无法保持领域逻辑的独立。
2.2.2.3 总结
上面两种其实都偏向于“贫血模型”。我们来重新复习一下:
“贫血对象模型”(Anemic Model)的实现风格,即:对象仅仅对简单的数据进行封装,而关联关系和业务计算都散落在对象的范围之外。也就是它们之间的关联关系是人为的实现的,而不是显示的展现在程序设计当中。
造成这种两难局面的根源在于,我们希望在模型中使用集合接口,并借助它封装具体技术实现的细节。
也就是我们总想着用List 去封装技术实现,然而它是jdk提供的包,我们不能改变代码。
至于为什么在我们的概念中,内存中的集合与数据库是等价的,都可以通过集合接口封装。
可以去这个链接看,这里就不展开了。
2.2.3 解决方案-关联对象法
关联对象,顾名思义,就是将对象间的关联关系直接建模出来,然后再通过接口与抽象的隔离,把具体技术实现细节封装到接口的实现中。这样既可以保证概念上的统一,又能够避免技术实现上的限制。
2.3 落地的障碍2-上下文过载
所谓上下文过载,就是指领域模型中的某个对象会在多个上下文中发挥重要作用,甚至是聚合根。一来这个对象本身会变得很复杂,造成模型僵化;二来可能会带来潜在的性能问题。
2.3.1 上下文过载产生的问题
2.3.1.1 过于富含逻辑而产生的过大类
为了帮助你理解上下文过载会产生哪些问题,我还是借助极客时间专栏的例子来说明一下。当然,首先我们需要扩展这个领域模型。
在这个扩展了的模型中,包含了三个不同的上下文。
- 订阅:用户阅读订阅内容的上下文,根据订阅关系判断某些内容是否对用户可见;
- 社交:用户维护朋友关系的上下文,通过朋友关系分享动态与信息;
- 订单:用户购买订阅专栏的上下文,通过订单与支付,完成对专栏的订阅。按照这个模型,我们很容易得到与之对应的“富含知识的模型”: ```java
public class User { private long id;
// 社交上下文private List<Friendship> friends;private List<Moments> moments;// 订阅上下文private List<Subscription> subscriptions;// 订单上下文private List<Order> orders;private List<Payment> payments;// 社交上下文public void make(Friendship friend) {...}public void break(Friendship friend) {...}// 订单上下文public void placeOrder(Column column) {...}// 订阅上下文public boolean canView(Content content) {...}
} ``` 如果对代码坏味道敏感的同学,估计已经发现问题所在了:一个对象中包含了不同的上下文,而这恰好是坏味道过大类(Large Class)的定义。
那么过大类会带来什么问题呢?首当其冲是模型僵硬。想要理解这个类的行为,就必须理解所有的上下文。而只有理解了所有的上下文,才能判断其中的代码和行为是否合理。
于是,上下文的过载就变成了认知的过载(Cognitive Overloading),而认知过载就会造成维护的困难。通俗地讲,就是“看不懂、改不动”,代码就变成“祖传代码”了。
但是我们不要忘了,这是与模型关联的代码啊!改不动的代码就是改不动的模型!改不动的僵硬的模型,要怎么参与提炼知识的循环呢?
2.3.1.2 逻辑汇聚于上下文还是实体?
上下文过载问题最根本的症结在于,逻辑是需要汇聚于实体(User)还是上下文(订阅、社交与订单)。
原味面向对象范型(也是领域驱动设计的默认风格)的答案是汇聚于实体,但是缺少有效分离不同上下文的方式。而 DCI 范型(Data-Context-Interaction,数据 - 上下文 - 交互)要求汇聚于显式建模的上下文对象(Context Object),或者上下文中的角色对象(Role Object)上。如果按照 DCI 范型的思路,我们可以如下图这样建立模型:

这其实也很容易理解。在不同的上下文中,用户是以不同的角色与其他对象发生交互的,而一旦离开了对应的上下文,相关的交互也就不会发生了。
从 DCI 的角度看待聚合与聚合根关系,我们可以发现,并不是 User 聚合了 Subscription,而是订阅上下文中的 Reader 聚合了它。同时,并不是 User 聚合了 Friendship 与 Moments,而是社交上下文中的 Contact 聚合了它们。可以说,User 只是恰好在不同的上下文中扮演了这些角色而已。
明白这一点,解决方案也就呼之欲出了:针对不同上下文中的角色建模,将对应的逻辑富集到角色对象中,再让实体对象去扮演不同的角色,就能解决上下文过载的问题了。
当然,理想永远是清晰且美好的,在实践中却没有这么简单,主要是如何在代码中实现这种实体与角色间的扮演关系。
一个思路就是通过装饰器(Decorator),我们可以构造一系列角色对象(Role Object)作为 User 的装饰器:
角色对象(Role Object)和上下文对象(Context Object)
上下文过载: 指领域模型中的某个对象会在多个上下文中发挥重要作用,甚至是聚合根。一来这个对象本身会变得很复杂,造成模型僵化;二来可能会带来潜在的性能问题
2.4 落地障碍3- 分层结构
如何组织领域逻辑与非领域逻辑,才能避免非领域逻辑对模型的污染?
