【EF Core】两种方法记录生成的 SQL 语句

原本计划 N 天前写的内容,无奈拖到今天。大伙伴们可能都了解,年近岁末,风干物燥,bug 特多,改需求的精力特旺盛。有几个工厂的项目需要不同程度的修改或修复。这些项目都是老周个人名义与他们长期合作的(有些项目已断尾了,他们觉得不用再改了),所以不一定都是新项目,有两三个都维护好几年了。

今天咱们的主题是记录 SQL 语句。用过 EF 的都知道,它可以将 LINQ 表达式树翻译成 SQL 语句,然后发送到数据库执行。这个框架从 Framework 时代走到 Core 时代,虽说不是什么新鲜技术,但这活真的是好活,以面向对象的方式与数据库交互是真的爽。

将 LINQ 转译为 SQL 是框架内部功能,官方团队或许也没考虑让我们做太多的扩展(实际开发中也的确很少),因此,框架并没有提供独立的服务让我们去做表达式树的翻译。在执行查询时,EF Core 是经过几个步骤的,这个可以看看 QueryCompilationContext 类的源代码。其实处理查询转译的代码是写在这个类里面的,不是 Database 类。上次在某公司有位妹子程序员问过老周,她想看看 LINQ 翻译 SQL 的大致过程,可在源代码中找不到。你不要惊讶,这个公司的团队绝对少见,七个成员,四个是女的,恐怕你都找不出第二个这样的团队。

老周告诉她,源代码庞大,直接拿着看很多东西不好找的,你可以用调试进入源码,一步步跟进去,才比较好找。不废话了,咱们看代码。

复制代码
    public virtual Expression<Func<QueryContext, TResult>> CreateQueryExecutorExpression<TResult>(Expression query)
    {
        var queryAndEventData =Logger.QueryCompilationStarting(Dependencies.Context, _expressionPrinter, query);
        var interceptedQuery = queryAndEventData.Query;

        var preprocessedQuery = _queryTranslationPreprocessorFactory.Create(this).Process(interceptedQuery);
        var translatedQuery = _queryableMethodTranslatingExpressionVisitorFactory.Create(this).Translate(preprocessedQuery);
        var postprocessedQuery = _queryTranslationPostprocessorFactory.Create(this).Process(translatedQuery);

        var compiledQuery = _shapedQueryCompilingExpressionVisitorFactory.Create(this).Visit(postprocessedQuery);

        // If any additional parameters were added during the compilation phase (e.g. entity equality ID expression),
        // wrap the query with code adding those parameters to the query context
        var compiledQueryWithRuntimeParameters = InsertRuntimeParameters(compiledQuery);

        return Expression.Lambda<Func<QueryContext, TResult>>(
            compiledQueryWithRuntimeParameters,
            QueryContextParameter);
    }

这代码一旦展开是非常复杂的,你不仅要有使用 LINQ 表达式树的知识,还得看懂其思路,所以,没兴趣的话就不用看了。而且看不懂也不影响写代码。大体过程是这样的:

1、先执行拦截器。拦截器这东西老周以后会介绍,拦截器可以拦截你执行的 LINQ 表达式树,并且你可以在翻译之前修改它。

2、预处理。这里面又是一堆处理,如参数命名规整化、把如 long.Max 这样的方法调用标准化为 Math.Max 调用、表达式简化等等。

3、特殊方法调用转换,如调用 Where、All、FirstOrDefault 这样标准查询方法,还有 ExecuteUpdate、ExecuteDelete 这些专用方法的调用转换等。

4、转换扫尾工作,这个主要是不同数据库的特殊处理,比如,Sqlite 和 SQL Server 的处理不同。

5、正式转译为 SQL 语句。

6、生成 Lambda 表达式树。这个委托接收 QueryContext 类型的参数(可以用 IQueryContextFactory 服务创建),返回的结果一般是 IEnumerable<T>。

想想,调用这些代码获取 SQL 太麻烦,这等同于把人家源代码抄一遍了。其实,单纯的把 LINQ 转 SQL 意义不大的,许多场景下,可能最需要的是日志功能------记录发送到数据库的 SQL 语句。

好了,上面的只是理论铺设,接下来咱们聊主题。咱们有两种方法可以记录SQL语句,不废话,老周直接说答案:

1、通过日志 + 事件过滤功能。这个最简单;

2、通过拦截器拦截 DbCommand 对象,从而获取 SQL 语句。


先说第一种,先写个实体类,随便写就行。

复制代码
public class Song
{
    public int ID { get; set; }
    public string Name { get; set; } = null!;
    public string? Artist { get; set; }
    public long Duration { get; set; }
}

然后写数据库上下文。

复制代码
public class MyDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=demo.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Song>(et =>
        {
            et.ToTable("tb_songs");
            et.HasKey(x => x.ID).HasName("PK_Song");
            et.Property(a => a.Name).HasMaxLength(20);
        });
    }

    // 公开数据集合
    public DbSet<Song> Songs { get; set; }
}

写好后回过头看 OnConfiguring 方法,现在咱们要配置日志。

复制代码
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=demo.db");
        optionsBuilder.LogTo(
            // 第一个委托:过滤事件
            (eventid, loglevel) =>
            {
                if(eventid.Id ==RelationalEventId.CommandExecuting)
                {
                    return true;
                }
                return false;
            },
            // 第二个委托:记录SQL
            eventData =>
            {
                // 转换事件数据
                if(eventData is CommandEventData data)
                {
                    // 记录SQL
                    Console.Write("命令源:{0}", data.CommandSource);
                    Console.Write(",SQL 语句:{0}", data.LogCommandText);
                    Console.Write("\n\n");
                }
            });
    }

这里,LogTo 调用的是以下重载:

复制代码
public virtual DbContextOptionsBuilder LogTo(
    Func<EventId, LogLevel, bool> filter,
    Action<EventData> logger)

filter 是个过滤器,EventId 表示相关事件,LogLevel 表示日志级别,如 Information、Warning、Error 等。第三个是返回值,布尔类型。所以,这个委托的用法很明显,如果返回 false,表示不记录该事件的日志,第二个委托logger就不会调用;如果过滤器返回 true,表明要接收此事件的日志,此时,logger 委托会调用。

咱们的代码只关心 CommandExecuting 事件,这是 DbCommand 执行之前触发的,如果是命令执行之后,会触发 CommandExecuted 事件。咱们的目标明确------获取生成的 SQL 语句,其实这里也可以用 CommandInitialized 事件。其实 CommandInitialized、CommandExecuting、CommandExecuted 三个事件都能得到 SQL 语句,任意抓取一个用即可。

在第二个委托中,它有一个输入参数------ EventData,它是所有事件数据的基类,所以,在委托内部需要进行类型分析。

复制代码
if(eventData is CommandEventData data)
     ......

不过这里我们不必关心其他类型,毕竟 filter 只选出一个事件,其他事件都返回 false,不会调用 logger 委托。

从 LogCommandText 属性上就能得到 SQL 语句。另外,CommandSource 是一个枚举,它表示这个 SQL 语句是由哪个操作引发的。如

  • SaveChanges:你调用 DbContext.SaveChanges 方法后保存数据时触发的。
  • Migrations:迁移数据库时触发,包括在运行阶段执行迁移,或者调用 Database.EnsureCreate 或 EnsureDelete 等方法也会触发。
  • LinqQuery:这个熟悉,就是你常规操作,使用 LINQ 查询转 SQL 后执行。
  • ExecuteDelete 与 ExecuteUpdate:就是调用 ExecuteUpdate、ExecuteDelete 方法时触发。

好,咱们试一下,先用 EnsureCreate 创建数据库,然后执行一个查询。

复制代码
using var ctx = new MyDbContext();
ctx.Database.EnsureCreated();
// 查询
var res = ctx.Songs
                    .Where(s => s.ID > 2)
                    .ToArray();

运行一下看看。结果如下:

复制代码
命令源:Migrations,SQL 语句:PRAGMA journal_mode = 'wal';

命令源:Migrations,SQL 语句:CREATE TABLE "tb_songs" (
    "ID" INTEGER NOT NULL CONSTRAINT "PK_Song" PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL,
    "Artist" TEXT NULL,
    "Duration" INTEGER NOT NULL
);


命令源:LinqQuery,SQL 语句:SELECT "t"."ID", "t"."Artist", "t"."Duration", "t"."Name"
FROM "tb_songs" AS "t"
WHERE "t"."ID" > 2

前两个语句的命令源都是 Migrations,这是创建数据库和表时的语句(SQLite 不需要 CREATE DATABASE 语句,直接建表)。第三个就是咱们执行查询生成的 SQL 语句,可以看到命令源是 LinqQuery。


现在看一下第二种方案,咱们先把数据库上下文的 OnConfiguring 方法中的日志配置注释掉。

现在,咱们实现自己的命令拦截器。

拦截器的基础接口是 IInterceptor,它是个空接口,没有任何成员,仅作为标志。咱们一般不会直接实现它。

拦截命令,框架提供的是 IDbCommandInterceptor 接口,它要求你实现以下成员:

复制代码
public interface IDbCommandInterceptor : IInterceptor
{
    // 当 DbCommand 对象(不同数据库有具体的类)被创建前触发
    // 这个时候是获取不到 SQL 语句的,命令对象原则上还没创建
    // 但是,你可以自己创建一个,并用 InterceptorResult 返回
    // 你要么原样返回,要么用 SuppressWithResult 静态方法自己创建一个命令对象
    // 这时候 EF Core 会用你创建的命令对象代替内部代码所创建的命令对象
    // 注意这里只是抑制内部创建命令对象而已,并不能阻止命令的执行
    InterceptionResult<DbCommand> CommandCreating(CommandCorrelatedEventData eventData, InterceptionResult<DbCommand> result)
    {
        return result;
    }

    // 命令对象创建后,这里是 EF Core 负责创建命令对象,你负责修改
    // 不修改就原样返回。此时,你不能自己 new 命令对象了,只能修属性
    DbCommand CommandCreated(CommandEndEventData eventData, DbCommand result)
    {
        return result;
    }

    // 这里可以获取到 SQL 了
    DbCommand CommandInitialized(CommandEndEventData eventData, DbCommand result)
    => result;

    // 和前面的命令对象一样,这里你可以用自己创建的 DataReader 代替框架内部创建的
    // 这是有查询结果的 reader,比如 SELECT 语句
    // 此时还没有执行 SQL
    InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
        => result;

    // 查询单个标量值之前调用此方法,你可以自己分配一个值来代替     
    // 此时还没执行 SQL 语句
    InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result)
        => result;

     // 执行无结果查询前触发,你可以自己弄一个结果值覆盖真实查询的结果
     // 此时还没有执行 SQL 语句
    InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
        => result;

     // 异步版本
    ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
        => new(result);

     // 异步版本
    ValueTask<InterceptionResult<object>> ScalarExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<object> result,
        CancellationToken cancellationToken = default)
        => new(result);

    // 异步版本
    ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
        => new(result);

    // 以下是 SQL 语句执行完毕,且从数据库返回结果,但你仍可以处理这些结果
    DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
        => result;
    object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
        => result;
    int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
        => result;

     // 以下是异步版本
    ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
        => new(result);
    ValueTask<object?> ScalarExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        object? result,
        CancellationToken cancellationToken = default)
        => new(result);
    ValueTask<int> NonQueryExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
        => new(result);

      // 以下是命令被取消或执行失败后调用
    void CommandCanceled(DbCommand command, CommandEndEventData eventData)
    {
    }
    Task CommandCanceledAsync(DbCommand command, CommandEndEventData eventData, CancellationToken cancellationToken = default)
        => Task.CompletedTask;
    void CommandFailed(DbCommand command, CommandErrorEventData eventData)
    {
    }
    Task CommandFailedAsync(DbCommand command, CommandErrorEventData eventData, CancellationToken cancellationToken = default)
        => Task.CompletedTask;

     // 以下是当 dataReader 被关闭前触发
    InterceptionResult DataReaderClosing(DbCommand command, DataReaderClosingEventData eventData, InterceptionResult result)
        => result;
    ValueTask<InterceptionResult> DataReaderClosingAsync(DbCommand command, DataReaderClosingEventData eventData, InterceptionResult result)
        => new(result);

    // dataReader 被释放前触发
    // 这个最好原样返回,就算你 Suppressed 它,阻止不了连象、命令、阅读器被设为 null
    // Suppressed 它纯粹只是在设为 null 前不调用 Dispose 方法罢了
    InterceptionResult DataReaderDisposing(DbCommand command, DataReaderDisposingEventData eventData, InterceptionResult result)
        => result;
}

你看看,我只是想拦截某个操作,却要实现这么多方法,这太不像话了。为了你觉得像话,EF Core 提供了一个抽象类,叫 DbCommandInterceptor,它会以默认行为实现 IDbCommandInterceptor 接口。这样你就轻松了,想修改哪个操作,只要重写某个方法成员就好了。

这里,咱们要获取 SQL 语句,只有在 CommandInitialized 时 SQL 语句才被设置,所以重写 CommandInitialized 方法。

复制代码
public class MyCommandInterceptor : DbCommandInterceptor
{
    public override DbCommand CommandInitialized(CommandEndEventData eventData, DbCommand result)
    {
        // 只获取 LINQ 查询生成的 SQL 语句
        if (eventData.CommandSource == CommandSource.LinqQuery)
        {
            // 第一种方法
            Console.WriteLine($"\nLog Command:\n{eventData.LogCommandText}");
            // 第二种方法
            Console.WriteLine($"DB Command:\n{result.CommandText}\n");
            // 第三种方法
            //Console.WriteLine($"From Event Data:\n{eventData.Command.CommandText}\n");
        }
        return base.CommandInitialized(eventData, result);
    }
}

其实传入方法的参数里面有些对象是重复的,所以你有多个方法来获取 SQL。eventData.Command 其实就是 result 参数所引用的对象,所以随便哪个的 CommandText 属性都能获取 SQL 语句;另外,eventData 的 LogCommandText 属性也是 SQL 语句。这些方法你随便选一个。

上述代码中,老周用 CommandSource.LinqQuery 进行判断,咱们只记下由 LINQ 查询生成的 SQL 语句。

现在回到数据库上下文类,在 OnConfiguring 方法中添加刚刚弄好的拦截器。

复制代码
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=demo.db");
        optionsBuilder.AddInterceptors(newMyCommandInterceptor());
    }

调用 AddInterceptors 方法,把你想要添加的拦截器实例扔进去就完事了。

再次运行程序,控制台输出以下内容:

复制代码
Log Command:
SELECT "t"."ID", "t"."Artist", "t"."Duration", "t"."Name"
FROM "tb_songs" AS "t"
WHERE "t"."ID" > 2
DB Command:
SELECT "t"."ID", "t"."Artist", "t"."Duration", "t"."Name"
FROM "tb_songs" AS "t"
WHERE "t"."ID" > 2

好了,今天的内容就到这里完毕了,下次老周继续聊 EF Core。这是老周的习惯,抓住一个主题聊他个天荒地老。