[ASP Net MVC] 自訂Model Binder綁定List<T>內抽象類別的所有實體

自訂Model Binder綁定List<T>內抽象類別的所有實體

前言

 

客戶想要開發一套問卷調查系統。筆者第一個想法就是將所有問題進行分類,讓各型態問題類別都繼承自Question類別,所以我們就可以把所有問題存放於List<Question>中方便存取使用。然後再針對各型態問題類別製作相對應的Editor Template編輯畫面,如此只要透過EditFor()就可將一連串問題依照其特性顯示,預期結果應如以下畫面呈現。

 

image

 

實作

 

簡單描述需完成的工作。首先需要先把使用到的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,所以資料也都遺失了。

 

image

image

 

仔細的思考一下整個過程,畫面會正常顯示是因為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的實際型態為何。

image

 

再來建立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後的結果,我們就可以發現資料都如原型態自動產生與綁定資料了。

 

image

image

不僅型態正確且資料也正確Binding

文字問題答案

image

日期問題答案

image

單選問題答案

image

 

 

參考資料

 

http://msdn.microsoft.com/en-us/magazine/hh781022.aspx

http://stackoverflow.com/questions/5460081/asp-net-mvc-3-defaultmodelbinder-with-inheritance-polymorphism

http://lostechies.com/jimmybogard/2009/03/18/a-better-model-binder/

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


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !