7

【ASP.NET Core】MVC模型绑定:非规范正文内容的处理 - 东邪独孤

 2 years ago
source link: https://www.cnblogs.com/tcjiaan/p/16058491.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

【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
width:   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 对象。

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

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
 
width: 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 也可以实现同样功能的,这个老周下一篇再扯。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK