【ASP.NET Core】MVC模型繫結:非規範正文內容的處理

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

本篇老周就和老夥伴們分享一下,對於客戶端提交的不規範 Body 如何做模型繫結。不必多說,這種情況下,只能自定義 ModelBinder 了。而且最佳方案是不要註冊為全域性 Binder——畢竟這種特殊情況是針對極少數情形的,我們們沒必要去干擾標準格式的正常執行(情況複雜,特殊 binder 註冊為全域性很危險,弄不好容易出“八阿哥”)。

你可能會說,用標準的 JSON 或 XML 不香嗎,為什麼要做不規範的資料?你可別說,實際開發中,就是有不少思維奇葩的客戶,提出各種連外星人都感到神經病的需要。所以,倒了九輩子大黴遇到這樣的需求,就不得不根據實際資料格式來自定義繫結了。

OK,老周深刻意識到,講再多的理論各位都不感興趣的,人們更習慣於聽故事,不然的話為什麼正史所記錄的事情知者甚少,而很多虛構的故事會在民間大量傳播。比如“狸貓換太子”什麼的,都是故事,一隻死貓能把皇子換掉,你以為皇宮是菜市場呢。

這裡有這樣的故事:某API,引數是 Room 物件。Room 型別有三個屬性——Length、Width、Height,它們的型別都是浮點(float)。然後這個 API 呼叫時怎麼 POST 的呢?三行文字,每一行表示一個屬性,格式是 name: value,並且不區分大小寫。也就是說,呼叫 API 時,正文部分這樣傳:

length:  3.5
width:   0.7
height:  12.5

也可能這樣:

LENGTH: 5.5
WIDTH:   10.0
HEIGHT:   2.7

還可能是這樣:

Length: 50.2
Width:   11.55
Height:  14.3

所以,待會兒我們們實現屬性名稱查詢時,統一轉為小寫,這樣好比較。

 

下面,開始幹活。

1、定義模型

public class Room
{
    public float Length { get; set; }
    public float Width { get; set; }
    public float Height { get; set; }
}

2、定義 API 控制器

[ApiController, Route("api/test")]
public class WhatController : ControllerBase
{
    [HttpPost, Route("abc")]
    public float TestDone(Room rom)
    {
        if (rom.Height <= 0f || rom.Width <= 0f || rom.Length <= 0f)
            return -1f;
        return rom.Height * rom.Width * rom.Length;
    }
}

 

3、自定義 ValueProvider

由於在本例中,body 的內容既不是標準的 XML 和 JSON,也不是 Form-data 結構,所以 .NET Core 預設註冊的幾個 ValueProvider 是不起作用的。我們們得自己實現一個。

    public class CustValueProvider : IValueProvider
    {
        private readonly IDictionary<string, string> innerDic;

        public CustValueProvider(HttpContext ctx)
        {
            innerDic = new Dictionary<string, string>();
            var req = ctx.Request;
            HttpRequestStreamReader reader = new(req.Body, Encoding.UTF8);
string? line;
            // 一行一行地讀
            for (line = reader.ReadLineAsync().GetAwaiter().GetResult(); line != null; line = reader.ReadLineAsync().GetAwaiter().GetResult())
            {
                string[] parts = line!.Split(":");
                // 左邊是name,右邊是value
                string name = parts[0].Trim().ToLower();
                string value = parts[1].Trim();
                innerDic[name] = value;
            }
            reader.Dispose();
        }

        public bool ContainsPrefix(string prefix)
        {
            return true;
        }

        public ValueProviderResult GetValue(string key)
        {
            if (!innerDic.ContainsKey(key))
                return ValueProviderResult.None;
            return new ValueProviderResult(innerDic[key]);
        }
    }

通過建構函式的引數獲得 HttpContext 物件,這樣就可以訪問到 Body,它是個流物件。

然後使用一個叫 HttpRequestStreamReader 的類,它位於 Microsoft.AspNetCore.WebUtilities 名稱空間。這個類很好用,可以讀文字檔案那樣讀 Body。這裡我們們一行一行地讀就行,注意要呼叫 ReadLineAsync 方法,不能呼叫同步方法(除非你配置 Kestrel 充許同步呼叫)。非同步呼叫可以確保響應能力,所以還是推薦非同步呼叫。不過,此處是從建構函式呼叫的,就同步獲其結果了。

分析過程是這樣的:讀出一行文字,然後用英文的冒號分隔字串,變成一個二元素陣列,[0] 就是 name,[1] 就是 value 了。把所有分析出來的東東都新增到字典物件中,方便後面提取值。

ContainsPrefix 方法是分析是否包含字首,比如前X篇文章中提到的像 stu.name、stu.age 等,stu 就是字首。這此我們們不考慮這個,所以直接返回 true。

GetValue 方法是關鍵,這是供給外面呼叫的規範方法,通過 key 從字典物件中查詢出值。查詢的 key 用的就是 Room 類的屬性名稱。

 

4、自定義 ModelBinder

    public class RoomBinder : IModelBinder
    {
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            // 引數不能為 null
            if (bindingContext == null)
                throw new ArgumentNullException(nameof(bindingContext));

            // 特殊處理,把全域性的 ValueProvider 替換
            bindingContext.ValueProvider = new CustValueProvider(bindingContext.HttpContext);

            var objMetadata = bindingContext.ModelMetadata;
            // 目標型別有三個屬性
            if (objMetadata.Properties.Count == 0)
                return Task.CompletedTask;

            Type modeltype = objMetadata.ModelType;
            // 建立例項
            object? modelObj = Activator.CreateInstance(modeltype);
            if (modelObj is null)
                return Task.CompletedTask;

            // 給屬性賦值
            foreach (var prop in objMetadata.Properties)
            {
                // 找到 key
                string key = prop.Name!.ToLower();
                var wrapValue = bindingContext.ValueProvider.GetValue(key);
                if (wrapValue == ValueProviderResult.None)
                    continue;   //無值,有請下一位
                // 取內容
                string realVal = wrapValue.FirstValue!;
                // null 或者長度為 0,不可用
                if (realVal is null or { Length: 0 })
                    continue;   //沒有用的值,有請下一位
                // 看看能不能轉換
                var proptype = prop.ModelType;  //這是屬性值的型別
                object? propval;
                try
                {
                    // 把取出的字串轉換為屬性值的型別
                    propval = Convert.ChangeType(realVal, proptype);
                }
                catch { continue; }
                // 設定屬性值
                // PropertyGetter:獲取屬性的值
                // PropertySetter:設定屬性的值
                if (propval != null && prop.PropertySetter != null)
                {
                    prop.PropertySetter(modelObj!, propval);
                }
            }
            // 重要:一定要設定繫結的結果
            bindingContext.Result = ModelBindingResult.Success(modelObj);

            return Task.CompletedTask;
        }
    }

由於是針對特殊情形的繫結,所以這裡老周就不分析 Content-Type,假設它任意內容均可,文字提交一般用 text/* 或者明確一點 text/plain。

這裡我們們也不使用全域性的 ValueProvider,所以把 bindingContext 的也替換掉。

   bindingContext.ValueProvider = new CustValueProvider(bindingContext.HttpContext);

因為針對性強,這裡其實你可以直接 new 一個 Room 例項,然後從 ValueProvider 中找出三個屬性的值,直接轉換為 float 值,賦給物件的三個屬性即可。不過,老周為了裝逼,寫得複雜了一點。

ModelMetadata 包含目標型別相關的資訊,比如它的 Type,它有幾個屬性(Properties),每個屬性也能獲取到 ModelMetaData,表示屬性值的型別資訊。

所以,首先我們們從 ModelType 得到 Room 類的 Type,接著,用 Activator 來建立它的例項。

從 Properties 中取出各個屬性的 ModelMetaData,並根據屬性名(有定義屬性的型別通常不會為null)來查詢出各屬性的值,型別是字串。然後獲取屬性值的 Type,再用 Convert.ChangeType 做型別轉換,轉為屬性值的型別(這裡其實是 float)。

再通過 PropertySetter 來設定屬性的值,它是委託型別,和屬性的 set 訪問器繫結。

最後 ModelBindingResult.Success 設定填充 Room 物件。

 

補充:忘了最後一步,要在 Room 類的定義上應用 ModelBinder 特性。

[ModelBinder(typeof(RoomBinder))]
public class Room
{
    ……
}

------------------------------------------------------------------------------------------------

測試

POST /api/test/abc HTTP/1.1
Content-Type: text/plain
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:5018
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 36
 
width: 3.6
height: 5.5
length: 8.0
 
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 26 Mar 2022 05:00:16 GMT
Server: Kestrel
Transfer-Encoding: chunked
 
158.4

好了,這個特殊繫結就順利完成了。

 

其實,自定義 InputFormatter 也可以實現同樣功能的,這個老周下一篇再扯。

相關文章