2013年5月14日 星期二

Entity Framework 更新時出現「ObjectStateManager 中已經有具有相同索引鍵的物件。ObjectStateManager 無法追蹤多個具有相同索引鍵的物件。」錯誤

今天一位朋友寫信向我詢問有關「ObjectStateManager 中已經有具有相同索引鍵的物件。ObjectStateManager 無法追蹤多個具有相同索引鍵的物件。」的問題,他看了我的「分層架構」系列文章後也動手實作練習,然後就在要更新資料並且執行 GenericRepository 的 Update 方法時就出現了錯誤。

這個錯誤的發生無關分層也無關使用 Unity bootstrapper for ASP.NET MVC 或其他 IoC Container,甚至也跟因為使用 IoC Container 而修改 GenericRepository Constructor 是沒有關係的,接下來就稍微跟大家說一下是怎麼一回事,然後怎麼解決這個問題。

 


重現問題

我以分層架構系列文章的程式來做說明,所使用的是尚未加入使用 Unity bootstrapper for ASP.NET MVC 的版本,先看 CategoryController 的 Edit 這個 Action 方法,如下:

image

在 Edit 方法裡是將 View 所 POST 過來的 Category 物件資料使用 CategoryService 的 Update 方法來做資料更新,接著就是看 CategoryService 的 Update 方法,如下:

image

這邊也很簡單(實際的更新操作情況下,這個方法並沒有這麼簡單地),再去使用實作 IRepository 的 GenericRepository 的 Update 方法,

image

在 GenericRepository 的 Update 方法裡,我們把前面所傳過來的實體物件給附加到 DbContext 裡,然後再去將狀態指定為「Modified」,接下來當執行 DbContext 的 SaveChanges 方法時就會把實體物件裡的資料更新到資料庫裡;這樣的操作對於剛接觸或是還不熟 ADO.NET Entity Framework 的朋友可能覺得這樣處理並不是那麼直接,為何不是用一般的方式做更新呢?如下:

public ActionResult Edit(Category instance)
{
    if (instance != null && ModelState.IsValid)
    {
        NorthwindEntities db = new NorthwindEntities();
 
        var original = db.Categories
            .FirstOrDefault(x => x.CategoryID == instance.CategoryID);
 
        original.CategoryName = instance.CategoryName;
        original.Description = instance.Description;
        //省略其餘屬性的賦值操作
 
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(Category);
}

上面的方式也是可行的,先從資料可裡把原本的資料取出來,然後再把從前端 POST 過來的實體物件一一地將每個屬性資料放過去,物件類別的屬性如果少的話,這麼做沒有什麼感覺,如果屬性很多的話就會讓人覺得很麻煩,另一個重點就是前端所 POST 過來的已經是一個實體物件了,只不過是有修改過,實在是沒有必要再去多做上面那些動作,所以我們就把 POST 過來的實體物件放到目前的 DbContext 裡,然後執行 SaveChanges 更新到資料庫。

所以原先在 CategoryController 的 Edit,CategoryService 與 GenericRepository 相關 Update 方法,這些方法的程式都沒有問題,而且可以正常執行,但假如我想要在進入 GenericRepository Update 之前先取出原本的資料,因為可能需要做一些比對或是其他因為必要的操作,於是我先在 CategoryController Edit 方法裡,先取出原本的資料,如下,

image

然後執行更新,最後卻出現了「ObjectStateManager 中已經有具有相同索引鍵的物件。ObjectStateManager 無法追蹤多個具有相同索引鍵的物件。」這個例外,

image

因為我們已經在同一個 Context 先去執行的取出原本在資料庫裡的資料,然後又再把 POST 進來的實體物件給加到 Context 裡,最後執行 SaveChanges() 就會出現兩個具有相同索引鍵的物件了。

 

解決

參考資料:

An object with the same key already exists in the ObjectStateManager. The ObjectStateManager cannot track multiple objects with the same key

看看這個在 StackOverflow 上同樣狀況的解答,

public override void Update(T entity) where T : IEntity {
    if (entity == null) {
        throw new ArgumentException("Cannot add a null entity.");
    }
 
    var entry = _context.Entry<T>(entity);
 
    if (entry.State == EntityState.Detached) {
        var set = _context.Set<T>();
        T attachedEntity = set.Find(entity.Id);  // You need to have access to key
 
        if (attachedEntity != null) {
            var attachedEntry = _context.Entry(attachedEntity);
            attachedEntry.CurrentValues.SetValues(entity);
        } else {
            entry.State = EntityState.Modified; // This should attach entity
        }
    }
}  

先使用 DbContext.Entry<T>() 方法建立 DbEntryEntry 實例,藉由這個實例來去判斷傳進來的實體物件是否有在 context 裡,如果這個 entry 其狀態為 EntityState.Detached(不在 context 裡),接下來就是來判斷目前 context 的 DbSet<T> 裡是否有相同索引鍵的物件,這邊使用的是 DbSet<T>.Find() 方法,如果有找到有相同索引鍵的物件在 context 中,那麼我們就用 TEntry.CurrentValues.SetValues(TEntity) 方法把傳進來的實體物件資料放進已經存在 context 的相同索引鍵物件裡,如果沒有找到的話,那就將 entry 的狀態設定為 EntityState.Modified,最後執行 SaveChanges() 就會更新到資料庫。

不過上面的程式有個問題,那就是「keyValues」,因為我們在 GenericRepository 是使用泛型,在 Update 方法裡也不會知道傳進來的實體物件其主鍵為何,所以這個主鍵就由各個 Service 裡 Update 方法傳過來,以下 IRepository 裡所定義的方法,

void Update(TEntity instance, params object[] keyValues);

GenericRepository 的實作,

/// <summary>
/// Updates the specified instance.
/// </summary>
/// <param name="instance">The instance.</param>
/// <param name="keyValues">The key values.</param>
/// <exception cref="System.ArgumentNullException">instance</exception>
public void Update(TEntity instance, params object[] keyValues)
{ 
    if (instance == null)
    {
        throw new ArgumentNullException("instance");
    }
 
    var entry = _context.Entry<TEntity>(instance);
 
    if (entry.State == EntityState.Detached)
    {
        var set = _context.Set<TEntity>();
 
        TEntity attachedEntity = set.Find(keyValues);
 
        if (attachedEntity != null)
        {
            var attachedEntry = _context.Entry(attachedEntity);
            attachedEntry.CurrentValues.SetValues(instance);
        }
        else
        {
            entry.State = EntityState.Modified;
        }
    }
    this.SaveChanges();
}

 

使用

CategoryController 的 Edit 方法不用變動,需要變動的是 CategoryService 的 Update() 程式內容,

image

執行程式並且在 GenericRepository 的 Update(TEntity, keyValues) 下中斷點做觀察。

下圖,在 context 當中找到具有相同索引鍵的物件,

image

下圖,因為在 Controller Action 方法裡有先取出原本的資料,context 裡就會有這一個物件,

image

在 context 的 attachedEntry,其屬性值都使用有修改過的實體物件值,

image

這樣就可以正常執行更新了。

 

延伸閱讀:

MSDN - DbContext.Entry(TEntity) Method (TEntity) (System.Data.Entity)

MSDN - DbSet(TEntity).Find Method (System.Data.Entity)

MSDN - Data Developer Center - Entity Framework Add/Attach and Entity States

Entity Framework 4.1 DbContext使用记之三——如何玩转实体的属性值? - LingzhiSun - 博客园

 

以上

9 則留言:

  1. 太感謝大大的分享,今天剛好遇到這問題。得以解決

    回覆刪除
    回覆
    1. 其實會發生這個問題大部分都是在更新的時候發生的,都是把接收到的 modal 去做更新的處理,但是更新前會先把原本資料庫的同一筆資料 Query 出來,這樣就會造成同一個 DbContext 有兩筆同樣索引鍵的資料存在,所以當 db.SaveChange 的時候就會保錯,這篇文章的作法是其中一種解決方式,但也比較麻煩,容易造成誤解,最簡單的作法就是將 Query 出來的那個 instance 內容做資料的更新,接收的 model 只是裝載更新資料的容器而已,這樣的作法是比較簡單。

      刪除
    2. 一開始是這樣做的,但後來覺得如果欄位太多,要去更新的話有點麻煩。
      所以嘗試利用 AutoMapper 來處理。就遇到同樣索引鍵的資料存在。
      但利用 EntityState.Detached 就可以處理掉,但不知道這跟 Modified 的用法上有甚麼差異
      不知道大大有沒有處理過這部份,謝謝。

      刪除
  2. 感謝分享,
    不過在stackoverflow原討論串中,
    set.Find(entity.Id)已改為使用set.Local.SingleOrDefault(e => e.Id == entity.Id);

    回覆刪除
  3. 感謝分享

    但想請問使用Find()的話是否會進入資料庫中再撈取一次?
    或是只是針對目前DbContext中已經Track的物件做搜尋?
    (我自己測試看起來比較像前者)

    另我找到相關文章可使用ObjectContext來取得EntityKey
    如下:
    var keyValue = ((IObjectContextAdapter)context).ObjectContext.CreateEntityKey(typeof(TEntity).Name, item);
    object o = null;
    objContext.TryGetObjectByKey(keyValue, out o);

    這樣子是否就不需要再另外傳入PK值, 或是這樣做會有什麼不妥的地方?

    回覆刪除
    回覆
    1. Hello, 你好
      第一個問題,其實我沒有太深究有關這部分的內容,抱歉。
      第二個問題,我還是會由外部傳入 PK 值,在使用的情境與行為上,從外部指定物件資料的 PK 值給執行端去做處理,這比較合理,而且有些狀況 PK 值並不是只有一個而已,所以這部分我就會由外部去指定 PK 值。

      刪除
    2. TryGetObjectByKey() 我測試過是可以應用在組合key的情況下的
      CreateEntityKey() 也可取得Entity的組合key值, 故多個欄位組成PK也沒問題
      只是還沒有試過若Entity有繼承等特殊情況是否正常

      還是感謝你的分享~

      刪除

提醒

千萬不要使用 Google Talk (Hangouts) 或 Facebook 及時通訊與我聯繫、提問,因為會掉訊息甚至我是過了好幾天之後才發現到你曾經傳給我訊息過,請多多使用「詢問與建議」(在左邊,就在左邊),另外比較深入的問題討論,或是有牽涉到你實作程式碼的內容,不適合在留言板裡留言討論,請務必使用「詢問與建議」功能(可以夾帶檔案),謝謝。