最近做了個多對多對實體對象,結果發現每次只要增加一個子實體,就會自動添加一個父實體進去,而不管該父實體是否已經存在.
找了好久,終於找到這篇文章,照文章內容來看,應該是斷開連接導致的.
原文地址:http://msdn.microsoft.com/zh-cn/magazine/dn166926.aspx
------------------------------------------------------------------------------
在為本期專欄的主題構思的時候,有三位朋友通過 twitter 和郵件問我,實體框架為什麼向他們的數據庫再次插入已有對象。
看來,我不用為本期專欄寫什麼而頭疼了。
由於實體框架具有狀態管理能力,因此當它處理圖形時,其實體狀態行為並不總是符合你的期望。
我們來看一個典型示例。
假定有兩個類:Screencast 和 Topic 類,且為每個 Screencast 對象分配一個 Topic 對象,如圖 1 所示。
圖 1 Screencast 和 Topic 類
Id { ; Title { ; Description { ; Topic Topic { ; TopicId { ; Id { ; Name { ;
如果我想要檢索 Topic 的列表,並將其中一個對象分配給新的 Screencast 對象然後保存(整個操作集都包含在一個上下文中),整個過程不會有任何問題,如下例所示:
( context = dataTopic ==>t.Name.Contains(== =
於是,數據庫中就會插入一個 Screencast 對象,並且具有指向所選 Topic 的相應外鍵。
如果你是在客戶端應用程序中工作,或是在上下文跟蹤所有活動的單個工作單元內執行這些步驟,那麼上述處理方式可能正是你期望的。
不過,如果您正在處理已斷開連接的數據,那麼其處理方式將會迥然不同,結果也可能會讓許多開發者大吃一驚。
我在處理引用列表時通常采用的一種模式是使用獨立的上下文,當保存任何用戶修改時該上下文將不再處於可訪問范圍內。
這對 Web 應用程序和 Web 服務來說是常見的情景,但也可能發生在客戶端應用程序中。
下面的例子使用一個存儲庫來存儲引用數據,通過下面的 GetTopicList 方法來檢索 Topic 的列表:
List<Topic> ( context =
然後你可以將這些 Topic 對象以列表形式展現在一個 Windows Presentation Foundation (WPF) 表單中,以便讓用戶可以新建 Screencast 對象,例如圖 2 所示的表單。
圖 2 用來輸入新 Screencast 對象的 Windows Presentation Foundation 表單
然後,在客戶端應用程序中(如圖 2 所示的 WPF 表單),將下拉列表中選定的條目賦給新 Screencast 對象的 Topic 屬性,代碼如下:
Save_Click(=== topicListBox.SelectedItem
此時 Screencast 變量是一個包含了新建的 Screencast 和 Topic 實例的圖形。
將該變量傳遞給存儲庫的 SaveNewScreencast 方法,即可將此圖形添加到新建的上下文實例中並隨即保存到數據庫,如下列代碼所示:
( context =
對數據庫活動進行分析,我們發現以上代碼不僅向數據庫插入了 Screencast 對象,而且在此之前,還向 Topics 表插入了關於 Data Dev 主題的一行新記錄,即使該主題已經存在:
exec sp_executesql N @@ROWCOUNT > and [Id] =@ nvarchar(max) Data Dev
這種行為使許多開發者感到困惑。
發生這種情況的原因是,當你調用 DBSet.Add 方法(即 Screencasts.Add)時,不僅根實體的狀態標記為“Added”,圖形中上下文之前未知的所有實體的狀態也都標記為 Added。
盡管開發者可能注意到 Topic 對象已經有一個 Id 值,但實體框架則以其 EntityState (Added) 狀態為准,無視已有的 Id,仍然為該 Topic 對象創建一條 Insert 數據庫命令。
雖然許多開發者可能會預測到這種行為,但是還有許多人並不了解。
在後一種情況下,如果你沒有對數據庫活動進行分析,可能不會意識到發生了什麼,直到下次你(或用戶)在 Topics 列表中發現重復條目才知道出了問題。
注: 如果你對實體框架如何插入新記錄不太了解,可能會對上文所述的 SQL 中的 select 語句感到好奇。
它是用來確保實體框架能夠取回新創建的 Screencast 記錄的 Id 值,以便在 Screencast 實例中設置此值。
我們來看看另一種可能發生此問題的場景。
如果不向存儲庫傳遞圖形,而是讓存儲庫方法將新建的 Screencast 和選定的 Topic 同時作為請求參數,會怎麼樣?
這樣一來,不再是添加整個圖形,而是添加 Screencast 實體,然後設置其 Topic 導航屬性:
( context = =
在本例中,SaveChanges 的行為與已添加圖形的行為沒什麼兩樣。
您可能已經熟悉如何使用實體框架的 Attach 方法將未跟蹤的實體附加到上下文。
在本例中,實體的初始狀態是 Unchanged。
但在這裡,當我們把 Topic 賦給 Screencast 實例而非上下文時,實體框架會把它看成是未識別的實體,而實體框架對無狀態的未識別實體的默認處理方式是將其標記為 Added。
這樣一來,Topic 將在調用 SaveChanges 時被再次插入數據庫。
我們可以對狀態進行控制,但這需要對實體框架的行為有更深入的理解。
例如,如果你准備將 Topic 直接附加到上下文,而不是附加到狀態為 Added 的 Screencast 對象,那麼其 EntityState 狀態的初始值將會是 Unchanged。
此時將 Topic 賦值給 screencast.Topic 將不會引起狀態變化,因為上下文已經意識到 Topic 的存在了。
下面是展示這一邏輯的修改後的代碼:
( context = =
還有另外一種處理方法:不調用 context.Topics.Attach(topic),而是代之以在此前或此後設置 Topic 的狀態,明確地將其狀態設置為 Unchanged:
context.Entry(topic).State = EntityState.Unchanged
如果在上下文意識到 Topic 的存在之前調用上述代碼,會導致上下文附加該 Topic,並隨即設置其狀態。
盡管上述這些做法是處理該問題的正確模式,但我們不會自然而然地想到這麼做。
除非你已經預先了解實體框架的這種處理方式,並知道所需的代碼模式,否則你可能會更傾向於編寫看起來符合正常邏輯的代碼,然後在實際運行中遇到這個問題,只有到這時候你才會開始研究到底出了什麼事。
但還有一種簡單得多的方法,利用外鍵屬性,可以避免這種迷惑/混淆(原諒我的俏皮話)。
與其設置 Topic 這個導航屬性並且不得不為其狀態操心,不如只設置 TopicId 屬性,因為你確實可以在 Topic 實例中訪問到它的值。
這是我經常給開發者建議的做法。
甚至在 Twitter 上,我也看到這樣的問題: “為什麼實體框架會插入已經存在的數據?”而我在回復中經常猜對了: “你是不是在對新建實體設置導航屬性,而沒有用外鍵? J”
因此,讓我們回顧一下 WPF 表單中的 Save_Click 方法,並改為設置 TopicId 屬性而非 Topic 導航屬性:
repo.SaveNewScreencast(=== (
此時,發送給存儲庫方法的 Screencast 就不再是圖形,只是單個實體。
實體框架可以用該外鍵屬性來直接設置表的 TopicId。
這樣一來,對實體框架來說,為包含 TopicId 值(在本例中,其值為 2)的 Screencast 實體創建一個 insert 方法就簡單了(而且更快了):
sp_executesql NN,N,
如果你想把這段構造邏輯限制在存儲庫內,而且不想讓用戶界面開發者操心外鍵的設置,可以把 Topic 的 Id 和 Screencast 指定為存儲庫方法的參數,如下所示:
( context = =
我們需要擔心的不止於此,還需要考慮到,開發者可能還會設置 Topic 導航屬性。
換言之,即使我們想用外鍵來避免 EntityState 問題,但萬一 Topic 實例是圖形的一部分怎麼辦?例如以下所示 Save_Click 按鈕的另一種代碼實現:
repo.SaveNewScreencastWithTopicId(===topicListBox.SelectedItem
不幸的是,這將讓你回到問題的原點: 實體框架將 Topic 實體看成是圖形,並將該實體與 Screencast 一起添加到上下文中,即使已經設置了 Screencast.TopicId 屬性也是如此。 而且 Topic 實例的 EntityState 再次造成了混淆: 實體框架將插入一條新的 Topic 記錄,並在插入 Screencast 記錄時用該值作為新記錄的 Id。
避免這一問題的最安全方法,是在設置外鍵的值時將 Topic 屬性設置為 null。
如果有其他用戶界面要使用存儲庫方法,而您又無法確保只會用到已有的 Topic,那麼你甚至可能想在這種可能的情況下新建一個 Topic 傳遞過去。
圖 3 展示了為完成這一任務而再次修改的存儲庫方法。
圖 3 旨在防止向數據庫意外插入導航屬性的存儲庫方法
(topicId > = = ( context =
此時我的存儲庫方法就可以應對若干種場景,甚至還提供了相應的邏輯,可以提供新的 Topic 並傳遞給該方法。
盡管斷開連接的應用程序天生存在這個問題,但如果你用 ASP.NET MVC 4 基架來生成視圖和 MVC 控制器,就可以避免導航實體被重復插入數據庫的問題。
鑒於 Screencast 與 Topic 以及 TopicId 屬性(該屬性是 Screencast 類型中的外鍵)之間是一對多關系,基架在控制器中生成以下 Create 方法:
= SelectList(db.Topics, ,
這段代碼構建了一個 Topic 列表,命名為 TopicId(與外鍵屬性同名),並將其傳遞給視圖。
基架也在 Create 視圖的標記中包含了以下列表:
<div =>=></div>
當該視圖將數據提交回來時,HttpRequest.Form 中包含了一個名為 TopicId 的查詢字符串值,該值來自 ViewBag 屬性。
TopicId 的值是 DropDownList 中選定條目的值。
因為查詢字符串的名稱與 Screencast 的屬性名匹配,所以 ASP.NET MVC 模型綁定將使用所創建的 Screencast 實例的 TopicId 屬性值作為方法參數,如圖 4 所示。
圖 4 新的 Screencast 從匹配的 HttpRequest 查詢字符串值來獲取其 TopicId 值
為了檢驗這一點,你可以將控制器的 TopicId 變量改為其他名字,例如 TopicIdX,然後在視圖的 @Html.DropDownList 中對“TopicId”字符串作同樣修改,則查詢字符串值(現在是 TopicIdX)將被忽略,screencast.TopicId 的值將為 0。
這時,將不會有 Topic 實例通過管道傳遞回來。
因此 ASP.NET MVC 默認根據外鍵屬性,從而避免了向數據庫重復插入已有的 Topic。
盡管實體框架的開發團隊在一版又一版的更新升級中做了大量工作,使斷開連接的數據處理起來更容易,但它仍然是個讓許多並不熟知實體框架預期行為的開發者為之氣餒的問題。
在 Rowan Miller 和我共同編著的《Programming Entity Framework: DbContext》(實體框架編程:DbContext)一書(O' Reilly Media,2012)中,我們花了一整章討論斷開連接的實體和圖形。 而且在制作近期的一集 Pluralsight 課程時,我額外增加了 25 分鐘的時間,專門講解斷開連接的圖形在存儲庫中的復雜性。
用圖形進行數據查詢和交互是非常方便的,但要建立圖形與現有數據的關系時,外鍵是不可或缺的朋友!
請查閱我在 2012 年 1 月的專欄文章“設法應對缺少的外鍵”(msdn.microsoft.com/magazine/hh708747),其中也討論了不用外鍵的一些編程陷阱。
在下一期專欄文章中,我將繼續探索如何減輕開發者在斷開連接的場景中與圖形打交道所遇到的痛苦。
那期專欄是本主題的第二部分,將集中討論如何在多對多關系和導航集合中對 EntityState 進行控制。
Julie Lerman
是 Microsoft MVP、.NET 導師和顧問,住在佛蒙特州的山區。 您可以在全球的用戶組和會議中看到她對數據訪問和其他 Microsoft .NET 主題的演示。 她是《Programming Entity Framework》(2010) 以及“代碼優先”版 (2011) 和 DbContext 版 (2012)(均出自 O’Reilly Media)的作者,博客網址為 thedatafarm.com/blog。 請關注她的 Twitter:twitter.com/julielerman。