• 【ASP.NET Core】MVC模型绑定:非规范正文内容的处理


    本篇老周就和老伙伴们分享一下,对于客户端提交的不规范 Body 如何做模型绑定。不必多说,这种情况下,只能自定义 ModelBinder 了。而且最佳方案是不要注册为全局 Binder——毕竟这种特殊情况是针对极少数情形的,咱们没必要去干扰标准格式的正常运行(情况复杂,特殊 binder 注册为全局很危险,弄不好容易出“八阿哥”)。

    你可能会说,用标准的 JSON 或 XML 不香吗,为什么要做不规范的数据?你可别说,实际开发中,就是有不少思维奇葩的客户,提出各种连外星人都感到神经病的需要。所以,倒了九辈子大霉遇到这样的需求,就不得不根据实际数据格式来自定义绑定了。

    OK,老周深刻意识到,讲再多的理论各位都不感兴趣的,人们更习惯于听故事,不然的话为什么正史所记录的事情知者甚少,而很多虚构的故事会在民间大量传播。比如“狸猫换太子”什么的,都是故事,一只死猫能把皇子换掉,你以为皇宫是菜市场呢。

    这里有这样的故事:某API,参数是 Room 对象。Room 类型有三个属性——Length、Width、Height,它们的类型都是浮点(float)。然后这个 API 调用时怎么 POST 的呢?三行文本,每一行表示一个属性,格式是 name: value,并且不区分大小写。也就是说,调用 API 时,正文部分这样传:

    length:  3.5
       0.7
    height:  12.5

    也可能这样:

    LENGTH: 5.5
    WIDTH:   10.0
    HEIGHT:   2.7

    还可能是这样:

    Length: 50.2
    Width:   11.55
    Height:  14.3

    所以,待会儿咱们实现属性名称查找时,统一转为小写,这样好比较。

    下面,开始干活。

    1、定义模型

    public class Room
    {
        public float Length { get; set; }
        public float Width { get; set; }
        public float Height { get; set; }
    }

    2、定义 API 控制器

    [ApiController, Route("api/test")]
    public class WhatController : ControllerBase
    {
        [HttpPost, Route("abc")]
        public float TestDone(Room rom)
        {
            if (rom.Height <= 0f || rom.Width <= 0f || rom.Length <= 0f)
                return -1f;
            return rom.Height * rom.Width * rom.Length;
        }
    }

    3、自定义 ValueProvider

    由于在本例中,body 的内容既不是标准的 XML 和 JSON,也不是 Form-data 结构,所以 .NET Core 默认注册的几个 ValueProvider 是不起作用的。咱们得自己实现一个。

        public class CustValueProvider : IValueProvider
        {
            private readonly IDictionary<string, string> innerDic;
    
            public CustValueProvider(HttpContext ctx)
            {
                innerDic = new Dictionary<string, string>();
                var req = ctx.Request;
                HttpRequestStreamReader reader = new(req.Body, Encoding.UTF8);
    string? line;
                // 一行一行地读
                for (line = reader.ReadLineAsync().GetAwaiter().GetResult(); line != null; line = reader.ReadLineAsync().GetAwaiter().GetResult())
                {
                    string[] parts = line!.Split(":");
                    // 左边是name,右边是value
                    string name = parts[0].Trim().ToLower();
                    string value = parts[1].Trim();
                    innerDic[name] = value;
                }
                reader.Dispose();
            }
    
            public bool ContainsPrefix(string prefix)
            {
                return true;
            }
    
            public ValueProviderResult GetValue(string key)
            {
                if (!innerDic.ContainsKey(key))
                    return ValueProviderResult.None;
                return new ValueProviderResult(innerDic[key]);
            }
        }

    通过构造函数的参数获得 HttpContext 对象,这样就可以访问到 Body,它是个流对象。

    然后使用一个叫 HttpRequestStreamReader 的类,它位于 Microsoft.AspNetCore.WebUtilities 命名空间。这个类很好用,可以读文本文件那样读 Body。这里咱们一行一行地读就行,注意要调用 ReadLineAsync 方法,不能调用同步方法(除非你配置 Kestrel 充许同步调用)。异步调用可以确保响应能力,所以还是推荐异步调用。不过,此处是从构造函数调用的,就同步获其结果了。

    分析过程是这样的:读出一行文本,然后用英文的冒号分隔字符串,变成一个二元素数组,[0] 就是 name,[1] 就是 value 了。把所有分析出来的东东都添加到字典对象中,方便后面提取值。

    ContainsPrefix 方法是分析是否包含前缀,比如前X篇文章中提到的像 stu.name、stu.age 等,stu 就是前缀。这此咱们不考虑这个,所以直接返回 true。

    GetValue 方法是关键,这是供给外面调用的规范方法,通过 key 从字典对象中查找出值。查找的 key 用的就是 Room 类的属性名称。

    4、自定义 ModelBinder

        public class RoomBinder : IModelBinder
        {
            public Task BindModelAsync(ModelBindingContext bindingContext)
            {
                // 参数不能为 null
                if (bindingContext == null)
                    throw new ArgumentNullException(nameof(bindingContext));
    
                // 特殊处理,把全局的 ValueProvider 替换
                bindingContext.ValueProvider = new CustValueProvider(bindingContext.HttpContext);
    
                var objMetadata = bindingContext.ModelMetadata;
                // 目标类型有三个属性
                if (objMetadata.Properties.Count == 0)
                    return Task.CompletedTask;
    
                Type modeltype = objMetadata.ModelType;
                // 创建实例
                object? modelObj = Activator.CreateInstance(modeltype);
                if (modelObj is null)
                    return Task.CompletedTask;
    
                // 给属性赋值
                foreach (var prop in objMetadata.Properties)
                {
                    // 找到 key
                    string key = prop.Name!.ToLower();
                    var wrapValue = bindingContext.ValueProvider.GetValue(key);
                    if (wrapValue == ValueProviderResult.None)
                        continue;   //无值,有请下一位
                    // 取内容
                    string realVal = wrapValue.FirstValue!;
                    // null 或者长度为 0,不可用
                    if (realVal is null or { Length: 0 })
                        continue;   //没有用的值,有请下一位
                    // 看看能不能转换
                    var proptype = prop.ModelType;  //这是属性值的类型
                    object? propval;
                    try
                    {
                        // 把取出的字符串转换为属性值的类型
                        propval = Convert.ChangeType(realVal, proptype);
                    }
                    catch { continue; }
                    // 设置属性值
                    // PropertyGetter:获取属性的值
                    // PropertySetter:设置属性的值
                    if (propval != null && prop.PropertySetter != null)
                    {
                        prop.PropertySetter(modelObj!, propval);
                    }
                }
                // 重要:一定要设置绑定的结果
                bindingContext.Result = ModelBindingResult.Success(modelObj);
    
                return Task.CompletedTask;
            }
        }

    由于是针对特殊情形的绑定,所以这里老周就不分析 Content-Type,假设它任意内容均可,文本提交一般用 text/* 或者明确一点 text/plain。

    这里咱们也不使用全局的 ValueProvider,所以把 bindingContext 的也替换掉。

       bindingContext.ValueProvider = new CustValueProvider(bindingContext.HttpContext);

    因为针对性强,这里其实你可以直接 new 一个 Room 实例,然后从 ValueProvider 中找出三个属性的值,直接转换为 float 值,赋给对象的三个属性即可。不过,老周为了装逼,写得复杂了一点。

    ModelMetadata 包含目标类型相关的信息,比如它的 Type,它有几个属性(Properties),每个属性也能获取到 ModelMetaData,表示属性值的类型信息。

    所以,首先咱们从 ModelType 得到 Room 类的 Type,接着,用 Activator 来创建它的实例。

    从 Properties 中取出各个属性的 ModelMetaData,并根据属性名(有定义属性的类型通常不会为null)来查找出各属性的值,类型是字符串。然后获取属性值的 Type,再用 Convert.ChangeType 做类型转换,转为属性值的类型(这里其实是 float)。

    再通过 PropertySetter 来设置属性的值,它是委托类型,和属性的 set 访问器绑定。

    最后 ModelBindingResult.Success 设置填充 Room 对象。

    补充:忘了最后一步,要在 Room 类的定义上应用 ModelBinder 特性。

    [ModelBinder(typeof(RoomBinder))]
    public class Room
    {
        ……
    }

    ------------------------------------------------------------------------------------------------

    测试

    POST /api/test/abc HTTP/1.1
    Content-Type: text/plain
    User-Agent: PostmanRuntime/7.29.0
    Accept: */*
    Host: localhost:5018
    Accept-Encoding: gzip, deflate, br
    Connection: keep-alive
    Content-Length: 36
     
     3.6
    height: 5.5
    length: 8.0
     
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    Date: Sat, 26 Mar 2022 05:00:16 GMT
    Server: Kestrel
    Transfer-Encoding: chunked
     
    158.4

    好了,这个特殊绑定就顺利完成了。

    其实,自定义 InputFormatter 也可以实现同样功能的,这个老周下一篇再扯。

  • 相关阅读:
    CentOS 7基于Containerd部署Kubernetes v1.20.5
    【k8s记录】二进制安装kubernetes高可用集群全过程完整版v1.20
    前台后台$.psot交互
    $.ajax与$.post、$.get的一点区别
    layui与jQuery一起使用
    用Emmet写前端代码
    栅格布局的实现过程,容器到列的划分
    bootstrap环境
    2个爬虫
    think PHP5中,模板、控制器、JavaScript的url跳转重定向方法
  • 原文地址:https://www.cnblogs.com/tcjiaan/p/16058491.html
Copyright © 2020-2023  润新知