【ASP.NET Core】MVC 控制器的模型繫結(巨集觀篇)

東邪獨孤發表於2022-03-18

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

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

嗯,真相大白了。

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

相關文章