DDD领域驱动设计-ValueObject(值对象)如何使⽤EF进⾏正
确映射
⾸先,这篇博⽂是⽤博客园新发布的编写的,这也是我第⼀次使⽤,语法也不是很熟悉,但我觉得应该会很爽,博⽂后⾯再记录下⽤过的感受,这边就不多说。
阅读⽬录:
1. 上⼀篇回顾-设计误区
2. 值对象映射探讨
3. ⾛过的坑-正确配置
4. 后记-附带(CNBlogs 使⽤ Mardown ⼩记)
领域驱动设计中,关于领域模型和 EntityFramework 之间的映射配置,其实之前写过⼀篇《》博⽂,因为当时主要精⼒是在领域模型的设计中,持久化问题考虑的太早,所以在当时领域驱动设计的道路上跑偏了。现在领域模型设计的差不多了,因为之前都是在 Repository(仓储)中使⽤静态集合跑程序,现在持久化的问题是该考虑了。
说真的,其实现在来看,上⼀篇探讨的内容还是蛮有价值的,如果你对领域模型和 EntityFramework 之间映射配置感兴趣,最好还是阅读下上⼀篇博⽂,如果没时间阅读也没关系,我来带你简单回顾⼀下。
上⼀篇回顾-设计误区
上⼀篇博⽂的关键字是:死去活来,⽽不变质,也就是:如何把活的变成死的?⼜如何把死的变成活的?更重要的是如何保证在这个“死去活来”的过程中,死的和活的是同⼀个?
活的:Domain Model(领域模型),主要是领域模型中的 Entity(实体)对象。
死的:使⽤ ORM ⼯具映射,把领域模型映射到关系型数据库的表数据。
在领域驱动设计中,数据库设计的概念是被我们所抛弃的,也就是说,在你领域模型设计的过程中,不应该考虑数据库的因素,这个过程应该放到最后,也就是我现在所考虑的,这也就是为什么之前探讨持久化问题是跑偏的原因了。还有⼀个重要概念,就是数据库不是被设计的,⽽是应该被⽣成的,当你应⽤程序设计完成的时候,你只需要配置下仓储的持久化实现,这样数据库就可以使⽤ Code First 进⾏⽣成了。
过程虽然说起来简单,实现起来却不是那么容易,因为我们长久以往受数据库驱动模式的影响,在应⽤程序开发的时候,就会不⾃觉的去考虑数据库。⽐如⼀个⽤户模块,按照我们传统的开发模式,应该是先设计⽤户模块的表结构(⽤户表、⽤户部门表、⽤户权限表等等),然后根据表结构去设计⼀⼤堆的 SQL 语句(左关联、右关联、⾃⼰关联等等),数据库访问层(DAL)就充斥着⼤量的 SQL 代码,其实这些代码就反应了业务需求,以⾄于我们的业务逻辑层(BLL)变成了⼀个⽅法调⽤者(),它确实很薄,薄到可以直接忽略掉,客户端代码是怎样的呢?简单的来说就是从界⾯上获取值,然后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:www.github/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:www.github/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:www.github/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();
enum怎么用}
}
}
异常信息:
注意红圈⾥⾯的信息,因为我只到这个异常信息(第⼀段):在 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 使⽤感受
1. 写代码,写博⽂,这种⽅式很爽。
2. 以前⽤其他编辑器写博⽂,会有很多样式⼲扰,⽐如复制编辑器中的内容,会把格式也复制进来,造成 html 的臃肿(看着很多重复的 span 标
记,就是不爽)。
3. 修改起来很⽅便,⽐如修改插⼊的代码,直接在⾥⾯修改就可以了。
4. ⽅便统⼀博⽂内容整体的样式。
5. 写起来超迅速,流畅,这篇博⽂内容也不是很少,历时⼏个⼩时(平常会多点),写起来的“⼿感”很好。
6. 当然是简约了,但不失简单。
7. 。。。。。
CNBlogs 使⽤ Mardown 使⽤⼩技巧
1. 如果博⽂是使⽤ Mardown 编写的,正⽂的 div 会添加⼀个 cnblogs-markdown class 样式,这样⽅便我们修改⽤ Mardown 写的博⽂样式,⽐如
修改字体,就可以添加如下样式:blogs-markdown p { font-size: 15px; }。
2. 可以使⽤,这样可以⼀边写,⼀边查看样式,然后再复制到 CNBlogs 中。
3. 暂时发现这么多,后⾯再补充。。。
回到正题,关于 Value Object(值对象)如何使⽤ EF 进⾏正确映射?你会发现,其实也就是这⼀点内容,但都是踩着坑⾛过来的,需要注意的是,在进⾏映射配置的时候,要始终记得:映射配置不能影响到领域模型,也就是说,如果映射配置出现了问题,不能从领域模型中去解决⽅案,这是技术问题,不能污染到领域模型。
关于领域驱动设计的实践-,也开发不少时间了,同时也整理了⼏篇博⽂,如果你对领域驱动设计感兴趣,可以访问下进⾏了解,后⾯有时间再做个详细总结,这篇内容就到这⾥,也感谢你可以看到这。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论