試做ASP.NET MVC CRUD沒多久就碰到個小釘子,原因是我在Model中將金額欄位定義成decimal型別(古有明訓: 算錢用浮點,遲早被人扁) ,ASP.NET MVC在Post回Controller時,會自動將傳回欄位一一對應到Method的輸入參數上,遇到decimal時出了點狀況。

用一個簡單的範例來重現問題。Index.cshtml內容如下,按下Submit鈕時會呼叫HomeController的Test Method出來處理。

@using (Html.BeginForm("Test", "Home")) { 
    <div>
        <span>Amount:</span>
        @Html.TextBox("amount")
    </div>
    <input type="submit" value="Submit" />
}

而HomeController.cs中宣告了Test(decimal amount)承接前端傳回的amount參數。

using System.Web.Mvc;
 
namespace MvcApplication1.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            ViewBag.Message = "Welcome to ASP.NET MVC!";
            return View();
        }
        public ActionResult Test(decimal amount)
        {
            return View(amount);
 
        }
    }
}

輸入1,234.56,按下Submit鈕,爆出以下錯誤。

The parameters dictionary contains a null entry for parameter 'amount' of non-nullable type 'System.Decimal' for method 'System.Web.Mvc.ActionResult Test(System.Decimal)' in 'MvcApplication1.Controllers.HomeController'. An optional parameter must be a reference type, a nullable type, or be declared as an optional parameter.
Parameter name: parameters

Phil Haack有篇Model Binding Decimal Values說明了這算ASP.NET MVC3的一個Bug(有趣的是,Phil所遇到的問題來自文化差異,巴西慣用"1.234,56"格式的數字標示,跟我們常用的”.”與”,",意義剛好相反),目前的解決方法是為decimal型別額外註冊ModelBinder,自訂string轉成decimal的邏輯。

我做了一點點改寫,把string –> object的部分抽離出來,變成建構式的傳入參數,定義了FlexModelBinder(等一下會示範要如此設計的好處),如此,傳入不同的Func<string, object>就可以實現各種型別的ModelBinder囉!

using System;
using System.Web.Mvc;
using System.Web.Routing;
using System.Globalization;
 
namespace MvcApplication1
{
    public class MvcApplication : System.Web.HttpApplication
    {
 
        //...省略...
 
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
 
            RegisterGlobalFilters(GlobalFilters.Filters);
            RegisterRoutes(RouteTable.Routes);
 
            //加上String->Decimal的轉換函數
            ModelBinders.Binders.Add(
                typeof(decimal),
                new FlexModelBinder(
                    s =>
                    Convert.ToDecimal(s, CultureInfo.CurrentCulture)
                ));
        }
    }
    //將轉換核心抽出來變成Func<string, object>當成初始化參數
    //傳入不同轉換函數,就可以變成不同型別的ModelBinder
    public class FlexModelBinder : IModelBinder
    {
        Func<string, object> _convFn = null;
        public FlexModelBinder(Func<string, object> convFunc)
        {
            _convFn = convFunc;
        }
 
        public object BindModel(ControllerContext controllerContext,
            ModelBindingContext bindingContext)
        {
            ValueProviderResult valueResult = bindingContext.ValueProvider
                .GetValue(bindingContext.ModelName);
            ModelState modelState = new ModelState { Value = valueResult };
            object actualValue = null;
            try
            {
                actualValue = _convFn(valueResult.AttemptedValue);
            }
            catch (FormatException e)
            {
                modelState.Errors.Add(e);
            }
 
            bindingContext.ModelState.Add(bindingContext.ModelName, modelState);
            return actualValue;
        }
    }
}

如此,即便輸入1,234.56也能正確傳到Controller端囉!

約略了解ASP.NET MVC ModelBinder原理後,我們可以玩一些更有趣的應用。例如: 將Javascript物件陣列轉成JSON字串Post回Controller,Controller Method直接用List<SomeObject>作為參數型別接回料。

舉個實例,假設我們在C#裡定義一個資料類別ScoreRecord:

    public class ScoreRecord
    {
        public string UserId { get; set; }
        public int Score { get; set; }
    }

在HomeController裡宣告Index(List<ScoreRecord> scores),大大方方地直接將HTML Form傳回的scores欄位視為List<ScoreRecord>讀入,再將List內容轉成字串透過ViewBag.Result抛回前端。

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
 
namespace MvcLab.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            ViewBag.Result = "Waiting for input!";
            return View();
        }
 
        [HttpPost]
        public ActionResult Index(List<ScoreRecord> scores)
        {
            ViewBag.Result = string.Join("\n",
                scores.Select(
                    o =>
                    string.Format("<li>{0} : {1}</li>", 
                        o.UserId, o.Score)).ToArray()
            );
            return View();
        }
 
        public ActionResult About()
        {
            return View();
        }
    }
}

至於Index.cshtml,宣告一個<textarea name="scores">用來傳資料,寫一小段Javascript,建立Javascript版的物件陣列,一樣使用UserId及Score當屬性名稱,轉成JSON字串後存入<textarea name=”scores”>,按Submit時傳回後端。

@using (Html.BeginForm())
{ 
    <textarea name="scores" id="scores" style="width: 200px; height: 80px;">
    </textarea>
    <input type="submit" value="Submit" />
    <script type="text/javascript">
        $(function () {
            var scores = [
                { UserId: "Jeffrey", Score: 9999 },
                { UserId: "Darkthread", Score: 32767 }
            ];
            $("#scores").val(JSON.stringify(scores));
        });
    </script>
    <ul>
        @Html.Raw(ViewBag.Result)
    </ul>
}

再施一點點魔法,就可實現"HTML Form傳回JSON字串,Controller直接收取.NET物件參數"的美妙結果!

魔法是什麼? 其實,我只是在Global.asax.cs原本註冊decimal轉換函數的地方,為List<ScoreRecord>也註冊一段使用JavaScriptSerializer的轉換JSON字串為物件的邏輯罷了。因為有FlexModelBinder,這裡只需要寫一行Code就搞定了。

    ModelBinders.Binders.Add(
        typeof(List<ScoreRecord>),
        new FlexModelBinder(
            s =>
            (new JavaScriptSerializer()).Deserialize<List<ScoreRecord>>(s)
        ));

為ASP.NET MVC架構的可擴充性按一個【讚】! (呵! 我好像每天都在為.NET按讚,不過,就真的很讚咩!)


Comments

# by 心爾

黑大真是厲害,這系列豬走路文章算是開了眼,不過小弟還是不知道如何有效率與有系統的學 asp.net mvc 該如何著手,不知會否出入門系列文章與實際應用面的文章,若是無理詢問,不理即可。

# by Jeffrey

to 心爾,我目前已開始嘗試將ASP.NET MVC小規模應用在工作專案上,會陸續寫一些開發經驗分享,但會以自己開發要克服的問題為主,不太能當成系統化學習的入門教材。若要系統化學習,保哥有一本中文書,另外http://www.asp.net/mvc/videos 有不少教學錄影(多是MVC2時代錄的,但MVC3基本原理沒有差太多),雖然是英文,但蠻淺顯的,看畫面操作就可以懂,也值得看看。

Post a comment