【ASP.NET Core】MVC 控制器的模型綁定(宏觀篇)

歡迎來到老周的水文演播中心。

咱們都知道,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;
        }

嗯,真相大白了。

今天就水到這裡,下一篇咱們再聊聊模型綁定的微觀層面,尤其是怎麼去自定義。