最近做了個多對多對實體對象,結果發現每次只要增加一個子實體,就會自動添加一個父實體進去,而不管該父實體是否已經存在.
找了好久,終于找到這篇文章,照文章內容來看,應該是斷開連接導致的.
原文地址:http://msdn.microsoft.com/zh-cn/magazine/dn166926.aspx
------------------------------------------------------------------------------
在為本期專欄的主題構思的時候,有三位朋友通過 twitter 和郵件問我,實體框架為什么向他們的數據庫再次插入已有對象。
看來,我不用為本期專欄寫什么而頭疼了。
由于實體框架具有狀態管理能力,因此當它處理圖形時,其實體狀態行為并不總是符合你的期望。
我們來看一個典型示例。
假定有兩個類:Screencast 和 Topic 類,且為每個 Screencast 對象分配一個 Topic 對象,如圖 1 所示。
圖 1 Screencast 和 Topic 類
public class Screencast{ public int Id { get; set; } public string Title { get; set; } public string Description { get; set; } public Topic Topic { get; set; } public int TopicId { get; set; }}public class Topic{ public int Id { get; set; } public string Name { get; set; }}如果我想要檢索 Topic 的列表,并將其中一個對象分配給新的 Screencast 對象然后保存(整個操作集都包含在一個上下文中),整個過程不會有任何問題,如下例所示:
using (var context = new ScreencastContext()){ var dataTopic = context.Topics.FirstOrDefault(t=>t.Name.Contains("Data")); context.Screencasts.Add(new Screencast { Title="EF101", Description = "Entity Framework 101", Topic = dataTopic }); context.SaveChanges();} 于是,數據庫中就會插入一個 Screencast 對象,并且具有指向所選 Topic 的相應外鍵。
如果你是在客戶端應用程序中工作,或是在上下文跟蹤所有活動的單個工作單元內執行這些步驟,那么上述處理方式可能正是你期望的。
不過,如果您正在處理已斷開連接的數據,那么其處理方式將會迥然不同,結果也可能會讓許多開發者大吃一驚。
我在處理引用列表時通常采用的一種模式是使用獨立的上下文,當保存任何用戶修改時該上下文將不再處于可訪問范圍內。
這對 Web 應用程序和 Web 服務來說是常見的情景,但也可能發生在客戶端應用程序中。
下面的例子使用一個存儲庫來存儲引用數據,通過下面的 GetTopicList 方法來檢索 Topic 的列表:
public class SimpleRepository{ public List<Topic> GetTopicList() { using (var context = new ScreencastContext()) { return context.Topics.ToList(); } } ... }然后你可以將這些 Topic 對象以列表形式展現在一個 Windows PResentation Foundation (WPF) 表單中,以便讓用戶可以新建 Screencast 對象,例如圖 2 所示的表單。
圖 2 用來輸入新 Screencast 對象的 Windows Presentation Foundation 表單
然后,在客戶端應用程序中(如圖 2 所示的 WPF 表單),將下拉列表中選定的條目賦給新 Screencast 對象的 Topic 屬性,代碼如下:
private void Save_Click(object sender, RoutedEventArgs e){ repo.SaveNewScreencast(new Screencast { Title = titleTextBox.Text, Description = descriptionTextBox.Text, Topic = topicListBox.SelectedItem as Topic });}此時 Screencast 變量是一個包含了新建的 Screencast 和 Topic 實例的圖形。
將該變量傳遞給存儲庫的 SaveNewScreencast 方法,即可將此圖形添加到新建的上下文實例中并隨即保存到數據庫,如下列代碼所示:
public void SaveNewScreencast(Screencast screencast){ using (var context = new ScreencastContext()) { context.Screencasts.Add(screencast); context.SaveChanges(); }}對數據庫活動進行分析,我們發現以上代碼不僅向數據庫插入了 Screencast 對象,而且在此之前,還向 Topics 表插入了關于 Data Dev 主題的一行新記錄,即使該主題已經存在:
exec sp_executesql N'insert [dbo].[Topics]([Name])values (@0)select [Id]from [dbo].[Topics]where @@ROWCOUNT > 0 and [Id] = scope_identity()',N'@0 nvarchar(max) ',@0=N'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 導航屬性:
public void SaveNewScreencastWithTopic(Screencast screencast, Topic topic){ using (var context = new ScreencastContext()) { context.Screencasts.Add(screencast); screencast.Topic = topic; context.SaveChanges(); }}在本例中,SaveChanges 的行為與已添加圖形的行為沒什么兩樣。
您可能已經熟悉如何使用實體框架的 Attach 方法將未跟蹤的實體附加到上下文。
在本例中,實體的初始狀態是 Unchanged。
但在這里,當我們把 Topic 賦給 Screencast 實例而非上下文時,實體框架會把它看成是未識別的實體,而實體框架對無狀態的未識別實體的默認處理方式是將其標記為 Added。
這樣一來,Topic 將在調用 SaveChanges 時被再次插入數據庫。
我們可以對狀態進行控制,但這需要對實體框架的行為有更深入的理解。
例如,如果你準備將 Topic 直接附加到上下文,而不是附加到狀態為 Added 的 Screencast 對象,那么其 EntityState 狀態的初始值將會是 Unchanged。
此時將 Topic 賦值給 screencast.Topic 將不會引起狀態變化,因為上下文已經意識到 Topic 的存在了。
下面是展示這一邏輯的修改后的代碼:
using (var context = new ScreencastContext()){ context.Screencasts.Add(screencast); context.Topics.Attach(topic); screencast.Topic = topic; context.SaveChanges();}還有另外一種處理方法:不調用 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(new Screencast { Title = titleTextBox.Text, Description = descriptionTextBox.Text, TopicId = (int)topicListBox.SelectedValue) });此時,發送給存儲庫方法的 Screencast 就不再是圖形,只是單個實體。
實體框架可以用該外鍵屬性來直接設置表的 TopicId。
這樣一來,對實體框架來說,為包含 TopicId 值(在本例中,其值為 2)的 Screencast 實體創建一個 insert 方法就簡單了(而且更快了):
exec sp_executesql N'insert [dbo].[Screencasts]([Title], [Description], [TopicId])values (@0, @1, @2)select [Id]from [dbo].[Screencasts]where @@ROWCOUNT > 0 and [Id] = scope_identity()',N'@0 nvarchar(max) ,@1 nvarchar(max) ,@2 int', @0=N'EFFK101',@1=N'Using Foreign Keys When Setting Navigations',@2=2
如果你想把這段構造邏輯限制在存儲庫內,而且不想讓用戶界面開發者操心外鍵的設置,可以把 Topic 的 Id 和 Screencast 指定為存儲庫方法的參數,如下所示:
public void SaveNewScreencastWithTopicId(Screencast screencast, int topicId){ using (var context = new ScreencastContext()) { screencast.TopicId = topicId; context.Screencasts.Add(screencast); context.SaveChanges(); }}我們需要擔心的不止于此,還需要考慮到,開發者可能還會設置 Topic 導航屬性。
換言之,即使我們想用外鍵來避免 EntityState 問題,但萬一 Topic 實例是圖形的一部分怎么辦?例如以下所示 Save_Click 按鈕的另一種代碼實現:
repo.SaveNewScreencastWithTopicId(new Screencast { Title = titleTextBox.Text, Description = descriptionTextBox.Text, Topic=topicListBox.SelectedItem as Topic }, (int) topicListBox.SelectedValue); 不幸的是,這將讓你回到問題的原點: 實體框架將 Topic 實體看成是圖形,并將該實體與 Screencast 一起添加到上下文中,即使已經設置了 Screencast.TopicId 屬性也是如此。 而且 Topic 實例的 EntityState 再次造成了混淆: 實體框架將插入一條新的 Topic 記錄,并在插入 Screencast 記錄時用該值作為新記錄的 Id。
避免這一問題的最安全方法,是在設置外鍵的值時將 Topic 屬性設置為 null。
如果有其他用戶界面要使用存儲庫方法,而您又無法確保只會用到已有的 Topic,那么你甚至可能想在這種可能的情況下新建一個 Topic 傳遞過去。
圖 3 展示了為完成這一任務而再次修改的存儲庫方法。
圖 3 旨在防止向數據庫意外插入導航屬性的存儲庫方法
public void SaveNewScreencastWithTopicId(Screencast screencast,
新聞熱點
疑難解答