Entity Framework 7预览版7新功能——拦截器功能特点及使用教程

431 阅读13分钟

Entity Framework Core 7 (EF7) Preview 7已经发货,支持几个新的拦截器,以及对现有拦截器的改进。这篇博文将介绍这些变化,但预览版7还包含了一些额外的增强功能,包括:

在GitHub上查看EF7预览版7的完整变化列表

拦截器

EF Core拦截器实现了对EF Core操作的拦截、修改和/或压制。这包括低级别的数据库操作,如执行命令,以及高级别的操作,如调用SaveChanges 。 拦截器支持异步操作以及修改或抑制操作的能力,使其比传统的事件、日志和诊断功能更强大。

拦截器是在配置DbContext实例时注册的,可以在OnConfiguringAddDbContext 比如说:

public class ExampleContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}

本文章中所有例子的代码都可以在GitHub上找到

EF7中新的和改进的拦截器

EF7包括以下拦截器的改进:

此外,EF7还包括新的传统的.NET事件,用于:

下面几节展示了一些使用这些新拦截功能的例子。

实体创建时的简单动作

新的IMaterializationInterceptor支持在实体实例创建前后以及该实例的属性被初始化前后的拦截。拦截器可以在每个点上改变或替换实体实例。这允许:

  • 设置未映射的属性或调用验证、计算值或标志所需的方法
  • 使用一个工厂来创建实例
  • 创建与EF通常创建的不同的实体实例,例如来自缓存的实例,或代理类型的实例
  • 将服务注入到实体实例中

例如,想象一下,我们想跟踪一个实体从数据库中检索出来的时间,也许这样就可以显示给编辑数据的用户。为了达到这个目的,我们首先定义一个接口:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

使用接口在拦截器中很常见,因为它允许同一个拦截器与许多不同的实体类型一起工作。比如说:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

注意,[NotMapped] 属性是用来表示这个属性只在处理实体的时候使用,而不应该被持久化到数据库中。

然后,拦截器必须从IMaterializationInterceptor ,实现适当的方法,并设置检索的时间:

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

在配置DbContext ,这个拦截器的实例被注册:


public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

TIP这个拦截器是无状态的,这很常见,所以要创建一个实例,并在所有DbContext 实例之间共享。

现在,每当从数据库中查询到一个CustomerRetrieved 属性就会被自动设置。比如说:

using (var context = new CustomerContext())
{
    var customer = context.Customers.Single(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

产生输出:

Customer 'Alice' was retrieved at '8/6/2022 7:50:25 PM'

LINQ表达式树拦截

EF Core利用了.NET的LINQ查询。这通常涉及到使用C#、VB或F#编译器来构建表达式树,然后由EF Core翻译成适当的SQL。例如,考虑一个返回客户页面的方法:

List<Customer> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToList();
}

小贴士这个查询使用 EF.Property方法来指定要排序的属性。这允许应用程序动态地传入属性名称,轻松地允许按实体类型的任何属性进行排序。

只要用于排序的属性总是返回一个稳定的排序,这就能正常工作。但情况可能并不总是这样。例如,上面的LINQ查询在SQLite上按Customer.City 排序时,会产生以下结果:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

如果有多个客户具有相同的City ,那么这个查询的排序就不稳定。这可能导致用户在翻阅数据时出现遗漏或重复的结果。

解决这个问题的一个常见方法是按主键进行二次排序。然而,我们可以拦截查询表达式树,并在遇到OrderBy ,动态地添加二级排序,而不是手动将其添加到每个查询中。为了方便起见,我们将再次使用一个接口,这次是针对任何具有整数主键的实体:

public interface IHasIntKey
{
    int Id { get; }
}

这个接口是由感兴趣的实体类型实现的:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

然后我们需要一个拦截器来实现 IQueryExpressionInterceptor:

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression ProcessingQuery(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        methodCallExpression,
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

这可能看起来很复杂,而且确实如此与表达式树一起工作通常是不容易的。让我们来看看发生了什么:

  • 从根本上说,拦截器封装了一个 ExpressionVisitor.访客重写了VisitMethodCall ,每当查询表达式树中有方法被调用时,它就会被调用。
  • 访客会检查这是否是对我们感兴趣的 Queryable.OrderBy方法的调用。
  • 如果是这样,那么访问者就会进一步检查泛型方法的调用是否为实现了我们的IHasIntKey 接口的类型。
  • 在这一点上,我们知道方法调用的形式是OrderBy(e => ...) 。我们从这个调用中提取lambda表达式,并获得该表达式中使用的参数--即e
  • 我们现在使用构建器方法建立一个新的MethodCallExpressionExpression.Callbuilder方法。在这种情况下,被调用的方法是ThenBy(e => e.Id) 。我们使用上面提取的参数和对IHasIntKey 接口的Id 属性的访问来构建这个。
  • 这个调用的输入是原始的OrderBy(e => ...) ,所以最终的结果是OrderBy(e => ...).ThenBy(e => e.Id) 的表达式。
  • 这个修改过的表达式从访问者那里返回,这意味着LINQ查询现在已经被适当地修改,以包括一个ThenBy 的调用。
  • EF Core继续将这个查询表达式编译为所使用的数据库的适当SQL。

这个拦截器的注册方式与我们在第一个例子中的注册方式相同。现在执行GetPageOfCustomers ,会生成以下SQL:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

现在这将总是产生一个稳定的排序,即使有多个客户具有相同的City

唷!这可是一大堆代码。为了对一个查询做一个简单的改变,这可是一大堆代码。更糟糕的是,它甚至不一定对所有的查询都有效。编写一个表达式访问者,使其能够识别所有应该识别的查询形状,而不识别任何不应该识别的查询形状,这是众所周知的困难。例如,如果排序是在子查询中完成的,这可能就不工作。

这给我们带来了一个关于拦截器的关键点--总是问自己是否有一个更简单的方法来做你想要的事情。拦截器很强大,但也很容易出错。俗话说,拦截器是一种很容易让你自食其果的方法。

例如,想象一下,如果我们把我们的GetPageOfCustomers 方法改成这样:

List<Customer> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToList();
}

在这种情况下,ThenBy 被简单地添加到查询中。是的,这可能需要对每个查询单独进行,但它很简单,容易理解,而且总是能发挥作用。

乐观的并发性拦截

EF Core通过检查实际受更新或删除影响的行数是否与预期受影响的行数相同来支持乐观的并发模式。这通常与并发标记结合在一起;也就是说,只有在读取预期值后该行没有被更新的情况下,该列值才会与预期值一致。

EF通过抛出一个信号,表明违反了乐观的并发性。 DbUpdateConcurrencyException.在EF7中 ISaveChangesInterceptor有新的方法ThrowingConcurrencyExceptionThrowingConcurrencyExceptionAsync ,在抛出DbUpdateConcurrencyException 之前被调用。这些拦截点允许抑制异常,可能会配合异步数据库变化来解决违规问题。

例如,如果两个请求几乎同时试图删除同一个实体,那么第二个删除可能会失败,因为数据库中的行已经不存在了。这可能是好的--最终的结果是,无论如何实体已经被删除了。下面的拦截器演示了如何做到这一点:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData) eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

关于这个拦截器,有几件事值得注意:

  • 同步和异步拦截方法都已实现。如果应用程序可能会调用SaveChangesSaveChangesAsync ,这一点就很重要。然而,如果所有的应用程序代码都是异步的,那么只需要实现ThrowingConcurrencyExceptionAsync 。同样地,如果应用程序从不使用同步数据库方法,那么只需要实现ThrowingConcurrencyException 。这对于所有具有同步和异步方法的拦截器来说都是如此。(也许值得实现你的应用程序不使用的抛出方法,以防一些同步/async代码悄悄进入。)
  • 拦截器可以访问 EntityEntry被保存的实体的对象。在这种情况下,这被用来检查在删除操作中是否发生了并发性违反。
  • 如果应用程序使用的是关系型数据库提供者,那么该 ConcurrencyExceptionEventData对象可以被转换为一个 RelationalConcurrencyExceptionEventData对象。这提供了关于正在执行的数据库操作的额外的、特定于关系的信息。在这种情况下,关系命令文本被打印到控制台。
  • 返回InterceptionResult.Suppress() ,告诉EF Core抑制它将要采取的行动--在这种情况下,抛出DbUpdateConcurrencyException 。这是拦截器最强大的功能之一,可以改变EF Core的行为,而不仅仅是观察EF Core在做什么。

摘要

EF Core 7.0(EF7)增加了几个新的拦截器,并增强了已有的拦截器的行为。拦截器允许应用程序以类似于事件的方式对EF正在做的事情做出反应。然而,拦截器也允许通过抑制或替换EF将要执行的动作,或者修改或替换该动作的结果来改变EF的行为。在适当的时候,拦截器可以执行异步数据库访问,允许拦截器改变完整的数据库命令。

关于EF Core拦截器的更多信息,请参见 拦截器在EF Core文档中。

另外,EF团队在《.NET Data Community Standup》的一集中展示了一些额外的深入的拦截器例子--现在可以在YouTube上观看

最后,别忘了,本篇文章中的所有代码都可以在GitHub上找到

EF7的先决条件

  • EF7的目标是.NET 6,这意味着它可以在.NET 6(LTS)或.NET 7上使用。
  • EF7不会在.NET框架上运行。

EF7是EF Core 6.0的后继者,不能与EF6混淆。如果你正在考虑从EF6升级,请阅读我们的指南,从EF6移植到EF Core

如何获得EF7预览版

EF7是以一套NuGet包的形式专门发布的。例如,要在你的项目中添加SQL Server提供者,你可以使用dotnet工具使用以下命令。

dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.0-preview.7.22376.2

下面的表格链接到EF Core包的预览7版本,并描述了它们的用途。

用途
Microsoft.EntityFrameworkCore独立于特定数据库提供者的主要EF Core包。
Microsoft.EntityFrameworkCore.SqlServerMicrosoft SQL Server和SQL Azure的数据库提供商。
Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuiteSQL Server对空间类型的支持
Microsoft.EntityFrameworkCore.SqliteSQLite的数据库提供者,包括数据库引擎的本地二进制文件
Microsoft.EntityFrameworkCore.Sqlite.Core用于SQLite的数据库提供者,没有打包的本地二进制文件
Microsoft.EntityFrameworkCore.Sqlite.NetTopologySuiteSQLite对空间类型的支持
微软.EntityFrameworkCore.Cosmos用于Azure Cosmos DB的数据库提供商
Microsoft.EntityFrameworkCore.InMemory内存数据库提供者
微软.EntityFrameworkCore.Tools用于Visual Studio Package Manager Console的EF Core PowerShell命令;使用它可以将脚手架迁移等工具与Visual Studio集成起来。
Microsoft.EntityFrameworkCore.Design用于EF Core工具的共享设计时组件
Microsoft.EntityFrameworkCore.Proxies懒惰的加载和变化跟踪的代理
Microsoft.EntityFrameworkCore.Abstractions解耦的EF Core抽象;使用它来实现EF Core定义的扩展数据注释等功能。
Microsoft.EntityFrameworkCore.Relational用于关系型数据库提供者的共享的EF Core组件
Microsoft.EntityFrameworkCore.Analyzers用于EF Core的C#分析器

我们还发布了用于ADO.NETMicrosoft.Data.Sqlite.Core提供者的7.0预览7版。

安装EF7命令行界面(CLI)

在你执行EF7 Core迁移或脚手架命令之前,你必须将CLI包安装为全局或本地工具。

要全局安装预览工具,请安装:

dotnet tool install --global dotnet-ef --version 7.0.0-preview.7.22376.2 

如果你已经安装了该工具,你可以用下面的命令来升级它:

dotnet tool update --global dotnet-ef --version 7.0.0-preview.7.22376.2 

在使用旧版本的EF Core运行时的项目中使用这个新版本的EF7 CLI是可能的。

日常构建

EF7预览版与.NET 7预览版是一致的。这些预览版往往滞后于EF7的最新工作。考虑使用日常构建来获得最新的EF7功能和错误修复。

与预览版一样,日常构建需要.NET 6。

.NET数据社区的准备工作

.NET数据团队现在每隔周三在太平洋时间上午10点,东部时间下午1点,或UTC时间17点进行直播。加入流媒体,就您选择的数据相关主题提出问题,包括最新的预览版:

文档和反馈

所有EF Core文档的起点是docs.microsoft.com/ef/

请在dotnet/efcore GitHub repo上提交发现的问题和任何其他反馈。

有用的链接

以下链接是为方便参考和访问而提供的。

来自团队的感谢

EF团队对多年来使用EF并为之做出贡献的所有人表示衷心的感谢!

欢迎来到EF7。