
                            
                            因收到Google相关通知，网站将会择期关闭。相关通知内容
                            
                            
                            05  聚合、仓库与工厂：傻傻分不清楚
                            上一讲，我们知道了，要将领域模型最终转换为程序设计，可以落实到 3 种类型的对象设计，即服务、实体与值对象，然后进行一些贫血模型与充血模型的设计思路。但这远远不够，还需要有聚合、仓库与工厂的设计。

聚合的设计思路

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

尽管如此，在领域模型的设计中，我们又将其还原到真实世界中，以“聚合”的形式进行设计。在领域模型中，即将订单明细设计成订单中的一个属性，具体代码如下：

public class Order {

    private Set<Items> items;

    public void setItems(Set<Item> items){

          this.items = items;

    }

    public Set<Item> 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<OrderItem> 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<OrderItem> getOrderItems() {

  return orderItems;

 }

 /**

  * @param orderItems the orderItems to set

  */

 public void setOrderItems(List<OrderItem> orderItems) {

  this.orderItems = orderItems;

 }

}




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


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


这样，当订单对象在创建时，在该对象中填入 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 的架构设计部分进一步探讨。

                        
                        
                            