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

写在前面

首先,这篇博文是用博客园新发布的 MarkDown编辑器 编写的,这也是我第一次使用,语法也不是很熟悉,但我觉得应该会很爽,博文后面再记录下用过的感受,这边就不多说。

阅读目录:

  1. 上一篇回顾-设计误区
  2. 值对象映射探讨
  3. 走过的坑-正确配置
  4. 后记-附带(CNBlogs 使用 Mardown 小记)

领域驱动设计中,关于领域模型和 EntityFramework 之间的映射配置,其实之前写过一篇《死去活来,而不变质:Domain Model(领域模型) 和 EntityFramework 如何正确进行对象关系映射?》博文,因为当时主要精力是在领域模型的设计中,持久化问题考虑的太早,所以在当时领域驱动设计的道路上跑偏了。现在领域模型设计的差不多了,因为之前都是在 Repository(仓储)中使用静态集合跑程序,现在持久化的问题是该考虑了。

说真的,其实现在来看,上一篇探讨的内容还是蛮有价值的,如果你对领域模型和 EntityFramework 之间映射配置感兴趣,最好还是阅读下上一篇博文,如果没时间阅读也没关系,我来带你简单回顾一下。

上一篇回顾-设计误区

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

上一篇博文的关键字是:死去活来,而不变质,也就是:如何把活的变成死的?又如何把死的变成活的?更重要的是如何保证在这个“死去活来”的过程中,死的和活的是同一个?

活的:Domain Model(领域模型),主要是领域模型中的 Entity(实体)对象。

死的:使用 ORM 工具映射,把领域模型映射到关系型数据库的表数据。

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

异常信息:

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

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

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

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

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

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

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

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

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

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

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);

生成相应数据库:

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

单元测试:

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

其实在 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 标签 进行了解,后面有时间再做个详细总结,这篇内容就到这里,也感谢你可以看到这。

上一篇:CentOS 7 配置 nginx php-fpm 详细教程


下一篇:[转]Spring注解原理的详细剖析与实现