DDD 中的那些模式 — 使用 Specification 管理业务规则

用户头像
Joshua
关注
发布于: 2020 年 06 月 01 日
DDD 中的那些模式 — 使用 Specification 管理业务规则

许多开发者在项目中希望能够使用 DDD 原因在于能够管理业务的复杂度,避免在业务规则愈发复杂的情况下代码以及架构发生腐化,最终变的难以维护。系统复杂度体现在多个层面,例如繁琐的流程,繁复的校验规则,数据的多样性等,DDD 对于不同层面的复杂度提供了不同的应对模式,今天的文章会聚焦与如何使用 Specification 模式解决「业务规则」相关的复杂性。



业务规则

在介绍 Specification 模式之前,我们先明确一下什么是「业务规则」。作为一个开发者,以下的这些一定是你日常工作中常见的工作。



  • 校验业务对象的某些状态是否合法,例如当前账户是否启用,账户余额是否充足,事故日期是否在保险单的有效时间内。

  • 从业务对象的集合中筛选出符合条件的结果集,例如从用户的交易记录中找出购买打折产品的记录。

  • 检查一个新创建的业务对象是否符合某些业务条件,例如一张新创建的订单,它对应的客户与商户都应该是合法系统用户。



为了方便后续的讨论,我们将业务规则的概念窄化为以上三种类型。接下来的问题是这些业务规则在系统中是如何实现的?



最原始的方法是编写了许多小方法实现这些校验或是筛选逻辑,分散在各个 Domain Service 类中。这样做的缺点很明显,其一是难以管理,当业务规则越来越多时,这些散落在各处的方法就无法复用,而开发人员也没有办法集中的管理这些方法。其二是丢失了业务知识,这些方法大部分都很短小,简单,但是这些规则其实包含了大量的业务知识,如果任其分散在不同的 Domain Service 中,后续的开发过程中就很容易丢失这些业务知识。



在此基础上的另一个方案也是实际项目中使用比较多的,即编写各种不同的 Validator 类,每个 Validator 类中有大量对于领域对象的校验方法。这种做法一定程度上解决了第一个问题,通过特定的类将检验方法集中起来,可以方便开发人员进行维护和扩展。下面是一个典型的 Validator 的示例代码:



public class CustomerValidator {
public static boolean isVIP(Customer customer) {
……
}
}



但是这对于业务知识的传递没有太大的帮助。Validator 类更像是一些工具类,和领域层并没有什么关联。而当需要对这些校验方式进行复用时,特别是将几个校验规则按照 andor 这样的逻辑关系组合起来时,Validator 就支持不是那么好了。



另一种方法是是将这些校验的规则在领域对象中实现,作为该领域对象的一个方法。参考如下的代码:



public class Customer {
public boolean isVIP() {
……
}
}



这种做法优点是校验规则与领域对象结合的非常紧密,开发人员一看就明白。但是缺点同样明显,就是你的领域对象会变得日益臃肿,充斥着大量类似这种的校验方法掩盖了核心的业务规则。



那么有没有更好些的做法呢?Specification 模式提供了一种不错的选择。



Specification 模式

DDD 中认为这些规则都是纯粹的「动词」,因此需要单独的建立模型,而这些模型都应该是简单的「值对象」。一个 Specification 接口示例如下:



public interface Specification<T> {
boolean isMatch(T domainObject);
}



然后我们可以实现是否 VIP 客户的校验:



public class VIPCustomerSpecification<Customer> {
@Override
public boolean isMatch(Customer customer) {
……
}
}



通过实现 Specification 接口,我们可以对不同的领域对象扩展不同的校验逻辑,而这些类都是可以复用的。同时这些 Specification 可以作为基础元素进行任意的组合,组合更为复杂的校验规则与筛选逻辑。例如下面的例子中,我们将所有更上层的领域逻辑封装在 CustomerSpecifications 中,而它可以通过组合各个单独的 Specification 提供具体的功能。



public class CustomerSpecification {
public boolean isSpecialCustomer(List<Specification<Customer>> specifications, Customer customer) {
……
}
}



在上面的 isSpecialCustomer 的方法中可以传入校验所需的一系列 Specification,并依次校验,这种做法也便于扩展与复用,与领域模型结合的更为紧密。



使用 Specification 模式过滤数据

从一个数据集中筛选出符合条件的结果也是日常开发中常常需要实现的业务规则。那么我们一般的做法是如何的呢?



假设我们需要筛选出某个客户名下在一月到二月的订单记录,一种最简单也是最常见的做法就是通过 SQL(假设这些数据都是存放在关系型数据库中)。例如通过如下的 SQL 查询:



select * from t_order where t_order.customer_id = ? and t_order.created_at >= ? and t_order.created_at <= ?



使用 SQL 在查询中直接实现筛选逻辑看起来很自然,但问题是这部分的逻辑本来应该是属于领域层的,现在却泄漏到了数据层,造成的后果就是维护的难度大大提升,很多业务系统到后期都是在和大段大段的 SQL 做斗争,而应该编写逻辑的 Service 层,Domain 层都成了摆设,退化成了纯粹的数据对象,传来传去,与 DTO 没什么差别。而 Specification 模式可以提供一种不错的解决思路。



我们可以在有如下的代码:



public class OrderSpecifications {
public Specification<Order> inPeriod(LocalDateTime beginTime, LocalDateTime endTime) {
……
}
}



在这里 OrderSpecifications 并没有直接进行数据筛选,而是通过输入参数创建了一个特有的 Specification 对象,然后由 OrderRepository 对象接受 Specification 为参数进行真正的数据筛选操作。这样就将查询与过滤的逻辑分开了。



此时需要考虑的问题是性能,如果按照 SQL 的做法,那么在数据库端就会完成数据的查询与过滤,返回给应用端的数据量不会很大,但是如果使用 Specification 模式那么,就是在内存中进行过滤了,在数据量大的情况下必然会遭遇性能的问题。之前在项目中的确也遇到过类似的问题,由于查询结果数据量过大,而且需要分页展示,这些都在内存中完成的化,并发量一大内存的占用量以及响应速度都变得非常差。



解决的办法有两种,一种如 DDD 书上所介绍,在 Specification 接口上提供一个类似 asSQL() 的方法,将当前的 Specification 对象转化为 SQL 语句。在我一些项目的实践中这种做法比较麻烦,可以认为是换了一种方式拼接 SQL,效果并不好,且难以处理。而另一种则是使用 ORM 框架或是其他高级框架的能力。例如 Spring Data JPA 就提供了基于 JPA 的 Specification 模式的查询功能,使用起来非常方便,也是我建议大家在项目中可以尝试的方式。



小结

Specification 模式是一种非常实用的模式,能够很方便的帮助开发人员对状态校验,数据筛选这样的业务规则进行管理与抽象,而且实际操作的难度较低,对外部的依赖也少,是个十分值得推荐的 DDD 最佳实践。



往期推荐

https://xie.infoq.cn/article/531904c5ae41f9664d358b7f8 DDD 实践手册(番外篇: 事件风暴-概念)

https://xie.infoq.cn/article/e36e68f9c6516fa10cf546f32 DDD 实践手册(番外篇: 事件风暴-实践)

https://xie.infoq.cn/article/e5b24073ad7880713c92b49c3 DDD 中的那些模式 — CQRS



发布于: 2020 年 06 月 01 日 阅读数: 121
用户头像

Joshua

关注

FIND YOUR RHYTHM ENJOY YOUR RUN 2018.05.27 加入

花旗银行/360/ThoughtWorks

评论 (1 条评论)

发布
用户头像
非常好的文章,针对各种场景举得例子很容易理解,对我很有帮助。发现一个小错误:class CustomerSpecification 类名少了复数s
2020 年 06 月 01 日 18:24
回复
没有更多了
DDD 中的那些模式 — 使用 Specification 管理业务规则