本文章是由機器翻譯。

資料點

實體框架為什麼向我的資料庫再次插入已有物件?

Julie Lerman

下載代碼示例

就在為本期專欄的主題構思的時候,有三位朋友通過 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 上,我也看到這樣的問題: “Why is EF inserting data that already exists?” And I often guess correctly in reply:「你是不是在對新建實體設置導覽屬性,而沒有用外鍵? 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, 
  int topicId)
{
  if (topicId > 0)
  {
    screencast.Topic = null;
    screencast.TopicId = topicId;
  }
  using (var context = new ScreencastContext())
  {
    context.Screencasts.Add(screencast);
    context.SaveChanges();
  }
}

此時我的存儲庫方法就可以應對若干種場景,甚至還提供了相應的邏輯,可以提供新的 Topic 並傳遞給該方法。

用 ASP.NET MVC 4 基架生成的代碼來避免這一問題

儘管斷開連接的應用程式天生存在這個問題,但如果你用 ASP.NET MVC 4 基架來生成視圖和 MVC 控制器,就可以避免導航實體被重複插入資料庫的問題。

鑒於 Screencast 與 Topic 以及 TopicId 屬性(該屬性是 Screencast 類型中的外鍵)之間是一對多關聯性,基架在控制器中生成以下 Create 方法:

public ActionResult Create()
{
  ViewBag.TopicId = new SelectList(db.Topics, "Id", "Name");
  return View();
}

這段代碼構建了一個 Topic 清單,命名為 TopicId(與外鍵屬性同名),並將其傳遞給視圖。

基架也在 Create 視圖的標記中包含了以下清單:

<div class="editor-field">
  @Html.DropDownList("TopicId", String.Empty)
  @Html.ValidationMessageFor(model => model.TopicId)
</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

衷心感謝以下技術專家對本文的審閱: Diego Vega (Microsoft)