DDD(领域驱动设计)理论结合实践

DDD(领域驱动设计)理论结合实践 写在前面插一句:本人超爱落网-《平凡的世界》这一期,分享给大家。阅读目录:关于DDD 前期分析 框架搭建 代码实现 开源

DDD(领域驱动设计)理论结合实践

 

写在前面

      插一句:本人超爱落网-《平凡的世界》这一期,分享给大家。

  阅读目录:

第一次听你,清风吹送,田野短笛;第一次看你,半弯新湖,鱼跃翠堤;第一次念你,燕飞巢冷,释怀记忆;第一次梦你,云翔海岛,轮渡迤逦;第一次认你,怨江别续,草桥知己;第一次怕你,命悬一线,遗憾禁忌;第一次悟你,千年菩提,生死一起。

  人生有很多的第一次:小时候第一次牙牙学语、第一次学蹒跚学步。。。长大后第一次上课、第一次逃课、第一次骑自行车、第一次懂事、第一次和喜欢的人说“我爱你”、第一次旅行、第一次敞开心扉去认识这个世界。。。

  第一次的感觉:有甜蜜、有辛酸;有勇敢、有羞涩;有成功、有失败。不管怎样,都要勇敢的迈出第一步,不论成功与失败,至少自己努力过,证明过自己就好,就像哥伦布探索美洲一样,没有勇敢迈出第一步,也许现在“美洲”的概念会推迟不知多少年。

  以下内容,只是一些个人看法和实现,仅供参考学习,也欢迎讨论指教。

关于DDD

  对DDD(领域驱动设计)最初的了解,始于这一篇博文:http://www.cnblogs.com/netfocus/archive/2011/10/10/2204949.html,当时花了四五个小时阅读完,但只是初步对DDD有个了解,有点颠覆自己对编程思想的看法。2004年 Eric Evans 发表 Domain-Driven Design –Tackling Complexity in the Heart of Software (领域驱动设计- 软件核心复杂性应对之道),简称Evans DDD,这本书网上一直没有买到,很遗憾,如果有的朋友有珍藏,可以高价收购。

  什么是DDD(领域驱动设计)?DDD中最核心的是Domain Model(领域模型),和领域模型相对的是事务脚本,领域模型和事务脚本说到底就是面向对象和面向过程的区别。

  • 事务脚本:围绕功能,以功能为中心。将所有逻辑组织在一个单一过程,进行数据库直接调用,每笔交易(业务请求)都有自己的事务脚本,并且是一个类的公开方法。
  • 领域模型:描述领域类,以类之间的协作完成所需功能。所谓领域模型,是一系列相互关联的对象,每个对象代表一定意义的独立体,既可以一起以一种大规模方式协作;也可以小到以单线方式运行。

  好像有个报告统计,大约80%的程序员使用事务脚本编程,三层架构(UI、BLL、DAL)对于我们来说太熟悉了,编程的时候代码一般会集中在DAL层,致使数据访问层充斥着大量的业务逻辑,而且很难复用,每个DAL中的类就像一个单元,只为某一功能实现,也就是上面所说的“单一过程”,因为业务逻辑都实现在数据访问层了,这样业务逻辑层就成了一个空架子,有的人就会觉得BLL-业务逻辑层没有存在的必要,然后设计的时候就把业务逻辑层去掉了,就只剩UI和DAL层了,外加一些HelpClass,然后的然后。。。

  领域驱动设计的概念从提出到现在十年了,现在很少的公司能真正的去应用,而还是采用事务脚本的方式,为什么?其实就是一种思想,或者说方式的转变,就好比你以前习惯用手直接吃饭,现在让你拿筷子吃饭,肯定会不习惯。当然还有一部分原因是领域驱动设计的推行,或者说国内有关这领域的大牛们很少,但我觉得不管怎样,这是个趋势,就像黑夜过后,一定会是清晨一样。

  上面说到三层架构(UI、BLL、DAL),我们再看一下领域驱动设计的分层:

          来自:dax.net

主要分为四层(表现层、应用层、领域层和基础层):

  • Presentation Layer:表现层,负责显示和接受输入;
  • Application Layer(Service):应用层,很薄的一层,只包含工作流控制逻辑,不包含业务逻辑;
  • Domain Layer(Domain):领域层,包含整个应用的所有业务逻辑;
  • Infrastructure Layer:基础层,提供整个应用的基础服务;

  领域驱动设计主张充血模型,也就是富模型的意思,大多业务逻辑都应该被放在Domain Object里面(包括持久化业务逻辑),而Service层应该是很薄的一层,仅仅封装事务和少量逻辑,不和Dao层打交道。 

  优点:

  1. 更加符合OO的原则。
  2. Service层很薄,只充当Facade的角色,不和Dao打交道。

  缺点:

  1. Dao和Domain Object形成了双向依赖,复杂的双向依赖会导致很多潜在的问题。 
  2. 如何划分Service层逻辑和Domain层逻辑是非常含混的,在实际项目中,由于设计和开发人员的水平差异,可能导致整个结构的混乱无序。 (这个问题在项目实际运作的时候会出现,划分很重要。)
  3. 考虑到Service层的事务封装特性,Service层必须对所有的Domain Object的逻辑提供相应的事务封装方法,其结果就是Service完全重定义一遍所有的Domain Logic,非常烦琐,而且Service的事务化封装其意义就等于把OO的Domain Logic转换为过程的Service TransactionScript。该充血模型辛辛苦苦在Domain层实现的OO在Service层又变成了过程式,对于Web层程序员的角度来看,和贫血模型没有什么区别了。 (和第二点类似,如何做到Application层不包含业务逻辑,协调领域层和基础层很重要。

  领域模型概念参照:http://www.oschina.net/question/12_21641

  领域驱动设计系列:http://www.cnblogs.com/daxnet/archive/2010/11/02/1867392.html

前期分析

  关于DDD(领域驱动设计)概念有一定了解后,下面开始做一个基于领域驱动设计的项目:MessageManager(短消息系统),至于为什么要拿短消息当小白鼠?是有原因的,当然随便一个业务需求也是可以的,实践是检验理论的唯一标准。

  MessageManager(后面就这样命名)大概类似于博客园-短消息系统,用户模块暂不考虑,只考虑短消息,大致画了一张功能分析图:

  可能当你看到这张图的第一反应是:Are you kidding me???对,你没看错,MessageManager功能就是这么简单,其实领域驱动设计的项目应用应该是一些包含大型业务逻辑的,这种简单的“CURD”操作很难体现出领域驱动设计的作用,但重点不是去实现,而是一个示例框架,可能设计不是很合理,但是一个完整的流程要走下来,当然领域驱动设计包含很多东西,不只是框架设计这一点,很不幸,本篇就只是讨论的这一点。

  MessageManager数据分析图:

  Are you kidding me again???对,你又没看错!!!数据库设计就这么简单,其实不应该说是数据库设计,应该是领域模型设计-数据部分,主要体现在数据库存储,主要是两个表:User(用户表)和Message(消息表),注意我在画图的时候并没有设计字段类型,只是字段名称,类型设计应该在 Infrastructure Layer(基础层)去实现,准确的来说应该是ORM,领域模型只是定义,并不包含实现,有时候我们在做设计的时候,比如ORM使用的是EntityFramework,采用的模式是:Database First,也就是dax.net所说的:

  EntityFramework中的从数据库生成模型”功能应该去掉,但只是相对于领域驱动设计而言,如果项目采用事务脚本,你会发现这个功能是多么的方便,凡事都有相对性。后来EntityFramework推出“Code First”模式,这种模式就符合领域驱动设计思想,MessageManager就是采用这种方式。

  MessageManager的扩展图:

  因为不考虑用户模块,所以用户接入暂不考虑,只扩展一个消息接口,实现方式是:ASP.NET WebAPI,采用WebAPI主要原因是支持REST(无状态),这里需要注意的是此接口虽然是服务,但是属于Presentation Layer(表现层)。关于ASP.NET WebAPI可以参考:http://www.cnblogs.com/xishuai/p/3651370.html

  注:以上前期分析都是按照自己理解去完成,如果严格按照领域驱动设计,应该是建模专家按照严格的流程去做分析的,而不是像我这样随便画几张图。

框架搭建

  MessageManager主要用到概念或技术点:EntityFramework、ASP.NET MVC、ASP.NET WebAPI、AutoMapper、Nunit、Unity、Unit Of Work、Repository、Specification等等。

  解决方案:

  主要分为四层,可以对比上面的领域驱动设计分层图,当然复杂一点不只分为四层,但是这是最基本的,dax.net在 http://www.cnblogs.com/daxnet/archive/2011/05/10/2042095.html,一文中就增加了很多东西,示例图:

                  来自:dax.net

  XXXX.Repositories项目dax.net在设计的时候放在了Domain中,也就是命名:XXXX.Domain.Repositories,但我觉得仓储实现应该在Infrastructure(应用层)中实现,Domain中只是定义仓储契约,也就是Infrastructure(应用层)中的MessageManager.Repositories,实现仓储的具体实现,并提供持久化操作。

  工作流程描述可以用Unit Of Work一文中画过一张图表现:

点击查看大图

代码实现

  MessageManager代码编写主要是四个方面:框架底层、功能实现、单元测试、前端页面。

  框架底层实现可以结合上面那张图和源码去理解,前端页面整理放在MessageManager.WebFiles项目中,页面原始来自博客园-短消息系统,做了一点修改。这边说下单元测试,关于单元测试可以参考:http://www.cnblogs.com/xishuai/p/3728576.html,因为我开发工具使用的是VS 2012,使用的是:NUnit Test Adapter,MessageManager项目中进行单元测试最重要的是Infrastructure(基础层)和Application(应用层),Infrastructure(基础层)主要是对MessageManager.Repositories项目进行单元测试,也就是测试项目:MessageManager.Repositories.Tests,测试主要包含仓储持久化操作,如下:

  功能实现主要是领域模型设计、仓储设计、应用层协调、表现层(MVC、WebAPI)代码编写等,当然还有一些应用程序配置,比如Automapper类型映射、Unity依赖注入配置等。说到领域模型设计,就多说一点,先了解领域模型涉及的概念:实体、值对象、聚合、聚合根。MessageManager项目包含两个实体:User实体和Message(实体),当时设计的时候,我是把User作为实体、Message作为聚合根,也就是下面代码:

复制代码
/**
* author:xishaui
* address:https://www.github.com/yuezhongxin/MessageManager
**/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace MessageManager.Domain.DomainModel
{
    public class Message : IAggregateRoot
    {
        #region 构造方法
        public Message()
        {
            this.ID = Guid.NewGuid().ToString();
        }
        #endregion
        
        #region 实体成员
        public string FromUserID { get; set; }
        public string FromUserName { get; set; }
        public string ToUserID { get; set; }
        public string ToUserName { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }
        public DateTime SendTime { get; set; }
        public bool IsRead { get; set; }
        public virtual User FromUser { get; set; }
        public virtual User ToUser { get; set; }
        #endregion

        #region IEntity成员
        /// <summary>
        /// 获取或设置当前实体对象的全局唯一标识。
        /// </summary>
        public string ID { get; set; }
        #endregion
    }
}
复制代码

  Message继承IAggregateRoot,User和Message组成一个消息聚合,聚合根为Message,访问消息聚合内的成员,必须通过聚合根(Message)才能访问,但是在做的过程中,有一个需求就是要通过用户名获取User,如果通过Message访问就很不合理,因为这不包含任何的消息操作,所以后面就把User单独作为一个聚合,聚合根为其本身,这边说明的就是,聚合边界划分不一定一成不变,需要根据具体的业务场景去划分,就比如:做User模块的时候,Message就不能设计成聚合了,而应该是User。

  还有一点就是EntityFramework使用Code First的时候,因为我们“字段”都是设计在Domain层中(并不包含配置),实现却是在Infrastructure层,如何进行数据库字段类型设计?或是表字段关联?实现主要是使用ModelConfigurations,在生成之前添加Model配置,我觉得这是EntityFramework在领域驱动设计开发中优点之一,设计和实现完全区分开,示例代码:

复制代码
 1 using System.ComponentModel.DataAnnotations;
 2 using System.Data.Entity.ModelConfiguration;
 3 using MessageManager.Domain.DomainModel;
 4 
 5 namespace MessageManager.Repositories.EntityFramework.ModelConfigurations
 6 {
 7     public class MessageConfiguration : EntityTypeConfiguration<Message>
 8     {
 9         /// <summary>
10         /// Initializes a new instance of <c>MessageConfiguration</c> class.
11         /// </summary>
12         public MessageConfiguration()
13         {
14             HasKey(c => c.ID);
15             Property(c => c.ID)
16                 .IsRequired()
17                 .HasMaxLength(36)
18                 .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
19             Property(c => c.FromUserID)
20                 .IsRequired()
21                 .HasMaxLength(36);
22             Property(c => c.ToUserID)
23                 .IsRequired()
24                 .HasMaxLength(36);
25             Property(c => c.Title)
26                 .IsRequired()
27                 .HasMaxLength(50);
28             Property(c => c.Content)
29                 .IsRequired()
30                 .HasMaxLength(2000);
31             Property(c => c.SendTime)
32                 .IsRequired();
33             Property(c => c.IsRead)
34                 .IsRequired();
35             ToTable("Messages");
36 
37             // Relationships
38             this.HasRequired(t => t.FromUser)
39                 .WithMany(t => t.SendMessages)
40                 .HasForeignKey(t => t.FromUserID)
41                 .WillCascadeOnDelete(false);
42             this.HasRequired(t => t.ToUser)
43                 .WithMany(t => t.ReceiveMessages)
44                 .HasForeignKey(t => t.ToUserID)
45                 .WillCascadeOnDelete(false);
46         }
47     }
48 }
复制代码

  上面代码中的下面部分是添加外键配置,EntityFramework中的模型-添加配置:

复制代码
1         protected override void OnModelCreating(DbModelBuilder modelBuilder)
2         {
3             modelBuilder
4                 .Configurations
5                 .Add(new UserConfiguration())
6                 .Add(new MessageConfiguration());
7             base.OnModelCreating(modelBuilder);
8         }
复制代码

  下面再说下MessageManager.Application(应用层)的协调配置,先看下面的一张图,注意后面所做的操作都是领域层或是基础层去实现的,并不是应用层实现,应用层只是做协调处理,不要把应用层当做BLL(业务逻辑层)。

                        点击查看大图

开源-发布

  注:ASP.NET WebAPI 暂只包含:获取发送放消息列表和获取接收方消息列表。

  调用示例:

  WebAPI 客户端调用可以参考 MessageManager.WebAPI.Tests 单元测试项目中的示例调用代码。

  Web 示例页面:

撰写短消息:

发件箱:

查看/回复短消息:

  WebAPI 示例页面:

后记

  关于时间成本:

  • MessageManager项目:两天(包含晚上)+两个晚上;
  • 本篇博客:一个下午+一个晚上(很晚)+外加更正无数;

  关于DDD实践-MessageManager项目,有几个问题需要记录一下:

  • Domain Model(领域模型):领域模型到底该怎么设计?你会看到,MessageManager项目中的User和Message领域模型是非常贫血的,没有包含任何的业务逻辑,现在网上很多关于DDD示例项目多数也存在这种情况,当然项目本身没有业务,只是简单的“CURD”操作,但是如果是一些大型项目的复杂业务逻辑,该怎么去实现?或者说,领域模型完成什么样的业务逻辑?什么才是真正的业务逻辑?这个问题很重要,后续探讨。
  • Application(应用层):应用层作为协调服务层,当遇到复杂性的业务逻辑时,到底如何实现,而不使其变成BLL(业务逻辑层)?认清本质很重要,后续探讨。
  • 。。。

  因为时间比较紧,MessageManager 项目中很多设计或功能实现不是很合理或完善,比如:异常拦截、日志管理等都没有实现,但走出第一步,就有第二步,第三步。。。

  如果你觉得本篇文章对你有所帮助,请点击右下部“推荐”,^_^