DDD 领域驱动设计-Value Object(值对象)如何使用 EF 进行正确映射

简介:

在领域驱动设计中,数据库设计的概念是被我们所抛弃的,也就是说,在你领域模型设计的过程中,不应该考虑数据库的因素,这个过程应该放到最后,也就是我现在所考虑的,这也就是为什么之前探讨持久化问题是跑偏的原因了。还有一个重要概念,就是数据库不是被设计的,而是应该被生成的,当你应用程序设计完成的时候,你只需要配置下仓储的持久化实现,这样数据库就可以使用 Code First 进行生成了。

过程虽然说起来简单,实现起来却不是那么容易,因为我们长久以往受数据库驱动模式的影响,在应用程序开发的时候,就会不自觉的去考虑数据库。比如一个用户模块,按照我们传统的开发模式,应该是先设计用户模块的表结构(用户表、用户部门表、用户权限表等等),然后根据表结构去设计一大堆的 SQL 语句(左关联、右关联、自己关联等等),数据库访问层(DAL)就充斥着大量的 SQL 代码,其实这些代码就反应了业务需求,以至于我们的业务逻辑层(BLL)变成了一个方法调用者(dal.GetUser....),它确实很薄,薄到可以直接忽略掉,客户端代码是怎样的呢?简单的来说就是从界面上获取值,然后 new 一个 bll 对象,调用方法传入值,没错,就是这样。

那这样致使的结果是怎样的呢?比如要该一个需求,麻烦一点的就是,我们需要改表结构,改完表结构,我们需要改数据访问层的 SQL 代码,改完 SQL 代码,我们需要改业务逻辑层中的方法参数,改完方法参数,我们需要改客户端的调用....没完没了,这还只是一个需求的变更,我相信我们每天遇到的不只是一个吧,想想真是太痛苦了。

好像有点偏离主题了,但是体会这个传统开发模式是很重要的,因为只有体会到它的痛苦,你才会想办法去改变它,当然除非你是处在一个“温水煮青蛙”的环境中,这个就没办法了。

回到领域驱动设计上来,领域模型(主要是实体,后面用实体表示)如何使用 EntityFramework 进行映射配置?简单一点,这个实体没有任何对象的关联,那我们根根不需要什么映射配置,只需要配置一下主键和字段长度就行了。但是如果存在对象关联,我们怎么配置呢?按照之前数据库驱动模式的开发,肯定要在相应的关联表中加入外键,那我们的实体就会变成这样:

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
     }
 }

按照我们之前数据库模式,会觉得这样设计没错啊,但是现在是基于领域驱动设计,你会那发现 FromUserIDToUserID 这两个是什么东西啊?只是为了方便数据库映射,就加入这两个“外键”,很显然,这种设计是不合理的。

还有一种设计也是不合理的,就是在实体属性上面加入 EntityFramework 属性配置,领域模型中应该是和技术无关的,如果加入技术实现,那这个领域模型就被污染了,像 EntityFramework 的 Attribute 配置应该放在基础层去实现,当然我个人觉得,这是 EntityFramework 有点误导人的感觉,因为在实体属性上面进行配置更方便,但是在领域驱动设计中,这样实现并不合理,比如下面这段代码:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace DemoTag.Domain.Entities
{
    [Table("TagUseCount")]
    public class TagUseCount
    {
        [Key]
        [Column(Order = 1)]
        public Guid AppGuid { get; set; }

        [Key]
        [Column(Order = 2)]
        [ForeignKey("Tag")]
        public int TagId { get; set; }

        public int UseCount { get; set; }

        public virtual Tag Tag { get; set; }
    }
}

如果我们不这样进行实现,那我们如何进行映射配置呢?这个实现在后面有讲解,在实现之前,要先明确几个重要概念:

1,领域模型不参杂任何的技术实现。
2,数据库的映射配置,不影响领域模型(比如上面的 FromUserIDToUserID,就是很不合理)。
3,数据库的映射配置,属于技术实现,应该放在基础层中。

因为第二点相对比较难理解一点,这边我就再简单说明下,数据库是领域模型存储数据的一种方式(我们也可以使用其他方式进行存储),现在的关系型数据库都是“扁平化”存储,所以像对象之中关联对象,我们一般都是要进行外键配置,这因为有了 ORM 工具,所以我们可以很方便的进行对象关系映射(ORM 的中文意思),对象指的就是领域模型,关系就是关系型数据库。所以我们映射配置不应该影响领域模型,具体怎么进行配置?这是 ORM 工具所考虑的问题,上一篇的内容是主要是关于实体映射配置,下面简单说下领域模型中值对象的映射配置。

值对象映射探讨

有人可能有些疑问,值对象需要映射配置吗?当然,简单一点的枚举类型的值对象,是不需要进行映射配置的,比如下面 MessageState 这个值对象:

/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/

namespace MessageManager.Domain.ValueObject
{
    public enum MessageState
    {
        Unread,
        Read,
    }
}

在 Message 实体中对应的关联:

public MessageState State { get; private set; }

上面这段代码,如果我们使用 EntityFramework,是不需要任何映射配置的,枚举类型的值对象会自动映射为 int 类型,比如上面 MessageState 的映射结果为:0 代表 Unread,1 代表 Read。这个映射过程,在领域驱动设计中是不关心的,在应用层,我只关心从仓储中持久化的对象或者获取的对象,是不是正确的实体对象?是不是正确的值对象?也就是说我现在在应用层中去编写下面这段代码:

using (IRepositoryContext repositoryContext = new EntityFrameworkRepositoryContext())
{
    IMessageRepository messageRepository = new MessageRepository(repositoryContext);
    Message message = messageRepository.GetByKey(1);
    if (message.State == MessageState.Unread)
    {
        //默认是未读
    }
}

message.State == MessageState.Unread 这是我所关心的,我从仓储中取的是不是我所存储的正确值对象。其实这也是 EntityFramework 这一类 ORM 工具的强大之处,在领域驱动设计中更能得到体现,它让我们更专注于领域模型的设计,而不考虑数据是怎样进行存储的,那如何进行隔离他们两者呢?答案就是 Repository(仓储),很多时候,都是由问题引出概念,这样理解的才会更加深刻。

如果我们映射的不是枚举类型的值对象,而是其他类型的值对象,我们怎么进行映射配置呢?比如下面 Contact 值对象:

/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/

namespace MessageManager.Domain.ValueObject
{
    public class Contact
    {
        public Contact(string name)
        {
            this.Name = name;
        }

        public Contact(string name, string displayName)
        {
            this.Name = name;
            this.DisplayName = displayName;
        }

        public string Name { get; private set; }
        public string DisplayName { get; private set; }
    }
}

先说一下 Contact 值对象的意思,表示 Message 实体中的抽象“联系人”标识,说白了就是发送人和接收人的意思,但这个发送人或接收人不一定是“人”,也可能是邮箱等,就是一个标识的意思,这个“标识”从是外部取得的,也就是说在消息这个系统中是不存储的,我只知道这个标识是什么?那不需要知道它是哪个?这也就是为什么设计成值对象的原因了。

Contact 值对象就不像 MessageState 值对象不需要那样了,这个就必须在 EntityFramework 进行配置的,具体如何进行映射配置,请看下面,走过的坑

走过的坑-正确配置

首先,我试了下,如果不进行映射配置会是怎样的结果,比如我们在 MessageConfiguration 映射配置类中(实现在基础层)配置如下:

using MessageManager.Domain.Entity;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Entity.ModelConfiguration;

namespace MessageManager.Repositories.EntityFramework.ModelConfigurations
{
    public class MessageConfiguration : EntityTypeConfiguration<Message>
    {
        /// <summary>
        /// Initializes a new instance of <c>MessageConfiguration</c> class.
        /// </summary>
        public MessageConfiguration()
        {
            HasKey(c => c.ID);
            Property(c => c.ID)
                .IsRequired()
                .HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
            Property(c => c.Title)
                .IsRequired()
                .HasMaxLength(50);
            Property(c => c.Content)
                .IsRequired()
                .HasMaxLength(2000);
            Property(c => c.SendTime)
                .IsRequired();
        }
    }
}

可以看到,我们只对一些简单属性进行了简单配置,并没有对 Contact 进行任何的映射配置,那 EntityFramework 生成数据库会是怎样呢(使用 Code First 模式)?答案就是:报错

RepositoryTest_AddMessage 单元测试代码(一定要先进行单元测试,在领域驱动设计开发过程中,非常重要):

/**
* author:xishuai
* address:https://www.github.com/yuezhongxin/MessageManager
**/

using MessageManager.Domain.Entity;
using MessageManager.Domain.Repositories;
using MessageManager.Domain.ValueObject;
using MessageManager.Repositories.EntityFramework;
using Xunit;

namespace MessageManager.Repositories.Tests
{
    public class MessageRepositoryTest
    {
        [Fact]
        public void RepositoryTest_AddMessage()
        {
            IMessageRepository messsageRepository = new MessageRepository(new EntityFrameworkRepositoryContext());
            messsageRepository.Add(new Message("title", "content", new Sender("1", "小菜"), new Recipient("2", "大神")));
            messsageRepository.Context.Commit();
        }
    }
}

异常信息:

注意红圈里面的信息,因为我只找到这个异常信息(第一段):在 System.Data.Entity.Utilities.Check.NotNull T (T value, String parameterName),完全不知道是什么原因,NotNull 也就是有一个参数为 NULL,具体是什么,并不知道,怎么办呢?难道让我去调试 EntityFramework 源码?把 Google 给忘了,搜索了一下,在 stackoverflow 中找到了类似问题,解决方案就是:

[NotMapped]
public HttpPostedFileBase Photo { get; set; }

NotMapped 顾名思义,就是忽略映射的意思,也就是说在 EntityFramework 生成数据库的时候,Photo 这个属性并不映射。NotMapped 是直接在实体中定义属性配置,这个我们在上面强调过,这样设计不是合理的,我们应该在 MessageConfiguration 中进行配置,那就不能使用 NotMapped 属性了,在 EntityTypeConfiguration 配置中,找到 Ignore 方法,配置如下:

Ignore(c => c.Sender);
Ignore(c => c.Recipient);

配置好了,我们再生成数据库:

可以看到我们是生成成功的,Message 实体对象的 Sender 和 Recipient 是被忽略的,但是这并不是我们想要的结果,因为我们是要映射配置 Contact,这才是我们的目的,怎么把它给忽略了啊。虽然走了弯路,但是让我们发现异常问题,确实是 Contact 映射引起的(我之前还怀疑是不是 EntityFramework 配置有什么问题)。

确定了问题的原因,就要找相应的解决办法。因为值对象强调的是“值”的概念,也就是说映射到数据库的时候,要把值对象进行“扁平化”处理,Contact 值对象包含 Name 和 DisplayName 两个属性(之前还有一个 LoginName 属性,后来考虑了一下,其实并不需要),也就是说,这两个属性都必须映射到 Message 实体中,然后 EntityFramework 进行数据到对象的转化,我们就可以通过 message.Sender 访问到 Contact 值对象了,这是我们想要的效果,在仓储中只需要 Add 和Get`Message 对象,并不需要 Contact 值对象的任何操作,因为 Contact 值对象是依附于 Message 实体的,所以必须通过 Message 实体进行操作。

Google 中搜索“entitytypeconfiguration value object”,在 stackoverflow 中找到相似的解决方法,配置如下:

Property(c => c.Sender.Name)
     .HasColumnName("SenderName")
     .IsRequired()
     .HasMaxLength(36);
Property(c => c.Recipient.Name)
     .HasColumnName("RecipientName")
     .IsRequired()
     .HasMaxLength(36);
Property(c => c.Sender.DisplayName)
     .HasColumnName("SenderDisplayName")
     .HasMaxLength(50);
Property(c => c.Recipient.DisplayName)
     .HasColumnName("RecipientDisplayName")
     .HasMaxLength(50);

生成相应数据库:

单元测试:

其实在 entitytypeconfiguration 的配置中,不止上面的一些坑,还有很多没有记录到,关于 entitytypeconfiguration 的正确配置,请参考 MSDN 中的相关内容

后记-附带(CNBlogs 使用 Mardown 小记)

CNBlogs 使用 Mardown 使用感受

  1. 写代码,写博文,这种方式很爽。
  2. 以前用其他编辑器写博文,会有很多样式干扰,比如复制编辑器中的内容,会把格式也复制进来,造成 html 的臃肿(看着很多重复的 span 标记,就是不爽)。
  3. 修改起来很方便,比如修改插入的代码,直接在里面修改就可以了。
  4. 方便统一博文内容整体的样式。
  5. 写起来超迅速,流畅,这篇博文内容也不是很少,历时几个小时(平常会多点),写起来的“手感”很好。
  6. 当然是简约了,但不失简单。
  7. 。。。。。

CNBlogs 使用 Mardown 使用小技巧

  1. 如果博文是使用 Mardown 编写的,正文的 div 会添加一个 cnblogs-markdown class 样式,这样方便我们修改用 Mardown 写的博文样式,比如修改字体,就可以添加如下样式:.cnblogs-markdown p { font-size: 15px; }。
  2. 可以使用 Mardown 在线编辑器,这样可以一边写,一边查看样式,然后再复制到 CNBlogs 中。
  3. 暂时发现这么多,后面再补充。。。

回到正题,关于 Value Object(值对象)如何使用 EF 进行正确映射?你会发现,其实也就是这一点内容,但都是踩着坑走过来的,需要注意的是,在进行映射配置的时候,要始终记得:映射配置不能影响到领域模型,也就是说,如果映射配置出现了问题,不能从领域模型中去找解决方案,这是技术问题,不能污染到领域模型。

关于领域驱动设计的实践-MessageManager,也开发不少时间了,同时也整理了几篇博文,如果你对领域驱动设计感兴趣,可以访问下 DDD 标签 进行了解,后面有时间再做个详细总结,这篇内容就到这里,也感谢你可以看到这。



本文转自田园里的蟋蟀博客园博客,原文链接:http://www.cnblogs.com/xishuai/p/ddd_valueobject_entityframework.html,如需转载请自行联系原作者

相关文章
|
3月前
ES6中map对象的使用,确实比Object好使哈
ES6中Map对象的使用优势,包括任意类型作为键、直接获取大小、增删查改操作等。Map的键可以是函数、对象、NaN等,支持forEach循环和for...of循环。
38 1
ES6中map对象的使用,确实比Object好使哈
|
2月前
|
Python
通过 type 和 object 之间的关联,进一步分析类型对象
通过 type 和 object 之间的关联,进一步分析类型对象
68 3
|
2月前
|
JavaScript 前端开发 大数据
在JavaScript中,Object.assign()方法或展开语法(...)来合并对象,Object.freeze()方法来冻结对象,防止对象被修改
在JavaScript中,Object.assign()方法或展开语法(...)来合并对象,Object.freeze()方法来冻结对象,防止对象被修改
40 0
|
4月前
|
数据安全/隐私保护
作用域通信对象:session用户在登录时通过`void setAttribute(String name,Object value)`方法设置用户名和密码。点击登录按钮后,跳转到另外一个页面显示用户
该博客文章通过示例演示了如何使用session对象的`setAttribute`和`getAttribute`方法在不同页面间传递和显示用户的用户名和密码信息,并说明了如何设置会话的有效期。
作用域通信对象:session用户在登录时通过`void setAttribute(String name,Object value)`方法设置用户名和密码。点击登录按钮后,跳转到另外一个页面显示用户
|
4月前
|
SQL 存储 数据库
对象关系映射(Object-Relational Mapping)
【8月更文挑战第17天】
101 2
|
4月前
【Azure Developer】使用PowerShell Where-Object方法过滤多维ArrayList时候,遇见的诡异问题 -- 当查找结果只有一个对象时,返回结果修改了对象结构,把多维变为一维
【Azure Developer】使用PowerShell Where-Object方法过滤多维ArrayList时候,遇见的诡异问题 -- 当查找结果只有一个对象时,返回结果修改了对象结构,把多维变为一维
网易:所有的对象最终都会继承自 Object.prototype ? ——原型链(二)详细讲解!
网易:所有的对象最终都会继承自 Object.prototype ? ——原型链(二)详细讲解!
|
4月前
|
JavaScript
网易:所有的对象最终都会继承自 Object.prototype ? ——原型链(一)详细讲解!
网易:所有的对象最终都会继承自 Object.prototype ? ——原型链(一)详细讲解!
|
6月前
|
Java 编译器 数据处理
JavaSE——面相对象高级一(4/4)-继承相关的注意事项:权限修饰符、单继承、Object类、方法重写、子类访问成员的特点......
JavaSE——面相对象高级一(4/4)-继承相关的注意事项:权限修饰符、单继承、Object类、方法重写、子类访问成员的特点......
63 0
|
7月前
|
算法 Java 测试技术
简介Object类+接口实例(深浅拷贝、对象数组排序)
简介Object类+接口实例(深浅拷贝、对象数组排序)