自定义 Code First 约定

注意

仅限 EF6 及更高版本 - 此页面中讨论的功能、API 等已引入实体框架 6。 如果使用的是早期版本,则部分或全部信息不适用。

使用 Code First 时,模型是使用一组约定从类计算的。 默认 Code First 约定可决定哪些属性成为实体的主键、实体要映射到的表的名称,以及默认情况下小数列的精度和小数位数。

有时,这些默认约定对于模型并不理想,必须通过使用数据注释或 Fluent API 配置多个单独的实体来解决这些问题。 自定义 Code First 约定允许定义自己的约定,为模型提供配置默认值。 本演练将探讨不同类型的自定义约定以及如何创建它们。

基于模型的约定

本页介绍用于自定义约定的 DbModelBuilder API。 此 API 应足以创作大多数自定义约定。 但是,还能够创作基于模型的约定(在最终模型创建后对其进行操作的约定)来处理高级场景。 有关详细信息,请参阅基于模型的约定

 

我们的模型

我们首先定义一个可以用于约定的简单模型。 将以下类添加到项目中。

    using System;
    using System.Collections.Generic;
    using System.Data.Entity;
    using System.Linq;

    public class ProductContext : DbContext
    {
        static ProductContext()
        {
            Database.SetInitializer(new DropCreateDatabaseIfModelChanges<ProductContext>());
        }

        public DbSet<Product> Products { get; set; }
    }

    public class Product
    {
        public int Key { get; set; }
        public string Name { get; set; }
        public decimal? Price { get; set; }
        public DateTime? ReleaseDate { get; set; }
        public ProductCategory Category { get; set; }
    }

    public class ProductCategory
    {
        public int Key { get; set; }
        public string Name { get; set; }
        public List<Product> Products { get; set; }
    }

 

自定义约定简介

我们来编写一种约定,将名为 Key 的任何属性配置为其实体类型的主键。

约定在模型生成器上启用,该模型生成器可以通过在上下文中替代 OnModelCreating 来访问。 按如下所示更新 ProductContext 类:

    public class ProductContext : DbContext
    {
        static ProductContext()
        {
            Database.SetInitializer(new DropCreateDatabaseIfModelChanges<ProductContext>());
        }

        public DbSet<Product> Products { get; set; }

        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            modelBuilder.Properties()
                        .Where(p => p.Name == "Key")
                        .Configure(p => p.IsKey());
        }
    }

现在,模型中名为 Key 的任何属性都将配置为其所属实体的主键。

我们还可以通过筛选要配置的属性类型,使约定更具体:

    modelBuilder.Properties<int>()
                .Where(p => p.Name == "Key")
                .Configure(p => p.IsKey());

这会将名为 Key 的所有属性配置为其实体的主键,但仅适用于属性是整数的情况。

IsKey 方法的一个有趣特征是具有累加性。 这意味着,如果在多种属性上调用 IsKey,它们都会成为组合键的一部分。 需要注意的是,为键指定多种属性时,还必须指定这些属性的顺序。 为此,可以调用 HasColumnOrder 方法,如下所示:

    modelBuilder.Properties<int>()
                .Where(x => x.Name == "Key")
                .Configure(x => x.IsKey().HasColumnOrder(1));

    modelBuilder.Properties()
                .Where(x => x.Name == "Name")
                .Configure(x => x.IsKey().HasColumnOrder(2));

此代码将配置模型中的类型,使其具有包含 int key 列和字符串 Name 列的复合键。 如果我们在设计器中查看模型,模型将如下所示:

组合键

属性约定的另一个示例是配置模型内的所有 DateTime 属性,以映射到 SQL Server 中的 datetime2 类型,而不是映射到 datetime。 可以通过以下方式实现此目的:

    modelBuilder.Properties<DateTime>()
                .Configure(c => c.HasColumnType("datetime2"));

 

约定类

定义约定的另一种方式是使用约定类来封装约定。 使用约定类时,你会创建一种类型,该类型继承自System.Data.Entity.ModelConfiguration.Conventions 命名空间中的约定类。

通过执行以下操作,使用前面所示的 datetime2 约定创建约定类:

    public class DateTime2Convention : Convention
    {
        public DateTime2Convention()
        {
            this.Properties<DateTime>()
                .Configure(c => c.HasColumnType("datetime2"));        
        }
    }

若要告知 EF 使用此约定,请将其添加到 OnModelCreating 中的约定集合,如果一直遵循此演练,则此集合将如下所示:

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Properties<int>()
                    .Where(p => p.Name.EndsWith("Key"))
                    .Configure(p => p.IsKey());

        modelBuilder.Conventions.Add(new DateTime2Convention());
    }

如你所见,我们将约定的实例添加到约定集合。 继承约定提供了一种在团队或项目之间将约定进行分组和共享的简便方法。 例如,可以拥有一个类库,该类库包含所有组织项目都使用的一组通用约定。

 

自定义特性

约定的另一种重要用途是在配置模型时启用新特性。 为了说明这一点,让我们创建一种特性,可用于将字符串属性标记为 NonUnicode。

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class NonUnicode : Attribute
    {
    }

现在,让我们创建一种约定,将此特性应用到我们的模型:

    modelBuilder.Properties()
                .Where(x => x.GetCustomAttributes(false).OfType<NonUnicode>().Any())
                .Configure(c => c.IsUnicode(false));

通过此约定,我们可以将 NonUnicode 特性添加到任何字符串属性,这意味着数据库中的列将存储为 varchar,而不是 nvarchar。

有关此约定的一个注意事项是,如果将 NonUnicode 特性放在字符串属性之外的其他任何对象上,则会引发异常。 引发异常的原因是你不能对除字符串外的任何类型配置 IsUnicode。 如果发生这种情况,可以使约定更具体,以便筛选出任何非字符串的对象。

虽然上述约定适用于定义自定义属性,但还有另一种 API 更易于使用,尤其是在要使用特性类中的属性时。

对于此示例,我们将更新特性,并将其更改为 IsUnicode 特性,使其内容如下所示:

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    internal class IsUnicode : Attribute
    {
        public bool Unicode { get; set; }

        public IsUnicode(bool isUnicode)
        {
            Unicode = isUnicode;
        }
    }

获得此特性后,我们可以在特性上设置一个布尔值,以告知约定属性是否应为 Unicode。 通过访问配置类的 ClrProperty,可以按照已有的约定实现此要求,如下所示:

    modelBuilder.Properties()
                .Where(x => x.GetCustomAttributes(false).OfType<IsUnicode>().Any())
                .Configure(c => c.IsUnicode(c.ClrPropertyInfo.GetCustomAttribute<IsUnicode>().Unicode));

这很简单,但有一种更简洁的方法实现此目的,即使用约定 API 的 Having 方法。 Having 方法具有类型为 Func<PropertyInfo, T> 的参数,该参数接受与 Where 方法相同的 PropertyInfo,但应返回对象。 如果返回的对象为 NULL,则不配置该属性,这意味着可以像 Where 那样用它筛选掉属性,但不同的是,它还将捕获返回的对象,并将其传递给 Configure 方法。 工作原理如下:

    modelBuilder.Properties()
                .Having(x => x.GetCustomAttributes(false).OfType<IsUnicode>().FirstOrDefault())
                .Configure((config, att) => config.IsUnicode(att.Unicode));

自定义属性不是使用 Having 方法的唯一原因,在需要对配置类型或属性时筛选内容进行解释的任何情况下都很有用。

 

配置类型

到目前为止,所有约定都针对属性,但是约定 API 还有另一个领域用于配置模型中的类型。 此体验类似于我们到目前看到的约定,但配置中的选项将为实体级,而不是属性级。

类型级约定真正有用的功能之一是更改表命名约定,以映射到不同于 EF 默认值的现有架构,或者创建具有不同命名约定的新数据库。 为此,我们首先需要一个方法,该方法可以接受模型中某个类型的 TypeInfo,并返回该类型的表名应具有的内容:

    private string GetTableName(Type type)
    {
        var result = Regex.Replace(type.Name, ".[A-Z]", m => m.Value[0] + "_" + m.Value[1]);

        return result.ToLower();
    }

此方法接受一种类型,并返回一个字符串,该字符串使用带下划线的小写,而不是 CamelCase。 在我们的模型中,这意味着 ProductCategory 类将映射到名为 product_category(而不是 ProductCategories)的表。

获得该方法后,我们可以采用如下所示的约定来调用它:

    modelBuilder.Types()
                .Configure(c => c.ToTable(GetTableName(c.ClrType)));

此约定将模型中的每种类型都配置为映射到从 GetTableName 方法返回的表名。 此约定等效于使用模型 Fluent API 为模型的每个实体调用 ToTable 方法。

要注意的一点是,调用 ToTable 时,EF 将使用你提供的字符串作为确切的表名称,且不会使用其在确定表名时通常会执行的任何复数形式。 正因如此,我们约定中的表名称是 product_category,而不是 product_categories。 我们可以通过自行调用复数形式服务来解决约定中的该问题。

在下面的代码中,我们将使用 EF6 中新增的依赖项解析功能来检索 EF 可能使用的复数化服务,并使表名复数化。

    private string GetTableName(Type type)
    {
        var pluralizationService = DbConfiguration.DependencyResolver.GetService<IPluralizationService>();

        var result = pluralizationService.Pluralize(type.Name);

        result = Regex.Replace(result, ".[A-Z]", m => m.Value[0] + "_" + m.Value[1]);

        return result.ToLower();
    }

注意

GetService 的通用版本是 System.Data.Entity.Infrastructure.DependencyResolution 命名空间中的扩展方法,需要在上下文中添加 using 语句才能使用它。

ToTable 和继承

ToTable 的另一个重要方面是,如果将类型显式映射到给定表,则可以更改 EF 将使用的映射策略。 如果为继承层次结构中的每种类型调用 ToTable,并像上面那样将类型名称作为表的名称传递,则你将默认的每个层次结构一张表 (TPH) 映射策略更改为每个类型一张表 (TPT)。 对此进行说明的最佳方式是利用具体示例:

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

    public class Manager : Employee
    {
        public string SectionManaged { get; set; }
    }

默认情况下,员工和经理都映射到数据库中的同一个表 (Employees)。 该表将包含“员工”和“经理”,其中有一个 discriminator 列,该列可告知各行中存储的实例类型。 这是 TPH 映射,因为层次结构只有一个表。 但是,如果在这两种类上都调用 ToTable,则每种类型都将改为映射到其自己的表,这也称为 TPT,因为每个类型都有自己的表。

    modelBuilder.Types()
                .Configure(c=>c.ToTable(c.ClrType.Name));

上面的代码将映射到如下所示的表结构:

tpt 示例

可通过以下两种方式避免这种情况,并保持默认的 TPH 映射:

  1. 对层次结构中的每种类型使用同一表名调用 ToTable。
  2. 仅在层次结构的基类(本例中为“员工”)上调用 ToTable。

 

执行顺序

约定以最后一个优先的方式运行,与 Fluent API 相同。 这意味着,如果编写的两种约定配置相同属性的相同选项,则最后执行的约定优先。 例如,在下面的代码中,所有字符串的最大长度设置为 500,但我们随后在模型中将名为 Name 的所有属性配置为最大长度为 250。

    modelBuilder.Properties<string>()
                .Configure(c => c.HasMaxLength(500));

    modelBuilder.Properties<string>()
                .Where(x => x.Name == "Name")
                .Configure(c => c.HasMaxLength(250));

由于将最大长度设置为 250 的约定在将所有字符串设置为 500 的约定之后,因此模型中名为 Name 的所有属性的最大长度将为 250,而任何其他字符串(如说明)将为 500。 这样使用约定意味着可以为模型中的类型或属性提供通用约定,然后针对不同子集对其进行替代。

Fluent API 和数据注释还可用于替代特定情况下的约定。 在以上示例中,如果已使用 Fluent API 设置属性的最大长度,则将其置于约定之前或之后,因为更具体的 Fluent API 将优先于更通用的配置约定。

 

内置约定

由于自定义约定可能受默认 Code First 的影响,因此添加要在另一个约定之前或之后运行的约定可能会很有用。 为此,可以在派生的 DbContext 上使用约定集合的 AddBefore 和 AddAfter 方法。 下面的代码将添加前面创建的约定类,使其在内置键发现约定之前运行。

    modelBuilder.Conventions.AddBefore<IdKeyDiscoveryConvention>(new DateTime2Convention());

添加需要在内置约定之前或之后运行的约定时,这将是最常用的方法,则可在以下位置找到内置约定列表:System.Data.Entity.ModelConfiguration.Conventions 命名空间

还可以删除不希望应用于模型的约定。 若要删除约定,请使用 Remove 方法。 下面是删除 PluralizingTableNameConvention 的示例。

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
    }