最近在项目开发过程中,有个别接口需要同时支持GET和POST请求,经过一番测试,貌似NetCore只能接收指定的FromBody、FromQuery等参数,经过一番查找后发现文章:为ASP.NET Core实现一个自适应ModelBinder,让Action自适应前端参数传递
文章地址:https://masuit.org/1889?t=0HMUL0LVM3L9U
后续说明使用与原文不一致的代码,原文内容如下:
在以前.NET Framework写MVC5的时候,Action的参数前端传递的时候默认是可以自适应的,即:以queryString、表单或者json传递都能够被正确接收,而到了asp.net core中,action接收参数默认只有queryString,显式声明了FromForm或FromBody之后也只能被表单或json接受,即使是同时打上FromForm和FromBody,也只有FromForm生效,FromBody不会起作用的,比如下面的代码:
public ActionResult Test([FromBody]MyClass model) // 只能接受以application/json传递过来的参数 {return Ok(model);}public ActionResult Test([FromForm]MyClass model) // 只能接受以表单传递过来的参数 {return Ok(model);}public ActionResult Test([FromBody, FromForm]MyClass model) // 只能接受以application/json传递过来的参数,从表单来的无效 {return Ok(model);}
这就很麻烦了,如果我想同一个接口同时支持queryString、表单和json请求类型的参数绑定到模型上,那只能写多个接口重载来适配,如果想一个action同时支持queryString、表单和json请求类型的参数绑定,我们的主要目的是替换掉FromBody的默认行为,那么只有写一个自定义的ModelBinder;
话不多说,直接上代码,再说原理:
public class BodyOrDefaultModelBinder : IModelBinder{private readonly IModelBinder _bodyBinder;private readonly IModelBinder _complexBinder;public BodyOrDefaultModelBinder(IModelBinder bodyBinder, IModelBinder complexBinder){_bodyBinder = bodyBinder;_complexBinder = complexBinder;}public async Task BindModelAsync(ModelBindingContext bindingContext){var request = bindingContext.HttpContext.Request;request.EnableBuffering();var buffer = new byte[Convert.ToInt32(request.ContentLength)];_ = await request.Body.ReadAsync(buffer, 0, buffer.Length);var text = Encoding.UTF8.GetString(buffer);request.Body.Position = 0;if (bindingContext.ModelType.IsPrimitive || bindingContext.ModelType == typeof(string) || bindingContext.ModelType.IsEnum || bindingContext.ModelType == typeof(DateTime) || bindingContext.ModelType == typeof(Guid)){var parameter = bindingContext.ModelMetadata.ParameterName;var value = "";if (request.Query.ContainsKey(parameter)){value = request.Query[parameter] + "";}else if (request.ContentType.StartsWith("application/json")){try{value = JObject.Parse(text)[parameter] + "";}catch{value = text;}}else if (request.HasFormContentType){value = request.Form[bindingContext.ModelMetadata.ParameterName] + "";}if (value.TryConvertTo(bindingContext.ModelType, out var result)){bindingContext.Result = ModelBindingResult.Success(result);}return;}if (request.HasFormContentType){if (bindingContext.ModelType.IsClass){await DefaultBindModel(bindingContext);}else{bindingContext.Result = ModelBindingResult.Success(request.Form[bindingContext.ModelMetadata.ParameterName].ToString().ConvertTo(bindingContext.ModelType));}return;}try{bindingContext.Result = ModelBindingResult.Success(JsonConvert.DeserializeObject(text, bindingContext.ModelType) ?? request.Query[bindingContext.ModelMetadata.ParameterName!].ToString().ConvertTo(bindingContext.ModelType));}catch{await DefaultBindModel(bindingContext);}}private async Task DefaultBindModel(ModelBindingContext bindingContext){await _bodyBinder.BindModelAsync(bindingContext);if (bindingContext.Result.IsModelSet){return;}bindingContext.ModelState.Clear();await _complexBinder.BindModelAsync(bindingContext);}}
这一大片代码,看懵了吧,接下来说下原理:
既然是要同时支持queryString、表单和json请求类型,那么肯定是在模型绑定的时候做各种的兼容处理,这里就优先从请求体里面获取传递的参数信息,如果请求体里面拿不到,则从queryString里面找,而从请求体获取又分为了表单和json;而action的参数又分为了基本类型的参数和复杂类型的参数,所以模型绑定的时候还需要检测被绑定的模型是基本类型还是复杂类型。
首先,我们不管有没有请求体参数过来,我们先从请求体里把内容解析成字符串出来留作之后的备用,然后检查被绑定模型的类型,如果是基本类型,比如int类型的id参数,那我们就可以先看queryString中有没有这个key,没有就从json或者表单里面去找,找到之后转换成对应的类型ConvertTo,其中的bindingContext.ModelMetadata.ParameterName拿到参数名字(id),bindingContext.ModelType拿到参数对应的类型是int。
如果是复杂类型的模型,那就检测是表单还是json,尝试从表单或反序列化json进行模型绑定,如果绑定失败,再调用框架自带的BodyBinder和ComplexBinder。
但是,就上面这段代码,也用不了啊,它还需要传入bodyBinder和complexBinder这两个框架的模型绑定器,也跟FromBody还没有任何关系啊,所以我们还需要实现一个ModelBinderProvider,让它跟FromBody产生关系:
public class BodyOrDefaultModelBinderProvider : IModelBinderProvider{private readonly BodyModelBinderProvider _bodyModelBinderProvider;private readonly ComplexObjectModelBinderProvider _complexDataModelBinderProvider;public BodyOrDefaultModelBinderProvider(BodyModelBinderProvider bodyModelBinderProvider, ComplexObjectModelBinderProvider complexDataModelBinderProvider){_bodyModelBinderProvider = bodyModelBinderProvider;_complexDataModelBinderProvider = complexDataModelBinderProvider;}public IModelBinder GetBinder(ModelBinderProviderContext context){if (context.BindingInfo.BindingSource != null && context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Body)){var bodyBinder = _bodyModelBinderProvider.GetBinder(context);var complexBinder = _complexDataModelBinderProvider.GetBinder(context);return new BodyOrDefaultModelBinder(bodyBinder, complexBinder);}return null;}}
在获取绑定器的时候,检测绑定器上下文的绑定源是否是FromBody;其中bodyBinder和complexBinder则由对应的provider提供,那么你的问题可能又来了:BodyModelBinderProvider和ComplexObjectModelBinderProvider又从哪儿来呢?
既然bodyBinder和complexBinder这两个框架的模型绑定器是框架自带的,那么BodyModelBinderProvider和ComplexObjectModelBinderProvider肯定也是框架自带的,它们就在services.AddControllers()或者services.AddMvc()的时候,ModelBinderProviders里面就已经有了。
而我们写自定义的模型绑定器,最终也是要注册到ModelBinderProviders中才会生效的,那怎么获取BodyModelBinderProvider和ComplexObjectModelBinderProvider呢?ModelBinderProviders是个抽象的IModelBinderProvider集合,我们在这个集合里面找到类型是BodyModelBinderProvider和ComplexObjectModelBinderProvider的ModelBinderProvider然后传递给我们自己的BodyOrDefaultModelBinderProvider即可,这样我们便能够注册BodyOrDefaultModelBinderProvider到ModelBinderProviders中,但是,注册的时候有个讲究,我们的目的是替换掉原始的FromBody行为,让其同时支持queryString、表单和json请求类型,所以我们直接粗暴的将BodyOrDefaultModelBinderProvider插到ModelBinderProviders的第一位即可:
builder.Services.AddControllers(options =>{options.ModelBinderProviders.Insert(0, new BodyOrDefaultModelBinderProvider(options.ModelBinderProviders.OfType<BodyModelBinderProvider>().Single(), options.ModelBinderProviders.OfType<ComplexObjectModelBinderProvider>().Single()));});
这样看起来还是不够优雅,我们稍微再弄个扩展函数封装一下:
public static class BodyOrDefaultModelBinderProviderSetup{public static void InsertBodyOrDefaultBinding(this IList<IModelBinderProvider> providers){var bodyProvider = providers.OfType<BodyModelBinderProvider>().Single();var complexDataProvider = providers.OfType<ComplexDataModelBinderProvider>().Single();providers.Insert(0, new BodyOrDefaultModelBinderProvider(bodyProvider, complexDataProvider));}}builder.Services.AddControllers(options => options.ModelBinderProviders.InsertBodyOrDefaultBinding());
这样,是否优雅了许多,且只需要在程序启动的时候注册一下BodyOrDefaultModelBinderProvider,其他的没有任何代码侵入,即可实现全局的请求参数自适应绑定。
但是,还没有完
你以为这就完了?光是上面实现的这样,我们只能支持到单个参数的action自适应,多个参数的时候程序会报错的,比如下面这个action:
[HttpPost("/test2")] public IActionResult Test([FromBody] string name, [FromBody] int age) { return Ok(new { name, age }); }
意思就是说FromBody只适用于单个参数的action,有多个参数的action它就不支持了。所以我们还需要实现一个自定义的attribute来支持这种多参数的action,那我们按照FromBody的源码抄一个吧:
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]public class FromBodyOrDefaultAttribute : Attribute, IBindingSourceMetadata{}
根据VS的提示,它还需要一个BindingSource
而框架自带的BindingSource.Body肯定是不能用的了,所以我们还需要实现一个BindingSource,并重写CanAcceptDataFrom函数,判断传入的BindingSource是否和BindingSource.Body或者当前类型的BindingSource相同即可:
public class BodyOrDefaultBindingSource : BindingSource{public static readonly BindingSource BodyOrDefault = new BodyOrDefaultBindingSource("BodyOrDefault", "BodyOrDefault", true, true);public BodyOrDefaultBindingSource(string id, string displayName, bool isGreedy, bool isFromRequest) : base(id, displayName, isGreedy, isFromRequest){}public override bool CanAcceptDataFrom(BindingSource bindingSource){return bindingSource == Body || bindingSource == this;}}
然后将BodyOrDefaultBindingSource.BodyOrDefault传递给FromBodyOrDefaultAttribute的BindingSource属性:
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]public class FromBodyOrDefaultAttribute : Attribute, IBindingSourceMetadata{public BindingSource BindingSource => BodyOrDefaultBindingSource.BodyOrDefault;}
最后再改造一下BodyOrDefaultModelBinderProvider的代码,将GetBinder函数里的判断条件改成:
if (context.BindingInfo.BindingSource != null && (context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.Body) || context.BindingInfo.BindingSource.CanAcceptDataFrom(BodyOrDefaultBindingSource.BodyOrDefault)))
到此为止,才算是完整实现了action参数模型自适应绑定的功能。
跑起来演示一遍
你以为就这样让你抄代码用?
那也太不友好了,基于上面的示例代码已经完善成了一个nuget包:Masuit.Tools.AspNetCore,你直接安装这个nuget包即可使用。
完整的源代码也上传到了github:https://github.com/ldqk/Masuit.Tools/tree/master/Masuit.Tools.AspNetCore/ModelBinder
以上均为引用原文内容,感谢大佬开源分享。
不知道原文作者使用的是哪个版本,本文使用的是 Masuit.Tools.AspNetCore-1.2.7.4 版本,引用后按照原文尝试,其中:
services.AddControllers(options => options.ModelBinderProviders.InsertBodyOrDefaultBinding());
代码中 InsertBodyOrDefaultBinding 方法已不存在,于是开始阅读作者源代码,发现作者已封装为中间件:
于是直接在Startup.cs的Configure方法中加入 app.UseBodyOrDefaultModelBinder(); 即可:
编写控制器方法:
/// <summary>/// /// </summary>/// <param name="data"></param>/// <returns></returns>[HttpPost,HttpGet]public IActionResult RequestAction([FromBodyOrDefault] ParamEntity data){ return Ok(data);}
编写完成后,使用PostMan进行测试:
POST请求:
请求结果:
GET请求:
看上去调用比较顺利,希望本文对你有帮助。