【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("nn");                 }             });     }

这里,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(new MyCommandInterceptor());     }

调用 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。这是老周的习惯,抓住一个主题聊他个天荒地老。

 

发表评论

评论已关闭。

相关文章