一、前言
二、整体更新(不考虑更新属性)
三、按需更新(考虑更新属性)
四、源码获取
系列导航
最近在整理EntityFramework数据更新的代码,颇有体会,觉得有分享的价值,于是记录下来,让需要的人少走些弯路也是好的。
为方便起见,先创建一个控制台工程,使用using(var db = new DataContext)的形式来一步一步讲解EF数据更新的可能会遇到的问题及对应的解决方案。在获得最佳方案之后,再整合到本系列的代码中。
本示例中,用到的数据模型如下图所示:
并且,我们通过数据迁移策略初始化了一些数据:
class="code_img_closed" src="/Upload/Images/2013090103/0015B68B3C38AA5B.gif" alt="" />logs_code_hide('48595cca-934d-40cf-8d9e-fcab64ef902d',event)" src="/Upload/Images/2013090103/2B1B950FA3DF188F.gif" alt="" />1 protected override void Seed(GmfEFUpdateDemo.Models.DataContext context) 2 { 3 //部门 4 var departments = new [] 5 { 6 new Department {Name = "技术部"}, 7 new Department {Name = "财务部"} 8 }; 9 context.Departments.AddOrUpdate(m => new {m.Name}, departments); 10 context.SaveChanges(); 11 12 //角色 13 var roles = new[] 14 { 15 new Role{Name = "技术部经理", Department = context.Departments.Single(m=>m.Name =="技术部")}, 16 new Role{Name = "技术总监", Department = context.Departments.Single(m=>m.Name =="技术部")}, 17 new Role{Name = "技术人员", Department = context.Departments.Single(m=>m.Name =="技术部")}, 18 new Role{Name = "财务部经理", Department = context.Departments.Single(m=>m.Name =="财务部")}, 19 new Role{Name = "会计", Department = context.Departments.Single(m=>m.Name =="财务部")} 20 }; 21 context.Roles.AddOrUpdate(m=>new{m.Name}, roles); 22 context.SaveChanges(); 23 24 //人员 25 var members = new[] 26 { 27 new Member 28 { 29 UserName = "郭明锋", 30 Password = "123456", 31 Roles = new HashSet<Role> 32 { 33 context.Roles.Single(m => m.Name == "技术人员") 34 } 35 } 36 }; 37 context.Members.AddOrUpdate(m => new {m.UserName}, members); 38 context.SaveChanges(); 39 }初始化数据
代码:
1 private static void Method01() 2 { 3 using (var db = new DataContext()) 4 { 5 const string userName = "郭明锋"; 6 Member oldMember = db.Members.Single(m => m.UserName == userName); 7 Console.WriteLine("更新前:{0}。", oldMember.AddDate); 8 9 oldMember.AddDate = oldMember.AddDate.AddMinutes(10); 10 int count = db.SaveChanges(); 11 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 12 13 Member newMember = db.Members.Single(m => m.UserName == userName); 14 Console.WriteLine("更新后:{0}。", newMember.AddDate); 15 } 16 }
代码解析:操作必然成功,执行的sql语句如下:
exec sp_executesql N'update [dbo].[Members] set [AddDate] = @0 where ([Id] = @1) ',N'@0 datetime2(7),@1 int',@0='2013-08-31 13:17:33.1570000',@1=1
注意,这里并没有对更新实体的属性进行筛选,但EF还是聪明的生成了只更新AddDate属性的sql语句。
代码:
1 private static void Method02() 2 { 3 const string userName = "郭明锋"; 4 5 Member updateMember; 6 using (var db1 = new DataContext()) 7 { 8 updateMember = db1.Members.Single(m => m.UserName == userName); 9 } 10 updateMember.AddDate = DateTime.Now; 11 12 using (var db2 = new DataContext()) 13 { 14 db2.Members.Attach(updateMember); 15 DbEntityEntry<Member> entry = db2.Entry(updateMember); 16 Console.WriteLine("Attach成功后的状态:{0}", entry.State); //附加成功之后,状态为EntityState.Unchanged 17 entry.State = EntityState.Modified; 18 int count = db2.SaveChanges(); 19 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 20 21 Member newMember = db2.Members.Single(m => m.UserName == userName); 22 Console.WriteLine("更新后:{0}。", newMember.AddDate); 23 } 24 }
代码解析:对于db2而言,updateMemner是一个全新的外来的它不认识的对象,所以需要使用Attach方法把这个外来对象附加到它的上下文中,Attach之后,实体的对象为 EntityState.Unchanged,如果不改变状态,在SaveChanged的时候将什么也不做。因此还需要把状态更改为EntityState.Modified,而由Unchanged -> Modified的改变,是我们强制的,而不是由EF状态跟踪得到的结果,因而EF无法分辨出哪个属性变更了,因而将不分青红皂白地将所有属性都刷一遍,执行如下sql语句:
exec sp_executesql N'update [dbo].[Members] set [UserName] = @0, [Password] = @1, [AddDate] = @2, [IsDeleted] = @3 where ([Id] = @4) ',N'@0 nvarchar(50),@1 nvarchar(50),@2 datetime2(7),@3 bit,@4 int',@0=N'郭明锋',@1=N'123456',@2='2013-08-31 13:28:01.9400328',@3=0,@4=1
代码:
1 private static void Method03() 2 { 3 const string userName = "郭明锋"; 4 5 Member updateMember; 6 using (var db1 = new DataContext()) 7 { 8 updateMember = db1.Members.Single(m => m.UserName == userName); 9 } 10 updateMember.AddDate = DateTime.Now; 11 12 using (var db2 = new DataContext()) 13 { 14 //先查询一次,让上下文中存在相同主键的对象 15 Member oldMember = db2.Members.Single(m => m.UserName == userName); 16 Console.WriteLine("更新前:{0}。", oldMember.AddDate); 17 18 db2.Members.Attach(updateMember); 19 DbEntityEntry<Member> entry = db2.Entry(updateMember); 20 Console.WriteLine("Attach成功后的状态:{0}", entry.State); //附加成功之后,状态为EntityState.Unchanged 21 entry.State = EntityState.Modified; 22 int count = db2.SaveChanges(); 23 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 24 25 Member newMember = db2.Members.Single(m => m.UserName == userName); 26 Console.WriteLine("更新后:{0}。", newMember.AddDate); 27 } 28 }
代码解析:此代码与情景二相比,就是多了14~16三行代码,目的是制造一个要更新的数据在上下文2中正在使用的场景,这时会发生什么情况呢?
当代码执行到18行的Attach的时候,将引发一个EF数据更新时非常常见的异常:
捕捉到 System.InvalidOperationException HResult=-2146233079 Message=ObjectStateManager 中已存在具有同一键的对象。ObjectStateManager 无法跟踪具有相同键的多个对象。 Source=System.Data.Entity StackTrace: 在 System.Data.Objects.ObjectContext.VerifyRootForAdd(Boolean doAttach, String entitySetName, IEntityWrapper wrappedEntity, EntityEntry existingEntry, EntitySet& entitySet, Boolean& isNoOperation) 在 System.Data.Objects.ObjectContext.AttachTo(String entitySetName, Object entity) 在 System.Data.Entity.Internal.Linq.InternalSet`1.<>c__DisplayClass2.<Attach>b__1() 在 System.Data.Entity.Internal.Linq.InternalSet`1.ActOnSet(Action action, EntityState newState, Object entity, String methodName) 在 System.Data.Entity.Internal.Linq.InternalSet`1.Attach(Object entity) 在 System.Data.Entity.DbSet`1.Attach(TEntity entity) 在 GmfEFUpdateDemo.Program.Method03() 位置 d:\Documents\Visual Studio 2012\Projects\GmfEFUpdateDemo\GmfEFUpdateDemo\Program.cs:行号 148 在 GmfEFUpdateDemo.Program.Main(String[] args) 位置 d:\Documents\Visual Studio 2012\Projects\GmfEFUpdateDemo\GmfEFUpdateDemo\Program.cs:行号 54 InnerException:
原因正是上下文2中已经有了一个相同主键的对象,不能再附加了。
这应该是一个非常常见的场景,也就是必须想办法解决的场景。其实只要获得现有实体数据的跟踪,再把新数据赋到现有实体上,就可以解决问题了,此方法唯一的缺点就是要获取到旧的实体数据。代码如下:
1 private static void Method04() 2 { 3 const string userName = "郭明锋"; 4 5 Member updateMember; 6 using (var db1 = new DataContext()) 7 { 8 updateMember = db1.Members.Single(m => m.UserName == userName); 9 } 10 updateMember.AddDate = DateTime.Now; 11 12 using (var db2 = new DataContext()) 13 { 14 //先查询一次,让上下文中存在相同主键的对象 15 Member oldMember = db2.Members.Single(m => m.UserName == userName); 16 Console.WriteLine("更新前:{0}。", oldMember.AddDate); 17 18 DbEntityEntry<Member> entry = db2.Entry(oldMember); 19 entry.CurrentValues.SetValues(updateMember); 20 int count = db2.SaveChanges(); 21 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 22 23 Member newMember = db2.Members.Single(m => m.UserName == userName); 24 Console.WriteLine("更新后:{0}。", newMember.AddDate); 25 } 26 }
代码中的18~19行是核心代码,先从上下文中的旧实体获取跟踪,第19行的SetValues方法就是把新值设置到旧实体上(这一条很强大,支持任何类型,比如ViewObject,DTO与POCO可以直接映射传值)。由于值的更新是直接在上下文中的现有实体上进行的,EF会自己跟踪值的变化,因此这里并不需要我们来强制设置状态为Modified,执行的sql语句也足够简单:
exec sp_executesql N'update [dbo].[Members] set [AddDate] = @0 where ([Id] = @1) ',N'@0 datetime2(7),@1 int',@0='2013-08-31 14:03:27.1425875',@1=1
综合上面的几种情景,我们可以得到EF对实体整体更新的最佳方案,这里写成DbContext的扩展方法,代码如下:
1 public static void Update<TEntity>(this DbContext dbContext, params TEntity[] entities) where TEntity : EntityBase 2 { 3 if (dbContext == null) throw new ArgumentNullException("dbContext"); 4 if (entities == null) throw new ArgumentNullException("entities"); 5 6 foreach (TEntity entity in entities) 7 { 8 DbSet<TEntity> dbSet = dbContext.Set<TEntity>(); 9 try 10 { 11 DbEntityEntry<TEntity> entry = dbContext.Entry(entity); 12 if (entry.State == EntityState.Detached) 13 { 14 dbSet.Attach(entity); 15 entry.State = EntityState.Modified; 16 } 17 } 18 catch (InvalidOperationException) 19 { 20 TEntity oldEntity = dbSet.Find(entity.Id); 21 dbContext.Entry(oldEntity).CurrentValues.SetValues(entity); 22 } 23 } 24 }
调用代码如下:
1 db.Update<Member>(member); 2 int count = db.SaveChanges();
针对不同的情景,将执行不同的行为:
前面已经有整体更新了,很多时候也都能做到只更新变化的实体属性,为什么还要来个“按需更新”的需求呢?主要基于以下几点理由:
按需更新,也就是知道要更新的实体属性,比如用户要修改密码,就只是要把Password这个属性的值变更为指定的新值,其他的最好是尽量不惊动。当然,至少还是要知道要更新数据的主键的,否则,更新对象就不明了。下面就以设置密码为例来说明问题。
要设置密码,我构造了一个空的Member类来装载新密码:
1 Member member = new Member {Id = 1, Password = "NewPassword" + DateTime.Now.Second};
然后,我们想当然的写出了如下实现代码:
1 private static void Method06() 2 { 3 Member member = new Member {Id = 1, Password = "NewPassword" + DateTime.Now.Second}; 4 using (var db = new DataContext()) 5 { 6 DbEntityEntry<Member> entry = db.Entry(member); 7 entry.State = EntityState.Unchanged; 8 entry.Property("Password").IsModified = true; 9 int count = db.SaveChanges(); 10 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 11 12 Member newMember = db.Members.Single(m => m.Id == 1); 13 Console.WriteLine("更新后:{0}。", newMember.Password); 14 } 15 }
然后,在执行第9行SaveChanges的时候引发了如下异常:
捕捉到 System.Data.Entity.Validation.DbEntityValidationException HResult=-2146232032 Message=对一个或多个实体的验证失败。有关详细信息,请参见“EntityValidationErrors”属性。 Source=EntityFramework StackTrace: 在 System.Data.Entity.Internal.InternalContext.SaveChanges() 在 System.Data.Entity.Internal.LazyInternalContext.SaveChanges() 在 System.Data.Entity.DbContext.SaveChanges() 在 GmfEFUpdateDemo.Program.Method06() 位置 d:\Documents\Visual Studio 2012\Projects\GmfEFUpdateDemo\GmfEFUpdateDemo\Program.cs:行号 224 在 GmfEFUpdateDemo.Program.Main(String[] args) 位置 d:\Documents\Visual Studio 2012\Projects\GmfEFUpdateDemo\GmfEFUpdateDemo\Program.cs:行号 63 InnerException:
为什么出现此异常?因为前面我们创建的Member对象只包含一个Id,一个Password属性,其他的属性并没有赋值,也不考虑是否规范,这样就定义出了一个不符合实体类验证定义的对象了(Member类要求UserName属性是不可为空的)。幸好,DbContext.Configuration中给我们定义了是否在保存时验证实体有效性(ValidateOnSaveEnabled)这个开关,我们只要在执行按需更新的保存时把验证闭,在保存成功后再开启即可,更改代码如下:
1 private static void Method06() 2 { 3 Member member = new Member {Id = 1, Password = "NewPassword" + DateTime.Now.Second}; 4 using (var db = new DataContext()) 5 { 6 DbEntityEntry<Member> entry = db.Entry(member); 7 entry.State = EntityState.Unchanged; 8 entry.Property("Password").IsModified = true; 9 db.Configuration.ValidateOnSaveEnabled = false; 10 int count = db.SaveChanges(); 11 db.Configuration.ValidateOnSaveEnabled = true; 12 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 13 14 Member newMember = db.Members.Single(m => m.Id == 1); 15 Console.WriteLine("更新后:{0}。", newMember.Password); 16 } 17 }
与整体更新一样,理所当然的会出现当前上下文已经存在了相同主键的实体数据的情况,当然,根据之前的经验,也很容易的进行处理了:
1 private static void Method07() 2 { 3 Member member = new Member { Id = 1, Password = "NewPassword" + DateTime.Now.Second }; 4 using (var db = new DataContext()) 5 { 6 //先查询一次,让上下文中存在相同主键的对象 7 Member oldMember = db.Members.Single(m => m.Id == 1); 8 Console.WriteLine("更新前:{0}。", oldMember.AddDate); 9 10 try 11 { 12 DbEntityEntry<Member> entry = db.Entry(member); 13 entry.State = EntityState.Unchanged; 14 entry.Property("Password").IsModified = true; 15 } 16 catch (InvalidOperationException) 17 { 18 DbEntityEntry<Member> entry = db.Entry(oldMember); 19 entry.CurrentValues.SetValues(member); 20 entry.State = EntityState.Unchanged; 21 entry.Property("Password").IsModified = true; 22 } 23 db.Configuration.ValidateOnSaveEnabled = false; 24 int count = db.SaveChanges(); 25 db.Configuration.ValidateOnSaveEnabled = true; 26 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 27 28 Member newMember = db.Members.Single(m => m.Id == 1); 29 Console.WriteLine("更新后:{0}。", newMember.Password); 30 } 31 }
但是,上面的代码却无法正常工作,经过调试发现,当执行到第20行的时候,entry中跟踪的数据又变回oldMember了,经过一番源码搜索,终于找到了问题的出处(System.Data.Entity.Internal.InternalEntityEntry类中):
1 public EntityState State 2 { 3 get 4 { 5 if (!this.IsDetached) 6 return this._stateEntry.State; 7 else 8 return EntityState.Detached; 9 } 10 set 11 { 12 if (!this.IsDetached) 13 { 14 if (this._stateEntry.State == EntityState.Modified && value == EntityState.Unchanged) 15 this.CurrentValues.SetValues(this.OriginalValues); 16 this._stateEntry.ChangeState(value); 17 } 18 else 19 { 20 switch (value) 21 { 22 case EntityState.Unchanged: 23 this._internalContext.Set(this._entityType).InternalSet.Attach(this._entity); 24 break; 25 case EntityState.Added: 26 this._internalContext.Set(this._entityType).InternalSet.Add(this._entity); 27 break; 28 case EntityState.Deleted: 29 case EntityState.Modified: 30 this._internalContext.Set(this._entityType).InternalSet.Attach(this._entity); 31 this._stateEntry = this._internalContext.GetStateEntry(this._entity); 32 this._stateEntry.ChangeState(value); 33 break; 34 } 35 } 36 } 37 }
第14、15行,当状态由Modified更改为Unchanged的时候,又把数据重新设置为旧的数据OriginalValues了。真吭!
好吧,看来在DbContext中折腾已经没戏了,只要去它老祖宗ObjectContext中找找出路,更改实现如下:
1 private static void Method08() 2 { 3 Member member = new Member { Id = 1, Password = "NewPassword" + DateTime.Now.Second }; 4 using (var db = new DataContext()) 5 { 6 //先查询一次,让上下文中存在相同主键的对象 7 Member oldMember = db.Members.Single(m => m.Id == 1); 8 Console.WriteLine("更新前:{0}。", oldMember.AddDate); 9 10 try 11 { 12 DbEntityEntry<Member> entry = db.Entry(member); 13 entry.State = EntityState.Unchanged; 14 entry.Property("Password").IsModified = true; 15 } 16 catch (InvalidOperationException) 17 { 18 ObjectContext objectContext = ((IObjectContextAdapter)db).ObjectContext; 19 ObjectStateEntry objectEntry = objectContext.ObjectStateManager.GetObjectStateEntry(oldMember); 20 objectEntry.ApplyCurrentValues(member); 21 objectEntry.ChangeState(EntityState.Unchanged); 22 objectEntry.SetModifiedProperty("Password"); 23 } 24 db.Configuration.ValidateOnSaveEnabled = false; 25 int count = db.SaveChanges(); 26 db.Configuration.ValidateOnSaveEnabled = true; 27 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 28 29 Member newMember = db.Members.Single(m => m.Id == 1); 30 Console.WriteLine("更新后:{0}。", newMember.Password); 31 } 32 }
catch代码块使用了EF4.0时代使用的ObjectContext来实现,很好的达到了我们的目的,执行的sql语句如下:
exec sp_executesql N'update [dbo].[Members] set [Password] = @0 where ([Id] = @1) ',N'@0 nvarchar(50),@1 int',@0=N'NewPassword2',@1=1
以上的实现中,属性名都是以硬编码的形式直接写到实现类中,作为底层的封闭,这是肯定不行的,至少也要作为参数传递到一个通用的更新方法中。参照整体更新的扩展方法定义,我们很容易的就能定义出如下签名的扩展方法:
public static void Update<TEntity>(this DbContext dbContext, string[] propertyNames, params TEntity[] entities) where TEntity : EntityBase 方法调用方式: dbContext.Update<Member>(new[] {"Password"}, member);
调用中属性名依然要使用字符串的方式,写起来麻烦,还容易出错。看来,强类型才是最好的选择。
写到这,突然想起了做数据迁移的时候使用到的System.Data.Entity.Migrations.IDbSetExtensions 类中的扩展方法
public static void AddOrUpdate<TEntity>(this IDbSet<TEntity> set, Expression<Func<TEntity, object>> identifierExpression, params TEntity[] entities) where TEntity : class
其中的参数Expression<Func<TEntity, object>> identifierExpression就是用于传送实体属性名的,于是,我们参照着,可以定义出如下签名的更新方法:
public static void Update<TEntity>(this DbContext dbContext, Expression<Func<TEntity, object>> propertyExpression, params TEntity[] entities) where TEntity : EntityBase 方法调用方式: db.Update<Member>(m => new { m.Password }, member);
到这里,如何从Expression<Func<TEntity, object>>获得属性名成为了完成封闭的关键。还是经过调试,有了如下发现:
运行时的Expression表达式中,Body属性中有个类型为ReadOnlyCollection<MemberInfo> 的 Members集合属性,我们需要的属性正以MemberInfo的形式存在其中,因此,我们借助一下 dynamic 类型,将Members属性解析出来,即可轻松得到我们想的数据。
ReadOnlyCollection<MemberInfo> memberInfos = ((dynamic)propertyExpression.Body).Members;
经过上面的分析,难点已逐个击破,很轻松的就得到了如下扩展方法的实现:
1 public static void Update<TEntity>(this DbContext dbContext, Expression<Func<TEntity, object>> propertyExpression, params TEntity[] entities) 2 where TEntity : EntityBase 3 { 4 if (propertyExpression == null) throw new ArgumentNullException("propertyExpression"); 5 if (entities == null) throw new ArgumentNullException("entities"); 6 ReadOnlyCollection<MemberInfo> memberInfos = ((dynamic)propertyExpression.Body).Members; 7 foreach (TEntity entity in entities) 8 { 9 try 10 { 11 DbEntityEntry<TEntity> entry = dbContext.Entry(entity); 12 entry.State = EntityState.Unchanged; 13 foreach (var memberInfo in memberInfos) 14 { 15 entry.Property(memberInfo.Name).IsModified = true; 16 } 17 } 18 catch (InvalidOperationException) 19 { 20 TEntity entityOrgin = dbContext.Set<TEntity>().Find(((dynamic)entity).Id); 21 ObjectContext objectContext = ((IObjectContextAdapter)dbContext).ObjectContext; 22 ObjectStateEntry objectEntry = objectContext.ObjectStateManager.GetObjectStateEntry(entityOrgin); 23 objectEntry.ApplyCurrentValues(entity); 24 objectEntry.ChangeState(EntityState.Unchanged); 25 foreach (var memberInfo in memberInfos) 26 { 27 objectEntry.SetModifiedProperty(memberInfo.Name); 28 } 29 } 30 } 31 }
此外,还有一个可以封闭的地方就是关闭了ValidateOnSaveEnabled属性的SaveChanges方法,可封闭为如下:
1 public static int SaveChanges(this DbContext dbContext, bool validateOnSaveEnabled) 2 { 3 bool isReturn = dbContext.Configuration.ValidateOnSaveEnabled != validateOnSaveEnabled; 4 try 5 { 6 dbContext.Configuration.ValidateOnSaveEnabled = validateOnSaveEnabled; 7 return dbContext.SaveChanges(); 8 } 9 finally 10 { 11 if (isReturn) 12 { 13 dbContext.Configuration.ValidateOnSaveEnabled = !validateOnSaveEnabled; 14 } 15 } 16 }
辛苦不是白费的,经过一番折腾,我们的按需更新实现起来就非常简单了:
1 private static void Method09() 2 { 3 Member member = new Member { Id = 1, Password = "NewPassword" + DateTime.Now.Second }; 4 using (var db = new DataContext()) 5 { 6 //先查询一次,让上下文中存在相同主键的对象 7 Member oldMember = db.Members.Single(m => m.Id == 1); 8 Console.WriteLine("更新前:{0}。", oldMember.AddDate); 9 10 db.Update<Member>(m => new { m.Password }, member); 11 int count = db.SaveChanges(false); 12 Console.WriteLine("操作结果:{0}", count > 0 ? "更新成功。" : "未更新。"); 13 14 Member newMember = db.Members.Single(m => m.Id == 1); 15 Console.WriteLine("更新后:{0}。", newMember.Password); 16 } 17 }
只需要第10,11行两行代码,即可完成完美的按需更新功能。
本文示例源码下载:GmfEFUpdateDemo.zip
为了让大家能第一时间获取到本架构的最新代码,也为了方便我对代码的管理,本系列的源码已加入微软的开源项目网站 http://www.codeplex.com,地址为:
https://gmframework.codeplex.com/
可以通过下列途径获取到最新代码: