记录一次BUG修复-Entity Framwork SaveChanges()失效


一、 前言

这是笔者在参与一个小型项目开发时所遇到的一个BUG,因为项目经验不足对Entity Framwork框架认识不足导致了这一BUG浪费了一天的时间,特此在这里记录。给自己一个警醒希望大家遇到相同问题能帮助到大家。

注:笔者水平有限,大家发现错误望批评指正。

二、问题背景

1.本次项目是一个ASP.NET MVC项目,因为项目比较小的关系,我们采用的是基本三层和仓储模式进行开发。
2.使用的ORM框架是Entity Framwork 6.0,对其进行了封装,形成Repository层,负责对数据库进行增删改查操作。
3.项目较小和层次不多的原因,我们使用Spring.net IOC容器对每层之间的调用进行DI解耦和。
4.整个框架是从一个其它项目中搬过来的,迁移花了半天之后直接就开始实际的项目开发。
5.原有框架对Entity Framwork封装采用的都是同步方式,这里我们试水异步,项目中出现很多await/async的访问。

三、问题描述

1.因项目较小,在开发过程中后端先行,前端还没有仔细测试。这是后端开发基本完成以后,加入前端测试时出现的问题。
2.前端测试过程中,可以增加、删除数据但无法保存修改的数据

贴出关键代码

以下是UI层代码,其作用是更改用户的当前密码。

[HttpPost]
public async Task<ActionResult> ChangePassword(ChangePasswordViewModel changePasswordViewModel)
{
    // 检查模型
    if (ModelState.IsValid == false)
    {
        return OpContext.JsonMsgFail(MODEL_VALIDATE_ERROR);
    }
    
    // 检查验证码
    if (OpContext.CheckValidateCode(changePasswordViewModel.validateCode) == false)
    {
        return OpContext.JsonMsgFail(MODEL_VALIDATECODE_ERROR);
    }
    
    // 从数据库查找记录
    var user = await OpContext.Service.User
    .Where(u =>u.Id==OpContext.UserEntity.Id).FirstOrDefaultAsync();
    if (changePasswordViewModel.oldPassword != user.UserPassword)
    {
        return OpContext.JsonMsgFail(CHECK_PASSWORD_ERROR);
    }
    
    // 更改密码并保存更改
    user.UserPassword = changePasswordViewModel.newPassword;
    try
    {
        OpContext.Service.User.Modify(user, new string[]{ "UserPassword" });
        if(await OpContext.Service.SaveChangesAsync() < 1)
            return OpContext.JsonMsgErr(DATA_SAVECHANGES_ERROR);
    }
    catch (Exception ex)
    {
        return OpContext.JsonMsgErr(ex.Message);
    }

    return OpContext.JsonMsgOK(DATA_MODIFY_SUCCESS);
}

以下是Repository层代码,关键是获取DbContext对象和更改实体的代码。

protected EntitiesContainer DbContext { get; private set; } = EFFactory.GetDBContext();

......

/// <summary>
/// 修改实体
/// </summary>
/// <param name="model">模型</param>
/// <returns></returns>
public void Modify(T model)
{
    DbContext.Entry<T>(model).State = System.Data.Entity.EntityState.Modified;
}

/// <summary>
/// 修改实体
/// </summary>
/// <param name="model">模型</param>
/// <param name="modifyPropertyNames">修改的属性名</param>
/// <returns></returns>
public void Modify(T model,params string[] modifyPropertyNames)
{
    var entry = DbContext.Entry<T>(model);
    entry.State = System.Data.Entity.EntityState.Unchanged;
    foreach(var pName in modifyPropertyNamesValues)
    {
        entry.Property(pName).IsModified = true;
    }
}

/// <summary>
/// 修改指定实体
/// </summary>
/// <param name="whereLamdba">修改条件</param>
/// <param name="modifyPropertyNamesValues">修改属性和值</param>
/// <returns></returns>
public void ModifyBy(Expression<Func<T, bool>> whereLamdba, Dictionary<string, object> modifyPropertyNamesValues)
{
    var models = DbContext.Set<T>().Where(whereLamdba);
    Type t = typeof(T);

    foreach (var model in models)
    {
        foreach (var pNameValue in modifyPropertyNamesValues)
        {
            PropertyInfo pi = t.GetProperty(pNameValue.Key);
            pi.SetValue(model, pNameValue.Value);
        }
    }
}

EF工厂从当前线程上下文获取数据库上下文。

public static class EFFactory
{
    /// <summary>
    /// 从线程上下文中获取EF容器
    /// </summary>
    /// <returns></returns>
    public static EntitiesContainer GetDBContext()
    {
        var context = CallContext.GetData(nameof(EntitiesContainer));

        if (context == null)
        {
            context = new EntitiesContainer();
            CallContext.SetData(nameof(EntitiesContainer), context);
        }

        return context as EntitiesContainer;
    }
}

四、问题解决步骤

以上一节中的代码是有问题的源代码,因为该项目框架是从别的正常项目中移植过来,所以开始并没有怀疑代码的正确性,从客户端代码入手。

提交的表单数据如下,原始密码为:admin,需修改为1234567
QQ图片20180124152333.png-4.3kB
1.因为引入了异步编程的方式,开始将上文中UI层的所有异步查询和修改数据都改为了同步方法。

// 从数据库查找记录
var user = OpContext.Service.User.Where(u =>u.Id==OpContext.UserEntity.Id).FirstOrDefault();
...
if(OpContext.Service.SaveChanges() < 1)
...

QQ图片20180124152457.png-22.7kB
QQ截图20180124152729.png-6.7kB

更改以后通过断电可以发现,数据正常提交至服务器,进入修改密码保存流程;但没有效果,问题依旧,便开始查找更深层次的原因。

2.在其它地方添加了断点,进行了第二次重试。有趣的事情发生了。
密码admin居然登录不上去了,而使用上一轮修改的1234567可以正常登录。于是经接着提交了第二次表单。
QQ图片20180124152715.png-28.1kB

由上图可以看出,在内存中user.UserPassword已经变更为1234567但是数据库中任然没有反应。这是为什么?聪明的大伙说不定已经猜出原因了。

笔者看到这个情况估计是Entity Framwork的数据缓存机制的原因,在上一次的修改中数据在内存中已经被修改,但是由于其它原因没有写入数据库。所以造成了第二次登录时直接使用的缓存中的数据。

由上可得以下分析:

(1).大家都知道,在项目中一些常用的工具类可以编写成静态类的方式节省时间和内存,其它不能编写为静态类的可通过单例模式来让整个程序运行空间只有一个实例。
(2).所以项目中的Repository层其实都是单例模式,节省new的时间和内存开支。而我们的DbContext数据上下文因为EF会追踪所有实体如果使用单例的话会疯狂吃内存,而且可能会发生“脏读”现象,所以一般都把它做成线程内唯一,也是笔者这个项目的做法。
(3).所以按照正常逻辑一个HTTP请求对应一个处理线程和一个DbContext对象,不可能发生第二次请求会使用第一次的缓存的现象,绝对是线程唯一出现了问题。

3.于是查看代码,发现了这一条语句。

protected EntitiesContainer DbContext { get; private set; } = EFFactory.GetDBContext();

这一条语句在笔者在设计文档中查看其作用是:“每次访问DbContext对象都调用EFFactory.GetDBContext()方法,从而从当前线程中读取线程惟一的DbContext对象。”
相当于以下代码。

protected EntitiesContainer DbContext()
{
    return EFFactory.GetDBContext();
}

但是现在这一条语句的作用却相当于这段代码,也就是说只会初始化一次。

private EntitiesContainer dbContext = EFFactory.GetDBContext();
protected EntitiesContainer DbContext()
{
    return dbContext;
}

然后将其改为设计中等价的代码,发现缓存的问题就不存在了,但是仍然不能保存更改。
一不小心揪出了一个存在项目中4年的BUG,好兴奋。

protected HynuIOTAEntitiesContainer DbContext => EFFactory.GetDBContext();
    return EFFactory.GetDBContext();
}

但是现在这一条语句的作用却相当于这段代码,也就是说只会初始化一次。

private EntitiesContainer dbContext = EFFactory.GetDBContext();
protected EntitiesContainer DbContext()
{
    return dbContext;
}

然后将其改为设计中等价的代码,发现缓存的问题就不存在了,但是仍然不能保存更改。
一不小心揪出了一个存在项目中4年的BUG,好兴奋。

protected HynuIOTAEntitiesContainer DbContext => EFFactory.GetDBContext();
    那为什么老项目用的好好的,没有问题呢?因为笔者在开头提过,为了节省时间和内存,将Repository层被设置成单例层,所以才造成这一问题,老项目中每次使用Respository都是重新new,所并不存在问题。

3.但是问题还是没有解决,于是继续断点调试,在检查这两个断点时发现了更有趣的现象。
QQ图片20180124160227.png-28.3kB
在第77行的时候我检查model其中UserPassword属性已经被改为"1234567",但是到第79,神奇的UserPas
sword属性又变为了"admin",给还原了。 WHY???????
于是笔者查看了老项目中的代码,是一个更新服务器列表的操作,代码如下。

var serverState = OpContext.Service.ServerState
    .Where(s => s.Id == Server.MachineId).FirstOrDefault();
if(Server.IsConnect == false)
{
    serverState.IsConnect = false;
    result = OpContext.Service.SaveChanges();
}

老项目中的代码完全没有执行Modify操作,难道不需要Modify就可以直接保存么?
于是笔者将Modify操作的代码删除以后,更改正常同步进入了数据库中。
查询了相关文档,发现了重点的几句话。

Entity Framwork ChangeTracker会跟踪数据上下文实体的更改状况,只有当数据上下文中不存在其实体,才会使用Modify将更改添加至数据上下文,进行更改操作。

知识点:
也就是说在之前使用OpContext.Service.User.Where(u=>u.Id==OpContext.UserEntity.Id).FirstOrDefault()已经将数据查询出来,数据上下文中已经存在实体对象,ChangeTracker会跟踪其更改状态,不用多此一举的使用Modify方法,直接SaveChange就可以。

问题就这么解决了么?目前是的,所有功能都正常,可以正常更改并保存至数据库中。
于是我又愉快的把代码改回异步形式,重新测试了一遍。
Excuse me??
image_1c4jl21tk106vmk8102i1a14em54c.png-12.6kB

这个错误我知道,是在当前程序空间内,有一个实体对象存在于多个Entity数据上下文中,所以触发了该错误,上文中将DbContext变为线程唯一就是为了解决这个错误;现在这个错误很明显就是唯一性出问题了。而这是我将方法改为异步形式后出现的,所以有以下原因。

首先得理解异步中的await关键字,假设当前主线程运行,遇到await关键字,然后主线程就返回了。await关键字以下的代码由异步操作完成的其它线程继续执行。

说明白点,就是下图中178行和187行的代码不是同一个线程执行的,所以通过EFFactory.GetDBContext()方法创建了多个DbContext对象,造成了这一问题。
image_1c4jlgvquuuvrm2e41ra81rgc4p.png-97.4kB

解决这个问题很简单,既然一个HTTP请求对应多个线程,线程唯一对象没办法满足要求,那么我们使用HTTP请求内唯一的方法改造GetDBContext()。

public static EntitiesContainer GetDBContext()
{
    var context = HttpContext.Current.Items[nameof(EntitiesContainer)] as EntitiesContainer;
    if (context == null)
    {
        context = new EntitiesContainer();
        HttpContext.Current.Items[nameof(EntitiesContainer)] = context;
    }
    return context as EntitiesContainer;
}

这样就实现了一个HTTP请求对应一个DbContext对象

六、总结

在本次BUG的查找和修复过程中,感触良多。因为对Entity Framwork框架的不熟悉,走了很多弯路。这一次BUG的出现让我很大的理解了Entity Framwork数据缓存和ChangeTracker技术,打算近段时间出一个专栏,详细了解一下Entity Framwork技术,希望能有时间。

注:笔者水平有限,大家发现错误望批评指正。
posted @ 2018-07-30 13:44  InCerry  阅读(...)  评论(... 编辑 收藏