2014-11-24

ASP.NET MVC - 如何讓Form參數內的XML字串綁定到Model

最近在學習MVC,目前有個需求,需要使用者將Form Post到某個Action內,裡面的參數還會有xml文字,整個參數會像底下一樣:
A=123&B=abc&C=<Root><A>123</A><B>abc</B><C>kkk</C><D>iii</D></Root>

其中A與B是普通字串,而C是XML字串

在Visual Studio 2013裡有個好用的功能,可以將XML或JSON字串轉成Class,只要使用選擇性貼上即可。底下就是寫好的一個使用者參數的Model:
public class SendModel
{
    [Required(AllowEmptyStrings = false, ErrorMessage = "{0}不可為空")]
    [StringLength(3, MinimumLength = 3, ErrorMessage = "{0}長度必須為{2}")]
    public string A { get; set; }
 
    [Required(AllowEmptyStrings = false, ErrorMessage = "{0}不可為空")]
    [StringLength(3, MinimumLength = 3, ErrorMessage = "{0}長度必須為{2}")]
    public string B { get; set; }
 
 
    [Required(AllowEmptyStrings = false, ErrorMessage="xml不可為空,請檢查格式是否正確")]
    public SendXmlModel C { get; set; }
 
    //這個類別就是將XML貼上之後所產生的,格式相同就可序列化成字串或反序列化回該類別的物件
    [Serializable]
    [System.Xml.Serialization.XmlTypeAttribute(AnonymousType = true)]
    [System.Xml.Serialization.XmlRootAttribute("Root", Namespace = "", IsNullable = false)]
    public partial class SendXmlModel {
        public string A { get; set; }
        public string B { get; set; }
        public string C { get; set; }
        public string D { get; set; }
    }
}


其中SendXmlModel就是我們將XML以選擇性貼上之後,Visual Studio自動幫我們產生的Class。

接下來我們產生新的Controller,裡面有個自訂的Action:
[HttpPost]
[ValidateInput(false)]
public ActionResult Test(SendModel model)
{
    if (!ModelState.IsValid) {
        return Content("error");
    }
    return Content("aaa");
}

設定好之後,我們就將指定好的參數發送到這個Action裡面,它會自動為我們將參數綁定到指定好的Model裡面,而C只要XML格式對,我想應該會自動轉成XendXmlModel的物件才是。
但結果會是顯示error!參數也都對啊,但為什麼還是會報錯呢?

仔細看錯誤訊息:

A與B都有正確綁定到Model內,但C是null...

再看,有回報錯誤訊息「從型別System.String到型別SendXmlModel的參數轉換失敗,因為沒有型別轉換器可以在這些型別之間轉換」


看來是因為我們的Model裡面,C是屬於自訂的Class,預設的模型綁定沒辦法處理...問題應該就是在那個「型別轉換器」上了!

為了這個問題我找了好幾天,網路上很難找到有類似的案例,後來終於查到,要自訂一個型別轉換器,也就是新的ValueProvider讓程式知道這個參數的型別才行(也就是錯誤訊息裡說的型別轉換器)。

所以我們來寫一個自訂的ValueProviderFactory,繼承ValueProviderFactory:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace WebApplication3.Provider
{
    public class MyValueProviderFactory : ValueProviderFactory
    {
        public override IValueProvider GetValueProvider(ControllerContext controllerContext) {

        }
    }
}

接著再新增自訂ValueProvider,繼承IValueProvider介面:
public class StringToXmlValueProvider : IValueProvider
{
    private HttpContextBase httpContext;

    public StringToXmlValueProvider(HttpContextBase httpContext) {
        this.httpContext = httpContext;
    }

    public bool ContainsPrefix(string prefix) {
        return prefix.Contains("C"); //指定如果參數名稱是C,則回傳true
    }

    public ValueProviderResult GetValue(string key) {
        if (!ContainsPrefix(key)) { return null; } //參數如果是C,則進行底下轉換
        string _xml = httpContext.Request[key];
        SendXmlModel xml;
        try {
            xml = SerializeTool.XmlDeserialize<SendXmlModel>(_xml);
        }
        catch { xml = null; }
        return new ValueProviderResult(xml, _xml, System.Globalization.CultureInfo.CurrentCulture);
    }
}

將兩個整合:
public class MyValueProviderFactory : ValueProviderFactory
{
    public override IValueProvider GetValueProvider(ControllerContext controllerContext) {
        return new StringToXmlValueProvider(controllerContext.HttpContext);
    }

    public class StringToXmlValueProvider : IValueProvider
    {
        private HttpContextBase httpContext;

        public StringToXmlValueProvider(HttpContextBase httpContext) {
            this.httpContext = httpContext;
        }

        public bool ContainsPrefix(string prefix) {
            return prefix.Contains("C"); //指定如果參數名稱是C,則回傳true
        }

        public ValueProviderResult GetValue(string key) {
            if (!ContainsPrefix(key)) { return null; } //參數如果是C,則進行底下轉換
            string _xml = httpContext.Request[key];
            SendXmlModel xml;
            try {
                xml = SerializeTool.XmlDeserialize<SendXmlModel>(_xml); //這裡使用一個泛型的XML序列化與反序列化工具,程式碼最後會補上
            }
            catch { xml = null; }
            return new ValueProviderResult(xml, _xml, System.Globalization.CultureInfo.CurrentCulture);
        }
    }
}

最後將MyValueProviderFactory註冊到Global.asax的Application_Start()內,使用插入的方式,讓自訂的ValueProviderFactory可以優先被搜尋到:
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            ValueProviderFactories.Factories.Insert(0, new MyValueProviderFactory());
        }
    }

再跑一下,可看到已經正常綁定了:


這樣就正常囉!!但要注意的是,我們在自訂的ValueProvider裡面,只有判斷參數名稱是「C」的時候,才要進行轉換,所以如果有這需求的話,參數名稱要注意一下使用相同名稱。


事情到這邊,基本綁定就OK了,但我還有另一個特殊需求,必須檢查A和B參數的值,是否和C XML裡面的A與B參數是否相同,在Model裡面使用Compare驗證是沒有辦法的,所以我們必須再寫個自訂的模型綁定才行...

新增一個自訂類別MyBinder,繼承DefaultModelBinder:
public class MyBinder : DefaultModelBinder
{
}

再來覆寫原有的BindModel方法,這裡判斷如果Model類型是我們自訂的SendModel,才要做處理,否則回傳預設的BindModel:
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) {
    if (bindingContext.ModelType == typeof(SendModel)) {

    }
    else {
        return base.BindModel(controllerContext, bindingContext);
    }
}

完成後的完整程式:
public class MyBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) {
        if (bindingContext.ModelType == typeof(SendModel)) {
            //取得參數
            var request = controllerContext.HttpContext.Request;
            var reqA = request["A"];
            var reqB = request["B"];
            var _reqC = request["C"];
            SendXmlModel reqC;
            try {
                reqC = SerializeTool.XmlDeserialize<SendXmlModel>(_reqC);
            }
            catch {
                reqC = null;
            }

            //建立新的ModelBindingContext
            ModelBindingContext NewBindingContext = new ModelBindingContext() {
                ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(
                    () =&gt; new SendModel() {
                        A = reqA,
                        B = reqB,
                        C = reqC
                    },
                    typeof(SendModel)),
                ModelState = bindingContext.ModelState,
                ValueProvider = bindingContext.ValueProvider
            };

            //加入自訂的模型驗證與ModelState錯誤訊息
            if (reqC != null) {
                if (reqA != reqC.A) {
                    NewBindingContext.ModelState.AddModelError("A", "參數A必須與XML內的A相符");
                }
                if (reqB != reqC.B) {
                    NewBindingContext.ModelState.AddModelError("B", "參數B必須與XML內的B相符");
                }
            }

            //回傳BindModel
            return base.BindModel(controllerContext, NewBindingContext);
        }
        else {
            return base.BindModel(controllerContext, bindingContext);
        }
    }
}

完成後一樣要在Global.asax註冊:
protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
    ValueProviderFactories.Factories.Insert(0, new MyValueProviderFactory());
            
    ModelBinders.Binders.Add(typeof(SendModel), new MyBinder());
}

接著在Action裡面,也要用我們指定的綁定模型才行:
[HttpPost]
[ValidateInput(false)]
public ActionResult Test([ModelBinder(typeof(MyBinder))] SendModel model)
{
    if (!ModelState.IsValid) {
        return Content("error");
    }
    return Content("aaa");
}


來試試看,假設A參數與XML內的A不同的話,ModelState會回報驗證不通過:


這樣就完成我們的要求了。


底下是好用的泛型將XML序列化或反序列化工具:
using System.Xml;
using System.Xml.Serialization;
public class SerializeTool
{
    /// <summary>
    /// 以UTF-8編碼將XML物件序列化成字串
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="value"></param>
    /// <returns></returns>
    public static string SerializeXml<T>(T value) {
        if (value == null) { return null; }

        XmlSerializer ser = new XmlSerializer(typeof(T));
        XmlWriterSettings settings = new XmlWriterSettings();
        settings.OmitXmlDeclaration = true;
        settings.NamespaceHandling = NamespaceHandling.Default;
        settings.Encoding = Encoding.UTF8;

        XmlSerializerNamespaces nspace = new XmlSerializerNamespaces();
        nspace.Add("", "");
        StringBuilder sb = new StringBuilder();
        using (XmlWriter xmlWriter = XmlWriter.Create(sb, settings)) {
            ser.Serialize(xmlWriter, value, nspace);
        }
        return sb.ToString();
    }

    /// <summary>
    /// 將XML字串反序列化成指定型別物件
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="xml"></param>
    /// <returns></returns>
    public static T DeserializeXml<T>(string xml) {

        if (string.IsNullOrEmpty(xml)) {
            return default(T);
        }

        XmlSerializer ser = new XmlSerializer(typeof(T));

        XmlReaderSettings settings = new XmlReaderSettings();
        // No settings need modifying here

        using (StringReader textReader = new StringReader(xml)) {
            using (XmlReader xmlReader = XmlReader.Create(textReader, settings)) {
                return (T)ser.Deserialize(xmlReader);
            }
        }
    }
}


參考資料:
http://stackoverflow.com/questions/5820637/custom-model-binding-model-state-and-data-annotations

http://www.codeproject.com/Articles/605595/ASP-NET-MVC-Custom-Model-Binder

http://donovanbrown.com/post/How-to-create-a-custom-Value-Provider-for-MVC.aspx

http://www.dotblogs.com.tw/maev85/archive/2010/09/16/17772.aspx