8

【ASP.NET Core】MVC模型绑定——实现同一个API方法兼容JSON和Form-data输入 - 东邪独孤

 2 years ago
source link: https://www.cnblogs.com/tcjiaan/p/16048609.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

在上一篇文章中,老周给大伙伴们大致说了下 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

[email protected]

于是,你的自定义 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 写两个版本了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK