起因主要是因為看到博客園又有朋友開始討論LINQ2SQL的問題,這次說的是Attach。通過解讀Attach,可以發現LINQ2SQL內部是如何維護和跟蹤對象實例、如何實現延遲加載,並且還可以引發關於延遲加載和N-Tier Application中LINQ2SQL的應用技巧的討論。本文所討論內容適用於.Net Framework 3.5版本的LINQ2SQL,所使用數據庫是Northwnd。
對於對象添加和刪除操作,LINQ2SQL在Table<T>類定義中直接提供了InsertOnSubmit()/DeleteOnSubmit()。而對於對象的更新,由於LINQ2SQL中采取了對象跟蹤的機制(可參考LINQ2SQL對象生命周期管理),所以我們在修改了對象屬性後無需顯式通知DataContext,當調用DataContext.SubmitChanges()時會自動的把我們所做的修改提交到數據庫保存。這種基於上下文的操作是非常方便的,否則在代碼中會出現大量的Update調用,但是也存在限制——只有在同一個 DataContext對象的作用域內,對象所做的修改才會在SubmitChanges()時得到保存。如:
1 using (var context = new Northwnd())
2 {
3 var customer = context.Customers.First();
4 customer.City = "Beijing";
5 context.SubmitChanges();
6 }
而在Web和N-Tier Application開發時,數據查詢和更新同在一個DataContext中往往得不到滿足,所以LINQ2SQL在Table<T>類定義了Attach方法,用於把已與查詢DataContext上下文斷開的對象關聯到Table所屬的DataContext對象,這樣就可以通過新的 DataContext執行對象的更新操作。如:
01 Customer customer = null;
02
03 using (var context1 = new Northwnd())
04 {
05 customer = context1.Customers.First();
06 }
07
08 customer.City = "Beijing";
09
10 using (var context = new Northwnd())
11 {
12 context.Customers.Attach(customer);
13 context.SubmitChanges();
14 }
但是問題來了,這段代碼執行錯誤,拋出以下異常:
System.NotSupportedException: An attempt has been made to Attach or Add an entity that is not new, perhaps having been loaded from another DataContext. This is not supported.
這個問題已經不是一個新鮮的問題了,google一下有很多的解決方法,但這看起來很正常的代碼為什麼會拋出異常呢?其實還是和 DataContext的作用域有關的,本文嘗試剖析這個問題,然後還會討論在N-Tier Application中使用LINQ2SQL的一些須知技巧。
都是Association惹的禍?
出錯的地方在System.Data.Linq.Table<T>.Attach(TEntity entity, bool asModified),下列條件只要滿足其中一個,就會造成Attach調用失敗:
1、DataContext為只讀,相當於把DataContext.ObjectTrackingEnabled=false。只讀的DataContext只能進行數據的查詢,其他的操作都會拋出異常。
2、當調用Attach時,DataContext正在執行SubmitChanges()操作。
3、DataContext對象已經被顯式調用Dispose()銷毀。
4、asModified為true,但TEntity類的屬性映射信息(MetaType)中包含 UpdateCheck=WhenChanged或UpdateCheck=Always設置。這是因為Attach的對象在當前DataContext 中並沒有原始值的記錄,DataContext無法根據UpdateCheck的設置生成where字句以避免並發沖突。需要說明的是幾乎不會用到 asModified=true的調用,尤其是在對查詢顯示-用戶修改-提交保存這樣的Web應用場景,本文稍後會討論這樣的場景如何操作。如果堅持要用 asModified=true的調用,那麼可以在TEntity類增加RowVersion屬性的定義,LINQ2SQL引入RowVersion就是為了提供除UpateCheck以外的另一個沖突檢測的方法,由於RowVersion應該在每一次更新操作後都應該修改,所以一般對應 Timestamp類型。
5、嘗試Attach一個已屬於當前DataContext上下文的對象。
6、嘗試Attach的對象包含未載入的Assocation屬性,或是未載入的嵌套Association屬性。
其中原因(6)屬於本文的討論內容,我們來看看Attach函數中的調用
1 ...
2 if (trackedObject == null)
3 {
4 trackedObject = this.context.Services.ChangeTracker.Track(entity, true);
5 }
6 ...
Attach函數中調用StandardChangeTracker.Track(TEntity entity, bool recursive)方法,請注意第二個參數表示遞歸,Attach調用Track(entity, true)會導致entity的所有嵌套Association屬性都會被檢查。代碼就是在StandardChangeTracker.Track中拋出了異常:
1 ...
2 if (trackedObject.HasDeferredLoaders)
3 {
4 throw Error.CannotAttachAddNonNewEntities();
5 }
6 ....
再看看trackedObject.HasDeferredLoaders做了什麼:
01 internal override bool HasDeferredLoaders
02 {
03 get
04 {
05 foreach (MetaAssociation association in this.Type.Associations)
06 {
07 if (this.HasDeferredLoader(association.ThisMember))
08 {
09 return true;
10 }
11 }
12 foreach (MetaDataMember member in from p in this.Type.PersistentDataMembers
13 where p.IsDeferred && !p.IsAssociation
14 select p)
15 {
16 if (this.HasDeferredLoader(member))
17 {
18 return true;
19 }
20 }
21 return false;
22 }
23 }
很快就要找到關鍵點了,在看看this.HasDeferredLoader:
01 private bool HasDeferredLoader(MetaDataMember deferredMember)
02 {
03 if (!deferredMember.IsDeferred)
04 {
05 return false;
06 }
07 MetaAccessor storageAccessor = deferredMember.StorageAccessor;
08 if (storageAccessor.HasAssignedValue(this.current) || storageAccessor.HasLoadedValue(this.current))
09 {
10 return false;
11 }
12 IEnumerable boxedValue = (IEnumerable) deferredMember.DeferredSourceAccessor.GetBoxedValue(this.current);
13 return (boxedValue != null);
14 }
答案揭曉:storageAccessor.HasAssignedValue檢測了Association屬性是否被賦值(針對 EntityRef),storageAccessor.HasLoadedValue檢測了Association屬性是否已被加載(針對 EntitySet),如果沒有任何的賦值或加載,並且由GetBoxedValue獲取的延遲源對象(DeferredSource)不為空,則拋出異常。
要解釋Attach為什麼在這種情況下會拋出異常?首先要弄明白延遲源對象,這是LINQ2SQL實現延遲加載的關鍵。在延遲加載的模式 (DataContext.DeferredLoading=true)下,EntitySet和EntityRef屬性只有當被訪問時,才會產生數據庫的查詢。以EntitySet為例,當調用GetEnumerator()時:
1 public IEnumerator<TEntity> GetEnumerator()
2 {
3 this.Load();
4 return new Enumerator<TEntity>((EntitySet<TEntity>) this);
5 }
this.Load中調用了延遲源進行數據的加載:
01 public void Load()
02 {
03 if (this.HasSource)
04 {
05 ItemList<TEntity> entities = this.entities;
06 this.entities = new ItemList<TEntity>();
07 foreach (TEntity local in this.source)
08 {
09 this.entities.Add(local);
10 }
11 ...
12 }
13 }
再進一步就要追溯到 System.Data.Linq.CommonDataServices.GetDeferredSourceFactory(MetaDataMember) 和System.Data.Linq.Mapping.EntitySetValueAccessor。當DataContext對象初始化模型信息時,會調用GetDeferredSourceFactory為指定屬性生成相應的DeferredSourceFactory對象,該工廠對象通過 CreateDeferredSource()生成延遲源對象。在執行查詢操作時,DataContext將會調用每個對象的EntitySet屬性的 SetSource方法,為每一個EntitySet綁定延遲源,由延遲源來調用DataContext實現延遲加載,這樣就實現了EntitySet和 DataContext的解耦,讓POCO類也變智能了。對於EntitySet,當執行延遲加載後,延遲源將被清空,並且相應的已加載標志也將設為 true。
接下來我們驗證一下,為了方便示例我只保留Customer類的Orders作為唯一的Association屬性:(文章最後會給出代碼下載,有興趣可以照著驗證)
01 Customer customer = null;
02
03 using (var context = CreateNorthwnd())
04 {
05 customer = context.Customers.First();
06 // forces to load order association
07 customer.Orders.Count.Dump();
08 }
09
10 customer.City = "Beijing";
11
12 using (var context = CreateNorthwnd())
13 {
14 context.Customers.Attach(customer);
15 context.SubmitChanges();
16 }
別急,還是錯的!雖然customer.Orders.Count的調用讓customer.Orders被加載,但Order對象還包含幾個未被加載的Association屬性,你把Order對象的Association屬性定義去掉就對了!
剖析到這裡你明白為什麼當存在Association或嵌套Association未被賦值或加載,且延遲源不為空時會拋出異常了麼?這是因為和需要Attach的對象一樣,延遲源關聯的DataContext對象已經被銷毀了,延遲源無法在加載數據,所以DataContext拒絕關聯這樣的對象。
說了那麼多,是為了讓大家能夠明白為什麼會產生異常,解決的方法很簡單,不需要修改實體的定義,同時也是個人認為LINQ2SQL最佳實踐之一:
01 Customer customer = null;
02
03 using (var context = CreateNorthwnd())
04 {
05 var option = new DataLoadOptions();
06 // disabled the deferred loading
07 context.DeferredLoadingEnabled = false;
08 // specify the association in needed
09 option.LoadWith<Customer>(c => c.Orders);
10 context.LoadOptions = option;
11
12 customer = context.Customers.First();
13 }
14
15 customer.City = "Beijing";
16
17 using (var context = CreateNorthwnd())
18 {
19 context.Customers.Attach(customer);
20 context.Refresh(RefreshMode.KeepCurrentValues, customer);
21 context.SubmitChanges();
22 }
首先我們關閉了DataContext的延遲加載,並且通過DataLoadOption顯式指定了需要加載的關聯數據,這樣的做法不但解決Attach的問題,而且還避免了在N-Tier Application中由於延遲加載所可能導致的異常。
LINQ2SQL最佳實踐
文章最後羅列一些我自己總結的應用LINQ2SQL的心得。
1、建議通過using來使用DataContext對象,這樣當操作完畢立即銷毀DataContext對象。默認狀態下(ObjectTrackingEnabled=true),DataContext將在內存中保存查詢對象的副本,如果長時間保持 DataContext對象,會造成內存不必要的占用。
2、對於僅查詢的操作,設ObjectTrackingEnabled=false,關閉對象跟蹤有助於提高DataContext的查詢性能,這也是所謂的只讀DataContext。再根據所需數據,設置DataLoadOption.AssociateWith()、 DataLoadOption.LoadWith(),可實現高效的查詢。
3、當用LINQ2SQL編寫N-Tier Application時,建議關閉延遲加載,因為這帶來的麻煩遠遠大於好處。注意當ObjectTrackingEnabled=false時,延遲加載是不可用的,相當於DataContext.DeferredLoading=false。
4、Attach(entity, false) + DataContext.Refresh(RefreshMode.KeepCurrentValues, entity) + SubmitChanges(),實現斷開對象的更新,這就避免了DuplicateKey的問題,如上面給出的代碼所示。
麒麟的帖子引發了不少討論,挺有趣的:
1、“方法簽名中不出現linq to sql的實體,方法代碼塊中肯定要出現的。我看人家的開源項目都是在訪問數據庫的時候再將DomainModel轉化為linq to sql的Entity,這樣使用的linq to sql。”
對於N-Tier Application,DomainModel(領域對象)的應用范圍在Presentation Layer和Business Layer之間的層次,而LINQ生成的POCO類屬於DataModel,應用范圍在整個N-Tier。在概念上DomainModel和 DataModel是不同的,但是在大多數的3-tier應用中,DomainModel和DataModel是同一個類——實體類。至於為什麼“人家開源項目”會這樣做,我想大多數原先並不是用LINQ開發,後來移植過來的吧?
2、“先根據傳入product對象的id,查詢出原始的product,然後利用反射自動copy新屬性”
DataContext專門提供了Refresh()函數,可以讀取entity的數據庫值,再通過指定的RefreshMode來刷新 entity的當前值或原始值。在更新entity前,我們首先需要Attach對象,因為通過 DataContext.Refresh(RefreshMode.KeepCurrentValues, entity)可獲得entity的原始值,把判斷entity是否已更改的工作交給DataContext,所以我們只需要調用 Attach(entity)或Attach(entity, false),而不需要調用Attach(entity, true)或Attach(entity, originalEntity),更不需要”copy”了。
3、“樓主的NorthwindDataContext實例化太厲害了,要知道datacontext是個很大的對象,應該避免不停地實例化。最好是一次request只有一個實例,你的問題就迎刃而解了。”
在LINQ2SQL的Design Intent有說過,LINQ2SQL的應用模式是“Unit of work”,即創建-調用-銷毀,目的就是為了在調用完畢後快速釋放DataContext由於保存對象副本和SQL連接所占用的資源,DataContext提供了足夠的機制來保證實例化的消耗在可以接受的范圍。但如果在一次http request裡keep住DataContext對象,不小心反而會造成內存不必要的占用。
本文配套源碼