通用的仓库和工厂
我有一个梦,就是希望DDD能够成为今后软件研发的主流,越来越多研发团队都转型DDD,采用DDD的设计思想和方法,设计开发软件系统。这个梦想在不久的将来是有可能达成的,因为DDD是软件复杂性的解决之道,而今后的软件会越来越庞大,越来越复杂。然而,经过这几年的DDD热潮,阻碍各研发团队转型DDD的拦路虎是什么呢?我认为是DDD落地开发编码过于复杂,编码工作量大,阻碍了DDD的推广。试想,一个新的开发思想能降低研发的工作量,必然得到大家的普遍欢迎,推行就比较容易,而反之则非常困难,抵触情绪大。
那么,为什么DDD落地开发的成本很高,编码的工作量大呢?针对这个问题,我们要好好分析分析,进而找到解决的思路。按照DDD的开发规范,当团队经过一系列的业务梳理,形成领域模型以后,就开始以领域模型为核心,设计开发业务系统。如图所示是DDD的分层架构:
通过这张图可以看到,展现层就是前端界面(如WebUI、客户端程序、移动App等等),应用层就是Controller,领域层的服务就是Service,聚合、实体、值对象都是从领域模型中映射过来的领域对象,其它都是基础设施(其中也包括了上一期提到的仓库、工厂、缓存)。这样一个架构集中体现了“整洁架构”的设计思想,即将上层的业务代码与底层的技术框架通过分层进行分离,进而实现解耦。也就是说,只有领域层的服务Service、领域对象(聚合、实体、值对象)才能编写业务代码,实现业务规则、业务操作、业务流程,是领域模型的映射。其它层次的代码都是技术,包括前端UI、应用层的Controller、基础设施,以及这张图没有画出来的服务网关、数据库,等等。按照这样的设计思想,最后代码编写的效果就变成了这样:
在整个系统中,每个功能都必须要有各自的Controller、Service和该功能所需的领域对象(聚合、实体、值对象),然后在持久化到数据库的时候,还得有对应的仓库、工厂、缓存。并且,每个功能的仓库、工厂、缓存都不一样。譬如,订单仓库中存在聚合,在增删改订单表的同时,还有增删改订单明细表,而库存仓库只需要管库存表的增删改;订单工厂在查询的时候需要装配与订单相关的客户、地址、订单明细,而库存工厂只需要装配与库存相关的商品对象。正因为如此,每个功能在开发的时候,都需要编写大量代码。
不仅如此,在不同层次中,数据是存储在不同格式的数据对象中,因此数据在各个层次中流转时,还要编写各种格式的数据对象及转换程序,在Json, DTO, DO与PO中转换数据。这样一套下来,DDD的软件开发就麻烦死了,如果我是程序员,我也会不胜其烦。这就是DDD目前的问题所在。
很多时候就是这样的,当分析和查找到问题以后,就离解决问题不远了。我的思路就是,通过一个底层平台(如低代码平台)将DDD中那些繁杂的操作统一起来,实现集约化,那么开发人员就只需要去编写那些各自的业务代码,那么工作量不就变小了吗?也就是说,如果数据接入层、应用层、基础设施层都通过平台实现了,开发人员就只需要编写领域层的领域服务Service和领域对象(聚合、实体、值对象),以及前端的UI界面,开发人员的工作量就减少了,就可以更加专注地按照领域模型去设计编码,DDD不就更容易落地了。
这的确是一个非常完美的思路,然而要实现这个思路,很显然需要一个支持DDD的强大底层平台。那么,这个强大的底层平台需要提供哪些功能呢?我认为有3个:通用的Controller、仓库及其工厂。按照CQRS(Command Query Responsibility Segregation)架构的设计思想,该平台可以划分成两部分设计:增删改(即命令)和查询,我们先看看增删改的设计思路。
在业务系统实现增删改的操作(即命令操作)时,和过去一样,每个功能在前端都有各自的UI。但和过去不一样的是,所有的UI在请求后端时,都是请求的那一个Controller(即OrmController)。前端请求的Url中包含了要请求的功能,因此这个Controller通过反射去请求后端的Service,并自动将Json转换为领域对象。这里有一个很神奇的事情就是,这个Controller怎么能自动将Json转换为领域对象呢?我们可以在开发规范上规定,前端请求后端时,Json对象必须与后台的领域对象保持一致。如前端提交订单,其Json对象长这样:
{
"id": 1,
"customerId": 10001,
"addressId": 1000100,
"orderItems": [
{
"id": 10,
"productId": 30001,
"quantity": 2
}
]
}
可以看到,Json对象不必包含领域对象的所有属性,而是必要属性。后端的OrmController收到这个Json以后,就可以通过DDD工厂去读取DSL,将Json转换为订单对象,并请求OrderService中的create()方法,就可以完成创建订单的操作。当然,如果用户请求的是“下单”,要做的就不仅仅是创建订单,还有支付、库存扣减等操作,需要分布式事务,因此请求的是OrderAggService的placeOrder()方法,详细的设计详见测试用例:
OrderService的测试用例
OrderAggService的测试用例
接着,整个业务操作都在领域层的Service和领域对象中进行(详见《充血模型 or 贫血模型》)。当所有的业务操作的执行完以后,通过仓库进行数据持久化。这时,所有的Service都注入了通用仓库,由它去完成相关的增删改操作(详见上一期的通用仓库设计思路)。
有了DDD的底层平台的支持,所有的领域对象和Service完成的都是对业务的操作,它们只知道领域对象长什么样,只对领域对象进行操作,并不知道后面有数据库,从而实现整洁架构中业务与技术的解耦。接着,将增删改等数据库操作交给底层的通用仓库,包括聚合关系的增删改,实现了CQRS中的“C”。
那么,领域对象的查询(即CQRS中的“Q”)又该如何实现呢?按照DDD的设计思想,当我们将领域模型中对象的关系映射到程序中领域对象的关系以后,在查询领域对象时,底层也要保持这种关系。也就是说,当查询订单时,底层不仅仅是查询订单表,还要查询与订单相关的用户表、地址表与订单明细表,最后将它们装配成一个完整的订单对象。这个查询与装配的工作就交给了DDD的“工厂”来完成。
同样,过去的DDD编码实现,需要为每个领域对象编写工厂,来完成这个查询与装配的工作,这无疑会增加DDD的开发工作量。为了简化DDD的编码,降低落地难度,我们的思路同样是由底层提供一个通用的工厂,所有对领域对象的查询统统都交给这个通用工厂。当通用工厂要查询数据时,先查找DSL获取该对象对应的数据库表与所有的关系。然后,通用工厂根据这些信息,依次到数据库各对应表中去进行查询。最后,再依据DSL进行装配,返回一个完整的领域对象。这个领域对象在返回给Service前,还会在仓库中进行缓存,以提高下次查询的效率。所以,通用工厂不直接面向Service,而是被通用仓库封装。通用仓库封装了通用仓库与缓存,就可以完成上层Service的所有增删改与查询的操作。
譬如,现在要实现对订单的查询,首先通过MyBatis去编写一个mapper:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.edev.emall.query.dao.CustomerMapper">
<sql id="select">
SELECT * FROM t_customer WHERE 1 = 1
</sql>
<sql id="conditions">
<if test="id != '' and id != null">
and id in (${id})
</if>
<if test="value != '' and value != null">
and (id like '%${value}' or name like '%${value}')
</if>
<if test="gender != '' and gender != null">
and gender = #{gender}
</if>
</sql>
<sql id="isPage">
<if test="size != null and size !=''">
limit #{size} offset #{firstRow}
</if>
</sql>
<select id="query" parameterType="java.util.HashMap" resultType="java.util.HashMap">
<include refid="select"/>
<include refid="conditions"/>
<include refid="isPage"/>
</select>
<select id="count" parameterType="java.util.HashMap" resultType="java.lang.Long">
select count(*) from (
<include refid="select"/>
<include refid="conditions"/>
) count
</select>
<select id="aggregate" parameterType="java.util.HashMap" resultType="java.util.HashMap">
select ${aggregation} from (
<include refid="select"/>
<include refid="conditions"/>
) aggregation
</select>
</mapper>
在这个mapper中可以看到,对订单的查询就只查询订单表,而不进行其它任何的关联。接着,在spring中进行如下的装配:
@Configuration
public class QryConfig {
@Autowired @Qualifier("basicDaoWithCache")
private BasicDao basicDaoWithCache;
@Autowired @Qualifier("repositoryWithCache")
private BasicDao repositoryWithCache;
@Bean
public QueryDao customerQryDao() {
return new QueryDaoMybastisImplForDdd(
"com.edev.emall.customer.entity.Customer",
"com.edev.emall.query.dao.CustomerMapper");
}
@Bean
public QueryService customerQry() {
return new AutofillQueryServiceImpl(
customerQryDao(), repositoryWithCache);
}
@Bean
public QueryDao accountQryDao() {
return new QueryDaoMybastisImplForDdd(
"com.edev.emall.customer.entity.Account",
"com.edev.emall.query.dao.AccountMapper");
}
@Bean
public QueryService accountQry() {
return new AutofillQueryServiceImpl(
accountQryDao(), basicDaoWithCache);
}
}
先装配一个QueryDao,它有一个QueryDaoMybastisImplForDdd的实现类,通过MyBatis对订单进行查询,然后按照DDD返回订单对象的列表。接着,再注入到QueryService中,它有一个AutofillQueryServiceImpl的实现类。这样,当QueryDao通过分页查询出这一页的20条记录以后,AutofillQueryServiceImpl就会根据DSL进行数据补填,将这每一个订单对象的用户、地址、明细,都到数据库中进行查询,然后完成补填与装配,最后获得一个完整的领域对象列表。有了这样的设计,每一个模块的查询都变得简单了。你只需要按照领域模型先形成领域对象和DSL,然后编写一个MyBatis的mapper,进行spring的装配,查询的开发工作就完成了。注意,AutofillQueryServiceImpl的第二个参数是在进行补填时,用谁来查询并补填。如果要补填的对象里还有关系(如地址里有省、市、县的关联),则选择repositoryWithCache,否则就用basicDaoWithCache。
最后,每个查询功能都有各自的UI界面,但它们在查询时,请求的都是这一个Controller(即QueryController)。这样,通过领域建模,通过一些简单的配置就可以快速完成各个模块的查询功能。
有了这个平台,按照DDD的设计思想,我们只需要进行领域建模,将我们对业务的理解形成领域模型,就可以快速完成软件的开发。如今有了AI编程,甚至可以训练一个Agent,我们只要深入地理解业务,形成领域模型,就可以让AI按照这样的思路快速开发系统。有了这样的思路,不仅可以让我们将更多的精力放到业务理解而不是软件开发,给我们减负,又可以给AI编程制定规范,有利于日后长期的维护与变更。相信在这样的背景下,DDD又可以焕发生机,成为日后软件开发的主流。
(待续)