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