问题描述
在很多系统中,存在多对多关系的维护。如下图:
这种多对多结构在数据库中大部分有三个数据表,其中两个主表,还有一个关联表,关联表至少两个字段,即左表主键、右表主键。
如上图,其中的Supplier表和Product是主业务表,ProductSupplier是关联表,在一些复杂的业务系统中,这样的关系实在是太多了。之前在没有使用EF这类ORM框架的时候,可以通过代码来维护这样的关联关系,查询的时候扔过去一个Left Join语句,把数据取出来拼凑一下就可以了。
现在大多使用EF作为ORM工具,处理起来这种问题反而变得麻烦了。原因就是多关联表之间牵牵扯扯的外键关系,一不小心就会出现各种问题。本文将从建模开始演示这种操作,提供一个多对多关系维护的参考。也欢迎大家能提供一些更好的实现方式。
在EF中建模
在EF中建模已知的两种方式:
- 方式一,在数据上下文中添加两个主实体类。使用Fluent Api配置在数据库中生成其关联表,但是在EF中不会体现。
- 方式二,在数据上下文中添加三个实体类,除了两个主实体类外还包含第一个关联表的定义,数据库中存在三张表,EF数据上下文中对应三个实体。
两种不同的建模方式带来完全迥异的增删改查方式,第一种在EF中直接进行多对多的处理。而第二种是把多对多的关系处理间接的修改为了两个一对多关系处理。
在本文中重点介绍第一个多对多的情况,第二个处理方式可以参考Microsoft Identity代码中,关于用户角色的代码。
说了好多废话,下面正文。代码环境为VS 2017 ,MVC5+EF6 ,数据库 SQL Server 2012 r2
方式一 实体定义代码:
public class Product { public Product() { this.Suppliers = new List<Supplier>(); } [Display(Name = "Id")] public long ProductID { get; set; } [Display(Name = "产品名称")] public string ProductName { get; set; } //navigation property to Supplier [Display(Name = "供应商")] public virtual ICollection<Supplier> Suppliers { get; set; } } public class Supplier { public Supplier() { this.Products = new List<Product>(); } [Display(Name = "Id")] public long SupplierID { get; set; } [Display(Name = "供应商名称")] public string SupplierName { get; set; } [Display(Name = "提供产品")] // navigation property to Product public virtual ICollection<Product> Products { get; set; } }
数据上下文中,多对多关系配置:
public class MyDbContext : DbContext { public MyDbContext() : base("DefaultConnection") { Database.SetInitializer<MyDbContext>(null); } public DbSet<Product> Products { get; set; } public DbSet<Supplier> Suppliers { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity<Product>().HasMany(p => p.Suppliers).WithMany(s => s.Products) .Map(m => { m.MapLeftKey("ProductId"); m.MapRightKey("SupplierId"); m.ToTable("ProductSupplier"); }); } }
只是做一个下操作展示,尽量展示核心代码,不做多余的点缀了
使用VS的MVC脚手架,右键添加Controller,使用包含视图的MVC 5控制器(使用Entity Framework),模型类选择Product,同样操作为Supplier添加Controller。
Insert操作
多对多关系新增分两种情况:
- 左右侧同时新增。使用如下代码覆盖Create 动作的Post方法
[HttpPost] [ValidateAntiForgeryToken] public ActionResult Create([Bind(Include = "ProductID,ProductName")] Product product) { //左右侧都为新增 if (ModelState.IsValid) { //使用代码模拟新增右侧表 var supplier = new List<Supplier> { new Supplier { SupplierName = "后台新增供应商"+new Random(Guid.NewGuid().GetHashCode()).Next(1,100) }, new Supplier { SupplierName = "后台新增供应商"+new Random(Guid.NewGuid().GetHashCode()).Next(1,100) }, }; //左右侧表建立关联关系 supplier.ForEach(s => )); //将左侧表添加到数据上下文 db.Products.Add(product); //保存 db.SaveChanges(); return RedirectToAction("Index"); } return View(product); }
这里直接在后台模拟了新增产品和产品供应商的操作,当数据保存后,会在三个表中分别生成数据,如下:
可见这种新增的时候是不需要进行特别的处理
- 左侧新增,关联存在右表数据。常见业务场景如,博客发文选择已有分类时。使用如下代码覆盖Create 的Post方法。
//POST: Products/Create //为了防止“过多发布”攻击,请启用要绑定到的特定属性,有关 //详细信息,请参阅 https://go.microsoft.com/fwlink/?LinkId=317598。 [HttpPost] [ValidateAntiForgeryToken] public ActionResult Create1([Bind(Include = "ProductID,ProductName")] Product product) { //左侧新增数据,右侧为已存在数据 if (ModelState.IsValid) { //在数据库中随机取出两个供应商 var dbSuppliers = db.Suppliers.OrderBy(s => Guid.NewGuid()).Take(2).ToList(); //为产品添加供应商,建立与供应商之间的关联 dbSuppliers.ForEach(s => { // //因为EF有跟踪状态,所以无须添加状态也可以正常保存 // //db.Entry<Supplier>(s).State = System.Data.Entity.EntityState.Unchanged; }); //添加产品记录到数据上下文 db.Products.Add(product); //执行保存 db.SaveChanges(); return RedirectToAction("Index"); } return View(product); }
我们通过在后台获取第一个和最后一个供应商,然后模拟新增产品选择以有供应商的用户行为。在数据库中会添加一条产品记录,两条产品供应商关联数据。如下:
看起来也没什么问题么。so easy 啊。
注意:实际上我们在开发中基本不会像现在这样处理,执行编辑操作时实际流程是
- 进入编辑页面,获取要编辑的数据,在页面上展示
- 在页面上修改表单,建立与右侧表单关联关系(通过下拉框、多选操作、弹窗多选等)
- 提交表单,后台执行修改的保存动作
看似简单,这里还要注意另外一件事情,就是在操作过程中,我们是要进行数据对象的转换的,这个转换过程简单概括就是 Entity→Dto→(View Model→Dto→)Entity,所以我们看看实际情况下会碰到什么问题
使用如下代码替换Create的Post方法
//POST: Products/Create //为了防止“过多发布”攻击,请启用要绑定到的特定属性,有关 //详细信息,请参阅 https://go.microsoft.com/fwlink/?LinkId=317598。 [HttpPost] [ValidateAntiForgeryToken] public ActionResult Create2([Bind(Include = "ProductID,ProductName")] Product product) { //左侧新增数据,右侧为已存在数据 if (ModelState.IsValid) { //模拟数据库中取出数据 var dbSuppliers = db.Suppliers.OrderBy(s => Guid.NewGuid()).Take(2).AsNoTracking().ToList(); //加载右侧表数据,从中选择两个作为本次修改的关联对象,Entity→Dto(model)转换,转换过程中,Entity丢失了EF的状态跟踪 var suppliers = dbSuppliers.Select(s => new Supplier { SupplierID = s.SupplierID }).ToList(); //保存修改后的实体,Dto(model)→Entity转换,通常页面只回传右表的主键Id suppliers.ForEach(s => { product.Suppliers.Add(s); }); db.Products.Add(product); db.SaveChanges(); return RedirectToAction("Index"); } return View(product); }
这个代码执行后结果如下:
在上面的代码执行完成以后,EF把右侧表也做了新增处理,所以就出现右侧添加了空数据的问题。
修改代码:
//POST: Products/Create //为了防止“过多发布”攻击,请启用要绑定到的特定属性,有关 //详细信息,请参阅 https://go.microsoft.com/fwlink/?LinkId=317598。 [HttpPost] [ValidateAntiForgeryToken] public ActionResult Create2([Bind(Include = "ProductID,ProductName")] Product product) { //左侧新增数据,右侧为已存在数据 if (ModelState.IsValid) { //.AsNoTracking() 不添加的时候,保存也报错 var dbSuppliers = db.Suppliers.OrderBy(s => Guid.NewGuidTake(2).ToList(); //加载右侧表数据,从中选择两个作为本次修改的关联对象,Entity→Dto(model)转换,转换过程中,Entity丢失了EF的状态跟踪 var suppliers = dbSuppliers.Select(s => new Supplier { SupplierID = s.SupplierID }).ToList(); //保存修改后的实体,Dto(model)→Entity转换,通常页面只回传右表的主键Id suppliers.ForEach(s => { product.Suppliers.Add(s); }); db.Products.Add(product); db.SaveChanges(); return RedirectToAction("Index"); } return View(product); }
执行新增操作后结果:
以上终于获取了正常结果。上面两处高亮代码,下方修改状态的是新增的代码。我们做个小实验,把AsNoTracking()去掉看看会怎么样。
没错,直接报错了。
System.InvalidOperationException HResult=0x80131509 Message= Source=EntityFramework StackTrace: 在 System.Data.Entity.Core.Objects.ObjectContext.VerifyRootForAdd(Boolean doAttach, String entitySetName, IEntityWrapper wrappedEntity, EntityEntry existingEntry, EntitySet& entitySet, Boolean& isNoOperation) 在 System.Data.Entity.Core.Objects.ObjectContext.AttachTo(String entitySetName, Object entity) 在 System.Data.Entity.Internal.Linq.InternalSet`1.<>c__DisplayClassa.<Attach>b__9() 在 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.Internal.InternalEntityEntry.set_State(EntityState value) 在 System.Data.Entity.Infrastructure.DbEntityEntry`1.set_State(EntityState value) 在 Many2Many.Controllers.ProductsController.<>c__DisplayClass8_0.<Create2>b__1(Supplier s) 在 E:\Github\Many2Many\Controllers\ProductsController.cs 中: 第 178 行 在 System.Collections.Generic.List`1.ForEach(Action`1 action) 在 Many2Many.Controllers.ProductsController.Create2(Product product) 在 E:\Github\Many2Many\Controllers\ProductsController.cs 中: 第 175 行 在 System.Web.Mvc.ActionMethodDispatcher.Execute(ControllerBase controller, Object[] parameters) 在 System.Web.Mvc.ReflectedActionDescriptor.Execute(ControllerContext controllerContext, IDictionary`2 parameters) 在 System.Web.Mvc.ControllerActionInvoker.InvokeActionMethod(ControllerContext controllerContext, ActionDescriptor actionDescriptor, IDictionary`2 parameters) 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.<BeginInvokeSynchronousActionMethod>b__39(IAsyncResult asyncResult, ActionInvocation innerInvokeState) 在 System.Web.Mvc.Async.AsyncResultWrapper.WrappedAsyncResult`2.CallEndDelegate(IAsyncResult asyncResult) 在 System.Web.Mvc.Async.AsyncResultWrapper.WrappedAsyncResultBase`1.End() 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.EndInvokeActionMethod(IAsyncResult asyncResult) 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.AsyncInvocationWithFilters.<InvokeActionMethodFilterAsynchronouslyRecursive>b__3d() 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.AsyncInvocationWithFilters.<>c__DisplayClass46.<InvokeActionMethodFilterAsynchronouslyRecursive>b__3f()
看错误堆栈信息是不是很熟悉?说出来可能不信,我曾经被这个问题折磨了一天~ 其实就是因为EF有实体跟踪机制,很多时候问题就出在这里,对EF的机制如果不了解的话很容易碰到问题。
同样会产生错误的代码还有如下:
//POST: Products/Create //为了防止“过多发布”攻击,请启用要绑定到的特定属性,有关 //详细信息,请参阅 https://go.microsoft.com/fwlink/?LinkId=317598。 [HttpPost] [ValidateAntiForgeryToken] public ActionResult Create2([Bind(Include = "ProductID,ProductName")] Product product) { //左侧新增数据,右侧为已存在数据 if (ModelState.IsValid) { //.AsNoTracking() 不添加的时候,保存也报错 //var dbSuppliers = db.Suppliers.OrderBy(s => Guid.NewGuid()).AsNoTracking().Take(2).ToList(); var dbSuppliers = db.Suppliers.OrderBy(s => Guid.NewGuid()).Take(2).ToList(); //加载右侧表数据,从中选择两个作为本次修改的关联对象,Entity→Dto(model)转换,转换过程中,Entity丢失了EF的状态跟踪 var suppliers = dbSuppliers.Select(s => new Supplier { SupplierID = s.SupplierID }).ToList(); //保存修改后的实体,Dto(model)→Entity转换,通常页面只回传右表的主键Id suppliers.ForEach(item => { product.Suppliers.Add(item); //把这一行代码踢出去执行,会有奇效 //db.Entry<Supplier>(item).State = System.Data.Entity.EntityState.Unchanged; }); db.Products.Add(product); //在这里进行状态设置 db.SaveChanges(); return RedirectToAction("Index"); } return View(product); }
-我们只是调整了一下修改右侧表状态的时机,EF非常机智的换了个错误提示方式!
错误信息如下:堆栈跟踪信息:
System.InvalidOperationException HResult=0x80131509 Message= Source=EntityFramework StackTrace: 在 System.Data.Entity.Core.Objects.ObjectStateManager.FixupKey(EntityEntry entry) 在 System.Data.Entity.Core.Objects.EntityEntry.AcceptChanges() 在 System.Data.Entity.Core.Objects.EntityEntry.ChangeObjectState(EntityState requestedState) 在 System.Data.Entity.Core.Objects.EntityEntry.ChangeState(EntityState state) 在 System.Data.Entity.Internal.StateEntryAdapter.ChangeState(EntityState state) 在 System.Data.Entity.Internal.InternalEntityEntry.set_State(EntityState value) 在 System.Data.Entity.Infrastructure.DbEntityEntry`1.set_State(EntityState value) 在 Many2Many.Controllers.ProductsController.Create2(Product product) 在 E:\Github\Many2Many\Controllers\ProductsController.cs 中: 第 219 行 在 System.Web.Mvc.ActionMethodDispatcher.Execute(ControllerBase controller, Object[] parameters) 在 System.Web.Mvc.ReflectedActionDescriptor.Execute(ControllerContext controllerContext, IDictionary`2 parameters) 在 System.Web.Mvc.ControllerActionInvoker.InvokeActionMethod(ControllerContext controllerContext, ActionDescriptor actionDescriptor, IDictionary`2 parameters) 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.<BeginInvokeSynchronousActionMethod>b__39(IAsyncResult asyncResult, ActionInvocation innerInvokeState) 在 System.Web.Mvc.Async.AsyncResultWrapper.WrappedAsyncResult`2.CallEndDelegate(IAsyncResult asyncResult) 在 System.Web.Mvc.Async.AsyncResultWrapper.WrappedAsyncResultBase`1.End() 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.EndInvokeActionMethod(IAsyncResult asyncResult) 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.AsyncInvocationWithFilters.<InvokeActionMethodFilterAsynchronouslyRecursive>b__3d() 在 System.Web.Mvc.Async.AsyncControllerActionInvoker.AsyncInvocationWithFilters.<>c__DisplayClass46.<InvokeActionMethodFilterAsynchronouslyRecursive>b__3f()
以上两个错误信息的实际产生原因都是因为EF的实体跟踪机制导致的。如果碰到类似问题,检查你的实体是不是状态不多。
Update操作
使用第一个新增方法在增加一条数据,以区别现有数据,然后修改Edit 的Post方法:
// POST: Products/Edit/5 // 为了防止“过多发布”攻击,请启用要绑定到的特定属性,有关 // 详细信息,请参阅 https://go.microsoft.com/fwlink/?LinkId=317598。 [HttpPost] [ValidateAntiForgeryToken] public ActionResult Edit([Bind(Include = "ProductID,ProductName,SuppliersId")] Product product) { if (ModelState.IsValid) { var entity = db.Entry(product); entity.State = EntityState.Modified; entity.Collection(s => s.Suppliers).Load(); //不能像Identity中一样,先clear在add,需要区别对待 if (product.SuppliersId.Any()) { var newList = new List<Supplier>(); Array.ForEach(product.SuppliersId, s => { newList.Add(new Supplier { SupplierID = s }); }); //需要移除的关系 var removeRelation = product.Suppliers.Except(newList, new SupplierComparer()).ToList(); //新增的关系 var addRelation = newList.Except(product.Suppliers, new SupplierComparer()).ToList(); removeRelation.ForEach(item => product.Suppliers.Remove(item)); addRelation.ForEach(item => { product.Suppliers.Add(item); db.Entry(item).State = EntityState.Unchanged; }); } db.SaveChanges(); return RedirectToAction("Index"); } return View(product); }
修改前数据如下:
修改后数据如下:
在修改的时候其实是执行了三个操作
- 加载实体的关联关系
- 修改实体
- 移除实体关联关系 (多条sql)
- 添加新的实体关联关系 (多条sql)
Entity Framework算是比较强大的ORM框架了,在使用过程中同样的需求可能有不同的实现方式,简单的CRUD操作实现起来都很简单了。在多对多的关系处理中,通过通用的仓储类基本没法处理,一般要单独实现,上文总结了常用的集中实现方式。