查看原文
其他

ASP.NET Core 和 JSON请求这样用真简单

DotNet 2021-09-23

The following article is from 杨中科 Author 杨中科

(给DotNet加星标,提升.Net技能

前言


本文介绍了一种在ASP.NET Core MVC/ASP.NET Core WebAPI中,将axios等前端提交的json格式请求数据,映射到Action方法的普通类型参数的方法,并且讲解了其实现原理。


一、为什么要简化json格式请求的参数绑定    


在ASP.NET Core MVC/ ASP.NET Core WebAPI(以下简称ASP.NET Core)中,可以使用[FromQuery] 从QueryString中获取参数值,也可以使用[FromForm]从表单格式(x-www-form-urlencoded)的请求中获取参数值。


随着前后端分离的流行,现在越来越多的前端请求体是json格式的,比如非常流行的AJAX前端库axios的post请求默认就是json格式的,微信小程序的请求也默认是json格式的。


在ASP.NET Core中可以通过[FromBody]来把Action的参数和请求数据绑定在一起。假如Http请求的内容为:

{“UserName”:”test”,”Password”:”123”}

那么就要先声明一个包含UserName、Password两个属性的User类,然后再把Action的参数如下声明:

public IActionResultLogin([FromBody]User u);

这样几乎每一个Action方法都要声明一个和请求对应的复杂类,如果项目中Action很多的话,也就会有非常多的“Action参数类”,不胜其烦。ASP.NET Core对于Json请求,并不能像[FromQuery]一样把Json的某个属性和简单类型的Action参数绑定到一起。

因此我开发了YouZack.FromJsonBody这个开源库,让我们可以用这样的方式来进行简单类型参数的绑定:

Test([FromJsonBody] int i2,
[FromJsonBody("author.age")]intaAge,
[FromJsonBody("author.father.name")]string dadName)

这样的Action参数可以直接从如下的Json请求中获取数据:

{"i1":1,"i2":5,"author":{"name":"yzk","age":18,"father":{"name":"laoyang","age":28}}}

二、FromJsonBody使用方法

这个库使用.NET Standard开发,因此可以支持.NET Framework及.NET Core,既支持ASP.NET Core MVC,也支持ASP.NET Core Web API。GitHub地址:https://github.com/yangzhongke/YouZack.FromJsonBody

第一步:在ASP.NET Core项目中通过NuGet安装包:

Install-Package YouZack.FromJsonBody

第二步:在项目的Startup.cs中添加using YouZack.FromJsonBody;然后在Configure方法的UseEndpoints()之前添加如下代码:

app.UseFromJsonBody();

第三步:在Controller的Action参数中[FromJsonBody]这个Attribute,参数默认从Json请求的同名的属性中绑定获取值。

如果设定FromJsonBody的PropertyName参数,则从Json请求的PropertyName这个名字的属性中绑定获取值,PropertyName的值也支持[FromJsonBody("author.father.name")]这样的多级属性绑定。

举例1,对于如下的Json请求:

{"phoneNumber":"119110","age":3,"salary":333.3,"gender":true,"dir":"west","name":"zackyang"}

客户端的请求代码:

axios.post('@Url.Action("Test","Home")',
       {phoneNumber: "119110", age: 3, salary: 333.3, gender:true,dir:"west",name:"zack yang" })
.then(function (response)
{
       alert(response.data);
})
.catch(function (error)
{
       alert('Sendfailed');
});

服务器端Controller的Action代码:

public IActionResultTest([FromJsonBody]string phoneNumber, [FromJsonBody]string test1,
       [FromJsonBody][Range(0,100,ErrorMessage="Age must be between 0 and 100")]int? age,
       [FromJsonBody]bool gender,
       [FromJsonBody]double salary,[FromJsonBody]DirectionTypes dir,
       [FromJsonBody][Required]stringname)

{
       if(ModelState.IsValid==false)
       {
              varerrors = ModelState.SelectMany(e =>e.Value.Errors).Select(e=>e.ErrorMessage);
              returnJson("Invalid input!"+string.Join("\r\n",errors));
       }
      returnJson($"phoneNumber={phoneNumber},test1={test1},age={age},gender={gender},salary={salary},dir={dir}");
}

举例2,对于如下的Json请求:

{"i1":1,"i2":5,"author":{"name":"yzk","age":18,"father":{"name":"laoyang","age":28}}}

客户端的请求代码:

axios.post('/api/API',
       {i1: 1, i2: 5, author: { name: 'yzk', age: 18, father: {name:'laoyang',age:28}}})
.then(function (response)
{
       alert(response.data);
})
.catch(function (error)
{
       alert('Sendfailed');
});

服务器端Controller的Action代码:

public async Task<int>Post([FromJsonBody("i1")] int i3, [FromJsonBody] int i2,
       [FromJsonBody("author.age")]intaAge,[FromJsonBody("author.father.name")] string dadName)
{
       Debug.WriteLine(aAge);
       Debug.WriteLine(dadName);
       returni3 + i2+aAge;
}

三、FromJsonBody原理讲解

项目的全部代码请参考GitHub地址:https://github.com/yangzhongke/YouZack.FromJsonBody
FromJsonBodyAttribute是一个自定义的数据绑定的Attribute,主要源代码如下:

public class FromJsonBodyAttribute :ModelBinderAttribute
{
       public string PropertyName { getprivate set; }
 
       public FromJsonBodyAttribute(string propertyName=null) :base(typeof(FromJsonBodyBinder))
       {
              this.PropertyName= propertyName;
       }
}

所有数据绑定Attribute都要继承自ModelBinderAttribute类,当需要尝试计算一个被FromJsonBodyAttribute修饰的参数的绑定值的时候,FromJsonBodyBinder类就会被调用来进行具体的计算。

FromJsonBody这个库的核心代码都在FromJsonBodyBinder类中。

因为FromJsonBodyBinder需要从Json请求体中获取数据,为了提升性能,我们编写了一个自定义的中间件FromJsonBodyMiddleware来进行Json请求体字符串到解析完成的内存对象JsonDocument,然后把解析完成的JsonDocument对象供后续的FromJsonBodyBinder使用。

我们在Startup中调用的UseFromJsonBody()方法就是在应用FromJsonBodyMiddleware中间件,可以看一下UseFromJsonBody()方法的源代码如下:

public static IApplicationBuilderUseFromJsonBody(this IApplicationBuilder appBuilder)
{
       return appBuilder.UseMiddleware<FromJsonBodyMiddleware>();
}

如下是FromJsonBodyMiddleware类的主要代码(全部代码见Github)

public sealed class FromJsonBodyMiddleware
{
      public const string RequestJsonObject_Key = "RequestJsonObject";
 
       private readonly RequestDelegate _next;
 
       public FromJsonBodyMiddleware(RequestDelegate next)
       {
              _next= next;
       }
 
       publicasync Task Invoke(HttpContext context)
       {
              string method = context.Request.Method;
              if(!Helper.ContentTypeIsJson(context, out string charSet)
                     ||"GET".Equals(method,StringComparison.OrdinalIgnoreCase))
              {
                     await _next(context);
                     return;
              }
              Encoding encoding;
              if(string.IsNullOrWhiteSpace(charSet))
              {
                     encoding= Encoding.UTF8;
              }
              else
              {
                     encoding = Encoding.GetEncoding(charSet);
              }    
 
              context.Request.EnableBuffering();
              int contentLen = 255;
              if(context.Request.ContentLength != null)
              {
                     contentLen= (int)context.Request.ContentLength;
              }
              Streambody = context.Request.Body;
              string bodyText;
              if(contentLen<=0)
              {
                     bodyText= "";
              }
              else
              {
                     using(StreamReader reader = new StreamReader(body, encoding, true, contentLen,true))
                     {
                            bodyText= await reader.ReadToEndAsync();
                     }
              }
              if(string.IsNullOrWhiteSpace(bodyText))
              {
                     await_next(context);
                     return;
              }
              if(!(bodyText.StartsWith("{")&&bodyText.EndsWith("}")))
              {
                     await _next(context);
                     return;
              }
             
              try
              {
                   using(JsonDocument document =
                        JsonDocument.Parse(bodyText))
                     {
                            body.Position= 0;
                            JsonElementjsonRoot = document.RootElement;
                            context.Items[RequestJsonObject_Key]= jsonRoot;
                            await _next(context);
                     }
              }
              catch(JsonExceptionex)
              {
                     await _next(context);
                     return;
              }
       }
}

每个Http请求到达服务器的时候,Invoke都会被调用。因为Get请求一般不带请求体,所以这里对于Get请求不处理;同时对于请求的ContentType不是application/json的也不处理,这样可以避免无关请求被处理的性能影响。

为了减少内存占用,默认情况下,ASP.NETCore中对于请求体的数据只能读取一次,不能重复读取。FromJsonBodyMiddleware需要读取解析请求体的Json,但是后续的ASP.NET Core的其他组件也可能会还要再读取请求体,因此我们通过Request.EnableBuffering()允许请求体的多次读取,这样会对内存占用有轻微的提升。不过一般情况下Json请求的请求体都不会太大,所以这不会是一个严重的问题。接下来,使用.NET 新的Json处理库System.Text.Json来进行Json请求的解析:

JsonDocument document =JsonDocument.Parse(bodyText)

解析完成的Json对象放到context.Items中,供FromJsonBodyBinder使用:

context.Items[RequestJsonObject_Key]= jsonRoot

下面是FromJsonBodyBinder类的核心代码:

public class FromJsonBodyBinder :IModelBinder
{
 public static readonly 
IDictionary<string, FromJsonBodyAttribute>
fromJsonBodyAttrCache 
                = new ConcurrentDictionary<string,FromJsonBodyAttribute>();
 
   public Task BindModelAsync(ModelBindingContext bindingContext)
       {
              var key = FromJsonBodyMiddleware.RequestJsonObject_Key;
  objectitemValue =
        bindingContext.ActionContext.HttpContext.Items[key];
              JsonElement jsonObj =(JsonElement)itemValue;
              string fieldName = bindingContext.FieldName;
              FromJsonBodyAttribute fromJsonBodyAttr =
                GetFromJsonBodyAttr(bindingContext, fieldName);
              if(!string.IsNullOrWhiteSpace(fromJsonBodyAttr.PropertyName))
              {
                     fieldName =fromJsonBodyAttr.PropertyName;
              }
 
              object jsonValue;
              if(ParseJsonValue(jsonObj, fieldName, out jsonValue))
              {
                     objecttargetValue =
                        jsonValue.ChangeType(bindingContext.ModelType);
                     bindingContext.Result=
                        ModelBindingResult.Success(targetValue);
              }
              else
              {
                     bindingContext.Result= ModelBindingResult.Failed();
              }
              return Task.CompletedTask;
       }
 
       private static bool ParseJsonValue(JsonElement jsonObj, string fieldName, out objectjsonValue)
       {
              int firstDotIndex = fieldName.IndexOf('.');
              if(firstDotIndex>=0)
              {
               string firstPropName = fieldName.Substring(0, firstDotIndex);
                     string leftPart = fieldName.Substring(firstDotIndex + 1);
                     if(jsonObj.TryGetProperty(firstPropName,out JsonElement firstElement))
                     {
                            return ParseJsonValue(firstElement, leftPart, out jsonValue);
                     }
                     else
                     {
                            jsonValue= null;
                            return false;
                     }
              }
              else
              {
                     bool b = jsonObj.TryGetProperty(fieldName, out JsonElement jsonProperty);
                     if(b)
                     {
                            jsonValue= jsonProperty.GetValue();
                     }
                     else
                     {
                            jsonValue= null;
                     }
                     return b;
              }           
       }
 
private static FromJsonBodyAttribute GetFromJsonBodyAttr
    (ModelBindingContext bindingContext, string fieldName)

       {
              var actionDesc = 
                bindingContext.ActionContext.ActionDescriptor;
              string actionId = actionDesc.Id;
              string cacheKey = $"{actionId}:{fieldName}";
 
              FromJsonBodyAttribute fromJsonBodyAttr;
              if(!fromJsonBodyAttrCache.TryGetValue
                (cacheKey, out fromJsonBodyAttr))
              {
                     var ctrlActionDesc =
    bindingContext.ActionContext.ActionDescriptor as ControllerActionDescriptor;
                     var fieldParameter =
                        ctrlActionDesc.MethodInfo.GetParameters()
                        .Single(p =>p.Name == fieldName);
                     fromJsonBodyAttr=fieldParameter.GetCustomAttributes(typeof(FromJsonBodyAttribute),false)
.Single() as FromJsonBodyAttribute;
                     fromJsonBodyAttrCache[cacheKey]= fromJsonBodyAttr;
              }           
              return fromJsonBodyAttr;
       }
}

下面对FromJsonBodyBinder类的代码做一下分析,当对一个标注了[FromJsonBody]的参数进行绑定的时候,BindModelAsync方法会被调用,绑定的结果(也就是计算后参数的值)要设置到bindingContext.Result中,如果绑定成功就设置:ModelBindingResult.Success(绑定的值),如果因为数据非法等导致绑定失败就设置ModelBindingResult.Failed()在FromJsonBodyBinder类的BindModelAsync方法中,首先从bindingContext.ActionContext.HttpContext.Items[key]中把FromJsonBodyMiddleware中解析完成的JsonElement取出来。

如果Action有5个参数,那么BindModelAsync就会被调用5次,如果每次BindModelAsync都去做“Json请求体的解析”将会效率比较低,这样在FromJsonBodyMiddleware中提前解析好就可以提升数据绑定的性能。

接下来调用自定义方法GetFromJsonBodyAttr取到方法参数上标注的FromJsonBodyAttribute对象,检测一下FromJsonBodyAttribute上是否设置了PropertyName:如果设置了的话,就用PropertyName做为要绑定的Json的属性名;如果没有设置PropertyName,则用bindingContext.FieldName这个绑定的参数的变量名做为要绑定的Json的属性名。

接下来调用自定义方法ParseJsonValue从Json对象中取出对应属性的值,由于从Json对象中取出来的数据类型可能和参数的类型不一致,所以需要调用自定义的扩展方法ChangeType()进行类型转换。

ChangeType方法就是对Convert.ChangeType的封装,然后对于可空类型、枚举、Guid等特殊类型做了处理,具体到github上看源码即可。

自定义的ParseJsonValue方法中通过简单的递归完成了对于"author.father.name"这样多级Json嵌套的支持。

firstPropName变量就是取出来的” author”, leftPart变量就是剩下的"father.name",然后递归调用ParseJsonValue进一步计算。

自定义的GetFromJsonBodyAttr方法使用反射获得参数上标注的FromJsonBodyAttribute对象。为了提升性能,这里把获取的结果缓存起来。

非常幸运的是,ASP.NET Core中的ActionDescriptor对象有Id属性,用来获得一个Action方法唯一的标识符,再加上参数的名字,就构成了这个缓存项的Key。

四、总结

Zack.FromJsonBody可以让ASP.NET Core MVC和ASP.NET Core WebAPI程序的普通参数绑定到Http请求的Json报文体中。这个开源项目已经被youzack.com这个英语学习网站一年的稳定运行验证,各位可以放心使用。

希望这个开源项目能够帮助大家,欢迎使用过程中反馈问题,如果感觉好用,欢迎推荐给其他朋友。

- EOF -


推荐阅读  点击标题可跳转
ASP.NET Core 扩展库.NET Core 插件化开发理解C#泛型运作原理


看完本文有收获?请转发分享给更多人

推荐关注「DotNet」,提升.Net技能 

点赞和在看就是最大的支持❤️

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存