使用jQuery傳送物件JSON到ASP.NET MVC的做法之前介紹過,但最近我在專案又遇到新難題。

例如有一個參數物件,ArgObject,內含Name屬性及SubArg屬性,SubArg有其專屬型別SubArgObject,基於特殊需要,SubArgObject使用[JsonProperty]及[JsonIgnore]自訂JSON轉換邏輯(實際專案用的是[JsonConverter(...)],此處簡化為[JsonProperty],指定PropB在JSON中需更名為PropX):

    public class ArgObject
    {
        public string Name { get; set; }
        public SubArgObject SubArg { get; set; }
    }
 
    public class SubArgObject
    {
        public string PropA { get; set; }
        [JsonProperty("PropX")]
        public string PropB { get; set; }
        [JsonIgnore]
        public string PropC { get; set; }
    }

如果是Web API Controller,什麼都不用做就能順利接收參數。依Web API的Binding規則,參數如為複雜型別(Complex Type),將依Content-Type選擇適當的Media Type Formatter進行轉換[參考]。在本例中application/json會使用JsonMediaTypeFormatter,其核心為Json.NET,且已知轉換目標型別為ArgObject,故可精準轉換[JsonProperty("PropX")]無誤。

    public class WebApiController : ApiController
    {
        public ArgObject PostJson(ArgObject args)
        {
            return args;
        }
    }

同樣參數與POST內容,套用在MVC Action結果截然不同:(補充:傳回結果採用Json.NET版JsonResult,使用Json.NET序列化,避用預設的JavaScriptSerializer)

    public class MvcController : Controller
    {
        public ActionResult PostJson(ArgObject args)
        {
            return new Newtonsoft.JsonResult.JsonResult()
            {
                Data = args
            };
        }
    }

JSON中的PropX值沒有正確對應到PropB:

這個差異源自ApiController及Controller處理Model Binding的機制不同。Controller使用Value Provider概念解析Query Sting參數、HTML Form欄位、XML或JSON,再對應成輸入參數,而JSON由JsonValueProviderFactory負責解析,其內部使用的是JavaScriptSerializer。網路上有將JsonValueProviderFactory換成Json.NET版JsonDotNetValueProviderFactory的做法,但實測無法克服問題。因為MVC預設的Model Binding機制,會先用JsonValuProviderFactory將JSON內容轉成包含三組值的Dictionary:

Name: "Jeffrey",
SubArg.PropA: "AAA",
SubArg.PropX: "XXX"

再從Dictionary取值映對到新建的ArgObject及SubArgObject物件,導致[JsonProperty("PropX")]無從發揮作用。

評估之下,還是得回歸自訂Model Binder。執行IModelBinder.BindModel()時,因轉換對象型別已知,可善用Json.NET的.DeserializeObject(jsonString, bindingContext.ModelType),指定目標型別讓[JsonProperty]、[JsonConverter(…)]等設定發揮作用。

仿效System.Web.Http.FromBodyAtrribute,我做了一個Afet.Mvc.FromBodyAtrribute,將整個JSON內容轉成單一型別。

另外,藉此機會也順便解決實務上另一項常見困擾。為了傳送JSON到SomeAction(Type1 arg1, Type2 arg2),通常要另外宣告一個暫用彙總型別:

class AggTypes {
    public Type1 arg1 { get; set; }
    public Type2 arg2 { get; set; }
}

接收端再改成SomeAction([Afet.Mvc.FromBody]AggTypes args)。既有自訂Model Binder,若允許由JSON中只取出arg1及arg2,分別轉成Type1及Type2,就可以省去多宣告無意義彙總型別的困擾:

ActionResult SomeAction([Afet.Mvc.FromPartialBody]Type1 arg1, [Afet.Mvc.FromPartialBody]Type2 arg2)

FromBodyAttribute及FromPartialBodyAttribute的程式碼如下:

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Formatting;
using System.Web.Http.Controllers;
using System.Web.Http.Validation;
using System.Web.Mvc;
 
namespace Afet.Mvc
{
    [AttributeUsage(AttributeTargets.Parameter)]
    public class FromBodyAttribute : CustomModelBinderAttribute
    {
        protected bool DictionaryMode = false;
 
        public FromBodyAttribute(bool dictionaryMode = false)
        {
            this.DictionaryMode = dictionaryMode;
        }
 
        public override IModelBinder GetBinder()
        {
            return new JsonNetBinder(DictionaryMode);
        }
 
        public class JsonNetBinder : IModelBinder
        {
 
            private bool DictionaryMode;
            public JsonNetBinder(bool dictionaryMode)
            {
                this.DictionaryMode = dictionaryMode;
            }
 
            const string VIEW_DATA_STORE_KEY = "___BodyJsonString";
 
            public object BindModel(ControllerContext controllerContext,
                ModelBindingContext bindingContext)
            {
                //Verify content-type
                if (!controllerContext.HttpContext.Request.
                    ContentType.ToLower().Contains("json"))
                    return null;
                var stringified = controllerContext.Controller
                                .ViewData[VIEW_DATA_STORE_KEY] as string;
                if (stringified == null)
                {
                    var stream = controllerContext.HttpContext.Request.InputStream;
                    using (var sr = new StreamReader(stream))
                    {
                        stream.Position = 0;
                        stringified = sr.ReadToEnd();
                        controllerContext.Controller
                            .ViewData.Add(VIEW_DATA_STORE_KEY, stringified);
                    }
                }
                if (string.IsNullOrEmpty(stringified)) return null;
 
                try
                {
                    if (DictionaryMode)
                    {
                        //Find the property form JObject converted from body
                        var dict = JsonConvert.DeserializeObject<JObject>(stringified);
                        if (dict.Property(bindingContext.ModelName) == null)
                            return null;
                        //Convert the property to ModelType
                        return dict.Property(bindingContext.ModelName).Value
                            .ToObject(bindingContext.ModelType);
                    }
                    else
                    {
                        //Convert the whole body to ModelType
                        return JsonConvert.DeserializeObject(stringified,
                            bindingContext.ModelType);
                    }
                }
                catch
                {
 
                }
                return null;
            }
        }
    }
 
    [AttributeUsage(AttributeTargets.Parameter)]
    public sealed class FromPartialBodyAttribute : FromBodyAttribute
    {
        public FromPartialBodyAttribute()
            : base(true)
        {
        }
    }
}

使用方法如下:

        public ActionResult PostJson2(
            [Afet.Mvc.FromBody]ArgObject args)
        {
            return new Newtonsoft.JsonResult.JsonResult()
            {
                Data = args
            };
        }
 
        public ActionResult PostJson3(
            [Afet.Mvc.FromPartialBody]string Name,
            [Afet.Mvc.FromPartialBody]SubArgObject SubArg
            )
        {
            return new Newtonsoft.JsonResult.JsonResult()
            {
                Data = new
                {
                    ParamName = Name,
                    ParamSubArg = SubArg
                }
            };
        }

有了這兩項工具,使用Json.NET解析MVC輸入參數就方便多了。


Comments

# by 牛大神

為什麼要那麼複雜呢, 直接用 text 傳送就好了, 伺服器分析 json 就已經用上了很多資料, 但直接用 text 傳到伺服器的話, 直接 split 就可以了. 型別問題嗎? 不論 json 還是 text, 我們寫程式時都預設及預知那個變量是那個型別吧! 直的不知道為什麼要用 json? 整潔些? 偵錯易一點?

# by Jeffrey

to 牛大神, 使用CSV之類的純文字檔格式,相較於JSON有一些缺點: 1) 不管用什麼分隔符號,都要解決內容值出現該符號字元值的例外處理,資料都是純數字或日期時問題不大,但若包含可自由輸入文字時,蠻容易埋下地雷,很久之後某一天才引爆。而JSON已經充分考量過這些特殊情境。 2) 如果資料在Server端要還原成物件,必須自己寫轉換函式,將拆出來的值一一對應到欄位上。一旦要增減欄位,轉換函式就得修改,使用JSON格式就省去這層困擾。 3) 在各語言平台上都能找到JSON函式庫,方便與不同系統串接。 4) JSON肉眼可以讀取,也有很多現成工具可以解析結構,相較於CSV要數位置算欄位,的確容易偵錯多了。但JSON字串內含屬性名稱挺囉嗦(但還是比XML簡潔),簡潔不是它的最大強項。

Post a comment