写在前面
首先,这篇博文是用博客园新发布的 MarkDown编辑器 编写的,这也是我第一次使用,语法也不是很熟悉,但我觉得应该会很爽,博文后面再记录下用过的感受,这边就不多说。
阅读目录:
- 上一篇回顾-设计误区
- 值对象映射探讨
- 走过的坑-正确配置
- 后记-附带(CNBlogs 使用 Mardown 小记)
领域驱动设计中,关于领域模型和 EntityFramework 之间的映射配置,其实之前写过一篇《死去活来,而不变质:Domain Model(领域模型) 和 EntityFramework 如何正确进行对象关系映射?》博文,因为当时主要精力是在领域模型的设计中,持久化问题考虑的太早,所以在当时领域驱动设计的道路上跑偏了。现在领域模型设计的差不多了,因为之前都是在 Repository(仓储)中使用静态集合跑程序,现在持久化的问题是该考虑了。
说真的,其实现在来看,上一篇探讨的内容还是蛮有价值的,如果你对领域模型和 EntityFramework 之间映射配置感兴趣,最好还是阅读下上一篇博文,如果没时间阅读也没关系,我来带你简单回顾一下。
上一篇回顾-设计误区
上一篇博文的关键字是:死去活来,而不变质,也就是:如何把活的变成死的?又如何把死的变成活的?更重要的是如何保证在这个“死去活来”的过程中,死的和活的是同一个?
活的: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
}
}
按照我们之前数据库模式,会觉得这样设计没错啊,但是现在是基于领域驱动设计,你会那发现 FromUserID
、ToUserID
这两个是什么东西啊?只是为了方便数据库映射,就加入这两个“外键”,很显然,这种设计是不合理的。
还有一种设计也是不合理的,就是在实体属性上面加入 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,数据库的映射配置,不影响领域模型(比如上面的FromUserID
、ToUserID
,就是很不合理)。
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 使用感受
- 写代码,写博文,这种方式很爽。
- 以前用其他编辑器写博文,会有很多样式干扰,比如复制编辑器中的内容,会把格式也复制进来,造成 html 的臃肿(看着很多重复的 span 标记,就是不爽)。
- 修改起来很方便,比如修改插入的代码,直接在里面修改就可以了。
- 方便统一博文内容整体的样式。
- 写起来超迅速,流畅,这篇博文内容也不是很少,历时几个小时(平常会多点),写起来的“手感”很好。
- 当然是简约了,但不失简单。
- 。。。。。
CNBlogs 使用 Mardown 使用小技巧
- 如果博文是使用 Mardown 编写的,正文的 div 会添加一个 cnblogs-markdown class 样式,这样方便我们修改用 Mardown 写的博文样式,比如修改字体,就可以添加如下样式:.cnblogs-markdown p { font-size: 15px; }。
- 可以使用 Mardown 在线编辑器,这样可以一边写,一边查看样式,然后再复制到 CNBlogs 中。
- 暂时发现这么多,后面再补充。。。
回到正题,关于 Value Object(值对象)如何使用 EF 进行正确映射?你会发现,其实也就是这一点内容,但都是踩着坑走过来的,需要注意的是,在进行映射配置的时候,要始终记得:映射配置不能影响到领域模型,也就是说,如果映射配置出现了问题,不能从领域模型中去找解决方案,这是技术问题,不能污染到领域模型。
关于领域驱动设计的实践-MessageManager,也开发不少时间了,同时也整理了几篇博文,如果你对领域驱动设计感兴趣,可以访问下 DDD 标签 进行了解,后面有时间再做个详细总结,这篇内容就到这里,也感谢你可以看到这。