JavaScriptSerializer and ASP.Net MVC model binding

2019-01-17 23:48发布

问题:

I'm seeing a JSON deserialization problem which I can not explain or fix.

Code

public class Model
{
    public List<ItemModel> items { get; set; }
}
public class ItemModel
{

    public int sid { get; set; }
    public string name { get; set; }
    public DataModel data { get; set; }
    public List<ItemModel> items { get; set; }
}

public class DataModel
{
    public double? d1 { get; set; }
    public double? d2 { get; set; }
    public double? d3 { get; set; }
}

public ActionResult Save(int id, Model model) {
}

Data

{'items':[{'sid':3157,'name':'a name','items':[{'sid':3158,'name':'child name','data':{'d1':2,'d2':null,'d3':2}}]}]}

Unit test - passing

var jss = new JavaScriptSerializer();
var m = jss.Deserialize<Model>(json);
Assert.Equal(2, m.items.First().items.First().data.d1);

The problem

the same JSON string, when sent to the Save action, doesn't get deserialized the same way, specially the D1, D2, and D3 values are all set to NULL. Always.

What's going on here, and how can I fix it?

回答1:

It might sound counter-intuitive, but you should send those doubles as strings in the json:

'data':{'d1':'2','d2':null,'d3':'2'}

Here is my complete test code that invokes this controller action using AJAX, and allows binding to every value of the model:

$.ajax({
    url: '@Url.Action("save", new { id = 123 })',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify({
        items: [
            {
                sid: 3157,
                name: 'a name',
                items: [
                    {
                        sid: 3158,
                        name: 'child name',
                        data: {
                            d1: "2",
                            d2: null,
                            d3: "2"
                        }
                    }
                ]
            }
        ]
    }),
    success: function (result) {
        // ...
    }
});

And just to illustrate the extent of the problem of trying to deserialize numeric types from JSON, let's take a few examples:

  • public double? Foo { get; set; }
    • { foo: 2 } => Foo = null
    • { foo: 2.0 } => Foo = null
    • { foo: 2.5 } => Foo = null
    • { foo: '2.5' } => Foo = 2.5

  • public float? Foo { get; set; }
    • { foo: 2 } => Foo = null
    • { foo: 2.0 } => Foo = null
    • { foo: 2.5 } => Foo = null
    • { foo: '2.5' } => Foo = 2.5

  • public decimal? Foo { get; set; }
    • { foo: 2 } => Foo = null
    • { foo: 2.0 } => Foo = null
    • { foo: 2.5 } => Foo = 2.5
    • { foo: '2.5' } => Foo = 2.5

Now let's do the same with non-nullable types:

  • public double Foo { get; set; }
    • { foo: 2 } => Foo = 2.0
    • { foo: 2.0 } => Foo = 2.0
    • { foo: 2.5 } => Foo = 2.5
    • { foo: '2.5' } => Foo = 2.5

  • public float Foo { get; set; }
    • { foo: 2 } => Foo = 2.0
    • { foo: 2.0 } => Foo = 2.0
    • { foo: 2.5 } => Foo = 2.5
    • { foo: '2.5' } => Foo = 2.5

  • public decimal Foo { get; set; }
    • { foo: 2 } => Foo = 0
    • { foo: 2.0 } => Foo = 0
    • { foo: 2.5 } => Foo = 2.5
    • { foo: '2.5' } => Foo = 2.5

Conclusion: deserializing numeric types from JSON is one big hell-of-a mess. Use strings in the JSON. And of course, when you use strings, be careful with the decimal separator as it is culture dependent.


I have been asked in the comments section why this passes unit tests, but doesn't work in ASP.NET MVC. The answer is simple: It's because ASP.NET MVC does many more things than a simple call to a JavaScriptSerializer.Deserialize, which is what the unit test does. So you are basically comparing apples to oranges.

Let's dive deeper into what happens. In ASP.NET MVC 3 there's a built-in JsonValueProviderFactory which internally uses the JavaScriptDeserializer class to deserialize the JSON. This works, as you have already seen, in the unit test. But there's much more to it in ASP.NET MVC, as it also uses a default model binder that is responsible for instantiating your action parameters.

And if you look at the source code of ASP.NET MVC 3, and more specifically the DefaultModelBinder.cs class, you will notice the following method which is invoked for each property that will have a value to be set:

public class DefaultModelBinder : IModelBinder {

    ...............

    [SuppressMessage("Microsoft.Globalization", "CA1304:SpecifyCultureInfo", MessageId = "System.Web.Mvc.ValueProviderResult.ConvertTo(System.Type)", Justification = "The target object should make the correct culture determination, not this method.")]
    [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We're recording this exception so that we can act on it later.")]
    private static object ConvertProviderResult(ModelStateDictionary modelState, string modelStateKey, ValueProviderResult valueProviderResult, Type destinationType) {
        try {
            object convertedValue = valueProviderResult.ConvertTo(destinationType);
            return convertedValue;
        }
        catch (Exception ex) {
            modelState.AddModelError(modelStateKey, ex);
            return null;
        }
    }

    ...............

}

Let's focus more specifically on the following line:

object convertedValue = valueProviderResult.ConvertTo(destinationType);

If we suppose that you had a property of type Nullable<double>, here's what this would look like when you debug your application:

destinationType = typeof(double?);

No surprises here. Our destination type is double? because that's what we used in our view model.

Then take a look at the valueProviderResult:

See this RawValue property out there? Can you guess its type?

So this method simply throws an exception because it obviously cannot convert the decimal value of 2.5 to a double?.

Do you notice what value is returned in this case? That's why you end up with null in your model.

That's very easy to verify. Simply inspect the ModelState.IsValid property inside your controller action and you will notice that it is false. And when you inspect the model error that was added to the model state you will see this:

The parameter conversion from type 'System.Decimal' to type 'System.Nullable`1[[System.Double, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]' failed because no type converter can convert between these types.

You may now ask, "But why is the RawValue property inside the ValueProviderResult of type decimal?". Once again the answer lies inside the ASP.NET MVC 3 source code (yeah, you should have downloaded it by now). Let's take a look at the JsonValueProviderFactory.cs file, and more specifically the GetDeserializedObject method:

public sealed class JsonValueProviderFactory : ValueProviderFactory {

    ............

    private static object GetDeserializedObject(ControllerContext controllerContext) {
        if (!controllerContext.HttpContext.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) {
            // not JSON request
            return null;
        }

        StreamReader reader = new StreamReader(controllerContext.HttpContext.Request.InputStream);
        string bodyText = reader.ReadToEnd();
        if (String.IsNullOrEmpty(bodyText)) {
            // no JSON data
            return null;
        }

        JavaScriptSerializer serializer = new JavaScriptSerializer();
        object jsonData = serializer.DeserializeObject(bodyText);
        return jsonData;
    }

    ............

}

Do you notice the following line:

JavaScriptSerializer serializer = new JavaScriptSerializer();
object jsonData = serializer.DeserializeObject(bodyText);

Can you guess what the following snippet will print on your console?

var serializer = new JavaScriptSerializer();
var jsonData = (IDictionary<string, object>)serializer
    .DeserializeObject("{\"foo\":2.5}");
Console.WriteLine(jsonData["foo"].GetType());

Yep, you guessed it right, it's a decimal.

You may now ask, "But why did they use the serializer.DeserializeObject method instead of serializer.Deserialize as in my unit test?" It's because the ASP.NET MVC team made the design decision to implement JSON request binding using a ValueProviderFactory, which doesn't know the type of your model.

See now how your unit test is completely different than what really happens under the covers of ASP.NET MVC 3? Which normally should explain why it passes, and why the controller action doesn't get a correct model value?



回答2:

Solution 1: Pass data marked as "application/x-www-form-urlencoded". In this case Nullable<double> is deserialized correctly. Example:

<script type="text/javascript">

$("#post-data").click(function () {
    $.ajax({
        url: "/info/create",
        type: "PUT",
        //  contentType: 'application/json', //default is application/x-www-form-urlencoded
        data: JSON.stringify({
            Dbl1: null, //pass double as null
            Dbl2: 56.3  //pass double with value
        }),
        dataType: "json"
    });

    return false;
});

Solution 2: change double? to decimal? and send content as "application/json". Thanks to Darin investigation