Fork me on GitHub

追根溯源:EntityFramework 实体的状态变化

阅读目录:

  • 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 中,其他我们可以想怎么操作就怎么操作。

就记录到这。

posted @ 2015-07-15 10:27  田园里的蟋蟀  阅读(5747)  评论(7编辑  收藏  举报