模型批量配置

当需要在多个实体类型中以相同方式配置一个方面时,可以通过以下方法减少代码重复并合并逻辑。

请参阅包含以下所示代码片段的完整示例项目

OnModelCreating 中的批量配置

ModelBuilder 返回的每个生成器对象都会公开一个 ModelMetadata 属性,该属性提供对构成模型的对象的低级别访问。 具体而言,有些方法允许循环访问模型中的特定对象,并对其应用通用配置。

在以下示例中,模型包含一个自定义值类型 Currency

public readonly struct Currency
{
    public Currency(decimal amount)
        => Amount = amount;

    public decimal Amount { get; }

    public override string ToString()
        => $"${Amount}";
}

默认情况下不会发现此类型的属性,因为当前 EF 提供程序不知道如何将其映射到数据库类型。 此 OnModelCreating 代码片段添加类型 Currency 的所有属性添,并将值转换器配置为受支持的类型 - decimal

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    foreach (var propertyInfo in entityType.ClrType.GetProperties())
    {
        if (propertyInfo.PropertyType == typeof(Currency))
        {
            entityType.AddProperty(propertyInfo)
                .SetValueConverter(typeof(CurrencyConverter));
        }
    }
}
public class CurrencyConverter : ValueConverter<Currency, decimal>
{
    public CurrencyConverter()
        : base(
            v => v.Amount,
            v => new Currency(v))
    {
    }
}

元数据 API 的缺点

  • Fluent API不同,对模型的每次修改都需要显式完成。 例如,如果某些 Currency 属性被约定配置为导航,则需要先删除引用 CLR 属性的导航,然后再为其添加实体类型属性。 #9117 将改进此不足。
  • 每次更改后都会运行约定。 如果删除约定发现的导航,则约定将再次运行,并可以将导航添加回来。 为了防止这种情况发生,需要延迟约定,直到通过调用 DelayConventions() 添加属性之后再释放返回的对象,或者使用 AddIgnored 将 CLR 属性标记为忽略。
  • 发生此循环访问后,可能会添加实体类型,并且不会向其应用配置。 通常可以通过将此代码放在 OnModelCreating 的末尾来防止这种情况,但如果有两组相互依赖的配置,则可能没有一个顺序可以一致地应用这些配置。

预先约定配置

EF Core 允许为给定 CLR 类型指定一次映射配置;然后,此配置将应用于模型中发现的给定类型的所有属性。 这称为“预先约定模型配置”,因为它配置模型的各个方面,直到允许运行模型生成约定。 此类配置通过在 DbContext 派生的类型上替代 ConfigureConventions 来应用。

以下示例演示如何将类型 Currency 的所有属性配置为具有值转换器:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder
        .Properties<Currency>()
        .HaveConversion<CurrencyConverter>();
}

以下示例演示如何在类型 string 的所有属性上配置一些方面:

configurationBuilder
    .Properties<string>()
    .AreUnicode(false)
    .HaveMaxLength(1024);

注意

ConfigureConventions 调用中指定的类型可以是基类型、接口或泛型类型定义。 所有匹配的配置将从最不具体的配置开始按顺序应用:

  1. 接口
  2. 基类型
  3. 泛型类型定义
  4. 不可以为 null 的值类型
  5. 确切类型

重要

预先约定配置相当于将匹配对象添加到模型后立即应用的显式配置。 它将替代所有约定和数据注释。 例如,在上述配置中,所有字符串外键属性都将创建为 MaxLength 为 1024 的非 unicode,即使这与主体键不匹配。

忽略类型

预先约定配置还允许忽略某个类型,并防止约定将其作为实体类型或实体类型的属性发现:

configurationBuilder
    .IgnoreAny(typeof(IList<>));

默认类型映射

通常,只要为此类型的属性指定了值转换器,EF 就可以使用提供程序不支持的类型常量转换查询。 但是,在不涉及此类型的任何属性的查询中,EF 无法找到正确的值转换器。 在这种情况下,可以调用 DefaultTypeMapping 添加或替代提供程序类型映射:

configurationBuilder
    .DefaultTypeMapping<Currency>()
    .HasConversion<CurrencyConverter>();

预先约定配置的限制

  • 许多方面无法使用此方法进行配置。 #6787 将此限制扩展到更多类型。
  • 目前,配置仅由 CLR 类型确定。 #20418 将允许自定义谓词。
  • 在创建模型之前,将执行此配置。 如果应用此配置时出现任何冲突,则异常堆栈跟踪将不包含 ConfigureConventions 方法,因此可能更难找到原因。

约定

注意

EF Core 7.0 中引入了自定义模型生成约定。

EF Core 模型生成约定是根据在生成模型时对模型的更改触发的包含逻辑的类。 这使得模型在进行显式配置、应用映射属性以及运行其他约定时保持最新状态。 为了参与此过程,每个约定实现一个或多个接口,用于确定何时触发相应的方法。 例如,每当向模型添加新实体类型时,都会触发实现 IEntityTypeAddedConvention 的约定。 同样,每当将键或外键添加到模型时,都会触发实现 IForeignKeyAddedConventionIKeyAddedConvention 的约定。

模型生成约定是控制模型配置的强大方法,但可能很复杂且难以处理得当。 在许多情况下,可以使用现有的预先约定模型配置来轻松指定属性和类型的常见配置。

添加新约定

示例:鉴别器属性的限制长度

每个层次结构一个表继承映射策略需要一个鉴别器列来指定任何给定行中表示的类型。 默认情况下,EF 对鉴别器使用未绑定的字符串列,这可确保它适用于任何鉴别器长度。 但是,限制鉴别器字符串的最大长度可能会提高存储和查询的效率。 我们创建一个新的约定来执行此操作。

EF Core 模型生成约定是根据在生成模型时对模型的更改触发的。 这使得模型在进行显式配置、应用映射属性以及运行其他约定时保持最新状态。 为了参与此过程,每个约定实现一个或多个接口,用于确定何时触发约定。 例如,每当向模型添加新实体类型时,都会触发实现 IEntityTypeAddedConvention 的约定。 同样,每当将键或外键添加到模型时,都会触发实现 IForeignKeyAddedConventionIKeyAddedConvention 的约定。

了解要实现的接口可能很棘手,因为以后可能会更改或删除对模型的配置。 例如,键可以通过约定创建,但稍后在显式配置其他键时被替换。

我们通过首次尝试实现鉴别器长度约定来更具体地展示:

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

此约定实现 IEntityTypeBaseTypeChangedConvention,这意味着每当更改实体类型的映射继承层次结构时,都会触发它。 然后,约定查找并配置层次结构的字符串鉴别器属性。

接着,通过在 ConfigureConventions 中调用 Add 来使用此约定:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

注意

Add 方法接受用于创建约定实例的中心,而不是直接添加约定实例。 这样,约定就可以使用 EF Core 内部服务提供程序中的依赖项。 由于此约定没有依赖项,因此服务提供程序参数命名为 _,表示它永远不会使用。

生成模型并查看 Post 实体类型显示,这样做是有效的 - 现在已将鉴别器属性配置为最大长度为 24:

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

但是,如果我们现在显式配置不同的鉴别器属性,会发生什么情况? 例如:

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

查看模型的调试视图,我们发现不再配置鉴别器长度。

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

这是因为我们在约定中配置的鉴别器属性后来在添加自定义鉴别器时被删除。 我们可以尝试通过在约定上实现另一个接口来应对鉴别器更改来解决此问题,但找出要实现的接口并不容易。

幸运的是,有一种更简单的方法。 很多时候,只要最终模型正确,模型在生成时的样子就无关紧要。 此外,我们要应用的配置通常不需要触发其他约定来做出反应。 因此,我们的约定可以实现 IModelFinalizingConvention。 模型最终约定在所有其他模型生成完成后运行,因此有权访问模型的接近最终状态。 这与交互式约定相反,交互式约定对每个模型更改做出反应并确保模型在 OnModelCreating 方法执行的任何时间点都是最新状态。 模型最终约定通常会循环访问整个模型,并按原样配置模型元素。 因此,在这种情况下,我们会在模型中找到每个鉴别器并对其进行配置:

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

使用此新约定生成模型后,我们发现鉴别器长度现在配置正确,即使已对其进行自定义:

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

我们更进一步,将最大长度配置为最长的鉴别器值的长度。

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

现在,鉴别器列最大长度为 8,即“精选”的长度,这是使用中最长的鉴别器值。

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

示例:所有字符串属性的默认长度

我们看看另一个示例,其中可以使用最终约定 - 为任何字符串属性设置默认的最大长度。 约定看起来与前面的示例非常相似:

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

此约定非常简单。 它查找模型中的每个字符串属性,并将其最大长度设置为 512。 在调试视图中查看 Post 的属性,我们看到所有字符串属性现在的最大长度为 512。

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

注意

同样的操作可以通过预先约定配置来完成,但是使用约定可以进一步筛选适用的属性,并允许数据注释替代配置

最后,在离开此示例之前,如果同时使用 MaxStringLengthConventionDiscriminatorLengthConvention3 会发生什么情况? 答案是,这取决于添加的顺序,因为模型最终约定按照添加的顺序运行。 因此,如果最后添加 MaxStringLengthConvention,则它将最后运行,并将鉴别器属性的最大长度设置为 512。 因此,在这种情况下,最好最后添加 DiscriminatorLengthConvention3,以便它可以替代仅鉴别器属性的默认最大长度,同时将所有其他字符串属性保留为 512。

替换现有约定

有时,我们不想完全删除现有约定,而是想将其替换为一种操作基本相同但行为已更改的约定。 这很有用,因为现有约定已经实现了需要适当触发的接口。

示例:选择加入属性映射

EF Core 按约定映射所有公共读写属性。 这可能不适合定义实体类型的方式。 若要更改此项,我们可以将 PropertyDiscoveryConvention 替换为不映射任何属性的自己的实现,除非它在 OnModelCreating 中显式映射,或者使用名为 Persist 的新属性进行标记:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

下面是新的约定:

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

提示

替换内置约定时,新的约定实现应继承自现有约定类。 请注意,某些约定具有关系或提供程序特定的实现,在这种情况下,新的约定实现应继承自正在使用的数据库提供程序最具体的现有约定类。

然后,使用 ConfigureConventions 中的 Replace 方法注册该约定:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

提示

在这种情况下,现有约定具有依赖项,由 ProviderConventionSetBuilderDependencies 依赖项对象表示。 这些依赖项是使用 GetRequiredService 从内部服务提供程序获取的,并传递给约定构造函数。

请注意,此约定允许映射字段(除了属性),只要这些字段标记为 [Persist]。 这意味着我们可以在模型中将专用字段用作隐藏键。

例如,请考虑以下实体类型:

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

基于这些实体类型生成的模型为:

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

通常,IsClean 已映射,但由于它未标记为 [Persist],因此现在被视为未映射的属性。

提示

无法将此约定作为模型最终约定实现,因为在映射属性以进一步配置它之后,需要运行现有的模型最终约定。

约定实现注意事项

EF Core 会跟踪每个配置是如何进行的。 这由 ConfigurationSource 枚举表示。 不同类型的配置包括:

  • Explicit:模型元素在 OnModelCreating 中显式配置
  • DataAnnotation:模型元素是使用 CLR 类型的映射属性(即数据注释)配置的
  • Convention:模型元素是由模型生成约定配置的

约定永远不会替代标记为 DataAnnotationExplicit 的配置。 这是通过使用“约定生成器”来实现的,例如,从 Builder 属性获取的 IConventionPropertyBuilder。 例如:

property.Builder.HasMaxLength(512);

如果 HasMaxLength 尚未由映射属性配置或在 OnModelCreating 中配置,则在约定生成器上调用它只会设置最大长度。

此类生成器方法还有第二个参数:fromDataAnnotation。 如果约定代表映射属性进行配置,则将其设置为 true。 例如:

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

这会将 ConfigurationSource 设置为 DataAnnotation,这意味着现在可以通过 OnModelCreating 上的显式映射来替代该值,但不能通过非映射属性约定来替代该值。

如果无法替代当前配置,则该方法将返回 null,如果需要执行进一步的配置,则需要考虑到这一点:

property.Builder.HasMaxLength(512)?.IsUnicode(false);

请注意,如果无法替代 unicode 配置,仍将设置最大长度。 如果仅当两个调用都成功时才需要配置方面,则可以通过调用 CanSetMaxLengthCanSetIsUnicode 预先检查:

public class MaxStringLengthNonUnicodeConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            var propertyBuilder = property.Builder;
            if (propertyBuilder.CanSetMaxLength(512)
                && propertyBuilder.CanSetIsUnicode(false))
            {
                propertyBuilder.HasMaxLength(512)!.IsUnicode(false);
            }
        }
    }
}

在这里,我们可以确保调用 HasMaxLength 不会返回 null。 依然建议使用从 HasMaxLength 返回的生成器实例,因为它可能与 propertyBuilder 不同。

注意

其他约定在约定进行更改后不会立即触发,这些约定会延迟直到所有约定完成对当前更改的处理。

IConventionContext

所有约定方法都具有 IConventionContext<TMetadata> 参数。 它提供在某些特定情况下可能有用的方法。

示例:NotMappedAttribute 约定

此约定在添加到模型中的类型上查找 NotMappedAttribute,并尝试从模型中删除该实体类型。 但是,如果实体类型已从模型中删除,则不再需要运行实现 ProcessEntityTypeAdded 的任何其他约定。 这可以通过调用 StopProcessing() 来实现:

public virtual void ProcessEntityTypeAdded(
    IConventionEntityTypeBuilder entityTypeBuilder,
    IConventionContext<IConventionEntityTypeBuilder> context)
{
    var type = entityTypeBuilder.Metadata.ClrType;
    if (!Attribute.IsDefined(type, typeof(NotMappedAttribute), inherit: true))
    {
        return;
    }

    if (entityTypeBuilder.ModelBuilder.Ignore(entityTypeBuilder.Metadata.Name, fromDataAnnotation: true) != null)
    {
        context.StopProcessing();
    }
}

IConventionModel

传递给约定的每个生成器对象都会公开一个 Metadata 属性,该属性提供对构成模型的对象的低级别访问。 具体而言,有些方法允许循环访问模型中的特定对象,并对其应用通用配置,如示例:所有字符串属性的默认长度中所示。 此 API 类似于批量配置中的 IMutableModel

注意

建议始终通过调用作为 Builder 属性公开的生成器上的方法来执行配置,因为生成器会检查给定的配置是否会替代已使用 Fluent API 或数据注释指定的内容。

何时使用每种方法进行批量配置

在以下情况下使用元数据 API

  • 配置需要在某个时间应用,而无需对模型中的后续更改做出反应。
  • 模型生成速度非常重要。 元数据 API 的安全检查较少,因此比其他方法稍快一些,但是使用编译模型会产生更好的启动时间。

在以下情况下使用预先约定模型配置

  • 适用条件很简单,因为它仅取决于类型。
  • 需要在模型中添加给定类型的属性并替代数据注释和约定时,随时应用配置

在以下情况下使用最终约定

  • 适用条件很复杂。
  • 配置不应替代数据注释指定的内容。

在以下情况下使用交互式约定

  • 多个约定相互依赖。 最终约定按照添加顺序运行,因此无法对后面的最终约定所做的更改做出反应。
  • 逻辑在多个上下文之间共享。 交互式约定比其他方法更安全。