惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
F
Fortinet All Blogs
云风的 BLOG
云风的 BLOG
MyScale Blog
MyScale Blog
D
DataBreaches.Net
Stack Overflow Blog
Stack Overflow Blog
A
About on SuperTechFans
Google DeepMind News
Google DeepMind News
Microsoft Security Blog
Microsoft Security Blog
腾讯CDC
The GitHub Blog
The GitHub Blog
Jina AI
Jina AI
B
Blog RSS Feed
I
InfoQ
N
Netflix TechBlog - Medium
T
The Blog of Author Tim Ferriss
Microsoft Azure Blog
Microsoft Azure Blog
Recent Announcements
Recent Announcements
GbyAI
GbyAI
H
Help Net Security
L
LangChain Blog
M
MIT News - Artificial intelligence
Y
Y Combinator Blog
aimingoo的专栏
aimingoo的专栏

博客园 - 东邪独孤

【ASP.NET Core】Cookie 验证中回调用 URL 自动跳转的问题 【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】级联删除行为 【EF Core】继承策略——TPC 【EF Core】继承策略——TPT 【EF Core】继承策略——TPH 【EF Core】使用自定义的值比较器 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体追踪——Entry中记录的数据 【EF Core】实体状态与变更追踪 【EF Core】将一个实体映射到多个表的正确方法 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】未定义实体类的数据库模型 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【EF Core】三种方法记录生成的 SQL 语句
东邪独孤 · 2025-12-01 · via 博客园 - 东邪独孤

原本计划 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 语句;

3、ToQueryString 扩展方法。

修改说明:本文当初写的是两种方法,因为老周老糊涂了,遗忘了还有 ToQueryString 扩展方法,现改为三种方法。

-----------------------------------------------------------------------------------------------------------------------------------

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

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(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

-----------------------------------------------------------------------

第三种方法很简单,就是 IQueyable 的扩展方法。直接上示例就行了。

using var c = new MyDbContext();
var q = from s in c.Songs
             where s.Artist != null && s.Artist.StartsWith("L")
             select s;
string sql = q.ToQueryString();
Console.WriteLine(sql);

运行结果如下:

SELECT "t"."ID", "t"."Artist", "t"."Duration", "t"."Name"
FROM "tb_songs" AS "t"
WHERE "t"."Artist" IS NOT NULL AND "t"."Artist" LIKE 'L%'

此方法的缺点是只能获取到查询语句,插入、删除等语句不能获取。

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