模型批量配置

当需要跨多个实体类型以相同方式配置方面时,以下技术允许减少代码重复并合并逻辑。

请参阅包含下面介绍的代码片段 的完整示例项目

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 类型指定一次映射配置;该配置随后将在模型中该类型的所有属性被识别时应用。 这称为“约定前模型配置”,因为它在模型生成约定运行之前对模型的各个方面进行配置。 通过重写 ConfigureConventions 派生自 DbContext的类型来应用此类配置。

此示例演示如何将类型 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. 精确类型

重要

预先约定的配置等同于显式配置,并在匹配对象添加至模型时立即应用。 它将覆盖所有约定和数据注释。 例如,在上述配置中,所有字符串外键属性都将创建为非 Unicode,大小为 MaxLength 1024,即使这与主键不匹配。

忽略类型

预先约定配置还允许忽略某种类型,并防止其被约定用作实体类型或实体类型的属性。

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

默认类型映射

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

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

约定前配置的限制

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

惯例

注释

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

EF Core 模型构建约定是一些类,这些类包含逻辑,这些逻辑是在构建模型时根据对模型所做的更改触发。 通过进行显式配置、应用映射属性并执行其他约定,保证模型 up-to-date 的持续有效。 为了参与此目的,每个约定都实现一个或多个接口,用于确定何时触发相应的方法。 例如,每当向模型添加新实体类型时,都会触发实现 IEntityTypeAddedConvention 的约定。 无论何时在模型中添加键或外键,都会触发实现 IForeignKeyAddedConventionIKeyAddedConvention 的约定。

模型构建约定是控制模型配置的强大工具,但可能会很复杂,并且难以做到准确无误。 在许多情况下,可以使用 前约定模型配置 来轻松指定属性和类型的通用配置。

添加新约定

示例:约束歧视性属性的长度

每个层次结构的表继承映射策略需要一个鉴别器列来指定在任何给定行中表示的类型。 默认情况下,EF 使用不限制长度的字符串列作为鉴别器,这确保了它可以适用于任何长度的鉴别器。 但是,限制歧视性字符串的最大长度可能会提高存储和查询的效率。 让我们创建一个新的惯例来完成这一任务。

EF Core 模型生成约定是根据模型构建过程中对模型的更改来触发。 这样保持模型up-to的最新状态,因为进行了显式配置,并应用了映射属性及其他约定。 为了参与此目的,每个约定实现一个或多个接口,用于确定何时触发约定。 例如,每当向模型添加新实体类型时,都会触发实现 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 都 up-to-date。 模型完成约定通常会迭代处理整个模型,并配置和调整模型元素。 因此,在这种情况下,我们会在模型中找到每个鉴别器并对其进行配置:

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,这是 'Featured' 的长度,即使用中最长的鉴别器值。

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。 这是通过使用约定生成器实现的,例如IConventionPropertyBuilder,它是从Builder属性获取的。 例如:

property.Builder.HasMaxLength(512);

HasMaxLength 如果尚未由映射属性或 in 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 的安全检查更少,因此比其他方法要快一些,但是使用 已编译的模型 会产生更好的启动时间。

在以下情况下使用 会前模型配置

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

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

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

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

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