起因主要是因為看到部落格園又有朋友開始討論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()時得到儲存。如:
using (var context = new Northwnd()) { var customer = context.Customers.First(); customer.City = "Beijing"; context.SubmitChanges(); }
而在Web和N-Tier Application開發時,資料查詢和更新同在一個DataContext中往往得不到滿足,所以LINQ2SQL在Table<T>類定義了Attach方法,用於把已與查詢DataContext上下文斷開的對象關聯到Table所屬的DataContext對象,這樣就可以通過新的DataContext執行對象的更新操作。如:
Customer customer = null; using (var context1 = new Northwnd()) { customer = context1.Customers.First(); } customer.City = "Beijing"; using (var context = new Northwnd()) { context.Customers.Attach(customer); context.SubmitChanges(); }
但是問題來了,這段代碼執行錯誤,拋出以下異常:
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函數中的調用
...if (trackedObject == null) { trackedObject = this.context.Services.ChangeTracker.Track(entity, true); }...
Attach函數中調用StandardChangeTracker.Track(TEntity entity, bool recursive)方法,請注意第二個參數表示遞迴,Attach調用Track(entity, true)會導致entity的所有嵌套Association屬性都會被檢查。代碼就是在StandardChangeTracker.Track中拋出了異常:
...if (trackedObject.HasDeferredLoaders){throw Error.CannotAttachAddNonNewEntities();}....
再看看trackedObject.HasDeferredLoaders做了什麼:
internal override bool HasDeferredLoaders{ get { foreach (MetaAssociation association in this.Type.Associations) { if (this.HasDeferredLoader(association.ThisMember)) { return true; } } foreach (MetaDataMember member in from p in this.Type.PersistentDataMembers where p.IsDeferred && !p.IsAssociation select p) { if (this.HasDeferredLoader(member)) { return true; } } return false; }}
很快就要找到關鍵點了,在看看this.HasDeferredLoader:
private bool HasDeferredLoader(MetaDataMember deferredMember){ if (!deferredMember.IsDeferred) { return false; } MetaAccessor storageAccessor = deferredMember.StorageAccessor; if (storageAccessor.HasAssignedValue(this.current) || storageAccessor.HasLoadedValue(this.current)) { return false; } IEnumerable boxedValue = (IEnumerable) deferredMember.DeferredSourceAccessor.GetBoxedValue(this.current); return (boxedValue != null);}
答案揭曉:storageAccessor.HasAssignedValue檢測了Association屬性是否被賦值(針對EntityRef),storageAccessor.HasLoadedValue檢測了Association屬性是否已被載入(針對EntitySet),如果沒有任何的賦值或載入,並且由GetBoxedValue擷取的延遲來源物件(DeferredSource)不為空白,則拋出異常。
要解釋Attach為什麼在這種情況下會拋出異常?首先要弄明白延遲來源物件,這是LINQ2SQL實現消極式載入的關鍵。在消極式載入的模式(DataContext.DeferredLoading=true)下,EntitySet和EntityRef屬性只有當被訪問時,才會產生資料庫的查詢。以EntitySet為例,當調用GetEnumerator()時:
public IEnumerator<TEntity> GetEnumerator(){ this.Load(); return new Enumerator<TEntity>((EntitySet<TEntity>) this);}
this.Load中調用了延遲源進行資料的載入:
public void Load(){ if (this.HasSource) { ItemList<TEntity> entities = this.entities; this.entities = new ItemList<TEntity>(); foreach (TEntity local in this.source) { this.entities.Add(local); }... }}
再進一步就要追溯到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屬性:(文章最後會給出代碼下載,有興趣可以照著驗證)
Customer customer = null; using (var context = CreateNorthwnd()) { customer = context.Customers.First(); // forces to load order association customer.Orders.Count.Dump(); } customer.City = "Beijing"; using (var context = CreateNorthwnd()) { context.Customers.Attach(customer); context.SubmitChanges(); }
別急,還是錯的!雖然customer.Orders.Count的調用讓customer.Orders被載入,但Order對象還包含幾個未被載入的Association屬性,你把Order對象的Association屬性定義去掉就對了!
剖析到這裡你明白為什麼當存在Association或嵌套Association未被賦值或載入,且延遲源不為空白時會拋出異常了嗎?這是因為和需要Attach的對象一樣,延遲源關聯的DataContext對象已經被銷毀了,延遲源無法在載入資料,所以DataContext拒絕關聯這樣的對象。
說了那麼多,是為了讓大家能夠明白為什麼會產生異常,解決的方法很簡單,不需要修改實體的定義,同時也是個人認為LINQ2SQL最佳實務之一:
Customer customer = null; using (var context = CreateNorthwnd()) { var option = new DataLoadOptions(); // disabled the deferred loading context.DeferredLoadingEnabled = false; // specify the association in needed option.LoadWith<Customer>(c => c.Orders); context.LoadOptions = option; customer = context.Customers.First(); } customer.City = "Beijing"; using (var context = CreateNorthwnd()) { context.Customers.Attach(customer); context.Refresh(RefreshMode.KeepCurrentValues, customer); context.SubmitChanges(); }
首先我們關閉了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的問題,如上面給出的代碼所示。
下載
Demo Code: http://files.cnblogs.com/chwkai/LinqAttach.rar
連結
LINQ那些事(總)
LINQ那些事(6) - 對象生命週期管理
相關討論
麒麟的文章引發了不少討論,挺有趣的:
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對象,不小心反而會造成記憶體不必要的佔用。