通过ViewModel使用实体框架更新记录的方法



查看这个实体及其关联的视图模型类:

class MyEntity
{
public int Id { get; set; }
public String Name { get; set; }
...
}
class MyViewModel
{
public int Id { get; set; }
public String Name { get; set; }
...
}

以下是我想更新记录时经常做的事情:

var jack = MyDbContext.MyEntity.FirstOrDefault(e => e.Id=2);
jack.Name = "New name";
MyDbContext.SaveChanges();

它工作得很好!

现在,我想知道在使用viewmodel时应该如何做同样的事情:

var all = MyDbContext.MyEntity.Select(e => new MyViewModel
{
Id = e.Id,
Name = e.Name,
...
};
// Let's suppose I have a grid with all records.
// User may click on one record in order to see the details
var jackvm = all[1];  // For example jack !
jackvm.Name = "New Name";

在这一步,我不能调用SaveChanges,因为jackvm不是一个附加的实体。它是一个视图模型实例。以下是我在一个项目中看到的,我想知道这样工作是否是一个好的实践:

var jackEntity = new MyEntity { Id = jackvm.Id, Name = jackvm.Name, ... };
MyDbContext.Entry(jackEntity).State = EntityState.Modified;
MyDbContext.SaveChanges();

正如您所看到的,实体是根据视图模型的实例值创建的。然后,这个实体被附加到数据库上下文。SaveChanges将更新数据库中的记录。我的第一个问题是,你觉得这种模式怎么样?这是一个好的做法吗?

我还有第二个问题:我需要记录每个写操作。我已经覆盖SaveChanges以便跟踪更改,通过这种方式:

public override int SaveChanges()
{    
foreach (var entry in this.ChangeTracker.Entries())
{
if (entry.State == EntityState.Modified)
{
Console.WriteLine(entry.OriginalValues.GetValue<String>("Name"));        
Console.WriteLine(entry.CurrentValues.GetValue<String>("Name"));        
}
}
return base.SaveChanges();
}

它与我的第一个例子很好。但对于第二个示例,OrignalValues包含新值!在这种情况下,如何获取名称字段的原始值?请不要,我想避免进行选择查询。

非常感谢

否,不建议更新该模式。对于实体和视图模型与所有列具有一对一关系的简单场景,它可以正常工作,但一旦移动到更复杂的实体,它就会崩溃。以这样一个实体为例:

public class Person
{
public int PersonId { get; set; }
public string Name { get; set; }
public DateTime DoB { get; set; }
public virtual Address Address { get; set; }
}

和ViewModel

[Serializable]
public class PersonSummaryVm
{
public int PersonId { get; set; }
public string Name { get; set; }
public DateTime DoB { get; set; }
public int Age => DateTime.Now.Subtract(DoB).TotalYears;
public string Address { get; set; }
}

为了便于论证,这个视图模型是一个投影,我们希望显示他们的年龄,而不是他们的DoB,我们将地址压平。假设我们有一个操作,用户可以更新他们的一些详细信息。即使上面是一个非常简单的例子,但想象一下一个有50多列和关系的实体。我们可以传递一个PersonSummaryVm,或者我们可以传递一些类似UpdatePersonVm的东西,只包含我们想要更新的值,或者如果我们只是想要一个UpdatePersonName方法,我们可以只将PersonId和Name传递给函数。

让我们用最后一个例子来演示这种方法的一些问题,这些问题将适用于无论您传递什么视图模型或字段,除非您传递所有

[HttpPost]
public JsonResult UpdatePersonName(int personId, string name)
{
try
{
var person = new Person { PersonId = personId, Name = name };
_context.Attach(person);
_context.Entry(person).State = EntityState.Modified;
_context.SaveChanges();
return Json(ActionResponse.Success()); // Return ActionResult or View etc.
}
catch (Exception ex)
{
var logId = Logger.LogException(ex, personId);
return Json(ActionResponse.Failure($"Person could not be updated due to an error. (Ref #{logId})"));
}
}

代码通常会执行,但这里有几个重大问题。

  1. 无法验证具有该personId的Person记录是否确实存在,或者是否处于应允许编辑的状态
  2. 当我们创建一个新的Person对象时,DoB将默认为DateTime。Min()(1/1/0001),并且地址引用将为空。当我们保存那个Person时,值将被覆盖,因为我们没有填充它们,所以EF将部分填充的实体视为当前状态

即使您使用的视图模型与您暴露问题的实体具有相同的字段:

  1. 您向视图/消费者传递的数据和从视图/消费者传来的数据可能比该视图所需要的数据还要多,或者他们甚至应该看到的还要多
  2. 因为您从视图/使用者传递回每个字段,所以即使您只显示了一些字段并允许他们编辑一些字段,其他数据也是可见的,并且任何运行浏览器调试器的人都可以篡改
  3. 没有检测或保护陈旧的数据覆盖。自该消费者检索到您计划更新的源数据后,数据是否发生了更改
  4. 随着系统的发展,向实体添加新的属性和关系,视图模型可能会不同步。视图不一定需要显示新的数据/关系,但您的更新方法现在只会复制它所知道的数据,这会让您面临数据被擦除的情况,这意味着通过连线扩展视图模型和数据只是为了满足更新场景

更好的方法是获取实体,验证编辑是否有效并更新值。

[HttpPost]
public JsonResult UpdatePersonName(UpdatePersonVm personVm)
{
try
{
if ( personVm == null) throw new ArgumentNullException("personVm");
var person = _context.Persons.Single(x => x.PersonId == personVm.PersonId);
if (personVm.RowVersion != person.RowVersion)
return Json(ActionResponse.StaleUpdate(Mapper.Map<PersonSummaryVm>(person)));
person.Name = personVm.Name;
_context.SaveChanges();
return Json(ActionResponse.Success(Mapper.Map<PersonSummaryVm>(person))); // Return ActionResult or View etc.
}
catch (Exception ex)
{
var logId = Logger.LogException(ex, personId);
return Json(ActionResponse.Failure($"Person could not be updated due to an error. (Ref #{logId})"));
}
}

使用这种方法可以验证Person是否确实存在。如果找不到此人,它可以选择执行SingleOrDefault,以向消费者呈现更好的失败消息。实体/表和视图模型都可以包含RowVersion戳,该戳会随着行的更新而自动更新。当我们获取传递给客户端进行更新的虚拟机时,它将具有特定的RowVersion,该用户可能会在发布update操作之前花10分钟进行更改,在这段时间内,其他人可能已经更新了它。我们可以比较行版本,并处理它们可能覆盖已更改内容的情况。现在,因为我们已经加载了一个实体,所以我们可以只复制预期已更改的值,并且只有当其中任何值实际更改时,EF才会为这些修改后的值生成UPDATE语句。我们可以在这里使用Automapper的Map<TSource,TDestination>来提供帮助。

Mapper.Map(personVm, person);

以节省手动跨范围复制值的时间。如果使用Automapper,重要的是而不是使用默认映射调用:

person = Mapper.Map(personVm);

这会将"person"设置为对未附加到DbContext的person实体的新引用。

Automapper可以在通过ProjectTo<TDestination>()方法读取数据时提供帮助,该方法取代了使用Select()的方法。这有助于建立高效的查询:

var personVm = context.Persons
.Where(x => x.PersonId == personId)
.ProjectTo<PersonSummaryVm>(config)
.Single();

其中传递的mapper config包含关于如何将实体映射到视图模型的配置。例如,扁平化地址,而不是:

var personVm = context.Persons
.Where(x => x.PersonId == personId)
.Select(x => new PersonSummaryVm
{
PersonId = x.PersonId,
Name = x.Name,
DoB = x.DoB,
Address = x.Address.AddressLine1 + ", " + x.Address.City
RowVersion = x.RowVersion
}).Single();

另一个性能陷阱/w Automapper将尝试在Select():中使用Map()

var personVm = context.Persons
.Where(x => x.PersonId == personId)
.ToList()
.Select(x => Mapper.Map<PersonSummaryVm>(x))
.Single();

使用.Map()的问题是,它无法转换为SQL,因此您需要将ToList().AsEnumerable()放在Select之前,然后在上述情况下,由于Mapper希望从Address中解析属性,我们将触发Address上的延迟加载,或者必须记住急切地加载Person.Address。即使在这种情况下,Select也将从Person和Map在映射我们的VM之前需要接触的引用实体加载所有属性,其中ProjectTo将能够在生成的SQL中只组成所需的字段。

因此,Automapper所做的不仅仅是减少代码行。很值得花时间去熟悉。

最新更新