ASP.NET MVC - MVC和Web API之Model Binder的陷阱

26 May 2012 — Written by Sky Chang
#ASP.NET MVC#Silveright

其實也不能說陷阱啦,只能說小弟自己太笨,今天又踩進去一次 ( 記得以前也踩進過一次 ),所以決定在這邊紀錄一下,讓自己有個印象深刻的記憶QQ。

先說明一下,這個絕對不是ASP.NET MVC的Bug,只能說這種細節,一不小心就會撞到XDD

Model

首先我們先看看Model,這個Model簡單到爆炸,反正就是有Id、Name、Phone,然後利用DisplayName來定義未來要在View那邊顯示的資訊。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel;

namespace MvcOrz.Models
{
    public class Customer
    {
        [DisplayName("ID")]
        public int Id { get; set; }

        [DisplayName("Name")]
        public string Name { get; set; }

        [DisplayName("Phone")]
        public string Phone { get; set; }
    }
}

就這麼簡單,接下來我們看一下Controller。

Controller

這個Controller其實也很簡單,就是兩個Action,第一個Action Index很簡單,就只是顯示出畫面,第二個Action Index2則會傳入兩個參數,第一個是id,也就是會去收網址後面的值,例如/Home/Index2/123,就會把123帶進去到id裡面;而第二個則會把View那頁Post或是其他方式進來的資料,自動轉成Customer Model。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using MvcOrz.Models;

namespace MvcOrz.Controllers
{
    public class HomeController : Controller
    {
        //
        // GET: /Home/

        public ActionResult Index()
        {
            return View();
        }

        public ActionResult Index2(int id,Customer customer)
        {
            customer.Id = id;
            return View();
        }

    }
}

基本上這還滿簡單的,接下來我們看一下View。

VIew

View也簡單到一個不行,幾乎都是利用HtmlHelper來利用Model產生需要的東西,但有個地方要注意到的,就是在Html.BeginForm的地方,我們會多加上一個RouterValue,並設定id = 1;

@model MvcOrz.Models.Customer

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Index</title>
</head>
<body>
    <div>
    @using (Html.BeginForm("index2","Home", new { id = "1" }))
    {
        <fieldset>
            <legend>文案</legend>
            <dl>
                <dt>@Html.LabelFor(model =>model.Id)</dt>
                <dd>@Html.EditorFor(model => model.Id)</dd>

                <dt>@Html.LabelFor(model => model.Name)</dt>
                <dd>@Html.EditorFor(model => model.Name)</dd>

                <dt>@Html.LabelFor(model => model.Phone)</dt>
                <dd>@Html.EditorFor(model => model.Phone)</dd>
            </dl>
        </fieldset>
        <input type="submit" value="Send" />
    }
    </div>
</body>
</html>

就這樣!非常簡單,而且我相信各位大家應該也猜得出梗了。

第一個

我們把整個專案執行起來,並且在Name和Phone填入3。

image

到後面我們利用中斷偵錯,我們會發現,沒錯!,第一個參數id,變成3了!,但實際上,我們預期的卻是1阿!

image

其實這個沒有甚麼好奇怪,因為ASP.NET MVC的預設就是這樣,會依據Model對應到的屬性名稱優先,不過,通常也不會有人會寫這樣的程式碼,因為id通常都是不能改的,所以通常會寫成第二個範例的樣子。

第二個

接下來第二個範例,我們把id拿掉了。

image

的確,這樣第一個id參數就會如預期的變成1了。

image

但是好景不長XDD,Customer裡面的Id也變成1了。

image

這樣子的狀況,某方面來講也不算是問題的,因為通常網址後面帶的id,也和我們準備要改的資料id是同樣的 ( 會在BeginForm裡面的RouterValue值換成當前的id ),其次為了安全性,我們也通常都會再加上Exclue,來過濾id,例如下面程式碼,所以幾乎不會有問題。

public ActionResult Index2(
    int id, [Bind(Exclude = "Id")]Customer customer)
{
    customer.Id = id;
    return View();
}

第三個就比較容易犯錯…

第三個

第三個範例,是笨笨小弟我今天踩到的地雷,這是一個Silverlight裡面的一段程式片段,程式的畫面如下。

image

當然這裡不會全部解釋,主要的地方是更新的區塊,小弟我利用WebRequest來對Web Service進行Http的PUT命令,詳細的程式碼小弟就不解釋了,反正重點在於,我先用取得id這個TextBox的值( idTbx.Text ),因為要知道id才能進行Update,接下來,也從TextBox裡面取得要變更的Name和Phone,然後設定WebRequest的URI為Web Service再加上剛剛取得的id ( 例如URI為api/Customer/1 ),然後就進行更新,程式碼在下面,其實也不用看懂,因為重點不是程式碼XD。

    private void updateButton_Click(object sender, RoutedEventArgs e)
    {
        string id = idTbx.Text;

        Customer customer = new Customer();

        customer.Name = nameTbx.Text;
        customer.Phone = phoneTbx.Text;

        #region 使用WebRequest

        WebRequest webRequest = WebRequestCreator.ClientHttp.Create(new Uri(_url + "/" + id));
        webRequest.ContentType = "application/json";
        webRequest.Method = "PUT";
        webRequest.BeginGetRequestStream(requestAsyncCallback =>
        {
            Stream requestStream = webRequest.EndGetRequestStream(requestAsyncCallback);

            string json = JsonConvert.SerializeObject(customer, Formatting.Indented);
            byte[] buffer = System.Text.Encoding.Unicode.GetBytes(json);
            requestStream.Write(buffer, 0, buffer.Length);
            requestStream.Close();

            webRequest.BeginGetResponse(responseAsyncCallback =>
            {
                WebResponse webResponse = webRequest.EndGetResponse(responseAsyncCallback);
                using (StreamReader reader = new StreamReader(webResponse.GetResponseStream()))
                {
                    string result = reader.ReadToEnd();
                    this.Dispatcher.BeginInvoke(() =>
                    {
                        MessageBox.Show(result);
                    });
                }
            }, null);
        }, null);

        #endregion
    }

我們填入了這些值,希望針對id 1的Customer,將Name改為1,Phone也改為1。

image

結果看一下中斷點,變成0!!。

image

為什麼會這樣呢?ASP.NET MVC Web API裡面,第一個參數,應該就是URI後面的參數阿,我們的URI為api/Customer/1,那這裡的id應該為1阿,為什麼會為0呢?再看一下Customer變數。

image

沒錯Customer也為0,其實答案很簡單,那是因為在Silverlight裡面的程式碼段落,我們只有設定Customer.Name和Customer.Phone,而沒有設定Customer.Id,所以Customer.id就為0,如第一個範例一樣,預設上,ASP.NET MVC的模型繫節,會以Model為主,如果沒有對應到,才會以網址參數對應,但如果在Silverlight裡面補上Customer.Id = id的程式碼;就會讓Put的第一個參數id和Customer裡面的Id都有值了。

但其實到這邊,不管有無機率踩到。最好的方法,要嘛Model不要取名為id,要嘛把Router改一下。( 在Global.asax.cs檔案裡面 )

image

將下面的"{controller}/{action}/{id}", 的{id}改成新的名字,例如routerValueId。( 如果是Web Api,也要去改Web Api的地方,Web Api在這行 routeTemplate: "api/{controller}/{id}" ),下圖是ASP.NET MVC的地方,另外,別忘了後面的id也要改到,這樣如果沒有填入id值,就會自動幫我們設定預設值。

image

改成如下程式碼 ( 以下為MVC範例。 )

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    routes.MapRoute(
        "Default", // Route name
        "{controller}/{action}/{routerValueId}", // URL with parameters
        new { controller = "Home", action = "Index", routerValueId = UrlParameter.Optional } // Parameter defaults
    );

}

當然,別忘了所有Action的參數也全部都要改,例如Put的程式碼片段,但記得全部的地方都要改阿!

public void Put(int routerValueId, Customer customer)
{
    customer.Id = routerValueId;
    if (!_repository.Update(customer))
    {
        //如果找不到,就拋出HTTP的Response例外,內容是尋找不到,也就是404
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }
}

除非很不幸的,連改了名子都會和Model的屬性撞到;不然這樣幾乎就不會有任何的問題了。

後記

其實真正的重點是希望大家能了解Model Binder的一些特性,其實只要了解了,但不了解的話,也真的是欲哭無淚,找不到問題,所以也在這邊po出來,希望大家能注意一下,Model Binder的一些小細節。

Sky & Study4.TW