歡迎來到老周的水文演播中心。
我們們都知道,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 查詢字串,比如 http://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 引數,就訪問
https://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; }
嗯,真相大白了。
今天就水到這裡,下一篇我們們再聊聊模型繫結的微觀層面,尤其是怎麼去自定義。