Entity Framework Core 5中实现批量更新、删除
本文介紹了一個(gè)在EntityFramework Core 5中不需要預(yù)先加載數(shù)據(jù)而使用一句SQL語(yǔ)句批量更新、刪除數(shù)據(jù)的開(kāi)發(fā)包,并且分析了其實(shí)現(xiàn)原理,并且與其他實(shí)現(xiàn)方案做了比較。
一、背景
隨著微軟全面擁抱開(kāi)源,.Net開(kāi)源社區(qū)百花開(kāi)放,涌現(xiàn)了非常多優(yōu)秀的開(kāi)源,ORM項(xiàng)目就有Dapper、SqlSugar、PetaPoco、FreeSQL等。作為微軟官方提供的ORM框架,Entity Framework Core(以下簡(jiǎn)稱(chēng)EF Core)顯然是被關(guān)注最多的。EF Core非常優(yōu)秀而且功能豐富,但是EF Core有一個(gè)一直被人詬病的地方就是它并不能很好支持?jǐn)?shù)據(jù)的批量更新和批量刪除。在EF Core中批量更新和刪除數(shù)據(jù)都要先把數(shù)據(jù)加載到內(nèi)存中,然后再對(duì)數(shù)據(jù)操作,最后再SaveChanges,比如下面的代碼用于把所有Id大于2或者AuthorName中含有”zack”的價(jià)格增加3:
var books2 = ctx.Books.Where(b => b.Id >2||b.AuthorName.Contains("zack"));
foreach(var b in books2)
{
??? b.Price =b.Price + 3;
}
ctx.SaveChanges();
?
讓我們查看上面的程序幕后執(zhí)行的SQL語(yǔ)句:
?
可以看到,EF Core先把數(shù)據(jù)用Select查詢出來(lái),然后在內(nèi)存中逐個(gè)修改,最后再把被修改對(duì)象每個(gè)都執(zhí)行一次Update語(yǔ)句去更新。
?
再比如,如下的代碼用于刪除Price大于5元的記錄:
var books1 = ctx.Books.Where(b => b.Price > 5);
ctx.RemoveRange(books1);
ctx.SaveChanges();
?
讓我們查看上面的程序運(yùn)行幕后執(zhí)行的SQL語(yǔ)句:
?
?
可以看到,EF Core先把數(shù)據(jù)用Select查詢出來(lái),然后再對(duì)每條記錄都執(zhí)行Delete語(yǔ)句去刪除。
很顯然,如果批量更新或者刪除的數(shù)據(jù)量比較大,這樣的操作性能是非常低的。
因此,我們需要一種在EF Core中使用一條SQL語(yǔ)句就高性能地刪除或者更新數(shù)據(jù)的方法。
?
二、為什么微軟不提供這樣的方法
盡管用戶的要求強(qiáng)烈,但是微軟一直沒(méi)有提供高效的批量刪除和更新的方式。在EF Core Github的issue中?[1],微軟給出的理由是:這樣做會(huì)導(dǎo)致EF Core的對(duì)象狀態(tài)跟蹤混亂,比如對(duì)于同一個(gè)DbContext,如果用批量刪除的方法刪除了數(shù)據(jù),那么在被刪除之前查詢出來(lái)的數(shù)據(jù)狀態(tài)就混亂了,因此需要重構(gòu)EF Core的代碼,工作量比較大。
作為一個(gè)成熟的框架,考慮這些邏輯問(wèn)題以避免潛在的風(fēng)險(xiǎn)是有必要的,是可以理解的。但是作為實(shí)際的開(kāi)發(fā)者,我們是有辦法規(guī)避這些問(wèn)題的。比如一般的Web應(yīng)用中,刪除操作都是在一個(gè)單獨(dú)的Http請(qǐng)求進(jìn)行中的,因此不涉及到微軟擔(dān)心的問(wèn)題。即使在有的場(chǎng)景下,涉及到在通過(guò)同一個(gè)DbContext在數(shù)據(jù)刪除之前就把數(shù)據(jù)查詢出來(lái)的場(chǎng)景,那么也完全可以通過(guò)在刪除之后再查一次的方式來(lái)規(guī)避這個(gè)問(wèn)題。
根據(jù)github上那個(gè)issue的回復(fù),微軟有考慮在EF Core 6.0中加入高效地批量刪除和更新數(shù)據(jù)的方式,但是僅僅是“考慮”,并不確定。我們作為普通開(kāi)發(fā)者可等不及了,因此要自己去解決。
三、已有解決方法
有如下三種已有的解決方法:
執(zhí)行原生SQL語(yǔ)句。在EF Core中提供了ctx.Database.ExecuteSqlRaw()等方法可以用來(lái)執(zhí)行原生SQL語(yǔ)句,因此我們可以直接編寫(xiě)Delete、Update語(yǔ)句來(lái)刪除或者更新數(shù)據(jù)。這種方式比較直接,缺點(diǎn)就是這樣代碼中直接操作數(shù)據(jù)表的方式不太符合模型驅(qū)動(dòng)、分層隔離等思想,程序員直接面對(duì)數(shù)據(jù)庫(kù)表,無(wú)法利用EF Core強(qiáng)類(lèi)型的特性,如果模型發(fā)生改變,必須手動(dòng)變更SQL語(yǔ)句;而且如果調(diào)用了一些DBMS特有的語(yǔ)法、函數(shù),一旦程序遷移到其他DBMS,就可能要重新編寫(xiě)SQL語(yǔ)句,而無(wú)法利用EF Core強(qiáng)大的SQL翻譯機(jī)制來(lái)屏蔽不同底層數(shù)據(jù)庫(kù)的差異。
使用其他ORM。FreeSQL等ORM中提供了批量Delete、Update語(yǔ)句的方法,使用也非常簡(jiǎn)單。這種方式的缺點(diǎn)是項(xiàng)目中必須引入第三方的ORM,無(wú)法復(fù)用EF Core的代碼。
使用已有的EF Core擴(kuò)展。EF Plus、EFCore.BulkExtensions等開(kāi)源庫(kù)中都提供了在EF Core框架下進(jìn)行批量操作的方法。實(shí)現(xiàn)這個(gè)的核心就是要獲得EF Core生成的SQL語(yǔ)句以及SelectExpression。由于EF Core 5.0之前的版本中沒(méi)有提供公開(kāi)的API用于獲取一個(gè)LINQ操作對(duì)應(yīng)的SQL語(yǔ)句,所以這些開(kāi)源庫(kù)都是通過(guò)訪問(wèn)EF Core框架中一些類(lèi)的私有成員來(lái)完成的獲取LINQ對(duì)應(yīng)的SQL語(yǔ)句以及SelectExpression的方法?[2]。由于用的是訪問(wèn)私有成員這樣不符合面向?qū)ο笤瓌t的方式,所以一旦EF Core框架代碼發(fā)生改變,代碼就可能會(huì)失敗,之前就發(fā)生過(guò)EF Core新版本發(fā)布造成這些開(kāi)源庫(kù)無(wú)法工作的情況。而且,在撰寫(xiě)這篇文章的時(shí)候,這些開(kāi)源庫(kù)還沒(méi)有適配.Net 5。
?
四、我的實(shí)現(xiàn)Zack.EFCore.Batch
我開(kāi)發(fā)了一個(gè)EntityFramework Core的擴(kuò)展庫(kù),讓開(kāi)發(fā)者在Entity Framework Core中可以用一句SQL進(jìn)行數(shù)據(jù)的刪除或者更新。由于開(kāi)發(fā)中用到了Entity Framework Core5的API,所以這個(gè)庫(kù)要求Entity FrameworkCore 5及以上版本,也就是.Net 5及以上版本。
?
下面介紹一下使用方法:
第一步,通過(guò)Nuget安裝Install-Package Zack.EFCore.Batch
第二步,把如下代碼添加到你的DbContext類(lèi)的OnConfiguring方法中:
optionsBuilder.UseBatchEF();
第三步: 使用DbContext的擴(kuò)展方法DeleteRangeAsync()來(lái)刪除一批數(shù)據(jù). DeleteRangeAsync()的參數(shù)就是過(guò)濾條件的lambda表達(dá)式。
批量刪除的例子代碼如下:
?
await ctx.DeleteRangeAsync<Book>(b =>b.Price > n || b.AuthorName == "zack yang");
?
上面的代碼將會(huì)在數(shù)據(jù)庫(kù)中執(zhí)行如下SQL語(yǔ)句:
Delete FROM [T_Books] WHERE ([Price] > @__p_0) OR([AuthorName] = @__s_1)
?
DeleteRange()方法是DeleteRangeAsync()的同步方法版本。
使用DbContext的擴(kuò)展方法BatchUpdate()來(lái)創(chuàng)建一個(gè)BatchUpdateBuilder對(duì)象。BatchUpdateBuilder類(lèi)有如下四個(gè)方法:
Set()方法用于給一個(gè)屬性賦值。方法的第一個(gè)參數(shù)是屬性的lambda表達(dá)式,第二個(gè)參數(shù)是值的lambda表達(dá)式。
Where() 是過(guò)濾條件
ExecuteAsync()使用用于執(zhí)行BatchUpdateBuilder的異步方法
Execute()是ExecuteAsync()的同步方法版本。
?
例子代碼:
await ctx.BatchUpdate<Book>()
?? .Set(b =>b.Price, b => b.Price + 3)
?? .Set(b =>b.Title, b => s)
??.Set(b=>b.AuthorName,b=>b.Title.Substring(3,2)+b.AuthorName.ToUpper())
?? .Set(b =>b.PubTime, b => DateTime.Now)
?? .Where(b=> b.Id > n || b.AuthorName.StartsWith("Zack"))
??.ExecuteAsync();
?
上面的代碼將會(huì)在SQLServer數(shù)據(jù)庫(kù)中執(zhí)行如下SQL語(yǔ)句:
Update [T_Books] SET [Price] = [Price] + 3.0E0,[Title] = @__s_1, [AuthorName] = COALESCE(SUBSTRING([Title], 3 + 1, 2), N'') +COALESCE(UPPER([AuthorName]), N''), [PubTime] = GETDATE()
WHERE ([Id] > @__p_0) OR ([AuthorName] IS NOT NULLAND ([AuthorName] LIKE N'Zack%'))
?
這個(gè)開(kāi)發(fā)包使用EFCore實(shí)現(xiàn)的lambda表達(dá)式到SQL語(yǔ)句的翻譯,所以幾乎所有EF Core支持的lambda表達(dá)式寫(xiě)法都被支持。
?
項(xiàng)目的GitHub地址:https://github.com/yangzhongke/Zack.EFCore.Batch
五、實(shí)現(xiàn)原理分析
其實(shí)要把lambda表達(dá)式轉(zhuǎn)換為SQL語(yǔ)句并不難,只要對(duì)表達(dá)式樹(shù)進(jìn)行解析就可以生成SQL語(yǔ)句,但是最難的部分是對(duì)于.Net函數(shù)到SQL片段的翻譯,因?yàn)橄嗤?Net函數(shù)在不同DBMS中等效的SQL片段是不同的,如果我自己實(shí)現(xiàn)這個(gè)是很麻煩的,因此我想到了直接借用EF Core的表達(dá)式樹(shù)到SQL語(yǔ)句的翻譯引擎來(lái)實(shí)現(xiàn)是最佳的方法。
不幸的是,在.NetCore 3.x及之前,是無(wú)法直接獲取一個(gè)Linq查詢翻譯后的SQL語(yǔ)句的。.Net Core中可以通過(guò)日志等方式獲取翻譯后的SQL語(yǔ)句,但是這些都是Linq執(zhí)行后才能獲得的,而且是無(wú)法在拿到一個(gè)Lambda表達(dá)式或者IQueryable的時(shí)候立即獲得SQL的。經(jīng)過(guò)詢問(wèn).Net Core開(kāi)發(fā)團(tuán)隊(duì)得知,在.Net Core 3.X及之前,也是沒(méi)有公開(kāi)的API可以完成表達(dá)式樹(shù)到SQL片段翻譯的功能。
?
從.Net 5開(kāi)始,Entity Framework Core 中提供了不用執(zhí)行查詢,就可以直接獲取Linq查詢對(duì)應(yīng)的SQL語(yǔ)句的方法,那就是調(diào)用IQueryable的ToQueryString()方法?[3]。
?
因此我就想通過(guò)這個(gè)ToQueryString()方法拿到的SQL語(yǔ)句來(lái)入手來(lái)實(shí)現(xiàn)這個(gè)功能。可以把用到的Lambda表達(dá)式片段、過(guò)濾表達(dá)式拼接到一個(gè)查詢表達(dá)式中,然后調(diào)用ToQueryString()方法獲取翻譯后的SQL語(yǔ)句,然后編寫(xiě)詞法分析器和語(yǔ)法分析器對(duì)SQL語(yǔ)句進(jìn)行分析,提取出Where子句以及Select列中的表達(dá)式片段,然后再把這些片段重新組合成Update、Delete的SQL語(yǔ)句即可。
不過(guò),由于不同DBMS的語(yǔ)法不同,編寫(xiě)這樣的詞法及語(yǔ)法分析器是很麻煩的,我就想能否研究ToQueryString()的實(shí)現(xiàn)原理,然后直接拿到解析過(guò)程中的SQL片段,這樣就避免了生成SQL后再去解析的工作。
雖然EF Core是開(kāi)源的,不過(guò)由于關(guān)于EF Core的源代碼并沒(méi)有一個(gè)全面介紹的文檔,而EF Core的代碼又是非常復(fù)雜的,所以研究EF Core的源代碼是非常耗時(shí)的。研究過(guò)程中,我?guī)状味枷胍艞?#xff0c;最后終于把功能實(shí)現(xiàn)了,通過(guò)開(kāi)發(fā)這個(gè)庫(kù),我也對(duì)于EF Core的內(nèi)部原理,特別是從Lambda表達(dá)式到SQL的翻譯的整個(gè)過(guò)程了解的非常透徹。我這里不對(duì)研究的過(guò)程去回顧,而是直接為大家講解一下EFCore的原理,然后再講解一下我這個(gè)Zack.EFCore.Batch的實(shí)現(xiàn)原理。
1.? EF Core的SQL翻譯原理
EF Core中有很多的服務(wù),比如對(duì)于IQueryable進(jìn)行預(yù)處理的QueryTranslationPreprocessor、從查詢中提取查詢參數(shù)的RelationalParameterBasedSqlProcessor、把表達(dá)式樹(shù)翻譯為SQL語(yǔ)句的QuerySqlGenerator等。這些服務(wù)一般都是通過(guò)IXXX Factory這樣的工廠類(lèi)的Create()方法創(chuàng)建的,比如QueryTranslationPreprocessor對(duì)應(yīng)的IQueryTranslationPreprocessorFactory、QuerySqlGenerator對(duì)應(yīng)的IQuerySqlGeneratorFactory。而這些工廠類(lèi)的對(duì)象則是通過(guò)dbContext.GetService<XXX>()來(lái)從DbContext中獲得的。當(dāng)然,也有的服務(wù)是不需要通過(guò)工廠直接獲得的,比如Lambda編譯器服務(wù)IQueryCompiler就可以直接通過(guò)ctx.GetService<IQueryCompiler>()獲取。
?
因此,如果你想使用EF Core中其他的服務(wù),都可以嘗試把對(duì)應(yīng)的服務(wù)接口類(lèi)型或者工廠類(lèi)型放到GetService()中查詢一下試試。
EF Core中還允許調(diào)用DbContextOptionsBuilder的ReplaceService()方法把EF Core中的默認(rèn)服務(wù)替換為自定義實(shí)現(xiàn)類(lèi)。
?
EF Core中把一個(gè)IQueryable對(duì)象翻譯為SQL語(yǔ)句的代碼分散在各個(gè)類(lèi)中,我經(jīng)過(guò)努力,把它們整合為一段可以運(yùn)行的代碼,如下:
?
Expression query = queryable.Expression;
var databaseDependencies =ctx.GetService<DatabaseDependencies>();
IQueryTranslationPreprocessorFactory_queryTranslationPreprocessorFactory = ctx.GetService<IQueryTranslationPreprocessorFactory>();
IQueryableMethodTranslatingExpressionVisitorFactory_queryableMethodTranslatingExpressionVisitorFactory =ctx.GetService<IQueryableMethodTranslatingExpressionVisitorFactory>();
IQueryTranslationPostprocessorFactory_queryTranslationPostprocessorFactory =ctx.GetService<IQueryTranslationPostprocessorFactory>();
QueryCompilationContext queryCompilationContext =databaseDependencies.QueryCompilationContextFactory.Create(true);
?
IDiagnosticsLogger<DbLoggerCategory.Query>logger = ctx.GetService<IDiagnosticsLogger<DbLoggerCategory.Query>>();
QueryContext queryContext =ctx.GetService<IQueryContextFactory>().Create();
QueryCompiler queryComipler =ctx.GetService<IQueryCompiler>() as QueryCompiler;
//parameterize determines if it will use "Declare"or not
MethodCallExpression methodCallExpr1 =queryComipler.ExtractParameters(query, queryContext, logger, parameterize:true) as MethodCallExpression;
QueryTranslationPreprocessorqueryTranslationPreprocessor = _queryTranslationPreprocessorFactory.Create(queryCompilationContext);
MethodCallExpression methodCallExpr2 =queryTranslationPreprocessor.Process(methodCallExpr1) as MethodCallExpression;
QueryableMethodTranslatingExpressionVisitorqueryableMethodTranslatingExpressionVisitor =
?????? _queryableMethodTranslatingExpressionVisitorFactory.Create(queryCompilationContext);
ShapedQueryExpression shapedQueryExpression1 =queryableMethodTranslatingExpressionVisitor.Visit(methodCallExpr2) asShapedQueryExpression;
QueryTranslationPostprocessor queryTranslationPostprocessor=_queryTranslationPostprocessorFactory.Create(queryCompilationContext);
ShapedQueryExpression shapedQueryExpression2 =queryTranslationPostprocessor.Process(shapedQueryExpression1) asShapedQueryExpression;
?
IRelationalParameterBasedSqlProcessorFactory_relationalParameterBasedSqlProcessorFactory =
?????? ctx.GetService<IRelationalParameterBasedSqlProcessorFactory>();
RelationalParameterBasedSqlProcessor_relationalParameterBasedSqlProcessor =_relationalParameterBasedSqlProcessorFactory.Create(true);
?
SelectExpression selectExpression =(SelectExpression)shapedQueryExpression2.QueryExpression;
selectExpression =_relationalParameterBasedSqlProcessor.Optimize(selectExpression,queryContext.ParameterValues, out bool canCache);
IQuerySqlGeneratorFactory querySqlGeneratorFactory =ctx.GetService<IQuerySqlGeneratorFactory>();
QuerySqlGenerator querySqlGenerator =querySqlGeneratorFactory.Create();
var cmd =querySqlGenerator.GetCommand(selectExpression);
string sql = cmd.CommandText;
?
大致解釋一下上面的代碼:
queryable是一個(gè)待轉(zhuǎn)換的IQueryable對(duì)象,ctx是一個(gè)DbContext對(duì)象。QueryCompilationContext是Lambda到SQL翻譯這個(gè)“編譯”過(guò)程的上下文,很多工廠類(lèi)的Create方法都要用它做參數(shù)。QueryContext是查詢語(yǔ)句的上下文。SelectExpression是Linq查詢的表達(dá)式樹(shù)翻譯為強(qiáng)類(lèi)型的抽象語(yǔ)法樹(shù)的樹(shù)根。QuerySqlGenerator的GetCommand()方法用于遍歷SelectExpression生成目標(biāo)SQL語(yǔ)句。
QuerySqlGenerator的GetCommand方法最終會(huì)調(diào)用VisitSelect(SelectExpressionselectExpression)來(lái)拼接生成SQL語(yǔ)句,其中會(huì)調(diào)用VisitSqlBinary(SqlBinaryExpression sqlBinaryExpression)、VisitFromSql(FromSqlExpression fromSqlExpression)、VisitLike(LikeExpression likeExpression)等方法來(lái)把運(yùn)算表達(dá)式、From、Like等翻譯成對(duì)應(yīng)的SQL片段。由于不同DBMS中一些函數(shù)等實(shí)現(xiàn)不同,而SelectExpression、LikeExpression等都是一個(gè)抽象節(jié)點(diǎn),是獨(dú)立于具體DBMS的抽象模型,因此各個(gè)DBMS的EF Provider只要負(fù)責(zé)編寫(xiě)代碼把這些XXExpression翻譯為各自的SQL片段即可,不同DBMS的EF Core中的代碼大部分都是各種XXTranslatorProvider。
2.? Zack.EFCore.Batch的實(shí)現(xiàn)原理
這個(gè)庫(kù)最核心的代碼就是ZackQuerySqlGenerator,它是一個(gè)繼承自QuerySqlGenerator的類(lèi)。它通過(guò)override父類(lèi)的VisitSelect方法,然后把父類(lèi)的VisitSelect方法的代碼全部拷過(guò)來(lái)。這樣的目的就是在VisitSelect拼接SQL語(yǔ)句的過(guò)程中把各個(gè)SQL片段截獲到。以下面的代碼為例:
if (selectExpression.Predicate != null)
{
?????? Sql.AppendLine().Append("WHERE");
?????? varoldSQL = Sql.Build().CommandText;//zack's code
?????? Visit(selectExpression.Predicate);
?????? this.PredicateSQL= Diff(oldSQL, this.Sql.Build().CommandText); //zack's code
}
這里就是首先把拼接Where條件之前的SQL語(yǔ)句保存到oldSQL變量中,再把拼接Where條件之后的SQL語(yǔ)句和oldSQL求一個(gè)差運(yùn)算,就得到了Where語(yǔ)句的SQL片段。
?
然后通過(guò)optBuilder.ReplaceService<IQuerySqlGeneratorFactory,ZackQuerySqlGeneratorFactory>();把ZackQuerySqlGenerator對(duì)應(yīng)的ZackQuerySqlGeneratorFactory替換為IQuerySqlGeneratorFactory的默認(rèn)實(shí)現(xiàn)。這樣EF Core再完成從SelectExpression到SQL語(yǔ)句的翻譯,就會(huì)使用ZackQuerySqlGenerator類(lèi),這樣我們就可以截獲翻譯生成的SQL片段了。
?
再解釋一下批量更新數(shù)據(jù)庫(kù)的BatchUpdateBuilder類(lèi)的主要代碼。代碼主要就是把Age=Age+1,Name=AuthorName.Trim()這樣的賦值表達(dá)式重新生成Select(new{b.Age,b.Age+1,b.Name,b.AuthorName.Trime()})這樣的表達(dá)式,這樣就把N個(gè)賦值表達(dá)式重新拼接為2*N個(gè)查詢表達(dá)式,再把查詢條件拼接形成一個(gè)IQueryable對(duì)象,再調(diào)用ZackQuerySqlGenerator翻譯IQueryable獲取到Where的SQL片段以及各個(gè)列的SQL片段,最后重新拼接成一個(gè)Update的SQL語(yǔ)句。
?
六、局限性
Zack.EFCore.Batch有如下局限性:
由于Zack.EFCore.Batch用到了EF Core 5.0的新API,所以暫不支持EF Core 3.X及以下版本。
由于Zack.EFCore.Batch是直接操作數(shù)據(jù)庫(kù),所以更新、刪除后,會(huì)存在微軟擔(dān)心的同一個(gè)DbContext中已經(jīng)查詢出來(lái)的對(duì)象跟蹤狀態(tài)和數(shù)據(jù)庫(kù)不一致的情況。在同一個(gè)DbContext實(shí)例中,如果需要在批量刪除或者更新之后操作同一個(gè)DbContex中之前查詢出來(lái)的數(shù)據(jù),建議再執(zhí)行一遍查詢操作。
代碼中使用了一個(gè)內(nèi)部API QueryCompiler,這是不推薦的做法。
總結(jié)
以上是生活随笔為你收集整理的Entity Framework Core 5中实现批量更新、删除的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 小试YARP
- 下一篇: 在传统行业做数字化转型之业务篇