Telerik MVC Grid with Ajax Binding using EntityObjects gets Circular References exception

三世轮回 提交于 2020-01-01 00:21:24

问题


I have been using Telerik MVC Grid for quite a while now and It is a great control, however, one annoying thing keeps showing up related to using the grid with Ajax Binding to objects created and returned from the Entity Framework. Entity objects have circular references, and when you return an IEnumerable from an Ajax callback it generates an exception from the JavascriptSerializer if there are circular references. This happens because the MVC Grid uses a JsonResult which in turn uses JavaScriptSerializer which does not support serializing circular references.

My solution to this problem has been to use LINQ to create view objects that do not have the Related Entites. This works for all cases, but reqires the creation of new objects and the copying of data to / from entity objects to these view objects. Not alot of work, but it is work.

I have finally figured out how to generically make the grid not serialize the circular referneces (ignore them) and I wanted to share my solution for the general public, as I think it is generic, and plugs into the environment nicely.

The solution has a couple of parts

  1. Swap the default grid serializer with a custom serializer
  2. Install the Json.Net plug-in available from Newtonsoft (this is a great library)
  3. Implement the grid serializer using Json.Net
  4. Modify the Model.tt files to insert [JsonIgnore] attributes in front of the navigation properties
  5. Override the DefaultContractResolver of Json.Net and look for the _entityWrapper attribute name to ensure this is also ignored (injected wrapper by the poco classes or entity framework)

All of these steps are easy in and of themselves, but without all of them you cannot take advantage of this technique.

Once implemented correctly i can now easily send any entity framework object directly to the client without creating new View objects. I dont recommend this for every object, but sometimes it is the best option. It is also important to note that any related entires are not availalbe on the client side, so don't use them.

Here are the Steps required

  1. Create the following class in your application somewhere. This class is a factory object that the grid uses to obtain json results. This will be added to the telerik library in the global.asax file shortly.

    public class CustomGridActionResultFactory : IGridActionResultFactory
    {
        public System.Web.Mvc.ActionResult Create(object model)
        {
            //return a custom JSON result which will use the Json.Net library
            return new CustomJsonResult
            {
                Data = model
            };
        }
    }
    
  2. Implement the Custom ActionResult. This code is boilerplate for the most part. The only interesting part is at the bottom where it calls JsonConvert.SerilaizeObject passing in a ContractResolver. The ContactResolver looks for properties called _entityWrapper by name and sets them to be ignored. I am not exactly sure who injects this property, but it is part of the entity wrapper objects and it has circular referneces.

    public class CustomJsonResult : ActionResult
    {
        const string JsonRequest_GetNotAllowed = "This request has been blocked because sensitive information could be disclosed to third party web sites when this is used in a GET request. To allow GET requests, set JsonRequestBehavior to AllowGet.";
    
        public string ContentType { get; set; }
        public System.Text.Encoding ContentEncoding { get; set; }
        public object Data { get; set; }
        public JsonRequestBehavior JsonRequestBehavior { get; set; }
        public int MaxJsonLength { get; set; }
    
        public CustomJsonResult()
        {
            JsonRequestBehavior = JsonRequestBehavior.DenyGet;
            MaxJsonLength = int.MaxValue; // by default limit is set to int.maxValue
        }
    
        public override void ExecuteResult(ControllerContext context)
        {
            if (context == null)
            {
                throw new ArgumentNullException("context");
            }
    
            if ((JsonRequestBehavior == JsonRequestBehavior.DenyGet) && string.Equals(context.HttpContext.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidOperationException(JsonRequest_GetNotAllowed);
            }
    
            var response = context.HttpContext.Response;
            if (!string.IsNullOrEmpty(ContentType))
            {
                response.ContentType = ContentType;
            }
            else
            {
                response.ContentType = "application/json";
            }
            if (ContentEncoding != null)
            {
                response.ContentEncoding = ContentEncoding;
            }
            if (Data != null)
            {
                response.Write(JsonConvert.SerializeObject(Data, Formatting.None,
                                                           new JsonSerializerSettings
                                                               {
                                                                   NullValueHandling = NullValueHandling.Ignore,
                                                                   ContractResolver =  new PropertyNameIgnoreContractResolver()
                                                               }));
            }
        }
    }
    
  3. Add the factory object to the telerik grid. I do this in the global.asax Application_Start() method, but realistically it can be done anywhere that makes sense.

    DI.Current.Register<IGridActionResultFactory>(() => new CustomGridActionResultFactory());
    
  4. Create the DefaultContractResolver class that checks for _entityWrapper and ignores that attribute. The resolver is passed into the SerializeObject() call in step 2.

    public class PropertyNameIgnoreContractResolver : DefaultContractResolver
    {
        protected override JsonProperty CreateProperty(System.Reflection.MemberInfo member, MemberSerialization memberSerialization)
        {
            var property = base.CreateProperty(member, memberSerialization);
    
            if (member.Name == "_entityWrapper")
                property.Ignored = true;
    
            return property;
        }
    }
    
  5. Modify the Model1.tt file to inject attributes that ignore the related entity properties of the POCO Objects. The attribute that must be injected is [JsonIgnore]. This is the hardest part to add to this post but not hard to do in the Model1.tt (or whatever filename it is in your project). Also if you are using code first then you can manually place the [JsonIgnore] attributes in front of any attribute that creates a circular reference.

    Search for the region.Begin("Navigation Properties") in the .tt file. This is where all of the navigation properties are code generated. There are two cases that have to be taken care of the many to XXX and the Singular refernece. There is an if statement taht checks if the property is

    RelationshipMultiplicity.Many
    

    Just after that code block you need to insert the [JasonIgnore] attribute prior to the line

    <#=PropertyVirtualModifier(Accessibility.ForReadOnlyProperty(navProperty))#> ICollection<<#=code.Escape(navProperty.ToEndMember.GetEntityType())#>> <#=code.Escape(navProperty)#>
    

    Which injects the proprty name into the generated code file.

    Now look for this line which handles the Relationship.One and Relationship.ZeroOrOne relationships.

    <#=PropertyVirtualModifier(Accessibility.ForProperty(navProperty))#> <#=code.Escape(navProperty.ToEndMember.GetEntityType())#> <#=code.Escape(navProperty)#>
    

    Add the [JsonIgnore] attribute just before this line.

    Now the only thing left is to make sure the NewtonSoft.Json library is "Used" at the top of each generated file. Search for the call to WriteHeader() in the Model.tt file. This method takes a string array parameter that adds extra usings (extraUsings). Instead of passing null connstruct an array of strings and send in the "Newtonsoft.Json" string as the first element of the array. The call should now look like:

    WriteHeader(fileManager, new [] {"Newtonsoft.Json"});
    

Thats all there is to do, and everthing starts working, for every object.

Now for the disclaimers

  • I have never used Json.Net so my implementation of it might not be optimal.
  • I have been testing for about two days now and havent found any cases where this technique fails.
  • I also have not found any incompatibilities between the JavascriptSerializer and the JSon.Net serializer but that doesnt mean there arent any
  • The only other caveat is that the i am testing for a property called "_entityWrapper" by name to set its ignored property to true. This is obviously not optimal.

I would welcome any feedback on how to improve this solution. I hope it helps someone else.


回答1:


The first solution works with the grid editing mode, but we have the same problem with the load of the grid that already has rows of objects with circular reference, and to resolve this we need to create a new IClientSideObjectWriterFactory and a new IClientSideObjectWriter. This is what I do:

1- Create a new IClientSideObjectWriterFactory:

public class JsonClientSideObjectWriterFactory : IClientSideObjectWriterFactory
{
    public IClientSideObjectWriter Create(string id, string type, TextWriter textWriter)
    {
        return new JsonClientSideObjectWriter(id, type, textWriter);
    }
}

2- Create a new IClientSideObjectWriter, this time I do not implement the interface, I've inherited the ClientSideObjectWriter and overrided the AppendObject and AppendCollection methods:

public class JsonClientSideObjectWriter : ClientSideObjectWriter
{
    public JsonClientSideObjectWriter(string id, string type, TextWriter textWriter)
        : base(id, type, textWriter)
    {
    }

    public override IClientSideObjectWriter AppendObject(string name, object value)
    {
        Guard.IsNotNullOrEmpty(name, "name");

        var data = JsonConvert.SerializeObject(value,
            Formatting.None,
            new JsonSerializerSettings
                {
                    NullValueHandling = NullValueHandling.Ignore,
                    ContractResolver = new PropertyNameIgnoreContractResolver()
                });

        return Append("{0}:{1}".FormatWith(name, data));
    }

    public override IClientSideObjectWriter AppendCollection(string name, System.Collections.IEnumerable value)
    {
    public override IClientSideObjectWriter AppendCollection(string name, System.Collections.IEnumerable value)
    {
        Guard.IsNotNullOrEmpty(name, "name");

        var data = JsonConvert.SerializeObject(value,
            Formatting.Indented,
            new JsonSerializerSettings
                {
                    NullValueHandling = NullValueHandling.Ignore,
                    ContractResolver = new PropertyNameIgnoreContractResolver()
                });

        data = data.Replace("<", @"\u003c").Replace(">", @"\u003e");

        return Append("{0}:{1}".FormatWith((object)name, (object)data));
    }
}

NOTE: The replace its because the grid renders html tags for the client template in edit mode and if we don't encode then the browser will render the tags. I didn't find a workarround yet if not using a Replace from string object.

3- On my Application_Start on Global.asax.cs I registered my new factory like this:

DI.Current.Register<IClientSideObjectWriterFactory>(() => new JsonClientSideObjectWriterFactory());

And it worked for all components that Telerik has. The only thing that I do not changed was the PropertyNameIgnoreContractResolver that was the same for the EntityFramework classes.




回答2:


I put the new call into my Application_Start for implement the CustomGridActionResultFactory but the create method never called...




回答3:


I have taken a slightly different approach which I believe might be a little easier to implement.

All I do is apply an extended [Grid] attribute to the grid json returning method instead of the normal [GridAction] attribute

public class GridAttribute : GridActionAttribute, IActionFilter
  {    
    /// <summary>
    /// Determines the depth that the serializer will traverse
    /// </summary>
    public int SerializationDepth { get; set; } 

    /// <summary>
    /// Initializes a new instance of the <see cref="GridActionAttribute"/> class.
    /// </summary>
    public GridAttribute()
      : base()
    {
      ActionParameterName = "command";
      SerializationDepth = 1;
    }

    protected override ActionResult CreateActionResult(object model)
    {    
      return new EFJsonResult
      {
       Data = model,
       JsonRequestBehavior = JsonRequestBehavior.AllowGet,
       MaxSerializationDepth = SerializationDepth
      };
    }
}

and

public class EFJsonResult : JsonResult
  {
    const string JsonRequest_GetNotAllowed = "This request has been blocked because sensitive information could be disclosed to third party web sites when this is used in a GET request. To allow GET requests, set JsonRequestBehavior to AllowGet.";

    public EFJsonResult()
    {
      MaxJsonLength = 1024000000;
      RecursionLimit = 10;
      MaxSerializationDepth = 1;
    }

    public int MaxJsonLength { get; set; }
    public int RecursionLimit { get; set; }
    public int MaxSerializationDepth { get; set; }

    public override void ExecuteResult(ControllerContext context)
    {
      if (context == null)
      {
        throw new ArgumentNullException("context");
      }

      if (JsonRequestBehavior == JsonRequestBehavior.DenyGet &&
          String.Equals(context.HttpContext.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
      {
        throw new InvalidOperationException(JsonRequest_GetNotAllowed);
      }

      var response = context.HttpContext.Response;

      if (!String.IsNullOrEmpty(ContentType))
      {
        response.ContentType = ContentType;
      }
      else
      {
        response.ContentType = "application/json";
      }

      if (ContentEncoding != null)
      {
        response.ContentEncoding = ContentEncoding;
      }

      if (Data != null)
      {
        var serializer = new JavaScriptSerializer
        {
          MaxJsonLength = MaxJsonLength,
          RecursionLimit = RecursionLimit
        };

        serializer.RegisterConverters(new List<JavaScriptConverter> { new EFJsonConverter(MaxSerializationDepth) });

        response.Write(serializer.Serialize(Data));
      }
    }

Combine this with my serializer Serializing Entity Framework problems and you have a simple way of avoiding circular references but also optionally serializing multiple levels (which I need)

Note: Telerik added this virtual CreateActionResult very recently for me so you may have to download the latest version (not sure but I think maybe 1.3+)




回答4:


Another good pattern is to simply not avoid creating a ViewModel from the Model. It is a good pattern to include a ViewModel. It gives you the opportunity to make last minute UI related tweaks to the model. For example, you can tweak a bool to have an associated string Y or N to help make the UI look nice, or vice versa. Sometimes the ViewModel is exactly like the Model and the code to copy the properties seems unnecessary, but the pattern is a good one and sticking to it is the best practice.



来源:https://stackoverflow.com/questions/7888770/telerik-mvc-grid-with-ajax-binding-using-entityobjects-gets-circular-references

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!