operamasks-ui2.0 +MVC4.0+EF5.0实战 当EntityFramework遇上Json,引爆 循环引用 这颗雷
正文之前先说两句,距离上篇博客已将近两个月,这方面的学习和探索并没有停止,而是前进道路上遇上了各种各样的问题,需要不断的整理、反思和优化,这段时间的成果,将在最近陆续整理发出来。
个人感觉国内心态太浮躁了,很少有能深入研究下去并将自己经验分享的人,可能很忙,也可能嫌麻烦。特别是面向新技术,尤其是在学习资料有限的情况下,愿意花费时间摸索和分享的人实在太少太少,遇到问题,搜索结果一抓一大把,但是往往都是转载,连最起码的自己验证都没有,结果就是以讹传讹,不仅对解决问题无用,反而容易产生误导。最近这段时间感触颇深,遇到问题往往需要去认真考虑选择合适英文关键词从英文网站里找解决方案。
这块技术,我也是新手,遇到的问题比较多,我发现以现在的水平和能力写成一个逻辑性比较强的系列很有难度,因此决定从点入手,将发现问题-》寻找解决方法-》最终解决方法整个过程记录下来,最后再将一个个点串起来,形成线。作为自己的总结和反思,也为后来者提供一些经验和帮助,能少走一些弯路。同时,也留下一些目前无法解决的问题,欢迎交流,欢迎指正。
好了,转入正文,采用operamasks-ui2.0 +MVC4.0+EF5.0这种模式或者说架构,首先面对的是一个不可避免的问题,前后台交换数据,目前最流行、最高效的方式就是Json(上去几年可能用xml的多一些,关于Json和xml的优缺点,不在此处描述,有兴趣的自己查资料吧)。也就是数据通过EF取出来,这时候是对象,通过MVC调度,经过序列化转换成Json格式,传给前台,前台开发框架(operamasks-ui或easyui或extJS甚至Jquery等等)接收后进行解析和展现。这个时候问题就来了,如何序列化?如果没做过这块开发,会觉得这很简单,包括我在之前的系列里也提到过,自己为object写了一个扩展方法:
public static string ToJsonString(this Object obj) { JavaScriptSerializer s = new JavaScriptSerializer(); StringBuilder sb = new StringBuilder(); s.Serialize(obj, sb); return sb.ToString(); }
然后在控制器里做以下处理
public ActionResult GetMenu() { IQueryable<Menu> menu = MenuService.Query(); var nodes = new List<TreeNode>(); foreach (var item in menu.ToList()) { TreeNode node = new TreeNode(); node.id = item.ID; node.pid = item.ParentID; node.text = item.Name; node.url = item.Url; node.expanded = "true"; nodes.Add(node); } return Content(nodes.ToJsonString()); }
事实上,上面的方式是多此一举,完全没必要。更简便的做法是,直接调用Json(object)方法就行了,也即把上面的return Content(nodes.ToJsonString());替换为return Json(nodes);。其实,我在写扩展方法实现序列化之前,用过这种方式,结果发现前台接收不到数据,才自己又去查找资料,使用JavaScriptSerializer类序列化对象,采用return Content(序列化后的Json)方式。至于当初为什么不好用,原因也找到了,一个小细节,也是新手常犯的一个错误,在这也说一下。问题不是出在序列化对象为Json格式上,而是http协议的方法上。对,你没看错,就是http协议,前台通过ajax调用后台方法的时候,一定注意是Get还是Post,如果是Get,那么默认情况下,控制器里的方法处理完毕后,调用Json(object)是禁止Get获取的,而是必须调用其重载函数return Json(object, JsonRequestBehavior.AllowGet);这个问题很隐蔽,不知道这么回事,就会莫名其妙,明明后台调试传回了数据,但前台死活就接收不到数据。还有一个隐含的地雷就是,你在使用一些前台框架的一些控件的时候,往往只调用其方法,而其内部往往也是通过ajax来加载和刷新数据的,一不留意,也会发生上述问题,导致前台取不到数据。
上面篇幅不小,其实只说了一个小问题,为下文做一下铺垫。
下面就来说核心问题。采用EntityFramework,采用Code First模式,先定义实体类,类有普通属性和导航属性。以部门为例,这是一种常见的自关联模式,即定义一个字段,指向其上级部门,从而形成无限级层次扩展,很明显,这是一种一对多关系,即一个部门只有一个上级部门,而可能有多个下级部门。如下所示(已做简化处理,未包括一些无关属性)。
public class Department { [DisplayName("内码")] public string ID { get; set; } [DisplayName("部门名称")] public string Name { get; set; } public string ParentID { get; set; } [DisplayName("上级部门")] [ForeignKey("ParentID")] public virtual Department ParentDept { get; set; } public virtual ICollection<Department> SonDepts { get; set; } }
通过使用ForeignKey属性标记,可以实现自主指定映射成数据库的字段名称。如果不使用,EF也能自动生成,不过要按约定的规则(类名+ID)来写属性名,我还是喜欢自己的地盘自己做主,自动生成容易出现指示不明、修改出错的问题。至于ForeignKey里面的参数怎么写,刚学习的时候也困惑了半天,这个ForeignKey应该标记在属性ParentID上呢还是标记在导航属性ParentDept上呢?里面的参数是不是要写对应外键的库表名称呢?试了试,好像有些写法就提示出错,有些就提示正常,提示正常的还不止一种写法。后来通过摸索和查资料,才知道两种方法都可以,其一就是我上面写的,标记在导航属性ParentDept上,参数为属性ParentID,另一种就是标记在属性ParentID,里面参数是导航属性ParentDept,即
[ForeignKey("ParentDept")]
public string ParentID { get; set; }
public virtual Department ParentDept { get; set; }
现在回过头来觉得这样相互定义很自然,但当初的时候确实为究竟该怎么写才对困扰了一阵。
继续说正文。对于一对多关系,EF处理模式,就是加一个导航属性,指明上级,再加一个导航属性,指明下级,就像上面写的
public virtual Department ParentDept { get; set; }
public virtual ICollection<Department> SonDepts { get; set; }
当初我也是这样做的,完全是照网上一些教程做的。可以说,这样做本身其实也没什么问题,但序列化成Json对象的时候,就出了大问题,你会得到一个错误提示:序列化类型为XX的对象时检测到循环引用。刚看到这个错误的时候可能会觉得莫名其妙,为什么会报这个错误呢?其实原理很简单,序列化的时候,先找个一个部门A,然后有下级部门的导航属性,序列化下级部门B的时候,其上级部门的导航属性指向了部门A,结果就是循环引用,跟死循环一个道理。
如何解决这个问题呢?有一种解决方式,就是使用linq to entity,把基本属性取出来,抛弃掉导航属性,但这种方式局限性比较大,需要将实体类的非导航属性字段人工写一遍,很繁琐,通用性差,并且抛弃了引用属性,延迟加载的优点也丢了。
那么有没有更优的解决方式呢?
我从网上查资料,有人说将db.ContextOptions.ProxyCreationEnabled=false; 也有人说 将db.ContextOptions.LazyLoadingEnabled=false,还有人说为导航属性加上[ScriptIgnore]标记,我实际试过,没用,我觉得不是MVC版本问题,而是这根本就不是问题的本质,传这些资料的人根本就没自己验证过是否真有用。事实上,只要导航属性有Virtual关键字,那么EF就会延迟加载,序列化的时候就会发生循环引用,这才是问题的本质。既然找到了问题本质,那解决是不是也很简单了,把virtual关键字去掉就行了。这样做确实可行,序列化的时候不会报循环引用错误,但是,这样做的时候,EF延迟加载的优点也彻底被抛弃了。比如说,如果有延迟加载,你完全可以在视图里直接用model=>model.ParentDept.Name来绑定显示部门名称,不用写任何代码。如果没有延迟加载,那么,model.ParentDept对象就是null,你需要在控制器的方法里通过DbSet <Department>.Find(ParentID)方法找到并实例化一个Department对象,然后将名称通过ViewBag这个动态对象传递给前台,你可以想象一下,这有多繁琐。
如何既能排除掉循环引用的雷,又能保留延迟加载的优点呢?查阅了大量资料,然后试验各种所谓的解决方案,最终,在一家国外网站上找到了解决方法,就是定义序列化Json的行为,使其忽略引用对象(导航属性),在MVC Web应用程序的App_Start目录的WebApiConfig.cs文件的Register方法末尾追加一句,只需要一句:
config.Formatters.JsonFormatter.SerializerSettings.PreserveReferencesHandling= Newtonsoft.Json.PreserveReferencesHandling.Objects;
其实原文还有一句config.Formatters.Remove(config.Formatters.XmlFormatter); 这句跟问题无关,不要加。当然老外同样有以讹传讹的人士存在,说加config.Formatters.JsonFormatter.SerializerSettings.ReferenceLoopHandling= Newtonsoft.Json.ReferenceLoopHandling.Ignore;以及config.Formatters.JsonFormatter.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Serialize;这两种方法看上去有效,经过实际验证无效。
加了这句还不算完,你还需要在实体类中,一对多关系情况下,只保留其中一个导航属性,上面部门的例子里,你保留ParentDept可以,保留SonDepts也行,只保留一个,EF能正确识别出来这是一对多,库表中生成正确的外键。如果你仍然两个导航属性都留着,那么恭喜你,你还是会得到循环引用的错误提示。
采用上面的方式,你就可以为导航属性加上virtual关键字了,也可以保留ef延迟加载的优点了。
文章到这还没完,还有个尾巴。关系里还有一种常见的,就是多对多,比如角色和人员,一个角色有多个人员,一个人员有多个角色,按照教程,应该在人员实体类里加一个导航属性public virtual ICollection<Role> Roles { get; set; }, 在角色实体类里加一个导航属性public virtual ICollection<User> Users { get; set; },ef会自动识别,然后生成一张只有UserID和RoleID两个字段的中间库表。这一切看上去都没问题,就是常规的处理方式。但是,你在序列化成Json的时候,又会遭遇循环引用这颗雷,即使是按我上面那样那样去设置,仍然避免不了,想一想,依旧是死循环。
后来我想出了一个办法,就是自己定义一个中间实体,名字是RoleUser,然后,在User实体类里加public virtual ICollection<RoleUser> RoleUsers { get; set; },在Role实体类中加public virtual ICollection<RoleUser> RoleUsers { get; set; },也就是,一个多对多关系转成了两个一对多,采用上面的方式就能处理了。这样还有一个优点,就是可以在中间关系映射表中增加其他字段,比如创建时间、创建人,以及一些必要的业务信息,比如在学生的选课记录上增加成绩字段。
以上就是我自己摸索出来的解决方式,就目前来说,是自己能想到的最优解决方式,供大家参考下,欢迎批评指正,提供更佳的实现方式。
可能是EF用的人还不多,但是Hibernate应该同样有类似问题吧,没接触过Hibernate,也欢迎了解Hibernate人说下在这方面是如何处理的。
2013年4月5日补充修正:
本文发表后,有两位朋友留言提出自己的办法,一是用JsonIgnore属性,二是用DataContractJsonSerializer类,看上去貌似有用,经过试验,发现还是无效,不过也进一步提供了思路。其实文章整理出来后,就发现问题其实很简单了,就是序列化对象的时候如何处理循环引用的问题,只要为序列化类指定行为,那么一切问题也就解决了。其实我一直有点不踏实,就是我原来琢磨出来的方法,在WebApiConfig.cs里加对Json类的控制,是否真的能起作用?之前不了解WebApi,又查了下资料,发现跟普通的Control调用是两回事,也就意味着,不论在里面做什么,不应该对现有的Controller里面的方法产生影响。方向搞错了……进而想一下,如果去掉那段修改的代码,那么我的程序应该也能运行,于是试了一下,果然正常……只要将一对多关系只保留一个导航属性,那么自然就不存在循环引用的问题,我以为正确的解决方法,是在反复试验和多处修改后产生的混乱错觉,由此差点也成为误导别人的凶手……
前面的摸索并不都是无用功,对这块了解已经深入了,也有了新的想法。方向明确了,就是为序列化类指定行为来控制序列化时的行为。那么如何来控制呢,前面用JavaScriptSerializer类为object类写过扩展方法,明显没有可控的属性,而之前的摸索也知道了Newtonsoft.Json.JsonConvert这个类可以控制循环应用的行为处理,尝试改了一下之前的扩展方法,果然可以。
之前一直怀疑Controller.Json方法默认内部调用的是JavaScriptSerializer类来序列化对象的,按这个思路搜了一下,果然发现有人深入研究过,网址见http://blog.darkthread.net/post-2012-08-30-asp-net-mvc-and-json-net.aspx,是通过查看MVC源码获取的,并且提供了如何继承默认Controller和重载Json方法,把序列化的JavaScriptSerializer替换成Newtonsoft.Json.JsonConvert的方法,看上去逻辑上没问题,具体没验证。考虑到这种方式比较麻烦,且以后升级MVC版本容易出问题,还是用我以前写的扩展方法来解决最为方便。
为Oject对象增加ToJsonString方法(注意对项目添加Newtonsoft.Json.dll引用)
using System; using System.Text; using System.Web.Script.Serialization; using Newtonsoft.Json; namespace Common.Extentions { public static class ObjectExtentions { public static string ToJsonString(this Object obj) { JsonSerializerSettings jsSettings = new JsonSerializerSettings(); jsSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; return JsonConvert.SerializeObject(obj, jsSettings); } } }
然后在Control里,获取到数据对象后,return Content(data.ToJsonString());
这样处理后,你可以在一对多关系中,同时使用两个导航属性,并且都添加virtual关键字,从而保留延迟加载特性。
由此想到多对多关系也可以常规化处理了,不过你想为中间表增加字段,还得用上文拆分的思路,欢迎验证,欢迎指正。