Code First开发系列之领域建模和管理实体关系

返回《8天掌握EF的Code First开发》总目录


本篇目录


本篇的源码下载:点击下载
先附上codeplex上EF的源码:entityframework.codeplex.com,此外,本人的实验环境是VS 2013 Update 5,windows 10,MSSQL Server 2008。

上一篇《第一个Code First应用》简单介绍了如何使用EF的Code First方式创建一个项目,也介绍了如何进行简单的CRUD以及数据库模式的改变。这一篇,我们会深入学习领域建模需要注意的地方以及实体之间关系的管理。

理解Code First及其约定和配置

深入理解Code First

传统设计应用的方式都是由下而上的,即我们习惯优先考虑数据库,然后使用这个以数据为中心的方法来在数据之上构建应用程序。这种方法非常适合于数据密集的应用或者数据库很可能包含多个应用使用的业务逻辑的应用。对于这种应用,如果要使用EF的话,我们必须使用Database First方式。

设计应用的另一种方法就是以领域为中心的方式(领域驱动设计DDD)。DDD是一种由上而下的方式,我们通过从实现应用所需要的领域模型和实体的角度思考,从而开始设计应用。数据库很少用来用于领域模型数据的持久化。使用DDD意味着我们要根据每个应用的需求来设计模型和实体,而且模型和实体是数据库可忽略的,即可使用任何数据库技术实现保存。在这些情景中,我们应该使用EF的Code First方式,因为它允许我们创建POCOs(Plain Old CLR Objects)作为持久化可忽略的领域模型。

使用EF Code First的优势在于:

  • 支持DDD
  • 可以早早地着手开发,因为我们不必等待数据库的创建
  • 持久化层(底层的数据库)的改变不会对现有的模型有任何影响

MSDN上一篇介绍DDD的文章

理解Code First的约定和配置

我们需要搞清楚的第一件事就是约定大于配置的概念。Code First方式期望模型类遵守一些约定,这样的话数据库持久化逻辑就可以从模型中提取出来。比如,如果我们给一个模型定义了一个Id属性,那么它就会映射到数据库中该类所对应的那张表的主键。这种基于约定的方式的好处在于,如果我们遵守了这些约定,那么我们就不必写额外的代码来管理数据库持久逻辑。缺点在于,如果没有遵守某个约定,那么EF就不会从模型中提取到需要的信息,运行时会抛异常。

在这种没有遵守约定又要持久化数据的情况下,我们需要使用Code First的配置项提供关于模型一些额外的信息。比如,如果我们的模型类中没有Id属性作为主键,那么我们需要在想要的属性上加上[Key]特性,这样它就会被当作主键了。

EF使用模型类的复数的约定来创建数据表名,创建的列名和该类的属性名是一样的。

创建数据表结构

.Net类型和SQL类型之间的映射

首先,我们第一篇就说了,EF这个ORM工具就是用来解决.NET 类型和SQL Server列类型之间的阻抗失配的问题。比如,假设你在.net中定义了一个int类型的属性,那么你就可以认为EF已经安全地处理这个列的定义并使用了合适的类型与之对应。记住一些.Net类型和SQL Server列类型之间的映射是很有必要的,下面是一些最常用的映射关系:

图片

完整的映射列表可以参考MSDN SQL Server数据类型映射,如果你使用的其他类型的数据库,你可以在网上自行查找,比如,如果是Oracle,那么你可以在这里查看:Oracle数据类型映射

配置原始属性

就以.Net中的string类型的属性开始讨论吧。SQL Server中的很多类型都会映射到.Net中的string类型,其他主流的RDBMS也是一样的。因此,决定如何存储字符串类型的信息是很重要的,很多关系数据库管理引擎都有多个字符存储类型,他们通常都有以字母N打头的字符类型,这个字母表示要存在这些列中的数据是Unicode数据,基于每个字符以2个字节的格式存储。因此,如果你的数据库中的列存储的是英文的话,就可以使用varchar或者char(可能会加速查询),如果使用的是中文的话,就要使用nvarchar或者nchar。此外,还可以使用带有var的字符类型来指定列的长度是可变的,不使用var的话,字符长度是不可变的。

在EF中有以下几种配置数据库结构的方式,分别是:

  • 特性,也叫数据注解
  • DbModelBuilder API
  • 配置伙伴类

特性【数据注解】

这些特性类都是.Net的一部分,位于System.ComponentModel.DataAnnotaions命名空间。下面我们修改之前的代码:

[Table("Donator")]
public class Donator
{
    [Key]
    [Column("Id")]
    public int DonatorId { get; set; }
    [StringLength(10,MinimumLength = 2)]
    public string Name { get; set; }
    public decimal Amount { get; set; }
    public DateTime DonateDate { get; set; }
}



[Table("PayWay")]
public class PayWay
{
    public int Id { get; set; }
    [MaxLength(8,ErrorMessage = "支付方式的名称长度不能大于8")]
    public string Name { get; set; }
}

修改代码之后,我们将表的名字使用Table特性全部重新命名成了单数,将Donator的主键通过Colum特性更改为了Id,Key特性指定它是主键,还通过StringLength指定了Donator的名字最长为10个字符,最少为2个字符,下面对比一下默认约定生成的数据库和手动修改之后产生的数据库:

图片

图片
第一张图片是默认约定生成的数据库,第二张是修改代码后生成的数据库。

下面是常用的用于重写默认的约定的特性,使用这些特性可以更改数据库模式:

  • Table:指定该类要映射到数据库中的表名
  • Column:指定类的属性要映射到数据表中的列名
  • Key:指定该属性是否以主键对待
  • TimeStamp:将该属性标记为数据库中的时间戳列
  • ForeignKey:指定一个导航属性的外键属性
  • NotMapped:指定该属性不应该映射到数据库中的任何列
  • DatabaseGenerated:指定属性应该映射到数据表中计算的列。也可以用于映射到自动增长的数据库表。

此外,数据注解也用作验证特性。如果持久化数据时,模型对象的属性值和数据注解所标记的不一致,就会抛异常。例如上面的PayWay类的Name属性,ErrorMessage的值就是发生异常时抛出的信息。

fluent API

DbContext类有一个OnModelCreating方法,它用于流利地配置领域类到数据库模式的映射。下面我们以fluent API的方式来定义映射。

首先,先将Donator类注释掉,重新编写该类:


public class Donator
{
    public int DonatorId { get; set; }
    public string Name { get; set; }
    public decimal Amount { get; set; }
    public DateTime DonateDate { get; set; }
}

然后在数据库上下文中的OnModelCreating方法中使用Fluent API来定义Donator表的数据库模式:

namespace FirstCodeFirstApp
{
    public class Context:DbContext
    {
        public Context()
            : base("name=FirstCodeFirstApp")
        {
        }

        public DbSet<Donator> Donators { get; set; }
        public DbSet<PayWay> PayWays { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Donator>().ToTable("Donators").HasKey(m => m.DonatorId);//映射到表Donators,DonatorId当作主键对待
            modelBuilder.Entity<Donator>().Property(m => m.DonatorId).HasColumnName("Id");//映射到数据表中的主键名为Id而不是DonatorId
            modelBuilder.Entity<Donator>().Property(m => m.Name)
                .IsRequired()//设置Name是必须的,即不为null,默认是可为null的
                .IsUnicode()//设置Name列为Unicode字符,实际上默认就是unicode,所以该方法可不写
                .HasMaxLength(10);//最大长度为10

            base.OnModelCreating(modelBuilder);
        }
    }
}


modelBuilder.Entity<Donator>()会得到EntityTypeConfiguration类的一个实例。此外,使用fluent API的一个重要决定因素是我们是否使用了外部的POCO类,即实体模型类是否来自一个类库。我们无法修改类库中类的定义,所以不能通过数据注解来提供映射细节。这种情况,我们必须使用fluent API。

生成后的数据库表如下(刚才两张表名都是单数,现在又使用fluent API将Donator改为了复数):

图片

每个实体类配置一个伙伴类

不知道你有没有注意到一个问题?上面的OnModelCreating方法中,我们只配置了一个类Donator,也许代码不是很多,但也不算很少,如果我们有1000个类怎么办?都写在这一个方法中肯定不好维护!EF提供了另一种方式来解决这个问题,那就是为每个实体类单独创建一个配置类。然后再在OnModelCreating方法中调用这些配置伙伴类。

先创建Donator的配置伙伴类:

public class DonatorMap:EntityTypeConfiguration<Donator>
{
    public DonatorMap()
    {
        ToTable("DonatorFromConfig");//为了区分之前的结果
        Property(m => m.Name)
            .IsRequired()//将Name设置为必须的
            .HasColumnName("DonatorName");//为了区别之前的结果,将Name映射到数据表的DonatorName
    }
}


接下来直接在数据库上下文中调用就可以了:

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Configurations.Add(new DonatorMap());
            base.OnModelCreating(modelBuilder);
        }


查看数据库,可以看到符合我们的更改:

图片

这种写法和使用model builder是几乎一样的,只不过这种方法更好组织处理多个实体。你可以看到上面的语法和写jQuery的链式编程一样,这种方法的链式写法就叫Fluent API。

处理可空(nullable)属性

有些列是可空的,有些不可空。EF会通过约定来决定一列是否是nullable。比如,string类型允许null值,因此匹配的基于字符的列就是nullable。另一方面,datetime和int变量在.Net中是不能为null的,所以这些列是non-nullable。如果我们想让这些列是nullable或者想使得字符串存储列强制有值,该怎么办?

一是直接使用可空类型对实体类的属性进行定义,这是目前最简单的方法。例如,如果上面的打赏日期允许空值的话,那么应该这样定义:public DateTime? DonateDate { get; set; }

另一方面,如果Donator的名字不可为空,那么我们可以像上面的配置类中那样写,使用IsRequired()方法。相对应地,IsOptional()方法就是允许为空值。

需要格外注意的是,如果你使用的是其他的数据库,.Net中的某些类型可能不能正确地映射到这些数据库系统。解决方案就是在属性配置类中使用HasColumnType方法,然后指定你想要显式使用的名字。如果你想支持多个数据库引擎,那么只要写一个helper类就可以解决了,该helper类会基于当前配置的数据库引擎以字符串的形式返回正确的数据库类型。所有的原始属性配置类共享两个方法,HasColumnNameHasColumnOrderHasColumnName允许我们可以创建不同于属性名称的列名,如果你想定义成一样的,那么就不需要该方法了。HasColumnOrder可以让我们精确地控制列在表中的排列位置。

管理实体关系

我们现在已经知道如何使用Code First来定义简单的领域类,并且如何使用DbContext类来执行数据库操作。现在我们来看下数据库理论中的多样性关系,我们会使用Code First实现下面的几种关系:

  • 一对多关系
  • 一对一关系
  • 多对多关系

首先要明确关系的概念。关系就是定义两个或多个对象之间是如何关联的。它是由关系两端的多样性值识别的,比如,一对多意味着在关系的一端,只有一个实体,我们有时称为父母;在关系的另一端,可能有多个实体,有时称为孩子。EF API将那些端分别称为主体和依赖。一对多关系也叫做一或零对多(One-or-Zero-to-Many),这意味着一个孩子可能有或可能没有父母。一对一关系也稍微有些变化,就是关系的两端都是可选的。

一对多关系

要在数据库中配置一对多关系,我们可以依赖EF约定,或者可以使用数据注解/fluent API来显式创建关系。接下来还是使用捐赠者Donator和支付方法PayWay这两个类来举例子,这里的一对多关系是:一个人可以通过多种支付方式赞助我

图片

支付方式PayWay类采用数据注解的方式定义如下:

 [Table("PayWay")]
 public class PayWay
 {
     public int Id { get; set; }
     [MaxLength(8,ErrorMessage = "支付方式的名称长度不能大于8")]
     public string Name { get; set; }
 }


因为一个赞助者可以通过多种支付方式赞助我,这句话就表明了Donator对象应该有一个PayWay的集合,因此,我们要给Donator类新加入一个属性,Donator类采用配置伙伴类的方式定义如下:

public class Donator
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Amount { get; set; }
    public DateTime DonateDate { get; set; }
    public virtual ICollection <PayWay> PayWays { get; set; }
}


public class DonatorMap:EntityTypeConfiguration<Donator>
{
    public DonatorMap()
    {
        ToTable("Donators");
        Property(m => m.Name)
            .IsRequired(); //将Name设置为必须的
    }
}


为了避免潜在的null引用异常可能性,当Donator对象创建时,我们使用HashSet的T集合类型实例创建一个新的集合实例,如下所示:

public Donator()
{
    PayWays=new HashSet<PayWay>();
}


你会注意到当我定义PayWays属性时使用了virtual关键字,当为一个赞助者查询他的支付方式时,该关键字允许我们使用懒加载(lazy loading),也就是说当你尝试访问Donator的PayWays属性时,EF会动态地从数据库加载PayWays对象到该集合中。懒加载,顾名思义,就是首次不会执行查询来填充PayWays属性,而是在请求它时才会加载数据。还有另一加载相关数据的方式叫做预先加载(eager loading)。通过预先加载,在访问PayWays属性之前,PayWays就会主动加载。现在我们假设要充分使用懒加载功能,所以这里使用了virtual关键字。这里有意思的是,在支付方法PayWay类中并没有包含Donator的Id属性,这是作为数据库开发者必须要做的一些事,但在EF的世界中,我们有很大的灵活性来忽略这个属性,由于当我们看支付方式的时候可能没有合理的业务原因来知道该赞助者的Id,所以我们可以忽略该属性。这个例子中,我们只想在Donator的上下文中了解他的支付方式,并不把它们分离开作为独立对象。现在我们假设这能正常运行,然后添加一个网名叫做“键盘里的鼠标”的赞助者,因为他支付宝和微信都打赏过了。代码如下:

#region 6.0 一对多关系

var donator = new Donator
{
    Amount = 6,
    Name = "键盘里的鼠标",
    DonateDate =DateTime.Parse("2016-4-13"),
};
donator.PayWays.Add(new PayWay{Name = "支付宝"});
donator.PayWays.Add(new PayWay{Name = "微信"});
context.Donators.Add(donator);
context.SaveChanges();
#endregion


上面的代码中,我们添加了一个赞助者,然后给该对象的PayWays属性追加两个元素,最后批量保存它们。注释掉初始化器中的种子数据,然后运行应用,生成的结果如下:

图片

我们只编写了OOP代码:创建了一个类的实例,然后将PayWay类的实例添加到一个集合。EF的很多默认约定可以帮我们创建正确的数据库结构,包括将对象操作转成数据库查询。从上面的截图来看,在PayWays表中,EF使用默认约定帮我们自动创建了一个Donator_Id的列作为外键,当然,你完全可以通过代码手动修改这个外键的名字。比如要将Donator_Id修改为DonatorId,只需要在PayWays类中添加一个属性DonatorId(该属性名称是我们想要的列名)。然后,我们需要配置Donator对象,告诉它有多个支付方式,每一个支付方式都会通过DonatorId属性链接到一个Donator。给Donator配置伙伴类DonatorMap追加代码如下:

HasMany(d => d.PayWays)
    .WithRequired()
    .HasForeignKey(p => p.DonatorId);


上面的代码对于关系的定义很经典。HasMany方法告诉EF在DonatorPayway类之间有一个一对多的关系。WithRequired方法表明链接在PayWays属性上的Donator是必须的,换言之,Payway对象不是独立的对象,必须要链接到一个Donator。HasForeignKey方法会识别哪一个属性会作为链接。
更改默认的外键列名结果:

图片

另一个用例

接下来在看一个例子,这个用例出现在当主要实体上有一个查询属性,且该属性指向另一个实体时。查询属性指向一个完整的子实体的父亲,当操作或检查一个子记录需要访问父信息时,这些属性很有用。比如,我们再创建一个类DonatorType(该类用来表示赞助者的类型,比如有博客园园友和非博客园园友),然后给Donator类添加DonatorType属性。本质上,这个例子还是一个一对多的关系,但是方法有些不同。这种情况下,我们一般会在主实体的编辑页面使用一个包含查询父表值的下拉控件。我们的查询父表很简单,只有Id和Name列,我们将使该关系为可选的,以描述如何添加可空的外键。因此,Donator类中的DonatorTypeId必须是可空的。Donator类的定义如下:

public class Donator
{
    public Donator()
    {
        PayWays=new HashSet<PayWay>();
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Amount { get; set; }
    public DateTime DonateDate { get; set; }
    public virtual ICollection<PayWay> PayWays { get; set; }

    public int? DonatorTypeId { get; set; }
    public virtual DonatorType DonatorType { get; set; }
}


反过来,我们要在DonatorType类中添加一个集合属性,表示每种赞助者类型有很多赞助者,代码如下:

public class DonatorType
{
    public int Id { set; get; }
    public string Name { set; get; }

    public virtual ICollection<Donator> Donators { get; set; }
}


提到关系,我们可以在关系的任何一端(主体端或依赖端)进行配置。下面,我们创建一个新的DonatorTypeMap伙伴类以达到目的,代码如下:

public class DonatorTypeMap:EntityTypeConfiguration<DonatorType>
{
    public DonatorTypeMap()
    {
        HasMany(dt=>dt.Donators)
            .WithOptional(d=>d.DonatorType)
            .HasForeignKey(d=>d.DonatorTypeId)
            .WillCascadeOnDelete(false);
    }
}


WithOptional方法表示外键约束可以为空,使用WillCascadeOnDelete方法可以指定约束的删除规则。对于外键关系约束,大多数数据库引擎都支持删除规则的多操作,这些规则指定了当一个父亲删除之后会发生什么。将外键列设置为null之后,如果孩子不存在或者删除了所有相关的依赖就会报错。EF允许开发者要么删除所有的孩子行,要么啥也别做。一些数据库管理员反对级联删除,因为一些数据库引擎没有提供级联删除时的充足的日志信息。

不要忘了在Context类中添加DonatorType的DbSet,还有在model builder上添加DonatorTypeMap的配置类。调用WillCascadeOnDelete的另一种选择是,从 model builder中移除全局的约定,在数据库上下文的OnModelCreating方法中关闭整个数据库模型的级联删除规则,如下设置:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{

    modelBuilder.Configurations.Add(new DonatorMap());
    modelBuilder.Configurations.Add(new DonatorTypeMap());
    modelBuilder.Conventions.Remove<OneToManyCascadeDeleteConvention>();
    modelBuilder.Conventions.Remove<ManyToManyCascadeDeleteConvention>();
    base.OnModelCreating(modelBuilder);
}


运行程序,生成的数据库结构如下:

图片

创建一对多关系的代码:

#region 6.1 一对多关系 例子2

var donatorType = new DonatorType
{
    Name = "博客园园友",
    Donators = new List<Donator>
    {
        new Donator
        {
            Amount =6,Name = "键盘里的鼠标",DonateDate =DateTime.Parse("2016-4-13"),
            PayWays = new List<PayWay>{new PayWay{Name = "支付宝"},new PayWay{Name = "微信"}}
        }     
    }
};
var donatorType2 = new DonatorType
{
    Name = "非博客园园友",
    Donators = new List<Donator>
    {

         new Donator
        {
            Amount =10,Name = "待赞助",DonateDate =DateTime.Parse("2016-4-27"),
            PayWays = new List<PayWay>{new PayWay{Name = "支付宝"},new PayWay{Name = "微信"}}
        }
        
    }
};
context.DonatorTypes.Add(donatorType);
context.DonatorTypes.Add(donatorType2);
context.SaveChanges();

#endregion


运行程序,执行结果如下:

图片

可以看到,网友“键盘里的鼠标”的DonatorTypeId是1,即对应的DonatorType表中的第一行;第二条为测试数据。

一对一关系

一对一关系并不常用,但是偶尔也会出现。如果一个实体有一些可选的数据,那么你可以选择这种设计。下图中的两张表的主键是一一对应的。

图片

比如,创建两个实体类Person和Student,一个人可以是个具有注册日期的大学生,对于那些不是大学生的人来说,大学和注册日期就是可空的。因此,我们会将这些数据组合到一个新的实体Student中,如下所示:

public class Person
{
    public int PersonId { get; set; }
    public string Name { get; set; }
    public bool IsActive { get; set; }
	public virtual Student Student { get; set; }
}


public class Student
{
    public int PersonId { get; set; }
    public virtual Person Person { get; set; }
    public string CollegeName { get; set; }
    public DateTime EnrollmentDate { get; set; }
}


注意这里我们为了启用懒加载又用了virtual关键字,Student的配置伙伴类你应该已经很熟悉了,如下:

public class StudentMap:EntityTypeConfiguration<Student>
{
    public StudentMap()
    {
        HasRequired(s=>s.Person)
            .WithOptional(p=>p.Student);
        HasKey(s => s.PersonId);
        Property(s => s.CollegeName)
            .HasMaxLength(50)
            .IsRequired();
    }
}


这里使用了HasKey方法,指定了一个表的主键,换言之,这是一个允许我们找到一个实体的独一无二的值。之前我们没有用这个方法是因为我们要么用了Key特性或者遵守了EF的默认约定(如果属性名是由类名加上"Id"后缀或者只是"Id"组成,那么EF会计算出该主键)。因为我们现在使用了PersonId作为主键,所以我们现在需要给运行时提供额外的提示,这就是HasKey派生用场的地方。最后子表中的主键会成为父表中的外键。

因为该关系是可选的,所以它也称为一或零对一(One-or-Zero-to-One)。关系的两端都是必须要存在的关系称为一对一。比如,每个人必须要有一个单独的login,这是强制性的。你也可以使用WithRequiredDepentent或者WithRequiredPrincipal方法来代替WithOptional方法。

我们可以总是从该关系的主体端或者依赖端来配置关系。我们总是需要配置一对一关系的两端(即两个实体),使用HasWith方法确保一对一关系的创建。

创建数据的代码

#region 7 一对一关系

var student = new Student
{
    CollegeName = "XX大学",
    EnrollmentDate = DateTime.Parse("2011-11-11"),
    Person = new Person
    {
        Name = "Farb",
    }
};

context.Students.Add(student);
context.SaveChanges();

#endregion


运行程序,结果如下:

图片

多对多关系

当关系的两端都有多个实体时,我们就该考虑使用多对多(Many-to-Many)关系了。比如,每个人可以为多个公司干活,每个公司也可以雇佣多个人。在数据库层,这种关系是通过所谓的连接表(junction table)定义的,有时也叫交叉引用表,这个表会包含该关系两端表的主键列。这种类型的关系有两种用例对我们来说很重要,一个连接表可以没有额外的数据或者列,或者它可以有额外的数据。如果连接表没有其他的数据,那么从技术上讲,我们根本不需要创建表示这个连接表的模型。

下面就让我们对这种情况进行编码。创建新的模型类Company,Person类是在之前的基础上添加属性,然后修改PersonMap伙伴类来表示两个实体间的关系。就像一对多关系一样,我们会在Person和Company类中添加相关类的集合。代码如下:

public class Company
{
     public Company()
     {
        Persons = new HashSet<Person>();
     }
    public int CompanyId { get; set; }
    public string CompanyName { get; set; }
    public virtual ICollection <Person> Persons { get; set; }
}

public class Person
{
	public Person()
	{
    	Companies=new HashSet<Company>();
	}
    public int PersonId { get; set; }
    public string Name { get; set; }
    public bool IsActive { get; set; }
    public virtual Student Student { get; set; }
    public virtual ICollection<Company> Companies { get; set; }
}

public class PersonMap:EntityTypeConfiguration<Person>
{
    public PersonMap()
    {
        HasMany(p => p.Companies)
            .WithMany(c => c.Persons)
            .Map(m =>
            {
                m.MapLeftKey("PersonId");
                m.MapRightKey("CompanyId");
            });
    }
}

然后在数据库上下文中添加DbSet的属性和在OnModelCreating方法中添加PersonMap的配置引用。

public DbSet<Company> Companies { get; set; }
modelBuilder.Configurations.Add(new PersonMap());

技术上讲,如果你对EF生成列名的约定没问题的话,那么该配置是可省略的,意思就是说,EF实际上会根据该关系两端定义的类和属性独立地创建一个连接表,因为相关的实体有集合属性。如果我们想要不同于默认创建的表名或列名,就可以在连接表中显式指定表名或列名。

现在我们添加两个人(比尔盖茨和乔布斯)和一个公司(微软),代码如下:

#region 8 多对多关系

var person = new Person
{
    Name = "比尔盖茨",
};
var person2 = new Person
{
    Name = "乔布斯",
};
context.People.Add(person);
context.People.Add(person2);
var company = new Company
{
    CompanyName = "微软"
};
company.Persons.Add(person);
context.Companies.Add(company);
context.SaveChanges();

#endregion


运行程序,查看数据库结构及填充的数据:

图片

可以看到,EF自动把帮我们生成了连接表PersonCompanies,当然我们也可以在PersonMap伙伴类中自定义,只需要添加m.ToTable("PersonCompany");即可。

如果我们连接表需要保存更多的数据怎么办?比如当每个人开始为公司干活时,我们想为他们添加雇佣日期。这样的话,实际上我们需要创建一个类来模型化该连接表,我们暂且称为PersonCompany吧。它仍然具有两个的主键属性,PersonId和CompanyId,它还有Person和Company的属性以及雇佣日期的属性。此外,Person和Company类分别都有PersonCompanies的集合属性而不是单独的Person和Company集合属性。

三种继承模式

到现在为止,我们已经学会了如何使用EF的Code First将领域实体类映射到数据库表。我们也学会了如何创建具有多样性关系的实体,以及如何使用EF将这些关系映射到数据库表之间的关系。

现在我们看一下领域实体间的继承关系,以及使用EF将这些数据映射到单独的表中。接下来会介绍下面的三种继承类型:

  • Table per Type(TPT)继承
  • Table per Class Hierarchy(TPH)继承
  • Table per Concrete Class(TPC)继承

TPT继承

当领域实体类有继承关系时,TPT继承很有用,我们想把这些实体类模型到数据库中,这样,每个领域实体都会映射到单独一张表中。这些表会使用一对一关系相互关联,数据库会通过一个共享的主键维护这个关系。

假设有这么个场景:一个组织维护了在一个部门工作的所有人的数据库,这些人有些事拿着固定工资的员工,一些是按小时付费的供应商,要模型化这个情景,我们要创建三个领域实体,Person,Employee和Vendor。Person类是基类,另外两个类会从它继承。在VS中画的类图如下:

图片

在TPT继承中,我们想为每个领域实体类创建单独的一张表,这些表共享一个主键。因此生成的数据库就像下面这样:

图片

现在我新创建一个控制台项目(不明白的可下载源码),然后创建实体类:

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public string PhoneNumber { get; set; }
}

[Table("Employees")]
public class Employee : Person
{
    public decimal Salary { get; set; }
}

[Table("Vendors")]
 public class Vendor : Person
 {
     public decimal HourlyRate { get; set; }
 }

对于Person类,我们使用EF的默认约定来映射到数据库,而对Employee和Vendor类,我们使用了数据注解,将它们映射为我们想要的表名。

然后我们需要创建自己的数据库上下文类:

 public class Context:DbContext
 {
     public virtual DbSet<Person> People { get; set; }
 }


上面的上下文中,我们只添加了实体Person的DbSet。因为其它的两个领域模型都是从这个模型派生的,所以我们也就相当于将其它两个类添加到了DbSet集合中了,这样EF会使用多 态性来使用实际的领域模型。当然,你也可以使用fluent API和实体伙伴类来配置映射细节信息,这里不再多说。

现在,我们使用这些领域实体来创建一个Employee和Vendor类型:

#region 1.0  TPT继承

var employee = new Employee
{
    Name = "farb",
    Email = "farbguo@qq.com",
    PhoneNumber = "12345678",
    Salary = 1234m
};

var vendor = new Vendor
{
    Name = "tkb至简",
    Email = "farbguo@outlook.com",
    PhoneNumber = "78956131",
    HourlyRate = 4567m
};

context.People.Add(employee);
context.People.Add(vendor);
context.SaveChanges();
#endregion


运行程序,数据库结构及数据填充情况如下:

图片

我们可以看到每个表都包含单独的数据,这些表之间都有一个共享的主键。因而这些表之间都是一对一关系。

TPH继承

当领域实体有继承关系,但是我们想将来自所有的实体类的数据保存到单独的一张表中时,TPH继承很有用。从领域实体的角度,我们的模型类的继承关系仍然像上面的截图一样:

图片

但是从数据库的角度,应该只有一张表存储数据。因此,最终生成的数据库的样子应该是下面这样的:

图片

在这种情况下,无论何时我们创建了一个worker类型,公共的字段都会填充。如果该worker类型是Employee类型,那么除了公共字段外,Salary还会包含值,但是HourlyRate字段就会是null;如果该worker是Vendor类型,那么HourlyRate会包含值,Salary就会为null。

从数据库的角度来看,这种模式很不优雅,因为我们将无关的数据保存到了单张表中,我们的表示不标准的。如果我们使用这种方法,那么总会存在一些包含null值的冗余列。

现在我们创建实体类来实现该继承,注意,这次创建的三个实体类和之前创建的只是没有了类上的数据注解,这样它们就会映射到数据库的单张表中(EF会默认使用父类的DbSet属性名或其复数形式作为表名,并且将派生类的属性映射到那张表中):

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public string PhoneNumber { get; set; }
}


 public class Employee : Person
 {
     public decimal Salary { get; set; }
 }


public class Vendor : Person
{
    public decimal HourlyRate { get; set; }
}


对这些实体执行操作的数据库上下文的配置如下:

public class Context:DbContext
{
    public Context():base("ThreeInheritance")
    {
        
    }

    public virtual DbSet<Person> Person { get; set; }
}

现在,我们使用这些领域类来创建一个Employee和一个Vendor类型:

 #region 2.0 TPH 继承
  var employee = new Employee
  {
      Name = "farb",
      Email = "farbguo@qq.com",
      PhoneNumber = "12345678",
      Salary = 1234m
  };
 
  var vendor = new Vendor
  {
      Name = "tkb至简",
      Email = "farbguo@outlook.com",
      PhoneNumber = "78956131",
      HourlyRate = 4567m
  };
 
  context.Person.Add(employee);
  context.Person.Add(vendor);
  context.SaveChanges();
 #endregion


运行程序,发现数据库中只有一张表,而且三个类的所有字段都在这张表中了,如下图:

图片

如果你细心,你会发现生成的表中多了个字段Descriminator,它是用来找到记录的实际类型,即从Person表中找到Employee或者Vendor。

因此,如果我们没有在具有继承关系的实体之间提供确切的配置,那么EF会默认将其对待成TPH继承,并把数据放到单张表中。

TPC继承

当多个领域实体派生自一个基实体,并且我们想将所有具体类的数据分别保存在各自的表中,以及抽象基类实体在数据库中没有对应的表时,使用TPC继承。

从领域实体的角度看,我们仍然想要模型维护该继承关系。因此,实体模型和之前的一样:

然而,从数据库的角度看,只有所有具体类所对应的表,而没有抽象类对应的表。生成的数据库样子如下图:

图片

这种数据库设计的最大问题之一是数据表中列的重复问题,从数据库标准的角度这是不推荐的。

现在,创建领域实体类,这里Person基类应该是抽象的,其他的地方都和上面的一样:

public abstract class Person
{
	[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public string PhoneNumber { get; set; }
}

public class Vendor : Person
{
    public decimal HourlyRate { get; set; }
}

public class Employee : Person
{
    public decimal Salary { get; set; }
}

接下来就是应该配置数据库上下文了,如果我们只在数据库上下文中添加了Person的DbSet泛型集合属性,那么EF会当作TPH继承处理,如果我们需要实现TPC继承,那么还需要使用fluent API来配置映射(当然也可以使用配置伙伴类):

public virtual DbSet<Person> People { get; set; }

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<Employee>().Map(m =>
    {
        m.MapInheritedProperties();
        m.ToTable("Employees");
    });

    modelBuilder.Entity<Vendor>().Map(m =>
    {
        m.MapInheritedProperties();
        m.ToTable("Vendors");
    });
    base.OnModelCreating(modelBuilder);
}


上面的代码中,MapInheritedProperties方法将继承的属性映射到表中,然后我们根据不同的对象类型映射到不同的表中。

然后我们创建一个Employee和一个Vendor,代码和上面的一样,这里就不重复贴代码了。

运行程序,在VS中查看数据库如下:

图片

虽然数据是插入到数据库了,但是运行程序时也出现了异常,见下图。出现该异常的原因是EF尝试去访问抽象类中的值,它会找到两个具有相同Id的记录,然而Id列被识别为主键,因而具有相同主键的两条记录就会产生问题。这个异常清楚地表明了存储或者数据库生成的Id列对TPC继承无效。
如果我们想使用TPC继承,那么要么使用基于GUID的Id,要么从应用程序中传入Id,或者使用能够维护对多张表自动生成的列的唯一性的某些数据库机制。

图片

本章小结

这篇博客中,我们看到了如何使用EF Code First方法在应用程序中使用领域实体,以及如何持久化数据到数据库,也看了如何管理实体间的多样性关系,以及使用EF将这些关系映射到数据库。最后我们看了如何使用EF来管理涉及继承关系的实体,看到了三种继承关系对应三种不同的数据库模式。

自我测试

  1. 你可以使用哪种类型来定义存储整数的列,该整数不是必须的?

    1. Decimal
    2. Decimal?
    3. Int
    4. Int?
  2. 如果你想使得姓名列Name在数据库中是不可空的,那么你可以依赖EF的默认约定,对吗?

  3. 你不能重写EF预加载的约定,比如默认的外键约束级联删除,对吗?

  4. 下面哪一个不是关系?

    1. One-to-Many
    2. Many-to-Many
    3. One-or-Zero-to-Many
    4. Many-to-Default
  5. 给所有的实体类配置所有属性的最佳方法是在上下文的OnModelCreating方法中一个一个地列举它们,对吗?

  6. 如果没有为字符串属性配置一些额外的信息,那么SQL Server数据库默认会使用什么类型?

    1. NVARCHAR(4000)
    2. NVARCHAR(MAX)
    3. VARBINARY(MAX)
    4. VARCHAR(MAX)
  7. 下面哪一个不是一个关系的第一个端点的合适名称?

    1. Principal
    2. Parent
    3. Domain
  8. 如果你想使用一个伙伴类配置一个实体,那么你应该继承哪个类?

    1. EntityTypeConfiguration (of T)
    2. PrimitivePropertyConfiguration (of T)
    3. ComplexTypeConfiguration (of T)
    4. EntityConfiguration (of T)

9.EF中的三种继承模式指的是哪三种?

如果您觉得这篇文章对您有价值或者有所收获,请点击右下方的店长推荐,然后查看答案,谢谢!


参考书籍:
《Mastering Entity Framework》
《Code-First Development with Entity Framework》
《Programming Entity Framework Code First》

posted @ 2016-05-03 08:21  tkbSimplest  阅读(12969)  评论(22编辑  收藏  举报