概述

上单元介绍 DTO 时,我们已经简单讲解了模型绑定,本节学习一些模型绑定的实践和进阶知识。

从 URL 中提取数据

在 ASP.NET 或 Java Server Pages(JSP)这些较老的 Web 开发架构中,我们必须手动读取 HTTP Header、URL、HTTP Query String 和 Form Body 以提取用户通过 HTTP 请求发送的数据。这是一项繁琐的工作,且每次 HTML 元素更改名称时,读取数据的服务器端代码也必须做相应地更改。

对于 action 方法,我们将 HTTP 请求视为模型绑定的数据源。下表展示了如何从 HTTP 请求中提取数据:

数据部分 HTTP 方法 使用频率 数据格式 如何提取
Query String GET, POST 键值对 将 query strings 视为字典
Form Body POST 键值对 将表单数据视为字典
Route (URL string) GET, POST 普通字符串 解析字符串或使用正则表达式
HTTP Header GET, POST 键值对 讲 HTTP headers 视为字典

在 ASP.NET Core 中,模型绑定的功能就是自动执行提取解析的工作,以便将 HTTP 请求中包含的数据映射到 Action 参数。

运作的理念很简单,大致如下:

  • 对于简单类型(int、double、string 等)Action 参数,模型绑定引擎以不区分大小写的方式通过参数的名称查询所有数据部分。如果存在匹配的键值对或可以从 URL 字符串中提取值,则将值提取出来分配给参数

  • 对于复杂类型(Product、Movie 类等)Action 参数,模型绑定引擎以不区分大小写的方式通过参数的属性名称查询所有数据部分。如果存在匹配的键值对或可以从 URL 字符串中提取值,则将该值提取出来分配给参数的属性

由于 HTTP 请求中的所有值都是字符串类型,因此模型绑定将为我们执行类型转换。例如,键值对 price = 99.99 通过模型绑定引擎被映射到 Action 的 double price 参数后,price 参数将被赋值为 double 类型的 99.99。

如果类型转换失败会发生什么?例如假设键值对是 price = ABC,由于类型转换失败,price 参数将被赋值 0.0(double 类型的默认值)。

示例

下面用一个示例来讲解数据提取。

假设我们的 Web 程序运行在 http://localhost:5000,默认的路由模板是 {controller=Home}/{action=Index}/{id?}

领域模型 Product 如下所示:

  1. public class Product
  2. {
  3. public int ID { get; set; }
  4. public string Name { get; set; }
  5. public double Price { get; set; }
  6. public string MadeIn { get; set; }
  7. public bool IsAvailable { get; set; }
  8. public string Description { get; set; }
  9. }

ProductController:

  1. public class ProductController : Controller
  2. {
  3. [HttpPost]
  4. public IActionResult Create(Product product)
  5. {
  6. // business logic ...
  7. return View(product);
  8. }
  9. }

当浏览器向 URL http://localhost:5000/product/create/101?isavailable=false&madein=USA 提交 HTTP POST 请求时,表单体中的数据为:

  1. name = Autopilot Car
  2. price = 99999.99

Create 的 product 参数引用指向由模型绑定创建的 Product 类的实例。它的属性值为:

属性 类型 从何处提取 提取方式
ID int 101 URL 路由模板解析
IsAvailable bool false query string 名称匹配
MadeIn string “USA” query string 名称匹配
Name string “Autopilot Car” 表单数据 名称匹配
Price double 99999.99 表单数据 名称匹配
Description string null 未找到

这是一个比较极端的例子,在实际项目中,我们很少将对象的值分发到这么多部分。

在 Web 程序中传输数据的简单原则是:

  • 对于 HTTP GET 请求,使用 URL 字符串或 query string 传输数据

  • 对于 HTTP POST 请求,使用表单传输数据

  • 仅在必要时将数据分发到不同的部分

  • 可以通过使用 [FromQuery]、[FromForm]、[FromRoute] 和 [FromHeader] 标注 Action,限制数据源

总结

模型绑定提取数据的步骤:

  1. 通过 URL Route 确定目标 Action

  2. 确定 Action 后,明确 Action 的各个参数及参数类型

  3. 模型绑定将 HTTP 请求里面的数据作为数据源

  4. 对于 Action 中的简单类型参数:

    1. 模型绑定拿着参数名,通过名称匹配等方式去数据源里面找匹配项

    2. 模型绑定找到匹配项后,将值提取出来,进行类型转换

    3. 模型绑定将转换后的值给到 Action 的参数

  5. 对于 Action 中的复杂类型参数:

    1. 模型绑定根据该复杂类型创建一个对象

    2. 模型绑定遍历该对象的各个属性,获得属性名

    3. 模型绑定拿着属性名,通过名称匹配等方式去数据源里面找匹配项

    4. 模型绑定找到匹配项后,将值提取出来,进行类型转换

    5. 转换完后将属性值赋给该对象

    6. 最后将该对象给到 Action 的参数

自定义模型绑定器

虽然模型绑定可以将 HTTP 请求中的数据映射到 Action 参数,但如果 Action 参数的类型过于复杂,则模型绑定引擎将无法创建该对象。

例如,假设我们的领域模型类如下:

  1. public class Player
  2. {
  3. public string Name { get; set; }
  4. public int Rank { get; set; }
  5. }
  6. public class Game
  7. {
  8. public string City { get; set; }
  9. public Player Player1 { get; set; }
  10. public Player Player2 { get; set; }
  11. }

从表单提交过来的键值对:

  1. p1Name=Superman
  2. p1Rank=101
  3. p2Name=Ironman
  4. p2Rank=202
  5. gameCity=Seattle

模型绑定引擎应该如何构建 Game 对象?

在这种情况下,我们需要一个自定义模型绑定器。自定义模型绑定器是实现了 IModelBinder 接口的类。绑定 Game 对象的自定义模型绑定器如下所示:

  1. public class GameModelBinder : IModelBinder
  2. {
  3. public Task BindModelAsync(ModelBindingContext bindingContext)
  4. {
  5. var game = new Game();
  6. game.Player1 = new Player();
  7. game.Player2 = new Player();
  8. game.City = bindingContext.HttpContext.Request.Form["gameCity"];
  9. game.Player1.Name = bindingContext.HttpContext.Request.Form["p1Name"];
  10. game.Player1.Rank = int.Parse(bindingContext.HttpContext.Request.Form["p1Rank"]);
  11. game.Player2.Name = bindingContext.HttpContext.Request.Form["p2Name"];
  12. game.Player2.Rank = int.Parse(bindingContext.HttpContext.Request.Form["p2Rank"]);
  13. bindingContext.Result = ModelBindingResult.Success(game); // set the model binding result
  14. return TaskCache.CompletedTask;
  15. }
  16. }

注:此处为了保持演示代码的简洁,省略了大量数据验证、corner cases 和异常处理的代码。在实际项目中,自定义模型绑定器不会这么简单。

有两种方法可以使用 GameModelBinder。

  1. 使用 [ModelBinder(BinderType = typeof(GameModelBinder))] 标注 Game 类。除非你能保证 Game 对象始终是从表单体构建的,否则不建议你使用此方法

  2. 使用 [ModelBinder(BinderType = typeof(GameModelBinder))] 标注 Action 参数:

    1. public class GameController : Controller
    2. {
    3. public IActionResult Create([ModelBinder(BinderType = typeof(GameModelBinder))]Game game)
    4. {
    5. // business logic ...
    6. return View(game);
    7. }
    8. }

我们可以通过在 Action 中设置断点并使用 Postman 发送 HTTP POST请求,来触发 Action 并观察参数的值。
下面是截图供参考:
3.3 高级模型绑定 - 图1

启发

通过此处演示的自定义模型绑定器和 Docs 的自定义模型类绑定,我们不难推测出 ASP.NET Core 模型绑定大致的实现机制:

  1. 根据属性名去 bindingContext 中查找对应项

  2. 找到的项提取出来,并进行类型转换

  3. 用这些属性构建模型绑定的结果,并装到 bindingContext.Result 里面返回去

模型验证

模型验证是为了避免“脏数据”进入我们的业务逻辑或数据库。

脏数据指数据不一致或错误。例如,Product 对象缺少 Name 属性,密码长度太短,或者 Student 对象的年龄为负。脏数据通常来自用户输入,但并非总是如此。我们有很多方法可以防止用户通过脏数据影响我们的应用程序或业务逻辑。

避免脏数据的方式:

方式 如何实现 理念 优点 缺点
客户端验证 Web 端的 JS 代码 在用户提交数据前验证 无需向服务器发送请求 需要额外的客户端编程
路由参数约束 路由模板的特殊语法 由 URL 路由引擎运行 容易编写 发送 HTTP 请求,使用场景有限
模型验证 使用特性标注模型类的属性 通过 .NET 反射机制验证属性值 内建许多验证特性 ,无需额外编程 发送 HTTP 请求

因为客户端验证不在本课程的范畴,且我们已经在之前的课程中介绍了路由参数约束,所以这里我们只关注模型验证。

MVC 支持从 ValidationAttribute 派生的任何特性进行验证。可以在 System.ComponentModel.DataAnnotations 命名空间中找到许多有用的验证属性。下面列出一些常用的以供参考:

描述
CompareAttribute 比较两个属性的值
DataTypeAttribute 指定要与数据字段关联的其他类型的名称
EditableAttribute 指示数据字段是否可编辑
EmailAddressAttribute 验证邮件地址
FileExtensionsAttribute 验证文件扩展名
KeyAttribute 标注一个或多个属性是实体类的 Unique 键
MaxLengthAttribute 指定属性中允许的数组或字符串的最大长度
MinLengthAttribute 指定属性中允许的数组或字符串的最小长度
PhoneAttribute 使用正则表达式验证电话号码
RangeAttribute 指定数据字段值的数值范围
RegularExpressionAttribute 指定 ASP.NET Dynamic 数据字段值必须匹配指定正则表达式
RequiredAttribute 指定字段值不能为空
UrlAttribute 提供 URL 验证

验证特性示例:

  1. public class Book
  2. {
  3. [Required, Key]
  4. public int ISBN { get; set; }
  5. [Required, StringLength(100)]
  6. public string Title { get; set; }
  7. [Required, DataType(DataType.Date)]
  8. public DateTime PublishDate { get; set; }
  9. [Required, MaxLength(1000)]
  10. public string Description { get; set; }
  11. [Required, Range(0, 999.99)]
  12. public decimal Price { get; set; }
  13. [Url]
  14. public bool SamplePage { get; set; }
  15. [EmailAddress]
  16. public string AuthorEmail { get; set; }
  17. [Phone]
  18. public string AuthorPhone { get; set; }
  19. }

模型绑定时,一旦违反一个或多个模型验证规则,ModelState.IsValid 的值将为 false。基于此,我们可以控制程序流程:

  1. public class BookController : Controller
  2. {
  3. public IActionResult Create(Book book)
  4. {
  5. if (ModelState.IsValid == true)
  6. {
  7. // business logic ...
  8. }
  9. else
  10. {
  11. // let user re-input the data
  12. }
  13. }
  14. ...
  15. }