• 死去活来,而不变质:Domain Model(领域模型) 和 EntityFramework 如何正确进行对象关系映射?


    写在前面

      阅读目录:

      在上一篇《一缕阳光:DDD(领域驱动设计)应对具体业务场景,如何聚焦 Domain Model(领域模型)?》博文中,探讨的是如何聚焦领域模型(抛开一些干扰因素,才能把精力集中在领域模型的设计上)?需要注意的是,上一篇我讲的并不是如何设计领域模型(本篇也是)?而是如何聚焦领域模型,领域模型的设计是个迭代过程,不能一概而论,还在路上。

      当有一个简单的领域模型用例,完成一个从上而下过程的时候,就需要对领域模型和数据库进行对象关系映射(ORM),首先,在领域驱动设计中,领域模型是活的(具有自己的行为和状态),而映射到数据库中所谓的表是死的(只是一些字段),如何把活的变成死的?又如何把死的变成活的?更重要的是如何保证在这个“死去活来”的过程中,死的和活的是同一个?

      转换过程很简单,使用 ORM(对象关系映射)工具就很方便的完成这个“死去活来”的过程,但是有时候我们在这个转换过程中,可能会失去转换对象的本质,以致活的会变成死的,最后转换过程就只有死的变成死的(反复循环)。

    设计误区

      在 MessageManager 项目的上一个版本中,主要存在两个领域模型:Messgae 和 User,他们数据库之间的映射关系是一对多的关系,就是一个用户拥有多个消息,但是一个消息只能对应一个用户(发件人或收件人),我们看下领域模型的设计(暂不包含业务逻辑)。

      Domain Model-Message:

     1 namespace MessageManager.Domain.DomainModel
     2 {
     3     public class Message : IAggregateRoot
     4     {
     5         #region 构造方法
     6         public Message()
     7         {
     8             this.ID = Guid.NewGuid().ToString();
     9         }
    10         #endregion
    11         
    12         #region 实体成员
    13         public string FromUserID { get; set; }
    14         public string FromUserName { get; set; }
    15         public string ToUserID { get; set; }
    16         public string ToUserName { get; set; }
    17         public string Title { get; set; }
    18         public string Content { get; set; }
    19         public DateTime SendTime { get; set; }
    20         public bool IsRead { get; set; }
    21         public virtual User FromUser { get; set; }
    22         public virtual User ToUser { get; set; }
    23         #endregion
    24 
    25         #region IEntity成员
    26         /// <summary>
    27         /// 获取或设置当前实体对象的全局唯一标识。
    28         /// </summary>
    29         public string ID { get; set; }
    30         #endregion
    31     }
    32 }

      Domain Model-User:

     1 namespace MessageManager.Domain.DomainModel
     2 {
     3     public class User : IAggregateRoot
     4     {
     5         #region 构造方法
     6         public User()
     7         {
     8             this.ID = Guid.NewGuid().ToString();
     9         }
    10         #endregion
    11         
    12         #region 实体成员
    13         public string Name { get; set; }
    14         public virtual ICollection<Message> SendMessages { get; set; }
    15         public virtual ICollection<Message> ReceiveMessages { get; set; }
    16         #endregion
    17 
    18         #region IEntity成员
    19         /// <summary>
    20         /// 获取或设置当前实体对象的全局唯一标识。
    21         /// </summary>
    22         public string ID { get; set; }
    23         #endregion
    24     }
    25 }

      乍一看,Message 和 User 领域模型并没有什么问题,只是设计的太贫血(只是包含一些属性字段),抛开业务逻辑,我们看下 Message 和 User 之间的关联,Message 模型中拥有 FromUserID,FromUserName,ToUserID,ToUserName 字段,用来表示和 User 模型的关联,Navigation Properties(导航属性)为:FromUser 和 ToUser,类型为 User,再看一下 User 模型的导航属性:SendMessages 和 ReceiveMessages,类型为 ICollection<Message>,我们如果按照平常的开发模式(脚本驱动模式),这样设计没有一点问题,很方便对 ORM 进行配置:

     1         /// <summary>
     2         /// Initializes a new instance of <c>MessageConfiguration</c> class.
     3         /// </summary>
     4         public MessageConfiguration()
     5         {
     6             HasKey(c => c.ID);
     7             Property(c => c.ID)
     8                 .IsRequired()
     9                 .HasMaxLength(36)
    10                 .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
    11             Property(c => c.FromUserID)
    12                 .IsRequired()
    13                 .HasMaxLength(36);
    14             Property(c => c.ToUserID)
    15                 .IsRequired()
    16                 .HasMaxLength(36);
    17             Property(c => c.Title)
    18                 .IsRequired()
    19                 .HasMaxLength(50);
    20             Property(c => c.Content)
    21                 .IsRequired()
    22                 .HasMaxLength(2000);
    23             Property(c => c.SendTime)
    24                 .IsRequired();
    25             Property(c => c.IsRead)
    26                 .IsRequired();
    27             ToTable("Messages");
    28 
    29             // Relationships
    30             this.HasRequired(t => t.FromUser)
    31                 .WithMany(t => t.SendMessages)
    32                 .HasForeignKey(t => t.FromUserID)
    33                 .WillCascadeOnDelete(false);
    34             this.HasRequired(t => t.ToUser)
    35                 .WithMany(t => t.ReceiveMessages)
    36                 .HasForeignKey(t => t.ToUserID)
    37                 .WillCascadeOnDelete(false);
    38         }

      上面代码表示 Message 的映射配置,如果外键可以为 NULL,则使用 HasOptional 方法,多对对则使用 HasMany 和 WithMany,WillCascadeOnDelete 用来级联删除配置,EntityTypeConfiguration 的具体详细配置,请参照:http://msdn.microsoft.com/zh-cn/data/jj591620.aspx

      上面的设计到底有没有问题?我们来分析一下,首先 User 领域模型中的 SendMessages 和 ReceiveMessages 属性,如果单独作为导航属性,这是没有什么问题的,因为我们可以使用导航属性很方便的进行映射配置(比如上面代码),但是放在领域模型中就有点不伦不类了,User 是一个用户对象,我们不能在它的身上来挂一些属于它的东西,因为这些并不是用户本身所具有的,这就好像我设计一个用户模型,它拥有手机,电脑,背包,房子,车子等等,然后就必须在这个用户模型中添加这个属性,这样设计就会很不合理,这个应该设计在它所拥有的物品上,因为只有这些物品拥有用户,这些物品相对于用户来说,才有真正的存在意义。

      再来看 Message 领域模型,首先 FromUserID,FromUserName,ToUserID,ToUserName 这四个字段就让我们看得很不顺眼,因为这些都是已死的字段,Message 应该关联的是活的 User,而并不是在它身上打上几个 User 的标签,这个表现应该在数据库中(因为数据库中就是存的这些已死的字段),而并不是在活的 Message 领域模型中,FromUser 和 ToUser 的设计是没有问题的,因为关联的就是活的 User 对象。

      为什么有了 FromUser 和 ToUser 对象,Message 领域模型中还要添加上面那四个字段呢?主要原因还是受思维模式的影响(脚本驱动模式),虽然是基于领域模型设计,但是在设计过程中就会不自觉的往脚本驱动模式上套,为什么?因为我们要使用数据库,不管怎么设计,这些对象都是要存在数据库中的,而数据库存的都是一些已死的对象(只是包含字段),对象死了,那怎么来表示 Message 和 User 对象之间的关联呢?答案就是 FromUserID 和 ToUserID,因为只有通过这两个字段,才能在数据库中体现 Message 和 User 对象之间的关联,数据库存储中确实是这么做的,但是我们把数据库中的关联表现在领域模型中就很不合适了,最后的结果就是 FromUser 和 ToUser 对象的作用只是用来映射配置,Message 领域模型变成和数据库中的 Message 表一样,状态都是已死,转换也就是死的对象转换为死的对象。

      那到底怎么设计?答案就是把 Message 领域模型中的 FromUserID,FromUserName,ToUserID,ToUserName 四个属性去掉,User 领域模型中的 SendMessages 和 ReceiveMessages 属性也去掉,让领域模型变得干净。那有人会问了,你把这些关联字段去掉了,怎么去映射数据库呢?天无绝人之路,使用 EntityFramework(ORM 工具之一)就很方便的进行映射配置,具体配置,可以看下枚举映射和关联映射两个节点。

    数据库已死

      本节点纯属扯淡,兄台们不感兴趣的话,可以直接略过。

      “数据库已死”的这个概念,并不是本人提出的,早在六年前在解道中就有人提出,具体可以参考:

      首先,强调一点,数据库已死的概念,并不是说我们项目中不使用数据库(想想应用程序不使用数据库也不可能),只是说应用程序设计的核心不再是基于数据库设计的,而应该是基于面向对象设计,数据库只是存储数据的一种方式,当然也可以配置文件存储或者内存存储。以往我们进行应用程序设计的时候,都是先根据业务需求定义表结构,然后根据表结构用“面向对象”的语言去传递 SQL 放到数据库中执行,这样面向对象语言就成了所谓的 SQL 搬运工,这样造成的问题就是非常难维护,牵一发而动全身,而且性能瓶颈也主要体现在数据库方面,想想应用程序的性能问题(排除代码问题),我们可以使用负载均衡增加服务器,来分担所带来的压力,而应对数据库性能问题呢?从“MySpace”的经历上就可以看出,那是相当的难处理,而且性能问题主要集中在数据库方面,也是设计的不合理所造成的。

      我们来看一下 MySqace 的信息系统发展历程:

    • 第一代架构—添置更多的Web服务器:因为用户量小,所以我们一般部署应用程序的时候,都是应用程序和数据库各部署一台,当用户暴增之后,我们就开始部署更多的应用程序服务器(数据库服务器还是一台),但是当用户量达到一定的程度后,部署再多的应用程序服务器已没有什么用,因为数据库服务器就一台。
    • 第二代架构—增加数据库服务器:与增加 Web 服务器不同,增加数据库并没那么简单。如果一个站点由多个数据库支持,设计者必须考虑的是,如何在保证数据一致性的前提下让多个数据库分担压力。MySpace 运行在三个 SQL Server 数据库服务器上—一个为主,所有的新数据都向它提交,然后由它复制到其它两个;另两个数据库服务器全力向用户供给数据,用以在博客和个人资料栏显示。这种方式在一段时间内效果很好—只要增加数据库服务器,加大硬盘,就可以应对用户数和访问量的增加。说到低,这种方式就是拆分数据库,不同的应用程序使用数据库不同,然后部署再不同的服务器上,但是当用户再次达到一定程度后,这种方案也不太适合了。
    • 第三代架构—转到布式计算架构:MySpace 将目光移到分布式计算架构——它在物理上分布的众多服务器,整体必须逻辑上等同于单台机器。拿数据库来说,就不能再像过去那样将应用拆分,再以不同数据库分别支持,而必须将整个站点看作一个应用。现在,数据库模型里只有一个用户表,支持博客、个人资料和其他核心功能的数据都存储在相同数据库。既然所有的核心数据逻辑上都组织到一个数据库,那么 MySpace 必须找到新的办法以分担负荷——显然,运行在普通硬件上的单个数据库服务器是无能为力的。这次,不再按站点功能和应用分割数据库,MySpace 开始将它的用户按每百万一组分割,然后将各组的全部数据分别存入独立的SQL Server实例。可以看出这种方式显然也不能满足高用户量的需求。
    • 第四代架构—求助于微软方案:2005年早期,账户达到九百万,MySpace 开始用微软的 C# 编写 ASP.NET 程序。在收到一定成效后,MySpace 开始大规模迁移到 ASP.NET。用户达到一千万时,MySpace 再次遭遇存储瓶颈问题。SAN 的引入解决了早期一些性能问题,但站点目前的要求已经开始周期性超越 SAN 的 I/O 容量——即它从磁盘存储系统读写数据的极限速度。
    • 第五代架构—增加数据缓存层并转到支持 64 位处理器的 SQL Server 2005:MySpace 账户达到一千七百万,MySpace 又启用了新的策略以减轻存储系统压力,即增加数据缓存层——位于 Web 服务器和数据库服务器之间,其唯一职能是在内存中建立被频繁请求数据对象的副本,如此一来,不访问数据库也可以向 Web 应用供给数据。2005年中期,服务账户数达到两千六百万时,MySpace 因为我们对内存的渴求而切换到了还处于 beta 测试的支持 64 位处理器的 SQL Server 2005。升级到 SQL Server 2005 和 64 位 Windows Server 2003 后,MySpace 每台服务器配备了 32G 内存,后于 2006 年再次将配置标准提升到 64G。
    • 。。。。

      总结:从 MySpace 看更加验证,数据库是软件系统的瓶颈,而且最不可伸缩,一旦数据库成为系统瓶颈,就得动大手术,实现架构上的变迁,这是伤筋动骨,变迁人员压力巨大的。另外由于是社区,就是变迁数据丢失也没什么大不了,如果是企业那就......

      如果我们从软件系统开始之初,就使用对象分析设计,不与数据库沾边,整个流程就完全 OO,分析设计直至代码都摆脱了数据库影响,这个流程如下:

    1. 分析建模(基于领域驱动设计的业务建模)
    2. 细化设计(基于领域驱动设计的架构设计)
    3. 代码实现
    4. 调试测试
    5. 部署运行

      那么数据库在什么时候建立呢?数据库表结构的创建可以延缓到部署运行时,这样,整个上游环节就不涉及数据库技术,而是使用更符合自然的表达 OO 方式,软件质量就更高了。现在,很多人已经理解,分析设计要用 OO,但是数据库是运行阶段缺少不了的,确实,这是正确观点,我们夺取数据库的王位,不是将它打倒,只是理性和平移交权力重心而已,数据库退出主角地位,让位于中间件,也预示着过去数据库为王的时代的结束, 但是数据库会和操作系统一样,成为我们现代软件系统一个不可缺少重要的基础环节。

      了解了这么多,回到”设计误区“这一节点,你会发现,造成设计误区的主要原因还是,在设计的时候不自觉以数据库为中心了,而并非领域模型。

    枚举映射

      在 Message 领域模型中,有个 MessageState 枚举类型,用来表示消息的状态,当然我们也可以使用 Bool 类型的字段来表示,但是消息状态是消息本身的一种状态,用对象来表示更为合适,MessageState 枚举定义如下:

    1 namespace MessageManager.Domain.DomainModel
    2 {
    3     public enum MessageState
    4     {
    5         Read,
    6         NoRead
    7     }
    8 }

      我们使用单元测试对映射转换进行测试,也就是 Code First 模式,测试代码:

     1 namespace MessageManager.Repositories.Tests
     2 {
     3     public class UserRepositoryTest
     4     {
     5         [Fact]
     6         public void AddUserRepository()
     7         {
     8             IUserRepository userRepository = new UserRepository(new EntityFrameworkRepositoryContext());
     9             User user1 = new User("小菜");
    10             User user2 = new User("大神");
    11             userRepository.Add(user1);
    12             userRepository.Add(user2);
    13             userRepository.Context.Commit();
    14         }
    15     }
    16 }

      生成数据库发生异常:

      这个主要原因是当前 EntityFramework 版本不支持枚举类型映射,当前使用的 EntityFramework 版本为 4.3.1:

    1 <?xml version="1.0" encoding="utf-8"?>
    2 <packages>
    3   <package id="EntityFramework" version="4.3.1" targetFramework="net40" />
    4 </packages>

      EntityFramework 的版本太老了,更新版本为 6.1.1,NuGet 更新命令:update-package EntityFramework

      EntityFramework 从 4.3.1 升级到 6.1.1 更改的地方(http://msdn.microsoft.com/en-us/data/upgradeef6.aspx):

    1. System.Data.EntityState.Modified; 更改为 System.Data.Entity.EntityState.Modified;
    2. DatabaseGeneratedOption 需要添加 System.ComponentModel.DataAnnotations.Schema 命名空间。

      在升级完 EntityFramework 版本后,重新运行单元测试,但是发现又报如下错误:

      解决方案:

      在 MessageManagerDbContext 构造函数中添加如下代码:

    1         public MessageManagerDbContext()
    2             : base("MessageManagerDB")
    3         {
    4             var ensureDLLIsCopied = System.Data.Entity.SqlServer.SqlProviderServices.Instance; 
    5             this.Configuration.LazyLoadingEnabled = true;
    6         }

      重新运行单元测试,测试成功,就会发现在 MessageManagerDB 数据库的 Messages 表中已生成 State 字段,类型为 Int,当然也可以通过 EntityTypeConfiguration 中的 HasColumnType 进行自定义字段类型。

    关联映射

      先看一下,如果我们没有进行任何的 EntityTypeConfiguration 关联设置,生成数据库会是怎样?MessageConfiguration 和 UserConfiguration 配置如下:

     1     public class MessageConfiguration : EntityTypeConfiguration<Message>
     2     {
     3         /// <summary>
     4         /// Initializes a new instance of <c>MessageConfiguration</c> class.
     5         /// </summary>
     6         public MessageConfiguration()
     7         {
     8             HasKey(c => c.ID);
     9             Property(c => c.ID)
    10                 .IsRequired()
    11                 .HasMaxLength(36)
    12                 .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
    13             Property(c => c.Title)
    14                 .IsRequired()
    15                 .HasMaxLength(50);
    16             Property(c => c.Content)
    17                 .IsRequired()
    18                 .HasMaxLength(2000);
    19             Property(c => c.SendTime)
    20                 .IsRequired();
    21         }
    22     }
     1     /// <summary>
     2     /// Represents the entity type configuration for the <see cref="Customer"/> entity.
     3     /// </summary>
     4     public class UserConfiguration : EntityTypeConfiguration<User>
     5     {
     6         #region Ctor
     7         /// <summary>
     8         /// Initializes a new instance of <c>UserConfiguration</c> class.
     9         /// </summary>
    10         public UserConfiguration()
    11         {
    12             HasKey(c => c.ID);
    13             Property(c => c.ID)
    14                 .IsRequired()
    15                 .HasMaxLength(36)
    16                 .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
    17             Property(c => c.Name)
    18                 .IsRequired()
    19                 .HasMaxLength(20);
    20         }
    21         #endregion
    22     }

      上面代码中我们并没有进行关联配置,生成 MessageManagerDB 数据库中 Messages 表结构:

      可以看到我们虽然没有进行任何的关联设置,Code First 会自动为我们创建外键关联,仅仅是在 Message 领域模型中添加:

    1         public virtual User SendUser { get; set; }
    2         public virtual User ReceiveUser { get; set; }

      以上效果是我们想要的,这也是 EntityFramework 的进步之处,符合领域驱动设计的思想,领域模型中没有数据库中所谓的主外键关联,有的只是对象之间的关联,而数据库只是存储数据的一种表现,这样数据库设计的概念就不存在了,也让我们忘了数据库的存在,而把更多的精力放在领域模型的设计上,这就是领域驱动设计关键所在。

      除了 EntityFramework 默认生成关联配置,我们也可以进行自定义配置,比如,上面生成外键字段为:SendUser_ID 和 ReceiveUser_ID,也可以自定义字段名称:

    1             HasRequired(x => x.SendUser)
    2                 .WithMany()
    3                 .Map(x => x.MapKey("SendUserID"))
    4                 .WillCascadeOnDelete(false);
    5             HasRequired(x => x.ReceiveUser)
    6                 .WithMany()
    7                 .Map(x => x.MapKey("ReceiveUserID"))
    8                 .WillCascadeOnDelete(false);

      上面就是自定义外键字段为:SendUserID 和 ReceiveUserID,关于 EntityTypeConfiguration 的配置,比如一对一,一对多,多对多,联合主外键等等,可以参考:

    后记

      更新项目:MessageManager.Domain,MessageManager.Domain.Tests,MessageManager.Repositories 和 MessageManager.Repositories.Tests,其他未更新,获取生成会报错。

      幻想下,如果存在对象性数据库,存储的都是“活生生”的对象,那是怎样的一种情形?咳咳,只是幻想。

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

      参考资料:

  • 相关阅读:
    104.Maximum Depth of Binary Tree
    103.Binary Tree Zigzag Level Order Traversal
    102.Binary Tree Level Order Traversal
    101.Symmetric Tree
    100.Same Tree
    99.Recover Binary Search Tree
    98.Validate Binary Search Tree
    97.Interleaving String
    static静态初始化块
    serialVersionUID作用
  • 原文地址:https://www.cnblogs.com/xishuai/p/domainmodel_entityframework_database.html
Copyright © 2020-2023  润新知