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