为 Entity Framework 的 DbContext 实现撤销/重做功能






4.95/5 (10投票s)
Entity Framework DbContext的撤销重做
引言
我之前对 DbContext
的变更跟踪和属性值感到好奇,在研究时,我决定编写一个快照管理器。它通过存储条目来为我的每个变更创建快照,并且这个管理器还允许我对 DbContext
中的变更进行后退和前进操作。撤销和重做功能让你可以轻松纠正错误,或根据某些场景进行操作,同时也让你能够自由地尝试不同的路由和数据映射决策。撤销会逆转你执行的最后一个操作,而重做则会撤销上一次的撤销操作。
背景
当变更跟踪功能启用时,DbContext
会检查所有类型的所有实体,并验证它们的数据是否有任何变化。自动变更跟踪功能默认是启用的。禁用它将不会为实体中的每个变更触发 DbContext
的更新。实际上,它维护了实体的状态。当调用 SaveChanges()
方法时,它会使用这些状态来确定需要推送到数据库的变更。禁用变更跟踪仍然允许我们检查实体的旧值和当前值,但它会将状态保持为 UnChanged
(未更改),直到检测到变更为止。我们需要在 DbContext
上手动调用 DetectChanges()
来更新它们。在某些情况下,Entity Framework API 也会隐式调用它。
DbContext.DetectChanges()
会在以下情况下被隐式调用:
DbSet
上的Add
、Attach
、Find
、Local
或Remove
成员DbContext
上的GetValidationErrors
、Entry
或SaveChanges
成员DbChangeTracker
上的 Entries 方法
DetectChanges
是作为 SaveChanges
实现的一部分被调用的。这意味着,如果你在上下文中重写了 SaveChanges
,那么在你的 SaveChanges
方法被调用之前,DetectChanges
将不会被调用。这有时会让人感到困惑,尤其是在检查一个实体是否被修改时,因为在调用 DetectChanges
之前,它的状态可能不会被设置为“已修改”(Modified)。相比于 ObjectContext.SaveChanges
,这种情况在 DbContext.SaveChanges
中发生的频率要低得多,因为用于访问实体状态的 Entry 和 Entries 方法会自动调用 DetectChanges
。与 SaveChanges
不同,ValidateEntity
是在 DetectChanges
被调用之后才调用的。这是因为验证需要针对将要保存的内容进行,而这些内容只有在调用 DetectChanges
之后才能确定。
实现
那么,让我们回到创建变更快照的主题上。由于 DbContext
仍然没有提供任何关于 DbContext.DetectChanges()
执行的事件,我决定在我的 Repository 的 CUD(创建
、更新
、删除
)操作中保存我的快照,并且允许用户在需要时显式调用它。
public void Add(T entity)
{
this.Context.GetEntitySet<T>().Add(entity);
this.Context.SnapshotManager.TakeSnapshot();
}
public void Update(T entity)
{
this.Context.ChangeState(entity, System.Data.EntityState.Modified);
this.Context.SnapshotManager.TakeSnapshot();
}
public void Remove(T entity)
{
this.Context.ChangeState(entity, System.Data.EntityState.Deleted);
this.Context.SnapshotManager.TakeSnapshot();
}
并且也允许用户访问快照管理器。
现在,你可能会问我 ISnapshotManager
的定义是什么。以下是快照管理器将为我们提供的功能:
public interface ISnapshotManager
{
void TakeSnapshot();
void UnDo();
void Redo();
bool CanUndo();
bool CanRedo();
}
让我们逐一来看每个任务的实现。
首先,我们来看看创建快照的任务。在此之前,我想提醒你关于 DbContext
的变更跟踪选项,如果“DetectChanges()
”已被执行,我们将从中获取变更条目。
如你所知,通过为 DbContext
分别设置 AutoDetectChangesEnabled
为 true
/ false
,可以启用/禁用变更跟踪。在 DbContext
API 中,Entity Framework 会为每个被跟踪实体的每个属性记录两个值。当前值(current value),顾名思义,是实体中属性的当前值。原始值(original value)是当实体从数据库查询出来或附加到上下文时该属性所具有的值。一旦我们获得了变更条目,我们就可以访问 OriginalValues
和 CurrentValues
属性。它们都是 DbPropertyValues
类型。它是底层实体或复杂对象所有属性的集合。
public void TakeSnapshot()
{
_redoDoList.Clear();
if(!this.Configuration.AutoDetectChangesEnabled)
this.ChangeTracker.DetectChanges();
var entries = this.ChangeTracker.Entries().Where( e => e.State == EntityState.Added ||
e.State == EntityState.Modified || e.State == EntityState.Deleted );
if(null != entries)
{
var entrySnapList = new List<SnapData>();
foreach (var entry in entries)
{
if (entry.Entity != null
&& !_unDoList.Any(v => v.Any(s => s.Entity.Equals(entry.Entity))) )
{
entrySnapList.Add(new SnapData()
{
OrginalValue = (entry.State == EntityState.Deleted ||
entry.State == EntityState.Modified) ?
(entry.OriginalValues != null) ? entry.OriginalValues.ToObject() :
entry.GetDatabaseValues()
: null,
Value = (entry.State == EntityState.Added ||
entry.State == EntityState.Modified) ?
entry.CurrentValues.ToObject() : null,
State = entry.State,
Entity = entry.Entity
});
}
}
if (entrySnapList.Count > 0)
_unDoList.Push(entrySnapList.AsEnumerable());
}
}
在这里,我保留了两个堆栈列表(Stack List),用于将变更条目保存到撤销(UnDo)列表以便后退,以及重做(ReDo)列表以便前进。一旦一个条目被列入撤销列表,直到它被弹出之前,它不会被重复添加。只有已添加、已修改或已删除的条目才被考虑拥有撤销-重做功能。除了条目的状态,我在这里还保留了属性。一个已添加的条目只有当前属性,而已删除的条目只有原始属性,而已修改的条目则两者都有。我使用 ToObject()
方法将这些属性值的克隆保存在一个名为 SnapData
的载体中。
class SnapData
{
public EntityState State;
public object Value;
public object OrginalValue;
public object Entity;
}
其次,在 Undo
方法中,我弹出条目并将其状态设为 Unchanged
(未更改),以便 DbContext
在向数据库提交更改时会忽略它们。
public void UnDo()
{
if (CanUndo())
{
bool previousContiguration = this.Configuration.AutoDetectChangesEnabled;
this.Configuration.AutoDetectChangesEnabled = false;
var entries = _unDoList.Pop();
{
this._redoDoList.Push(entries);
foreach (var snap in entries)
{
var currentEntry = this.Entry(snap.Entity);
if (snap.State == EntityState.Modified)
{
currentEntry.CurrentValues.SetValues(snap.OrginalValue);
var dbValue = currentEntry.GetDatabaseValues();
currentEntry.OriginalValues.SetValues(dbValue);
}
else if (snap.State == EntityState.Deleted)
{
var dbValue = currentEntry.GetDatabaseValues();
currentEntry.OriginalValues.SetValues(dbValue);
}
currentEntry.State = EntityState.Unchanged;
}
}
this.Configuration.AutoDetectChangesEnabled = previousContiguration;
}
}
在这里,在操作条目之前,变更跟踪已被关闭。如果变更跟踪是开启的,DbContext
在改变状态(如:未更改、已分离)时会移除外键关系。为了保持对象原样,我决定先将其关闭,完成我的工作后再恢复到之前的配置。
一个实体的所有属性值都可以读入一个 DbPropertyValues
对象中。然后,DbPropertyValues
就像一个类似字典的对象,允许读取和设置属性值。DbPropertyValues
对象中的值可以从另一个 DbPropertyValues
对象中的值设置,也可以从其他某个对象中的值设置。对于已修改和已删除的条目,我用数据库的值设置了原始值,并将之前的原始值设置为已修改条目的当前值。获取数据库的值很有用,特别是当数据库中的值可能在实体被查询后发生了变化,例如当另一用户对数据库进行了并发编辑时。(有关处理乐观并发的更多详情,请参见第 9 部分。)
第三,在重做(Redo)操作中,我将它们的 State
(状态)和值一起恢复。已添加的条目不需要这样做,而已删除的条目应该总是与它存储在数据库中的值保持一致。但已修改的条目需要恢复更改,我通过我存储的值来做到这一点。
public void Redo()
{
if (CanRedo())
{
bool previousContiguration = this.Configuration.AutoDetectChangesEnabled;
this.Configuration.AutoDetectChangesEnabled = false;
var entries = _redoDoList.Pop();
if (null != entries && entries.Count() > 0)
{
foreach (var snap in entries)
{
var currentEntry = this.Entry(snap.Entity);
if (snap.State == EntityState.Modified)
{
currentEntry.CurrentValues.SetValues(snap.Value);
currentEntry.OriginalValues.SetValues(snap.OrginalValue);
}
else if (snap.State == EntityState.Deleted)
{
var dbValue = currentEntry.GetDatabaseValues();
currentEntry.OriginalValues.SetValues(dbValue);
}
currentEntry.State = snap.State;
}
}
this.Configuration.AutoDetectChangesEnabled = previousContiguration;
}
}
现在,一旦你的更改被保存或提交到数据库,你需要清空你的撤销和重做列表,因为在那之后你的条目将被清除。
public void ClearSnapshots()
{
_redoDoList.Clear();
_unDoList.Clear();
}
现在让我们来使用这个实现。
using (var db = _unitOfWork.Value)
{
db.EnableAuditLog = false;
using (var transaction = db.BeginTransaction())
{
try
{
IRepository<BlogPost> blogRepository = db.GetRepository<BlogPost>();
blogRepository.Add(post);
var blog = blogRepository.FindSingle(b => !b.Id.Equals(post.Id)); ;
blog.Title = "I am changed again!!";
blogRepository.Update(blog);
db.UnDo();
db.UnDo();
int i = db.Commit();
transaction.Commit();
return (i > 0);
}
catch (Exception)
{
transaction.Rollback();
throw;
}
}
}
在这里,我做了两次更改,然后请求撤销两次。结果会是什么?是的,结果将是零,db.Commit()
将通过受影响的行数返回这个结果给你。你可以在这里使用重做(ReDo)来前进你的更改。UnitOfWork
包含一个 IContext
,它为我提供了我之前定义的快照管理器。通过使用那个管理器,它通常在我的业务层中提供撤销/重做功能。
看点
变更跟踪和 DetectChanges
方法是技术栈中会带来性能开销的一部分。因此,了解实际发生了什么会很有用,这样如果默认行为不适合你的应用程序,你可以做出明智的决策。如果你的上下文没有跟踪大量的实体,你几乎永远不需要关闭自动 DetectChanges
,否则我建议你关闭它,并在必要时调用 DetectChanges
。
参考文献
- http://msmvps.com/blogs/matthieu/archive/2009/06/10/entity-framework-undo-redo-v2.aspx
- DbContext.ChangeTracker 属性
- http://blogs.msdn.com/b/adonet/archive/2011/02/06/using-dbcontext-in-ef-feature-ctp5-part-12-automatically-detecting-changes.aspx
- http://blogs.msdn.com/b/adonet/archive/2011/01/30/using-dbcontext-in-ef-feature-ctp5-part-5-working-with-property-values.aspx
历史
- 2012年10月10日:初始版本