ASP.NET Core Razor Pages in Action 5 使用表单:模型绑定

ASP.NET Core Razor Pages in Action 5 使用表单:模型绑定

本章涵盖

• Razor Pages 中的模型绑定
• 轻松绑定复杂对象
• 通过验证用户输入来保护应用程序
• 利用数据注释属性管理验证

在本书的开头,如果您想学习如何构建“以页面为中心的交互式 Web 应用程序”,我祝贺您选择了 ASP.NET Core Razor Pages in Action。当时我并没有真正扩展“互动”的含义;从本质上讲,交互式 Web 应用程序是用户可以在其中提供输入并影响应用程序行为的应用程序。在上一章中,您了解了用户如何更改 URL 以与应用程序交互,从而导致 City 页面根据 route 参数的值显示不同的内容。在本章中,您将开始了解和使用 Web 应用程序中的主要交互机制:表单。

表单有各种形状和大小。在本书的开头,您查看了 Manning 网站的主页,其中有几个表单,每个表单都有一个输入。其他表格(我想到的是保险报价表)可以跨越多个页面。如果您想构建交互式 Web 应用程序,您将不得不在某个阶段使用表单,无论它只是一个简单的联系表单,还是一个驱动关键业务目标的基于 CRUD 的大型系统。您需要创建表单并处理它们旨在捕获的数据。您还需要验证数据以确保其完整性,并在用户提交的数据不符合您的业务规则时向用户提供信息丰富且及时的反馈。

正如第 1 章中引用的 GitHub 问题中所述,Razor Pages 框架背后的设计目标之一是“简化实现常见的以页面为中心的模式所需的代码,例如动态页面、CRUD 表单。

Razor Pages 包含强大的功能,可最大限度地减少创建可靠表单、处理数据和验证数据所需的代码量。他们是

• 表单控件标记帮助程序
• 模型绑定
• 验证框架

这些主题给我们留下了很多内容,因此我在本章中重点介绍模型绑定和验证,并在下一章中重点介绍标记帮助程序。

在第 3 章中,您看到了查询字符串值可以通过称为模型绑定的功能绑定到处理程序方法参数。此功能还通过自动捕获 HTTP 请求中发送的数据并将其分配给 C# 代码,在简化已发布表单值的处理方面也发挥着关键作用。在本章中,您将详细探讨模型绑定,了解它如何处理 PageModel 属性和路由数据以及处理程序参数。

您还将了解如何通过在客户端和至关重要的服务器上验证用户输入来保护应用程序免受不良数据的侵害。验证标记帮助程序和模型绑定相结合,可以减少您需要编写的代码量,以保护数据完整性并通知用户提交中的错误。

数据注释属性提供了一种向 .NET 中的类型添加其他信息或元数据的方法,包括数据应遵循的特定规则,例如与状态、数据类型和范围相关的业务要求。验证框架是 .NET Framework 中响应数据注释提供的提示的众多领域之一。您将探索如何利用此功能在构建表单时进一步消除样板代码,并根据业务规则轻松验证数据。

本章中的示例基于上一章中启动的 CityBreaks 应用程序。我们假设您已将应用程序配置为对 URL 使用小写选项,并实施了两个参数转换器,因此路由和参数使用 kebab 大小写。如果您需要一个起点,本章的下载内容包括一个应用了这些设置的版本。

5.1 表单基础

表单由一个 HTML 表单标记组成,该标记包含许多用于收集用户输入的控件,通常还包括一个具有某些描述的控件,使用户能够将输入控件的内容提交到服务器进行处理。表单提交本身会导致 HTTP 请求。该请求将使用表单元素的 method 属性指定的任何 HTTP 方法,如果未指定方法,则 GET 为默认值。当使用 GET 方法时,表单值将作为查询字符串中的键值对附加到 URL(参见图 5.1)。键是从表单控件的 name 属性生成的,值是从控件获取的。使用 POST 方法时,表单的内容将作为键值对包含在请求正文中,该键值对使用与 GET 方法相同的模式构建。

图 5.1 表单值作为键值对传输到服务器。

尽管 GET 方法是默认值,但 GET 方法通常不用于表单。通常,您只对旨在捕获查询条件的表单(如搜索引擎)使用 GET 方法,其中使用嵌入在查询字符串中的查询条件为 URL 添加书签的功能使您能够有效地再次执行表单提交。大多数情况下,您将使用 POST 方法,尤其是对于更改应用程序状态的表单提交,例如,执行作,导致添加新内容或更新现有内容的表单提交。POST 方法还提供了一些好处。它增加了一个安全级别,因为提交的数据在查询字符串中不可见。例如,您不希望您的用户名和密码在浏览器地址栏中被任何人看到。

5.1.1 使用 post-redirect-get 模式

对于使用 POST 方法提交的表单,在服务器上成功处理表单内容后,通常会将用户重定向到另一个 URL。此过程称为 post-redirect-get (PRG) 模式,可用作防止用户刷新包含表单的页面,从而意外重新提交表单,从而导致再次执行处理作的机制。

在处理任何旨在将项添加到应用程序数据存储的表单时,实现此模式尤为重要。您最不希望看到的是重复的数据,或者您的客户抱怨他们只订购了一件商品,但已经收取了两件商品的费用!话虽如此,在后面的早期示例中,您将不会实现 PRG 模式,同时您将学习如何从表单提交中访问值。

图 5.2 使用 POST 方法提交表单后,post-redirect-get 模式指定将用户重定向到另一个页面。

在回发期间保持状态

提醒一下,OnGet 处理程序针对 GET 请求执行,OnPost 处理程序针对 POST 请求执行。作为表单管理的一部分,您通常需要在 OnGet 处理程序中初始化数据,例如为选择列表准备选项等。一旦框架在 HTML 生成过程中使用该数据,并且响应已发送到浏览器(图 5.2 中的第 2 步),服务器上生成的任何数据都将丢失。这是因为,默认情况下,Razor Pages 是无状态的。也就是说,它不会在任何位置维护该数据。这是有道理的,因为 HTTP 是一种无状态协议。
如果要在 OnPost 处理程序中处理相同的数据,则必须在 OnPost 处理程序中重新初始化它。如果不这样做,可能会导致运行时异常,因为您尝试引用不存在的数据,因为您尚未创建它!当表单提交未通过验证检查并需要再次显示时,通常会发生这种情况。我们很容易忽略这样一个事实,即选择列表尤其依赖于服务器上生成的数据,而每次显示表单时都需要生成这些数据。

显然,表单处理的一个重要部分是能够访问提交的值,以便您可以验证和处理它们。在本节中,我将展示如何将它们分配给 Request 对象,正如您所记得的,Request 对象是 HttpContext 的一个属性,以及用于检索 POST 和 GET 请求的这些值的各种选项。虽然在以下示例中您将直接访问 Request 对象,但在 Razor 页面中处理表单数据时,这不是推荐的方法。但是,一旦您了解了此方法的局限性,模型绑定(您应该用作默认方法)的角色及其带来的好处将更加有意义。

5.1.2 从 Request.Form 访问值

使用 POST 方法提交表单时,表单值由基础 ASP.NET Core 框架处理,并将其表单控件名称作为键值对分配给 Request.Form 集合。在 ASP.NET Core 中,可以使用基于字符串的索引(例如 Request.Form[“password”])访问每个项目,其中索引的值是原始表单控件的名称。返回类型是 StringValues 对象,这与以前版本的 ASP.NET 不同,后者的返回类型是简单字符串。

要实际查看这一点,您将创建一个非常简单的表单,使用户能够在表单中键入城市名称,然后将该信息提交给服务器进行处理。您的服务器端代码将从 HTTP 请求中提取提交城市的名称,并将其显示回给用户。

首先,在 Pages 文件夹中创建一个新文件夹,并将其命名为 CityManager。然后将名为 Create.cshtml 的新 Razor 页面添加到 CityManager 文件夹。将 Create.cshtml 中的代码替换为以下内容。

清单 5.1 带有表单的 Create 页面

@page
@model CityBreaks.Pages.CityManager.CreateModel
@{
   ViewData["Title"] = "Create City";
}
<div class="col-4">
    <form method="post">                                                 ❶
        <div class="mb-3">
            <label for="name">Enter city name</label>
            <input class="form-control" type="text" name="cityName" />   ❷
        </div>
        <button class="btn btn-primary">Submit</button>
    </form>
    <p>@Model.Message</p>                                                ❸
</div>

❶ 表单方法设置为 post。
❷ 文本输入的 name 属性设置为 “cityName”。
❸ 此处呈现一个名为 Message 的 PageModel 属性。

这是使用 Bootstrap 类设置样式的表单的标准 HTML。它包括一个 name 属性设置为 “cityName” 的输入。name 属性是任何表单控件上最重要的属性,因为它用作提交表单时发送到服务器的键值对的键。这通常会 ASP.NET 让 Web 窗体开发人员感到惊讶,他们习惯于将服务器控件的 id 属性视为标识传入表单值来源的方法,并倾向于将其与 HTML 元素上的 id 属性混淆。

键值对的传入集合将分配给 Request 对象的 Form 属性。PageModel 类和 Razor 页面都通过名为 Request 的属性提供对 Request 对象的直接访问,该属性方便地绕过了通过 HttpContext 属性访问它的需要。下一步是更改 PageModel 类文件内容,使其类似于以下清单的内容。

清单 5.2 使用 Request.Form 处理表单值

using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Primitives;                                  ❶

namespace CityBreaks.Pages.CityManager
{
    public class CreateModel : PageModel
    {
        public string Message { get; set; }                             ❷
        public void OnPost()                                            ❸
        {
            if(!StringValues.IsNullOrEmpty(Request.Form["cityName"]))   ❹
            {
                Message = $"You submitted {Request.Form["cityName"]}";  ❺
            }
        }
    }
}

❶ 包括 Microsoft.Extensions.Primitives 的 using 指令,以便您可以引用 StringValues 类型,而不必使用其完全限定的名称。
❷ 添加名为 Message 的公共字符串属性。
❸ 添加 OnPost 处理程序以处理 POST 请求。
❹ 检查 Request.Form 集合,查看键为“cityName”的项目是否有值。
❺ 如果是,请将其作为分配给 Message 属性的值的一部分包含在内。

运行应用程序,导航到 /city-manager/create(请记住您已经应用的路由自定义),然后在输入中输入一个值。该值包含在渲染的消息中(图 5.3)。

图 5.3 提交的值已成功呈现到浏览器。

清单 5.2 中的示例演示了如何使用分配给输入的 name 属性 (“cityName”) 的值作为基于字符串的索引来访问 Request.Form 集合中的项目。这种类型的方法在许多 Web 框架中很常见,经典的 ASP 和 PHP 开发人员以及涉足 ASP.NET Web Pages 框架的开发人员应该熟悉这种方法。使用 StringValues.IsNullOrEmpty 来确定指定键是否存在值。如果未提供任何值,或者表单集合中不存在指定的键,它将返回 false。

StringValues 的原因

引入 StringValues 类型是为了简化对可能为空、单个字符串或多个字符串的值的处理。完全可以将相同的名称分配给表单中的多个控件。例如,如果要允许用户使用一系列复选框进行多项选择,则可以执行此作。假设您希望为用户提供选择多个城市的能力,并且您提供了多个复选框,每个复选框的 name 属性设置为 city。做出选择并提交表单后,选择可能如下所示,作为请求正文的一部分:

city=London&city=Paris&city=Berlin 

在早期版本的 ASP.NET 中,Request.Form 将基于与单个逗号分隔的字符串相同的键返回多个值,因此 Request.Form[“city”] 将返回 “London,Paris,Berlin”。您有责任转换此字符串 — 可能使用字符串。Split 方法从中生成数组,以便可以单独迭代和处理发布的值。

StringValues 对象表示零 (null)、一个或多个字符串,并支持隐式转换为 string[],这意味着您可以直接在 Request.Form(和 Request.Query)集合中的项中使用迭代语句,而无需手动将其转换为某种类型的集合:

foreach(var city in Request.Form["city"])
{
    ...
}

它还具有到 string 的隐式转换,因此如果只需要一个值,则可以将其视为字符串。如果有多个值,则返回第一个值。

5.1.3 从 Request.Query 访问值

正如我前面提到的,大多数表单将使用 POST 方法,但在某些情况下,GET 方法会是首选,特别是对于您可能希望能够存储、共享和重用搜索结果 URL 的搜索等功能。在本节中,您将对现有代码进行两个小的修改,以检索使用 GET 方法在表单中提交的值。

对清单 5.1 中所示代码的唯一更改是表单上的 method 属性以指定 “get”:

<form method="get">

PageModel 代码需要两处更改,如清单 5.3 所示。处理程序方法应更改为 OnGet,对 Request.Form 的引用应更改为 Request.Query。

清单 5.3 从 Request.Query 访问提交的值

public class CreateModel : PageModel
{
    public void OnGet()                                                 ❶
    {
        if (!StringValues.IsNullOrEmpty(Request.Query["cityName"]))     ❷
        {                                                               ❷
            Message = $"You submitted {Request.Query["cityName"]}";     ❷
        }                                                               ❷
    }
}

❶ 处理程序方法更改为 OnGet。
❷ Request.Form 已替换为 Request.Query。

运行页面,并提交一个值。查看浏览器地址栏。您应该注意到,提交的值在 URL 中显示为查询字符串值(图 5.4)。

图 5.4 使用 GET 方法提交表单时,表单值将作为查询字符串包含在 URL 中。

如果复制 URL(包括查询字符串)并浏览到该 URL,则应用程序的行为就像再次提交了表单一样,并将查询字符串中的值处理到 Message 属性中。

注意在某些浏览器(例如 Opera 和 Safari)中,您可能看不到查询字符串。单击地址栏可查看完整的 URL。

如前所述,不建议在 Razor 页面中直接从 Request.Form 或 Request.Query 集合访问表单值。索引值很容易拼写错误,因此它不再引用有效的表单控件,或更改控件的 name 属性,从而导致相同的问题。但是,在某些用例中,能够直接访问这些集合是有用的。你在第 2 章中创建 PassThroughMiddleware 示例时看到了这样一个案例。

您可能还希望在记录应用程序内的活动时访问这些集合,我在第 12 章中对此进行了更详细的介绍。关于您的应用程序的用户,可以肯定的一点是,他们会找到巧妙的使用方法,这远远超出了您的期望或想象。由于您不能站在他们的肩膀上观看他们所有人,因此您需要某种方法来记录用户的活动,这样您就有机会复制他们在遇到问题时采取的步骤。作为其中的一部分,您需要收集尽可能多的有关他们提出的请求的信息,包括任何表单提交的内容。下面的清单迭代表单集合并输出所有键的名称及其值。

示例 5.4 迭代 Request.Form 集合并输出内容

<ul>
@if (Request.HasFormContentType)                       ❶
{
    foreach (var item in Request.Form)
    {
        <li>@item.Key: @Request.Form[item.Key]</li>    ❷
    }
}
</ul>

❶ 在访问 Request.Form 之前,您必须检查请求的内容类型,以防止引发 InvalidOperationException。
❷ 渲染集合键的名称及其关联值。

如果在现有页面中包含此代码,则在将表单的方法更改回 post 后,您应该会看到输出中还包含具有键 __RequestVerificationToken 的项。它包含在打击跨站点请求伪造攻击的安全措施中。我在第 13 章中更详细地讨论了这个问题。

迭代 Request.Form 的代码包装在检查中,以建立请求的内容类型。具体来说,引用 Request 对象的 HasFormContentType 属性。如果请求是使用 POST 方法执行的,则返回 true。如果不在 OnPost 处理程序之外执行此检查,框架将引发 InvalidOperationException,并显示以下消息:Content-Type 不正确。

5.2 模型绑定

在 PageModel 类中处理用户输入的推荐方法是利用 ASP.NET Core 中内置的模型绑定框架,而不是深入研究表单或查询集合的深度。模型绑定是从 HTTP 请求的各个部分(源)获取用户输入并尝试将其绑定到 C# 代码(目标)的过程。模型绑定框架考虑用户可以将以下一种或多种方式作为源与应用程序交互:

• Forms
• Route data
• Query strings
• Request body (useful for accessing data posted as JSON, as you will see in chapter 10)

Razor Pages 支持的绑定目标是已显式选择加入模型绑定的处理程序参数和公共 PageModel 属性。

在第 3 章中,当我们查看使用命名处理程序时,您已经遇到了绑定到处理程序参数的情况。在下一节中,我们将更详细地介绍此过程,然后,我们将深入探讨绑定到 PageModel 属性。

5.2.1 将模型绑定与处理程序参数一起使用
在本节中,您将稍微更改 OnPost 处理程序,以便不直接访问 Request 对象。这一次,您将依靠模型绑定将传入的表单值绑定到作为绑定目标的处理程序参数。更改现有的 OnPost 方法,使其采用名为 cityName 的字符串参数,并使用该参数生成 Message 属性的值。

清单 5.5 向 OnPost 方法添加参数

public class CreateModel : PageModel
{
    public string Message { get; set; }
    public void OnPost(string cityName)         ❶
    {
        Message = $"You submitted {cityName}";  ❷
    }
}

❶ 将 cityName 参数添加到 OnPost 处理程序方法中。
❷ 在分配给 Message 的值中包含参数值。

再次运行此页面时,您应该不会看到输出有任何差异。您在表单中输入的值应合并到呈现的 Message 中。

一旦路由系统选择了 OnPost 处理程序方法,模型绑定系统就会执行以下作(如图 5.5 所示):

  1. 检查处理程序方法参数的名称

  2. 在传入请求的绑定源中搜索具有匹配键的值

  3. 如果找到匹配项,它将尝试从请求提供的原始字符串数据中进行所需的任何类型转换

  4. 如果转化成功,则为参数分配结果值

图 5.5 处理程序参数根据其名称与传入的 HTTP 值进行匹配。

5.2.2 使用具有公共属性的模型绑定
在本节中,您将把 PageModel 类上的公共属性视为绑定目标。当绑定源为表单数据时,这是在 Razor Pages 中使用模型绑定器的推荐方法。建议使用此方法,因为公共属性可在 Razor 页面中访问,它们在该页面中与表单控件标记帮助程序一起使用,并可以参与验证用户输入。您很快就会详细了解这两个功能,但现在,您将更改现有的 PageModel 类,使其不再与处理程序参数一起使用。您将添加一个新属性来表示城市名称,同时删除现有的 Message 属性和 OnPost 处理程序。新属性将使用名为 BindProperty 的属性进行修饰,该属性将属性指定为绑定目标。

清单 5.6 绑定到公共属性

public class CreateModel : PageModel
{
    [BindProperty]    
    public string CityName { get; set; }      ❶
}

❶ CityName 声明为公共属性,并应用了 BindProperty 属性。

现在,Message 属性已被删除,您需要直接在 Create 页面中使用 CityName 属性。这些更改在下面的列表中以粗体显示。

示例 5.7 更新 Create 页面以使用 CityName 属性

<div class="col-4">
    <form method="post">
        <div class="mb-3">
            <label for="name">Enter city name</label>
            <input class="form-control" type="text" name="cityName" />
        </div>
        <button class="btn btn-primary">Submit</button>
    </form>
    @if(Request.HasFormContentType && 
     !string.IsNullOrWhiteSpace(Model.CityName))
    {
        <p>You submitted @Model.CityName</p>
    }
</div>

与以前一样,如果运行页面,则在提交表单时,它应生成相同的输出。

handler 参数已替换为同名的 public 属性。CityName 属性与已删除的 Message 属性的不同之处在于,它用 BindProperty 属性修饰,该属性指定该属性是绑定目标。如果没有此属性,该属性将不会参与模型绑定。

默认情况下,BindProperty 属性仅选择将属性加入 POST 请求的模型绑定。如果要在 GET 请求期间(例如,从路由数据或查询字符串)将值绑定到公共属性,则需要额外的步骤才能选择加入。BindProperty 属性具有一个名为 SupportsGet 的属性,您必须将其设置为 true:

[BindProperty(SupportsGet=true)]
public int Id  { get; set; }

除了 SupportsGet 属性之外,BindProperty 属性还具有 Name 属性,该属性使您能够将公共 PageModel 属性绑定到任意命名的表单字段。例如,您可能需要从将 name 属性设置为 “e-mail” 的表单控件进行绑定。这不是有效的 C# 标识符,因此您可以使用 Name 属性将传入的表单值映射到 C# 将容忍的属性:

[BindProperty(Name="e-mail")]
public string Email { get; set; }

如果要在模型绑定中包含大量 PageModel 属性,则可以使用 BindProperties(复数)属性修饰 PageModel 类:

[BindProperties]
public class IndexModel : PageModel

这种方法必须谨慎使用。这将导致 PageModel 类中的所有公共属性都包含在模型绑定中,这可能会使您的应用程序遭受过度发布攻击。

 过度发布攻击

过度发布(也称为批量分配)是一种漏洞,当用户能够修改他们不应访问的数据项时,就会发生这种漏洞。假设您正在创建一个结帐页面,用户可在其中确认其购买的详细信息。显然,您不希望他们能够修改项目的价格,因此此数据没有表单字段。但是,假设您已将 Price 作为公共属性包含在 PageModel 中,其中 PageModel 类使用 BindProperties 属性进行修饰,从而选择将所有公共属性(包括 Price 属性)作为绑定目标。
精通 Web 的用户完全有可能使用基本的开发工具,比如 Postman 甚至浏览器开发人员工具(我们将在第 11 章中介绍)来制作他们自己的 HTTP 请求,并包括一个价格的表单项。由于 Price 属性向模型绑定器公开,因此用户能够修改该值。在不知不觉中,您网站上那些 300 美元的耳机已经以 3 美元的价格出售。

5.2.3 绑定复杂对象

到目前为止,您一直在使用模型绑定从传入的表单值中填充简单属性。随着表单字段数量的增加,PageModel 类将开始吱吱作响,要么出现一长串属性(全部用 BindProperty 属性装饰)或应用于处理程序方法的大量参数。幸运的是,模型绑定也适用于复杂对象,因此要绑定的属性可以封装在一个对象中,该对象可以作为 PageModel 的属性或处理程序方法的参数公开。当以这种方式使用对象时,一些开发人员将对象称为输入模型,它提供了一种限制绑定目标范围的极好方法。要了解其工作原理,您将开始扩展 CityBreaks 应用程序的功能。

您的每个城市都属于一个国家。您将生成一个表单,使您能够使用输入模型捕获国家/地区数据。这将是一个非常简单的输入模型,但只是为了示例。

在 CityBreaks 应用程序的根目录中创建一个名为 Models 的文件夹,并向其添加一个名为 Country.cs 的 C# 类。修改代码,使其与下面的清单相同。

清单 5.8 Country 类

public class Country
{
    public string CountryName { get; set; }
    public string CountryCode { get; set; }
}

CountryName 属性表示国家/地区的名称,CountryCode 属性表示每个国家/地区的两个字符的 ISO 3166-1 alpha-2 代码。例如,United States 是 us,United Kingdom 是 gb。您可以从 Wikipedia 找到这些代码的完整列表:https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2。您将使用这些代码来显示每个国家/地区的国旗图标,因此您还需要下载一组以 alpha-2 代码命名的免费国旗图标。您可以从 https://flagpedia.net/download/icons 的 Flagpedia 获得免费的集合(我选择了 40 px 宽的原始版本),或者您可以从本章的下载中复制它们,该章涵盖了绑定复杂对象 (http://mng.bz/yvOB)。在 wwwroot 文件夹中创建一个名为 Images 的文件夹,并在该文件夹中创建另一个名为 Flags 的文件夹。如果您选择下载标志,请将图像文件提取到 Flags 文件夹中。打开布局文件,并修改页脚以在下一个列表中包含以粗体显示的署名。

清单 5.9 更新页脚以包含署名

<footer class="border-top footer text-muted">
    <div class="container">
        © 2021 - CityBreaks | Flag icons from 
         <a href="https://flagpedia.net">flagpedia.net</a>
    </div>
</footer>

现在,将新文件夹添加到 Pages 文件夹,并将其命名为 CountryManager。在该页面中,添加一个名为 Create 的新 Razor 页面。将 CreateModel 类内容更改为以下内容。

Listing 5.10 使用输入模型封装绑定目标

using CityBreaks.Models;                              ❶
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace CityBreaks.Pages.CountryManager
{
    public class CreateModel : PageModel
    {
        [BindProperty]
        public InputModel Input { get; set; }         ❷
        public Country Country { get; set; }          ❸

        public void OnPost()
        {
            Country = new Country                     ❹
            {                                         ❹
                CountryName = Input.CountryName,      ❹
                CountryCode = Input.CountryCode       ❹
            };                                        ❹
        }

        public class InputModel                       ❺
        {
            public string CountryName { get; set; }
            public string CountryCode { get; set; }
        }
    } 
}

❶ 添加 using 指令以引入 CityBreak.Models 命名空间。
❷ 添加输入模型和属性,并通过添加 BindProperty 属性使其成为绑定目标。
❸ 向 PageModel 添加 Country 属性。
❹ 从 InputModel 的属性中实例化 Country 属性。
❺ 声明一个类,用于封装国家/地区输入表单的字段。这是输入模型。

InputModel 类是在 PageModel 类中声明的,因此它是一个嵌套类。这会将其范围限制为当前 PageModel,从而允许您对充当其他页面上的绑定目标的类使用相同的名称 (InputModel)。

绑定复杂对象的关键是确保输入的 name 属性采用 . 的形式,其中第一个属性是应用了 BindProperty 属性的复杂 PageModel 属性(输入模型)的名称,第二个属性是输入模型类中的属性名称。 如图 5.6 所示。

图 5.6 使用点表示法确保绑定适用于复杂对象

若要将此付诸实践,请修改 Create.cshtml 文件以适应以下代码。

清单 5.11 创建国家/地区表单

@page
@model CityBreaks.Pages.CountryManager.CreateModel
@{
}

<h4>Create Country</h4>

<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div class="form-group">
               <label for="Input.CountryName"                         ❶
               ➥ class="control-label">Name</label>                  ❶
               <input name="Input.CountryName"                        ❶
               ➥ class="form-control" />                             ❶
            </div>
            <div class="form-group">
               <label for="Input.CountryCode"                         ❶
               ➥ class="control-label">ISO-3166-1 Code</label>       ❶
               <input name="Input.CountryCode"                        ❶
               ➥ class="form-control" />                             ❶
            </div>
            <div class="form-group">
               <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
@if(Model.Country != null){
    <p>You submitted @Model.Country.CountryName 
    <img src="/images/flags/@(Model.Country.CountryCode).png" /></p>
}

❶ 模式 . 已用于 label 和 input 元素的 for 和 name 属性。

运行应用程序,导航到 /country-manager/create,然后输入国家/地区名称和有效的 ISO-3166-1 代码(例如,Ireland 和 ie)。您应该会看到国家/地区的名称和国旗呈现到浏览器(图 5.7)。

图 5.7 使用点表示法构造表单控件名称,以确保值绑定到复杂对象。

您可能想知道为什么需要输入模型,因为它与 Models 文件夹中的 Country 类相同。目前它们确实是相同的,但在本书的后面,Country 类将获取您不想在创建新实例时向模型绑定器公开的新属性。

您可能还开始对 OnPost 方法中的映射代码感到好奇,该方法从一个对象中获取值,并将它们分配给另一个对象上具有相同名称的属性。你可能会认为,对于具有许多属性的对象,这看起来可能会变得相当费力。你绝对是对的。好消息是至少有两种解决方案可以加快映射速度。第一个是一个非常流行的开源库,称为 AutoMapper,它可以为您执行此作。我不会在本书中介绍如何使用它,但项目站点上的文档非常好:https://automapper.org/。如果您使用的是 Visual Studio 2022,第二种解决方案可能是您已经注意到的解决方案。不断改进的 AI 辅助 IntelliCode 功能似乎能够猜测你想要做的作业,并且经常提供整行代码作为建议;你只需要按两次 Tab 键来接受它(图 5.8)。

图 5.8 IntelliCode 加快了对象之间的简单映射速度。

下面的示例将 InputModel 类显示为 PageModel 的公共属性,但也可以将复杂对象作为参数应用于 OnPost 处理程序:

public void OnPost(InputModel model) 

大多数情况下,您将使用 public property 方法,因为它最适合与 form 标记帮助程序一起使用,您将在下一章中更详细地了解这一点。现在要提到的一个关键点是,当您将标记帮助程序与公共 PageModel 属性结合使用时,您无需担心在 input 元素上构造 name 属性的值,因为标记帮助程序会为您生成它。

只要 SupportsGet 参数设置为 true,在 GET 请求期间绑定到复杂对象就可以与使用 BindProperty 属性修饰的复杂对象一起使用,就像使用简单类型一样。Binding 还直接作用于充当处理程序参数的复杂对象。

列表 5.12 作为处理程序参数的 Complex 对象

public void OnGet(InputModel input)
{
    if (input.CountryName != null)
    {
        Country = new Country
        {
            CountryName = input.CountryName,
            CountryCode = input.CountryCode
        };
    }
}

5.2.4 绑定简单集合

到目前为止,您已经了解了如何绑定简单类型或复杂类型的单个实例。模型 Binder 还支持绑定到集合。例如,您可能希望允许用户进行多项选择,或为其提供用于输入多行数据的表单,这非常有用。

HTML 提供了两个选项来进行多项选择:一个应用了 multiple 属性的 select 元素(又名列表框控件)和一系列 type 属性设置为 checkbox 的输入。它们必须共享完全相同的 name 属性;您可以使用您喜欢的任何一个。它们之间的选择取决于您的 UI 首选项。

下一个代码示例包含一个表单,用户可以在其中使用复选框选择多个选项。在这种情况下,将邀请用户从多个城市中进行选择。所选内容将绑定到 List,其中每个元素都表示所选值之一。您需要向 Models 文件夹添加另一个类,该类将表示一个城市并具有两个属性。

Listing 5.13 City 类

public class City
{
    public int Id { get; set; }
    public string Name { get; set; }
}

接下来,更改 City.cshtml.cs 中的 CityModel,以包含 int 的 List 作为绑定目标,并包含一些代码以生成 List of cities。

清单 5.14 CityModel 类

public class CityModel : PageModel
{
    [BindProperty]                                                      ❶
    public List<int> SelectedCities { get; set; } = new List<int>();    ❶
    public List<City> Cities = new List<City>                           ❷
    {
        new City{ Id = 1, Name = "London"},
        new City{ Id = 2, Name = "Paris" },
        new City{ Id = 3, Name = "New York" },
        new City{ Id = 4, Name = "Rome" },
        new City{ Id = 5, Name = "Dublin" }
    };
}

❶ 添加公共 List<int> 属性,并将其设为绑定目标。实例化它,这样你就不需要检查 null。

❷ 声明一个 List<City>,并实例化它。

最后,更改 City.cshtml 文件内容以包含一个 foreach 循环,该循环循环访问城市列表,并呈现集合中每个条目的复选框和城市名称(列表 5.15)。每个复选框都将城市的 Id 应用于其 value 属性。您还将添加一些代码,如果用户选择了任何城市,这些代码将执行。它们的 Id 值将绑定到 SelectedCities 属性,因此您将遍历其中的任何值,并从您刚刚在 PageModel 代码中生成的集合中提取匹配的城市。您将把所选的总数与所选内容一起呈现为列表。

清单 5.15 带复选框的多选

@page 
@model CityBreaks.Pages.CityModel
<h4>Select Cities</h4>
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div class="form-group">
               @foreach(var city in Model.Cities)                   ❶
                {                                                   ❶
                    <div><input type="checkbox"                     ❶
                    ➥ name="SelectedCities"value="@city.Id"/>      ❶
                    ➥ @city.Name</div>                             ❶
                }                                                   ❶
            </div>
            <div class="form-group">
               <input type="submit" value="Select" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
@if(Model.SelectedCities.Any())                                     ❷
{
    <p>Number of cities selected: @Model.SelectedCities.Count()</p> ❸
    <ul>
    @foreach(var city in Model.Cities.Where(
     c => Model.SelectedCities.Contains(c.Id)))                   ❹
    {                                                               ❹
        <li>@city.Name</li>                                         ❹
    }                                                               ❹
    </ul>
}

❶ 遍历城市集合,并为每个城市呈现一个复选框。
❷ 检查是否有任何选定的城市。
❸ 渲染集合中的元素总数。
❹ 渲染每个元素的名称。

当您运行页面并选择一些城市时,您应该看到图 5.9 中的输出类型。

图 5.9 将多个选择绑定到集合

使此示例正常工作的关键是确保所有复选框共享相同的 name 属性 (SelectedCities),并且分配给 name 属性的值分配给 PageModel 类中的绑定目标。另一点需要注意:如果要在 Razor 页面中引用集合,则将其初始化作为其声明的一部分非常有用。这样,您就不必检查 null。您只需使用 Any 扩展方法检查它是否为空集合。

5.2.5 绑定复杂集合

模型 Binder 还支持绑定到复杂对象的集合。当您使用复杂对象的集合时,您需要合并一个索引值,以便在构造表单控件的 name 属性时标识集合中的每个元素。索引可以是以下两种类型之一:

• Sequential index (顺序索引) - 从 0 开始,每次以 1 为增量增加且无间隙的数值索引
• 显式索引 - 由任意类型但唯一的值组成的索引

顺序索引方法

以下示例说明了 sequential index 的用法。为此,您将修改 CountryManager 文件夹中的 Create 页面。您将继续使用前面示例中的 InputModel 类,但您将更改 PageModel 类中的属性以表示集合,并相应地更改 OnPost 处理程序中的赋值代码。

清单 5.16 country manager 中的 CreateModel 类

public class CreateModel : PageModel
{
    [BindProperty]
    public List<InputModel> Inputs { get; set; }
    public List<Country> Countries { get; set; } = new List<Country>();

    public void OnPost()
    {
        Countries = Inputs
            .Where(x => !string.IsNullOrWhiteSpace(x.CountryCode))
            .Select(x => new Country { 
            CountryCode = x.CountryCode, 
            CountryName = x.CountryName 
        }).ToList();
    }
}

接下来,您需要更改数据输入表单以接受多个国家/地区,而不是一次只接受一个国家/地区。您将在表中呈现五行,每行都包含国家/地区名称及其 ISO-3166-1 代码的输入。您需要确保每个输入控件上的 name 属性值的格式采用以下模式:

<property>[index].<property>

这与单个复杂对象的模式相同。唯一的变化是加入了索引器。因此,为输入呈现的 HTML 最终应类似于以下清单(删除无关的标记)。

Listing 5.17 使用顺序索引为多个输入渲染标记

<input name="Inputs[0].CountryName" /><input name="Inputs[0].CountryCode" />
<input name="Inputs[1].CountryName" /><input name="Inputs[1].CountryCode" />
<input name="Inputs[2].CountryName" /><input name="Inputs[2].CountryCode" />
<input name="Inputs[3].CountryName" /><input name="Inputs[3].CountryCode" />
<input name="Inputs[4].CountryName" /><input name="Inputs[4].CountryCode" />

下面的清单显示了使用以 0 开头的简单 for 循环生成这些输入的代码。

清单 5.18 使用顺序索引启用多个数据输入

@page
@model CityBreaks.Pages.CountryManager.CreateModel
<h4>Create Countries</h4>

<div class="row">
    <div class="col-md-8">
        <form method="post">
            <table class="table table-borderless">
               <tr>
                    <th>Name</th>
                    <th>ISO-3166-1 Code</th>
               </tr>
            @for (var i = 0; i < 5; i++)  
            {
               <tr>
                    <td class="w-75">
                    <input name="Inputs[@i].CountryName"   ❶
                     class="form-control" />             ❶
               </td>
                    <td class="w-25">
                    <input name="Inputs[@i].CountryCode"   ❶
                     class="form-control" />             ❶
               </td>
               </tr>
            }
            </table>
            <div class="form-group">
               <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
@if (Model.Countries.Any())
{
    <p>You submitted the following</p>
    <ul>
    @foreach (var country in Model.Countries)
    {
        <li>
            <img src="/images/flags/@(country.CountryCode).png" /> 
            @country.CountryName
        </li>
    }
    </ul>
}

❶ 在每次迭代中将变量 i 递增 1,并使用其值在表单控件上生成索引值。

提交表单时,模型绑定器将实例化五个 InputModel 对象的集合,并填充已发布的值。如果用户仅提供前三个国家/地区的值,则最后两个国家/地区的属性将设置为类型的默认值 — 字符串为 null。因此,您只需将具有非 null 值的 Labels 映射到 PageModel 中的 Countries 集合。然后,它们将与标志图标一起呈现(如果您输入了有效的 ISO 代码)。

使用显式索引

依赖于显式索引的方法更适合于为编辑现有值而设计的表单,其中唯一标识符(如每个项目的数据库主键)通常用作索引值。除了在表单控件的名称中合并索引值外,此方法还需要为每个项添加一个名为 的附加隐藏字段。Index,其值设置为唯一标识符。模型绑定器使用它来将相关控件分组在一起。

要查看此效果,您将模拟批量编辑国家/地区。首先,您需要修改 Country 类以包含名为 Id 的整数属性,该属性将用于存储国家/地区的唯一标识符。这在下一个列表中以粗体显示。

清单 5.19 向 Country 类添加唯一标识符属性

public class Country
{
    public int Id { get; set; }
    public string CountryName { get; set; }
    public string CountryCode { get; set; }
}

接下来,将新的 Razor 页面添加到名为 Edit 的 CountryManager 文件夹中。您将使用输入模型来表示国家/地区的可编辑属性,然后在 OnGet 处理程序中实例化它们的集合。属性将作为可编辑值以表单形式显示。提交表单后,更新的值将分配给国家/地区集合,就像上一个示例一样,并呈现到浏览器。

清单 5.20 用于处理显式索引的 EditModel

public class EditModel : PageModel
{
    [BindProperty]
    public List<InputModel> Inputs { get; set; }
    public List<Country> Countries { get; set; } = new List<Country>();
    public void OnGet()
    {
        Inputs = new List<InputModel> {                   ❶
            new InputModel{ Id = 840, CountryCode = "us", ❶
             CountryName ="United States" },            ❶
            new InputModel{ Id = 826, CountryCode = "en", ❶
             CountryName = "Great Britain" },           ❶
            new InputModel{ Id = 250, CountryCode = "fr", ❶
             CountryName = "France" }                   ❶
        };                                                ❶
    }

    public void OnPost()
    {
        Countries = Inputs
            .Where(x => !string.IsNullOrWhiteSpace(x.CountryCode))
            .Select(x => new Country
            {
                Id = x.Id, 
                CountryCode = x.CountryCode,
                CountryName = x.CountryName
            }).ToList();
    }

    public class InputModel
    {
        public int Id { get; set; }
        public string CountryName { get; set; }
        public string CountryCode { get; set; }
    }
}

❶ 创建 InputModel 的集合。唯一标识符实际上是该国的 ISO 3166-1 数字代码。这将用作索引值。

在此示例中,ID 为 826 的项目的数据不正确。国家/地区应命名为 United Kingdom,alpha 代码应为 gb。在创建并提交表单后,您将有机会对其进行编辑并查看结果。以下清单显示了 Razor 页面的代码。

清单 5.21 使用显式索引的编辑表单

<h4>Edit Countries</h4>

<div class="row">
    <div class="col-md-8">
        <form method="post">
            <table class="table table-borderless">
               <tr>
                    <th>Name</th>
                    <th>ISO-3166-1 Code</th>
               </tr>
               @foreach (var country in Model.Inputs)           ❶
               {
                    <input type="hidden" name="Inputs.Index"
                     value="@country.Id" />                   ❷
                    <tr>
                        <td class="w-75">
                        <input name="Inputs[@country.Id].       ❸
                         CountryName"                         ❸
                            value="@country.CountryName" 
                            class="form-control" />
                    </td>
                        <td class="w-25">
                        <input name="Inputs[@country.Id].       ❸
                         CountryCode"                         ❸
                            value="@country.CountryCode" 
                            class="form-control" />
                    </td>
                    </tr>
                }
            </table>
            <div class="form-group">
               <input type="submit" value="Update" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
@if (Model.Countries.Any())
{
    <p>You submitted the following</p>
    <ul>
    @foreach (var country in Model.Countries)
    {
        <li>
            <img src="/images/flags/@(country.CountryCode).png" />
            @country.CountryName
        </li>
    }
    </ul>
}

❶ 迭代输入模型的集合。
❷ 创建 hidden 字段以保存每个项目的显式索引值。将为其分配 Id 属性值。
❸ 创建 CountryName 和 CountryCode 输入,其索引值也设置为当前元素的 Id 属性。它们的当前值将分配给相应 input 控件上的 value 属性。

运行应用程序,然后导航到 /country-manager/edit。您应该看到表单字段中呈现的数据(图 5.10)。

图 5.10 将边界值渲染到输入控件。

对第二个条目进行修改,然后按 Update。修改后的值不仅在列表中与它们的标志一起呈现,而且它们被保留在表单控件中(图 5.11)。

图 5.11 更新后的值将呈现到浏览器。

到目前为止,您应该清楚地了解模型绑定如何与 PageModel 的处理程序参数和公共属性或简单值和复杂对象的输入模型一起工作。那么,您应该如何在绑定到处理程序参数和 PageModel 属性之间进行选择呢?

这个问题没有单一的正确答案。这在很大程度上取决于个人情况。作为文本到目前为止所涵盖内容的简要总结,表 5.1 提供了作为绑定目标的处理程序参数和 PageModel 属性的比较,因此您可以做出适用于您的特定场景的明智选择。

表 5.1 比较处理程序参数和 PageModel 属性

Handler parameter

PageModel property

HTTP method

Supports binding during both GET and POST requests by default

Requires opt-in using the BindProperty attribute and additional opt-in for GET request binding

Scope

Only accessible within the handler they belong to

Accessible throughout the PageModel class and in the associated Razor page via its Model property

Validation

Cannot participate in client-side validation

Participates in both client-side and server-side validation

上表中的最后一项涉及我们尚未探讨的主题:输入验证。这就是我们接下来要看的内容。

5.3 在 Razor Pages 中验证用户输入

当您允许用户提供您随后处理的值时,您需要确保传入值是预期的数据类型,它们在允许的范围内,并且存在所需的值。此过程称为 输入验证。

术语 用户输入 涵盖用户可控制的任何值。通过表单提供的值构成了用户输入的大部分,但用户输入也以 URL 和 Cookie 中提供的值的形式出现。默认位置应该是所有用户输入都被视为不受信任,并且应根据业务规则进行验证。在本节中,您将专注于验证表单值。

您可以在 Web 应用程序中的两个位置对表单数据执行验证:在浏览器中,使用客户端脚本或浏览器的内置数据类型验证,以及在服务器上,使用 C# 代码。但是,您应该只将客户端验证视为对用户的礼貌,因为任何知道如何使用浏览器开发人员工具的人都可以轻松绕过它。服务器端验证应被视为必不可少。

构建 Razor Pages 的 MVC 框架包括一个强大的验证框架,该框架适用于客户端和服务器上的入站模型属性。此框架极大地减轻了开发人员编写验证代码并将其保存在两个位置的负担。

输入验证框架中的关键参与者是

• DataAnnotation 属性
• jQuery 非侵入式验证
• 标记帮助程序
• 模型状态

5.3.1 DataAnnotation 属性

验证框架的主要构建块是一组继承自 ValidationAttribute 的属性。这些属性中的大多数都位于 System 中。ComponentModel.DataAnnotations 命名空间。每个属性都旨在执行特定类型的验证 - 无论是存在、数据类型还是范围。有些还允许您根据预期模式测试传入值。表 5.2 列出了您最有可能使用的验证属性、它们提供的验证类型以及示例用法。

表 5.2 用于 Razor Pages 的验证属性

Attribute

Description

Compare

Used to specify another property that the value should be compared to for equality.

[Compare(nameof(Password2))]

MaxLength

Sets the maximum number of characters/bytes/items that can be accepted.

[MaxLength(20)]

MinLength

Sets the minimum number of characters/bytes/items that can be accepted.

[MinLength(2)]

PageRemote

Enables client-side validation against a server-side resource, such as a database check to see if a username is already in use.

Range

Sets the minimum and maximum values of a range.

[Range(5,8)], Range(typeof(DateTime),"2021-1-1","2021-12-31")]

RegularExpression

Checks the value against the specified regular expression.

[RegularExpression(@"[a-zA-Z]+")]

Required

Specifies that a value must be provided for this property. Non-nullable value types, such as DateTime and numeric values, are treated as required by default and do not need this attribute applied to them.

[Required]

StringLength

Sets the maximum and, optionally, the minimum number of string characters allowed.

[StringLength(2)], [StringLength(10, MinimumLength=2)]

此外,还有一些数据类型验证属性,包括 Phone、EmailAddress、Url 和 CreditCard。这些参数会根据预先确定的格式验证传入的值,以确保它们“格式正确”。关于属性作者认为格式正确的文档很少,但您始终可以求助于查看源代码,以查看用于测试传入值的逻辑,以确保实现涵盖您的业务规则。.NET Source Browser 是实现此目的的出色工具 (https://source.dot.net/)。例如,使用该代码或直接转到 EmailAddressAttribute (http://mng.bz/44ww) 的源代码,将向您展示“验证”只包括检查输入中是否存在 @ 字符。该检查可确保字符只有一个实例,并且它不在输入的开头或结尾。所以 a@b 将通过此验证。

除了 PageRemote 属性之外,所有其他属性都会导致在客户端和服务器上进行验证。PageRemote 属性使用客户端代码对服务器进行验证。有关用法的更多详细信息,请参阅 http://mng.bz/QvBG

属性将应用于 PageModel 属性或输入模型的属性。它们也可以应用于处理程序方法参数,但是如果绑定到处理程序参数而不是 PageModel 属性,则会失去自动客户端验证。我们只关注将 validation attribute 应用于 properties。默认情况下,服务器端验证处于启用状态。必须专门启用客户端验证。

5.3.2 客户端验证

客户端验证应仅被视为对用户的礼貌,因为在用户没有提供令人满意的输入时,它会向用户提供即时反馈。您的应用程序不能仅仅依赖客户端验证,因为任何具有少量 HTML 或 JavaScript 知识的人都很容易绕过它。客户端验证支持目前由 Microsoft 开发的 jQuery Unobtrusive Validation 库提供,该库构建在常用且经过充分测试的 jQuery Validation 库之上。

基于 jQuery 的验证的未来

ASP.NET Core Github 存储库 (https://github.com/dotnet/aspnetcore/issues/8573) 中有一个打开的工作项,它讨论了将来可能将 jQuery 作为项目模板中的依赖项删除。显然,这将影响未来的客户端验证方法。如果要发生,这似乎是一项重大任务,但值得密切关注,看看问题如何发展。显然,如果从将来的模板中删除 jQuery,则不会对在此之前开发的应用程序产生任何影响。

您必须在包含表单的页面中包含 jQuery Unobtrusive Validation 脚本,以便客户端验证正常工作。这可以通过在页面中包含 _ValidationScriptsPartial.cshtml 文件(位于 Shared 文件夹中)来最容易实现,方法是使用您在第 3 章中看到的部分标记帮助程序:

@section scripts{
  <partial name="_ValidationScriptsPartial" />
}

您还必须确保 jQuery 对页面可用。它包含在默认布局页面中,因此只要您的表单位于引用该布局的页面中,就无需执行任何其他作。

客户端验证取决于标签帮助程序发出的自定义 HTML5 data-val-* 属性。要了解它是如何工作的,在清单 5.22 中,您将 CountryManager 中的 Create 页面恢复为一次插入一个国家/地区,但有一个区别:在 InputModel 属性上添加了验证属性。

Listing 5.22 向输入模型属性添加验证属性

public class CreateModel : PageModel
{
    [BindProperty]
    public InputModel Input { get; set; }
    public Country Country { get; set; }

    public void OnPost()
    {
        Country = new Country{ 
            CountryCode = Input.CountryCode,
            CountryName = Input.CountryName
        };
    }

    public class InputModel
    {
        [Required]    
        public string CountryName { get; set; }
        [Required, StringLength(2, MinimumLength = 2)]    ❶
        public string CountryCode { get; set; }
    }
}

❶ 两个属性都标记为必需。CountryCode 的长度必须为 2 个字符。

这些属性已应用于属性。这两个属性都具有 Required 属性,这意味着用户必须提供一个值。此外,您正在验证 length 的 CountryCode 值。用户提供的值必须为 2 个字符长。为此,您已使用 StringLength 属性,将最大值和最小值都设置为 2。您可以在一组方括号中应用多个属性,用逗号分隔,也可以根据需要单独应用它们。

修改后的表单使用 input 标记帮助程序和 validation 标记帮助程序。验证标记帮助程序以 span 元素为目标,并负责呈现由其 validation-for 属性指定的属性的验证错误消息。

清单 5.23 使用 validation 标签帮助程序发出验证错误消息

<form method""pos"">
    <div class""form-grou"">
        <label for""Input.CountryNam"" class""control-labe"">Name</label>   
        <input asp-for""Input.CountryNam"" class="form-control" />         ❶
        <span asp-validation-for="Input.CountryName"                       ❶
        ➥ class="text-danger"></span>                                     ❶
    </div>
    <div class="form-group">
        <label for="Input.CountryCode" class="control-label">ISO-3166-1 
        ➥ Code</label>
        <input asp-for="Input.CountryCode" class="form-control" />         ❶
        <span asp-validation-for="Input.CountryCode"                       ❶
        ➥ class="text-danger"></span>                                     ❶
    </div>
    <div class="form-group">
        <input type="submit" value="Create" class="btn btn-primary" />
    </div>
</form>

❶ 输入标记帮助程序和验证标记帮助程序将添加到这两个属性的表单中。

此外,若要激活客户端验证,必须引用验证脚本,因此请将以下内容添加到 Razor 页面的底部。

列表 5.24 添加 ValidationScriptsPartial 用于客户端验证

@section scripts{
    <partial name="_ValidationScriptsPartial"/>
}

运行应用程序,然后导航到 /country-manager/create。如果您尝试在不输入任何数据的情况下提交表单,您将看到错误消息。如果您尝试在 CountryCode 输入中提交单个字符,则会显示不同的错误消息。请注意,您不能在该控件中输入两个以上的字符。查看为 CountryCode 输入生成的 HTML。

列表 5.25 为 CountryCode 属性输入渲染的 HTML

<input 
    class="form-control" 
    type="text" 
    data-val="true" 
    data-val-length="The field CountryCode must be a string    ❶
     with a minimum length of 2 and a maximum length of 2."  ❶
    data-val-length-max="2"                                    ❶
    data-val-length-min="2"                                    ❶
    data-val-required="The CountryCode field                   ❷
     is required."                                           ❷
    id="Input_CountryCode" 
    maxlength="2"                                              ❶
    name="Input.CountryCode" 
    value=""
/>

❶ 生成这些属性是因为 StringLength 属性已应用于属性。
❷ 这是因包含 Required 属性而生成的默认错误消息。

为了便于阅读,我将其分成了多行,但您可以看到许多 data-val 属性已添加到渲染的输入中。这些属性与要使值进行验证的各种类型的验证相关。它们包括默认错误消息,这些消息将与有效值必须包含的参数一起显示。以下是当用户尝试提交小于 data-val-length-min 值的值时,客户端验证库生成的 span 标记帮助程序的内容。

清单 5.26 发生错误时为 validation tag helper 生成的源码

<span class="text-danger field-validation-error" 
    data-valmsg-for="Input.CountryCode" 
    data-valmsg-replace="true">
    <span id="Input_CountryCode-error" class="">
        The field CountryCode must be a string with a minimum length
            of 2 and a maximum length of 2.
    </span>
</span> 

发生错误时,span 会自动应用一个 field-validation-error 的 CSS 类。您可以使用此类将样式应用于呈现的错误消息,但您已经使用 Bootstrap text-danger 类将红色应用于输出。

所有验证属性都有一个 ErrorMessage 属性,该属性使您能够设置自己的自定义错误消息,而不是依赖属性作者设置的默认错误消息。下面的清单说明了如何更改 country code 字段的错误消息。

示例 5.27 通过 ErrorMessage 属性设置自定义错误消息

[Required, StringLength(2, MinimumLength = 2, 
    ErrorMessage = "You must provide a valid two character ISO 3166-1 code")]
public string CountryCode { get; set; }

您可以通过向属性添加 ValidationNever 属性来选择退出验证。此属性属于不同的命名空间,因此您需要添加 using 语句:

@using Microsoft.AspNetCore.Mvc.ModelBinding.Validation

data-val 验证属性不是由具有应用了此属性的属性的标记帮助程序呈现的。

验证标记帮助程序负责输出各个属性的验证错误消息。您可能还希望向用户显示验证错误摘要或一般验证错误消息,您可以使用验证摘要标记。它以 div 元素为目标,并允许您通过向 validation-summary 属性传递值来控制消息中包含的详细程度。可接受的值为 None、ModelOnly 和 All:

<div asp-validation-summary="All" class="text-danger">There are errors</div>

当您传递 None 时,仅呈现 div 的内容。实际上,标记帮助程序不执行任何作。ModelOnly 选项会导致应用于命名表单字段的 ModelState 错误(属性级错误)作为错误消息的无序列表包含在消息中。当您指定 All 时,模型级错误(即不适用于特定表单字段的错误)将包含在内,以及您通过 validation 属性生成的属性级错误。稍后更详细地检查 ModelState 时,您将了解如何添加模型级条目。

默认情况下,验证摘要标记帮助程序的内容是可见的,因此,如果要包含默认错误消息,则应确保将其隐藏。您可以使用应用于渲染的 div 元素的 validation-summary-valid CSS 类来管理它:

.validation-summary-valid{ display: none };

如果存在验证错误,则该类将从元素中删除并替换为 validation-summary-errors。

5.3.3 服务器端验证

由于绕过客户端验证非常容易,因此服务器端验证包含在 ASP.NET Core 验证框架中。一旦模型 Binders 在服务器上绑定了属性值,框架就会查找这些属性上的所有验证属性并执行它们。任何失败都会导致条目被添加到 ModelStateDictionary 中,ModelStateDictionary 是存储验证错误的类似字典的结构。这是通过 ModelState 在 PageModel 类中提供的。它有一个名为 IsValid 的属性,如果有任何条目,则返回 false。在服务器上处理表单提交的推荐做法是测试 ModelState.IsValid,如果没有验证错误,则处理表单提交,然后使用您已经查看过的 post-redirect-get (PRG) 模式将用户重定向到另一个页面。如果存在错误,则使用表单重新显示当前页面。

要对此进行测试,您应该首先从 Country Create 页面中删除 ValidationScriptsPartial 标记帮助程序,以便为表单禁用客户端验证。然后对 PageModel 类进行以下更改。

清单 5.28 更改 OnPost 处理程序以使用 PRG 模式

[TempData]                                                     ❶
public string CountryCode { get; set; }                        ❶
[TempData]                                                     ❶
public string CountryName { get; set; }                        ❶

public IActionResult OnPost()
{
    if (ModelState.IsValid)                                    ❷
    {
        CountryCode = Input.CountryCode;                       ❸
        CountryName = Input.CountryName;                       ❸
        return RedirectToPage("/CountryManager/Success");      ❸
    }
    return Page();                                             ❹
}

❶ 添加两个字符串属性,用 TempData 属性修饰。
❷ 检查 ModelState。
❸ 如果一切正常,则将表单值分配给 TempData 属性并重定向到另一个页面。
❹ 或重新显示表单。

您在此处引入了一个新项目:TempData。这是另一个字典,旨在为下一个请求的持续时间保存值。它最常见的用途是在重定向期间保留状态,如本例中所示。如果将 TempData 属性应用于简单属性(例如字符串),则会自动将其添加到字典中。

已更改 OnPost 处理程序以测试 ModelState.IsValid。如果有效,则将传入值分配给字符串属性,并将用户重定向到名为 Success 的页面。如果没有,将再次向用户显示表单。因此,您需要一个名为 Success.cshtml 的新页面。这应该添加到 CountryManager 文件夹中,并且 Razor 页面应该更改为类似于下面的清单。

列表 5.29 Success 页面内容

@page
@model CityBreaks.Pages.CountryManager.SuccessModel
<h1>Success!</h1>
<p>Your form submission was completed. You submitted:</p>
<p>
     @TempData["CountryName"]
    <img src="/images/flags/@(TempData["CountryCode"]).png" />
</p>

运行应用程序,然后导航到 country-manager/create。如果您尝试提交无效的表单,您将像以前一样看到错误消息。区别在于这些错误消息是由服务器生成的,而不是由浏览器生成的。尽管如此,您在表单中输入的任何值都会被保留,这些值将从 ModelState 字典中重新填充。当您提交有效值时,您将被重定向到 Success 页面,并显示您提交的详细信息(图 5.12)。

图 5.12 重定向期间 TempData 中保留的值

5.3.4 使用 ModelState 管理更复杂的验证

验证属性适用于绝大多数简单验证需求,但它们相对有限,因为它们处理的验证方案是二进制的。值要么通过测试,要么未通过。此外,它们只对一个值进行作。有时,您的验证方案会很复杂。例如,也许某些值仅在某些情况下是必需的,或者要应用的范围可以是可变的。这种可变性可能不时适用,也可能从一个用户应用到另一个用户。

应用此类验证的最直接方法是在 OnPost 处理程序中使用 C# 代码。编写代码来验证值,如果验证失败,则使用其 AddModelError 方法向 ModelState 对象添加一个条目。例如,假设您有一个验证规则,规定国家/地区名称的第一个字母及其代码必须匹配。以下代码显示了该国家/地区的 CreateModel 中的 OnPost 方法,该方法适用于应用此验证测试。

清单 5.30 向 ModelState 添加错误

public IActionResult OnPost()
{
    if(!string.IsNullOrWhiteSpace(Input.CountryName) && 
       !string.IsNullOrWhiteSpace(Input.CountryCode) &&
       Input.CountryName.ToLower().First() != 
       Input.CountryCode.ToLower().First())                        ❶
    {
        ModelState.AddModelError("Input.CountryName", 
        ➥ "The first letters of the name and code must match");   ❷
    }
    if (ModelState.IsValid)
    {
        CountryCode = Input.CountryCode;
        CountryName = Input.CountryName;
        return RedirectToPage("/CountryManager/Success");
    }
    return Page();
}

❶ 应用验证测试。
❷ 如果测试返回 false,则会记录一个错误,并将 ModelState 作为属性级条目。

您最有可能使用的 AddModelError 方法的版本包含两个字符串:错误适用的属性的名称,以及要向用户显示的错误消息。如果属性的名称与传递给验证标记帮助程序的名称匹配,则该标记帮助程序将显示错误消息。

由于此错误消息适用于两个表单字段,因此您可以决定不希望它对一个表单字段显示,而对着另一个表单字段显示。要防止此问题,您可以添加与其他属性相关的另一条错误消息:

ModelState.AddModelError("Input.CountryName", 
    "The first letter must match the first letter of the ISO 3166-1 code");
ModelState.AddModelError("Input.CountryCode", 
    "The first letter must match the first letter of the country name");

或者,您可以将空字符串传递给属性名称,使其成为模型级条目,无论它是设置为 ModelOnly 还是 All,该条目都将由验证摘要标记帮助程序显示:

ModelState.AddModelError(string.Empty, 
    "The first letters of the name and code must match");

5.3.5 自定义验证属性

在处理程序方法中编写验证代码存在一个问题:它不可重用。您已确定国家/地区名称和代码必须以相同的字母开头,并且您已确保在创建国家/地区时强制执行该规则。但是,无论出于何种原因,当国家/地区更新时,您还需要强制执行该规则。您可以从 Create 页面的 OnPost 处理程序中复制和粘贴代码,但随后会引入维护问题。如果需要对验证逻辑进行更改,则必须在多个位置应用这些更改。相反,您可以将逻辑集中在自定义验证属性中,该属性可以在整个应用程序中重复使用。

自定义验证属性派生自 ValidationAttribute。验证逻辑放置在返回 ValidationResult 对象的重写 IsValid 方法中。IsValid 方法的重载为我们提供了对 ValidationContext 的访问权限,该 ValidationContext 包含有关验证作各个方面的信息,包括正在验证的模型的其余部分。这是您需要使用的版本,因此您可以获取对其他属性的引用。在项目中创建一个名为 ValidationAttributes 的文件夹,并在该文件夹中添加一个名为 CompareFirstLetterAttribute 的 C# 类,其中包含以下代码。

列表 5.31 自定义验证属性

public class CompareFirstLetterAttribute : ValidationAttribute        ❶
{
    public string OtherProperty { get; set; }                         ❷

    protected override ValidationResult IsValid(object value,
     ValidationContext validationContext)                           ❶
    {
        var otherPropertyInfo =
         validationContext.ObjectType.GetRuntime
         Property(OtherProperty);                                   ❸
        if (otherPropertyInfo == null)                                ❸
        {                                                             ❸
            return new ValidationResult(                              ❸
             "You must specify another property to compare to");    ❸
        }                                                             ❸
        var otherValue =                                              ❸
         otherPropertyInfo.GetValue(validationContext.              ❸
         ObjectInstance, null);                                     ❸
        if (!string.IsNullOrWhiteSpace
         (value?.ToString()) &&                                     ❹
         !string.IsNullOrWhiteSpace(otherValue?.ToString()) &&      ❹

         value.ToString().ToLower().First() !=                      ❹
         otherValue.ToString().ToLower().First())                   ❹
        {
            return new ValidationResult(ErrorMessage
            ?? $"The first letters of                                 ❺
             {validationContext.DisplayName}                        ❺
             and {otherPropertyInfo.Name} must match");             ❺
        }                                                             ❺
        return ValidationResult.Success;                              ❺
    }
}

❶ 从 ValidationAttribute 派生类,并重写 IsValid 方法。
❷ 添加一个 public 属性,该属性表示要与之比较的表单属性的名称。
❸ 使用 ValidationContext 获取对其他属性的引用。
❹ 实现验证逻辑。
❺ 如果失败,则返回 ValidationResult,如果验证成功,则返回 ValidationResult.Success。

此自定义属性中的验证逻辑实际上与页面处理程序相同。使用 ValidationContext 访问要与之比较的其他属性的值。其 ObjectType 属性表示正在验证的对象,在本例中为 InputModel。如果找不到其他属性,则返回带有错误消息的 ValidationResult,该消息存储在 ModelState 中。如果找到,则比较每个属性的首字母。然后,如果验证失败,则返回带有不同错误消息的 ValidationResult,或者返回 ValidationResult.Success。

现在,该属性已创建,您可以删除 OnPost 方法中的原始检查,即在清单 5.29 中检查 ModelState.IsValid 之前的代码行。然后,将该特性应用于 target 属性,即 InputModel 的 CountryName 属性:

[Required, CompareFirstLetter(OtherProperty = nameof(CountryCode))]
public string CountryName { get; set; }
自定义属性在模型绑定后与框架属性一起执行。

除了编写自己的验证逻辑(无论是在处理程序中内联还是作为自定义属性)之外,您还可以考虑使用 IValidatableObject (http://mng.bz/XZRv) 来满足更复杂的验证要求。或者,您可以探索一些第三方验证库。一个特别流行的开源库是 Fluent Validation (https://fluentvalidation.net/)。它提供的灵活性比 .NET 的各个部分提供的验证属性要大得多。

这是深入的一章,但我们只完成了在 Razor Pages 应用程序中处理表单的一半。到目前为止,我们已经谈到了一些与表单相关的标签帮助程序的使用,但我们几乎没有触及表面。在下一章中,您将通过更深入地了解更多标记帮助程序来继续您的旅程,这些帮助程序可以减轻在 Razor Pages 中使用表单的负担。

总结

可以使用 POST 和 GET 方法提交表单。
使用 GET 提交的值将作为查询字符串值包含在 URL 中。
使用 POST 提交的值包含在请求正文中。
模型绑定从 HTTP 请求(源)获取值,并将其分配给处理程序方法参数和用 BindProperty 属性(目标)修饰的公共 PageModel 属性。
表单控件中的 name 属性必须与绑定目标的名称匹配,无论它是参数还是属性。
模型绑定支持绑定到集合。
在控件的 name 属性中包含索引,以标识集合中的各个元素。
索引器可以是 sequential 或 explicit。
在更新现有项时,显式索引更有用。
验证可以在客户端上执行,但必须在服务器上执行。
验证属性在客户端和服务器上都有效。
您必须通过在页面中包含所需的脚本来选择加入客户端验证。
您可以编写自己的验证属性来处理更复杂的验证方案。

Leave a Reply

Your email address will not be published. Required fields are marked *