3.3.4 仓库与工厂
前面只提到了聚合,要真正将聚合落实到软件设计中,还需要两个非常重要的组件:仓库与工厂。
比如,我们现在创建了一个订单,订单中包含多条订单明细,我们将它们做成了一个聚合。订单创建完成后,就需要保存到数据库里。怎么保存呢?需要同时保存订单表与订单明细表,并将其做到一个事务中。这时候谁来负责保存,并对其添加事务呢?
过去我们采用贫血模型,通过订单DAO与订单明细DAO去完成数据库的保存,然后由订单Service去添加事务。这样的设计没有聚合,缺乏封装性,不利于日后的维护。
采用了聚合以后,订单与订单明细的保存就会封装到订单仓库中去实现。也就是说采用了领域驱动设计以后,通常会实现一个仓库(Repository)去完成对数据库的访问。那么,仓库与数据访问层有什么区别呢?
一般来说,数据访问层就是对数据库中某个表的访问,如订单有订单DAO、订单明细有订单明细DAO、用户有用户DAO。当数据要保存到数据库中时,由DAO负责保存,但保存的是某个单表,如订单DAO保存订单表、订单明细DAO保存订单明细表、用户DAO保存用户表;当数据要查询时,还是通过DAO去查询,但查询的是某个单表,如订单DAO查订单表、订单明细DAO查订单明细表。那么,如果在查询订单的时候要显示用户名称,就需要做另一个订单对象,并在该对象里增加“用户名称”。这样,通过订单DAO查订单表时,在SQL语句中增加用户表,完成数据的查询。这时会发现,我们在系统中非常别扭地设计了两个或多个订单对象,并且新添加的订单对象与领域模型中的有较大的差别,不够直观。系统简单时尚能厘清,但当系统的业务逻辑越来越复杂时,程序可读性变差,变更也越来越麻烦。
因此,在应对复杂业务系统时,我们希望程序设计能较好地与领域模型对应上,代码如下:
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不同的是,订单仓库在查询订单时,只是简单地查询订单表,不会连接其他表。当查询到该订单以后,将其封装在订单对象中,然后再通过查询补填用户对象、订单明细对象。
补填后会得到一个用户对象和多个订单明细对象,需要将它们装配到订单对象中。这时,那些创建、装配的工作都交给了工厂组件。
DDD中的工厂与设计模式中的工厂不是同一个概念。在设计模式中,为了避免调用方与被调方的依赖,将被调方设计成一个接口下的多个实现,将这些实现放入工厂中。这样,调用方通过一个key值就可以从工厂中获得某个实现类。工厂就负责通过key值找到对应的实现类,创建出来,返回给调用方,从而降低了调用方与被调方的耦合度。
DDD中的工厂的主要工作是通过装配来创建领域对象,是领域对象生命周期的起点。譬如,系统要通过ID装载一个订单,这时订单仓库会将这个任务交给订单工厂。订单工厂就会分别调用订单DAO、订单明细DAO和用户DAO去查询,然后将得到的订单对象、订单明细对象、用户对象进行装配,即将订单明细对象与用户对象分别设置到订单对象的“订单明细”与“用户”属性中。最后,订单工厂将装配好的订单对象返回给订单仓库。这些就是DDD中工厂要做的事情。
然而,当订单工厂将订单对象返回给订单仓库以后,订单仓库不是简单地将该对象返回给客户程序,它还有一个缓存的功能。如果服务器是一个非常强大的服务器,那么我们不需要任何数据库。系统创建的所有领域对象都放在仓库中,当需要这些对象时,通过ID到仓库中去获取。
现实中没有那么强大的仓库,因此仓库在内部实现时会将领域对象持久化到数据库中。数据库是仓库进行数据持久化的一种内部实现,它也可以有另外一种内部实现,就是将最近反复使用的领域对象放入缓存中。这样,当客户程序通过ID去获取某个领域对象时,仓库会通过这个ID先到缓存中查找。查找到了,则直接返回,不需要查询数据库;没有找到,则通知工厂,工厂调用DAO去数据库中查询,然后装配成领域对象返回给仓库。仓库在收到这个领域对象以后,在返回给客户程序的同时,将该对象放到缓存中。
查询订单的操作同样是交给订单仓库去完成。订单仓库会先通过订单DAO去查询订单表,但这里只查询订单表,不做join操作。订单DAO查询了订单表以后,会进行一个分页,将某一页的数据返回给订单仓库。这时,订单仓库就会将查询结果交给订单工厂,让它去补填其对应的用户与订单明细,完成相应的装配,最终将装配好的订单对象集合返回给仓库。
简而言之,采用领域驱动的设计以后,对数据库的访问就不是一个简单的DAO了。通过仓库与工厂,对原有的DAO进行了一层封装,在保存、装载、查询等操作中,加入聚合、装配等操作,并将这些操作封装起来,屏蔽上层的客户程序。这样,客户程序不需要以上这些操作,就能完成领域模型中的各自业务,技术门槛降低了,变更与维护也变得简便。
另外一个值得思考的问题就是,传统的领域驱动设计是每个模块自己去实现各自的仓库与工厂,这样会大大增加开发工作量。但这些仓库与工厂的设计大致都是相同的,会产生大量的重复代码。如果能通过抽象提取出共性,形成通用的仓库与工厂,下沉到底层技术中台中,就能进一步降低领域驱动的开发成本与技术门槛。也就是说,实现领域驱动设计还需要相应的平台架构支持。关于这些方面的思路,我们将在开发架构设计部分进一步探讨(详见4.3.3节)。