【ASP.NET Core】MVC模型綁定——實現同一個API方法兼容JSON和Form-data輸入
- 2022 年 3 月 24 日
- 筆記
- .NET, Asp.Net Core, 個人文章
在上一篇文章中,老周給大夥伴們大致說了下 MVC 下的模型綁定,今天咱們進行一下細化,先聊聊模型綁定中涉及到的一些組件對象。
——————————————————————————
一、ValueProvider——提取綁定源的值
首先登場的小帥哥是 ValueProvider,即實現 IValueProvider 介面。
public interface IValueProvider { bool ContainsPrefix(string prefix); ValueProviderResult GetValue(string key); }
提取綁定源的值在操作上類似字典對象的訪問,通過一個指定的 key 來檢索。這個主要針對數據結構類似字典的數據源,比如
1、HTTP Header,它的結構就是 name: value;
2、Form 對象,比如 HTML 頁上的<form>元素,或者客戶端直接提交的 form-data,當然包括用 JQuery 等方式提交的 form;
3、Route Value,也就是路由參數。比如咱們在寫MVC時很熟悉的那個 {controller}/{action},若訪問的是 Home/Index,那麼這裡面就是兩個數據項。第一個 key 是 controller,value 是 Home;第二個 key 是 action,value 是 Index。
於是,.net core 中很自然地會內置一些已實現的 provider。
FormValueProvider
FormFileValueProvider
JQueryFormValueProvider
RouteValueProvider
HeaderValueProvider
看著它們的大名,估計你也能猜到它們的作用。你會問:咦,HeaderValueProvider 在哪,我咋沒看到?這廝藏得比大 Boss 還深!它是在 HeaderModelBinder 文件中定義的私有類,所以我們根本訪問不到它。
private class HeaderValueProvider : IValueProvider
許多 ValueProvider 類型還有專配的 ValueProviderFactory,實現 IValueProviderFactory 介面。該介面只需實現一個方法:CreateValueProviderAsync。
public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
返回值只是個 Task?那創建的 ValueProvider 實例放到哪?注意它有個參數是個上下文對象(ValueProviderFactoryContext),此對象有個屬性叫 ValueProviders。創建的 ValueProvider 實例被添加到這個列表中。
也就是說,可以調用各個 Factory 對象創建多種 ValueProvider,然後都添加進 ValueProviders 列表中。當我們自己編寫 ModelBinder 時,就可以從這個列表中的 N 個 ValueProvider 對象中提取值。這個 ValueProvider 的值會自動被合併,我們不需要關心它來自哪個 ValueProvider 對象。
比如,列表中有 QueryString 和 RouteValue 兩個值來源,其中 key1 來自 QueryString,key2 來自 RouteValue,我在自定義 binder 時,不必管它從哪裡來,我只要知道有兩個鍵叫 key1、key2 就行。
public Task BindModelAsync(ModelBindingContext bindingContext) { …… var modelName = bindingContext.ModelName; var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); if (valueProviderResult == ValueProviderResult.None) { …… return Task.CompletedTask; } var modelState = bindingContext.ModelState; modelState.SetModelValue(modelName, valueProviderResult); var metadata = bindingContext.ModelMetadata; var type = metadata.UnderlyingOrModelType; try { var value = valueProviderResult.FirstValue; object? model; if (string.IsNullOrWhiteSpace(value)) { // Parse() method trims the value (with common NumberStyles) then throws if the result is empty. model = null; } else if (type == typeof(float)) { model = float.Parse(value, _supportedStyles, valueProviderResult.Culture); } else { // unreachable throw new NotSupportedException(); } // When converting value, a null model may indicate a failed conversion for an otherwise required // model (can't set a ValueType to null). This detects if a null model value is acceptable given the // current bindingContext. If not, an error is logged. if (model == null && !metadata.IsReferenceOrNullableType) { modelState.TryAddModelError( modelName, metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor( valueProviderResult.ToString())); } else { bindingContext.Result = ModelBindingResult.Success(model); } } catch (Exception exception) { …… } _logger.DoneAttemptingToBindModel(bindingContext); return Task.CompletedTask; }
以上是從源程式碼中抄來的一段,用於綁定 float 類型的 binder。
內部已經提供基礎類型和複合類型的 Binder,所以一般情況下我們不需要自己花時間去寫 Binder。
二、Binder 對象
binder 對象必須實現 IModelBinder 介面。這個介面只要求實現一個方法。
Task BindModelAsync(ModelBindingContext bindingContext);
在這個方法的實現在,你要完成從數據源中提取值,然後產生綁定目標對象的過程。
例如,你的控制器類中有這麼個方法(API方法):
public Task UpdateStudent(Student stu) { …… }
假設這個 Student 類有 Name、Age、Email 三個屬性,不管數據是通過 URL 查詢字元串傳遞還是 form 提交,都需要提供這些值:
name=小明
age=21
於是,你的自定義 Binder 可以這樣寫
public class StudentBinder : IModelBinder { public Task BindModelAsync(ModelBindingContext bindingContext) { // 提取值 var valname = bindingContext.ValueProvider.GetValue("name"); var valemail = bindingContext.ValueProvider.GetValue("email"); var valage = bindingContext.ValueProvider.GetValue("age"); // 剝出真實的值 string? name; if(valname != ValueProviderResult.None) { name = valname.FirstValue; } int age; if(valage != ValueProviderResult.None) { _ = int.TryParse(valage.FirstValue, out age); } string? email; if(valemail != ValueProviderResult.None) { email = valemail.FirstValue; } // 實例化目標對象 Student s = new() { Name = name, Age = age, Email = email }; // 如果綁定成功,必須設置 Result bindingContext.Result = ModelBindingResult.Success(s); } }
綁定的過程可以很簡單,也可以弄得很複雜,主要看你的需求。bindingContext 對象的 Result 屬性一定要設置,默認是綁定失敗。傳遞給 ModelBindingResult.Success(s) 方法的參數就是目標對象。比如上面的,通過模型綁定的目標是給 Student 對象的屬性賦值,所以傳遞的就是 Student 實例的引用。控制器方法 UpdateStudent 的參數就會引用這個 Student 實例,綁定完成。
其實上面的 Binder 我只是寫著玩的,而且它只局限在 Student 類上,不能通用於所有類型。實際上咱們也不需寫,我只是做演示。內置的 ComplexObjectModelBinder 類就能完成這項工作,而且它是通用於所有複合類型。
binder 寫好了怎麼用呢?這分為全局和局部。像上面例子這種特定於 Student 類型的 binder,最好還是局部應用——在 Student 類上通過特性類來關聯。
[ModelBinder(typeof(StudentBinder))] public class Student { …… }
不想寫在類,也可以寫在控制器方法的參數上。
public Task UpdateStudent([ModelBinder(typeof(StudentBinder))] Student stu)
三、ModelBinderProvider
如果你希望自定義的 binder 可以應用於全局,那你得實現 IModelBinderProvider 介面。這個介面只有一個方法:
IModelBinder? GetBinder(ModelBinderProviderContext context);
通過這個方法你會發現,ModelBinderProvider 的作用就是獲取 binder 對象。
所以咱們上面那個例子,可以寫一個 StudentBinderProvider 類,實現 GetBinder 方法,返回一個 binder 實例。
return new StudentBinder();
既然是全局的,當然要在應用程式初始化時完成。在 Program.cs 文件中,通過 MvcOptions 對象來配置。
var builder = WebApplication.CreateBuilder(); builder.Services.AddControllers(opt => { opt.ModelBinderProviders.Insert(0, new StudentBinderProvider()); }); var app = builder.Build(); app.MapControllers(); app.Run();
不過,上面寫的那個例子,應用到全局後容易翻車。因為所有 MVC 請求都會調用,而上面程式碼中咱們是把 StudentBinderProvider 對象放在列表的首位,只要有 MVC 請求,都會調用它來獲取 binder,結果所有類型的綁定目標都會用 StudentBinder 來做綁定,不是 Student 類型的對象就無法綁定,獲取不到數據源的值。
當然你可以做一下類型判斷,不是 Student 的值接返回 null。這樣運行時會轉而嘗試其他 ModelBinderProvider。
public IModelBinder? GetBinder(ModelBinderProviderContext context) { if(context.Metadata.ModelType == typeof(Student)) { return new ..... } return null; }
ModelBinderProvider 不需要放到依賴注入容器中,在配置 MVC 功能時會默認添加,自定義的可以用上面的方法通過 MvcOptions 添加。
// Set up ModelBinding options.ModelBinderProviders.Add(new BinderTypeModelBinderProvider()); options.ModelBinderProviders.Add(new ServicesModelBinderProvider()); options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options)); options.ModelBinderProviders.Add(new HeaderModelBinderProvider()); options.ModelBinderProviders.Add(new FloatingPointTypeModelBinderProvider()); options.ModelBinderProviders.Add(new EnumTypeModelBinderProvider(options)); options.ModelBinderProviders.Add(new DateTimeModelBinderProvider()); options.ModelBinderProviders.Add(new TryParseModelBinderProvider()); options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider()); options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider()); options.ModelBinderProviders.Add(new ByteArrayModelBinderProvider()); options.ModelBinderProviders.Add(new FormFileModelBinderProvider()); options.ModelBinderProviders.Add(new FormCollectionModelBinderProvider()); options.ModelBinderProviders.Add(new KeyValuePairModelBinderProvider()); options.ModelBinderProviders.Add(new DictionaryModelBinderProvider()); options.ModelBinderProviders.Add(new ArrayModelBinderProvider()); options.ModelBinderProviders.Add(new CollectionModelBinderProvider()); options.ModelBinderProviders.Add(new ComplexObjectModelBinderProvider());
// Set up ValueProviders options.ValueProviderFactories.Add(new FormValueProviderFactory()); options.ValueProviderFactories.Add(new RouteValueProviderFactory()); options.ValueProviderFactories.Add(new QueryStringValueProviderFactory()); options.ValueProviderFactories.Add(new JQueryFormValueProviderFactory()); options.ValueProviderFactories.Add(new FormFileValueProviderFactory());
四、ModelBinderFactory
這個主要是實現 IModelBinderFactory 介面,此介面也要實現一個方法來創建 binder。
IModelBinder CreateBinder(ModelBinderFactoryContext context);
內部默認的實現類為 ModelBinderFactory。這個 factory 是註冊到依賴注入容器中的(單實例模式),它創建 binder 的依據是我們上面提到的各種 ModelBinderProvider,它就是調用了它們的 GetBinder 方法來獲得 binder 的實例引用。
它會循環訪問所有 provider,只要有一個 GetBinder 方法不返回 null,就算完成,然後就用這個 binder 來完成模型綁定。
for (var i = 0; i < _providers.Length; i++) { var provider = _providers[i]; result = provider.GetBinder(providerContext); if (result != null) { break; } }
這個類在獲取 binder 實例後會把 binder 快取,當下一次再用到時就直接從快取裡面獲取,免去了多次 new 的時間消耗。請大夥伴們記住它的快取功能,因為後文咱們在實現 API 同時支援 JSON 和 Form 提交時會因為它導致問題。
五、實現同一個API支援 JSON 和 form-data 正文
前面四個標題都是準備理論,現在咱們才正式開始幹活。
老周想要這樣一個功能:假設某API是 /demo/product/edit,調用時要 POST 數據,然後被 Product 類型的參數接收。客戶端使用 json 提交可以,使用 form-data 提交也可以。
要實現這個,可以用自定義 Binder 的方式。我們不需要自己編寫複雜的 binder,因為內置的有現成的:
1、當調用 API 時提交的是 JSON,使用 BodyModelBinder 就可以讀出內容;
2、當調用 API 時提交的是 form-data,使用 ComplexObjectModelBinder 就能讀出。
綜合上述兩條,老周有個大膽的想法,於是付諸大膽的行動。咱們自己寫個 ModelBinderProvider。
public class CustMtFmtBinderProvider : IModelBinderProvider { private readonly IModelBinderProvider bodybinderProvd; private readonly IModelBinderProvider complexobjbinderProvd; public CustMtFmtBinderProvider(BodyModelBinderProvider bdp, ComplexObjectModelBinderProvider cmplxprd) { bodybinderProvd = bdp; complexobjbinderProvd = cmplxprd; } public IModelBinder? GetBinder(ModelBinderProviderContext context) { HttpContext httpctx = context.Services.GetRequiredService<IHttpContextAccessor>()?.HttpContext; var request = httpctx.Request; IModelBinder binder; if(request.ContentType.StartsWith("multipart/form-data")) { binder = complexobjbinderProvd.GetBinder(context); } else { binder = bodybinderProvd.GetBinder(context); } return binder; } }
兩個類型的 binder 可以通過構造函數來傳,因為是用 MvcOptions 來添加的,不是依賴住入,所以我們可以自己傳參。原理老周相信大夥能懂,就是判斷 HTTP 請求的 Content-Type,如果是 multipart/form-data,就用複合類型的 binder,否則,用 Body binder,它默認支援JSON,不,是只支援JSON。
上一段 BodyModelBinder 的源程式碼:
public async Task BindModelAsync(ModelBindingContext bindingContext) { ……var formatter = (IInputFormatter?)null; for (var i = 0; i < _formatters.Count; i++) { if (_formatters[i].CanRead(formatterContext)) { formatter = _formatters[i]; …… break; } else { Log.InputFormatterRejected(_logger, _formatters[i], formatterContext); } } ……try { var result = await formatter.ReadAsync(formatterContext); if (result.HasError) { // Formatter encountered an error. Do not use the model it returned. _logger.DoneAttemptingToBindModel(bindingContext); return; } …… }
喲!這傢伙並不是用 ValueProvider 來獲取值的,而是使用 InputFormatter 讀取的。上一篇水文中老周提過,當我們讓控制器類用於 API 時,應用 [ApiController] 特性,它會把HTTP請求的整個 body 作為綁定源,並把模型綁定交給 InputFormatter 去處理。這裡我們就看到真相了,就是 BodyModelBinder 搞的鬼,它調用了 Inputformatter。
Inputformatter 實現 IInputFormatter 介面,而運行庫默認給應用註冊的是 SystemTextJsonInputFormatter ,對於返回數據,則用的是 SystemTextJsonOutputFormatter 。關於返回的數據格式,老周前面也寫過相關文章。
好了,回到主題,現在咱們的 BinderProvider 寫好了,把它配置到 MvcOptions 對象中,成為全局的 binder 提供者。
var builder = WebApplication.CreateBuilder(); // 一定要註冊這個 builder.Services.AddHttpContextAccessor(); builder.Services.AddControllers(); builder.Services.Configure<MvcOptions>(opt => { BodyModelBinderProvider bp = opt.ModelBinderProviders.OfType<BodyModelBinderProvider>().FirstOrDefault()!; ComplexObjectModelBinderProvider cp = opt.ModelBinderProviders.OfType<ComplexObjectModelBinderProvider>().FirstOrDefault()!; opt.ModelBinderProviders.Insert(0, new CustMtFmtBinderProvider(bp, cp)); }); var app = builder.Build();
我們那個 Provider 中用到 HttpContext ,要在服務容器上調用 AddHttpContextAccessor 方法註冊一下,不然會報錯(因為在 Provider 中默認沒有引用 Httpcontext 相關的對象,但 ModelBinder 的上下文中可以訪問)。
嗯,思路是沒錯的,這 Job 看起來很完美。下面咱們定義個模型類。
public class Cat { public string Nickname { get; set; } = ""; public string? Category { get; set; } public string Owner { get; set; } = ""; }
老周比較喜歡的兩種動物:一種是喵喵,另一種是兔崽子。這裡就定義個 Cat 類吧。
隨後是 Controller。
[Route("cat")] [ApiController] public class CatController : Controller { [HttpPost] [Route("new")] [Consumes("application/json", "multipart/form-data")] public string NewCat(Cat cc) { if (cc.Nickname == "") return "你養了個寂寞"; string m = $"你新養了一隻貓,它叫 {cc.Nickname}"; m += $"\n主人:{cc.Owner}\n品種:{cc.Category}"; return m; } }
Consumes 特性可以指定 API 方法支援哪些 content-type,當某個 action 有多個匹配方法時,還可以作為篩選方法的輔助依據。這裡我就明確了兩個類型—— application/json 和 multipart/form-data。
然而,但是,意外,沒想到,運行之後就讓人傻眼了。果然理想是花容月貌,現實是鬼妖當道。問題如下:
A、運行後,選用 form-data,測試成功;但改為 JSON 提交就不行了;
B、運行後,選用 JSON 測試,成功;但改為 form-data 提交就不行了。
總結一下,就是運行後,第一次調用是正確的,無論是 JSON 還是 FORM 都是可以的,但之後再調用 API 就不正確了。其實咱們這個示例的思路是沒有錯的,但這時候你怎麼 debug 都無法解決。問題出在哪呢?
前文老周提示了一下,記得乎?在介紹 ModelBinderFactory 時強調了一下,這傢伙在調用某個 ModelBinderProvider 成功獲得 ModelBinder 後會將其快取。本示例的問題就是出在這兒了。快取對象是字典類型(ConcurrentDictionary),Key 的組成要素之一(另一個可能是 ControllerParameterDescriptor,描述方法的參數資訊)就是模型綁定的元數據(ModelMetadata),而元數據是從模型類型產生的。在這個示例中,模型無論要使用 BodyModelBinder 還是 ComplexObjectModelBinder,它的模型都是 Cat 類(也是一樣的參數),因此元數據是不變的。
假設使用 JSON 方式提交,當第一次調用後,自定義 ModelBinderProvider 返回的 binder (假設叫 X)會被快取;然後改為用 form data 提交,調用時,會優先從快取中查找,然後找到 X,最後又用了 X 來進行模型綁定,於是就錯了(此次應該用 Y)。
幸好,這個問題是可以解決的。咱們調整一下思路,先自定義一個 binder,在這個 binder 里封裝 BodyModelBinder 和 ComplexObjectModelBinder 對象,然後在執行綁定時,再動態選擇用 body 還是用 complexobject。
於是,示例做以下調整:
先編寫一個自定義的 binder。
public class CustBinder : IModelBinder { private readonly BodyModelBinder _bodybinder; private readonly ComplexObjectModelBinder _objectbinder; public CustBinder(BodyModelBinder bodyb, ComplexObjectModelBinder objb) { _bodybinder = bodyb; _objectbinder = objb; } public async Task BindModelAsync(ModelBindingContext bindingContext) { // 通過 Content-Type 來區分 var request = bindingContext.HttpContext.Request; if(request.ContentType!.StartsWith("multipart/form-data")) { await _objectbinder.BindModelAsync(bindingContext); } else { await _bodybinder.BindModelAsync(bindingContext); } } }
這個自定義 Binder 中用到了另兩個 Binder:Body 和 ComplexObject。通過構造函數兩傳遞。在執行綁定時,通過請求的 ContentType 來判斷,如果是 form-data,就用 ComplexObjectModelBinder,否則用 BodyModelBinder(直接調用它們的 BindModelAsync 方法就行了)。
補充:程式碼中在對象後面出現的「!」運算符,是告訴分析器「這個對象不會是null的,請放心」。在運行階段無用處,只是在編譯時不會有警告。
然後,再寫一個 ModelBinderProvider。
public class CustFmtBinderProviderV2 : IModelBinderProvider { private readonly MvcOptions options; public CustFmtBinderProviderV2(MvcOptions o) { options = o; } public IModelBinder? GetBinder(ModelBinderProviderContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); if (context.BindingInfo.BindingSource == null || context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Body) == false) return null; BodyModelBinderProvider bodyprvd = options.ModelBinderProviders.OfType<BodyModelBinderProvider>().FirstOrDefault()!; ComplexObjectModelBinderProvider objprvd = options.ModelBinderProviders.OfType<ComplexObjectModelBinderProvider>().FirstOrDefault()!; // 創建 binder 實例 IModelBinder? binder1 = bodyprvd.GetBinder(context); IModelBinder? binder2 = objprvd.GetBinder(context); // 兩個 binder 都要用到,均不能為 null if(binder1 != null && binder2 != null) { return new CustBinder((BodyModelBinder)binder1, (ComplexObjectModelBinder)binder2); } return null; } }
BodyModelBinderProvider 和 ComplexObjectModelBinderProvider 從 MvcOptions 對象的 ModelBinderProviders 列表中獲取。創建 CustBinder 實例時,把這兩個 binder 傳給它的構造函數。
經過這樣處理之後,被 ModelBinderFactory 快取的是 CustBinder 實例,哪怕在第二次以上調用時,都能正確進行 Content-Type 的分析,因為篩選的程式碼是寫在 CustBinder 內部的,就算調用的是已快取的實例也不影響其邏輯。
最後,Program.cs 文件那裡也改一下。
var builder = WebApplication.CreateBuilder(); builder.Services.AddControllers(); builder.Services.Configure<MvcOptions>(opt => { opt.ModelBinderProviders.Insert(0, new CustFmtBinderProviderV2(opt)); }); var app = builder.Build();
測試一下,運行後,先以 form-data 輸入。
POST /cat/new HTTP/1.1
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:5168
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------031532983200066969593455
Content-Length: 395
----------------------------031532983200066969593455
Content-Disposition: form-data; name="nickname"
豆豆
----------------------------031532983200066969593455
Content-Disposition: form-data; name="owner"
小王
----------------------------031532983200066969593455
Content-Disposition: form-data; name="category"
大狸花
----------------------------031532983200066969593455--
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 24 Mar 2022 08:57:14 GMT
Server: Kestrel
Transfer-Encoding: chunked
你新養了一隻貓,它叫 豆豆
主人:小王
品種:大狸花
通過,沒問題。
接著,改為用 JSON 方式提交。
POST /cat/new HTTP/1.1
Content-Type: application/json
User-Agent: PostmanRuntime/7.29.0
Accept: */*
Host: localhost:5168
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 84
{
"nickname": "豆豆",
"category": "大橘",
"owner": "賽冬瓜"
}
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 24 Mar 2022 08:58:57 GMT
Server: Kestrel
Transfer-Encoding: chunked
你新養了一隻貓,它叫 豆豆
主人:賽冬瓜
品種:大橘
嗯嗯嗯,效果不錯吧。現在這個 API 既可以用 form-data 輸入數據,也能用 JSON 輸入數據了。咱們就不必把同一個 API 寫兩個版本了。