在MVC中使用AutoMapper从ViewModel更新实体
在MVC中使用AutoMapper从ViewModel更新实体
我有一个名为Supplier.cs
的实体和它的视图模型SupplierVm.cs
。我正在尝试更新一个现有的供应商,但是我遇到了黄屏幕(YSOD)并显示以下错误消息:
操作失败:由于一个或多个外键属性是非空的,所以无法更改关系。当对关系进行更改时,相关的外键属性将被设置为null值。如果外键不支持null值,则必须定义一个新的关系,将外键属性分配给另一个非null值,或者删除无关的对象。
我认为我知道为什么会发生这种情况,但是我不确定该如何解决。这是一个关于发生情况的录屏。我认为我得到这个错误的原因是当AutoMapper执行时,该关系丢失了。
代码
以下是我认为相关的实体:
public abstract class Business : IEntity { public int Id { get; set; } public string Name { get; set; } public string TaxNumber { get; set; } public string Description { get; set; } public string Phone { get; set; } public string Website { get; set; } public string Email { get; set; } public bool IsDeleted { get; set; } public DateTime CreatedOn { get; set; } public DateTime? ModifiedOn { get; set; } public virtual ICollection Addresses { get; set; } = new List(); public virtual ICollectionContacts { get; set; } = new List (); } public class Supplier : Business { public virtual ICollection PurchaseOrders { get; set; } } public class Address : IEntity { public Address() { CreatedOn = DateTime.UtcNow; } public int Id { get; set; } public string AddressLine1 { get; set; } public string AddressLine2 { get; set; } public string Area { get; set; } public string City { get; set; } public string County { get; set; } public string PostCode { get; set; } public string Country { get; set; } public bool IsDeleted { get; set; } public DateTime CreatedOn { get; set; } public DateTime? ModifiedOn { get; set; } public int BusinessId { get; set; } public virtual Business Business { get; set; } } public class Contact : IEntity { public Contact() { CreatedOn = DateTime.UtcNow; } public int Id { get; set; } public string Title { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string Phone { get; set; } public string Email { get; set; } public string Department { get; set; } public bool IsDeleted { get; set; } public DateTime CreatedOn { get; set; } public DateTime? ModifiedOn { get; set; } public int BusinessId { get; set; } public virtual Business Business { get; set; } }
这是我的视图模型:
public class SupplierVm { public SupplierVm() { Addresses = new List(); Contacts = new List (); PurchaseOrders = new List (); } public int Id { get; set; } [Required] [Display(Name = "公司名称")] public string Name { get; set; } [Display(Name = "税号")] public string TaxNumber { get; set; } public string Description { get; set; } public string Phone { get; set; } public string Website { get; set; } public string Email { get; set; } [Display(Name = "状态")] public bool IsDeleted { get; set; } public IList Addresses { get; set; } public IList Contacts { get; set; } public IList PurchaseOrders { get; set; } public string ButtonText => Id != 0 ? "更新供应商" : "添加供应商"; }
我的AutoMapper映射配置如下:
cfg.CreateMap(); cfg.CreateMap () .ForMember(d => d.Addresses, o => o.UseDestinationValue()) .ForMember(d => d.Contacts, o => o.UseDestinationValue()); cfg.CreateMap (); cfg.CreateMap () .Ignore(c => c.Business) .Ignore(c => c.CreatedOn); cfg.CreateMap(); cfg.CreateMap () .Ignore(a => a.Business) .Ignore(a => a.CreatedOn);
最后,这是我的SupplierController的编辑方法:
[HttpPost] public ActionResult Edit(SupplierVm supplier) { if (!ModelState.IsValid) return View(supplier); _supplierService.UpdateSupplier(supplier); return RedirectToAction("Index"); }
这是SupplierService.cs
上的UpdateSupplier
方法:
public void UpdateSupplier(SupplierVm supplier) { var updatedSupplier = _supplierRepository.Find(supplier.Id); Mapper.Map(supplier, updatedSupplier); // 这里丢失了导航属性 _supplierRepository.Update(updatedSupplier); _supplierRepository.Save(); }
我已经阅读了很多文章,根据这篇博客文章,我所做的应该可以工作!我还阅读了这样的文章,但在放弃AutoMapper更新实体之前,我想请读者们确认一下。
在使用MVC架构中,通过AutoMapper从ViewModel更新实体时出现了问题。问题的原因是数据库上下文的LazyLoadingEnabled属性被设置为true,导致在映射过程中出现了错误。为了解决这个问题,需要将LazyLoadingEnabled属性设置为false。
解决方法如下:
var message = JsonConvert.DeserializeObject(@"{.....}"); using (var db = new OracleDbContex()) { db.Configuration.LazyLoadingEnabled = false; var msguser = Mapper.Map (message); var dbuser = db.BAPUSER.FirstOrDefault(w => w.BAPUSERID == 1111); Mapper.Map(msguser, dbuser); db.SaveChanges(); }
以上代码片段中,首先通过JsonConvert反序列化UserMessage对象。然后创建一个OracleDbContex实例,并将其LazyLoadingEnabled属性设置为false。接下来,使用AutoMapper将ViewModel对象映射到实体对象msguser中。然后从数据库中获取一个具有特定BAPUSERID的BAPUSER实体对象dbuser。最后,使用AutoMapper将msguser的属性值映射到dbuser中,并通过调用SaveChanges方法将更改保存到数据库。
通过以上解决方法,我们可以成功地使用AutoMapper从ViewModel更新实体,并解决了LazyLoadingEnabled属性设置错误的问题。
问题的原因
下面这行代码...
Mapper.Map(supplier, updatedSupplier);
...实际上做了更多的事情。
- 在映射操作期间,
updatedSupplier
会因为AutoMapper (AM)的访问而懒加载它的集合(例如Addresses
)。你可以通过监测SQL语句来验证这一点。 - AM会用从视图模型映射的集合替换这些已加载的集合。这发生在即使设置了
UseDestinationValue
的情况下。(就个人而言,我认为这个设置是难以理解的。)
这个替换有一些意想不到的后果:
- 它使得原始项目仍然附加在上下文的集合中,但不再在你所在的方法范围内。这些项目仍然存在于
Local
集合(例如context.Addresses.Local
),但是由于EF执行了关系修复,它们已经失去了它们的父对象。它们的状态是Modified
。 - 它以
Added
状态将视图模型中的项目附加到上下文中。毕竟,它们对上下文来说是新的。如果此时你期望context.Addresses.Local
中有1个Address
,你会看到有2个。但是在调试器中,你只能看到被添加的项目。
正是这些没有父对象的“Modified”项目导致了异常的出现。如果没有这个异常,下一个惊喜将是在你只期望进行更新时却向数据库添加了新的项目。
那么现在怎么办?
你应该如何解决这个问题呢?
A. 我试图尽可能地重现你的场景。对我来说,可能的解决办法包括两个修改:
- 禁用懒加载。我不知道你如何在你的仓储中安排这一点,但是在某个地方应该有一行代码,像这样:
context.Configuration.LazyLoadingEnabled = false;
这样做,你只会有
Added
状态的项目,而不会有隐藏的Modified
状态的项目。 - 将
Added
状态的项目标记为Modified
。同样,在“某个地方”放置类似下面的代码:foreach (var addr in updatedSupplier.Addresses) { context.Entry(addr).State = System.Data.Entity.EntityState.Modified; }
...等等。
B. 另一种选择是将视图模型映射到新的实体对象...
var updatedSupplier = Mapper.Map<Supplier>(supplier);
...并将其及其所有子对象标记为Modified
。不过,这在更新方面的开销相对较大,可以参考下一个点。
C. 在我看来,一个更好的解决办法是完全不使用AM,而是手动绘制状态。我总是对在复杂的映射场景中使用AM持怀疑态度。首先,因为映射本身是在离它使用的代码很远的地方定义的,这使得代码难以检查。但主要是因为它带来了它自己的方式来做事情。它与其他敏感的操作(如更改跟踪)的交互方式并不总是清楚的。
手动绘制状态是一个费时的过程。基础可以是类似于下面的语句...
context.Entry(updatedSupplier).CurrentValues.SetValues(supplier);
...如果它们的名称匹配,它将supplier
的标量属性复制到updatedSupplier
。或者你可以使用AM(毕竟)将单个视图模型映射到它们的实体对应项上,但忽略导航属性。
选项C使你能够对要更新的内容进行精细控制,而不是像选项B那样进行全面的更新。当有疑问时,这篇文章可能会帮助你决定使用哪个选项。
谢谢Gert,这是一个很棒的答案!
嗨,我在问题中看到,用户将视图模型传递给了服务层。这样做好吗?
大多数情况下,视图模型只是一个DTO。对我来说,将DTO传递给服务层是可以的 - 实际上这就是它们的用途。如果视图模型真的是一个视图模型(包含INotifyPropertyChanged和/或指导视图的整个过程),那么不,我不会这样做,因为它可能会将UI库的依赖项泄露到服务层中。
问题的出现原因:父引用的外键Id与该外键实体的主键不匹配,即在加载实体时,Order的OrderStatusId = 1,而OrderStatus的Id = 1。如果将OrderStatusId更改为2,但没有更新OrderStatus的Id为2,那么就会出现此错误。
解决方法:要解决这个问题,可以通过以下两种方式之一来解决:
1. 加载Id为2的实体并更新引用实体。
2. 在保存之前将Order上的OrderStatus引用实体设置为null。
以下是使用AutoMapper在MVC中从ViewModel更新实体的示例代码:
// 定义ViewModel public class OrderViewModel { public int Id { get; set; } public int OrderStatusId { get; set; } // 其他属性... } // 定义实体 public class Order { public int Id { get; set; } public int OrderStatusId { get; set; } public OrderStatus OrderStatus { get; set; } // 其他属性... } // 定义映射关系 Mapper.Initialize(cfg => { cfg.CreateMap() .ForMember(dest => dest.OrderStatus, opt => opt.Ignore()); }); // 在Controller中更新实体 public ActionResult UpdateOrder(OrderViewModel viewModel) { if (ModelState.IsValid) { // 从数据库加载原始实体 var order = dbContext.Orders.SingleOrDefault(o => o.Id == viewModel.Id); // 使用AutoMapper将ViewModel映射到实体 Mapper.Map(viewModel, order); // 设置OrderStatus为null order.OrderStatus = null; // 保存更改 dbContext.SaveChanges(); return RedirectToAction("Index"); } return View(viewModel); }
以上示例代码中,我们首先定义了一个OrderViewModel和一个Order实体。然后使用AutoMapper配置了它们之间的映射关系。在Controller的UpdateOrder方法中,我们首先从数据库加载原始实体,然后使用AutoMapper将ViewModel映射到实体。最后,我们将OrderStatus引用实体设置为null,并保存更改。
通过这种方式,我们可以使用AutoMapper轻松地从ViewModel更新实体,并且可以解决上述问题引起的错误。