[TOC] 一、 前言 這是筆者在參與一個小型項目開發時所遇到的一個BUG,因為項目經驗不足對Entity Framwork框架認識不足導致了這一BUG浪費了一天的時間,特此在這裡記錄。給自己一個警醒希望大家遇到相同問題能幫助到大家。 註:筆者水平有限,大家發現錯誤望批評指正。 二、問題背景 1.本 ...
目錄
一、 前言
這是筆者在參與一個小型項目開發時所遇到的一個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
1.因為引入了非同步編程的方式,開始將上文中UI層的所有非同步查詢和修改數據都改為了同步方法。
// 從資料庫查找記錄
var user = OpContext.Service.User.Where(u =>u.Id==OpContext.UserEntity.Id).FirstOrDefault();
...
if(OpContext.Service.SaveChanges() < 1)
...
更改以後通過斷電可以發現,數據正常提交至伺服器,進入修改密碼保存流程;但沒有效果,問題依舊,便開始查找更深層次的原因。
2.在其它地方添加了斷點,進行了第二次重試。有趣的事情發生了。
密碼admin居然登錄不上去了,而使用上一輪修改的1234567可以正常登錄。於是經接著提交了第二次表單。
由上圖可以看出,在記憶體中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.但是問題還是沒有解決,於是繼續斷點調試,在檢查這兩個斷點時發現了更有趣的現象。
在第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??
這個錯誤我知道,是在當前程式空間內,有一個實體對象存在於多個Entity數據上下文中,所以觸發了該錯誤,上文中將DbContext變為線程唯一就是為瞭解決這個錯誤;現在這個錯誤很明顯就是唯一性出問題了。而這是我將方法改為非同步形式後出現的,所以有以下原因。
首先得理解非同步中的await關鍵字,假設當前主線程運行,遇到await關鍵字,然後主線程就返回了。await關鍵字以下的代碼由非同步操作完成的其它線程繼續執行。
說明白點,就是下圖中178行和187行的代碼不是同一個線程執行的,所以通過EFFactory.GetDBContext()方法創建了多個DbContext對象,造成了這一問題。
解決這個問題很簡單,既然一個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技術,希望能有時間。
註:筆者水平有限,大家發現錯誤望批評指正。