EF爲何向個人數據庫再次插入已有對象?(ZT)

最近作了個多對多對實體對象,結果發現每次只要增長一個子實體,就會自動添加一個父實體進去,而無論該父實體是否已經存在.sql

找了很久,終於找到這篇文章,照文章內容來看,應該是斷開鏈接致使的.數據庫

原文地址:http://msdn.microsoft.com/zh-cn/magazine/dn166926.aspx編程

------------------------------------------------------------------------------安全

在爲本期專欄的主題構思的時候,有三位朋友經過 twitter 和郵件問我,實體框架爲何向他們的數據庫再次插入已有對象。框架

 看來,我不用爲本期專欄寫什麼而頭疼了。        ide

因爲實體框架具備狀態管理能力,所以當它處理圖形時,其實體狀態行爲並不老是符合你的指望。 spa

咱們來看一個典型示例。        3d

假定有兩個類:Screencast 和 Topic 類,且爲每一個 Screencast 對象分配一個 Topic 對象,如圖 1 所示。        code

圖 1 Screencast 和 Topic 類                       orm

          

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, 

 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                  

相關文章
相關標籤/搜索