Domain Model(领域模型)
上一篇:《DDD 领域驱动设计-如何 DDD?》
开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代码已更新)
阅读目录:
- JsPermissionApply 生命周期
- 改进 JsPermissionApply 实体
- 重命名 UserAuthenticationService
- 改进 JsPermissionApplyRepository
- 改进领域单元测试
如何完善领域模型?指的是完善 JS 权限申请领域模型,也就是 JsPermissionApply Domain Model。
在上篇博文中,关于 JsPermissionApply 领域模型的设计,只是一个刚出生的“婴儿”,还不是很成熟,并且很多细致的业务并没有考虑到,本篇将对 JsPermissionApply 领域模型进行完善,如何完善呢?望着解决方案中的项目和代码,好像又束手无策,这时候如果没有一点思考,而是直接编写代码,到最后你会发现 DDD 又变成了脚本式开发,所以,我们在做领域模型开发的时候,需要一个切入点,把更多的精力放在业务上,而不是实现的代码上,那这个切入点是什么呢?没错,就是上篇博文中的“业务流程图”,又简单完善了下:
1. JsPermissionApply 生命周期
在完善 JsPermissionApply 领域模型之前,我们需要先探讨下 JsPermissionApply 实体的生命周期,这个在接下来完善的时候会非常重要,能影响 JsPermissionApply 实体生命周期的唯一因素,就是改变其自身的状态,从上面的业务流程图中,我们就可以看到改变状态的地方:“申请状态为待审核”、“申请状态改为通过”、“申请状态改为未通过”、“申请状态改为锁定”,能改变实体状态的行为都是业务行为,这个在领域模型设计的时候,要重点关注。
用户申请 JS 权限的最终目的是开通 JS 权限,对于 JsPermissionApply 实体而言,就是自身状态为“通过”,所以,我们可以认为,当 JsPermissionApply 实体状态为“通过”的时候,那么 JsPermissionApply 实体的生命周期就结束了,JsPermissionApply 生命周期开始的时候,就是创建 JsPermissionApply 实体对象的时候,也就是实体状态为“待审核”的时候。
好,上面的分析听起来很有道理,感觉应该没什么问题,但在实现 JsPermissionApplyRepository 的时候,就会发现有很多问题(后面会说到),JsPermissionApply 的关键字是 Apply(申请),对于一个申请来说,生命周期的结束就是其经过了审核,不论是通过还是不通过,锁定还是不锁定,这个申请的生命周期就结束了,再次申请就是另一个 JsPermissionApply 实体对象了,对于实体生命周期有效期内,其实体必须是唯一性的。
导致上面两种分析的不同,主要是关注点不同,第一种以用户为中心,第二种以申请为中心,以用户为中心的分析方式,在我们平常的开发过程中会经常遇到,因为我们开发的系统基本上都是给人用的,所以很多业务都是围绕用户进行展开,好像没有什么不对,但如果这样进行分析设计,那么每个系统的核心域都是用户了,领域模型也变成了用户领域模型,所以,我们在分析业务系统的时候,最好进行细分,并把用户的因素隔离开,最后把核心和非核心进行区分开。
2. 改进 JsPermissionApply 实体
先看下之前 JsPermissionApply 实体的部分代码:
namespace CNBlogs.Apply.Domain { public class JsPermissionApply : IAggregateRoot { private IEventBus eventBus; ... public void Process(string replyContent, Status status) { this.ReplyContent = replyContent; this.Status = status; this.ApprovedTime = DateTime.Now; eventBus = IocContainer.Default.Resolve<IEventBus>(); if (this.Status == Status.Pass) { eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId }); eventBus.Publish(new MessageSentEvent() { Title = "系统通知", Content = "审核通过", RecipientId = this.UserId }); } else if (this.Status == Status.Deny) { eventBus.Publish(new MessageSentEvent() { Title = "系统通知", Content = "审核不通过", RecipientId = this.UserId }); } } } }
Process 的设计会让领域专家看不懂,为什么?看下对应的单元测试:
[Fact]
public async Task ProcessApply() { var userId = 1; var jsPermissionApply = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync(); Assert.NotNull(jsPermissionApply); jsPermissionApply.Process("审核通过", Status.Pass); _unitOfWork.RegisterDirty(jsPermissionApply); Assert.True(await _unitOfWork.CommitAsync()); }
Process 是啥?如果领域专家不是开发人员,通过一个申请,他会认为应该有一个直接通过申请的操作,而不是调用一个不知道干啥的 Process 方法,然后再传几个不知道的参数,在 IDDD 书中,代码也是和领域专家交流的通用语言之一,所以,开发人员编写的代码需要让领域专家看懂,至少代码要表达一个最直接的业务操作。
所以,对于申请的处理,通过就是通过,不通过就是不通过,要用代码表达的简单粗暴。
改进代码:
namespace CNBlogs.Apply.Domain { public class JsPermissionApply : IAggregateRoot { private IEventBus eventBus; ... public async Task Pass() { this.Status = Status.Pass; this.ApprovedTime = DateTime.Now; this.ReplyContent = "恭喜您!您的JS权限申请已通过审批。"; eventBus = IocContainer.Default.Resolve<IEventBus>(); await eventBus.Publish(new JsPermissionOpenedEvent() { UserId = this.UserId }); await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请已批准", Content = this.ReplyContent, RecipientId = this.UserId }); } public async Task Deny(string replyContent) { this.Status = Status.Deny; this.ApprovedTime = DateTime.Now; this.ReplyContent = $"抱歉!您的JS权限申请没有被批准,{(string.IsNullOrEmpty(replyContent) ? "" : $"具体原因:{replyContent}<br/>")}麻烦您重新填写申请理由。"; eventBus = IocContainer.Default.Resolve<IEventBus>(); await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请未通过审批", Content = this.ReplyContent, RecipientId = this.UserId }); } public async Task Lock() { this.Status = Status.Lock; this.ApprovedTime = DateTime.Now; this.ReplyContent = "抱歉!您的JS权限申请没有被批准,并且申请已被锁定,具体请联系contact@cnblogs.com。"; eventBus = IocContainer.Default.Resolve<IEventBus>(); await eventBus.Publish(new MessageSentEvent() { Title = "您的JS权限申请已被锁定", Content = this.ReplyContent, RecipientId = this.UserId }); } } }
这样改进还有一个好处,就是改变 JsPermissionApply 状态会变的更加明了,也更加受保护,什么意思?比如之前的 Process 的方法,我们可以通过参数任意改变 JsPermissionApply 的状态,这是不被允许的,现在我们只能通过三个操作改变对应的三种状态。
JsPermissionApply 实体改变了,对应的单元测试也要进行更新(后面讲到)。
3. 重命名 UserAuthenticationService
UserAuthenticationService 是领域服务,一看到这个命名,会认为这是关于用户验证的服务,我们再看上面的流程图,会发现有一个“验证用户信息”操作,但前面还有一个“验证申请状态”操作,而在之前的设计实现中,这两个操作都是放在 UserAuthenticationService 中的,如下:
namespace CNBlogs.Apply.Domain.DomainServices { public class UserAuthenticationService : IUserAuthenticationService { private IJsPermissionApplyRepository _jsPermissionApplyRepository; public UserAuthenticationService(IJsPermissionApplyRepository jsPermissionApplyRepository) { _jsPermissionApplyRepository = jsPermissionApplyRepository; } public async Task<string> Verfiy(int userId) { if (!await UserService.IsHasBlog(userId)) { return "必须先开通博客,才能申请JS权限"; } var entity = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync(); if (entity != null) { if (entity.Status == Status.Pass) { return "您的JS权限申请正在处理中,请稍后"; } if (entity.Status == Status.Lock) { return "您暂时无法申请JS权限,请联系contact@cnblogs.com"; } } return string.Empty; } } }
IsHasBlog 属于用户验证,但下面的 jsPermissionApply.Status 验证就不属于了,放在 UserAuthenticationService 中也不合适,我的想法是把这部分验证独立出来,用 ApplyAuthenticationService 领域服务实现,后来仔细一想,似乎和上面实体生命周期遇到的问题有些类似,误把用户当作核心考虑了,在 JS 权限申请和审核系统中,对于用户的验证,其实就是对申请的验证,所验证的最终目的是:某个用户是否符合要求进行申请操作?
所以,对于申请相关的验证操作,应该命名为 ApplyAuthenticationService,并且验证代码都放在其中。
改进代码:
namespace CNBlogs.Apply.Domain.DomainServices { public class ApplyAuthenticationService : IApplyAuthenticationService { private IJsPermissionApplyRepository _jsPermissionApplyRepository; public ApplyAuthenticationService(IJsPermissionApplyRepository jsPermissionApplyRepository) { _jsPermissionApplyRepository = jsPermissionApplyRepository; } public async Task<string> Verfiy(int userId) { if (!await UserService.IsHasBlog(userId)) { return "必须先开通博客,才能申请JS权限"; } var entity = await _jsPermissionApplyRepository.GetEffective(userId).FirstOrDefaultAsync(); if (entity != null) { if (entity.Status == Status.Pass) { return "您的JS权限申请已开通,请勿重复申请"; } if (entity.Status == Status.Wait) { return "您的JS权限申请正在处理中,请稍后"; } if (entity.Status == Status.Lock) { return "您暂时无法申请JS权限,请联系contact@cnblogs.com"; } } return string.Empty; } } }
除了 UserAuthenticationService 重命名为 ApplyAuthenticationService,还增加了对 JsPermissionApply 状态为 Lock 的验证,并且 IJsPermissionApplyRepository 的 GetByUserId 调用改为了 GetEffective,这个下面会讲到。
4. 改进 JsPermissionApplyRepository
原先的 IJsPermissionApplyRepository 设计:
namespace CNBlogs.Apply.Repository.Interfaces { public interface IJsPermissionApplyRepository : IRepository<JsPermissionApply> { IQueryable<JsPermissionApply> GetByUserId(int userId); } }
这样的 IJsPermissionApplyRepository 的设计,看似没什么问题,并且问题也不出现在实现,而是出现在调用的时候,GetByUserId 会在两个地方调用:
- ApplyAuthenticationService.Verfiy 调用:获取 JsPermissionApply 实体对象,用于状态的验证,判断是否符合申请的要求。
- 领域的单元测试代码中(或者应用层):获取 JsPermissionApply 实体对象,用于更新其状态。
对于上面两个调用方来说,GetByUserId 太模糊了,甚至不知道调用的是什么东西?并且这两个地方的调用,获取的 JsPermissionApply 实体对象也并不相同,严格来说,应该是不同状态下的 JsPermissionApply 实体对象,我们仔细分析下:
- ApplyAuthenticationService.Verfiy 调用:判断是否符合申请的要求。什么情况下会符合申请要求呢?就是当状态为“未通过”的时候,对于申请验证来说,可以称之为“有效的”申请,相反,获取用于申请验证的 JsPermissionApply 实体对象,应该称为“无效的”,调用命名为 GetInvalid。
- 领域的单元测试代码中(或者应用层):用于更新 JsPermissionApply 实体状态。什么状态下的 JsPermissionApply 实体,可以更新其状态呢?答案就是状态为“待审核”,所以这个调用应该获取状态为“待审核”的 JsPermissionApply 实体对象,调用命名为 GetWaiting。
改进代码:
namespace CNBlogs.Apply.Repository { public class JsPermissionApplyRepository : BaseRepository<JsPermissionApply>, IJsPermissionApplyRepository { public JsPermissionApplyRepository(IDbContext dbContext) : base(dbContext) { } public IQueryable<JsPermissionApply> GetInvalid(int userId) { return _entities.Where(x => x.UserId == userId && x.Status != Status.Deny && x.IsActive); } public IQueryable<JsPermissionApply> GetWaiting(int userId) { return _entities.Where(x => x.UserId == userId && x.Status == Status.Wait && x.IsActive); } } }
5. 改进领域单元测试
原先的单元测试代码:
namespace CNBlogs.Apply.Domain.Tests { public class JsPermissionApplyTest { private IUserAuthenticationService _userAuthenticationService; private IJsPermissionApplyRepository _jsPermissionApplyRepository; private IUnitOfWork _unitOfWork; public JsPermissionApplyTest() { CNBlogs.Apply.BootStrapper.Startup.Configure(); _userAuthenticationService = IocContainer.Default.Resolve<IUserAuthenticationService>(); _jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>(); _unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>(); } [Fact] public async Task Apply() { var userId = 1; var verfiyResult = await _userAuthenticationService.Verfiy(userId); Console.WriteLine(verfiyResult); Assert.Empty(verfiyResult); var jsPermissionApply = new JsPermissionApply("我要申请JS权限", userId, ""); _unitOfWork.RegisterNew(jsPermissionApply); Assert.True(await _unitOfWork.CommitAsync()); } [Fact] public async Task ProcessApply() { var userId = 1; var jsPermissionApply = await _jsPermissionApplyRepository.GetByUserId(userId).FirstOrDefaultAsync(); Assert.NotNull(jsPermissionApply); jsPermissionApply.Process("审核通过", Status.Pass); _unitOfWork.RegisterDirty(jsPermissionApply); Assert.True(await _unitOfWork.CommitAsync()); } } }
看起来似乎没什么问题,一个申请和一个审核测试,但我们仔细看上面的业务流程图,会发现这个测试代码并不能完全覆盖所有的业务,并且这个测试代码也有些太敷衍了,在测试驱动开发中,测试代码就是所有的业务表达,它应该是项目中最全面和最精细的代码,在领域驱动设计中,当领域层的代码完成后,领域专家查看的时候,不会看领域层,而是直接看单元测试中的代码,因为领域专家不懂代码,并且他也不懂你是如何实现的,它关心的是我该如何使用它?我想要的业务操作,你有没有完全实现?单元测试就是最好的体现。
我们该如何改进呢?还是回归到上面的业务流程图,并从中归纳出领域专家想要的几个操作:
- 填写 JS 权限申请(需要填写申请理由)
- 通过 JS 权限申请
- 拒绝 JS 权限申请(需要填写拒绝原因)
- 锁定 JS 权限申请
- 删除(待考虑)
上面这几个操作,都必须在单元测试代码中有所体现,并且尽量让测试颗粒化,比如一个验证操作,你可以对不同的参数编写不同的单元测试代码。
改进代码:
namespace CNBlogs.Apply.Domain.Tests { public class JsPermissionApplyTest { private IApplyAuthenticationService _applyAuthenticationService; private IJsPermissionApplyRepository _jsPermissionApplyRepository; private IUnitOfWork _unitOfWork; public JsPermissionApplyTest() { CNBlogs.Apply.BootStrapper.Startup.Configure(); _applyAuthenticationService = IocContainer.Default.Resolve<IApplyAuthenticationService>(); _jsPermissionApplyRepository = IocContainer.Default.Resolve<IJsPermissionApplyRepository>(); _unitOfWork = IocContainer.Default.Resolve<IUnitOfWork>(); } [Fact] public async Task ApplyTest() { var userId = 1; var verfiyResult = await _applyAuthenticationService.Verfiy(userId); Console.WriteLine(verfiyResult); Assert.Empty(verfiyResult); var jsPermissionApply = new JsPermissionApply("我要申请JS权限", userId, ""); _unitOfWork.RegisterNew(jsPermissionApply); Assert.True(await _unitOfWork.CommitAsync()); } [Fact] public async Task ProcessApply_WithPassTest() { var userId = 1; var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync(); Assert.NotNull(jsPermissionApply); await jsPermissionApply.Pass(); _unitOfWork.RegisterDirty(jsPermissionApply); Assert.True(await _unitOfWork.CommitAsync()); } [Fact] public async Task ProcessApply_WithDenyTest() { var userId = 1; var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync(); Assert.NotNull(jsPermissionApply); await jsPermissionApply.Deny("理由太简单了。"); _unitOfWork.RegisterDirty(jsPermissionApply); Assert.True(await _unitOfWork.CommitAsync()); } [Fact] public async Task ProcessApply_WithLockTest() { var userId = 1; var jsPermissionApply = await _jsPermissionApplyRepository.GetWaiting(userId).FirstOrDefaultAsync(); Assert.NotNull(jsPermissionApply); await jsPermissionApply.Lock(); _unitOfWork.RegisterDirty(jsPermissionApply); Assert.True(await _unitOfWork.CommitAsync()); } } }
改进好了代码之后,对于开发人员来说,任务似乎完成了,但对于领域专家来说,仅仅是个开始,因为他必须要通过提供的四个操作,来验证各种情况下的业务操作是否正确,我们来归纳下:
- 申请 -> 申请:ApplyTest -> ApplyTest
- 申请 -> 通过:ApplyTest -> ProcessApply_WithPassTest
- 申请 -> 拒绝:ApplyTest -> ProcessApply_WithDenyTest
- 申请 -> 锁定:ApplyTest -> ProcessApply_WithLockTest
- 申请 -> 通过 -> 申请:ApplyTest -> ProcessApply_WithPassTest -> ApplyTest
- 申请 -> 拒绝 -> 申请:ApplyTest -> ProcessApply_WithDenyTest -> ApplyTest
- 申请 -> 锁定 -> 申请:ApplyTest -> ProcessApply_WithLockTest -> ApplyTest
确认上面的所有测试都通过之后,就说明 JsPermissionApply 领域模型设计的还算可以。
DDD 倾向于“测试先行,逐步改进”的设计思路。测试代码本身便是通用语言在程序中的表达,在开发人员的帮助下,领域专家可以阅读测试代码来检验领域对象是否满足业务需求。
当领域层的代码基本完成之后,就可以在地基上添砖加瓦了,后面的实现都是工作流程的实现,没有任何业务的包含,比如上面对领域层的单元测试,其实就是应用层的实现,在添砖加瓦的过程中,切记地基的重要性,否则即使盖再高的摩天大楼,地基不稳,也照样垮塌。
实际项目的 DDD 应用很有挑战,也会很有意思。????
无意间发现了 Visual Studio 2015 Update 2 一个很实用的功能: