【ASP.NET Core】MVC 控制器的模型綁定(宏觀篇)
- 2022 年 3 月 18 日
- 筆記
- .NET, Asp.Net Core, 個人文章
歡迎來到老周的水文演播中心。
咱們都知道,MVC的控制器也可以用來實現 Web API 的(它們原本就是一個玩意兒),區別嘛也就是一個有 View 而另一個沒有 View(嚴格上講,還不能談區別,只能說功能範圍吧)。於是,在依賴注入的服務容器中,我們可以這樣添加功能:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); //無 View builder.Services.AddControllersWithViews(); //有 View
如果你的控制器有用到視圖的時候,就調用第二個的方法。它們的核心服務一樣。
—————————————————————————–
當客戶端歷盡千辛萬苦,跨越數不清的躍點,把請求提交到服務器後,MVC 運行時會分析請求的內容,從中還原出我們代碼所需要的對象,通常是 Action 方法的參數。
要把客戶端提交的數據填充到咱們所需要的對象中,得用到模型綁定。
我們先別管這概念抽象不抽象,舉個例子,假設某控制器是有視圖的,返回了一個頁面,頁面上有 form 元素(表單),可以讓用戶填寫個人信息,然後提交(POST)給服務器,完成報名。
<form asp-action="PostData" asp-controller="Main"> <div class="line"> <div class="lhd"> <label for="name">姓名:</label> </div> <div class="rctl"> <input type="text" name="name" id="name" /> </div> </div> <div class="line"> <div class="lhd"> <label for="age">年齡:</label> </div> <div class="rctl"> <input type="number" name="age" id="age" max="120" min="10" /> </div> </div> <div class="line"> <div class="lhd"> <label for="phone">手機號碼:</label> </div> <div class="rctl"> <input type="tel" name="phone" id="phone" /> </div> </div> <div class="line"> <div class="lhd"> <label for="desc">簡介:</label> </div> <div class="rctl"> <input name="description" id ="desc" /> </div> </div> <div class="line"> <button type="submit">提交</button> </div> </form>
假設表示」會員「信息的是個叫 Member 的類。
public class Member { public int ID { get; set; } public string? Name { get; set; } public int Age { get; set; } public string? Phone { get; set; } public string? Description { get; set; } }
你如果注意看的話,你會發現:上面類中屬性名與 HTML 頁上<form>元素裏面的字段名是對應的(不要在意大小寫,ID 屬性是自動生成的,所以不需要用戶填)。
控制器中的某個 action 可能是這樣的:
public IActionResult PostData(Member mb) { // 干點別的事…… // ID 隨機 mb.ID = rand.Next(); if(!ModelState.IsValid) { return Content("夥計,你提交的數據不對勁啊"); } return Ok(mb); }
當你在瀏覽器中打開頁面時,你看到的是這樣的。
你填入個人信息後,HTTP 請求是用 form-data 的內容類型提交的。
name=%E5%B0%8F%E6%9D%8E&age=34&phone=19958240311&description=%E5%A5%BD%E5%81%9A%E6%87%92%E5%90%83&__RequestVerificationToken=CfDJ8LWOO6CmpapIpbgTQWfkRfjsIo5GgqvJaC2rqhwFEpA_gf8yWZ31sgsqzZg2BDpCdcKcrZ9zXpCcRqYdfMWwXsNuWFi6b1Yq69YP2SOmtOYlTBDNPRyTwYLzidJNCF_tGrOO0mNyNU59ovmUA4UYnBk
原文如下:
age: 34
description: "好做懶吃"
id: 1500452404
name: "小李"
phone: "19958240311"
運行時通過對提交的 form-data 進行分析,讀出與 Member 類各屬性名稱對應的字段值,然後進行綁定,最終程序代碼能得到一個帶屬性值 Member 實例。嗯,這就好像反序列化一樣。
在 MVC 裏面有一堆叫 ModelBinder 的東東,能夠針對 HTTP 提交的請求,將值轉化為 .NET 類型。ASP.NET Core 已為咱們內置了許多常用的 ModelBinder,包羅萬象,應有盡有。所以,99.9625% 的情況下我們不需要自己編寫 Binder。
這些 Binder 位於 Microsoft.AspNetCore.Mvc.ModelBinding.Binders 命名空間下。
如果沒有特別指定,在模型綁定時會在 HTTP 請求中查找與類型屬性名稱相同的字段,比如上面舉例中的<input>元素,它們的 name 分別為」id「、”name”、」age「等。
當然,form 字段名可以帶前綴,例如上面那個 action 方法的定義。
public IActionResult PostData(Member mb);
也就是說,參數的名字叫」mb「,所以,在 <form> 裏面,可以這樣命名:
<form asp-action="PostData" asp-controller="Main"> <div class="line"> …… <div class="rctl"> <input type="text" name="mb.name" id="name" /> </div> </div> <div class="line"> …… <div class="rctl"> <input type="number" name="mb.age" id="age" max="120" min="10" /> </div> </div> <div class="line"> …… <div class="rctl"> <input type="tel" name="mb.phone" id="phone" /> </div> </div> <div class="line"> …… <div class="rctl"> <input name="mb.description" id ="desc" /> </div> </div> …… </form>
上面所舉例的 form-data 數據是來源於 HTTP 請求的正文(body),其實,模型綁定的值還有其他來源:
1、正文(body),就是上文所列的;
2、URL 查詢字符串,比如 //dong_gua.com/action?name=小冬瓜&age=27&phone=13762634599&description=呵呵呵;
3、Header,即HTTP標頭,比如在發送 HTTP 請求時,你可以在 Header 集合中加入 name: 小王, age: 25……;
4、路由參數,比如這樣:
[Route("[controller]/[action]/{kid}")] public IActionResult GetLoaders(int kid) { …… }
要傳點什麼給 kid 參數,就訪問
//dabaojian.cn/home/getloaders/3561
數值 3561 就傳遞給 kid 參數了。那如果路由參數和參數的名字不同,但我還想傳值給它怎麼辦?欲知答案,且聽下回分解。
————————————————————————————————————-
咱們現在討論控制器,是不考慮它有沒有 View 的,畢竟都是一個東西。於是,問題就來了——如果控制器類上應用了 ApiControllerAttribute 後會怎麼樣?用上這個特性和不用這個特性又有啥不一樣?
多說無益,用例子來說明吧。假設我定義了這麼個不長臉的控制器。
[Route("api/zzz")] public class HomeController : ControllerBase { [Route("send")] public IActionResult PostData(Person p) { if (p.ID == 0 || p.Name is null) return Content("WHF !"); // 未成功 string msg = $"姓名:{p.Name},編號:{p.ID}。\n提交成功"; return Content(msg); } }
Person 類定義:
public class Person { public int ID { get; set; } public string? Name { get; set; } public int Age { get; set; } public string? Phone { get; set; } }
雖然這個控制器類上設有用到 ApiControllerAttribute,但它是可以作為 Web API 來調用的,試試看。
發送消息:
POST /api/zzz/send HTTP/1.1
Accept: */*
Host: localhost:2022
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------556592807377348094609386
Content-Length: 489
----------------------------556592807377348094609386
Content-Disposition: form-data; name="id"
1001
----------------------------556592807377348094609386
Content-Disposition: form-data; name="name"
小張
----------------------------556592807377348094609386
Content-Disposition: form-data; name="age"
29
----------------------------556592807377348094609386
Content-Disposition: form-data; name="phone"
18044332515
----------------------------556592807377348094609386--
響應的消息:
HTTP/1.1 200 OK
Content-Length: 47
Content-Type: text/plain; charset=utf-8
Date: Fri, 18 Mar 2022 03:17:28 GMT
Server: Kestrel
姓名:小張,編號:1001。
提交成功
嗯,以 form-data 的格式提交是沒問題的,試試 JSON 格式(Content-Type: application/json)。
/* 發送消息 */
POST /api/zzz/send HTTP/1.1
Content-Type: application/json
Accept: */*
Host: localhost:2022
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 86
{
"id": 45,
"name": "小於",
"age": 72,
"phone": "19952558123"
}
/* 響應消息 */
HTTP/1.1 200 OK
Content-Length: 5
Content-Type: text/plain; charset=utf-8
Date: Fri, 18 Mar 2022 03:22:19 GMT
Server: Kestrel
WHF !
咦?沒提取到數據?
MVC 默認的模型綁定能找到 form 格式提交的,但 JSON 格式提交的,它沒找到在哪。那咱們就告訴它數據從哪裡來。
[Route("send")] public IActionResult PostData([FromBody] Person p) { …… }
然後,它就找到了。
POST /api/zzz/send HTTP/1.1
Content-Type: application/json
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:2022
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 86
{
"id": 45,
"name": "小於",
"age": 72,
"phone": "19952558123"
}
HTTP/1.1 200 OK
Content-Length: 45
Content-Type: text/plain; charset=utf-8
Date: Fri, 18 Mar 2022 03:28:54 GMT
Server: Kestrel
姓名:小於,編號:45。
提交成功
要是你的控制器是專門作為 API 調用的,那麼,你應該在控制器類的定義上應用特性 ApiControllerAttribute。
[Route("api/zzz"), ApiController] public class HomeController : ControllerBase { [Route("send")] public IActionResult PostData(Person p) { …… } }
這時候,參數 p 不用加 FromBody 特性,你用 JSON 格式提交,它會完美處理。一旦控制器成為 API 專用控制器後,客戶端提交的數據它就交給 InputFormatter 去處理轉化了。
前面老周寫過自定義 OutputFormatter 的水文(就是有關返回數據格式的那兩篇)。你想啊,有輸出格式,肯定也有輸入格式。同理地,默認是支持 JSON 格式,XML 得你手動開啟,方法有老周以前寫的水文中的方法一樣,畢竟輸入輸出格式化是成對出現的。
A、針對 Web API ,一般使用 InputFormatter 來讀取數據,完成模型綁定。前提是控制器類上要有 ApiControllerAttribute;
B、對於沒有 ApiControllerAttribute 的控制器,就當作一般化處理,默認接收 form-data,也可以通過各種特性配置讓它支持其他數據內容。
在控制器類上應用 ApiControllerAttribute 就是讓運行時加入一些專門針對 API 調用的服務組件,讓你的代碼寫起來更方便。比如直接就能接收 JSON 數據,返回 JSON 結果。
不過,控制器類若是應用了 ApiControllerAttribute 後,就會有限制條件(特殊要求):
在 Program.cs 文件中,你既可以用 app.MapControllers() 方法來添加終結點處理的中間件,也可以用 app.MapControllerRoute() 方法來註冊全局路由規則;但是,API 專用的控制器上必須加 Route 特性來指定路由規則,不能共用全局路由規則。不然運行後被報錯。
.NET Core 運行時是怎麼知道的?先看看 ApiControllerAttribute 類的定義。
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class, AllowMultiple = false, Inherited = true)] public class ApiControllerAttribute : ControllerAttribute, IApiBehaviorMetadata, IFilterMetadata { public ApiControllerAttribute() { // } }
別的不用管,關鍵點是它實現了一個詭異的接口:IApiBehaviorMetadata,這個接口派生自 IFilterMetadata 接口。對這個接口不要抱什麼好奇心,裏面啥也沒有。它只不過是用來做」標記「的,標記你這個控制器是不是 Web API 特供。在 ApiBehaviorApplicationModelProvider 類中會進行驗證。
private static bool IsApiController(ControllerModel controller) { if (controller.Attributes.OfType<IApiBehaviorMetadata>().Any()) { return true; } var controllerAssembly = controller.ControllerType.Assembly; var assemblyAttributes = controllerAssembly.GetCustomAttributes(); return assemblyAttributes.OfType<IApiBehaviorMetadata>().Any(); }
正好,ApiControllerAttribute 類就是實現這個接口的。如果找到,表明這個控制器類是 API 特供,於是,下一步就要找控制器類和方法上有沒有應用 Route 特性。
if (!IsAttributeRouted(actionModel.Controller.Selectors) && !IsAttributeRouted(actionModel.Selectors)) { // Require attribute routing with controllers annotated with ApiControllerAttribute var message = Resources.FormatApiController_AttributeRouteRequired( actionModel.DisplayName, nameof(ApiControllerAttribute)); throw new InvalidOperationException(message); } static bool IsAttributeRouted(IList<SelectorModel> selectorModel) { for (var i = 0; i < selectorModel.Count; i++) { if (selectorModel[i].AttributeRouteModel != null) { return true; } } return false; }
嗯,真相大白了。
今天就水到這裡,下一篇咱們再聊聊模型綁定的微觀層面,尤其是怎麼去自定義。