【ASP.NET Core】MVC操作方法如何繫結Stream型別的引數

2022-12-18 21:01:03

咱們都知道,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 的。