领域驱动设计,面向领域驱动架构的查询实现方式

在上一篇文章《public interface ISpecification { bool IsSatisfiedBy(T obj); } public abstract class Specification : ISpecification { public abstract Expression> Expression { get; } public bool IsSatisfiedBy(T obj) { return this.Expression.Compile()(obj); } } public class OrderCustomerMatchesSpecification : Specification { private Customer customer; public OrderCustomerMatchesSpecification(Customer customer) { this.customer = customer; } public override Expression> Expression { get { return p => p.Customer.Id.Equals(customer.Id); } } } public interface IRepository where T : IAggregateRoot { void Add(T aggregateRoot); List GetAllBySpecification(ISpecification spec); } public class MemoryRepository : IRepository where T : IAggregateRoot { private readonly List store = new List(); public void Add(T aggregateRoot) { if (!this.store.Exists(p => p.Id.Equals(aggregateRoot.Id))) this.store.Add(aggregateRoot); } public List GetAllBySpecification(ISpecification spec) { return this.store.Where(spec.IsSatisfiedBy).ToList(); } } ISpecification spec = new OrderCustomerMatchesSpecification(custDaxnet); List 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记录的外键引用: imageimage面向领域驱动架构的查询实现方式领域驱动设计
  • 关联表映射模式(Association Table Mapping Pattern,PoEAA) 这种方式会引入第三张数据表,用来保存另外两张表之间的主键关联。比如“客户 - 地址”聚合,ORM会在数据库中产生三张表:Customer表、Address表以及CustomerAddress表: imageimageimage面向领域驱动架构的查询实现方式领域驱动设计
  • 嵌入值模式(Embedded Value Pattern,PoEAA) 嵌入值模式会将一个对象映射成另一个对象表的若干字段。比如“客户 - 地址”聚合,ORM仅会在数据库中产生一张表:Customer表,其中包含了Address对象所有属性值的字段: imageimageimageimage面向领域驱动架构的查询实现方式领域驱动设计
  • 序列化LOB模式(Serialized LOB Pattern,PoEAA) 该模式会将另一对象的数据序列化成一个LOB(BLOB或者CLOB),然后以一个字段的形式保存在当前对象所对应的数据表中。比如“客户 - 地址”聚合,ORM会在数据库中产生一张数据表:Customer表,并在其中保存“地址”对象的序列化LOB数据: imageimageimageimageimage面向领域驱动架构的查询实现方式领域驱动设计
因此,在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》一文中我已经给出了一张结构图,现在我再细化一下这个图以体现其查询部分的具体情况:
imageimageimageimageimageimage面向领域驱动架构的查询实现方式领域驱动设计
在上图中,领域模型在完成操作之后,会产生领域事件,在聚合被保存到数据库的同时,领域事件也会被发布到事件总线(Event Bus)上。然后,事件派发处理器(Event Dispatcher,在这里使用的是Microsoft Biztalk Server)会将事件派发到各种不同的订阅机制,比如Dynamics AX系统或者单独的查询数据库。这样,查询数据库将会有较大的设计空间(比如可以根据客户端View Model来设计关系型数据库的表结构),Query Reader的设计也会变得非常简单。在这样的结构下,实现通用查询、复杂查询也会非常简单。

总结

总之,领域模型可以提供一定的查询能力,比如通过仓储、规约以及对象关系导航等方式获得所需要的数据,但查询应该不是领域模型的组成部分,它是可以被分离出去的。对于经典的架构风格(比如Microsoft NLayerApp这样的架构风格),如果需要获得复杂的查询功能,那就直接绕过领域模型,单独出一个系统直接访问数据库进行查询,然后把查询返回给客户端;客户端获得查询结果后,再根据修改过的数据,通过仓储获得领域对象然后更新领域模型;对于CQRS的架构风格,我们将获得更大的查询部分的设计空间,查询功能的实现也不再成为问题。
希望本文能够对关注这方面内容的读者朋友一定的帮助。
Tags:  领域驱动模型 领域驱动开发 模型驱动架构 领域驱动 领域驱动设计

延伸阅读

最新评论

发表评论