Author Archives: user

ASP.NET Core Razor Pages in Action 10 通过授权控制访问

ASP.NET Core Razor Pages in Action 10 通过授权控制访问

本章涵盖

• 在 Razor Pages 应用程序中启用授权服务
• 使用角色和声明授权终端节点
• 根据需求和处理程序创建授权策略
• 授权访问资源

在上一章中,您学习了如何通过要求用户对自己进行身份验证来识别用户。通过身份验证后,用户不再是匿名的;他们有一个身份,我们可以使用它来限制对应用程序各个部分的访问。此过程称为授权,对于保护应用程序的某些部分免受不应访问的用户的攻击至关重要。

即使是最简单的动态 Web 应用程序也可能包括一个所有者维护内容的区域,即管理区域。这将需要防止未经授权的访问,除非您希望随机用户开始发布他们自己的内容,或者更糟的是:污损或删除您现有的内容。更复杂的应用程序可能需要复杂的访问策略,其中不同的用户对应用程序的某些部分具有不同级别的权限。例如,您可以允许选定数量的用户添加到您的网站提供的度假地点范围,但进一步限制谁可以管理价格。客户将能够预订假期并查看他们自己的订单的详细信息,但只有管理员才能查看所有订单的详细信息。超级用户可能是唯一可以更改订单部分内容的用户。

在最简单的级别上,您可以保护应用程序的某些部分,以便只有经过身份验证的用户才能访问它们。您将首先学习如何对单个页面或终端节点执行此作。您还将了解如何保护特定文件夹或整个应用程序中的所有页面。然后,您将探索 ASP.NET Core Identity 提供的一些功能,用于更精细地管理授权。您将了解如何根据用户的角色将用户分组在一起,并在此基础上管理对终端节点的访问。然后,您将更详细地探讨上一章中介绍的声明概念,并确定如何基于声明制定授权策略并应用它们来管理对终端节点的访问。阻止访问终端节点称为基于请求的授权。在学习如何创建策略时,您将了解它们如何基于要求及其处理程序。您将编写自己的需求和处理程序,并使用它们来制定一些策略。

最后,您将了解如何在页面中实现 fine-grained 授权。例如,许多用户可能有权访问列出要出租的房产的页面,但只有选定的用户才有权编辑这些房产。在这种情况下,您不希望向没有足够权限的用户显示 Edit 导航。在此类页面中应用授权策略称为基于资源的授权。

我一直认为授权与依赖项注入有一些共同点。这两个主题实际上都是相对容易理解的话题,但都笼罩在抽象概念的迷雾中。正如我希望在有关依赖关系注入的章节中消除迷雾一样,我将首先解释您将使用的有关授权的一些概念。

10.1 Razor Pages 中的基本授权

Razor Pages 应用程序中的授权由许多服务提供,包括 IAuthorizationService。这些必须在应用程序启动时添加到服务容器中。一种便捷的方法 AddAuthorization 负责添加所有必需的服务:

builder.Services.AddAuthorization();

默认情况下,授权中间件在 Web 应用程序模板中通过包含 app 来启用。UseAuthorization() 在 Program 类中。

授权取决于知道用户是谁以及他们试图做什么,以及在基于请求的授权的情况下,他们试图访问哪个页面。如第 4 章所述,由 UseRouting 添加到管道中的 EndpointRouting 中间件负责确定用户尝试访问的页面。在上一章中,您了解了身份验证中间件(使用 UseAuthentication 添加)负责确定用户是谁。最后,MapRazorPages 是执行所选页面的位置。此中间件流程如图 10.1 所示。

图 10.1 中间件授权顺序取决于知道用户是谁以及他们尝试去哪里。如果用户未授权,则管道短路。否则,请求将流向终端节点中间件,并执行页面。

鉴于您不想执行当前用户无权访问的页面,因此管道中放置 UseAuthorization 的唯一逻辑位置是在 UseRouting 和 UseAuthentication 之后以及 MapRazorPages 之前。因此,授权中间件必须放在身份验证中间件之后和调用 MapRazorPages 之前(清单 10.1)。

清单 10.1 app 的位置。UseAuthorization 至关重要

app.UseRouting();           ❶
app.UseAuthentication();    ❶
app.UseAuthorization();    
app.MapRazorPages();        ❷

❶ 路由和身份验证中间件必须放在授权中间件之前。
❷ 端点中间件必须放在授权中间件之后。

10.1.1 应用简单授权

您将使用 AuthorizeAttribute 将授权应用于终端节点。该属性具有一些属性,其中包括 Roles 和 Policy,在完成本章时,您将更详细地了解这些属性。在最基本的情况下,当您将属性应用于终端节点时,它会阻止匿名用户访问该终端节点。用户必须进行身份验证才能获得继续的授权。

有多种方法可以将属性应用于终端节点。将其添加到 PageModel 类的最简单方法。

注意熟悉 MVC 框架的读者可能习惯于将 Authorize 属性分配给控制器中的作方法。虽然 PageModel 处理程序方法类似于控制器作方法,但 Razor Pages 不支持将 Authorize 属性分配给处理程序方法。如果您考虑一下,这是有道理的,因为您正在授权终端节点,而不管用于访问它的 HTTP 方法如何。Razor 页面表示单个终结点,而 MVC 控制器通常负责处理多个终结点。

目前,如果通过 GET 请求访问该页面,则 Index 页面会向匿名用户返回 ChallengeResult。您将更改此设置,以便匿名用户无法通过使用 Authorize 属性保护页面来访问该页面。对 Pages\Index.cshtml.cs 中代码的更改将显示在下一个清单中。

清单 10.2 将 Authorize 属性应用于 home PageModel

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

namespace CityBreaks.Pages
{
    [Authorize]                                          ❷
    public class IndexModel : PageModel
    {
        private readonly ICityService _cityService;

        public IndexModel(ICityService cityService) =>
            _cityService = cityService;

        public List<City> Cities { get; set; }
        public async Task OnGetAsync() => 
            Cities = await _cityService.GetAllAsync();   ❸
    }
}

❶ 添加 using 以引入授权 API。
❷ 将 Authorize 属性添加到 PageModel 类中。
❸ 删除检查以查看用户是否经过身份验证。

启动应用程序时,您将被定向到登录页面,就像以前一样。授权服务能够访问所请求页面的终端节点元数据,并确定它需要授权用户。它还能够确定当前用户未经过身份验证,因此服务本身返回 ChallengeResult(401 状态代码),从而导致用户被定向登录。ChallengeResult 是三种可能的结果之一。如果用户已通过身份验证,但不符合指定的授权策略要求,则授权服务将返回 ForbidResult(403 状态代码)。如果用户获得授权,中间件会将请求传递给管道中的下一个中间件(图 10.2)。

图 10.2 授权中间件中的决策有三种可能的结果之一:401 Challenge、403 Forbidden 或将请求传递给管道中的下一个中间件。

在 Razor Pages 应用程序中,401 响应包括向浏览器发送重定向到登录页面的指令,默认情况下,该页面配置为位于 /identity/ account/login。如果您不使用 Identity,则可以使用 LoginPath cookie 选项对其进行自定义,如上一章所示:

builder.Services.AddAuthentication(CookieAuthenticationDefaults
➥ .AuthenticationScheme)
    .AddCookie(options =>
{
    options.LoginPath = "/Login";
});

当您使用 Identity 时,您可以通过应用程序 Cookie 配置自定义登录路径。此自定义应在将 Identity 服务添加到容器后进行:

builder.Services.ConfigureApplicationCookie(options =>
{
    options.LoginPath = "/Login";
});

403 响应包括重定向到由 AccessDeniedPath 选项指定的页面。如果您使用的是 Identity UI,则重定向位置为 /identity/account/accessdenied。由于 KebabPageRouteParameterTransformer 的效果,您需要自定义应用程序中的路径,它在 access 和 denied 之间插入一个连字符。将代码放在 AddDefaultIdentity 后面的 Program.cs 中的下一个列表中。

清单 10.3 自定义 AccessDenied 路径

builder.Services.ConfigureApplicationCookie(options =>
{
    options.AccessDeniedPath = "/identity/account/access-denied";
});

通过使用属性装饰 PageModel 来授权端点的声明性方法既快速又简单,但如果您有许多页面要防止匿名用户,则检查是否已将属性应用于相关 PageModel的唯一方法是单独查看每个文件。如果要保护整个文件夹的内容,则必须记住将属性添加到文件夹中的每个页面,包括在将来某个阶段添加的新页面。缓解此问题的一种方法是声明一个派生自 PageModel 的类,并将 Authorize 属性应用于该类,然后获取文件夹中要从该类继承的所有页面。下面说明了如何使用名为 AdminPageModel 的类来实现此目的。

清单 10.4 使用 BasePage 限制对衍生产品的访问

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace CityBreaks.Pages
{
    [Authorize]
    public class AdminPageModel : PageModel
    {
    }
}

当您创建需要保护的页面时,只需更改它继承的类型,使其派生自 AdminPageModel 并自动受到 Authorize 属性的保护:

public class CreateModel : AdminPageModel

这种基页方法非常常见,尤其是在旧版本的 .NET 中,但它仍然存在一个问题,即您需要记住更改生成的代码,以确保 Razor 页面派生自自定义类型而不是 PageModel。您始终可以编写有助于实施此目的的单元测试(无论如何,它们都是一个好主意)。

理想情况下,您希望集中将授权应用于终端节点的代码,以便您可以一目了然地了解应用程序的哪些部分受到保护以及受到保护的程度。在第 4 章中,您使用了 PageConventionCollection 类型的一些扩展方法,将新的路由和页面路由模型约定添加到路由系统中。存在其他扩展方法,使您能够通过约定将授权应用于单个页面和整个文件夹。使用这些规则,您可以在一个位置建立授权规则:Program 类。主要方法包括

• AuthorizePage - 向单个页面添加授权
• AuthorizeFolder - 向指定文件夹中的所有页面添加授权
• AuthorizeAreaFolder - 向指定区域内指定文件夹中的所有页面添加授权

这些方法中的每一个都采用页面、文件夹和/或区域的名称,并且还包括采用策略名称的重载。我们稍后会详细探讨政策。目前,您可以将策略视为代表授权要求,而不仅仅是进行身份验证。您可以在 Program.cs 中使用 AuthorizePage 方法替换之前应用于主页的 Authorize 属性。

清单 10.5 使用 AuthorizePage 方法将授权应用于特定页面

builder.Services.AddRazorPages(options => {
    options.Conventions.AuthorizePage("/Index");                           ❶
    options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
    options.Conventions.Add(new PageRouteTransformerConvention(new KebabPageRouteParameterTransformer()));
});

❶ 在没有策略的情况下使用 AuthorizePage 与将普通 Authorize 属性应用于端点 PageModel 的效果相同。

您只希望授权用户能够访问 Pages 目录中各种 *Manager 文件夹中的 CRUD 管理页面。您可以使用 AuthorizeFolder 方法立即禁止未经身份验证的用户访问,如下面的清单所示。

示例 10.6 使用 AuthorizeFolder 方法授权文件夹

builder.Services.AddRazorPages(options => {
    options.Conventions.AuthorizeFolder("/CityManager");
    options.Conventions.AuthorizeFolder("/CountryManager");
    options.Conventions.AuthorizeFolder("/PropertyManager");
});

 10.1.2 允许匿名访问

在某些情况下,您可能希望禁止匿名访问应用程序的大多数内容,但对奇数页面启用匿名访问。例如,您可能正在开发一个内部业务线应用程序,该应用程序需要锁定以防止匿名用户获得访问权限。为此,您可以指定一个 FallbackPolicy,该 FallbackPolicy 需要在配置授权服务时进行身份验证:

builder.Services.AddAuthorization(options => {
    options.FallbackPolicy = new AuthorizationPolicyBuilder().
        RequireAuthenticatedUser().Build();
});

FallbackPolicy 在未指定其他授权策略(例如通过属性或约定)的情况下成为应用程序的默认授权策略。

您仍然需要用户能够访问登录页面进行身份验证。对于这些实例,您可以使用 AllowAnonymousAttribute。就像 AuthorizeAttribute 一样,您可以将其应用于目标终端节点的 PageModel 类:

[AllowAnonymous]
public class LoginModel : PageModel

或者,可以将 AuthorizeFolder 方法的应用程序与 AllowAnonymousToPage 方法结合使用,该方法将覆盖指定页面的授权,从而允许用户访问该页面,而无需进行身份验证。

清单 10.7 允许匿名访问单个端点

builder.Services.AddRazorPages(options => {
    options.Conventions.AuthorizeFolder("/");              ❶
    options.Conventions.AllowAnonymousToPage("/Login");    ❷
});

❶ 默认阻止匿名访问应用程序。
❷ 允许匿名访问受保护文件夹中的登录页面。

到目前为止,我们已经研究了完全取决于用户是否经过身份验证的授权。在现实世界中,除了最简单的应用程序之外,其他任何内容都需要更精细的访问控制。在本章的其余部分,我们将探索表达这些需求并将其应用于用户的策略。

10.2 使用角色

角色提供了一种简单的机制,用于将具有相同访问级别的用户分组在一起。它们在复杂性不太可能增加且易于区分不同用户组的访问需求的应用程序中最有用。Identity 包括对角色的支持,但必须通过使用 AddRoles<TRole> 方法将与角色相关的服务添加到容器中来启用它。IdentityRole 是表示角色的默认实现:

builder.Services.AddDefaultIdentity<CityBreaksUser>(options => {
    options.Password.RequireDigit = false;
    options.Password.RequireLowercase = false;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireUppercase = false;
})
    .AddRoles<IdentityRole>()                        ❶
    .AddEntityFrameworkStores<CityBreaksContext>();

❶ 使用 AddRoles 方法将与角色相关的服务添加到应用程序。

没有用于管理角色的 UI,因此您必须构建自己的 UI。用于处理角色的主要 API 是 RoleManager<TRole> 服务,它是通过 AddRoles 方法添加到服务容器的服务之一。RoleManager 包括以下 CRUD 方法:

• CreateAsync
• UpdateAsync
• 删除异步

RoleManager 还包括一个属性 Roles,该属性返回 RoleStore 中的所有角色。RoleStore 表示角色的存储机制。在你的例子中,那就是 SQLite 数据库。通常,您不会直接使用 RoleStore;您将为此使用 RoleManager。在接下来的几节中,您将了解如何创建一个简单的管理区域,用于使用 RoleManager 创建和查看角色并将其分配给用户。

10.2.1 查看角色

首先,将名为 RolesManager 的文件夹添加到 Pages 文件夹。在该页面中,添加一个名为 Index.cshtml 的新 Razor 页面。将 RoleManager 服务注入 PageModel 类构造函数,并使用其 Roles 属性填充公共 List 属性。

清单 10.8 RolesManager IndexModel 类

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace CityBreaks.Pages.RolesManager
{
    public class IndexModel : PageModel
    {
        private readonly RoleManager<IdentityRole> _roleManager;    ❶
        public IndexModel(RoleManager<IdentityRole> roleManager)    ❶
        {                                                           ❶
            _roleManager  = roleManager;                            ❶
        }                                                           ❶
        public List<IdentityRole> Roles { get; set; }               ❷
        public void OnGet()
        {
            Roles = _roleManager.Roles.ToList();                    ❷
        }
    }
}

❶ 通过构造函数将 RoleManager<TRole> 服务注入到 IndexModel 中,并将其分配给私有字段供以后使用。
❷ 声明一个公共 List<IdentityRole> 属性,并使用 RoleManager 服务将其填充到所有现有角色中。

在 Razor 页面本身中,检查是否有任何角色,如果有,请在表中显示这些角色。此代码如下面的清单所示。

清单 10.9 列出角色并在表中显示它们

@page
@model CityBreaks.Pages.RolesManager.IndexModel
@{
    ViewData["Title"] = "Roles";
}
<a asp-page="/RolesManager/Create">New</a>
@if (Model.Roles.Any())
{
    <table class="table">
        @foreach(var role in Model.Roles)
        {
            <tr>
                <td>@role.Name</td>
            </tr>
        }
    </table>
}

这里的代码现在应该不需要任何解释。如果您运行该页面,则只会看到用于创建新角色的链接。它无处可去,因为该页面尚不存在。那是你的下一份工作。

10.2.2 添加角色

将名为 Create 的新 Razor 页面添加到 RolesManager 文件夹。这将包含用于创建新角色的表单。该角色唯一需要的数据是名称。将 RoleManager 服务注入页面,并使用其 CreateAsync 方法添加新角色。同样,代码应类似于您已经创建的 CRUD 页面。下面的清单显示了 PageModel 类的代码。

清单 10.10 RolesManager 的 CreateModel

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace CityBreaks.Pages.RolesManager
{
    public class CreateModel : PageModel
    {
        private readonly RoleManager<IdentityRole> _roleManager;
        public CreateModel(RoleManager<IdentityRole> roleManager)
        {
            _roleManager = roleManager;
        }

        [BindProperty]
        public string Name { get; set; }
        public async Task<IActionResult> OnPostAsync()
        {
            if (ModelState.IsValid)
            {
                var role = new IdentityRole { Name = Name };
                await _roleManager.CreateAsync(role);
                return RedirectToPage("/RolesManager/Index");
            }
            return Page();
        }
    }
}

接下来,将表单添加到 Razor 页面本身。

清单 10.11 创建角色表单

@page
@model CityBreaks.Pages.RolesManager.CreateModel
@{
    ViewData["Title"] = "Create Role";
}
<h4>Create Role</h4>

<div class="row">
    <div class="col-md-8">
        <form method="post">
              <div class="form-group mb-3">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Assign" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

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

现在运行应用程序,并导航到 /roles-manager /create。添加一个名为 Admin 的角色,当您满意它有效时,再添加两个名为 CityAdmin 和 PropertyAdmin 的角色。

10.2.3 为用户分配角色

在为用户分配角色之前,您需要一些用户。在应用程序中注册三个用户,使用以下电子邮件地址和相同的密码(为简单起见)。我在本章随附的代码下载中使用了 password:

anna@test.com
colin@test.com
paul@test.com

接下来,将新的 Razor 页面添加到名为 Assign 的 RolesManager 文件夹。在本页中,您将获取所有用户的列表和所有角色的列表,并将它们显示在用于将所选用户分配给所选角色的选定列表中。将 AssignModel 代码更改为以下清单中的代码。

清单 10.12 AssignModel 代码

using CityBreaks.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

namespace CityBreaks.Pages.RolesManager
{
    public class AssignModel : PageModel
    {
        private readonly RoleManager<IdentityRole> _roleManager;           ❶
        private readonly UserManager<CityBreaksUser> _userManager;         ❶
        public AssignModel(RoleManager<IdentityRole>                       ❶
        ➥ roleManager, UserManager<CityBreaksUser> userManager)           ❶
        {                                                                  ❶
            _roleManager = roleManager;                                    ❶
            _userManager = userManager;                                    ❶
        }                                                                  ❶
        public SelectList Roles { get; set; }
        public SelectList Users { get; set; }
        [BindProperty, Required, Display(Name ="Role")]
        public string SelectedRole { get; set; }
        [BindProperty, Required, Display(Name ="User")]
        public string SelectedUser { get; set; }
        public async Task OnGet()
        {
            await GetOptions();
        }

        public async Task<IActionResult> OnPostAsync()
        {
            if (ModelState.IsValid)
            {
                var user = await _userManager.FindByNameAsync(SelectedUser);❷
                await _userManager.AddToRoleAsync(user, SelectedRole);     ❸
                return RedirectToPage("/RolesManager/Index");
            }
            await GetOptions();
            return Page();
        }

        public async Task GetOptions()                                     ❹
        {                                                                  ❹
            var roles = await _roleManager.Roles.ToListAsync();            ❹
            var users = await _userManager.Users.ToListAsync();            ❹
            Roles = new SelectList(roles, nameof(IdentityRole.Name));      ❹
            Users = new SelectList(users, nameof(CityBreaksUser.UserName));❹
        }                                                                  ❹
    }
}

❶ 将 UserManager 和 RoleManager 服务注入 PageModel 类。
❷ 获取具有所选名称的用户。
❸ 将所选用户分配给所选角色。
❹ 声明一个私有方法,将用户和角色分配给 SelectList 对象。

Razor 页面本身在表单中包含两个 select 元素,如下面的清单所示。
示例 10.13 将用户分配给 Role 表单

@page
@model CityBreaks.Pages.RolesManager.AssignModel
@{
}
<h4>Assign User To Role</h4>

<div class="row">
    <div class="col-md-8">
        <form method="post">
              <div class="form-group mb-3">
                <label asp-for="SelectedUser" class="control-label"></label>
                <select asp-for="SelectedUser" asp-items="Model.Users" 
                 ➥ class="form-control">
                    <option></option>
                </select>
                <span asp-validation-for="SelectedUser" 
                 ➥ class="text-danger"></span>
            </div>
            <div class="form-group mb-3">
                <label asp-for="SelectedRole" class="control-label"></label>
                <select asp-for="SelectedRole" 
                 ➥ asp-items="Model.Roles" class="form-control">
                    <option></option>
                </select>
                <span asp-validation-for="SelectedRole" 
                 ➥ class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Assign" 
                 ➥ class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

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

完成此作后,启动应用程序,并导航到 /roles-manager/assign。将 anna@test.com 分配给 Admin 角色。完成此作后,返回到 Assign .cshtml 文件,并为 Microsoft.AspNetCore.Authorization 添加 using 指令。然后向 AssignModel 类添加 Authorize 属性,但这次将 “Admin” 分配为 Roles 属性中的值:

[Authorize(Roles = "Admin")]
public class AssignModel : PageModel
{

重新运行应用程序,这一次,使用 colin@test.com 或 paul@test.com 登录。然后尝试导航到 /roles-manager /assign。您应该会发现您被重定向到 Access Denied 页面。

注销,然后使用 anna@test.com 登录。这一次,当您导航到 roles-manager /assign 时,您应该会访问该页面。Anna 可以访问该页面,因为她是 Roles 属性中指定的角色的成员,而 Paul 则不是。尽管您只传入了一个角色名称,但 Roles 属性采用逗号分隔的角色名称列表,这在使用角色时提供了灵活性。此外,用户可以属于多个角色。

10.2.4 使用策略应用角色检查

您扩展了 Authorize 属性以检查当前用户是否属于指定角色。如果要对整个文件夹的内容应用此检查,可以使用采用策略的 AuthorizeFolder 方法的重载;我们很快会更详细地研究政策。不过,您可以将策略视为表示需要满足的要求,以确定当前用户是否有权访问请求的终端节点。

对于相对简单的策略,您可以在 AddAuthorization 方法中使用 AuthorizeOptions 来配置基于角色的策略。AddPolicy 方法采用策略的名称和 AuthorizationPolicyBuilder,后者具有 RequireRole 方法,使您能够声明需要哪些角色。

清单 10.14 在 Program.cs 中配置基于角色的策略

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminPolicy", 
    ➥ policyBuilder => policyBuilder.RequireRole("Admin"));
});

配置名为 AdminPolicy 的策略后,您可以将其应用于 AuthorizeFolder 方法,以确保只有 Admin 角色的成员才能访问内容:

builder.Services.AddRazorPages(options => {
    options.Conventions.AuthorizeFolder("/RolesManager", "AdminPolicy");
});

正如我在本节开头所指出的,角色对于相对简单的授权要求非常有用,其中访问策略可以应用于用户组。用户要么是角色的成员,要么不是。角色的优点是易于配置和管理。

如果您的应用程序的授权要求越来越复杂,您将开始发现越来越复杂的角色集合难以管理。此时,您需要根据对单个用户的了解来控制授权,而不是基于预定义的用户组来管理授权。您将用于确定这一点的机制称为 claims。

10.3 基于声明的授权

我们在上一章中谈到了声明,但作为复习,声明只是名称-值对,表示您了解的有关用户的数据项。它们附加到 ClaimsIdentity,然后附加到 ClaimsPrincipal(请参见图 10.3)。

图 10.3 ClaimsPrincipal 支持多个标识,每个标识支持多个声明。

在 .NET 中,声明由 Claim 类表示。其属性包括 Type、Value 和 Issuer。最后一个 (Issuer) 是颁发声明的颁发机构。在应用程序中将声明分配给用户时,默认情况下,颁发者为 LOCAL_AUTHORITY。如果您在应用程序中合并了外部身份验证提供程序(如 Google 或 Facebook),它们将颁发他们添加到他们身份验证的身份的任何声明。您可以根据为颁发者提供的权重来选择要使用的声明版本。例如,像 Facebook 这样的外部身份验证服务很可能会证明电子邮件声明,但电子邮件地址可能不存在。

Type 表示的内容没有限制。广泛使用的声明类型由域 schemas.xmlsoap.org 中的 URI 表示。期望开发人员在其代码中使用这些 URI 是不合理的,因此为方便起见,它们在 .NET 中由 ClaimTypes 类中的常量集合表示。表 10.1 显示了您最有可能使用的索赔类型。

表 10.1 常用的声明类型

Claim type

Description

ClaimTypes.Name

Represents the username of the user

ClaimTypes.Email

Used for the user’s email address

ClaimTypes.GivenName

The user’s first name

ClaimTypes.Surname

The user’s last name

ClaimTypes.NameIdentifier

The user’s unique identifier

当用户在应用程序中进行身份验证并为其分配了 ClaimsIdentity 时,身份验证系统会向其添加各种声明。这些包括 Name、Email 和 NameIdentifier(图 10.4)。

图 10.4 已验证用户的填充声明

如果要使用声明作为管理授权的基础,则需要某种方法来分配与访问级别相关的其他声明,并根据用户拥有的声明的存在或值来测试用户是否符合条件。在下一节中,您将创建一个简单的页面,用于向用户添加新声明。

10.3.1 向用户添加声明

首先,您将向 Pages 文件夹添加一个名为 ClaimsManager 的新文件夹。在该页面中,您将添加一个名为 Index 的新 Razor 页面。这将列出已分配其他声明的所有用户,以及已分配的声明的详细信息。

ClaimsManager 索引页面的 PageModel 代码将 UserManager 作为注入的依赖项,并将其分配给公共属性,以便页面的 Razor 部分可以通过其 Model 属性访问它。它还在 OnGetAsync 方法中用于填充 CityBreaksUser 对象的集合。

清单 10.15 应用程序的 claims manager 部分的 IndexModel 类

using CityBreaks.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;

namespace CityBreaks.Pages.ClaimsManager
{
    public class IndexModel : PageModel
    {
        public UserManager<CityBreaksUser> UserManager { get; set; }
        public IndexModel(UserManager<CityBreaksUser> userManager)
        {
            UserManager = userManager; 
        }
        public List<CityBreaksUser> Users { get; set; }
        public async Task OnGetAsync()
        {
            Users = await UserManager.Users.ToListAsync();
        }
    }
}

Razor 页面循环访问用户集合,并通过 UserManager.GetClaimsAsync 方法获取其声明。此方法与数据存储通信,因此它仅返回已存储在 AspNetUserClaims 表内的数据库中的声明。生成的数据不包括由身份验证服务等分配的声明。如果找到任何存储的声明,则会在屏幕上呈现它们的详细信息。

清单 10.16 ClaimsManager 索引页面

@page
@model CityBreaks.Pages.ClaimsManager.IndexModel
@{
    ViewData["Title"] = "User Claims";
}
<h4>User Claims</h4>
<a class="btn btn-success" asp-page="/ClaimsManager/Assign">New</a>
@foreach (var user in Model.Users)
{
    var claims = await Model.UserManager.GetClaimsAsync(user);
    if (claims.Any())
    {
        <h5>@user.UserName</h5>
        <table class="table-striped col-12">
            <tr>
                <th>Type</th>
                <th>Value</th>
                <th>Issuer</th>
            </tr>
            @foreach (var claim in claims)
            {
                <tr>
                    <td>@claim.Type</td>
                    <td>@claim.Value</td>
                    <td>@claim.Issuer</td>
                </tr>
            }
        </table>
    }
}

值得注意的是,前面的代码包含一些您应该避免用于生产应用程序的内容。这是 N + 1 问题的一个示例,之所以这样称呼,是因为代码对用户进行一次数据库调用(在 OnGetAsync 方法中),然后对数据库进行 N 次进一步调用,其中 N 表示在第一次调用中检索到的结果数。每次执行 GetClaimsAsync 时,都会发出一个数据库查询。根据您的应用程序,如果您有大量数据和/或并发用户,这可能会严重损害性能。如果您发现自己需要迭代所有用户的声明,您应该考虑编写自己的 SQL 以在一次调用中获取所有相关数据。

首次运行此页面时,将进行所有数据库调用,但没有要显示的数据,因此您只会看到邀请您添加新声明的按钮。目前它无处可去,因为您尚未创建页面。

将名为 Assign 的新页面添加到 ClaimsManager 文件夹中。此页面将提供选择列表中的用户列表以及声明类型和值的输入。您将使用此页面创建声明并将其分配给用户。UserManager 被注入到 AssignModel 构造函数中,并用于填充包含每个用户的 Id 和名称的 SelectList。所选内容将绑定到 SelectedUserId 属性。添加了两个进一步的绑定属性,表示声明类型和值。

清单 10.17 用于向用户添加声明的 AssignModel 代码

using CityBreaks.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;

namespace CityBreaks.Pages.ClaimsManager
{
    public class AssignModel : PageModel
    {
        private readonly UserManager<CityBreaksUser> _userManager;
        public AssignModel(UserManager<CityBreaksUser> userManager)
        {
            _userManager = userManager;
        }

        public SelectList Users { get; set; }
        [BindProperty, Required, Display(Name = "User")]
        public string SelectedUserId { get; set; }
        [BindProperty, Required, Display(Name ="Claim Type")]
        public string ClaimType { get; set; }
        [BindProperty, Display(Name = "Claim Value")]
        public string ClaimValue { get; set; }
        public async Task OnGetAsync()
        {
            await GetOptions();
        }

        public async Task<IActionResult> OnPostAsync()
        {
            if (ModelState.IsValid)
            {
                var claim = new Claim(ClaimType, ClaimValue ?? String.Empty);
                var user = await _userManager.FindByIdAsync(SelectedUserId);
                await _userManager.AddClaimAsync(user, claim);
                return RedirectToPage("/ClaimsManager/Index");
            }
            await GetOptions();
            return Page();
        }

        public async Task GetOptions()
        {
            var users = await _userManager.Users.ToListAsync();
            Users = new SelectList(users, 
            ➥ nameof(CityBreaksUser.Id), nameof(CityBreaksUser.UserName));
        }
    }
}

使用 UserManager 的 AddClaimAsync 方法将其他声明分配给用户。它采用 user 和 claim 作为参数。您可以使用 bound 属性来构造这些属性。这一次,由于您将用户的唯一标识符绑定到选择列表,因此您使用 FindByIdAsync 方法从 UserManager 获取用户。请记住,您之前使用过 FindByNameAsync。您根据传入的类型和值构造新声明。声明不必分配值,但该值不能为 null,因此如果未提供值,则传入 String.Empty。添加声明后,您将被定向到 Index 页面,您可以在其中看到显示的新声明。

运行应用程序,并导航到 /claims-manager /assign。向 anna@test.com 添加新声明。type 应该是 Admin,值应该留空(图 10.5)。分配声明后,下一步是将其用作授权策略的一部分。

图 10.5 分配给用户的声明列表

10.3.2 使用策略强制实施基于声明的授权

基于声明的授权依赖于策略,我之前已经提到过。策略由一个或多个要求组成。当策略中的所有要求都得到满足时,将授予授权。一个或多个授权处理程序的工作是评估策略中的每个需求是否得到满足(图 10.6)。

图 10.6 策略由一个或多个需求组成,每个需求都有一个或多个处理程序。

使用此模式,可以构建复杂的授权策略,从而对谁可以访问应用程序的哪些部分进行精细控制。除了保护端点之外,还可以在 Razor 页面本身内应用授权策略,因此,例如,您可以根据当前用户的声明切换 UI 部分的可见性。

之前使用 AuthorizationPolicyBuilder.RequireRole 方法时,将创建一个 RolesAuthorizationRequirement 类型的要求,该要求指定需要指定的角色。AuthorizationPolicyBuilder上提供了其他方法,这些方法使您只需使用其他内置要求和处理程序即可表达通用策略(表 10.2)。

表 10.2 构建简单策略的常用方法

Method

Description

RequireClaim(string claimtype)

The user must have the specified claim.

RequireClaim(string claimtype, params string[] allowedValues)

or

RequireClaim(string claimtype, IEmumerable<string> allowedValues)

The user must have the specified claim with one of the specified values.

RequireUserName

The user must have the specified name.

RequireAuthenticatedUser

The user must be authenticated.

RequireAssertion

Takes a delegate that represents an assertion to be tested to determine authorization status.

RequireClaim 方法的变体创建一个 ClaimsAuthorizationRequirement,其中包含一个处理程序,如果指定的声明存在,则返回 true,如果指定了值,则至少找到其中一个值。您可以通过更改现有策略的代码以使用 RequireClaim 方法而不是 RequireRole 来测试这一点:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminPolicy", policyBuilder => 
    ➥ policyBuilder.RequireClaim("Admin"));
});

您还可以通过将策略名称传递给 AuthorizeAttribute 的 Policy 属性,将此策略单独应用于页面:

[Authorize(Policy="AdminPolicy")]
public IndexModel : PageModel
{
    ...
}

10.3.3 将断言用于更复杂的要求

提供 RequireAssertion 方法是为了处理比其他方法所能处理的更复杂的要求。例如,假设您要实施一项要求,规定如果用户具有具有特定值的声明,则用户可以访问角色管理区域,但前提是他们已经在公司工作了六个月以上。为了能够确定这一点,您需要将用户的加入日期记录为索赔。然后,您需要将该值转换为 DateTime 并将其与当前日期进行比较,以确定用户在企业工作的时间。

为了证明这一点,请检查图 10.7 中所示的声明。请注意,在撰写本文时,Paul 的加入日期不到 6 个月,而其他用户的加入日期则超过 6 个月。如果要复制此练习,请务必输入满足相同条件的日期。只有 Anna 和 Paul 具有值为 View Roles 的 Permission 声明。

图 10.7 分配给用户的加入日期和权限声明

RequireAssertion 方法将 AuthorizationHandlerContext 作为参数,如清单 10.18 所示。此类型通过其 User 属性提供对当前用户的访问权限。从那里,您可以检查他们的主张。在下面的代码中,确保用户具有名为 Permission 的声明,并且其值为 View Roles。然后,您尝试检索名为 Joining Date 的声明的值并将其转换为 DateTime,以便您可以根据授权要求测试其值。

Listing 10.18 RequireAssertion 用于更复杂的授权需求

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ViewRolesPolicy", policyBuilder => 
        policyBuilder.RequireAssertion(context =>               ❶
        {
            var joiningDateClaim = context.User.FindFirst(c =>  ❷
            ➥ c.Type == "Joining Date")?.Value;                ❷
            var joiningDate = Convert.ToDateTime(               ❷
            ➥ joiningDateClaim);                               ❷
            return context.User.HasClaim("Permission", 
            ➥ "View Roles") &&                                 ❸
                joiningDate > DateTime.MinValue &&              ❹
                joiningDate < DateTime.Now.AddMonths(-6);       ❹
    }));
});

❶ 使用 RequireAssertion 方法,该方法将 AuthorizationHandlerContext 作为参数,为当前用户提供访问权限。
❷ 使用 FindFirst 方法访问声明并获取其值(如果有)并将其转换为 DateTime。
❸ 使用 HasClaim 方法确定存在具有指定值的声明。
❹ 将联接日期值与 DateTime.MinValue 与当前日期进行比较,以确保声明不为 null,并且日期早于 6 个月前。

内置要求和处理程序应涵盖最常见的授权要求。如果你有很多复杂的授权策略要应用于你的应用程序,你的 Program 类将很快被多个断言填满。此时,您可以通过编写自己的自定义要求和处理程序,将代码从授权配置中移出并移动到单独的类中。

10.3.4 自定义授权要求和处理程序

授权要求类实现 IAuthorizationRequirement 接口。它是一个空的标记接口,没有定义任何成员。处理程序由 IAuthorizationHandler 接口表示,该接口定义 HandleAsync 方法,该方法将 AuthorizationHandlerContext 对象作为参数并返回 Task。处理需求的逻辑位于此方法中。

要求可以有多个处理程序,但是如果要求和处理程序之间存在一对一的关系,则通常会看到两者的代码放在实现两个接口的同一类中。以下示例说明了如何将您之前创建的断言迁移到此类中,该类采用表示月数的参数。将名为 AuthorizationRequired 的新文件夹添加到项目中,然后使用下面清单中的代码添加一个名为 ViewRolesRequirement 的新类。

Listing 10.19 带有内置 handler 的自定义需求

using Microsoft.AspNetCore.Authorization;

namespace CityBreaks.AuthorizationRequirements
{
    public class ViewRolesRequirement : 
    ➥ IAuthorizationRequirement, IAuthorizationHandler                ❶
    {
        public int Months { get; }                                     ❷
        public ViewRolesRequirement(int months)                        ❷
        {                                                              ❷
            Months = months > 0 ? 0 : months;                          ❷
        }                                                              ❷

        public Task HandleAsync(                                       ❸
        ➥ AuthorizationHandlerContext context)                        ❸
        {

            var joiningDateClaim = context.User.                       ❹
            ➥ FindFirst(c => c.Type == "Joining Date")?.Value;        ❹
            if(joiningDateClaim == null)                               ❹
            {                                                          ❹
                return Task.CompletedTask;                             ❹
            }                                                          ❹
            var joiningDate = Convert.ToDateTime(                      ❺
            ➥ joiningDateClaim);                                      ❺
            if(context.User.HasClaim("Permission",                     ❺
            ➥ "View Roles") &&                                        ❺
                joiningDate > DateTime.MinValue &&                     ❺
                joiningDate < DateTime.Now.AddMonths(                  ❺
                ➥ Months))                                            ❺
            {                                                          ❺
                context.Succeed(this);                                 ❺
            }                                                          ❺
            return Task.CompletedTask;                                 ❻
        }
    }
}

❶ 该类同时实现 IAuthorizationRequirement 和 IAuthorizationHandler 接口。
❷ 构造函数将 int 作为参数,并确保它不是正数。
❸ HandleAsync 方法是根据 IAuthorization-Handler 接口的要求实现的。
❹ 检查用户以查看他们是否具有 joiningDateClaim。否则,将退出处理程序。
❺ 评估加入日期以查看它是否存在,以及其值是否早于传入的年龄。
❻ 如果不满足要求,则返回 Task.CompletedTask 以满足 HandleAsync 方法签名。

如果标记为已成功评估,则满足该要求。这是通过调用 AuthorizationHandlerContext 类的 Succeed 方法实现的。此类还提供 Fail 方法,您可以调用该方法以确保授权不成功。例如,如果您的处理程序允许除满足指定条件的用户以外的所有用户,则可以使用此方法。您可以使用 PolicyBuilder 注册策略,为 months 参数传入合适的值。

清单 10.20 注册自定义需求

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ViewRolesPolicy", policyBuilder => 
        policyBuilder.AddRequirements(new ViewRolesRequirement(months: -6)));
});

创建单独的处理程序类

对于简单的用例,构建组合的需求和处理程序很好,但更常见的是,您可能希望将处理程序创建为单独的类以实现重用。执行此作时,需要对常规方法进行一些更改。现有要求本身被缩减为仅 IAuthorizationRequirement 的实现。

Listing 10.21 独立需求类

public class ViewRolesRequirement : IAuthorizationRequirement
{
    public int Months { get; }
    public ViewRolesRequirement(int months)
    {
        Months = months > 0 ? 0 : months;
    }
}

创建一个名为 AuthorizationHandlers 的新文件夹,并向其添加一个名为 ViewRolesHandler 的新类。这是单独的 handler 类。它实现 IAuthorizationHandler。由于处理程序类本身的范围不限于特定要求,因此您必须访问 AuthorizationHandlerContext 的 PendingRequirements 属性,以筛选出正确的要求类型。PendingRequirements 属性获取尚未标记为成功的所有要求。

清单 10.22 被分离到一个类中的 ViewRolesHandler

public class ViewRolesHandler : IAuthorizationHandler
{
    public Task HandleAsync(AuthorizationHandlerContext context)
    {
        foreach (var requirement in                         ❶
        ➥ context.PendingRequirements.ToList())            ❶
        {
            if (requirement is ViewRolesRequirement req)    ❷
            {
                var joiningDateClaim = 
                ➥ context.User.FindFirst(c => c.Type == 
                ➥ "Joining Date")?.Value;
                if (joiningDateClaim == null)
                {
                    return Task.CompletedTask;
                }
                var joiningDate = Convert.ToDateTime(joiningDateClaim);

                if (context.User.HasClaim("Permission", "View Roles") &&
                        joiningDate < DateTime.Now.AddMonths(req.Months))
                {
                    context.Succeed(requirement);
                }
            }
        }
        return Task.CompletedTask;
    }
}

❶ PendingRequirements 返回所有未满足的要求。需要将它们分配给一个列表,以便您可以对它们执行作。
❷ 使用模式匹配来识别 ViewRolesRequirements 并将它们分配给局部变量。

此方法与组合需求处理程序之间的最后一个区别是,您需要将处理程序注册到 Service Container 中,作为 IAuthorizationHandler 的实现:

builder.Services.AddSingleton<IAuthorizationHandler, ViewRolesHandler>();

完成此作后,授权策略的工作方式与之前采用组合 requirement-handler 组合的方法相同。

对需求使用多个处理程序

正如我之前提到的,需求可以有多个处理程序。当有其他方法可以满足要求时,通常是这种情况。假设用户可以查看角色,前提是他们具有值为 View Roles 的 Permission 声明,并且已在公司工作至少 6 个月,或者他们处于 Admin 角色中。现在,您有两种替代方法来授权用户。您可以向现有处理程序添加更多代码以检查用户是否在指定的角色中,但随着时间的推移,随着更多替代方案的出现,该代码可能会变得一团糟。相反,您将为 ViewRolesRequirements 实现一个额外的处理程序。但是,这一次,您将采用另一种方法来制作处理程序,该处理程序专门针对需求进行类型化,因此无需过滤所有待处理的需求。

您将从抽象 AuthorizationHandler<TRequirement> 类派生,其中 TRequirement 表示处理程序所针对的需求类型。处理程序逻辑放置在重写的 HandleRequirementAsync 方法中,该方法除了 AuthorizationHandlerContext 之外,还采用要求类型作为参数。下面的清单显示了重构为名为 HasClaimHandler 的 AuthorizationHandler<TRequirement> 类的声明检查,该类位于名为 AuthorizationHandlers 的文件夹中。

清单 10.23 处理授权需求

using CityBreaks.AuthorizationRequirements;
using Microsoft.AspNetCore.Authorization;

namespace CityBreaks.AuthorizationHandlers
{
    public class HasClaimHandler : AuthorizationHandler<ViewRolesRequirement>
    {
        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, 
            ViewRolesRequirement req)
        {
            var joiningDateClaim = 
            ➥ context.User.FindFirst(c => c.Type == "Joining Date")?.Value;
            if (joiningDateClaim == null)
            {
                return Task.CompletedTask;
            }
            var joiningDate = Convert.ToDateTime(joiningDateClaim);

            if (context.User.HasClaim("Permission", "View Roles") &&
                  joiningDate < DateTime.Now.AddMonths(req.Months))
            {
                context.Succeed(req);
            }
            return Task.CompletedTask;
        }
    }
}

接下来,添加一个名为 IsInRoleHandler 的新类,该类负责处理授权的附加条件。该代码与前面的处理程序非常相似。

清单 10.24 IsInRoleHandler

using CityBreaks.AuthorizationRequirements;
using Microsoft.AspNetCore.Authorization;

namespace CityBreaks.AuthorizationHandlers
{
    public class IsInRoleHandler : AuthorizationHandler<ViewRolesRequirement>
    {
        protected override Task 
        ➥ HandleRequirementAsync(AuthorizationHandlerContext context, 
            ViewRolesRequirement req)
        {
            if (context.User.IsInRole("Admin"))
            {
                context.Succeed(req);
            }
            return Task.CompletedTask;
        }
    }
}

这两个处理程序都需要向服务容器注册:

builder.Services.AddSingleton<IAuthorizationHandler, IsInRoleHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, HasClaimHandler>();

这些注册应替换现有的处理程序注册;否则,它也将被评估。实现 IAuthorizationHandler 的处理程序将针对每个要求进行处理,而类型化为特定要求的处理程序仅针对该类型的要求执行。

要测试此新安排,请从 Anna 中删除 Permission 声明,以便她仅具有 Admin 角色和 Joining date 声明。您可以直接使用数据库管理工具执行此作。然后运行应用程序,并以她的身份登录。您应该会发现她仍然能够访问 Roles 文件夹内容。

10.3.5 角色还是声明?

我们探索了两种方法来确定用户是否获得授权:基于他们所处的角色或他们拥有的声明。但是您应该选择哪种方法呢?自 Identity 发布之前,角色就一直是 ASP.NET 的一部分,当时用户管理系统围绕成员资格框架展开。它们是一个非常简单易掌握的概念,但它们旨在与用户组一起使用,并旨在表示这些用户组可以执行的作。引入 Identity 时,重点转移到基于声明的声明。声明描述的是用户是什么,而不是他们可以做什么。甚至您一直在使用的 IsInRole 方法也会检查特定声明。

一般的建议是支持索赔而不是角色。声明比角色提供更大的灵活性,角色主要出于向后兼容性的原因包含在 Identity 中。话虽如此,至少有一个领域角色仍然可以提供价值:作为向用户批量分配声明的机制。

我们在上一章中查看了 Identity 表的架构。我们当时没有探索的已创建表之一是 AspNetRoleClaims 表。此表包含已分配给角色(而不是单个用户)的声明及其值。置于指定角色中的任何用户都会自动获取所有相关声明。

RoleManager 类具有一个 AddClaimsAsync 方法,该方法将 IdentityRole 对象和 Claim 作为参数,并将它们添加到此表中。使用现有的 ClaimsManager/Assign 页面作为基础,创建一个名为 AssignToRole 的附加页面,该页面使用此方法将声明添加到角色。原始 Assign 页面与此新页面之间的主要区别在于,新页面将 RoleManager 作为依赖项,而不是 UserManager。您将使用 RoleManager.Roles 属性获取选择列表的角色列表,并使用 FindByIdAsync 方法从所选标识值冻结角色。亲爱的读者,剩下的就看你了。如果您遇到困难,请查看本章的下载以获取有效版本。

10.4 授权资源

到目前为止,我们专注于授权端点 (页面)。用户要么有权访问终端节点,要么没有。有时,您需要根据终端节点公开的资源执行授权。例如,您可能遇到这样的情况:任何授权用户都可以添加新属性(资源),但只有创建者可以编辑属性。这意味着您和我都可以添加属性,但您不能编辑我的属性,我也无法编辑您的属性。要应用此类授权,您需要知道该资产的创建者。这只能通过执行端点并从数据库加载属性来完成。在本节中,您将了解如何使用将资源作为参数的授权处理程序来处理此任务。

在执行此作之前,您需要将创建者分配给属性。打开 Property 类,并添加以下两个属性:

public string CreatorId { get; set; }
public CityBreaksUser Creator { get; set; }

CreatorId 是一个字符串属性,因为它将保存用户的 identity 值。默认情况下,这是一个 GUID。添加名为 AddedCreatorToProperty 的新迁移:

[Powershell]   
add-migration AddedCreatorToProperty   
[CLI] 
dotnet ef migrations add AddedCreatorToProperty 

应用迁移,然后更新数据库中的许多属性,以便 CreatorId 列包含用户的 Id 值。我手动将三个用户的 Id 应用于数据库表中的前 20 条记录(图 10.8)。完成此作后,您就可以开始了。

图 10.8 更新属性,使 CreatorId 列填充现有用户的 Id 值。

 10.4.1 创建需求和处理程序

资源授权或多或少与其他授权相同。它需要一个 requirement 和一个处理程序。对于要求,我将使用 ASP.NET Core 授权附带的 OperationAuthorizationRequirement 类。这是一个具有单个属性的帮助程序类:Name。该类旨在表示特定于对数据执行的最常见作的授权要求。为要授权的每个作创建此类的实例,然后相应地设置 Name 属性。清单 10.25 显示了许多实例,代表基本的 CRUD作,它们被分组到一个名为 PropertyOperations 的类中。此类位于 AuthorizationRequirements 文件夹中。

清单 10.25 实现 OperationAuthorizationRequirementss

using Microsoft.AspNetCore.Authorization.Infrastructure;             ❶

namespace CityBreaks.AuthorizationRequirements
{
    public static class PropertyOperations                           ❷
    {
        public static OperationAuthorizationRequirement Create =
            new () { Name = nameof(Create) };                        ❸
        public static OperationAuthorizationRequirement Read =
            new () { Name = nameof(Read) };
        public static OperationAuthorizationRequirement Edit =
            new () { Name = nameof(Edit) };
        public static OperationAuthorizationRequirement Delete =
            new () { Name = nameof(Delete) };
    }
}

❶ OperationAuthorizationRequirement 帮助程序类位于 Microsoft.AspNetCore.Authorization.Infrastructure 命名空间中。
❷ PropertyOperations 是一个包装类,用于满足与 Property 类型作相关的多个授权要求。
❸ 实例化 OperationAuthorizationRequirement 的实例,并将其 Name 设置为 Create。其他 S 实例化时,其名称设置为其他 CRUD作。

现在,您已经拥有了需求类,可以创建处理程序。事实上,由于所有作的要求类型都是相同的(使用 OperationAuthorizationRequirement 类的一个主要好处),因此您只需创建一个处理程序 — 一个基于 AuthorizationHandler 的处理程序,其中 TResource 表示被授权的资源的类型。

处理程序类名为 PropertyAuthorizationhandler,并放置在 AuthorizationHandlers 文件夹中。为简洁起见,它仅包含对两个作的检查:Edit 和 Delete。由于将针对所有属性授权要求调用处理程序,因此请检查要求的 Name 属性以确定正在评估哪个特定要求。如果正在评估 Edit 要求,请检查当前用户的 NameIdentifier 声明中的其 ID,并将其与资源的 CreatorId 属性进行比较。如果存在匹配项,则当前用户创建了资源,因此您应该将要求标记为成功。

您还决定,只有具有 Admin 角色的用户才能删除属性。因此,如果当前要求是 Delete 要求,则仅在这些情况下将其标记为成功。您实际上不需要访问资源来进行此评估,但将资源的所有授权逻辑保存在一个位置是有意义的。处理程序的代码如下面的清单所示。

列表 10.26 PropertyAuthorizationHandler 类

using CityBreaks.AuthorizationRequirements;
using CityBreaks.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using System.Security.Claims;

namespace CityBreaks.AuthorizationHandlers
{
    public class PropertyAuthorizationHandler : 
        AuthorizationHandler<OperationAuthorizationRequirement, Property>    ❶
    {
        protected override Task                                              ❷
        ➥ HandleRequirementAsync(AuthorizationHandlerContext context,       ❷
            OperationAuthorizationRequirement requirement, Property resource)❷
        {
            if(requirement.Name == PropertyOperations.Edit.Name)           ❸❹
            {                                                              ❸
                if (resource.CreatorId ==                                  ❸
                ➥ context.User.FindFirst(c => c.Type ==                   ❸
                ➥ ClaimTypes.NameIdentifier).Value)                       ❸
                {                                                          ❸
                    context.Succeed(requirement);                          ❸
                }                                                          ❸
            }                                                              ❸
            if(requirement.Name == PropertyOperations.Delete.Name)           ❺
            {                                                                ❺
                if (context.User.IsInRole("Admin"))                          ❺
                {                                                            ❺
                    context.Succeed(requirement);                            ❺
                }                                                            ❺
            }                                                                ❺
            return Task.CompletedTask;
        }
    }
}

❶ TRequirement 是 OperationAuthorizationRequirement,TResource 是 Property。
❷ HandleRequirementAsync 方法同时采用需求和资源以及 Authorization-HandlerContext。
❸ 访问资源的 CreatorId 属性,并根据当前用户的 Id 检查其值。如果它们匹配,则要求成功。
❹ 检查当前要求的 Name 属性。如果它与 Edit requirement's name 相同,则处理授权检查。
❺ 如果要求是 Delete 要求,则检查当前用户是否为 Admin。

在这个阶段,您有一个选择。您可以像以前一样为您的需求注册正式策略,也可以选择直接根据需求进行评估。由于后者涉及的工作量较少,因此请选择该选项。

从数据库中检索要编辑的属性后,将在属性管理器 Edit 页面的 OnGetAsync 处理程序中进行 Edit authorization 检查。您将直接使用 IAuthorizationService 执行检查。它有一个 AuthorizeUserAsync 方法,该方法将用户、资源和要求作为参数,并返回一个具有布尔 Succeeded 属性的 AuthorizationResult 对象。您将传入当前用户、要编辑的属性以及 Edit 要求。如果 Succeeded 为 true,则当前用户已获得授权。否则,您将返回 Forbid 结果,该结果会将用户重定向到 Access Denied 终端节点。首先,您需要注入 IAuthorizationService 并在私有字段中捕获它。

清单 10.27 将 IAuthorizationService 注入 EditModel

public class EditModel : PageModel
{
    private readonly IPropertyService _propertyService;
    private readonly ICityService _cityService;
    private readonly IAuthorizationService _authService;

    public EditModel(IPropertyService propertyService, 
                    ICityService cityService, 
                    IAuthorizationService authService)
    {
        _propertyService = propertyService;
        _cityService = cityService;
        _authService = authService;
    }
...

然后,修改 OnGetAsync 方法以执行授权检查。

清单 10.28 使用 AuthorizeUserAsync 评估用户、资源和需求

public async Task<IActionResult> OnGetAsync()
{
    var property = await _propertyService.FindAsync(Id);

    if (property == null)
    {
        return NotFound();
    }
    var result = await _authService.AuthorizeAsync(User, 
    ➥ property, PropertyOperations.Edit);
    if (!result.Succeeded) 
    { 
        return Forbid();
    } 

在我的应用程序版本中,Paul 是 Id 以 beed 开头的用户。从图 10.8 中可以看出,他是 Id 为 1 的属性的创建者。如果我以 Paul 身份登录并导航到 /property-manager /edit/1,则我可以访问“编辑”表单。如果我尝试将 URL 中的 1 替换为 2,则会显示 Access Denied 页面,因为 Paul 不是 ID 为 2 的属性的创建者。

如果您更喜欢根据策略进行检查,而不是直接检查需求,则可以像这样注册策略:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("EditPropertyPolicy", policyBuilder => 
        policyBuilder.AddRequirements(PropertyOperations.Edit));
});

然后,将策略的名称传递给 AuthorizeUserAsync 方法,而不是要求的名称:

var result = await _authService.AuthorizeAsync(User, property, 
➥ "EditPropertyPolicy");

除了需要更多的工作外,检查策略还需要使用字符串,您可以使用常量来解决这种情况,但这需要更多的工作。

还有一件事需要注意。目前,属性管理器索引页面列出了所有属性,并带有链接,可以查看其详细信息、编辑和删除它们。当用户没有授权时,向用户提供编辑或删除属性的链接似乎毫无意义。理想情况下,您仅在用户创建资产时提供编辑链接,如果他们具有 Admin 角色,则删除链接。在本章的最后一节中,您将使用 IAuthorizationService 根据当前用户和资源管理演示文稿。

10.4.2 将授权应用于 UI

当您希望根据资源在 UI 中评估授权时,请在 IAuthorizationService 上使用与刚才在属性管理器的 EditModel 中使用的相同的 AuthorizeAsync 方法。您需要使该服务可用于 Razor 页面,因此使用 inject 指令注入该服务。然后,根据方法调用的结果显示 Edit 和 Delete 链接。

以下列表详细介绍了对 PropertyManager \Index.cshtml 文件的代码更改。它需要在页面顶部使用几个 using 指令以及 inject 指令才能使 IAuthorizationService 可用。

清单 10.29 将 IAuthorizationService 注入到索引页中

@page
@model CityBreaks.Pages.PropertyManager.IndexModel
@using CityBreaks.AuthorizationRequirements
@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService authService

唯一其他必需的更改发生在包含链接的 td 元素中。将该元素替换为以下清单中的代码。

清单 10.30 使用 IAuthorizationService

<td>
    <a asp-page="./Details" asp-route-id="@item.Id">Details</a> 
    @{
        var result = await authService.AuthorizeAsync(User, 
        ➥ item, PropertyOperations.Edit);
        if (result.Succeeded)
        {
            @:|
            <a asp-page="./Edit" asp-route-id="@item.Id">Edit</a>
        }
        result = await authService.AuthorizeAsync(User, 
        ➥ item, PropertyOperations.Delete);
        if (result.Succeeded)
        {
                @:|
                <a asp-page="./Delete" asp-route-id="@item.Id">Delete</a>
        }
    }
</td>

在这里,您将使用与使用 EditModel 的 OnGetAsync 方法相同的方法和相同的参数。这一次,如果授权评估成功,则呈现一个链接。

当 Paul 登录时,他看到一些 Edit 链接,但根本看不到 Delete 链接,因为他没有 Admin 角色。当 Anna 登录时,她可以删除任何属性,但只有 Edit 链接可用于您为其分配为创建者的属性(图 10.9)。

图 10.9作链接不同,具体取决于登录者。

还有一件重要的事情需要牢记。虽然您已从页面中删除了 Delete 链接,但了解应用程序 URL 的人员仍可以向 Delete 端点提交请求。您需要应用授权检查,仅允许 Admin 角色中的用户能够执行此作。您决定是使用 Authorize 属性还是页面约定来应用授权检查,但需要添加检查。您可以像在第 5 章中介绍的那样考虑基于 UI 的授权:很高兴有,但您仍然必须在服务器上应用检查,并且终端节点授权等同于服务器端输入验证。

在本章中,您已经完成了对所有主要框架功能的回顾。最后四章涵盖了您需要了解的一系列主题,以便对应用程序进行故障排除、保护应用程序并发布应用程序。在此之前,我们将在下一章中介绍与 Razor Pages 框架相关的客户端开发的某些方面,特别是 AJAX 技术的使用。

总结

授权是确定允许用户执行哪些作的过程。
授权通过 AddAuthorization 添加为服务,并通过 UseAuthorization 作为中间件启用。这应该放在身份验证之后。如果用户未经过身份验证,则无法授权用户。
您可以将授权应用于终端节点 (页面) 和资源。
使用 Authorize 属性,可以将简单授权应用于终端节点。默认情况下,它只允许经过身份验证的用户。
或者,您可以使用页面约定向页面、文件夹和区域添加授权,从而允许您集中授权配置。
ASP.NET Identity 支持基于角色的授权,这使您能够将具有相同权限的用户分组在一起。
您可以使用当前用户的声明作为授权的基础,这些声明表示有关用户的数据并构成其身份的一部分。
授权建立在要求、处理程序和策略之上。每个策略都由一个或多个需求组成,每个需求都有一个或多个处理程序。
要求处理程序包含用于确定当前用户是否获得授权的逻辑。处理程序调用 context.Succeed(requirement) 将要求标记为成功。
只有一个处理程序需要成功,要求就会成功。必须满足所有要求才能授予授权。
您可以通过调用 context 来确保授权失败。Fail 方法。
授权包括许多内置策略,包括需要一个或多个角色、一个或多个声明、经过身份验证的用户和特定用户名的策略。
您可以创建自己的自定义要求、处理程序和策略。自定义处理程序必须注册为服务。
资源授权使您能够对应用程序模型元素的访问应用精细控制。
OperationAuthorizationRequirement 帮助程序类旨在帮助定义用于授权对资源执行特定作的处理程序。
可以将 IAuthorizationService 注入 Razor 页面,以将授权应用于 UI 的呈现。

ASP.NET Core Razor Pages in Action 9 使用身份验证管理用户

ASP.NET Core Razor Pages in Action 9 使用身份验证管理用户

本章涵盖

• 实施基本身份验证
• 安装和配置 ASP.NET Core Identity
• 搭建基架和自定义身份 UI

本章和下一章将介绍如何在 Razor Pages 应用程序中管理用户。他们着眼于两个密切相关的主题:身份验证和授权。身份验证是识别用户身份的过程。授权是保护应用程序过程的一部分,它将用户的访问权限限制为仅允许他们访问的应用程序部分。

本章重点介绍用户的性质以及如何验证其身份,或确保他们是他们声称的身份。身份验证很难正确。从实际角度来看,您需要提供机制来捕获和存储用户的个人信息,包括只有他们知道的信息,例如密码。然后,您需要能够在后续访问中记住该用户。您的应用程序可能还要求您使用辅助身份验证机制,即双因素身份验证 (2FA),例如通过 SMS 发送的代码。您可能需要为用户提供重置密码(如果忘记密码)或管理其个人资料信息的功能。如何验证电子邮件地址?或者在反复错误登录尝试的情况下管理帐户锁定?您需要以加密安全的方式完成所有这些作。

要真正安全,您需要跟上不断变化的加密标准,并深入了解允许不良行为者(黑客)劫持或克隆用户身份的潜在攻击媒介。因此,除非您是该领域的专家,否则您永远不应该尝试实施自己的身份验证解决方案。如果他们的个人数据最终出现在 Pastebin 上供全世界查看,您的用户将不会钦佩您的技术努力。

相反,我们将探索由专家编写和测试的现成 ASP.NET Core Identity 库,该库解决了安全管理用户的问题。我们将了解它的默认实现以及它提供的自定义机会。然后,您将了解 Identity UI 包,其中包括涵盖各种用户管理场景的现成页面。您将使用基架生成其中一些页面的版本,以便根据您的要求对其进行自定义,并且您将为新注册实施电子邮件确认服务。

在本章结束时,您将了解身份验证基础知识,并使用 ASP.NET Core Identity 扩展现有数据库以处理用户信息存储。您将了解如何自定义 Identity 框架的各个方面以满足您的业务需求,并且已安装 Identity UI 包并学习如何根据您的应用程序要求对其进行修改。

9.1 身份验证基础知识

虽然本章的大部分内容集中在 ASP.NET Core Identity 库,但您首先要了解 ASP.NET Core 应用程序中身份验证背后的基础知识。您将了解如何向 Razor Pages 应用程序添加身份验证服务、启用它们,并使用它们为用户分配身份,以便它们对您不再匿名。

9.1.1 身份验证的工作原理

如果您曾经参加过贸易会议,您很可能在门口被要求表明自己的身份,可能是通过填写某种纸质或电子表格。您让自己登录并获得一个徽章,可以在会议厅周围佩戴,这样您就不必一次又一次地重新表明自己的身份。徽章将包含您编码为条形码或 QR 码的信息,因此需要知道您身份的人可以使用解码器(扫描仪)快速访问它,而无需询问您。徽章将在某个时候过期 - 可能在一天结束时或大会上。

Web 应用程序中的身份验证遵循类似的过程。Razor Pages 应用程序访问者需要通过填写某种登录表单来提供其身份信息。登录后,身份验证服务会将有关用户的信息序列化为加密的 Cookie,这相当于您在约定中佩戴的徽章。对于所有后续请求,该 Cookie 将在客户端和服务器之间传递,直到 Cookie 过期。身份验证中间件从此 Cookie 中读取值,并使用它们来合成 HttpContext 的 User 属性。此后,任何需要有关当前用户的信息的服务都可以检查 User 属性,该属性是 ClaimsPrincipal 类型。在 Razor Pages 应用程序中,等效于贸易协定访问者由 ClaimsPrincipal 的此实例表示。

9.1.2 添加简单身份验证

本节介绍向 CityBreaks 应用程序添加基于 Cookie 的身份验证所需的最少步骤。您将向服务容器添加身份验证服务,并指定有关身份验证服务应如何工作的一些默认信息。然后,您将添加启用身份验证功能的身份验证中间件。最后,您将添加一个简单的登录表单来获取用户的凭证并对其进行身份验证。

首先,您需要向 Program.cs 添加 using 指令,以使 Microsoft.AspNetCore .Authentication.Cookies 可用于您的代码。然后,使用 AddAuthentication 方法添加身份验证服务。

清单 9.1 向服务容器添加身份验证服务

builder.Services.AddAuthentication(CookieAuthenticationDefaults
 .AuthenticationScheme)
    .AddCookie();

就是这样。您添加了非常基本的 Cookie 身份验证。您必须指定默认方案的名称,该名称表示已注册的身份验证处理程序及其选项。在本例中,您使用了 CookieAuthenticationDefaults.AuthenticationSchxme 来表示方案的名称。它是一个解析为 Cookie 的常量。

下一阶段是添加身份验证中间件。您将通过调用 app 来执行此作。UseAuthentication() 请求管道中。但是,此调用的位置对于身份验证正常工作至关重要。它必须在添加路由之后但在 MapRazorPages 调用添加终结点之前放置。默认模板包括对 UseAuthorization 的调用,该调用添加了授权中间件。在此之前,您还必须添加身份验证;最终结果如下面的清单所示。

清单 9.2 在路由之后和授权之前添加身份验证中间件

app.UseRouting();

app.UseAuthentication();      ❶
app.UseAuthorization();

app.MapRazorPages();

❶ 在路由之后和授权和端点中间件之前添加身份验证。

我们将在下一章更详细地探讨授权,但目前,授权中间件需要有当前用户(由身份验证中间件产生)和当前端点(由路由中间件选择)的信息,才能知道如果当前用户无权访问所选端点,是否要短路管道,或者允许请求流向端点中间件。 ,以便可以执行选定的端点。接下来,您将在 Pages 文件夹中创建一个名为 Login 的新 Razor 页面。您将使用以下清单中的代码添加一个非常简单的表单,该表单仅捕获名称。

清单 9.3 简单的登录表单

@page
@model CityBreaks.Pages.LoginModel
@{
}
<div class="col-4">
    <form method="post">
        <div class="mb-3">
            <label class="form-label" asp-for="UserName"></label>
            <input class="form-control" asp-for="UserName" />
        </div>
        <div class="mb-3">
            <button class="btn btn-outline-primary">Sign in</button>
        </div>
    </form>
</div>

现在,转到 PageModel,您需要添加下一个清单中所示的 using 指令。

清单 9.4 LoginModel 类所需的 using 指令

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using System.Security.Claims;
using System.ComponentModel.DataAnnotations;

PageModel 类本身 (LoginModel) 具有 UserName 的绑定属性和异步 OnPost 处理程序,您可以在其中使用提供的用户名登录用户。

列表 9.5 LoginModel 类

public class LoginModel : PageModel
{
    [BindProperty, Display(Name="User name")]                       ❶
    public string UserName { get; set; }                            ❶

    public async Task OnPostAsync()
    {
        var claims = new List<Claim>                                ❷
        {                                                           ❷
            new Claim(ClaimTypes.Name, UserName)                    ❷
        };                                                          ❷
        var identity = new ClaimsIdentity(claims, 
        ➥ CookieAuthenticationDefaults.AuthenticationScheme);      ❸
        var principal = new ClaimsPrincipal(identity);              ❹
        await HttpContext.SignInAsync(principal);                   ❺
    }
}

❶ 将 UserName 属性分配为绑定目标。
❷ 将用户名分配给索赔。
❸ 根据声明创建身份。
❹ 使用标识创建 ClaimsPrincipal。
❺ 签到委托人。

专注于清单 9.5 中的 OnPostAsync 处理程序,您首先创建一个声明。声明是一致存储的有关用户的信息片段:声明的名称和值(可选)。这是基于声明的身份验证的基础,这也是 ASP.NET Core 使用的功能。例如,我声称自己有 Mike Brind 这个名字。我也声称自己是一名作家(尽管是偶然的)。而且我声称会开车......比刘易斯·汉密尔顿更好。好的,所以我的一些主张可能经不起推敲,但我们稍后会看看。

您将使用该声明创建身份。标识是支持声明的特定类型:ClaimsIdentity。正如您从用于构造此标识的声明是集合的一部分这一事实中推断出的那样,一个标识可以支持多个声明。此标识用于构造 ClaimsPrincipal,表示登录的用户。ClaimsPrincipal 能够支持多个身份,这也是有道理的,因为在现实生活中,我们实际上有多种形式的身份。例如,我有驾照和护照。两种形式的身份具有相似的声明:我的名字和地址。我的驾驶执照还包括有关我被允许驾驶的车辆类别的索赔。我有一张季票可以去观看我最喜欢的足球队。这是另一种形式的身份证明,包括我被允许进入体育场的入口、我被分配的座位以及通行证有效的赛季。图 9.1 说明了这个概念。

图 9.1 ClaimsPrincipal 支持多个标识,每个标识都支持多个声明。

创建主体后,使用 HttpContext 上的 SignInAsync 扩展方法将其登录。此方法使用已注册的身份验证处理程序创建 Cookie,从而确定用户已经过身份验证。

接下来,您将向布局页面添加一些代码,这些代码将显示经过身份验证的用户的名称,如果用户未经过身份验证,则显示指向登录页面的链接。将清单 9.6 中的代码块放在包含 ul 元素的 div 之后,该元素在 nav 元素中包含导航链接。

清单 9.6 显示用户名或登录链接

<div class="align-content-end">
    @if (User.Identity.IsAuthenticated)                   ❶
    {
        <p>@User.Identity.Name, you are logged in</p>     ❷
    }
    else
    {
        <a asp-page="/login">Sign in</a>
    }
</div>

❶ 检查用户是否经过身份验证。
❷ 使用 User.Identity.Name 属性访问用户名。

ClaimsPrincipal 通过页面的 User 属性进行访问。其 Identity 属性提供对用户的主要声明标识的访问。它公开了两个有用的属性:IsAuthenticated(用于确定当前请求是来自经过身份验证的用户还是匿名用户)和 Name(用于获取分配给 Name 声明的值)。

还有两个步骤需要采取。第一种是强制用户登录。您将通过发出质询来执行此作,使用我在第 3 章:ChallengeResult 中没有介绍的作结果。与 Cookie 一起使用时,质询会将匿名用户重定向到配置的登录路径,默认情况下为 /Account /Login。您正在使用不同的路径,因此您需要先配置该路径。导航到 Program.cs,然后修改身份验证服务注册以包含 Cookie 选项,如下所示。

示例 9.7 配置默认登录路径

builder.Services.AddAuthentication(CookieAuthenticationDefaults
➥ .AuthenticationScheme)
.AddCookie(options =>
{
    options.LoginPath = "/login";
});

最后,在主页 IndexModel 中修改 OnGetAsync 处理程序,以便在用户未通过身份验证时返回 ChallengeResult。还需要为 Microsoft.AspNetCore.Mvc 添加 using 指令。
列表 9.8 向 OnGetAsync 处理程序添加 ChallengeResult

public async Task<IActionResult> OnGetAsync()     ❶
{
    if (User.Identity.IsAuthenticated)            ❷
    {
        Cities = await _cityService.GetAllAsync();
        return Page();
    }
    return Challenge();                           ❸
} 

❶ 更改返回 Task<IActionResult> 的方法。
❷ 在显示内容之前检查用户是否经过身份验证。
❸ 如果没有,请发出质询。

除了 Razor 页面之外,PageModel 类还通过 User 属性公开当前用户。您将访问该作以确定当前用户是否经过身份验证,如果未通过身份验证,您将使用 Challenge 帮助程序方法返回新的 ChallengeResult。

现在一切都已准备就绪,您可以运行应用程序。当应用程序启动时,您应该会自动重定向到配置的登录路径,但请注意,浏览器地址栏中的 URL 还包含一个查询字符串:/login?ReturnUrl=%2F 的 ReturnUrl=%2F 中。ReturnUrl 查询字符串值表示发出质询的页面,在本例中为主页。%2F 是 URL 编码的正斜杠。通过输入用户名并提交登录表单进行身份验证后,SignInAsync 方法会将您重定向回该位置。尝试输入任何你喜欢的名字,并注意你已经通过身份验证了(图 9.2)。

图 9.2 您已成功通过身份验证,导航栏中的消息确认了这一点。

在网站上移动,请注意您保持登录状态。您只能在关闭浏览器时注销自己,而不仅仅是选项卡。这不是很安全。登录时可以假装成任何人的事实也不是。

我以地球上最富有的人之一(埃隆·马斯克 (Elon Musk))的身份登录,只是因为我能做到。目前,应用程序中没有代码来验证我声称自己是马斯克先生。想象一下,如果你能走到 Elon 的银行,说你就是他。当然,银行会要求您通过提供其他信息来验证您的索赔,例如只有马斯克先生知道的秘密。在大多数情况下,这至少采用密码的形式。

为了使您的登录更安全,您需要一种方法来安全地捕获和存储用户的密码。您还需要将提交的凭证与您存储的凭证进行比较,以确保登录者是他们所说的身份。理想情况下,您还需要为用户提供一种方法,以便在他们忘记密码或认为密码已泄露时注册帐户、注销和重置密码。这是从头开始进行的大量工作,尤其是当 ASP.NET Core Identity 为您完成大部分工作时。

9.2 ASP.NET 核心身份

ASP.NET Core Identity (Identity) 是一个支持身份验证 (确定访客是谁) 和授权 (确定允许他们执行哪些作) 的框架。默认实现使用 EF Core 将用户详细信息(包括其凭据)存储在数据库中。它还为许多常见场景提供了可自定义的 UI,包括注册用户;让他们登录;重置密码;管理他们的个人资料;生成帐户验证令牌;与外部身份验证提供商合作,例如 Google、Microsoft、Facebook、Twitter;和更多。

标识以 NuGet 包的形式提供。当您第一次使用 Visual Studio 中的 new project 向导创建 Web 应用程序时,您可以通过指定 Individual Accounts 作为身份验证类型来将其配置为在一开始就包含 Identity(图 9.3)。

图 9.3 选择 Individual Accounts 以在项目中包含 Identity。

如果您使用 CLI 创建应用程序,则可以使用 --auth 或 -au 开关来指定新项目使用个人账户:

dotnet new webapp --auth Individual

但是,您已经有一个项目。您需要采取某些步骤才能将其设置为与 Identity 一起使用。您需要添加所需的包,添加一个类来表示您的用户,配置现有数据库以使其与 Identity 一起使用,然后配置 Identity 所依赖的服务。完成这些步骤后,您可以添加新的迁移,该迁移将更新您的现有数据库以充当身份存储。

存储与 Identity 本身是分开的。您可以自由选择自己的存储机制,并创建自定义提供程序(如果尚不存在)以使用它。身份存储的默认实现依赖于 Microsoft.AspNetCore 。Identity.EntityFrameworkCore NuGet 包,适用于关系数据库。使用 dotnet add package(如果使用 CLI)将其添加到项目中,或者使用 install-package(对于包管理器控制台),通过直接添加对项目文件的引用或使用 Visual Studio 工具在解决方案中管理 NuGet 包。标识 UI 位于名为 Microsoft.AspNetCore 的包中。Identity.UI,因此也需要添加它。添加这两个包后,请确保执行 dotnet restore。

9.2.1 创建用户

下一步要求您创建一个类来表示您的用户。此类的一个关键功能是,如果要使用 Identity 框架,它必须从 IdentityUser 派生。IdentityUser 类型已定义多个属性,包括 UserName、Email、PhoneNumber 和一些特定于身份验证工作流程的属性,例如 LockoutEnabled。这表示是否启用了锁定功能,因此在指定次数的无效登录尝试后,帐户会自动锁定。您可以启用此功能来防止暴力攻击 — 通常是黑客系统地尝试所有可能的密码组合来自动尝试登录。

IdentityUser 还包括一个名为 HashedPassword 的属性。这提供了一个线索,表明 Identity 在存储密码之前使用加密安全算法对密码进行哈希处理。密码永远不应存储为纯文本。 用户很懒惰,倾向于重复使用密码。他们很可能在您的应用程序中使用与他们用于银行相同的应用程序。如果黑客能够获取用户数据的副本,则您可能犯了向他们提供对应用程序外部各种资源的访问权限的罪行。您将调用您的用户类 CityBreaksUser。将以下类添加到 Models 文件夹中。

清单 9.9 CityBreaksUser 类

using Microsoft.AspNetCore.Identity;

namespace CityBreaks.Models
{
    public class CityBreaksUser : IdentityUser      ❶
    {
    }
}

❶ 您的用户类派生自 IdentityUser。

在此阶段,我们将仅使用 IdentityUser 的默认属性。我们稍后会看看如何自定义这个类。

9.2.2 配置 DbContext

下一阶段涉及调整现有的 CityBreaksContext 以使用 Identity。为此需要进行两项修改(清单 9.10)。第一种是从 IdentityDbContext<TUser> 而不是 DbContext 派生上下文类,其中 TUser 表示您刚刚创建的用户类。IdentityDbContext 包括一些 DbSet 属性,表示 Identity 用于存储用户数据各个方面的数据库表。您还需要包含 Microsoft.AspNetCore.Identity 的 using 指令。EntityFrameworkCore 的 EntityFrameworkCore 中。

示例 9.10 调整 DbContext 以使用 Identity

using CityBreaks.Data.Configuration;
using CityBreaks.Models;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;   ❶
using Microsoft.EntityFrameworkCore;

namespace CityBreaks.Data
{
    public class CityBreaksContext : 
    ➥ IdentityDbContext<CityBreaksUser>                   ❷
    {
        public CityBreaksContext(DbContextOptions options) : 
        ➥ base(options)
        {
            ...

❶ 添加相关的 using 指令以将 Identity 引入范围。
❷ 从 IdentityDbContext而不是 DbContext 派生 CityBreaksContext,并将 CityBreaksUser 指定为类型参数。

您需要确保调用 base。OnModelCreating 在重写的 OnModelCreating 方法中;否则,将不会进行 IdentityDbContext 的模型配置,从而导致在尝试创建迁移时出现错误。这在以下清单中的粗体行中显示。

清单 9.11 调用基。OnModelCreating 在重写的 OnModelCreating 中

protected override void OnModelCreating(ModelBuilder builder)
{
    builder
        .ApplyConfiguration(new CityConfiguration())
        .ApplyConfiguration(new CountryConfiguration())
        .ApplyConfiguration(new PropertyConfiguration());
    base.OnModelCreating(builder);
}

配置 Identity 服务

您需要配置应用程序以包含 Identity 服务,并使用 CityBreaksContext 作为存储。将对 AddAuthentication 的现有调用替换为以下对 AddDefaultIdentity 的调用,Program.cs传入您的 CityBreaksUser。

清单 9.12 向应用程序添加 Identity 服务

builder.Services.AddDefaultIdentity<CityBreaksUser>()
    .AddEntityFrameworkStores<CityBreaksContext>();

此服务注册使用其默认设置配置 Identity。稍后您将看到这些设置是什么以及如何自定义它们。

 9.2.3 添加迁移

最后,您处于可以添加迁移的阶段,该迁移将搭建数据库中所需的表的基架。与上一章一样,您可以使用 add-migration 命令通过包管理器控制台或通过 CLI 的 dotnet ef migrations add 创建迁移。您将此迁移命名为 AddedIdentity:

[PMC]    
add-migration AddedIdentity   
[CLI]   
dotnet ef migrations add AddedIdentity  

执行此命令后,请花点时间检查迁移的 Up 方法中的代码。如果一切按计划进行,您应该有代码来创建 7 个表,每个表表示 IdentityDbContext 类中的一个 DbSet。通过 CLI 使用 update-database 命令或 dotnet ef database update 应用迁移,然后查看数据库的修订架构。果然,数据库中已经添加了 7 个表(图 9.4),每个表的名称都以 AspNet 为前缀。

图 9.4 Identity 添加了 7 个前缀为 AspNet 的表。

主表是 AspNetUsers 表。这是存储用户配置文件数据的位置。其他表是可选的。其中 3 个与 roles management 有关,这是一种管理不同级别授权的机制;我们将在下一章中探讨这些。AspNetUserClaims 表用于将有关用户的其他信息存储为声明集合,以支持授权方案。AspNetUserLogins 表用于存储有关用户的外部登录名(如 Google 或 Facebook)的信息(如果您选择实现第三方身份验证)。AspNetUserTokens 表是保存外部登录授权令牌的位置。我在本书中不介绍第三方身份验证,但您可以参考官方文档,以获取有关与最流行的社交登录服务集成的指导:http://mng.bz/p6QK

在运行应用程序之前,您只需要做最后一件事。您需要将上一节中添加到布局中的代码替换为部分,因为默认 Identity UI 需要一个名称:_LoginPartial。因此,将新的 Razor 视图 > Empty 添加到名为 _LoginPartial.cshtml 的 Pages\Shared 文件夹。从布局中剪切以下代码段,并将其粘贴到新的 partial 文件中。添加 Sign out anchor tag 帮助程序,并修改 Sign in anchor tag 帮助程序。

清单 9.13 从布局中提取并粘贴到 _LoginPartial.cshtml 中

<div class="align-content-end">
    @if (User.Identity.IsAuthenticated)
    {
        <p>@User.Identity.Name, you are logged in
        <a asp-area="Identity" asp-page="/Account/Logout">Sign out</a>    ❶
        </p>
    }
    else
    {
        <a asp-area="Identity" asp-page="/Account/Login">Sign in</a>      ❷
    }
</div>

❶ 添加用于注销的锚标签助手。
❷ 更改锚点标签助手以指定 Identity 区域和 Account/Login 页面。

在从布局中剪切代码的位置,将其替换为

<partial name="_LoginPartial" />

完成此作后,您可以启动应用程序。这一次,IndexModel OnGet 方法中的质询应该将您重定向到 Identity 的默认登录路径 /Identity/ Account/Login,如图 9.5 所示。

图 9.5 默认身份登录 UI

如果您在此时期待一个屡获殊荣的主题,您可能会感到有点失望。好消息是默认 UI 可以正常工作;但它看起来并不好看,就像您在上一章中创建的脚手架 CRUD 页面一样,它需要一些自定义,然后才能将其部署为工作应用程序的一部分。您的用户不会对右侧的内容有太多用处,该内容链接到有关配置外部身份验证提供程序(如 Facebook 或 Google)的文章。

目前,您将注册一个新帐户并使用它登录。单击 Register as a New User 链接,然后输入电子邮件地址和密码。默认密码要求是它应包含以下内容:

• 最少 6 个字符
• 至少一个非字母数字字符(例如 *!)
• 至少一个小写字符
• 至少一个大写字符
• 至少一位数字

提交注册表后,您应该会发现您已自动登录到应用程序(图 9.6)。您还应该看到 AspNetUsers 表中只有一个条目(图 9.7)。

图 9.6 使用 Identity 登录

图 9.7 AspNetUsers 表中添加了一个新条目。

在查看 AspNetUsers 表中的数据时,可以快速查看表的架构,该架构反映了 IdentityUser 类属性。一些字段与帐户状态相关,而其他字段则与用户的用户档案相关。配置文件字段仅限于账户管理所需的字段(姓名、电子邮件和电话号码),因此可以通过电子邮件或短信发送令牌和确认。如果您运营的是电子商务网站,您还需要存储用户的送货地址,并且可能还需要存储不同的账单地址。您可能希望存储他们的出生日期,以便向他们发送卡片或限制有年龄限制的产品和服务的销售。在本章的其余部分,您将了解如何自定义 IdentityUser 以启用此功能,以及其他与 Identity 相关的自定义。在执行此作之前,请单击 Sign Out 链接,然后按照说明注销应用程序以确认注销功能是否有效。

9.3 自定义身份

现在,您已经有了 Identity 及其 UI 工作,我们可以查看一些选项,用于自定义各个方面以满足您自己的应用程序要求。您已经看到了默认密码选项;现在我们将探索如何控制这些选项以及与 Identity 工作原理相关的其他选项。然后,我们将研究如何自定义 IdentityUser 的实现,以便您可以存储比基类型所需的更多信息。最后,我们将更仔细地研究默认 UI,并了解如何控制其外观和行为。

9.3.1 自定义身份选项

IdentityOptions 类表示可用于配置 Identity 系统的所有选项。它具有许多属性,提供对与 Identity 的特定区域相关的选项的访问。表 9.1 显示了您最有可能使用的属性。

表 9.1 选择 IdentityOptions 属性

Property

Type

Description

User

UserOptions

Provides access to the options for user validation

Password

PasswordOptions

Enables customization of your application’s password policy

SignIn

SignInOptions

Represents configurable account confirmation options

Lockout

LockoutOptions

The options for managing your application’s policy for locking accounts in the event of failed login attempts

其他属性允许您为电子邮件确认、密码重置等配置令牌生成,以及与密钥值和个人数据的存储相关的几个选项。对于大多数应用程序,您不太可能触及这些内容。

您将通过 AddDefaultIdentity <TUser> 的重载来配置 IdentityOptions,该重载将 Action<IdentityOptions> 作为参数。下面的清单通过显示如何配置 User 和 Password 属性的特定选项来说明这一点。

示例 9.14 设置身份选项

builder.Services.AddDefaultIdentity<CityBreaksUser>(options => {
    options.User.RequireUniqueEmail = false;          ❶
    options.Password.RequiredLength = 8;              ❷
})

❶ 通过 User 属性设置用户选项。
❷ 通过 Password 属性设置密码选项。

配置用户选项

表 9.2 显示了 UserOptions 类的两个属性,它们表示可配置的用户选项。默认 Identity UI 包使用电子邮件地址作为用户名,该用户名必须是唯一的。请记住这一点,如果您要进一步限制允许的字符并删除符号,用户将无法注册,因为电子邮件地址至少需要两个符号。这些选项仅在您更改用户的注册方式时生效 - 这相对容易做到,您稍后将看到。

表 9.2 UserOptions 属性

Property

Description

AllowedUserNameCharacters

Specifies the range of characters permitted in a user’s name. Defaults to a-z, A-Z, 0-9, and the symbols -._@+.

RequireUniqueEmail

Boolean. If set to true, email addresses must be unique.

配置密码选项

密码正在成为一个热门话题。一段时间以来,Microsoft 一直在从密码转向生物识别技术(例如指纹、面部和语音模式识别)以及通过 SMS 发送给用户的安全代码。尽管如此,密码仍然是 Identity 默认实现的核心。如前所述,通过 PasswordOptions 对象(表 9.3)有许多配置选项。

表 9.3 PasswordOptions 属性

Name

Default

Description

RequireDigit

true

Specifies whether the password must include a digit.

RequiredLength

6

Specifies the minimum number of characters in a password.

RequireLowercase

true

Determines whether passwords must contain a lowercase ASCII character.

RequireNonAlphanumeric

true

Indicates whether passwords must contain a non-alphanumeric character.

RequiredUniqueChars

1

Specifies the number of unique characters required in the password.

RequireUppercase

true

If true, the password must contain an uppercase ASCII character.

在 Identity 中,密码在存储之前会进行哈希处理。哈希过程还包括盐,即随机生成的值,该值被添加到密码中,以确保两个相同密码的结果哈希值是唯一的。然后,盐和哈希密码一起存储在 AspNetUsers 表的 PasswordHash 列中。

当用户在登录过程中提交密码时,将检索用户名的 PasswordHash 值,并提取盐并用于对提交的密码进行哈希处理。哈希是确定性的,因为给定相同的输入,哈希将始终导致相同的输出。因此,假设提交的密码正确,则提交的密码的加盐和哈希版本应与数据库中存储的密码相匹配。与加密不同,哈希也是单向的。您无法撤消哈希值以检索原始值。

这听起来可能很复杂,而负责处理密码以进行存储的算法肯定很复杂,这强调了一点,除非您知道自己在做什么,否则您不应尝试替换现有实现。

在您的应用程序中,您将删除大部分限制,因此您可以使用简单的密码进行测试。应用以下选项,无需使用特殊字符。

清单 9.15 配置口令选项

builder.Services.AddDefaultIdentity<CityBreaksUser>(options =>{
    options.Password.RequireDigit = false;
    options.Password.RequireLowercase = false;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireUppercase = false;
}).AddEntityFrameworkStores<CityBreaksContext>();

注意 如果更改 RequiredLength 值,则还需要更新已应用于 Identity UI 注册表中使用的 RegisterModel 中的 UserName 属性的 StringLength 验证。稍后,当您搭建基架并自定义 UI 本身时,您将了解到这一点。

配置 SignInOptions

SignInOptions 支持配置各种帐户确认要求,详见表 9.4。提供 IUserConfirmation 接口是为了使您能够设置自己的允许用户登录的条件。我在这里不详细介绍这一点,但该接口有一个成员:IsConfirmedAsync,它返回一个 Task,您可以在其中应用自定义逻辑来确定是否允许用户登录。然后,您可以根据第 7 章中提供的指南将您的实施注册为服务。

表 9.4 SignInOptions 属性

Name

Default

Description

RequireConfirmedAccount

false

Determines whether conditions specified by the IUserConfirmation<TUser> interface need to be met to enable sign-in. The default implementation of this interface specifies that an email address is required.

RequireConfirmedEmail

false

Determines whether a confirmed email is needed to enable the user to log in.

RequireConfirmedPhoneNumber

false

Specifies whether a confirmed phone number is needed to enable the user to log in.

默认情况下,Identity UI 包不支持电话号码确认。因此,如果将此项设置为 true,则会将所有用户锁定在应用程序之外。如果要启用密码重置或其他依赖于电子邮件地址的功能,最好启用 RequireConfirmedEmail。稍后,我们将了解如何在本地启用和测试此功能,而无需访问电子邮件提供商。

配置 LockoutOptions

主要是为了防御暴力攻击,您可以在多次尝试登录失败时启用帐户锁定。Table 9.5 中详细介绍了可配置选项。

表 9.5 LockoutOptions 属性

Name

Default

Description

AllowedForNewUsers

true

Determines the value applied to the LockoutEnabled column for newly created users

DefaultLockoutTimeSpan

5

Sets the duration for the lockout (in minutes)

MaxFailedAccessAttempts

5

Specifies the maximum number of failed attempts to sign in before the account is locked out

表 9.5 中的选项配置帐户锁定(如果使用)。在本章后面的章节中,当您自定义 UI 时,您将看到如何管理此流程。在此之前,我们将研究自定义用户。

9.3.2 自定义用户

您的用户类 CityBreaksUser 派生自 IdentityUser。正如我所讨论的,这与用户的个人资料相关的属性数量有限。如果您想允许用户预订住宿地点,您至少需要他们提供一些其他信息,例如他们的姓名和地址。请记住,Identity 背后的默认数据访问技术是 EF Core,因此您可以将新属性添加到用户实体,然后使用迁移将这些更改传播到数据库,而不是手动修改数据库架构来容纳这些数据。首先,您将修改 CityBreaksUser 以包含三个属性。

列表 9.16 自定义 IdentityUser

public class CityBreaksUser : IdentityUser
{
    public string FirstName { get; set; }
    public string LastName {  get; set; }
    public string Address { get; set; }
}

然后,您将创建一个新的迁移:

[Powershell]
add-migration CustomizeIdentityUser
[CLI]
dotnet ef migrations add CustomizeIdentityUser

应用迁移(update-database 或 dotnet ef database update)后,您可以检查 AspNetUsers 表的架构,以确认已成功添加新列,如图 9.8 所示。

图 9.8 迁移后修改后的 AspNetUser 表

现在,您可以容纳额外的用户数据,您需要在注册时捕获这些数据。因此,在下一节中,您将看到它是如何完成的。

9.3.3 基架和自定义 UI

标识 UI 是作为 Razor 类库 (RCL) 开发的,使其能够部署为 NuGet 包并插入到想要使用它的应用程序中。RCL 可以包含 Razor 页面、静态资产,甚至 MVC 控制器和视图。开发 RCL 是本书未涵盖的高级主题,但您可以参考官方文档了解更多详细信息:http://mng.bz/O6Vw。只要您在 RCL 中复制文件结构,RCL 的内容就可以被覆盖。磁盘上的物理文件优先于 RCL 的内容。

Identity UI 包括对基架的支持,以生成部分(如果不是全部)Identity 页面的物理副本。基架页面会复制 RCL 中的文件结构,这意味着它们会自动覆盖默认 UI。身份基架取决于您在第 8 章:Microsoft.VisualStudio.Web.CodeGeneration.Design 中生成 CRUD 页面时安装的同一软件包。如果您使用的是 Visual Studio,则可以通过 Add...New Scaffolded Item 对话框(图 9.9)。

图 9.9 New Scaffolded Item 对话框

当你点击 Add 按钮时,你会看到一些要搭建脚手架的文件(图 9.10)。

图 9.10 Identity UI 基架选择对话框

此对话框列出了身份 UI 中的每个页面。他们中的大多数的目的从他们的名字中相对容易弄清楚。但是,如果您想查看它们的作用,您始终可以勾选 覆盖所有文件 选项来生成每个文件并检查其内容。我建议在一个单独的项目中执行此作,您可以将其用作参考。构建页面后,您负责管理其代码。

如果您使用的是 VS Code,则基架对话框对您不可用。相反,您可以使用 CLI 为页面搭建基架。首先,您可以使用 -lf 或 --listFiles 选项列出所有可用文件,就像从项目文件夹中执行的以下命令一样:

dotnet aspnet-codegenerator identity --listFiles

请注意,listFiles 选项区分大小写,并且工具名称必须包含连字符: aspnet-codegenerator。在搭建 CRUD 页面的基架时,您可能还记得您不需要包含连字符。输出使用点表示法代替您在 Visual Studio 版本的文件列表中看到的路径分隔符来显示文件名(图 9.11)。

图 9.11 使用 CLI 进行基架列出的身份文件

根据您用于搭建基架的工具,在对话框中选择 Account\Login 和 Account\ Register 文件,然后选择 CityBreaksContext 作为数据上下文类,或使用 CLI 中的以下命令:

dotnet aspnet-codegenerator identity -dc CityBreaks.Data.CityBreaksContext 
➥ -sqlite -fi "Account.Login;Account.Register"

要完全了解您在此命令中设置的选项及其较长的替代版本,您可以随时从 CLI 执行 dotnet aspnet-codegenerator identity --help。

单击对话框中的 Add,或执行命令。完成后,代码生成器应该已经创建了一个名为 Areas 的新文件夹,其中包含一系列嵌套的文件夹和文件(图 9.12)。

图 9.12 基架文件夹和文件

Razor 类库使用名为 areas 的功能,我在讨论定位点标记帮助程序上的 asp-area 属性时简要介绍了该功能。如果您还记得,在本章的前面部分,您必须在 login 标记帮助程序中包含 asp-area 属性,该属性指向名为 Identity 的区域。任何子文件夹都按照约定定义 Areas 文件夹中的区域。所以你有一个 Identity 区域;在该文件夹中,将 Razor 页面存储在各个区域自己的 Pages 文件夹中。除了 Login 和 Register 页面之外,基架还生成了 ViewStart、ViewImports 和用于管理验证脚本的部件。所有这些都会覆盖 RCL 中的匹配内容。这是您现在的代码。您拥有它。在开始使用代码之前,您将快速查看其内容,以更好地了解两个主要的身份参与者:UserManager 和 SignInManager。

UserManager 和 SignInManager

Identity UI 是可选的。无论您是否使用它,如果您基于 Identity 本身构建应用程序,您都会发现您需要或多或少地自定义您的身份验证工作流程。执行此作时,您必须使用 UserManager 和 SignInManager。

SignInManager 类提供了一个用于管理用户登录的 API。Table 9.6 总结了使用此类时最有可能使用的方法。

表 9.6 常用的 SignInManager 方法

Method

Description

SignInAsync

Signs the user in and assigns an Identity cookie

SignOutAsync

Signs the user out and removes the Identity cookie

PasswordSignInAsync

Attempts to sign the user in with the specified password

此外,SignInManager 还提供了一系列方法,可帮助您使用外部登录提供程序、双重身份验证、锁定等。在 Register 和 Login 页面中查看 PageModel 代码时,您可以看到 SignInManager 作为服务注入到构造函数中,仅用于登录用户以及获取可能已注册的外部登录提供程序(例如 Twitter 和 Google)的列表。SignInManager 类在 http://mng.bz/m2z4 中完整记录。

UserManager 提供了一个 API,用于使用数据库或其他已注册的持久性存储来管理用户。因此,它包括保存和检索用户数据的广泛方法,包括表 9.7 中的方法。

表 9.7 选择 UserManager 方法

Method

Description

CreateAsync

Adds a user to the database

UpdateAsync

Updates the user details in the database

DeleteAsync

Removes the user from the database

GetUserAsync

Gets the user corresponding to the ClaimsPrincipal passed in to the method

FindByIdAsync

Retrieves the user with the specified Id

FindByNameAsync

Retrieves the user with the specified Name claim

AddClaimAsync

Adds a claim to the user

AddToRoleAsync

Adds the user to the specified Role

此外,还有一些方法可用于管理密码、确认令牌、双重身份验证、锁定等。表中的最后两种方法更有可能用作授权工作流的一部分,我们将在下一章中更详细地介绍它们。与 SignInManager 一样,UserManager 作为服务注入到需要的任何位置。有关 UserManager 类的大量其他方法和属性的详细信息,您可以参考文档:http://mng.bz/5mYa

返回自定义 UI,首先您将修改 Login 页面,使其不再提供有关连接外部身份验证服务的指导。打开基架 Login.cshtml 文件,并找到以以下代码开头的 div 元素:

<div class="col-md-6 col-md-offset-2">
    <section>
        <h3>Use another service to log in.</h3>

注释掉整个 div,或将其根除。然后运行应用程序。这一次,登录页面应该没有之前占据页面右侧的内容(图 9.13)。接下来,您将更改 Registration 页面以删除有关外部登录的相同内容,并捕获您添加到 user 类的其他信息。

图 9.13 修改后的 Login 页面

修改 Registration 页面

位于 Areas\Identity\Pages\Account\Register.cshtml.cs 中的 RegisterModel 类使用输入模型模式来封装用户名和电子邮件的绑定目标。您需要为名字、姓氏和地址添加属性,因此请将以下代码行添加到 RegisterModel 中声明的 InputModel 类中。

清单 9.17 为要捕获的其他注册数据添加属性

[Required]
[Display(Name ="First Name")]
public string FirstName { get; set; }
[Required]
[Display(Name = "Last Name")]
public string LastName { get; set; }
[Required]
public string Address { get; set; }

在此阶段,如果在配置密码选项时更改了 RequiredLength 值,则应相应地调整已应用于 InputModel 的 Password 属性的 StringLength 属性的 MinimumLength 属性值。

接下来,您需要将新属性的绑定值分配给用户。这发生在 OnPostAsync 方法中,其中包含大量代码。但是,在此阶段,如果 ModelState 有效,您应该只对创建用户的位感兴趣。

列表 9.18 如果 ModelState 有效,则创建用户

if (ModelState.IsValid)
{
    var user = CreateUser();                               ❶

    await _userStore.SetUserNameAsync(user, Input.Email,   ❷
    ➥ CancellationToken.None);                            ❷
    await _emailStore.SetEmailAsync(user, Input.Email,     ❸
    ➥ CancellationToken.None);                            ❸
    var result = await _userManager.CreateAsync(user,      ❹
    ➥ Input.Password);                                    ❹

❶ 用户的实例由文件底部的私有 CreateUser 方法创建。
❷ 用户名已分配给用户。
❸ 电子邮件已分配给用户。
❹ 用户由 UserManager 保存到数据存储中。

在以前版本的 Identity UI 中,用户是在一行代码中使用其电子邮件和用户名创建的:

var user = new CityBreaksUser { UserName = Input.Email, Email = Input.Email };

作为 .NET 6 的一部分发布的更新版本要详细得多。创建被委托给一个名为 CreateUser 的私有方法,该方法使用 Activator.CreateInstance,对基架开发人员无法预见的某些边缘情况进行错误处理。你不必坚持下去。您可以改为将代码替换为更简单的版本。毕竟,这是您现在的代码。

与以前的版本还有其他差异。username 和 email 属性分别通过 IUserStore.SetUserNameAsync 和 IUserEmailStore.SetEmailAsync 方法分配。这些 API 提供了一种一致的方法来获取和设置用户的用户名和电子邮件。还有一个用于管理电话号码的类似 API:IUserPhoneNumberStore 接口。在本书中,我不会详细介绍这些接口。只需知道默认实施只需将指定值 (Input.Email) 分配给用户的 UserName 和 Email 属性就足够了。目前,您需要做的就是将传入值分配给用户的新属性。

清单 9.19 为 Identity 用户的自定义属性赋值

if (ModelState.IsValid)
{
    var user = CreateUser();

    user.FirstName = Input.FirstName;     ❶
    user.LastName = Input.LastName;       ❶
    user.Address = Input.Address;         ❶

    await _userStore.SetUserNameAsync(user, Input.Email, 
    ➥ CancellationToken.None);
    await _emailStore.SetEmailAsync(user, Input.Email, 
    ➥ CancellationToken.None);
    var result = await _userManager.CreateAsync(user, Input.Password);

❶ 在这里,您将绑定值分配给您添加到 IdentityUser 实现中的自定义属性。

您对 Razor 页面进行了两项修改:第一次注释掉或删除您在 Login 页面中处理的有关外部登录的相同代码块,第二次在表单开头添加以下表单字段,就在验证摘要标记帮助程序下方。

清单 9.20 其他 IdentityUser 属性的 forms 字段

<div class="form-floating">
    <input asp-for="Input.FirstName" class="form-control" />
    <label asp-for="Input.FirstName"></label>
    <span asp-validation-for="Input.FirstName" class="text-danger"></span>
</div>
<div class="form-floating">
    <input asp-for="Input.LastName" class="form-control" />
    <label asp-for="Input.LastName"></label>
    <span asp-validation-for="Input.LastName" class="text-danger"></span>
</div>
<div class="form-floating">
    <textarea asp-for="Input.Address" class="form-control"></textarea>
    <label asp-for="Input.Address"></label>
    <span asp-validation-for="Input.Address" class="text-danger"></span>
</div>

现在您可以运行应用程序并单击 Login 页面上的 Register as a New User 链接,这应该会带您到修改后的表单,如图 9.14 所示。完成它(使用不同的电子邮件地址),然后提交。您应该发现自己已登录,并且还应该看到其他字段已填充到 AspNetUsers 表中。

图 9.14 修订后的登记表

9.3.4 启用电子邮件确认

Identity 支持一些使用电子邮件的方案。例如,您可以要求用户确认他们能够控制用于注册的电子邮件地址,方法是向他们发送一封电子邮件,其中包含一个链接,该链接必须单击以验证其注册。密码重置功能也依赖于电子邮件。Identity UI 包含由 IEmailSender 接口表示的电子邮件服务。它有一个方法 SendEmailAsync,在默认实现中,该方法根本不执行任何作。

清单 9.21 默认的 EmailSender 服务

internal class EmailSender : IEmailSender
{
    public Task SendEmailAsync(string email, string subject, string htmlMessage)
    {
        return Task.CompletedTask;
    }
}

您不必使用 IEmailSender 接口。您可以使用任何您喜欢的内容,但在此示例中,您将提供自己的实现,该实现使用名为 MailKit (http://mng.bz/69GA) 的开源电子邮件管理库生成电子邮件。Internet 上(以及我的站点上)的无数示例演示了如何使用 System.Net.Mail 类中的类从 ASP.NET 应用程序发送电子邮件,但这种方法现在已经过时了。相反,Microsoft 建议您使用更现代的库,MailKit 在其文档中特别提到作为示例。所以你需要做的第一件事是安装包:

[CLI]
dotnet add package MailKit
[Package Manager Console]
install-package MailKit

接下来,将一个名为 TempMail 的新文件夹添加到项目的根目录下。您将模拟过时的 System 提供的 SpecifiedPickupDirectory 交付方法。Net.Mail 类。这使您可以指定邮件库生成的电子邮件在磁盘上放置的位置。这对于测试和调试非常有用,因为这意味着您不必依赖网络可用性。MailKit 不支持开箱即用的 SpecifiedPickupDirectory,因此您将根据项目常见问题解答 (http://mng.bz/YK8z) 中的代码提供自己的实现。

将新的类文件添加到 Services 文件夹,并将其命名为 EmailService.cs。将任何现有内容替换为以下清单中的代码。

清单 9.22 EmailService 类

using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Identity.UI.Services;
using MimeKit;
using MimeKit.IO;

namespace CityBreaks.Services
{
    public class EmailService : IEmailSender                                ❶
    {
        private readonly IHostEnvironment _environment;                     ❷
        public EmailService(IHostEnvironment environment)                   ❷
        {                                                                   ❷
            _environment = environment;                                     ❷
        }                                                                   ❷
        public async Task SendEmailAsync(string email, string subject,      ❷
        ➥ string htmlMessage)
        {
            var pickupDirectory = Path.Combine(_environment.ContentRootPath,❸
            ➥ "TempMail");                                                 ❸
            var message = new MimeMessage();                                ❹
            message.From.Add(MailboxAddress.Parse(                          ❹
            ➥ "test@test.com"));                                           ❹
            message.To.Add(MailboxAddress.Parse(email));                    ❹
            message.Subject = subject;                                      ❹

            message.Body = new TextPart("html")                             ❹
            {                                                               ❹
                Text = htmlMessage                                          ❹
            };                                                              ❹

            Await SaveToPickupDirectory(message, pickupDirectory);          ❺
            await Task.CompletedTask;
        }
    }
    // SaveToPickupDirectory method here
}

❶ 该类实现 IEmailSender 接口。
❷ 注入 IHostEnvironment 接口,以便您可以使用它来生成电子邮件文件夹的路径。
❸ 生成文件夹路径。
❹ 从发送到 SendEmailAsync 方法的参数构造消息。
❺ “发送” 电子邮件。

从 MailKit 项目的 FAQ 或本节的下载 (http://mng.bz/G18D) 中获取 SaveToPickupDirectory 方法的代码,并将其插入到清单 9.22 中注释中指示的点。

注意此实现是硬编码的,用于将电子邮件转储到磁盘上的文件夹中。虽然这在本地运行应用程序时很方便,但当应用程序在生产环境中运行时,它就没有多大用处了。在最后一章中,我们将介绍管理它的方法,因此邮件服务的行为取决于应用程序运行的环境。

现在,您已经拥有了 IEmailSender 服务的实现,您需要向服务容器注册您的实现:

builder.Services.AddTransient<IEmailSender, EmailService>();

请记住,默认情况下,Identity 不要求用户确认其账户。仅当需要帐户确认时,才会使用电子邮件服务,因此您需要在 Program.cs 中更改 SignIn 选项。添加以下清单中所示的粗体行。

清单 9.23 要求进行账户确认

builder.Services.AddDefaultIdentity<CityBreaksUser>(options => {
    options.Password.RequireDigit = false;
    options.Password.RequireLowercase = false;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequireUppercase = false;
    options.SignIn.RequireConfirmedAccount = true;
}).AddEntityFrameworkStores<CityBreaksContext>();

运行应用程序,然后再次完成注册过程。这一次,您在填写表单时不会自动登录。您应该会收到一条消息来检查您的电子邮件(图 9.15)。

图 9.15 Register Confirmation 页面

在此阶段,如果您没有正确配置电子邮件服务,您将在页面中看到一条消息,其中包含一个链接,您应该单击该链接以确认您的帐户。

但是,希望你做对了,现在你的 TempMail 文件夹中有一个 EML 文件(图 9.16)。

图 9.16 电子邮件已生成并放置在指定的取件目录中。

使用合适的应用程序(电子邮件客户端)打开 EML 文件,然后单击链接(图 9.17)。

图 9.17 确认邮件

您应该被引导回应用程序,并收到一条消息,表明您的电子邮件已确认。现在,您可以登录到应用程序。您可以访问所有 Identity UI,甚至是提供您不打算实施的工作流程的部分。例如,identity/account/manage 端点上的多个页面允许用户更改其个人数据、获取数据副本、删除数据、管理 2FA 等。我们将在下一节中介绍管理这些的方法。

在我们继续之前,您应该记住关于此功能如何工作的一件事。生成的电子邮件中的链接在查询字符串中包含验证令牌。令牌使用 Base64 URL 编码 (http://mng.bz/09YJ) 进行编码。结果值包括大小写混合字符。如果您在路由选项中将 LowercaseQueryStrings 设置为 true,则编码值将更改为全小写,从而破坏令牌的完整性。在这种情况下,令牌将始终无法通过验证。

9.3.5 禁用 UI 功能

当您包含 UI 包时,您可以选择加入所有 UI 包。毫无疑问,会有一些你不想实现的部分。您可以选择如何禁用这些功能。您可以使用授权来防止未经授权的访问,同时保持页面的功能,我们将在下一章中介绍。

当然,可以删除指向您不希望用户访问的任何页面的链接。然而,这并不是一个万无一失的解决方案。其他精明的开发人员在访问或使用您的应用程序时可能会很好地识别出 Identity UI URL 方案,并且可能会想四处闲逛。相反,您可以搭建不想实现的页面的基架,并更改其代码以禁用它们。

在此示例中,您将搭建 LoginWith2Fa 页面的基架并禁用它。如果您使用的是 Visual Studio,请使用 Add...用于生成 Account\ LoginWith2fa 的新基架项选项。或者,使用 CLI 执行以下命令:

dotnet aspnet-codegenerator identity -dc CityBreaks.Data.CityBreaksContext 
 -sqlite -fi Account.LoginWith2fa 

搭建页面基架后,打开 PageModel 文件,并注释掉现有的 OnGetAsync 和 OnPostAsync 方法。完成此作后,请将它们替换为以下实现:

public IActionResult OnGet() => NotFound();
public IActionResult OnPost() => NotFound();

如果用户尝试通过 GET 或 POST 请求访问此终端节点,我选择返回 404 Not Found 消息。如果您愿意,可以提供替代响应。您可能希望将用户重定向到其他位置;例如,您可以使用 RedirectToPage 方法。重要的是替换 OnGet 和 OnPost 处理程序。当禁用其他只有 OnGet 处理程序的页面时,实现 OnPost 处理程序仍然是明智的。

您已经完成了确定用户身份的任务,因此他们不再是匿名的。有了这些信息,您现在可以确定他们在网站上被授权做什么。目前,您有一个向所有人开放的管理区域。例如,您不希望任何人都能访问物业管理页面。他们可以设定假期价格,但这永远行不通。在下一章中,您将了解如何使用授权来根据您对选定访客的了解来限制对他们的访问。

总结

身份验证是识别站点用户的过程,因此他们不再是匿名的。
您需要添加身份验证服务和中间件才能在应用程序中启用身份验证。
基于浏览器的 Web 应用程序中的身份验证主要依赖于以加密格式保存当前用户身份的 Cookie。
ASP.NET Core Identity 是一个用于管理身份验证和用户的框架。它使用 EF Core 将用户数据存储在数据库中。
您可以通过迁移自定义 IdentityUser。
Identity UI 是一个包,它为许多身份验证方案提供页面。
您可以使用基架生成 Identity UI 页面的版本,并根据您的需要对其进行自定义。

ASP.NET Core Razor Pages in Action 8 处理数据

ASP.NET Core Razor Pages in Action 8 处理数据

本章涵盖

• 了解 Entity Framework Core 的价值及其工作原理
• 使用 Entity Framework Core 管理数据库架构
• 使用 Entity Framework Core 查询和管理数据
• 搭建与 Entity Framework Core 配合使用的 Razor Pages 并改进输出的基架

到目前为止,我们已集中精力探索 Razor Pages 框架的功能以及它们如何生成 HTML。本章与此略有不同,而是重点介绍称为 Entity Framework Core (EF Core) 的不同框架。

除了最简单的交互式 Web 应用程序之外,所有应用程序都依赖于数据的持久性和检索来支持其动态内容。该数据通常存储在某种关系数据库中并从中检索。在过去,管理这些数据访问任务所需的代码非常重复。每次要与数据库通信时,您都需要在代码中建立与数据库的连接,定义要执行的 SQL 查询,执行该查询,并在低级容器(如 Recordset、DataTable 或 DataReader)中捕获返回的数据,然后将数据处理为应用程序可以使用的某种形式。EF Core 的主要作用是将其抽象出来,因此您可以专注于编写代码来处理数据,而不是编写代码从数据库中检索数据。本章通过其上下文(派生自 DbContext 的对象)使用 EF Core 执行基本的 CRUD作,该上下文是使用 EF Core 的核心。

EF Core 的功能比仅仅替换对数据库执行命令所需的样板代码要强大得多。我们将探讨如何使用它从应用程序模型生成数据库,然后通过称为 migrations 的功能使数据库架构与模型保持同步。我们还将了解 EF Core 用于将模型映射到数据库的约定,以及如何根据需要使用配置来自定义这些映射。

本章还将介绍一个称为基架的功能。此功能结合了您的应用程序模型和数据库知识,可为模型中的特定对象快速生成工作 CRUD 页面。您将了解基架工具生成的代码的局限性,并了解如何改进它们以符合我们在上一章中介绍的一些软件工程原则。

在本章结束时,您将了解 EF Core 的角色,以及如何使用它对关系数据库执行命令、管理该数据库的架构以及生成 CRUD 页面。然而,EF Core 是一个很大的话题;在本书中,我们只触及了它的功能和使用的皮毛。为了充分利用这个出色的工具,您应该获取 Jon P. Smith (http://mng.bz/vXr4) 编写的优秀 Entity Framework Core in Action (2nd ed.) 的副本,并参阅官方文档 (https://docs.microsoft.com/en-us/ef/core/)。

8.1 什么是 Entity Framework Core?

EF Core 是一种对象关系映射 (ORM) 工具。它的作用是在对象 (应用程序模型) 和关系世界 (数据库) 之间进行映射。EF Core 适用于许多数据库,包括流行的 Microsoft SQL Server 和 MySQL 数据库系统。本书将 EF Core 与 SQLite 结合使用,SQLite 是一个开源、跨平台、基于文件的数据库。虽然缺少许多功能,但在更强大的基于服务器的系统中找到,SQLite 易于使用,无需安装或配置,并且足以满足我们将在本章中探讨的 EF Core 功能。

8.1.1 为什么选择 EF Core?

您可以使用低级、老式的 ADO.NET API 来管理与数据库的通信,但所需的代码是重复的(我已经说过了吗?)并且编写起来很无聊。一种解决方案是编写自己的 helper 库以减少重复。但是,您必须自己维护该代码。数据访问库非常丰富,除非您能找到改进现有产品的方法,否则如果您只需要继续制作 Web 应用程序,那么编写自己的库可能会浪费时间。

你可以自由使用任何你喜欢的库来管理 Razor Pages 应用程序中的数据访问,那么在所有丰富的现有库中,为什么选择 EF Core?作为 .NET 的一部分,它得到了很好的支持和测试,并享有大量的官方文档。除了官方文档之外,还有大量的社区贡献,例如书籍、博客文章和教程网站,它们探索了 EF Core 更深奥的功能及其最常见的工作流程。如果所有其他方法都失败了,并且您难以使某些内容正常工作,则可以将问题发布到 EF Core GitHub 存储库 (https://github.com/dotnet/efcore),您甚至可以从EF Core团队的一位开发人员那里得到回复。

EF Core 在 Visual Studio 中提供工具支持,可帮助你根据应用程序模型快速生成 CRUD 页面。虽然结果并不完美,但它们为开发应用程序中更普通的部分提供了一个重要的开端。基架支持也可供非 Visual Studio 用户从命令行使用。您稍后将看到它的实际效果。

8.1.2 EF Core 的工作原理是什么?

在基本层面上,EF Core 会创建一个概念模型,说明域对象及其属性(应用程序模型)如何映射到数据库中的表和列。它还了解域对象之间的关联,并可以将这些关联映射到数据库关系。它是应用程序的插入式数据层,位于域(图 8.1 的左侧)和数据存储(图 8.1 的右侧)之间。

图 8.1 EF Core 位于左侧的域对象和右侧的数据库之间,将对象及其属性和关联映射到数据库表、列和关系。

EF Core 管理业务对象与数据存储之间的通信。语言集成查询 (LINQ) 将帮助您在应用程序代码中创建查询规范,并将这些规范提供给 EF Core。EF Core 将 LINQ 查询转换为 SQL 命令,EF Core 对数据库执行这些查询,如图 8.2 所示。SQL EF 核心生成的是参数化的,这意味着它受到保护,可以抵御潜在的 SQL 注入攻击 (http://mng.bz/49qj)。您将在第 13 章中更详细地研究 SQL 注入攻击,届时您将专注于保护应用程序免受外部威胁。

图 8.2 EF Core 工作流,获取 LINQ 查询,将其转换为 SQL 以针对数据库执行,并以可在应用程序中使用的形式返回结果

如果查询旨在返回数据,EF Core 会负责将数据从数据库转换为域对象。如果您熟悉软件设计模式,则很可能将其识别为 Repository 模式 (http://mng.bz/QnYv) 的实现。

EF Core 生成的 SQL 取决于你使用的提供程序。每个数据库系统都有自己的提供程序,因此理论上,生成的 SQL 应针对特定数据库进行优化。EF Core 将所有这些隐藏在你的应用程序代码之外,因此如果你在某个时候需要更改提供程序,你的 LINQ 查询将无需修改即可工作。虽然从一个实际数据库系统迁移到另一个实际数据库系统的情况在现实世界中很少见,但如果您想将物理数据库替换为内存中数据库以进行测试,则此功能会更有用。

使用 EF Core 时采用的方法称为代码优先(而不是数据库优先),这意味着你将精力集中在开发应用程序模型上,并允许 EF Core 使用该模型作为维护数据库架构的基础,使用称为迁移的概念。如果数据库不存在,EF Core 还可以创建数据库本身。EF Core 依赖于多个约定将对象及其属性映射到数据库表和列并创建关系。在优化域模型以使用 EF Core 时,你将探索最重要的约定。除了“正常工作”的约定之外,EF Core 还提供了广泛的配置选项,使你能够控制模型映射到数据库中的表和列的方式。

8.1.3 管理关系

关系数据库系统的存在只是为了方便处理彼此相关的数据集。在数据库中,不同实体之间的关系由外键的存在来表示。在图 8.3 中,Country 和 City 通过 City 表上的 CountryId 外键以一对多的关系关联。

图 8.3 国家/地区和城市之间存在一对多关系,一个国家可以有多个城市。

按照惯例,EF Core 模型中的关系由导航属性表示。这些是类中的属性,不能映射到基元或标量值,例如字符串、布尔值、整数或日期时间类型。您现有的 City 类(请参阅下一个清单)已经具有一个符合导航属性描述的 Country 属性。

清单 8.1 具有 Country 导航属性的 City 类

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

❶ Country 是一个导航属性。

这就是 EF Core 推断 Country 和 City 实体之间的一对多关系所需的全部内容,其中 Country 是关系中的主体,City 是依赖项。在此示例中,Country 属性称为引用导航属性,该属性的重数(关系一端的潜在项数)为 0 或 1。更常见的是,EF Core 关系是完全定义的,具有表示关系每一端的属性和一个表示外键值的属性。在列表 8.2 中,您将一个表示 CountryId 外键的属性添加到 City 类中,并向 Country 类添加一个集合导航属性,该类表示可能属于单个国家/地区的所有城市。作为最佳实践,您应该始终将集合导航属性作为其声明的一部分进行实例化,这样就可以避免在代码中访问它们时必须测试 null。

清单 8.2 Country 和 City 之间完全定义的一对多关系

public class City
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CountryId { get; set; }                          ❶
    public Country Country { get; set; }                        ❷
}

public class Country
{
    public int Id { get; set; }
    public string CountryName { get; set; }
    public string CountryCode { get; set; }
    public List<City> Cities { get; set; } = new List<City>();  ❸
}

❶ 外键属性
❷ 引用导航属性,表示城市所属的国家/地区
❸ 集合导航属性,表示可以属于一个国家/地区的许多城市,实例化以确保它永远不会为 null

面向对象的纯粹主义者通常不热衷于在领域类中包含外键属性的想法,因为他们认为这是关系数据库世界“渗入”领域的一个例子。如果省略外键属性,EF Core 将创建一个影子外键属性 (http://mng.bz/Xawa) 作为其概念模型的一部分。

按照约定,名为 Id 或 Id 的属性被视为主键值,EF Core 会将其映射到 IDENTITY 列。两个域类都有一个 Id 属性,但 EF Core 还会在 city 类中看到 CityId 或在 country 类中看到 CountryId 作为表示主键。在我们的示例中,外键属性应使用引用导航属性的名称,后跟 Id—CountryId。

您尚未在模型中创建一个类,该类表示度假者可以租用的房产。因此,使用上面的信息在城市和属性之间创建一对多的关系,下面的清单显示了应该添加到 Models 文件夹的 Property 类。

示例 8.3 Property 类

public class Property
{
    public int Id { get; set; }                  ❶
    public string Name { get; set; }
    public string Address { get; set; }
    public int CityId { get; set; }              ❷
    public City City { get; set; }               ❷
    public int MaxNumberOfGuests { get; set; }
    public decimal DayRate { get; set; }
    public bool SmokingPermitted { get; set; }
    public DateTime AvailableFrom { get; set; }
}

❶ 主键属性
❷ 外键和引用导航属性

您还需要修改 City 类,以包含一个集合导航属性,该属性表示属于城市的属性,在下面的清单中以粗体显示。

清单 8.4 更新 City 类以包含 Properties 的集合

public class City
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CountryId { get; set; }
    public Country Country { get; set; }
    public List<Property> Properties { get; set; } = new List<Property>();
}

现在,模型已配置好,以便 EF Core 能够识别其关系,你可以开始使用 EF Core。

8.1.4 安装 Entity Framework Core

默认情况下,EF Core 不包含在 Web 应用程序项目中。需要将其作为 NuGet 的附加包进行安装。安装包的最简单方法(不依赖于所使用的 IDE)是向项目文件添加新的包引用:应用程序文件夹根目录中的 CityBreaks.csproj 文件。项目文件在资源管理器中可见,如果您使用的是 VS Code,则可以轻松访问。它在 Visual Studio 解决方案资源管理器中不可见。您需要右键单击项目名称,然后从出现的上下文菜单中选择 Edit Project File。打开文件后,将新的 PackageReference 条目添加到现有 ItemGroup 中,或创建新的 ItemGroup 节点:

<ItemGroup>
   <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" 
    ➥ Version="6.0.0" />
</ItemGroup>

Visual Studio 用户应该注意到,当你以这种方式添加包时,VS 会自动运行 restore 命令并从 NuGet 获取所需的库。使用 VS Code,您需要从终端自行执行 dotnet restore 命令。C# 扩展应提示您执行此作。

或者,可以使用 dotnet add 命令在 VS Code 中添加包。命令为:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

执行该命令后,VS Code 将自动恢复所有包。

Visual Studio 用户具有用于管理包的内置工具。转到 NuGet 包管理器>工具“,然后从那里,您可以选择 Manage NuGet Packages for Solution。这将打开一个仪表板 UI,允许您管理以前安装的软件包以及搜索和安装其他软件包。或者,您可以调用 Package Manager 控制台 (PMC) 并执行用于管理包的命令。要添加软件包,请使用 install-package 命令:

install-package Microsoft.EntityFrameworkCore.Sqlite

同样,VS 将在解决包后自动还原包。

8.1.5 创建上下文

在代码中使用 EF Core 的入口点是上下文,即派生自 DbContext 的对象。它表示与数据库的会话,并提供用于与数据库通信以执行数据作(如查询和数据持久性)的 API。它还支持更高级的功能,例如模型构建和数据映射(我们将在后面介绍),以及事务管理、对象缓存和更改跟踪,这些功能在本书中不涉及。

将工作上下文交付给应用程序所需的步骤是

  1. 创建从 DbContext 派生的类。
  2. 提供连接字符串。
  3. 向服务容器注册上下文。

从步骤 1 开始,向项目中添加一个名为 Data 的新文件夹,并在该文件夹中添加一个名为 CityBreaksContext.cs 的新类文件,其中包含以下代码。

清单 8.5 CityBreaksContext

using Microsoft.EntityFrameworkCore;
 namespace CityBreaks.Data
{
    public class CityBreaksContext : DbContext
    {
        public CityBreaksContext(DbContextOptions options) : base(options)
        {
        }    
    }
}

该类具有一个将 DbContextOptions 对象作为参数的构造函数。在将上下文注册为服务时,您将配置此对象,并提供连接字符串。首先,您需要向应用程序添加连接字符串。为此,您将使用主配置文件。如果您还记得第 2 章,这是 appSettings.json 文件。您将添加一个名为 ConnectionStrings 的属性。此属性或部分的命名非常重要,因为它是配置 API 查找连接字符串所依赖的约定。然后,您将提供连接字符串的名称及其值。

清单 8.6 向 appSettings.json 添加连接字符串

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {    
    "CityBreaksContext": "Data source=Data/CityBreaks.db"   ❶
  }    
}

❶ 连接字符串部分和实际连接字符串

SQLite 连接字符串很好,很简单,表示数据库文件的路径。在您的应用程序中,您将数据库文件放在 Data 文件夹中,与上下文并排。稍后运行第一次迁移时,EF Core 将创建该文件。

最后一步是向服务容器注册上下文。正如您从上一章中学到的那样,您可以在 Program.cs 文件中执行此作。在添加以下代码之前,您需要添加一些 using 指令以引入 Microsoft.EntityFrameworkCore 和 CityBreaks.Data。

清单 8.7 配置 CityBreaksContext

builder.Services.AddDbContext<CityBreaksContext>(options =>
{
    options.UseSqlite(builder.Configuration.GetConnectionString
    ➥ ("CityBreaksContext"));
});

GetConnectionString 方法在 appSettings.json 的 ConnectionStrings 部分中找到指定的连接字符串,而 UseSqlite 方法则设置正确的数据库提供程序供 EF Core 使用。

您拥有上下文并将其注册为服务。目前,它几乎没用;这就像有一个空的数据库。您将需要一些在上下文中由 DbSet<TEntity> 属性表示的数据库表,其中 TEntity 是您希望表表示的实体。图 8.4 说明了实体、DbSet和数据库之间的关系。

图 8.4 每个实体都由一个 DbSet 表示,该 DbSet 映射到数据库中的表。

8.1.6 添加 DbSet

首先,您将 DbSet 添加到要映射到数据库表的每个实体的上下文中。按照约定,该表将采用 DbSet 属性的名称。下一个清单显示了到目前为止您在模型中创建的三个类,每个类都表示为 DbSet<TEntity>

列表 8.8 映射到数据库中的 Table 的 DbSet 属性

public class CityBreaksContext : DbContext
{
    public CityBreaksContext(DbContextOptions options) : base(options)
    {

    }
    public DbSet<City> Cities { get; set; }
    public DbSet<Country> Countries { get; set; }
    public DbSet<Property> Properties { get; set; }
}

8.1.7 配置模型

如果要在此阶段创建迁移,它将生成一个包含三个表的数据库,每个 DbSet 一个表,并且它将使用约定根据 DbSet 类型参数表示的每种类型的属性创建列。在大多数实际应用程序中,默认约定对于大多数模型都是可以接受的,尤其是在您从头开始时。对于约定不适用或 EF Core 需要帮助了解你的意图的情况,EF Core 提供了允许你替代约定的配置 API。

配置面向三个级别:模型、类型和属性。您可以在模型级别配置 EF Core 用于对象的架构。类型配置选项使您能够配置类型映射到的表名称,或者应如何指定类型之间的关系。属性配置提供了广泛的选项,用于管理各个属性映射到列的方式,包括其名称、数据类型、默认值等。

可以通过两种方式应用配置:使用特性修饰类和属性,或者使用由可链接在一起的扩展方法集合组成的 Fluent API。属性仅提供配置选项的子集。因此,对于任何相当复杂的模型,你都可能需要依赖 Fluent API 进行某些配置。因此,对所有配置使用 Fluent API 是有意义的,这样可以保持配置代码的一致性,从而更容易推理,并且集中在一个地方。

那么,您应该将 Fluent API 配置代码放在哪里呢?您有两个选项:可以在自己的上下文类中重写 DbContext OnModelCreating 方法并将配置代码放在该上下文中,也可以将配置代码放在每个实体的单独类中,然后在 OnModelCreating 方法中引用这些类。您将采用后一种方法,因为它是管理应用程序这一方面的推荐方法。

配置类派生自 IEntityTypeConfiguration<TEntity>,它实现一种方法 Configure,该方法将 EntityTypeBuilder<TEntity> 作为参数。您将在此处放置配置代码。

首先在 Data 文件夹中创建一个新文件夹,将其命名为 Configurations。使用以下代码将名为 CountryConfiguration 的 C# 类文件添加到新文件夹中。

列表 8.9 CountryConfiguration 类

using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace CityBreaks.Data.Configuration;
public class CountryConfiguration : IEntityTypeConfiguration<Country>   ❶
{
    public void Configure(EntityTypeBuilder<Country> builder)           ❷
    {
        builder.Property(x => x.CountryName)
            .HasMaxLength(50);                                          ❸
        builder.Property(x => x.CountryCode)
            .HasColumnName("ISO 3166 code")                             ❹
            .HasMaxLength(2);                                           ❹
    }
}

❶ 该类实现 IEntityTypeConfiguration
❷ 根据接口的要求实现 Configure 方法。
❸ 使用 HasMaxLength 方法约束 CountryName 属性的文本字段的长度。
❹ 将 CountryCode 属性映射到名为“ISO 3166 Code”的列并限制其大小。

字符串属性通常映射到 SQL Server 中的 nvarchar(max) 数据类型。您已使用 HasMaxLength 方法对支持基于文本的列的数据库中基于文本的列的大小应用限制。SQLite 不支持此方法,因此除非您使用的是 SQL Server,否则此配置将不起作用。不过,HasColumnName 方法将适用于任何数据库,并将 CountryCode 属性映射到“ISO 3166 代码”列。在配置 CountryCode 属性时,您可以看到 HasMaxLength 方法链接到 HasColumnName 方法的 Fluent API。

8.2 迁移

您几乎可以创建迁移,使数据库架构与模型保持同步。迁移工具检查上下文的 DbSet 属性,并将它们与上一个迁移生成的快照(如果有)进行比较。任何差异都会导致生成 C# 代码,这些代码在执行时被转换为 SQL,该 SQL 将更改应用于实际数据库。如果数据库尚不存在,则第一次迁移将导致创建数据库。还可以按需生成迁移 SQL 脚本,因此您可以自行将它们应用于数据库。这对于对实时数据库进行更改特别有用,因为在实时数据库中,执行 C# 代码通常很困难(如果不是不可能的话)。

配置的另一个非常有用的方面是指定种子数据的能力,该数据用于在迁移期间填充数据库。此功能具有明显的用途,因为它可以让您开始使用一组数据,而无需手动输入。在下一节中,您将了解如何将此功能与一些国家/地区数据一起使用,然后是一些城市数据。

本章 (http://mng.bz/jAra) 附带的迁移下载还包括 Property 类型的种子数据以及 cities 的一些图像(由 https://unsplash.com/ 提供)。我建议您从 GitHub 存储库获取相关代码和图像,并将其用于迁移,以便您的数据库内容与以后的示例相匹配。

8.2.1 种子数据

您将使用 Fluent API HasData 方法为实体指定种子数据,作为其配置的一部分。您必须指定主键值和外键值,以便迁移可以确定是否在迁移之外对数据进行了任何更改。此类更改将被覆盖,因此种子设定功能最适合于不会更改的静态数据。如果数据在使用种子设定功能添加后可能会更改,则可以注释掉相关代码,这样就不会在后续迁移中调用它。下面的清单显示了 CountryConfiguration 类中的 Configure 方法,该方法经过修改后包括 HasData 方法调用,该方法采用相关类型的集合。

清单 8.10 国家种子数据

public void Configure(EntityTypeBuilder<Country> builder)
{
    builder.Property(x => x.CountryName)
        .HasMaxLength(50);
    builder.Property(x => x.CountryCode)
        .HasColumnName("ISO 3166 code")
        .HasMaxLength(2);
    builder.HasData(new List<Country>
    {
        new Country {Id = 1, CountryName = "Croatia", CountryCode="hr" },
        new Country {Id = 2, CountryName = "Denmark", CountryCode =  "dk" },
        new Country {Id = 3, CountryName = "France", CountryCode = "fr" },
        new Country {Id = 4, CountryName = "Germany", CountryCode = "de" },
        new Country {Id = 5, CountryName = "Holland", CountryCode = "nl" },
        new Country {Id = 6, CountryName = "Italy", CountryCode = "it" },
        new Country {Id = 7, CountryName = "Spain", CountryCode = "es" },
        new Country {Id = 8, CountryName = "United Kingdom", 
        ➥ CountryCode = "gb" },
        new Country {Id = 9, CountryName = "United States", 
        ➥ CountryCode = "us" }
    });
}

要为城市添加种子数据,您首先需要向 City 类添加一个属性来表示图像。我将此属性命名为 Photo,但您需要将其配置为映射到名为 Image 的列。

示例 8.11 向 City 类添加 Photo 属性

public class City
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Photo { get; set; }
    public int CountryId { get; set; }
    public Country Country { get; set; }
    public List<Property> Properties {  get; set; } = new List<Property>();
}

现在,您需要将另一个 IEntityTypeConfiguration 类添加到 Configuration 文件夹,这次名为 CityConfiguration,代码如下。

列表 8.12 City 配置类

using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace CityBreaks.Data.Configuration;
public class CityConfiguration : IEntityTypeConfiguration<City>
{
    public void Configure(EntityTypeBuilder<City> builder)
    {
        builder.Property(x => x.Photo).HasColumnName("Image");
        builder.HasData(new List<City>
        {
            new City { Id = 1, Name = "Amsterdam", CountryId = 5, 
            ➥ Photo = "amsterdam.jpg" },
            new City { Id = 2, Name = "Barcelona", CountryId = 7, 
            ➥ Photo ="barcelona.jpg" },
            new City { Id = 3, Name = "Berlin", CountryId = 4, 
            ➥ Photo ="berlin.jpg" },
            new City { Id = 4, Name = "Copenhagen", CountryId = 2, 
            ➥ Photo ="copenhagen.jpg" },
            new City { Id = 5, Name = "Dubrovnik", CountryId = 1, 
            ➥ Photo ="dubrovnik.jpg" },
            new City { Id = 6, Name = "Edinburgh", CountryId = 8, 
            ➥ Photo ="edinburgh.jpg" },
            new City { Id = 7, Name = "London", CountryId = 8, 
            ➥ Photo ="london.jpg" },
            new City { Id = 8, Name = "Madrid", CountryId = 7, 
            ➥ Photo ="madrid.jpg" },
            new City { Id = 9, Name = "New York", CountryId = 9, 
            ➥ Photo ="new-york.jpg" },
            new City { Id = 10, Name = "Paris", CountryId = 3, 
            ➥ Photo ="paris.jpg" },
            new City { Id = 11, Name = "Rome", CountryId = 6, 
            ➥ Photo ="rome.jpg" },
            new City { Id = 12, Name = "Venice", CountryId = 6, 
            ➥ Photo ="venice.jpg" }
        });
    }
}

请注意,您已使用 HasColumnName 方法将 Photo 属性映射到名为 Image 的列。

最终配置适用于 Property 类型。此配置完全由种子数据组成,该书下载中的示例包括 50 个虚构的属性详细信息。您可以根据目前所学的知识生成种子数据,也可以从本书的 GitHub 存储库 http://mng.bz/yaBd 复制配置文件的内容。

完成配置类后,您需要向 DbContext 注册它们。为此,您可以重写 CityBreaksContext 类中的 OnModelCreating 方法,然后使用 ModelBuilder ApplyConfiguration 方法注册每个类型。由于 ApplyConfiguration 方法返回 ModelBuilder,因此您可以链接这些调用。

示例 8.13 在 OnModelCreating 方法中注册配置

protected override void OnModelCreating (ModelBuilder builder)
{
    builder
        .ApplyConfiguration(new CityConfiguration())
        .ApplyConfiguration(new CountryConfiguration())
        .ApplyConfiguration(new PropertyConfiguration());
}

8.2.2 添加迁移工具

在创建迁移之前,您需要将必要的包添加到包含用于管理迁移的命令的项目中。有两个软件包可用,每个软件包都有一组不同的命令。您使用的 ID 取决于您要用于执行迁移命令的工具。

如果您是 Visual Studio 用户,则可以使用 Package Manager 控制台;在这种情况下,您将需要 PowerShell 命令包含在 Microsoft.EntityFrameworkCore.Tools 包中。或者,您也可以改用 Microsoft.EntityFrameworkCore.Design 软件包中提供的跨平台 CLI 命令。选择包后,您可以使用前面介绍的那些您喜欢的方法将包添加到项目中。如果您使用的是 CLI 命令,则还必须确保全局安装 dotnet-ef 工具,您可以使用以下命令执行此作:

dotnet tool install --global dotnet-ef

8.2.3 创建和应用迁移

安装工具和相关包后,您可以创建您的第一个迁移。您将使用以下命令之一:

[Powershell]                       ❶
add-migration Create               ❶
[CLI]                              ❷
dotnet ef migrations ad Create     ❷

❶ 要从 Visual Studio 的包管理器控制台中执行的 PowerShell 命令

❷ 要从包含 csproj 文件的目录中的命令提示符执行的 CLI 命令

迁移名为 Create。成功执行您使用的任何命令都会导致将一个名为 Migrations 的新文件夹添加到项目中。图 8.5 显示了 Visual Studio Code 文件资源管理器中的 Migrations 文件夹及其内容。

图 8.5 生成的 Migrations 文件夹,包含三个文件

新文件夹包含三个文件:

  1. [Timestamp]_Create.cs - 包含一个名为 Create 的类,该类具有两个方法:Up 和 Down。Up 方法将更改应用于数据库,而 Down 方法则还原这些更改。
  2. [Timestamp]_Create.Designer.cs - 包含 EF Core 使用的元数据。
  3. CityBreaksContextModelSnapshot.cs - 模型的当前快照。当您添加另一个迁移时,此快照将用作基准来确定已更改的内容。

前两个文件特定于迁移。将为其他迁移添加新的 VPN。模型快照文件将针对每次新的迁移进行更新。如果查看第一个文件中 Up 方法的内容,则 C# 代码应该是不言自明的。在将迁移应用到数据库之前,您可以根据需要自由修改此版本。例如,在以后的迁移中,您可能希望能够在过程中执行一些自定义 SQL,例如引入非种子数据。在本书中,我没有介绍这一点,但知道该功能在您需要时可用是很有用的。但是,我通常会查看迁移代码,以确保我打算应用的更改反映在那里。不止一次,通过查看迁移代码,我意识到我没有正确配置属性。在这种情况下,您可以调整模型配置,然后使用 remove-migration (PowerShell) 或 dotnet ef migrations remove (CLI) 命令删除现有迁移,然后再使用与以前相同的命令将其添加回去。

生成迁移并检查它是否按您的要求运行后,您将使用以下命令之一应用它:

[Powershell]
update-database
[CLI]
dotnet ef database update

执行这些命令之一后,您应该会看到已在 Data 文件夹中创建了一个 SQLite 数据库文件:CityBreaks.db。使用您喜欢的任何工具(我使用 SQLite 的跨平台 DB 浏览器;https://sqlitebrowser.org/),请查看 schema(图 8.6)。除了每个模型类的表之外,数据库还包括一个名为 __EFMigrationsHistory 的表。

图 8.6 新数据库包含名为 __EFMigrationsHistory 的表。

此表跟踪已应用于数据库的迁移。目前,它包含一条记录,该记录由您刚刚应用的迁移的名称以及使用的 EF Core 版本组成。

8.3 查询数据

您的数据库已填充种子数据,现在是开始使用它的时候了。你将使用 LINQ 来表达你希望 EF Core 对数据库执行的命令。LINQ 包含一组 IEnumerable 类型的扩展方法,这些方法支持对集合进行选择和筛选作。您作的集合是上下文中的 DbSet 对象。

EF Core 负责将 LINQ 查询转换为要针对数据库执行的 SQL。生成的 SQL 取决于所使用的提供程序,同时考虑到特定于数据库的功能。

在编写 LINQ 查询时,可以采用以下两种方法之一。您可以使用 query 语法或 method 语法。查询语法看起来类似于 SQL 语句,有些人对它感觉更舒服。以下示例显示了用于获取属于主键值为 1 的国家/地区的所有城市的查询语法:

var data = from c in _context.Cities where c.CountryId == 1 select c;

但是,就像数据注释属性仅提供模型配置选项的子集一样,查询语法并不总是足够的。某些查询只能使用方法调用来表示。

我更喜欢方法语法,它包括将调用链接到 IEnumerable 类型 (http://mng.bz/M0RB) 上的扩展方法。使用查询语法,您的代码在编译时会转换为方法调用,因此这两种方法之间没有性能差异。在本书中,我们只使用方法语法。如果您有兴趣了解有关查询语法的更多信息,官方 LINQ 文档提供了许多示例:http://mng.bz/aPNm

8.3.1 检索多条记录

您要做的第一件事是为城市创建一个新服务,这些城市将使用数据库作为其数据源。将一个新的类文件添加到 Services 文件夹,将此类文件命名为 CityService.cs。此类将实现您在上一章中创建的 ICityService 接口,并将 CityBreaksContext 作为依赖项。该类的初始代码如下面的清单所示。

Listing 8.14 CityService 类

using CityBreaks.Data;
using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;

namespace CityBreaks.Services;
public class CityService : ICityService
{
    private readonly CityBreaksContext _context;
    public CityService(CityBreaksContext context) => _context = context;
    public async Task<List<City>> GetAllAsync()
    {
        var cities = _context.Cities             ❶
            .Include(c => c.Country)             ❷
            .Include(c => c.Properties);         ❷
        return await cities.ToListAsync();       ❸
    }
}

❶ 查询的入口点是 DbSet。
❷ 使用 Include 方法指定要包含在查询中的相关数据。
❸ ToListAsync 方法调用会导致查询执行。

LINQ 查询由两个阶段组成:规范阶段和执行阶段。在我们的示例中,查询的规范发生在 GetAllAsync 方法的前三行中。执行将延迟到最后一行,此时调用 ToListAsync 方法。只有在该点上,才会调用数据库。这种延迟执行的能力使您能够通过添加其他条件来继续编写规范。例如,您可能希望筛选查询以仅返回法国的城市,您可以在采用表示筛选条件的谓词的 Where 方法调用中执行此作:

var cities = _context.Cities
    .Where(c => c.Country.CountryName == "France")
    .Include(c => c.Country)
    .Include(c => c.Properties);

您希望返回所有城市,包括其相关的国家/地区和属性,以便您可以在应用程序的主页上显示详细信息。但是,您只想包含当前可用的属性,因此将过滤器应用于 Include 方法:

var cities = _context.Cities
    .Include(c => c.Country)
    .Include(c => c.Properties.Where(p => p.AvailableFrom < DateTime.Now));

下一步是更新 Index.cshtml.cs 文件内容,并将现有内容替换为以下代码,该代码将 ICityService 注入构造函数中,并使用它来获取城市数据。

清单 8.15 修改后的 Index Model 主页代码

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

namespace CityBreaks.Pages
{
    public class IndexModel : PageModel
    {
        private readonly ICityService _cityService;

        public IndexModel(ICityService cityService)
        {
           _cityService = cityService;  
        }

        public List<City> Cities { get; set; }
        public async Task OnGetAsync() => Cities = 
        ➥ await _cityService.GetAllAsync();
    }
}

然后,从主页中删除列表框(如果第 5 章中它仍然存在)并将其替换为以下内容。

列表 8.16 更新了主页 Razor 代码

<h1>City Breaks</h1>
<div class="container">
    <div class="row">

        @foreach (var city in Model.Cities)                                  ❶
        {
            <div class="col-4 p-3" style="text-shadow: rgb(0, 0, 0) 1px 1px 
             ➥ 1px">
                <div class="card p-3 shadow"                                 ❷
                ➥ style="background:url(/images/cities/@city.Photo)         ❷
                ➥ no-repeat center;background-size: cover;height:240px;">   ❷
                    <h3>
                        <a class="text-white text-decoration-none"           ❸
                        ➥ asp-page="/City" asp-route-name="@city.Name">     ❸
                        ➥ @city.Name</a>                                    ❸
                       <img                                                  ❹
                       ➥ src="/images/flags/@(city.Country.CountryCode).png"❹
                       ➥ aria-label="@($"{city.Name},                       ❹
                       ➥ {city.Country.CountryName}")">                     ❹
                    </h3>
                    <h6 class="text-white">                                  ❺
                     ➥ @city.Properties.Count()properties</h6>              ❺
                </div>
            </div>
        }
    </div>
</div>

❶ 遍历所有城市。
❷ 使用每个城市的 Photo 属性设置背景图像。
❸ 输出城市的名称。
❹ 引用城市的 Country 属性,并使用其 CountryCode 呈现相应的国旗图标。
❺ 使用 Count() 方法呈现与每个城市关联的属性总数。

现在,您只需将 Program.cs 中现有的 SimpleCityService 注册替换为指定新 CityService 作为用于 ICityService 的实现的注册:

builder.Services.AddScoped<ICityService, CityService>();

完成此作后,您可以运行应用程序并享受新的主页(图 8.7)

图 8.7 主页显示数据库中的数据。

在继续之前,请导航到 /property-manager /create 以确保已填充 select-city 列表。以前,该数据来自 SimpleCityService,现在它来自数据库。您不仅有一个连接到应用程序的工作数据库,而且还有一个很好的示例,说明松散耦合如何使应用程序能够轻松进行更改。您不必修改 Create 页面的任一文件中的代码即可使其与数据库一起使用。您所要做的就是更改服务注册。

8.3.2 选择单个记录

现在,您已经有了一个选择多条记录的良好工作示例,您将修改 City 页面,以根据 URL 中传递的值检索单个记录。首先,您需要更新 ICityService 以包含一个名为 GetByNameAsync 的新方法,该方法将字符串作为参数并返回 Task<City>

清单 8.17 向 ICityService 添加新方法

public interface ICityService
{
    Task<List<City>> GetAllAsync();
    Task<City> GetByNameAsync(string name);
}

您有两个服务实现此接口;您将不会再次使用 SimpleCityService,因此您可以安全地删除它或使用 NotImplementedException 为满足编译器接口约定的方法创建一个存根:

public Task<City> GetByNameAsync(string name) => throw new 
 NotImplementedException();

如果您选择采用后一种方式,则需要记住对以后添加到 ICityService 接口的所有其他方法执行相同的作。接下来,您将在 CityService 类中提供一个有效的实现。

列表 8.18 使用名称作为条件返回单个城市的查询

public async Task<City> GetByNameAsync(string name)
{
    return await _context.Cities
        .Include(c => c.Country)
        .Include(c => c.Properties.Where(p => p.AvailableFrom < 
         ➥ DateTime.Now))
        .SingleOrDefaultAsync(c => c.Name == name);
}

该查询与前一个查询的不同之处仅在于用于导致执行的方法。这一次,您将使用 SingleOrDefaultAsync 方法。此方法期望数据库中有零个或一个匹配的记录。如果没有匹配的记录,该方法将返回默认值,在本例中为 null。如果多条记录与条件匹配,则会引发异常。如果预计只有一条匹配记录,则可以使用 SingleAsync 方法,该方法在没有匹配项的情况下返回异常。如果您认为可能有多个记录与条件匹配,则应使用 FirstAsync 或 FirstOrDefaultAsync 方法,具体取决于是否有可能没有匹配项。这将根据数据库的默认顺序或通过 OrderBy 方法指定的顺序返回第一个匹配项。例如:

return _context.Cities.OrderBy(c => c.Name).FirstAsync(c => c.Name == name);

您在此处使用 SingleOrDefaultAsync 方法,因为您无法完全控制传递给该方法的值。您可能认为这样做是因为您的代码从来自数据库的数据生成链接。但是,当您将这些数据作为 URL 的一部分包含在其中时,您就是在向外界公开该数据,并且不能相信它不会被无辜或其他方式更改。接下来,您需要更改 CityModel 类的代码,因此将 \Pages\City.cshtml.cs 的内容替换为以下代码。

Listing 8.19 修订后的 CityModel 代码

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

namespace CityBreaks.Pages;

public class CityModel : PageModel
{
    private readonly ICityService _cityService;
    public CityModel(ICityService cityService)
    {
        _cityService = cityService;
    }

    [BindProperty(SupportsGet = true)]
    public string Name { get; set; }
    public City City { get; set; }

    public async Task<IActionResult> OnGetAsync()
    {
        City = await _cityService.GetByNameAsync(Name);
        if(City == null)
        {
            return NotFound();
        }
        return Page();
    }
}

您注入 ICityService 并使用它来检索与传递给 URL 中的页面的名称匹配的城市。您应该预料到结果可能为 null,在这种情况下,您将让用户知道没有匹配的页面。现在剩下的就是显示匹配记录的详细信息(如果找到)。

为了给细节增加一些视觉趣味,你将加入一些来自 Font Awesome (https://fontawesome.com) 的免费图标。您需要在 Pages\Shared \ _Layout.cshtml 文件中添加指向其图标的 CDN 版本的链接。在结束 </head> 标记之前添加以下代码行:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />

现在,将 Pages\City.cshtml 文件的内容更改为以下内容。

Listing 8.20 渲染指定 City 的细节

@page "{name}"
@model CityBreaks.Pages.CityModel
@{
    ViewData["Title"] = $"{Model.City.Name} Details";
}
<h3>@Model.City.Name</h3>
@foreach (var property in Model.City.Properties)
{
    <div class="mb-3">
        <h5>@property.Name</h5>
        <p class="mb-1">@property.Address</p>
        <i class="fas fa-euro-sign text-muted"></i> 
        ➥ @property.DayRate.ToString("F2")<br>
        @if (!property.SmokingPermitted)
        {
            <i class="fas fa-smoking-ban text-danger"></i>
        }
        @for (var i = 1; i <= property.MaxNumberOfGuests; i++)
        {
            <i class="fas fa-bed text-info"></i>
        }
    </div>
}

该代码遍历城市中所有可用的属性并呈现其详细信息,使用 Font Awesome 图标显示禁止吸烟标志(如果不允许吸烟),并使用多个床图标表示允许的最多客人数。您还使用了欧元图标来表示货币。您现在所要做的就是启动应用程序并单击主页上的一个城市(图 8.8)。

图 8.8 City(城市)页面返回 404 Not Found(未找到)。

哦!那里发生了什么?好吧,如果您使用的是 SQL Server 而不是 SQLite,则很可能可以看到所选城市的详细信息。SQLite 的问题在于,默认情况下,字符串比较区分大小写。您将一个小写值从 URL 传递给 service 方法,后者将其与数据库中的混合大小写值进行比较。要解决此问题,您可以使用 EF.Functions.Collate 方法来指定 SQLite 应该用于此比较的排序规则。英 孚。函数包含一组有用的方法,这些方法可转换为数据库函数,并且仅适用于 EF Core 中使用的 LINQ 查询。该文档提供了它们的完整列表 (http://mng.bz/gRrv)。打开 CityService,并更改 GetByNameAsync 方法,如下所示。

Listing 8.21 指定要用于查询的排序规则

public async Task<City> GetByNameAsync(string name)
{
    name = name.Replace("-"," ");                             ❶
    return await _context.Cities
        .Include(c => c.Country)
        .Include(c => c.Properties.Where(p => 
        ➥ p.AvailableFrom < DateTime.Now))
        .SingleOrDefaultAsync(c => 
        ➥ EF.Functions.Collate(c.Name, "NOCASE") == name);   ❷
}

❶ 将页面路由参数 transformer 添加的连字符替换为空格,以便它们与数据库条目匹配。
❷ 使用 EF。Functions.Collate 为 SQLite 指定 NOCASE 的排序规则。

完成此校正后,您应该能够查看所选城市的详细信息(图 8.9)。

图 8.9 显示所选城市的详细信息。

伟大!此时,您已经检索了数据的单个实例以及集合。您还检索了相关数据。接下来,我们将专注于 CRUD 的其他方面:创建新记录、更新记录和删除记录。同时,我们将研究另一个可以加快这些示例开发的功能:脚手架。

8.4 脚手架 CRUD 页面

基架是一种用于在设计时生成代码的技术,用于在使用 EF Core 时支持多种常见的应用程序方案。基架工具支持使用表 8.1 中的模板生成与数据库交互的 Razor 页面。

表 8.1 基架工具可用的模板

Template

Description

Create

Generates a Razor page with a form for creating the specified model class and PageModel handlers complete with code for saving to a database

Edit

Generates a Razor page with a form for editing the specified model class and PageModel handlers complete with code for retrieving the specified entity and saving changes to a database

Delete

Produces a page that displays details of the selected entity and a form with a hidden field that passes the key value of the entity to a handler method that removes it from the database

Details

Produces a page that displays details of the selected entity

List

Produces a page that displays details of all instances of the selected entity in a table

此外,基架工具支持空模板,这相当于每次从 Visual Studio 对话框或命令行向应用程序添加新的 Razor 页面时所看到的模板。基架工具生成的代码很少可用于生产。它仅提供一个起点。您将使用该工具为 Property 类生成所有 CRUD 页面,然后查看生成的代码以了解其缺点以及您需要采取哪些措施来解决任何问题。

在使用基架工具之前,必须安装包含模板的 NuGet 包:Microsoft.VisualStudio.Web.CodeGeneration.Design。如果您使用的是 Visual Studio,则当您使用基架时,IDE 将尝试添加对此包的最新稳定版本的引用。但是,根据我的经验,Visual Studio 在安装包后会报告一个错误,要求您再次指定基架选择。因此,我通常会手动添加包引用。假设您要使用命令行进行基架。在这种情况下,您仍然需要使用我们已经介绍的用于管理 NuGet 的任何可用方法手动添加包。

8.4.1 Visual Studio 基架说明

基架内置于 Visual Studio 中,可从 Add (添加) 对话框访问。右键单击 Pages\PropertyManager 文件夹,然后从关联菜单中选择添加。然后选择 New Scaffolded Item...从第二个菜单组。从出现的对话框中选择 Razor Pages,然后选择 Razor Pages using Entity Framework (CRUD)。接下来,单击 Add 按钮。在下一个对话框中,选择 Property (CityBreaks.Models) 作为 Model 类,并选择 CityBreaksContext 作为 Data 上下文类。将其他选项保留为默认值;也就是说,Reference Script Libraries 和 Use a Layout Page 都应该被选中。将布局页面输入留空。参见图 8.10。

图 8.10 Visual Studio 中的基架对话框

单击 Add 按钮。系统将提示您替换与 Create 页面相关的现有文件。单击 Yes (是)。然后,代码生成器应运行。您可能会发现,基架工具添加了对 Microsoft.EntityFrameworkCore.SqlServer 包的引用。在使用 SQLite 数据库时,您的应用程序不需要这样做;只有基架工具需要它。完成基架后,您可以根据需要删除此引用。如果使用的是 SQL Server,则此引用已存在,并且应用程序需要此引用。

8.4.2 从命令行搭建基架

若要从命令行搭建项,必须先安装 dotnet-aspnet-codegenerator 工具。这是一个全局工具,类似于您之前安装的 dotnet-ef 工具。使用以下命令安装该工具:

dotnet tool install --global dotnet-aspnet-codegenerator

安装该工具后,您就可以使用它了。命令名称与工具相同:dotnet-aspnet-codegenerator。该命令需要您要使用的生成器的名称,后跟您要应用的选项。Razor Pages 生成器的名称是 razorpage。表 8.2 中详细介绍了 Razor Pages 生成器选项。

表 8.2 Razor Pages 生成器选项

Option

Description

--model|-m

Model class to use

--dataContext|-dc

DbContext class to use

--referenceScriptLibraries|-scripts

Switch to specify whether to reference script libraries in the generated views

--layout|-l

Custom layout page to use

--useDefaultLayout|-udl

Switch to specify that the default layout should be used for the views

--force|-f

Overwrites existing files

--relativeFolderPath|-outDir

Specifies the relative output folder path from project where the file needs to be generated; if not specified, the file will be generated in the project folder

--namespaceName|-namespace

Specifies the name of the namespace to use for the generated PageModel

--partialView|-partial

Generates a partial view; other layout options (-l and -udl) are ignored if this is specified

--noPageModel|-npm

Switch to prevent generating a PageModel class for an empty template

--useSqlite|-sqlite

Flag to specify if DbContext should use SQLite instead of SQL Server

您可以通过指定 Razor 页面的名称以及要使用的模板的名称(以下选项之一)来基于现有模板搭建各个页面的基架:Empty、Create、Edit、Delete、Details 或 List。或者你可以省略 name 和 template;在这种情况下,生成器将搭建除 empty template 之外的所有 Template。

您希望为 Property 类搭建所有 CRUD 页面的基架,并且希望使用 CityBreaksContext 作为数据上下文。您还希望生成的文件被放置在 Pages\PropertyManager 文件夹中,并且您希望它们使用该文件夹的默认布局页面。您不会为页面指定命名空间;相反,脚手架将根据项目名称和文件夹路径生成一个:CityBreaks.Pages.PropertyManager。您希望包含不显眼的验证脚本,并且希望指定上下文使用 SQLite。将所有这些放在一起,您的命令如下所示:

dotnet aspnet-codegenerator razorpage -m Property
 -dc CityBreaksContext -outDir
 Pages\PropertyManager -udl -scripts -sqlite

此命令必须在包含项目文件的文件夹中执行。完成后,页面将显示在图 8.11 所示的指定文件夹中。

图 8.11 基架式 CRUD 页面

8.4.3 使用基架页
无论您采用哪种方法来搭建 CRUD 页面的基架,您现在都应该得到相同的结果。您可能会注意到的第一件事是它们无法构建。在撰写本文时,基架工具中存在一个错误,该错误导致 @ 字符(不能用作有效的 C# 标识符)作为参数应用于 Edit、Delete、Details 和 Index PageModel 类的 Include 方法中:

Property = await _context.Properties
    .Include(@ => @.City).FirstOrDefaultAsync(m => m.Id == id);

这需要替换为另一个字符 — 比如 p:

Property = await _context.Properties
    .Include(p => p.City).FirstOrDefaultAsync(m => m.Id == id);

完成该更改后,您可以检查各个页面中的代码并寻求改进它。在本章中,我将重点介绍 Edit (编辑) 页面的文件。一旦您了解了此页面中需要解决的问题范围,您就可以很好地对其他页面进行适当的更改。

首先,我将展示 EditModel 代码的前几行。您将注意到的第一件事是 EditModel 依赖于 EF Core 上下文。

清单 8.22 脚手架 EditModel 构造函数

private readonly CityBreaks.Data.CityBreaksContext _context;

public EditModel(CityBreaks.Data.CityBreaksContext context)
{
    _context = context;
}

参考上一章,这违反了依赖倒置原则,因为上下文不是抽象的。您的 PageModel 类与您选择的数据访问技术紧密耦合,例如,如果您在单元测试中实例化此类的实例,它将调用上下文的连接字符串中定义的任何数据库。这不是单元测试。这是一个集成测试。理想情况下,您应该将上下文替换为服务,或者更确切地说,将其抽象替换为服务。

生成的代码的下一个主要问题会打开一个潜在的攻击媒介。生成的代码使整个 Property 类成为绑定目标:

[BindProperty]
public Property Property { get; set; }

如果您回想一下第 5 章,您应该将页面上的绑定目标的范围限制为仅您希望用户设置的那些属性。如果公开更多,则应用程序容易受到过度发布攻击。您确实希望将 Property 类的所有当前属性公开给模型绑定器,但情况可能并非总是如此。将来,您可能会向类添加更多属性。默认情况下,它们将公开给模型绑定,因为您使用 BindProperty 属性修饰了整个类。因此,作为最佳实践,您应该首先单独或通过 InputModel 显式公开属性。下一个清单显示了基架 OnGetAsync 处理程序方法。

清单 8.23 脚手架 EditModel OnGetAsync 处理程序

public async Task<IActionResult> OnGetAsync(int? id)
{
    if (id == null)
    {
        return NotFound();
    } 

    Property = await _context.Properties
        .Include(p => p.City).FirstOrDefaultAsync(m => m.Id == id);

    if (Property == null)
    {
        return NotFound();
    }
    ViewData["CityId"] = new SelectList(_context.Cities, "Id", "Id");
    return Page();
}

我不喜欢无用的代码,此处理程序的开头有一个很好的示例。该方法采用一个可为 null 的参数,该参数表示要编辑的项的标识。然后,一个代码块检查是否传递了值,如果没有,则返回 404。您有一种机制,可以确保只有在提供号码时才能访问此页面。它被称为 route constraints,你在第 4 章中了解了它。相反,您可以将 id 设为此页面的必需路由参数,并将其限制为数字类型,从而不需要第一个代码块,因为如果未提供数字,框架将返回 404。

此代码块将创建一个填充了 City 数据的 SelectList。SelectList 被分配给 ViewData,正如您可能还记得第 3 章中的那样,它是一个弱类型字典。从 ViewData 检索到的对象需要强制转换为其正确的类型,以便在代码中再次使用。理想情况下,SelectList 应该是 PageModel 的一个属性,因此在 Razor 页面中使用对象时,无需使用强制转换。

获取 city 数据的代码不是异步的。作为性能最佳实践,您应该始终致力于在 ASP.NET Core Web 应用程序中使用异步 API(如果它们可用 http://mng.bz/epwV)。大多数进行进程外调用 (I/O) 的库(例如,支持与数据库通信、发送电子邮件、文件处理等的库)都提供异步 API。

异步代码的原因

想象一下,您正在安装一个厨房。您用 DIY 套件构建橱柜,但您会发现其中一扇门缺少铰链。你决定在得到铰链之前不能前进,所以你开车去商店拿一个。您开车去商店的所有时间都被浪费了,并增加了您的任务延迟。您正在同步工作,在进入下一个任务之前完成一个任务。
对于这种情况,更有效的方法是致电 store 并让他们提供一个 hinge。这样,您就可以继续执行其他任务,同时等待您委派给商店的配送任务已完成的通知(门铃响起)。这就是异步编程的工作原理。
Web 服务器的可用线程数有限,在高负载情况下,所有可用线程都可能正在使用中。发生这种情况时,服务器无法处理新请求,直到线程被释放。使用同步代码时,许多线程可能会在实际上没有执行任何工作时被占用,因为它们正在等待 I/O(如数据库调用)完成。使用异步代码时,当进程等待 I/O 完成时,其线程将被释放供服务器用于处理其他请求。因此,异步代码使服务器资源能够更有效地使用,并且服务器能够无延迟地处理更多流量。

生成代码的下一个主要问题乍一看可能并不明显,但当您运行应用程序并导航到 /property-manager/ edit?id=1 时,它很快就会变得清晰,如图 8.12 所示。城市选择列表中显示的值是键值,而不是城市名称。

图 8.12 键值显示在选择列表中,而不是城市名称中。

您的补救计划将执行以下作:

  1. 将注入的上下文交换为服务,从而启用松散耦合。
  2. 绑定到单个属性以降低安全风险。
  3. 减少对 ViewData 的依赖。
  4. 尽可能使用异步代码。

8.5 创建、修改和删除数据

在本节中,您将创建一个 PropertyService 以满足基架页面处理的方案的要求。您将添加用于创建和编辑 Property 实体的方法,并根据其键值检索单个实例。您暂时不会生成用于删除实体的方法。稍后,您将快速查看从数据库中删除项目所需的代码,但对于此应用程序,您将使用软删除,将项目标记为已删除,而不实际删除它。

在 EF Core 中,可以直接使用 DbContext 的 Add、Update 和 Remove 方法对 DbContext 执行导致添加、更新或删除数据的作。这些方法中的每一种都将要作的实体作为参数,并将其状态设置为 Added、Modified 或 Deleted 之一。您将调用 DbContext 的异步 SaveChangesAsync 方法,以将更改提交到数据库。上下文将根据实体的状态生成相应的 SQL。此工作流如图 8.13 所示。

图 8.13 Add 方法将实体的状态设置为 Added。调用 SaveChangesAsync 时,EF Core 会生成一个 SQL INSERT 语句,并针对数据库执行该语句。

因此,让我们首先为封装这些作的 Property 实体创建服务类。第一步是将名为 IPropertyService 的新接口添加到“服务”文件夹中,其中包含以下代码。

清单 8.24 带有 CRUD 方法的 IPropertyService 接口

using CityBreaks.Models;

namespace CityBreaks.Services
{
    public interface IPropertyService
    {
        Task<Property> CreateAsync(Property property);
        Task<List<Property>> GetAllAsync();
        Task<Property> FindAsync(int id);
        Task<Property> UpdateAsync(Property property);
        Task DeleteAsync(int id);
    }
}

现在,将一个名为 PropertyService 的新类添加到实现该接口的 Services 文件夹中。

清单 8.25 在 PropertyService 中实现 CRUD 方法

using CityBreaks.Data;
using CityBreaks.Models;
using Microsoft.EntityFrameworkCore;

namespace CityBreaks.Services;
public class PropertyService : IPropertyService
{
    private readonly CityBreaksContext _context;

    public PropertyService(CityBreaksContext context) =>
        _context = context;

    public async Task<Property> FindAsync(int id) =>
        await _context.Properties
        .FindAsync(id);

    public async Task<List<Property>> GetAllAsync() =>
        await _context.Properties
        .Include(x => x.City)
        .ToListAsync();

    public async Task<Property> CreateAsync(Property property)
    {
        _context.Add(property);
        await _context.SaveChangesAsync();
        return property;
    }

    public async Task<Property> UpdateAsync(Property property)
    {
        _context.Update(property);
        await _context.SaveChangesAsync();
        return property;
    }
}

第一种方法使用 FindAsync 方法检索单个实体。此方法与您目前看到的以 First 和 Single 开头的方法不同。它需要一个值,该值表示要检索的实体的键,但不能将其与 Include 方法一起使用。编辑项目时,您不一定需要其关联数据;您只需要 Foreign key 值。FindAsync 方法非常适合此目的。

CreateAsync 方法使用 DbContext.Add 方法获取上下文以开始跟踪属性实体。EntityState 应用于上下文跟踪的所有实体,这是一个指定实体当前状态的枚举。使用 Add 方法时,将分配 EntityState.Added 值。这告诉上下文应该将实体添加为新记录,并且生成的 SQL 是 INSERT 语句。

DbContext.Add 方法是在 EF Core 中引入的。在早期版本的 EF 中,对相关的 DbSet 执行数据作,等效的

_context.Properties.Add(property)

UpdateAsync 方法使用 DbContext.Update 方法,该方法指示上下文开始跟踪处于 EntityState.Modified 状态的实体。DbContext .Update 方法也是 EF Core 中的新增功能。在早期版本的 EF 中,您必须将修改后的实体附加到上下文,并将其状态显式设置为 EntityState.Modified,这类似于已应用于基架代码中现有 OnPostAsync 处理程序的模式:

_context.Attach(Property).State = EntityState.Modified;
当实体处于 Modified 状态时,EF Core 会生成一个 SQL UPDATE 语句,该语句会导致修改实体的所有非键值。我们将了解如何控制 SQL,以便它仅在您稍后实施软删除时更新单个属性值。

CreateAsync 和 UpdateAsync 方法都包含同一行:

await _context.SaveChangesAsync();

SaveChangesAsync 方法会导致对数据的所有挂起更改写入数据库。它返回一个 int,表示受作影响的行数。当您使用 Add 方法创建新记录时,生成的 SQL 会检索新创建记录的主键值,EF Core 会将其分配给跟踪的实体。您的数据库作非常简单,只涉及一个命令。可以设置多个作,并通过对 SaveChangesAsync 的一次调用同时提交所有作。默认情况下,EF Core 使用事务来执行这些作,因此,如果其中任何一个作失败,所有其他作都将回滚,从而保持数据库不变。

该服务几乎已准备好替换 PageModel 中的 DbContext作。在代码中使用该服务之前,必须先将其注册到服务容器。转到 Program.cs,并添加以下注册:

builder.Services.AddScoped<IPropertyService, PropertyService>();

8.5.1 修改数据

转到基架 EditModel 类,您将进行以下更改,以将现有的私有字段替换为 IPropertyService 和 ICityService 的新私有字段。注入的上下文将替换为服务。您还需要添加一个 using 指令来引用 CityBreaks.Services。

示例 8.26 注入 IPropertyService 代替 DbContext

private readonly IPropertyService _propertyService;
private readonly ICityService _cityService;

public EditModel(IPropertyService propertyService, ICityService cityService)
{
    _propertyService = propertyService;
    _cityService = cityService; 
}

在清单 8.27 所示的步骤中,您将 Property 绑定目标替换为表示要向用户公开的值的单个绑定目标。您还可以添加一个公共 SelectList 属性,以替换当前对 city 下拉列表采用的 ViewData 方法。最后,将 OnGet 处理程序参数 (id) 替换为一个公共属性,该属性使您能够在两个处理程序方法中使用该值,并且您将确保在请求使用 GET 方法时可以将其绑定到该值。

清单 8.27 将绑定到实体替换为绑定到属性

public SelectList Cities { get; set; }
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
[BindProperty, Display(Name = "City")]
public int CityId { get; set; }
[BindProperty, Required]
public string Name { get; set; }
[BindProperty, Required]
public string Address { get; set; }
[BindProperty, Display(Name = "Maximum Number Of Guests")]
public int MaxNumberOfGuests { get; set; }
[BindProperty, Display(Name = "Daily Rate")]
public decimal DayRate { get; set; }
[BindProperty, Display(Name = "Smoking?")]
public bool SmokingPermitted { get; set; }
[BindProperty, Display(Name = "Available From")]
public DateTime AvailableFrom { get; set; }

您需要在 OnGetAsync 处理程序中填充 Cities SelectList 属性,如果存在 ModelState 错误,则需要在 OnPostAsync 处理程序中再次填充。您已经为此建立了一个减少重复的模式。在列表 8.28 中,你添加了一个私有方法,该方法返回一个 SelectList,该方法使用异步代码到 PageModel 类的末尾。

清单 8.28 用于填充 SelectList 对象的可重用私有方法

private async Task<SelectList> GetCityOptions()
{
    var cities = await _cityService.GetAllAsync();
    return new SelectList(cities, nameof(City.Id), nameof(City.Name));
}

现在,你将看到许多红色波浪线,指示编译器错误。首先在 OnGetAsync 方法中处理这些问题,方法是将整个方法块替换为以下代码,该代码使用该服务获取要编辑的 Property 实例,并将其值分配给公共 PageModel 属性。

清单 8.29 修改后的 OnGetAsync 方法

public async Task<IActionResult> OnGetAsync()
{
    var property = await _propertyService.FindAsync(Id);

    if (property == null)
    {
        return NotFound();
    }
    Address = property.Address;
    AvailableFrom = property.AvailableFrom;
    CityId = property.CityId;
    DayRate = property.DayRate;
    MaxNumberOfGuests = property.MaxNumberOfGuests;
    Name = property.Name;
    SmokingPermitted = property.SmokingPermitted;

    Cities = await GetCityOptions();
    return Page();
}

对于相对简单的实体,将值从数据库中检索到的实体映射到 PageModel 属性的代码是可管理的。您可以想象,对于具有更多属性的实体,编写和维护这种类型的代码将非常费力。可以使用一些工具来帮助显著减少此代码(在许多情况下,减少到一行代码),例如流行的 AutoMapper (https://automapper.org/),这是我的首选选项。在本书中,您不会使用这样的工具,但我建议您为自己的应用程序探索这种节省大量时间的工具

这样,在移动页面的 Razor 部分之前,只需整理 OnPostAsync 方法即可。基架代码将捕获 DbUpdateConcurrencyException(如果引发),这表示您正在编辑的项不再存在;在您从数据库中检索它并提交您的修改之间,其他人已经删除了它。这不是您需要担心的情况,因为您不会从数据库中删除条目。所以你的任务很简单。检查 ModelState,如果有效,则将发布的值作为 Property 实例传递给服务的 UpdateAsync 方法。

示例 8.30 更新的 OnPostAsync 方法

public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        Cities = await GetCityOptions();
        return Page();
    }

    var property = new Property
    {
        Address = Address,
        AvailableFrom = AvailableFrom,
        CityId = CityId,
        DayRate = DayRate,Id = Id,
        MaxNumberOfGuests = MaxNumberOfGuests,
        Name = Name,
        SmokingPermitted = SmokingPermitted
    };
    await _propertyService.UpdateAsync(property);

    return RedirectToPage("./Index");
}

您已到达转换的最后一部分:Razor 页面本身。这里不需要太多更改。您需要做的就是

• 添加路由模板。
• 从标记帮助程序模型表达式中删除 Property 前缀。
• 更新 cities select 列表中的 city 数据源。

除非在 URL 中传递数字,否则您不希望访问此页面,因此路由模板必须包含约束。因此,您将以下模板添加到 @page 指令中:

@page "{id:int}"

您在 URL 中有 id,并且您已在 PageModel 中启用了与 route 参数的绑定,因此您不再需要表单中的 hidden 字段:

<input type="hidden" asp-for="Property.Id" />

您可以将其注释掉或完全删除。接下来,找到 cities select list 的 HTML 部分,如下面的清单所示。

Listing 8.31 脚手架选择列表 HTML

<div class="form-group">
    <label asp-for="Property.CityId" class="control-label"></label>
    <select asp-for="Property.CityId" class="form-control" 
     ➥ asp-items="ViewBag.CityId"></select>
    <span asp-validation-for="Property.CityId" class="text-danger"></span>
</div>

所有对 Property 的引用都应具有红色波浪线,表示编译器错误。您需要删除它们,以及页面中其他标签帮助程序中的那些。您还需要更新 asp-items 属性以引用 Model.Cities 而不是 ViewBag.CityId。修改后的版本如下面的清单所示。

列表 8.32 修改后的选择列表

<div class="form-group">
    <label asp-for="CityId" class="control-label"></label>
    <select asp-for="CityId" class="form-control" 
     ➥ asp-items="Model.Cities"></select>
    <span asp-validation-for="CityId" class="text-danger"></span>
</div>

现在,您可以测试修订版了。运行应用程序,然后导航到 /property-manager。您将被带到脚手架索引页面,其中列出了所有属性(图 8.14)。

图 8.14 原始基架索引页

请记住,这是未修改的基架版本,因此它在 City 列中显示键值,而不是 Name 列。键值也显示在 Create 页面的选择列表中,您可以通过单击页面标题下方的 Create New 链接来访问该列表。它们也会显示在每个属性的 Details (详细信息) 页面上。单击其中一个属性的 Edit 链接,查看它与基架的 Create 页面有何不同。城市的名称出现在选择列表中,表单标签是用户友好的(图 8.15)。

图 8.15 修改后的 Edit 页面

通过将 Available From date 设置为将来的日期来更改该属性。提交这些更改,并在您将重定向到 Index 页面时确认您的修订有效,该页面应显示修订日期。然后导航到主页,并确认指定城市的房产数量已减少 1。

8.5.2 删除数据

基架页面包括一个用于删除实体的页面。DeleteModel 类中的 OnPostAysnc 方法包含实际从数据库中删除该条目的代码。了解它的工作原理很重要,因为它不是最优的。

清单 8.33 DeleteModel 中的基架 OnPostAsync 方法

public async Task<IActionResult> OnPostAsync(int? id)
{
    if (id == null)                                         ❶
    {                                                       ❶
        return NotFound();                                  ❶
    }                                                       ❶

    Property = await _context.Properties.FindAsync(id);     ❷

    if (Property != null)
    {
        _context.Properties.Remove(Property);               ❸
        await _context.SaveChangesAsync();                  ❹
    }

    return RedirectToPage("./Index");
}

❶ 检查是否已将键值传递给方法
❷ 这将从数据库中检索匹配的条目。上下文开始跟踪它。
❸ DbSet.Remove 方法将实体的状态设置为 Deleted。
❹ SaveChangesAsync 将更改提交到数据库。

我们已经讨论了如何使用路由约束来替换此方法开始时的 null 检查。基架代码的另一个次优功能是,它会导致对数据库执行两个命令。第一个命令从数据库中检索项目,以便上下文可以开始跟踪它。该代码使用 DbSet.Remove 方法将实体的状态设置为 Deleted。第二个命令在调用 SaveChangesAsync 时执行,由一个 SQL DELETE 语句组成,该语句将其从数据库中删除。

实际上根本不需要检索实体。您可以使用所谓的存根来表示要删除的实体。存根是仅分配了其键值的实体。假设您已经修改了此页面,以使用表示键值的受约束路由参数,而不是可为 null 的处理程序参数。下一个清单显示了如何使用存根来表示要在 OnPostAsync 方法中删除的实体。

示例 8.34 删除由 stub 表示的实体

public async Task<IActionResult> OnPostAsync()
{
    var property = new Property { Id = Id };     ❶
    _context.Remove(property );                  ❷
    await _context.SaveChangesAsync();

    return RedirectToPage("./Index");
}

❶ 创建一个存根,仅分配其 key 值。
❷ 将存根传递给 DbContext.Remove 方法,该方法将实体标记为 Deleted。

采用这种方法,您可以显著降低代码的复杂性,并将实现目标所需的数据库调用次数减半。请注意,您还使用了 DbContext.Remove 方法,而不是基架生成的 DbSet.Remove 方法。与 DbContext Add 和 Update 方法一样,Remove 方法是 EF Core 的新增功能,可帮助你减少代码。

删除实体时,所有依赖数据都将丢失或孤立。根据外键的设置方式,删除作将级联到所有依赖数据,并且还会删除该数据,或者将其外键值更新为 null,从而导致孤立数据。如果依赖数据是业务关键型数据(如 orders),则不需要这样做。您显然希望保留有关住宿预订的历史数据,例如,尽管它已被推平。如果它被意外删除,您甚至可能需要恢复它。因此,您将更频繁地使用软删除,即以某种方式将记录标记为已删除,而不是完全删除记录,这就是 DbContext.Remove 方法的结果。在本章的最后一节中,您将向 Property 类添加一个新属性,该属性表示实体标记为已删除的日期和时间。您将添加新的迁移以更新数据库架构,然后修改 Delete 页面以适应您修订后的删除管理策略。

第一步是将可为 null 的 DateTime 属性添加到 Property 类:

public DateTime? Deleted { get; set; }

为此,我通常使用 DateTime 而不是 bool,因为不可避免地会有人询问项目何时被删除。在没有更复杂的日志记录的情况下,至少这可以帮助我回答这个问题。

我将借此机会强调关于过度发布攻击的观点。您刚刚向类中添加了一个不希望用户直接设置的新属性。如果允许模型绑定器直接绑定到类的实例,则会向用户公开此属性。通过仅将单个属性指定为绑定目标,可以防止用户设置 Deleted 属性的值。

添加 Deleted 属性后,您可以使用包管理器控制台或命令行添加新的迁移,这将检测您对模型所做的更改,并通过相应地修改数据库架构来反映这些更改。Powershell 和命令行选项都显示在下面的清单中。

列表 8.35 添加迁移

[Powershell]                                      ❶
add-migration AddedDeleteToProperty               ❶
[CLI]                                             ❷
dotnet ef migrations add AddedDeleteToProperty    ❷

❶ 要从 Visual Studio 的包管理器控制台中执行的 Powershell 命令
❷ 要从包含 csproj 文件的目录中的命令提示符执行的 CLI 命令

执行后,您可以检查迁移代码中的 Up 方法,以确保它将添加可为 null 的 Delete 列。在 SQLite 中,这将是一个 TEXT 类型。

清单 8.36 新迁移的 Up 方法

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<DateTime>(
        name: "Deleted",
        table: "Properties",
        type: "TEXT",
        nullable: true);
}

现在,您可以使用以下命令之一应用迁移:

[Powershell]
update-database
[CLI]
dotnet ef database update

完成后,向 IPropertyService 接口添加新方法:

Task DeleteAsync(int id);

然后向 PropertyService 类添加一个实现。

列表 8.37 Delete 方法实现

public async Task DeleteAsync(int id)
{
    var property = new Property { Id = id, Deleted = DateTime.Now };      ❶
    _context.Attach(property).Property(p => p.Deleted).IsModified = true; ❷
    await _context.SaveChangesAsync();                                    ❸
}

❶ 创建一个存根来表示要修改的项。
❷ 将实体附加到上下文,并指定应修改的属性。
❸ 提交更改。

此方法提供了另一个 stub 有用的示例。您只想更新此实体的数据库中的 Deleted 列。如果将整个实体传递给 Update 方法,则所有属性都包含在生成的 SQL UPDATE 语句中。为避免这种情况,您可以使用 Attach 告诉上下文开始跟踪您的实体,并将其状态设置为 Unchanged。然后,将 Deleted 属性显式设置为 modified。将单个属性设置为已修改时,UPDATE 语句中仅包含这些属性。您可以通过将 DeleteModel 中注入的上下文替换为 IPropertyService 来利用此方法。

列表 8.38 修改后的 DeleteModel 依赖于 IPropertyService

public class DeleteModel : PageModel
{
    private readonly IPropertyService _propertyService;      ❶

    public DeleteModel(IPropertyService propertyService)     ❶
    {                                                        ❶
        _propertyService = propertyService;                  ❶
    }                                                        ❶

    public Property Property { get; set; }                   ❷

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

    public async Task<IActionResult> OnGetAsync()            ❹
    {                                                        ❹
        Property = await _propertyService.FindAsync(Id);     ❹

        if (Property == null)                                ❹
        {                                                    ❹
            return NotFound();                               ❹
        }                                                    ❹
        return Page();                                       ❹
    }                                                        ❹

    public async Task<IActionResult> OnPostAsync()           ❺
    {                                                        ❺
        await _propertyService.DeleteAsync(Id);              ❺
        return RedirectToPage("./Index");                    ❺
    }                                                        ❺
}

❶ 注入的上下文将替换为 IPropertyService。
❷ 从 Property 属性中删除不必要的 BindProperty 属性。
❸ 为 key 值添加 bound 属性,替换处理程序参数。
❹ 删除对 key 值的 null 检查,因为您将改用路由约束。
❺ 使用服务“删除”实体。

与 “编辑 ”页一样,如果 URL 中包含整数值,则只希望访问 “删除 ”页,因此转到 Delete.cshtml 并添加路由约束作为路由模板的一部分:

@page "{id:int}"

在测试之前,还有两件事要做。第一种方法是更改 PropertyService 中的 GetAllAsync 方法,以排除具有分配给其 Deleted 属性的值的属性。

清单 8.39 从结果集中排除已删除的属性

public async Task<List<Property>> GetAllAsync() =>
    await _context.Properties
    .Where(p => !p.Deleted.HasValue)
    .Include(x => x.City)
    .ToListAsync();

然后,您更改 PropertyManager\Index.cshtml.cs 文件中的 IndexModel 类,以从属于 IPropertyService 而不是上下文,以便您可以使用新方法填充页面。

清单 8.40 修改后的 PropertyManager IndexModel

public class IndexModel : PageModel
{
    private readonly IPropertyService _propertyService;

    public IndexModel(IPropertyService propertyService)
    {
        _propertyService = propertyService;
    }

    public IList<Property> Property { get;set; }

    public async Task OnGetAsync()
    {
        Property = await _propertyService.GetAllAsync();
    }
}

现在运行应用程序,并导航到 /property-manager。观察列表中的第一个属性。如果您使用的是上一章下载中提供的种子数据,则列表中的第一个属性应该是 Hotel Paris。点击 Delete 链接将带您进入 Delete 页面,该页面要求您确认是否要删除此项目(图 8.16)。

图 8.16 “删除”页面

单击 Delete 按钮,然后观察 Hotel Paris 不再在列表中。作为最后的检查,使用您熟悉的任何数据库工具查看 Properties 表中的数据,以确认相关记录仍然存在 — 尽管现在 Deleted 列中有一个值(图 8.17)。

图 8.17 数据库视图显示 “deleted” 记录仍然存在。

这是一个很长的章节,但我们只真正触及了 EF Core 可以做什么的皮毛。我将再次推荐 Jon P. Smith 的 Entity Framework Core in Action(第 2 版;http://mng.bz/WMeg)作为了解有关如何使用 EF Core 以及官方文档 (https://docs.microsoft.com/en-us/ef/) 的更多信息的绝佳资源。

您已经将应用程序向前推进,因为它现在可以与数据库交互。但是,如果访问该站点的任何人知道 CRUD 页面的 URL,就可以添加和修改数据。在接下来的两章中,我们将介绍如何对用户进行身份验证,以便您了解他们是谁,然后保护未经授权的用户对这些页面的访问。

总结

Entity Framework Core 是 Microsoft 提供的一种对象关系映射 (ORM) 工具,它充当插入式数据层,抽象出使用关系数据库所需的样板代码。
使用 EF Core 的主要入口点是通过上下文,即派生自 DbContext 的对象。
EF Core 上下文跟踪对象并根据其状态生成 SQL。
实体通过 DbSet 对象映射到数据库表。
EF Core 将 LINQ 查询转换为 SQL,然后针对数据库执行 SQL。
约定驱动对象和数据库之间的 EF Core 映射。
您可以使用 configuration 自定义映射。
EF Core 迁移使你能够使模型和数据库架构彼此保持同步。
您可以使用种子设定将数据添加到数据库中,作为迁移的一部分。
基架使您能够根据 EF Core 的映射快速生成 CRUD 页面。

ASP.NET Core Razor Pages in Action 7 使用依赖关系注入管理服务

ASP.NET Core Razor Pages in Action 7 使用依赖关系注入管理服务

本章涵盖

• 了解依赖关系注入的作用
• 检查 ASP.NET Core 中的依赖项注入
• 创建和使用您自己的服务
• 管理服务生命周期

依赖注入 (DI) 是一种软件工程技术,目前许多 Web 框架都包含在内。它存在的理由是实现软件组件之间的松散耦合,从而使代码不那么脆弱、更适应变化、更易于维护和更易于测试。如果您以前使用过依赖注入,那么所有这些对您来说都会很熟悉。如果依赖注入对你来说是一个新概念,本章将通过解释它是什么以及为什么你应该关心来提供帮助。

DI 是 ASP.NET Core 的核心。整个框架使用内置的 DI 功能来管理自己的依赖项或服务。当应用程序启动时,服务在容器中全局注册,然后在使用者需要时由容器提供。在第 2 章中,您遇到了服务容器的主入口点,Program.cs它通过 WebApplicationBuilder 的 Services 属性进行访问。您会记得,组成 Razor Pages 框架的服务是通过 AddRazorPages 方法注册到容器中的:

var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();

除了框架使用其容器之外,还鼓励您使用它来注册自己的服务,以便容器可以将它们注入到使用者中,容器将负责代表您管理这些服务的生命周期(创建和销毁)。这里要说明的一个关键点是,您不需要将 DI 用于您自己的服务。但是,您至少应该了解 DI 在 Razor Pages 应用程序中的工作原理,以便根据需要自定义框架服务。

在本章结束时,您将了解什么是依赖项注入、如何在 Razor Pages 应用程序中管理它,以及将 DI 用于您自己的服务的好处。您还将更清楚地了解 Razor Pages 应用程序上下文中的服务是什么。您将创建一些服务并将它们注册到依赖项注入容器中,以便它们在整个应用程序中可用。

例如,服务需要由 DI 容器管理其生命周期,因此您的应用程序不会耗尽内存。您也不应该不必要地实例化服务,尤其是在应用程序只需要一个实例的情况下。在本章中,您将了解可用的不同生命周期,以及如何在注册服务时选择正确的生命周期。

7.1 依赖注入的原因

在我们了解 ASP.NET Core 中依赖项注入的基础知识之前,我们需要清楚它所解决的问题的性质。该讨论涉及使用软件工程社区吸收的术语来描述原则和技术。许多术语都非常抽象,用于描述抽象概念。因此,它们可能很难理解,尤其是对于字面意义上的思考者。

我已经将术语松散耦合作为软件工程师在设计系统时应该努力实现的主要目标之一。我已经提到过,这种松散耦合应该发生在组件之间。在研究组件的性质之前,我想退后一步,从软件工程设计原则的角度看一下更大的图景。

7.1.1 单一责任原则

在设计系统时,您应该考虑的一个关键原则是单一责任原则 (SRP)。这就是 S in SOLID,这是一组旨在使软件更易于理解、灵活和可维护的原则。从根本上说,SRP 规定应用程序中的任何组件、模块或服务都应该只有一个更改的理由:由于某些业务原因,其唯一职责需要更改。如果您以此原则考虑 PageModel 类,则可以看到它负责处理其页面的 HTTP 请求。因此,唯一需要更改 PageModel 中代码任何方面的情况是处理请求所需的逻辑是否应更改。

如果你查看你在上一章中放在一起的属性管理器的 Create 页面的 PageModel,你可以看到这个原则被违反了。PageModel 有两个职责:处理请求和以城市集合的形式为 SelectList 生成数据(图 7.1)。

图 7.1 PageModel 目前有两个职责。

在下一章中,我们将了解如何在 Razor Pages 应用程序中使用数据库。您将需要通过从数据库中检索城市选项来更改城市选项的生成方式。该更改与 PageModel 类的主要角色 — 处理请求无关。但是,按照当前设计的方式,迁移到数据库将要求我们深入研究 CreateModel 类以更改 GetCityOptions 方法。换句话说,数据访问策略的更改当前为更改 PageModel 类提供了另一个原因。如果要遵守 SRP,则需要将生成 City 数据的逻辑移动到其自己的组件中,并负责管理 City 实体的数据。

回想一下第 3 章中讨论的 don't repeat yourself (DRY) 原则,该原则鼓励您最大程度地减少代码重复。原则指出,每个 logic 在系统中应该只有一个表示。我们也违反了这一原则。我们在主页中有代码,用于为列表框示例生成城市,从而有效地复制了刚才在 CreateModel 中讨论的 GetCityOptions 代码。此代码应该是集中的,并且再次将其移动到自己的组件将解决错误。

让我们从解决这个问题开始。您将构建一个组件,负责生成 City 对象的集合,并将其提供给需要它的应用程序的任何部分。然后,您将在 PageModel 类中使用该组件,为您目前构建的一个选择列表提供源数据。

首先,将名为 Services 的新文件夹添加到应用程序的根目录。在此范围内,添加一个名为 SimpleCityService.cs 的新 C# 类。下面的清单中提供了该文件的内容。

清单 7.1 SimpleCityService 代码

using CityBreaks.Models;

namespace CityBreaks.Services
{
    public class SimpleCityService
    {   
        public Task<List<City>> GetAllAsync()
        {
            return Task.FromResult(Cities);
        }

        private readonly List<City> Cities = new()
        {
            new City { Id = 1, Name = "Amsterdam", Country = new Country { 
                Id = 5, CountryName = "Holland", CountryCode = "nl" 
            } },
            new City { Id = 2, Name = "Barcelona", Country = new Country { 
                Id = 7, CountryName = "Spain", CountryCode = "es" 
            } },
            new City { Id = 3, Name = "Berlin", Country = new Country { 
                Id = 4, CountryName = "Germany", CountryCode = "de" 
            } },
            new City { Id = 4, Name = "Copenhagen", Country = new Country { 
                Id = 2, CountryName = "Denmark", CountryCode = "dk" 
            } },
            new City { Id = 5, Name = "Dubrovnik", Country = new Country { 
                Id = 1, CountryName = "Croatia", CountryCode = "hr" 
            } },
            new City { Id = 6, Name = "Edinburgh", Country = new Country { 
                Id = 8, CountryName = "United Kingdom", CountryCode = "gb" 
            } },
            new City { Id = 7, Name = "London", Country = new Country { 
                Id = 8, CountryName = "United Kingdom", CountryCode = "gb" 
            } },
            new City { Id = 8, Name = "Madrid", Country = new Country { 
                Id = 7, CountryName = "Spain", CountryCode = "es"
            } },
            new City { Id = 9, Name = "New York", Country = new Country { 
                Id = 9, CountryName = "United States", CountryCode = "us" 
            } },
            new City { Id = 10, Name = "Paris", Country = new Country { 
                Id = 3, CountryName = "France", CountryCode = "fr" 
            } },
            new City { Id = 11, Name = "Rome", Country = new Country { 
                Id = 6, CountryName = "Italy", CountryCode = "it" 
            } },
            new City { Id = 12, Name = "Venice", Country = new Country {
                Id = 6, CountryName = "Italy", CountryCode = "it" 
            } }
        };
    }
}

这确实是一项简单的城市服务。此代码所做的只是生成城市及其所属国家/地区的集合,然后通过名为 GetAllAsync 的公共方法使它们可用。您添加了一些看起来像关系数据库主键的唯一标识符。您可以将此类代码用于概念验证应用程序,或用作单元测试的测试替身(关系数据库的替代品)。或者,如果您曾经写过一本书,您可以将这样的代码用作演示应用程序的一部分,用于学习目的!这里唯一有点奇怪的是,GetAllAsync 方法返回 Task<List<City>>而不仅仅是 List<City>。这是因为您将在下一章中迁移到使用数据库,因此您希望模拟数据库调用,这些调用通常在 ASP.NET Core 应用程序中异步执行。我将在下一章中更详细地讨论这一点。

现在您已经集中了城市的创建,您可以在 PropertyManager \Create.cshtml.cs 文件中使用您的新零部件。打开该选项,并更改现有处理程序方法和 GetCityOptions 方法,使各种页面属性保持原样。您还需要为 CityBreaks.Services 添加 using 指令。

清单 7.2 修改了使用新的 SimpleCityService 的 Create Property 页面

public async Task OnGetAsync()                                          ❶
{    
    Cities = await GetCityOptions();                                    ❶
}    

public async Task OnPostAsync()                                         ❶
{    
    Cities = await GetCityOptions();                                    ❶
    if (ModelState.IsValid)
    {
        var city = Cities.First(o => o.Value == SelectedCity.ToString());
        Message = $"You selected {city.Text} with value of {SelectedCity}";
    }
}

private async Task<SelectList> GetCityOptions()                         ❶
{
    var service = new SimpleCityService();                              ❷
    var cities = await service.GetAllAsync();                           ❷
    return new SelectList(cities, nameof(City.Id), 
    ➥ nameof(City.Name), null, "Country.CountryName");
}

❶ 修改处理程序方法和 GetCityOptions 方法,使它们异步。
❷ 从 SimpleCityService 类获取数据,而不是在 PageModel 中生成数据。

除了转换为使用异步方法之外,这里唯一真正的变化是生成城市集合的责任不再是 CreateModel 类的责任。该工作已委派给新类:SimpleCityService。CreateModel 类依赖于数据的 SimpleCityService,因此 SimpleCityService 是 CreateModel 类的依赖项。

7.1.2 松耦合

我相信是史蒂夫·史密斯(Steve Smith),又名“Ardalis”,一位著名的 ASP.NET 演说家、作家和培训师,首先创造了“新即胶水”(https://ardalis.com/new-is-glue/)这句话。他建议,无论何时在 C# 代码中使用 new 关键字,都要考虑是否在使用者(“高级模块”)与其依赖项(“低级模块”)之间创建紧密耦合,如果是,则从长远来看可能会产生什么影响。在此示例中,在将提供城市数据的逻辑转移到其自己的组件中时,您已有效地将 SimpleCityService 组件粘附到 CreateModel 类。这些参与者之间的关系如图 7.2 所示。

图 7.2 SimpleCityServices 与 Create-Model 类紧密耦合。

您在这里违反了软件工程原则,即显式依赖关系原则,该原则指出“方法和类应显式要求(通常通过方法参数或构造函数参数)它们需要的任何协作对象,以便正常运行”(http://mng.bz/9Vzj)。您的协作对象 SimpleCityService 是 CreateModel 类的隐式依赖项,因为只有在查看使用类的源代码时,才能明显地看到 CreateModel 类依赖于 SimpleCityService。应避免隐式依赖关系。它们难以测试,并使使用者 (CreateModel) 更加脆弱且难以更改。

如果你想把数据提供者的实现改为另一个,比如说从数据库获取数据,你必须遍历代码中调用 new SimpleCityService() 的所有地方,并改变它以引用你的替代实现。您将在下一章中更改实现。您可能认为使用开发工具的 Find and Replace 功能可以使这项工作相对轻松,但这不是构建应用程序的可持续方式,尤其是当有更好的选项可用于交换实现时,我们接下来将介绍这一点。

7.1.3 依赖倒置

那么如何实现松耦合呢?如何重新设计组件或服务的使用者,使它们不再与特定或具体的实现紧密耦合?一种解决方案是依赖抽象,而不是特定的实现。这种方法被称为依赖关系反转原则 (DIP),它是 SOLID 首字母缩略词中的 D。依赖关系倒置也称为控制倒置 (IoC)。

抽象类和接口表示 C# 中的抽象。根据经验,您通常会使用 interfaces 作为抽象,除非您有一些常见的默认行为,您希望所有实现共享;在这种情况下,您应该选择一个抽象类。

依赖关系倒置原则指出,“高级模块不应依赖于低级模块。两者都应该依赖于抽象。抽象不应依赖于细节。细节应该取决于抽象“(Robert C. Martin: Agile Software Development, Principles, Patterns, and Practices, Pearson, 2002)。

高级模块往往是服务的消费者,而低级模块往往是服务本身。因此,DIP 的第一部分指出,消费者和服务都应该依赖于抽象,而不是消费者依赖于特定的服务实现。在此示例中,抽象将是一个接口;Service 将实现它,并且 Consumer 将调用它(图 7.3)。

图 7.3 PageModel 和 SimpleCityService 依赖于一个抽象:ICityService 接口。

现在,依赖关系链已倒置,您需要设计 ICityService 接口。DIP 的第二部分指出,接口也应该依赖于抽象,而不是 “细节”。也就是说,接口不应绑定到特定的实现。因此,您的接口不应返回特定于实现的类型,例如 DbDataReader,它仅适用于关系数据库。它应该依赖于更通用的类型,如 List<T>。幸运的是,您的 SimpleCityService 类已经这样做了。因此,您将基于其现有 API 创建一个接口。

将新的 C# 代码文件添加到 Services 文件夹,并将其命名为 ICityService.cs。请注意,如果您使用的是 Visual Studio,则 Add...New Item 对话框包括 Interface (界面) 选项。将现有代码替换为以下内容。

清单 7.3 ICityService 接口

using CityBreaks.Models;

namespace CityBreaks.Services
{
    public interface ICityService
    {
        Task<List<City>> GetAllAsync();
    }
}

现在你需要确保低级组件依赖于抽象。更改 SimpleCityService,使其实现以下接口:

public class SimpleCityService : ICityService

请注意,如果您使用 Visual Studio 向导提取接口,则此步骤不是必需的。

最后一步是让高级模块 CreateModel 类也依赖于抽象。你怎么做呢?请打鼓......您使用依赖项注入。

7.1.4 依赖注入

依赖注入是一种帮助我们实现依赖倒置的技术。顾名思义,您将依赖项注入到消费模块中,通常通过其构造函数作为参数,并将其分配给私有字段,以便在消费类中使用。正如您将记得的那样,将依赖项作为参数注入构造函数方法有助于我们遵守显式依赖项原则。

下面的代码清单显示了 CreateModel,它经过更改后包含一个将 ICityService 作为参数的显式构造函数。它会将其分配给私有字段,以便在需要时可以在类中引用它。

清单 7.4 通过 CreateModel 的构造函数注入 ICityService 依赖项

public class CreateModel : PageModel
{
    private readonly ICityService _cityService;    ❶
    public CreateModel(ICityService cityService)   ❷
    {
        _cityService = cityService;                ❸
    }
    ...
}

❶ 向类添加私有字段以存储依赖项。
❷ 通过构造函数注入依赖项。
❸ 将注入的依赖项分配给 private 字段。

现在,您的依赖项是显式的。CreateModel 类所需的协作对象(实现 ICityService 接口的任何类型的)通过它在类的构造函数中的存在来向外界标识。

7.2 控制反转容器

当然,在 C# 中,您无法实例化接口,那么此代码怎么有意义呢?传递给 constructor 参数的实际类型可以是实现指定接口的任何类型。在运行时,将向构造函数提供接口的实现。内置的依赖项注入容器提供了实现。在 Microsoft 文档中,这通常称为服务容器,但更广泛地说,您可能还会看到这种类型的组件称为 IoC 容器或 DI 容器。

所以这就只剩下一个问题:容器如何知道要提供哪个实现?答案是,您可以通过在容器中注册您的服务来告诉它。

7.2.1 服务注册

通过将注册添加到 WebApplicationBuilder 的 Services 属性,在 Program 类中进行服务注册。您可能还记得在第 2 章中讨论使用 IMiddleware 接口构建中间件时看到过这一点,尽管我当时没有详细介绍。标准 Web 应用程序模板已包含通过 AddRazorPages 方法注册 Razor Pages,该方法负责注册 Razor Pages 所依赖的所有服务,包括负责生成和匹配路由、处理程序方法选择和页面执行的服务,以及 Razor 视图引擎本身。

Services 属性是 IServiceCollection,它是框架的服务容器。它包含一组 ServiceDescriptor 对象,每个对象都表示一个已注册的服务。基本的注册服务由服务类型、实现和服务生命周期组成。下面的清单显示了如何将 ICityService 注册为新的 ServiceDescriptor。

清单 7.5 在服务容器中注册 ICityService

builder.Services.AddRazorPages();
builder.Services.Add(new ServiceDescriptor(typeof(ICityService), typeof(SimpleCityService), ServiceLifetime.Transient));

但是,您更有可能使用 IServiceCollection 上提供的特定于生命周期的扩展方法之一,该方法将服务类型和实现作为泛型参数(图 7.4)。

builder.Services.AddTransient<ICityService, SimpleCityService>();

图 7.4 注册会导致将 ServiceDescriptor 对象添加到 IServiceCollection 中,该对象由服务类型、实现和生存期组成。

服务容器的工作是在请求服务类型时(例如,注入到构造函数中)提供正确的实现。此过程也称为 解决依赖关系。因此,当容器看到对 ICityService 的请求时,它将提供 SimpleCityService 的实例(图 7.5)。

图 7.5 当容器看到服务请求时,它会提供 implementation

7.2.2 服务生命周期

服务容器不仅负责解析 implementation。它还负责管理服务的生命周期。也就是说,容器负责创建服务并销毁服务,这取决于服务注册到的生命周期。服务可以注册为具有以下三个生命周期之一:

• Singleton
• Transient
• Scoped

对于每个生命周期,都有一个扩展方法,该方法以单词 Add 开头,后跟生命周期的名称。例如,您已使用 AddTransient 方法注册具有瞬态生存期的 ICityService。

单例服务

使用 AddSingleton 方法注册的服务在首次请求服务时实例化为单一实例,并在容器的生存期内保留,这通常与正在运行的应用程序相同。顾名思义,一个单例只能存在一个实例。它被重新用于所有请求。绝大多数框架服务(模型绑定、路由、日志记录等)都注册为单一实例。它们都具有相同的特征,因为它们没有任何状态并且是线程安全的,这意味着同一个实例可以跨多个线程使用;处理并发请求可能需要这样做。相同的特征也必须适用于单一实例服务所依赖的与服务一起实例化的任何依赖项。

我将简要地偏离我们当前应用程序的主要方向,通过一个简单的演示来探讨它是如何工作的。您将创建一个服务,该服务公开在其构造函数中设置的值,然后将该服务注册为单一实例。您将对该值使用 GUID,因为几乎可以肯定,每次生成该值时,GUID 都会有所不同。然后,您将向浏览器呈现该值。您将注意到,当您刷新页面时,该值不会更改。使用以下代码将新的 C# 类添加到名为 LifetimeDemoService 的 Services 文件夹中。

清单 7.6 LifetimeDemoService 类

using System;
namespace CityBreaks.Services
{
    public class LifetimeDemoService
    {
        public LifetimeDemoService()
        {
            Value = Guid.NewGuid();
        }
        public Guid Value { get; }  
    }
}

每当调用类构造函数时(即容器实例化服务时),都会设置 public Value 属性。您将此服务注册为单一实例,这应确保它在应用程序的生命周期中只实例化一次:

builder.Services.AddRazorPages();
builder.Services.AddTransient<ICityService, SimpleCityService>();
builder.Services.AddSingleton<LifetimeDemoService>();

该服务注册到 AddSingleton 方法的一个版本,该方法采用表示实现的单个泛型参数。此示例没有抽象。这不是必需的,因为这是一个简单的演示,抽象会不必要地分散对后面示例的要点的注意力。在 Pages 文件夹中创建一个名为 LifetimeDemo.cshtml 的新 Razor 页面,并在 PageModel 类文件中使用以下代码。

列表 7.7 用于演示服务生命周期如何工作的 LifetimeDemoModel 代码

using CityBreaks.Services;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace CityBreaks.Pages
{
    public class LifetimeDemoModel : PageModel
    {
        private readonly LifetimeDemoService _lifetimeDemoService;
        public LifetimeDemoModel(LifetimeDemoService lifetimeDemoService)
        {
            _lifetimeDemoService = lifetimeDemoService; 
        }

        public Guid Value { get; set; }
        public void OnGet()
        {
            Value = _lifetimeDemoService.Value;
        }
    }
}

通过添加以下清单中突出显示的行来更改 Razor 页面本身中的代码。

列表 7.8 LifetimeDemo Razor 页面

@page
@model CityBreaks.Pages.LifetimeDemoModel
@{
    ViewData["Title"] = "Lifetime demo";
}
<h2>Service Lifetime Demo</h2>
<p>The Singleton service returned @Model.Value</p>

运行应用程序,导航到 /lifetime-demo (记住 KebabPageRouteParameterTransformer 的效果),并记下呈现给浏览器的值。刷新页面,并确认值保持不变。使用其他浏览器请求该页面。该值不会更改。这是因为该值是在首次实例化服务时设置的,并且作为单一实例,服务的所有使用者在所有请求中共享相同的服务实例。

瞬态服务

使用 AddTransient 方法注册的服务被赋予瞬态生命周期,这意味着每次解析它们时都会创建它们。这些类型的服务应该是轻量级的无状态服务,其实例化成本相对较低。当 service scope 被销毁时,它们将被销毁。在 ASP.NET Core 应用程序的上下文中,范围在 HTTP 请求结束时销毁。如果您有一个复杂的依赖关系图,其中相同的服务类型被注入到多个构造函数中,则每个使用者都将收到自己的服务实例。SimpleCityService 是瞬态生存期的良好候选者,因为它满足不维护状态且实例化成本低的服务的定义。

要查看其工作情况,您需要将服务的第二个实例注入 PageModel,并将其值呈现给浏览器。对 LifetimeDemoModel 类进行以下更改。

清单 7.9 向 PageModel 注入第二个服务

private readonly LifetimeDemoService _lifetimeDemoService;
private readonly LifetimeDemoService _secondService;                 ❶
public LifetimeDemoModel(LifetimeDemoService lifetimeDemoService, 
    LifetimeDemoService secondService)                               ❷
{
    _lifetimeDemoService = lifetimeDemoService; 
    _secondService = secondService;                                  ❸
}

public Guid Value { get; set; }
public Guid SecondValue { get; set; }                                ❹
public void OnGet()
{
    Value = _lifetimeDemoService.Value;
    SecondValue = _secondService.Value;                              ❺
}

❶ 为第二个服务添加私有字段。
❷ 注入 LifetimeDemoService 的第二个实例。
❸ 将其分配给 private 字段。
❹ 向 PageModel 添加另一个公共属性。
❺ 将其 value 设置为第二个服务的 Value。

接下来,更改 Razor 页面中呈现服务值的代码,如下所示。

列表 7.10 渲染来自两个服务的值

<p>The first transient service returned @Model.Value</p>
<p>The second transient service returned @Model.SecondValue</p>

最后,更改 Program.cs 中的注册以使用瞬态生存期:

builder.Services.AddTransient<LifetimeDemoService>();

运行应用程序,导航到 /lifetime-demo,并注意呈现给浏览器的值不同。每次刷新页面时,它们都会更改,从而确认每次请求时都会实例化每个服务。

范围服务

最后一个生命周期选项是 Scoped 生命周期。如前所述,在 ASP.NET Core Web 应用程序中,范围是 HTTP 请求,这意味着每个 HTTP 请求都会创建一次范围服务。实际发生的情况是,为每个 HTTP 请求创建一个容器实例,并在请求结束时销毁。作用域服务由此作用域容器解析,因此在作用域结束时销毁容器时,这些服务将被销毁。作用域服务与瞬态服务的不同之处在于,每个作用域只解析作用域内的服务的一个实例,而瞬态服务的多个实例可以实例化。

实例化后,作用域服务将在范围 (请求) 期间根据需要多次重复使用,并在请求结束时释放。每个请求都有自己的范围容器,因此对同一资源的并发请求将使用不同的容器。

要了解其工作原理,您需要做的就是将 LifetimeDemoService 的注册更改为使用 AddScoped 方法:

builder.Services.AddScoped<LifetimeDemoService>();

然后更改 Razor 页面以引用范围化的服务值:

<p>The first scoped service returned @Model.Value</p>
<p>The second scoped service returned @Model.SecondValue</p>

现在,当您运行应用程序时,您应该会看到两个服务生成相同的值。一旦 LifetimeDemoService 被实例化,它就会在 HTTP 请求范围内需要的任何地方被重用。在这方面,它类似于单一实例,其范围限定为请求,而不是应用程序的生命周期。

作用域生命周期最适合于实例化成本高昂和/或需要在请求期间保持状态的服务。在 Razor Pages 应用程序中,受益于作用域生存期的最常用服务之一是实体框架 DbContext,我们将在下一章中更详细地介绍它。DbContext 满足这两个条件,因为它创建与外部资源(数据库)的连接,并且它可能需要维护有关从数据库中检索的数据的信息。

7.2.3 捕获依赖项

在为服务选择生命周期时,您还应考虑服务所依赖的任何依赖项的生命周期。例如,如果依赖项是已注册到作用域内生存期的 DbContext,则应确保您的服务也注册到作用域内生存期。否则,您最终可能会遇到称为捕获依赖项的问题。当依赖项注册的生命周期短于其使用者时,会出现此问题。如果尝试从单一实例中使用作用域内服务,则 DI 容器将引发 InvalidOperationException,但不会收到此类保护,以防止从单一实例中使用暂时性服务。如果将临时服务注入另一个服务,然后注册为单一实例,则依赖项实际上也会成为单一实例,因为在应用程序的生命周期内,使用服务的构造函数只会被调用一次,并且只会在应用程序停止时销毁。

让我们来看看这个,这样你就可以清楚地理解它。将以下代码作为新的 C# 类添加到 Services 文件夹中。

清单 7.11 SingletonService 类

namespace CityBreaks.Services
{
    public class SingletonService
    {
        private readonly LifetimeDemoService _dependency;
        public SingletonService(LifetimeDemoService dependency)
        {
           _dependency = dependency;
        }
        public Guid DependencyValue => _dependency.Value;
    }
}

代码很简单;SingletonService 类将您现有的 LifetimeDemoService 作为依赖项,并使用它来生成值。现在你需要将 SingletonService 注册为单一实例,同时让 LifetimeDemoService 注册为瞬态生命周期:

builder.Services.AddTransient<LifetimeDemoService>();
builder.Services.AddSingleton<SingletonService>();

更改 Razor 页面中的标记以输出以下内容:

<p>The singleton service's transient dependency returned @Model.Value</p>

运行页面并刷新它。请注意,该值永远不会更改。你不会在每个请求上获得 LifetimeDemoService 的新实例,因为使用者的构造函数没有被调用,因为它是一个单一实例。

7.2.4 其他服务注册选项

上述示例使用了 Add[LIFETIME] 方法之一,该方法采用两个泛型参数 — 第一个参数表示服务类型,第二个参数表示实现。这是您可能最常使用的模式。我们还查看了采用实现的 Add[LIFETIME] 方法的版本。在这里,我们将回顾一些提供额外功能的其他注册选项。

想象一下你的 SimpleCityService 需要传递给它一些构造函数参数。您可以通过传入定义要传递的参数的工厂来做到这一点:

builder.Services.AddTransient<ICityService>(provider => new 
➥ SimpleCityService(args));

如果构造函数参数包含来自容器的依赖项,则工厂将提供对该服务的访问,因此您可以解析依赖项。下面的示例演示 SimpleCityService 依赖于 IMyService 和 args 的实现时,其工作原理。使用 IServiceProvider GetService 方法解析依赖项。在本章末尾,我们将介绍直接从服务提供商访问服务的其他方法:

builder.Services.AddTransient(provider => 
    new SimpleCityService(args, provider.GetService<IMyService>())
);

factory 选项是首选选项,因为它将更新或激活服务的责任交给了服务容器。如果容器负责服务激活,它还负责服务处置。还有一种适用于单例服务的替代方法,该方法涉及传入构造的服务:

builder.Services.AddSingleton<IMyService>(new MyService(args));

当您使用此方法注册服务时,您也必须对其处置负责。如果使用 implementation-only 选项传入构造的服务,则情况也是如此:

builder.Services.AddSingleton(new MyService(args));

7.2.5 注册多个 implementation

可以通过重复具有相同服务类型但不同实现的相关 Add[LIFETIME] 方法来注册服务的多个实现:

builder.Services.AddTransient<ICityService, SimpleCityService>();
builder.Services.AddTransient<ICityService, CityService>();

这就提出了一个明显的问题:当它的抽象被注入到构造函数中时,哪一个会得到解决?该问题的答案是您注册的最后一个。所以另一个问题出现了:注入多个 implementation的能力有什么用?

假设您有多个不同的服务实现,但您依赖运行时数据来确定使用哪个实现。例如,您可能希望根据访客的位置计算价格、税费和折扣。你可以为你服务的每个位置填充一个服务的条件代码,但你可以想象这种方法很快就会变得非常混乱,尤其是在计算很复杂的情况下。例如,如果您需要更新代码以反映一个地区的法律变化,您也可以想象维护问题。这有可能无意中更改其他位置的代码,并引入与您需要进行的更改无关的 bug。

相反,您可以为每个位置提供单独的实施。请考虑以下简单接口:IPriceService。

示例 7.12 IPriceService 接口

public interface IPriceService
{
    string GetLocation();
    double CalculatePrice();
}

此接口定义了两个方法:一个返回适用于任何特定实现的位置,另一个表示计算价格的逻辑。假设此服务定义的每个实现都返回您已经知道的 ISO 3166-1 Alpha-2 代码,但默认价格服务除外,它返回“XX”。美国版本如清单 7.13 所示。其他选项可在本节随附的下载 (http://mng.bz/o54p) 中找到。

清单 7.13 美国 IPriceService 的示例实现

public class UsPriceService : IPriceService
{
    public string GetLocation() => "us";
    public double CalculatePrice()
    {
        ...
    }
}

您可以向服务容器注册各种实现:

builder.Services.AddScoped<IPriceService, FrPriceService>();
builder.Services.AddScoped<IPriceService, GbPriceService>();
builder.Services.AddScoped<IPriceService, UsPriceService>();
builder.Services.AddScoped<IPriceService, DefaultPriceService>();

如果要将 IPriceService 注入 PageModel 构造函数,则始终会获得 DefaultPriceService,如上所示,因为它是最后一个注册的。但是,您也可以注入 IEnumerable,它将解析为所有已注册实现的集合。然后,只需选择适用于当前请求的实现即可。

我是 Cloudflare (https://www.cloudflare.com/) 的粉丝,它提供一系列与 Web 相关的服务,包括地理定位(可以使用其他地理定位服务提供商),从而根据请求的 IP 地址识别请求的位置。该位置在请求标头中作为 ISO-3166-1 Alpha-2 代码或“XX”(无法解析该位置)提供给应用程序代码。下面的清单显示了如何使用此标头值根据当前请求解析要调用的正确服务的示例。

清单 7.14 从多个已注册的服务中解析一个

public class CityModel : PageModel
{
    private readonly IEnumerable<IPriceService> _priceServices;
    public CityModel(IEnumerable<IPriceService> priceServices)     ❶
    {
        _priceServices = priceServices;
    }

    public void OnGet()
    {
        var locationCode = Request.Headers["CF-IPCountry"];        ❷
        var priceService = _priceServices.FirstOrDefault(s=> s.GetLocation()  
        ➥ == locationCode);                                       ❸
        // do something with priceService
    }
}

❶ 注入一个表示所有已注册实现的集合。
❷ 获取用于定义适用于此请求的实现的运行时数据。
❸ 查询与传入 FirstOrDefault 方法的谓词匹配的服务的集合。

采用此模式有两个明显的好处。首先,每个 IPriceService 实现都是特定于位置的,这减少了它们所需的代码量,从而简化了维护体验。第二个是,如果您想迎合更多位置,您只需创建一个新服务并将其与其他服务一起注册。它将自动解析为注入的集合的一部分。

还有另一种注册服务的方法,在注册了多个 implementations 的情况下,将导致第一个 registration 得到解决,而不是最后一个 implementation。即使用 TryAdd<LIFETIME> 方法。如果使用 TryAddScoped 重复注册 IPriceService 实现(如下面的清单所示),则将解析第一个实现,除非注入 IEnumerable。

列表 7.15 TryAdd<LIFETIME>导致第一个实现被解析

builder.Services.TryAddScoped<IPriceService, FrPriceService>();    ❶
builder.Services.TryAddScoped<IPriceService, GbPriceService>();
builder.Services.TryAddScoped<IPriceService, UsPriceService>();
builder.Services.TryAddScoped<IPriceService, DefaultPriceService>();

❶ 解析首先注册的 implementation。

那么,何时使用 TryAdd 方法注册服务呢?通常,如果要确保默认情况下不使用意外进行的其他注册,则可以使用此方法。如果不清楚正在进行哪些注册,则可能会发生这种情况,因为它们隐藏在扩展方法(例如 AddRazorPages 方法)中。库作者可能希望确保他们的注册被使用,而不管框架的使用者随后尝试做什么。

7.3 访问已注册服务的其他方式

构造函数注入可能是使用已注册服务的最常见方式。但是,您应该注意其他访问服务的方法。您可能会在某个阶段使用其中一些选项,但它们有其注意事项。这些选项包括直接注入 Razor 文件、方法注入和直接从服务容器检索服务。

7.3.1 视图注入

框架提供的一些服务旨在帮助生成 HTML。一个示例是 IHtmlLocalizer 服务,该服务用于在需要处理多种语言的 Web 应用程序中本地化 HTML 代码段。它在 Razor 页面或视图之外没有任何用途。可以将此服务注入到需要它的页面的 PageModel 中,然后将其分配给公共属性,以便可以通过 Razor 页面本身中的 Model 访问它。但更好的解决方案是简单地使用 @inject 指令将服务直接注入页面。

清单 7.16 使用 @inject 指令将服务注入 Razor 页面

@page
@inject IHtmlLocalizer<IndexModel> htmlLocalizer  ❶
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="text-center">
    <h1>Welcome</h1>
    <p>@htmlLocalizer["Intro"]</p>                ❷
</div>

IHtmlLocalizer<T> 服务使用 @inject 指令注入,并分配给变量 htmlLocalizer。
❷ 本地化工具服务用于本地化标识为“Intro”的 HTML 片段。

我应该强调的是,当您仅将此方法用于基于 HTML 的服务时,此方法很好。您不应将任何包含业务逻辑的服务直接注入到页面中。我们的业务逻辑远离 HTML,不是吗?

7.3.2 方法注入

开箱即用,默认服务容器仅支持构造函数注入。但是,ASP.NET Core 在几个地方添加了方法参数注入。您已经在第 2 章中看到过一个例子,当时我们研究了如何创建传统的中间件。如果您还记得,您已将 ILogger<T> 注入到 InvokeAsync 方法中:

public async Task InvokeAsync(HttpContext context, 
➥ ILogger<IpAddressMiddleware> logger)

但是处理程序方法呢?毕竟,处理程序方法参数被模型 Binder 视为绑定目标。当模型绑定器遇到 IPriceService 参数时会发生什么情况?您的应用程序中断。除非在 service 参数前面加上 FromServices 属性,否则就会发生这种情况:

public async Task OnGetAsync([FromServices]IPriceService service)
{
    // do something with service
}

对于创建成本高昂但在 Razor 页面中仅使用一小部分时间的服务,这是一种有用的模式。例如,页面中有一个命名处理程序,该处理程序需要 OnGet 和 OnPost 处理程序不需要的服务,并且仅在某些情况下调用命名处理程序。在这种情况下,将服务注入 PageModel 构造函数几乎没有意义。FromServices 属性允许您将服务范围限定为需要它的处理程序方法,并且仅在需要时解析它。

7.3.3 使用 GetService 和 GetRequiredService 直接从服务容器

有时,您需要直接访问服务的服务容器。这种方法称为 Service Locator 模式。这听起来像是一件好事,作为一种设计模式等等,但它通常被认为是一种反模式,应该避免。但是,有时您别无选择。在本章前面,当您使用工厂注册一个服务时,您已经看到了这样一个例子,该服务将另一个服务作为依赖项。

定义反模式是针对反复出现的问题(模式)的常用解决方案,通常在某种程度上是次优的。这可能是因为该解决方案引入了新问题,或者因为它只是将问题转移到了其他位置。

IServiceProvider 服务提供对已注册服务的访问。它有一个方法 GetService,该方法返回指定的服务,如果未找到,则返回 null。此外,还有一个扩展方法 GetRequiredService,如果未找到指定的服务,则会引发异常。将 IServiceProvider 注入到使用者中,然后使用者使用它来检索所需的服务。

清单 7.17 服务定位器模式的示例用法

public class IndexModel : PageModel
{
    private readonly IServiceProvider _serviceProvider;           ❶

    public IndexModel(IServiceProvider serviceProvider) =>] 
    ➥  _serviceProvider = serviceProvider;                       ❶

    public List<City> Cities { get; set; }                        ❶
    public async Task OnGetAsync()
    {
        var cityService = 
        ➥ _serviceProvider.GetRequiredService<ICityService>();   ❶
        Cities = await cityService.GetAllAsync();
    }
}

❶ 将 IServiceProvider 注入类构造函数。

回想一下我所说的显式依赖关系原则,您也许能够辨别为什么服务定位器是一种反模式。从清单 7.17 中的代码中不清楚 IndexModel 的依赖项是什么——除了服务提供商之外。事实上,它仍然依赖于 ICityService,但该详细信息不再对类外部的代码可见。

服务提供者也作为请求功能 (http://mng.bz/neE2) 提供,因此您甚至不需要将提供者注入可以访问 HttpContext 的类中。您可以将解析清单 7.17 中 city 服务的代码行替换为以下内容:

var cityService = 
➥ HttpContext.RequestServices.GetRequiredService<ICityService>();

依赖项注入和随之而来的其他术语听起来很复杂,但现实情况是,它是一种非常简单的技术,有助于实现高质量的代码。内置服务容器应该足以满足大多数使用案例,但如果您发现自己需要更高级的东西,您可以使用支持 ASP.NET Core 的众多(通常是免费和开源的)第三方容器之一。集成通常非常简单,供应商应完整记录。

在下一章中,我们将了解如何在 Razor Pages 应用程序中处理数据。在此过程中,我们将创建一个新服务,从数据库中获取数据,并将现有服务无缝地替换为新服务,这展示了 using DI 的主要优势之一。

总结

依赖项注入 (DI) 是 ASP.NET Core 中的一项关键功能。
DI 可帮助您实现控制反转,这是一种促进代码松散耦合的技术。
服务被注入到依赖于它们的类中,这些类作为显式依赖项。
依赖关系反转原则 (DIP) 指出,高级类和低级类应依赖于抽象,例如接口。
您可以通过 WebApplication Services 属性在 Program.cs 中配置服务,该属性表示为应用程序配置的服务。
服务注册为服务容器的类型和实现。
服务使用以下三个生存期之一进行注册:单一实例、瞬态或作用域。
只能存在一个单一实例的实例。它在容器的生命周期内持续。
每次请求临时服务时,都会对其进行解析。
分区服务在 ASP.NET Core 中 Web 请求的持续时间内持续。
可以注册同一服务的多个实现。最后一个注册的问题将被解析。
您可以通过注入和 IEnumerable <ServiceType> 访问所有已注册的实现。
您可以通过在 service 参数前面加上 [FromServices] 来注入到页面处理程序方法中。
您可以通过 @inject 属性直接注入 Razor 页面。

ASP.NET Core Razor Pages in Action 6 使用表单:标记辅助函数

ASP.NET Core Razor Pages in Action 6 使用表单:标记辅助函数

本章涵盖

• 使用标记帮助程序构建表单
• 使用数据注释控制输入类型
• 使用服务器端代码填充选择列表
• 使用表单中的复选框和单选按钮
• 将文件上传到服务器

上一章介绍了模型绑定如何采用输入并将其绑定到 Razor Pages 中的处理程序参数和公共属性。您了解了确保成功绑定的关键是确保绑定源的名称与绑定目标的名称匹配。到目前为止,您已经手动生成了表单控件的名称。这种可能容易出错的方法除了在 Request.Form 集合中寻找已发布的值之外,不会让您感到困惑。

在 Razor Pages 应用程序中,表单是标记帮助程序大放异彩的地方。您已经通过 validation 标签帮助程序看到了它们在客户端和服务器端验证中的角色。本章将探讨如何生成表单以从用户那里收集数据并使用模型绑定系统。您将了解如何使用它们来确保控件名称与绑定目标名称顺利工作。以下标记帮助程序可用于表单构建:

• Form
• Form action
• Label
• Input
• Select
• Option
• Textarea

每个标记帮助程序都以它命名的 HTML 元素为目标,但表单作标记帮助程序除外,该标记帮助程序以 type 属性设置为 submit 或 image 的按钮和 input 元素为目标。

本章首先介绍 form 标签帮助程序,以帮助您了解其角色并对其进行配置。我们将广泛研究 input tag helper 并学习如何根据绑定目标数据类型控制它呈现的输入类型,以及如何在需要时使用数据注释属性来微调内容。

在技术社区支持站点和论坛上,选择列表、复选框和单选按钮似乎会引发大多数与表单相关的问题,因此我们将详细介绍如何使用它们的标记帮助程序。最后,我们将研究使用表单上传文件。

6.1 表单和表单作标签辅助函数
表单标记帮助程序以 HTML 表单元素为目标,具有两个主要职责。它确保请求验证令牌包含在其方法设置为 post 的每个表单的隐藏字段中。它还会根据您提供给表单自定义属性的值生成表单的 action 属性的 URL。

表单作标记帮助程序以按钮和输入为目标,其 type 属性设置为 submit 或 image。它的作用是设置这些元素上的 formaction 属性的 URL,该 URL 指定表单应提交到的位置。因此,它会覆盖表单的 action 属性。

接下来,当我在文本中引用标记帮助程序的自定义属性时,我将省略 asp- 前缀。请记住,所有框架属性都以 asp- 为前缀。表单标签帮助程序和表单作标签帮助程序上可用的生成 URL 的自定义属性与我在第 4 章中详细介绍的锚点标签帮助程序上的自定义属性相同,并且它们以相同的方式根据应用程序的路由配置构造值:

• page
• page-handler
• route-*
• all-route-data
• host
• protocol
• area

如果在表单元素中包含 action 属性以及这些自定义路由属性中的任何一个,则框架将引发错误。表单标签帮助程序使用传递给这些自定义属性的值来生成 action 属性,类似于锚点标签帮助程序从相同的自定义路由属性生成 href 属性的方式。您可以自己通过 action 属性显式设置 URL,也可以使用标签帮助程序属性来配置 URL。但你不能两者兼得。如果您不包含 action 属性或任何自定义路由属性,则表单将提交到当前页面的 URL。对于绝大多数 CRUD 情况,这很可能是您要执行的作,您将在生成表单的同一页面的 OnPost 处理程序中处理提交。当处理页面是不同的页面或在命名处理程序中处理表单时,您通常会使用 tag helper 属性来设置表单的作。例如,当我们在第 3 章中介绍命名处理程序时,您已经看到了您可能希望设置页面处理程序名称的位置:

<form method="post" asp-page-handler="Search">

表单标记帮助程序支持一个额外的自定义属性:防伪。这需要一个 Boolean 值来控制是否呈现请求验证令牌,因此它为您提供了一种有选择地逐个表单禁用请求验证的方法。如果省略该属性,则该值默认为 true。回想一下我们在第 3 章中介绍的命名处理程序的例子,清单 3.32 中的搜索表单可以编写为使用表单作标签帮助程序,而不是通过表单标签帮助程序设置提交 URL。下面的清单演示了如何使用 page-handler 属性在渲染的按钮中生成 formaction 属性。

清单 6.1 表单动作标签助手

<form method="post">    
    <p>Search</p>
    <input name="searchTerm" />    
    <button asp-page-handler="Search">Search</button>   ❶
</form>

❶ page-handler 属性被应用于按钮,导致 formaction 属性被渲染到浏览器。

6.2 输入和标签标签帮助程序

在上一章中,您从将表单字段控件的名称映射到 Form 和 Query 集合中的键这一可能容易出错的做法,发展到绑定到由于应用了 BindProperty 属性而被显式标记为绑定目标的公共属性。但您仍然有出错的余地,因为控件的 name 属性的值必须与目标 PageModel 属性的 name 匹配。回想一下城市的 Create 页面中表单的 HTML,在该页面中,您将字符串 “cityName” 分配给 name 属性:

<input class="form-control" type="text" name="cityName" />

如前所述,您不能依赖字符串匹配。对其中一个或另一个的更改只会导致运行时出错。由于属性可在 Razor 页面中访问,因此可以直接在 Razor 代码中使用 nameof 表达式,以确保为表单控件的 name 属性生成的值与属性名称匹配:

<input name="@nameof(Model.CityName)" type="text" />

但是,使用 input tag helper 可以以更少的仪式实现类似的效果,并提供更多的收益。它有一个 for 属性,该属性采用 PageModel 属性的名称,并有效地将该属性绑定到标记帮助程序,从而在呈现的 HTML 中为表单控件生成 name 和 id 属性。input 标记帮助程序还将呈现 type 属性,根据 bound 属性的数据类型将其值设置为合适的值,因此您也不需要将其包含在标记中。标记帮助程序还呈现一个 value 属性,该属性被设置为分配给该属性的值。以下简单代码是生成完全连接 input 元素所需的全部代码:

<input asp-for="CityName" />

上面一行的渲染输出是

<input type="text" id="CityName" name="CityName" value="" />

通过这种方式,你可以在表单控件和公共 PageModel 属性之间,或者绑定源和它的目标之间实现一种双向绑定(图 6.1)。

图 6.1 使用标签帮助程序实现绑定源与其目标之间的双向绑定。

Create City 页面中的现有 label 元素可以替换为 tag 帮助程序。当前 HTML for 属性需要以 asp- 为前缀才能激活标记帮助程序。就像 input 标签帮助程序一样,传递给 for 属性的值应该是 model 属性的名称:

<label asp-for="CityName"></label>

在此示例中,标签标签帮助程序为空。它没有内容。当像这样使用时,属性名称将在标签中呈现:

<label for="CityName">CityName</label>

你可以通过将文本添加到标签的内容或使用 data annotation 属性来覆盖它,我们稍后将介绍该属性。

因此,总而言之,在表单中使用标记帮助程序的好处是,您可以在服务器代码中的属性和呈现给浏览器的表单控件之间建立双向绑定关系。如果在表单中更新了该值,则在提交时,该更新将自动应用于服务器。同样,如果更改服务器上的值,则呈现的表单控件将自动反映这些更改。

6.2.1 了解输入类型

现在,您已经了解了在表单中使用标记帮助程序的好处,是时候考虑确保呈现正确类型的输入的方法了。首先,我们将了解 .NET 和 HTML 类型之间的默认映射。HTML 5 在现有选项(包括 checkbox、radio、file 等)的基础上,添加了对不同类型数据(数字、日期时间-本地时间、周和范围)的新输入类型集合的支持。某些 HTML 5 输入类型在浏览器上仅享有有限的支持,但在可以的情况下,您应该在使用提供类似功能的第三方库(例如,日期选择器)之前使用它们。

通常,您可以依靠 Razor Pages 仅根据绑定到标记帮助程序的属性数据类型生成正确的类型。表 6.1 提供了有关输入标记帮助程序为指定 .NET 数据类型的 HTML type 属性生成哪个值的详细信息。

表 6.1 .NET 数据类型与输入标记帮助程序生成的 type 属性之间的映射

.NET type

HTML input type

string

text

bool

checkbox

int, byte, short, long

number

decimal, double, float

text

DateTime

datetime-local

IFormFile

file

要对此进行测试,请将名为 PropertyManager 的新文件夹添加到 Pages 文件夹。这将包含用于管理网站上可用的出租物业详细信息的页面。然后添加名为 Create.cshtml 的新 Razor 页面。将以下公共属性添加到 PageModel 中,表示有关允许用户使用一系列 .NET 类型的租赁属性的信息。

清单 6.2 Property Manager 的 CreateModel

public class CreateModel : PageModel
{
    [BindProperty]
    public string Name { get; set; }
    [BindProperty]
    public int MaxNumberOfGuests { get; set; }
    [BindProperty]
    public decimal DayRate { get; set; }
    [BindProperty]
    public bool SmokingPermitted { get; set; }
    [BindProperty]
    public DateTime AvailableFrom { get; set; }
}

接下来,将 Create.cshtml 文件更改为包含表单,对每个 PageModel 属性使用标记帮助程序,使代码如下所示。

清单 6.3 Create Property 页面的表单,使用标签助手

@page
@model CityBreaks.Pages.PropertyManager.CreateModel
@{
    ViewData["Title"] = "Create Property";
}

<form method="post">
    <div class="mb-3">
        <label class="form-label" asp-for="Name"></label>
        <input class="form-control" asp-for="Name" />
    </div>
    <div class="mb-3">
        <label class="form-label" asp-for="MaxNumberOfGuests"></label>
        <input class="form-control" asp-for="MaxNumberOfGuests" />
    </div>
    <div class="mb-3">
        <label class="form-label" asp-for="DayRate"></label>
        <input class="form-control" asp-for="DayRate" />
    </div>
    <div class="mb-3">
        <label class="form-label" asp-for="AvailableFrom"></label>
        <input class="form-control" asp-for="AvailableFrom" />
    </div>
    <div class="mb-3">
        <label asp-for="SmokingPermitted"></label>
        <input asp-for="SmokingPermitted" />
    </div>
    <div class="mb-3">
        <button class="btn btn-primary">Submit</button>
    </div>
</form>

除了用于表示的 Bootstrap 类之外,该表单还由一系列标签和输入标记帮助程序组成,这些标记表示 PageModel 中的每个属性。当您导航到 /property-manager/create 时,您应该会看到类似于图 6.2 的内容。

图 6.2 “Create Property”(创建属性)页面的呈现表单

label 标记帮助程序已呈现属性的名称,并且此值也已分配给呈现的标签的 for 属性。其中一些标签值不是特别用户友好。我们稍后会看看你可以管理它的方法。

输入是根据关联属性的数据类型生成的,如表 6.1 中所述。请注意,即使您尚未设置值,也已为多个输入分配了值。关联的属性不可为空,因此它们被视为必需属性,并且已为其分配了默认值。现在让我们看看表单的渲染源代码。

清单 6.4 表单的渲染源

<form method="post">
    <div class="mb-3">
        <label class="form-label" for="Name">Name</label>
        <input class="form-control" type="text" id="Name" 
         ➥ name="Name" value="" />
    </div>
    <div class="mb-3">
        <label class="form-label"
         ➥ for="MaxNumberOfGuests">MaxNumberOfGuests</label>
        <input class="form-control" type="number" data-val="true" 
data-val-required="The MaxNumberOfGuests field is required." 
id="MaxNumberOfGuests" name="MaxNumberOfGuests" value="0" />
    </div>
    <div class="mb-3">
        <label class="form-label" for="DayRate">DayRate</label>
        <input class="form-control" type="text" data-val="true" 
data-val-number="The field DayRate must be a number." 
➥ data-val-required="The DayRate field is required." 
➥ id="DayRate" name="DayRate" value="0.00" />
    </div>
    <div class="mb-3">
        <label class="form-label" for="AvailableFrom">AvailableFrom</label>
        <input class="form-control" type="datetime-local" data-val="true" 
        ➥ data-val-required="The AvailableFrom field is required."          
        ➥ id="AvailableFrom"
 name="AvailableFrom" value="0001-01-01T00:00:00.000" />
    </div>
    <div class="mb-3">
        <label for="SmokingPermitted">SmokingPermitted</label>
        <input type="checkbox" data-val="true" 
        ➥ data-val-required="The SmokingPermitted field is required." 
        ➥ id="SmokingPermitted" name="SmokingPermitted" value="true" />
    </div>
    <div class="mb-3">
        <button class="btn btn-primary">Submit</button>
    </div>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ...F4" />
<input name="SmokingPermitted" type="hidden" value="false" />
</form>

所有不可为 null 的属性都包括您在上一章中遇到的其他客户端验证属性:data-val 和 data-val-required。每个属性的 data-val 属性都设置为 true。该 data-val-required 属性分配了一条错误消息,该消息已自定义为关联属性。

忽略我在第 13 章中介绍的请求验证令牌,另一个值得关注的点是已经为 SmokingPermitted 属性生成了两个输入。第一个是您希望看到的复选框,根据表 6.1 中描述的映射,为 Boolean 值呈现。第二个是包含在结束 form 标签之前的隐藏输入:

<input name="SmokingPermitted" type="hidden" value="false" />

通常,如果未选中复选框,则在提交表单时,不会向服务器传递关联属性的值。隐藏字段将确保表单提交中将包含名称-值对,无论是否选中该复选框。如果选中该复选框,则发布的值将为 true,false。否则,它将为 false。基于此,模型绑定器将能够推断出要分配给 PageModel 属性的正确值。此行为实际上是 MVC 的一个功能,其中选择要在控制器上执行的特定作可以归结为作方法采用的参数,这些参数是从已发布的值的集合中确定的。

如果您不希望呈现隐藏字段,解决方法是避免使用标记帮助程序来呈现布尔属性的复选框。在这些情况下,请改用纯 HTML。

6.2.2 使用数据注释属性控制表示

数据注释属性驻留在 System.ComponentModel.DataAnnotations 命名空间中,它提供了一种向类型添加额外信息或元数据的方法。.NET 中的各种组件框架可以使用这些附加信息来影响其行为。数据注释属性广泛用于 Razor Pages 中的表单,以影响验证(如您所见)和演示。本节介绍如何使用属性来影响作为表单的一部分生成的 HTML。在管理 UI 时,有两个属性特别值得关注:DataTypeAttribute 和 DisplayAttribute。

使用 DataTypeAttribute 影响呈现的输入类型

DataTypeAttribute 使您能够指定比 .NET 类型系统提供的更具体的类型的名称,并将该类型与属性相关联。输入标记帮助程序将使用您指定的元数据来覆盖为输入标记帮助程序的 type 属性生成的值,从而产生对指定类型唯一的 UI 行为。例如,.NET 中没有密码类型,但您可以通过数据注释属性指定字符串应被视为密码类型。框架的大部分内容都会忽略此元数据,但 input 标记帮助程序将通过将呈现的控件上的 type 属性设置为 password 来响应它。浏览器将呈现一个类型设置为密码的输入,作为一个表单控件,作为安全措施,该控件会隐藏默认情况下输入的字符。

DataType 属性有两个构造函数。一个采用 DataType 枚举值,另一个采用字符串。枚举值范围很广,但只有一个子集会影响 HTML 输入上生成的类型。表 6.2 中详细介绍了它们。

表 6.2 DataType 枚举和输入类型之间的映射

DataType enumeration

Input element’s type attribute

Date

date

EmailAddress

email

Password

password

PhoneNumber

tel

Time

time

Url

url

当您将多个属性应用于一个属性(例如,BindProperty 属性和 DataType)时,您可以在它们自己的一组方括号中单独应用它们,也可以使用一组括号并用逗号分隔每个属性。清单 6.5 中的两个示例都是有效的。

清单 6.5 将多个 attribute 应用于单个属性的不同选项

[BindProperty]
[DataType(DataType.Time)]
public  DateTime ArrivalTime { get; set; }

[BindProperty, DataType(DataType.Time)]
public  DateTime DepartureTime { get; set; }

大多数主流浏览器都支持 week 和 month 输入,使用户能够指定一年中的一周或一个月。这些输入类型没有匹配的 DataType 枚举值。相反,你可以使用 DataType 属性的构造函数,该构造函数接受一个字符串,将 input 类型设置为这些选项以及表 6.2 中未涵盖的任何其他选项:

[DataType("week")]
public  DateTime ArrivalWeek { get; set; }

除了 DataType 枚举之外,还有名称类似的独立属性:

• EmailAddress
• Phone
• Url

虽然这些也会影响分配给 rendered type 属性的值,但它们继承自 ValidationAttribute 并参与验证,如上一章所示。System.ComponentModel.DataAnnotations 并不是可以找到这些类型的属性的唯一位置。HiddenInput 是属于 MVC 框架的另一个独立属性;可以在 Microsoft.AspNetCore.Mvc 命名空间下找到它。当应用于 PageModel 属性时,关联的输入将呈现,并将其 type 属性设置为 hidden。

 6.2.3 格式化渲染的日期或时间值

在查看刚刚为添加新属性而创建的表单时,您可能已经注意到,为 DateTime PageModel 属性生成的 HTML 5 日期时间本地输入类型支持将值设置为毫秒(图 6.3)。但是,向用户公开此功能几乎没有意义。

图 6.3 日期时间值的默认绑定会激活在日期时间本地控件中设置毫秒的功能。

默认情况下,如果将值分配给绑定的 DateTime 属性,则其毫秒部分将公开给浏览器控件,从而激活设置这部分值的功能。您可以通过将格式字符串应用于 DateTime 值来控制这一点。使用 DisplayFormat 属性可以将格式字符串应用于属性。该格式适用于使用该属性的任何地方(假设该位置是框架识别并应用数据注释提示的位置)。格式字符串是通过属性的 DataFormatString 属性指定的,另一个属性 ApplyFormatInEditMode 必须设置为 true,才能在可编辑设置(如表单输入)中应用格式字符串:

[BindProperty, DisplayFormat(DataFormatString = "{0:yyyy-MM-ddTHH:mm}",
ApplyFormatInEditMode = true)]
public DateTime AvailableFrom { get; set; } = DateTime.Now;

或者,您可以使用 input 标签帮助程序的 format 属性来控制各个 input 控件的格式:

<input asp-for="AvailableFrom" asp-format="{0:yyyy-MM-ddTHH:mm}" />

应用了这些示例中所示的格式字符串后,只有时间的小时和分钟元素可以在浏览器中设置(图 6.4)。

图 6.4 秒和毫秒部分的时间不再可设置。

 支持的日期和时间格式

基于日期和时间的输入类型主要支持根据 RFC 3339 (https://datatracker.ietf.org/doc/html/rfc3339) 中指定的规则设置格式的值。简而言之,它们要求将年份表示为四位数,并将日期和时间组件从最不具体(即年份)到最具体的顺序排序。时间部分应与日期之间用大写的 T 分隔。在 .NET 中,以下格式字符串表示这一点:

yyyy-MM-ddTHH:mm:ss.fff

秒 (ss) 和毫秒 (fff) 对于大多数浏览器来说是可选的,前提是你包含 time 元素。

周输入类型用于根据 ISO 8601 周编号选择一年中的一周。周输入支持的格式为 yyyy-Www,其中 -W 是文本,ww 表示一年中的一周。默认模型绑定器不包括对此格式的支持,因为 .NET 不支持将其作为有效的日期/时间格式字符串。如果需要使用 week 输入类型,则必须自己以正确的格式生成值。然后,您可以自己解析 Request.Form 中的值,也可以实现自定义模型 Binder。这是一个高级主题,本书不会涉及,但您可以参考我的博客文章,该文章提供了一个特定于使用周输入类型的示例:http://mng.bz/M5eW

 6.2.4 使用 DisplayAttribute 控制标签

前面,我注意到表单中呈现的许多标签值(图 6.1)缺乏用户友好性。标签标记帮助程序只是获取 C# 属性的名称,并按原样呈现它 - 包括 Pascal 大小写。如果你将 label 标签 helper 的内容留空,就会发生这种情况,就像我们在此示例表单中所做的那样。覆盖此行为的一种方法是在 label 标签的每个实例中提供您的内容:

<label asp-for="DayRate">Day Rate</label>

或者,您也可以将 DisplayAttribute 应用于属性,以通过将首选标签文本分配给其 Name 属性来控制输出:

[BindProperty]
[Display(Name="Day Rate")]
public decimal DayRate { get; set; } 

此方法的主要好处是可以集中配置,因此无需在 Razor 页面中四处寻找进行更改。下面的清单显示了应用了数据类型和显示属性的 Create Property 页的修订后的 PageModel 属性。图 6.5 说明了框架如何将属性值应用于呈现的标签。

清单 6.6 使用合适的属性装饰的公共 PageModel 属性

[BindProperty]
public string Name { get; set; }
[BindProperty]
[Display(Name = "Maximum Number Of Guests")]
public int MaxNumberOfGuests { get; set; }
[BindProperty]
[Display(Name ="Day Rate")]
public decimal DayRate { get; set; }
[BindProperty]
[Display(Name = "Smoking Permitted")]
public bool SmokingPermitted { get; set; }
[BindProperty] 
[DataType(DataType.Date)]
[Display(Name ="Available From")]
public DateTime AvailableFrom { get; set; }

图 6.5 数据注释属性用于管理标签和输入表示。

这样就完成了我们对基本 input 和 label 标签帮助程序的了解。在接下来的部分中,我们将重点介绍用于向用户提供一系列固定选项的 select 标签助手,然后继续讨论复选框和单选按钮输入类型的特殊性质。

6.3 select 标签助手

在我们的应用程序中,每个属性都必须位于特定城市,因此当用户添加新属性时,他们应该能够指定该属性所在的城市。您可以为此提供文本输入,但允许用户输入他们想要的任何内容的问题在于,您可能会遇到拼写错误和变化。例如,他们是输入 New York 还是 New York City?该解决方案提供了一组预先确定的选项,用户可以从中进行选择。从 UI 的角度来看,最经济和可用的方法是在 HTML select 元素中为可能会增长很多的列表提供选项。

select 标签帮助程序以 HTML select 元素为目标。它有两个自定义属性:现在熟悉的 for 属性(用于将表示所选值的 PageModel 属性绑定到 select 元素)和 items 属性(通过该属性分配选项)。选项是从 SelectListItem 对象、SelectList 对象或枚举的集合中填充的。选项也可以通过 option tag helper 单独设置。

6.3.1 创建选项

HTML option 元素表示 select 元素中的单个选项。至少,它由浏览器中显示的文本组成。为 select 标记帮助程序创建选项的最简单方法是将某种集合(如上一章中使用的城市数组)传递给 SelectList 的构造函数。要了解其工作原理,您将向属性创建表单中添加一个新元素,使用户能够指定属性所在的城市。调整 PropertyManager 文件夹中的 CreateModel 类以包括两个额外的高亮显示属性,确保 SelectedCity 属性使用 BindProperty 属性进行装饰。

列表 6.7 添加属性来表示选项和所选值

public class CreateModel : PageModel
{
    [BindProperty] 
    public string Name { get; set; }
    [BindProperty]
    public int MaxNumberOfGuests { get; set; }
    [BindProperty]
    public decimal DayRate { get; set; }
    [BindProperty]
    public bool SmokingPermitted { get; set; }
    [BindProperty]
    public DateTime AvailableFrom { get; set; }
    [BindProperty]                                          ❶
    [Display(Name="City")]                                  ❶
    public string SelectedCity { get; set; }                ❶
    public SelectList Cities { get; set; }                  ❷

    public void OnGet()                                     ❸
    {                                                       ❸
        var cities = new[] { "London", "Berlin", "Paris",   ❸
                             ➥ "Rome", "New York" };       ❸
        Cities = new SelectList(cities);                    ❸
    }                                                       ❸
}

❶ SelectedCity 属性表示所选值。

❷ Cities 属性表示用户可以从中进行选择的选项。

❸ 添加了一个 OnGet 处理程序,其中包含创建城市名称数组并传递给 SelectList 构造函数的代码。

您还应该添加一个 using 指令以包括 Microsoft.AspNetCore.Mvc。Rendering namespace,这是 SelectList 类型所在的位置。现在,对位于 PropertyManager 文件夹中的 Create.cshtml.cs 文件的标记部分进行下一个清单中详述的更改。更改将包括一个 select 标签帮助程序,该帮助程序提供一系列城市供用户选择,当提交表单时,所选城市的名称将呈现到页面。

清单 6.8 修改表单以包含一个 select 标签助手

@if (Request.HasFormContentType)                                      ❶
{                                                                     ❶
    <p>You selected @Model.SelectedCity</p>                           ❶
}                                                                     ❶
<form method="post">
    <div class="mb-3">                                                ❷
        <label class="form-label" asp-for="SelectedCity"></label>     ❷
        <select class="form-control" asp-for="SelectedCity"           ❷
         ➥ asp-items="Model.Cities"></select>                        ❷
    </div>                                                            ❷
    <div class="mb-3">
        <label class="form-label" asp-for="Name"></label>
        <input class="form-control" asp-for="Name" />
    </div>
    <div class="mb-3">
        <label class="form-label" asp-for="MaxNumberOfGuests"></label>
        <input class="form-control" asp-for="MaxNumberOfGuests" />
    </div>
    <div class="mb-3">
        <label class="form-label" asp-for="DayRate"></label>
        <input class="form-control" asp-for="DayRate" />
    </div>
    <div class="mb-3">
        <label class="form-label" asp-for="AvailableFrom"></label>
        <input class="form-control" asp-for="AvailableFrom" />
    </div>
    <div class="mb-3">
        <label asp-for="SmokingPermitted"></label>
        <input asp-for="SmokingPermitted" />
    </div>
    <button class="btn btn-primary">Submit</button>    
</form>

❶ 如果表单已提交,则添加此代码块以呈现所选城市。
❷ 添加标签和用于管理城市选择的 select 标签助手。
导航到 /property-manager/create。您应该看到 London 出现在 select 控件中(图 6.6)。

图 6.6 London 是 select 控件中的默认选项。

请随意将选择保留为 London,或从选项中选择另一个城市,然后提交表单。您应该注意两件事:第一是选定的城市被呈现给浏览器,第二是选项列表为空(图 6.7)。

图 6.7 显示所选选项,但选项列表已消失。

那么选择去哪儿了呢?这些选项当前在 OnGet 处理程序中生成。使用 POST 方法将表单提交到服务器时,不会执行 OnGet 处理程序。由于 OnPost 处理程序中没有用于恢复 city 选项的内容,因此不会为 POST 请求生成任何选项。在下一个示例中,我们将介绍一个简单的策略,当您将更复杂的对象传递给 SelectList 时,可以纠正此问题。

设置 options 的值

到目前为止,您已将一个简单的字符串数组传递到 SelectList 中。SelectList 将数组的每个元素分配给选项的文本,从而为 select 元素生成以下 HTML。

清单 6.9 为 select city 元素生成的 HTML

<select class="form-control" id="SelectedCity" name="SelectedCity">
    <option>London</option>
    <option>Berlin</option>
    <option>Paris</option>
    <option>Rome</option>
    <option>New York</option>
</select>

当 HTML 选项元素仅分配了文本时,该文本将在提交表单时作为值传递。当您处理数据库中的数据时(从第 8 章开始),您将更经常地希望使用与数据库中的数据项关联的唯一标识符,即其主键值。这些键值对普通用户没有任何意义,因此您需要将键分配给 select 选项的 value 属性,同时仍传入一段文本,使用户能够了解每个选项所代表的内容。使用 SelectList 构造函数的重载,可以将不同的值映射到选项的 value 属性和文本。下一个示例演示如何使用此重载来模拟将每个城市的数字标识符映射到 option 值的过程,同时仍向用户显示城市的名称。

您将生成 City 对象的集合,就像在上一章的复选框示例中所做的那样。您将这些属性分配给 SelectList,并指定哪个属性表示 DataValueField(选项的值),以及应将哪个属性分配给 DataTextField(选项的文本)。由于您对每个城市使用数字键,因此 SelectedCity 属性将从字符串更改为 int。此外,您需要将生成 SelectList 的代码放在一个单独的方法中,并在 OnGet 和 OnPost 方法中调用它,以便在提交表单后重新填充选项。下面的清单显示了 CreateModel 类的更改部分。SelectedCity 之前的所有属性均保持不变。

Listing 6.10 修改后的 Property Manager 的 CreateModel

[BindProperty]
[Display(Name = "City")]
public int SelectedCity { get; set; }                                      ❶
public SelectList Cities { get; set; }
public string Message { get; set; }                                        ❷
public void OnGet()
{
    Cities = GetCityOptions();                                             ❸
}

public void OnPost()
{
    Cities = GetCityOptions();                                             ❸
    if (ModelState.IsValid)
    {
        var city = GetCityOptions().First(o => o.Value ==                  ❹
        ➥ SelectedCity.ToString());                                       ❹
        Message = $"You selected {city.Text} with value of {SelectedCity}";❹
    }
}

private SelectList GetCityOptions()                                        ❺
{                                                                          ❺
    var 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" }                                ❺
    };                                                                     ❺
    return new SelectList(cities, nameof(City.Id), nameof(City.Name));     ❺
}                                                                          ❺

❶ 将 SelectedCity 从 string 更改为 int。
❷ 添加名为 Message 的字符串属性。
❸ 从新的私有 GetCityOptions 方法分配 Cities。
❹ 从 GetCityOptions 方法生成的数据中获取所选城市的详细信息。
❺ 添加一个用于生成城市的私有方法,以便将逻辑集中在一个地方。

Razor 页面唯一需要的更改是包括 Message 属性,以便你可以看到所选城市的确认。我将它放在页面顶部,在开始的 form 元素之前:

<p>@Model.Message</p>

运行页面并选择一个城市。提交表单后,您会注意到所选城市的详细信息已呈现给浏览器(图 6.8)。

图 6.8 所选城市的详细信息将呈现到浏览器中。

当你查看页面的源代码时,你会看到你选择的选项也应用了 selected 属性(图 6.9)。

图 6.9 所选选项应用了所选属性。

您应该能够从此行为中推断出,您可以通过将 select 标记帮助程序的值分配给绑定到其 for 属性的属性来设置所选项目。当然,您自己没有将值分配给 SelectedCity 属性;模型活页夹为您完成了这项工作。

设置所选值

除了为 bound 属性分配值之外,还可以通过将附加参数值传递给表示所选选项值的 SelectList 构造函数来设置所选值:

var cities = new SelectList(cities, nameof(City.Id), nameof(City.Name), 3);

或者,您可以显式构造 SelectListItem的集合,并将其中一个元素上的 Selected 属性设置为 true。

列表 6.11 构造 SelectListItem的集合

private SelectList GetCityOptions()
{
    var cities =  new List<SelectListItem>    
    {
        new SelectListItem{ Value = "1", Text = "London"},
        new SelectListItem{ Value = "2", Text = "Paris" },
        new SelectListItem{ Value = "3", Text = "New York", Selected = 
        ➥ true },
        new SelectListItem{ Value = "4", Text = "Rome" },
        new SelectListItem{ Value = "5", Text = "Dublin" }
    };
    return new SelectList(cities);
}

像这样的方法(在你显式设置所选项的地方)只有在你不通过其 for 属性将属性绑定到标记帮助程序时才有效。分配给 bound 属性的值(甚至是其默认值)将覆盖设置所选项的任何其他尝试。

6.3.2 绑定多个值

到目前为止,您已经使用了 select 标记帮助程序,使用户能够选择一系列可用选项之一。在某些情况下,您可能希望允许他们选择多个选项。当您使用 HTML 时,只需向 select 元素添加一个 multiple 属性即可启用此功能:

<select name="cities" multiple>

当这个属性存在时,浏览器通过呈现通常所说的列表框来做出响应——一个列出所有选项的框,如果内容超过控件的高度,则可能有一个滚动条(图 6.10)。在大多数浏览器中,用户可以在选择时按住 Ctrl 键来选择多个选项。

图 6.10 应用了多个属性的 Select

如果属性绑定到集合,则 select 标记帮助程序会自动呈现 Boolean multiple 属性。准确地说,它渲染 multiple=“multiple”,但仅仅存在 multiple 属性就是启用多选所需的全部内容。大多数浏览器会忽略传递给 multiple 属性的任何值。

当用户到达网站时,他们可能还没有决定要访问哪个城市。他们可能想要查看各种选项。您可以通过将 collection 属性绑定到 select 标记帮助程序,为它们提供一种轻松选择多个选项的方法。让我们通过在网站的主页上为用户添加选择表单来探讨这一点。打开 Pages 文件夹中的 Index.cshtml.cs 文件,并将以下公共属性添加到 IndexModel,表示 select 标记帮助程序 (int[] SelectedCities) 的绑定,以及带有消息的选项。

清单 6.12 多选列表的 PageModel 属性

[BindProperty]
[Display(Name = "Cities")]
public int[] SelectedCities { get; set; }
public SelectList Cities { get; set; }
public string Message { get; set; }

现在,从您一直在使用的 Create Property 页面复制相同的私有 GetCityOptions 方法以生成 SelectList,并将其添加到 IndexModel。

清单 6.13 复制 GetCityOptions 方法并将其添加到 IndexModel 中

private SelectList GetCityOptions()
{
    var 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" }
    };
    return new SelectList(cities, nameof(City.Id), nameof(City.Name));
}

最后,在 OnGet 方法中分配 Cities。然后添加一个 OnPost 方法,该方法使用 SelectedCities 属性中的绑定值,并将其用作 Cities 集合上的筛选器,以提取所选城市的详细信息。

示例 6.14 向 IndexModel 添加处理程序方法来设置 city 选项

public void OnGet()
{
    Cities = GetCityOptions();
}
public void OnPost()
{
    Cities = GetCityOptions();
    if (ModelState.IsValid)
    {
        var cityIds = SelectedCities.Select(x => x.ToString());     ❶
        var cities = GetCityOptions().Where(o => cityIds.Contains(o.Value)).Select(o=>o.Text);                       ❷
        Message = $"You selected {string.Join(", ", cities)}";      ❸
    }
}

❶ 将 int 的集合转换为字符串。
❷ 过滤城市选项以仅选择已选择的城市选项。
❸ 使用字符串。Join 方法从生成的集合中构造消息。

现在,将 Index.cshtml 中的现有内容替换为下一个列表中显示的简单表单,该表单仅包含 select 标记帮助程序和一个按钮。

Listing 6.15 将表单添加到主页

<div class="col-4">
    <form method="post">
        <div class="mb-3">
            <label class="form-label" asp-for="SelectedCities"></label>
            <select class="form-control" asp-for="SelectedCities" 
             ➥ asp-items="Model.Cities"></select>
        </div>

        <div class="mb-3">
            <button class="btn btn-primary">Submit</button>
        </div>
    </form>
</div>
<p>@Model.Message</p>

运行应用程序时,很明显 select 标记帮助程序的输出与前面的示例不同,因为您应该会看到列表框出现。在按住 Ctrl 键的同时选择几个选项,然后提交表单。确认您的选择已包含在 Message 输出中,并且它们在列表框中保持选中状态(图 6.11)。

图 6.11 所选选项保持选中状态,并包含在呈现的消息中。

 6.3.3 使用 OptGroup

SelectList 类包括另一个构造函数,该构造函数使您能够指定应该用于表示 HTML optgroup 元素的属性的名称,即 DataGroupField,该元素用于在向用户显示的选项列表中将相关选项组合在一起。

每个城市都属于一个国家。在 select 元素中使用选项组按国家/地区对城市选项进行分组似乎是合理的。当用户在应用程序中创建新属性时,他们可以更轻松地找到该属性所在的城市。因此,让我们向 City 类添加新的公共属性来表示国家/地区名称:

public string CountryName { get; set; }

现在,您将更改 Pages\PropertyManager\Create .cshtml.cs 文件中的 GetCityOptions 方法,以将国家/地区名称分配给所选城市,然后传入 nameof(City.CountryName) 来表示 DataGroupField。

Listing 6.16 向 city 选项添加一个选项组

private SelectList GetCityOptions()
{
    var cities = new List<City>
    {
        new City{ Id = 1, Name = "Barcelona" , CountryName = "Spain" },
        new City{ Id = 2, Name = "Cadiz" , CountryName = "Spain" },
        new City{ Id = 3, Name = "London", CountryName = "United Kingdom" },
        new City{ Id = 4, Name = "Madrid" , CountryName = "Spain" },
        new City{ Id = 5, Name = "Rome", CountryName = "Italy" },
        new City{ Id = 6, Name = "Venice", CountryName = "Italy" },
        new City{ Id = 7, Name = "York" , CountryName = "United Kingdom" },
    };
    return new SelectList(cities, nameof(City.Id), nameof(City.Name), 
    ➥ null, nameof(City.CountryName));
}

当您运行该页面时,您应该会看到按国家/地区分组的数据,并将国家/地区名称设置为分组标签(图 6.12)。大多数情况下,您将以面向对象的方式处理数据。您已经有一个 Country 类,因此可以通过向 City 类添加 Country 属性(在下面的清单中以粗体显示)来使用组合来表示城市和国家之间的关系。

图 6.12 城市按国家分组。

清单 6.17 将 CountryName 属性替换为 Country 属性

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

❶ 将字符串属性替换为 Country 属性。

下一个清单显示了 GetCityOptions 方法的修订版本,其中国家/地区名称是通过 Country 属性设置的。

Listing 6.18 使用组合为每个城市设置国家

private SelectList GetCityOptions()
{
    var cities = new List<City>
    {
        new City{ Id = 1, Name = "London", Country = new Country{
                  ➥ CountryName = "United Kingdom"} },
        new City{ Id = 2, Name = "York" , Country = new Country{
                  ➥ CountryName = "United Kingdom"} },
        new City{ Id = 3, Name = "Venice", Country = new Country{
                  ➥ CountryName = "Italy"} },
        new City{ Id = 4, Name = "Rome", Country = new Country{
                  ➥ CountryName = "Italy" } },
        new City{ Id = 5, Name = "Madrid" , Country = new Country{
                  ➥ CountryName = "Spain" } },
        new City{ Id = 5, Name = "Barcelona" , Country = new Country{
                  ➥ CountryName = "Spain" } },
        new City{ Id = 5, Name = "Cadiz" , Country = new Country{
                  ➥ CountryName = "Spain" } }
    };
    return new SelectList(cities, nameof(City.Id), nameof(City.Name), 
                          ➥ null, "Country.CountryName");               ❶
}

❶ 在处理复杂对象时,您可以使用文本字符串来表示 DataGroupField。

这一次,您不能使用 nameof 运算符来表示 DataGroupField 名称。您必须将完整的属性名称作为字符串传入;否则,将找不到它,从而导致在运行时出现 NullReferenceException。

6.3.4 绑定枚举

在大多数实际应用程序中,您填充选择列表的选项将来自数据库,尤其是在它们可能更改的情况下。当选项的范围自然受到限制时,您可以决定由枚举代替它们。例如,合理描述他们的工作状态的方式有限:全职就业、兼职就业、自雇、找工作、退休或接受教育。因此,您可以创建一个名为 WorkStatus 的枚举,其中包含表示所有可能选项的成员。

在您的应用程序中,应用于可以保留的属性的评级是枚举的良好候选项。评分范围从 1 星到 5 星不等,您还必须处理尚未评级的房产。因此,您将创建一个包含涵盖所有选项的成员的枚举。将新的 C# 类文件添加到名为 Rating.cs 的 Models 文件夹中,并将该类更改为枚举。

Listing 6.19 Rating 枚举

public enum Rating
{
    Unrated, OneStar, TwoStar, ThreeStar, FourStar, FiveStar      
}

下一步是将属性添加到 PropertyManager 文件夹中的 CreateModel:

[BindProperty]
public Rating Rating { get; set; }

您希望能够在 Razor 页面中引用此类型,因此需要使其可以使用 CityBreaks.Models 命名空间。如果您回想一下第 3 章,您会记得您可以通过向 ViewImports 文件添加合适的 using 指令来全局实现这一点,因此打开 Pages 文件夹中的那个指令,并通过在下面的列表中添加粗体代码来做到这一点。

清单 6.20 向 ViewImports 添加 using 指令

@using CityBreaks
@using CityBreaks.Models
@namespace CityBreaks.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

提供了 Html.GetEnumSelectList 方法,因此您可以轻松地将枚举绑定为选择列表的数据源。您将在将添加到“创建属性”表单的 select 标记帮助程序的 asp-items 属性中使用该方法。在 select city 列表之后添加以下代码块。

清单 6.21 使用 Html.GetEnumSelectList 方法绑定枚举

<div class="mb-3">
    <label asp-for="Rating"></label>
    <select class="form-control" asp-for="Rating" asp-items="Html.GetEnumSelectList<Rating>()"></select>
</div>

运行页面。枚举值应出现在选择列表中(图 6.13)。

图 6.13 通过 Html.GetEnumSelectList 方法绑定到 select list 的枚举

在此示例中,选择了 Unrated 选项。您可以检查页面的源代码以确认这一事实。这是因为其选项值 (零) 是 Rating 的默认值。如果不希望预先选择该值,可以将 PageModel 属性设置为可为 null:

[BindProperty]
public Rating? Rating { get; set; }

回想一下上一章,如果你使属性可为空,但你仍然希望使关联的表单控件成为必填字段,你将需要添加一个 Required 属性。

呈现给用户的实际值可以稍作整理。如果您以前使用过枚举,则可能会想将 Description 属性应用于您希望更加用户友好的每个成员。但是,这不会影响 select 标记帮助程序。就像其他与表单相关的标记帮助程序一样,select 标记帮助程序响应 DisplayAttribute。下面的清单演示了如何将其应用于您希望向用户显示原始属性名称以外的内容的每个成员。

清单 6.22 使用 DisplayAttribute 更改显示的值

public enum Rating
{
    Unrated,
    [Display(Name="1 Star")]
    OneStar,
    [Display(Name = "2 Star")]
    TwoStar,
    [Display(Name = "3 Star")]
    ThreeStar,
    [Display(Name = "4 Star")]
    FourStar,
    [Display(Name = "5 Star")]
    FiveStar      
}

渲染的结果如图 6.14 所示。

图 6.14 显示名称会自动应用于选项文本。

现在我们已经介绍了使用选择列表时出现的最常见问题,我们可以继续查看复选框和单选按钮控件。虽然 input 标签帮助程序以他们为目标,但他们行为的各个方面是独一无二的,值得关注。

6.4 复选框和无线电控制

我们已经介绍了在 Razor Pages 表单中使用复选框的一些方面,但值得重复这些知识,同时我们将探索使用类似的控件:单选按钮。复选框旨在使用户能够从预定义选项(如选择列表)中进行选择零个或多个。复选框可以单独表示布尔选项,当这是要求时,您应该将 PageModel 属性绑定到它们,该属性可以是布尔值或字符串,其值可以转换为布尔值(例如,“true”/“false”)。还可以通过共享相同的名称将复选框组合在一起,以使用户能够进行多项选择,例如您刚刚使用的列表框。在上一章中,你查看了模型绑定到简单集合时所做的。您是选择使用列表框还是一组复选框来促进多选,与任何事情一样,都是一个演示文稿设计决策。

单选控件表示互斥的选项。它们主要用于允许用户从多个选项中选择一个,并且只能选择一个。因此,它们几乎总是被归为一组。当选择组中的一个单选按钮时,将自动取消选择所有其他单选按钮。您不能取消选择单个无线电,这就是为什么它们不能真正单独使用的原因。取消选择一个无线电的唯一方法是选择同一组中的另一个无线电。以强制选择的形式使用一个无线电控件可能是有意义的,例如在继续之前表明您同意条款和条件。

在下一个示例中,您将使用单选按钮而不是 select 元素来选择属性评级枚举值之一。唯一需要的更改是表单本身。将 select 元素替换为下一个清单中的代码。

Listing 6.23 将一个 select 元素替换为一组 radio 控件

<p>Rating</p>
<ul class="list-group border-0">
@foreach(var option in Html.GetEnumSelectList<Rating>())       ❶
{
    <li class="list-group-item p-0 border-0">
        <input asp-for="Rating" type="radio" 
         ➥ id="rating-@option.Value" value="@option.Value">   ❷
        <label for="rating-@option.Value">
         ➥ @option.Text</label>                               ❸
    </li>
}
</ul>

❶ 使用 Html.GetEnumSelectList 方法将枚举转换为 IEnumerable,并循环访问它们。
❷ 将单选按钮绑定到 Rating 属性,但显式设置 type、id 和 value。
❸ 添加标签,并将其 for 属性设置为相应单选按钮的 id。

Html.GetEnumSelectList 方法可用于 Razor 页面中的任何位置,以将枚举转换为 IEnumerable<SelectListItem>。您可以迭代这些并为每个 API 呈现一个 Input。您可以使用输入标记帮助程序的 for 属性将单选按钮控制组绑定到 Rating 属性,但必须使用此输入标记帮助程序显式设置类型。没有从 .NET 类型到无线电控件的映射。当您在标签帮助程序上显式设置 HTML 属性时,标签帮助程序将遵循您的分配。您对 id 属性执行了相同的作,该属性通常由 tag helper 生成。您需要自己设置该值。否则,所有输入都将具有相同的自动生成的 ID “Rating”,并且您不能在同一文档中拥有具有相同 ID 值的多个元素。因此,通过将评级的数值连接到 “rating-”,生成 “rating-0”、“rating-1” 等,从而构建了唯一值。图 6.15 显示了单选按钮的最终渲染集合。

图 6.15 基于枚举渲染的无线电

我在此处使用了 Html.GetEnumSelectList 来生成适合在 Razor 页面中使用的类型的集合,因为返回类型 IEnumerable <SelectListItem> 可以更轻松地将枚举值绑定到无线电。数据可以由任何类型表示,这些类型公开了可以映射到输入值的属性,以及某种类型的描述性标签。

6.5 上传文件

在大多数情况下,您将使用表单将用户的数据作为简单字符串捕获,尽管在服务器上,它们可能会转换为数字、DateTime 或布尔值。表单还可用于将二进制数据发送到服务器,从而可以上传文件。要将模型绑定与成功上传文件相结合,您必须满足三个基本要求:

  1. 表单必须使用 POST 方法。
  2. 表单必须将 enctype 属性设置为 multipart/form-data。
  3. 如果要启用多个文件上传,则上传的文件必须映射到 IFormFile 类型的实例或它们的集合。

IFormFile 接口位于 Microsoft.AspNetCore.Http 命名空间中,表示通过 HTTP 发送的文件。除了一些提供对文件相关信息(其 FileName、Length、ContentType 等)的访问的属性外,IFormFile 接口还指定了几种将内容复制到 Stream 的方法,以便您可以保存它。

在 Razor Pages 应用程序中,可以将文件以二进制形式本地保存到数据库,也可以将其保存到文件系统。过去曾爆发过宗教战争,争论哪种方法更好!通过一些搜索,您可以在互联网上找到这些历史战场的遗迹。战斗往往肆虐的山丘与储存和加工成本有关。由于现在两者都相对便宜,因此现在更可能基于可访问性和安全性来决定存储位置。如果您希望能够出于任何原因快速访问上传的文件,那么文件系统是有意义的。如果您存储的文档是敏感的,则可能需要利用数据库系统提供的内置安全功能。

文件上传的危险

允许将文件上传到您的应用程序存在危险。至少,文件上传为恶意用户提供了一种将恶意软件上传到您的 Web 服务器的方法。您应该花一些时间查看开放 Web 应用程序安全项目 (OWASP;http://mng.bz/aJx7)了解您可能会面临的潜在问题以及您应该实施的缓解措施来保护自己
默认情况下,早期版本的 ASP.NET 运行时将 HTTP 请求的最大大小限制为 4 MB,以帮助防止因 Web 服务器因过多的数据而无法在内存中处理而导致拒绝服务 (DOS) 攻击。在 ASP.NET Core 中,Kestrel Web 服务器的请求长度限制为 30 MB。您可以通过将 RequestSizeLimit 属性应用于 PageMode 类来实现下限,并提供一个以字节为单位的值,该值表示请求的最大允许大小。例如,以下将对总请求强制实施 1 MB 的限制:

[RequestSizeLimit(1048576)]
public class CreateModel : PageModel

如果总大小超过该值,框架将返回 400 Bad Request 状态代码。

在本章的最后一个示例中,您将上传一个文件并将其保存到文件系统中。您将介绍一个用于创建城市的页面,该表单将包含一个文件上传控件,用于存储每个城市的标志性图像。这将使您有机会复习这两章中关于表单的其他知识。您将应用验证以确保每个城市都有一个名称和一个图像,并验证上传文件扩展名以确保仅接受和处理 JPG 文件。您将为此目的编写自定义验证器,因为您可能希望重用验证逻辑。有一个可用的扩展验证器,但它根据字符串而不是文件上传进行验证。

上传文件后,您会将其重命名为城市的名称。这样,您可以确保每个城市的图像都是唯一的名称,并且在出现命名冲突时不会意外覆盖另一个城市的图像。如果上传的文件包含恶意文件名,它还可以缓解覆盖服务器上关键文件的情况。成功处理上传后,您将使用 PRG (post-redirect-get) 模式重定向到将显示图像的另一个页面,并使用 TempData 存储城市名称和图像名称。

让我们从自定义验证器开始。将 C# 类文件添加到现有的 ValidationAttributes 文件夹中,并将其命名为 UploadFileExtensionsAttribute.cs。以下是验证器的完整代码,包括 using 指令。

清单 6.24 UploadFileExtensions 自定义验证器

using System.ComponentModel.DataAnnotations;

namespace CityBreaks.ValidationAttributes
{
    public class UploadFileExtensionsAttribute : ValidationAttribute
    {
        private IEnumerable<string> allowedExtensions;                 ❶
        public string Extensions { get; set;  }                        ❷
        protected override ValidationResult IsValid
        ➥ (object value, ValidationContext validationContext)
        {
            allowedExtensions = Extensions?                            ❸
                .Split(new char[] { ',' },                             ❸
                        ➥ StringSplitOptions.RemoveEmptyEntries)      ❸
                .Select(x => x.ToLowerInvariant());                    ❸
            if (value is IFormFile file && 
                ➥ allowedExtensions.Any())                                 
            {
                var extension =                                        ❹
                ➥ Path.GetExtension(file.FileName.ToLowerInvariant());❹
                if (!allowedExtensions.Contains(extension))            ❹❺
                {                                                      ❺
                    return new ValidationResult(ErrorMessage           ❹
                        ?? $"The file extension must be                ❹
                        ➥ {Extensions}");                             ❹
                }                                                      ❹
            }
            return ValidationResult.Success;                             ❺
        }
    }
}

❶ 创建一个私有字段来容纳允许的扩展。
❷ 创建一个 public property,以便该属性的用户可以设置允许的扩展。
❸ 将用户提供的扩展分配给 private 字段,将它们从逗号分隔的字符串转换为小写值的集合。
❹ 使用模式匹配来确保此属性已应用到的属性是 IFormFile。如果是,并且用户提供了扩展名,则检查文件名是否与其中任何一个匹配。否则,将返回一条错误消息。
❺ 在所有其他情况下,验证成功。

这不是生产就绪的验证器。例如,它没有考虑到用户不知道是否在允许的扩展名列表中包含前导点。但是,它足以进行演示。它与前面的验证器非常相似,因为它提供了一个 public 属性,使用户能够传入接受的文件扩展名列表。假设特性已应用到的属性是 IFormFile 类型。在这种情况下,将执行验证逻辑,如果未提供文件扩展名,则验证将失败。

现在您需要添加一个用于创建城市的页面。将新文件夹添加到名为 CityManager 的 Pages 文件夹,然后在其中添加一个名为 Create 的新 Razor 页面。打开 Create.cshtml.cs 文件,并将 using 指令更改为以下内容。

示例 6.25 城市 CreateModel 类的 Using 指令

using CityBreaks.ValidationAttributes;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;

然后更改 CreateModel 类本身的内容。

Listing 6.26 City CreateModel 类

public class CreateModel : PageModel
{
    private readonly IWebHostEnvironment _environment;                  ❶
    public CreateModel(IWebHostEnvironment environment)                 ❶
    {                                                                   ❶
        _environment = environment;                                     ❶
    }                                                                   ❶

    [BindProperty]                                                      ❷
    [Required]                                                          ❷
    public string Name { get; set; }                                    ❷
    [BindProperty]                                                      ❷
    [Required]                                                          ❷
    [UploadFileExtensions(Extensions = ".jpg")]                         ❷
    public IFormFile Upload { get; set; }                               ❷
    [TempData]                                                          ❸
    public string Photo { get; set; }                                   ❸

    public async Task<IActionResult> OnPostAsync()
    {
        if (ModelState.IsValid)
        {
            TempData["Name"] = Name;                                    ❹
            Photo = $"{Name.ToLower().Replace(" ", "-")}                ❺
            ➥ {Path.GetExtension(Upload.FileName)}";                   ❺
            var filePath = Path.Combine(                                ❺
            ➥ _environment.WebRootPath, "images", "cities", Photo);    ❺
            using var stream = System.IO.File.                          ❺
            ➥ Create(filePath);                                        ❺
            await Upload.CopyToAsync(stream);                           ❺
            return RedirectToPage(                                      ❻
            ➥ "/CityManager/Index");                                   ❻
        }
        return Page();
    }
}

❶ 将 IWebHostEnvironment 服务注入 CreateModel,以便您可以使用它来查找 wwwroot 文件夹。
❷ 为城市名称和图片上传添加绑定目标。
❸ 添加 TempData 属性以表示图像文件名。
❹ 将提交的城市名称分配给 TempData。
❺ 重命名上传的文件,并将其保存到 image 文件夹中名为 cities 的文件夹中。
❻ 重定向用户。

IWebHostEnvironment 服务提供有关应用程序正在运行的 Web 托管环境的信息。将其注入 CreateModel 以使用其 WebRootPath 属性查找 wwwroot 文件夹,然后为城市名称和文件上传添加绑定目标。两者都是必需的 — 使用 Required 属性进行修饰 — 并且 Upload 属性也使用新的 validation 属性进行修饰。您选择只接受 JPG 文件。您还添加了另一个使用 TempData 属性修饰的属性。您将使用它来存储上传图像的文件名,以便在重定向用户时可以使用它。

如果验证成功,您将获取提交的城市名称并将其添加到 TempData:

TempData["Name"] = Name;

但是,为什么不直接将 TempData 特性添加到现有的 Name 属性中,就像对 Photo 属性所做的那样呢?使用 TempData 属性,您可以设置 TempData 和从 TempData 获取值。模型绑定和 TempData 彼此冲突,因为它们都提供了一种机制,用于使用 HTTP 请求中的值填充页面属性。一个必须在另一个之后执行,默认行为是 TempData 填充在模型绑定之后进行。因此,模型绑定分配给属性的任何值都将被 TempData 中的任何内容覆盖,这通常为零。因此,您将手动进行分配。

您相当简单的重命名逻辑会从城市名称中删除空格和连字符,然后使用小写版本作为文件名。然后,文件将保存在 cities 文件夹中,该文件夹位于 wwwroot\images 中,位于您在上一章中创建的 Flags 文件夹旁边。在将文件保存到该文件夹之前,您需要手动创建该文件夹。保存文件后,将用户重定向到尚不存在的页面。

在创建该页面之前,您需要将表单添加到 Create.cshtml 文件。清单 6.27 显示了上传表单的代码。您现在应该已经熟悉其中的大部分内容。唯一需要注意的一点是在 form 标记帮助程序上添加了 enctype 属性,指定了表单数据应该如何编码。这被设置为 multipart/ form-data,这是成功上传表单的三个要求之一。如果省略此项,则编码将默认为 application/x-www-form-urlencoded,从而仅将文件名发布到服务器。您已将 accept 属性应用于文件输入。其值设置为应限制上传的文件扩展名。支持此属性的用户代理将对用户可以从中选择的可用文件应用过滤器,以防止上传错误的类型。这可以被视为客户端验证的另一种方法,但您不应依赖它。

列表 6.27 创建城市表单

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

<h4>Create City</h4>

<div class="row">
    <div class="col-md-4">
        <form method="post" enctype="multipart/form-data">
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Upload" class="control-label"></label><br />
                <input asp-for="Upload" accept=".jpg" />
                <span asp-validation-for="Upload" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
@section scripts{
    <partial name="_ValidationScriptsPartial"/>
}

现在,您将添加成功提交表单重定向到的页面。将新的 Razor 页面添加到名为 Index.cshtml 的 CityManager 文件夹,并将页面内容更改为以下内容。

Listing 6.28 确认图片上传

@page
@model CityBreaks.Pages.CityManager.IndexModel
@{
}
@if(TempData["Name"] != null){
    <div class="col-6">
        <h3>@TempData["Name"]</h3>
        <img src="/images/cities/@TempData["Photo"]" class="img-fluid" alt="@TempData["Name"]"/>
    </div>
}

同样,这里没有什么新东西。检查 TempData 是否包含某些内容,然后将值呈现给浏览器。然后,将图像文件名分配给 img 元素的 src 的一部分,并使用 Bootstrap img-fluid CSS 类使图像响应以适应其容器。

运行应用程序,导航到 /city-manager/create,然后输入城市名称。找到该城市的图片(我使用了 Unsplash—https://unsplash.com/—用于免费用于商业和非商业用途的图片),并上传它。如果一切正确,您应该看到城市的名称和图像呈现到浏览器中,以及保存到 Images\cities 文件夹中的图像(图 6.16)。

图 6.16 上传到浏览器的图片文件

在过去的几章中,您已经完成了大量表单方面的工作,这将对您创建自己的应用程序有所帮助。之前,您了解了如何使用 Razor 语法生成动态内容,以及如何通过路由显示页面。因此,您已经在应用程序的表示层方面介绍了很多内容。

在接下来的几章中,您将开始了解应用程序的逻辑组件:其服务。下一章将介绍什么是服务以及如何在 ASP.NET Core 应用程序中管理服务。除此之外,您将开始查看 ASP.NET Core 提供的用于管理数据和用户的服务。

总结

表单和表单作标记帮助程序会生成用于表单提交的 URL。
表单标记帮助程序会生成一个包含请求验证令牌的隐藏输入。
输入标记帮助程序会根据绑定到它们的模型属性的数据类型生成不同的类型属性。它们还会生成用于客户端验证的 data-val 属性。
您可以在模型属性上使用 DataType 属性来生成更具体的类型属性。
默认情况下,label 标签帮助程序呈现原始属性名称。您可以使用 Display 属性来呈现对用户更友好的内容。
select 标签帮助程序以 HTML select 元素为目标。它通过其 items 属性呈现绑定到它的选项。
选项可以作为 SelectListItem的集合或 SelectList 提供。
您可以通过为 select 标记帮助程序上绑定到 for 属性的属性分配值来设置所选项。
您可以通过将 select 标记帮助程序绑定到集合属性来启用多个选择。
SelectList 支持使用选项组。
可以使用 Html.GetEnumSelectList 方法将枚举绑定到 select 标记帮助程序。
当您使用输入标记帮助程序生成复选框时,将 Boolean 属性绑定到该复选框会生成一个隐藏字段,其值设置为 false。
无线电控件必须显式指定其类型。
文件上传需要 multipart/form-data 编码才能正常工作。
必须发布上传的文件,并且必须将其绑定到 IFormFile 类型。

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。
在更新现有项时,显式索引更有用。
验证可以在客户端上执行,但必须在服务器上执行。
验证属性在客户端和服务器上都有效。
您必须通过在页面中包含所需的脚本来选择加入客户端验证。
您可以编写自己的验证属性来处理更复杂的验证方案。

ASP.NET Core Razor Pages in Action 4 使用路由将 URL 匹配到 Razor 页面

ASP.NET Core Razor Pages in Action 4 使用路由将 URL 匹配到 Razor 页面

本章涵盖

• 评估 URL 与 Razor 页面的匹配方式
• 检查使用路由模板来控制匹配过程
• 使用在 URL 中传递的数据
• 覆盖传统路由
• 生成出站 URL

在上一章中,我们研究了如何通过将 HTTP 方法名称合并到处理程序方法的名称中来影响为特定请求调用的处理程序方法,例如 OnGet、OnPost 等。在处理程序选择过程开始之前,必须选择正确的页面。本章重点介绍路由,路由是将传入请求映射到特定页面或终端节点(传入 URL)并生成映射到这些终端节点的 URL(传出 URL)的过程。

许多 Web 应用程序框架根据 URL 段与文件夹结构的匹配,将 URL 映射到网页在磁盘上的文件路径。延续上一章中提供度假套餐的 Web 应用程序的主题,表 4.1 提供了一些示例,说明一些想象中的分段 URL 与其在 Web 应用程序中的文件路径之间的一对一匹配。

表 4.1 在传入 URL 和页面文件路径之间找到一对一的映射是很常见的。

Incoming URL

Maps to

https://domain.com/city/london

c:\website\city\london.cshtml

https://domain.com/booking/checkout

c:\website\booking\checkout.cshtml

https://domain.com/login

c:\website\login.cshtml

本章将探讨 Razor Pages 如何使用基于页面的路由(URL 和文件路径之间的映射)作为应用程序内路由的基础。它还将探讨当 URL 与磁盘上位置之间的直接关系不足以满足您的需求时,如何使用配置来自定义 URL 映射到端点的方式。

URL 和文件路径之间的一一对应关系很容易推理,但其本身就非常有限。表 4.1 中的第一个示例暗示了一个页面,该页面提供有关伦敦作为度假胜地的信息。如果还想提供有关 Paris (https://domain.com/city/paris) 的信息,则必须将另一个页面添加到名为 paris.cshtml 的 city 文件夹。正如前几章所讨论的,这不是构建应用程序的可持续方式。在上一章中,我们研究了如何将 URL 中的数据作为查询字符串值传递。本章将探讨将数据作为 URL 本身的一部分传递,以及如何让单个页面根据该数据做出不同的响应。

URL 被视为 Web 应用程序 UI 的一部分。因此,您需要能够对它们进行与在应用程序中生成 HTML 一样多的控制。路由系统控制框架如何生成指向端点的 URL,但也有可用的自定义选项。您将了解如何使用它们来确保您的 URL 具有描述性和人类可读性。

在上一章中,我们查看了一些使用 Razor 语法迭代城市列表的示例。为了演示本章和后续章节中的概念,我们将继续此方案,并开始构建一个在全球城市提供度假套餐的应用程序(在英国,我们称之为 City Breaks)。如果您想尝试本章和以下章节中的代码示例,您应该根据第 2 章中的说明创建一个新的 Razor Pages 应用程序,并将其命名为 CityBreaks。

4.1 路由基础知识

路由不是 Razor Pages 框架的一部分。它作为单独的组件提供,因此作为中间件插入到您的应用程序中。在 Razor Pages 应用程序中控制路由的两个关键组件是 EndpointRouting 中间件和 Endpoint 中间件,如图 4.1 所示。它们分别通过 UseRouting 和 MapRazorPages 方法添加到应用程序管道中。清单 4.1 显示了 Program 类中的相关代码行作为提醒。

图 4.1 路由中间件在静态文件中间件之后注册。Endpoint middleware 在管道的末尾注册。

列表 4.1 在 UseEndpoints 之前调用 UseRouting

app.UseStaticFiles();      ❶
app.UseRouting();          ❷
app.UseAuthorization();
app.MapRazorPages();       ❸

❶ 静态文件中间件在路由之前注册。
❷ EndpointRouting 中间件在 UseRouting 方法中注册。
❸ 终结点中间件在管道末尾注册,并根据 Razor Pages 约定注册终结点。

EndpointRouting 中间件的作用是将传入的 URL 与端点匹配。对于 Razor Pages 应用程序,终结点通常是 Razor 页面。如果进行了匹配,则有关匹配端点的信息将添加到 HttpContext 中,该信息将沿管道传递。图 4.2 说明了此过程。将端点添加到 HttpContext 后,可以通过 HttpContext 的 GetEndpoint 方法访问它。

图 4.2 传入 URL 的路径与路由集合匹配。与匹配的路由相关的端点被添加到 HttpContext 中。

Endpoint 中间件负责执行选定的端点。如果未找到匹配的终端节点,则框架在管道中注册的最后一个中间件将返回 404 Not Found HTTP 状态代码。

不依赖于路由的中间件(如静态文件中间件)应放置在调用 UseRouting 之前。任何需要了解所选终结点的中间件都放置在 UseRouting 和 UseEndpoints 之间。授权中间件需要了解所选的端点,例如,确定当前用户是否有权访问它。

4.1.1 路由模板

EndpointRouting 中间件尝试通过将 URL 的路径(域后面的部分)与路由模板进行比较来将 URL 与终端节点匹配。路由模板是路由模式的字符串表示形式。该调用在 Endpoint 中间件注册中创建到 MapRazorPages 的路由模板,该注册指示框架根据 Razor Pages 约定创建终结点。具体而言,将创建 RouteEndpoint 对象的集合。RouteEndpoint 是一种可用于 URL 匹配和生成的终端节点。

Razor 页面的路由模板是根据默认根目录(Pages 文件夹)中找到的 Razor 文件的文件路径和名称创建的。

注意 : 在启动阶段,可以通过 Razor-PagesOptions 对象为 Razor 页面配置备用根。以下示例将根目录更改为 Content 而不是 Pages:

builder.Services.AddRazorPages().AddRazorPagesOptions(options => {
    options.RootDirectory = "/Content";
});

或者,可以使用 WithRazorPagesRoot 扩展方法:

builder.Services.AddRazorPages().WithRazorPagesRoot("/Content");

要使 Razor 文件被视为可导航页面,它的顶部需要有一个 @page 指令,并且其名称中不能有前导下划线。表 4.2 显示了为默认项目模板页面生成的路由模板以及与生成的模板匹配的 URL。

表 4.2 路由模板由文件名和位置构成

Page

Route template

Matches

\Pages\Index.cshtml

"Index"

https://domain.com/index

\Pages\Index.cshtml

""

https://domain.com/

\Pages\Privacy.cshtml

"Privacy"

https://domain.com/privacy

\Pages\Error.cshtml

"Error"

https://domain.com/error

Index.cshtml 文件是一种特殊情况。为此生成了两个路由模板 — 一个包含文件名,另一个由空字符串而不是 Index 组成。发生这种情况是因为 Index 被视为默认文档。因此,只需匹配 URL 中的文件夹名称即可访问它。如果您不小心,这可能会导致路由不明确导致问题。例如,如果添加名为 Privacy 的文件夹,其中包含 Index 文件,则会生成表 4.3 中的路由模板。

表 4.3 可能会生成不明确的路由。

Page

Route template

\Pages\Privacy\Index.cshtml

"Privacy/Index"

\Pages\Privacy\Index.cshtml

"Privacy"

\Pages\Privacy.cshtml

"Privacy"

表 4.3 中的最后两个路由模板相同。终端节点选择过程无法区分哪个终端节点(页面)应该匹配,因此当您尝试导航到 http://domain.com/privacy 时,框架会引发 AmbiguousMatchException。有时这种类型的文件配置是不可避免的,因此您需要知道如何解决歧义路由,这将在本章中介绍。

4.2 自定义路由模板

到目前为止,您看到的路由模板模式由文本组成。如果要成功进行终端节点匹配,它们需要 URL 和路由模板中的字符完全匹配。如果路由系统仅限于生成与现有文件路径完全匹配的模板,那将毫无意义。正如您对任何实际 Web 应用程序框架所期望的那样,Razor Pages 提供了足够的范围来自定义路由模板生成过程。

路由模板自定义的主要入口点是 Razor 页面本身中的 @page 指令。在 Razor 页面文件的第一行 @page 指令之后,唯一允许的其他内容是用于自定义页面的路由模板的字符串:

@page "route-template"

除了文字文本,路由模板还可以包含另外两种类型的元素:parameters 和 separators,如图 4.3 所示。参数是动态值的占位符,就像 C# 方法的参数一样,分隔符表示 URL 中段之间的边界。文本和参数之间的区别在于后者括在大括号中。参数是 URL 到终端节点匹配方面非常强大的工具。您很快就会学到更多关于在路由中使用参数的信息,但在此之前,我们将了解如何使用文字文本来覆盖页面的默认路由模板。

图 4.3 演示了路由模板中的文本、分隔符和大括号内参数值的占位符

4.2.1 覆盖路由

假设您发现自己处于表 4.3 中所示的那种 pickle 中,其中两个页面生成相同的路由模板。在这种情况下,您可能希望覆盖其中一个 S S 生成的模板,以防止在运行时引发异常。为此,可以向以分隔符 (/) 开头的 @page 指令提供替代文本值。例如,您可以在 \Pages\Privacy.cshtml 文件中的 @page 指令后添加“/privacy-policy”,如下所示:

@page "/privacy-policy"

这将替换默认路由模板。表4.4显示了由于此更改而生成的路由。

表 4.4 在路由模板中使用文本覆盖默认路由生成

Page

Route template

\Pages\Privacy\Index.cshtml

"Privacy\Index"

\Pages\Privacy\Index.cshtml

"Privacy"

\Pages\Privacy.cshtml

"privacy-policy"

您可以将路由模板的行为方式与 URL 相同,因为那些不以路径分隔符开头的模板是相对于当前页面的,而那些以路径分隔符开头的模板是绝对的,不会附加到当前页面的路由中。

4.2.2 路由参数

我们了解了 URL https://domain.com/city/london 如何映射到名为 City 的文件夹中名为 london.cshtml 的单个文件。london 可以表示数据,而不是文件名。在 Razor Pages 中,在 URL 中传递的数据称为路由数据,在路由模板中由路由参数表示。城市不再是一个文件夹;它是一个处理传入数据的页面。通过在大括号 { } 中指定参数的名称,将参数添加到路由模板中。名称本身或多或少可以是任何内容,但以下保留字除外:

• Action
• Area
• Controller
• Handler
• Page

如果要继续作,请将新的 Razor 页面添加到名为 City 的 Pages 文件夹。以下代码行显示了正在添加到 City 页面的路由模板中的名为 cityName 的参数:

@page "{cityName}"

为页面创建的生成路由模板将为

City/{cityName}

传递给 name 参数的任何值都将添加到 RouteValueDictionary 对象(实际上是路由值的字典)中,该对象将添加到 RouteData 对象中。然后,在页面中,可以使用 RouteData.Values 检索该值。清单 4.2 显示了如何定义 product route 参数,以及如何在 City 页面的代码中检索参数的值。

清单 4.2 向页面添加 route 参数并检索其值以进行显示

@page "{cityName}"                                ❶
@model CityBreaks.Pages.CityModel
 <h2>@RouteData.Values["cityName"] Details</h2>    ❷

❶ 路由模板在大括号中添加一个名为 “name” 的参数。
❷ name 参数的值从 RouteData.Values 获取并显示在页面中。

现在,如果您导航到 /City,您注意到的第一件事是收到 404 Not Found 错误。

图 4.4 如果所需的路由参数没有提供值,则应用程序会返回 404 HTTP 错误码。

这样做的原因是,默认情况下,name 参数的值是必需的。URL /City 本身与任何现有路由模板都不匹配(图 4.4)。导航到 /City/London,然后检索并显示参数值(图 4.5)。

图 4.5 获取并显示参数值。

通过在参数名称后放置问号,可以将参数设置为可选参数,例如 {cityName?}。将参数设为可选后,您可以导航到该页面,而无需提供值。可选参数只能用作路由模板中的最后一个参数。所有其他参数必须是必需的,或者必须为其分配默认值。

将默认值分配给路由参数的方式与分配给普通 C# 方法参数的方式相同,尽管字符串值周围没有引号:

"{cityName=Paris}"

现在,如果您导航到 /City,则会显示默认值(图 4.6)。

图 4.6 路由参数可以分配默认值。

4.2.3 将路由数据绑定到处理程序参数

您将记得上一章中,如果 URL 中的查询字符串值名称匹配,则它们会自动绑定到 PageModel 处理程序方法中的参数。路由数据也是如此。如果路由参数名称匹配,则传入值将自动分配给处理程序方法参数。然后,您可以将参数值分配给 PageModel 属性,这是使用路由数据的推荐方法,而不是访问路由值字典。清单 4.3 显示了修改了 CityModel OnGet 方法,该方法采用一个名为 cityName 的参数,并将传入的值分配给公共属性,然后在 City Razor 页面中进行更改以使用它。

列表 4.3 将路由数据绑定到 PageModel 处理程序参数

[City.cshtml.cs]
public class CityModel : PageModel
{
    public string CityName { get; set; }    ❶
    public void OnGet(string cityName)      ❷
    {
        CityName = cityName;                ❸
    }
}

[City.cshtml]
@page "{cityName=Paris}"
@model CityBreaks.Pages.CityModel
@{
}
<h3>@Model.CityName Details</h3>            ❹

❶ 将公共属性添加到 PageModel。
❷ OnGet 中添加了一个参数,该参数的名称与 route 参数相同,数据类型相同。
❸ 参数值分配给 PageModel 属性。
❹ 在页面中引用 PageModel 属性,而不是 RouteData.Values。

您可以在路由模板中指定多个参数。通常,每个参数都会在 URL 中占据自己的段。清单 4.4 显示了 City 页面及其 PageModel 的更新,以处理新 URL 段中的附加可选参数,该参数可能使用户能够指定指定城市内潜在场所的最低可接受评级。

列表 4.4 单独 URL 段中的多个参数

[City.cshtml.cs]
public class CityModel : PageModel
{
    public string CityName { get; set; }
    public int? Rating { get; set; }
    public void OnGet(string cityName, int? rating)
    {
        CityName = cityName;
        Rating = rating;
    }
}

[City.cshtml]
@page "{cityName}/{rating?}"
@model CityBreaks.Pages.CityModel
@{
}
<h3>@Model.CityName Details</h3>
<p>Minimum Rating: @Model.Rating.GetValueOrDefault()</p>

还允许在同一区段中使用多个参数。每个参数必须由参数值中未包含的文本分隔。假设您的 City 页面接受表示所需到达日期的值,而不是传入的评级值。由于连字符未出现在日期部分中,因此可以按如下方式构建路由模板:

 "{cityName}/{arrivalYear}-{arrivalMonth}-{arrivalDay}"

例如,这将匹配 /City/London/2022-4-18,但不匹配 /City/London/2022/ 4/18。

4.2.4 Catchall 参数

到目前为止,您看到的路由参数用于匹配 URL 中的特定段。有时,您可能不知道 URL 将包含多少个区段。例如,您可以构建一个内容管理系统,允许用户构建自己的任意 URL,这些 URL 不会映射到文件路径。它们会映射到数据库条目。您将需要一个匹配任何内容的路由模板,以便所选的终端节点可以负责在数据库中查找 URL 并显示相关内容。路由系统提供了一个 catchall 参数来满足这种情况。

catchall 参数的声明方式是在名称前加上一个或两个星号,例如 {*cityName} 或 {**cityName}。Catchall 参数会匹配从 URL 中的参数位置到末尾的所有内容,因此在 catchall 参数之后包含其他参数是没有意义的。无论您使用一个星号还是两个星号,匹配过程都是相同的。当您使用路由模板生成 URL 时,使用一个或两个星号之间的区别很明显(您将在本章后面看到)。如果使用一个星号,则生成的 URL 中的路径分隔符将进行 URL 编码,即,它们将呈现为 %2F。例如,URL /City/London/2022/4/18 将呈现为 /City%2FLondon%2F2022%2F4%2F18。当您使用两个星号时,编码将被解码或往返,并且生成的 URL 将包含文本路径分隔符:/City/London/2022/4/18。

4.2.5 路由约束

正如您现在应该已经了解的那样,路由参数类似于您在大多数编程语言中遇到的方法参数。它们是在运行时提供给应用程序各部分的变量数据的占位符。因此,您不可避免地希望能够更频繁地对这些 Importing 执行某种处理。您可能希望使用传递到到达日期示例的路由值构造一个 .NET DateTime 对象,以便您可以在 .NET 代码中使用它:

public DateTime Date { get; set; }
public void OnGet(string cityName, int arrivalYear, int arrivalMonth, int arrivalDay)
{
    Date = new DateTime(arrivalYear, arrivalMonth, arrivalDay);
}

出现潜在问题,因为没有对传入值执行任何类型的检查。就路由而言,所有 URL 数据都是字符串类型。因此,对 /City/London/foo-bar-baz 的请求将匹配路由,并导致在 .NET 尝试在 foo 年中构造有效日期时引发异常。Route constraints 为此问题提供了解决方案。它们使您能够指定路由数据项必须符合的数据类型和可接受值范围,才能将其视为与路由模板匹配。

路由约束是通过用冒号将它们与参数名称分隔来应用的。以下示例演示如何将每个日期部分限制为整数类型:

"{cityName}/{arrivalYear:int}-{arrivalMonth:int}-{arrivalDay:int}"

URL City/London/foo-bar-baz 不再匹配此模板,但 /City/ London/2022-4-18 匹配。但是,用户仍然可以调整 URL 并提供导致创建无效日期的值,例如 /City/London/2022-4004-18。为了防止这种情况,您可以使用 range 约束来限制有效值的范围。范围约束接受最小和最大可接受值,并且可以应用于 month 参数,如下所示:

"{cityName}/{arrivalYear:int}-{arrivalMonth:range(1-12)}-{arrivalDay:int}"

但是,更有可能的是,您会完全更改模板并将传入值限制为 datetime 类型:

"{cityName}/{arrivalDate:datetime}"

可以使用多种约束来限制对特定数据类型和范围的匹配。表 4.5 中列出了最常用的 constraints。https://github.com/dotnet/aspnetcore/tree/main/src/Http/Routing/src/Constraints 中提供了所有的约束。

表 4.5 路由模板中可用的约束

Constraint

Description

Example

alpha

Matches uppercase or lowercase Latin alphabet characters (a-z or A-Z)

{title:alpha}

bool

Matches a Boolean value

{isActive:bool}

int

Matches a 32-bit integer value

{id:int}

datetime

Matches a DateTime value

{startdate:datetime}

decimal

Matches a decimal value

{cost:decimal}

double

Matches a 64-bit floating-point value

{latitude:double}

float

Matches a 32-bit floating-point value

{x:float}

long

Matches a 64-bit integer value

{x:long}

guid

Matches a GUID value

{id:guid}

length

Matches a string with the specified length or within a specified range of lengths

{key:length(8)} {postcode:length(6,8)}

min

Matches an integer with a minimum value

{age:min(18)}

max

Matches an integer with a maximum value

{height:max(10)}

minlength

Matches a string with a minimum length

{title:minlength(2)}

maxlength

Matches a string with a maximum length

{postcode:maxlength(8)}

range

Matches an integer within a range of values

{month:range(1,12)}

regex

Matches a regular expression

{postcode:regex(^[A-Z]{2}\d\s?\d[A-Z]{2}$)}

在本章的前面部分,您了解了将 Index 文件添加到 Privacy 文件夹时如何创建不明确的路由。如果您添加 City 文件夹并在其中放置 Index 文件,则会出现相同的情况。上次,我为其中一个页面提供了一个全新的路由模板,以防止生成重复的路由模板。约束还可用于确保每个页面都被视为不同。“{cityName:alpha}”的模板要求参数仅由字母(a-z 或 A-Z)组成,而模板“{id:int}”将仅匹配数字。

可以将多个约束应用于一个参数。例如,您可能希望指定一个值必须仅由字母表的字母组成,并且长度不得超过 9 个字符。这是通过使用冒号分隔符附加其他约束来实现的:

"{cityName:alpha:maxlength(9)}"
自定义路由约束

如表 4.5 所示,您可以使用广泛的路由约束来满足您的大多数需求。还可以创建自己的自定义 route constraint 并将其注册到 routing 服务以满足应用程序特定的需要。

最终,您的 City 页面可能会采用传递给 cityName 参数的值,并使用该值对数据库执行查找,以获取指定位置的更多详细信息。这工作正常,但您很快就会开始意识到有时数据库查询不会返回结果。当您查看日志时,您会发现传递给数据库查询的值并不是您希望在 URL 中看到的值。它可能是城市名称的一部分,可能添加了一些额外的字符,或者实际上,它可能与数据库中的任何内容完全没有相似之处。当被编写不佳的机器人共享或存储时,指向您网站的链接可能会以无数种方式断开。或者精明的用户可能会破解您的 URL 并添加数据库中不存在的城市名称。

防止这些数据库查找不存在的值所产生的浪费处理,并通知请求者他们正在寻找的页面不存在,这可能是明智的。在本例中,您希望返回 404 Not Found 状态代码。

以下步骤向您展示如何创建自己的路由约束,以根据预先确定的城市集合检查 cityName 参数的值。如果路由数据项不在列表中,则匹配失败,并且应用程序将返回 404 Not Found 状态代码。此示例中的集合是一个简单的数组。在实际应用程序中,数据将驻留在数据库中,但您将对城市的缓存版本执行查找,以避免需要进行数据库调用。在第 14 章中,您将探索如何使用 ASP.NET Core 提供的缓存功能来查询存储在 Web 服务器内存中的集合。

路由约束是实现 IRouteConstraint 接口的类。因此,第一步是创建一个名为 CityRouteConstraint 的 C# 类。这应该放在名为 RouteConstraints 的文件夹中,这样您就不会有杂乱的代码文件弄乱项目的根文件夹。下面的清单中详细介绍了 CityRouteConstraint 类的代码。

清单 4.5 自定义 CityRouteConstraint 类

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;    
using System;
using System.Linq;

namespace CityBreaks.RouteContraints
{
    public class CityRouteConstraint : IRouteConstraint                    ❶
    {
        public bool Match(HttpContext httpContext, IRouter route,          ❷
         string routeKey, RouteValueDictionary values, RouteDirection    ❷
         routeDirection)                                                 ❷
        {
            var cities = new[] { "amsterdam", "barcelona", "berlin",       ❸
                                  ”copenhagen”, “dubrovnik”, “edinburgh”,❸
                                  "london", "madrid", "paris", "rome",   ❸
                                  "venice" };                            ❸
            return cities.Contains(values[routeKey]?.ToString()
.ToLowerInvariant());                                       ❹
        }
    }
}

❶ 约束必须实现 IRouteConstraint。
❷ IRouteConstraint 指定返回布尔值的 Match 方法。
❸ 有效值的数组
❹ 确定匹配是否有效的代码。

IRouteConstraint 接口有一个成员,即一个名为 Match 的方法,该方法返回一个布尔值。它需要许多项目作为参数,并非所有项目都是在每种情况下执行匹配所必需的。在此示例中,只需要 RouteValuesDictionary 和 routeKey。routeKey 值是需要检查的参数的名称。如果传入的参数值与 cities 数组中的项之间存在匹配项,则 Match 方法将返回 true。

注意IRouteConstraint 中的 Match 方法是同步方法,这使得 IRouteConstraint 不适用于应涉及异步处理的任何要求。如果你想异步地约束实际应用程序中的传入路由,支持此功能的替代机制包括 middleware (请参阅 第 2 章) 和 IEndPointSelectorPolicy (https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.routing.matching.iendpointselectorpolicy?view=aspnetcore-7.0),本书未对此进行介绍

必须注册自定义路由约束。这是在 Program 类中完成的。如下面的清单所示,通过传入约束的名称和应该使用的匹配类型,将约束添加到 ConstraintMap 集合中。

列表 4.6 使用 RouteOptions 注册的自定义路由约束

builder.Services.AddRazorPages();
builder.Services.Configure<RouteOptions>(options =>
{
    options.ConstraintMap.Add("city", typeof(CityRouteConstraint));   ❶
});

❶ 约束以名称 “city” 注册。

一旦 constraint 被注册,就可以像任何其他 constraint 一样使用它,只需将 constraint 的名称放在参数 name 之后即可:

@page "{cityName:city}"

4.2.6 创建其他路由

当您通过 @page 指令自定义路由模板时,会影响路由系统的结果模板。除了替换默认路由模板之外,您还可以指定页面可以匹配的其他模板。执行此作的最直接方法是在配置 RazorPagesOptions 时使用 AddPageRoute 方法(类似于为页面指定备用根文件夹),如清单 4.7 所示,其中 Index 页面应用了额外的路由,因此可以在 URL /FindMe 以及系统生成的路由中找到它。

列表 4.7 通过配置添加额外的路由模板

builder.Services
    .AddRazorPages()
    .AddRazorPagesOptions(options =>
    {
        options.Conventions.AddPageRoute("/Index", "FindMe");
    });

使用此方法可以为特定页面定义的其他路由数量没有限制,该方法采用要映射的页面的名称和与该页面匹配的路由模板。此特定示例显示了文本值为“FindMe”的路由模板,但应用于 AddPageRoute 方法的模板可以包含参数和约束,就像您到目前为止一直在使用的模板一样。

4.2.7 直接使用 PageRouteModel 约定

在上一节中,您通过 AddPageRoute 方法为页面声明了其他路由模板。此方法提供了一种向应用程序添加新路由约定的便捷方法,尽管一次只能添加到一个页面。但是,如果要将新的路由约定应用于多个页面,甚至应用程序中的所有页面,该怎么办?AddPageRoute 方法的扩展性不是很好。在 ConfigureServices 方法中,您最终可能会有数百行代码,并且必须记住为每个新页面添加新的注册。

使用 AddPageRoute 方法时,框架会创建一个新的 PageRouteModelConvention,并将其添加到由 RazorPagesOptions Conventions 属性表示的 PageConventionCollection 中。该约定由 IPageRouteModelConvention 接口表示,您可以直接使用该接口将新约定应用于多个页面。为了说明其工作原理,假设您的新兴应用程序需要支持以多种语言显示内容。

注意为要以多种语言显示的内容提供支持的过程称为本地化,这是一个专门的主题,除了此示例之外,本书不会详细介绍。如果您想了解有关 Razor Pages 中本地化的更多信息,请在此处查看我的系列博客文章:https://www.mikesdotnetting.com/category/41/localization

要呈现内容的本地化(翻译)版本,应用程序需要知道用户更喜欢哪种区域性或语言。通常,这是通过要求用户从多个选项中进行选择,然后以某种方式跟踪该选择来确定的。存储所选内容并将其从一个页面传递到另一个页面的常用方法之一是在 URL 中作为数据。包含首选区域性的 URL 的典型示例可能是 https://domain.com/en/Welcome,其中 /en/ 区段指定英语作为区域性,或者 https://domain.com/fr/Welcome 表示法语版本。

如前所述,您将 URL 中的数据作为参数值传递,并且需要显式声明包含参数的路由模板。如果要支持将路由模板添加到多个页面,则可以创建可应用于任意数量的页面的 PageRoute- ModelConvention。

PageRouteModelConventions 实现 IPageRouteModelConvention 接口,该接口指定必须实现的单个方法,即 Apply 方法,该方法将 PageRouteModel 作为参数:

public interface IPageRouteModelConvention : IPageConvention
{
    void Apply(PageRouteModel model);
}

请注意,IPageRouteModelConvention 接口反过来又实现 IPageConvention 接口。这是一个标记接口。它没有方法,用作路由发现过程的一部分,用于将实现类表示为包含应应用于应用程序的路由模型约定的类。

PageRouteModel 参数提供了一个网关,用于应用新约定来为 Razor 页面生成路由。正是通过此对象,您可以应用自定义约定。PageRouteModel 具有 Selectors 属性,该属性表示 SelectorModel 对象的集合。每个选项都有一个 AttributeRouteModel 属性,而该属性又具有一个 Template 属性,该属性表示一个路由模板,该模板允许将 URL 映射到此特定页面。以下是此结构在应用程序中查找当前 Index 页的方式:

PageRouteModel
    RelativePath: "/Pages/Index.cshtml"
    Selectors: [Count = 3]
        SelectorModel[0]:
            AttributeRouteModel:
                Template: "Index"
        SelectorModel[1]:
            AttributeRouteModel:
                Template: ""
        SelectorModel[2]:
            AttributeRouteModel:
                Template: "FindMe"

这是一个大规模简化的表示形式,只关注那些直接感兴趣的属性。PageRouteModel 类和组成其属性的类比这复杂得多。请注意,在 Index 页的此表示形式中有三个 SelectorModel 对象。最终的 SelectorModel 包含 FindMe 路由模板,该模板是在上一节中通过 AddPageRoute 方法添加的。

在 Apply 方法中,您可以访问现有的 SelectorModel 对象并修改 Template 属性的值以更改现有模板,也可以将 SelectorModel 对象添加到 Selectors 集合以添加其他路由模板。下面的清单显示了一个 PageRouteModelConvention,它复制现有的路由模板,插入一个名为 culture 的可选路由参数作为第一个段,然后将该副本添加到应用程序中发现的每个页面的新 SelectorModel 中。

列表 4.8 创建 PageRouteModelConvention 以添加自定义路由约定

using Microsoft.AspNetCore.Mvc.ApplicationModels;                            ❶

namespace CityBreaks.PageRouteModelConventions
{
    public class CultureTemplatePageRouteModelConvention :
     IPageRouteModelConvention                                             ❷
    {
        public void Apply(PageRouteModel model)                              ❸
        {
            var selectorCount = model.Selectors.Count;

            for (var i = 0; i < selectorCount; i++)                          ❹
            {
                var selector = model.Selectors[i];

                model.Selectors.Add(new SelectorModel                        ❺
                {
                    AttributeRouteModel = new AttributeRouteModel
                    {
                        Order = 100,                                         ❻
                        Template =                                           ❻
                         AttributeRouteModel.CombineTemplates("{culture?}",❻
                         selector.AttributeRouteModel.Template)            ❻
                    }
                });
            }
        }
    }
}

❶ Microsoft.AspNetCore.Mvc.ApplicationModels 命名空间需要 using 指令。
❷ 您正在调用此类 CultureTemplatePage-RouteModelConvention 并实现 IPageRouteModelConvention 接口。
❸ 该类实现 Apply 方法。
❹ 迭代 PageRouteModel 的 SelectorModels 集合以获取有关其路由模板的信息。
❺ 通过将名为 culture 的新参数与现有选择器的副本相结合,将新的 SelectorModel 添加到集合中。
❻ 新模板的 order 设置为较高的数字,因此它不会干扰现有路线。

通过将 CultureTemplatePageRouteModelConvention 添加到 RazorPagesOptions Conventions 集合来注册该 CultureTemplatePageRouteModelConvention,其方式与之前调用 AddPageRoute 方法的方式类似:

builder.Services.AddRazorPages().AddRazorPagesOptions(options => {
    options.Conventions.AddPageRoute("/Index", "FindMe");
    options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
});

注册约定后,将调用该约定,并在启动时为应用程序中的每个 Razor 页面执行其 Apply 方法。最终结果是应用程序中的路由模板总数增加了一倍。例如,索引页面的 SelectorModels 总数现在为 6 个,具有以下路由模板:

"Index"
""
"FindMe"
"{culture?}/Index"
"{culture?}"
"{culture?}/FindMe"

现在,您可以访问 URL 中包含或不包含区域性的主页。如果您想将区域性设为强制性的,则可以更新现有模板:

foreach (var selector in model.Selectors)
{
    selector.AttributeRouteModel.Template = AttributeRouteModel.CombineTemplates("{culture}",
                selector.AttributeRouteModel.Template);
}

这样,我们就完成了对如何将 URL 映射到各个 Razor 页面以及如何在必要时使用各种方式自定义过程的探索。下一节将介绍可用于根据应用程序的路由信息生成 URL 的工具。

4.3 生成 URL

任何对 HTML 有一点了解的人都知道,只需将锚元素添加到页面中,并将目标 URL 作为硬编码字符串应用于 href 属性,那么为什么本书专门用一节来讨论生成 URL 的主题呢?如果你要创建链接的 URL 位于你的网站外部,那么你将使用普通的锚元素和硬编码的 href 值。你也可以将硬编码的 URL 用于内部链接,但正如你刚刚阅读的那样,你可以非常轻松地更改 URL 映射到页面的方式。如果需要调整特定页面或页面组的查找方式,则需要在应用程序内的所有位置更新链接到这些页面的 URL。现在,这可能会带来维护方面的麻烦,而 Razor Pages 框架的开发人员不喜欢维护方面的麻烦,因此他们提供了此问题的解决方案。

Razor Pages 包含一些工具,这些工具可根据应用程序的路由配置生成 URL。因此,如果您更改路由,它们生成的链接将自动更新。主要工具是锚点标记帮助程序(我们之前已经简要介绍过)和 LinkGenerator 服务。这些是我们接下来要看的内容。

4.3.1 锚点标签助手

如上一章所述,标记帮助程序是自动生成 HTML 的组件。锚点标签帮助程序负责生成链接到应用程序内页面的锚点元素。href 属性的值是 anchor 元素的基本部分。锚点标记帮助程序负责根据应用程序的路由配置生成此消息。如果通过调整页面的路由模板或添加新约定来更改路由系统配置,则标记帮助程序的输出会自动相应地调整。您无需浏览 Razor 页面即可更新整个应用程序的链接。

提醒一下,以下是您在第 2 章中添加到导航的 Welcome 页面的锚点标签帮助程序:

<a class="nav-link text-dark" asp-area="" asp-page="/Welcome">Welcome</a>

这个特定的锚点标签帮助程序包括两个自定义属性,这两个属性都以 asp- 为前缀。请注意,没有 href 属性。不能在锚点标记帮助程序中包含 href 属性。如果尝试这样做,应用程序将在运行时引发 InvalidOperationException。Razor Pages 应用程序最感兴趣的 anchor 标记支持的自定义属性如下:

• page
• page-handler
• route-*
• all-route-data
• host
• protocol
• area

page、page-handler 和 route-* 属性是您最常使用的。page 属性是最重要的;它采用要生成链接的页面名称(没有扩展名的路径,根位于 Pages 文件夹中),前面有一个正斜杠(例如,/Welcome)。您必须传入有效的页面名称,如果页面位于子文件夹中,则包括路径分隔符。因此,如果要生成链接的页面位于 \Pages\Admin\Index.cshtml,则传递给 page 属性的值将为 /Admin/Index。如果路由系统找不到匹配的页面,它将生成一个带有空字符串的 href 属性。因此,如果您发现定位标记帮助程序的呈现输出意外地将您带到应用程序的主页,则应仔细检查分配给 page 属性的值。

某些页面具有多个路由模板。在这种情况下,最后一个模板将用于生成 href 值(参见图 4.7)。名为 Index.cshtml 的文件的默认约定是生成两个路由模板;第一个是 Index,最后一个是空字符串,如前所述。将 /Index 或 /Admin/Index 传递给 asp-page 属性时,生成的输出为 href=“/” 或 href=“/Admin/”。您之前通过 Startup 中的 AddPageRoute 方法为 Index 页面添加了第三个路由模板 — “FindMe”,该方法将生成 href 值。因此,<a asp-page=“/Index”>Home</a> 的呈现 HTML 将为 <a href=“/FindMe”>Home</a>

图 4.7 属于最后一个 SelectorModel 的模板用于生成 URL。

page-handler 属性用于指定应执行的命名处理程序方法的名称。其工作方式与上一章中讨论使用 Search 处理程序和 Register 处理程序的命名处理程序时看到的表单标记帮助程序的工作方式相同。默认情况下,传递给 page-handler 属性的值将应用于具有名为 “handler” 的键的查询字符串:

?handler=Search

您可以通过更改页面的路由模板以包含名为 “handler” 的参数来更改此行为,以便处理程序的名称成为 URL 的一部分。这通常作为可选参数添加,因此也可以访问常规的 OnGet 和 OnPost 处理程序:

@page "{handler?}"

route- 属性适用于路由参数值,其中 表示参数的名称。下面是一个标签帮助程序,它使用您在路由模板中指定的 cityName 参数生成指向罗马城市页面的链接:

<a asp-page="/City" asp-route-cityName="Rome">Rome</a>

如果参数不是路由模板的一部分,则传递给 route-* 属性的名称和值将应用于 URL 查询字符串。因此,如果将上一个示例中的 route 属性替换为 asp-route-foo=“bah”,则生成的 URL 将为 /City?foo=bah。

all-route-data 参数采用 Dictionary<string, string> 作为值,用于包装多个路由参数值。它是为了方便您,让您不必添加多个 route-* 属性:

var d = new Dictionary<string, string> { { "cityName", "Madrid" },{ "rating", 
 "5" } };
<a asp-page="/City" asp-all-route-data="d">Click</a>

锚点标签帮助程序的默认行为是根据目标页面的位置生成相对 URL。假设您选择使用 protocol 属性指定协议(例如 HTTPS)或使用 host 属性指定主机名(域)。在这种情况下,锚点标记帮助程序将使用指定的值生成绝对 URL。

列表中的最后一个属性是 area 属性,用于指定目标页面所在的区域。区域的名称将作为生成的 URL 的第一个段包含在内。

Razor Pages 中的区域

Razor Pages 中的区域功能很奇怪,因为了解它最重要的事情是您通常应该避免使用它。在本书中,我根本不会费心提及 areas,除了默认模板包含具有 area 属性的锚点标签帮助程序(尽管它们分配了一个空字符串),并且 areas 在 Identity 框架中使用,您将在第 9 章中介绍。
区域源自 MVC 框架。MVC 框架的一个问题是,将控制器类放在名为 Controllers 的文件夹中,将视图文件放在名为 Views 的文件夹中,这是由自动代码基架系统强制执行的约定。在大型应用程序中,此文件夹中可能会有大量的控制器类文件。区域功能旨在帮助将大型应用程序分解为单独的子应用程序。MVC 项目中的每个区域都有其自己的 Controllers、Views 和 Models 文件夹。通过将区域名称作为 URL 中的第一个段来访问区域的内容,因此该区域还实施一个层次结构以进行路由。您可以通过向 Pages 文件夹添加新的子文件夹,在 Razor Pages 中实现完全相同的分层路由效果,这通常是不建议在 Razor Pages 中使用区域的原因。它们大大增加了更容易解决的问题的复杂性。
在 Razor Pages 中启用区域的主要原因是它们促进了 Razor 类库,这是本书不会详细介绍的另一个功能。如果您有兴趣了解有关 Razor 类库的更多信息,可以参考官方文档 (http://mng.bz/o2zd)。Identity 框架包括一个示例 UI,该 UI 作为 Razor 类库实现。因此,虽然您不会添加任何自己的区域,但如果您想自定义 Identity UI,您很可能会发现自己必须使用区域。

4.3.2 使用 IUrlHelper 生成 URL

锚标签助手用于为内部链接生成 URL,但有时,您需要生成将用于其他目的的 URL。您可能需要生成一个 URL,该 URL 将包含在电子邮件中,例如,您经常需要单击以验证您在网站上的注册的 URL。或者,您可能需要在 Razor 页面上的 img 标记中生成 URL。IUrlHelper 服务可用于此目的。它通过 PageModel 类的 Url 属性和 Razor 页面本身在 Razor Pages 中提供给您。

Url 属性具有许多方法,其中两种方法在 Razor Pages 应用程序中特别有趣:Page 方法和 PageLink 方法。Page 方法提供了许多版本或重载,这些版本或重载从传递给它的页面名称以及页面处理程序方法和路由值生成相对 URL 作为字符串。其他重载可用于生成绝对 URL。PageLink 方法根据当前请求生成绝对 URL。下面的示例演示如何使用 Page 方法生成包含路由数据的相对 URL,该 URL 作为匿名类型传递到 Page 方法,其中属性名称与路由参数名称匹配。

清单 4.9 使用 IUrlHelper 生成相对 URL

public class IndexModel : PageModel
{
    public void OnGet()
    {
        var target = Url.Page("City", new { cityName = "Berlin", rating = 4 });
    }
}

根据清单 4.4 中的 “{cityName}/{rating?}” 模板,目标变量的值为 “/City/Berlin/4”。匿名类型中名称与参数名称不匹配的任何属性都将添加到 URL 查询字符串中。

下一个清单演示了如何使用 PageLink 方法生成绝对 URL。基于同一路由模板的输出为“https://localhost :5001/City/Berlin/4”。

清单 4.10 使用 IUrlHelper 生成绝对 URL

public class IndexModel : PageModel
{
    public void OnGet()
    {
        var target = Url.PageLink("City", values: new { cityName = "Berlin", 
         rating = 4 });
    }
}

4.3.3 从 ActionResults 生成重定向 URL

在上一章的结尾,我们研究了不同的 ActionResult 类型,这些类型可以用作处理程序方法的返回类型,以及用于创建 ActionResult 类实例的帮助程序方法。其中两个帮助程序方法生成作为响应的一部分包含在位置标头中的 URL:RedirectToPage 和 RedirectToPagePermanent。这两种方法都用于指示浏览器重定向到生成的 URL。该方法 RedirectToPage 还会生成 HTTP 302 状态代码,该代码指示位置更改是暂时的。相比之下,该方法 RedirectToPagePermanent 会生成 301 HTTP 状态代码,指示应将重定向视为表示永久更改。当您想要通知搜索引擎资源已移动到新 URL 时,通常会使用后一种方法。下面的清单显示了如何使用 RedirectToPage 方法生成将用户发送到特定城市页面的 RedirectToPageResult。

列表 4.11 使用 IUrlHelper 生成绝对 URL

public class IndexModel : PageModel
{
    public RedirectToPageResult OnGet()                                        ❶
    {
        return RedirectToPage("City", new { cityName = "Berlin", rating = 4 });❷
    }
}

❶ 最好尽可能具体地设置处理程序返回类型,在本例中为 RedirectToPageResult。
❷ 用户将被定向到 /City/Berlin/4。

如果要将用户重定向到某个区域内的页面,则必须将区域名称作为路由参数传递:

return RedirectToPage("/Account/Login", new { area = "Identity" });

4.3.4 自定义 URL 生成

在本章的开头,我指出 URL 应被视为 Web 应用程序 UI 的一部分。在此基础上,您需要能够尽可能多地控制他们的生成。理想情况下,您希望您的 URL 可读且令人难忘。用于生成 URL 的约定可能不适合您的目的。例如,通常将 Pascal 大小写(复合词中每个单词的首字母大写)用于 Razor 页面名称。默认情况下,页面路由使用与页面名称相同的大小写。如果您像我一样,喜欢在应用程序中使用小写 URL,则可以使用 RouteOptions 对象在 Program 类中对其进行配置。它与之前用于添加自定义约束的 RouteOptions 对象相同。它有一个名为 LowercaseUrls 的 Boolean 属性,当设置为 true 时,将导致以小写形式生成出站 URL 的 path 部分。

清单 4.12 配置出站 URL 路径以使用小写

builder.Services.Configure<RouteOptions>(options =>
{
    options.LowercaseUrls = true;                               ❶
    options.ConstraintMap.Add("city", typeof(CityConstraint));   
});

❶ 将小写设置为生成的 URL 的 path 部分的默认值。

使用 RouteOptions 对象,您可以通过其 LowercaseQueryStrings 属性(也是一个布尔值)对查询字符串应用相同的大小写首选项:

options.LowercaseQueryStrings = true;

今后,您在阅读本书时将构建的应用程序将把小写 URL 设置为 true。您不会对查询字符串值应用小写,因为这会破坏安全令牌,您将在第 9 章中作为用户管理的一部分使用。

有些人喜欢他们的 URL 以正斜杠 (/) 结尾。从技术上讲,这样做没有任何好处,但如果您选择实现此模式,则必须保持一致,因为搜索引擎将 City/London 和 City/London/ 视为两个不同的 URL。RouteOptions 对象包含名为 AppendTrailingSlash 的属性,该属性在设置为 true 时始终会导致附加斜杠:

options.AppendTrailingSlash = true;

4.3.5 使用参数变换器自定义路由和参数值生成

现在,您已以小写形式生成 URL,但您可能仍需要自定义 URL 生成的某些方面。假设你有一个名为 CityReview.cshtml 的页面。为此页面生成的 URL 将为 /cityreview,而您可能希望复合词 CityReview 中的每个单词都用连字符分隔:city-review。这被称为烤肉串案例(想想烤串)。您可以通过将页面命名为 City-Review.cshtml 来实现此目的,这将生成名为 City_ReviewModel 的 PageModel。如果您不喜欢 PageModel 类的名称中包含下划线,则可以更改该类的名称,但您可能还受到某些全局命名约束的约束,这些约束会阻止您首先在页面名称中包含连字符。另一种可能的解决方案是使用 AddPageRoute 方法应用其他路由模板,该模板将用于 URL 生成,但您需要记住,对于名称中可能包含复合词的所有其他页面,您都要这样做。因此,理想情况下,您需要一个影响应用程序中所有页面的全局解决方案。参数变压器提供全局解决方案。

参数转换器是实现 IOutboundParameterTransformer 接口的类,该接口指定一种方法:TransformOutbound。该方法将对象作为参数并返回字符串。尽管名称如此,但参数转换器可用于转换生成的页面路由以及参数值,具体取决于它在应用程序中的注册方式。转换逻辑放置在 TransformOutbound 方法中。

下面的清单显示了一个参数转换器,该参数转换器在 Pascal 大小写复合词中第二个和后续单词的第一个字母之前插入连字符,因此 CityReview 变为 City-Review。

Listing 4.13 作用于页面路由的参数转换器

using Microsoft.AspNetCore.Routing;                                        ❶
using System.Text.RegularExpressions;                                      ❷

public class KebabPageRouteParameterTransformer :                          ❸
 IOutboundParameterTransformer                                           ❸
{                                                                          ❸
    public string TransformOutbound(object value)                          ❸
    {
        if (value == null)                                                 ❹
        {
            return null;
        }
        return Regex.Replace(value.ToString(), "([a-z])([A-Z])", "$1-$2"); ❺
    }
}

❶ IOutboundParameterTransformer 位于 Microsoft.AspNetCore.Routing 命名空间中。
❷ 你在方法体中使用了一个正则表达式,所以你需要为适当的命名空间添加一个 using 指令。
❸ 该类实现 IOutboundParameterTransformer 接口及其 TransformOutbound 方法。
❹ 需要进行 null 检查,以防传入的页面名称不正确。
❺ 一个简单的(如果有的话)正则表达式,用于标识字符串中大写字母跟在小写字母后面的位置,并在找到它们的位置插入连字符

此特定转换器旨在处理页面的路由,而不是参数值,因此必须将其注册为 PageRouteTransformerConvention。注册在 Program 类中进行,就像本章前面的 CultureTemplatePageRouteModelConvention 一样:

builder.Services.AddRazorPages().AddRazorPagesOptions(options => {
    options.Conventions.AddPageRoute("/Index", "FindMe");
    options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
    options.Conventions.Add(new PageRouteTransformerConvention(
        new KebabPageRouteParameterTransformer())); 
});

PageRouteTransformerConvention 类型实现 IPageRouteModelConvention 接口。您之前已作为 CultureTemplatePageRouteModelConvention 的一部分介绍了该接口及其 Apply 方法。当应用程序启动时,您的新参数 transformer 将分配给 PageRouteTransformerConvention 的 Apply 方法中的 PageRouteModel 的 RouteParameterTransformer 属性。从那里,每当需要出站路由时,都会应用其逻辑。

变换路径参数值

我们了解如何管理 URL 生成的最后一部分侧重于自定义路由参数值。假设您有一个城市列表,并且您希望为每个城市生成链接作为 City 页面的 route 参数值。将您在前几章中学到的知识付诸实践,您可能会想出一些代码,这些代码类似于 PageModel 类中的以下清单。

清单 4.14 在 PageModel 中生成城市数组

public class IndexModel : PageModel
{ 
    public string[] Cities { get; set; }                           ❶
    public void OnGet()
    {
        Cities = new[] { "London", "Berlin", "Paris", "Rome" };    ❷
    }
}

❶ 字符串数组在 PageModel 上声明为公共属性。
❷ 它被分配了许多城市的值。

您将循环访问 Razor 页面中的数组,并使用 anchor 标记帮助程序呈现每个城市的链接。

清单 4.15 生成每个城市的链接列表

<ul>
@foreach (var city in Model.Cities)
{
    <li><a asp-page="/City" asp-route-cityName="@city">@city</a></li>
}
</ul>

请记住,你已将 LowercaseUrls 设置为 true,因此生成的 HTML 如图 4.8 所示。

图 4.8 anchor 标签从数据中呈现的链接

这正是您想要的,但是如果您将 New York 包含在集合中会发生什么?嗯,当呈现锚标签时,两个单词之间的空格被 URL 编码为 %20(图 4.9)。

图 4.9 默认情况下,URL 中的空格编码为 %20。

这不是一个好看的外观,特别是如果你的链接开始出现在搜索引擎结果中,其中有很多东西可以最好地被普通访问者描述为其中的 gobbledygook。你真的想使用与 routes 相同的 kebab 大小写来渲染这些 route 值,以使其更具可读性,这次将参数值中的空格替换为连字符。当 URL 包含博客文章标题或书名等内容时,您经常会看到此类内容,例如,这本书的 URL 为:www.manning.com/books/ asp-net-core-razor-pages-in-action。URL 的粗体部分通常称为 slug。

现在,您将使用另一个参数转换器转换参数值。此代码如下面的清单所示。

Listing 4.16 在路由参数值中将空格转换为连字符

public class SlugParameterTransformer :                ❶
 IOutboundParameterTransformer                       ❶
{                                                      ❶
    public string TransformOutbound(object value)      ❶
    {
        return value?.ToString().Replace(" ", "-");    ❷
    }
}

❶ 该类实现 IOutboundParameterTransformer 及其 TransformOutbound 方法。
❷ 处理逻辑只是用连字符替换空格。

这种特殊的实现非常幼稚。例如,它不考虑正在转换的值中的任何现有连字符或任何双空格,但作为示例就足够了。作用于路由参数值的参数转换器的注册过程与之前用于注册自定义路由约束的过程非常相似。参数 transformer 被添加到 RouteOptions 对象的 ConstraintMap 属性中,并且它被分配给目标路由参数,其方式与路由约束相同。下一个清单显示了如何将 SlugParameterTransformer 注册为 RouteOptions 的一部分。

Listing 4.17 像 constraint 一样注册一个参数转换器

builder.Services.Configure<RouteOptions>(options =>
{
    options.LowercaseUrls = true;
    options.ConstraintMap.Add("city", typeof(CityConstraint));
    options.ConstraintMap.Add("slug", typeof(SlugParameterTransformer));
});

注册参数 transformer 后,您可以将其应用于选定的路由参数,其方式与具有注册时指定的名称的路由约束相同:

@page "{name:slug}/{rating?}"

注意尽管您以类似于 route constraint 的方式注册和应用了参数 transformer,但参数 transformer 的作用与 constraint 不同。它在将 URL 映射到路由模板方面不起任何作用。

现在,当您重新运行应用程序时,指向 New York 页面的链接看起来要好得多(图 4.10)。

图 4.10 参数 transformer 适用于呼出路由。

我们对 Razor Pages 中的路由系统的探索到此结束。你已了解将 URL 映射到路由模板的默认行为,路由模板是从应用程序中的 Razor 页面位置生成的。您还学习了如何基于单个页面和全局自定义此行为,以及如何使用路由参数在 URL 中传递数据。

你已了解如何使用定位标记帮助程序生成链接的 URL,以及如何通过 Razor 页面和 PageModel 的 Url 属性使用 IUrlHelper 生成 URL,以便可能用于其他目的。最后,您研究了如何使用 RouteOptions 和参数转换器自定义生成的 URL。下一章重点介绍如何使用 Razor Pages 应用程序中的表单来捕获和处理数据。

总结

路由是将传入 URL 映射到终端节点并生成传出 URL 的过程。
路由是使用 UseRouting 和 UseEndpoints 方法作为中间件添加的。
路由模板表示要匹配的终端节点。它们由文本、分隔符和参数组成,这些文本、分隔符和参数表示 URL 中的数据。
路由是从磁盘上 Razor 页面的位置生成的。
您可以通过向 @page 指令提供新模板来自定义各个页面路由。
您可以使用路由约束来消除类似路由模式之间的歧义。
您可以使用 AddPageRoute 向页面添加其他路由。
或者,您可以创建自己的 PageRouteModelConvention 来自定义多个页面的路由。
定位点标记帮助程序根据路由系统生成传出 URL,IUrlHelper 也是如此。
RouteOptions 对象提供了一些自定义 URL 生成过程的方法。
您可以使用参数转换器来完全控制 URL 生成过程。

ASP.NET Core Razor Pages in Action 3 使用 Razor Pages

ASP.NET Core Razor Pages in Action 3 使用 Razor Pages

本章涵盖

• 使用 Razor 模板生成 HTML
• 学习 Razor 语法
• 使用布局、局部和标记帮助程序
• 将 PageModel 理解为控制器和视图模型
• 使用处理程序方法和 IActionResult

此时,您应该对 Razor Pages 应用程序的工作部分有很好的了解,包括如何创建一个应用程序、生成文件的角色以及如何通过请求管道配置应用程序的行为。现在,你已准备好深入了解如何使用 Razor Pages 应用程序中的主要参与者:Razor 页面本身。

在学习本章时,您将学习如何使用 Razor 语法生成动态 HTML 并协调布局和部分文件,以减少代码重复并提高重用率。您已经简要介绍了布局和部分,但要提醒您,布局充当多个页面的一种主模板,而部分文件由可插入主机页面或布局的 UI 片段组成。

您还将了解 PageModel 类,这是 Razor Pages 的一项基本功能,它既充当 MVC 控制器又充当视图模型,或者充当特定于特定视图或页面的数据容器。您将探索如何使用 PageModel 的视图模型方面以强类型方式向 Razor 页面公开数据,这将提高您作为开发人员的效率。这样做还使您能够有效地使用标记帮助程序,或使服务器端代码能够参与 HTML 生成过程的组件。您将了解 ASP.NET Core 中提供的一些不同类型的标签帮助程序以及如何使用它们。

最后,您将看到 PageModel 对象如何充当页面控制器,处理页面请求并决定使用哪个模型和视图。处理程序方法在请求处理中起着重要作用,您将了解其使用背后的重要约定以及它们通常使用的返回类型 (IActionResult)。

3.1 使用 Razor 语法

所有 Web 开发框架都需要能够动态生成 HTML。它们几乎完全依赖于一种称为 Template View 的设计模式。此模式涉及使用由嵌入在 HTML 中的服务器端代码组成的标记或占位符,这些代码解析为对处理和呈现动态内容的调用。

动态内容可以采用多种形式。它通常采用从数据存储(例如数据库)中提取的数据的形式,但如您所见,它也可以只是一些计算的结果,例如一天中的时间。除了内容本身之外,您还需要嵌入服务器端代码来控制内容的呈现。例如,如果动态内容是一个集合(如列表),则需要在代码中迭代它以显示每个项目。或者,您可能只需要在某些条件下显示数据,例如,如果用户有权查看数据。因此,除了使您能够将数据实例嵌入到页面中之外,模板语法还必须使您能够包含控制页面内处理的语句块。

许多框架(如旧版本的 ASP.NET、PHP、Ruby on Rails 和 Java Server Pages)使用类似 HTML 的标记作为标记,让模板处理器知道 HTML 和服务器端代码之间的转换位置。Razor 语法使用 @ 符号作为过渡标记,并具有几个简单的规则。第一条规则是 Razor 语法仅适用于扩展名为 .cshtml 的 Razor 文件的内容。这是 cs 代表 C Sharp 和 html 代表超文本标记语言的混合体。以下部分介绍了更多规则,这些部分研究了使用 Razor 语法的特定方案,首先介绍如何向 Razor 页面添加指令和代码块。

3.1.1 指令和代码块

首先,让我们再看一下上一章中用于计算一天中的时间的代码,了解如何使用 Razor 语法在 Razor 页面中包含服务器端 C# 代码。清单 3.1 中的示例演示了 Razor 语法的三个方面:如何在页面中包含指令,如何包含 C# 代码的独立块,以及如何在呈现的 HTML 中包含 C# 表达式的结果或变量的值。

清单 3.1 在 Welcome 页面中回顾 Razor 语法

@page                                                               ❶
@model WebApplication1.Pages.WelcomeModel                           ❶
@{                                                                  ❷
    ViewData["Title"] = "Welcome!";                                 ❷

    var partOfDay = "morning";                                      ❷
    if(DateTime.Now.Hour > 12){                                     ❷
        partOfDay = "afternoon";                                    ❷
    }                                                               ❷
    if(DateTime.Now.Hour > 18){                                     ❷
        partOfDay = "evening";                                      ❷
    }                                                               ❷
}                                                                   ❷
<h1>Welcome</h1>
<p>It is @partOfDay on @DateTime.Now.ToString("dddd, dd MMMM")</p>  ❸

❶ 指令
❷ C# 代码块
❸ 作为输出的一部分呈现的 C# 内联表达式

此示例中 @ 符号的第一个实例演示了如何将指令添加到页面。指令是 C# 表达式,以 @ 符号开头,后跟保留字(例如,page 或 model),并启用页面内的功能或更改内容的解析方式。支持多个指令。page 指令将此文件表示表示可导航页面,如果它表示要浏览的页面,则它必须出现在 CSHTML 文件的顶行中。model 指令指定充当此页面模型的数据类型,默认情况下,该数据类型是页面附带的 PageModel 类。PageModel 是本章后面要关注的重点。

下一个最常用的指令可能是 using 指令,它将命名空间引入范围,因此可以在不使用其完全限定名称的情况下引用它们的类型。下一个清单说明了用于简化在 System.IO 中使用静态 Path 类的 using static 指令,否则该指令将与 Razor 页面的 Path 属性冲突。

清单 3.2 使用 Razor 语法添加 using 指令

@page
@model WebApplication1.Pages.WelcomeModel
@using static System.IO.Path                       ❶
@{
    var extension = GetExtension("somefile.ext");  ❷
}

❶ using static 指令使静态 Path 类可用,而无需指定类名。请注意,using 指令的末尾没有分号,就像 C# 代码文件中那样。分号在 Razor 文件中是可选的。
❷ 调用静态 Path.GetExtension 方法,而无需包含类名。

Razor 页面支持许多指令。有些是特定于页面的,例如 page 和 model 指令,但其他一些,包括 using 指令,可以通过将它们包含在 ViewImports 文件中来应用于多个页面。

ViewImports 文件是一种名为 _ViewImports.cshtml 的特殊类型的文件,它提供了一种机制,用于集中适用于 CSHTML 文件的指令,因此无需像在前面的示例中对 System.IO.Path 所做的那样,将它们单独添加到 Razor 页面。默认的 ViewImports 文件包括三个指令:

• 引用项目命名空间的 using 指令(在我们的示例中为 WebApplication1)
• 一个命名空间指令,用于为受 ViewImports (WebApplication1.Pages) 影响的所有页面设置命名空间
• 用于管理标签帮助程序的 addTagHelper 指令

标记帮助程序是与标记中的标记一起使用以自动生成 HTML 的组件。本章稍后将更详细地介绍它们。

ViewImports 文件中的指令会影响位于同一文件夹及其子文件夹中的所有 .cshtml 文件。Razor Pages 应用程序可以支持的 ViewImports 文件数没有限制。您可以将其他 ViewImports 文件放在子文件夹中,以添加到顶级 ViewImports 文件的指令或覆盖其设置。某些指令,例如用于管理标签帮助程序的指令、using 指令和 inject 指令(用于使服务(在第 7 章中介绍)对页面可用)是累加的,而其他指令会随着您靠近页面而相互覆盖。因此,例如,如果为该子文件夹中的 ViewImports 文件中的 namespace 指令分配了不同的值,则 Pages 文件夹中的 ViewImports 中指定的命名空间将被覆盖该子文件夹中的页面。

清单 3.1 中第二个突出显示的项目是一个代码块。代码块以 @ 符号开头,后跟左大括号,然后是右大括号:

@{
    ... C# code goes here
}

放置在代码块中的任何内容都是纯 C# 代码,必须遵循 C# 语法规则。您可以在 Razor 页面中包含多个代码块,但应将它们保持在最低限度,仅将它们限制为用于管理演示文稿的逻辑。Razor 页面中的代码块过多通常表明 UI 中可能有应用程序逻辑,应避免这种情况,因为当它混合在 HTML 中时,很难测试。例如,计算一天中时间的逻辑不应位于 Razor 页面中。它应该位于 PageModel 类中,该类可以单独测试,或者如果算法可能在多个位置使用,则应将其放在自己的类中。在本章后面,您将把算法移动到 PageModel 类中。

Razor 还支持另一种类型的代码块:函数块。通过添加 functions 指令,然后左大括号和右大括号来创建 functions 块:

@functions{
    ... C# code goes here
}

同样,functions 块中的代码是纯 C#。您可以将计算一天中的部分时间的算法重构为 functions 块,如下所示。

清单 3.3 在 functions 块中声明方法

@functions{
    string GetPartOfDay(DateTime dt)
    {
        var partOfDay = "morning";
        if (dt.Hour > 12)
        {
            partOfDay = "afternoon";
        }
        if (dt.Hour > 18)
        {
            partOfDay = "evening";
        }
        return partOfDay; 
   }
}
<p>It is @GetPartOfDay(DateTime.Now)</p>

您还可以将此方法添加到标准代码块中。标准代码块和功能块之间的区别在于,功能块支持公共成员的声明,而标准代码块不支持公共成员。但是,通常建议尽量减少功能块的使用,原因与完全减少代码块的原因相同。它们鼓励将应用程序代码与 HTML 混合,使其难以重用、隔离和测试。

在 Razor 页面文件中适当使用功能块将包括管理表示逻辑的小例程,并且仅适用于放置它们的页面。它们对于您当前的目的也很有用,即简化 Razor 语法的学习,而无需在文件之间切换。

3.1.2 使用表达式呈现 HTML

Razor 的主要用途是将动态内容呈现为 HTML。您已经了解了如何将变量或表达式的值呈现给浏览器,方法是将它们内联放置在要输出值的 HTML 中,并在它们前面加上 @ 符号:

<p>It is @partOfDay on @DateTime.Now.ToString("dddd, dd MMMM")</p>

此示例中的表达式称为 隐式表达式。在 Razor 文件中经常使用的另一种表达式类型是显式表达式,其中表达式本身位于括号内,并以 @ 符号为前缀。通常在表达式中有空格或表达式包含尖括号(即 < 和 >)的情况下使用显式表达式,例如在泛型方法中。如果不将此类表达式放在括号内,则尖括号将被视为 HTML。下面是一个涉及使用三元运算符的表达式示例。表达式中包含空格,因此它必须作为显式表达式包含在 Razor 文件中:

<p>It is @(DateTime.Now.Hour > 12 ? "PM" : "AM")</p>

此示例将向浏览器呈现 “PM” 或 “AM”,具体取决于执行表达式的时间。

3.1.3 Razor 中的控制方块

服务器端代码主要用于 Razor 文件中,以控制演示输出。因此,您使用的大多数 Razor 语法都将由控制块组成,例如,页面中的选择和迭代语句(如 if-else、foreach 等),这些语句应用处理逻辑来有条件地呈现输出或循环访问项集合。这些控制块与您之前看到的代码块的不同之处在于,它们嵌入在要呈现的 HTML 内容中,而不是与大括号内的标记隔离。

Razor 支持使用 C# 选择和迭代语句,方法是在打开块的关键字前面加上 @ 符号。下面的清单演示了如何将其应用于 if-else 语句。

列表 3.4 Razor 中的选择语句支持

@if(DateTime.Now.Hour <= 12)
{
    <p>It is morning</p>
}
else if (DateTime.Now.Hour <= 18) 
{ 
    <p>It is afternoon</p>
}
else
{
    <p>It is evening</p>
}

在此示例中,if-else 语句的工作原理是仅根据正在测试的条件(在本例中为执行页面的时间)呈现其中一个段落。请注意,在 else 关键字之前不需要 @ 符号。事实上,如果您尝试这样做,将导致错误。

清单 3.5 说明了使用 switch 语句作为清单 3.4 中 if-else 块的替代方案。同样,@ 符号仅在 opening switch 关键字之前是必需的。

列表 3.5 Razor 中的 Switch 语句示例

@switch (DateTime.Now.Hour)
{
    case int _ when DateTime.Now.Hour <= 12:
        <p>It is morning</p>
        break;
    case int _ when DateTime.Now.Hour <= 18:
        <p>It is afternoon</p>
        break;
    default:
        <p>It is evening</p>
        break;
}

在迭代集合进行渲染时,您经常会发现自己需要在 Razor 页面中使用迭代语句。假设您正在创建一个度假套餐网站,并且您需要呈现可能目的地的列表。以下清单中的代码演示了如何使用 foreach 语句将城市名称数组的成员呈现为无序列表。

列表 3.6 Razor 中的 foreach 语句示例

@functions{
    public class City
    {
        public string Name { get; set; }
        public string Country { get; set; }
    }
    List<City> cities = new List<City>{
        new City { Name = "London", Country = "UK" },
        new City { Name = "Paris", Country = "France" },
        new City { Name = "Rome", Country = "Italy" } ,
        new City { Name = "Berlin", Country = "Germany" },
        new City { Name = "Washington DC", Country = "USA" }
    };
}

<ul>
    @foreach (var city in cities)
    {
        <li>@city.Name</li>
    }
</ul>

3.1.4 渲染文本字符串

到目前为止,所有示例都显示了 Razor 在 HTML 和 C# 代码之间的转换。@ 符号后面的任何内容都被视为 C# 代码,直到遇到 HTML 标记。有时你可能需要渲染文本字符串而不是 HTML。有两种方法可以告诉 Razor 值是文本字符串,而不是 C# 代码。第一种方法是在每行中文本字符串的第一个实例前面加上 @:。

清单 3.7 在 Razor 中渲染文本字符串

@foreach (var city in cities)
{
    if (city.Country == "UK")
    {
        @:Country:  @city.Country, Name: @city.Name
    }
}

或者,您也可以使用 标记将内容括起来。如果您正在处理多行内容,这尤其有效,如下面的清单所示。

清单 3.8 使用 text 标签渲染多行文字字符串

@foreach (var city in cities)
{
    if (city.Country == "UK")
    {
        <text>Country:  @city.Country<br />
        Name: @city.Name</text>
    }
}

文本标记不会作为输出的一部分呈现;仅呈现其内容。此外,使用文本标签会导致从出现在它们之前或之后的输出中删除空格。

3.1.5 渲染文本 HTML

Razor 的默认行为是按字面呈现 Razor 页面中的任何标记,但将 HTML 编码应用于解析为字符串的所有表达式的结果。任何非 ASCII 字符以及可能不安全的字符(可能有助于将恶意脚本注入网页的字符),如 <、> 和 “,都被编码为它们的 HTML 等效字符:<、>、&、” 等。 下面的清单显示了一些 HTML 被分配给呈现给浏览器的变量。

列表 3.9 分配给渲染的输出变量的 HTML

@{
    var output = "<p>This is a paragraph.</p>";
}
@output

生成的 HTML 是

<p>This is a paragraph.</p>

图 3.1 演示了它在浏览器中的显示方式。

图 3.1 默认情况下,非 ASCII 和特殊 HTML 字符被编码以供输出。

如果你有一个 HTML 字符串,并且不希望 Razor 对其进行编码,则可以使用 Html.Raw 方法来阻止编码:

@Html.Raw(“<p>This is a paragraph.</p>”)

这适用于以下方案:例如,将 HTML 存储在数据库中以供显示 - 这是大多数内容管理系统等的典型要求。但是,您应该确保在将 HTML 包含在页面之前对其进行清理。否则,您可能会让您的站点受到脚本注入攻击。您将在第 13 章中了解这些漏洞和其他漏洞。

对于以大量使用或专门使用非 ASCII 字符的语言存储和输出内容的网站的站点的开发人员来说,默认情况下应用的激进编码级别可能并不可取。以这段德语为例(翻译过来就是 “Charges for oversized luggage”):

var output = "Gebühren für übergroßes Gepäck";

当它作为 @output 嵌入到标记中时,将编码为以下内容:

Gebühren für übergroßes Gepäck

对于非拉丁语语言(如西里尔语、中文和阿拉伯语),每个字符都编码为其 HTML 等效项,这可能会显著增加生成的源代码中的字符数。虽然输出可以正确呈现,但页面的最终大小和整体网站性能可能会受到不利影响。

可以安排更广泛的字符,因此不会对它们进行编码。您可以通过设置 WebEncoderOptions 在 Program.cs 中执行此作。默认情况下,允许的字符范围(即未编码的字符)仅限于基本拉丁字符集。清单 3.10 演示了如何配置选项以将 Latin-1 Supplement 字符集添加到允许的范围,其中包括重音元音和德语 eszett 字符 (ß)。

清单 3.10 配置 WebEncoderOptions 以添加 Latin-1 补充

builder.Services.Configure<WebEncoderOptions>(options =>
{
    options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.BasicLatin, UnicodeRanges.Latin1Supplement);
});

请注意,您在此处设置的任何内容都将覆盖默认设置,这就是为什么您需要包含 BasicLatin 范围的原因。如果您不确定应该包含哪些字符集,可以在此处查看:http://www.unicode.org/charts/。或者,您可以只指定 UnicodeRanges.All。

模板化 Razor 委托

模板化 Razor 委托功能使你能够使用委托创建 Razor 模板并将其分配给变量以供重复使用。你可能还记得我们在上一章中对中间件的讨论,委托是一种表示方法签名和返回类型的类型。Razor 模板委托表示为 Func<dynamic、object>(泛型委托)。该方法的主体包含 Razor 的片段,其开始的 HTML 标记以 @ 符号为前缀。input 参数表示数据,并且是动态类型,因此它可以表示任何内容。数据可通过名为 item 的参数在模板中访问。

在列表 3.6 中,我们创建了一个城市列表,然后使用嵌入在 HTML 中的 foreach 语句将其呈现给浏览器,以呈现一个无序列表。在示例 3.11 中,我们将无序列表的渲染提取到一个模板中,该模板构成了委托的主体。

myUnorderedListTemplate 变量定义为 Func<dynamic, object>,与模板化 Razor 委托的定义匹配。在该数据库中,假定 item 参数表示城市的集合。这些被迭代并呈现为无序列表。以下清单显示了如何将列表的生成分配给 Razor 模板委托。

清单 3.11 定义模板化 Razor 委托

@{
    Func<dynamic, object> myUnorderedListTemplate = @<ul>
        @foreach (var city in item)
        {
            <li>@city.Name</li>
        }
    </ul>;
}

定义模板后,您可以将清单 3.6 中生成的数据传递到其中:

@myUnorderedListTemplate(cities)

此示例依赖于动态输入参数,这会导致潜在的错误,这些错误仅在运行时出现,例如,如果您拼写了属性的名称错误,或者尝试访问不存在的成员。您可以使用强类型来限制模板接受的类型,如下面的清单所示,其中 dynamic 参数已替换为 List

清单 3.12 缩小模板化 Razor 委托中数据项的类型

@{
    Func<List<City>, object> myUnorderedListTemplate = @<ul>
        @foreach (var city in item)
        {
            <li>@city.Name</li>
        }
    </ul>;
}

模板化 Razor 委托的缺点之一是它只接受一个表示数据项的参数,尽管对数据类型没有限制。它可以表示复杂类型,因此,如果您的模板设计为使用多个城市列表,则可以创建一个包含模板所需的一切的包装器类型。

还有一个替代方法,可用于定义可以采用任意数量的参数的模板。要利用此功能,请在返回 void(或 Task,如果需要异步处理)的代码或函数块中创建一个方法,并且只在方法正文中包含 HTML 标记。

列表 3.13 在 Razor 页面中声明模板的替代方法

@{ 
   void MyUnorderedListTemplate(List<City> cities, string style)    ❶
    {
        <ul>                                                        ❷
        @foreach(var city in cities)
        {
            <li class="@(city.Name == "London" ? style : null)">@city.Name</li>
        }
        </ul>
    }
}
@{ MyUnorderedListTemplate(cities, "active"); }                     ❸

❶ 允许在返回 void 或 Task 的方法中使用标记。
❷ 开始标签没有 @ 符号的前缀。
❸ 由于该方法返回 void 或 Task,因此必须在代码块中调用它。

请注意,与模板委托不同,该方法可以指定任意数量的参数,并且前导 HTML 标记不以 @ 符号为前缀。此方法采用两个参数 — 第二个参数表示应有条件地应用于列表项的 CSS 类的名称。如果希望能够使用模板委托实现类似的功能,则需要创建一个包装 List<City> 和 String 的新类型。

无论是使用此方法还是模板委托,这些帮助程序都只能在同一 Razor 页面中重复使用。如果您想在多个页面中重用 HTML 代码段,则有更灵活的替代方案,包括部分页面、标签帮助程序和视图组件。我们将在本章后面更详细地介绍部分页面和标签帮助程序。视图组件在第 14 章中介绍。

Razor 中的注释

Razor 页面文件支持页面标记区域中的标准 HTML 注释和代码块中的 C# 注释。它们还支持 Razor 注释,这些注释以 @ 开头,以 @ 结尾。与 HTML 注释中的内容不同,Razor 注释之间的任何内容都不会呈现到浏览器。清单 3.14 中的代码显示了 HTML 注释中的 C# foreach 语句。呈现页面时,将处理 Razor 代码,生成的项列表在源代码中显示为注释。

列表 3.14 导致内容被渲染的 HTML 注释

<!--<ul>    
    @foreach(var city in cities)
    {
        <li>@city.Name</li>
    }
</ul>-->

以下清单在 Razor 注释中提供了相同的 foreach 语句。源代码中不包含任何内容。

列表 3.15 从源代码中排除 Razor 注释中的内容

@*<ul>    
    @foreach(var city in cities)
    {
        <li>@city.Name</li>
    }
</ul>*@

这样就完成了对 Razor 语法的了解,该语法演示了如何在 HTML 中嵌入服务器端代码以形成单个页面的模板。在以下部分中,您将了解如何使用布局页面和部件创建可跨多个页面重复使用的代码模板。

3.2 布局页面

这是一个罕见的网站,不会在多个页面之间共享通用内容。在本书的后面部分,您将构建一个提供度假套餐的 Web 应用程序。此类站点的轮廓草图很可能类似于图 3.2。

图 3.2 此示例中的页眉、导航、Deal of the Day 小部件和页脚旨在显示在所有页面上。主内容区域表示特定于每个页面的内容。

在示例草图中,页眉、导航、每日交易 Widget 和页脚旨在显示在网站的所有页面上。其中一些元素的实际内容可能因页面而异,但基本结构将适用于所有页面。主内容区域表示特定于页面的内容,该内容可能是 Contact Us (联系我们) 页面上的联系表单和邮政地址,也可能是特定位置提供的休息时间的详细信息。下面的清单显示了如何使用基本的 Bootstrap 样式将图像转换为 HTML。

清单 3.16 包含将要重复内容的网页的基本轮廓

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" 
    ➥ />
</head>
<body>
    <div class="container">
        <header class="card alert-success border-5 p-3 mt-3">Header</header>
        <nav class="card alert-primary border-5 p-3 mt-2">Navigation</nav>
        <div class="row mt-2">
            <div class="col-3">
                <div class="card alert-warning p-5 border-5">
                    Deal Of The Day Widget 1
                </div>
            </div>
            <div class="col-9 card border-5">
                Main Content
            </div>
        </div>
        <footer class="card border-5 p-3 mt-2">Footer</footer>
    </div>
</body>
</html>

如果您创建了多个页面,并且每个页面分别包含这些通用内容,则维护它的负担可能会变得难以忍受。每次向网站添加新页面时,都必须更新所有现有页面中的导航。理想情况下,您希望将这些重复内容集中在一个位置,以便于维护。这种方法被称为 DRY(不要重复自己)。DRY 是软件开发的基本原则之一。

布局页面支持使用 DRY 方法管理常见页面内容。它们充当引用它的所有内容页面的父模板或主模板。在将新页面添加到站点导航时,您已经简要地查看了示例应用程序中的布局页面。布局是扩展名为 .cshtml 的常规 Razor 文件,但使其充当布局的原因是它包含对 RenderBody 方法的调用,其中呈现特定于页面的内容,如下面的列表所示。

列表 3.17 一个 Razor 布局页面,包括对 RenderBody 的调用

<!DOCTYPE html>
<html>
<head>
    <title></title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" 
     />
</head>
<body>
    <div class="container">
        <header class="card alert-success border-5 p-3 mt-3">Header</header>
        <nav class="card alert-primary border-5 p-3 mt-2">Navigation</nav>
        <div class="row mt-2">
            <div class="col-3">
                <div class="card alert-warning p-5 border-5">
                    Deal Of The Day Widget 1
                </div>
            </div>
            <div class="col-9 card border-5">
                @RenderBody()                   ❶
            </div>
        </div>
        <footer class="card border-5 p-3 mt-2">Footer</footer>
    </div>
</body>
</html>

❶ 这是将内容页面的输出注入布局的点。

RenderBody 方法调用是布局页面的唯一要求。布局页面中的其他所有内容都包含在引用它的所有人气页面中,这使得管理它变得非常容易。您对布局所做的任何更改都会立即应用于引用该布局的所有页面。

3.2.1 指定 Layout 属性

特定页面的布局是通过页面的 Layout 属性以编程方式分配的。传递给 Layout 属性的值是一个字符串,表示不带扩展名的布局文件的名称或布局文件的路径。以下清单中的任何一种方法都可以使用。

示例 3.18 通过 Layout 属性设置布局页面

@{
    Layout = “_Layout”;
    Layout = “/Pages/Shared/_Layout.cshtml”;
}

使用第一种方法按名称设置布局时,框架会在多个预定义位置搜索具有匹配名称和预配置扩展名(默认为 .cshtml)的文件。首先搜索包含调用页面的文件夹,如果适用,将搜索层次结构中直到根 Pages 文件夹的所有文件夹。最后,再搜索两个位置:\Pages\Shared 和 \Views\Shared。后者是使用 MVC 框架本身构建的应用程序的遗留问题。例如,如果将调用页放在 \Pages\Admin\DestinationsOrders 中,则搜索位置将如下所示:

\Pages\Admin\DestinationsOrders\ _Layout.cshtml
\Pages\Admin\ _Layout.cshtml
\Pages\_Layout.cshtml
\Pages\Shared\ _Layout.cshtml
\Views\Shared\ _Layout.cshtml

如果您想将相同的布局分配给多个页面,那么逐页设置布局并不是一种非常有效的方法,原因与您最初使用布局的原因相同:在多个位置更新它成为一件苦差事。要解决此问题,您可以使用 ViewStart 文件。这是一个名为 _ViewStart.cshtml 的特殊 Razor 文件,您可以在 Pages 文件夹中找到该文件的示例。此文件通常仅包含一个代码块,尽管它也可以包含标记。ViewStart 文件中的代码在它影响的任何页面之前执行,该页面是同一文件夹和所有子文件夹中的任何 Razor 页面。图 3.3 显示了请求传入时的执行顺序。首先是 ViewStart 文件,然后是内容页,然后是布局文件中的任何代码。

图 3.3 Razor 文件的执行顺序:ViewStart,然后是内容页,然后是布局页

ViewStart 在它影响的任何页面之前执行,这使其成为为所有这些页面设置布局的理想方式。如果您查看现有 ViewStart 文件的内容,您会发现这正是它的作用:

@{
    Layout = “_Layout”;
}

我提到过,ViewStart 代码在页面中的代码之前执行,使您能够根据需要逐页更改全局设置。在单个页面中发生的任何布局分配都将覆盖 ViewStart。同样,如果您将其他 ViewStart 文件放在 Pages 文件夹层次结构的较低位置,则其中的布局分配将覆盖层次结构较高位置的 ViewStart 文件中的任何分配。

关于 layouts 的最后一点说明:可以嵌套 layout 文件,因此一个 layout 引用另一个 layout。为此,您需要在子布局中显式分配布局。图 3.4 显示了页面、嵌套(子)布局和主布局之间的关系。Index 内容将注入到放置 RenderBody 的嵌套布局中。合并的内容将注入到布局文件中。

图 3.4 索引页面引用 _ChildLayout 文件,而该文件又引用主 _Layout 文件。

您不能依赖 ViewStart 文件在子布局中设置父布局文件。ViewStart 文件对其文件夹或子文件夹中的布局没有影响。嵌套布局可以启用一些有价值的方案,在这些方案中,您希望为页面子集显示其他内容,例如,您将这些内容应用于子布局。

3.2.2 使用 sections 注入可选内容

在某些情况下,您可能希望某些内容页面能够选择性地提供其他基于 HTML 的内容,以作为布局的一部分进行呈现。图 3.5 显示了具有不同小组件的上一个布局:Things to Do。您可以想象此微件包含有关您当前正在查看的度假地点的感兴趣景点的其他信息 - 例如,如果您选择巴黎作为目的地,则参观埃菲尔铁塔。此小组件包含在布局区域中,该区域对所有页面都是通用的,但只有在选择目标后才会显示,并且其内容将取决于所选目标。

图 3.5 “待办事项” 小组件表示位于布局中的特定于页面的内容。

Razor 包含启用此方案的部分,这些部分使用内容页中的 @section 指令定义。下面的清单显示了一个名为 ThingsToDoWidget 的部分,该部分与一些 HTML 内容一起定义。

列表 3.19 使用 @section 指令定义 Razor 部分

@section ThingsToDoWidget{
    <p>Visit Eiffel Tower</p>
}

通过在希望内容显示的位置调用 RenderSectionAsync 方法,可以在布局页中呈现部分的内容。该方法有两个版本:一个版本将部分的名称作为字符串,另一个版本也采用布尔值,指示所有内容页面是否需要定义部分 (true) 或可选 (false)。在下一个示例中,只有目标页面会为该部分提供内容,因此对于所有其他页面来说,它都是可选的。因此,您将使用用于将 false 传递给第二个参数的重载:

@await RenderSectionAsync(“ThingsToDoWidget”, false)

碰巧的是,已经有一个对 RenderSectionAsync 方法的调用,该方法引用默认项目布局文件中的 scripts 部分,位于结束 body 元素之前:

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>

    @await RenderSectionAsync("Scripts", required: false)
</body>

本部分的目的是在布局中包含特定于页面的 JavaScript 文件,以便它们显示在全局脚本文件之后。这样,特定于页面的文件就可以使用全局文件的内容。

IsSectionDefined 方法可用于布局页,以确定内容页是否已定义特定部分。例如,如果要在调用页面未定义部分时显示一些默认内容,则此方法可能很有用。

列表 3.20 使用 IsSectionDefined 来确定调用页面是否定义了一个部分

<div class="card alert-danger p-5 border-5 mt-2">
@if (IsSectionDefined("ThingsToDoWidget"))
{
    @await RenderSectionAsync("ThingsToDoWidget")
}
else
{
    <p>Things To Do Widget default content</p>
}
</div>

如果内容页定义了一个部分,则必须在布局页中对其进行处理,通常通过调用 RenderSectionAsync 进行处理。但是,当您不想呈现部分的内容时,您可能有条件。在这些情况下,可以使用 IgnoreSection 方法来阻止呈现。

示例 3.21 使用 IgnoreSection 阻止渲染章节内容

@if(!IsAdmin)
{
    IgnoreSection(“admin”);
}
else
{
    @await RenderSectionAsync(“admin”)
}

请注意,IgnoreSection 方法返回 void,因此它不以 @ 符号为前缀,必须以分号结尾。

3.3 带有部分视图、标签帮助程序和视图组件的可重用 HTML

布局是实现可重用 HTML 的一种方式。您可以在一个位置定义网站布局,引用该布局的所有页面都会使用该布局。ASP.NET Core 提供了其他基于 Razor 的机制来处理可重用的 HTML:分部视图、标记帮助程序和视图组件。在本节中,我将介绍所有 3 个功能并解释它们的使用方法。

3.3.1 部分视图

分部视图是一个 Razor (.cshtml) 文件,其中包含一个 HTML 块和一些 Razor 语法(可选)。它与标准 Razor 页面的不同之处在于,分部视图不包含 @page 指令,因为它不打算直接浏览。可以使用分部视图

• 将复杂的 UI 分解为更易于管理的部分
• 避免代码重复
• 在 AJAX 方案中生成用于异步部分页更新的 HTML

奇怪的是,Visual Studio 中没有部分视图模板。您可以使用生成单个 Razor 文件的任何选项。我通常使用 Razor View > Empty 模板,然后删除默认内容。VS Code 用户只需添加带有 .cshtml 后缀的新文件,或使用 CLI 生成新的 ViewStart 或 ViewImports 文件,然后更改文件名并删除默认内容:

dotnet new viewimports
dotnet new viewstart

通常,分部视图在文件名中使用前导下划线命名,例如 _myPartial.cshtml。此约定不是必需的,但它可能有助于区分 partials 和其他文件。您可以将分部视图放置在 Pages 文件夹中的任意位置。局部视图的发现过程与布局相同:包含当前页面和所有父项的文件夹,后跟 Pages\Shared 和 Views\Shared 文件夹。

到目前为止,我们构建的应用程序中的布局文件非常简单,但它可能会变得更加复杂。UI 的单独部分都是分部视图的候选项。以导航为例。此区域的代码可以分离到另一个文件中,然后从布局文件中引用该文件。

若要测试如何创建分部视图,可以从清单 3.16 中的示例布局文件中剪切 nav 元素,并将其粘贴到名为 _NavigationPartial .cshtml 的新 Razor 文件中,应将其放置在 Pages\Shared 文件夹中。现在,您的布局中有一个漏洞,您需要引用分部视图。包含部分视图的推荐机制是 partial 标记帮助程序。我们稍后将更详细地介绍标记帮助程序,但现在,只需知道以下内容将在调用页面中呈现部分视图的内容就足够了:

<partial name=”_NavigationPartial” />

标记帮助程序必须放置在要从要渲染的部分视图中输出的位置。在默认模板中,它位于布局中的 header 元素下。

Listing 3.22 用于包含部分视图内容的 partial 标签助手

<body>
    <div class="container">
        <header class="card alert-success border-5 p-3 mt-3">Header</header>
        <partial name="_NavigationPartial" />
        <div class="row mt-2">
            <div class="col-3">
                <div class="card alert-warning p-5 border-5">
                    Deal Of The Day Widget 1
                </div>

图 3.6 提供了将部分文件 (_NavigationPartial.cshtml) 的内容插入到调用页中放置部分标记帮助程序的位置的过程图示。在此示例中,导航位于标题下方,而不是位于标题内部,就像默认项目模板中一样(图 3.6)。

图 3.6 _NavigationPartial的内容将插入到 Razor 文件中,通过部分标记帮助程序引用它们的位置。

还可以使用分部视图来避免代码重复。标准项目模板在 Pages\Shared 文件夹中包含一个名为 _ValidationScriptsPartial.cshtml 的部分文件。它包含两个 script 元素,这些元素引用用于验证表单值的脚本。在第 5 章中,当你查看表单验证时,你将使用这个部分。

示例中的 partial 和验证脚本 partial 由静态内容组成。Partials 还可以处理动态内容。动态内容的性质是使用 partial 文件顶部的 @model 指令指定的。例如,假设您的导航菜单的数据是由父页面生成的,它由 Dictionary<string、string> 组成,其中键表示要链接到的页面的名称,值可能表示链接的文本。这是 _NavigationPartial.cshtml 文件的第一行的外观:

@model Dictionary<string,string>

数据本身将由引用部分的页面生成,并作为属性包含在其自己的模型中。稍后,当您浏览 PageModel 时,您将看到如何完成此作。目前,您可以假定此部分的数据由名为 Nav 的主机页面的属性表示。您将此数据传递给 partial 标记帮助程序的 model 属性:

<partial name=”_NavigationPartial” model=”Model.Nav” />

或者,您可以使用 for 属性来指定部分的数据。这一次,Model 是隐式的:

<partial name=”_NavigationPartial” for=”Nav” />

在第 11 章中,您将看到如何使用分部视图生成 HTML,以便在 AJAX 方案中用于部分页面更新。

3.3.2 标签辅助函数

标记帮助程序是自动生成 HTML 的组件。它们旨在作用于 Razor 页面的 HTML 中的标记。ASP.NET Core 框架中内置了许多标记帮助程序,其中大多数标记都针对标准 HTML 标记,例如锚点(在将“欢迎”页面添加到导航时,您使用了其中一个标记)、input、link、form 和 image 标记。其他 (如您刚才看到的部分标签帮助程序) 则以您自己创建的自定义标签为目标。每个标记帮助程序都旨在作用于特定标记,并解析服务器端处理期间使用的数据的属性值,以生成生成的 HTML。标记帮助程序解析的大多数属性都是自定义的,以 asp- 开头。提醒一下,以下是您添加到 Welcome (欢迎) 页面的网站导航中的锚点标签帮助程序:

<a class="nav-link text-dark" asp-area="" asp-page="/Welcome">Welcome</a>

asp-area 和 asp-page 属性是自定义属性。他们的角色是向锚标签帮助程序提供有关标签帮助程序用于生成 URL 的区域和页面的信息。在下一章中,当您探索路由和 URL 时,您将了解区域s。当锚点标记帮助程序完成处理并将标记呈现为 HTML 时,生成的 URL 将显示为标准 href 属性。不会呈现自定义属性。

启用标签帮助程序

标记帮助程序是一项可选功能。默认情况下,它们未启用;不过,如果从标准项目模板开始,则它们将通过以下代码行在位于 Pages 文件夹中的 _ViewImports .cshtml 文件中全局启用:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

addTagHelper 指令采用两个参数:要启用的标记帮助程序和包含要启用的标记帮助程序的程序集的名称。通配符 (*) 指定应启用指定程序集中的所有标记帮助程序。框架标记帮助程序位于 Microsoft.AspNetCore.Mvc.TagHelper 中,这就是你会看到默认添加的此程序集的名称的原因。您可以创建自己的自定义标签帮助程序。本书不会介绍这一点,但如果您的自定义标记帮助程序属于 WebApplication1 项目,则可以按如下方式启用它:

@addTagHelper *, WebApplication1

有关构建自己的自定义标签助手的指南,请参阅官方文档 (https://learn.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/authoring?view=aspnetcore-7.0) 或 Andrew Lock 的 ASP.NET Core In Action, Second Edition(Manning,2021 年)。

addTagHelper 指令具有孪生体 removeTagHelper,使您能够有选择地选择不处理某些标记。以下代码行选择退出锚点标签帮助程序处理:

@removeTagHelper "Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, 
 Microsoft.AspNetCore.Mvc.TagHelpers"

您可以通过在标签名称前放置 ! 前缀来选择不处理单个标签。例如,如果特定元素仅由客户端代码使用,则可能需要执行此作。这样就没有必要浪费周期在服务器上处理它。以下示例说明了如何将其应用于锚点标签以防止其被不必要地处理:

<!a href="https://www.learnrazorpages.com">Learn Razor Pages</!a>

前缀同时放置在 start 和 end 标记中。任何没有 ! 前缀的标签都将由关联的标签帮助程序处理。另一种方法是在解析时选择特定标签进行处理。为此,您可以向 @tagHelperPrefix 指令注册自定义前缀,然后将您选择的前缀应用于要参与处理的标签。您可以在最初启用标签帮助程序处理的 ViewImports 文件中注册您的前缀:

@tagHelperPrefix x

你几乎可以使用任何你喜欢的字符串作为前缀。然后,将其应用于 start 和 end 标签,就像 ! 前缀一样:

<xa asp-page="/Index">Home</xa>

将仅处理具有前缀的那些标签。为清楚起见,大多数开发人员可能会使用标点符号将前缀与标签名称分开,例如:

@tagHelperPrefix x:
<x:a asp-page="/Index">Home</x:a>

这应该会减少任何视觉上的混淆,特别是对于设计人员在查看 HTML 内容时。

3.3.3 查看组件

视图组件是用于生成可重用 HTML 的更高级的解决方案。它们类似于分部视图,因为它们可用于帮助分解和简化复杂的布局,也可以表示可在多个页面中使用的 UI 部分。每当需要任何类型的服务器端逻辑来获取或处理数据以包含在生成的 HTML 代码段中时(具体而言,就是对外部资源,如文件、数据库或 Web 服务)的调用,建议使用视图组件而不是部分页面。视图组件运行良好的典型场景包括数据库驱动的菜单、标签云和购物车 — 这些窗口小部件通常出现在布局页面中,并依赖于它们自己的数据源。View 组件也适合进行单元测试。

由于视图组件依赖于到目前为止尚未涵盖的高级概念,因此如果该主题有任何意义,则必须等待该主题的进一步讨论。但请放心,您将在第 14 章中构建一个视图组件。

 3.4 PageModel

现在,您已经了解了在 Razor Pages 应用程序中生成 HTML 的主要机制 — Razor 页面和 Razor 语法,该语法通过将 HTML 与服务器端 C# 代码混合来支持动态内容生成。您还了解了一些有助于 UI 代码重用的组件,包括布局、分部视图、标记帮助程序和视图组件。现在,是时候了解 Razor 页面的合作伙伴了:PageModel 类。

在本节中,您将了解 PageModel 的两个主要角色:控制器和视图模型。回想一下第 1 章中关于 MVC 中控制器的讨论。您将记住,它的作用是接收请求,使用 bid 中的信息对模型执行命令,然后获取该处理的结果并将其传递给视图进行显示。图 3.7 显示了该过程的相关部分。

图 3.7 控制器获取输入,作用于模型,并将结果数据传递给视图。

作为请求处理的一部分,控制器必须准备视图的数据,并以视图可以使用的形式将其提供给视图。此表单称为视图模型,是本节第一部分的重点。在此之前,我将讨论 ViewData,它还提供了一种将数据传递到视图页面的方法。

当您向应用程序添加新的 Razor 页面时,将自动生成 PageModel 类。它以页面命名,带有单词 Model,因此欢迎页面的 PageModel 类是 WelcomeModel。PageModel 类派生自 PageModel 类型,该类型具有许多与 PageModel 类本身中的 HTTP 请求相关的属性和方法。公共 PageModel 属性通过包含引用 PageModel 类型的 @model 指令向 Razor 页面公开。

3.4.1 将数据传递到页面

有许多选项可用于将数据传递到页面。推荐的方法是以强类型方式将数据作为视图模型处理。还有另一个选项,虽然它是弱类型,但有时也很有用。在上一章中,您已经使用此方法将 Welcome 页面的标题传递给布局。它称为 ViewData。

ViewData 是一种基于字典的功能。项作为键值对存储在 ViewDataDictionary 中,并通过引用页面中 ViewData 属性的不区分大小写的字符串键进行访问。以下是使用 ViewData 分配 Welcome 页面标题的方法:

ViewData["Title"] = "Welcome!";

在布局页面中访问此值,如下所示:

<title>@ViewData["Title"] - WebApplication1</title>

ViewDataDictionary 中的值是对象类型,这意味着您可以在其中存储任何您喜欢的内容。如果你想使用非字符串类型(例如,调用特定于类型的方法),则需要将它们强制转换为正确的类型。如果你只想渲染值,并且类型的 ToString() 方法会生成适合渲染的值,则这可能不是必需的。以下赋值将 DateTime 类型添加到 ViewData:

ViewData["SaleEnds"] = new DateTime(DateTime.Now.Year, 6, 30, 20, 0, 0);

如果要呈现该值,可以简单地执行此作:

<p>Sale ends at @ViewData[“SaleEnds”]</p>

输出将根据服务器的默认设置进行渲染。在我的例子中,这是英语(英国),这会导致呈现销售结束时间:30/06/2021 20:00:00。如果我想使用日期和时间格式字符串来控制渲染,则需要转换为 DateTime:

Sale Ends at: @(((DateTime)ViewData["SaleEnds"]).ToString("h tt, MMMM dd"))

现在我得到了我想要的输出:销售结束时间:6 月 30 日晚上 8 点。

您可以在 PageModel 类中设置 ViewData 项的值。下一个列表显示了在名为 OnGet 的处理程序方法中分配给 ViewData 的页面的标题;您很快就会了解 Handler 方法。

清单 3.23 在 PageModel 类的 OnGet 中分配 ViewData 值

public class WelcomeModel : PageModel
{
    public void OnGet()
    {
        ViewData["Title"] = "Welcome!";
    }
}

要使此分配在 Razor 页面中生效,必须确保 Razor 页面包含引用 WelcomeModel 类型的 @model 指令:

@model WelcomeModel

您还应该注意,如果在 Razor 页面本身中设置了 PageModel 中所做的分配,则它将被覆盖。

访问 ViewData 值的另一种方法是通过名为 ViewBag 的属性。此属性是 ViewData 的包装器,使您能够将其视为动态对象。因此,您可以通过与键匹配的属性名称来访问项目。由于值是动态的,因此在使用非字符串类型时无需显式强制转换:

@ViewBag.SaleEnds.ToString("h tt, MMMM dd")

ViewBag 仅在 Razor 文件中可用。它在 PageModel 类中不可用,尽管它在 MVC 控制器中可用,但它是从名为 ASP.NET Web Pages 的旧框架继承而来的。ASP.NET Core 团队决定不在 Razor Pages PageModel 类中实现 ViewBag,因为它是动态的,会对使用它的视图和页面产生可衡量的性能影响。因此,根本不建议使用 ViewBag。

ViewData 是弱类型,因此您没有编译时检查或 IntelliSense 支持。它依赖于使用基于字符串的键引用项目,这种方法有时称为使用魔术字符串。因此,使用 ViewData 可能会导致错误,因为很容易在一个位置键入错误或重命名字符串,而忘记更新其他引用。如果您尝试渲染不存在的项,则不会渲染任何内容。如果您尝试对不存在的 ViewData 条目调用方法,将引发 NullReferenceException。如果尝试将 ViewData 条目强制转换为错误的类型,将生成 InvalidCastException。这些异常将在运行时发生。

ViewData 本身应谨慎使用。它可用于将小块简单数据传递到布局页面,例如页面的标题。对于需要将数据从 PageModel 类传递到 Razor 页面的所有其他方案,应使用公共 PageModel 属性,我们接下来将介绍这些属性。

3.4.2 将 PageModel 作为视图模型

如果有一件事似乎一直让不熟悉任何形式的 MVC 和 ASP.NET 的开发人员感到困惑,那就是视图模型的概念 — 它是什么、它的用途以及如何创建一个视图模型。从本质上讲,视图模型是一件非常简单的事情。它是一个封装特定视图或页面所需数据的类。从概念上讲,它执行与静态数据库视图相同的功能。视图模型包含来自一个或多个对象的数据子集。

例如,考虑网站上的订单摘要页面。它通常包含与您要订购的产品或服务相关的详细信息的子集,可能只是其标识符、名称和价格。它还可能包括您的姓名、账单地址和一个复选框,以指示您也希望向该地址发货。这些信息将来自网站数据库中的 Products 表和 Customers 表。但是,订单摘要视图不需要这些数据库表中的其余信息。例如,订单摘要页面不会显示客户的密码或他们创建账户的日期。它也不会显示可能存储在 Products 表中的产品的内部详细信息,例如供应商的详细信息或再订购级别。

图 3.8 显示了视图所需的 Products 和 Customers 表所包含的总信息中有多少。此数据子集构成了 OrderSummaryViewModel 的基础,OrderSummaryViewModel 是订单摘要视图的模型,可能类似于清单 3.24 的内容。

图 3.8 订单摘要视图只需要分级显示的列或属性

示例 3.24 视图模型类

public class OrderSummaryViewModel
{
    public int CustomerId { get; set; }
    public string CustomerName { get; set; }
    public string BillingAddress { get; set; }
    public bool UseBillingForShipping { get; set; } 
    public int ProductId
    public string Name { get; set; }
    public decimal Price { get; set; }
}

这就是:视图模型 — 一个仅包含视图所需数据的容器。使用 MVC 框架的开发人员广泛使用视图模型。它们的视图通常包含一个 @model 指令,该指令引用为特定视图设计的 ViewModel 类:

@model OrderSummaryViewModel
你已在 Razor 页面文件中看到 @model 指令。它引用 Razor 页面附带的 PageModel 类。因此,Razor Pages 中的 PageModel 是 MVC 意义上的视图模型。添加到 PageModel 的任何公共属性都将可供引用 PageModel 的 Razor 页面访问,该页面通过页面内的特殊 Model 属性。

清单 3.25 显示了 WelcomeModel,这是您在上一章中创建的 Welcome 页面的 PageModel 类。此版本具有一个名为 SaleEnds 的公共属性,它等效于您之前看到的 ViewData 值。

清单 3.25 PageModel 中的公共属性

public class WelcomeModel : PageModel
{
    public DateTime SaleEnds { get; set; } = new DateTime(DateTime.Now.Year, 
     6, 30);
    public void OnGet()
    {
    }
}

列表 3.26 显示了 Welcome Razor 页面,其中包括引用 WelcomeModel 类型的 @model 指令和通过页面的 Model 属性访问的 SaleEnds 值。

列表 3.26 通过页面的 Model 属性公开的 PageModel 属性

@page
@model WebApplication1.Pages.WelcomeModel                          ❶
@{    
}
<p>Sale ends at @Model.SaleEnds.ToString("h tt, MMMM dd")</p>      ❷

❶ PageModel 类型由 @model 指令引用。
❷ PageModel 的 SaleEnds 属性可通过 Razor 页面的特殊 Model 属性进行访问。

与弱类型的 ViewData 不同,您可以获得对 PageModel 属性的 IntelliSense 支持,如图 3.9 所示,它显示了 IntelliSense 在 VS Code 中发挥作用,以帮助完成代码。

图 3.9 IntelliSense 支持 PageModel 属性。

这是建议使用 PageModel 属性而不是 ViewData 作为将数据传递到页面的机制的主要原因。Visual Studio 和 VS Code 中的工具支持可以提高您的工作效率,并最大限度地减少错误潜入代码的可能性。

3.4.3 作为控制器的 PageModel

控制器的主要作用是处理请求。PageModel 中的请求处理在处理程序方法中执行。对于熟悉支持它们的 MVC 框架的读者来说,PageModel 处理程序方法类似于控制器作。按照约定,处理程序方法的选择基于使用 On[method] 模式将用于请求的 HTTP 方法的名称与处理程序方法的名称进行匹配,并选择性地附加了 Async,以表示该方法旨在异步运行。为 GET 请求选择 OnGet 或 OnGetAsync 方法,为 POST 请求选择 OnPost 或 OnPostAsync 方法。

Listing 3.27 展示了 WelcomeModel 类,其中添加了一个简单的字符串属性 Message,以及 OnGet 和 OnPost 处理程序方法。每个处理程序都设置 Message 属性的值,以报告执行了哪个处理程序。

列表 3.27 不同的处理程序方法,为 Message 属性分配不同的值

public class WelcomeModel : PageModel
{
    public string Message { get; set; }    ❶

    public void OnGet()
    {
        Message = "OnGet executed";        ❷
    }

    public void OnPost()
    {
        Message = "OnPost executed";       ❸
    }
}

❶ 添加了 public 属性,该属性可通过 Model 属性在 Razor 页面中访问。
❷ 如果执行 OnGet 处理程序,则消息将包含“OnGet”。
❸ 如果执行 OnPost 处理程序,则消息将包含 “OnPost”。

清单 3.28 显示了 Welcome Razor 页面。该页面包括一个锚点标签帮助程序和一个 method 属性设置为 post 的表单。单击锚点标签帮助程序生成的链接将导致 GET 请求,提交表单将导致 POST 请求。在相应处理程序方法中设置的 Message 属性值将呈现到输出中。图 3.10 说明了基于请求页面的方法的不同输出。

图 3.10 通过将处理程序名称与 HTTP 方法匹配来选择处理程序方法。

列表 3.28 欢迎页面,包括生成 GET 和 POST 的机制

@page
@model WebApplication1.Pages.WelcomeModel                ❶
@{
}
<p>@Model.Message</p>                                    ❷
<a asp-page="/Welcome">Get</a>                           ❸
<form method="post"><button>Post</button></form>         ❹

❶ WelcomeModel 通过 @model 指令引用。
❷ WelcomeModel 的 Message 属性可通过页面的 Model 属性进行访问。
❸ 锚点标签帮助程序会导致 GET 请求。
❹ 提交表单会导致 POST 请求。

处理程序方法参数

处理程序方法可以采用参数。在 URL 中传递的数据将根据参数名称与与 URL 数据项关联的名称之间的匹配绑定到处理程序方法参数。要了解其工作原理,请查看下一个清单,其中 OnGet 方法已更改为接受名为 id 的参数,并且 Message 属性包含绑定到 id 参数的值。

示例 3.29 向 OnGet 处理程序方法添加参数

public void OnGet(int id)                         ❶
{
    Message = $"OnGet executed with id = {id}";   ❷
}

❶ 名为 id 的数值参数已添加到 OnGet 方法中。
❷ 参数的值将合并到分配给 Message 属性的值中。

Razor 页面中的 anchor 标记已更新为包含值为 5 的 asp-route-id 属性,如列表 3.30 所示。asp-route-* 属性用于在 URL 中传递数据。默认情况下,数据在查询字符串中传递,查询字符串 item 的名称取自星号表示的属性部分。

列表 3.30 使用 anchor 标签辅助程序上的 asp-route-* 属性传递一个值

<a asp-page="/Welcome" asp-route-id="5">Get</a>

首次运行页面时没有查询字符串值,因此 handler 参数设置为其默认值 0。为锚点标记的 href 属性生成的值为 /Welcome?id=5。当您点击该链接时,消息将更新为包含参数值,如图 3.11 所示。

图 3.11 查询字符串值根据匹配名称绑定到参数。

负责将传入数据与参数匹配的魔术称为模型绑定。在第 5 章中,当我们看到使用表单时,我将详细介绍模型绑定。

命名处理程序方法

C# 允许您通过改变方法接受的参数的数量和类型来创建方法的重载。虽然可以在单个 PageModel 类中创建多个版本的OnGet或OnPost方法,这些方法因参数而异,并使其成功编译,但 Razor Pages 框架不允许这样做。PageModel 中只能有一个 OnGet 或一个 OnPost 方法。事实上,你甚至不能在同一个 PageModel 类中拥有 OnGet 和 OnGetAsync 方法。当 Razor Pages 将处理程序与 HTTP 方法名称匹配时,它会忽略 Async 后缀。如果多个处理程序与给定请求匹配,您将在运行时收到 AmbiguousActionException。

有时您可能希望为同一 HTTP 方法执行不同的代码。例如,您可能有一个包含多个表单的页面。Manning 主页同时提供搜索表单和注册表单。一个采用搜索词,另一个采用电子邮件地址。假设他们都发回到同一个页面,您如何知道用户提交了哪一个页面?您可以在 OnPost 处理程序中添加一些逻辑,以尝试识别用户是尝试注册新闻稿还是通过其电子邮件地址搜索作者,也可以使用命名处理程序方法。

命名处理程序方法的开头约定与标准处理程序方法相同:On 后跟 HTTP 方法名称。这后跟处理程序方法的名称,用于消除它与标准处理程序方法和其他命名处理程序方法的歧义。例如,您可能希望创建一个名为 OnPostSearch 的方法来处理搜索表单提交,并创建另一个名为 OnPost- Register 的方法来处理注册表单提交。下面的清单显示了如何在示例应用程序的 WelcomeModel 中实现这些。

清单 3.31 显示两个命名的处理程序方法

public class WelcomeModel : PageModel
{
    public string Message { get; set; }                         ❶
    public void OnPostSearch(string searchTerm)                 ❷
    {
        Message = $"You searched for {searchTerm}";             ❸
    }

    public void OnPostRegister(string email)                    ❹
    {
        Message = $"You registered {email} for newsletters";    ❺
    }
}

❶ 添加了 Message 属性。
❷ OnPostSearch 方法采用名为 searchTerm 的字符串。
❸ 该消息包含 searchTerm 参数值。
❹ OnPostRegister 方法使用名为 email 的参数处理注册。
❺ 消息包含 email 参数值。

创建了两个处理程序方法:一个名为 OnPostSearch,另一个名为 OnPostRegister。Search 和 Register 表示命名处理程序方法的名称部分。这两种方法都采用字符串参数,但它们都根据调用的方法将 Message 属性设置为不同的值。

清单 3.32 显示了 Index 页面中的两个简单表单 — 一个用于搜索,另一个用于注册。表单标签是标签帮助程序所针对的标签之一,因此可以使用自定义属性。page-handler 属性接受页面处理程序方法的名称,该方法用于在提交表单时处理请求。

清单 3.32 page-handler 属性支持针对不同的处理程序方法

@page
@model WelcomeModel
@{
}

<div class="col">
    <form method="post" asp-page-handler="Search">     ❶
        <p>Search</p>
        <input name="searchTerm" />                    ❷
        <button>Search</button>
    </form>

    <form method="post" asp-page-handler="Register">  
        <p>Register</p>
        <input name="email" />     
        <button>Register</button>    
    </form>
    <p>@Model.Message</p>                              ❸
</div>

❶ 处理程序方法的 name 部分被分配给 form 标记帮助程序的 page-handler 属性。
❷ input name 属性与目标处理程序方法上的参数名称匹配。
❸ Message 的值呈现给浏览器。

图 3.12 显示了呈现页面并且用户搜索 Razor Pages 时会发生什么情况。表单标签帮助程序使用键处理程序将处理程序的名称附加到查询字符串中:

?handler=Search

图 3.12作中的命名处理程序方法选择

根据处理程序查询字符串值与处理程序方法名称之间的成功匹配,Razor Pages 选择了 OnPostSearch 处理程序来处理请求,并相应地生成结果输出。

处理程序方法返回类型

到目前为止,您看到的所有处理程序方法示例的 return 类型都是 void。其他支持的返回类型包括 Task 和实现 IActionResult 接口的任何类型(称为作结果),它们具有生成响应的作用。对于不同类型的响应,可以使用各种作结果。例如,您可能希望返回一个文件,而不是呈现 Razor 页面。或者,您可能希望返回具有特定 HTTP 状态代码的空响应。或者,您可能希望将用户重定向到其他位置。

此时,您可能想知道,当您的处理程序方法未返回 IActionResult 类型时,您的页面是如何生成响应的。这是因为返回类型为 void 或 Task 的处理程序方法隐式返回 PageResult,即呈现关联的 Razor 页面的作结果。下面的清单显示了 OnPostSearch 处理程序,该处理程序已更新为包含显式返回类型 PageResult。

Listing 3.33 显式返回 action 结果的处理程序方法

public PageResult OnPostSearch(string searchTerm)    ❶
{
    Message = $"You searched for {searchTerm}";
    return new PageResult();                         ❷
}

❶ 已更改 OnPostSearch 方法以返回 PageResult。
❷ 创建并返回一个新的 PageResult 实例。

PageModel 类包含许多帮助程序方法,这些方法提供了一种创建作结果的简写方法,从而避免使用 new 运算符。下一个清单显示了 Page() 帮助程序方法的用法,它是表达式 new PageResult() 的包装器。

列表 3.34 Page() 方法中new PageResult()的简写

public PageResult OnPostSearch(string searchTerm)   ❶
{
    Message = $"You searched for {searchTerm}";
    return Page();                                  ❷
}

❶ 已更改 OnPostSearch 方法以返回 PageResult。
❷ Page() 方法充当对 new PageResult() 的调用的包装器。

通常,如果您的处理程序只导致当前页面被处理和呈现,则无需显式返回 PageResult。表 3.1 显示了您最有可能在 Razor Pages 应用程序中使用的作结果类型及其帮助程序方法。

表 3.1 Razor Pages 中的action结果

Action result

class name

Helper method

Description

PageResult

Page

Renders the current Razor page.

FileContentResult

File

Returns a file from a byte array, stream, or virtual path.

NotFoundResult

NotFound

Returns an HTTP 404 status code indicating that the resource was not found.

PartialResult

Partial

Renders a partial view or page.

RedirectToPageResult

RedirectToPage,

RedirectToPagePermanent

Redirects the user to the specified page. The RedirectToPage method returns an HTTP 302 status code, indicating that the redirect is temporary.

StatusCodeResult

StatusCode

Returns an HTTP response with the specified status code.

在指定处理程序方法的返回类型时,应尽可能具体。有时,您需要根据应用程序逻辑返回两个或多个作结果类型之一。例如,您可以使用参数值在数据库中查找某个条目,如果该条目不存在,则需要返回 NotFoundResult。否则,您将返回 PageResult。在这种情况下,您应该指定 IActionResult 作为处理程序方法的返回类型。

示例 3.35 恢复 IActionResult 以表示任何 ActionResult 类型

public IActionResult OnGet(int id)    ❶
{
    var data = database.Find(id);     ❷
    if (data == null)
    {
        return NotFound();            ❸
    }
    else
    {
        return Page();                ❹
    }
}

❶ 该方法返回 IActionResult。
❷ 尝试查找与参数值匹配的数据。
❸ 如果数据库未返回匹配数据,则使用 NotFound 帮助程序方法创建 NotFoundActionResult。
❹ 如果获取了数据,则呈现页面。

在本章的开头,我提到了 @page 指令表示一个 Razor 文件,该文件表示一个旨在浏览的可导航页面。在下一章中,我们将介绍称为路由的过程,该过程负责确保将 URL 映射到包含此指令的 Razor 文件。

总结

Razor 是一种模板语法,可用于在 HTML 标记中嵌入服务器端代码。
Razor 语法放置在扩展名为 .cshtml 的 Razor 文件中。
C# 代码块括在 @{ ... } 中。
当变量和表达式以 @ 为前缀时,它们将呈现到输出中。
可以通过在 Razor 文件的标记部分前加上 @ 来嵌入控制块。
出于安全原因,Razor HTML 会对其呈现的所有输出进行编码。
您可以使用 Html.Raw 禁用 HTML 编码。
布局页面用于消除跨多个页面的公共内容重复。
标签帮助程序以特定标签为目标并自动生成 HTML。
PageModel 是一个组合的视图模型和控制器。
@model 指令使 PageModel 可用于 Razor 页面。
Razor 页面的 Model 属性提供对 PageModel 属性的访问。
PageModel 中的处理程序方法负责处理请求。它们以特定的 HTTP 方法为目标,并且可以返回 void、Task 或 IActionResult。
处理程序方法参数可以从具有相同名称的查询字符串参数中获取其值。
命名处理程序方法允许您为同一 HTTP 方法指定多个处理程序。

ASP.NET Core Razor Pages in Action 2 构建您的第一个应用程序

ASP.NET Core Razor Pages in Action 2 构建您的第一个应用程序

本章涵盖

• 创建 Razor Pages 应用程序
• 添加您的第一个页面
• 探索项目文件及其所扮演的角色
• 使用中间件配置应用程序管道

在上一章中,你了解了 Razor Pages Web 开发框架(作为 ASP.NET Core 的一部分)如何适应整个 .NET Framework。您已经发现了可以使用 Razor Pages 构建的应用程序类型,而且重要的是,当它不是最佳解决方案时。您已经了解了使用 Razor Pages 高效工作所需的工具,并希望下载并安装了 Visual Studio 或 VS Code 以及最新版本的 .NET SDK。现在您已经设置了开发环境,是时候开始使用代码了。

在本章中,您将使用 Visual Studio 和 CLI 创建您的第一个 Razor Pages 应用程序,以便您可以在所选的作系统上进行作。大多数 Web 开发框架都提供初学者工具包或项目 — 一个简单的应用程序,构成您自己的应用程序的起点。Razor Pages 也不例外。构成初学者工具包的应用程序只有三个页面,但它包括一个基本配置,您可以在此基础上构建以创建自己的更复杂的应用程序。

创建应用程序并设法在浏览器中启动它后,您将向应用程序添加新页面并包含一些动态内容,以便您可以开始了解 Razor 页面的实际含义。测试页面以确保其正常工作后,您将使用网站的主模板文件将页面添加到网站导航中。

然后,我将讨论该工具生成的应用程序文件,以了解每个生成的文件在 Razor Pages 应用程序中所扮演的角色。本演练将帮助您了解所有 ASP.NET Core 应用程序背后的基础知识。

在本演练的最后,我们将仔细研究主要应用程序配置:请求管道。这是应用程序的核心。它定义应用程序如何处理请求以及向客户端提供响应。您将了解如何从中间件组件构建它,以及如何通过添加自己的中间件来扩展它。

在本章结束时,您应该对 Razor Pages 应用程序的工作原理有一个很好的高级了解,从接收请求到最终将 HTML 发送回客户端。然后,您将准备好在第 3 章中深入探讨如何使用 Razor 页面及其配套 PageModel 类。

2.1 创建您的第一个网站

本部分将介绍如何使用可用工具快速生成功能齐全的 Razor Pages 应用程序。您将在 Windows 10 上使用 Visual Studio 2022 Community Edition,并为非 Windows 读者使用 CLI。我将讨论在 Visual Studio Code 中使用 CLI,尽管您可以使用任何终端应用程序来执行 CLI 命令。因此,以下部分假定您已安装并运行环境,以及支持 .NET 6 开发的 SDK 版本。您可以通过打开命令 shell 并执行以下命令来测试您的机器上是否安装了合适的 SDK 版本:

dotnet --list-sdks

您应该会看到列出了一个或多个版本,每个版本都有自己的安装路径。至少有一个版本应以 6 开头。在此阶段,如果您是第一次使用的用户,您还需要信任自签名证书,该证书是在本地系统上通过 HTTPS 轻松浏览站点所需的(第 14 章中有更详细的介绍)。为此,请执行以下命令:

dotnet dev-certs https --trust

证书本身作为 SDK 安装的一部分进行安装。

2.1.1 使用 Visual Studio 创建网站

如第 1 章所述,Visual Studio 是在 Windows 上工作的 .NET 开发人员的主要 IDE。它包括用于执行最常见任务的简单菜单驱动工作流。Razor Pages 应用程序是在 Visual Studio 中创建为项目,因此打开 Visual Studio 后,您的起点是创建新项目。您可以通过单击启动启动画面上的 Create a New Project 按钮或转到 File > New Project...在主菜单栏中。

在下一个屏幕上,您可以从模板列表中选择要创建的项目类型。在此之前,我建议从右侧窗格顶部的语言选择器中选择 C# 以过滤掉一些干扰。选择 ASP.NET Core Web App 模板 — 名称中没有 (Model-View-Controller) 的模板,还要注意避免选择名称非常相似的 ASP.NET Core Web API 模板。正确的模板带有以下说明:“用于创建 ASP.NET Core 应用程序的项目模板,其中包含 ASP.NET Razor Pages 内容。

为应用程序文件选择合适的位置并移动到下一个屏幕后,请确保您的 Target Framework 选择是 .NET 6,将所有其他选项保留为默认值。Authentication Type 应该设置为 None,应该选中 Configure for HTTPS,并且你应该取消选中 Enable Docker 选项(图 2.1)。对选择感到满意后,单击 Create 按钮。此时,Visual Studio 应该会打开,并在 Solution Explorer 中显示您的新应用程序(图 2.2)。

图 2.1 在点击 Create 按钮之前检查您是否已应用这些设置。

图 2.2 新应用程序将在 Visual Studio 中打开,其中有一个概述页,右侧打开“解决方案资源管理器”窗口,其中显示了 WebApplication1 解决方案及其单个项目(也称为 WebApplication1)的结构和内容。

尽管 Solution Explorer 的内容看起来像文件结构,但并非您看到的所有项实际上都是文件。我们将在本章后面仔细研究这些项目。

2.1.2 使用命令行界面创建网站

如果您已经使用 Visual Studio 构建了应用程序,则可能需要跳过此步骤。但是,我建议您也尝试这种方法来创建应用程序,因为该过程会揭示 Visual Studio 中的新项目创建向导隐藏的一两个令人兴奋的事情。

CLI 是一种基于文本的工具,用于对 dotnet.exe 工具执行命令,这两者都是作为 SDK 的一部分安装的。CLI 的入口点是 dotnet 命令,用于执行 .NET SDK 命令和运行 .NET 应用程序。在接下来的部分中,您将将其用于第一个目的。SDK 的默认安装会将 dotnet 工具添加到您的 PATH 变量中,因此您可以从系统上的任何位置对它执行命令。

可以使用您喜欢的任何命令 shell 调用 CLI 工具,包括 Windows 命令提示符、Bash、终端或 PowerShell(有跨平台版本)。从现在开始,我将 shell 称为终端,主要是因为它在 VS Code 中命名。以下步骤并不假定您使用 VS Code 执行命令,但您可以使用 VS Code 提供的集成终端来执行命令。

首先,在系统上的适当位置创建一个名为 WebApplication1 的文件夹,然后使用终端导航到该文件夹,或在 VS Code 中打开该文件夹。如果您选择使用 VS Code,则可以通过按 Ctrl-' 访问终端。在命令提示符下,键入以下命令,并在每个命令后按 Enter 键。

列表 2.1 使用 CLI 创建 Razor Pages 应用程序

dotnet new sln                                           ❶
dotnet new webapp -o WebApplication1                     ❷
dotnet sln add WebApplication1\WebApplication1.csproj    ❸

❶ 创建解决方案文件
❷ 搭建新的 Razor Pages 应用程序基架,并将输出放入名为 WebApplication1 的子文件夹中
❸ 将 Razor Pages 应用程序添加到解决方案

执行最后一个命令后,所有应用程序文件都应该成功创建。您还应该从终端获得一些与某些 “post-creation actions” 相关的反馈。您到 WebApplication1 的路径可能与我的路径大不相同,如下面的清单所示,但其余的反馈应该相似。

列表 2.2 CLI 执行的创建后作的通知

Processing post-creation actions...
Running 'dotnet restore' on WebApplication1\WebApplication1.csproj...
  Determining projects to restore...
  Restored D:\MyApps\WebApplication1\WebApplication1\WebApplication1.csproj 
(in 80 ms).
Restore succeeded.

CLI 在您的应用程序上执行 dotnet restore 命令,确保您的应用程序所依赖的所有软件包都已获取且是最新的。如果使用 Visual Studio 创建应用程序,将执行相同的命令,但指示它已发生并不那么明显。它显示在 IDE 底部的状态栏中(图 2.3)。

图 2.3 Visual Studio 底部的状态栏显示项目已恢复。

2.1.3 运行应用程序

现在,应用程序已使用您选择的任何方式创建,您可以在浏览器中运行和查看它。要从 Visual Studio 运行应用程序,您只需按 Ctrl-F5 或单击顶部菜单栏中轮廓的绿色三角形(不是实心三角形)。这将负责构建和启动应用程序,以及在浏览器中启动它。如果您使用的是 CLI,请执行以下命令:

dotnet run --project WebApplication1\WebApplication1.csproj

此命令包括 --project 开关,用于指定项目文件的位置。如果从包含 csproj 文件的文件夹中执行命令,则省略 --project 开关。如果您更喜欢在 Visual Studio 中使用 CLI,请按 Ctrl-' 打开集成终端,然后从内部执行命令。

您应该在终端中收到正在构建应用程序的反馈,然后再确认它正在侦听两个 localhost 端口,其中一个使用 HTTP,另一个使用 HTTPS。实际端口号因项目而异:

info: Microsoft.Hosting.Lifetime[0]
      Now listening on: https://localhost:7235
info: Microsoft.Hosting.Lifetime[0]
      Now listening on: http://localhost:5235

打开浏览器,然后导航到使用 HTTPS 的 URL。在此示例随附的下载中,即 https://localhost:7235。如果您的浏览器警告您该站点不安全,您可能忽略了信任自签名证书所需的命令:dotnet dev-certs https --trust。如果一切顺利,您应该会看到类似于图 2.4 的内容。

图 2.4 首页

该应用程序是初级的。主页包含最少的样式和内容。使用页面顶部的导航或页脚中的链接导航到 Privacy (隐私) 页面。请注意,相同的最小样式也被应用于 Privacy 页面(图 2.5),并且存在导航。

图 2.5 隐私页面包含与主页相同的页眉、页脚和样式。

目前,您可以使用此应用程序执行的其他作不多。目前还没有任何有趣的方式来与它交互,因此是时候向应用程序添加一个页面了。

2.1.4 添加新页面

在本节中,您将向应用程序添加新页面。您还将探索添加到 .NET 6 中的新功能,称为热重载。此功能会导致对代码所做的更改反映在正在运行的应用程序中,而无需重新启动它。这是为 Visual Studio 用户自动激活的。VS Code 用户需要使用略有不同的命令来启用热重载。此功能适用于对现有文件的更改。由于您要添加新文件,因此需要先停止应用程序。Visual Studio 用户只需关闭浏览器即可停止应用程序。如果您使用 CLI 命令启动了应用程序,则应在终端窗口中按 Ctrl-C 以关闭应用程序。

Visual Studio 用户应右键单击 Solution Explorer 中的 Pages 文件夹,然后从可用选项中选择 Add > Razor Page(添加 Razor 页面)(图 2.6)。将文件命名为 Welcome .cshtml。

图 2.6 要在 Visual Studio 中添加新页面,请右键单击 Pages 文件夹,然后选择 Add,然后选择 Razor Page。

VS Code 用户应确保其终端位于项目文件夹(包含 csproj 文件的文件夹)中,然后执行以下命令:

dotnet new page -n Welcome -o Pages -na WebApplication1.Pages  

new page 命令将 Razor 页面添加到应用程序。-n(或 --name)选项指定创建页面时应使用的名称。-o(或 --output)选项指定将放置页面的输出位置。-na(或 --namespace)选项指定应应用于生成的 C# 代码文件的命名空间。或者,您可以导航到 Pages 文件夹以创建页面并省略 -o 选项。如果这样做,则必须记住导航回包含 csproj 文件的文件夹,以便在没有其他参数的情况下执行 run 命令。

Visual Studio 用户不需要指定命名空间。应用于使用 Visual Studio 向导创建的代码文件的默认命名空间是通过将项目名称与其在项目中的位置连接起来自动生成的。

现在运行应用程序。请记住,在 Visual Studio 中是 Ctrl-F5,而 CLI 用户(VS Code 或 Visual Studio)这次应该在终端中执行 dotnet watch run(而不是 dotnet run),然后打开浏览器并导航到记录到终端的第一个 URL。导航到 /welcome。页面应该除了页眉和页脚之外没有任何内容(图 2.7)。

图 2.7 新页面除了页眉和页脚之外是空的。

这里有三个有趣的点需要注意。第一个原因是您导航到 /welcome,并且找到并呈现了您刚刚添加到应用程序的 Welcome 页面。您无需执行任何配置即可实现此目的。ASP.NET Core 框架中负责此作的部分称为路由。它会根据 Razor 页面在项目中的位置自动查找 Razor 页面。第 4 章详细介绍了 routing。

需要注意的第二点是,新页面包括您在主页和隐私页面中看到的导航、页脚和样式。您的页面从布局文件(一种主模板)继承了这些内容。同样,这种情况的发生无需您采取任何具体步骤即可实现。您将在下一章中了解 layout 文件以及如何配置它们。

最后要注意的是页面的标题,如浏览器选项卡中所示:WebApplication1。布局页面也提供此值。

现在,可以向页面添加一些代码。更新 Welcome .cshtml 的内容,使其如下所示。

清单 2.3 向 Welcome 页面添加内容

@page
@model WebApplication1.Pages.WelcomeModel
@{
    ViewData["Title"] = "Welcome";
}
<h1>Welcome!</h1>

您甚至不需要刷新浏览器,您应用的更改就会在保存后立即显示。这是热重载功能的工作原理。您应该会看到一个一级标题,并且浏览器选项卡中的标题已更改为包含您应用于 ViewData[“Title”] 的值(图 2.8)。ViewData 是一种将数据从 Razor 页面传递到其布局的机制。您将在下一章中看到 ViewData 的工作原理。

图 2.8 对 Razor 页面所做的更改可见,无需刷新浏览器。

 2.1.5 修改以包含动态内容

到目前为止,您添加的是静态内容。每次运行此页面时,它看起来都一样。使用 Razor Pages 的全部意义在于显示动态内容,因此现在是时候添加一些内容了。假设您需要在输出中包含当天部分的名称(例如,上午、下午或晚上),也许作为送达确认说明的一部分(例如,“您的包裹将在早上送到您身边”)。首先,您需要根据时间计算一天的一部分,然后您需要渲染它。下面的清单显示了如何从当前时间获取一天中的部分并将其呈现给浏览器。

列表 2.4 向 Razor 页面添加动态内容

@page
@model WebApplication1.Pages.WelcomeModel
@{
    ViewData["Title"] = "Welcome!";

    var partOfDay = "morning";                                        ❶
    if(DateTime.Now.Hour > 12){
        partOfDay= "afternoon";                                       ❷
    }
    if(DateTime.Now.Hour > 18){
        partOfDay= "evening";                                         ❸
    }
}
<h1>Welcome</h1>
<p>It is @partOfDay on @DateTime.Now.ToString(“dddd, dd MMMM”)</p>    ❹

❶ partOfDay 变量被声明并初始化为值 “morning”。
❷ 如果是在中午之后,则使用值 “afternoon” 重新分配变量。
❸ 如果是在下午 6:00 之后,该值将更新为“晚上”。
❹ 变量与当前时间一起呈现到浏览器。

这些更改涉及声明一个名为 partOfDay 的变量,该变量实例化为值 “morning”。两个 if 语句随后会根据一天中的时间更改值。如果是在中午之后,则 partOfDay 将更改为 “afternoon”。下午 6:00 后再次更改为“晚上”。所有这些都是纯 C# 代码,并放置在代码块中,该代码块以 @{ 开头,以结束 } 结尾。然后,您在 Welcome 标题下添加了一个 HTML 段落元素,包括带有两个 C# 表达式的文本,这两个表达式都以 @ 符号为前缀。您刚刚编写了第一段 Razor 模板语法。@ 前缀指示 Razor 呈现 C# 表达式的值。这一次,根据一天中的时间,您应该会在标题下看到呈现给浏览器的新段落,如图 2.9 所示。

图 2.9 浏览器中修改后的 Welcome 页面

2.1.6 将页面添加到导航

接下来,您将新页面添加到站点导航中,因此您不必在浏览器中键入地址即可找到它。在 Pages/Shared 文件夹中找到 _Layout.cshtml 文件并打开它。使用 navbar-nav flex-grow-1 的 CSS 类标识 ul 元素,并在下面的清单中添加粗体代码行。

清单 2.5 将 Welcome 页面添加到主导航中

<ul class="navbar-nav flex-grow-1">
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>       
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="" asp-page="/Welcome">Welcome</a>
    </li>
</ul>

再次刷新浏览器;现在,每个页面顶部的导航菜单将包含指向 Welcome 页面的链接。您刚才所做的更改已应用于应用程序中的每个页面。这是因为您更改了布局文件,该文件由应用程序中的所有页面使用。Razor 页面的内容与布局页面中的内容合并,以生成最终输出。

您可能想知道为什么您添加到布局页面以创建链接的锚元素上没有 href 属性。此元素称为锚点标记帮助程序。标记帮助程序是针对常规 HTML 元素的组件,它使服务器端代码能够通过通常以 asp- 开头的特殊属性来影响它们呈现到浏览器的方式。例如,asp-page 属性采用一个值,该值表示要生成链接的页面的名称。标签帮助程序将在下一章中更详细地介绍。

因此,您已经了解了 C# 和 HTML 在 Razor 页面中协同工作以生成 HTML 的一些方法。通常,最好的建议是将 Razor 页面中包含的 C# 代码量限制为仅影响演示文稿所需的代码量。应用程序逻辑(包括确定时间的算法)应保留在 Razor 页面文件中。Razor 页面文件和应用程序逻辑之间的第一级分离是 PageModel 类,该类构成了下一章的重点,以及我已经介绍的其他与视图相关的技术,包括布局、部件和标记帮助程序。

2.2 浏览工程文件

现在,您已经创建了第一个 Razor Pages 应用程序并尝试了一些 Razor 语法,现在是时候更详细地探索构成您刚刚创建的 Web 应用程序的每个文件夹和文件的内容,以了解每个文件夹和文件在应用程序中所扮演的角色。在此过程中,您将更清楚地了解 ASP.NET Core 应用程序的工作原理。您还将了解磁盘上的物理文件与您在 Visual Studio 的“解决方案资源管理器”窗口中看到的内容之间的区别。

2.2.1 WebApplication1.sln 文件

SLN 文件称为解决方案文件。在 Visual Studio 中,解决方案充当管理相关项目的容器,解决方案文件包含每个项目的详细信息,包括项目文件的路径。Visual Studio 在打开解决方案时使用此信息加载所有相关项目。

较大的 Web 应用程序通常由多个项目组成:一个负责 UI 的 Web 应用程序项目和多个类库项目,每个项目负责应用程序中的一个逻辑层,例如数据访问层或业务逻辑层。也可能有一些单元测试项目。然后,您可能会看到其他项目添加了表示其用途的后缀:WebApplication1.Tests、WebApplication1.Data 等。

此应用程序由单个项目组成。因此,它实际上根本不需要放在解决方案中,但 Visual Studio 仍然会创建解决方案文件。如果使用 CLI 创建应用程序,则通过 dotnet new sln 命令创建了解决方案文件。然后,通过 dotnet sln add 命令将 WebApplication1 项目显式添加到解决方案中。您可以跳过这些步骤,仅在需要向应用程序添加其他项目时才创建解决方案文件。

2.2.2 WebApplication1.csproj 文件

CSPROJ 文件是一个基于 XML 的文件,其中包含有关生成系统(称为 MSBuild)的项目的信息,它负责将源代码文件转换为可针对 .NET 运行时执行的格式。首先,项目文件包含与项目目标的 .NET Framework 版本和您正在使用的 SDK 相关的信息。Microsoft.NET.Sdk 是基本 SDK,用于构建控制台和类库项目等。Web 应用程序是针对 Microsoft.NET.Sdk.Web SDK 构建的。

项目文件包括两个附加属性:Nullable 和 ImplicitUsings。这些功能使您能够切换新的 C# 功能。第一个选项为项目设置可为 null 的注释和警告上下文。简单来说,这控制了您从代码分析器获得的反馈级别,这些代码分析器在代码中查找 NullReferenceException 的潜在来源。此异常是整个 中更多混淆和问题的原因。比其他任何社区都专注于 NET。该功能称为可为 null 的引用类型,默认处于启用状态。您可以通过将值更改为 disable 来关闭它。

ImplicitUsings 属性用于启用或禁用 C# 10 功能,该功能可减少代码文件中所需的显式 using 指令的数量。相反,它们是在 SDK 中全局设置的。已全局启用的 using 指令的选择包括以下常用 API:

• System
• System.Collections.Generic
• System.Linq
• System.Threading.Tasks

此外,该列表还包括一系列特定于 ASP.NET Core 的 API。默认情况下,此功能也处于启用状态。您可以通过将值设置为 disable 或删除该属性来禁用它。

随着时间的推移,项目文件将包含有关项目所依赖的包或外部库的信息。您可以手动将包添加到此文件中,或者更常见的是使用工具添加包(包管理器),该工具将为您更新工程文件的内容。您可以编辑文件的内容以自定义构建的元素。

项目文件在 Visual Studio 中的“解决方案资源管理器”中不可见。您可以通过右键单击 Solution Explorer 中的项目并选择 Edit Project File(编辑项目文件)来访问它。如果您使用的是 VS Code,则该文件在文件资源管理器中可见,您可以像访问任何其他文件一样访问和编辑它。

2.2.3 bin 和 obj 文件夹

bin 和 obj 文件夹在构建过程中使用。这两个文件夹又细分为两个文件夹(Debug 和 Release),它们对应于构建项目时使用的构建配置。最初,bin 和 obj 文件夹仅包含 Debug 文件夹。只有在 Release 模式下构建后,才会创建 Release 文件夹。除非您在上一节中按 Ctrl-F5 时更改了任何配置设置,否则您的应用程序目前仅在 Debug 模式下构建。

obj 文件夹包含构建过程中使用的工件,bin 文件夹包含构建的最终输出。在第 14 章中发布应用程序时,您将更详细地了解此输出。如果删除 bin 或 obj 文件夹,则会在下次生成项目时重新创建它们。

默认情况下,这两个文件夹在解决方案资源管理器中都不可见。但是,如果单击“显示所有文件”选项,则可以看到它们以虚线轮廓表示。此指示符表示文件夹不被视为项目本身的一部分。同样,它们并没有对 VS Code 用户隐藏。

2.2.4 Properties 文件夹

Properties 文件夹包含特定于项目的资源和设置。当前文件夹中的唯一项目是 launchSettings.json 文件,其中包含运行应用程序时要使用的设置的详细信息。

第一组设置与用于在本地运行应用程序的 IIS Express Web 服务器配置相关。IIS Express 是完整 IIS Web 服务器的轻量级版本,与 Visual Studio 一起安装。

第二组设置表示不同的启动配置文件。IIS Express 配置文件指定应用程序应在 IIS Express 上运行。请注意,applicationUrl 包含一个端口号。为 SSL 端口提供了不同的端口号。这些是按项目生成的。如果您愿意,您可以自由更改端口号。

第二个配置文件使用项目名称来标识自身。如果选择此配置文件来启动应用程序,它将完全在其内部或进程内 Web 服务器上运行。默认服务器实现称为 Kestrel。您将在本章后面了解更多信息。最终配置文件 (WSL 2) 与在适用于 Linux 的 Windows 子系统中运行应用程序有关。本书不涉及 WSL,但如果您想了解更多信息,Microsoft 文档提供了一个很好的起点:https://docs.microsoft.com/en-us/windows/wsl/

2.2.5 wwwroot 文件夹

wwwroot 文件夹是 Web 应用程序中的一个特殊文件夹。它在 Solution Explorer 中有一个地球图标。它是 Web 根目录,包含静态文件。由于是 Web 根目录,wwwroot 被配置为允许直接浏览其内容。它是样式表、JavaScript 文件、图像和其他内容的正确位置,这些内容在下载到浏览器之前不需要任何处理。因此,您不应将任何不希望用户能够访问的文件放在 wwwroot 文件夹中。可以将备用位置配置为 Web 根目录,但新位置不会在“解决方案资源管理器”中获得特殊图标。

项目基架在 wwwroot 文件夹中创建了三个文件夹:css、js 和 lib。css 文件夹包含一个 site.css 文件,其中包含模板站点的一些基本样式声明。js 文件夹包含一个名为 site.js 的文件,除了一些注释外,它什么都没有。一般的想法是,您将自己的 JavaScript 文件放在此文件夹中。lib 文件夹包含外部样式和脚本库。模板提供的库是 Bootstrap,一种流行的 CSS 框架;jQuery,一个跨浏览器的 JavaScript 实用程序库;以及两个基于 jQuery 的验证库。它们用于验证表单提交。

wwwroot 中的文件夹结构不是一成不变的。你可以随心所欲地移动东西。

2.2.6 Pages 文件夹

按照约定,Pages 文件夹配置为 Razor 页面文件的主页。这是框架希望找到 Razor 页面的位置。

项目模板从三个页面开始。您已经看到了其中两个 - 索引(或主页)和隐私页面。当然,您的示例包括您创建的 Welcome 页面。项目模板提供的第三个页面是 Error。查看磁盘上的实际文件夹,您会注意到每个页面都包含两个文件:一个扩展名为 .cshtml 的文件(Razor 文件),另一个以 .cshtml.cs 结尾的文件(C# 代码文件)。当您查看 Solution Explorer 时,这可能不是立即显而易见的。默认情况下,文件是嵌套的(图 2.10)。您可以通过在解决方案资源管理器顶部的工具栏中禁用文件嵌套或单击页面旁边的展开器图标来查看它们,这不仅会显示嵌套文件,还会显示一个显示 C# 类大纲(包括属性、字段和方法)的树。

图 2.10 解决方案资源管理器自动嵌套相关文件。您可以使用 menu 命令切换文件嵌套。

顶级文件 (.cshtml 文件) 是 Razor 页面文件。它也称为内容页面文件或视图文件。为了保持一致性,我将其称为 Razor 页面(单数,带有小写 p 以区别于 Razor Pages 框架)。如上一节所示,此文件充当视图模板,包含 Razor 语法,该语法是 C# 和 HTML 的混合体,因此,文件扩展名是 cs 和 html。第二个文件是一个 C# 代码文件,其中包含一个派生自 PageModel 的类。此类充当 Razor 页面的组合控制器和视图模型。您将在下一章中详细介绍这些文件。

Pages 文件夹中还有两个文件 — 一个名为 _ViewStart.cshtml,另一个名为 _ViewImports.cshtml。以前导下划线命名的 Razor 文件不应直接呈现。这两个文件在应用程序中起着重要作用,不应重命名它们。这些文件的用途将在下一章中解释。

Pages 文件夹还包含一个 Shared 文件夹。其中还有另外两个 Razor 文件,名称中都有前导下划线。_Layout.cshtml 文件充当其他文件的主模板,其中包含常见内容,包括您在上一节中更改的导航。另一个 Razor 文件 _ValidationScriptsPartial .cshtml) 是部分文件。部分文件通常用于包含可插入页面或布局的 UI 代码片段。它们支持 HTML 和 Razor 语法。此特定部分文件包含对客户端验证库的一些脚本引用。您将在第 5 章中介绍这些内容。最后一个文件是一个 CSS 样式表,它有一个奇怪的名字:_Layout .cshtml.css。它包含应用于 _Layout.cshtml 文件的样式声明。命名约定由 .NET 6 中的一项新功能使用,称为 CSS 隔离。您将在第 11 章中了解这是什么以及它是如何工作的。

2.2.7 应用设置文件

应用程序设置文件用作存储应用程序范围的配置设置信息的地方。项目模板由两个应用程序设置文件组成:appSettings.json 和 appSettings.Development.json。第一个 appSettings.json 是将与已发布应用程序一起部署的生产版本。另一个版本是开发应用程序时使用的版本。文件内容的结构为 JSON。

这两个版本都包含用于日志记录的基本配置。开发版本还包含一个名为 DetailErrors 的配置条目,该条目设置为 true。这样就可以将应用程序中发生的任何错误的完整详细信息呈现到浏览器。主机筛选是在生产版本中配置的。您几乎可以在 app-settings 文件中存储任何应用程序配置信息。稍后,您将使用它们来存储数据库连接字符串和电子邮件设置。

应用程序设置文件并不是您可以存储配置信息的唯一位置。许多其他位置(包括环境变量)都是开箱即用的,您可以配置自己的位置。您将在第 14 章中了解有关配置的更多信息。

2.2.8 Program.cs

熟悉 C# 编程的读者都知道,Program.cs 提供了控制台应用程序的入口点。按照约定,它包含一个静态 Main 方法,其中包含用于执行应用程序的逻辑。此文件没有什么不同,只是没有可见的 Main 方法。项目模板利用了一些较新的 C# 语言功能,这些功能在 C# 10 中引入,其中之一是顶级语句。此功能允许您省略 Program.cs 中的类声明和 Main 方法,并开始编写可执行代码。编译器将生成 class 和 Main 方法,并在该方法中调用您的可执行代码。

Program.cs 文件中的代码负责配置或引导 Web 应用程序并启动它。在 .NET 5 及更早版本中,此代码被拆分为两个单独的文件。大部分应用程序配置被委托给一个名为 Startup 的单独类。随着 .NET 6 的发布,ASP.NET 背后的开发人员试图降低过去存在于基本应用程序配置中的复杂性。他们没有将代码跨两个文件,而是将其合并到一个文件中,利用一些新的 C# 功能来进一步减少样板,然后引入了他们所说的最小托管 API,以获取启动和运行 Razor Pages 应用程序所需的最少代码,代码最少为 15 行。在以前的版本中,它接近 80 行代码,分布在两个文件中。

第一行代码创建一个 WebApplicationBuilder:

var builder = WebApplication.CreateBuilder(args);

请记住,此代码将在编译器生成的 Main 方法中执行,因此传递给 CreateBuilder 方法的 args 是由调用应用程序的任何进程传递到任何 C# 控制台应用程序的 Main 方法的标准 args。

WebApplicationBuilder 是 .NET 6 中的新增功能,与另一种新类型(WebApplication)一起构成了最小托管 API 的一部分,您稍后将介绍它。WebApplicationBuilder 具有多个属性,每个属性都支持对应用程序的各个方面进行配置:

• Environment - 提供有关应用程序运行的 Web 托管环境的信息
• Services — 表示应用程序的服务容器(请参阅 第 7 章)
• Configuration - 启用配置提供程序的组合(请参阅 14)
• Logging — 通过 ILoggingBuilder 启用日志记录配置
• Host — 支持配置特定于应用程序主机的服务,包括第三方 DI 容器
• WebHost — 启用 Web 服务器配置

应用程序主机负责引导应用程序、启动和关闭应用程序。术语 bootstrapping 是指应用程序本身的初始配置。此配置包括以下内容:

• 设置内容根路径,这是包含应用程序内容文件的目录的绝对路径
• 从传入 args 参数、app-settings 文件和环境变量的任何值加载配置信息
• 配置日志记录提供程序

所有 .NET 应用程序都以这种方式进行配置,无论它们是 Web 应用程序、服务还是控制台应用程序。最重要的是,为 Web 应用程序配置了 Web 服务器。Web 服务器通过 WebHost 属性进行配置,该属性表示 IWebHostBuilder 类型的实现。默认 Web 服务器是名为 Kestrel 的轻量级且速度极快的 Web 服务器。Kestrel 服务器已合并到您的应用程序中。IWebHostBuilder 还配置主机筛选以及与 Internet Information Services (IIS)(即 Windows Web 服务器)的集成。

IWebHostBuilder 对象公开了多个扩展方法,这些方法支持进一步配置应用程序。例如,前面我讨论了将 wwwroot 文件夹的替代路径配置为 Web 根路径。WebHost 属性使您能够在有充分理由的情况下执行此作。在下面的清单中,Content 文件夹被配置为 wwwroot 的替代品。

列表 2.6 配置静态文件位置

builder.WebHost.UseWebRoot("content");

Services 属性提供依赖项注入容器的入口点,该容器是应用程序服务的集中位置。您将在第 7 章中更详细地探讨依赖关系注入,但与此同时,只需知道容器负责管理应用程序服务的生命周期并根据需要为应用程序的任何部分提供实例就足够了。默认模板包括以下代码行,这些代码行使 Razor Pages 基础结构所依赖的基本服务可供应用程序使用:

builder.Services.AddRazorPages();

这些服务包括 Razor 视图引擎、模型绑定、请求验证、标记帮助程序、内存缓存和 ViewData。如果这些术语看起来不熟悉,请不要担心。在阅读本书时,您将更详细地了解它们。需要注意的重要一点是,Services 属性为您提供了一个位置,可以根据需要注册和配置其他服务。

有时,这些服务是你选择启用的框架的一部分(如 Razor Pages 示例),有时它们表示你作为单独包安装的服务。通常,它们将是您自己编写的包含应用程序逻辑的服务,例如获取和保存数据。

Build 方法将配置的应用程序作为 WebApplication 类型的实例返回。此类型表示其他三种类型的合并:

• IApplicationBuilder — 允许配置应用程序的请求或中间件管道
• IEndpointRouteBuilder - 启用将传入请求映射到特定页面的配置
• IHost - 提供启动和停止应用程序的方法

WebApplication 允许您注册中间件组件来构建和配置应用程序的请求管道。现在,让我们从高级角度看一下以下清单中的默认配置。您将在本书的后面详细了解 pipeline 中更有趣的部分。

列表 2.7 默认请求管道

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();

每个中间件都通过 IApplicationBuilder 类型的扩展方法添加到管道中,该方法由 WebApplication 实现。IWebHost- Environment 可通过 Environment 属性访问,该属性包含有关当前环境的信息。您将在第 14 章中了解有关环境的更多信息,但目前,只需说此属性用于确定应用程序当前是否在 Development 模式下运行就足够了,如果是,则调用 UseException- Handler 方法,该方法添加中间件以捕获错误并在浏览器中显示其详细信息。否则,您在 Pages 文件夹中看到的错误页面将用于显示一条平淡无奇的消息,该消息向用户隐藏了有关错误细节的任何敏感信息,例如包含用户凭据的数据库连接字符串或有关服务器上文件路径的信息。添加 HTTP 严格传输安全标头的中间件也已注册 (app.UseHsts()),但前提是应用程序未在开发模式下运行。此标头告诉浏览器在访问网站时仅使用 HTTPS。我在第 13 章中更详细地介绍了这一点。

UseHttpsRedirection 方法添加了中间件,以确保任何 HTTP 请求都重定向到 HTTPS。然后,在此之后,注册静态文件中间件。默认情况下,ASP.NET Core 应用程序不支持提供静态文件,例如图像、样式表和脚本文件。您必须选择使用此功能,并且可以通过添加静态文件中间件来实现。此中间件将 wwwroot 文件夹配置为允许直接请求静态文件,并将其提供给客户端。

路由中间件负责根据请求中包含的信息选择应执行的端点。我在第 4 章中讨论了路由在 Razor Pages 中的工作原理。然后,注册授权中间件,它负责确定当前用户是否有权访问所请求的资源。授权在第 10 章中介绍。

最后,MapRazorPages 方法将中间件添加到最初将 Razor Pages 配置为终结点的管道。此后,此中间件还负责执行请求。

2.3 理解 middleware

哇。那是很多抽象的术语!端点、中间件、管道 ...但它们实际上意味着什么呢?他们代表什么?在下一节中,我们将更详细地探讨它们。

注意 ASP.NET Core 中间件是一个相当大的话题。我将只介绍可能在大多数 Razor Pages 应用程序中使用的区域。如果您想探索更高级的中间件概念,例如分支管道,我推荐 Andrew Lock 的 ASP.NET Core in Action, Second Edition(Manning,2021 年)。

首先,鉴于 Razor Pages 应用程序的目的是提供对 HTTP 请求的响应,因此查看和了解 HTTP 请求的性质以及它在 Razor Pages 应用程序中的表示方式是合适的。这将构成您了解管道和终端节点的基础。

2.3.1 HTTP 刷新器

超文本传输协议 (HTTP) 是万维网的基础。它是在客户端-服务器模型中的系统之间传输信息的协议。HTTP 事务可以看作由两个基本元素组成:请求和响应。请求是输入,响应是输出。客户端发起请求,服务器提供响应,如图 2.11 所示。

图 2.11 客户端(浏览器)发起 HTTP 请求,该请求被发送到服务器。服务器负责将请求路由到已配置的应用程序并返回 HTTP 响应。

HTTP 请求包含许多数据。请求消息的第一行 (起始行) 包括以下内容:

• HTTP 方法
• 资源的标识符
• 协议版本(例如 HTTP/1.1)

该方法由动词(例如 GET、POST、PUT、DELETE、TRACE 或 CONNECT)或名词(例如 HEAD 或 OPTIONS)表示。向网站请求最常用的方法是 GET 和 POST,其中 GET 主要用于从服务器请求数据,POST 主要用于将数据传输到服务器,尽管 POST 方法也可能导致数据被发送回客户端。这是本书中将介绍的仅有的两种方法。

该标识符由统一资源标识符 (URI) 表示。此特定数据通常也称为统一资源定位符 (URL),就好像它们表示同一事物一样。从技术上讲,它们有所不同。就本书而言,知道所有 URL 都是 URI,但并非所有 URI 都是 URL 就足够了。RFC3986 的 1.1.3 节详细解释了差异: https://www.ietf.org/rfc/rfc3986.txt.在示例中,我将使用的 URI 类型在所有情况下都是 URL。

该请求还包括一组标头 — 名称-值对,可用于向服务器提供可能影响其响应的其他信息。例如,If-Modified-Since 标头指定日期时间值。如果请求的资源在指定时间后未被修改,则服务器应返回 304 Not Modified 状态码;否则,它应该发送修改后的资源。其他标头可能会通知服务器响应的首选语言或请求者可以处理的内容类型。

该请求还可以包括 cookie,即浏览器存储的信息片段,这些信息片段可能特定于网站用户,也可能不特定于网站用户。Cookie 的最常见用途包括:在用户登录到网站后存储用户的身份验证状态,或存储令牌,用于唯一标识访客以进行 Analytics 跟踪。

请求还可以包括 body。通常,这适用于 POST 请求,其中正文包含提交给服务器的表单值。

服务器返回的响应的结构与此类似。它有一个状态行,该行指定正在使用的协议版本、HTTP 状态代码和一些用于描述结果的文本 - 正式名称为原因短语。状态行示例可能如下所示:

HTTP/1.1 200 OK

响应还可以包含标头,这些标头可以指定所发送数据的内容类型、大小以及用于对响应进行编码的方法(如果已编码),例如 gzip。响应通常包括一个包含已请求数据的正文。

2.3.2 HttpContext

HTTP 事务中的所有信息都需要可供 Razor Pages 应用程序使用。用于封装当前 HTTP 事务(请求和响应)的详细信息的对象是 HttpContext 类。处理请求的进程内 Web 服务器负责使用实际 HTTP 请求中的详细信息创建 HttpContext 的实例。它为您(开发人员)提供了通过正式 API 访问请求数据的权限,而不是强迫您自己解析 HTTP 请求以获取此信息。HttpContext 还封装了此特定请求的响应。Web 服务器创建 HttpContext 后,它就可供请求管道使用。HttpContext 以各种形式在整个应用程序中显示,因此您可以根据需要使用其属性。表 2.1 详细介绍了 HttpContext 的主要属性以及它们所代表的内容。

表 2.1 HttpContext 属性

Property Description
Request Represents the current HTTP request (see table 2.2)
Response Represents the current HTTP response (see table 2.3)
Connection Contains information about the underlying connection for the request, including the port number and the IP address information of the client
Session Provides a mechanism for storing data scoped to a user, while they browse the website
User Represents the current user (see chapters 9 and 10)

Request 属性由 HttpRequest 类表示。表 2.2 详细介绍了此类的主要属性及其用途。

表 2.2 主要 HttpRequest 属性

Property

Description

Body

A Stream containing the request body.

ContentLength

The value of the content-length header detailing the size of the request, measured in bytes.

ContentType

The value of the content-type header detailing the media type of the request.

Cookies

Provides access to the cookies collection.

Form

Represents submitted form data. You won’t work with this directly. You are more likely to use model binding to access this data (see chapter 5).

Headers

Provides access to all request headers.

IsHttps

Indicates whether the current request was made over HTTPS.

Method

The HTTP verb used to make the request

Path

The part of the URL after the domain and port

Query

Provides access to query string values as key-value pairs

The Response property is represented by the HttpResponse class. Table 2.3 details the main members of this class and their purpose.

Table 2.3 Primary HttpResponse members

Property

Description

ContentLength

The size of the response in bytes, which is assigned to the content-length header.

ContentType

The media type of the response, which is assigned to the content-type header.

Cookies

The cookie collection of the outgoing response.

HasStarted

Indicates whether the response headers have been sent to the client. If they have, you should not attempt to alter the response. If you do, the values provided in the content-length and content-type headers may no longer be valid, leading to unpredictable results at the client.

Headers

Provides access to the response headers.

StatusCode

The HTTP status code for the response (e.g., 200, 302, 404, etc.).

WriteAsync

An extension method that writes text to the response body, using UTF-8 encoding.

Redirect

Returns a temporary (302) or permanent (301) redirect response to the client, together with the location to redirect to.

Response 属性由 HttpResponse 类表示。表 2.3 详细说明了该类的主要成员及其用途。

表 2.3 主要 HttpResponse 成员

Property

Description

ContentLength

The size of the response in bytes, which is assigned to the content-length header.

ContentType

The media type of the response, which is assigned to the content-type header.

Cookies

The cookie collection of the outgoing response.

HasStarted

Indicates whether the response headers have been sent to the client. If they have, you should not attempt to alter the response. If you do, the values provided in the content-length and content-type headers may no longer be valid, leading to unpredictable results at the client.

Headers

Provides access to the response headers.

StatusCode

The HTTP status code for the response (e.g., 200, 302, 404, etc.).

WriteAsync

An extension method that writes text to the response body, using UTF-8 encoding.

Redirect

Returns a temporary (302) or permanent (301) redirect response to the client, together with the location to redirect to.

上表中详述的方法和属性在直接处理请求和响应时非常有用,例如,在创建自己的中间件时将执行此作。

2.3.3 应用程序请求管道

当 Web 服务器将请求路由到您的应用程序时,应用程序必须决定如何处理它。需要考虑许多因素。请求应定向或路由到何处?是否应记录请求的详细信息?应用程序是否应该只返回文件的内容?它应该压缩响应吗?如果在处理请求时遇到异常,会发生什么情况?发出请求的人是否真的被允许访问他们请求的资源?应如何处理 Cookie 或其他与请求相关的数据?

此决策过程称为请求管道。在 ASP.NET Core 应用程序中,请求管道由一系列软件组件组成,每个组件都有自己的单独责任。其中一些组件在请求进入应用程序的途中作用于请求,而其他组件则对应用程序返回的响应进行作。有些人可能会两者兼而有之。执行这些功能的各个组件称为中间件。

图 2.12 说明了这个概念,显示了一个来自 Web 服务器的请求,然后通过多个中间件组件的管道传递,然后到达标记为 Razor Pages 的实际应用程序本身。

图 2.12 请求进入顶部的管道,流经所有中间件,直到到达 Razor Pages,在那里进行处理并作为响应返回。

这就是对示例应用程序主页的请求的流动方式。每个中间件都会检查请求,并确定在将请求传递到管道中的下一个中间件之前是否需要执行任何作。请求到达 Razor Pages 并得到处理后,响应将流回服务器,因为管道继续沿相反方向进行。管道本身在 Web 服务器上开始和结束。在图 2.13 中,静态文件中间件做出决策,并将控制权传递给下一个中间件,或者使进程短路并返回响应。

图 2.13 中间件处理请求,并在请求针对已知文件时返回响应。

静态文件中间件会检查到达它的每个请求,以确定该请求是否针对已知文件,即驻留在 wwwroot 文件夹中的文件。如果是这样,静态文件中间件只会返回文件,从而使管道的其余部分短路。否则,请求将传递到管道中的下一个中间件。

2.3.4 创建 middleware

现在,您已经更好地了解了中间件所扮演的角色,您应该了解它是如何实现的,以便您可以为请求管道提供自己的自定义功能。本节将介绍如何创建您自己的中间件组件并将其注册到管道中。

中间件组件作为 RequestDelegate实现,即,将 HttpContext 作为参数并返回 Task 的 .NET 委托,或者换句话说,表示 HttpContext 上的异步作的方法:

public delegate Task RequestDelegate(HttpContext context) 

代表 101:快速复习

.NET 中的委托是表示方法签名和返回类型的类型。下面的示例声明一个名为 MyDelegate 的委托,该委托将 DateTime 作为参数并返回一个整数:

delegate int MyDelegate(DateTime dt);

任何具有相同签名和返回类型的方法都可以分配给 MyDelegate 的实例并调用,包括下面显示的两个方法。

根据匹配的签名和返回类型为委托分配方法

int GetMonth(DateTime dt)                    ❶
{
 return dt.Month;
}
int PointlessAddition(DateTime dt)           ❶
{
    return dt.Year + dt.Month + dt.Day;
}

MyDelegate example1 = GetMonth;              ❷
MyDelegate example2 = PointlessAddition;     ❷
Console.WriteLine(example1(DateTime.Now));   ❸
Console.WriteLine(example2(DateTime.Now));   ❸

❶ 两种方法都采用 DateTime 参数并返回一个整数。
❷ 将两种方法都分配给委托实例。
❸ 通过委托实例调用方法。

你可以将内联匿名方法分配给委托:

MyDelegate example3 = delegate(DateTime dt) { 
return dt.Now.AddYears(-100).Year; };
Console.WriteLine(example3(DateTime.Now));

更常见的是,您将看到以 lambda 表达式形式编写的匿名内联方法,其中推断了方法参数的数据类型:

MyDelegate example4 = (dt) => { return dt.Now.AddYears(-100).Year; };
Console.WriteLine(example4(DateTime.Now));

因此,任何将 HttpContext 作为参数并返回任务的方法都可以用作中间件。

如前所述,中间件是通过 WebApplication 添加到管道中的。通常,中间件创建为通过扩展方法注册的单独类,但也可以将 RequestDelegate直接添加到管道。清单 2.8 展示了一个简单的方法,该方法将 HttpContext 作为参数并返回一个 Task,这意味着它满足 RequestDelegate 类型规范。如果您想尝试此示例,可以将方法添加到 Program.cs。您还需要向 Startup 类添加 using 指令,以将 Microsoft.AspNetCore.Http 引入范围。

示例 2.8 RequestDelegate 将 HttpContext 作为参数并返回 Task

async Task TerminalMiddleware(HttpContext context)
{
    await context.Response.WriteAsync("That’s all, folks!");
}

此特定中间件将消息写入响应。控制权不会传递给任何其他中间件组件,因此这种类型的中间件称为终端中间件。它会终止管道中的进一步处理。终端中间件通过 WebApplication 对象的 Run 方法注册:

app.Run(TerminalMiddleware);

RequestDelegate 是标准的 .NET 委托,因此也可以使用 lambda 表达式将其内联编写为匿名函数,而不是命名方法。

列表 2.9 使用 lambda 表达式内联指定主体的委托

app.Run(async context => 
     await context.Response.WriteAsync("That’s all, folks!")
);

尝试使用任一方法通过放置应用程序来注册此中间件。在管道的开头运行 call — 在检查当前环境是否为 Development 的条件之前。

列表 2.10 将中间件添加到管道的开头

app.Run(async context => 
     await context.Response.WriteAsync("That’s all, folks!")
);
if (app.Environment.IsDevelopment())
{
   ...

然后运行应用程序。您应该看到如图 2.14 所示的输出。

图 2.14 中间件的输出

下一个清单说明了一个中间件,它有条件地将处理传递给管道中的下一个中间件。

列表 2.11 有条件地将控制权传递给下一个中间件的中间件

async Task PassThroughMiddleware(HttpContext context, Func<Task> next)
{
    if (context.Request.Query.ContainsKey("stop"))
    {
        await context.Response.WriteAsync("Stop the world");
    }
    else
    {
         await next();
    }
}

此示例将 HttpContext 作为参数,但它也采用返回 Task 的 Func,表示管道中的下一个中间件。如果请求包含名为 stop 的查询字符串参数,则中间件会将管道短路,并将 Stop the world! 写入响应。不会调用其他中间件。否则,它将调用传入的 Func<Task>,将控制权传递给下一个中间件。将控制权传递给管道中下一个组件的中间件使用 Use 方法注册:

app.Use(PassThroughMiddleware);

同样,此中间件可以编写为内联 lambda。

清单 2.12 使用 Use 方法内联注册中间件

app.Use(async (context, next) =>
{
    if (context.Request.Query.ContainsKey("stop"))
    {
        await context.Response.WriteAsync("Stop the world");
    }
    await next();
});

你可以通过将代码放在 await next() 之后,将代码添加到控制权传递给下一个中间件后运行。假设没有其他中间件使管道短路,则您放置在其中的任何 logic 都将在管道反转其方向返回 Web 服务器时执行。例如,您可能希望执行此作以包括 logging。

Listing 2.13 在调用其他中间件后执行函数

app.Use(async (context, next) =>
{
    if (context.Request.Query.ContainsKey("stop"))
    {
        await context.Response.WriteAsync("Stop the world");
    }
    else
    {
        await next();
        logger.LogInformation("The world keeps turning");
    }
});

注册中间件时,位置很关键。如果要将此中间件放在管道的开头,它将针对每个请求执行并记录信息消息,除非找到指定的查询字符串项。假设你要在 static files middleware 之后注册此中间件。在这种情况下,它只会执行和记录对非静态文件资源的请求,因为静态文件中间件在返回静态文件时会使管道短路。

2.3.5 中间件类

到目前为止,您看到的所有示例中间件都已添加为内联 lambda。这种方法适用于你目前看到的简单中间件,但如果你的中间件涉及任何复杂程度,则很快就会达不到要求,可重用性和可测试性都会受到不利影响。此时,您可能会在中间件自己的类中编写中间件。

有两种方法可以实现中间件类。第一种选择是使用基于约定的方法,该方法从一开始就是 ASP.NET Core 的一部分。第二个选项涉及实现 IMiddleware 接口,该接口与 Razor Pages 同时引入 ASP.NET Core 2.0。

基于约定的方法

约定是必须应用于某些组件设计的规则,这些组件旨在与框架一起使用,以便它们按预期方式运行。可能必须以特定方式命名类,以便框架可以识别它的意图。例如,MVC 中的 controller 类就是这种情况,其名称必须包括 Controller 作为后缀。或者,可能适用一个约定,指定为特定用例设计的类必须包含以某种方式命名并带有预定义签名的方法。

必须应用于基于约定的中间件类的两个约定是:(1) 声明一个构造函数,该构造函数将 RequestDelegate 作为参数,表示管道中的下一个中间件,以及 (2) 一个名为 Invoke 或 InvokeAsync 的方法,该方法返回一个 Task 并至少具有一个参数,第一个参数是 HttpContext。

要尝试此作,请将名为 IpAddressMiddleware 的新类添加到应用程序中。为简单起见,以下示例直接添加到项目的根目录中。将代码替换为下一个列表,该列表说明了一个中间件类,该类实现这些约定并记录访客 IP 地址的值。

列表 2.14 基于约定的方法的中间件类

namespace WebApplication1
{
    public class IpAddressMiddleware
    {
        private readonly RequestDelegate _next;
        public IpAddressMiddleware(RequestDelegate next) => _next =
next;                                                               ❶

        public async Task InvokeAsync(HttpContext context, 
         ILogger<IpAddressMiddleware> logger)                     ❷
        {
            var ipAddress = context.Connection.RemoteIpAddress;
            logger.LogInformation($"Visitor is from {ipAddress}");  ❸
            await _next(context);                                   ❹
        }
    }
}

❶ 构造函数将 RequestDelegate 作为参数。
❷ InvokeAsync 方法返回一个任务,并将 HttpContext 作为第一个参数。任何其他服务都将注入到 Invoke/InvokeAsync 方法中。
❸ 在 InvokeAsync 方法中执行处理
❹ 将控制权传递给管道中的下一个中间件

接下来,将 using 指令添加到 Program.cs 文件的顶部,以将 WebApplication1 命名空间引入范围:

using WebApplication1;
var builder = WebApplication.CreateBuilder(args);

中间件类通过 WebApplication 上的 UseMiddleware 方法添加到管道中。此方法有两个版本。第一个选项将类型作为参数:

app.UseMiddleware(typeof(IpAddressMiddleware));

第二个版本采用一个泛型参数,表示中间件类。这个版本是你更有可能遇到的版本:

app.UseMiddleware<IpAddressMiddleware>();

或者,建议您在 IApplicationBuilder 上创建自己的扩展方法来注册中间件。以下示例(如下面的清单所示)放置在名为 Extensions 的类中,该类也已添加到项目的根目录中。

清单 2.15 使用扩展方法注册中间件

namespace WebApplication1
{
    public static class Extensions
    {
        public static IApplicationBuilder UseIpAddressMiddleware(this IApplicationBuilder app)
        {
            return app.UseMiddleware<IpAddressMiddleware>();
        }
    }
}

然后,扩展方法的使用方式与注册框架中间件时遇到的所有其他扩展方法相同:

app.UseIpAddressMiddleware();

在这种情况下,您可能希望在 static files 中间件之后注册此中间件,这样它就不会为每个请求的文件记录同一访问者的 IP 地址。

遵循基于约定的方法的中间件在应用程序首次启动时创建为单一实例,这意味着在应用程序的生命周期内只创建一个实例。此实例将重复用于到达它的每个请求。

实现中间件

编写新中间件类的推荐方法涉及实现 IMiddleware 接口,该接口公开一种方法:

Task InvokeAsync(HttpContext context, RequestDelegate next)
下一个清单显示了您使用基于约定的方法创建的相同 IpAddressMiddleware,并进行了重构以实现 IMiddleware。

列表 2.16 重构 IpAddressMiddleware 以实现 IMiddleware

public class IpAddressMiddleware : IMiddleware                             ❶
{
    private ILogger<IpAddressMiddleware> _logger;
    public IpAddressMiddleware(ILogger<IpAddressMiddleware> logger)
        => _logger = logger;                                               ❷

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)❸
    {
        var ipAddress = context.Connection.RemoteIpAddress;
        _logger.LogInformation($"Visitor is from {ipAddress}");
        await next(context);
    }
}

❶ 中间件类实现 IMiddleware 接口。
❷ 依赖项被注入到构造函数中。
❸ InvokeAsync 将 HttpContext 和 RequestDelegate 作为参数。

InvokeAsync 与使用基于约定的方法编写的 InvokeAsync 非常相似,不同之处在于这次的参数是 HttpContext 和 RequestDelegate。该类所依赖的任何服务都是通过中间件类的构造函数注入的,因此需要字段来保存注入的服务的实例。

此中间件的注册方式与基于约定的示例完全相同:通过 UseMiddleware 方法或扩展方法。但是,基于 IMiddle ware 的组件还需要执行一个额外的步骤:它们还必须注册到应用程序的服务容器中。在第 7 章中,您将了解有关服务和依赖关系注入的更多信息,但目前,只需知道您需要将下一个清单中的粗体代码行添加到 Program 类就足够了。

清单 2.17 将 IMiddleware 注册为服务

builder.Services.AddRazorPages();
builder.Services.AddScoped<IpAddressMiddleware>();

那么,为什么有两种不同的方法可以创建中间件类,您应该使用哪一种呢?嗯,基于约定的方法要求您学习特定的约定并记住它们。没有编译时检查来确保你的 middleware 正确实现约定。这种方法称为弱类型。通常,当您第一次发现忘记将方法命名为 Invoke 或 InvokeAsync 或第一个参数应该是 HttpContext 时,它会崩溃。如果你和我一样,你经常会发现你得回头查阅文档,以提醒自己约定的细节。

第二种方法会产生强类型中间件,因为您必须实现 IMiddleware 接口的成员;否则,编译器会抱怨,您的应用程序甚至不会构建。因此,IMiddleware 方法不太容易出错,并且实现起来可能更快,尽管您必须采取额外的步骤来向服务容器注册中间件。

这两种方法之间还有另一个区别。我之前提到过,在首次构建管道时,基于约定的中间件被实例化为单例。IMiddleware 组件由实现 IMiddlewareFactory 接口的组件针对每个请求进行实例化,并且这种差异会根据中间件的生存期对中间件所依赖的服务产生影响。我在第 7 章中更详细地解释了服务生命周期。现在,请理解 lifetime 不是 singleton 的服务不应该被注入到 singleton 的构造函数中。这意味着非单例服务不应该被注入到基于约定的中间件的构造函数中。但是,它们可以注入到 IMiddleware 组件的构造函数中。请注意,可以将非单例服务安全地注入到基于约定的中间件的 Invoke/InvokeAsync 方法中。

需要注意的是,大多数框架中间件都是使用基于约定的方法编写的。这主要是因为它大部分是在引入 IMiddleware 之前编写的。虽然没有迹象表明框架设计人员认为有必要将现有组件迁移到 IMiddleware,但他们建议您将 IMiddleware 用于您自己创建的任何中间件。

我们已经详细研究了如何使用中间件来构建请求管道,但尚未真正详细地介绍已添加到默认项目模板中的中间件。这将在接下来的章节中更深入地介绍。具体来说,我们将在第 4 章中介绍路由和端点中间件如何组合,在第 10 章中介绍授权的工作原理,在第 12 章中介绍如何管理自定义错误页面。

总结

Razor Pages 应用程序的起点基于模板。
Razor Pages 应用程序创建为项目。
解决方案是用于管理项目的容器。
Razor 语法可用于向页面添加动态内容。
Razor 语法支持将 C# 代码嵌入到 HTML 中。
Razor 运行时编译通过刷新浏览器使对 Razor 文件的更改可见。
布局页面充当整个网站的主模板。
Razor Pages 应用程序是以 Main 方法作为入口点的控制台应用程序。Main 方法作为 C# 10 中顶级语句功能的一部分隐藏在视图中。
WebApplicationBuilder 用于配置应用程序的服务和请求管道。
请求管道确定应用程序的行为。
请求管道由中间件组件组成。
中间件作为 RequestDelegate 实现,RequestDelegate 是一个将 HttpContext 作为参数并返回 Task 的函数。
中间件通过 WebApplication 对象添加到管道中。中间件可以终止管道或将控制权传递给下一个中间件。
Middleware 将按照其注册顺序进行调用。
可以使用内联 lambda 表达式添加简单的中间件。
复杂中间件可以创建为单独的类,并使用 IApplicationBuilder 类型的扩展方法进行注册。
中间件类应使用约定或实现 IMiddleware 接口。
基于约定的中间件实例化为单一实例,并且应该通过 Invoke/InvokeAsync 方法获取依赖项。
IMiddleware 按请求实例化,并且可以通过其构造函数获取依赖项。

ASP.NET Core Razor Pages in Action 1 Razor Pages 入门

ASP.NET Core Razor Pages in Action 1 Razor Pages 入门
本章涵盖

• 什么是 Razor Pages
• 为什么你应该使用 Web 开发框架
• 您可以使用 Razor Pages 做什么
• 何时以及为何应选择 Razor Pages
• 使用 Razor Pages 所需的工具

感谢您购买此 Razor Pages in Action 副本,无论是实体版还是虚拟版。通过这样做,您将了解什么是 Razor Pages、可以使用 Razor Pages 做什么,以及在决定 Razor Pages 是否是构建下一个 Web 应用程序的不错选择时需要考虑的事项。剧透警告:如果您想开发以页面为中心的交互式 Web 应用程序,那就好了!

本章将探讨 Razor Pages 的技术,并研究 Razor Pages 与其他 Web 开发框架之间的异同。完成本章后,您应该知道 Razor Pages 是否适合您的下一个应用程序,并期待在下一章中使用 Razor Pages 构建您的第一个应用程序。

如果可以的话,我将对你做一些假设。我假设您已经了解 Web 的核心技术 — HTTP、HTML、CSS 和 JavaScript— 以及它们如何协同工作。我假设您知道 Bootstrap 不仅仅是鞋类的固定。我假设您已经了解 C# 或类似的面向对象语言,或者您能够在学习 Razor Pages 的同时学习 C#。最后,我以您了解关系数据库的基础知识为前提。我提到这一切是因为我在本书中没有详细介绍这些主题中的任何一个,尽管我可能会给你一个奇怪的复习,我认为它有助于提供上下文。

还在我身边?好!我们走吧!

1.1 什么是 Razor Pages?

Razor Pages 是 Microsoft 提供的服务器端、跨平台、开源 Web 开发框架,使您能够将现有的 HTML、CSS 和 JavaScript 知识与 C# 语言结合使用,以构建以页面为中心的新式 Web 应用程序。现在,这有点拗口,所以让我们稍微分解一下。

1.1.1 Web 开发框架

首先,让我们看看什么是 Web 开发框架以及为什么您可能需要它。图 1.1 显示了本书出版商网站的主页 Manning.com。

图 1.1 Manning.com 屏幕截图

看看您可以在此网站上做的一些事情:

• 您可以搜索网站内容。
• 您可以从此站点购买东西。
• 您可以创建一个帐户并登录。
• 您可以注册时事通讯。
• 您可以查看最新的图书发行。
• 您可以查看您之前访问时查看的项目。
• 您可以阅读对作者的采访。

这是很多复杂的功能,而且 Manning 有这么多的书籍和作者,必须有大量的页面需要维护。想想重新设计网站以使其焕然一新所需的工作,将更改应用于所有这些无数的页面!

Web 开发框架通过为常见任务提供预构建的解决方案来减轻这些复杂性,因此您可以继续构建应用程序。以显示所有这些书籍的详细信息的任务为例。不必为每本书创建一个页面,框架(如 Razor Pages)将为您提供创建模板以显示任何书籍的功能。它还包括占位符,因此可以从中央存储(例如数据库)获取特定书籍的详细信息,例如其标题、作者、ISBN 和页数(很像邮件合并文档的工作方式)。现在,您只需管理所有书籍的一页,而不是每本书一页。

管理用户信息的任务怎么样?您将需要某种方法来存储此信息并将其与用户提供的登录详细信息进行匹配。您还需要提供一种机制来识别当前用户已成功登录,这样他们就不必为要查看的每个后续页面再次进行身份验证。您需要安全地完成所有这些作,采用可接受级别的加密技术。同样,一个好的框架将为您提供这些功能。您所要做的就是了解这些功能的工作原理并将它们插入到您的应用程序中,将实现加密和哈希等低级专业任务的谜团留给知道自己在做什么的专家。

这些示例涉及 Web 开发框架提供的几个功能。(图 1.2)。但名单并不止于此。想想开发 Web 应用程序可能需要您执行的任何常见重复性任务:处理传入的数据请求、映射不包含文件扩展名的 URL、与数据库通信、处理和验证表单提交、处理文件、发送电子邮件。使用包含这些功能的框架时,所有这些任务都会变得更加容易。当您完成本书时,您将能够使用 Razor Pages 轻松完成所有这些任务。

图 1.2 工作流图显示了涉及使用模板的过程在 Razor Pages 中的工作原理。此工作流从左下角开始,客户端请求 /book/razor-pages-in-action 或类似内容。白色箭头显示通过 Internet 到 Web 服务器的行进方向,该服务器找到正确的应用程序,然后将处理传递给 Razor 页面(其中包含 func())。然后,控制权将传递给应用程序服务层,该层负责从数据库中检索详细信息。数据将发送到服务层(请参阅灰色箭头),然后发送到 Razor 页面,在那里它与视图模板(带有 @ 符号的模板)合并以创建 HTML。生成的 HTML 通过应用程序传递到 Web 服务器,然后返回给客户端。

除了为功能需求提供解决方案外,框架通常还提供构建和部署应用程序的标准方法。它们可能会鼓励您在构建应用程序时采用经过验证的软件设计模式,以使结果更易于测试和维护。

从本质上讲,Web 开发框架可以通过为常见的重复性任务提供预构建和测试的解决方案来加快开发 Web 应用程序的过程。他们可以通过鼓励您按照一组标准工作来帮助您产生一致的结果。

1.1.2 服务器端框架

接下来,我们将了解一下 Razor Pages 是服务器端框架的含义。在开发动态 Web 应用程序时,您必须确定 HTML 的生成位置。您可以选择在用户的设备(客户端)或 Web 服务器上生成 HTML。

在客户端上生成 HTML 的应用程序或单页应用程序 (SPA) 在可以使用的技术方面受到限制。直到最近,你还只能真正使用 JavaScript 来创建这类应用程序。自从 Blazor 推出以来,这种情况发生了变化,它使你能够使用 C# 作为应用程序编程语言。若要详细了解此内容,请参阅 Chris Sainty 的 Blazor in Action(Manning,2021 年)。由于大多数应用程序处理都在用户的设备上进行,因此您必须注意其资源,您无法控制这些资源。在编写代码时,您还必须考虑浏览器功能之间的差异。另一方面,客户端应用程序可以带来丰富的用户体验,甚至可以与桌面应用程序非常相似。主要在客户端上呈现的应用程序的优秀示例包括 Facebook 和 Google Docs。

在服务器上呈现 HTML 的应用程序可以利用服务器支持的任何框架或语言,并拥有服务器可以提供的尽可能多的处理能力。这意味着 HTML 生成是可控且可预测的。此外,所有应用程序逻辑都部署到服务器本身,这意味着它与服务器一样安全。由于处理的输出应该是符合标准的 HTML,因此您不需要太担心浏览器的怪癖。

1.1.3 跨平台功能

可以在各种平台上创建和部署 Razor Pages 应用程序。Windows、Linux、macOS 和 Docker 均受支持。如果您想在超薄且昂贵的 MacBook Air 或 Surface Pro 上创建应用程序,您可以。或者,如果您更喜欢使用运行 Debian 或 Ubuntu 的翻新 ThinkPad,没问题。您仍然可以与使用不同平台的同事共享您的源代码。您的部署选项同样不受限制,这意味着您可以利用您的网络托管公司提供的最优惠价格。

1.1.4 开源

过去,当我第一次被授予 Microsoft 最有价值专业人士(MVP,Microsoft 评判为通过分享技术专业知识为社区做出重大贡献的人的年度奖项)时,该奖项的好处之一是可以直接访问负责 MVP 专业领域的 Microsoft 产品组。就我而言(我确信这是错误的身份之一),专业领域是 ASP.NET,Microsoft 的 Web 开发框架。

能够访问 ASP.NET 产品组是一个特权地位。请记住,在那个年代,Microsoft 在很大程度上是一家闭源公司。Microsoft MVP 比社区其他成员更早地了解了 Microsoft 在其领域的一些新产品计划。他们甚至可能会被邀请对他们的新产品进行一些 beta 测试或提供改进建议,尽管所有主要设计决策通常是在您获得访问权限时做出的。

几年后,Microsoft 已经转变为一家开源公司。他们开发平台的源代码在 GitHub 上供所有人查看。不仅如此,我们鼓励每个人通过提交可能的错误并提供改进、新功能、错误修复或更好的文档来为源代码做出贡献。与其被告知 Microsoft 将在遥远的将来发布什么,不如参与关于框架应该采取的方向的对话。任何人都可以在 GitHub 上询问有关框架的问题,通常可以从 Microsoft 开发人员那里获得答案。

Microsoft 在这种方法上取胜,因为他们受益于公司外部的专家,增加了他们的技术专长,甚至增加了时间,而框架的用户则受益,因为他们获得了其他真实用户影响的更好的产品。在撰写本文时,Razor Pages 所属的 ASP.NET 的当前版本 ASP.NET Core 拥有超过 1,000 个活跃的贡献者。

1.1.5 使用您现有的知识

Razor Pages 支持的服务器端语言是 C#,而视图模板主要由 Web 语言(HTML、CSS 和 JavaScript)组成。前面讨论的动态内容的占位符是 C# 代码。使用 Razor(一种简单易学的模板语法)在视图模板中嵌入服务器端表达式和代码。您无需学习任何新语言即可使用 Razor Pages。您甚至不需要真正了解 SQL 即可访问数据库,因为 .NET 包含您将用于生成数据库的框架。

1.2 您可以使用 Razor Pages 做什么?

Razor Pages 是一个以页面为中心的框架。它的主要目的是生成 HTML。因此,它适用于创建任何 Web 应用程序或由网页组成的基于 Web 的应用程序的任何部分。事实上,列出你不能用 Razor Pages 做的事情可能更容易!

您之前查看了 Manning 的网站 — 一个在线目录和电子商务网站。我被可靠地告知它不是用 Razor Pages 构建的,但它可能是。我在博客和教程网站上使用了 Razor Pages,其中数据存储在数据库中或作为需要转换为 HTML 的 Markdown 文件。我还在日常工作中使用 Razor Pages 来构建杂志网站,使用基于 Web 的内部工具来管理与业务相关的工作流程和报告,甚至是自定义内容管理系统。将页面作为要求的一部分的任何类型的 Web 应用程序都是 Razor Pages 的候选对象 - 从简单的博客网站到下一个 eBay。

Razor Pages 特别适用于任何类型的基于表单的应用程序。创建、读取、更新和删除通常称为 CRUD 应用程序,代表与模型的持久存储相关的四个基本作。这些工具可用于快速搭建用于管理任何实体的表单集合,您将在本书的后面部分使用这些工具。

1.3 支撑 Razor Pages 的技术

Razor Pages 位于从 .NET 6 开始的堆栈的顶部,.NET 6 是 Microsoft 的一个大型框架,支持各种跨平台应用程序的开发,包括桌面、移动、云、游戏,当然还有 Web(图 1.3)。基层也称为基类库 (BCL),包括大多数开发类型通用的较低级别库,例如提供数据类型或支持处理集合、文件、数据、线程异常、电子邮件等的库。

图 1.3 .NET 堆栈。Razor Pages 是 MVC 框架的一项功能,而 MVC 框架又是 ASP.NET Core 框架的一部分,该框架代表 Web 开发层。

堆栈的 Web 层称为 ASP.NET Core。它包括用于处理 HTTP、路由、身份验证的库,以及用于支持 Razor 语法和 HTML 生成的类。除了我之前提到的 Blazor 之外,ASP.NET Core 还包括 SignalR,这是一个用于将数据从服务器推送到连接的客户端的框架。SignalR 用例的最简单示例是聊天应用程序。

除了 SignalR 和 Blazor 之外,还有 ASP.NET Core 模型-视图-控制器 (MVC) 框架,顶部是 Razor Pages。Razor Pages 是 MVC 框架的一项功能,它支持开发遵循 MVC 设计模式的 Web 应用程序。要理解这意味着什么,有必要了解 ASP.NET Core MVC 框架的性质。

1.3.1 ASP.NET Core MVC 框架

ASP.NET Core MVC 是 Microsoft 的原始跨平台 Web 应用程序框架。这就是所谓的固执己见的框架。框架设计者对框架的用户应该应用的架构决策、约定和最佳实践有意见,以产生最高质量的结果。然后,框架设计人员生成一个框架,引导用户采用这些架构决策、约定和最佳实践。整个 Microsoft 的开发人员将此过程描述为帮助客户陷入“成功的深渊”。

1.3.2 模型-视图-控制器

MVC 框架背后的开发人员的主要架构决策是支持实现 MVC 模式的 Web 应用程序的开发,因此,框架的名称也应运而生。之所以做出这一决定,是因为 MVC 是 Web 开发中一种众所周知的表示设计模式,其目的是强制分离关注点 — 具体而言,应用程序模型及其表示的关注点。

MVC 中的 V 是视图或页面。M 是应用程序模型,它是一个模糊的术语,表示应用程序中不是视图或控制器的所有内容。该模型包括数据访问代码、业务或域对象(在曼宁的情况下,您的应用程序的全部内容(书籍、作者和客户))以及旨在管理它们的编程逻辑(即业务逻辑)。然后,根据其他良好的软件设计实践,应用程序模型需要进一步分离,但这不是 MVC 的业务,它纯粹是一种表示设计模式。在 UI 和模型的其余部分之间强制分离的主要原因是提高维护和可测试性。如果应用程序逻辑与 HTML 混合在一起,则很难测试应用程序逻辑。

MVC 的控制器部分是模型和视图之间分离的主要方式。它的作用是接受请求,然后使用请求中的信息对模型执行命令。然后,它将获取该处理的结果并将其传递给视图进行显示。

控制器可以通过不同的方式实现。您可以创建类似前端控制器的东西来处理对整个应用程序或应用程序子集的请求,也可以使用页面控制器模式来处理对单个页面的请求。最初的 ASP.NET MVC 框架实现利用了前端控制器方法,其中单个控制器负责协调与应用程序中的功能或业务区域相关的多个端点(AuthorController、BookController 等)的处理。Razor Pages 实现页面控制器方法,控制器是从 PageModel 派生的类。

ASP.NET MVC 框架中的前端控制器单独负责的不仅仅是页面控制器(图 1.4)。他们必须协调与特定业务领域相关的所有作的处理 — 创建、更新、删除、获取列表、获取详细信息等。随着时间的推移,前端控制器可能会增长到数百行(如果不是数千行)代码。它们采用的依赖项数量增加,这肯定表明控制器做得太多了。它们变得难以管理。另一方面,页面控制器要简单得多,只需要管理其单个页面的处理。其中一些几乎没有任何代码。

图 1.4 MVC 中使用的前端控制器协调多个视图的处理,可能会变得非常繁忙和复杂。在 Razor Pages 中,每个页面都有自己的控制器,使它们保持精简且更易于使用。

1.3.3 Razor Pages 的设计目标

正如您已经了解到的,MVC 框架是一个固执己见的框架。如果您想使用它,则需要使用框架作者的约定或开发某种解决方法。ASP.NET MVC 包含许多用于命名文件并将其放置在应用程序中的约定。例如,假设您的客户或老板希望您向现有 MVC 应用程序添加新功能。请记住,前端控制器类按照约定是功能驱动的,您必须将表示该功能的新类文件添加到 Models 文件夹,将新的控制器类添加到 Controllers 文件夹,将新功能的文件夹添加到 Views 文件夹中,将新的 Razor 视图添加到该文件夹,最后添加 viewmodel 类来表示视图的数据。如果要对该功能进行任何更改,则必须在整个代码库中插入和退出文件夹和文件。

不熟悉 MVC 模式的开发人员可能会发现使用 ASP.NET 实现的复杂性相当令人生畏。如果您不熟悉 ASP.NET MVC 应用程序的结构,并且发现自己对我刚才描述的工作流有点迷茫,欢迎加入我的目标受众!甚至 Microsoft 自己也把这个框架描述为具有 “高概念数”。因此,Razor Pages (https://github.com/aspnet/mvc/issues/494) 的设计目标是在该背景下设定的,并隐式地将使用 Razor Pages 与 MVC 框架进行比较。它们包括(引用的 GitHub 问题)以下内容:

• 使用 ASP.NET Core 使动态 HTML 和表单更加容易,例如,在页面中打印 Hello World 需要多少个文件和概念,构建 CRUD 表单等。
• 减少以页面为中心的 MVC 方案所需的文件数量和文件夹结构的大小
• 简化实现常见的以页面为中心的模式所需的代码,例如动态页面、CRUD 表单等。
• 启用在必要时返回非 HTML 响应的功能,例如 404s
• 尽可能多地使用和公开现有的 MVC 基元(组件)

最终,引入了 Razor Pages,使使用 MVC 模式比使用现有框架更简单。这并不是说 Razor Pages 仅适用于简单的场景 — 远非如此,尽管您可能会在各种网站上找到这种视图。但是,当被追问时,您会发现大多数持有这种观点的人都承认没有尝试过 Razor Pages。

1.4 什么时候应该使用 Razor Pages?

为了与我的说法保持一致,列出 Razor Pages 不能执行的作可能更容易,我将通过查看何时不应考虑使用 Razor Pages 的示例来开始本节:

• 单页应用程序 - 作为服务器端开发框架,Razor Pages 不是构建单页应用程序的合适工具,在单页应用程序中,应用程序通常用 JavaScript 编写并在浏览器中执行,除非需要服务器呈现 (http://mng.bz/YGWB)。
• 静态内容站点 – 如果站点仅由静态内容组成,则启动 Razor Pages 项目不会有任何好处。您只是不需要一个主要目的是在服务器上动态生成 HTML 的框架。
• Web API - Razor Pages 主要是一个 UI 生成框架。但是,Razor 页面处理程序可以返回任何类型的内容,包括 JSON。不过,如果您的应用程序主要是基于 Web 的服务,则 Razor Pages 不是正确的工具。您应该考虑改用 MVC API 控制器。应该指出的是,如果您的要求是生成 HTML 以及通过 HTTP 提供服务,那么在同一个项目中混合使用 Razor 页面和 API 控制器是完全可能的(并且很容易的)。
• 从旧版本的 MVC 迁移 – 如果您希望将现有 MVC 应用程序从早期版本的 .NET Framework 迁移到 ASP.NET Core,则移植到 ASP.NET Core MVC 可能更有意义,因为您的许多现有代码无需修改即可重复使用。迁移后,您可以将 Razor Pages 用于迁移的应用程序中的所有以页面为中心的新功能,因为 MVC 控制器和 Razor Pages 可以愉快地位于同一应用程序中。

Razor Pages 是在 Visual Studio 中构建基于页面的 Web 应用程序的默认项目类型,因此,在除上述例外情况之外的所有情况下,都应将 Razor Pages 用于以页面为中心的应用程序,无论其复杂程度如何。

ASP.NET Core 的设计将性能作为一流的功能。该框架经常在备受推崇的 TechEmpower Web 框架性能评级 (https://www.techempower.com/benchmarks) 中名列前茅。因此,如果您需要一个提供 HTML 的高性能应用程序,Razor Pages 有一个很好的基础。

ASP.NET Core 应用程序设计为模块化。也就是说,您只包含应用程序所需的功能。如果您不需要某个功能,则不包括在内。这样做的好处是使已发布的应用程序的占用空间尽可能小。如果限制已部署应用程序的整体大小对您很重要,Razor Pages 也可以勾选该框。

最后,ASP.NET Core 背后的团队一定做对了什么,因为根据 Stack Overflow 的 2020 年开发人员调查,ASP.NET Core 是“最受欢迎”的 Web 开发框架(参见 https://insights.stackoverflow.com/survey/2020#technology-most-loved-dreaded-and-wanted-web-frameworks)。

1.5 使用 Razor Pages

此时,您知道什么是 Razor Pages、它的工作原理以及它可以为您做什么。您现在应该知道它是否适合您的应用程序。如果是,您需要知道从何处获取 Razor Pages 以及可以使用哪些工具来使用框架。下一节将提供这些问题的答案。首先,我们将介绍如何获取 Razor Pages;然后,我们将介绍使用该框架开发 Web 应用程序所需的工具。

1.5.1 如何获得 Razor Pages?

要开始开发 Razor Pages 应用程序,您需要 .NET 软件开发工具包 (SDK)。当您首次安装 Visual Studio(Microsoft 的旗舰软件开发环境)时,将自动包含此密钥。之后,您可能需要手动安装 SDK 的更新版本。如果您使用的编辑器不包含 SDK,则需要手动安装 SDK。SDK 可在 https://dotnet.microsoft.com/download 获取。

版本可用于 Windows、Linux、macOS 和 Docker(图 1.5)。当前版本已明确标记并推荐使用,因为它包含最新的错误修复和其他改进。一个版本也将被标记为长期支持 (LTS) 版本;这可能是也可能不是当前版本。LTS 版本会在较长一段时间内继续接收关键错误修复。当前版本 .NET 6 是 LTS 版本,自其发布日期(2021 年 11 月)起,将继续受支持三年。Microsoft 的目标是使从一个 LTS 版本迁移到下一个 LTS 版本成为一种相对轻松的体验。

图 1.5 SDK 下载页面图

下载页提供对每个 .NET/.NET Core 版本的 SDK 和运行时的访问。SDK 包括运行时和一组用于开发应用程序的工具,包括用于 .NET 的命令行界面 (CLI)。CLI 提供对一系列命令的访问,这些命令使您能够开发、构建、运行和发布 .NET 应用程序。

运行时仅包括运行 .NET 应用程序所需的组件。运行时主要用于在不进行开发的计算机上进行部署。您可以在计算机上安装多个版本的 SDK 和/或运行时。他们快乐地比肩生活。

1.5.2 选择开发环境

从理论上讲,您可以只使用命令行开发 Razor Pages 应用程序,也许还可以使用 Windows 记事本等基本文本编辑器,但现实情况是,您将需要使用旨在支持 .NET Core 开发的工具,从而减轻您的大部分繁重工作。这些工具中最强大的是集成开发环境 (IDE),将包括源代码编辑器,这些编辑器具有语法突出显示、代码完成、静态代码分析以及用于调试、编译和发布应用程序的功能。IDE 通常支持常见的工作流程,例如创建应用程序和基于现有模板添加各种类型的文件。它们通常还包括与数据库和版本控制系统的集成。

用于 .NET 开发的最流行的 IDE 是 Microsoft 的 Visual Studio。要享受 .NET 6 支持,您需要使用 2022 版本。它有三个版本:Community、Professional 和 Enterprise。社区版是 Visual Studio 的完整版,与专业版的不同仅在于其许可证。社区版对个人和小型公司(如许可条款 (https://visualstudio.microsoft.com/vs/community/ 中所定义)免费,也可供学术使用或参与开源项目。企业版旨在供大型团队使用,并相应地定价。所有版本都仅适用于 Windows(图 1.6)。

图 1.6 https://visualstudio.microsoft.com/ 截图,读者可以获取目前提到的所有三个 IDE

有一个适用于 Mac 用户的 Visual Studio 版本,但它不是 Windows 版本的直接移植。它是 Xamarin Studios 的改编版本,主要是移动应用程序开发环境。但是,它支持 Razor Pages 开发,并且提供免费的社区版。

Visual Studio Code (VS Code) 是一种流行的免费跨平台代码编辑器(与开发环境相反)。大量且不断增长的扩展可用,使 VS Code 中的 .NET Core 开发变得非常容易,包括 C# 语言集成、调试和版本控制集成。VS Code 不包含 Visual Studio 提供的用于处理 Razor Pages 的相同类型的工具集成,但它确实具有集成终端,可轻松访问 .NET CLI,并且出色的 OmniSharp 扩展为 VS Code 中的 C# 开发提供了出色的支持。本书将讨论如何使用 VS Code 终端执行 CLI 命令;您可以从 https://code.visualstudio.com/ 下载 VS Code。

如果您想在 Mac 或 Linux 系统上进行开发,VS Code 是一个不错的选择。或者,JetBrains 的 Rider 是一个跨平台的 .NET IDE,提供 30 天免费试用。

在本书中,我将向您展示如何使用 Visual Studio Community Edition 和 VS Code 开发 Razor Pages 应用程序,但无论您选择使用哪个平台,您都可以按照这些示例进行作。

 1.5.3 选择数据库系统

Web 应用程序需要一种方法来持久保存数据。ASP.NET Core 不会对您的选项施加任何技术限制。如果需要,可以将数据存储为一系列文本文件,但最常用的数据存储是某种关系数据库。您还需要一种方法来在应用程序和数据库之间建立连接、执行数据库命令以及访问任何生成的数据。.NET 6 包括一种称为 ADO.NET 的低级数据访问技术。它以类似于内存中数据库表或视图的结构向应用程序公开数据。如果要访问数据片段,则必须使用索引器和转换或强制转换:

var myInt = Convert.ToInt32(dataTable.Rows[1][4]);

这是一种丑陋且容易出错的应用程序开发方法。它只需要有人更改上一个 C# 语句所依赖的 SQL 语句中的列顺序,因为目标位置的具体化值无法再转换为 int。如今,开发人员通常更喜欢将数据作为对象(例如,Book 类或 Author 类)来处理,并将使用对象关系映射 (ORM) 工具来管理数据库和应用程序之间的通信。ORM 还负责(除其他外)将数据从数据库查询映射到指定的对象或对象集合。

.NET 开发人员可以使用多种 ORM 工具。他们中的大多数由第三方拥有和管理。我为本书选择的 ORM 是 Entity Framework Core (EF Core)。我将使用这个 ORM,因为它是一种 Microsoft 技术,是 .NET 的一部分。图 1.7 是图 1.3 的更新版本,显示了 EF Core 在 .NET 堆栈中的位置。

图 1.7 Entity Framework Core 是一个可选组件,但它可用于支持在 .NET 6 上构建的各种应用程序类型(包括 ASP.NET、桌面、移动、云和游戏)中的数据访问。

定义提供程序是处理 C# 应用程序代码与数据存储本身之间的通信的组件。像 EF Core 这样的 ORM 的真正好处之一是,您不需要用数据存储特定的语言编写命令。您可以使用 C# 来表达数据命令,这与数据存储无关。每个单独的提供商都负责生成所选数据存储支持的域特定语言 (DSL)(除许多其他事项外)。在大多数情况下,此 DSL 是 SQL。

使用 EF Core 将提高您的工作效率,但也会根据专业提供商的可用性和/或成本,在数据库系统方面为您提供的选项增加限制因素。话虽如此,EF Core 支持大量数据库系统,尤其是最流行的数据库系统。要检查是否有适用于您首选数据库系统的提供商,请参阅官方文档:https://docs.microsoft.com/en-us/ef/core/providers/

当您使用 EF Core 等 ORM 时,数据库系统之间的差异或多或少完全隐藏在应用程序本身之外。您为一个数据库系统的数据存储和检索编写的 C# 代码在另一个系统上的工作方式完全相同。一个系统与另一个系统之间唯一真正的区别是初始配置。在本书中,我选择了两个数据库系统:一个 SQL Server 版本,适用于仅限 Windows 的开发人员,以及 SQLite,适用于希望了解其他作系统的读者。我将强调它们之间出现的罕见差异。

在 Microsoft 世界中工作,您比其他任何选择都更有可能遇到他们的旗舰关系数据库系统 SQL Server。安装 Visual Studio 时,可以很容易地安装 SQL Server 的一个版本 LocalDB。它不是为生产用途而设计的,并且仅包含运行 SQL Server 数据库所需的最小文件集。因此,我选择了 LocalDB 作为想要使用 Windows 的读者使用的版本。

您使用 LocalDB 创建的任何数据库也可以与完整版的 SQL Server 一起使用。Visual Studio 包含一项称为“服务器资源管理器”的功能,该功能使您能够从 IDE 中连接到数据库并执行基本的数据库管理任务,例如修改表和运行查询。或者,您可以免费下载和安装 SQL Server Management Studio (SSMS) (https://learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-server-ver16)。SSMS 是一个功能更强大的工具,包括用于管理数据库、分析查询性能和管理 SQL Server 安装的功能。

有大量的跨平台数据库选项可用,包括免费且非常流行的 MySQL。但是,我为希望在非 Windows 环境中进行开发的读者选择了基于文件的 SQLite 数据库系统,这仅仅是从易用性的角度来看。它已经安装在大多数版本的 Linux 和 macOS 上。话虽如此,如果您在 Windows 上进行开发,则没有理由不使用 SQLite。对于较小的网站来说,这是一个相当不错的选择。它与其余应用程序文件一起部署,从而可能简化部署并降低托管成本。在管理 SQLite 数据库方面,我使用免费的 SQL 数据库浏览器,它是跨平台的,可在 https://sqlitebrowser.org/ 使用。

无论您选择使用哪种数据库系统,您现在都应该准备好继续开发 Razor Pages 应用程序。您了解 Razor Pages 在 Web 开发侨民中的作用,以及使其成为绝佳选择的关键功能。它是现代且快速的,不会妨碍开发过程。在下一章中,您将立即生成第一个有效的 Razor Pages 应用程序,并学习构建更复杂应用程序的基础知识。

 总结

Razor Pages 是一个以页面为中心的框架,用于开发动态 Web 应用程序。
Razor Pages 是一项 Microsoft 技术。
Razor Pages 是 ASP.NET Core 的一部分,而 Core 又是 .NET 6 的一部分。
Razor Pages 是跨平台的。
Razor Pages 是开源且免费的。
Razor Pages 建立在 ASP.NET Core MVC 的最佳部分之上。
Razor Pages 是使用页面控制器模式的 MVC 实现。
Razor Pages 主要关注在 Web 服务器上生成 HTML。
使用 C# 对 Razor Pages 应用程序进行编程。
HTML 是基于 Razor 语法(HTML 和 C# 的混合)从模板动态生成的。
Razor Pages 适用于数据库。