2014-11-24

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

最近在學習MVC,目前有個需求,需要使用者將Form Post到某個Action內,裡面的參數還會有xml文字,整個參數會像底下一樣:
  1. 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:
  1. public class SendModel
  2. {
  3. [Required(AllowEmptyStrings = false, ErrorMessage = "{0}不可為空")]
  4. [StringLength(3, MinimumLength = 3, ErrorMessage = "{0}長度必須為{2}")]
  5. public string A { get; set; }
  6. [Required(AllowEmptyStrings = false, ErrorMessage = "{0}不可為空")]
  7. [StringLength(3, MinimumLength = 3, ErrorMessage = "{0}長度必須為{2}")]
  8. public string B { get; set; }
  9. [Required(AllowEmptyStrings = false, ErrorMessage="xml不可為空,請檢查格式是否正確")]
  10. public SendXmlModel C { get; set; }
  11. //這個類別就是將XML貼上之後所產生的,格式相同就可序列化成字串或反序列化回該類別的物件
  12. [Serializable]
  13. [System.Xml.Serialization.XmlTypeAttribute(AnonymousType = true)]
  14. [System.Xml.Serialization.XmlRootAttribute("Root", Namespace = "", IsNullable = false)]
  15. public partial class SendXmlModel {
  16. public string A { get; set; }
  17. public string B { get; set; }
  18. public string C { get; set; }
  19. public string D { get; set; }
  20. }
  21. }


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

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

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

仔細看錯誤訊息:

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

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


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

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

所以我們來寫一個自訂的ValueProviderFactory,繼承ValueProviderFactory:
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Web;
  5. using System.Web.Mvc;
  6.  
  7. namespace WebApplication3.Provider
  8. {
  9. public class MyValueProviderFactory : ValueProviderFactory
  10. {
  11. public override IValueProvider GetValueProvider(ControllerContext controllerContext) {
  12.  
  13. }
  14. }
  15. }

接著再新增自訂ValueProvider,繼承IValueProvider介面:
  1. public class StringToXmlValueProvider : IValueProvider
  2. {
  3. private HttpContextBase httpContext;
  4.  
  5. public StringToXmlValueProvider(HttpContextBase httpContext) {
  6. this.httpContext = httpContext;
  7. }
  8.  
  9. public bool ContainsPrefix(string prefix) {
  10. return prefix.Contains("C"); //指定如果參數名稱是C,則回傳true
  11. }
  12.  
  13. public ValueProviderResult GetValue(string key) {
  14. if (!ContainsPrefix(key)) { return null; } //參數如果是C,則進行底下轉換
  15. string _xml = httpContext.Request[key];
  16. SendXmlModel xml;
  17. try {
  18. xml = SerializeTool.XmlDeserialize<SendXmlModel>(_xml);
  19. }
  20. catch { xml = null; }
  21. return new ValueProviderResult(xml, _xml, System.Globalization.CultureInfo.CurrentCulture);
  22. }
  23. }

將兩個整合:
  1. public class MyValueProviderFactory : ValueProviderFactory
  2. {
  3. public override IValueProvider GetValueProvider(ControllerContext controllerContext) {
  4. return new StringToXmlValueProvider(controllerContext.HttpContext);
  5. }
  6.  
  7. public class StringToXmlValueProvider : IValueProvider
  8. {
  9. private HttpContextBase httpContext;
  10.  
  11. public StringToXmlValueProvider(HttpContextBase httpContext) {
  12. this.httpContext = httpContext;
  13. }
  14.  
  15. public bool ContainsPrefix(string prefix) {
  16. return prefix.Contains("C"); //指定如果參數名稱是C,則回傳true
  17. }
  18.  
  19. public ValueProviderResult GetValue(string key) {
  20. if (!ContainsPrefix(key)) { return null; } //參數如果是C,則進行底下轉換
  21. string _xml = httpContext.Request[key];
  22. SendXmlModel xml;
  23. try {
  24. xml = SerializeTool.XmlDeserialize<SendXmlModel>(_xml); //這裡使用一個泛型的XML序列化與反序列化工具,程式碼最後會補上
  25. }
  26. catch { xml = null; }
  27. return new ValueProviderResult(xml, _xml, System.Globalization.CultureInfo.CurrentCulture);
  28. }
  29. }
  30. }

最後將MyValueProviderFactory註冊到Global.asax的Application_Start()內,使用插入的方式,讓自訂的ValueProviderFactory可以優先被搜尋到:
  1. public class MvcApplication : System.Web.HttpApplication
  2. {
  3. protected void Application_Start()
  4. {
  5. AreaRegistration.RegisterAllAreas();
  6. FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
  7. RouteConfig.RegisterRoutes(RouteTable.Routes);
  8. BundleConfig.RegisterBundles(BundleTable.Bundles);
  9.  
  10. ValueProviderFactories.Factories.Insert(0, new MyValueProviderFactory());
  11. }
  12. }

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


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


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

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

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

完成後的完整程式:
  1. public class MyBinder : DefaultModelBinder
  2. {
  3. public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext) {
  4. if (bindingContext.ModelType == typeof(SendModel)) {
  5. //取得參數
  6. var request = controllerContext.HttpContext.Request;
  7. var reqA = request["A"];
  8. var reqB = request["B"];
  9. var _reqC = request["C"];
  10. SendXmlModel reqC;
  11. try {
  12. reqC = SerializeTool.XmlDeserialize<SendXmlModel>(_reqC);
  13. }
  14. catch {
  15. reqC = null;
  16. }
  17.  
  18. //建立新的ModelBindingContext
  19. ModelBindingContext NewBindingContext = new ModelBindingContext() {
  20. ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(
  21. () =&gt; new SendModel() {
  22. A = reqA,
  23. B = reqB,
  24. C = reqC
  25. },
  26. typeof(SendModel)),
  27. ModelState = bindingContext.ModelState,
  28. ValueProvider = bindingContext.ValueProvider
  29. };
  30.  
  31. //加入自訂的模型驗證與ModelState錯誤訊息
  32. if (reqC != null) {
  33. if (reqA != reqC.A) {
  34. NewBindingContext.ModelState.AddModelError("A", "參數A必須與XML內的A相符");
  35. }
  36. if (reqB != reqC.B) {
  37. NewBindingContext.ModelState.AddModelError("B", "參數B必須與XML內的B相符");
  38. }
  39. }
  40.  
  41. //回傳BindModel
  42. return base.BindModel(controllerContext, NewBindingContext);
  43. }
  44. else {
  45. return base.BindModel(controllerContext, bindingContext);
  46. }
  47. }
  48. }

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

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


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


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


底下是好用的泛型將XML序列化或反序列化工具:
  1. using System.Xml;
  2. using System.Xml.Serialization;
  3. public class SerializeTool
  4. {
  5. /// <summary>
  6. /// 以UTF-8編碼將XML物件序列化成字串
  7. /// </summary>
  8. /// <typeparam name="T"></typeparam>
  9. /// <param name="value"></param>
  10. /// <returns></returns>
  11. public static string SerializeXml<T>(T value) {
  12. if (value == null) { return null; }
  13.  
  14. XmlSerializer ser = new XmlSerializer(typeof(T));
  15. XmlWriterSettings settings = new XmlWriterSettings();
  16. settings.OmitXmlDeclaration = true;
  17. settings.NamespaceHandling = NamespaceHandling.Default;
  18. settings.Encoding = Encoding.UTF8;
  19.  
  20. XmlSerializerNamespaces nspace = new XmlSerializerNamespaces();
  21. nspace.Add("", "");
  22. StringBuilder sb = new StringBuilder();
  23. using (XmlWriter xmlWriter = XmlWriter.Create(sb, settings)) {
  24. ser.Serialize(xmlWriter, value, nspace);
  25. }
  26. return sb.ToString();
  27. }
  28.  
  29. /// <summary>
  30. /// 將XML字串反序列化成指定型別物件
  31. /// </summary>
  32. /// <typeparam name="T"></typeparam>
  33. /// <param name="xml"></param>
  34. /// <returns></returns>
  35. public static T DeserializeXml<T>(string xml) {
  36.  
  37. if (string.IsNullOrEmpty(xml)) {
  38. return default(T);
  39. }
  40.  
  41. XmlSerializer ser = new XmlSerializer(typeof(T));
  42.  
  43. XmlReaderSettings settings = new XmlReaderSettings();
  44. // No settings need modifying here
  45.  
  46. using (StringReader textReader = new StringReader(xml)) {
  47. using (XmlReader xmlReader = XmlReader.Create(textReader, settings)) {
  48. return (T)ser.Deserialize(xmlReader);
  49. }
  50. }
  51. }
  52. }


參考資料:
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


沒有留言:

張貼留言