Fork me on GitHub

DDD 领域驱动设计-如何完善 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 一个很实用的功能:

posted @ 2016-04-19 23:08  田园里的蟋蟀  阅读(8961)  评论(9编辑  收藏  举报