在上一篇文章《.NET应用框架架构设计实践 - 概述》的评论部分,有网友提出了一个在面向领域驱动架构的实践中比较常见的问题:“DDD使用聚合根访问,那例如那些通用查询如何实现?难道都要经过聚合根多步得到么?DDD如何实现关联表的查询,例如3表关联查询?”这个问题比较泛,涉及的内容也比较多,我就单独一篇文章介绍一下我对这个问题的看法。关于上面问题中的“通用查询”- 呃,这个定义比较模糊,我只能给出我的一些想法或者经验性的东西,我在本文中的经验与观点并不一定会100%适合您的应用场景,但我想应该还是具有一定指导性意义的。
聚合与聚合根
我想,还是从聚合根谈起吧。聚合根是DDD中的概念,不管是经典的DDD架构,还是基于事件驱动的CQRS架构,其实它们之间绝大部分概念都是相通的,比如实体、值对象、服务、工厂、仓储以及聚合/聚合根等。根据我的理解,聚合根是一个实体,它保持着与其它实体/值对象的引用,并与这些实体/值对象一起,来表达领域的通用语言中的一个唯一的无二义的逻辑概念。比如最常见的“客户(Customer)”,在“在线销售”的领域中,“客户”不仅包含它所指代的那个个人(或者是组织)的名称、联系电话、联系电邮,还会包含它的联系地址(Contact Address)以及送货地址(Delivery Address),那么就Address而言,在此我们可以将其视为值对象,因为我们只关心地址本身所包含的信息。在这里,“客户(Customer)”不仅是实体,而且是“客户-地址”所组成的对象集合(聚合)的聚合根。
在这里会有异议的地方就是“销售订单(Sales Order)”是否应该属于“客户(Customer)”聚合。我觉得这还是要看在当前的领域中,“销售订单”是不是“客户”的必有信息,换句话说,“客户”是不是没有“销售订单”就不成其为“客户”。我想,在大多数情况下,“客户”应该是一个可以脱离“销售订单”而单独存在的实体,那这样的话,“销售订单”也将不属于“客户”聚合。
现在让我们来看“在线销售”领域中的另一部分:销售订单。当然,“销售订单(Sales Order)”是实体,本身也是订单主体与“订单明细(Sales Lines)”所组成的聚合的聚合根,这是很自然的事情,因为“销售订单”如果没有订单的明细信息,也就失去了订单本身的意义。此外,“客户”实体也是这个聚合的一个组成部分,这也很好理解,“销售订单”本身就是客户下达的,它不可能脱离“客户”而凭空存在。于是,以“销售订单”为根的聚合,还包括“客户”实体,以及“订单明细”(至于“订单明细”是实体还是值对象,这跟具体的领域定义有密切关系,比如如果涉及商品Item与购买量的打折等内容,那么“订单明细”就需要以实体方式处理,否则可以设计成“值对象”以减小系统开销,本文绕过这个问题的讨论)。在作进一步讨论之前,让我们回顾一下DDD中的仓储。DDD告诉我们,仓储是作用在聚合根上的:领域模型中对象的保存与读取都是以聚合为单位而进行的。
通过上面的讨论,针对“在线销售”领域,我们大致得到了如下的领域模型(为了缩短篇幅,图中可能会省略某些部分)
![image image](https://images.cnblogs.com/cnblogs_com/daxnet/201108/201108031004319410.png)
问题来了,如果我们需要获得某个“客户”的所有订单,该怎么办?在上面的领域模型中,Customer实体并没有某个属性或者方法来获得其所有的销售订单。那么在遇到这样的问题时,通常都是通过SalesOrder的仓储,配合规约(Specification)来筛选出所有符合特定“客户”条件的销售订单,然后由仓储返回销售订单的列表。你或许会觉得这种做法比较不科学,你会觉得应该通过Customer实体的某个属性(比如SalesOrders)来获得该“客户”所拥有的所有销售订单,这样会更直截了当些。但在上面我们已经对这个领域模型进行了讨论,在我们的案例中,Customer是一个独立的实体,SalesOrder不是它的必要组成部分。于是,为了维护领域模型的完整性,我们需要利用“销售订单”的仓储来完成这个功能。伪代码如下:
01 |
public interface ISpecification<T> |
03 |
bool IsSatisfiedBy(T obj); |
06 |
public abstract class Specification<T> : ISpecification<T> |
08 |
public abstract Expression<Func<T, bool >> Expression { get ; } |
10 |
public bool IsSatisfiedBy(T obj) |
12 |
return this .Expression.Compile()(obj); |
16 |
public class OrderCustomerMatchesSpecification : Specification<SalesOrder> |
18 |
private Customer customer; |
19 |
public OrderCustomerMatchesSpecification(Customer customer) |
21 |
this .customer = customer; |
23 |
public override Expression<Func<SalesOrder, bool >> Expression |
25 |
get { return p => p.Customer.Id.Equals(customer.Id); } |
29 |
public interface IRepository<T> |
30 |
where T : IAggregateRoot |
32 |
void Add(T aggregateRoot); |
33 |
List<T> GetAllBySpecification(ISpecification<T> spec); |
36 |
public class MemoryRepository<T> : IRepository<T> |
37 |
where T : IAggregateRoot |
39 |
private readonly List<T> store = new List<T>(); |
41 |
public void Add(T aggregateRoot) |
43 |
if (! this .store.Exists(p => p.Id.Equals(aggregateRoot.Id))) |
44 |
this .store.Add(aggregateRoot); |
47 |
public List<T> GetAllBySpecification(ISpecification<T> spec) |
49 |
return this .store.Where(spec.IsSatisfiedBy).ToList(); |
53 |
ISpecification<SalesOrder> spec = new OrderCustomerMatchesSpecification(custDaxnet); |
54 |
List<SalesOrder> daxnetOrders = salesOrderRepository.GetAllBySpecification(spec); |
在上面的代码中,daxnetOrders对象所保存的就是所有属于custDaxnet这个Customer的销售订单。通过这个例子我们可以看出,当我们需要某些信息的时候,我们只与领域模型中的聚合、实体、值对象以及仓储打交道,我们完全没有涉及任何数据库、数据表、字段、记录等等这些概念,从上面的代码也可以看出,我们可以使用服务桩(Service Stub,PoEAA)模式来Mock一个基于内存的仓储,与关系型数据库毫不相干。事实上也是如此,我们软件设计者、开发者以及领域专家在同一个事物上达成共识:领域模型。聚合、实体、值对象等成为领域模型的主要组成部分,而这些对象又各自保持着自己的状态,也就是我们所需要的数据。在经典的DDD架构风格(例如Microsoft NLayerApp这样的架构)中,我们通过领域模型中的对象及其之间的关系来获得我们所需要的信息,因此,数据的查询应该是由仓储引起,并通过聚合实现导航(Navigation)查询。接下来,让我们引入关系型数据库,来谈谈本文最开始提出的“多个表关联查询”的问题。
领域模型 vs 关系型数据库
在我之前所写的《经典的应用系统结构、CQRS与事件溯源》一文中,讨论了领域模型与关系型数据模型之间的“阻抗失衡”效应,在此也就不再重复了,但我们必须弄清楚一件事情,就是在DDD的实践中,我们必须抛开关系型数据库,甚至是其它的一切数据持久化机制,而只关注领域模型。于是,领域模型本身也需要屏蔽数据持久化的细节内容(我们通常称之为“持久化无关性”,Persistence Ignorance)。这有两个方面的原因:首先,DDD是面向领域的,不是面向数据的,领域模型对问题域进行了表述,这也是软件人员与领域专家的沟通桥梁,如果引入数据存储的细节内容,既不利于沟通,也会使得领域模型过多依赖具体的技术实现方案,提高了系统的耦合度;其次,由于“阻抗失衡”效应的存在,就需要有一个中介角色来解决这个失衡效应,通常是ORM承担了这个角色,然而,从技术实现的角度看,针对同一个领域模型,ORM可以有不同的处理方式,具体采用哪种处理方式,可以通过ORM框架的配置信息(例如,NHibernate的hbm映射文件)来决定;在这种情况下,领域模型+ORM决定了关系型数据库的结构,于是,对数据表、字段、记录等关系型数据库的讨论就没多大意义了,因为关系型数据库本身的结构也是不确定的。现在,让我们来看个例子,了解一下ORM处理同一个领域模型的不同方式。就以上文所提到的“客户 - 地址”聚合为例,ORM处理这个聚合至少(但不限于)可以有如下四个方式:
- 外键映射模式(Foreign Key Mapping Pattern,PoEAA)
这种方式会将对象间的关系映射到数据表的外键关联。比如“客户 - 地址”聚合,ORM会在数据库中产生两张表:Customer表和Address表,Customer表中包含两个Address记录的外键引用:
![image image](https://images.cnblogs.com/cnblogs_com/daxnet/201108/201108031004358793.png)
- 关联表映射模式(Association Table Mapping Pattern,PoEAA)
这种方式会引入第三张数据表,用来保存另外两张表之间的主键关联。比如“客户 - 地址”聚合,ORM会在数据库中产生三张表:Customer表、Address表以及CustomerAddress表:
![image image](https://images.cnblogs.com/cnblogs_com/daxnet/201108/201108031010068361.png)
- 嵌入值模式(Embedded Value Pattern,PoEAA)
嵌入值模式会将一个对象映射成另一个对象表的若干字段。比如“客户 - 地址”聚合,ORM仅会在数据库中产生一张表:Customer表,其中包含了Address对象所有属性值的字段:
![image image](https://images.cnblogs.com/cnblogs_com/daxnet/201108/201108031010138532.png)
- 序列化LOB模式(Serialized LOB Pattern,PoEAA)
该模式会将另一对象的数据序列化成一个LOB(BLOB或者CLOB),然后以一个字段的形式保存在当前对象所对应的数据表中。比如“客户 - 地址”聚合,ORM会在数据库中产生一张数据表:Customer表,并在其中保存“地址”对象的序列化LOB数据:
![image image](https://images.cnblogs.com/cnblogs_com/daxnet/201108/201108031010387302.png)
因此,在DDD实践中,我们不会存在“如何进行关联表查询”这样的问题,我们关注的是领域模型,至于关系型数据库方面的工作,就交给ORM吧。
当然,理论归理论,实际项目与理论上的东西相差太大,我们也需要具体问题具体分析。例如,ORM的引入虽然解决了领域模型与关系型数据模型之间的“阻抗失衡”,但也带来了一定程度的性能问题,对于某些性能要求很高的系统,采用DDD实践可能就不是一个很好的选择,当然也可以想办法找一个折中的方式来处理问题。比如,假设某个系统基本上对性能要求不高,可以采用DDD的实践方式,只是个别查询功能(比如总账报表生成、数据统计等)要求高效,此时,我们还是可以应用DDD的实践经验,并试图在这几个功能上绕过领域模型,直接采用高效率的数据库查询方式(比如ADO.NET),当然这已经脱离了DDD的讨论范围,不过我们的目的就是为了实现一套稳定、安全、高效的系统,DDD或不DDD这并不是重点,重点在于合适就好。我想,这也是架构师的职责所在吧。
在我们采用“非正常手段”慢慢地绕过领域模型的时候,我们会发现一个有趣的现象:其实“查询”根本就不是领域模型的一部分,“查询”是可以作为一个单独的系统而独立存在的,在需要的时候,这个“查询系统”可以被整合到实际系统当中(比如采用Microsoft Biztalk Server等手段),为客户端提供查询服务。既然“查询”可以是一个单独的系统,那么如何实现这个“查询”系统,方法也就五花八门了:可以继续结合ORM实现查询,也可以直接写SQL语句进行查询,甚至还可以使用一些现有的查询框架,总之只要能够向客户端提供所需要的数据就行了。“查询”不再受到领域模型的牵制,在如此广泛的技术选型背景下,我想,要实现一套复杂的、可定制的查询机制根本就不会是什么难事。
面向领域驱动的CQRS(Command Query Responsibility Segregation,命令查询职责分离)架构就是这样一种架构风格:它完全将“查询”部分从领域模型中分离出来。
CQRS体系结构模式
在我之前所写的《EntityFramework之领域驱动设计实践【扩展阅读】:CQRS体系结构模式》一文(以下简称《CQRS》)中,已经非常详细地对CQRS体系结构模式进行了介绍和总结,在这里再对这种结构的“查询”部分简要地说几句。
在CQRS中,我们可以看到,作用在聚合根上的“仓储”,已经退化成“领域仓储(Domain Repository)”,领域仓储也是作用在聚合根上的,但它只有两个操作:Save以及GetByAggregateRootId。显而易见,Save的功能就是将整个聚合保存起来,而GetByAggregateRootId则是通过聚合根的标识来获得整个聚合。于是,像上面我所例举的“获取某个客户的所有销售订单”这样的操作,在CQRS的Command部分是无法完成的:你无法通过规约(Specification)来获得“包含”某个客户的所有订单,你只能够通过订单号来获取订单信息。或许(我是说或许),在CQRS架构的领域模型中我们根本无需知道某个订单是属于哪个客户的,OK,直接将“客户”实体从“销售订单”聚合中排除出去。关于这个问题我在领域驱动设计的官方论坛里讨论过,得到的结论就是:领域模型只应该包含必要的信息,一切与查询有关的内容,都应该设计在“查询”部分。
在《CQRS》一文中我已经给出了一张结构图,现在我再细化一下这个图以体现其查询部分的具体情况:
![image image](https://images.cnblogs.com/cnblogs_com/daxnet/201108/201108031010541462.png)
在上图中,领域模型在完成操作之后,会产生领域事件,在聚合被保存到数据库的同时,领域事件也会被发布到事件总线(Event Bus)上。然后,事件派发处理器(Event Dispatcher,在这里使用的是Microsoft Biztalk Server)会将事件派发到各种不同的订阅机制,比如Dynamics AX系统或者单独的查询数据库。这样,查询数据库将会有较大的设计空间(比如可以根据客户端View Model来设计关系型数据库的表结构),Query Reader的设计也会变得非常简单。在这样的结构下,实现通用查询、复杂查询也会非常简单。
总结
总之,领域模型可以提供一定的查询能力,比如通过仓储、规约以及对象关系导航等方式获得所需要的数据,但查询应该不是领域模型的组成部分,它是可以被分离出去的。对于经典的架构风格(比如Microsoft NLayerApp这样的架构风格),如果需要获得复杂的查询功能,那就直接绕过领域模型,单独出一个系统直接访问数据库进行查询,然后把查询返回给客户端;客户端获得查询结果后,再根据修改过的数据,通过仓储获得领域对象然后更新领域模型;对于CQRS的架构风格,我们将获得更大的查询部分的设计空间,查询功能的实现也不再成为问题。
希望本文能够对关注这方面内容的读者朋友一定的帮助。