你所不知道的ASP.NET Core MVC/WebApi基础系列 (二)

转自博客:https://www.cnblogs.com/CreateMyself/p/10604293.html

前言

本节内容,我们来讲讲.NET Core当中的模型绑定系统、模型绑定原理、自定义模型绑定、混合绑定、ApiController特性本质,可能有些园友已经看过,但是效果不太好哈,这篇是解释最为详细的一篇,建议已经学过我发布课程的童鞋也看下,本篇内容略长,请保持耐心,我只讲你们会用到的或者说能够学到东西的内容。 

模型绑定系统

对于模型绑定,.NET Core给我们提供了[BindRequired]、[BindNever]、[FromHeader]、[FromQuery]、[FromRoute]、[FromForm]、[FromServices]、[FromBody]等特性,[BindRequired]和[BindNever]翻译成必须绑定,从不绑定我们称之为行为绑定,而紧跟后面的五个From,翻译成从哪里来,我们称之为来源绑定,下面我们详细介绍这两种绑定类型,本节内容使用版本.NET Core 2.2版本。

行为绑定

 [BindRequired]表示参数的键必须要提供,但是并不关心参数的值是否为空,[BindNever]表示忽略对属性的绑定,行为绑定看似很简单,其实不然,待我娓娓道来,首先我们来看如下代码片段。

    public class Customer
    {
        [BindNever]
        public int Id { get; set; }
    }

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost]
        public IActionResult Post(Customer customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }
    }

上述我们定义了一个Customer类,然后类中的id字段通过[BindNever]特性进行标识,接下来我们一切都通过Postman来发出请求

当我们如上发送请求时,响应将返回状态码200成功且id没有绑定上,符合我们的预期,其意思就是从不绑定属性id,好接下来我们将控制器上的Post方法参数添加[FromBody]标识看看,代码片段变成如下: 

  [HttpPost]
  public IActionResult Post([FromBody]Customer customer)
  {
      if (!ModelState.IsValid)
       {
         return BadRequest(ModelState);
       }
       return Ok();
   }    

  这是为何,我们通过[FromBody]特性标识后,此时也将属性id加上了[BindNever]特性(代码和如上一样,不重复贴了),结果id绑定上了,说明[BindNever]特性对通过[FromBody]特性标识的参数无效,情况真的是这样吗?接下来我们尝试将[BindNever]绑定到对象看看,如下: 

    public class Customer
    {
        public int Id { get; set; }
    }

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost]
        public IActionResult Post([BindNever][FromBody]Customer customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }
    }

  上述我们将[BindNever]绑定到对象Customer上,同时对于[BindNever]和[FromBody]特性没有先后顺序,也就是说我们也可以将[FromBody]放在[BindNever]后面,接下来我们利用Postman再次发送如下请求。

 

  此时我们可以明确看到,我们发送的请求包含id字段,且此时我们将[BindNever]绑定到对象上时,最终id则没绑定到对象上,达到我们的预期且验证通过,但是话说回来,将[BindNever]绑定到对象上毫无意义,因为此时对象上所有属性都将会被忽略。所以到这里我们可以得出[BindNever]对于[FromBody]特性请求的结论:

 

对于使用【FromBody】特性标识的请求,【BindNever】特性应用到模型上的属性时,此时绑定无效,应用到模型对象上时,此时将完全忽略对模型对象上的所有属性

对于来自URL或者表单上的请求,【BindNever】特性应用到模型上的属性时,此时绑定无效,应用到模型对象时,此时将完全忽略对模型对象上的所有属性

 

 好了,接下来我们再来看看[BindRequired],我们继续给出如下代码: 

    public class Customer
    {
        [BindRequired]
        public int Id { get; set; }
    }

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost]
        public IActionResult Post(Customer customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }
    }

  通过[BindRequired]特性标识属性,我们基于表单的请求且未给出属性id的值,此时属性未绑定上且验证未通过,符合我们预期。接下来我们再来看看【FromBody】特性标识的请求,代码就不给出了,我们只是在对象上加上了[FromBody]而已,我们看看最终结果。

 

  此时从表面上看好像达到了我们的预期,在这里即使我们对属性id不指定【BindRequired】特性,结果也是一样验证未通过,这是为何,因为默认情况下,在.NET Core中对于【FromBody】特性标识的对象不可为空,内置进行了处理,我们进行如下设置允许为空。 

services.AddMvc(options=> 
{
    options.AllowEmptyInputInBodyModelBinding = true;
}).SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

  到这里我们发现我们对属性Age添加了【BindRequired】特性,此时验证却是通过的,我们再加思考一番,或许是我们给定的属性Age是int有默认值为0,所以验证通过,好想法,你可以继续添加一个字符串类型的属性,然后添加【BindRequired】特性,同时最后请求中不包含该属性,此时结果依然是验证通过的(不信自己试试)。

  此时我们发现通过[FromBody]特性标识的请求,我们将默认对象不可空的情况排除在外,说明[BindRequired]特性标识的属性对[FromBody]特性标识的请求无效,同时呢,我们转到[BindRequired]特性的定义有如下解释:

 // 摘要:
// Indicates that a property is required for model binding. When applied to a property, the model binding system requires a value for that property. When applied to
// a type, the model binding system requires values for all properties that type defines.

   翻译过来不难理解,当我们通过[BindRequired]特性标识时,说明在模型绑定时属性是必须给出的,当应用到属性时,要求模型绑定系统必须验证此属性的值必须要给出,当应用到类型时,要求模型绑定系统必须验证类型中定义的所有属性必须有值。这个解释让我们无法信服,对于基于URL或者基于表单的请求和【FromBody】特性的请求明显有区别,但是定义却是一概而论。到这里我们遗漏到了一个【Required】特性,我们添加一个Address属性,然后请求中不包含Address属性 

    public class Customer
    {
        [BindRequired]
        public int Id { get; set; }
        [BindRequired]
        public int Age { get; set; }
        [Required]
        public string Address { get; set; }
    }

  从上图看出使用【FromBody】标识的请求,通过Required特性标识属性也符合预期,当然对于URL和表单请求也符合预期,在此不再演示。我并未看过源码,我大胆猜测下是否是如下原因才有其区别呢(个人猜测)

   解释都在强调模型绑定系统,所以在.NET Core中出现的【BindNever】和【BindRequired】特性专为.NET Core MVC模型绑定系统而设计,而对于【FromBody】特性标识后,因为其进行属性的序列化和反序列化与Input Formatter有关,比如通过JSON.NET,所以至于属性的忽略和映射与否和我们使用序列化和反序列化的框架有关,由我们自己来定义,比如使用JSON.NET则属性忽略使用【JsonIgnore】。

  所以说基于【FromBody】特性标识的请求,是否映射,是否必须由我们使用的序列化和反序列化框架决定,在.NET Core中默认是JSON.NET,所以对于如上属性是否必须提供,我们需要使用JSON.NET中的Api,比如如下:

    public class Customer
    {
        [JsonProperty(Required = Required.Always)]
        public int Id { get; set; }

        [JsonProperty(Required = Required.Always)]
        public int Age { get; set; }
    }

  请求参数安全也是需要我们考虑的因素,比如如下我们对象包含IsAdmin属性,我们后台会根据该属性值判断是否为对应角色进行UI的渲染,我们可以通过[Bind]特性应用于对象指定映射哪些属性,此时请求中参数即使显式指定了该参数值也不会进行映射(这里仅仅只是举例说明,例子可能并非合理),代码如下:

    public class Customer
    {
        public int Id { get; set; }
        public int Age { get; set; }
        public string Address { get; set; }
        public bool IsAdmin { get; set; }
    }

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost]
        public IActionResult Post(
            [Bind(nameof(Customer.Id),nameof(Customer.Age),nameof(Customer.Address)
           )] Customer customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }
    }

 

 

来源绑定

  在.NET Core中出现了不同的特性,比如上述我们所讲解的行为绑定,然后是接下来我们要讲解的来源绑定,它们出现的意义和作用在哪里呢?它比.NET中的模型绑定更加灵活,而不是一样,为何灵活不是我嘴上说说而已,通过实际例子证明给你看,每一个新功能或特性的出现是为了解决对应的问题或改善对应的问题,首先我们来看如下代码: 

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost("{id:int}")]
        public IActionResult Post(int id, Customer customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }
    }

  我们通过路由指定id为4,然后url上指定为2,你猜映射到后台id上的参数结果是4还是2呢,在customer上的参数id是4还是2呢?

  从上图我们看到id是4,而customer对象中的id值为2,我们从中可以得出一个什么结论呢,来,我们进行如下总结。

  在.NET Core中,默认情况下参数绑定存在优先级,路由的优先级大于表单的优先级,表单的优先级大于URL的优先级即(路由>表单>URL)

  这是默认情况下的优先级,为什么说在.NET Core中非常灵活呢,因为我们可以通过来源进行显式绑定,比如强制指定id来源于查询字符串,而customer中的id源于查询路由,如下:

[HttpPost("{id:int}")]
public IActionResult Post([FromQuery]int id, [FromRoute] Customer customer)
{
  if (!ModelState.IsValid)
  {
      return BadRequest(ModelState);
   }
    return Ok();
}

  还有什么[FromForm]、[FromServices]、[FromHeader]等来源绑定都是强制指定参数到底是来源于表单、请求头、查询字符串、路由还是Body,到这里无需我再过多讲解了,一个例子足以说明其灵活性。

模型绑定(强大支持举例)

  上述讲解来源绑定我们认识到其灵活性,可能有部分童鞋压根都不知道.NET Core中对模型绑定的强大支持,哪里强大了,在讲解模型绑定原理之前,来给大家举几个实际的例子来说明,首先我们来看如下请求代码:

  对于如上请求,我们大部分的做法则是通过如下创建一个类来接受上述URL参数。 

    public class Example
    {
        public int A { get; set; }
        public int B { get; set; }
        public int C { get; set; }
    }

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpGet]
        public IActionResult Post(Example employee)
        {
            return Ok();
        }
    }

  这种常见做法在ASP.NET MVC/Web Api中也是支持的,好了,接下来我们将上述控制器代码进行如下修改后在.NET Core中是支持的,而在.NET MVC/Web Api中是不支持的,不信,您可以试试。 

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpGet]
        public IActionResult Get(Dictionary<string, int> pairs)
        {
            return Ok();
        }
    }

  至于在.NET Core中为何能够绑定上,主要是在.NET Core实现了字典的DictionaryModelBinder,所以可以将URL上的参数当做字典的键,而参数值作为键对应的值,看的不过瘾,对不对,好,接下来我们看看如下请求,您觉得控制器应该如何接收URL上的参数呢?

 

  大胆发挥您的想象,在我们的控制器Action方法上,我们如何去接收上述URL上的参数呢?好了,不卖关子了 

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpGet]
        public IActionResult Post(List<Dictionary<string, int>> pairs)
        {
            return Ok();
        }
    }

  是不是说明.NET Core就不支持了呢?显然不是,我们将参数名称需要修改一致才行,我们将URL上的参数名称修改为和控制器方法上的参数一致(当然类型也要一致,否则也会映射不上),如下:

 

  好了,见识到.NET Core中模型绑定系统的强大,接下来我们快马加鞭去看看模型绑定原理是怎样的吧,GO。 

模型绑定原理

  了解模型绑定原理有什么作用呢?当.NET Core提供给我们的模型绑定系统不满足我们的需求时,我们可以自定义模型绑定来实现我们的需求,这里我简单说下整个过程是这样的,然后呢,给出我画的一张详细图关于模型绑定的整个过程是这样。当我们在startup中使用services.AddMvc()方法时,说明我们会使用MVC框架,此时在背后对于模型绑定做了什么呢?

【1】初始化ModelBinderProviders集合,并向此集合中添加16个已经实现的ModelBinderProvider

【2】初始化ValuesProviderFactories集合,并向此集合中添加4个ValueFactory

【3】以单例形式注入<IModelBinderFactory,ModelBinderFactory>

【4】添加其他模型元数据信息

接下来到底是怎样将参数进行绑定的呢?首先我们来定义一个IModelBinder接口,如下:

    public interface IModelBinder
    {
        Task BindModelAsync(ModelBindingContext bindingContext);
    }

  那这个接口用来干嘛呢,通过该接口中定义的方法名称我们就知道,这就是最终我们得到的ModelBinder,继而通过绑定上下文来绑定参数, 那么具体ModelBinder又怎么来呢?接下来定义IModelBinderProvder接口,如下:

    public class ModelBinderFactory : IModelBinderFactory
    {
        public IModelBinder CreateBinder(ModelBinderFactoryContext context)
        {
            .....
        }
    }

  那这个方法内部是如何实现的呢?其实很简单,也是在我们添加MVC框架时,初始了16个具体ModelBinderProvider即List<IModelBinderProvider>,此时在这个方法里面去遍历这个集合,此时上述方法内部实现变成如下伪代码:

    public class ModelBinderFactory : IModelBinderFactory
    {
        public IModelBinder CreateBinder(ModelBinderFactoryContext context)
        {
            IModelBinderProvider[] _providers;
            IModelBinder result = null;

            for (var i = 0; i < _providers.Length; i++)
            {
                var provider = _providers[i];
                result = provider.GetBinder(providerContext);
                if (result != null)
                {
                    break;
                }
            }
        }
    }

  至于它如何得到是哪一个具体的ModelBinderProvider的,这就涉及到具体细节实现了,简单来说根据绑定来源(Bindingsource)以及对应的元数据信息而得到,有想看源码细节的童鞋,可将如下图下载放大后去看。

自定义模型绑定

  简单讲了下模型绑定原理,更多细节参看上述图查看,接下来我们动手实践下,通过上述从整体上的讲解,我们知道要想实现自定义模型绑定,我们必须实现两个接口,实现IModelBinderProvider接口来实例化ModelBinder,实现IModelBinder接口来将参数进行绑定,最后呢,将我们自定义实现的ModelBinderProvider添加到MVC框架选项中的ModelBinderProvider集合中去。首先我们定义如下类: 

    public class Employee
    {
        [Required]
        public decimal Salary { get; set; }
    }

  我们定义一个员工类,员工有薪水,如果公司遍布于全世界各地,所以对于各国的币种不一样,假设是中国员工,则币种为人民币,假设一名中国员工薪水为10000人民币,我们想要将【¥10000】绑定到Salary属性上,此时我们通过Postman模拟请求看看。

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost]
        public IActionResult Post(Employee customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }

  从如上图响应结果看出,此时默认的模型绑定系统将不再适用,因为我们加上了币种符号,所以此时我们必须实现自定义的模型绑定,接下来我们通过两种不同的方式来实现自定义模型绑定。

 

货币符号自定义模型绑定方式(一)

  我们知道对于货币符号可以通过NumberStyles.Currency来指定,有了解过模型绑定原理的童鞋应该知道对于在.NET Core默认的ModelBinderProviders集合中并有DecimalModelBinderProvider,而是FloatingPointTypeModelBinderProvider来支持货币符号,而对应背后的具体实现是DecimalModelBinder,所以我们大可借助于内置已经实现的DecimalModelBinder来实现自定义模型绑定,所以此时我们仅仅只需要实现IModelBinderProvider接口,而IModelBinder接口对应的就是DecimalModelBinder内置已经实现,代码如下: 

    public class RMBModelBinderProvider : IModelBinderProvider
    {
        private readonly ILoggerFactory _loggerFactory;
        public RMBModelBinderProvider(ILoggerFactory loggerFactory)
        {
            _loggerFactory = loggerFactory;

        }
        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            //元数据为复杂类型直接跳过
            if (context.Metadata.IsComplexType)
            {
                return null;
            }

            //上下文中获取元数据类型非decimal类型直接跳过
            if (context.Metadata.ModelType != typeof(decimal))
            {
                return null;
            }
            
            return new DecimalModelBinder(NumberStyles.Currency, _loggerFactory);
        }
    }

  接下来则是将我们上述实现的RMBModelBinderProvider添加到ModelBinderProviders集合中去,这里需要注意,我们知道最终得到具体的ModelBinder,内置是采用遍历集合而实现,一旦找到直接跳出,所以我们将自定义实现的ModelBinderProvider强烈建议添加到集合中首位即使用Insert方法,而不是Add方法,如下: 

services.AddMvc(options =>
{
    var loggerFactory = _serviceProvider.GetService<ILoggerFactory>();
    options.ModelBinderProviders.Insert(0, new RMBModelBinderProvider(loggerFactory));
}).SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

货币符号自定义模型绑定方式(二)

 上述我们是采用内置提供给我们的DecimalModelBinder解决了货币符号问题,接下来我们将通过特性来实现指定属性为货币符号,首先我们定义如下接口解析属性值是否成功与否

    public interface IRMB
    {
        decimal RMB(string modelValue, out bool success);
    }

然后写一个如下RMB属性特性实现上述接口。

    [AttributeUsage(AttributeTargets.Property)]
    public class RMBAttribute : Attribute, IRMB
    {
        private static NumberStyles styles = NumberStyles.Currency;
        private CultureInfo CultureInfo = new CultureInfo("zh-cn");
        public decimal RMB(string modelValue, out bool success)
        {
            success = decimal.TryParse(modelValue, styles, CultureInfo, out var valueDecimal);
            return valueDecimal;
        }
    }

  接下来我们则是实现IModelBinderProvider接口,然后在此接口实现中去获取模型元数据类型中的属性是否实现了上述RMB特性,如果是,我们则实例化ModelBinder并将RMB特性传递过去并得到其值,完整代码如下:

    public class RMBAttributeModelBinderProvider : IModelBinderProvider
    {
        private readonly ILoggerFactory _loggerFactory;
        public RMBAttributeModelBinderProvider(ILoggerFactory loggerFactory)
        {
            _loggerFactory = loggerFactory;

        }
        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            if (!context.Metadata.IsComplexType)
            {
                var propertyName = context.Metadata.PropertyName;
                var propertyInfo = context.Metadata.ContainerMetadata.ModelType.GetProperty(propertyName);
                var attribute = propertyInfo.GetCustomAttributes(typeof(RMBAttribute), false).FirstOrDefault();
                if (attribute != null)
                {
                    return new RMBAttributeModelBinder(context.Metadata.ModelType, attribute as RMBAttribute, _loggerFactory);
                }
            }
            return null;
        }
    }
    public class RMBAttributeModelBinder : IModelBinder
    {
        IRMB rMB;
        private SimpleTypeModelBinder modelBinder;
        public RMBAttributeModelBinder(Type type, RMBAttribute attribute, ILoggerFactory loggerFactory)
        {
            rMB = attribute as IRMB;
            modelBinder = new SimpleTypeModelBinder(type, loggerFactory);
        }
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var modelName = bindingContext.ModelName;
            var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
            if (valueProviderResult != ValueProviderResult.None)
            {
                bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);
                var valueString = valueProviderResult.FirstValue;
                var result = rMB.RMB(valueString, out bool success);
                if (success)
                {
                    bindingContext.Result = ModelBindingResult.Success(result);
                    return Task.CompletedTask;
                }
            }
            return modelBinder.BindModelAsync(bindingContext);
        }
    }

  最后则是添加到集合中去并在属性Salary上使用RMB特性,比如ModelBinderContext和ModelBinderProviderContext上下文是什么,无非就是模型元数据和一些参数罢了,这里就不一一解释了,自己调试还会了解的更多。如下:

services.AddMvc(options =>
{
  var loggerFactory = _serviceProvider.GetService<ILoggerFactory>();
  options.ModelBinderProviders.Insert(0, new RMBAttributeModelBinderProvider(loggerFactory));
}).SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
public class Employee
{
    [Required]
    [RMB]
    public decimal Salary { get; set; }
}

混合绑定 

什么是混合绑定呢?就是将不同的绑定模式混合在一起使用,有的人可说了,你这和没讲有什么区别,好了,我来举一个例子,比如我们想将URL上的参数绑定到【FromBody】特性的参数上,前提是在URL上的参数在【FromBody】参数没有,好像还是有点模糊,来,上代码: 

    [Route("[controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost("{id:int}")]
        public IActionResult Post([FromBody]Employee customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }
    }
    public class Employee
    {
        public int Id { get; set; }
        [Required]
        public decimal Salary { get; set; }
    }

  如上示意图想必已经很明确了,在Body中我们并未指定属性Id,但是我们想要将路由中的id也就是4绑定到【FromBody】标识的参数Employee的属性Id,例子跟实际不是合理的,只是为了演示混合绑定,这点请忽略。问题已经阐述的非常明确了,不知您是否有了解决思路,既然是【FromBody】,内置已经实现的BodyModelBinder我们依然要绑定,我们只需要将路由中的值绑定到Employee对象中的id即可,来,我们首先实现IModelBinderProvider接口,如下: 

    public class MixModelBinderProvider : IModelBinderProvider
    {
        private readonly IList<IInputFormatter> _formatters;
        private readonly IHttpRequestStreamReaderFactory _readerFactory;

        public MixModelBinderProvider(IList<IInputFormatter> formatters,
            IHttpRequestStreamReaderFactory readerFactory)
        {
            _formatters = formatters;
            _readerFactory = readerFactory;
        }
        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            //如果上下文为空,返回空
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            //如果元数据模型类型为Employee实例化MixModelBinder
            if (context.Metadata.ModelType == typeof(Employee))
            {
                return new MixModelBinder(_formatters, _readerFactory);
            }

            return null;
        }
    }

接下来则是实现IModelBinder接口诺,绑定【FromBody】特性请求参数,绑定属性Id。 

    public class MixModelBinder : IModelBinder
    {
        private readonly BodyModelBinder bodyModelBinder;
        public MixModelBinder(IList<IInputFormatter> formatters,
            IHttpRequestStreamReaderFactory readerFactory)
        {
            //原来【FromBody】绑定参数依然要绑定,所以需要实例化BodyModelBinder
            bodyModelBinder = new BodyModelBinder(formatters, readerFactory);
        }
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
            {
                throw new ArgumentNullException(nameof(bindingContext));
            }

            //绑定【FromBody】特性请求参数
            bodyModelBinder.BindModelAsync(bindingContext);

            if (!bindingContext.Result.IsModelSet)
            {
                return null;
            }

            //获取绑定对象
            var model = bindingContext.Result.Model;

            //绑定属性Id
            if (model is Employee employee)
            {
                var idString = bindingContext.ValueProvider.GetValue("id").FirstValue;
                if (int.TryParse(idString, out var id))
                {
                    employee.Id = id;
                }

                bindingContext.Result = ModelBindingResult.Success(model);
            }
            return Task.CompletedTask;
        }
    }

其实到这里我们应该更加明白,【BindRequired】和【BindNever】特性只针对MVC模型绑定系统起作用,而对于【FromBody】特性的请求参数与Input Formatter有关,也就是与所用的序列化和反序列化框架有关。接下来我们添加自定义实现的混合绑定类,如下:

services.AddMvc(options =>
{
  var readerFactory = services.BuildServiceProvider().GetRequiredService<IHttpRequestStreamReaderFactory>();
  options.ModelBinderProviders.Insert(0, new MixModelBinderProvider(options.InputFormatters, readerFactory));
}).SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

ApiController特性本质

.NET Core每个版本的迭代更新都带给我们最佳体验,直到.NET Core 2.0版本我们知道MVC和Web Api将控制器合并也就是共同继承自Controller,但是呢,毕竟如果仅仅只是做Api开发所以完全用不到MVC中Razor视图引擎,在.NET Core 2.1版本出现了ApiController特性, 同时出现了新的约定,也就是我们控制器基类可以不再是Controller而是ControllerBase,这是一个更加轻量的控制器基类,它不支持Razor视图引擎,ControllerBase控制器和ApiController特性结合使用,完全演变成干净的Api控制器,所以到这里至少我们了解到了.NET Core中的Controller和ControllerBase区别所在,Controller包含Razor视图引擎,而要是如果我们仅仅只是做接口开发,则只需使用ControllerBase控制器结合ApiController特性即可。那么问题来了,ApiController特性的出现到底为我们带来了什么呢?说的更加具体一点则是,它为我们解决了什么问题呢?有的人说.NET Core中模型绑定系统或者ApiController特性的出现显得很复杂,其实不然,只是我们不了解背后它所解决的应用场景,一旦用了之后,发现各种问题呈现出来了,还是基础没有夯实,接下来我们一起来看看。在讲解模型绑定系统时,我们了解到对于参数的验证我们需要通过代码 ModelState.IsValid 来判断,比如如下代码: 

    public class Employee
    {
        public int Id { get; set; }

        [Required]
        public string Address { get; set; }
    }

    [Route("[Controller]")]
    public class ModelBindController : Controller
    {
        [HttpPost]
        public IActionResult Post([FromBody]Employee employee)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
            return Ok();
        }
    }

  当我们请求参数中未包含Address属性时,此时通过上述模型验证未通过响应400。当控制器通过ApiController修饰时,此时内置会自动进行验证,也就是我们不必要在控制器方法中一遍遍写ModelState.IsValid方法,那么问题来了,内置到底是如何进行自动验证的呢?首先会在.NET Core应用程序初始化时,注入如下接口以及具体实现。 

ervices.TryAddEnumerable(
                ServiceDescriptor.Transient<IApplicationModelProvider, ApiBehaviorApplicationModelProvider>());

  那么针对ApiBehaviorApplicationModelProvider这个类到底做了什么呢?在此类构造函数中添加了6个约定,其他四个不是我们研究的重点,有兴趣的童鞋可以私下去研究,我们看看最重要的两个类: InvalidModelStateFilterConvention 和 InferParameterBindingInfoConvention ,然后在此类中有如下方法:

        public void OnProvidersExecuting(ApplicationModelProviderContext context)
        {
            foreach (var controller in context.Result.Controllers)
            {
                if (!IsApiController(controller))
                {
                    continue;
                }

                foreach (var action in controller.Actions)
                {
                    // Ensure ApiController is set up correctly
                    EnsureActionIsAttributeRouted(action);

                    foreach (var convention in ActionModelConventions)
                    {
                        convention.Apply(action);
                    }
                }
            }
        }    

  至于方法OnProviderExecuting方法在何时被调用我们无需太多关心,这不是我们研究的重点,我们看到此方法中的具体就是做了判断我们是否在控制器上通过ApiController进行了修饰,如果是,则遍历我们默认添加的6个约定,好了接下来我们首先来看InvalidModelStateFilterConvention约定,最终我们会看到此类中添加了ModelStateInvalidFilterFactory,然后针对此类的实例化ModelStateInvalidFilter类,然后在此类中我们看到实现了IAactionFilter接口,如下: 

public void OnActionExecuting(ActionExecutingContext context)
{
    if (context.Result == null && !context.ModelState.IsValid)
    {
         _logger.ModelStateInvalidFilterExecuting();
         context.Result =_apiBehaviorOptions.InvalidModelStateResponseFactory(context);
    }
}    

  到这里想必我们明白了在控制器上通过ApiController修饰解决了第一个问题:在添加MVC框架时,会为我们注入一个ModelStateInvalidFilter,并在OnActionExecuting方法期间运行,也就是执行控制器方法时运行,当然也是在进行模型绑定之后自动进行ModelState验证是否有效,未通过则立即响应400。到这里是不是就这样完事了呢,显然不是,为何,我们在控制器上通过ApiController来进行修饰,如下代码:

    [Route("[Controller]")]
    [ApiController]
    public class ModelBindController : Controller
    {
        [HttpPost]
        public IActionResult Post(Employee employee)
        {
            //if (!ModelState.IsValid)
            //{
            //    return BadRequest(ModelState);
            //}
            return Ok();
        }
    }

  对比上述代码,我们只是添加ApiController修饰控制器,同时我们已了然内部会自动进行模型验证,所以我们注释了模型验证代码,然后我们也将【FromBody】特性去除,这时我们进行请求,响应如下,符合我们预期:

  我们仅仅只是将添加了ApiController修饰控制器,为何我们将【FromBody】特性去除则请求依然好使,而且结果也如我们预期一样呢?答案则是:参数来源绑定推断,通过ApiController修饰控制器,会用到我们上述提出的第二个约定类(参数绑定信息推断),到了这里是不是发现.NET Core为我们做了好多,别着急,事情还未完全水落石出,接下来我们来看看,我们之前所给出的URL参数绑定到字典上的例子。 

    [Route("[Controller]")]
    [ApiController]
    public class ModelBindController : Controller
    {
        [HttpGet]
        public IActionResult Get(List<Dictionary<string, int>> pairs)
        {
            return Ok();
        }
    }

  到这里我们瞬间懵逼了,之前的请求现在却出现了415,也就是媒介类型不支持,我们什么都没干,只是添加了ApiController修饰控制器而已,如此而已,问题出现了一百八十度的大转折,这个问题谁来解释解释下。我们还是看看参数绑定信息约定类的具体实现,一探究竟,如下: 

if (!options.SuppressInferBindingSourcesForParameters)
{
    var convention = new InferParameterBindingInfoConvention(modelMetadataProvider)
    {
        AllowInferringBindingSourceForCollectionTypesAsFromQuery = options.AllowInferringBindingSourceForCollectionTypesAsFromQuery,
    };

    ActionModelConventions.Add(convention);
}    

第一个判断则是是否启动参数来源绑定推断,告诉我们这是可配置的,好了,我们将其还原不启用,此时再请求回归如初,如下:

  services.Configure<ApiBehaviorOptions>(options=>
  {
     options.SuppressInferBindingSourcesForParameters = true;
  }).AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

  那么内置到底做了什么,其实上述答案已经给出了,我们看看上述这行代码: options.AllowInferringBindingSourceForCollectionTypesAsFromQuery ,因为针对集合类型,.NET Core无从推断到底是来自于Body还是Query,所以呢,.NET Core再次给定了我们一个可配置选项,我们显式配置通过如下配置集合类型是来自于Query,此时请求则好使,否则将默认是Body,所以出现415 

services.Configure<ApiBehaviorOptions>(options=>
{
    options.AllowInferringBindingSourceForCollectionTypesAsFromQuery = true;
}).AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

好了,上述是针对集合类型进行可配置强制指定其来源,那么问题又来了,对于对象又该如何呢?首先我们将上述显式配置集合类型来源于Query给禁用(禁不禁用皆可),我们看看下如下代码:

    [Route("[Controller]")]
    [ApiController]
    public class ModelBindController : Controller
    {
        [HttpGet("GetEmployee")]
        public IActionResult GetEmployee(Employee employee)
        {
            return Ok();
        }
    }

再次让我们大跌眼镜,好像自从添加上了ApiController修饰控制器,各种问题呈现,我们还是看看.NET Core最终其推断,到底是如何推断的呢? 

        internal void InferParameterBindingSources(ActionModel action)
        {
            for (var i = 0; i < action.Parameters.Count; i++)
            {
                var parameter = action.Parameters[i];
                var bindingSource = parameter.BindingInfo?.BindingSource;
                if (bindingSource == null)
                {
                    bindingSource = InferBindingSourceForParameter(parameter);

                    parameter.BindingInfo = parameter.BindingInfo ?? new BindingInfo();
                    parameter.BindingInfo.BindingSource = bindingSource;
                }
            }
            ......
        }

        // Internal for unit testing.
        internal BindingSource InferBindingSourceForParameter(ParameterModel parameter)
        {
            if (IsComplexTypeParameter(parameter))
            {
                return BindingSource.Body;
            }

            if (ParameterExistsInAnyRoute(parameter.Action, parameter.ParameterName))
            {
                return BindingSource.Path;
            }

            return BindingSource.Query;
        }

        private bool ParameterExistsInAnyRoute(ActionModel action, string parameterName)
        {
            foreach (var (route, _, _) in ActionAttributeRouteModel.GetAttributeRoutes(action))
            {
                if (route == null)
                {
                    continue;
                }

                var parsedTemplate = TemplateParser.Parse(route.Template);
                if (parsedTemplate.GetParameter(parameterName) != null)
                {
                    return true;
                }
            }

            return false;
        }

        private bool IsComplexTypeParameter(ParameterModel parameter)
        {
            // No need for information from attributes on the parameter. Just use its type.
            var metadata = _modelMetadataProvider
                .GetMetadataForType(parameter.ParameterInfo.ParameterType);

            if (AllowInferringBindingSourceForCollectionTypesAsFromQuery && metadata.IsCollectionType)
            {
                return false;
            }

            return metadata.IsComplexType;
        }    

  通过上述代码我们可知推断来源结果只有三种:Body、Path、Query。因为我们未显式配置绑定来源,所以走参数推断来源,然后首先判断是否为复杂类型,判断条件是如果AllowInferringBindingSourceForCollectionTypesAsFromQuery配置为true,同时为集合类型说明来源为Body。此时我们无论是否显式配置绑定集合类型是否来源于FromQuery,肯定不满足这两个条件,接着执行metadate.IsComplexType,很显然Employee为复杂类型,我们再次通过源码也可证明,在获取模型元数据时,通过 !TypeDescriptor.GetConverter(typeof(ModelType)).CanConvertFrom(typeof(string))  判断是否为复杂类型,所以此时返回绑定来源于Body,所以出现415,问题已经分析的很清楚了,来,最终,我们给ApiController特性本质下一个结论: 

通过ApiController修饰控制器,内置实现了6个默认约定,其中最重要的两个约定则是,其一解决模型自动验证,其二则是当未配置绑定来源,执行参数推断来源,但是,但是,这个仅仅只是针对Body、Path、Query而言。

当控制器方法上参数为字典或集合时,如果请求参数来源于URL也就是查询字符串请显式配置AllowInferringBindingSourceForCollectionTypesAsFromQuery为true,否则会推断绑定来源为Body,从而响应415。

当控制器方法上参数为复杂类型时,如果请求参数来源于Body,可以无需显式配置绑定来源,如果参数来源为URL也就是查询字符串,请显式配置参数绑定来源【FromQuery】,如果参数来源于表单,请显式配置参数绑定来源【FromForm】,否则会推断绑定为Body,从而响应415。 

总结

  本文比较详细的阐述了.NET Core中的模型绑定系统、模型绑定原理、自定义模型绑定原理、混合绑定等等,其实还有一些基础内容我还未写出,后续有可能我接着研究并补上,.NET Core中强大的模型绑定支持以及灵活性控制都是.NET MVC/Web Api不可比拟的,虽然很基础但是又有多少人知道并且了解过这些呢,同时针对ApiController特性确实给我们省去了不必要的代码,但是带来的参数来源推断让我们有点懵逼,如果不看源码,断不可知这些,我个人认为针对添加ApiController特性后的参数来源推断,没什么鸟用,强烈建议显式配置绑定来源,也就不必记住上述结论了,本篇文章耗费我三天时间所写,修修补补,其中所带来的价值,一个字:值。

 

posted @ 2019-04-06 14:05  为之守望  阅读(697)  评论(0编辑  收藏  举报