文章出處

閱讀目錄:

  • 1. 應用場景
  • 2. 場景測試
  • 3. 問題分析
  • 4. 追根溯源
  • 5. 簡要總結

1. 應用場景

首先,應用程序使用 EntityFramework,應用場景中有兩個實體 S_Class(班級)和 S_Student(學生),并且是一對多的關系,即一個班級對應多個學生,業務存在這樣的需求,學生要更換班級,所以我們要對某一個學生對應的班級值進行更改,就這么簡單,這也是我們應用程序最常遇到的一個場景,即關聯實體的更改。但我在更改中遇到了一些“奇怪”問題,修改學生對應的班級值后,在持久化保存的時候,EF 把修改的班級對象作為新對象添加了。

問題先拋在一邊,我們看一下應用示例代碼:

public class SchoolDbContext : DbContext
{
    public SchoolDbContext()
        :base("name=Demo")
    {
        this.Configuration.LazyLoadingEnabled = false;
        Database.SetInitializer<SchoolDbContext>(null);
    }

    public DbSet<S_Student> S_Students { get; set; }
    public DbSet<S_Class> S_Classs { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Entity<S_Class>()
            .HasKey(n => n.ClassId);

        modelBuilder.Entity<S_Student>()
            .HasKey(n => n.StudentId);
        modelBuilder.Entity<S_Student>()
            .HasRequired(w => w.S_Class);

        base.OnModelCreating(modelBuilder);
    }
}

public class S_Class
{
    public int ClassId { get; set; }
    public int Name { get; set; }
}

public class S_Student
{
    public int StudentId { get; set; }
    public int ClassId { get; set; }
    public string Name { get; set; }
    public virtual S_Class S_Class { get; set; }
}

需要注意的是,S_Class 和 S_Student 的關聯配置是通過 .HasRequired(w => w.S_Class),如果我們把 Database.SetInitializer<SchoolDbContext>(null); 代碼注釋掉(不強制映射到數據庫),在應用程序運行的時候,EF 會自動在 S_Student 表中生成一個 S_Class_Id 外鍵,如果我們把這個外鍵刪掉(看起來很丑,我們想使用 ClassId),S_Class 和 S_Student 中的主鍵由 ClassId 和 StudentId,修改為 Id 和 Id,然后重新運行應用程序(Database.SetInitializer 代碼取消注釋),這時候就會拋出這樣一個異常:

異常詳情:{"列名 'S_Class_Id' 無效。"},其實解決上面這個異常問題,由很多的方法,如果我們不想使用 EF 自動生成的 S_Class_Id 外鍵,而是使用 ClassId,我們可以在 OnModelCreating 中添加一個外鍵字段映射就可以了,還有一種更好的方法是,就是上面的示例代碼,因為在 S_Student 中定義了關聯(或稱之為導航)屬性 S_Class,這時候 EF 在映射數據庫的時候,會自動找 S_Class 中的主鍵,以及 S_Student 所對應的外鍵,如果找不到就會自動生成(在強制映射到數據庫配置取消的情況下),那 EF 是怎么進行查找外鍵的呢?就是通過 S_Class 中的主鍵 ClassId,然后根據它的命名,去 S_Student 中找對應的屬性字段,最后就找到了 S_Student 實體下的 ClassId,然后就把它當作了所謂的“外鍵”(數據庫中并未映射),有人會說,這有什么用呢?看這樣一段代碼:

using (var context = new SchoolDbContext())
{
    var student = context.S_Students.Include(s => s.S_Class).FirstOrDefault();
}

其實,ClassId 這個所謂的外鍵,就是我們在 Include 查詢的時候會起作用,還有就是對 S_Student 實體的 S_Class,進行賦值的時候,會自動映射到 ClassId,最后的表現就是:數據庫中 S_Students 表的 ClassId 值更改了。

有點扯遠了,完全和主題不相關,關于這個問題,我最后想說的是:好的實體命名設計,可以幫你減少一些不必要的問題出現,并且省掉很多的工作

2. 場景測試

回到正題,針對一開始描述的問題,我們編寫下測試代碼:

static void Main(string[] args)
{
    using (var context = new SchoolDbContext())
    {
        var s_Student = context.S_Students.Include(s => s.S_Class).FirstOrDefault(c => c.StudentId == 1);
        var s_Class = GetSingleClass(2);
        s_Student.Name = "xishuai";
        s_Student.S_Class = s_Class;
        context.SaveChanges();
    }
}

public static S_Class GetSingleClass(int id)
{
    using (var context = new SchoolDbContext())
    {
        return context.S_Classs.FirstOrDefault(c => c.ClassId == id);
    }
}

測試代碼所表達的意思是:取出一個 StudentId 為 1 的 s_Student 對象,然后再取出一個 ClassId 為 2 的 s_Class 對象,并將它賦值給 s_Student 的 S_Class 屬性,如果運行正常的話,數據庫中 S_Students 表的 ClassId 字段值,會更新為 2。

但測試的結果,卻和我們預想的大相徑庭,S_Classs 表中新增了一條數據,然后 S_Students 表的 ClassId 值為新增的 S_Classs 主鍵值,我們想對 S_Students 進行修改,最后卻變成了對 S_Classs 的新增。

3. 問題分析

看了上面的描述,我想有人應該可以看出問題緣由,沒錯,就是 S_Classs 的獲取和 S_Students 的更新,不在同一個 DbContext 中。

一開始,我壓根沒從這個問題上想,而是認為是 EF 的映射配置出了問題,走了很多彎路,因為我太相信 EF 了,s_Class 對象是從數據庫中獲取的,那么我賦值更新 S_Students 中的 S_Class 對象,s_Class 主鍵是存在的,在 EF SaveChanges 持久化保存的時候,應該會根據 s_Class 的 ClassId,去找這個 S_Class 是否存在,如果存在的話,S_Students 中的 S_Class 則相應更新,但結果顯然不是這樣,EF 并沒有根據外鍵去找這個對象是否存在,而是通過上下文中對象的狀態去持久化,如果切換上下文,那么對象的狀態將會丟失。

來自《Understanding How DbContext Responds to Setting the State of a Single Entity》文章的一段描述:

Setting an entity to the Detached state used to be important before the Entity Framework supported POCO objects. Prior to POCO support, your entities would have references to the context that was tracking them. These references would cause issues when trying to attach an entity to a second context. Setting an entity to the Detached state clears out all the references to the context and also clears the navigation properties of the entity—so that it no longer references any entities being tracked by the context. Now that you can use POCO objects that don’t contain references to the context that is tracking them, there is rarely a need to move an entity to the Detached state. We will not be covering the Detached state in the rest of this chapter.

關鍵句:These references would cause issues when trying to attach an entity to a second context.

你可以在上面的測試代碼中,添加一行這樣的代碼:

Console.WriteLine(context.Entry(s_Student.S_Class).State);//輸出結果:Added

既然不在一個上下文中,s_Student.S_Class 對象的狀態會變成 Added,那如果我們將操作放在一個上下文中,結果會怎樣呢?我們來寫下測試代碼:

static void Main(string[] args)
{
    using (var context = new SchoolDbContext())
    {
        var s_Student = context.S_Students.Include(s => s.S_Class).FirstOrDefault(s => s.StudentId == 1);
        var s_Class = context.S_Classs.FirstOrDefault(c => c.ClassId == 2);
        s_Student.Name = "xishuai";
        s_Student.S_Class = s_Class;
        Console.WriteLine(context.Entry(s_Student).State);//輸出結果:Modified
        Console.WriteLine(context.Entry(s_Student.S_Class).State);//輸出結果:Unchanged
        context.SaveChanges();
    }
}

先說下結果:數據庫中 S_Students 表的 ClassId 字段值會更新為 2,并且 S_Classs 表不會新增數據,這是我們想要的結果,但我們發現 s_Student.S_Class).State 的值為 Unchanged,按理說,它應該為 Modified 的,那 Unchanged 是什么意思呢?從字面上就可以看到是無修改或無變化的意思,上面文章中也有對它的具體說明:

  • Unchanged: The entity already exists in the database and has not been modified since it was retrieved from the database. SaveChanges does not need to process the entity.

說白了就是,如果實體的狀態是 Unchanged,那么 SaveChanges 將不進行更新,從 EF 的源碼中就可以看出(稍后說下),既然 s_Student.S_Class 的狀態是 Unchanged,那為什么 EF 又對它進行更新了呢?一個問題還沒解決,又引出了另一個問題。

只做測試,很顯然不能了解更多內容,如果想刨根問底的話,我們就必須從 EF 的源碼下手(EF7):https://github.com/aspnet/EntityFramework

4. 追根溯源

首先,有一個疑問:實體中的屬性,它的作用,其實說白了,就是存儲數據值的,那屬性值的對象狀態變化是如何記錄的呢?從測試代碼中,可以看出,我們并沒有對屬性狀態做一些修改,而 context.Entry(s_Student).State 卻可以得到 Modified 的狀態值,很顯然,答案就在 context.Entry 中,我們找下 DbContext.Entry 的相關源碼,發現了下面的一些東西:

public virtual EntityEntry<TEntity> Entry<TEntity>([NotNull] TEntity entity) where TEntity : class
{
    Check.NotNull(entity, nameof(entity));
    TryDetectChanges(GetStateManager());
    return EntryWithoutDetectChanges(entity);
}

TryDetectChanges 很直觀,就是去發現實體的一些修改(好的命名是多么的重要啊!!!),最后,順藤摸瓜,我們又找到了下面的一些東西:

public virtual void DetectChanges(InternalEntityEntry entry)
{
    DetectPropertyChanges(entry);
    DetectRelationshipChanges(entry);
}

private void DetectPropertyChanges(InternalEntityEntry entry)
{
    var entityType = entry.EntityType;

    if (entityType.HasPropertyChangedNotifications())
    {
        return;
    }

    var snapshot = entry.TryGetSidecar(Sidecar.WellKnownNames.OriginalValues);
    if (snapshot == null)
    {
        return;
    }

    foreach (var property in entityType.GetProperties())
    {
        if (property.GetOriginalValueIndex() >= 0
            && !Equals(entry[property], snapshot[property]))
        {
            entry.SetPropertyModified(property);
        }
    }
}

private void DetectRelationshipChanges(InternalEntityEntry entry)
{
    var snapshot = entry.TryGetSidecar(Sidecar.WellKnownNames.RelationshipsSnapshot);
    if (snapshot != null)
    {
        DetectKeyChanges(entry, snapshot);
        DetectNavigationChanges(entry, snapshot);
    }
}

DetectChanges 中調用了兩個方法:DetectPropertyChanges(entry);DetectRelationshipChanges(entry);,從字面上我們可以很直觀的知道他們是什么意思,DetectPropertyChanges 是監測實體屬性的值變化,DetectRelationshipChanges 是檢測管理實體的值變化,從 DetectPropertyChanges 方法內容中,可以看到,foreach 所有的屬性,然后進行舊值是否為空盒新舊值對比判斷,如果舊值不為空并且新舊值不想等,則對此屬性狀態設置為 Modified。DetectRelationshipChanges 方法中,主要進行了兩個操作:DetectKeyChanges 和 DetectNavigationChanges,意思是監測外鍵屬性和導航屬性值的變化,在我們的應用示例中,其實并沒有外鍵,而是導航屬性,但 DetectNavigationChanges 好像并沒有起到作用,因為 s_Student.S_Class).State 的值為 Unchanged,這部分代碼,我看了好久也沒看懂,因為很多的 C# 寫法看不懂,有個感觸就是,原來 C# 還可以這么寫?有點井底之蛙看到天空的感覺,大家如果能看懂的話,歡迎指教。

再說一點,上面的 DetectChanges 代碼都是獲取判斷操作,也就是 Get 屬性值,然后有兩個,一個是原始值,一個是新值,那又有一個疑問:屬性的原始值和新值是如何記錄的呢?從上面的代碼,根據 var snapshot = entry.TryGetSidecar(Sidecar.WellKnownNames.OriginalValues); 這段代碼線索,我們再順藤摸瓜,可以得到一些信息:原始值是通過新值進行獲取,然后由 Sidecar 類型對象存儲,具體的內容,都在 InternalEntityEntry 代碼中,我貼一段 AddSidecar 和 TryGetSidecar 的代碼:

public virtual Sidecar AddSidecar([NotNull] Sidecar sidecar)
{
    var newArray = new[] { sidecar };
    _sidecars = _sidecars == null
        ? newArray
        : newArray.Concat(_sidecars).ToArray();

    if (sidecar.TransparentRead
        || sidecar.TransparentWrite
        || sidecar.AutoCommit)
    {
        _stateData.TransparentSidecarInUse = true;
    }

    return sidecar;
}

public virtual Sidecar TryGetSidecar([NotNull] string name) => _sidecars?.FirstOrDefault(s => s.Name == name);

看到里面的代碼,瞬間又蒙圈了,實在是看不太懂,也就不具體分析了,關于屬性值的記錄,我想是我們在設置屬性值的時候,EF 在上下文中幫我們記錄了(所以切換上下文,會造成狀態的丟失),具體是怎么記錄的,看懂上面的相關代碼,也許可以得到一些答案。

context.Entry(s_Student.S_Class).State 這段代碼開始,我們大致可以了解 EF 實體屬性狀態的一些流程,包括記錄新舊值、判斷新舊值、設置屬性狀態等。

最后,我們再來看下 SaveChanges 的一些源碼:

[DebuggerStepThrough]
public virtual int SaveChanges(bool acceptAllChangesOnSuccess)
{
    var stateManager = GetStateManager();

    TryDetectChanges(stateManager);

    try
    {
        return stateManager.SaveChanges(acceptAllChangesOnSuccess);
    }
    catch (Exception ex)
    {
        _logger.LogError(
            new DatabaseErrorLogState(GetType()),
            ex,
            (state, exception) =>
                Strings.LogExceptionDuringSaveChanges(Environment.NewLine, exception));

        throw;
    }
}

[DebuggerStepThrough]
public virtual int SaveChanges(bool acceptAllChangesOnSuccess)
{
    var entriesToSave = Entries
        .Where(e => e.EntityState == EntityState.Added
                    || e.EntityState == EntityState.Modified
                    || e.EntityState == EntityState.Deleted)
        .Select(e => e.PrepareToSave())
        .ToList();

    if (!entriesToSave.Any())
    {
        return 0;
    }

    try
    {
        var result = SaveChanges(entriesToSave);

        if (acceptAllChangesOnSuccess)
        {
            AcceptAllChanges(entriesToSave);
        }

        return result;
    }
    catch
    {
        foreach (var entry in entriesToSave)
        {
            entry.AutoRollbackSidecars();
        }
        throw;
    }
}

在 SaveChanges 方法中,又看到了似曾相識的代碼(TryDetectChanges),沒錯,EF 在持久化保存之前,首先會設置實體屬性值的狀態,為什么?看到下面的代碼就懂了,entriesToSave 獲取的是要保存的實體集合,Where(e => e.EntityState == EntityState.Added || e.EntityState == EntityState.Modified || e.EntityState == EntityState.Deleted),看了這段代碼,你就會知道為什么 EF 上下文不會持久化實體屬性狀態為 Unchanged 的值,因為沒有它的判斷。

追根溯源大致就到這里,想要深入了解 EF,也不是看幾段源碼就能了解的,最后還有一個疑問:context.Entry(s_Student.S_Class).State); 的狀態值為 Unchanged,也就是在 TryDetectChanges 中并沒有設置為 Modified,但為什么它的值在 SaveChanges 中保存了?看了 SaveChanges 中的代碼,并沒有發現一些特殊的代碼,這是為什么呢?大家如果曉得的話,歡迎指教。

5. 簡要總結

最后來個總結吧,首先,關聯屬性值的修改無效,出現這個問題的緣由是我太“相信” EF 了,認為它會根據外鍵 Id 去查找數據是否存在,然后進行判斷是否修改還是新增,但很顯然并不是這樣,EF 并沒有那么智能化,在 SaveChanges 持久化保存的時候,它只認屬性值的狀態,它不管什么主外鍵,這個需要切記切記。

解決上面的問題,有很多的方式,比如:

  • 使用 Unit Of Work。
  • 操作在一個 using DbContext 中。
  • 手動設置實體屬性的狀態,也就是 context.Entry(s_Student.S_Class).State = EntityState.Modified;

需要注意的是,這種問題只有在設置實體關聯(導航)屬性值的時候,才會出現,設置關聯對象值,然后映射修改到數據庫中的外鍵值,如果僅僅是對實體屬性值的修改,只要 Get 和 Save 在一個 using DbContext 中,其他我們可以想怎么操作就怎么操作。

就記錄到這。


文章列表




Avast logo

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


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

    IT工程師數位筆記本

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