一切從一段代碼說起。。。
下面一段代碼是最近我在對一EF項目進行重構時發現的。
protected override void DoRemove(T entity) { this.dbContext.Entry(entity).State = EntityState.Deleted; Committed = false; } protected override int DoRemove(System.Linq.Expressions.Expression<Func<T, bool>> predicate = null) { var set = dbContext.Set<T>().AsQueryable(); set = (predicate == null) ? set : set.Where(predicate); int i = 0; foreach (var item in set) { DoRemove(item); i++; } return i; }
沒錯,這是使用Expression表達式實現批量刪除數據的代碼。當中的foreach代碼塊循環調用DoRemove(T entity)方法來修改符合條件的實體的狀態碼,最后執行dbContext的SaveChanges方法來提交到數據庫執行刪除命令。正是這個foreach引起了我的思考,我們知道EF會自動生成SQL語句提交到數據庫執行,刪除一條記錄的話,會生成一條delete語句。如果要刪除多條記錄,手寫SQL的話,只需一條帶where條件的delete語句即可(如:delete from [tableName] where [condition]),而通過執行上面代碼的批量刪除方法,EF會不會生成一條類似這樣的SQL呢?通過SQL Server Profiler跟蹤發現這樣的結果:
一向NB的EF這下反而SB了,生成的是多條delete語句,一條記錄生成一條SQL!這樣搞的話,如果要刪除的記錄有上百條、上千條,甚至上萬條的話,那不是把EF給累死,數據庫DB也會跟着被連累:”老大,一句話可以說完的事,你給我拆開一個字一個字來說,欠揍啊!”。我們知道任何ORM框架在生成SQL時都會存在性能的損耗,某些SQL會被優化后生成,但在按指定條件批量刪除記錄這方面很多ORM框架都存在上面出現的尷尬場面。正如老趙在《擴展LINQ to SQL:使用Lambda Expression批量刪除數據》一文所說這並不是ORM的問題,只是使用起來不方便。老趙一文所提的方法不失為一種好方案,主要思路是通過解析Expression表達式構造SQL的Where條件子句。看到這里大家估計也清楚了問題的焦點其實就是一條SQL。如果手寫SQL的話上面的批量刪除SQL語句會類似這樣:delete from [DomainObjectA] where [Prop1] in (N'prop2',N'prop3',N'prop5'),而EF對於Expression參數的查詢是十分給力的,自動生成的查詢SQL是這樣的:
SELECT [Extent1].[ID] AS [ID], [Extent1].[Prop1] AS [Prop1], [Extent1].[Prop2] AS [Prop2] FROM [dbo].[DomainObjectA] AS [Extent1] WHERE [Extent1].[Prop1] IN (N'prop2',N'prop3',N'prop5')
大家發現了吧,EF生成的查詢SQL跟手寫的刪除SQL語句結構上是如此的神似!如果我們把生成的SQL從SELECT到FROM那段替換成DELETE FROM的話,其實就是我們期望得到的一段SQL。那就按照這個思路動手吧,首先要獲取EF生成的SQL語句,對於返回數據集是IQueryable<T>類型的,我們直接對結果ToString()即可獲得,夠簡單吧。
var set = dbContext.Set<T>().AsQueryable(); string sql = set.Where(predicate).ToString().Replace("\r", "").Replace("\n", "").Trim();
對於獲取到的SQL字符串我們要進行處理,去除換行符、回車符、多余的空白字符,最關鍵是要把select到from這一段替換掉。如何替換呢?其實還有個細節遺漏了,先看看替換后的SQL,等會再講明。
DELETE FROM [dbo].[DomainObjectA] AS [Extent1] WHERE [Extent1].[Prop1] IN (N'prop2',N'prop3',N'prop5')
一眼看上去,似乎沒問題,挺標准的一段SQL呀。問題恰恰是在太過標准了!在SQL查詢器上運行會報這樣的出錯提示:“Incorrect syntax near the keyword 'AS'.” 原來delete from。。。這樣的SQL不接受“AS”關鍵字啊!其實也不能說完全不接受“AS",如果改為下面這樣是可以通過的。
DELETE [dbo].[DomainObjectA] FROM ( SELECT [Extent1].[ID] AS [ID], [Extent1].[Prop1] AS [Prop1], [Extent1].[Prop2] AS [Prop2] FROM [dbo].[DomainObjectA] AS [Extent1] WHERE [Extent1].[Prop1] IN (N'prop2',N'prop3',N'prop5') ) AS [T1]
細心的你可能已經發現了,括號里面的SQL其實就是EF生成的那段。如果這樣做的話,我們完全可以把生成的SQL整段拿來用,只需在delete后再指定表名即可。
現在問題變為如何從生成的SQL中提取出表名了。從前面EF生成的SQL中可以看出,它的結構是比較固定的(SELECT...FROM... AS...[WHERE...],當返回所有結果的話WHERE不會被生成)。如果熟悉正則表達式的話你可能第一時間已經想到了,沒錯!其實我們一開始就可以用正則表達式來解決。只怪本人的正則表達式功力欠佳,走了很多彎路。這次只能求助“度娘”了。。。求助中。。。有答案了,原來很早很早前就已經有位大牛在一篇名為《Linq to Sql: 批量刪除之投機取巧版》解決了我所面臨的問題,看來園子里不差牛人,閑來多逛逛,還是有意外收獲的。借用他文中的正則表達式得了,省時省力。寫到這,插個題外話:作為開發人員,信息搜索能力也是必備的(甚至可以毫不誇張地說是開發人員的第一門學問),對遇到的問題難題能迅速搜索到解決方法,從中借鑒別人的經驗(不代表抄襲別人的作品),可以少走彎路,節省的時間、精力可以更多花在業務知識方面。言歸正傳,上代碼!
Regex reg = new Regex("^SELECT[\\s]*(?<Fields>.*)[\\s]*FROM[\\s]*(?<Table>.*)[\\s]*AS[\\s]*(?<TableAlias>.*)[\\s]*WHERE[\\s]*(?<Condition>.*)", RegexOptions.IgnoreCase); Match match = reg.Match(sql); if (!match.Success) throw new ArgumentException("Cannot delete this type of collection"); string table = match.Groups["Table"].Value.Trim(); string tableAlias = match.Groups["TableAlias"].Value.Trim(); string condition = match.Groups["Condition"].Value.Trim().Replace(tableAlias, table); string sql1 = string.Format("DELETE FROM {0} WHERE {1}", table, condition);
現在已經得到我們期望的SQL了,接下來是想辦法讓數據庫去執行它即可,還好EF還是比較人性化的,它支持開放底層ADO.net框架,有三個API可以支持直接訪問數據庫。
1、DbContext.Database.ExecuteSqlCommand 2、DbContext.Database.SqlQuery 3、DbSet.SqlQuery
從名字我們知道后兩個API主要用來查詢數據,我們選用第一個API:DbContext.Database.ExecuteSqlCommand,而且返回是受影響的結果數,我們還能知道被刪除的數據有多少條,簡直是為此量身定制嘛。
protected override int DoRemove(System.Linq.Expressions.Expression<Func<T, bool>> predicate = null) { var set = dbContext.Set<T>().AsQueryable(); set = (predicate == null) ? set : set.Where(predicate); string sql = set.ToString().Replace("\r", "").Replace("\n", "").Trim(); if (predicate == null && !string.IsNullOrEmpty(sql) && !string.IsNullOrWhiteSpace(sql)) sql += " WHERE 1=1"; Regex reg = new Regex("^SELECT[\\s]*(?<Fields>.*)[\\s]*FROM[\\s]*(?<Table>.*)[\\s]*AS[\\s]*(?<TableAlias>.*)[\\s]*WHERE[\\s]*(?<Condition>.*)", RegexOptions.IgnoreCase); Match match = reg.Match(sql); if (!match.Success) throw new ArgumentException("Cannot delete this type of collection"); string table = match.Groups["Table"].Value.Trim(); string tableAlias = match.Groups["TableAlias"].Value.Trim(); string condition = match.Groups["Condition"].Value.Trim().Replace(tableAlias, table); string sql1 = string.Format("DELETE FROM {0} WHERE {1}", table, condition); int i = 0; i = dbContext.Database.ExecuteSqlCommand(sql1);return i; }
執行上面方法,通過SQL Server Profiler跟蹤,發現確實是我們想要的結果,只有一條DELETE語句,而且滿足條件的數據也確實被刪除了。
搞定,收工!等等。。。還有什么問題呢?貌似還漏了點東西。嗯。。。還有”事務“!差點被人說成”標題黨“啦。我們知道EF對於實體對象的新增(Add)、修改(Update)、刪除(Remove)等操作都要等到最后DbContext.SaveChanges()方法執行后才最終提交到數據庫執行。而DbContext.Database.ExecuteSqlCommand是繞過EF直接交給數據庫去執行了,這樣就出現很尷尬的情況:調用Remove(T entity)方法刪除一條數據時要執行SaveChanges(),而通過批量刪除的方法刪除一條或更多的數據就不用經過SaveChanges()直接可以刪除了。如何將SaveChanges和ExecuteSqlCommand放到同一個事務來提交呢?這正是下面要繼續探討的。通過查看DbContext.Database命名空間下面的方法,發現了這樣一個方法:
DbContext.Database.UseTransaction(DbTransaction transaction)
這個方法的摘要說明如下:
// 摘要: // Enables the user to pass in a database transaction created outside of the // System.Data.Entity.Database object if you want the Entity Framework to execute // commands within that external transaction. Alternatively, pass in null to // clear the framework's knowledge of that transaction.
也就是說,我們可以給EF指定一個外部的事務(參數:transaction)來控制其提交Commands到數據庫,這樣一來所有由EF產生的Commands的提交都要通過這個外部事務(參數:transaction)來控制了。
protected override int DoRemove(System.Linq.Expressions.Expression<Func<T, bool>> predicate = null) { var set = dbContext.Set<T>().AsQueryable(); set = (predicate == null) ? set : set.Where(predicate); string sql = set.ToString().Replace("\r", "").Replace("\n", "").Trim(); if (predicate == null && !string.IsNullOrEmpty(sql) && !string.IsNullOrWhiteSpace(sql)) sql += " WHERE 1=1"; Regex reg = new Regex("^SELECT[\\s]*(?<Fields>.*)[\\s]*FROM[\\s]*(?<Table>.*)[\\s]*AS[\\s]*(?<TableAlias>.*)[\\s]*WHERE[\\s]*(?<Condition>.*)", RegexOptions.IgnoreCase); Match match = reg.Match(sql); if (!match.Success) throw new ArgumentException("Cannot delete this type of collection"); string table = match.Groups["Table"].Value.Trim(); string tableAlias = match.Groups["TableAlias"].Value.Trim(); string condition = match.Groups["Condition"].Value.Trim().Replace(tableAlias, table); string sql1 = string.Format("DELETE FROM {0} WHERE {1}", table, condition); int i = 0; dbContext.UseTransaction(efContext.Transaction); i = dbContext.Database.ExecuteSqlCommand(sql1); efContext.Committed = false; return i; }
接下來我們要把DbContext的SaveChanges()和事務Transaction的Commit()封裝到同一個方法Commit中去,為此采用Repository模式來實現。具體的實現過程就不細說了,直接給出重構后的代碼吧。

1 public interface IEntityFrameworkRepositoryContext 2 { 3 DbContext Context { get; } 4 5 DbTransaction Transaction { get; set; } 6 7 bool Committed { get; set; } 8 9 void Commit(); 10 11 void Rollback(); 12 13 }

1 public class EntityFrameworkRepositoryContext : IEntityFrameworkRepositoryContext 2 { 3 private readonly DbContext efContext; 4 private readonly object sync = new object(); 5 6 public EntityFrameworkRepositoryContext(DbContext efContext) 7 { 8 this.efContext = efContext; 9 } 10 11 public DbContext Context 12 { 13 get { return this.efContext; } 14 } 15 16 private DbTransaction _transaction = null; 17 public DbTransaction Transaction 18 { 19 get 20 { 21 if (_transaction == null) 22 { 23 var connection = this.efContext.Database.Connection; 24 if (connection.State != ConnectionState.Open) 25 { 26 connection.Open(); 27 } 28 _transaction = connection.BeginTransaction(); 29 } 30 return _transaction; 31 } 32 set { _transaction = value; } 33 } 34 35 private bool _committed; 36 public bool Committed 37 { 38 get 39 { 40 return _committed; 41 } 42 set 43 { 44 _committed = value; 45 } 46 } 47 48 public void Commit() 49 { 50 if (!Committed) 51 { 52 lock (sync) 53 { 54 efContext.SaveChanges(); 55 56 if (_transaction != null) 57 _transaction.Commit(); 58 } 59 Committed = true; 60 } 61 } 62 63 public override void Rollback() 64 { 65 Committed = false; 66 if (_transaction != null) 67 _transaction.Rollback(); 68 } 69 70 //其他方法略。。。 71 72 }

1 public class EntityFrameworkRepository<T> : IRepository<T> 2 where T : class,IEntity 3 { 4 private readonly IEntityFrameworkRepositoryContext efContext; 5 6 public EntityFrameworkRepository(IEntityFrameworkRepositoryContext context) 7 { 8 this.efContext = context 9 } 10 11 //Add, Update, Find等等其他方法略。。。 12 13 public int Remove(System.Linq.Expressions.Expression<Func<T, bool>> predicate = null) 14 { 15 var set = efContext.Context.Set<T>().AsQueryable(); 16 set = (predicate == null) ? set : set.Where(predicate); 17 18 string sql = set.ToString().Replace("\r", "").Replace("\n", "").Trim(); 19 if (predicate == null && !string.IsNullOrEmpty(sql) && !string.IsNullOrWhiteSpace(sql)) 20 sql += " WHERE 1=1"; 21 22 Regex reg = new Regex("^SELECT[\\s]*(?<Fields>.*)[\\s]*FROM[\\s]*(?<Table>.*)[\\s]*AS[\\s]*(?<TableAlias>.*)[\\s]*WHERE[\\s]*(?<Condition>.*)", RegexOptions.IgnoreCase); 23 Match match = reg.Match(sql); 24 25 if (!match.Success) 26 throw new ArgumentException("Cannot delete this type of collection"); 27 string table = match.Groups["Table"].Value.Trim(); 28 string tableAlias = match.Groups["TableAlias"].Value.Trim(); 29 string condition = match.Groups["Condition"].Value.Trim().Replace(tableAlias, table); 30 31 string sql1 = string.Format("DELETE FROM {0} WHERE {1}", table, condition); 32 int i = 0; 33 efContext.Context.Database.UseTransaction(efContext.Transaction); 34 i = efContext.Context.Database.ExecuteSqlCommand(sql1); 35 efContext.Committed = false; 36 return i; 37 } 38 39 }

1 public interface IRepository<T> 2 { 3 void Add(T entity); 4 void Update(T entity); 5 void Remove(T entity); 6 int Remove(Expression<Func<T, bool>> predicate = null); 7 IQueryable<T> Find(Expression<Func<T, bool>> predicate = null); 8 ...... 9 }