本篇老周就和老伙伴們分享一下,對於客戶端提交的不規范 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 也可以實現同樣功能的,這個老周下一篇再扯。