领域驱动设计实践上篇
一、前言
领域驱动设计的概念最早是由著名的建模专家Eric Evans在2004年发表的著名书籍 Domain-Driven Design –Tackling Complexity in the Heart of Software(中文译名:领域驱动设计 2006年3月清华出版社译本,或称 Domain Driven-Design architecture [Evans DDD])。园子里有很多人早已将其实践并应用,关于其要素、特点不再赘述,很多人在技术选型时想用它但又怕驾驭不了它,无非是没有真的动手去实践过,我自己也是如此,从两年开始关注DDD,dax.net的DDD系列收获甚多,无奈没有机会实践,仅仅作为个人的研究项目断断续续在写,中途回老家过了半年的标准“乡村生活”,那段时间早上早起溜小孩,晚上哄睡小孩写代码,算是实现了“只写感兴趣的代码”这事,只是半年其乐融融的生活反倒让我觉得危机四伏,于是今年年初就又回待了3年多的上海。要指出的是DDD并不是万金油,没有必要为了用而用,合适才是王道,DDD的核心是领域建模思想,你可以用最新最潮的语言和框架去实践,但核心本质“领域(业务理解)”并不会随之变化,而学习最新最潮的技术也需要时间成本,况且以有涯之人生去追随无涯的技术变迁也是一件非常痛苦的事情。
二、经典分层架构
对原图用PPT稍微加工了下
表现层:负责向用户展现信息及解释用户命令,跟传统三层里的表现层意思差不多,表现层与应用层之间是通过数据传输对象(DTO)进行交互的,数据传输对象是没有行为的POCO对象,它的目的只是为了对领域对象进行数据封装,实现层与层之间的数据传递。为何不能直接将领域对象用于数据传递?因为领域对象更注重领域,而DTO更注重数据。不仅如此,由于“富领域模型”的特点,这样做会直接将领域对象的行为暴露给表现层。
应用层:该层不包含任何领域逻辑,但它会对任务进行协调,并可以维护应用程序的状态,因此,它更注重流程性的东西。在某些领域驱动设计的实践中,也会将其称为“工作流层”。应用层是领域驱动中最有争议的一个层次,也会有很多人对其职责感到模糊不清。图中的应用一和应用二在我们项目里实际上是用WebApi实现的Web服务,可以说这一层其实是没有的,或者说是与web服务合并了,但web服务其实应该是表现层的东西。
领域层:包含了业务所涉及的领域对象(实体、值对象)、领域服务以及它们之间的关系。这部分内容的具体表现形式就是领域模型(Domain Model)。领域驱动设计提倡富领域模型,即尽量将业务逻辑归属到领域对象上,实在无法归属的部分则以领域服务的形式进行定义。将数据持久托管给基础设施层。
基础设施层:该层专为其它各层提供技术框架支持。注意,这部分内容不会涉及任何业务知识。众所周知的数据访问的内容,也被放在了该层当中,因为数据的读写是业务无关的。
三、聚合及聚合根(Aggregate,Aggregate Root)
聚合通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的难以维护的对象关系网的形成。
每个聚合有一个根和一个边界,边界定义了一个聚合内部有哪些实体或值对象,根是聚合内的某个实体。
聚合内部的对象之间可以相互引用,但是聚合外部如果要访问聚合内部的对象时,必须通过聚合根开始导航,绝对不能绕过聚合根直接访问聚合内的对象,也就是说聚合根是外部可以保持 对它的引用的唯一元素。
聚合根负责与外部其他对象打交道并维护自己内部的业务规则。
根据Eric Evans《领域驱动设计》一书中的例子,一辆车包含四个轮子,轮子离开“车”就毫无意义,此时这个联合体就是聚合,而“车”就是聚合根(Aggregate Root)。
如下图,所有对象的联合体称之为聚合,Users则为聚合根。
通俗的说,领域模型需要根据领域概念分成多个聚合,每个聚合都有一个实体作为“聚合根”,领域对象从无到有的创建,以及CRUD操作都应该作用在聚合根上,而不是单独的某个实体。那当你的代码需要直接对聚合内部的实体进行CRUD操作时,就说明你的模型设计已经存在问题了。
四、仓储(数据的持久化)
模型的已经有了,接下来该考虑怎么持久化的问题了。
上面有说过,领域驱动设计的核心建立正确的领域模型,它并不关心用什么技术去持久化这些模型,这也是DDD的一贯宗旨“领域模型与技术架构分离”,但我们的领域层并不是只包含了模型而已,还包含了领域服务和事件等,或者说持久化模型的发起是从领域层开始的,因此只要满足不混入任何技术实现且仅发起调用即可,此时较好的方式就是将持久化和类似技术实现如发短信等这些功能接口定义在领域层,把具体实现托管给基础设施层。此时领域层只调用接口而对其技术实现一无所知,程序运行时将具体实现通过IOC方式注入即可,这样能保证我们的领域层的一个纯净,而领域模型的纯净程度便成为了衡量系统架构优劣的一项指标。
以持久化为例,首先定义接口,顺便说一句,虽然建议将功能接口定义在领域层,但把接口也定义在基础设施层也没有问题,甚至于基础设施层足够强健的话可以使用Nuget单独维护其版本供不同项目引用。
此处在基础设施层定义接口。
using System; using System.Collections.Generic; namespace DDD.Infrastructure { public interface IRepository<TEntity> where TEntity : BaseEntity, IAggregateRoot { void Add(TEntity entity); void Update(TEntity entity); void Remove(TEntity entity); TEntity GetByKey(int key); IEnumerable<TEntity> Get(Func<TEntity, bool> where); } }EF实现
using System.Data.Entity; using System.Linq; using System.Threading.Tasks; namespace DDD.Infrastructure.Database { public class Repository<TEntity, TContext> : IRepository<TEntity> where TEntity : BaseEntity, IAggregateRoot where TContext : DbContext { private readonly DbSet<TEntity> db; private readonly TContext context; public Repository(TContext context) { this.context = context; this.db = context.Set<TEntity>(); } public void Add(TEntity entity) { db.Add(entity); Commit(); } public void Update(TEntity entity) { var dbSet = context.Set<TEntity>(); var entry = context.Entry(entity); dbSet.Attach(entity); entry.State = EntityState.Modified; Commit(); } public void Remove(TEntity entity) { db.Remove(entity); Commit(); } public TEntity GetByKey(int key) { return db.Find(key); } public IQueryable<TEntity> Get() { return db; } public IQueryable<TEntity> ReadonlyGet() { return db.AsNoTracking(); } private void Commit() { context.SaveChanges(); } #if NET45 public async Task AddAsync(TEntity entity) { db.Add(entity); await CommitAsync(); } public async Task UpdateAsync(TEntity entity) { var dbSet = context.Set<TEntity>(); var entry = context.Entry(entity); dbSet.Attach(entity); entry.State = EntityState.Modified; await CommitAsync(); } public async Task RemoveAsync(TEntity entity) { db.Remove(entity); await CommitAsync(); } public async Task<TEntity> GetByKeyAsync(int key) { return await db.FindAsync(key); } private async Task CommitAsync() { await context.SaveChangesAsync(); } #endif } }此处用EF作为演示具体实现,没有使用UnitOfWork来同步上下文,但这都不是重点,特别指出的是为了表现出基础设施层应该具备一定的兼容性,该实现默认采用EF的同步的实现,当环境支持异步接口时则可选择调用异步方法,代码内#if NET45作为条件编译符号指示是否启用。这样一来非4.5框架引用只能使用同步方法,4.5及以上只需要在项目属性->生成->条件编译符号内写入NET45并保存即可启用异步方法。
五、CQRS体系结构
CQRS全称为Command Query Responsibility Segregation,命令查询职责分离。
有人曾经说系统无非两种行为:命令和查询。可以简单粗暴的理解为任何写操作都是命令,其余皆为查询。
这与软件设计的思想“读写分离”不谋而合,因为当命令和查询被分离的时候,我们将会有更多的机会去把握整个事情的细节,以我公司的项目为例,因我们采用WebApi实现的web服务作为边界,我们可以采用将所有写服务部署为一个站点,所有读服务作为一个站点,写服务的调用都以命令形式(Command),读服务还是传统实现,此时有几个好处:
1:服务实现读写分离。
2:读服务因其无状态性,根据硬件情况可以水平拓展做负载均衡实现分布式。
3:不会因大量读请求影响到写服务。
4:读服务和写服务可以采用不同技术架构,细致优化性能,比如我们采用的方式是读服务用WebApi实现,写服务用ServiceStack实现。
引用一张dax.net的示例图
CQRS还包含几个重要概念,如事件溯源(Event Sourcing)、快照(Snapshots)以及事件存储(Event Store),本想都讲讲,但不知不觉吃完饭到现在都深夜了,明天早上还得带小孩出去,所以放在下篇结合代码示例讲解,相信会直观一点,也可以直接参考netfocus和dax.net的系列博客,这两位在github上的项目也是非常具有参考价值的。