博客 / 詳情

返回

【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(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。這是老周的習慣,抓住一個主題聊他個天荒地老。

 

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.