我們們都知道,MVC在輸入/輸出中都需要模型繫結。因為HTTP請求傳送的都是文字,為了使其能變成各種.NET 型別,於是在填充引數值之前需 ModelBinder 的參與,以將文字轉換為 .NET 型別。
儘管 ASP.NET Core 已內建基礎型別和複雜型別的各種 Binder,但有些資料還是不能處理的。比如老周下面要說的情況。
------------------------------------------------- 白金分割線 -------------------------------------------------------
情景假設:
1、我需要讀取HTTP訊息的整個 body 來填充 MVC 方法引數;
2、HTTP訊息的 body 不是 form-data,而是完全的二進位制內容。
最簡單的方法就是不使用模型繫結,即在MVC方法中直接訪問 HttpContext.Request.Body。
var request = HttpContext.Request; using(StreamReader reader = new(request.Body)) { …… }
這樣很省事。不過這法子是不走模型繫結路線的,不時候我們是不希望這麼弄,而是用這樣的控制器。
// 魔鬼控制器 [HttpPost("/magic/post")] public ActionResult PostSomething(Stream data) { // 計算個雜湊 byte[] hash = SHA1.HashData(data); // 長度 long len = data.Length; // 響應 return Content($"你提交的資料長度:{len},SHA1:{Convert.ToHexString(hash)}"); }
這裡我用單元測試來嘗試呼叫它。
[TestClass] public class UnitTest1 { [TestMethod] public async Task TestMethod1() { Uri rootURL = new Uri("https://localhost:7194"); HttpClient client = new(); client.BaseAddress = rootURL; // 隨便弄點資料 byte[] data = new byte[512]; Random.Shared.NextBytes(data); // 建立流 MemoryStream mmstream = new MemoryStream(data); // 構建內容 StreamContent content = new StreamContent(mmstream); // 設定標準頭 application/octet-stream content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Octet); // 發輸出一下雜湊 string sha1 = Convert.ToHexString(SHA1.HashData(data)); Console.WriteLine("SHA1: {0}", sha1); // 傳送POST請求 var response = await client.PostAsync("/magic/post", content); // 輸出結果 Console.WriteLine($"響應程式碼:{response.StatusCode}"); Console.WriteLine("響應內容:{0}", await response.Content.ReadAsStringAsync()); Assert.IsTrue(response.StatusCode == System.Net.HttpStatusCode.OK); } }
先執行伺服器,再執行單元測試。結果:Failed。
這個提示是說不能建立 Stream 類的例項。是的,因為這廝不是實現類,它很抽象,抽象到連 ComplexObjectModelBinder 都玩不下去了。這同時也說明,對於非基礎型別,ASP.NET Core 預設是把引數當成複雜型別來繫結的。
於是我們們又冒出另一個思路:用 BodyModelBinder 試試。就是在引數上加個[FromBody]特性。
[HttpPost("/magic/post")] public ActionResult PostSomething([FromBody]Stream data) { …… }
其實,Web API 說白了就是不用檢視的 MVC 控制器。在控制器上應用 [ApiController] 特性後,在方法引數上可以省略 [FromBody] 特性。如果控制器上不應用 [ApiController] 特性,就要手動加 [FromBody] 特性。
再執行一下單元測試。結果還是 Failed。
這次返回的狀態是 UnsupportedMediaType,即415。
---------------------------------------------------------------------------------------------------------------------
接下來是無聊的理論知識,請準備好奶茶。
BodyModelBinder 在進行繫結時實際上是使用 IInputFormatter 來讀取HTTP訊息正文(body)的。允許使用多個 IInputFormatter,只要有一個能解析成功就行。預設情況下,僅支援 application/json、text/json 格式。這個我們們可以從原始碼看出來。
// Set up default input formatters. options.InputFormatters.Add(new SystemTextJsonInputFormatter(_jsonOptions.Value, _loggerFactory.CreateLogger<SystemTextJsonInputFormatter>())); // Media type formatter mappings for JSON options.FormatterMappings.SetMediaTypeMappingForFormat("json", MediaTypeHeaderValues.ApplicationJson);
於是,我們們把單元測試的程式碼改一下。
// 構建內容 //StreamContent content = new StreamContent(mmstream); JsonContent content = JsonContent.Create<Stream>(data); // 設定標準頭 application/json content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json);
這樣做也是不行的。
這次是 HashData 方法丟擲的異常,問題還是出在 Stream 型別的引數不能例項化。若把操作方法的引數型別改為 byte[] 就沒問題了。
public ActionResult PostSomething([FromBody]byte[] data)
可是這樣一改,就與我們當初的要求相差太大了,我就喜歡用 Stream 型別啊,咋辦?
---------------------------------------------------------------------------------------------------------------------
那隻好自己寫 Binder 了,反正也不難。
public class StreamModelBinder : IModelBinder { public async Task BindModelAsync(ModelBindingContext bindingContext) { if(bindingContext == null) { throw new ArgumentNullException(nameof(bindingContext)); } // 資料來源要來自body Console.WriteLine($"Binding Source: {bindingContext.BindingSource?.Id}"); if(bindingContext.BindingSource == null || bindingContext.BindingSource != BindingSource.Body) { return; } var request = bindingContext.HttpContext.Request; // 我們們不關心Content-Type是啥 long? len = request.ContentLength; // 只關心有沒有正文 if(len == null && len == 0L) { return; } // 由於這個流型別有些成員不支援(比如Length屬性),所以複製到記憶體流中 MemoryStream mstream = new MemoryStream(); await request.Body.CopyToAsync(mstream); // 回位 mstream.Position = 0L; bindingContext.Result = ModelBindingResult.Success(mstream); } }
然後改一下控制器方法,並將上面的 Binder 透過 [ModelBinder] 特性應用到 Stream 型別的引數上。
[HttpPost("/magic/post")] public async Task<ActionResult> PostSomething([FromBody, ModelBinder(typeof(StreamModelBinder))]Stream data) { // 計算個雜湊 byte[] hash = await SHA1.HashDataAsync(data); // 長度 long len = data.Length; // 響應 return Content($"你提交的資料長度:{len}\nSHA1:{Convert.ToHexString(hash)}"); }
[ModelBinder] 特性可以區域性使用自定義的 ModelBinder。此處老周建議不需要全域性註冊,僅在有 Stream 型別的輸入引數時才用,畢竟這貨也不是通用型的。
如果要全域性應用,你得實現 IModelBinderProvider 介面,讓 GetBinder 方法返回 StreamModelBinder 例項。然後把這個實現 IModelBinderProvider 的型別新增到 MvcOptions 選項類的 ModelBinderProviders 列表中。
經過這麼一弄,嘿,有門!
只有兩個雜湊值相同才表明資料被正確傳輸。
有大夥伴肯定又有疑問了:在 StreamModelBinder 中把 Body 複製到記憶體流,再用記憶體流來為模型賦值。這……這……這不閒得肛門疼嗎?在註釋里老周寫明瞭,因為 Body 那個是 HttpRequest 網路流,像 Length 屬性等成員是不支援的,在控制器方法中訪問會拋異常。
你也可以節能一下,直接用 Body 來設定模型值,但在控制器程式碼中不能用 Length 屬性來讀取長度了。
public class StreamModelBinder : IModelBinder { public Task BindModelAsync(ModelBindingContext bindingContext) { if(bindingContext == null) { throw new ArgumentNullException(nameof(bindingContext)); } // 資料來源要來自body //Console.WriteLine($"Binding Source: {bindingContext.BindingSource?.Id}"); if(bindingContext.BindingSource == null || bindingContext.BindingSource != BindingSource.Body) { return Task.CompletedTask; } var request = bindingContext.HttpContext.Request; // 我們們不關心Content-Type是啥 long? len = request.ContentLength; // 只關心有沒有正文 if(len == null && len == 0L) { return Task.CompletedTask; } // 直接賦值 bindingContext.Result = ModelBindingResult.Success(request.Body); return Task.CompletedTask; } }
控制器中的程式碼可以改為繫結 HTTP 訊息頭來獲取長度。
[HttpPost("/magic/post")] public async Task<ActionResult> PostSomething([FromBody, ModelBinder(typeof(StreamModelBinder))]Stream data, [FromHeader(Name = "Content-Length")]long len) { // 計算個雜湊 byte[] hash = await SHA1.HashDataAsync(data); // 響應 return Content($"你提交的資料長度:{len}\nSHA1:{Convert.ToHexString(hash)}"); }
len 引數的值來自 Content-Length 訊息頭。
執行伺服器,再執行一下單元測試,結果是有效的。
最後,補充一下,Mini-API 方式是支援使用 Stream 型別的引數的,不用自定義寫程式碼。
app.MapPost("/dowork", async (Stream data) => { byte[] hash = await SHA1.HashDataAsync(data); string hashstr = Convert.ToHexString(hash); return Results.Content($"接收的資料的雜湊:{hashstr}"); });
結果是 Success 的。