自訂Model Binder綁定List<T>內抽象類別的所有實體
前言
客戶想要開發一套問卷調查系統。筆者第一個想法就是將所有問題進行分類,讓各型態問題類別都繼承自Question類別,所以我們就可以把所有問題存放於List<Question>中方便存取使用。然後再針對各型態問題類別製作相對應的Editor Template編輯畫面,如此只要透過EditFor()就可將一連串問題依照其特性顯示,預期結果應如以下畫面呈現。
實作
簡單描述需完成的工作。首先需要先把使用到的ViewModel定義出來,建立Question類別,然後依照各型態問題設計繼承自Question的子類別,並依各子類別建立出相對應的EditorTemplate作為編輯畫面使用。最後就是設計Controller及View就完成了,各部分程式如下。
ViewModel
{
public string QuestionContent { get; set; }
}
// 文字類型 問題
public class TextQuestion : Question
{
public string TxtAnswer { get; set; }
}
// 日期類型 問題
public class DateQuestion : Question
{
[DataType(System.ComponentModel.DataAnnotations.DataType.Date, ErrorMessage = "{0} 欄位未填或格式錯誤")]
[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
public DateTime DateAnswer { get; set; }
}
// 單選類型 問題
public class SingleSelectQuestion : Question
{
public string SelectedAnswer { get; set; }
public IEnumerable<SelectListItem> Options {get; set;}
}
EditorTemplate – TextQuestion.cshtml
<div class="form-horizontal">
<div class="form-group">
<div class="col-md-2">
@Html.DisplayFor(model => model.QuestionContent)
</div>
<div class="col-md-10">
@Html.EditorFor(model => model.TxtAnswer, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(model => model.TxtAnswer, "", new { @class = "text-danger" })
</div>
</div>
</div>
EditorTemplate – DateQuestion.cshtml
<div class="form-horizontal">
<div class="form-group">
<div class="col-md-2">
@Html.DisplayFor(model => model.QuestionContent)
</div>
<div class="col-md-10">
@Html.EditorFor(model => model.DateAnswer, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(model => model.DateAnswer, "", new { @class = "text-danger" })
</div>
</div>
</div>
EditorTemplate – SingleSelectQuestion.cshtml
<div class="form-horizontal">
<div class="form-group">
<div class="col-md-2">
@Html.DisplayFor(model => model.QuestionContent)
</div>
<div class="col-md-10">
<td>@Html.DropDownListFor(model => Model.SelectedAnswer, Model.Options, htmlAttributes: new { @class = "form-control"})</td>
@Html.ValidationMessageFor(model => model.SelectedAnswer, "", new { @class = "text-danger" })
</div>
</div>
</div>
Controller
{
public ActionResult Create()
{
// Prepare view model
SuveryCreateViewModel viewModel = new SuveryCreateViewModel();
viewModel.Title = "The survey of menu satisfaction";
viewModel.Questions = new List<Question>();
// Txt type question
var question1 = new TextQuestion()
{ QuestionContent = "What is your name?", TxtAnswer = "" };
// Date type question
var question2 = new DateQuestion()
{ QuestionContent = "What is your birthday?", DateAnswer = DateTime.Now };
// Single selection type question
var question3= new SingleSelectQuestion()
{
QuestionContent = "What is your gender?",
SelectedAnswer = "0",
Options = new List<SelectListItem>()
{
new SelectListItem { Value = "0", Text = "Girl" },
new SelectListItem { Value = "1", Text = "Boy" }
}
};
// add questions in suvery list
viewModel.Questions.Add(question1);
viewModel.Questions.Add(question2);
viewModel.Questions.Add(question3);
return View(viewModel);
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create(SuveryCreateViewModel viewModel)
{
if (ModelState.IsValid)
{
// save in db
return View("Index");
}
return View(viewModel);
}
}
View – Create.cshtml
<h2>@Html.DisplayFor(model => model.Title)</h2>
@using (Html.BeginForm("Create", "Suvery", FormMethod.Post))
{
@Html.AntiForgeryToken()
@Html.EditorFor(model => model.Questions)
<input type="submit" value="Submit" class="btn btn-default" />
}
依照上述方法實作後,即可不費吹灰之力的在Controller中自由地設計問卷,且透過各EditorTemplate產出各類型問題的編輯畫面,所以我們可以看到Create的View是相當的精簡。
問題發生
當畫面如同我們所想的方式呈現後,想當然爾應該可輕易的將資料透過Model Binding機制綁定至ViewModel供後續使用了吧。但結果卻不如想像中順利,發現ViewModel的問題清單(List)中資料型態都是Question,並非預期的TextQusetion、DateQusetion及SingleSelectQuetion,所以資料也都遺失了。
仔細的思考一下整個過程,畫面會正常顯示是因為ViewModel中List<Question>包含的每個Question都還保有自己的型態(TextQuestion, DateQuestoin, SingleSelectQuestion),所以才可以透過本身的型態來對應至各EditorTemplate產出編輯畫面;反觀Model Binding的過程,只提供ViewModel中清單內資料型態為Question,且並無任何資訊來幫助DefaultModelBinder來判別清單中實際的Question子類別,所以會有這種結果似乎也是合情合理。
解決方案
既然問題發生在ModelBinder身上,所以須實作一個ModelBinder來符合我們所希望的Binding機制。首先,如同先前所提到的問題,還是必須提供ModelBinder需產出及綁定的Question實際型態(類別)為何,所以需要在EditorTemplate中加入一個hidden的資訊,目的是明確記載此類別的全名(Namespace+ClassName),好讓自行定義的ModelBinder有跡可循,產生並Binding到正確的物件上。
分別在TextQuestion, DateQuestion, SingleSelectQuestion的EditorTemplate上加入以下hidden資訊。
@Html.Hidden("__type__", Model.GetType().FullName)
其中__type__只是個識別碼而已,只要與自行定義ModelBinder中取值得Key相符即可。實際產出的Html如下圖所示,可明確標示各Question的實際型態為何。
再來建立AbstractModelBinder (繼承自DefaultModelBinder),主要的工作是在進行ModelBinding的時候,找尋是否存在__type__的資訊,若有將會以該值(CustomerModelBinderApp.ViewModels.TextQuestion)查詢所有參考的Assemblies,查看是否有對應類別存在,若有就以此類別作為Binding的Model。
{
private readonly string _typeNameKey;
public AbstractModelBinder()
{
_typeNameKey = "__type__";
}
public override object BindModel
(
ControllerContext controllerContext,
ModelBindingContext bindingContext
)
{
// get full type name key
// ex. Question[0].__type__
var fullTypeNameKey = bindingContext.ModelName + "." + _typeNameKey;
var providerResult =
bindingContext.ValueProvider.GetValue(fullTypeNameKey);
if (providerResult != null)
{
// get model type name
// ex. CustomerModelBinderApp.ViewModels.TextQuestion
var modelTypeName = providerResult.AttemptedValue;
var modelType =
System.Web.Compilation.BuildManager.GetReferencedAssemblies()
.Cast<System.Reflection.Assembly>()
.SelectMany(x => x.GetExportedTypes())
.Where(type => !type.IsInterface)
.Where(type => !type.IsAbstract)
.Where(bindingContext.ModelType.IsAssignableFrom)
.FirstOrDefault(type =>
string.Equals(type.FullName, modelTypeName,
StringComparison.OrdinalIgnoreCase));
if (modelType != null)
{
var metaData =
ModelMetadataProviders.Current
.GetMetadataForType(null, modelType);
bindingContext.ModelMetadata = metaData;
}
}
// Fall back to default model binding behavior
return base.BindModel(controllerContext, bindingContext);
}
}
接著就在需套用AbstractModelBinder進行Model Binding的類別上註記即可,如下所示。
public class Question
{
public string QuestionContent { get; set; }
}
或者在Application_Start()中註冊也行
{
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
// register the class
ModelBinders.Binders.Add(typeof(Question), new AbstractModelBinder());
}
}
甚至定義為預設的ModelBinder也可
{
protected void Application_Start()
{
// Set default binder
ModelBinders.Binders.DefaultBinder = new AbstractModelBinder();
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
}
如此在Binding Question時就會使用AbstractModelBinder來處理綁定作業,所以在查看一下Sumit後的結果,我們就可以發現資料都如原型態自動產生與綁定資料了。
不僅型態正確且資料也正確Binding
文字問題答案
日期問題答案
單選問題答案
參考資料
http://msdn.microsoft.com/en-us/magazine/hh781022.aspx
http://lostechies.com/jimmybogard/2009/03/18/a-better-model-binder/
http://www.codeproject.com/Articles/605595/ASP-NET-MVC-Custom-Model-Binder
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !