05 聚合、仓库与工厂:傻傻分不清楚 上一讲,我们知道了,要将领域模型最终转换为程序设计,可以落实到 3 种类型的对象设计,即服务、实体与值对象,然后进行一些贫血模型与充血模型的设计思路。但这远远不够,还需要有聚合、仓库与工厂的设计

聚合的设计思路

聚合是领域驱动设计中一项非常重要的设计与概念,它表达的是真实世界中那些整体与部分的关系,比如订单与订单明细、表单与表单明细、发票与发票明细。以订单为例,在真实世界中,订单与订单明细本来是同一个事物,订单明细是订单中的一个属性。但是,由于在关系型数据库中没有办法在一个字段中表达一对多的关系,因此必须将订单明细设计成另外一张表。

尽管如此,在领域模型的设计中,我们又将其还原到真实世界中,以“聚合”的形式进行设计。在领域模型中,即将订单明细设计成订单中的一个属性,具体代码如下: public class Order { private Set items; public void setItems(Set items){ this.items = items; } public Set getItems(){ return this.items; } …… }

有了这样的设计,在创建订单的时候,将不再单独创建订单明细了,而是将订单明细创建在订单中;在保存订单的时候,应当同时保存订单表与订单明细表,并放在同一事务中;在查询订单时,应当同时查询订单表与订单明细表,并将其装配成一个订单对象。这时候,订单就作为一个整体在进行操作,不需要再单独去操作订单明细。

也就是说,对订单明细的操作是封装在订单对象内部的设计实现。对于客户程序来说,去使用订单对象就好了,这就包括了作为属性去访问订单对象中的订单明细,而不再需要关注它内部是如何操作的。

按照以下思路进行的设计就是聚合

  • 创建或更新订单时,在订单对象中填入或更新订单的明细就好了;
  • 保存订单时,只需要将订单对象作为整体去保存,而不需要关心订单数据是怎么保存的、保存到哪几张表中、是不是有事务,保存数据库的所有细节都封装在了订单对象内部;
  • 删除订单时,删除订单对象就好了,至于如何删除订单明细,是订单对象内部的实现,外部的程序不需要关注;
  • 查询或装载订单时,客户程序只需要根据查询语句或 ID 查询订单对象就好了,查询程序会在查询过程中自动地去补填订单对应的订单明细。

聚合体现的是一种整体与部分的关系。正是因为有这样的关系,在操作整体的时候,整体就封装了对部分的操作。但并非所有对象间的关系都有整体与部分的关系,而那些不是整体与部分的关系是不能设计成聚合的。因此,正确地识别聚合关系就变得尤为重要。

所谓的整体与部分的关系,就是当整体不存在时,部分就变得没有了意义。部分是整体的一个部分,与整体有相同的生命周期。比如,只有创建了这张订单,才能创建它的订单明细;如果没有了这张订单,那么它的订单明细就变得没有意义,就需要同时删除掉。这样的关系才具备整体与部分的关系,才是聚合。

譬如:订单与用户之间的关系就不是聚合。因为用户不是创建订单时才存在的,而是在创建订单时早就存在了;当删除订单时,用户不会随着订单的删除而删除,因为删除了订单,用户依然还是那个用户。

那么,饭店和菜单的关系是不是聚合关系呢?关键要看系统如何设计。如果系统设计成每个饭店都有各不相同的菜单,每个菜单都是隶属于某个饭店,则饭店和菜单是聚合关系。这种设计让各个饭店都有“宫保鸡丁”,但每个饭店都是各自不同的“宫保鸡丁”,比如在描述、图片或价格上的不同,甚至在数据库中也是有各不相同的记录。这时,要查询菜单就要先查询饭店,离开了饭店的菜单是没有意义的。

但是,饭店和菜单还可以有另外一种设计思路,那就是所有的菜单都是公用的,去每个饭店只是选择有还是没有这个菜品。这时,系统中有一个菜单对象,“宫保鸡丁”只是这个对象中的一条记录,其他各个饭店,如果他们的菜单上有“宫保鸡丁”,则去引用这个对象,否则不引用。这时,菜单就不再是饭店的一个部分,没有这个饭店,这个菜品依然存在,就不再是聚合关系。

因此,判断聚合关系最有效的方法就是去探讨:如果整体不存在时,部分是否存在。如果不存在,就是聚合;反之,则不是。

聚合根——外部访问的唯一入口

有了聚合关系,部分就会被封装在整体里面,这时就会形成一种约束,即外部程序不能跳过整体去操作部分,对部分的操作都必须要通过整体。这时,整体就成了外部访问的唯一入口,被称为 “聚合根”

也就是说,一旦将对象间的关系设计成了聚合,那么外部程序只能访问聚合根,而不能访问聚合中的其他对象。这样带来的好处就是,当聚合内部的业务逻辑发生变更时,只与聚合内部有关,只需要对聚合内部进行更新,与外部程序无关,从而有效降低了变更的维护成本,提高了系统的设计质量。

然而,这样的设计有时是有效的,但并非都有效。譬如,在管理订单时,对订单进行增删改,聚合是有效的。但是,如果要统计销量、分析销售趋势、销售占比时,则需要对大量的订单明细进行汇总、进行统计;如果每次对订单明细的汇总与统计都必须经过订单的查询,必然使得查询统计变得效率极低而无法使用。

因此,领域驱动设计通常适用于增删改的业务操作,但不适用于分析统计。在一个系统中,增删改的业务可以采用领域驱动的设计,但在非增删改的分析汇总场景中,则不必采用领域驱动的设计,直接 SQL 查询就好了,也就不必再遵循聚合的约束了。

聚合的设计实现

前面谈到了领域驱动设计中一个非常重要的概念:聚合。通过聚合的设计,可以真实地反映现实世界的状况,提高软件设计的质量,有效降低日后变更的成本。然而,前面只提出了聚合的概念,要想真正将聚合落实到软件设计中,还需要两个非常重要的组件:仓库与工厂

比如,现在创建了一个订单,订单中包含了多条订单明细,并将它们做成了一个聚合。这时,当订单完成了创建,就需要保存到数据库里,怎么保存呢?需要同时保存订单表与订单明细表,并将其做到一个事务中。这时候谁来负责保存,并对其添加事务呢?

过去我们采用贫血模型,那就是通过订单 DAO 与订单明细 DAO 去完成数据库的保存,然后由订单 Service 去添加事务。这样的设计没有聚合、缺乏封装性,不利于日后的维护。那么,采用聚合的设计应当是什么样呢?

采用了聚合以后,订单与订单明细的保存就会封装在订单仓库中去实现。也就是说采用了领域驱动设计以后,通常就会实现一个仓库(Repository) 去完成对数据库的访问。那么,仓库与数据访问层(DAO)有什么区别呢?

一般来说,数据访问层就是对数据库中某个表的访问,比如订单有订单 DAO、订单明细有订单明细 DAO、用户有用户 DAO。

  • 当数据要保存到数据库中时,由 DAO 负责保存,但保存的是某个单表,如订单 DAO 保存订单表、订单明细 DAO 保存订单明细表、用户 DAO 保存用户表;
  • 当数据要查询时,还是通过 DAO 去查询,但查询的也是某个单表,如订单 DAO 查订单表、订单明细 DAO 查订单明细表。

那么,如果在查询订单的时候要显示用户名称,怎么办呢?做另一个订单对象,并在该对象里增加“用户名称”。这样,通过订单 DAO 查订单表时,在 SQL 语句中 Join 用户表,就可以完成数据的查询。这时会发现,在系统中非常别扭地设计了两个或多个订单对象,并且新添加的订单对象与领域模型中的订单对象有较大的差别,显得不够直观。系统简单时还好说,但系统的业务逻辑变得越来越复杂时,程序阅读起来越来越困难,变更就变得越来越麻烦。

因此,在应对复杂业务系统时,我们希望程序设计能较好地与领域模型对应上:领域模型是啥样,程序就设计成啥样。我们就将订单对象设计成这样,订单对象的关联设计代码如下: public class Order { …… private Long customer_id; private Customer customer; private List orderItems; //*/* /* @return the customerId /*/ public Long getCustomerId() { return customer_id; } //*/* /* @param customerId the customerId to set /*/ public void setCustomerId(Long customerId) { this.customer_id = customerId; } //*/* /* @return the customer /*/ public Customer getCustomer() { return customer; } //*/* /* @param customer the customer to set /*/ public void setCustomer(Customer customer) { this.customer = customer; } //*/* /* @return the orderItems /*/ public List getOrderItems() { return orderItems; } //*/* /* @param orderItems the orderItems to set /*/ public void setOrderItems(List orderItems) { this.orderItems = orderItems; } }

DDD 05–金句.png

可以看到,在订单对象中加入了对用户对象和订单明细对象的引用:

  • 订单对象与用户对象是多对一关系,做成对象引用;
  • 订单对象与订单明细对象是一对多关系,做成对集合对象的引用。

这样,当订单对象在创建时,在该对象中填入 customerId,以及它对应的订单明细集合 orderItems;然后交给订单仓库去保存,在保存时,就进行了一个封装,同时保存订单表与订单明细表,并在其上添加了一个事务。

这里要特别注意,对象间的关系是否是聚合关系,它们在保存的时候是有差别的。譬如,在本案例中,订单与订单明细是聚合关系,因此在保存订单时还要保存订单明细,并放到同一事务中;然而,订单与用户不是聚合关系,那在保存订单时不会去操作用户表,只有在查询时,比如在查询订单的同时,才要查询与该订单对应的用户。

这是一个比较复杂的保存过程。然而,通过订单仓库的封装,对于客户程序来说不需要关心它是怎么保存的,它只需要在领域对象建模的时候设定对象间的关系,即将其设定为“聚合”就可以了。既保持了与领域模型的一致性、又简化了开发,使得日后的变更与维护变得简单。至于仓库的设计实现,将在后面的课程中讲解。

有了这样的设计,装载与查询又应当怎样去做呢?所谓的 “装载(Load)”,就是通过主键 ID 去查询某条记录。比如,要装载一个订单,就是通过订单 ID 去查询该订单,那么订单仓库是如何实现对订单的装载呢?

首先,比较容易想到的是,用 SQL 语句到数据库里去查询这张订单。与 DAO 不同的是:

  • 订单仓库在查询订单时,只是简单地查询订单表,不会去 Join 其他表,比如 Join 用户表,不会做这些事情;
  • 当查询到该订单以后,将其封装在订单对象中,然后再去通过查询补填用户对象、订单明细对象;
  • 通过补填以后,就会得到一个用户对象、多个订单明细对象,需要将它们装配到订单对象中。

这时,那些创建、装配的工作都交给了另外一个组件——工厂来完成。

DDD 的工厂

DDD 中的工厂,与设计模式中的工厂不是同一个概念,它们是有差别的。在设计模式中,为了避免调用方与被调方的依赖,将被调方设计成一个接口下的多个实现,将这些实现放入工厂中。这样,调用方通过一个 key 值就可以从工厂中获得某个实现类。工厂就负责通过 key 值找到对应的实现类,创建出来,返回给调用方,从而降低了调用方与被调方的耦合度。

而 DDD 中的工厂,与设计模式中的工厂唯一的共同点可能就是,它们都要去做创建对象的工作。

DDD 中的工厂,主要的工作是通过装配,创建领域对象,是领域对象生命周期的起点。譬如,系统要通过 ID 装载一个订单:

  • 这时订单仓库会将这个任务交给订单工厂,订单工厂就会分别调用订单 DAO、订单明细 DAO 和用户 DAO 去进行查询;
  • 然后将得到的订单对象、订单明细对象、用户对象进行装配,即将订单明细对象与用户对象,分别 set 到订单对象的“订单明细”与“用户”属性中;
  • 最后,订单工厂将装配好的订单对象返回给订单仓库。

这些就是 DDD 中工厂要做的事情。

DDD 的仓库

然而,当订单工厂将订单对象返回给订单仓库以后,订单仓库不是简单地将该对象返回给客户程序,它还有一个缓存的功能。在DDD 中“仓库”的概念,就是如果服务器是一个非常强大的服务器,那么我们不需要任何数据库系统创建的所有领域对象都放在仓库中,当需要这些对象时,通过 ID 到仓库中去获取。

但是,在现实中没有那么强大的仓库,因此仓库在内部实现时,会将领域对象持久化到数据库中。数据库是仓库进行数据持久化的一种内部实现,它也可以有另外一种内部实现,就是将最近反复使用的领域对象放入缓存中。这样,当客户程序通过 ID 去获取某个领域对象时,仓库会通过这个 ID 先到缓存中进行查找:

  • 查找到了,则直接返回,不需要查询数据库;
  • 没有找到,则通知工厂,工厂调用 DAO 去数据库中查询,然后装配成领域对象返回给仓库。

仓库在收到这个领域对象以后,在返回给客户程序的同时,将该对象放到缓存中。

以上是通过 ID 装载订单的过程,那么通过某些条件查询订单的过程又是怎么做呢?查询订单的操作同样是交给订单仓库去完成。

  • 订单仓库会先通过订单 DAO 去查询订单表,但这里是只查询订单表,不做 Join 操作;
  • 订单 DAO 查询了订单表以后,会进行一个分页,将某一页的数据返回给订单仓库;
  • 这时,订单仓库就会将查询结果交给订单工厂,让它去补填其对应的用户与订单明细,完成相应的装配,最终将装配好的订单对象集合返回给仓库。

简而言之,采用领域驱动的设计以后,对数据库的访问就不是一个简单的 DAO 了,这不是一种好的设计。通过仓库与工厂,对原有的 DAO 进行了一层封装,在保存、装载、查询等操作中,加入聚合、装配等操作。并将这些操作封装起来,对上层的客户程序屏蔽。这样,客户程序不需要以上这些操作,就能完成领域模型中的各自业务。技术门槛降低了,变更与维护也变得简便了。

总结

本讲讲解了 DDD 中一个非常重要的设计思想:聚合,以及它的设计实现:工厂与仓库,它们是 DDD 中充血模型设计的重要支柱。通过这些设计我们会发现,它们与我们传统的基于 DAO 的贫血模型设计有诸多的不同。

  • 通过聚合实现了整体与部分的关系,客户程序只能操作整体,而将对部分的操作封装在了仓库与工厂中;
  • 客户程序不必关注对数据库的操作,操作仓库就好了。对缓存、对数据库的操作都封装在了仓库与工厂中,从而降低了业务开发的技术门槛与开发工作量;
  • 对数据的查询不再通过 SQL 语句进行 Join,而是通过工厂进行补填与装配。这样的设计更有利于微服务的设计与大数据的调优。

它们为软件系统提高设计质量、降低维护成本以及应对高并发,提供了很好的设计。

另外,一个值得思考的问题就是,传统的领域驱动设计,是每个模块自己去实现各自的仓库与工厂,这样会大大增加开发工作量。但这些仓库与工厂的设计大致都是相同的,会催生大量的重复代码。能不能通过抽象,提取出共性,形成通用的仓库与工厂,下沉到底层技术中台中,从而进一步降低领域驱动的开发成本与技术门槛?也就是说,实现领域驱动设计还需要相应的平台架构支持。关于这些方面的思路,我们将在 DDD 的架构设计部分进一步探讨。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/DDD%20%e5%be%ae%e6%9c%8d%e5%8a%a1%e8%90%bd%e5%9c%b0%e5%ae%9e%e6%88%98/05%20%20%e8%81%9a%e5%90%88%e3%80%81%e4%bb%93%e5%ba%93%e4%b8%8e%e5%b7%a5%e5%8e%82%ef%bc%9a%e5%82%bb%e5%82%bb%e5%88%86%e4%b8%8d%e6%b8%85%e6%a5%9a.md