文章出處

今天在開發項目的時候,使用 EF,突然遇到了這樣一個錯誤:

An entity object cannot be referenceed by multiple instances of IEntityChangeTracker

這個異常我想大家應該很熟悉,大致的意思是 EF 實體操作不在同一個 DbContext,我貼下出現錯誤的代碼:

public class AdTextUnitService : IAdTextUnitService
{
    private IAdTextUnitRepository _adTextUnitRepository;
    private IUnitOfWork _unitOfWork;

    public AdTextUnitService(IAdTextRepository adTextRepository,
        IUnitOfWork unitOfWork)
    {
        _adTextUnitRepository = adTextRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<bool> Update(int id, string title)
    {
        var adTextUnit = await _adTextUnitRepository.Get(id);

        adTextUnit.Title = title;
        _unitOfWork.RegisterDirty(adTextUnit);// 這里出錯
        return await _unitOfWork.CommitAsync());
    }
}

Update 的過程就是先使用 adTextUnitRepository 獲取 adTextUnit 對象,然后進行修改,最后通過 _unitOfWork 提交更改,Repository 和 UnitOfWork 共享一個 IDbContext,具體實現請點擊這里,既然共享同一個 IDbContext,那為什么還會出現上面的錯誤呢?并且同樣的代碼,我對其他實體操作卻是可以,但對 AdTextUnit 操作就是不行。

對比了AdTextUnit 和其他實體的區別,發現 AdTextUnit 存在 virtual 屬性,那為什么有 virtual 屬性就不行呢?首先,我想到了 EF 中的 LazyLoadingEnabled(懶加載配置),然后我就在 DbContext 中進行了下面配置:

public CNBlogsAdDbContext()
{
    Configuration.LazyLoadingEnabled = false;
}

但運行測試代碼,發現還是出現錯誤,那就肯定不是 LazyLoadingEnabled 的問題,Google 搜索相關問題,發現解決方式就是“共享”同一個 IDbContext,比如下面示例代碼:


//1. 操作同一 using 塊中
using (var context = new CNBlogsAdDbContext()) 
{ 
    var adTextUnit = context.AdTextUnit.FirstOrDefault(); 
    adTextUnit.Title = title;
    context.SaveChanges();
}

//2. DbContext 放在 HttpContext 請求中
internal static class ContextPerRequest
{
      internal static CNBlogsAdDbContext Current
      {
          get
          {
              if (!HttpContext.Current.Items.Contains("context"))
              {
                  HttpContext.Current.Items.Add("context", new CNBlogsAdDbContext());
              }
              return HttpContext.Current.Items["context"] as CNBlogsAdDbContext;
          }
      }
}
protected void Application_EndRequest(object sender, EventArgs e)
{
   var entityContext = HttpContext.Current.Items["context"] as CNBlogsAdDbContext;
   if (entityContext != null) 
      entityContext.Dispose();
}

插一段:以上內容是我下午寫的,現在是晚上時間,本來很簡單的問題,讓我搞復雜了,汗汗汗!!!當時除了嘗試 LazyLoadingEnabled 解決,還試了 IoC.RegisterPerRequestType 注入(上面的第二種解決方案),但當時寫代碼比較急,RegisterPerRequestType 寫在了 IUnitOfWork 接口注入上(眼瞎啊),然后我以為這種方式無效,就找其他方案,雖然最后用 ProxyCreationEnabled 解決了,這個過程真是坑爹,但也加深了對 DbContext 的一些理解。

還是要記錄一下,一開始的問題,我們只需要這樣就可以解決(和使用 using 是一樣的效果):

IoC.RegisterPerRequestType<IDbContext, CNBlogsAdDbContext>();

RegisterPerRequestType 的具體實現,就是上面第二種解決方案,雖然解決了問題,但還是有些疑惑,下面我們做個測試:

[Fact]
public void DbContextTest()
{
    var context = new CNBlogsAdDbContext();
    var context2 = new CNBlogsAdDbContext();
    AdTextUnit adTextUnit;
    using (context)
    {
        adTextUnit = context.Set<AdTextUnit>().FirstOrDefault();
    }
    using (context2)
    {
        adTextUnit.DateUpdated = DateTime.Now;
        context2.Entry<AdTextUnit>(adTextUnit).State = EntityState.Modified;
        context2.SaveChanges();
    }
}

上面的測試代碼,我們是想模擬不同 DbContext 操作實體的情況,由第一個 DbContext 查詢,然后第二個 DbContext 進行修改提交,執行測試代碼,測試是通過并且正確,是不是有點奇怪?這種情況和我們的項目代碼差不多(不使用 RegisterPerRequestType 的情況下),為什么測試代碼卻是可以的呢?想不通沒關系,下面我們對上面測試代碼進行修改,去掉 using:

[Fact]
public void DbContextTest()
{
    var context = new CNBlogsAdDbContext();
    var context2 = new CNBlogsAdDbContext();
    AdTextUnit adTextUnit;
    adTextUnit = context.Set<AdTextUnit>().FirstOrDefault();
    adTextUnit.DateUpdated = DateTime.Now;
    context2.Entry<AdTextUnit>(adTextUnit).State = EntityState.Modified;
    context2.SaveChanges();
}

執行測試代碼:

這個錯誤是那么的熟悉,通過測試代碼,我們大致可以推斷出這個錯誤到底是如何發生的?

下面我說一下自己的理解,EF 要修改實體對象,需要記錄實體對象值的變更,怎么記錄的呢?就是通過 EntityChangeTracker(注意上面錯誤中的 IEntityChangeTracker),比如這段代碼 adTextUnit.DateUpdated = DateTime.Now;,如果這個修改操作是在一個 using 中,那 EF DbContext 會通過 EntityChangeTracker 進行記錄修改值的變化,具體是怎么記錄的?大家可以看下 EF 的源碼,我之前有篇博文(《追根溯源:EntityFramework 實體的狀態變化》也說了一點點,之前有人問我,EF 修改實體的某一個屬性值,最后執行 SQL 的時候,到底是更改一個屬性值,還是全部更新呢?我們看一個段簡單代碼:

[Fact]
public void DbContextTest()
{
    using (var context = new CNBlogsAdDbContext())
    {
        var adTextUnit = context.Set<AdTextUnit>().FirstOrDefault();
        adTextUnit.DateUpdated = DateTime.Now;
        context.SaveChanges();
    }
}

這段代碼再簡單不過,先通過 context 獲取 adTextUnit 實體對象,然后進行修改一個屬性值,最后進行保存,需要注意的是,我們并沒有像之前那樣手動 EntityState.Modified 配置,在修改屬性值的時候,EF 會通過 EntityChangeTracker 追蹤這個值的修改,然后在持久化的時候,進行檢測并生成最終的 SQL 代碼:

exec sp_executesql N'UPDATE [dbo].[AdTextUnits]
SET [DateUpdated] = @0
WHERE ([Id] = @1)
',N'@0 datetime2(7),@1 int',@0='2015-10-27 21:23:20.4506808',@1=4

從 SQL Server Profiler 捕獲的 SQL,就可以看出 EF 是很智能的,如果我們把上面的測試代碼,改成下面這樣:

[Fact]
public void DbContextTest()
{
    using (var context = new CNBlogsAdDbContext())
    {
        var adTextUnit = context.Set<AdTextUnit>().FirstOrDefault();
        adTextUnit.DateUpdated = DateTime.Now;
        context.Entry<AdTextUnit>(adTextUnit).State = EntityState.Modified;
        context.SaveChanges();
    }
}

手動加了一個實體 State 修改,最后生成的 SQL 如下:

exec sp_executesql N'UPDATE [dbo].[AdTextUnits]
SET [AdTextId] = @0, [AdTextModuleId] = @1, [StartDate] = @2, [EndDate] = @3, [Sort] = @4, [IsActive] = @5, [DateAdded] = @6, [DateUpdated] = @7, [OperatorName] = @8
WHERE ([Id] = @9)
',N'@0 int,@1 int,@2 datetime2(7),@3 datetime2(7),@4 int,@5 bit,@6 datetime2(7),@7 datetime2(7),@8 nvarchar(max) ,@9 int',@0=1,@1=1,@2='2015-10-19 00:00:00',@3='2016-10-19 00:00:00',@4=4,@5=1,@6='2015-10-19 00:00:00',@7='2015-10-27 21:43:16.8353133',@8=N'田園里的蟋蟀',@9=4

結果是 AdTextUnit 的全部屬性進行了更新,為什么會這樣呢?就是因為我們強制對 EF 說,AdTextUnit 被更改了,你記錄的 EntityChangeTracker 無效了,但我們沒具體指出哪個值被更改了,所以最后 EF 把所有屬性值都更改了一遍,由這個示例,我們可以看出,盡量不要“多此一舉”的對 Entry State 狀態進行更改,什么情況下會進行更改呢?就是我們在丟失 EntityChangeTracker 的情況下,也就是說實體對象并不是由 DbContext 進行獲取的,比如下面這段代碼:

[Fact]
public void DbContextTest()
{
    using (var context = new CNBlogsAdDbContext())
    {
        var adTextUnit = new AdTextUnit { Id = 1 };
        adTextUnit.DateUpdated = DateTime.Now;
        context.Entry<AdTextUnit>(adTextUnit).State = EntityState.Modified;
        context.SaveChanges();
    }
}

注意 Modified,而不是 Added,這段代碼是可以執行成功的,因為 adTextUnit 是我們手動進行創建的,如果不對 Entry State 狀態進行進行設置,執行是會報錯的,為什么?因為 EF 最后執行 SaveChanges 檢測不到 EntityChangeTracker。

感覺越說越亂了,關于 EntityChangeTracker 就說到這,了解了這么多,感覺可以對一開始的那段測試做出一些解釋,但好像還差什么?是什么呢?就是 Proxy(代理)

Proxy(代理):為 POCO 實體類型創建實例時,實體框架常常為充當實體代理的動態生成的派生類型創建實例。此代理重寫實體的某些虛擬屬性,這樣可在訪問屬性時插入掛鉤,從而自動執行操作。例如,此機制用于支持關系的延遲加載。本主題中所示方法同樣適用于使用 Code First 和 EF 設計器創建的模型。

需要注意關鍵詞:虛擬屬性(virtual),這也就是為什么 AdTextUnit 有問題,而其他屬性卻沒問題,那 Proxy 具體是什么鬼呢?其實就是這個東西:

后面一串的東西到底有什么用呢?其實它的作用就是記錄虛擬屬性,并修改保存和加載值,用一句話概括就是:Proxy 是實體虛擬屬性的 EntityChangeTracker

我們也可以手動進行配置,比如通過設置 ProxyCreationEnabled 取消代理:

public CNBlogsAdDbContext()
{
    Configuration.ProxyCreationEnabled = false;
}

再次執行:

會發現 AdTextUnit 的類型少了很長的“一坨東西”,另外,需要注意的是 ProxyCreationEnabled 和 LazyLoadingEnabled 有所不同,LazyLoadingEnabled 表示是否啟用懶加載,也就是我們在 EF 查詢實體的時候,會不會加載虛擬屬性(導航屬性)?如果設置為 false,就默認不啟用懶加載,但我們可以使用 Inculde 進行手動加載,但 ProxyCreationEnabled 表示的是追蹤,它和 EntityChangeTracker 很類似,只不過局限于實體的虛擬屬性。

最后,我們再來說下這段測試代碼,為什么會報錯?

[Fact]
public void DbContextTest()
{
    var context1 = new CNBlogsAdDbContext();
    var context2 = new CNBlogsAdDbContext();
    AdTextUnit adTextUnit;
    adTextUnit = context1.Set<AdTextUnit>().FirstOrDefault();
    adTextUnit.DateUpdated = DateTime.Now;
    context2.Entry<AdTextUnit>(adTextUnit).State = EntityState.Modified;
    context2.SaveChanges();
}

總結幾個關鍵點:

  • AdTextUnit 的查詢和修改不在一個 DbContext 中。
  • 兩個 DbContext 沒有使用 using 塊。
  • AdTextUnit 存在虛擬屬性。
  • ProxyCreationEnabled 默認為 true。

上面這幾個關鍵點,最后就導致了報錯發生,了解了我們上面的分析,其實很簡單,首先,通過 context1 獲取 adTextUnit 實體對象,因為我們開啟了代理,所以 adTextUnit 會進行追蹤其內部的虛擬屬性,并且這個追蹤和 context1 密切相關,接下來通過 context2 進行修改和保存 adTextUnit 實體,需要注意的是,因為我們沒有 using context1,所以這時候 adTextUnit 對應的代理追蹤還是存在的,又因為我們使用 context2 強制表示 adTextUnit 進行了修改,context1 下的代理追蹤,怎么能在 context2 進行標識呢?所以這時候肯定會拋出異常,這個道理用通俗的話來講,就是老李家的兒子,跑到老張家喊爸爸,老家肯定會打斷他兒子的腿。

這篇博文比較亂,而且內容和標題感覺差別也比較大,但一開始確實是由 EF DbContext.Configuration.ProxyCreationEnabled 引發的,并且一步一步測試分析,把這個過程分享出來,現在感覺還是蠻有價值的,一個點可以牽出其他很多點,對 EF 的理解和使用又進了一步,就到這!

參考資料:


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()