【ASP.NET Core】MVC操作方法如何綁定Stream類型的參數

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

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章