ASP.NET Core 高级系列,一【上】:模型绑定

此为系列文章,对MSDN ASP.NET Core 的官方文档进行系统学习与翻译。其中或许会添加本人对 ASP.NET Core 的浅显理解。

这篇文章解释了模型绑定是什么,它是如何工作的,以及如何自定义它的行为。

什么是模型绑定

控制器以及Razor 页面与来自于HTTP请求的数据一起工作。举个例子,路由数据或许会提供一个记录键,post 表单字段可能会为模型的属性提供值。如果编写代码来取出这些值并将它们从字符串类型转换为.NET类型将会是枯燥乏味并且是容易出错的。而模型绑定将这个过程自动化。模型绑定系统:

  • 从各种数据源中取出数据,比如路由数据,表单字段,以及查询字符串。
  • 以方法参数以及公共属性的方式为控制器及Razor页面提供数据。
  • 将字符串类型的数据转换为.NET类型。
  • 更新复杂类型的属性。

示例

假设你有如下的动作(Action)方法:

[HttpGet("{id}")]
public ActionResult<Pet> GetById(int id, bool dogsOnly)

而且app接受到了一个带有如下URL的请求:

http://contoso.com/api/pets/2?DogsOnly=true

在路由系统选择合适的动作方法之后,模型绑定会经理如下的步骤:

  • 找到GetByID方法的第一个参数,一个名为id的整型。
  • 在HTTP请求的可用的数据源进行查找,并在路由数据中找到 id = "2"。
  • 将字符串类型“2”转换为整数2。
  • 找到GetByID方法的下一个参数,一个名为 dogsOnly 的布尔值。
  • 查找数据源并在查询字符串中找到 “DogsOnly=true”。名称匹配是大小写不敏感的。
  • 将字符串类型的 “true” 转换为布尔类型的 true。

框架然后会调用 GetById 方法,然后会给id参数传2,给dogsOnly参数传递 dogsOnly。

在之前的示例中,模型参数目标是简单类型的方法参数。模型绑定的目标也可能是复杂类型的属性。在每个属性都被成功绑定后,那个属性会发生model validation。什么数据被绑定给模型的记录,以及任何绑定或者验证错误,都会存储在ControllerBase.ModelStatePageModel.ModelState中。为了证实这个过程是否成功,可以检查ModelState.IsValid标记。

目标

模型绑定尝试为如下类型的目标查找数值:

  • 请求路由到的一个控制器的动作方法。
  • 请求被路由到的Razor 页面的处理方法。
  • 一个控制器或者PageModel 类的公共属性,如果被属性指定。

[BindProperty] 属性

这个属性可被应用于一个控制器或者是PageModel 类的 public 属性上,以在那个属性上实现模型绑定。

public class EditModel : InstructorsPageModel
{
    [BindProperty]
    public Instructor Instructor { get; set; }

[BindProperties] 属性

在ASP.NET Core 2.1及后续版本可用。其可用应用到控制器或者一个PageModel 类来告诉模型绑定,其目标为这个类的所有的public 属性。

[BindProperties(SupportsGet = true)]
public class CreateModel : InstructorsPageModel
{
    public Instructor Instructor { get; set; }
}

HTTP GET 请求的模型绑定

默认情况下,对于HTTP GET 请求,属性不会被绑定。经典的,对于一个HTTP GET 请求,所有你需要的只是一个记录 ID 参数。这个记录ID 会被使用来在数据库中查询这个条目。因此,没有必要将其绑定到持有模型实例的一个属性上。在你确实想要将属性绑定到来自于HTTP GET 请求的数据上时,可以将 SupportsGet 属性设置为 true。

[BindProperty(Name = "ai_user", SupportsGet = true)]
public string ApplicationInsightsCookie { get; set; }

数据源

默认情况下,模型绑定以键值对的形式从一个HTTP 请求的如下数据源中获取数据:

  1. 表单字段。
  2. 请求体(对于实现了[ApiController] 属性的控制器)。
  3. 路由数据。
  4. 查询字符串参数。
  5. 已上传的文件。

对于每一个目标参数及属性,数据源都会以如上的顺序被扫描。但是还有一些例外:

  1. 路由数据以及查询字符串值仅被用作简单类型。
  2. 已上传的文件仅仅被绑定到实现了 IFormFileIEnumerable<IFormFile>接口的目标类型。

如果默认源不正确,请使用如下属性之一来指定数据源:

这些属性:

  • 被添加到各个模型属性上,而不是模型类上,如同如下示例:
public class Instructor
{
    public int ID { get; set; }

    [FromQuery(Name = "Note")]
    public string NoteFromQueryString { get; set; }
  • 在构造函数中可选的接受一个模型名称值。在属性名称不匹配请求中的值的情形,可以使用这个选项。比如,请求中的值或许是一个其名字中带有连字的头信息,如同如下示例:
public void OnGet([FromHeader(Name = "Accept-Language")] string language)

[FromBody] 属性

将[FromBody] 属性应用到一个属性上以从一个HTTP 请求体中填充这个属性值。ASP.NET Core 运行时代理了将读取请求体的数据到一个输入格式化器的职责。输入格式化器将在本章的后续进行解释。

当[FromBody]属性被应用到一个复杂类型参数上的时候,任何应用到这个属性的绑定源属性都会被忽略。如下的Create方法指定了它的pet 会被从请求体中进行填充。

public ActionResult<Pet> Create([FromBody] Pet pet)

Pet 类指定了其 Breed 属性将会从查询字符串中的值进行填充:

public class Pet
{
    public string Name { get; set; }

    [FromQuery] // Attribute is ignored.
    public string Breed { get; set; }
}

在上述示例中:

  • [FromQuery] 属性会被忽略。
  • Breed 属性不会从查询字符串中进行填充。

输入格式化器仅仅读取请求体,其并不理解绑定源属性。如果一个合适的值在请求体中被发现,这个值将会被用来填充Breed 属性。

对于一个Action 方法,请不要应用[FromBody]属性给多于一个的属性。一旦请求流被一个输入格式化器读取,它便不再可用被再次读取以绑定到其他 [FromBody]属性。

额外的源

源数据通过值提供器被提供给模型绑定系统。你可以编写并注册自定义的值提供器,其为模型绑定从其他源中获取数据。举个例子,你或许想从Cookies 或者会话状态中获取数据。为了从一个新数据源获取数据,你可以:

  • 创建一个实现了IValueProvider 的类。
  • 创建一个实现了IValueProviderFactory 的类。
  • 将工厂类注册到Startup.ConfigureServices。

示例app包括了一个 value providerfactory 示例,其从cookies 中获取值。如下是 Startup.ConfigureServices中的注册代码:

services.AddRazorPages()
    .AddMvcOptions(options =>
{
    options.ValueProviderFactories.Add(new CookieValueProviderFactory());
    options.ModelMetadataDetailsProviders.Add(
        new ExcludeBindingMetadataProvider(typeof(System.Version)));
    options.ModelMetadataDetailsProviders.Add(
        new SuppressChildValidationMetadataProvider(typeof(System.Guid)));
})
.AddXmlSerializerFormatters();

演示的代码将自定义的值提供器放置在内置的值提供器之后。为了使其成为列表中的第一个,调用 Insert(0, new CookieValueProviderFactory()) 来代替 Add()。

没有数据源的模型属性

默认情况下,如果一个模型属性没有找到对应的值,那么不会创建模型状态错误的。这个时候,属性会被设置为 null 或者默认值:

  • 可空简单类型被设置为 null。
  • 不可空值类型被设置为 default(T),举个例子,一个 int 型的参数 id 被设置为 0。
  • 对于复杂类型,模型绑定通过默认构造函数创建了一个实例,而不会设置其属性。
  • 数组被设置为 Array.Empty<T>(),而字节数组被设置为 null。

当在一个表单字段中为某个属性没有找到对应的绑定值,而需要模型状态为 非验证通过时,可以使用 [BindRequired] 属性。

请注意[BindRequired] 行为应用到模型绑定是从 posted 表单数据,而不是在请求体中的 JSON 或者 XML 数据,请求体数据通过 input formatters 进行处理。

类型转换错误

如果一个数据源被找到但是没有转换为目标类型,模型状态便会被标记为 invalid。目标参数或者属性会被设置为 null 或者默认值,就如同以上章节所提到的。

在一个具有 [ApiController] 属性的API 控制器中,invalid 模型状态导致了一个自动的 HTTP 400 响应。

在一个Razor 页面,会重新显示这个页面,并显示了一个错误信息。

public IActionResult OnPost()
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    _instructorsInMemoryStore.Add(Instructor);
    return RedirectToPage("./Index");
}

客户端验证会捕获大部分的异常数据,否则它们便会被提交到Razor 页面表单中。这些验证使得我们很难触发如上高亮显示的代码。示例app 包含了一个 Submit with Invalid Date 按钮,其将异常数据放在 Hire Date 字段中并提交数据。这个按钮演示了当转换错误发生时, 用来重新显示页面的代码是如何工作的。

当页面被上述的代码重新显示时,无效的输入不会显示在表单字段中,这是因为模型属性被设置为 null 或者默认值。无效的输入会出现在一个错误信息中,如果你想将异常数据显示在表单字段中,考虑将模型字段设置为字符串并手动进行数据转换。

如果你不想类型转换错误导致模型状态错误,我们推荐同样的策略。在那种情形下,将模型属性设置为一个字符串。

To be continued...