ASP.NET MVC - MVC和Web API之Model Binder的陷阱
其實也不能說陷阱啦,只能說小弟自己太笨,今天又踩進去一次 ( 記得以前也踩進過一次 ),所以決定在這邊紀錄一下,讓自己有個印象深刻的記憶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。
到後面我們利用中斷偵錯,我們會發現,沒錯!,第一個參數id,變成3了!,但實際上,我們預期的卻是1阿!
其實這個沒有甚麼好奇怪,因為ASP.NET MVC的預設就是這樣,會依據Model對應到的屬性名稱優先,不過,通常也不會有人會寫這樣的程式碼,因為id通常都是不能改的,所以通常會寫成第二個範例的樣子。
第二個
接下來第二個範例,我們把id拿掉了。
的確,這樣第一個id參數就會如預期的變成1了。
但是好景不長XDD,Customer裡面的Id也變成1了。
這樣子的狀況,某方面來講也不算是問題的,因為通常網址後面帶的id,也和我們準備要改的資料id是同樣的 ( 會在BeginForm裡面的RouterValue值換成當前的id ),其次為了安全性,我們也通常都會再加上Exclue,來過濾id,例如下面程式碼,所以幾乎不會有問題。
public ActionResult Index2( int id, [Bind(Exclude = "Id")]Customer customer) { customer.Id = id; return View(); }
第三個就比較容易犯錯…
第三個
第三個範例,是笨笨小弟我今天踩到的地雷,這是一個Silverlight裡面的一段程式片段,程式的畫面如下。
當然這裡不會全部解釋,主要的地方是更新的區塊,小弟我利用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。
結果看一下中斷點,變成0!!。
為什麼會這樣呢?ASP.NET MVC Web API裡面,第一個參數,應該就是URI後面的參數阿,我們的URI為api/Customer/1,那這裡的id應該為1阿,為什麼會為0呢?再看一下Customer變數。
沒錯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檔案裡面 )
將下面的"{controller}/{action}/{id}", 的{id}改成新的名字,例如routerValueId。( 如果是Web Api,也要去改Web Api的地方,Web Api在這行 routeTemplate: "api/{controller}/{id}" ),下圖是ASP.NET MVC的地方,另外,別忘了後面的id也要改到,這樣如果沒有填入id值,就會自動幫我們設定預設值。
改成如下程式碼 ( 以下為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的一些小細節。