通过


自定义代码优先约定

注释

EF6 及更高版本 - 实体框架 6 中介绍了本页中讨论的功能、API 等。 如果使用早期版本,某些或全部信息不适用。

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

有时,这些默认约定不适合模型,你可能需要使用数据注释或 Fluent API 来配置多个单独的实体以解决这些问题。 使用自定义的代码优先约定,您可以定义自己的规则,为模型提供配置默认值。 在本演练中,我们将探讨不同类型的自定义约定以及如何创建每个约定。

基于模型的惯例

本页介绍用于自定义约定的 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 键列和字符串 Name 列组成的复合键。 如果我们在设计器中查看模型,它如下所示:

复合键

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

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

 

约定类

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

我们可以通过执行以下操作,创建一个具有前面显示的 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());
    }

可以看到,我们将约定的实例添加到约定集合中。 从约定继承提供了在团队或项目之间分组和共享约定的便捷方式。 例如,可以有一个类库,其中包含所有组织项目使用的通用约定集。

 

自定义属性

另一种有效利用约定的方法是配置模型时,可以启用一些新属性。 为了说明这一点,让我们创建一个属性,可用于将 String 属性标记为非 Unicode。

    [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)。 该表将包含员工和经理,每行都有一个用于区分的列,该列将告诉你存储的实例类型。 这是 TPH 映射,因为层次结构有一个表。 但是,如果在这两个类上调用 ToTable,则每个类型将改为映射到自己的表,也称为 TPT,因为每种类型都有自己的表。

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

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

tpt 示例

可以通过以下几种方式避免此问题并维护默认 TPH 映射:

  1. 使用 ToTable 对层次结构中的每个类型调用相同的表名。
  2. 仅在层次结构的基类上调用 ToTable,在本示例中为 Employee。

 

执行顺序

约定以最后一个胜利的方式运行,与 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 的 MaxLength,而任何其他字符串(如说明)将为 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>();
    }