领域驱动设计(DDD)的实践经验分享之持久化透明
前一篇文章中,我谈到了领域驱动设计中,关于ORM工具该如何使用的问题。谈了很多我心里的想法,大家也对我的观点做了一些回复,或多或少让我深深感觉到面向对象设计和领域驱动设计是两个不同层次的东西。你会面向对象并不代表你就会面向领域设计。后来,我无意中发现了一个网站,http://www.jdon.com,这个网站中所包含的知识在我看来非常深入,而且基本上都包含了现在一些最新的设计思想。我看了几篇文章后渐渐感觉到领域驱动设计并不是我想象中那么简单。其实学技术,学框架并不是太难,只要你肯花时间就一定能慢慢领悟。但要学会领域建模,我现在觉得非一朝一夕就能学会。好了。接下来还是回到我今天晚上的这篇文章的主题吧。
首先,既然是领域驱动设计,最后就会设计出一个模型,该模型只包含领域对象和领域逻辑。我学习领域驱动设计时间还很短,所以也没能设计出好的模型出来。但我也确实在思考该如何为我设计的那个自以为设计良好的模型和外界解耦。然后通过各处查看资料也积累的一些技巧,在此在狗胆拿出来和大家分享一下。
关于如何让一个领域模型和外部的交互解耦,我主要考虑以下两点:
- 领域对象是否应该使用Repository;
- 如何让数据持久层透明的对领域对象进行持久化;
Presentation Layer + Service Layer + Domain Layer + Persistence Layer
其中Domain Layer中就包含了我们说的领域模型,接下来我说的领域层的时候意思就是在强调领域模型的概念。
关于领域对象是否应该使用Repository:
关于这个问题,我觉得领域对象不应该也不需要使用Repository,甚至在我看来领域层中根本就不需要有Repository。先想想我们为什么要把Repository放在领域层中?因为我们把它看作是一个管理特定领域对象的容器,我们会用它来获取或保存领域对象,并且它也知道该如何将它所管理的任何领域对象的修改持久化到数据库。总体来说,Repository可以将领域和持久层完全隔离。好了,我能想到的Repository的作用大概就是这个了。接下来看看Repository的缺点,我觉得它没有明显的缺点。但是他会有一个潜在的危险,如果领域模型过分依赖于Repository,那将很容易导致领域模型变成贫血模型,因为你很可能会习惯性地将业务逻辑转移到Repository中。不知道我的理解是否对,但我就是这么觉得的。后来我去网上找了一些资料,发现领域事件这么一个东东,觉得事件确实应该被引入到模型中来。但是看了别人的很多领域事件的实现方式,要么太复杂,一搞就是一个框架,要么太简单,不实用。所以打算先学习其设计意图,然后自己写一个简单的适合自己的领域事件和领域模型相结合的简单框架。到现在为止终于整出一个能工作的东西出来了。先回答一个问题,就是为什么要用领域事件而不是用Repository来做Repository做的事情?我的回答是,因为这两种实现方式会导致领域对象和外界交互时的主动性不同,领域对象触发一个领域事件是是一个主动的行为,而领域对象调用Repository的某个方法是一种请求性的被动的行为。事件可以确保领域对象永远处于某个领域逻辑的起始位置,这样就能确保领域模型的业务逻辑不会被分散。整个领域模型是完全封闭的,它不需要去请求别人帮它完成某个任务,而是只要告诉别人我做了什么或者我将要做什么或者我想要做什么,等等。总之,一定是领域模型去通知别人,而不是去请求别人。而调用Repository就不一样了,它就会使领域领域模型依赖于Repository,即便只是接口的依赖。
首先声明一下,我下面所提到的任何事件和事件总线和CQRS架构中的事件和事件总线有一定的区别。我说的事件的主要职责是用来让领域对象和外界进行互动的,并且我的事件的实现方式和CQRS中也有比较大的区别,我的事件不会被持久化到数据库,仅仅是一种通讯的实现机制。
好了,如果我们用领域事件,那要怎么实现领域事件呢?其实很简单,分为两个步骤:1)生成一个事件;2)通知“事件总线“去发布这个事件;下面来看一个例子:
假设某个论坛中的帖子(Thread),它有一个属性(PostList)表示该帖子的所有的回复(Post)。
1 private List<Post> PostList 2 { 3 get 4 { 5 if (posts == null) 6 { 7 RaiseEvent(new ThreadPostsQueryEvent 8 { 9 Id = Id, SetPosts = new Action<IEnumerable<Post>>(postList => posts = postList.ToList()) }); SortPosts(posts, true); } return posts; } } protected void RaiseEvent(IEvent evnt) { InstanceLocator.Current.GetInstance<IEventBus>().Publish(evnt); }
这里我希望大家不要太关注这个PostList本身的设计是否合理,而是重点关注事件在将领域对象和外部解耦的实现上。
首先,我们先实例化一个事件ThreadPostsQueryEvent,并初始化它,然后调用RaiseEvent方法来“触发”这个事件,其实实质上就是让事件总线IEventBus来发布这个事件。现在,你也许会问,那领域对象不是和IEventBus耦合了吗?的确,是这样的,但没关系因为我们只是用它来发布事件的,用它来告诉外界领域模型中发生了某个事件。这样外界就可以接收到这个事件,然后一些关心这个事件的Event Handler就会响应这个事件。基于这样的设计,那事件总线(即IEventBus)也是属于领域模型的一部分,它的职责就是发布某个事件。我觉得它应该算是整个领域模型的核心了,因为任何领域对象需要和外界交互时,都是先告诉它,然后由它来通知外界。
另外,你可能还注意到了上例中的一个细节。那就是事件中携带了一个委托实例,并把它传了出去。它的作用显而易见,就是获取事件的响应信息。上例中,ThreadPostsQueryEvent事件的意图就是要告诉外界说,我想要属于我的所有的回复。 然后外界处理了这个事件后如何将返回值(回复信息)返回给领域模型呢?我想到的我认为最简单也是最直接的方式就是让事件传递一个委托出去,然后外界直接调用该委托来将处理结果返回给领域模型。我觉得这样做并没有破坏事件的独立性,原因在于被事件传递出去的委托并不是事件自己去掉的,而是外界掉的。所以可以理解为是外界响应事件并调用某个它并不认识但知道如何调用的委托方法,之所以说外界不认识该委托方法是因为该委托方法是私有的,模型并没有把它暴露出来,外界不需要知道该委托方法的方法名和具体实现,那是事件的事情,它不需要也无权关心。
有了上面这样的设计,我想我们就能很轻而易举的将模型可能和外界产生的任何交互全部用类似上面这种事件来完成了。比如,告诉外界我发生了什么,我将要发生什么,我想要什么,等等。如果需要根据外界的响应的结果来决定接下来做什么事情的情况,就传递一个委托方法实例出去即可。 而且我还觉得,利用事件可以很方便的实现延迟加载(Lazy Load)而不需要依赖于任何的ORM框架。当我们需要某个还没有Load的Aggregate Child时,只要触发一个事件即可。
最后,关于IEventBus实例,我是通过Ioc容器注入进来,这让就可以让领域模型和外界完全解耦,不依赖外界的任何东西。因此,我们的领域模型就不在需要Repository了,它只需要有:领域对象+领域事件+一个EventBus。当然,可能还有领域服务和领域工厂,非常干净。
关于如何让数据持久层透明的对领域对象进行持久化:
上面提到,Repository不会出现在领域模型中,但并不表示我们不会再用到它。Repository确实是一个用来将领域模型和数据持久化隔离的好东西。我认为我们可以将它用在前面提到的Service Layer,注意,这个Service Layer不是领域层的中Service。大家都知道,Service Layer层的逻辑是控制逻辑,而领域层的逻辑是业务逻辑。接下来先说一下我所表达的持久化透明的意思:领域层不需要知道领域对象如何被持久化。好了,有了这个目标后,我就可以谈一下该如何做到这个目标。
说白了,就是要解决让Repository实现对领域对象的新增、删除、更新三种操作的跟踪,并让Repository知道该如何持久化。我想该是贴一段代码的时候了。下面是我设计的Repository的架构:
1 public interface IEntity<TEntityId> 2 { 3 TEntityId Id { get; } 4 } 5 public interface IAggregateRoot<TEntityId> : IEntity<TEntityId> 6 { 7 } 8 public interface IRepository<TAggregateRoot, TEntityId> : ICanPersistRepository 9 where TAggregateRoot : class, IAggregateRoot<TEntityId> { TAggregateRoot Get(TEntityId id); void Add(TAggregateRoot aggregateRoot); void Remove(TAggregateRoot aggregateRoot); } public interface ICanPersistRepository { void PersistChanges(); } public interface ISectionRepository : IRepository<Section, Guid>, IEventHandler<SectionGroupChangedEvent>, IEventHandler<SectionAdminUserAddedEvent>, IEventHandler<SectionAdminUserRemovedEvent>, IEventHandler<SectionAdminUsersQueryEvent>, IEventHandler<SectionTotalThreadCountQueryEvent>, IEventHandler<SectionQueryEvent>, IEventHandler<SectionCreatedEvent> { }
IEntity表示领域模型中的实体,IAggreageRoot表示聚合根,IRepository就是前面所说的Repository,ICanPersistRepository接口表示某个Repository是否有持久化的能力。之所以把持久化的功能独立定义在一个接口中是为了考虑事务的设计,这点我会将后面的文章中再做更详细的讨论。 Section表示一个论坛中的版块,它是一个聚合根。而像SectionGroupChangedEvent等这些就是领域事件了。最后,ISectionRepository就是管理Section的Repository了。
上面的设计和实现我认为已经基本上解决持久化的问题了,比如ISectionRepository可以记录新增和删除的Section,而对于Section的部分修改,Section会以事件的方式通知外界,由于ISectionRepository会响应Section的这些事件,所以自然也就知道这些更新了。最后就是如何记录Section中的那些没有用事件来通知的修改。你可能会问,为什么不用事件来通知呢?下面听我的解释:
我觉得一般一个领域对象包含一些基本属性,还包含一些引用属性,还有一些方法,等。以一个论坛版块为例:
1 public class Section : AggregateRoot<Guid> 2 { 3 #region Private Variables 4 5 private Group group; 6 private int? totalThreadCount; 7 private List<User> adminUserList; 8 9 #endregion 10 11 #region Constructors 12 13 public Section(Guid id, Group group) : base(id) 14 { 15 this.group = group; 16 } 17 18 #endregion 19 20 #region Public Properties 21 22 [TrackingProperty] 23 public string Subject { get; set; } 24 25 [TrackingProperty] 26 public bool Enabled { get; set; } 27 28 public Group Group 29 { 30 get 31 { 32 return group; 33 } 34 set 35 { 36 if (group != value && value != null) 37 { 38 group = value; 39 RaiseEvent(new SectionGroupChangedEvent { Id = Id, Group = group }); 40 } 41 } 42 } 43 public int TotalThreadCount 44 { 45 get 46 { 47 if (totalThreadCount == null) 48 { 49 RaiseEvent(new SectionTotalThreadCountQueryEvent 50 { 51 Id = Id, 52 SetTotalThreadCount = new Action<int>(count => totalThreadCount = count) 53 }); 54 } 55 return totalThreadCount.Value; 56 } 57 } 58 public ReadOnlyCollection<User> AdminUsers 59 { 60 get 61 { 62 return AdminUserList.AsReadOnly(); 63 } 64 } 65 66 #endregion 67 68 #region Public Methods 69 70 public void AddAdminUser(User user) 71 { 72 if (!AdminUserList.Contains(user)) 73 { 74 AdminUserList.Add(user); 75 RaiseEvent(new SectionAdminUserAddedEvent { Id = Id, User = user }); 76 } 77 } 78 public void RemoveAdminUser(User user) 79 { 80 if (AdminUserList.Contains(user)) 81 { 82 AdminUserList.Remove(user); 83 RaiseEvent(new SectionAdminUserRemovedEvent { Id = Id, User = user }); 84 } 85 } 86 87 #endregion 88 89 #region Private Properties 90 91 private List<User> AdminUserList 92 { 93 get 94 { 95 if (adminUserList == null) 96 { 97 RaiseEvent(new SectionAdminUsersQueryEvent 98 { 99 Id = Id, SetUsers = new Action<IEnumerable<User>>(users => adminUserList = users.ToList()) }); } return adminUserList; } } #endregion }
同样,希望大家不要把重点放在分析我设计的领域对象(Section)是否合理,我现在清楚的知道自己在如何设计领域对象方面还没什么经验,还要好好学习。我希望大家只要把焦点放在我是如何做到让一个领域对象告诉外界或让外界有能力知道他的状态更新了。
首先,上例中,Subject、Enabled这两个就是我说的基本属性,而Group就是一个引用属性,Group是一个版块分组,一个版块分组下有多个版块,是一对多的关系。所以Section会有对一个Group的引用。另外,TotalThreadCount(版块总帖子数)和AdminUsers(版主信息)也是引用属性。判定什么属性是基本属性什么属性是引用属性的方法很简单,就是看该属性的数据是否是该AggregateRoot类本身固有的简单类型或值类型。如果不是,则是引用属性,如果是则是基本属性。比如TotalThreadCount,根据我目前的设计他并不是一个基本属性,因为它的获取要通过发事件来获取。 同理Group和AdminUsers也不是。
一般情况下,我们对于基本属性的修改,往往是直接赋值的,比如下面的例子:
1 public BaseReply UpdateSection(UpdateSectionRequest request) 2 { 3 BaseReply reply = new BaseReply(); 4 5 using (IUnitOfWork unitOfWork = InstanceLocator.Current.GetInstance<IUnitOfWork>()) 6 { 7 try 8 { 9 request.Validate(); var sectionRepository = InstanceLocator.Current.GetInstance<ISectionRepository>(); Section section = sectionRepository.Get(request.Id); section.Subject = request.Subject; section.Enabled = request.Enabled; unitOfWork.SubmitChanges(); reply.Success = true; } catch (BusinessValidationException ex) { reply.Success = false; reply.ErrorState.ErrorItems = ex.ValidationError.GetErrors().ToErrorItemList(); } catch (Exception ex) { reply.Success = false; reply.ErrorState.ExceptionMessage = ex.Message; } }; return reply; }
上面的UpdateSection是Service Layer层中的一个方法,用来更新一个Section。该方法的执行流程是:首先根据Repository根据SectionId获取领域对象Section,然后更新Section的Subject和Body属性(第12行和13行),最后调用Unit of Work的SubmitChanges方法将修改持久化到数据库。当Subject和Body属性被修改时并没有触发任何的事件。主要是我考虑到如果要为每个这种简单属性都弄个与之对应的事件,那会导致事件泛滥。并且每次一个基本属性被修改,就触发一个事件,这样性能也不好。再者,有些情况下一些属性会被连续修改好多次,举个例子,比如现在你把Subject先赋值为“subject1”,后来又赋值为"subject2",如果每次都触发事件,那就会出发两个事件,也就是该字段会被持久化两次,但实际上我们只关心Subject属性最后的状态而已。因此,我觉得更好的做法,应该是对于这种基本属性被修改时,不触发事件,而应该采用备份初始状态和在保存是比较是否被修改的方法来实现。但是考虑到基础框架可能不知道哪些属性需要被备份,如果把整个领域对象的所有属性都备份,那无疑性能会很差,所以用了一个折中的方法,就是在需要备份的属性上加一个“TrackingProperty”的特性来指明该属性需要被备份。具体的实现方法可以看下面的介绍。
在我的设计中,我会遵循这样的原则,如果是引用属性的任何修改,就通过发事件,因为往往这种属性的修改往往比较难跟踪(想象一下集合中套集合,又套其他引用对象什么的,真那个复杂呀,对吧),而且往往都是更新其他关联表中的数据;如果是基本类型,则用一个Attribute特性来标识,并且在属性值修改时也不会发事件。但是加一个Attribute已经足以,因为我们可以在将一个Section通过ISectionRepository取出来的时候,将Section中标识了TrackingProperty特性的属性通过一个Dictionary保存起来。Dictionary的Key是属性名,Value是属性值。也就是会将Section的所有简单属性的值备份起来。然后当ISectionRepository在做持久化操作的时候,我们将最新的Section中的基本属性和之前备份过的Dictionary中的值进行比较,如果有修改过,则更新,没修改过,则不更新。下面是我实现的关于如何备份和判断是否有修改的相关代码:
1 private AggregateRootBackupObject<TEntityId> CreateBackupObject(TAggregateRoot aggregateRoot) 2 { 3 var backupObject = new AggregateRootBackupObject<TEntityId>() { Id = aggregateRoot.Id }; 4 5 GetTrackingProperties(aggregateRoot).ForEach( 6 propertyInfo => backupObject.TrackingProperties.Add(propertyInfo.Name, propertyInfo.GetValue(aggregateRoot, null)) 7 ); 8 9 return backupObject; } private bool IsAggregateRootModified(TrackObject<TAggregateRoot, TEntityId> trackingObject) { if (trackingObject.Status == ObjectStatus.Tracking && trackingObject.CurrentValue != null) { foreach (var propertyInfo in GetTrackingProperties(trackingObject.CurrentValue)) { var backupValue = trackingObject.BackupValue.TrackingProperties[propertyInfo.Name]; var currentValue = propertyInfo.GetValue(trackingObject.CurrentValue, null); if (backupValue != currentValue) { return true; } } } return false; } private List<PropertyInfo> GetTrackingProperties(TAggregateRoot aggregateRoot) { return (from propertyInfo in aggregateRoot.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) where propertyInfo.GetCustomAttributes(typeof(TrackingPropertyAttribute), true).Length > select propertyInfo).ToList(); } public enum ObjectStatus { New, Tracking, Removed } public class AggregateRootBackupObject<TEntityId> { public AggregateRootBackupObject() { TrackingProperties = new Dictionary<string, object>(); } public TEntityId Id { get; set; } public Dictionary<string, object> TrackingProperties { get; private set; } } public class TrackObject<TAggregateRoot, TEntityId> where TAggregateRoot : class, IAggregateRoot<TEntityId> { public AggregateRootBackupObject<TEntityId> BackupValue { get; set; } public TAggregateRoot CurrentValue { get; set; } public ObjectStatus Status { get; set; } }
关于代码的思路,我已经在上面阐述过了。相信大家应该能轻松看懂。没什么深奥的东西的。
好了,总结一下,关于如何让Repository跟踪AggregateRoot的新增、修改、删除。我是通过如下的设计来实现的:
新增:通过IRepository.Add方法跟踪;
修改: 简单属性,通过备份和比较,引用属性,通过事件;
删除:通过IRepository.Remove方法跟踪;
新增和删除以及基本属性的修改操作在Service Layer层做,事件的触发在Domain Layer层做。
我觉得这样的设计已经实现了我的既定目标:
1)领域层很干净,连Repository都没有;
2)实现了持久化透明;
3)效率方面应该不会太差,因为Repository没有做任何多做的事情,它只做了需要做的事情;
好了,不知不觉都这么晚了,老婆都睡的很香了呢,我得睡了,明天睡个懒觉,哈哈。 希望本文能带给大家一些以前没看到过的东西。
领域驱动设计(DDD)的实践经验分享之持久化透明的更多相关文章
- 后端开发实践系列之二——领域驱动设计(DDD)编码实践
Martin Fowler在<企业应用架构模式>一书中写道: I found this(business logic) a curious term because there are f ...
- 领域驱动设计(DDD)编码实践
写在前面 Martin Fowler在<企业应用架构模式>一书中写道: I found this(business logic) a curious term because there ...
- 领域驱动设计(DDD)
领域驱动设计(DDD)实现之路 2004年,当Eric Evans的那本<领域驱动设计——软件核心复杂性应对之道>(后文简称<领域驱动设计>)出版时,我还在念高中,接触到领域驱 ...
- 领域驱动设计(DDD:Domain-Driven Design)
领域驱动设计(DDD:Domain-Driven Design) Eric Evans的"Domain-Driven Design领域驱动设计"简称DDD,Evans DDD是一套 ...
- python 全栈开发,Day116(可迭代对象,type创建动态类,偏函数,面向对象的封装,获取外键数据,组合搜索,领域驱动设计(DDD))
昨日内容回顾 1. 三个类 ChangeList,封装列表页面需要的所有数据. StarkConfig,生成URL和视图对应关系 + 默认配置 AdminSite,用于保存 数据库类 和 处理该类的对 ...
- 关于领域驱动设计 DDD(Domain-Driven Design)
以下旨在 理解DDD. 1. 什么是领域? 妈妈好是做母婴新零售的产品,应该属于电商平台,那么电商平台就是一个领域. 同一个领域的系统都有相同的核心业务. eg: 电商领域都有:商品浏览.购物 ...
- 基于领域驱动设计(DDD)超轻量级快速开发架构(二)动态linq查询的实现方式
-之动态查询,查询逻辑封装复用 基于领域驱动设计(DDD)超轻量级快速开发架构详细介绍请看 https://www.cnblogs.com/neozhu/p/13174234.html 需求 配合Ea ...
- 领域驱动设计(DDD)实践之路(一)
本文首发于 vivo互联网技术 微信公众号 链接: https://mp.weixin.qq.com/s/gk-Hb84Dt7JqBRVkMqM7Eg 作者:张文博 领域驱动设计(Domain Dr ...
- 分享我对领域驱动设计(DDD)的学习成果
本文内容提要: 1. 领域驱动设计之领域模型 2. 为什么建立一个领域模型是重要的 3. 领域通用语言(Ubiquitous Language) 4.将领域模型转换为代码实现的最佳实践 5. 领域建模 ...
随机推荐
- GTID的限制
1.不支持非事务引擎(从库报错,stop slave;start slave;忽略). 2.不支持create table ... select 语句复制(主库直接报错). 3.不允许一个SQL同时更 ...
- [Angular] Some performance tips
The talk from here. 1. The lifecycle in Angular component: constructor vs ngOnInit: Constructor: onl ...
- WPF中自动增加行(动画)的TextBox
原文:WPF中自动增加行(动画)的TextBox WPF中自动增加行(动画)的TextBox WPF中的Textbox控件是可以自动换行的,只要设置TextWrapping属性为"Wrap& ...
- 【9602】&&【b402】合并果子
Time Limit: 1 second Memory Limit: 50 MB [问题描述] 在一个果园里,多多已经将所有的果子打了下来,而且按果子的不同种类分成了不同的堆.多多决定把所有的果子合成 ...
- 【codeforces 782C】Andryusha and Colored Balloons
[题目链接]:http://codeforces.com/contest/782/problem/C [题意] 给你一棵树 让你满足要求 ->任意相连的3个节点的颜色不能相同 的情况下进行染色 ...
- 【33.00%】【vijos P1002】过河
描述 在河上有一座独木桥,一只青蛙想沿着独木桥从河的一侧跳到另一侧.在桥上有一些石子,青蛙很讨厌踩在这些石子上.由于桥的长度和青蛙一次跳过的距离都是正整数,我们可以把独木桥上青蛙可能到达的点看成数轴上 ...
- QT5.5.1 为Qtcreator 编译的程序添加管理员权限
QT版本:5.5.1 QT Creator QT Creator 编译出来的程默认是不带管理员权限的.有时是需要管理员权限. 第一步: 创建文件 uac.manifest 添加如下代码 <?xm ...
- C++调用IDL程序的做法(一)
作者:朱金灿 来源:http://blog.csdn.net/clever101 IDL是一种数据分析和图像化应用程序及编程语言,先由美国ITT公司所有.最初在七十年代后期用于帮助科学家分析火星探险卫 ...
- Clustered filesystem with membership version support
A computer system with read/write access to storage devices creates a snapshot of a data volume at a ...
- VS解决方案文件格式说明
作者:朱金灿 来源:http://blog.csdn.net/clever101 VS解决方案文件本质是一个文件文件,这个用记事本或者Node++之类的文本编辑软件打开一个VS解决方案文件就知道了.了 ...