Author Archives: usernames

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="[email protected]" value="@option.Value">   ❷
        <label for="[email protected]">
         ➥ @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 适用于数据库。

后端架构演进介绍

后端架构演进介绍

10年前,你只需要知道 GoF模式,现在需要掌握:N层、DDD、六边形Hexagon、洋葱Onion、Clean架构。

想想回到过去的美好时光,根本没有所谓架构,那些日子是多么幸福啊,只有了解 GoF 模式,你就能称自己为架构师。然而,计算机变得更加强大,用户的需求增加,导致应用程序的复杂性增加。开发人员解决的第一件事是将UI 与业务逻辑分离。根据 UI 框架的不同,诞生了不同的类似 MVC 的模式:

这在一段时间内有所帮助,但效果并不那么明显。如果你来自 C# 社区,可能会错误地认为那些图中名为Model的黄色框只是 DTO。这一切都是因为微软。这张图让我们对他们的 ASP MVC 框架感到困惑。事实上,这里的Model代表领域模型,也称为业务逻辑,这在任何应用程序中都相当关键。

你能打赌,上面这三个组件中哪一个造成的问题最多?虽然视图只是简单的图像和按钮,但控制器充当中间人,所有复杂性都集中在模型中。

那是一个 GoF 模式根本不够用的时期。因此必须出现新的想法。我们如何处理复杂性?

分而治之。我们已经使用 MVC 做到了,所以让我们再做一次。

2002 — N 层

理想的架构并不是凭空出现的。与所有事情一样,它在尝试和错误中走自己的路。

Jesu.. khe Martin Fowler是软件开发架构的先驱,并在未来十年影响了一代又一代的开发人员:

《企业应用架构模式》描述了N层架构。想法很简单,将所有相关代码组合在一起并依次调用这些层中代码。

然而,还有更多的事情要做。MF知道不一致的危害很大。因此,为了防止我们朝自己的腿开枪,他试图指导我们一些限制:

• 您可以按照您想要的方式命名图层
• 您可以根据需要拥有任意数量的层
• 你可以在中间添加层
• 同一层中可以有多个组件
• 只需确保各层之间存在清晰的 层次结构,并逐一相互引用

它不仅帮助开发人员消除代码重复,而且最终帮助他们构建代码。尽管这些规则非常灵活,但实际上,3 层对于大多数项目来说已经足够了。

• 用户界面(UI)——负责与用户交互。
• 业务逻辑层 (BLL) — 表示业务概念。它规定了您的应用程序正在执行的操作,并使其与其他应用程序相比如此独特。
• 数据访问层 (DAL) — 将数据保留在内存中并保持应用程序的状态

对业务逻辑和 UI 进行了明确的分离。事实证明,数据库与业务规则一样重要,因此它值得拥有自己的层。实际上,所有外部技术也可以进入最后一层。

如果您想知道这些彩色矩形和箭头对您意味着什么,请不要担心,这很简单。这些层只是解决方案中的项目,箭头表示这些层之间的依赖关系。

这种分离不一定是项目的物理分离,而可以是文件夹的逻辑分离。您还可以结合使用这两种方法。使用最适合您的。

文件夹和项目之间的区别很大。项目实际上允许您控制依赖项。对于文件夹,您甚至可能不会注意到,当一个层开始使用另一层的组件时。另一方面,项目太多,代码变得更加脆弱且难以维护。

请记住,这没有严格的规则。亲自尝试一下,看看什么最适合您。这是可靠性和复杂性之间的权衡。我的建议是不要创建太多项目,除非您确实需要它们,每层一个项目就足够了。每一层通过其 API 调用下面的一层,该 API 通常以interface.每个类上的访问修饰符与这些层一样重要:

现在这对你来说似乎是显而易见的,但这只是因为你并没有经历过真正艰难的时期。它总是很容易使用,但很难发明。

2003 — 领域驱动设计

《领域驱动设计:解决软件核心的复杂性》,这让世界上至少有一个马丁感到非常难过。

Evans 同意 Fowler 的所有想法,即项目依赖关系应该针对一个方向。然而,他也提到,低级模块调用上面的模块是可以的,除非它不违反依赖方向规则。可以通过回调、观察者模式等来实现。

他还发现控制器有太多逻辑,因此他将其移动到另一个称为“应用程序Application”层。我们开始获得用例的萌芽,但尚未完全。但埃文斯所做的最重要的事情是说“去它的数据库,业务逻辑更重要”。 他这么说,然后什么也没做。不过,从架构的角度来看,他并没有太大的改变。

在他的架构中,定义了下一层:

• 表示层——负责与用户交互。
• 应用层——协调任务并将工作委托给域对象。
• 领域层——代表业务概念。它规定了您的应用程序正在执行的操作,并使其与其他应用程序相比如此独特。
• 基础设施层——将数据保存在内存中并保持应用程序的状态

你可以看到,他做了一些重命名。

用户界面意味着您有用户,但情况并非总是如此。有时是用户的GUI(图形用户界面),有时是开发人员的CLI(命令行界面),更多时候是程序的API(应用程序编程接口)。表示层只是一个更通用和合适的名称。

业务逻辑对于一些开发人员来说是令人困惑的,特别是对于那些根本不做业务的人来说,因此引入了一个新名称——Domain领域层。

数据库不是我们使用的唯一外部工具,因此所有电子邮件发送器、事件总线、SQL 和其他都移至基础设施。

基本上就是这样。这里有一些重命名。在那里加上一个新层。我们为该领域付出了很多努力。但它是具有相同依赖关系的相同架构。如果他知道依赖倒置原则就好了。

2005 年 — Hexagon(端口和适配器)

以前,模块必须引用行中的下一个模块。随着依赖倒置DI/IOC的发现,一切都改变了。这对于软件开发人员来说是一个难以置信的机会。我们终于学会了如何控制依赖项的方向,以我们喜欢的方式指向它们!这意味着业务逻辑不再引用数据访问。

潜力的人是阿利斯泰尔·科伯恩(Alistair Cockburn)。那家伙很嗨,画了一个六边形,试图召唤撒旦,等等。我不需要告诉你,你自己更清楚摇滚派对是如何进行的。这里没什么特别的,有一天你抽了一些麻醉,第二天,早上醒来,宿醉很厉害,发现自己意外地发现了一个新的架构。

阿利斯泰尔厌倦了矩形,所以他画了一个六边形,为所有东西想出了两个名字,试图让它变得神圣。但别吓到我的开发者小伙子。事实上,这种架构并不比 N 层架构复杂多少:

阿利斯泰尔让埃文斯梦想成真。现在,领域已成为系统的核心组成部分,不仅在言语上,而且在行动上。它不引用任何其他项目。

为了强调它确实是心脏,Business Logic 更名为Core。

基础设施模块分为两半——抽象(接口)和实现。抽象成为业务逻辑的一部分,并被重命名为端口Port。实施停留在基础设施层。现在它们被称为适配器Adapter。

实践证明,UI 和 DB 是同一个框架层,所以也遭遇了同样的命运。

在您的业务逻辑中拥有基础设施的接口,可以使域具有自主性和无依赖性。

因此,业务逻辑可以在任何环境中使用任何工具工作。您想更改数据库吗?只需更改实现,实现所需的适配器,并将其“插入”到可用端口即可。

任何适配器(数据库、电子邮件发送器、用户界面)的更改都不会影响业务逻辑。接口保持不变。

每个组件都可以单独部署。如果更改数据访问,则只需重建数据访问。如果更改 UI,则仅更改 UI。

由于模块可以单独部署,也就意味着可以单独开发。

调用我们系统的适配器称为主(驱动)。那些被我们系统调用的称为次要(驱动)。

就解决方案结构而言,这些最适合我:

同样,文件夹与项目是您应该自己决定的。

只需遵循参考文献并确保它们不会交叉到不应该交叉的地方:

2008 — 洋葱架构

杰弗里·巴勒莫。这是一个充满悲伤和黑暗的悲伤故事,讲述了一个男孩天真的童年被洋葱的残酷沉思所破坏的故事。随着他的成长,一种炽热的仇恨在他内心熊熊燃烧,燃烧着他有一天会实现的复仇承诺:

相信我,他永远信守了自己的诺言。他的小洋葱让全世界数以百万计的开发者一边哭一边跑到妈妈的怀里。

这种架构在端口和适配器方面得到了很大的增强。它仍然涉及依赖倒置。它通过抽象和实现来分割代码。端口仍然是业务逻辑的一部分。只是这次巴勒莫从埃文斯模式添加了应用程序层,它也可以包含一些端口。

这种架构的最大挑战是模块之间的依赖关系,它导致了如此多的混乱。

然而,规则很简单:任何外层只能且仅依赖于内层。

域位于最中间。它内部没有内层,因此它不应该依赖于任何其他层。

应用程序仅包装域,因此这正是它应该具有的唯一依赖项。

基础设施层和表示层位于同一级别,它们不能相互依赖,但可以依赖于应用程序和域,其中定义了所有需要的接口。

您还可以看到它具有 DDD 架构中的所有模块,但处理方式不同。

这实际上是一件大事!这里的关键是,中间的组件很少修改,而边缘的组件经常更改。应用程序或任何其他层的更改不会影响域,只会影响依赖层。域发生变化的唯一原因是业务逻辑发生变化,而这种情况无论如何都会影响整个系统。

理论看起来就是这样。实际上,您的组合根(Main()注册所有依赖项并将模块组合在一起的函数)将是表示层(ASP、WPF、CLI)的一部分,因此该图将具有以下外观:

你看起来很熟悉吗?它是N层架构,但组件的顺序不同。

无论它看起来如何,六边形,端口或洋葱,您的最终目标应该是让您的依赖关系以无环图或树的形式指出。

2012 — 清洁Clean架构

有一个叫鲍勃叔叔的人,

他是工作中最干净的程序员,

凭借他敏捷的动作和架构,

他会让你的代码焕然一新,

他看到了围绕架构的所有炒作,并决定破坏这个聚会。马丁知道任何开发人员的主要秘密,所以他甚至不想隐藏它。只是厚颜无耻地窃取别人的想法并称其为自己的。

开个玩笑,现在没什么原创的想法了,大家互相抄袭

我们可怜的域名再次被重命名。现在是实体。然而,不仅如此。这意味着您没有领域服务和贫乏的模型,而是具有数据和行为的丰富类。

存储库和其他端口的接口从域移至应用层。这又得到了一个更合适的名称——用例。

表示层和基础设施层保持不变。然而,Martin 还在上面添加了一层额外的层,其中包括框架、DLL 和其他外部依赖项。这并不一定意味着您的数据库将引用实体,它只是阻止您从内层引用那些外部工具。

再次强调,没有严格的规则。您可以根据需要在任何级别添加任意数量的层。因此,如果您想为域服务定义一个层,则可以。

马丁还在架构大图附近画了一个小图。

它显示用户通过触发控制器的端点与系统进行通信,该端点调用用例,然后通过演示器返回数据(黑线)。用例可以通过接口(绿线)调用任何类似的端口。而实际实现是外层的一部分(橙色线)。

它试图强调执行流程(虚线)并不总是对应于依赖方向(直线),这就是依赖倒置原则。

基本上,它再次强调了控制反转的用法。当我们讨论端口和适配器时,您已经看到了这一点。

通常在 ASP 中我们没有单独的 Presenter 组件。这也是由控制器完成的。因此整个图可以用如下代码表示:

class OrderController : ControllerBase、IInputPort、IOutputPort
{
[ HttpGet ]
public IActionResult Get ( int id )

{
_getOrderUserCase.Execute(id); }
return DisplayOutputPortResult();
}
}

其他形式的隔离

所有这些架构的目标都是通过划分职责来将一个代码与另一个代码隔离。然而,还有其他形式的隔离:垂直切片、有界上下文、模块、微服务等等。这里的目标是按功能拆分代码。

有些人不认为它们是“真正的”架构方法,而有些人则认为。它是由你决定。最终,他们将发展到仍然会使用上面的任何架构风格,甚至是这些风格的组合:

结论

在本文中,我们讨论了 N 层、DDD、Hexagon、Onion 和 Clean 架构。这些并不是唯一存在的架构。然而,所描述的是最著名的。您可能还听说过 BCE、DCI 等。

尽管细节上存在细微差别,但所有架构几乎都是相同的。它们都服务于同一个目的——分担责任。他们都是通过将代码拆分到不同的层来实现的。全部区别在于定义了哪些组件以及这些层之间存在哪些依赖关系。

https://www.jdon.com/Backend-Architecture.html

Mastering Minimal APIs in ASP.NET Core

Mastering Minimal APIs in ASP.NET Core
Copyright © 2022 Packt Publishing

In memory of my mother and father, Giovanna and Francesco, for their sacrifices and for supporting me in studying and facing new challenges every day.
为了纪念我的父母 Giovanna 和 Francesco,感谢他们的牺牲,以及支持我学习和每天面对新的挑战。
– 安德里亚·土里
– Andrea Tosato

To my family, friends, and colleagues, who have always believed in me during this journey.
– Marco Minerva
感谢我的家人、朋友和同事,他们在这段旅程中一直相信我。
– 马可·密涅瓦

In memory of my beloved mom, and to my wife, Francesca, for her sacrifices and understanding.
Last but not least, to my son, Leonardo. The greatest success in my life.
– Emanuele Bartolesi
为了纪念我敬爱的妈妈,以及我的妻子弗朗西斯卡,感谢她的牺牲和理解。
最后但并非最不重要的一点是,感谢我的儿子莱昂纳多。我一生中最大的成功。
– 埃马努埃莱·巴托莱西

Contributors
贡献

About the authors
作者简介

Andrea Tosato is a full stack software engineer and architect of .NET applications. Andrea has successfully developed .NET applications in various industries, sometimes facing complex technological challenges. He deals with desktop, web, and mobile development but with the arrival of the cloud, Azure has become his passion. In 2017, he co-founded Cloudgen Verona (a .NET community based in Verona, Italy) with his friend, Marco Zamana. In 2019, he was named Microsoft MVP for the first time in the Azure category. Andrea graduated from the University of Pavia with a degree in computer engineering in 2008 and successfully completed his master’s degree, also in computer engineering, in Modena in 2011. Andrea was born in 1986 in Verona, Italy, where he currently works as a remote worker. You can find Andrea on Twitter.
Andrea Tosato 是一名全栈软件工程师和 .NET 应用程序架构师。Andrea 在各个行业成功开发了 .NET 应用程序,有时面临复杂的技术挑战。他处理桌面、Web 和移动开发,但随着云的到来,Azure 已成为他的热情所在。2017 年,他与朋友 Marco Zamana 共同创立了 Cloudgen Verona(一个位于意大利维罗纳的 .NET 社区)。2019 年,他首次被评为 Azure 类别的 Microsoft MVP。Andrea 于 2008 年毕业于帕维亚大学,获得计算机工程学位,并于 2011 年在摩德纳成功完成了计算机工程硕士学位。Andrea 于 1986 年出生于意大利维罗纳,目前在那里担任远程工作者。你可以在 Twitter 上找到 Andrea。

Marco Minerva has been a computer enthusiast since elementary school when he received an old Commodore VIC-20 as a gift. He began developing with GW-BASIC. After some experience with Visual Basic, he has been using .NET since its first introduction. He got his master’s degree in information technology in 2006. Today, he lives in Taggia, Italy, where he works as a freelance consultant and is involved in designing and developing solutions for the Microsoft ecosystem, building applications for desktop, mobile, and web. His expertise is in backend development as a software architect. He runs training courses, is a speaker at technical events, writes articles for magazines, and regularly makes live streams about coding on Twitch. He has been a Microsoft MVP since 2013. You can find Marco on Twitter.
Marco Minerva 从小学开始就是一个计算机爱好者,当时他收到了一台旧的 Commodore VIC-20 作为礼物。他开始使用 GW-BASIC 进行开发。在具备一些 Visual Basic 经验后,他自首次引入 .NET 以来就一直在使用 .NET。他于 2006 年获得信息技术硕士学位。如今,他住在意大利塔吉亚,在那里他是一名自由顾问,参与为 Microsoft 生态系统设计和开发解决方案,构建桌面、移动和 Web 应用程序。他的专长是作为软件架构师进行后端开发。他举办培训课程,在技术活动中发表演讲,为杂志撰写文章,并定期在 Twitch 上制作有关编码的直播。自 2013 年以来,他一直是 Microsoft MVP。您可以在 Twitter 上找到 Marco。

Emanuele Bartolesi is a Microsoft 365 architect who is passionate about frontend technologies and everything related to the cloud, especially Microsoft Azure. He currently lives in Zurich and actively participates in local and international community activities and events. Emanuele shares his love of technology through his blog. He has also become a Twitch affiliate as a live coder, and you can find him as kasuken on Twitch to write some code with him. Emanuele has been a Microsoft MVP in the developer technologies category since 2014, and a GitHub Star since 2022. You can find Emanuele on Twitter.
Emanuele Bartolesi 是一名 Microsoft 365 架构师,他对前端技术以及与云相关的一切(尤其是 Microsoft Azure)充满热情。他目前居住在苏黎世,积极参与当地和国际社区活动。Emanuele 通过他的博客分享了他对技术的热爱。他还作为实时编码员成为 Twitch 的附属机构,您可以在 Twitch 上找到他作为 kasuken 与他一起编写一些代码。Emanuele 自 2014 年以来一直是开发人员技术类别的 Microsoft MVP,自 2022 年以来一直是 GitHub Star。您可以在 Twitter 上找到 Emanuele。

About the reviewers
关于审稿人

Marco Parenzan is a senior solution architect for Smart Factory, IoT, and Azure-based solutions at beanTech, a tech company in Italy. He has been a Microsoft Azure MVP since 2014 and has been playing with the cloud since 2010. He speaks about Azure and .NET development at major community events in Italy. He is a community lead for 1nn0va, a recognized Microsoft-oriented community in Pordenone, Italy, where he organizes local community events. He wrote a book on Azure for Packt Publishing in 2016. He loves playing with his Commodore 64 and trying to write small retro games in .NET or JavaScript.
Marco Parenzan 是意大利科技公司 beanTech 的智能工厂、IoT 和基于 Azure 的解决方案的高级解决方案架构师。自 2014 年以来,他一直是 Microsoft Azure MVP,自 2010 年以来一直在玩云。他在意大利的主要社区活动中谈论 Azure 和 .NET 开发。他是 1nn0va 的社区负责人,这是意大利波代诺内一个公认的面向 Microsoft 的社区,他在那里组织当地社区活动。他在 2016 年为 Packt Publishing 撰写了一本关于 Azure 的书。他喜欢玩他的 Commodore 64,并尝试用 .NET 或 JavaScript 编写小型复古游戏。

Marco Zamana lives in Verona in the magnificent hills of Valpolicella. He has a background as a software developer and architect. He was Microsoft’s Most Valuable Professional for 3 years in the artificial intelligence category. He currently works as a cloud solution architect in engineering at Microsoft. He is the co-founder of Cloudgen Verona, a Veronese association that discusses topics related to the cloud and, above all, Azure.
Marco Zamana 住在维罗纳 Valpolicella 壮丽的山丘上。他拥有软件开发人员和架构师的背景。他在人工智能类别中连续 3 年被评为 Microsoft 最有价值专家。他目前在 Microsoft 担任工程部门的云解决方案架构师。他是 Cloudgen Verona 的联合创始人,这是一个 Veronese 协会,讨论与云相关的主题,尤其是 Azure。

Ashirwad Satapathi works as an associate consultant at Microsoft and has expertise in building scalable applications with ASP.NET Core and Microsoft Azure. He is a published author and an active blogger in the C# Corner developer community. He was awarded the title of C# Corner Most Valuable Professional (MVP) in September 2020 and September 2021 for his contributions to the developer community. He is also a member of the Outreach Committee of the .NET Foundation.
Ashirwad Satapathi 是 Microsoft 的助理顾问,拥有使用 ASP.NET Core 和 Microsoft Azure 构建可缩放应用程序的专业知识。他是 C# Corner 开发人员社区的出版作者和活跃的博客作者。他于 2020 年 9 月和 2021 年 9 月被授予 C# Corner 最有价值专家 (MVP) 称号,以表彰他对开发者社区的贡献。他还是 .NET Foundation 外展委员会的成员。

Table of Contents
目录

Preface
前言

Part 1: Introduction
第 1 部分:简介

1 Introduction to Minimal APIs
最小 API 简介

2 Exploring Minimal APIs and Their Advantages
探索最小 API 及其优势

3 Working with Minimal APIs
使用最少的 API

Part 2: What’s New in .NET 6?
第 2 部分:.NET 6 中的新增功能

4 Dependency Injection in a Minimal API Project
最小 API 项目中的依赖关系注入

5 Using Logging to Identify Errors
使用日志记录识别错误

6 Exploring Validation and Mapping
探索验证和映射

7 Integration with the Data Access Layer
与 Data Access Layer 集成

Part 3: Advanced Development and Microservices Concepts
第 3 部分:高级开发和微服务概念

8 Adding Authentication and Authorization
添加身份验证和授权

9 Leveraging Globalization and Localization
利用全球化和本地化

10 Evaluating and Benchmarking the Performance of Minimal APIs
评估最小 API 的性能并对其进行基准测试

Index
索引

Other Books You May Enjoy
您可能喜欢的其他书籍

Preface

前言

The simplification of code is every developer’s dream. Minimal APIs are a new feature in .NET 6 that aims to simplify code. They are used for building APIs with minimal dependencies in ASP.NET Core. Minimal APIs simplify API development through the use of more compact code syntax.
简化代码是每个开发人员的梦想。最小 API 是 .NET 6 中的一项新功能,旨在简化代码。它们用于在 ASP.NET Core 中构建具有最小依赖项的 API。最少的 API 通过使用更紧凑的代码语法简化了 API 开发。

Developers using minimal APIs will be able to take advantage of this syntax on some occasions to work more quickly with less code and fewer files to maintain. Here, you will be introduced to the main new features of .NET 6 and understand the basic themes of minimal APIs, which weren’t available in .NET 5 and previous versions. You’ll see how to enable Swagger for API documentation, along with CORS, and how to handle application errors. You will learn to structure your code better with Microsoft’s new .NET framework called Dependency Injection. Finally, you will see the performance and benchmarking improvements in .NET 6 that are introduced with minimal APIs.
使用最少 API 的开发人员将能够在某些情况下利用此语法,以更少的代码和更少的文件更快地工作。在这里,将向您介绍 .NET 6 的主要新功能,并了解最小 API 的基本主题,这些主题在 .NET 5 和以前的版本中不可用。您将了解如何为 API 文档以及 CORS 启用 Swagger,以及如何处理应用程序错误。您将学习如何使用 Microsoft 的新 .NET 框架(称为 Dependency Injection)更好地构建代码。最后,您将看到 .NET 6 中的性能和基准测试改进,这些改进是通过最少的 API 引入的。

By the end of this book, you will be able to leverage minimal APIs and understand in what way they are related to the classic development of web APIs.
在本书结束时,您将能够利用最少的 API,并了解它们与 Web API 的经典开发有何关系。

Who this book is for
这本书是给谁的

This book is for .NET developers who want to build .NET and .NET Core APIs and want to study the new features of .NET 6. Basic knowledge of C#, .NET, Visual Studio, and REST APIs is assumed.
本书适用于想要构建 .NET 和 .NET Core API 并希望学习 .NET 6 新功能的 .NET 开发人员。假定您具备 C#、.NET、Visual Studio 和 REST API 的基本知识。

What this book covers
本书涵盖的内容

Chapter 1, Introduction to Minimal APIs, introduces you to the motivations behind introducing minimal APIs within .NET 6. We will explain the main new features of .NET 6 and the work that the .NET team is doing with this latest version. You will come to understand the reasons why we decided to write the book.
第 1 章 最小 API 简介,介绍了在 .NET 6 中引入最小 API 的动机。我们将解释 .NET 6 的主要新功能以及 .NET 团队正在使用此最新版本所做的工作。您将了解我们决定写这本书的原因。

Chapter 2, Exploring Minimal APIs and Their Advantages, introduces you to the basic ways in which minimal APIs differ from .NET 5 and all previous versions. We will explore in detail routing and serialization with System.Text.JSON. Finally, we will end with some concepts related to writing our first REST API.
第 2 章“探索最小 API 及其优势”介绍了最小 API 与 .NET 5 和所有以前版本的基本区别。我们将详细探讨 System.Text.JSON 的路由和序列化。最后,我们将介绍与编写第一个 REST API 相关的一些概念。

Chapter 3, Working with Minimal APIs, introduces you to the advanced ways in which minimal APIs differ from .NET 5 and all previous versions. We will explore in detail how to enable Swagger for API documentation. We will see how to enable CORS and how to handle application errors.
第 3 章 使用最小 API 介绍了最小 API 与 .NET 5 和所有以前版本的不同之处。我们将详细探讨如何为 API 文档启用 Swagger。我们将了解如何启用 CORS 以及如何处理应用程序错误。

Chapter 4, Dependency Injection in a Minimal API Project, introduces you to Dependency Injection and goes over how to use it with a minimal API.
第 4 章 最小 API 项目中的依赖注入 介绍了依赖注入,并介绍了如何将其与最小 API 一起使用。

Chapter 5, Using Logging to Identify Errors, teaches you about the logging tools that .NET provides. A logger is one of the tools that developers have to use to debug an application or understand its failure in production. The logging library has been built into ASP.NET with several features enabled by design.
第 5 章 使用日志记录识别错误,介绍 .NET 提供的日志记录工具。记录器是开发人员用来调试应用程序或了解其在生产中的故障的工具之一。日志记录库已内置于 ASP.NET 中,并通过设计启用了多项功能。

Chapter 6, Exploring Validation and Mapping, will teach you how to validate incoming data to an API and how to return any errors or messages. Once the data is validated, it can be mapped to a model that will then be used to process the request.
第 6 章 探索验证和映射 将教您如何验证 API 的传入数据以及如何返回任何错误或消息。验证数据后,可以将其映射到模型,然后该模型将用于处理请求。

Chapter 7, Integration with the Data Access Layer, helps you understand the best practices for accessing and using data in minimal APIs.
第 7 章 与数据访问层集成 可帮助您了解在最小 API 中访问和使用数据的最佳实践。

Chapter 8, Adding Authentication and Authorization, looks at how to write an authentication and authorization system by leveraging our own database or a cloud service such as Azure Active Directory.
第 8 章 添加身份验证和授权,介绍如何利用我们自己的数据库或云服务(如 Azure Active Directory)编写身份验证和授权系统。

Chapter 9, Leveraging Globalization and Localization, shows you how to leverage the translation system in a minimal API project and provide errors in the same language of the client.
第 9 章 利用全球化和本地化 向您展示如何在最小的 API 项目中利用翻译系统,并以客户端的相同语言提供错误。

Chapter 10, Evaluating and Benchmarking the Performance of Minimal APIs, shows the improvements in .NET 6 and those that will be introduced with the minimal APIs.
第 10 章 评估最小 API 的性能并对其进行基准测试,介绍了 .NET 6 中的改进以及最小 API 将引入的改进。

To get the most out of this book
充分利用本书

You will need Visual Studio 2022 with ASP.NET and a web development workload or Visual Studio Code and K6 installed on your computer.
您的计算机上需要带有 ASP.NET 和 Web 开发工作负载的 Visual Studio 2022 或 Visual Studio Code 和 K6。

All code examples have been tested using Visual Studio 2022 and Visual Studio Code on the Windows OS.
所有代码示例均已在 Windows作系统上使用 Visual Studio 2022 和 Visual Studio Code 进行了测试。

If you are using the digital version of this book, we advise you to type the code yourself or access the code from the book’s GitHub repository (a link is available in the next section). Doing so will help you avoid any potential errors related to the copying and pasting of code.
如果您使用的是本书的数字版本,我们建议您自己输入代码或从本书的 GitHub 存储库访问代码(下一节中提供了链接)。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。

Basic development skills for Microsoft web technology are required to fully understand this book.
要完全理解本书,需要具备 Microsoft Web 技术的基本开发技能。

Download the example code files
下载示例代码文件

You can download the example code files for this book from GitHub at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6. If there’s an update to the code, it will be updated in the GitHub repository.
您可以从 GitHub 下载本书的示例代码文件,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6。如果代码有更新,它将在 GitHub 存储库中更新。

We also have other code bundles from our rich catalog of books and videos available at https://github.com/PacktPublishing/. Check them out!
我们还在 https://github.com/PacktPublishing/ 上提供了丰富的书籍和视频目录中的其他代码包。看看他们吧!

Download the color images
下载彩色图像

We also provide a PDF file that has color images of the screenshots and diagrams used in this book.You can download it here: https://packt.link/GmUNL
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以在此处下载:https://packt.link/GmUNL

Conventions used
使用的约定

There are a number of text conventions used throughout this book.
本书中使用了许多文本约定。

Code in text: Indicates code words in text, database table names, folder names, filenames, file extensions, pathnames, dummy URLs, user input, and Twitter handles. Here is an example: “In minimal APIs, we define the route patterns using the Map methods of the WebApplication object.”
文本中的代码:指示文本中的代码词、数据库表名称、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。下面是一个示例:“在最小的 API 中,我们使用 WebApplication 对象的 Map
方法定义路由模式。

A block of code is set as follows:
代码块设置如下:

app.MapGet("/hello-get", () => "[GET] Hello World!"); 
app.MapPost("/hello-post", () => "[POST] Hello World!"); 
app.MapPut("/hello-put", () => "[PUT] Hello World!"); 
app.MapDelete("/hello-delete", () => "[DELETE] Hello World!");

When we wish to draw your attention to a particular part of a code block, the relevant lines or items are set in bold:
当我们希望您注意到代码块的特定部分时,相关行或项目以粗体设置:

if (app.Environment.IsDevelopment()) 
{
    app.UseSwagger(); 
    app.UseSwaggerUI(); 
}

Any command-line input or output is written as follows:
任何命令行输入或输出的编写方式如下:

dotnet new webapi -minimal -o Chapter01

Bold: Indicates a new term, an important word, or words that you see onscreen. For instance, words in menus or dialog boxes appear in bold. Here is an example: “Open Visual Studio 2022 and from the main screen, click on Create a new project.”
粗体:表示新词、重要字词或您在屏幕上看到的字词。例如,菜单或对话框中的单词以粗体显示。这是一个例子:“打开 Visual Studio 2022,然后在主屏幕上单击创建新项目。

Tips or important notes
提示或重要说明
Appear like this.
如下所示。

Get in touch
联系我们

Feedback from our readers is always welcome.
我们始终欢迎读者的反馈。

General feedback: If you have questions about any aspect of this book, email us at [email protected] and mention the book title in the subject of your message.
一般反馈:如果您对本书的任何方面有任何疑问,请发送电子邮件至 [email protected] 并在邮件主题中提及书名。

Errata: Although we have taken every care to ensure the accuracy of our content, mistakes do happen. If you have found a mistake in this book, we would be grateful if you would report this to us. Please visit www.packtpub.com/support/errata and fill in the form.
勘误表: 尽管我们已尽一切努力确保内容的准确性,但错误还是会发生。如果您发现本书中有错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

Piracy: If you come across any illegal copies of our works in any form on the internet, we would be grateful if you would provide us with the location address or website name. Please contact us at [email protected] with a link to the material.
盗版:如果您在互联网上发现任何形式的非法复制我们的作品,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过 [email protected] 与我们联系,并提供材料链接。

If you are interested in becoming an author: If there is a topic that you have expertise in and you are interested in either writing or contributing to a book, please visit authors.packtpub.com.
如果您有兴趣成为作者:如果您擅长某个主题,并且您对写作或为一本书做出贡献感兴趣,请访问 authors.packtpub.com。

Share Your Thoughts
分享您的想法

Once you’ve read Mastering Minimal APIs in ASP.NET Core, we’d love to hear your thoughts! Please click here to go straight to the Amazon review page for this book and share your feedback.
阅读了掌握 ASP.NET Core 中的最小 API 后,我们很想听听你的想法!请单击此处直接进入本书的亚马逊评论页面并分享您的反馈。

Your review is important to us and the tech community and will help us make sure we’re delivering excellent quality content.
您的评论对我们和技术社区都很重要,这将有助于我们确保我们提供卓越的内容质量。

Part 1: Introduction

第 1 部分:简介

In the first part of the book, we want to introduce you to the context of the book. We will explain the basics of minimal APIs and how they work. We want to add, brick by brick, the knowledge needed to take advantage of all the power that minimal APIs can grant us.
在本书的第一部分,我们想向您介绍这本书的背景。我们将解释最小 API 的基础知识及其工作原理。我们希望一砖一瓦地添加所需的知识,以利用最小 API 可以赋予我们的所有功能。

We will cover the following chapters in this part:
我们将在这部分介绍以下章节:

Chapter 1, Introduction to Minimal APIs
第 1 章 最小 API 简介

Chapter 2, Exploring Minimal APIs and Their Advantages
第 2 章 探索最小 API 及其优点

Chapter 3, Working with Minimal APIs
第 3 章 使用最少的 API

1 Introduction to Minimal APIs

1 最小 API 简介

In this chapter of the book, we will introduce some basic themes related to minimal APIs in .NET 6.0, showing how to set up a development environment for .NET 6 and more specifically for developing minimal APIs with ASP.NET Core.
在本书的这一章中,我们将介绍一些与 .NET 6.0 中的最小 API 相关的基本主题,展示如何为 .NET 6 设置开发环境,更具体地说,如何为 ASP.NET Core 开发最小 API。

We will first begin with a brief history of minimal APIs. Then, we will create a new minimal API project with Visual Studio 2022 and Visual Code Studio. At the end, we will take a look at the structure of our project.
首先,我们将从最小 API 的简要历史开始。然后,我们将使用 Visual Studio 2022 和 Visual Code Studio 创建一个新的最小 API 项目。最后,我们将看看我们项目的结构。

By the end of this chapter, you will be able to create a new minimal API project and start to work with this new template for a REST API.
在本章结束时,您将能够创建一个新的最小 API 项目,并开始为 REST API 使用这个新模板。

In this chapter, we will be covering the following topics:
在本章中,我们将介绍以下主题:

• A brief history of the Microsoft Web API
• Creating a new minimal API project
• Looking at the structure of the project

Technical requirements
技术要求

To work with the ASP.NET Core 6 minimal APIs you need to install, first of all, .NET 6 on your development environment.
要使用 ASP.NET Core 6 最小 API,您需要首先在开发环境中安装 .NET 6。

If you have not already installed it, let’s do that now:
如果您还没有安装它,我们现在就安装它:

  1. Navigate to the following link: https://dotnet.microsoft.com.
    导航到以下链接:https://dotnet.microsoft.com

  2. Click on the Download button.
    点击 下载 按钮。

  3. By default, the browser chooses the right operating system for you, but if not, select your operating system at the top of the page.
    默认情况下,浏览器会为您选择合适的作系统,如果没有,请在页面顶部选择您的作系统。

  4. Download the LTS version of the .NET 6.0 SDK.
    下载 .NET 6.0 SDK 的 LTS 版本。

  5. Start the installer.
    启动安装程序。

  6. Reboot the machine (this is not mandatory).
    重新启动计算机(这不是强制性的)。

You can see which SDKs are installed on your development machine using the following command in a terminal:
您可以在终端中使用以下命令查看开发计算机上安装了哪些 SDK:

dotnet –list-sdks

Before you start coding, you will need a code editor or an Integrated Development Environment (IDE). You can choose your favorite from the following list:
在开始编码之前,您需要一个代码编辑器或集成开发环境 (IDE)。您可以从以下列表中选择您最喜欢的:

• Visual Studio Code for Windows, Mac, or Linux
• Visual Studio 2022
• Visual Studio 2022 for Mac

In the last few years, Visual Studio Code has become very popular not only in the developer community but also in the Microsoft community. Even if you use Visual Studio 2022 for your day-to-day work, we recommend downloading and installing Visual Studio Code and giving it a try.
在过去的几年里,Visual Studio Code 不仅在开发人员社区中非常流行,而且在 Microsoft 社区中也非常流行。即使您将 Visual Studio 2022 用于日常工作,我们也建议您下载并安装 Visual Studio Code 并试一试。

Let’s download and install Visual Studio Code and some extensions:
让我们下载并安装 Visual Studio Code 和一些扩展:

  1. Navigate to https://code.visualstudio.com.
    导航到 https://code.visualstudio.com

  2. Download the Stable or the Insiders edition.
    下载 Stable 或 Insiders 版本。

  3. Start the installer.
    启动安装程序。

  4. Launch Visual Studio Code.
    启动 Visual Studio Code。

  5. Click on the Extensions icon.
    单击 Extensions 图标。

You will see the C# extension at the top of the list.
您将在列表顶部看到 C# 扩展。

  1. Click on the Install button and wait.
    点击 Install 安装 按钮并等待。

You can install other recommended extensions for developing with C# and ASP.NET Core. If you want to install them, you see our recommendations in the following table:
您可以安装其他推荐的扩展,以便使用 C# 和 ASP.NET Core 进行开发。如果您想安装它们,您可以在下表中看到我们的建议:

Additionally, if you want to proceed with the IDE that’s most widely used by .NET developers, you can download and install Visual Studio 2022.
此外,如果您想继续使用 .NET 开发人员使用最广泛的 IDE,您可以下载并安装 Visual Studio 2022。

If you don’t have a license, check if you can use the Community Edition. There are a few restrictions on getting a license, but you can use it if you are a student, have open source projects, or want to use it as an individual. Here’s how to download and install Visual Studio 2022:
如果您没有许可证,请检查是否可以使用 Community Edition。获得许可证有一些限制,但如果您是学生、拥有开源项目或想以个人身份使用它,则可以使用它。以下是下载和安装 Visual Studio 2022 的方法:

  1. Navigate to https://visualstudio.microsoft.com/downloads/.
    导航到 https://visualstudio.microsoft.com/downloads/

  2. Select Visual Studio 2022 version 17.0 or later and download it.
    选择 Visual Studio 2022 版本 17.0 或更高版本并下载它。

  3. Start the installer.
    启动安装程序。

  4. On the Workloads tab, select the following:
    在 Workloads (工作负载) 选项卡上,选择以下选项:

• ASP.NET and web development
• Azure Development

  1. On the Individual Components tab, select the following:
    在 Individual Components 选项卡上,选择以下选项:

• Git for Windows

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter01.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter01

Now, you have an environment in which you can follow and try the code used in this book.
现在,您有一个环境,可以在其中遵循和尝试本书中使用的代码。

A brief history of the Microsoft Web API
Microsoft Web API 简史

A few years ago in 2007, .NET web applications went through an evolution with the introduction of ASP.NET MVC. Since then, .NET has provided native support for the Model-View-Controller pattern that was common in other languages.
几年前的 2007 年,随着 ASP.NET MVC 的推出,.NET Web 应用程序经历了一场演变。从那时起,.NET 就为其他语言中常见的 Model-View-Controller 模式提供了本机支持。

Five years later, in 2012, RESTful APIs were the new trend on the internet and .NET responded to this with a new approach for developing APIs, called ASP.NET Web API. It was a significant improvement over Windows Communication Foundation (WCF) because it was easier to develop services for the web. Later, in ASP.NET Core these frameworks were unified under the name ASP.NET Core MVC: one single framework with which to develop web applications and APIs.
五年后,即 2012 年,RESTful API 成为 Internet 上的新趋势,.NET 以一种称为 ASP.NET Web API 的 API 开发新方法对此做出了回应。与 Windows Communication Foundation (WCF) 相比,这是一个重大改进,因为它更容易开发 Web 服务。后来,在 ASP.NET Core 中,这些框架统一为 ASP.NET Core MVC:一个用于开发 Web 应用程序和 API 的单一框架。

In ASP.NET Core MVC applications, the controller is responsible for accepting inputs, orchestrating operations, and at the end, returning a response. A developer can extend the entire pipeline with filters, binding, validation, and much more. It’s a fully featured framework for building modern web applications.
在 ASP.NET Core MVC 应用程序中,控制器负责接受输入、编排作,并在最后返回响应。开发人员可以使用过滤器、绑定、验证等来扩展整个管道。它是一个功能齐全的框架,用于构建现代 Web 应用程序。

But in the real world, there are also scenarios and use cases where you don’t need all the features of the MVC framework or you have to factor in a constraint on performance. ASP.NET Core implements a lot of middleware that you can remove from or add to your applications at will, but there are a lot of common features that you would need to implement by yourself in this scenario.
但在现实世界中,也有一些场景和用例不需要 MVC 框架的所有功能,或者必须考虑性能约束。ASP.NET Core 实现了许多中间件,你可以随意从应用程序中删除或添加到应用程序中,但在这种情况下,有许多常见功能需要你自己实现。

At last, ASP.NET Core 6.0 has filled these gaps with minimal APIs.
最后,ASP.NET Core 6.0 用最少的 API 填补了这些空白。

Now that we have covered a brief history of minimal APIs, we will start creating a new minimal API project in the next section.
现在我们已经简要介绍了最小 API 的历史,我们将在下一节中开始创建一个新的最小 API 项目。

Creating a new minimal API project
创建新的最小 API 项目

Let’s start with our first project and try to analyze the new template for the minimal API approach when writing a RESTful API.
让我们从第一个项目开始,尝试在编写 RESTful API 时分析最小 API 方法的新模板。

In this section, we will create our first minimal API project. We will start by using Visual Studio 2022 and then we will show how you can also create the project with Visual Studio Code and the .NET CLI.
在本节中,我们将创建我们的第一个最小 API 项目。我们将从使用 Visual Studio 2022 开始,然后我们将展示如何使用 Visual Studio Code 和 .NET CLI 创建项目。

Creating the project with Visual Studio 2022
使用 Visual Studio 2022 创建项目

Follow these steps to create a new project in Visual Studio 2022:
按照以下步骤在 Visual Studio 2022 中创建新项目:

  1. Open Visual Studio 2022 and on the main screen, click on Create a new project:
    打开 Visual Studio 2022 并在主屏幕上单击 Create a new project:

Figure 1.1 – Visual Studio 2022 splash screen
图 1.1 – Visual Studio 2022 初始屏幕

  1. On the next screen, write API in the textbox at the top of the window and select the template called ASP.NET Core Web API:
    在下一个屏幕上,在窗口顶部的文本框中编写 API,然后选择名为 ASP.NET Core Web API 的模板:

    Figure 1.2 – Create a new project screen
    图 1.2 – Create a new project 屏幕

  2. Next, on the Configure your new project screen, insert a name for the new project and select the root folder for your new solution:
    接下来,在 Configure your new project 屏幕上,插入新项目的名称,然后选择新解决方案的根文件夹:

    Figure 1.3 – Configure your new project screen
    图 1.3 – 配置您的新项目屏幕

For this example we will use the name Chapter01, but you can choose any name that appeals to you.
在此示例中,我们将使用名称 Chapter01,但您可以选择任何吸引您的名称。

  1. On the following Additional information screen, make sure to select .NET 6.0 (Long-term-support) from the Framework dropdown. And most important of all, uncheck the Use controllers (uncheck to use minimal APIs) option.
    在下面的 Additional information 屏幕上,确保从 Framework 下拉列表中选择 .NET 6.0 (Long-term-support)。最重要的是,取消选中 Use controllers (取消选中以使用最少的 API) 选项。

Figure 1.4 – Additional information screen

  1. Click Create and, after a few seconds, you will see the code of your new minimal API project.
    单击 Create(创建),几秒钟后,您将看到新的最小 API 项目的代码。

Now we are going to show how to create the same project using Visual Studio Code and the .NET CLI.
现在,我们将展示如何使用 Visual Studio Code 和 .NET CLI 创建相同的项目。

Creating the project with Visual Studio Code
使用 Visual Studio Code 创建项目

Creating a project with Visual Studio Code is easier and faster than with Visual Studio 2022 because you don’t have to use a UI or wizard, rather just a terminal and the .NET CLI.
使用 Visual Studio Code 创建项目比使用 Visual Studio 2022 更容易、更快捷,因为您不必使用 UI 或向导,而只需使用终端和 .NET CLI。

You don’t need to install anything new for this because the .NET CLI is included with the .NET 6 installation (as in the previous versions of the .NET SDKs). Follow these steps to create a project using Visual Studio Code:
您无需为此安装任何新内容,因为 .NET CLI 包含在 .NET 6 安装中(与以前版本的 .NET SDK 一样)。按照以下步骤使用 Visual Studio Code 创建项目:

  1. Open your console, shell, or Bash terminal, and switch to your working directory.
    打开您的控制台、shell 或 Bash 终端,然后切换到您的工作目录。

  2. Use the following command to create a new Web API application:
    使用以下命令创建新的 Web API 应用程序:

    dotnet new webapi -minimal -o Chapter01

As you can see, we have inserted the -minimal parameter in the preceding command to use the minimal API project template instead of the ASP.NET Core template with the controllers.
如您所见,我们在前面的命令中插入了 -minimal 参数,以使用最小 API 项目模板,而不是控制器的 ASP.NET Core 模板。

  1. Now open the new project with Visual Studio Code using the following commands:
    现在使用以下命令使用 Visual Studio Code 打开新项目:

    cd Chapter01
    code.

Now that we know how to create a new minimal API project, we are going to have a quick look at the structure of this new template.
现在我们知道如何创建一个新的最小 API 项目,我们将快速了解一下这个新模板的结构。

Looking at the structure of the project
查看项目结构

Whether you are using Visual Studio or Visual Studio Code, you should see the following code in the Program.cs file:
无论您使用的是 Visual Studio 还是 Visual Studio Code,您都应该在 Program.cs 文件中看到以下代码:

var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.
ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm",
"Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateTime.Now.AddDays(index),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
internal record WeatherForecast(DateTime Date, int
TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC /
0.5556);
}

First of all, with the minimal API approach, all of your code will be inside the Program.cs file. If you are a seasoned .NET developer, it’s easy to understand the preceding code, and you’ll find it similar to some of the things you’ve always used with the controller approach.
首先,使用最小 API 方法,您的所有代码都将位于 Program.cs 文件中。如果您是一位经验丰富的 .NET 开发人员,则很容易理解前面的代码,并且您会发现它类似于您一直使用控制器方法的一些内容。

At the end of the day, it’s another way to write an API, but it’s based on ASP.NET Core.
归根结底,这是编写 API 的另一种方式,但它基于 ASP.NET Core。

However, if you are new to ASP.NET, this single file approach is easy to understand. It’s easy to understand how to extend the code in the template and add more features to this API.
但是,如果您不熟悉 ASP.NET,这种单文件方法很容易理解。很容易理解如何扩展模板中的代码并向此 API 添加更多功能。

Don’t forget that minimal means that it contains the minimum set of components needed to build an HTTP API but it doesn’t mean that the application you are going to build will be simple. It will require a good design like any other .NET application.
不要忘记,minimal 意味着它包含构建 HTTP API 所需的最少组件集,但这并不意味着您要构建的应用程序会很简单。与任何其他 .NET 应用程序一样,它需要良好的设计。

As a final point, the minimal API approach is not a replacement for the MVC approach. It’s just another way to write the same thing.
最后一点,最小 API 方法不能替代 MVC 方法。这只是另一种写同样东西的方法。

Let’s go back to the code.
让我们回到代码。

Even the template of the minimal API uses the new approach of .NET 6 web applications: a top-level statement.
即使是最小 API 的模板也使用 .NET 6 Web 应用程序的新方法:顶级语句。

It means that the project has a Program.cs file only instead of using two files to configure an application.
这意味着项目只有一个 Program.cs 文件,而不是使用两个文件来配置应用程序。

If you don’t like this style of coding, you can convert your application to the old template for ASP.NET Core 3.x/5. This approach still continues to work in .NET as well.
如果您不喜欢这种编码样式,可以将应用程序转换为 ASP.NET Core 3.x/5 的旧模板。此方法在 .NET 中也将继续有效。

Important note : We can find more information about the .NET 6 top-level statements template at https://docs.microsoft.com/dotnet/core/tutorials/top-level-templates.
重要提示 : 我们可以在 https://docs.microsoft.com/dotnet/core/tutorials/top-level-templates 中找到有关 .NET 6 顶级语句模板的更多信息。

By default, the new template includes support for the OpenAPI Specification and more specifically, Swagger.
默认情况下,新模板包括对 OpenAPI 规范的支持,更具体地说,包括对 Swagger 的支持。

Let’s say that we have our documentation and playground for the endpoints working out of the box without any additional configuration needed.
假设我们有现成的端点文档和 Playground,无需任何额外的配置。

You can see the default configuration for Swagger in the following two lines of codes:
您可以在以下两行代码中看到 Swagger 的默认配置:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

Very often, you don’t want to expose Swagger and all the endpoints to the production or staging environments. The default template enables Swagger out of the box only in the development environment with the following lines of code:
通常,您不希望将 Swagger 和所有终端节点公开给生产或暂存环境。默认模板仅在开发环境中启用开箱即用的 Swagger,代码行如下:

if (app.Environment.IsDevelopment())
{
         app.UseSwagger();
         app.UseSwaggerUI();
}

If the application is running on the dev elopment environment, you must also include the Swagger documentation, but otherwise not.
如果应用程序在 dev elopment 环境中运行,则还必须包含 Swagger 文档,否则不得包含。

Note : We’ll talk in detail about Swagger in Chapter 3, Working with Minimal APIs.
注意:我们将在第 3 章 使用最小 API 中详细讨论 Swagger。

In these last few lines of code in the template, we are introducing another generic concept for .NET 6 web applications: environments.
在模板的最后几行代码中,我们引入了 .NET 6 Web 应用程序的另一个通用概念:环境。

Typically, when we develop a professional application, there are a lot of phases through which an application is developed, tested, and finally published to the end users.
通常,当我们开发专业应用程序时,应用程序会经历许多开发、测试并最终发布给最终用户的阶段。

By convention, these phases are regulated and called development, staging, and production. As developers, we might like to change the behavior of the application based on the current environment.
按照惯例,这些阶段受到监管,称为开发、暂存和生产。作为开发人员,我们可能希望根据当前环境更改应用程序的行为。

There are several ways to access this information but the typical way to retrieve the actual environment in modern .NET 6 applications is to use environment variables. You can access the environment variables directly from the app variable in the Program.cs file.
有多种方法可以访问此信息,但在现代 .NET 6 应用程序中检索实际环境的典型方法是使用环境变量。您可以直接从 Program.cs 文件中的 app 变量访问环境变量。

The following code block shows how to retrieve all the information about the environments directly from the startup point of the application:
以下代码块演示如何直接从应用程序的启动点检索有关环境的所有信息:

if (app.Environment.IsDevelopment())
{
           // your code here
}
if (app.Environment.IsStaging())
{
           // your code here
}
if (app.Environment.IsProduction())
{
           // your code here
}

In many cases, you can define additional environments, and you can check your custom environment with the following code:
在许多情况下,您可以定义其他环境,并且可以使用以下代码检查您的自定义环境:

if (app.Environment.IsEnvironment("TestEnvironment"))
{
           // your code here
}

To define routes and handlers in minimal APIs, we use the MapGet, MapPost, MapPut, and MapDelete methods. If you are used to using HTTP verbs, you will have noticed that the verb Patch is not present, but you can define any set of verbs using MapMethods.
要在最小的 API 中定义路由和处理程序,我们使用 MapGet、MapPost、MapPut 和 MapDelete 方法。如果您习惯使用 HTTP 动词,您会注意到动词 Patch 不存在,但您可以使用 MapMethods 定义任何动词集。

For instance, if you want to create a new endpoint to post some data to the API, you can write the following code:

例如,如果要创建一个新的终端节点以将一些数据发布到 API,则可以编写以下代码:

app.MapPost("/weatherforecast", async (WeatherForecast 
    model, IWeatherService repo) =>
{
         // ...
});

As you can see in the short preceding code, it’s very easy to add a new endpoint with the new minimal API template.
正如您在前面的简短代码中所看到的,使用新的最小 API 模板添加新终端节点非常容易。

It was more difficult previously, especially for a new developer, to code a new endpoint with binding parameters and use dependency injection.
以前,使用绑定参数编写新终端节点并使用依赖项注入更加困难,尤其是对于新开发人员而言。

Important note : We’ll talk in detail about routing in Chapter 2, Exploring Minimal APIs and Their Advantages, and about dependency injection in Chapter 4, Dependency Injection in a Minimal API Project.
重要提示 : 我们将在第 2 章 探索最小 API 及其优势中详细讨论路由,并在第 4 章 最小 API 项目中的依赖注入。

Summary
总结

In this chapter, we first started with a brief history of minimal APIs. Next, we saw how to create a project with Visual Studio 2022 as well as Visual Studio Code and the .NET CLI. After that, we examined the structure of the new template, how to access different environments, and how to start interacting with REST endpoints.
在本章中,我们首先从最小 API 的简要历史开始。接下来,我们了解了如何使用 Visual Studio 2022 以及 Visual Studio Code 和 .NET CLI 创建项目。之后,我们检查了新模板的结构、如何访问不同的环境以及如何开始与 REST 端点交互。

In the next chapter, we will see how to bind parameters, the new routing configuration, and how to customize a response.
在下一章中,我们将了解如何绑定参数、新的路由配置以及如何自定义响应。

2 Exploring Minimal APIs and Their Advantages

探索最小 API 及其优势

In this chapter of the book, we will introduce some of the basic themes related to minimal APIs in .NET 6.0, showing how they differ from the controller-based web APIs that we have written in the previous version of .NET. We will also try to underline both the pros and the cons of this new approach of writing APIs.
在本书的这一章中,我们将介绍与 .NET 6.0 中的最小 API 相关的一些基本主题,展示它们与我们在早期版本的 .NET 中编写的基于控制器的 Web API 有何不同。我们还将尝试强调这种编写 API 的新方法的优缺点。

In this chapter, we will be covering the following topics:
在本章中,我们将介绍以下主题:

• Routing
• Parameter binding
• Exploring responses
• Controlling serialization
• Architecting a minimal API project

Technical requirements
技术要求

To follow the descriptions in this chapter, you will need to create an ASP.NET Core 6.0 Web API application. You can either use one of the following options:
要按照本章中的描述进行作,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。您可以使用以下选项之一:

• Option 1: Click on the New | Project command in the File menu of Visual Studio 2022 – then, choose the ASP.NET Core Web API template. Select a name and the working directory in the wizard and be sure to uncheck the Use controllers (uncheck to use minimal APIs) option in the next step.
选项 1:点击新建 |Visual Studio 2022 的 File (文件) 菜单中的 Project (项目) 命令,然后选择 ASP.NET Core Web API 模板。在向导中选择一个名称和工作目录,并确保在下一步中取消选中 Use controllers (不选中使用最少的 API) 选项。

• Option 2: Open your console, shell, or Bash terminal, and change to your working directory. Use the following command to create a new Web API application:
选项 2:打开您的控制台、shell 或 Bash 终端,然后切换到您的工作目录。使用以下命令创建新的 Web API 应用程序:

dotnet new webapi -minimal -o Chapter02

Now, open the project in Visual Studio by double-clicking the project file, or in Visual Studio Code, by typing the following command in the already open console:
现在,通过在 Visual Studio 中双击项目文件或在 Visual Studio Code 中通过在已打开的控制台中键入以下命令来打开项目:

cd Chapter02
code.

Finally, you can safely remove all the code related to the WeatherForecast sample, as we don’t need it for this chapter.
最后,您可以安全地删除与 WeatherForecast 示例相关的所有代码,因为本章不需要它。

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter02.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter02

Routing
路由

According to the official Microsoft documentation available at https://docs.microsoft.com/aspnet/core/fundamentals/routing, the following definition is given for routing:
根据 https://docs.microsoft.com/aspnet/core/fundamentals/routing 上提供的官方 Microsoft 文档,路由给出了以下定义:

Routing is responsible for matching incoming HTTP requests and dispatching those requests to the app’s executable endpoints. Endpoints are the app’s units of executable request-handling code. Endpoints are defined in the app and configured when the app starts. The endpoint matching process can extract values from the request’s URL and provide those values for request processing. Using endpoint information from the app, routing is also able to generate URLs that map to endpoints.
路由负责匹配传入的 HTTP 请求并将这些请求分派到应用程序的可执行端点。端点是应用程序的可执行请求处理代码单元。终端节点在应用程序中定义,并在应用程序启动时进行配置。终端节点匹配过程可以从请求的 URL 中提取值,并提供这些值以供请求处理。使用应用程序中的终端节点信息,路由还能够生成映射到终端节点的 URL。

In controller-based web APIs, routing is defined via the UseEndpoints() method in Startup.cs or using data annotations such as Route, HttpGet, HttpPost, HttpPut, HttpPatch, and HttpDelete right over the action methods.
在基于控制器的 Web API 中,路由是通过 Startup.cs 中的 UseEndpoints() 方法定义的,或者使用作方法上的数据注释(如 Route、HttpGet、HttpPost、HttpPut、HttpPatch 和 HttpDelete)来定义。

As mentioned in Chapter 1, Introduction to Minimal APIs in minimal APIs, we define the route patterns using the Map methods of the WebApplication object. Here’s an example:
如第 1 章 最小 API 简介中所述,在最小 API 中,我们使用 WebApplication 对象的 Map
方法定义路由模式。下面是一个示例:

app.MapGet("/hello-get", () => "[GET] Hello World!");
app.MapPost("/hello-post", () => "[POST] Hello World!");
app.MapPut("/hello-put", () => "[PUT] Hello World!");
app.MapDelete("/hello-delete", () => "[DELETE] Hello
                World!");

In this code, we have defined four endpoints, each with a different routing and method. Of course, we can use the same route pattern with different HTTP verbs.
在此代码中,我们定义了四个终端节点,每个终端节点都有不同的路由和方法。当然,我们可以对不同的 HTTP 动词使用相同的路由模式。

Note : As soon as we add an endpoint to our application (for example, using MapGet()), UseRouting() is automatically added at the start of the middleware pipeline and UseEndpoints() at the end of the pipeline.
注意 : 一旦我们将端点添加到应用程序(例如,使用 MapGet()),UseRouting() 就会自动添加到中间件管道的开头,UseEndpoints() 会自动添加到管道的末尾。

As shown here, ASP.NET Core 6.0 provides Map methods for the most common HTTP verbs. If we need to use other verbs, we can use the generic MapMethods:
如此处所示,ASP.NET Core 6.0 为最常见的 HTTP 动词提供了 Map
方法。如果我们需要使用其他动词,我们可以使用通用的 MapMethods:

app.MapMethods("/hello-patch", new[] { HttpMethods.Patch }, 
    () => "[PATCH] Hello World!");
app.MapMethods("/hello-head", new[] { HttpMethods.Head }, 
    () => "[HEAD] Hello World!");
app.MapMethods("/hello-options", new[] { 
    HttpMethods.Options }, () => "[OPTIONS] Hello World!");

In the following sections, we will show in detail how routing works effectively and how we can control its behavior.
在以下部分中,我们将详细展示路由如何有效工作以及如何控制其行为。

Route handlers
路由处理程序

Methods that execute when a route URL matches (according to parameters and constraints, as described in the following sections) are called route handlers. Route handlers can be a lambda expression, a local function, an instance method, or a static method, whether synchronous or asynchronous:
当路由 URL 匹配时执行的方法(根据参数和约束,如以下部分所述)称为路由处理程序。路由处理程序可以是 lambda 表达式、本地函数、实例方法或静态方法,无论是同步方法还是异步方法:

• Here’s an example of a lambda expression (inline or using a variable):
以下是 lambda 表达式的示例(内联或使用变量):

app.MapGet("/hello-inline", () => "[INLINE LAMBDA]

             Hello World!");

var handler = () => "[LAMBDA VARIABLE] Hello World!";

app.MapGet("/hello", handler);

• Here’s an example of a local function:
下面是一个本地函数的示例:

string Hello() => "[LOCAL FUNCTION] Hello World!";

app.MapGet("/hello", Hello);

• The following is an example of an instance method:
以下是实例方法的示例:

var handler = new HelloHandler();

app.MapGet("/hello", handler.Hello);

class HelloHandler

{

    public string Hello()

      => "[INSTANCE METHOD] Hello

           World!";

}

• Here, we can see an example of a static method:
在这里,我们可以看到一个静态方法的示例:

app.MapGet("/hello", HelloHandler.Hello);

class HelloHandler

{

    public static string Hello()

      => "[STATIC METHOD] Hello World!";

}

Route parameters
路由参数

As with the previous versions of .NET, we can create route patterns with parameters that will be automatically captured by the handler:
与以前版本的 .NET 一样,我们可以创建路由模式,其中包含处理程序将自动捕获的参数:

app.MapGet("/users/{username}/products/{productId}", 
          (string username, int productId) 
         => $"The Username is {username} and the product Id 
              is {productId}");

A route can contain an arbitrary number of parameters. When a request is made to this route, the parameters will be captured, parsed, and passed as arguments to the corresponding handler. In this way, the handler will always receive typed arguments (in the preceding sample, we are sure that the username is string and the product ID is int).
路由可以包含任意数量的参数。当向此路由发出请求时,参数将被捕获、解析并作为参数传递给相应的处理程序。这样,处理程序将始终接收类型化参数(在前面的示例中,我们确保 username 是 string,产品 ID 是 int)。

If the route values cannot be casted to the specified types, then an exception of the BadHttpRequestException type will be thrown, and the API will respond with a 400 Bad Request message.
如果无法将路由值强制转换为指定类型,则将引发 BadHttpRequestException 类型的异常,并且 API 将以 400 Bad Request 消息进行响应。

Route constraints
路由约束

Route constraints are used to restrict valid types for route parameters. Typical constraints allow us to specify that a parameter must be a number, a string, or a GUID. To specify a route constraint, we simply need to add a colon after the parameter name, then specify the constraint name:
路由约束用于限制路由参数的有效类型。典型约束允许我们指定参数必须是数字、字符串或 GUID。要指定路由约束,我们只需要在参数名称后添加一个冒号,然后指定约束名称:

app.MapGet("/users/{id:int}", (int id) => $"The user Id is 
                                            {id}");
app.MapGet("/users/{id:guid}", (Guid id) => $"The user Guid 
                                              is {id}");

Minimal APIs support all the route constraints that were already available in the previous versions of ASP.NET Core. You can find the full list of route constraints at the following link: https://docs.microsoft.com/aspnet/core/fundamentals/routing#route-constraint-reference.
最小 API 支持以前版本的 ASP.NET Core 中已经提供的所有路由约束。您可以在以下链接中找到路由约束的完整列表:https://docs.microsoft.com/aspnet/core/fundamentals/routing#route-constraint-reference

If, according to the constraints, no route matches the specified path, we don’t get an exception. Instead we obtain a 404 Not Found message, because, in fact, if the constraints do not fit, the route itself isn’t reachable. So, for example, in the following cases we get 404 responses:
如果根据约束,没有路由与指定的路径匹配,则不会收到异常。相反,我们会收到 404 Not Found 消息,因为事实上,如果约束不合适,则路由本身无法访问。因此,例如,在以下情况下,我们会收到 404 个响应:

Table 2.1 – Examples of an invalid path according to the route constraints
表 2.1 – 根据路由约束的无效路径示例

Every other argument in the handler that is not declared as a route constraint is expected, by default, in the query string. For example, see the following:
默认情况下,处理程序中未声明为路由约束的所有其他参数都应在查询字符串中。例如,请参阅以下内容:

// Matches hello?name=Marco
app.MapGet("/hello", (string name) => $"Hello, {name}!"); 

In the next section, Parameter binding, we’ll go deeper into how to use binding to further customize routing by specifying, for example, where to search for routing arguments, how to change their names, and how to have optional route parameters.
在下一节 参数绑定 中,我们将更深入地介绍如何使用 binding 进一步自定义路由,例如,指定在何处搜索路由参数、如何更改其名称以及如何拥有可选的路由参数。

Parameter binding
参数绑定

Parameter binding is the process that converts request data (i.e., URL paths, query strings, or the body) into strongly typed parameters that can be consumed by route handlers. ASP.NET Core minimal APIs support the following binding sources:
参数绑定是将请求数据(即 URL 路径、查询字符串或正文)转换为路由处理程序可以使用的强类型参数的过程。ASP.NET Core 最小 API 支持以下绑定源:

• Route values
• Query strings
• Headers
• The body (as JSON, the only format supported by default)
• A service provider (dependency injection)

We’ll talk in detail about dependency injection in Chapter 4, Implementing Dependency Injection.
我们将在 第 4 章 实现依赖注入 中详细讨论依赖注入。

As we’ll see later in this chapter, if necessary, we can customize the way in which binding is performed for a particular input. Unfortunately, in the current version, binding from Form is not natively supported in minimal APIs. This means that, for example, IFormFile is not supported either.
正如我们在本章后面看到的那样,如有必要,我们可以自定义对特定 input 执行绑定的方式。遗憾的是,在当前版本中,最小的 API 本身并不支持从 Form 进行绑定。这意味着,例如,IFormFile 也不受支持。

To better understand how parameter binding works, let’s take a look at the following API:
为了更好地理解参数绑定的工作原理,我们来看一下以下 API:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<PeopleService>();
var app = builder.Build();
app.MapPut("/people/{id:int}", (int id, bool notify, Person 
             person, PeopleService peopleService) => { });
app.Run();
public class PeopleService { }
public record class Person(string FirstName, string 
                           LastName);

Parameters that are passed to the handler are resolved in the following ways:
传递给处理程序的参数通过以下方式解析:

Table 2.2 – Parameter binding sources
表 2.2 – 参数绑定源

As we can see, ASP.NET Core is able to automatically understand where to search for parameters for binding, based on the route pattern and the types of the parameters themselves. For example, a complex type such as the Person class is expected in the request body.
正如我们所看到的,ASP.NET Core 能够根据路由模式和参数本身的类型,自动理解在何处搜索要绑定的参数。例如,请求正文中应包含复杂类型(如 Person 类)。

If needed, as in the previous versions of ASP.NET Core, we can use attributes to explicitly specify where parameters are bound from and, optionally, use different names for them. See the following endpoint:
如果需要,就像在早期版本的 ASP.NET Core 中一样,我们可以使用属性来显式指定参数的绑定位置,并可选择为它们使用不同的名称。请参阅以下终端节点:

app.MapGet("/search", string q) => { });

The API can be invoked with /search?q=text. However, using q as the name of the argument isn’t a good idea, because its meaning is not self-explanatory. So, we can modify the handler using FromQueryAttribute:
可以使用 /search?q=text 调用 API。但是,使用 q 作为参数的名称并不是一个好主意,因为它的含义不言自明。因此,我们可以使用 FromQueryAttribute 修改处理程序:

app.MapGet("/search", ([FromQuery(Name = "q")] string 
             searchText) => { });

In this way, the API still expects a query string parameter named q, but in the handler its value is now bound to the searchText argument.
这样,API 仍然需要名为 q 的查询字符串参数,但在处理程序中,其值现在绑定到 searchText 参数。

Note : According to the standard, the GET, DELETE, HEAD, and OPTIONS HTTP options should never have a body. If, nevertheless, you want to use it, you need to explicitly add the [FromBody] attribute to the handler argument; otherwise, you’ll get an InvalidOperationException error. However, keep in mind that this is a bad practice.
注意 : 根据该标准,GET、DELETE、HEAD 和 OPTIONS HTTP 选项不应有正文。但是,如果要使用它,则需要将 [FromBody] 属性显式添加到 handler 参数;否则,您将收到 InvalidOperationException 错误。但是,请记住,这是一种不好的做法。

By default, all the parameters in route handlers are required. So, if, according to routing, ASP.NET Core finds a valid route, but not all the required parameters are provided, we will get an error. For example, let’s look at the following method:
默认情况下,路由处理程序中的所有参数都是必需的。因此,如果根据路由,ASP.NET Core 找到了一个有效的路由,但未提供所有必需的参数,我们将收到错误。例如,让我们看看下面的方法:

app.MapGet("/people", (int pageIndex, int itemsPerPage) => { });

If we call the endpoint without the pageIndex or itemsPerPage query string values, we will obtain a BadHttpRequestException error, and the response will be 400 Bad Request.
如果我们在没有 pageIndex 或 itemsPerPage 查询字符串值的情况下调用终端节点,我们将获得 BadHttpRequestException 错误,并且响应将为 400 Bad Request。

To make the parameters optional, we just need to declare them as nullable or provide a default value. The latter case is the most common. However, if we adopt this solution, we cannot use a lambda expression for the handler. We need another approach, for example, a local function:
要使参数成为可选的,我们只需要将它们声明为 nullable 或提供默认值。后一种情况是最常见的。但是,如果我们采用此解决方案,则不能对处理程序使用 lambda 表达式。我们需要另一种方法,例如本地函数:

// This won't compile
//app.MapGet("/people", (int pageIndex = 0, int 
                         itemsPerPage = 50) => { });
string SearchMethod(int pageIndex = 0, 
                    int itemsPerPage = 50) => $"Sample 
                    result for page {pageIndex} getting 
                    {itemsPerPage} elements";
app.MapGet("/people", SearchMethod);

In this case, we are dealing with a query string, but the same rules apply to all the binding sources.
在本例中,我们正在处理查询字符串,但相同的规则适用于所有绑定源。

Keep in mind that if we use nullable reference types (which are enabled by default in .NET 6.0 projects) and we have, for example, a string parameter that could be null, we need to declare it as nullable – otherwise, we’ll get a BadHttpRequestException error again. The following example correctly defines the orderBy query string parameter as optional:
请记住,如果我们使用可为 null 的引用类型(在 .NET 6.0 项目中默认启用),并且我们有一个可能为 null 的字符串参数,则需要将其声明为可为 null,否则,我们将再次收到 BadHttpRequestException 错误。以下示例正确地将 orderBy 查询字符串参数定义为可选:

app.MapGet("/people", (string? orderBy) => $"Results ordered by {orderBy}");

Special bindings
特殊绑定

In controller-based web APIs, a controller that inherits from Microsoft.AspNetCore.Mvc.ControllerBase has access to some properties that allows it to get the context of the request and response: HttpContext, Request, Response, and User. In minimal APIs, we don’t have a base class, but we can still access this information because it is treated as a special binding that is always available to any handler:
在基于控制器的 Web API 中,从 Microsoft.AspNetCore.Mvc.ControllerBase 继承的控制器有权访问一些属性,这些属性允许它获取请求和响应的上下文:HttpContext、Request、Response 和 User。在最小的 API 中,我们没有基类,但我们仍然可以访问此信息,因为它被视为任何处理程序始终可用的特殊绑定:

app.MapGet("/products", (HttpContext context, HttpRequest req, HttpResponse res, ClaimsPrincipal user) => { });

Tip : We can also access all these objects using the IHttpContextAccessor interface, as we did in the previous ASP.NET Core versions.
提示 : 我们还可以使用 IHttpContextAccessor 接口访问所有这些对象,就像我们在以前的 ASP.NET Core 版本中所做的那样。

Custom binding
自定义绑定

In some cases, the default way in which parameter binding works isn’t enough for our purpose. In minimal APIs, we don’t have support for the IModelBinderProvider and IModelBinder interfaces, but we have two alternatives to implement custom model binding.
在某些情况下,参数绑定的默认工作方式不足以满足我们的目的。在最小的 API 中,我们不支持 IModelBinderProvider 和 IModelBinder 接口,但我们有两种实现自定义模型绑定的方法。

Important note : The IModelBinderProvider and IModelBinder interfaces in controller-based projects allow us to define the mapping between the request data and the application model. The default model binder provided by ASP.NET Core supports most of the common data types, but, if necessary, we can extend the system by creating our own providers. We can find more information at the following link: https://docs.microsoft.com/aspnet/core/mvc/advanced/custom-model-binding.
重要提示 : 基于控制器的项目中的 IModelBinderProvider 和 IModelBinder 接口允许我们定义请求数据和应用程序模型之间的映射。ASP.NET Core 提供的默认模型 Binder 支持大多数常见数据类型,但如有必要,我们可以通过创建自己的提供程序来扩展系统。我们可以在以下链接中找到更多信息:https://docs.microsoft.com/aspnet/core/mvc/advanced/custom-model-binding

If we want to bind a parameter that comes from a route, query string, or header to a custom type, we can add a static TryParse method to the type:
如果我们想将来自路由、查询字符串或标头的参数绑定到自定义类型,我们可以向该类型添加静态 TryParse 方法:

// GET /navigate?location=43.8427,7.8527
app.MapGet("/navigate", (Location location) => $"Location: 
            {location.Latitude}, {location.Longitude}");
public class Location
{
    public double Latitude { get; set; }
    public double Longitude { get; set; }
    public static bool TryParse(string? value, 
      IFormatProvider? provider, out Location? location)
    {
          if (!string.IsNullOrWhiteSpace(value))
          {
               var values = value.Split(',', 
               StringSplitOptions.RemoveEmptyEntries);
               if (values.Length == 2 && double.
                   TryParse(values[0],
                   NumberStyles.AllowDecimalPoint, 
                   CultureInfo.InvariantCulture, 
                   out var latitude) && double.
                   TryParse(values[1], NumberStyles.
                   AllowDecimalPoint, CultureInfo.
                   InvariantCulture, out var longitude))
               {
                       location = new Location 
                       { Latitude = latitude, 
                       Longitude = longitude };
                       return true;
               }
          }
          location = null;
          return false;
    }
}

In the TryParse method, we can try to split the input parameter and check whether it contains two decimal values: in this case, we parse the numbers to build the Location object and we return true. Otherwise, we return false because the Location object cannot be initialized.
在 TryParse 方法中,我们可以尝试拆分输入参数并检查它是否包含两个十进制值:在本例中,我们解析数字以构建 Location 对象并返回 true。否则,我们将返回 false,因为无法初始化 Location 对象。

Important note : When the minimal API finds that a type contains a static TryParse method, even if it is a complex type, it assumes that it is passed in the route or the query string, based on the routing template. We can use the [FromHeader] attributes to change the binding source. In any case, TryParse will never be invoked for the body of the request.
重要提示 : 当最小 API 发现某个类型包含静态 TryParse 方法时,即使它是一个复杂类型,它也会根据路由模板假定它是在路由或查询字符串中传递的。我们可以使用 [FromHeader] 属性来更改绑定源。在任何情况下,都不会为请求正文调用 TryParse。

If we need to completely control how binding is performed, we can implement a static BindAsync method on the type. This isn’t a very common solution, but in some cases, it can be useful:
如果我们需要完全控制绑定的执行方式,我们可以在类型上实现静态 BindAsync 方法。这不是一个非常常见的解决方案,但在某些情况下,它可能很有用:

// POST /navigate?lat=43.8427&lon=7.8527
app.MapPost("/navigate", (Location location) => 
   $"Location: {location.Latitude}, {location.Longitude}");
public class Location
{
    // ...
    public static ValueTask<Location?> BindAsync(HttpContext 
    context, ParameterInfo parameter)
    {
        if (double.TryParse(context.Request.Query["lat"], 
            NumberStyles.AllowDecimalPoint, CultureInfo.
            InvariantCulture, out var latitude)&& double.
            TryParse(context.Request.Query["lon"], 
            NumberStyles.AllowDecimalPoint, CultureInfo.
            InvariantCulture, out var longitude))
        {
                var location = new Location 
                { Latitude = latitude, Longitude = longitude };
                return ValueTask.
                  FromResult<Location?>(location);
        }
        return ValueTask.FromResult<Location?>(null);
    }
}

As we can see, the BindAsync method takes the whole HttpContext as an argument, so we can read all the information we need to create the actual Location object that is passed to the route handler. In this example, we read two query string parameters (lat and lon), but (in the case of POST, PUT, or PATCH methods) we can also read the entire body of the request and manually parse its content. This can be useful, for instance, if we need to handle requests that have a format other than JSON (which, as said before, is the only one supported by default).
正如我们所看到的,BindAsync 方法将整个 HttpContext 作为参数,因此我们可以读取创建传递给路由处理程序的实际 Location 对象所需的所有信息。在此示例中,我们读取两个查询字符串参数(lat 和 lon),但(在 POST、PUT 或 PATCH 方法的情况下)我们还可以读取请求的整个正文并手动解析其内容。例如,如果我们需要处理格式不是 JSON 的请求(如前所述,JSON 是默认支持的唯一格式),这可能很有用。

If the BindAsync method returns null, while the corresponding route handler parameter cannot assume this value (as in the previous example), we will get an HttpBadRequestException error, which. as usual, will be wrapped in a 400 Bad Request response.
如果 BindAsync 方法返回 null,而相应的路由处理程序参数不能采用此值(如前面的示例所示),我们将收到 HttpBadRequestException 错误。像往常一样,将包装在 400 Bad Request 响应中。

Important note : We shouldn’t define both the TryParse and BindAsync methods using a type; if both are present, BindAsync always has precedence (that is, TryParse will never be invoked).
重要提示 : 我们不应该使用类型同时定义 TryParse 和 BindAsync 方法;如果两者都存在,则 BindAsync 始终具有优先权(即,永远不会调用 TryParse)。

Now that we have looked at parameter binding and understood how to use it and customize its behavior, let’s see how to work with responses in minimal APIs.
现在我们已经了解了参数绑定并了解了如何使用它并自定义其行为,让我们看看如何在最小的 API 中使用响应。

Exploring responses
探索响应

As with controller-based projects, with route handlers of minimal APIs as well, we can directly return a string or a class (either synchronously or asynchronously):
与基于控制器的项目一样,使用最小 API 的路由处理程序,我们可以直接返回字符串或类(同步或异步):

• If we return a string (as in the examples of the previous section), the framework writes the string directly to the response, setting its content type to text/plain and the status code to 200 OK
如果我们返回一个字符串(如上一节的示例所示),框架会将该字符串直接写入响应,将其内容类型设置为 text/plain,并将状态代码设置为 200 OK

• If we use a class, the object is serialized into the JSON format and sent to the response with the application/json content type and a 200 OK status code
如果我们使用类,则对象将序列化为 JSON 格式,并使用 application/json 内容类型和 200 OK 状态代码发送到响应

However, in a real application, we typically need to control the response type and the status code. In this case, we can use the static Results class, which allows us to return an instance of the IResult interface, which in minimal APIs acts how IActionResult does for controllers. For instance, we can use it to return a 201 Created response rather than a 400 Bad Request or a 404 Not Found message. L et’s look at some examples:
但是,在实际应用程序中,我们通常需要控制响应类型和状态代码。在这种情况下,我们可以使用静态 Results 类,该类允许我们返回 IResult 接口的实例,该实例在最小的 API 中的作用类似于 IActionResult 对控制器的作用。例如,我们可以使用它来返回 201 Created 响应,而不是 400 Bad Request 或 404 Not Found 消息。我们来看看一些例子:

app.MapGet("/ok", () => Results.Ok(new Person("Donald", 
                                              "Duck")));
app.MapGet("/notfound", () => Results.NotFound());
app.MapPost("/badrequest", () =>
{
    // Creates a 400 response with a JSON body.
    return Results.BadRequest(new { ErrorMessage = "Unable to
                                    complete the request" });
});
app.MapGet("/download", (string fileName) => 
             Results.File(fileName));
record class Person(string FirstName, string LastName);

Each method of the Results class is responsible for setting the response type and status code that correspond to the meaning of the method itself (e.g., the Results.NotFound() method returns a 404 Not Found response). Note that even if we typically need to return an object in the case of a 200 OK response (with Results.Ok()), it isn’t the only method that allows this. Many other methods allow us to include a custom response; in all these cases, the response type will be set to application/json and the object will automatically be JSON-serialized.
Results 类的每个方法都负责设置与方法本身的含义相对应的响应类型和状态代码(例如,Results.NotFound() 方法返回 404 Not Found 响应)。请注意,即使我们通常需要在 200 OK 响应的情况下返回一个对象(使用 Results.Ok()),它也不是唯一允许这样做的方法。许多其他方法允许我们包含自定义响应;在所有这些情况下,响应类型都将设置为 application/json,并且对象将自动进行 JSON 序列化。

The current version of minimal APIs does not support content negotiation. We only have a few methods that allow us to explicitly set the content type, when getting a file with Results.Bytes(), Results.Stream(), and Results.File(), or when using Results.Text() and Results.Content(). In all other cases, when we’re dealing with complex objects, the response will be in JSON format. This is a precise design choice since most developers rarely need to support other media types. By supporting only JSON without performing content negotiation, minimal APIs can be very efficient.
当前版本的 minimal API 不支持内容协商。只有少数方法允许我们显式设置内容类型,当使用 Results.Bytes()、Results.Stream() 和 Results.File() 获取文件时,或者使用 Results.Text() 和 Results.Content() 时。在所有其他情况下,当我们处理复杂对象时,响应将采用 JSON 格式。这是一个精确的设计选择,因为大多数开发人员很少需要支持其他媒体类型。通过仅支持 JSON 而不执行内容协商,最少的 API 可以非常高效。

However, this approach isn’t enough in all scenarios. In some cases, we may need to create a custom response type, for example, if we want to return an HTML or XML response instead of the standard JSON. We can manually use the Results.Content() method (which allows us to specify the content as a simple string with a particular content type), but, if we have this requirement, it is better to implement a custom IResult type, so that the solution can be reused.
但是,这种方法并非在所有情况下都足够。在某些情况下,我们可能需要创建自定义响应类型,例如,如果我们要返回 HTML 或 XML 响应而不是标准 JSON。我们可以手动使用 Results.Content() 方法(它允许我们将内容指定为具有特定内容类型的简单字符串),但是,如果我们有此要求,最好实现自定义 IResult 类型,以便可以重用解决方案。

For example, let’s suppose that we want to serialize objects in XML instead of JSON. We can then define an XmlResult class that implements the IResult interface:
例如,假设我们想用 XML 而不是 JSON 来序列化对象。然后,我们可以定义一个实现 IResult 接口的 XmlResult 类:

public class XmlResult : IResult
{
   private readonly object value;
   public XmlResult(object value)
   {
       this.value = value;
   }
   public Task ExecuteAsync(HttpContext httpContext)
   {
       using var writer = new StringWriter();

       var serializer = new XmlSerializer(value.GetType());
       serializer.Serialize(writer, value);
       var xml = writer.ToString();
       httpContext.Response.ContentType = MediaTypeNames.
       Application.Xml;
       httpContext.Response.ContentLength = Encoding.UTF8
      .GetByteCount(xml);
       return httpContext.Response.WriteAsync(xml);
   }
}

The IResult interface requires us to implement the ExecuteAsync method, which receives the current HttpContext as an argument. We serialize the value using the XmlSerializer class and then write it to the response, specifying the correct response type.
IResult 接口要求我们实现 ExecuteAsync 方法,该方法接收当前 HttpContext 作为参数。我们使用 XmlSerializer 类序列化该值,然后将其写入响应,并指定正确的响应类型。

Now, we can directly use the new XmlResult type in our route handlers. However, best practices suggest that we create an extension method for the IResultExtensions interface, as with the following one:
现在,我们可以直接在路由处理程序中使用新的 XmlResult 类型。但是,最佳实践建议我们为 IResultExtensions 接口创建一个扩展方法,如下所示:

public static class ResultExtensions
{
    public static IResult Xml(this IResultExtensions 
    resultExtensions, object value) => new XmlResult(value);
}

In this way, we have a new Xml method available on the Results.Extensions property:
这样,我们在 Results.Extensions 属性上就有了一个新的 Xml 方法:

app.MapGet("/xml", () => Results.Extensions.Xml(new City { Name = "Taggia" }));
public record class City
{
    public string? Name { get; init; }
}

The benefit of this approach is that we can reuse it everywhere we need to deal with XML without having to manually handle the serialization and the response type (as we should have done using the Result.Content() method instead).
这种方法的好处是,我们可以在需要处理 XML 的任何地方重用它,而不必手动处理序列化和响应类型(就像我们应该使用 Result.Content() 方法所做的那样)。

Tip : If we want to perform content validation, we need to manually check the Accept header of the HttpRequest object, which we can pass to our handlers, and then create the correct response accordingly.
提示 : 如果我们想执行内容验证,我们需要手动检查 HttpRequest 对象的 Accept 标头,我们可以将其传递给我们的处理程序,然后相应地创建正确的响应。

After analyzing how to properly handle responses in minimal APIs, we’ll see how to control the way our data is serialized and deserialized in the next section.
在分析了如何在最小 API 中正确处理响应之后,我们将在下一节中了解如何控制数据的序列化和反序列化方式。

Controlling serialization
控制序列化

As described in the previous sections, minimal APIs only provide built-in support for the JSON format. In particular, the framework uses System.Text.Json for serialization and deserialization. In controller-based APIs, we can change this default and use JSON.NET instead. This is not possible when working with minimal APIs: we can’t replace the serializer at all.
如前几节所述,最小 API 仅提供对 JSON 格式的内置支持。具体而言,框架使用 System.Text.Json 进行序列化和反序列化。在基于控制器的 API 中,我们可以更改此默认值并改用 JSON.NET。当使用最少的 API 时,这是不可能的:我们根本无法替换序列化器。

The built-in serializer uses the following options:
内置序列化程序使用以下选项:

• Case-insensitive property names during serialization
序列化期间不区分大小写的属性名称

• Camel case property naming policy
驼峰式大小写属性命名策略

• Support for quoted numbers (JSON strings for number properties)
支持带引号的数字(数字属性的 JSON 字符串)

Note : We can find more information about the System.Text.Json namespace and all the APIs it provides at the following link: https://docs.microsoft.com/dotnet/api/system.text.json.
注意 : 我们可以在以下链接中找到有关 System.Text.Json 命名空间及其提供的所有 API 的更多信息:https://docs.microsoft.com/dotnet/api/system.text.json

In controller-based APIs, we can customize these settings by calling AddJsonOptions() fluently after AddControllers(). In minimal APIs, we can’t use this approach since we don’t have controllers at all, so we need to explicitly call the Configure method for JsonOptions. So, let’s consider this handler:
在基于控制器的 API 中,我们可以通过在 AddControllers() 之后流畅地调用 AddJsonOptions() 来自定义这些设置。在最小的 API 中,我们不能使用这种方法,因为我们根本没有控制器,因此我们需要显式调用 JsonOptions 的 Configure 方法。那么,让我们考虑一下这个处理程序:

app.MapGet("/product", () =>
{
    var product = new Product("Apple", null, 0.42, 6);
    return Results.Ok(product); 
});
public record class Product(string Name, string? Description, double UnitPrice, int Quantity)
{
    public double TotalPrice => UnitPrice * Quantity;
}

Using the default JSON options, we get this result:
使用默认的 JSON 选项,我们得到以下结果:

{
    "name": "Apple",
    "description": null,
    "unitPrice": 0.42,
    "quantity": 6,
    "totalPrice": 2.52
}

Now, let’s configure JsonOptions:
现在,让我们配置 JsonOptions:

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.
JsonOptions>(options =>
{
    options.SerializerOptions.DefaultIgnoreCondition = 
    JsonIgnoreCondition.WhenWritingNull;
    options.SerializerOptions.IgnoreReadOnlyProperties 
    = true;
});

Calling the /product endpoint again, we’ll now get the following:
再次调用 /product 端点,我们现在将获得以下内容:

{
    "name": "Apple",
    "unitPrice": 0.42,
    "quantity": 6
}

As expected, the Description property hasn’t been serialized because it is null, as well as TotalPrice, which isn’t included in the response because it is read-only.
正如预期的那样,Description 属性尚未序列化,因为它为 null,以及 TotalPrice,由于它是只读的,因此未包含在响应中。

Another typical use case for JsonOptions is when we want to add converters that will be automatically applied for each serialization or deserialization, for example, JsonStrinEnumConverter to convert enumeration values into or from strings.
JsonOptions 的另一个典型用例是当我们想要添加将自动应用于每个序列化或反序列化的转换器时,例如,JsonStrinEnumConverter 用于将枚举值转换为字符串或从字符串转换。

Important note : Be aware that the JsonOptions class used by minimal APIs is the one available in the Microsoft.AspNetCore.Http.Json namespace. Do not confuse it with the one that is defined in the Microsoft.AspNetCore.Mvc namespace; the name of the object is the same, but the latter is valid only for controllers, so it has no effect if set in a minimal API project.
重要提示 : 请注意,最小 API 使用的 JsonOptions 类是 Microsoft.AspNetCore.Http.Json 命名空间中可用的类。不要将其与 Microsoft.AspNetCore.Mvc 命名空间中定义的名称混淆;对象的名称相同,但后者仅对控制器有效,因此如果在最小 API 项目中设置,则无效。

Because of the JSON-only support, if we do not explicitly add support for other formats, as described in the previous sections (using, for example, the BindAsync method on a custom type), minimal APIs will automatically perform some validations on the body binding source and handle the following scenarios:
由于仅支持 JSON,如果我们没有显式添加对其他格式的支持,如前面部分所述(例如,在自定义类型上使用 BindAsync 方法),则最小 API 将在正文绑定源上自动执行一些验证并处理以下情况:

Table 2.3 – The response status codes for body binding problems
表 2.3 – 正文绑定问题的响应状态代码

In these cases, because body validation fails, our route handlers will never be invoked, and we will get the response status codes shown in the preceding table directly.
在这些情况下,由于主体验证失败,我们的路由处理程序将永远不会被调用,我们将直接获取上表中显示的响应状态代码。

Now, we have covered all the pillars that we need to start developing minimal APIs. However, there is another important thing to talk about: the correct way to design a real project to avoid common mistakes within the architecture.
现在,我们已经涵盖了开始开发最小 API 所需的所有支柱。但是,还有一件重要的事情要谈:设计真实项目的正确方法,以避免架构中的常见错误。

Architecting a minimal API project
构建一个最小的 API 项目

Up to now, we have written route handlers directly in the Program.cs file. This is a perfectly supported scenario: with minimal APIs, we can write all our code inside this single file. In fact, almost all the samples show this solution. However, while this is allowed, we can easily imagine how this approach can lead to unstructured and therefore unmaintainable projects. If we have fewer endpoints, it is fine – otherwise, it is better to organize our handlers in separate files.
到目前为止,我们已经直接在 Program.cs 文件中编写了路由处理程序。这是一个完全支持的场景:使用最少的 API,我们可以在这个文件中编写所有代码。事实上,几乎所有样本都显示了这种解决方案。然而,虽然这是允许的,但我们可以很容易地想象这种方法如何导致非结构化的、因此无法维护的项目。如果端点较少,那很好 —— 否则,最好将我们的处理程序组织在单独的文件中。

Let’s suppose that we have the following code right in the Program.cs file because we have to handle CRUD operations:
假设 Program.cs 文件中有以下代码,因为我们必须处理 CRUD作:

app.MapGet("/api/people", (PeopleService peopleService) => 
            { });
app.MapGet("/api/people/{id:guid}", (Guid id, PeopleService 
             peopleService) => { });
app.MapPost("/api/people", (Person Person, PeopleService 
              people) => { });
app.MapPut("/api/people/{id:guid}", (Guid id, Person 
             Person, PeopleService people) => { });
app.MapDelete("/api/people/{id:guid}", (Guid id, 
                PeopleService people) => { });

It’s easy to imagine that, if we have all the implementation here (even if we’re using PeopleService to extract the business logic), this file can easily explode. So, in real scenarios, the inline lambda approach isn’t the best practice. We should use the other methods that we have covered in the Routing section to define the handlers instead. So, it is a good idea to create an external class to hold all the route handlers:
很容易想象,如果我们在这里拥有所有实现(即使我们使用 PeopleService 来提取业务逻辑),此文件很容易爆炸。因此,在实际场景中,内联 lambda 方法并不是最佳实践。我们应该使用 路由 部分介绍的其他方法来定义处理程序。因此,创建一个外部类来保存所有路由处理程序是一个好主意:

public class PeopleHandler
{
   public static void MapEndpoints(IEndpointRouteBuilder 
   app)
   {
       app.MapGet("/api/people", GetList);
       app.MapGet("/api/people/{id:guid}", Get);
       app.MapPost("/api/people", Insert);
       app.MapPut("/api/people/{id:guid}", Update);
       app.MapDelete("/api/people/{id:guid}", Delete);
   }

   private static IResult GetList(PeopleService    
   peopleService) { /* ... */ }
   private static IResult Get(Guid id, PeopleService 
   peopleService) { /* ... */ }
   private static IResult Insert(Person person, 
   PeopleService people) { /* ... */ }
   private static IResult Update(Guid id, Person 
   person, PeopleService people) { /* ... */ }
   private static IResult Delete(Guid id) { /* ... */ }
}

We have grouped all the endpoint definitions inside the PeopleHandler.MapEndpoints static method, which takes the IEndpointRouteBuilder interface as an argument, which in turn is implemented by the WebApplication class. Then, instead of using lambda expressions, we have created separate methods for each handler, so that the code is much cleaner. In this way, to register all these handlers in our minimal API, we just need the following code in Program.cs:
我们已将所有端点定义分组到 PeopleHandler.MapEndpoints 静态方法中,该方法将 IEndpointRouteBuilder 接口作为参数,而该接口又由 WebApplication 类实现。然后,我们没有使用 lambda 表达式,而是为每个处理程序创建了单独的方法,以便代码更加简洁。这样,要在我们的最小 API 中注册所有这些处理程序,我们只需要在 Program.cs 中编写以下代码:

var builder = WebApplication.CreateBuilder(args);
// ..
var app = builder.Build();
// ..
PeopleHandler.MapEndpoints(app);
app.Run();

Going forward
展望未来

The approach just shown allows us to better organize a minimal API project, but still requires that we explicitly add a line to Program.cs for every handler we want to define. Using an interface and a bit of reflection, we can create a straightforward and reusable solution to simplify our work with minimal APIs.
刚才展示的方法使我们能够更好地组织一个最小的 API 项目,但仍然需要我们为要定义的每个处理程序显式添加一行 to Program.cs。使用接口和一些反射,我们可以创建一个简单且可重用的解决方案,以最少的 API 简化我们的工作。

So, let’s start by defining the following interface:
因此,让我们从定义以下接口开始:

public interface IEndpointRouteHandler
{
   public void MapEndpoints(IEndpointRouteBuilder app);
}

As the name implies, we need to make all our handlers (as with PeopleHandler previously) implement it:
顾名思义,我们需要让所有的处理程序(就像之前的 PeopleHandler 一样)实现它:

public class PeopleHandler : IEndpointRouteHandler
{
       public void MapEndpoints(IEndpointRouteBuilder app)
         {
                // ...
         }
         // ...
}

Note : The MapEndpoints method isn’t static anymore, because now it is the implementation of the IEndpointRouteHandler interface.
注意 : MapEndpoints 方法不再是静态的,因为它现在是 IEndpointRouteHandler 接口的实现。

Now we need a new extension method that, using reflection, scans an assembly for all the classes that implement this interface and automatically calls their MapEndpoints methods:
现在,我们需要一个新的扩展方法,该方法使用反射扫描程序集中实现此接口的所有类,并自动调用其 MapEndpoints 方法:

public static class IEndpointRouteBuilderExtensions
{
    public static void MapEndpoints(this
    IEndpointRouteBuilder app, Assembly assembly)
    {
        var endpointRouteHandlerInterfaceType = 
          typeof(IEndpointRouteHandler);
        var endpointRouteHandlerTypes = 
        assembly.GetTypes().Where(t =>
        t.IsClass && !t.IsAbstract && !t.IsGenericType
        && t.GetConstructor(Type.EmptyTypes) != null
        && endpointRouteHandlerInterfaceType
        .IsAssignableFrom(t));
        foreach (var endpointRouteHandlerType in 
        endpointRouteHandlerTypes)
        {
            var instantiatedType = (IEndpointRouteHandler)
              Activator.CreateInstance
                (endpointRouteHandlerType)!;
            instantiatedType.MapEndpoints(app);
        }
    }
}

Tip : If you want to go into further detail about reflection and how it works in .NET, you can start by browsing the following page: https://docs.microsoft.com/dotnet/csharp/programming-guide/concepts/reflection.
提示 : 如果您想更详细地了解反射及其在 .NET 中的工作原理,可以先浏览以下页面:https://docs.microsoft.com/dotnet/csharp/programming-guide/concepts/reflection

With all these pieces in place, the last thing to do is to call the extension method in the Program.cs file, before the Run() method:
完成所有这些部分后,最后要做的是在 Run() 方法之前调用 Program.cs 文件中的扩展方法:

app.MapEndpoints(Assembly.GetExecutingAssembly());
app.Run();

In this way, when we add new handlers, we should only need to create a new class that implements the IEndpointRouteHandler interface. No other changes will be required in Program.cs to add the new endpoints to the routing engine.
这样,当我们添加新的处理程序时,我们应该只需要创建一个实现 IEndpointRouteHandler 接口的新类。Program.cs 中无需进行其他更改即可将新终端节点添加到路由引擎。

Writing route handlers in external files and thinking about a way to automate endpoint registrations so that Program.cs won’t grow for each feature addition is the right way to architect a minimal API project.
在外部文件中编写路由处理程序并考虑一种自动化终端节点注册的方法,以便Program.cs不会因每个功能添加而增长,这是构建最小 API 项目的正确方法。

Summary
总结

ASP.NET Core minimal APIs represent a new way of writing HTTP APIs in the .NET world. In this chapter, we covered all the pillars that we need to start developing minimal APIs, how to effectively approach them, and the best practices to take into consideration when deciding to follow this architecture.
ASP.NET Core 最小 API 代表了在 .NET 环境中编写 HTTP API 的一种新方法。在本章中,我们介绍了开始开发最小 API 所需的所有支柱、如何有效地处理它们,以及在决定遵循此架构时要考虑的最佳实践。

In the next chapter, we’ll focus on some advanced concepts such as documenting APIs with Swagger, defining a correct error handling system, and integrating a minimal API with a single-page application.
在下一章中,我们将重点介绍一些高级概念,例如使用 Swagger 记录 API、定义正确的错误处理系统以及将最小 API 与单页应用程序集成。

3 Working with Minimal APIs

使用最少的 API

In this chapter, we will try to apply some advanced development techniques available in earlier versions of .NET. We will touch on four common topics that are disjointed from each other.
在本章中,我们将尝试应用早期版本的 .NET 中提供的一些高级开发技术。我们将讨论四个彼此脱节的常见主题。

We’ll cover productivity topics and best practices for frontend interfacing and configuration management.
我们将介绍前端接口和配置管理的生产力主题和最佳实践。

Every developer, sooner or later, will encounter the issues that we describe in this chapter. A programmer will have to write documentation for APIs, will have to make the API talk to a JavaScript frontend, will have to handle errors and try to fix them, and will have to configure the application according to parameters.
每个开发人员迟早都会遇到我们在本章中描述的问题。程序员必须为 API 编写文档,必须使 API 与 JavaScript 前端通信,必须处理错误并尝试修复它们,并且必须根据参数配置应用程序。

The themes we will touch on in this chapter are as follows:
我们将在本章中讨论的主题如下:

• Exploring Swagger
• Supporting CORS
• Working with global API settings
• Error handling

Technical requirements
技术要求

As reported in the previous chapters, it will be necessary to have the .NET 6 development framework available; you will also need to use .NET tools to run an in-memory web server.
如前几章所述,有必要提供 .NET 6 开发框架;您还需要使用 .NET 工具来运行内存中的 Web 服务器。

To validate the functionality of cross-origin resource sharing (CORS), we should exploit a frontend application residing on a different HTTP address from the one where we will host the API.
为了验证跨域资源共享 (CORS) 的功能,我们应该利用驻留在与我们将托管 API 的 HTTP 地址不同的 HTTP 地址上的前端应用程序。

To test the CORS example that we will propose within the chapter, we will take advantage of a web server in memory, which will allow us to host a simple static HTML page.
为了测试我们将在本章中提出的 CORS 示例,我们将利用内存中的 Web 服务器,这将允许我们托管一个简单的静态 HTML 页面。

To host the web page (HTML and JavaScript), we will therefore use LiveReloadServer, which you can install as a .NET tool with the following command:
因此,为了托管网页(HTML 和 JavaScript),我们将使用 LiveReloadServer,您可以使用以下命令将其作为 .NET 工具安装:

dotnet tool install -g LiveReloadServer

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter03.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter03

Exploring Swagger
探索 Swagger

Swagger has entered the life of .NET developers in a big way; it’s been present on the project shelves for several versions of Visual Studio.
Swagger 已经在很大程度上进入了 .NET 开发人员的生活;它已出现在多个版本的 Visual Studio 的项目架上。

Swagger is a tool based on the OpenAPI specification and allows you to document APIs with a web application. According to the official documentation available at https://oai.github.io/Documentation/introduction.xhtml:
Swagger 是基于 OpenAPI 规范的工具,允许您使用 Web 应用程序记录 API。根据 https://oai.github.io/Documentation/introduction.xhtml 上提供的官方文档:

“The OpenAPI Specification allows the description of a remote API accessible through HTTP or HTTP-like protocols.

An API defines the allowed interactions between two pieces of software, just like a user interface defines the ways in which a user can interact with a program.
“OpenAPI 规范允许描述可通过 HTTP 或类似 HTTP 的协议访问的远程 API。API 定义两个软件之间允许的交互,就像用户界面定义用户与程序交互的方式一样。

An API is composed of the list of possible methods to call (requests to make), their parameters, return values and any data format they require (among other things). This is equivalent to how a user’s interactions with a mobile phone app are limited to the buttons, sliders and text boxes in the app’s user interface.”
API 由可能调用的方法列表 (发出的请求) 、它们的参数、返回值和它们需要的任何数据格式 (以及其他内容) 组成。这相当于用户与手机应用程序的交互仅限于应用程序用户界面中的按钮、滑块和文本框。

Swagger in the Visual Studio scaffold
Visual Studio 基架中的 Swagger

We understand then that Swagger, as we know it in the .NET world, is nothing but a set of specifications defined for all applications that expose web-based APIs:
然后我们明白,正如我们在 .NET 世界中所知道的那样,Swagger 只不过是为公开基于 Web 的 API 的所有应用程序定义的一组规范:

Figure 3.1 – Visual Studio scaffold

By selecting Enable OpenAPI support, Visual Studio goes to add a NuGet package called Swashbuckle.AspNetCore and automatically configures it in the Program.cs file.
通过选择“启用 OpenAPI 支持”,Visual Studio 将添加一个名为 Swashbuckle.AspNetCore 的 NuGet 包,并自动在 Program.cs 文件中对其进行配置。

We show the few lines that are added with a new project. With these few pieces of information, a web application is enabled only for the development environment, which allows the developer to test the API without generating a client or using tools external to the application:
我们显示了随新项目添加的几行。有了这几条信息,Web 应用程序仅针对开发环境启用,这允许开发人员在不生成客户端或使用应用程序外部工具的情况下测试 API:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

The graphical part generated by Swagger greatly increases productivity and allows the developer to share information with those who will interface with the application, be it a frontend application or a machine application.
Swagger 生成的图形部分大大提高了生产力,并允许开发人员与将与应用程序交互的人员共享信息,无论是前端应用程序还是机器应用程序。

Note : We remind you that enabling Swagger in a production environment is strongly discouraged because sensitive information could be publicly exposed on the web or on the network where the application resides.
注意 : 我们提醒您,强烈建议不要在生产环境中启用 Swagger,因为敏感信息可能会在 Web 或应用程序所在的网络上公开暴露。

We have seen how to introduce Swagger into our API applications; this functionality allows us to document our API, as well as allow users to generate a client to call our application. Let’s see the options we have to quickly interface an application with APIs described with OpenAPI.
我们已经了解了如何将 Swagger 引入我们的 API 应用程序;此功能允许我们记录我们的 API,并允许用户生成客户端来调用我们的应用程序。让我们看看我们必须选择哪些选项来快速将应用程序与 OpenAPI 中描述的 API 连接起来。

OpenAPI Generator
OpenAPI 生成器

With Swagger, and especially with the OpenAPI standard, you can automatically generate clients to connect to the web application. Clients can be generated for many languages but also for development tools. We know how tedious and repetitive it is to write clients to access the Web API. Open API Generator helps us automate code generation, inspect the API documentation made by Swagger and OpenAPI, and automatically generate code to interface with the API. Simple, easy, and above all, fast.
使用 Swagger,尤其是 OpenAPI 标准,您可以自动生成客户端以连接到 Web 应用程序。可以为多种语言生成客户端,也可以为开发工具生成客户端。我们知道编写客户端来访问 Web API 是多么乏味和重复。Open API Generator 帮助我们自动生成代码,检查 Swagger 和 OpenAPI 制作的 API 文档,并自动生成代码以与 API 交互。简单、轻松,最重要的是,快速。

The @openapitools/openapi-generator-cli npm package is a very well-known package wrapper for OpenAPI Generator, which you can find at https://openapi-generator.tech/.
@openapitools/openapi-generator-cli npm 包是 OpenAPI 生成器的一个非常知名的包包装器,您可以在 https://openapi-generator.tech/ 中找到它。

With this tool, you can generate clients for programming languages as well as load testing tools such as JMeter and K6.
使用此工具,您可以为编程语言生成客户端以及 JMeter 和 K6 等负载测试工具。

It is not necessary to install the tool on your machine, but if the URL of the application is accessible from the machine, you can use a Docker image, as described by the following command:
无需在计算机上安装该工具,但如果可以从计算机访问应用程序的 URL,则可以使用 Docker 映像,如以下命令所述:

docker run --rm \

    -v ${PWD}:/local openapitools/openapi-generator-cli generate \

    -i /local/petstore.yaml \

    -g go \

    -o /local/out/go

The command allows you to generate a Go client using the OpenAPI definition found in the petstore.yaml file that is mounted on the Docker volume.
该命令允许您使用挂载在 Docker 卷上的 petstore.yaml 文件中找到的 OpenAPI 定义生成 Go 客户端。

Now, let’s go into detail to understand how you can leverage Swagger in .NET 6 projects and with minimal APIs.
现在,让我们详细介绍如何在 .NET 6 项目中利用 Swagger 并使用最少的 API。

Swagger in minimal APIs
在最少的 API 中使用Swagger

In ASP.NET Web API, as in the following code excerpt, we see a method documented with C# language annotations with the triple slash (///).
在 Web API ASP.NET,如以下代码摘录所示,我们看到一个使用带有三斜杠 () 的 C# 语言注释记录的方法。

The documentation section is leveraged to add more information to the API description. In addition, the ProducesResponseType annotations help Swagger identify the possible codes that the client must handle as a result of the method call:
利用 documentation 部分向 API 描述添加更多信息。此外,ProducesResponseType 注释可帮助 Swagger 识别客户端在方法调用后必须处理的可能代码:

/// <summary>
/// Creates a Contact.
/// </summary>
/// <param name="contact"></param>
/// <returns>A newly created Contact</returns>
/// <response code="201">Returns the newly created contact</response>
/// <response code="400">If the contact is null</response>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(Contact contactItem)
{
     _context.Contacts.Add(contactItem);
     await _context.SaveChangesAsync();
     return CreatedAtAction(nameof(Get), new { id = 
     contactItem.Id }, contactItem);
}

Swagger, in addition to the annotations on single methods, is also instructed by the documentation of the language to give further information to those who will then have to use the API application. A description of the methods of the parameters is always welcome by those who will have to interface; unfortunately, it is not possible to exploit this functionality in the minimal API.
除了单个方法的注释外,该语言的文档还指示 Swagger 为那些随后必须使用 API 应用程序的人提供更多信息。对参数方法的描述总是受到那些必须进行接口的人的欢迎;遗憾的是,无法在最小 API 中利用此功能。

Let’s go in order and see how to start using Swagger on a single method:
让我们按顺序来看看如何在单个方法上开始使用 Swagger:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() 
    { 
        Title = builder.Environment.ApplicationName,
        Version = "v1", Contact = new() 
        { Name = "PacktAuthor", Email = "[email protected]",
          Url = new Uri("https://www.packtpub.com/") },
          Description = "PacktPub Minimal API - Swagger",
          License = new Microsoft.OpenApi.Models.
            OpenApiLicense(),
          TermsOfService = new("https://www.packtpub.com/")
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

With this first example, we have configured Swagger and general Swagger information. We have included additional information that enriches Swagger’s UI. The only mandatory information is the title, while the version, contact, description, license, and terms of service are optional.
在第一个示例中,我们配置了 Swagger 和常规 Swagger 信息。我们添加了丰富 Swagger UI 的其他信息。唯一的必填信息是标题,而版本、联系人、描述、许可证和服务条款是可选的。

The UseSwaggerUI() method automatically configures where to put the UI and the JSON file describing the API with the OpenAPI format.
UseSwaggerUI() 方法自动配置放置 UI 和描述 OpenAPI 格式 API 的 JSON 文件的位置。

Here is the result at the graphical level:
这是图形级别的结果:

Figure 3.2 – The Swagger UI

We can immediately see that the OpenAPI contract information has been placed in the /swagger/v1/swagger.json path.
我们可以立即看到 OpenAPI 合约信息已经放在 /swagger/v1/swagger.json 路径下。

The contact information is populated, but no operations are reported as we haven’t entered any yet. Should the API have versioning? In the top-right section, we can select the available operations for each version.
联系信息已填充,但未报告任何作,因为我们尚未输入任何作。API 应该有版本控制吗?在右上角,我们可以为每个版本选择可用的作。

We can customize the Swagger URL and insert the documentation on a new path; the important thing is to redefine SwaggerEndpoint, as follows:
我们可以自定义 Swagger URL 并将文档插入到新路径上;重要的是重新定义 SwaggerEndpoint,如下所示:

app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", $"{builder.Environment.ApplicationName} v1"));

Let’s now go on to add the endpoints that describe the business logic.
现在,我们继续添加描述业务逻辑的终端节点。

It is very important to define RouteHandlerBuilder because it allows us to describe all the properties of the endpoint that we have written in code.
定义 RouteHandlerBuilder 非常重要,因为它允许我们描述我们在代码中编写的端点的所有属性。

The UI of Swagger must be enriched as much as possible; we must describe at best what the minimal APIs allow us to specify. Unfortunately, not all the functionalities are available, as in ASP.NET Web API.
必须尽可能丰富 Swagger 的 UI;我们最多只能描述最小 API 允许我们指定的内容。遗憾的是,并非所有功能都可用,就像 ASP.NET Web API 一样。

Versioning in minimal APIs
在最少的 API 中进行版本控制

Versioning in minimal APIs is not handled in the framework functionality; as a result, even Swagger cannot handle UI-side API versioning. So, we observe that when we go to the Select a definition section shown in Figure 3.2, only one entry for the current version of the API is visible.
最小 API 中的版本控制不在框架功能中处理;因此,即使是 Swagger 也无法处理 UI 端 API 版本控制。因此,我们观察到,当我们转到图 3.2 所示的 Select a definition 部分时,只有当前版本 API 的一个条目可见。

Swagger features
Swagger 功能

We just realized that not all features are available in Swagger; let’s now explore what is available instead. To describe the possible output values of an endpoint, we can call functions that can be called after the handler, such as the Produces or WithTags functions, which we are now going to explore.
我们刚刚意识到并非所有功能在 Swagger 中都可用;现在让我们来探索一下可用的内容。为了描述终端节点的可能输出值,我们可以调用可以在处理程序之后调用的函数,例如 Produces 或 WithTags 函数,我们现在将探讨这些函数。

The Produces function decorates the endpoint with all the possible responses that the client should be able to manage. We can add the name of the operation ID; this information will not appear in the Swagger screen, but it will be the name with which the client will create the method to call the endpoint. OperationId is the unique name of the operation made available by the handler.
Produces 函数使用客户端应该能够管理的所有可能的响应来装饰终端节点。我们可以添加作 ID 的名称;此信息不会显示在 Swagger 屏幕中,但它将是客户端创建调用终结点的方法时使用的名称。OperationId 是处理程序可用的作的唯一名称。

To exclude an endpoint from the API description, you need to call ExcludeFromDescription(). This function is rarely used, but it is very useful in cases where you don’t want to expose endpoints to programmers who are developing the frontend because that particular endpoint is used by a machine application.
要从 API 描述中排除终端节点,您需要调用 ExcludeFromDescription()。此函数很少使用,但在您不想将端点公开给正在开发前端的程序员的情况下,它非常有用,因为该特定端点由机器应用程序使用。

Finally, we can add and tag the various endpoints and segment them for better client management:
最后,我们可以添加和标记各种终端节点,并对其进行细分以更好地管理客户端:

app.MapGet("/sampleresponse", () =>
    {
        return Results.Ok(new ResponseData("My Response"));
    })
    .Produces<ResponseData>(StatusCodes.Status200OK)
    .WithTags("Sample")
    .WithName("SampleResponseOperation"); // operation ids to 
                                             Open API
app.MapGet("/sampleresponseskipped", () =>
{
    return Results.Ok(new ResponseData("My Response Skipped"));
})
    .ExcludeFromDescription();
app.MapGet("/{id}", (int id) => Results.Ok(id));
app.MapPost("/", (ResponseData data) => Results.Ok(data))
   .Accepts<ResponseData>(MediaTypeNames.Application.Json);

This is the graphical result of Swagger; as I anticipated earlier, the tags and operation IDs are not shown by the web client:
这是 Swagger 的图形结果;正如我之前所预料的那样,Web 客户端不会显示标签和作 ID:

Figure 3.3 – Swagger UI methods
图 3.3 – Swagger UI 方法

The endpoint description, on the other hand, is very useful to include. It’s very easy to implement: just insert C# comments in the method (just insert three slashes, ///, in the method). Minimal APIs don’t have methods like we are used to in web-based controllers, so they are not natively supported.
另一方面,终端节点描述非常有用。这很容易实现:只需在方法中插入 C# 注释(只需在方法中插入三个斜杠 , 即可)。Minimal API 没有我们在基于 Web 的控制器中习惯的方法,因此它们本身不受支持。

Swagger isn’t just the GUI we’re used to seeing. Above all, Swagger is the JSON file that supports the OpenAPI specification, of which the latest version is 3.1.0.
Swagger 不仅仅是我们习惯看到的 GUI。首先,Swagger 是支持 OpenAPI 规范的 JSON 文件,最新版本为 3.1.0。

In the following snippet, we show the section containing the description of the first endpoint that we inserted in the API. We can infer both the tag and the operation ID; this information will be used by those who will interface with the API:
在以下代码段中,我们显示了包含我们在 API 中插入的第一个终端节点的描述的部分。我们可以推断 tag 和作 ID;此信息将由将与 API 交互的人员使用:

"paths": {
         "/sampleresponse": {
              "get": {
                   "tags": [
                        "Sample"
                   ],
                   "operationId": "SampleResponseOperation",
                   "responses": {
                        "200": {
                             "description": "Success",
                             "content": {
                                  "application/json": {
                                       "schema": {
                                            "$ref": "#/components/schemas/ResponseData"
                                       }
                                  }
                             }
                        }
                   }
              }
         },

In this section, we have seen how to configure Swagger and what is currently not yet supported.
在本节中,我们了解了如何配置 Swagger 以及当前尚不支持的内容。

In the following chapters, we will also see how to configure OpenAPI, both for the OpenID Connect standard and authentication via the API key.
在接下来的章节中,我们还将了解如何配置 OpenAPI,包括 OpenID Connect 标准和通过 API 密钥进行身份验证。

In the preceding code snippet of the Swagger UI, Swagger makes the schematics of the objects involved available, both inbound to the various endpoints and outbound from them.
在 Swagger UI 的前面的代码片段中,Swagger 使所涉及对象的示意图可用,包括入站到各个端点和从它们出站的示意图。

Figure 3.4 – Input and output data schema
图 3.4 – 输入和输出数据架构

We will learn how to deal with these objects and how to validate and define them in Chapter 6, Exploring Validation and Mapping.
我们将在第 6 章 探索验证和映射 中学习如何处理这些对象以及如何验证和定义它们。

Swagger OperationFilter
Swagger OperationFilter

The operation filter allows you to add behavior to all operations shown by Swagger. In the following example, we’ll show you how to add an HTTP header to a particular call, filtering it by OperationId.
作筛选器允许您向 Swagger 显示的所有作添加行为。在以下示例中,我们将向您展示如何向特定调用添加 HTTP 标头,并按 OperationId 对其进行筛选。

When you go to define an operation filter, you can also set filters based on routes, tags, and operation IDs:
在定义作筛选条件时,您还可以根据路由、标签和作 ID 设置筛选条件:

public class CorrelationIdOperationFilter : IOperationFilter
{
    private readonly IWebHostEnvironment environment;
    public CorrelationIdOperationFilter(IWebHostEnvironment 
    environment)
    {
        this.environment = environment;
    }
    /// <summary>
    /// Apply header in parameter Swagger.
    /// We add default value in parameter for developer 
        environment
    /// </summary>
    /// <param name="operation"></param>
    /// <param name="context"></param>
    public void Apply(OpenApiOperation operation, 
    OperationFilterContext context)
    {
        if (operation.Parameters == null)
        {
            operation.Parameters = new 
            List<OpenApiParameter>();
        }
        if (operation.OperationId == 
            "SampleResponseOperation")
        {
             operation.Parameters.Add(new OpenApiParameter
             {
                 Name = "x-correlation-id",
                 In = ParameterLocation.Header,
                 Required = false,
                 Schema = new OpenApiSchema { Type = 
                 "String", Default = new OpenApiString("42") }
             });
        }
         }
}

To define an operation filter, the IOperationFilter interface must be implemented.
要定义作过滤器,必须实现 IOperationFilter 接口。

In the constructor, you can define all interfaces or objects that have been previously registered in the dependency inject engine.
在构造函数中,您可以定义之前在 dependency inject 引擎中注册的所有接口或对象。

The filter then consists of a single method, called Apply, which provides two objects:
然后,筛选器由一个名为 Apply 的方法组成,该方法提供两个对象:

• OpenApiOperation: An operation where we can add parameters or check the operation ID of the current call
• OperationFilterContext: The filter context that allows you to read ApiDescription, where you can find the URL of the current endpoint

Finally, to enable the operation filter in Swagger, we will need to register it inside the SwaggerGen method.
最后,要在 Swagger 中启用作筛选器,我们需要在 SwaggerGen 方法中注册它。

In this method, we should then add the filter, as follows:
在此方法中,我们应该添加过滤器,如下所示:

builder.Services.AddSwaggerGen(c =>
{
         … removed for brevity
         c.OperationFilter<CorrelationIdOperationFilter>();
});

Here is the result at the UI level; in the endpoint and only for a particular operation ID, we would have a new mandatory header with a default parameter that, in development, will not have to be inserted:
下面是 UI 级别的结果;在终端节点中,并且仅针对特定的作 ID,我们将有一个带有 default 参数的新 mandatory 标头,在开发中,不必插入该参数:

Figure 3.5 – API key section
图 3.5 – API 密钥部分

This case study helps us a lot when we have an API key that we need to set up and we don’t want to insert it on every single call.
当我们有一个需要设置的 API 密钥并且我们不想在每次调用时都插入它时,这个案例研究对我们有很大帮助。

Operation filter in production
生产中的作过滤器

Since Swagger should not be enabled in the production environment, the filter and its default value will not create application security problems.
由于不应在生产环境中启用 Swagger,因此过滤器及其默认值不会造成应用程序安全问题。

We recommend that you disable Swagger in the production environment.
建议您在生产环境中关闭 Swagger。

In this section, we figured out how to enable a UI tool that describes the API and allows us to test it. In the next section, we will see how to enable the call between single-page applications (SPAs) and the backend via CORS.
在本节中,我们弄清楚了如何启用描述 API 并允许我们测试它的 UI 工具。在下一节中,我们将了解如何通过 CORS 启用单页应用程序 (SPA) 与后端之间的调用。

Enabling CORS
启用 CORS

CORS is a security mechanism whereby an HTTP/S request is blocked if it arrives from a different domain than the one where the application is hosted. More information can be found in the Microsoft documentation or on the Mozilla site for developers.
CORS 是一种安全机制,如果 HTTP/S 请求来自与托管应用程序的域不同的域,则 HTTP/S 请求将被阻止。有关详细信息,请参阅 Microsoft 文档或 Mozilla 开发人员网站。

A browser prevents a web page from making requests to a domain other than the domain that serves that web page. A web page, SPA, or server-side web page can make HTTP requests to several backend APIs that are hosted in different origins.
浏览器会阻止网页向提供该网页的域以外的域发出请求。网页、SPA 或服务器端网页可以向托管在不同源中的多个后端 API 发出 HTTP 请求。

This restriction is called the same-origin policy. The same-origin policy prevents a malicious site from reading data from another site. Browsers don’t block HTTP requests but do block response data.
此限制称为同源策略。同源策略可防止恶意站点从其他站点读取数据。浏览器不会阻止 HTTP 请求,但会阻止响应数据。

We, therefore, understand that the CORS qualification, as it relates to safety, must be evaluated with caution.
因此,我们理解必须谨慎评估与安全相关的 CORS 资格。

The most common scenario is that of SPAs that are released on web servers with different web addresses than the web server hosting the minimal API:
最常见的情况是在 Web 服务器上发布的 SPA,这些 SPA 的 Web 地址与托管最小 API 的 Web 服务器不同:

Figure 3.6 – SPA and minimal API
图 3.6 – SPA 和最小 API

A similar scenario is that of microservices, which need to talk to each other. Each microservice will reside at a particular web address that will be different from the others.
类似的场景是微服务,它们需要相互通信。每个微服务将驻留在一个与其他微服务不同的特定 Web 地址上。

Figure 3.7 – Microservices and minimal APIs
图 3.7 – 微服务和最少的 API

In all these cases, therefore, a CORS problem is encountered.
因此,在所有这些情况下,都会遇到 CORS 问题。

We now understand the cases in which a CORS request can occur. Now let’s see what the correct HTTP request flow is and how the browser handles the request.
现在,我们了解了可能发生 CORS 请求的情况。现在让我们看看正确的 HTTP 请求流是什么,以及浏览器如何处理请求。

CORS flow from an HTTP request
来自 HTTP 请求的 CORS 流

What happens when a call leaves the browser for a different address other than the one where the frontend is hosted?
当调用离开浏览器前往托管前端的地址以外的其他地址时,会发生什么情况?

The HTTP call is executed and it goes all the way to the backend code, which executes correctly.
HTTP 调用被执行,并一直进入后端代码,后端代码正确执行。

The response, with the correct data inside, is blocked by the browser. That’s why when we execute a call with Postman, Fiddler, or any HTTP client, the response reaches us correctly.
包含正确数据的响应被浏览器阻止。这就是为什么当我们使用 Postman、Fiddler 或任何 HTTP 客户端执行调用时,响应会正确到达我们。

Figure 3.8 – CORS flow
图 3.8 – CORS 流程

In the following figure, we can see that the browser makes the first call with the OPTIONS method, to which the backend responds correctly with a 204 status code:
在下图中,我们可以看到浏览器使用 OPTIONS 方法进行了第一次调用,后端以 204 状态码正确响应:

Figure 3.9 – First request for the CORS call (204 No Content result)
图 3.9 – CORS 调用的第一个请求(204 No Content 结果)

In the second call that the browser makes, an error occurs; the strict-origin-when-cross-origin value is shown in Referrer Policy, which indicates the refusal by the browser to accept data from the backend:
在浏览器进行的第二次调用中,会发生错误;strict-origin-when-cross-origin 值显示在 Referrer Policy 中,该值表示浏览器拒绝接受来自后端的数据:

Figure 3.10 – Second request for the CORS call (blocked by the browser)
图 3.10 – CORS 调用的第二个请求(被浏览器阻止)

When CORS is enabled, in the response to the OPTIONS method call, three headers are inserted with the characteristics that the backend is willing to respect:
启用 CORS 后,在对 OPTIONS 方法调用的响应中,将插入三个标头,这些标头具有后端愿意遵循的特征:

Figure 3.11 – Request for CORS call (with CORS enabled)
图 3.11 – 请求 CORS 调用(启用 CORS)

In this case, we can see that three headers are added that define Access-Control-Allow-Headers, Access-Control-Allow-Methods, and Access-Control-Allow-Origin.
在本例中,我们可以看到添加了三个标头,分别定义 Access-Control-Allow-Headers、Access-Control-Allow-Methods 和 Access-Control-Allow-Origin。

The browser with this information can accept or block the response to this API.
具有此信息的浏览器可以接受或阻止对此 API 的响应。

Setting CORS with a policy
使用策略设置 CORS

Many configurations are possible within a .NET 6 application for activating CORS. We can define authorization policies in which the four available settings can be configured. CORS can also be activated by adding extension methods or annotations.
在 .NET 6 应用程序中可以使用许多配置来激活 CORS。我们可以定义授权策略,在其中可以配置四个可用设置。还可以通过添加扩展方法或注释来激活 CORS。

But let us proceed in order.
但是,让我们按顺序进行吧。

The CorsPolicyBuilder class allows us to define what is allowed or not allowed within the CORS acceptance policy.
orsPolicyBuilder 类允许我们定义 CORS 接受策略中允许或不允许的内容。

We have, therefore, the possibility to set different methods, for example:
因此,我们可以设置不同的方法,例如:

• AllowAnyHeader
• AllowAnyMethod
• AllowAnyOrigin
• AllowCredentials

While the first three methods are descriptive and allow us to enable any settings relating to the header, method, and origin of the HTTP call, respectively, AllowCredentials allows us to include the cookie with the authentication credentials.
虽然前三种方法是描述性的,并允许我们分别启用与 HTTP 调用的标头、方法和来源相关的任何设置,但 AllowCredentials 允许我们将 Cookie 与身份验证凭据一起包含。

CORS policy recommendations
CORS 策略建议

We recommend that you don’t use the AllowAny methods but instead filter out the necessary information to allow for greater security. As a best practice, when enabling CORS, we recommend the use of these methods:
我们建议您不要使用 AllowAny 方法,而是筛选掉必要的信息以提高安全性。作为最佳实践,在启用 CORS 时,我们建议使用以下方法:

• WithExposedHeaders
• WithHeaders
• WithOrigins

To simulate a scenario for CORS, we created a simple frontend application with three different buttons. Each button allows you to test one of the possible configurations of CORS within the minimal API. We will explain these configurations in a few lines.
为了模拟 CORS 的场景,我们创建了一个具有三个不同按钮的简单前端应用程序。每个按钮都允许您在最小 API 中测试 CORS 的一种可能配置。我们将用几行来解释这些配置。

To enable the CORS scenario, we have created a single-page application that can be launched on a web server in memory. We have used LiveReloadServer, a tool that can be installed with the .NET CLI. We talked about it at the start of the chapter and now it’s time to use it.
为了启用 CORS 方案,我们创建了一个单页应用程序,该应用程序可以在内存中的 Web 服务器上启动。我们使用了 LiveReloadServer,这是一个可以使用 .NET CLI 安装的工具。我们在本章的开头讨论过它,现在是时候使用它了。

After installing it, you need to launch the SPA with the following command:
安装后,您需要使用以下命令启动 SPA:

livereloadserver "{BasePath}\Chapter03\2-CorsSample\Frontend"

Here, BasePath is the folder where you are going to download the examples available on GitHub.
此处,BasePath 是您要下载 GitHub 上可用示例的文件夹。

Then you must start the application backend, either through Visual Studio or Visual Studio Code or through the .NET CLI with the following command:
然后,您必须使用以下命令通过 Visual Studio 或 Visual Studio Code 或通过 .NET CLI 启动应用程序后端:

dotnet run .\Backend\CorsSample.csproj

We’ve figured out how to start an example that highlights the CORS problem; now we need to configure the server to accept the request and inform the browser that it is aware that the request is coming from a different source.
我们已经想出了如何开始一个突出 CORS 问题的示例;现在我们需要配置服务器以接受请求并通知浏览器它知道请求来自不同的来源。

Next, we will talk about policy configuration. We will understand the characteristics of the default policy as well as how to create a custom one.
接下来,我们将讨论策略配置。我们将了解默认策略的特征以及如何创建自定义策略。

Configuring a default policy
配置默认策略

To configure a single CORS enabling policy, you need to define the behavior in the Program.cs file and add the desired configurations. Let’s implement a policy and define it as Default.
要配置单个 CORS 启用策略,您需要在 Program.cs 文件中定义行为并添加所需的配置。让我们实现一个策略并将其定义为 Default。

Then, to enable the policy for the whole application, simply add app.UseCors(); before defining the handlers:
然后,要为整个应用程序启用策略,只需添加 app.UseCors();在定义处理程序之前:

var builder = WebApplication.CreateBuilder(args);
var corsPolicy = new CorsPolicyBuilder("http://localhost:5200")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .Build();
builder.Services.AddCors(c => c.AddDefaultPolicy(corsPolicy));
var app = builder.Build();
app.UseCors();
app.MapGet("/api/cors", () =>
{
         return Results.Ok(new { CorsResultJson = true });
});
app.Run();

Configuring custom policies
配置自定义策略

We can create several policies within an application; each policy may have its own configuration and each policy may be associated with one or more endpoints.
我们可以在一个应用程序中创建多个策略;每个策略可能有自己的配置,并且每个策略可能与一个或多个终端节点关联。

In the case of microservices, having several policies helps to precisely segment access from a different source.
对于微服务,拥有多个策略有助于精确分段来自不同来源的访问。

In order to configure a new policy, it is necessary to add it and give it a name; this name will give access to the policy and allow it to be associated with the endpoint.
要配置新策略,必须添加该策略并为其命名;此名称将授予对策略的访问权限,并允许它与终端节点关联。

The customized policy, as in the previous example, is assigned to the entire application:
如前面的示例所示,自定义策略被分配给整个应用程序:

var builder = WebApplication.CreateBuilder(args);
var corsPolicy = new CorsPolicyBuilder("http://localhost:5200")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .Build();
builder.Services.AddCors(options => options.AddPolicy("MyCustomPolicy", corsPolicy));
var app = builder.Build();
app.UseCors("MyCustomPolicy");
app.MapGet("/api/cors", () =>
{
    return Results.Ok(new { CorsResultJson = true });
});
app.Run();

We next look at how to apply a single policy to a specific endpoint; to this end, two methods are available. The first is via an extension method to the IEndpointConventionBuilder interface. The second method is to add the EnableCors annotation followed by the name of the policy to be enabled for that method.
接下来,我们将了解如何将单个策略应用于特定终端节点;为此,有两种方法可供选择。第一种是通过 IEndpointConventionBuilder 接口的扩展方法。第二种方法是添加 EnableCors 注释,后跟要为该方法启用的策略的名称。

Setting CORS with extensions
使用扩展设置 CORS

It is necessary to use the RequireCors method followed by the name of the policy.
必须使用 RequireCors 方法,后跟策略的名称。

With this method, it is then possible to enable one or more policies for an endpoint:
使用此方法,可以为终端节点启用一个或多个策略:

app.MapGet("/api/cors/extension", () =>
{
    return Results.Ok(new { CorsResultJson = true });
})
.RequireCors("MyCustomPolicy");

Setting CORS with an annotation
使用注释设置 CORS

The second method is to add the EnableCors annotation followed by the name of the policy to be enabled for that method:
第二种方法是添加 EnableCors 注释,后跟要为该方法启用的策略的名称:

app.MapGet("/api/cors/annotation", [EnableCors("MyCustomPolicy")] () =>
{
   return Results.Ok(new { CorsResultJson = true });
});

Regarding controller programming, it soon becomes apparent that it is not possible to apply a policy to all methods of a particular controller. It is also not possible to group controllers and enable the policy. It is therefore necessary to apply the individual policy to the method or the entire application.
关于控制器编程,很快就会发现不可能将策略应用于特定控制器的所有方法。也无法对控制器进行分组并启用策略。因此,有必要将单个策略应用于方法或整个应用程序。

In this section, we found out how to configure browser protection for applications hosted on different domains.
在本节中,我们了解了如何为托管在不同域上的应用程序配置浏览器保护。

In the next section, we will start configuring our applications.
在下一节中,我们将开始配置我们的应用程序。

Working with global API settings
使用全局 API 设置

We have just defined how you can load data with the options pattern within an ASP.NET application. In this section, we want to describe how you can configure an application and take advantage of everything we saw in the previous section.
我们刚刚定义了如何在 ASP.NET 应用程序中使用 options 模式加载数据。在本节中,我们想描述如何配置应用程序并利用我们在上一节中看到的所有内容。

With the birth of .NET Core, the standard has moved from the Web.config file to the appsettings.json file. The configurations can also be read from other sources, such as other file formats like the old .ini file or a positional file.
随着 .NET Core 的诞生,该标准已从 Web.config 文件移至 appsettings.json 文件。还可以从其他来源读取配置,例如其他文件格式,如旧.ini文件或位置文件。

In minimal APIs, the options pattern feature remains unchanged, but in the next few paragraphs, we will see how to reuse the interfaces or the appsettings.json file structure.
在最小 API 中,选项模式功能保持不变,但在接下来的几段中,我们将看到如何重用接口或 appsettings.json 文件结构。

Configuration in .NET 6
.NET 6 中的配置

The object provided from .NET is IConfiguration, which allows us to read some specific configurations inside the appsettings file.
从 .NET 提供的对象是 IConfiguration,它允许我们读取 appsettings 文件中的一些特定配置。

But, as described earlier, this interface does much more than just access a file for reading.
但是,如前所述,此接口的作用不仅仅是访问文件进行读取。

The following extract from the official documentation helps us understand how the interface is the generic access point that allows us to access the data inserted in various services:
以下摘录自官方文档有助于我们了解接口如何成为允许我们访问插入各种服务中的数据的通用接入点:

Configuration in ASP.NET Core is performed using one or more configuration providers. Configuration providers read configuration data from key-value pairs using a variety of configuration sources.
ASP.NET Core 中的配置是使用一个或多个配置提供程序执行的。配置提供程序使用各种配置源从键值对中读取配置数据。

The following is a list of configuration sources:
以下是配置源的列表:

• Settings files, such as appsettings.json
• Environment variables
• Azure Key Vault
• Azure App Configuration
• Command-line arguments
• Custom providers, installed or created
• Directory files
• In-memory .NET objects

(https://docs.microsoft.com/aspnet/core/fundamentals/configuration/)

The IConfiguration and IOptions interfaces, which we will see in the next chapter, are designed to read data from the various providers. These interfaces are not suitable for reading and editing the configuration file while the program is running.
我们将在下一章中看到的 IConfiguration 和 IOptions 接口旨在从各种提供程序读取数据。这些接口不适合在程序运行时读取和编辑配置文件。

The IConfiguration interface is available through the builder object, builder.Configuration, which provides all the methods needed to read a value, an object, or a connection string.
IConfiguration 接口可通过 builder 对象 builder 获得。Configuration,它提供读取值、对象或连接字符串所需的所有方法。

After looking at one of the most important interfaces that we will use to configure the application, we want to define good development practices and use a fundamental building block for any developer: namely, classes. Copying the configuration into a class will allow us to better enjoy the content anywhere in the code.
在查看了我们将用于配置应用程序的最重要的接口之一之后,我们想要定义良好的开发实践并为任何开发人员使用一个基本构建块:即类。将配置复制到类中将使我们能够更好地享受代码中任何位置的内容。

We define classes containing a property and classes corresponding appsettings file:
我们定义包含属性的类和对应的 appsettings 文件的类:

Configuration classes

public class MyCustomObject
{
    public string? CustomProperty { get; init; }
}
public class MyCustomStartupObject
{
    public string? CustomProperty { get; init; }
}

And here, we bring back the corresponding JSON of the C# class that we just saw:
在这里,我们返回我们刚刚看到的 C# 类的相应 JSON:

appsettings.json definition
appsettings.json定义

{
    "MyCustomObject": {
         "CustomProperty": "PropertyValue"
    },
    "MyCustomStartupObject": {
         "CustomProperty": "PropertyValue"
    },
    "ConnectionStrings": {
         "Default": "MyConnectionstringValueInAppsettings"
    }
}

Next, we will be performing several operations.
接下来,我们将执行几项作。

The first operation we perform creates an instance of the startupConfig object that will be of the MyCustomStartupObject type. To populate the instance of this object, through IConfiguration, we are going to read the data from the section called MyCustomStartupObject:
我们执行的第一个作将创建一个 startupConfig 对象的实例,该实例将为 MyCustomStartupObject 类型。为了填充此对象的实例,通过 IConfiguration,我们将从名为 MyCustomStartupObject 的部分读取数据:

var startupConfig = builder.Configuration.GetSection(nameof(MyCustomStartupObject)).Get<MyCustomStartupObject>();

The newly created object can then be used in the various handlers of the minimal APIs.
然后,新创建的对象可以在最小 API 的各种处理程序中使用。

Instead, in this second operation, we use the dependency injection engine to request the instance of the IConfiguration object:
相反,在第二个作中,我们使用依赖项注入引擎来请求 IConfiguration 对象的实例:

app.MapGet("/read/configurations", (IConfiguration configuration) =>
{
    var customObject = configuration.
    GetSection(nameof(MyCustomObject)).Get<MyCustomObject>();

With the IConfiguration object, we will retrieve the data similarly to the operation just described. We select the GetSection(nameof(MyCustomObject)) section and type the object with the Get<T>() method.
使用 IConfiguration 对象,我们将检索数据,类似于刚才描述的作。我们选择 GetSection(nameof(MyCustomObject)) 部分,并使用Get<T>()方法键入对象。

Finally, in these last two examples, we read a single key, present at the root level of the appsettings file:
最后,在最后两个示例中,我们读取了一个键,该键位于 appsettings 文件的根级别:

MyCustomValue = configuration.GetValue<string>("MyCustomValue"),
ConnectionString = configuration.GetConnectionString("Default"),

The configuration.GetValue<T>(“JsonRootKey”) method extracts the value of a key and converts it into an object; this method is used to read strings or numbers from a root-level property.
configuration.GetValue<T>(“JsonRootKey”) 方法提取键的值并将其转换为对象;此方法用于从根级别属性中读取字符串或数字。

In the next line, we can see how you can leverage an IConfiguration method to read ConnectionString.
在下一行中,我们可以看到如何利用 IConfiguration 方法来读取 ConnectionString。

In the appsettings file, connection strings are placed in a specific section, ConnectionStrings, that allows you to name the string and read it. Multiple connection strings can be placed in this section to exploit it in different objects.
在 appsettings 文件中,连接字符串放置在特定部分 ConnectionStrings 中,该部分允许你命名和读取字符串。可以在此部分中放置多个连接字符串,以便在不同的对象中利用它。

In the configuration provider for Azure App Service, connection strings should be entered with a prefix that also indicates the SQL provider you are trying to use, as described in the following link: https://docs.microsoft.com/azure/app-service/configure-common#configure-connection-strings.
在 Azure 应用服务的配置提供程序中,应输入连接字符串,并带有一个前缀,该前缀也指示你尝试使用的 SQL 提供程序,如以下链接所述:https://docs.microsoft.com/azure/app-service/configure-common#configure-connection-strings

At runtime, connection strings are available as environment variables, prefixed with the following connection types:
在运行时,连接字符串可用作环境变量,前缀为以下连接类型:

• SQLServer: SQLCONNSTR
• MySQL: MYSQLCONNSTR

• SQLAzure: SQLAZURECONNSTR
• Custom: CUSTOMCONNSTR

• PostgreSQL: POSTGRESQLCONNSTR_

For completeness, we will bring back the entire code just described in order to have a better general picture of how to exploit the IConfiguration object inside the code:
为了完整起见,我们将返回刚才描述的整个代码,以便更好地了解如何在代码中利用 IConfiguration 对象:

var builder = WebApplication.CreateBuilder(args);
var startupConfig = builder.Configuration.GetSection(nameof(MyCustomStartupObject)).Get<MyCustomStartupObject>();
app.MapGet("/read/configurations", (IConfiguration configuration) =>
{
    var customObject = configuration.GetSection
    (nameof(MyCustomObject)).Get<MyCustomObject>();
    return Results.Ok(new
    {
        MyCustomValue = configuration.GetValue
        <string>("MyCustomValue"),
         ConnectionString = configuration.
         GetConnectionString("Default"),
         CustomObject = customObject,
         StartupObject = startupConfig
    });
})
.WithName("ReadConfigurations");

We’ve seen how to take advantage of the appsettings file with connection strings, but very often, we have many different files for each environment. Let’s see how to take advantage of one file for each environment.
我们已经了解了如何利用带有连接字符串的 appsettings 文件,但通常,每个环境都有许多不同的文件。让我们看看如何为每个环境利用一个文件。

Priority in appsettings files
appsettings 文件中的优先级

The appsettings file can be managed according to the environments in which the application is located. In this case, the practice is to place key information for that environment in the appsettings.{ENVIRONMENT}.json file.
可以根据应用程序所在的环境来管理 appsettings 文件。在这种情况下,做法是将该环境的关键信息放在 appsettings.{ENVIRONMENT}.json文件。

The root file (that is, appsettings.json) should be used for the production environment only.
根文件(即 appsettings.json)应仅用于生产环境。

For example, if we created these examples in the two files for the “Priority” key, what would we get?
例如,如果我们在两个文件中为 “Priority” 键创建这些示例,我们会得到什么?

appsettings.json

"Priority": "Root"

appsettings.Development.json

"Priority":    "Dev"

If it is a Development environment, the value of the key would result in Dev, while in a Production environment, the value would result in Root.
如果是 Development 环境,则 key 的值将导致 Dev,而在 Production 环境中,该值将导致 Root。

What would happen if the environment was anything other than Production or Development? For example, if it were called Stage? In this case, having not specified any appsettings.Stage.json file, the read value would be that of one of the appsettings.json files and therefore, Root.
如果环境不是生产或开发,会发生什么情况?例如,如果它被称为 Stage?在本例中,未指定任何 appsettings.Stage.json文件中,读取值将是其中一个appsettings.json文件的值,因此是 Root。

However, if we specified the appsettings.Stage.json file, the value would be read from the that file.
但是,如果我们指定 appsettings.Stage.json文件中,将从该文件中读取该值。

Next, let’s visit the Options pattern. There are objects that the framework provides to load configuration information upon startup or when changes are made by the systems department. Let’s go over how.
接下来,让我们访问 Options 模式。框架提供了一些对象,用于在启动时或系统部门进行更改时加载配置信息。让我们来看看如何作。

Options pattern
选项模式

The options pattern uses classes to provide strongly typed access to groups of related settings, that is, when configuration settings are isolated by scenario into separate classes.
选项模式使用类提供对相关设置组的强类型访问,即,当配置设置按方案隔离到单独的类中时。

The options pattern will be implemented with different interfaces and different functionalities. Each interface (see the following subsection) has its own features that help us achieve certain goals.
选项模式将使用不同的接口和不同的功能实现。每个界面(请参阅以下小节)都有自己的功能,可以帮助我们实现某些目标。

But let’s start in order. We define an object for each type of interface (we will do it to better represent the examples), but the same class can be used to register more options inside the configuration file. It is important to keep the structure of the file identical:
但让我们按顺序开始。我们为每种类型的接口定义一个对象(我们将这样做以更好地表示示例),但同一个类可用于在配置文件中注册更多选项。保持文件的结构相同非常重要:

public class OptionBasic
{
    public string? Value { get; init; }
}
    public class OptionSnapshot
    {
        public string? Value { get; init; }
    }
    public class OptionMonitor
    {
        public string? Value { get; init; }
    }
    public class OptionCustomName
    {
        public string? Value { get; init; }
    }

Each option is registered in the dependency injection engine via the Configure method, which also requires the registration of the T type present in the method signature. As you can see, in the registration phase, we declared the types and the section of the file where to retrieve the information, and nothing more:
每个选项都通过 Configure 方法在依赖项注入引擎中注册,该方法还需要注册方法签名中存在的 T 类型。如你所见,在注册阶段,我们声明了类型和文件部分,用于检索信息,仅此而已:

builder.Services.Configure<OptionBasic>(builder.Configuration.GetSection("OptionBasic"));
builder.Services.Configure<OptionMonitor>(builder.Configuration.GetSection("OptionMonitor"));
builder.Services.Configure<OptionSnapshot>(builder.Configuration.GetSection("OptionSnapshot"));
builder.Services.Configure<OptionCustomName>("CustomName1", builder.Configuration.GetSection("CustomName1"));
builder.Services.Configure<OptionCustomName>("CustomName2", builder.Configuration.GetSection("CustomName2"));

We have not yet defined how the object should be read, how often, and with what type of interface.
我们尚未定义应该如何读取对象、读取频率以及使用什么类型的接口。

The only thing that changes is the parameter, as seen in the last two examples of the preceding code snippet. This parameter allows you to add a name to the option type. The name is required to match the type used in the method signature. This feature is called named options.
唯一更改的是参数,如前面代码段的最后两个示例所示。此参数允许您向选项类型添加名称。该名称必须与方法签名中使用的类型匹配。此功能称为 named options。

Different option interfaces
不同的选项接口

Different interfaces can take advantage of the recordings you just defined. Some support named options and some do not:
不同的界面可以利用您刚刚定义的记录。有些支持命名选项,有些则不支持:

IOptions<TOptions>:
Is registered as a singleton and can be injected into any service lifetime
注册为单一实例,可以注入到任何服务生命周期中
Does not support the following:
不支持以下内容:
Reading of configuration data after the app has started
在应用程序启动后读取配置数据
Named options
命名选项

IOptionsSnapshot<TOptions>:
Is useful in scenarios where options should be recomputed on every request
在应在每个请求上重新计算选项的情况下非常有用
Is registered as scoped and therefore cannot be injected into a singleton service
注册为 scoped,因此不能注入到单一实例服务
Supports named options
支持命名选项

IOptionsMonitor<TOptions>:
Is used to retrieve options and manage options notifications for TOptions instances
用于检索选项和管理 TOptions 实例的选项通知
Is registered as a singleton and can be injected into any service lifetime
注册为单一实例,可以注入到任何服务生命周期中
Supports the following:
支持以下功能:
Change notifications
更改通知
Named options
命名选项
Reloadable configuration
可重新加载配置
Selective options invalidation (IOptionsMonitorCache<TOptions>)
选择性选项失效 (IOptionsMonitorCache<TOptions>)

We want to point you to the use of IOptionsFactory<TOptions>, which is responsible for creating new instances of options. It has a single Create method. The default implementation takes all registered IConfigureOptions<TOptions> and IPostConfigureOptions and performs all configurations first, followed by post-configuration (https://docs.microsoft.com/aspnet/core/fundamentals/configuration/options#options-interfaces).
我们想向您介绍一下 IOptionsFactory<TOptions> 的使用,它负责创建新的选项实例。它只有一个 Create 方法。默认实现采用所有已注册的 IConfigureOptions<TOptions> IPostConfigureOptions<TOptions>并首先执行所有配置,然后执行后配置 (https://docs.microsoft.com/aspnet/core/fundamentals/configuration/options#options-interfaces)。

The Configure method can also be followed by another method in the configuration pipeline. This method is called PostConfigure and is intended to modify the configuration each time it is configured or reread. Here is an example of how to record this behavior:
Configure 方法也可以后跟配置管道中的另一个方法。此方法称为 PostConfigure,旨在在每次配置或重新读取配置时修改配置。以下是如何记录此行为的示例:

builder.Services.PostConfigure<MyConfigOptions>(myOptions =>
{
   myOptions.Key1 = "my_new_value_post_configuration";
});

Putting it all together
把它们放在一起

Having defined the theory of these numerous interfaces, it remains for us to see IOptions at work with a concrete example.
在定义了这些众多接口的理论之后,我们仍然需要通过一个具体的例子来了解 IOptions 的工作原理。

Let’s see the use of the three interfaces just described and the use of IOptionsFactory, which, along with the Create method and with the named options function, retrieves the correct instance of the object:
让我们看看刚才描述的三个接口的用法以及 IOptionsFactory 的用法,它与 Create 方法和命名选项函数一起检索对象的正确实例:

app.MapGet("/read/options", (IOptions<OptionBasic> optionsBasic,
         IOptionsMonitor<OptionMonitor> optionsMonitor,
         IOptionsSnapshot<OptionSnapshot> optionsSnapshot,
         IOptionsFactory<OptionCustomName> optionsFactory) =>
{
         return Results.Ok(new
         {
             Basic = optionsBasic.Value,
             Monitor = optionsMonitor.CurrentValue,
             Snapshot = optionsSnapshot.Value,
             Custom1 = optionsFactory.Create("CustomName1"),
             Custom2 = optionsFactory.Create("CustomName2")
         });
})
.WithName("ReadOptions");

In the previous code snippet, we want to bring attention to the use of the different interfaces available.
在前面的代码片段中,我们希望提请注意可用不同接口的使用。

Each individual interface used in the previous snippet has a particular life cycle that characterizes its behavior. Finally, each interface has slight differences in the methods, as we have already described in the previous paragraphs.
上一个代码段中使用的每个接口都有一个特定的生命周期,用于描述其行为。最后,正如我们在前面的段落中已经描述的那样,每个接口在方法上略有不同。

IOptions and validation
操作和验证

Last but not least is the validation functionality of the data present in the configuration. This is very useful when the team that has to release the application still performs manual or delicate operations that need to be at least verified by the code.
最后但并非最不重要的一点是配置中存在的数据的验证功能。当必须发布应用程序的团队仍然执行至少需要由代码验证的手动或精细作时,这非常有用。

Before the advent of .NET Core, very often, the application would not start because of an incorrect configuration. Now, with this feature, we can validate the data in the configuration and throw errors.
在 .NET Core 出现之前,应用程序经常由于配置不正确而无法启动。现在,借助此功能,我们可以验证配置中的数据并引发错误。

Here is an example:
下面是一个示例:

Register option with validation
带验证的 Register 选项

builder.Services.AddOptions<ConfigWithValidation>().Bind(builder.Configuration.GetSection(nameof(ConfigWithValidation)))
.ValidateDataAnnotations();
app.MapGet("/read/options", (IOptions<ConfigWithValidation> optionsValidation) =>
{
    return Results.Ok(new
    {
        Validation = optionsValidation.Value
    });
})
.WithName("ReadOptions");

This is the configuration file where an error is explicitly reported:
这是明确报告错误的配置文件:

Appsettings section for configuration validation
用于配置验证的 Appsettings 部分

"ConfigWithValidation": {
         "Email": "[email protected]",
         "NumericRange": 1001
    }

And here is the class containing the validation logic:
下面是包含验证逻辑的类:

public class ConfigWithValidation
{
    [RegularExpression(@"^([\w\.\-]+)@([\w\-]+)((\.(\w)
                      {2,})+)$")]
    public string? Email { get; set; }
    [Range(0, 1000, ErrorMessage = "Value for {0} must be 
                                    between {1} and {2}.")]
    public int NumericRange { get; set; }
}

The application then encounters errors while using the particular configuration and not at startup. This is also because, as we have seen before, IOptions could reload information following a change in appsettings:
然后,应用程序在使用特定配置时遇到错误,而不是在启动时遇到错误。这也是因为,正如我们之前看到的,IOptions 可以在 appsettings 更改后重新加载信息:

Error validate option
错误验证选项

Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'ConfigWithValidation' members: 'NumericRange' with the error: 'Value for NumericRange must be between 0 and 1000.'.

Best practice for using validation in IOptions
在 IOptions 中使用验证的最佳实践

This setting is not suitable for all application scenarios. Only some options can have formal validations; if we think of a connection string, it is not necessarily formally incorrect, but the connection may not be working.
此设置并不适合所有应用程序方案。只有某些选项可以进行正式验证;如果我们考虑一个连接字符串,它不一定在形式上是错误的,但连接可能无法正常工作。

Be cautious about applying this feature, especially since it reports errors at runtime and not during startup and gives an Internal Server Error, which is not a best practice in scenarios that should be handled.
在应用此功能时请谨慎,尤其是因为它在运行时而不是在启动期间报告错误,并给出内部服务器错误,这在应该处理的场景中不是最佳实践。

Everything we’ve seen up to this point is about configuring the appsettings.json file, but what if we wanted to use other sources for configuration management? We’ll look at that in the next section.
到目前为止,我们所看到的所有内容都是关于配置 appsettings.json 文件的,但是如果我们想使用其他源进行配置管理呢?我们将在下一节中介绍这一点。

Configuration sources
配置源

As we mentioned at the beginning of the section, the IConfiguration interface and all variants of IOptions work not only with the appsettings file but also on different sources.
正如我们在本节开头提到的,IConfiguration 接口和 IOptions 的所有变体不仅适用于 appsettings 文件,也适用于不同的源。

Each source has its own characteristics, and the syntax for accessing objects is very similar between providers. The main problem is when we must define a complex object or an array of objects; in this case, we will see how to behave and be able to replicate the dynamic structure of a JSON file.
每个源都有其自己的特征,并且访问对象的语法在提供程序之间非常相似。主要问题是当我们必须定义一个复杂对象或一个对象数组时;在这种情况下,我们将了解如何作并能够复制 JSON 文件的动态结构。

Let’s look at two very common use cases.
让我们看两个非常常见的用例。

Configuring an application in Azure App Service
在 Azure 应用服务中配置应用程序

Let’s start with Azure, and in particular, the Azure Web Apps service.
让我们从 Azure 开始,特别是 Azure Web 应用服务。

On the Configuration page, there are two sections: Application settings and Connection strings.
在 Configuration (配置) 页面上,有两个部分: Application settings (应用程序设置) 和 Connection strings (连接字符串)。

In the first section, we need to insert the keys and values or JSON objects that we saw in the previous examples.
在第一部分中,我们需要插入我们在前面的示例中看到的键和值或 JSON 对象。

In the Connection strings section, you can insert the connection strings that are usually inserted in the appsettings.json file. In this section, in addition to the textual string, it is necessary to set the connection type, as we saw in the Configuration in .NET 6 section.
在 Connection strings (连接字符串) 部分中,您可以插入通常插入 appsettings.json 文件中的连接字符串。在本节中,除了文本字符串之外,还需要设置连接类型,正如我们在 .NET 6 中的配置部分中看到的那样。

Figure 3.12 – Azure App Service Application settings
图 3.12 – Azure 应用服务应用程序设置

Inserting an object
插入对象

To insert an object, we must specify the parent for each key.
要插入对象,我们必须为每个键指定 parent。

The format is as follows:
格式如下:

parent__key

Note that there are two underscores.
请注意,有两个下划线。

The object in the JSON file would be defined as follows:
JSON 文件中的对象将定义如下:

"MyCustomObject": {
         "CustomProperty": "PropertyValue"
    }

So, we should write MyCustomObjectCustomProperty.
所以,我们应该写MyCustomObject
CustomProperty。

Inserting an array
插入数组

Inserting an array is much more verbose.
插入数组要详细得多。

The format is as follows:
格式如下:

parent__child__ArrayIndexNumber_key

The array in the JSON file would be defined as follows:
JSON 文件中的数组定义如下:

{
"MyCustomArray": {
"CustomPropertyArray": [
{ "CustomKey": "ValueOne" },
{ "CustomKey ": "ValueTwo" }
]
}
}
So, to access the ValueOne value, we should write the following: MyCustomArrayCustomPropertyArray0CustomKey.
因此,要访问 ValueOne 值,我们应该编写以下内容:MyCustomArray
CustomPropertyArray0CustomKey。

Configuring an application in Docker
在 Docker 中配置应用程序

If we are developing for containers and therefore for Docker, appsettings files are usually replaced in the docker-compose file, and very often in the override file, because it behaves analogously to the settings files divided by the environment.
如果我们针对容器和 Docker 进行开发,则 appsettings 文件通常会在 docker-compose 文件中被替换,并且经常在 override 文件中被替换,因为它的行为类似于按环境划分的设置文件。

We want to provide a brief overview of the features that are usually leveraged to configure an application hosted in Docker. Let’s see in detail how to define root keys and objects, and how to set the connection string. Here is an example:
我们想简要概述通常用于配置 Docker 中托管的应用程序的功能。让我们详细看看如何定义根键和对象,以及如何设置连接字符串。下面是一个示例:

app.MapGet("/env-test", (IConfiguration configuration) =>
{
    var rootProperty = configuration.
    GetValue<string>("RootProperty");
    var sampleVariable = configuration.
    GetValue<string>("RootSettings:SampleVariable");
    var connectionString = configuration.
    GetConnectionString("SqlConnection");
    return Results.Ok(new
    {
        RootProperty = rootProperty,
        SampleVariable = sampleVariable,
        Connection String = connectionString
    });
})
.WithName("EnvironmentTest");

Minimal APIs that use configuration
使用配置的最小 API

The docker-compose.override.yaml file is as follows:
docker-compose.override.yaml 文件如下:

services:
    dockerenvironment:
         environment:
              - ASPNETCORE_ENVIRONMENT=Development
              - ASPNETCORE_URLS=https://+:443;http://+:80
              - RootProperty=minimalapi-root-value
              - RootSettings__SampleVariable=minimalapi-variable-value
              - ConnectionStrings__SqlConnection=Server=minimal.db;Database=minimal_db;User Id=sa;Password=Taggia42!

There is only one application container for this example, and the service that instantiates it is called dockerenvironment.
此示例只有一个应用程序容器,实例化它的服务称为 dockerenvironment。

In the configuration section, we can see three particularities that we are going to analyze line by line.
在配置部分,我们可以看到我们将逐行分析的三个特性。

The snippet we want to show you has several very interesting components: a property in the configuration root, an object composed of a single property, and a connection string to a database.
我们要向您展示的代码段有几个非常有趣的组件:配置根中的属性、由单个属性组成的对象以及数据库的连接字符串。

In this first configuration, you are going to set a property that is the root of the configurations. In this case, it is a simple string:
在第一个配置中,您将设置一个属性,该属性是配置的根。在本例中,它是一个简单的字符串:

# First configuration
- RootProperty=minimalapi-root-value

In this second configuration, we are going to set up an object:
在第二个配置中,我们将设置一个对象:

# Second configuration
- RootSettings__SampleVariable=minimalapi-variable-value

The object is called RootSettings, while the only property it contains is called SampleVariable. This object can be read in different ways. We recommend using the Ioptions object that we have seen extensively before. In the preceding example, we show how to access a single property present in an object via code.
该对象称为 RootSettings,而它包含的唯一属性称为 SampleVariable。可以通过不同的方式读取此对象。我们建议使用我们之前广泛看到的 Ioptions 对象。在前面的示例中,我们展示了如何通过代码访问对象中存在的单个属性。

In this case, via code, you need to use the following notation to access the value: RootSettings:SampleVariable. This approach is useful if you need to read a single property, but we recommend using the Ioptions interfaces to access the object.
在这种情况下,您需要通过代码使用以下表示法来访问该值:RootSettings:SampleVariable。如果需要读取单个属性,此方法非常有用,但我们建议使用 Ioptions 接口来访问对象。

In this last example, we show you how to set the connection string called SqlConnection. This way, it will be easy to retrieve the information from the base methods available on Iconfiguration:
在最后一个示例中,我们将向您展示如何设置名为 SqlConnection 的连接字符串。这样,就很容易从 Iconfiguration 上可用的 base 方法中检索信息:

# Third configuration
- ConnectionStrings__SqlConnection=Server=minimal.db;Database=minimal_db;User Id=sa;Password=Taggia42!

To read the information, it is necessary to exploit this method: GetConnectionString(“SqlConnection”).
要读取信息,必须利用此方法: GetConnectionString(“SqlConnection”)。

There are a lot of scenarios for configuring our applications; in the next section, we will also see how to handle errors.
配置我们的应用程序有很多场景;在下一节中,我们还将了解如何处理错误。

Error handling
错误处理

Error handling is one of the features that every application must provide. The representation of an error allows the client to understand the error and possibly handle the request accordingly. Very often, we have our own customized methods of handling errors.
错误处理是每个应用程序都必须提供的功能之一。错误的表示允许客户端理解错误并可能相应地处理请求。很多时候,我们有自己的自定义错误处理方法。

Since what we’re describing is a key functionality of the application, we think it’s fair to see what the framework provides and what is more correct to use.
由于我们所描述的是应用程序的关键功能,因此我们认为查看框架提供的内容以及使用起来更正确的内容是公平的。

Traditional approach
传统方法

.NET provides the same tool for minimal APIs that we can implement in traditional development: a Developer Exception Page. This is nothing but middleware that reports the error in plain text format. This middleware can’t be removed from the ASP.NET pipeline and works exclusively in the development environment (https://docs.microsoft.com/aspnet/core/fundamentals/error-handling).
.NET 为最小 API 提供了我们可以在传统开发中实现的相同工具:开发人员异常页。这只不过是以纯文本格式报告错误的中间件。此中间件无法从 ASP.NET 管道中删除,并且只能在开发环境 (https://docs.microsoft.com/aspnet/core/fundamentals/error-handling) 中运行。

Figure 3.13 – Minimal APIs pipeline, ExceptionHandler
图 3.13 – 最小 API 管道 ExceptionHandler

If exceptions are raised within our code, the only way to catch them in the application layer is through middleware that is activated before sending the response to the client.
如果在我们的代码中引发了异常,那么在应用程序层捕获它们的唯一方法是通过在将响应发送到客户端之前激活的中间件。

Error handling middleware is standard and can be implemented as follows:
错误处理中间件是标准的,可以按如下方式实现:

app.UseExceptionHandler(exceptionHandlerApp =>
{
    exceptionHandlerApp.Run(async context =>
    {
        context.Response.StatusCode = StatusCodes.
        Status500InternalServerError;
        context.Response.ContentType = Application.Json;
        var exceptionHandlerPathFeature = context.Features.
          Get<IExceptionHandlerPathFeature>()!;
        var errorMessage = new
        {
            Message = exceptionHandlerPathFeature.Error.Message
        };
        await context.Response.WriteAsync
        (JsonSerializer.Serialize(errorMessage));
         if (exceptionHandlerPathFeature?.
             Error is FileNotFoundException)
         {
             await context.Response.
             WriteAsync(" The file was not found.");
         }
         if (exceptionHandlerPathFeature?.Path == "/")
         {
             await context.Response.WriteAsync("Page: Home.");
         }
    });
});

We have shown here a possible implementation of the middleware. In order to be implemented, the UseExceptionHandler method must be exploited, allowing the writing of management code for the whole application.
我们在这里展示了中间件的可能实现。为了实现,必须利用 UseExceptionHandler 方法,允许为整个应用程序编写管理代码。

Through the var functionality called exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>()!;, we can access the error stack and return the information of interest for the caller in the output:
通过名为 exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>()!;,我们可以访问错误堆栈并在输出中返回调用方感兴趣的信息:

app.MapGet("/ok-result", () =>
{
         throw new ArgumentNullException("taggia-parameter", 
         "Taggia has an error");
})
.WithName("OkResult");

When an exception occurs in the code, as in the preceding example, the middleware steps in and handles the return message to the client.
当代码中发生异常时,如前面的示例所示,中间件会介入并处理发送给客户端的返回消息。

If the exception were to occur in internal application stacks, the middleware would still intervene to provide the client with the correct error and appropriate indication.
如果内部应用程序堆栈中发生异常,中间件仍会进行干预,为客户端提供正确的错误和适当的指示。

Problem Details and the IETF standard
问题详细信息和 IETF 标准

Problem Details for HTTP APIs is an IETF standard that was approved in 2016. This standard allows a set of information to be returned to the caller with standard fields and JSON notations that help identify the error.
HTTP API 的问题详细信息是 2016 年批准的 IETF 标准。此标准允许使用标准字段和 JSON 表示法将一组信息返回给调用方,以帮助识别错误。

HTTP status codes are sometimes not enough to convey enough information about an error to be useful. While the humans behind web browsers can be informed about the nature of the problem with an HTML response body, non-human consumers, such as machine, PC, and server, of so-called HTTP APIs usually cannot.
HTTP 状态代码有时不足以传达有关错误的足够信息,因此没有用。虽然 Web 浏览器背后的人类可以通过 HTML 响应正文了解问题的性质,但所谓的 HTTP API 的非人类使用者(如机器、PC 和服务器)通常不能。

This specification defines simple JSON and XML document formats to suit this purpose. They are designed to be reused by HTTP APIs, which can identify distinct problem types specific to their needs.
此规范定义了简单的 JSON 和 XML 文档格式以适应此目的。它们旨在供 HTTP API 重用,HTTP API 可以识别特定于其需求的不同问题类型。

Thus, API clients can be informed of both the high-level error class and the finer-grained details of the problem (https://datatracker.ietf.org/doc/html/rfc7807).
因此,API 客户端可以了解高级错误类和问题的更细粒度的详细信息 (https://datatracker.ietf.org/doc/html/rfc7807)。

In .NET, there is a package with all the functionality that meets the IETF standard.
在 .NET 中,有一个包,其中包含满足 IETF 标准的所有功能。

The package is called Hellang.Middleware.ProblemDetails, and you can download it at the following address: https://www.nuget.org/packages/Hellang.Middleware.ProblemDetails/.
该包名为 Hellang.Middleware.ProblemDetails,您可以在以下地址下载:https://www.nuget.org/packages/Hellang.Middleware.ProblemDetails/

Let’s see now how to insert the package into the project and configure it:
现在让我们看看如何将包插入到项目中并对其进行配置:

var builder = WebApplication.CreateBuilder(args);
builder.Services.TryAddSingleton<IActionResultExecutor<ObjectResult>, ProblemDetailsResultExecutor>();
builder.Services.AddProblemDetails(options =>
{   options.MapToStatusCode<NotImplementedException>
    (StatusCodes.Status501NotImplemented);
});
var app = builder.Build();
app.UseProblemDetails();

As you can see, there are only two instructions to make this package work:
如您所见,只有两条说明可以使此软件包正常工作:

builder.Services.AddProblemDetails
app.UseProblemDetails();

Since, in the minimal APIs, the IActionResultExecutor interface is not present in the ASP.NET pipeline, it is necessary to add a custom class to handle the response in case of an error.
由于在最小 API 中,ASP.NET 管道中不存在 IActionResultExecutor 接口,因此有必要添加自定义类以在出现错误时处理响应。

To do this, you need to add a class (the following) and register it in the dependency injection engine: builder.Services.TryAddSingleton<IActionResultExecutor<ObjectResult>, ProblemDetailsResultExecutor>(); .
为此,您需要添加一个类(如下)并在依赖项注入引擎 builder 中注册它。 builder.Services.TryAddSingleton<IActionResultExecutor<ObjectResult>, ProblemDetailsResultExecutor>();。

Here is the class to support the package, also under minimal APIs:
以下是支持该包的类,也在最小 API 下:

public class ProblemDetailsResultExecutor : IActionResultExecutor<ObjectResult>
{
    public virtual Task ExecuteAsync(ActionContext context, 
    ObjectResult result)
{
        ArgumentNullException.ThrowIfNull(context);
        ArgumentNullException.ThrowIfNull(result);
        var executor = Results.Json(result.Value, null, 
        "application/problem+json", result.StatusCode);
        return executor.ExecuteAsync(context.HttpContext);
    }
}

As mentioned earlier, the standard for handling error messages has been present in the IETF standard for several years, but for the C# language, it is necessary to add the package just mentioned.
如前所述,处理错误消息的标准在 IETF 标准中已经存在了几年,但对于 C# 语言,有必要添加刚才提到的包。

Now, let’s see how this package goes about handling errors on some endpoints that we report here:
现在,让我们看看这个软件包如何处理我们在此处报告的某些端点上的错误:

app.MapGet("/internal-server-error", () =>
{
    throw new ArgumentNullException("taggia-parameter", 
    "Taggia has an error");
})
    .Produces<ProblemDetails>(StatusCodes.
     Status500InternalServerError)
         .WithName("internal-server-error");

We throw an application-level exception with this endpoint. In this case, the ProblemDetails middleware goes and returns a JSON error consistent with the error. We then have the handling of an unhandled exception for free:
我们使用此终端节点引发应用程序级异常。在这种情况下,ProblemDetails 中间件会返回与错误一致的 JSON 错误。然后,我们可以免费处理未处理的异常:

{
    "type": "https://httpstatuses.com/500",
    "title": "Internal Server Error",
    "status": 500,
    "detail": "Taggia has an error (Parameter 'taggia-
     parameter')",
    "exceptionDetails": [
         {
 ------- for brevity
         }
    ],
    "traceId": "00-f6ff69d6f7ba6d2692d87687d5be75c5-
     e734f5f081d7a02a-00"
}

By inserting additional configurations in the Program file, you can map some specific exceptions to HTTP errors. Here is an example:
通过在 Program 文件中插入其他配置,您可以将某些特定异常映射到 HTTP 错误。下面是一个示例:

builder.Services.AddProblemDetails(options =>
{
    options.MapToStatusCode<NotImplementedException>
      (StatusCodes.Status501NotImplemented);
});

The code with the NotImplementedException exception is mapped to HTTP error code 501:
具有 NotImplementedException 异常的代码映射到 HTTP 错误代码 501:

app.MapGet("/not-implemented-exception", () =>
{
    throw new NotImplementedException
      ("This is an exception thrown from a Minimal API.");
})
    .Produces<ProblemDetails>(StatusCodes.
     Status501NotImplemented)
         .WithName("NotImplementedExceptions");

Finally, it is possible to create extensions to the ProblemDetails class of the framework with additional fields or to call the base method by adding custom text.
最后,可以使用其他字段创建框架的 ProblemDetails 类的扩展,或者通过添加自定义文本来调用基方法。

Here are the last two examples of MapGet endpoint handlers:
以下是 MapGet 端点处理程序的最后两个示例:

app.MapGet("/problems", () =>
{
    return Results.Problem(detail: "This will end up in 
                                    the 'detail' field.");
})
    .Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
    .WithName("Problems");
app.MapGet("/custom-error", () =>
{
    var problem = new OutOfCreditProblemDetails
    {
        Type = "https://example.com/probs/out-of-credit",
        Title = "You do not have enough credit.",
        Detail = "Your current balance is 30, 
        but that costs 50.",
        Instance = "/account/12345/msgs/abc",
        Balance = 30.0m, Accounts = 
        { "/account/12345", "/account/67890" }
    };
    return Results.Problem(problem);
})
    .Produces<OutOfCreditProblemDetails>(StatusCodes.
     Status400BadRequest)
     .WithName("CreditProblems");
app.Run();
public class OutOfCreditProblemDetails : ProblemDetails
{
    public OutOfCreditProblemDetails()
    {
        Accounts = new List<string>();
    }
    public decimal Balance { get; set; }
    public ICollection<string> Accounts { get; }
}

Summary
总结

In this chapter, we have seen several advanced aspects regarding the implementation of minimal APIs. We explored Swagger, which is used to document APIs and provide the developer with a convenient, working debugging environment. We saw how CORS handles the issue of applications hosted on different addresses other than the current API. Finally, we saw how to load configuration information and handle unexpected errors in the application.
在本章中,我们了解了有关实现最小 API 的几个高级方面。我们探索了 Swagger,它用于记录 API,并为开发人员提供方便、有效的调试环境。我们了解了 CORS 如何处理托管在当前 API 以外的不同地址上的应用程序问题。最后,我们了解了如何加载配置信息和处理应用程序中的意外错误。

We explored the nuts and bolts that will allow us to be productive in a short amount of time.
我们探索了使我们能够在短时间内提高工作效率的具体细节。

In the next chapter, we will add a fundamental building block for SOLID pattern-oriented programming, namely the dependency injection engine, which will help us to better manage the application code scattered in the various layers.
在下一章中,我们将为 SOLID 面向模式的编程添加一个基本构建块,即依赖注入引擎,这将帮助我们更好地管理分散在各个层中的应用程序代码。

Part 2: What’s New in .NET 6?

第 2 部分:.NET 6 中的新增功能

In the second part of the book, we want to show you the features of the .NET 6 framework and how they can also be used in minimal APIs.
在本书的第二部分,我们想向你展示 .NET 6 框架的功能,以及如何在最小的 API 中使用它们。

We will cover the following chapters in this section:
在本节中,我们将介绍以下章节:

Chapter 4, Dependency Injection in a Minimal API Project
第 4 章 最小 API 项目中的依赖关系注入

Chapter 5, Using Logging to Identify Errors
第 5 章 使用日志记录识别错误

Chapter 6, Exploring Validation and Mapping
第 6 章 探索验证和映射

Chapter 7, Integration with the Data Access Layer
第 7 章 与数据访问层集成

4 Dependency Injection in a Minimal API Project

最小 API 项目中的依赖关系注入

In this chapter of the book, we will discuss some basic topics of minimal APIs in .NET 6.0. We will learn how they differ from the controller-based Web APIs that we were used to using in the previous version of .NET. We will also try to underline the pros and the cons of this new approach of writing APIs.
在本书的这一章中,我们将讨论 .NET 6.0 中最小 API 的一些基本主题。我们将了解它们与我们以前在 .NET 版本中习惯使用的基于控制器的 Web API 有何不同。我们还将尝试强调这种编写 API 的新方法的优缺点。

In this chapter, we will be covering the following topics:
在本章中,我们将介绍以下主题:

• What is dependency injection?
什么是依赖项注入?

• Implementing dependency injection in a minimal API project
在最小 API 项目中实现依赖关系注入

Technical requirements
技术要求

To follow the explanations in this chapter, you will need to create an ASP.NET Core 6.0 Web API application. You can refer the Technical requirements section of Chapter 2, Exploring Minimal APIs and Their Advantages to know how to do it.
要按照本章中的说明进行作,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。您可以参考 第 2 章 探索最小 API 及其优势 的技术要求 部分来了解如何作。

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter04.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter04

What is dependency injection?
什么是依赖项注入?

For a while, .NET has natively supported the dependency injection (often referred to as DI) software design pattern.
一段时间以来,.NET 本身就支持依赖关系注入(通常称为 DI)软件设计模式。

Dependency injection is a way to implement in .NET the Inversion of Control (IoC) pattern between service classes and their dependencies. By the way, in .NET, many fundamental services are built with dependency injection, such as logging, configuration, and other services.
依赖项注入是在 .NET 中实现服务类及其依赖项之间的控制反转 (IoC) 模式的一种方式。顺便说一句,在 .NET 中,许多基本服务都是通过依赖项注入构建的,例如日志记录、配置和其他服务。

Let’s look at a practical example to get a good understanding of how it works.
让我们看一个实际示例,以更好地理解它是如何工作的。

Generally speaking, a dependency is an object that depends on another object. In the following example, we have a LogWriter class with only one method inside, called Log:
一般来说,依赖项是依赖于另一个对象的对象。在下面的示例中,我们有一个 LogWriter 类,其中只有一个方法,称为 Log:

public class LogWriter
{
    public void Log(string message)
    {
        Console.WriteLine($"LogWriter.Write
          (message: \"{message}\")");
    }
}

Other classes in the project, or in another project, can create an instance of the LogWriter class and use the Log method.
项目或其他项目中的其他类可以创建 LogWriter 类的实例并使用 Log 方法。

Take a look at the following example:
请看以下示例:

public class Worker
{
    private readonly LogWriter _logWriter = new LogWriter();
    protected async Task ExecuteAsync(CancellationToken 
                                      stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logWriter.Log($"Worker running at: 
             {DateTimeOffset.Now}");
             await Task.Delay(1000, stoppingToken);
        }
    }
}

This class depends directly on the LogWriter class, and it’s hardcoded in each class of your projects.
此类直接依赖于 LogWriter 类,并且在项目的每个类中都是硬编码的。

This means that you will have some issues if you want to change the Log method; for instance, you will have to replace the implementation in each class of your solution.
这意味着,如果要更改 Log 方法,您将遇到一些问题;例如,您必须替换解决方案的每个类中的 implementation。

The preceding implementation has some issues if you want to implement unit tests in your solution. It’s not easy to create a mock of the LogWriter class.
如果要在解决方案中实现单元测试,前面的实现存在一些问题。创建 LogWriter 类的 mock 并不容易。

Dependency injection can solve these problems with some changes in our code:
依赖项注入可以通过对代码进行一些更改来解决这些问题:

  1. Use an interface to abstract the dependency.
    使用接口抽象依赖项。

  2. Register the dependency injection in the built-in service connecte to .NET.
    在内置服务 connecte to .NET 中注册依赖项注入。

  3. Inject the service into the constructor of the class.
    将服务注入到类的构造函数中。

The preceding things might seem like they require big change in your code, but they are very easy to implement.
上述内容似乎需要对代码进行大量更改,但它们很容易实现。

Let’s see how we can achieve this goal with our previous example:
让我们看看如何通过前面的示例来实现这个目标:

  1. First, we will create an ILogWriter interface with the abstraction of our logger:
    public interface ILogWriter
    首先,我们将使用记录器的抽象创建一个 ILogWriter 接口:
{
    void Log(string message);
}
  1. Next, implement this ILogWriter interface in a real class called ConsoleLogWriter:
    public class ConsoleLogWriter : ILogWriter
    接下来,在名为 ConsoleLogWriter 的实际类中实现此 ILogWriter 接口:

    {
    public void Log(string message)
    {
        Console.WriteLine($"ConsoleLogWriter.
        Write(message: \"{message}\")");
    }
    }
  2. Now, change the Worker class and replace the explicit LogWriter class with the new ILogWriter interface:

现在,更改 Worker 类,并将显式 LogWriter 类替换为新的 ILogWriter 接口:

public class Worker
{
    private readonly ILogWriter _logWriter;
    public Worker(ILogWriter logWriter)
    {
        _logWriter = logWriter;
    }

    protected async Task ExecuteAsync
      (CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logWriter.Log($"Worker running at:
                             {DateTimeOffset.Now}");
             await Task.Delay(1000, stoppingToken);
        }
    }
}

As you can see, it’s very easy to work in this new way, and the advantages are substantial. Here are a few advantages of dependency injection:
如您所见,以这种新方式工作非常容易,而且优势非常大。以下是依赖项注入的一些优点:

• Maintainability 可维护性
• Testability 测试
• Reusability 可重用

Now we need to perform the last step, that is, register the dependency when the application starts up.
现在我们需要执行最后一步,即在应用程序启动时注册依赖项。

4.At the top of the Program.cs file, add this line of code:
在 Program.cs 文件的顶部,添加以下代码行:

builder.Services.AddScoped<ILogWriter, ConsoleLogWriter>();

In the next section, we will discuss the difference between dependency injection lifetimes, another concept that you need to understand before using dependency injection in your minimal API project.
在下一节中,我们将讨论依赖注入生命周期之间的区别,这是在最小 API 项目中使用依赖注入之前需要了解的另一个概念。

Understanding dependency injection lifetimes
了解依赖关系注入生命周期

In the previous section, we learned the benefits of using dependency injection in our project and how to transform our code to use it.
在上一节中,我们了解了在项目中使用依赖项注入的好处,以及如何转换代码以使用它。

In one of the last paragraphs, we added our class as a service to ServiceCollection of .NET.
在最后一段中,我们将类作为服务添加到 .NET 的 ServiceCollection 中。

In this section, we will try to understand the difference between each dependency injection’s lifetime.
在本节中,我们将尝试了解每个依赖注入的生命周期之间的差异。

The service lifetime defines how long an object will be alive after it has been created by the container.
服务生存期定义对象在容器创建后将处于活动状态的时间。

When they are registered, dependencies require a lifetime definition. This defines the conditions when a new service instance is created.
注册依赖项时,它们需要生命周期定义。这定义了创建新服务实例时的条件。

In the following list, you can find the lifetimes defined in .NET:
在以下列表中,您可以找到 .NET 中定义的生存期:

• Transient: A new instance of the class is created every time it is requested.
Transient:每次请求时都会创建类的新实例。

• Scoped: A new instance of the class is created once per scope, for instance, for the same HTTP request.
范围:每个范围创建一次类的新实例,例如,针对同一 HTTP 请求。

• Singleton: A new instance of the class is created only on the first request. The next request will use the same instance of the same class.
Singleton:仅在第一个请求时创建类的新实例。下一个请求将使用同一类的相同实例。

Very often, in web applications, you only find the first two lifetimes, that is, transient and scoped.
很多时候,在 Web 应用程序中,你只能找到前两个生命周期,即 transient 和 scoped。

If you have a particular use case that requires a singleton, it’s not prohibited, but for best practice, it is recommended to avoid them in web applications.
如果您有需要单例的特定用例,则不禁止这样做,但为了最佳实践,建议在 Web 应用程序中避免使用它们。

In the first two cases, transient and scoped, the services are disposed of at the end of the request.
在前两种情况中,transient 和 scoped,服务将在请求结束时被释放。

In the next section, we will see how to implement all the concepts that we have mentioned in the last two sections (the definition of dependency injection and its lifetime) in a short demo that you can use as a starting point for your next project.
在下一节中,我们将通过一个简短的演示来了解如何实现我们在最后两节中提到的所有概念(依赖注入的定义及其生命周期),您可以将其用作下一个项目的起点。

Implementing dependency injection in a minimal API project
在最小 API 项目中实现依赖关系注入

After understanding how to use dependency injection in an ASP.NET Core project, let’s try to understand how to use dependency injection in our minimal API project, starting with the default project using the WeatherForecast endpoint.
在了解了如何在 ASP.NET Core 项目中使用依赖项注入之后,让我们尝试了解如何在最小 API 项目中使用依赖项注入,从使用 WeatherForecast 端点的默认项目开始。

This is the actual code of the WeatherForecast GET endpoint:
这是 WeatherForecast GET 端点的实际代码:

app.MapGet("/weatherforecast", () =>
{
    var forecast = Enumerable.Range(1, 5).Select(index =>
    new WeatherForecast
    (
        DateTime.Now.AddDays(index),
        Random.Shared.Next(-20, 55),
        summaries[Random.Shared.
        Next(summaries.Length)]
    ))
    .ToArray();
    return forecast;
});

As we mentioned before, this code works but it’s not easy to test it, especially the creation of the new values of the weather.
正如我们之前提到的,这段代码可以工作,但并不容易测试它,尤其是创建 weather 的新值。

The best choice is to use a service to create fake values and use it with dependency injection.
最好的选择是使用服务创建假值并将其与依赖项注入一起使用。

Let’s see how we can better implement our code:
让我们看看如何更好地实现我们的代码:

  1. First of all, in the Program.cs file, add a new interface called IWeatherForecastService and define a method that returns an array of the WeatherForecast entity:
    首先,在 Program.cs 文件中,添加一个名为 IWeatherForecastService 的新接口,并定义一个返回 WeatherForecast 实体数组的方法:
public interface IWeatherForecastService
{
           WeatherForecast[] GetForecast();
}
  1. The next step is to create the real implementation of the class inherited from the interface.
    下一步是创建从接口继承的类的真正实现。

The code should look like this:
代码应如下所示:

public class WeatherForecastService : IWeatherForecastService
{
}
  1. Now cut and paste the code from the project template inside our new implementation of the service. The final code looks like this:
    现在,将项目模板中的代码剪切并粘贴到我们新的服务实现中。最终代码如下所示:

    public class WeatherForecastService : IWeatherForecastService
    {
    public WeatherForecast[] GetForecast()
    {
        var summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool",
            "Mild", "Warm", "Balmy", "Hot", "Sweltering",
            "Scorching"
        };
        var forecast = Enumerable.Range(1, 5).
        Select(index =>
        new WeatherForecast
        (
            DateTime.Now.AddDays(index),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next
            (summaries.Length)]
        ))
        .ToArray();
        return forecast;
    }
    }
  2. We are now ready to add our implementation of WeatherForecastService as a dependency injection in our project. To do that, insert the following line below the first line of code in the Program.cs file:
    现在,我们已准备好将 WeatherForecastService 的实现作为依赖项注入添加到我们的项目中。为此,请在 Program.cs 文件中的第一行代码下方插入以下行:

builder.Services.AddScoped<IWeatherForecastService, WeatherForecastService>();

When the application starts, insert our service into the services collection. Our work is not finished yet.
当应用程序启动时,将我们的服务插入到服务集合中。我们的工作还没有完成。

We need to use our service in the default MapGet implementation of the WeatherForecast endpoint.
我们需要在 WeatherForecast 端点的默认 MapGet 实现中使用我们的服务。

The minimal API has his own parameter binding implementation and it’s very easy to use.
最小的 API 有自己的参数绑定实现,非常易于使用。

First of all, to implement our service with dependency injection, we need to remove all the old code from the endpoint.
首先,要使用依赖项注入实现我们的服务,我们需要从端点中删除所有旧代码。

The code of the endpoint, after removing the code, looks like this:
删除代码后,端点的代码如下所示:

app.MapGet("/weatherforecast", () =>
{
});

We can improve our code and use the dependency injection very easily by simply replacing the old code with the new code:
我们可以通过简单地将旧代码替换为新代码来非常轻松地改进我们的代码并使用依赖注入:

app.MapGet("/weatherforecast", (IWeatherForecastService weatherForecastService) =>
{
    return weatherForecastService.GetForecast();
});

In the minimal API project, the real implementations of the services in the service collection are passed as parameters to the functions and you can use them directly.
在最小 API 项目中,服务集合中服务的真实实现作为参数传递给函数,您可以直接使用它们。

From time to time, you may have to use a service from the dependency injection directly in the main function during the startup phase. In this case, you must retrieve the instance of the implementation directly from the services collection, as shown in the following code snippet:
有时,您可能必须在启动阶段直接在 main 函数中使用依赖项注入中的服务。在这种情况下,您必须直接从 services 集合中检索实现的实例,如以下代码片段所示:

using (var scope = app.Services.CreateScope())
{
    var service = scope.ServiceProvider.GetRequiredService
                  <IWeatherForecastService>();
    service.GetForecast();
}

In this section, we have implemented dependency injection in a minimal API project, starting from the default template.
在本节中,我们从默认模板开始,在最小 API 项目中实现了依赖注入。

We reused the existing code but implemented it with logic that’s more geared toward an architecture that’s better suited to being maintained and tested in the future.
我们重用了现有代码,但使用更适合将来维护和测试的架构的逻辑来实现它。

Summary
总结

Dependency injection is a very important approach to implement in modern applications. In this chapter, we learned what dependency injection is and discussed its fundamentals. Then, we saw how to use dependency injection in a minimal API project.
依赖项注入是在现代应用程序中实现的一种非常重要的方法。在本章中,我们了解了什么是依赖注入并讨论了它的基础知识。然后,我们了解了如何在最小 API 项目中使用依赖注入。

In the next chapter, we will focus on another important layer of modern applications and discuss how to implement a logging strategy in a minimal API project.
在下一章中,我们将重点介绍现代应用程序的另一个重要层,并讨论如何在最小的 API 项目中实现日志记录策略。

5 Using Logging to Identify Errors

5 使用日志记录识别错误

In this chapter, we will begin to learn about the logging tools that .NET provides us with. A logger is one of the tools that developers must use to debug an application or understand its failure in production. The log library has been built into ASP.NET with several features enabled by design. The purpose of this chapter is to delve into the things we take for granted and add more information as we go.
在本章中,我们将开始了解 .NET 为我们提供的日志记录工具。记录器是开发人员用来调试应用程序或了解其在生产中的故障时必须使用的工具之一。日志库已内置于 ASP.NET 中,通过设计启用了多项功能。本章的目的是深入研究我们认为理所当然的事情,并在此过程中添加更多信息。

The themes we will touch on in this chapter are as follows:
我们将在本章中讨论的主题如下:

• Exploring logging in .NET
探索 .NET 中的日志记录

• Leveraging the logging framework
利用日志记录框架

• Storing a structured log with Serilog
使用 Serilog 存储结构化日志

Technical requirements
技术要求

As reported in the previous chapters, it will be necessary to have the .NET 6 development framework.
如前几章所述,有必要具有 .NET 6 开发框架。

There are no special requirements in this chapter for beginning to test the examples described.
本章中没有对开始测试所描述的示例的特殊要求。

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter05.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter05

Exploring logging in .NET
探索 .NET 中的日志记录

ASP.NET Core templates create a WebApplicationBuilder and a WebApplication, which provide a simplified way to configure and run web applications without a startup class.
ASP.NET Core 模板创建 WebApplicationBuilder 和 WebApplication,它们提供了一种无需启动类即可配置和运行 Web 应用程序的简化方法。

As mentioned previously, with .NET 6, the Startup.cs file is eliminated in favor of the existing Program.cs file. All startup configurations are placed in this file, and in the case of minimal APIs, endpoint implementations are also placed.
如前所述,在 .NET 6 中,Startup.cs 文件被消除,取而代之的是现有的 Program.cs 文件。所有启动配置都放置在此文件中,对于最小的 API,还会放置端点实现。

What we have just described is the starting point of every .NET application and its various configurations.
我们刚才描述的是每个 .NET 应用程序及其各种配置的起点。

Logging into an application means tracking the evidence in different points of the code to check whether it is running as expected. The purpose of logging is to track over time all the conditions that led to an unexpected result or event in the application. Logging in an application can be useful both during development and while the application is in production.
登录到应用程序意味着跟踪代码不同点的证据,以检查它是否按预期运行。日志记录的目的是随着时间的推移跟踪导致应用程序中出现意外结果或事件的所有条件。在开发期间和应用程序处于生产状态时,登录应用程序都非常有用。

However, for logging, as many as four providers are added for tracking application information:
但是,对于日志记录,将添加多达四个提供程序来跟踪应用程序信息:

• Console: The Console provider logs output to the console. This log is unusable in production because the console of a web application is usually not visible. This kind of log is useful during development to make logging fast when you are running your app under Kestrel on your desktop machine in the app console window.
控制台:控制台提供程序将输出记录到控制台。此日志在生产中不可用,因为 Web 应用程序的控制台通常不可见。在开发过程中,这种日志非常有用,当您在应用程序控制台窗口中的桌面计算机上的 Kestrel 下运行应用程序时,可以快速进行日志记录。

• Debug: The Debug provider writes log output by using the System.Diagnostics.Debug class. When we develop, we are used to seeing this section in the Visual Studio output window.
调试:调试提供程序使用 System.Diagnostics.Debug 类写入日志输出。在开发时,我们习惯于 Visual Studio 输出窗口中看到此部分。

Under the Linux operating system, information is tracked depending on the distribution in the following locations: /var/log/message and /var/log/syslog.
在 Linux作系统下,根据以下位置的分发情况跟踪信息:/var/log/message 和 /var/log/syslog。

• EventSource: On Windows, this information can be viewed in the EventTracing window.
EventSource:在 Windows 上,可以在 EventTracing 窗口中查看此信息。

• EventLog (only when running on Windows): This information is displayed in the native Windows window, so you can only see it if you run the application on the Windows operating system.
EventLog (仅在 Windows 上运行时):此信息显示在本机 Windows 窗口中,因此只有在 Windows作系统上运行应用程序时才能看到它。

A new feature in the latest .NET release
最新 .NET 版本中的新功能

New logging providers have been added in the latest versions of .NET. However, these providers are not enabled within the framework.
最新版本的 .NET 中添加了新的日志记录提供程序。但是,这些提供程序未在框架内启用。

Use these extensions to enable new logging scenarios: AddSystemdConsole, AddJsonConsole, and AddSimpleConsole.
使用以下扩展启用新的日志记录方案:AddSystemdConsole、AddJsonConsole 和 AddSimpleConsole。

You can find more details on how to configure the log and what the basic ASP.NET settings are at this link: https://docs.microsoft.com/aspnet/core/fundamentals/host/generic-host.
您可以在以下链接中找到有关如何配置日志以及基本 ASP.NET 设置的更多详细信息:https://docs.microsoft.com/aspnet/core/fundamentals/host/generic-host

We’ve started to see what the framework gives us; now we need to understand how to leverage it within our applications. Before proceeding, we need to understand what a logging layer is. It is a fundamental concept that will help us break down information into different layers and enable them as needed:
我们已经开始看到框架给我们带来了什么;现在我们需要了解如何在我们的应用程序中利用它。在继续之前,我们需要了解什么是日志层。这是一个基本概念,可帮助我们将信息分解为不同的层并根据需要启用它们:

Table 5.1 – Log levels
表 5.1 – 日志级别

Table 5.1 shows the most verbose levels down to the least verbose level.
表 5.1 显示了最详细的级别到最不详细的级别。

To learn more, you can read the article titled Logging in .NET Core and ASP.NET Core, which explains the logging process in detail here: https://docs.microsoft.com/aspnet/core/fundamentals/logging/.
若要了解详细信息,可以阅读标题为“在 .NET Core 和 ASP.NET Core 中登录”的文章,其中详细介绍了日志记录过程:https://docs.microsoft.com/aspnet/core/fundamentals/logging/

If we select our log level as Information, everything at this level will be tracked down to the Critical level, skipping Debug and Trace.
如果我们将日志级别选为 Information,则此级别的所有内容都将被跟踪到 Critical 级别,跳过 Debug 和 Trace。

We’ve seen how to take advantage of the log layers; now, let’s move on to writing a single statement that will log information and can allow us to insert valuable content into the tracking system.
我们已经看到了如何利用日志层;现在,让我们继续编写一个语句,该语句将记录信息,并允许我们将有价值的内容插入到跟踪系统中。

Configuring logging
配置日志记录

To start using the logging component, you need to know a couple of pieces of information to start tracking data. Each logger object (ILogger<T>) must have an associated category. The log category allows you to segment the tracking layer with a high definition. For example, if we want to track everything that happens in a certain class or in an ASP.NET controller, without having to rewrite all our code, we need to enable the category or categories of our interest.
要开始使用 logging 组件,您需要了解一些信息才能开始跟踪数据。每个记录器对象 (ILogger<T>) 必须具有关联的类别。日志类别允许您对高清晰度的跟踪层进行分段。例如,如果我们想跟踪某个类或 ASP.NET 控制器中发生的所有事情,而不必重写所有代码,我们需要启用我们感兴趣的一个或多个类别。

A category is a T class. Nothing could be simpler. You can reuse typed objects of the class where the log method is injected. For example, if we’re implementing MyService, and we want to track everything that happens in the service with the same category, we just need to request an ILogger<MyService> object instance from the dependency injection engine.
类别是 T 类。没有比这更简单的了。您可以重用注入 log 方法的类的类型化对象。例如,如果我们正在实现 MyService,并且想要跟踪具有相同类别的服务中发生的所有事情,则只需从依赖项注入引擎请求 ILogger<MyService> 对象实例。

Once the log categories are defined, we need to call the ILogger<T> object and take advantage of the object’s public methods. In the previous section, we looked at the log layers. Each log layer has its own method for tracking information. For example, LogDebug is the method specified to track information with a Debug layer.
定义日志类别后,我们需要调用 ILogger<T> 对象并利用该对象的公共方法。在上一节中,我们了解了日志层。每个日志层都有自己的跟踪信息方法。例如,LogDebug 是指定用于使用 Debug 层跟踪信息的方法。

Let’s now look at an example. I created a record in the Program.cs file:
现在让我们看一个示例。我在 Program.cs 文件中创建了一条记录:

internal record CategoryFiltered();

This record is used to define a particular category of logs that I want to track only when necessary. To do this, it is advisable to define a class or a record as an end in itself and enable the necessary trace level.
此记录用于定义我只想在必要时跟踪的特定日志类别。为此,建议将类或记录定义为其本身的 end,并启用必要的跟踪级别。

A record that is defined in the Program.cs file has no namespace; we must remember this when we define the appsettings file with all the necessary information.
在 Program.cs 文件中定义的记录没有命名空间;当我们使用所有必要的信息定义 AppSettings 文件时,我们必须记住这一点。

If the log category is within a namespace, we must consider the full name of the class. In this case, it is LoggingSamples.Categories.MyCategoryAlert:
如果日志类别位于命名空间内,则必须考虑类的全名。在本例中,它是 LoggingSamples.Categories.MyCategoryAlert:

namespace LoggingSamples.Categories
{
    public class MyCategoryAlert
    {
    }
}

If we do not specify the category, as in the following example, the selected log level is the default:
如果我们不指定类别,如以下示例所示,则所选日志级别为默认日志级别:

  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "CategoryFiltered": "Information",
      "LoggingSamples.Categories.MyCategoryAlert": "Debug"
    }
  }

Anything that comprises infrastructure logs, such as Microsoft logs, stays in special categories such as Microsoft.AspNetCore or Microsoft.EntityFrameworkCore.
构成基础结构日志的任何内容(如 Microsoft 日志)都属于特殊类别,如 Microsoft.AspNetCore 或 Microsoft.EntityFrameworkCore。

The full list of Microsoft log categories can be found at the following link:
Microsoft 日志类别的完整列表可在以下链接中找到:
https://docs.microsoft.com/aspnet/core/fundamentals/logging/#aspnet-core-and-ef-core-categories

Sometimes, we need to define certain log levels depending on the tracking provider. For example, during development, we want to see all the information in the log console, but we only want to see errors in the log file.
有时,我们需要根据跟踪提供商定义某些日志级别。例如,在开发过程中,我们希望在日志控制台中看到所有信息,但我们只想在日志文件中看到错误。

To do this, we don’t need to change the configuration code but just define its level for each provider. The following is an example that shows how everything that is tracked in the Microsoft categories is shown from the Information layer to the ones below it:
为此,我们不需要更改配置代码,只需为每个提供程序定义其级别。以下示例显示了如何从信息层向其下方的 Microsoft 类别中跟踪的所有内容显示:

{
  "Logging": {      // Default, all providers.
    "LogLevel": {
      "Microsoft": "Warning"
    },
    "Console": { // Console provider.
      "LogLevel": {
        "Microsoft": "Information"
      }
    }
  }
}

Now that we’ve figured out how to enable logging and how to filter the various categories, all that’s left is to apply this information to a minimal API.
现在我们已经弄清楚了如何启用日志记录以及如何筛选各种类别,剩下的工作就是将此信息应用于最小的 API。

In the following code, we inject two ILogger instances with different categories. This is not a common practice, but we did it to make the example more concrete and show how the logger works:
在下面的代码中,我们注入了两个不同类别的 ILogger 实例。这不是一种常见的做法,但我们这样做是为了使示例更加具体并展示 Logger 的工作原理:

app.MapGet("/first-log", (ILogger<CategoryFiltered> loggerCategory, ILogger<MyCategoryAlert> loggerAlertCategory) =>
{
    loggerCategory.LogInformation("I'm information 
      {MyName}", "My Name Information");
    loggerAlertCategory.LogInformation("I'm information
      {MyName}", "Alert Information");
    return Results.Ok();
})
.WithName("GetFirstLog");

In the preceding snippet, we inject two instances of the logger with different categories; each category tracks a single piece of information. The information is written according to a template that we will describe shortly. The effect of this example is that based on the level, we can show or disable the information displayed for a single category, without changing the code.
在前面的代码段中,我们注入了两个不同类别的 Logger 实例;每个类别跟踪一条信息。该信息是根据我们稍后将介绍的模板编写的。此示例的效果是,根据级别,我们可以显示或禁用为单个类别显示的信息,而无需更改代码。

We started filtering the logo by levels and categories. Now, we want to show you how to define a template that will allow us to define a message and make it dynamic in some of its parts.
我们开始按级别和类别过滤徽标。现在,我们想向您展示如何定义一个模板,该模板将允许我们定义消息并使其在某些部分中是动态的。

Customizing log message
自定义日志消息

The message field that is asked by the log methods is a simple string object that we can enrich and serialize through the logging frameworks in proper structures. The message is therefore essential to identify malfunctions and errors, and inserting objects in it can significantly help us to identify the problem:
log 方法询问的 message 字段是一个简单的字符串对象,我们可以通过日志记录框架以适当的结构对其进行扩充和序列化。因此,该消息对于识别故障和错误至关重要,在其中插入对象可以显着帮助我们识别问题:

string apples = "apples";
string pears = "pears";
string bananas = "bananas";
logger.LogInformation("My fruit box has: {pears}, {bananas}, {apples}", apples, pears, bananas);

The message template contains placeholders that interpolate content into the textual message.
消息模板包含将内容插入到文本消息中的占位符。

In addition to the text, it is necessary to pass the arguments to replace the placeholders. Therefore, the order of the parameters is valid but not the name of the placeholders for the substitution.
除了文本之外,还需要传递参数来替换占位符。因此,参数的顺序有效,但替换的占位符名称无效。

The result then considers the positional parameters and not the placeholder names:
然后,结果会考虑位置参数,而不是占位符名称:

My fruit box has: apples, pears, bananas

Now you know how to customize log messages. Next, let us learn about infrastructure logging, which is essential while working in more complex scenarios.
现在您知道如何自定义日志消息了。接下来,让我们了解一下基础设施日志记录,这在更复杂的场景中工作时是必不可少的。

Infrastructure logging
基础设施日志记录

In this section, we want to tell you about a little-known and little-used theme within ASP.NET applications: the W3C log.
在本节中,我们想向您介绍 ASP.NET 应用程序中一个鲜为人知且很少使用的主题:W3C 日志。

This log is a standard that is used by all web servers, not only Internet Information Services (IIS). It also works on NGINX and many other web servers and can be used on Linux, too. It is also used to trace various requests. However, the log cannot understand what happened inside the call.
此日志是所有 Web 服务器都使用的标准,而不仅仅是 Internet Information Services (IIS)。它也适用于 NGINX 和许多其他 Web 服务器,也可以在 Linux 上使用。它还用于跟踪各种请求。但是,日志无法理解调用中发生的情况。

Thus, this feature focuses on the infrastructure, that is, how many calls are made and to which endpoint.
因此,此功能侧重于基础设施,即进行多少次调用以及调用到哪个终端节点。

In this section, we will see how to enable tracking, which, by default, is stored on a file. The functionality takes a little time to find but enables more complex scenarios that must be managed with appropriate practices and tools, such as OpenTelemetry.
在本节中,我们将了解如何启用跟踪,默认情况下,跟踪存储在文件中。该功能需要一点时间才能找到,但支持更复杂的场景,这些场景必须使用适当的实践和工具(如 OpenTelemetry)进行管理。

OpenTelemetry
开放遥测

OpenTelemetry is a collection of tools, APIs, and SDKs. We use it to instrument, generate, collect, and export telemetry data (metrics, logs, and traces) to help analyze software performance and behavior. You can learn more at the OpenTelemetry official website: https://opentelemetry.io/.
OpenTelemetry 是工具、API 和 SDK 的集合。我们使用它来检测、生成、收集和导出遥测数据(指标、日志和跟踪),以帮助分析软件性能和行为。您可以在 OpenTelemetry 官方网站上了解更多信息: https://opentelemetry.io/.

To configure W3C logging, you need to register the AddW3CLogging method and configure all available options.
要配置 W3C 日志记录,您需要注册 AddW3CLogging 方法并配置所有可用选项。

To enable logging, you only need to add UseW3CLogging.
要启用日志记录,您只需添加 UseW3CLogging。

The writing of the log does not change; the two methods enable the scenario just described and start writing data to the W3C log standard:
日志的写入不会改变;这两种方法启用刚才描述的方案并开始将数据写入 W3C 日志标准:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddW3CLogging(logging =>
{
    logging.LoggingFields = W3CLoggingFields.All;
});
var app = builder.Build();
app.UseW3CLogging();
app.MapGet("/first-w3c-log", (IWebHostEnvironment webHostEnvironment) =>
{
    return Results.Ok(new { PathToWrite = 
      webHostEnvironment.ContentRootPath });
})
.WithName("GetW3CLog");

We report the header of the file that is created (the headers of the information will be tracked later):
我们报告所创建文件的标题(稍后将跟踪信息的标题):

#Version: 1.0
#Start-Date: 2022-01-03 10:34:15
#Fields: date time c-ip cs-username s-computername s-ip s-port cs-method cs-uri-stem cs-uri-query sc-status time-taken cs-version cs-host cs(User-Agent) cs(Cookie) cs(Referer)

We’ve seen how to track information about the infrastructure hosting our application; now, we want to increase log performance with new features in .NET 6 that help us set up standard log messages and avoid errors.
我们已经了解了如何跟踪有关托管应用程序的基础设施的信息;现在,我们希望通过 .NET 6 中的新功能来提高日志性能,这些功能可以帮助我们设置标准日志消息并避免错误。

Source generators
源生成器

One of the novelties of .NET 6 is the source generators; they are performance optimization tools that generate executable code at compile time. The creation of executable code at compile time, therefore, generates an increase in performance. During the execution phase of the program, all structures are comparable to code written by the programmer before compilation.
.NET 6 的新颖之处之一是源生成器;它们是在编译时生成可执行代码的性能优化工具。因此,在编译时创建可执行代码会提高性能。在程序的执行阶段,所有结构都与程序员在编译前编写的代码相当。

String interpolation using $”” is generally great, and it makes for much more readable code than string.Format(), but you should almost never use it when writing log messages:
使用 $“” 的字符串插值通常很棒,并且它使代码比 string 更具可读性。Format(),但在编写日志消息时几乎不应该使用它:

logger.LogInformation($"I'm {person.Name}-{person.Surname}")

The output of this method to the Console will be the same when using string interpolation or structural logging, but there are several problems:
使用字符串插值或结构日志记录时,此方法对 Console 的输出将相同,但存在几个问题:

• You lose the structured logs and you won’t be able to filter by the format values or archive the log message in the custom field of NoSQL products.
您将丢失结构化日志,并且无法按格式值进行筛选,也无法在 NoSQL 产品的自定义字段中存档日志消息。

• Similarly, you no longer have a constant message template to find all identical logs.
同样,您不再有固定的消息模板来查找所有相同的日志。

• The serialization of the person is done ahead of time before the string is passed into LogInformation.
将字符串传递到 LogInformation 之前,会提前完成人员的序列化。

• The serialization is done even though the log filter is not enabled. To avoid processing the log, it is necessary to check whether the layer is active, which would make the code much less readable.
即使未启用日志过滤器,也会完成序列化。为避免处理日志,有必要检查该层是否处于活动状态,这将使代码的可读性大大降低。

Let us say you decide to update the log message to include Age to clarify why the log is being written:
假设您决定更新日志消息以包含 Age 以阐明写入日志的原因:

logger.LogInformation("I'm {Name}-{Surname} with {Age}", person.Name, person.Surname);

In the previous code snippet, I added Age in the message template but not in the method signature. At compile time, there is no compile-time error, but when this line is executed, an exception is thrown due to the lack of a third parameter.
在前面的代码段中,我在消息模板中添加了 Age,但没有在方法签名中添加。在编译时,没有编译时错误,但是当执行此行时,由于缺少第三个参数,会引发异常。

LoggerMessage in .NET 6 comes to our rescue, automatically generating the code to log the necessary data. The methods will require the correct number of parameters and the text will be formatted in a standard way.
.NET 6 中的 LoggerMessage 可以帮我们忙,自动生成代码来记录必要的数据。这些方法将需要正确数量的参数,并且文本将以标准方式格式化。

To use the LoggerMessage syntax, you can take advantage of a partial class or a static class. Inside the class, it will be possible to define the method or methods with all the various log cases:
要使用 LoggerMessage 语法,您可以利用分部类或静态类。在类中,可以使用所有不同的日志情况定义一个或多个方法:

public partial class LogGenerator
    {
        private readonly ILogger<LogGeneratorCategory> 
          _logger;
        public LogGenerator(ILogger<LogGeneratorCategory>
          logger)
        {
            _logger = logger;
        }
        [LoggerMessage(
            EventId = 100,
            EventName = "Start",
            Level = LogLevel.Debug,
            Message = "Start Endpoint: {endpointName} with
              data {dataIn}")]
        public partial void StartEndpointSignal(string 
          endpointName, object dataIn);
        [LoggerMessage(
           EventId = 101,
           EventName = "StartFiltered",
           Message = "Log level filtered: {endpointName} 
             with data {dataIn}")]
        public partial void LogLevelFilteredAtRuntime(
          LogLevel, string endpointName, object dataIn);
    }
    public class LogGeneratorCategory { }

In the previous example, we created a partial class, injected the logger and its category, and implemented two methods. The methods are used in the following code:
在前面的示例中,我们创建了一个分部类,注入了 Logger 及其类别,并实现了两个方法。这些方法在以下代码中使用:

app.MapPost("/start-log", (PostData data, LogGenerator logGenerator) =>
{
    logGenerator.StartEndpointSignal("start-log", data);
    logGenerator.LogLevelFilteredAtRuntime(LogLevel.Trace,
      "start-log", data);
})
.WithName("StartLog");
internal record PostData(DateTime Date, string Name);

Notice how in the second method, we also have the possibility to define the log level at runtime.
请注意,在第二种方法中,我们还可以在运行时定义日志级别。

Behind the scenes, the [LoggerMessage] source generator generates the LoggerMessage.Define() code to optimize your method call. The following output shows the generated code:
在后台,[LoggerMessage] 源生成器会生成 LoggerMessage.Define() 代码来优化方法调用。以下输出显示了生成的代码:

[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "6.0.5.2210")]
        public partial void LogLevelFilteredAtRuntime(
          global::Microsoft.Extensions.Logging.LogLevel 
          logLevel, global::System.String endpointName,
          global::System.Object dataIn)
        {
            if (_logger.IsEnabled(logLevel))
            {
                _logger.Log(
                    logLevel,
                    new global::Microsoft.Extensions.
                     Logging.EventId(101, "StartFiltered"),
                    new __LogLevelFilteredAtRuntimeStruct(
                      endpointName, dataIn),
                    null,
                      __LogLevelFilteredAtRuntimeStruct.
                          Format);
            }
        }

In this section, you have learned about some logging providers, different log levels, how to configure them, what parts of the message template to modify, enabling logging, and the benefits of source generators. In the next section, we will focus more on logging providers.
在本节中,您了解了一些日志记录提供程序、不同的日志级别、如何配置它们、要修改消息模板的哪些部分、启用日志记录以及源生成器的好处。在下一节中,我们将更多地关注日志提供程序。

Leveraging the logging framework
利用日志记录框架

The logging framework, as mentioned at the beginning of the chapter, already has by design a series of providers that do not require adding any additional packages. Now, let us explore how to work with these providers and how to build custom ones. We will analyze only the Console log provider because it has all the sufficient elements to replicate the same reasoning on other log providers.
如本章开头所述,日志框架在设计上已经有一系列不需要添加任何其他包的提供程序。现在,让我们探索如何与这些提供商合作以及如何构建自定义提供商。我们将仅分析 Console 日志提供程序,因为它具有在其他日志提供程序上复制相同推理的所有足够元素。

Console log
控制台日志

The Console log provider is the most used one because, during the development, it gives us a lot of information and collects all the application errors.
Console 日志提供程序是最常用的一种,因为在开发过程中,它为我们提供了大量信息并收集了所有应用程序错误。

Since .NET 6, this provider has been joined by the AddJsonConsole provider, which, besides tracing the errors like the console, serializes them in a JSON object readable by the human eye.
从 .NET 6 开始,此提供程序已由 AddJsonConsole 提供程序加入,该提供程序除了像控制台一样跟踪错误外,还会将它们序列化为人眼可读的 JSON 对象。

In the following example, we show how to configure the JsonConsole provider and also add indentation when writing the JSON payload:
在以下示例中,我们将展示如何配置 JsonConsole 提供程序,并在写入 JSON 有效负载时添加缩进:

builder.Logging.AddJsonConsole(options =>
        options.JsonWriterOptions = new JsonWriterOptions()
        {
            Indented = true
        });

As we’ve seen in the previous examples, we’re going to track the information with the message template:
正如我们在前面的示例中所看到的,我们将使用 message 模板跟踪信息:

app.MapGet("/first-log", (ILogger<CategoryFiltered> loggerCategory, ILogger<MyCategoryAlert> loggerAlertCategory) =>
{
    loggerCategory.LogInformation("I'm information 
      {MyName}", "My Name Information");
    loggerCategory.LogDebug("I'm debug {MyName}",
      "My Name Debug");
    loggerCategory.LogInformation("I'm debug {Data}", 
      new PayloadData("CategoryRoot", "Debug"));
    loggerAlertCategory.LogInformation("I'm information 
      {MyName}", "Alert Information");
    loggerAlertCategory.LogDebug("I'm debug {MyName}",
      "Alert Debug");
    var p = new PayloadData("AlertCategory", "Debug");
    loggerAlertCategory.LogDebug("I'm debug {Data}", p);
    return Results.Ok();
})
.WithName("GetFirstLog");

Finally, an important note: the Console and JsonConsole providers do not serialize objects passed via the message template but only write the class name.
最后,需要注意的是:Console 和 JsonConsole 提供程序不会序列化通过消息模板传递的对象,而只写入类名。

var p = new PayloadData("AlertCategory", "Debug");
loggerAlertCategory.LogDebug("I'm debug {Data}", p);

This is definitely a limitation of providers. Thus, we suggest using structured logging tools such as NLog, log4net, and Serilog, which we will talk about shortly.
这绝对是提供商的限制。因此,我们建议使用结构化日志记录工具,例如 NLog、log4net 和 Serilog,我们稍后会讨论这些工具。

We present the outputs of the previous lines with the two providers just described:
我们将前面几行的输出与刚才描述的两个提供商一起呈现:

Figure 5.1 – AddJsonConsole output
图 5.1 – AddJsonConsole 输出

Figure 5.1 shows the log formatted as JSON, with several additional details compared to the traditional console log.
图 5.1 显示了格式为 JSON 的日志,与传统控制台日志相比,还有一些额外的细节。

Figure 5.2 – Default logging provider Console output
图 5.2 – 默认日志记录提供程序控制台输出

Figure 5.2 shows the default logging provider Console output.
图 5.2 显示了默认的日志记录提供程序 Console 输出。

Given the default providers, we want to show you how you can create a custom one that fits the needs of your application.
给定默认提供程序,我们想向您展示如何创建适合您应用程序需求的自定义提供程序。

Creating a custom provider
创建自定义提供程序

The logging framework designed by Microsoft can be customized with little effort. Thus, let us learn how to create a custom provider.
Microsoft 设计的日志记录框架可以毫不费力地进行自定义。因此,让我们学习如何创建自定义提供商(provider)。

Why create a custom provider? Well, put simply, to not have dependencies with logging libraries and to better manage the performance of the application. Finally, it also encapsulates some custom logic of your specific scenario and makes your code more manageable and readable.
为什么要创建自定义提供商?嗯,简单地说,不要依赖日志库,并更好地管理应用程序的性能。最后,它还封装了特定方案的一些自定义逻辑,并使代码更易于管理和可读。

In the following example, we have simplified the usage scenario to show you the minimum components needed to create a working logging provider for profit.
在以下示例中,我们简化了使用场景,向您展示了创建有效的日志记录提供商以获取利润所需的最少组件。

One of the fundamental parts of a provider is the ability to configure its behavior. Let us create a class that can be customized at application startup or retrieve information from appsettings.
提供程序的基本部分之一是配置其行为的能力。让我们创建一个类,该类可以在应用程序启动时自定义或从 appsettings 中检索信息。

In our example, we define a fixed EventId to verify a daily rolling file logic and a path of where to write the file:
在我们的示例中,我们定义了一个固定的 EventId 来验证每日滚动文件逻辑和写入文件的路径:

public class FileLoggerConfiguration
{
        public int EventId { get; set; }
        public string PathFolderName { get; set; } = 
          "logs";
        public bool IsRollingFile { get; set; }
}

The custom provider we are writing will be responsible for writing the log information to a text file. We achieve this by implementing the log class, which we call FileLogger, which implements the ILogger interface.
我们正在编写的自定义提供程序将负责将日志信息写入文本文件。我们通过实现 log 类来实现这一点,我们称之为 FileLogger,它实现 ILogger 接口。

In the class logic, all we do is implement the log method and check which file to put the information in.
在 class logic中,我们所做的只是实现 log 方法并检查将信息放入哪个文件。

We put the directory verification in the next file, but it’s more correct to put all the control logic in this method. We also need to make sure that the log method does not throw exceptions at the application level. The logger should never affect the stability of the application:
我们将目录验证放在下一个文件中,但将所有 control logic 都放在此方法中更为正确。我们还需要确保 log 方法不会在应用程序级别引发异常。记录器不应影响应用程序的稳定性:

    public class FileLogger : ILogger
    {
        private readonly string name;
        private readonly Func<FileLoggerConfiguration> 
          getCurrentConfig;
        public FileLogger(string name,
          Func<FileLoggerConfiguration> getCurrentConfig)
        {
            this.name = name;
            this.getCurrentConfig = getCurrentConfig;
        }
        public IDisposable BeginScope<TState>(TState state)
          => default!;
        public bool IsEnabled(LogLevel logLevel) => true;
        public void Log<TState>(LogLevel logLevel, EventId
          , TState state, Exception? exception, 
          Func<TState, Exception?, string> formatter)
        {
            if (!IsEnabled(logLevel))
            {
                return;
            }
            var config = getCurrentConfig();
            if (config.EventId == 0 || config.EventId ==
                eventId.Id)
            {
                string line = $"{name} - {formatter(state,
                  exception)}";
                string fileName = config.IsRollingFile ? 
                  RollingFileName : FullFileName;
                string fullPath = Path.Combine(
                  config.PathFolderName, fileName);
                File.AppendAllLines(fullPath, new[] { line });
            }
        }
        private static string RollingFileName => 
          $"log-{DateTime.UtcNow:yyyy-MM-dd}.txt";
        private const string FullFileName = "logs.txt";
    }

Now, we need to implement the ILoggerProvider interface, which is intended to create one or more instances of the logger class just discussed.
现在,我们需要实现 ILoggerProvider 接口,该接口旨在创建刚才讨论的 Logger 类的一个或多个实例。

In this class, we check the directory we mentioned in the previous paragraph, but we also check whether the settings in the appsettings file change, via IOptionsMonitor<T>:
在这个类中,我们检查了我们在上一段中提到的目录,但我们也会通过 IOptionsMonitor<T>检查 appsettings 文件中的设置是否发生了变化:

public class FileLoggerProvider : ILoggerProvider
{
    private readonly IDisposable onChangeToken;
    private FileLoggerConfiguration currentConfig;
    private readonly ConcurrentDictionary<string,
      FileLogger> _loggers = new();
    public FileLoggerProvider(
      IOptionsMonitor<FileLoggerConfiguration> config)
    {
        currentConfig = config.CurrentValue;
        CheckDirectory();
        onChangeToken = config.OnChange(updateConfig =>
        {
            currentConfig = updateConfig;
            CheckDirectory();
        });
    }
    public ILogger CreateLogger(string categoryName)
    {
        return _loggers.GetOrAdd(categoryName, name => new 
          FileLogger(name, () => currentConfig));
    }
    public void Dispose()
    {
        _loggers.Clear();
        onChangeToken.Dispose();
    }
    private void CheckDirectory()
    {
        if (!Directory.Exists(currentConfig.PathFolderName))
            Directory.CreateDirectory(currentConfig.
            PathFolderName);
    }
}

Finally, to simplify its use and configuration during the application startup phase, we also define an extension method for registering the various classes just mentioned.
最后,为了简化它在应用程序启动阶段的使用和配置,我们还定义了一个扩展方法,用于注册刚才提到的各种类。

The AddFile method will register ILoggerProvider and couple it to its configuration (very simple as an example, but it encapsulates several aspects of configuring and using a custom provider):
AddFile 方法将注册 ILoggerProvider 并将其耦合到其配置(示例非常简单,但它封装了配置和使用自定义提供程序的几个方面):

public static class FileLoggerExtensions
    {
        public static ILoggingBuilder AddFile(
        this ILoggingBuilder builder)
        {
            builder.AddConfiguration();
           builder.Services.TryAddEnumerable(
             ServiceDescriptor.Singleton<ILoggerProvider,
             FileLoggerProvider>());
            LoggerProviderOptions.RegisterProviderOptions<
              FileLoggerConfiguration, FileLoggerProvider>
              (builder.Services);
            return builder;
        }
        public static ILoggingBuilder AddFile(
            this ILoggingBuilder builder,
            Action<FileLoggerConfiguration> configure)
        {
            builder.AddFile();
            builder.Services.Configure(configure);
            return builder;
        }
    }

We record everything seen in the Program.cs file with the AddFile extension as shown:
我们使用 AddFile 扩展名记录 Program.cs 文件中看到的所有内容,如下所示:

builder.Logging.AddFile(configuration =>
{
    configuration.PathFolderName = Path.Combine(
      builder.Environment.ContentRootPath, "logs");
    configuration.IsRollingFile = true;
});

The output is shown in Figure 5.3, where we can see both Microsoft log categories in the first five lines (this is the classic application startup information):
输出如图 5.3 所示,我们可以在前五行中看到两个 Microsoft 日志类别(这是经典应用程序启动信息):

Figure 5.3 – File log provider output
图 5.3 – 文件日志提供程序输出

Then, the handler of the minimal APIs that we reported in the previous sections is called. As you can see, no exception data or data passed to the logger is serialized.
然后,调用我们在前面几节中报告的最小 API 的处理程序。如您所见,不会序列化任何异常数据或传递给 logger 的数据。

To add this functionality as well, it is necessary to rewrite ILogger formatter and support serialization of the object. This will give you everything you need to have in a useful logging framework for production scenarios.
若要同时添加此功能,必须重写 ILogger 格式化程序并支持对象的序列化。这将为您提供用于生产场景的有用日志记录框架所需的一切。

We’ve seen how to configure the log and how to customize the provider object to create a structured log to send to a service or storage.
我们已经了解了如何配置日志以及如何自定义 provider 对象以创建要发送到服务或存储的结构化日志。

In the next section, we want to describe the Azure Application Insights service, which is very useful for both logging and application monitoring.
在下一部分中,我们将介绍 Azure Application Insights 服务,该服务对于日志记录和应用程序监视都非常有用。

Application Insights
应用程序洞察

In addition to the already seen providers, one of the most used ones is Azure Application Insights. This provider allows you to send every single log event in the Azure service. In order to insert the provider into our project, all we would have to do is install the following NuGet package:
除了已经看到的提供程序之外,最常用的提供程序之一是 Azure Application Insights。此提供程序允许您发送 Azure 服务中的每个日志事件。为了将提供程序插入到我们的项目中,我们只需安装以下 NuGet 包:

<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.20.0" />

Registering the provider is very easy.
注册提供商非常简单。

We first register the Application Insights framework, AddApplicationInsightsTelemetry, and then register its extension on the AddApplicationInsights logging framework.
我们首先注册 Application Insights 框架 AddApplicationInsightsTelemetry,然后在 AddApplicationInsights 日志记录框架上注册其扩展。

In the NuGet package previously described, the one for logging the component to the logging framework is also present as a reference:
在前面描述的 NuGet 包中,用于将组件记录到日志记录框架的包也作为参考存在:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplicationInsightsTelemetry();
builder.Logging.AddApplicationInsights();

To register the instrumentation key, which is the key that is issued after registering the service on Azure, you will need to pass this information to the registration method. We can avoid hardcoding this information by placing it in the appsettings.json file using the following format:
若要注册检测密钥(在 Azure 上注册服务后颁发的密钥),您需要将此信息传递给注册方法。我们可以使用以下格式将此信息放在 appsettings.json 文件中,从而避免对此信息进行硬编码:

"ApplicationInsights": {
    "InstrumentationKey": "your-key"
  },

This process is also described in the documentation (https://docs.microsoft.com/it-it/azure/azure-monitor/app/asp-net-core#enable-application-insights-server-side-telemetry-no-visual-studio).
文档 (https://docs.microsoft.com/it-it/azure/azure-monitor/app/asp-net-core#enable-application-insights-server-side-telemetry-no-visual-studio) 中也介绍了此过程。

By launching the method already discussed in the previous sections, we have all the information hooked into Application Insights.
通过启动前面部分中已讨论的方法,我们将所有信息挂接到 Application Insights 中。

Application Insights groups the logs under a particular trace. A trace is a call to an API, so everything that happens in that call is logically grouped together. This feature takes advantage of the WebServer information and, in particular, TraceParentId issued by the W3C standard for each call.
Application Insights 将日志分组到特定跟踪下。跟踪是对 API 的调用,因此该调用中发生的所有事情都在逻辑上分组在一起。此功能利用 WebServer 信息,特别是 W3C 标准为每个调用颁发的 TraceParentId。

In this way, Application Insights can bind calls between various minimal APIs, should we be in a microservice application or with multiple services collaborating with each other.
通过这种方式,Application Insights 可以在各种最小 API 之间绑定调用,前提是我们位于微服务应用程序中或多个服务相互协作。

Figure 5.4 – Application Insights with a standard log provider
图 5.4 – 具有标准日志提供程序的 Application Insights

We notice how the default formatter of the logging framework does not serialize the PayloadData object but only writes the text of the object.
我们注意到日志记录框架的默认格式化程序不会序列化 PayloadData 对象,而只写入对象的文本。

In the applications that we will bring into production, it will be necessary to also trace the serialization of the objects. Understanding the state of the object on time is fundamental to analyzing the errors that occurred during a particular call while running queries in the database or reading the data read from the same.
在我们即将投入生产的应用程序中,还需要跟踪对象的序列化。了解对象的按时状态对于在数据库中运行查询或读取从数据库中读取的数据时分析特定调用期间发生的错误至关重要。

Storing a structured log with Serilog
使用 Serilog 存储结构化日志

As we just discussed, tracking structured objects in the log helps us tremendously in understanding errors.
正如我们刚才讨论的,跟踪日志中的结构化对象对我们理解错误有很大帮助。

We, therefore, suggest one of the many logging frameworks: Serilog.
因此,我们建议使用众多日志框架之一:Serilog。

Serilog is a comprehensive library that has many sinks already written that allow you to store log data and search it later.
Serilog 是一个综合库,它已经编写了许多接收器,允许您存储日志数据并在以后进行搜索。

Serilog is a logging library that allows you to track information on multiple data sources. In Serilog, these sources are called sinks, and they allow you to write structured data inside the log applying a serialization of the data passed to the logging system.
Serilog 是一个日志记录库,允许您跟踪有关多个数据源的信息。在 Serilog 中,这些源称为 sink,它们允许您在日志中写入结构化数据,应用传递给日志记录系统的数据的序列化。

Let’s see how to get started using Serilog for a minimal API application. Let’s install these NuGet packages. Our goal will be to track the same information we’ve been using so far, specifically Console and ApplicationInsights:
让我们看看如何开始将 Serilog 用于最小的 API 应用程序。让我们安装这些 NuGet 包。我们的目标是跟踪我们目前一直在使用的相同信息,特别是控制台和 ApplicationInsights:

<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.20.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
<PackageReference Include="Serilog.Sinks.ApplicationInsights" Version="3.1.0" />

The first package is the one needed for the ApplicationInsights SDK in the application. The second package allows us to register Serilog in the ASP.NET pipeline and to be able to exploit Serilog. The third package allows us to configure the framework in the appsettings file and not have to rewrite the application to change a parameter or code. Finally, we have the package to add the ApplicationInsights sink.
第一个包是应用程序中 ApplicationInsights SDK 所需的包。第二个包允许我们在 ASP.NET 管道中注册 Serilog,并能够利用 Serilog。第三个包允许我们在 appsettings 文件中配置框架,而不必重写应用程序来更改参数或代码。最后,我们有了用于添加 ApplicationInsights 接收器的包。

In the appsettings file, we create a new Serilog section, in which we should register the various sinks in the Using section. We register the log level, the sinks, the enrichers that enrich the information for each event, and the properties, such as the application name:
在 appsettings 文件中,我们创建一个新的 Serilog 部分,我们应该在其中注册 Using 部分的各种接收器。我们注册日志级别、接收器、扩充每个事件信息的 enricher 以及属性,例如应用程序名称:

"Serilog": {
    "Using": [ "Serilog.Sinks.Console",
      "Serilog.Sinks.ApplicationInsights" ],
    "MinimumLevel": "Verbose",
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "ApplicationInsights",
        "Args": {
          "restrictedToMinimumLevel": "Information",
          "telemetryConverter": "Serilog.Sinks.
           ApplicationInsights.Sinks.ApplicationInsights.
           TelemetryConverters.TraceTelemetryConverter, 
           Serilog.Sinks.ApplicationInsights"
        }
      }
    ],
    "Enrich": [ "FromLogContext"],   
    "Properties": {
      "Application": "MinimalApi.Packt"
    }
  }

Now, we just have to register Serilog in the ASP.NET pipeline:
现在,我们只需要在 ASP.NET 管道中注册 Serilog:

using Microsoft.ApplicationInsights.Extensibility;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddSerilog();
builder.Services.AddApplicationInsightsTelemetry();
var app = builder.Build();
Log.Logger = new LoggerConfiguration()
.WriteTo.ApplicationInsights(app.Services.GetRequiredService<TelemetryConfiguration>(), TelemetryConverter.Traces)
.CreateLogger();

With the builder.Logging.AddSerilog() statement, we register Serilog with the logging framework to which all logged events will be passed with the usual ILogger interface. Since the framework needs to register the TelemetryConfiguration class to register ApplicationInsights, we are forced to hook the configuration to the static Logger object of Serilog. This is all because Serilog will turn the information from the Microsoft logging framework over to the Serilog framework and add all the necessary information.
builder.Logging.AddSerilog()语句中,我们将 Serilog 注册到日志记录框架,所有记录的事件都将使用通常的 ILogger 接口传递到该框架。由于框架需要注册 TelemetryConfiguration 类来注册 ApplicationInsights,因此我们被迫将配置挂接到 Serilog 的静态 Logger 对象。这都是因为 Serilog 会将信息从 Microsoft 日志记录框架转移到 Serilog 框架并添加所有必要的信息。

The usage is very similar to the previous one, but this time, we add an @ (at) to the message template that will tell Serilog to serialize the sent object.
用法与前一个非常相似,但这次,我们在消息模板中添加一个 @ (at),它将告诉 Serilog 序列化发送的对象。

With this very simple {@Person} wording, we will be able to achieve the goal of serializing the object and sending it to the ApplicationInsights service:
使用这个非常简单的 {@Person} 措辞,我们将能够实现序列化对象并将其发送到 ApplicationInsights 服务的目标:

app.MapGet("/serilog", (ILogger<CategoryFiltered> loggerCategory) =>
{
    loggerCategory.LogInformation("I'm {@Person}", new
      Person("Andrea", "Tosato", new DateTime(1986, 11, 
      9)));
    return Results.Ok();
})
.WithName("GetFirstLog");
internal record Person(string Name, string Surname, DateTime Birthdate);

Finally, we have to find the complete data, serialized with the JSON format, in the Application Insights service.
最后,我们必须在 Application Insights 服务中找到使用 JSON 格式序列化的完整数据。

Figure 5.5 – Application Insights with structured data
图 5.5 – 包含结构化数据的 Application Insights

Summary
总结

In this chapter, we have seen several logging aspects of the implementation of minimal APIs.
在本章中,我们了解了最小 API 实现的几个日志记录方面。

We started to appreciate the ASP.NET churned logging framework, and we understood how to configure and customize it. We focused on how to define a message template and how to avoid errors with the source generator.
我们开始欣赏 ASP.NET 的 churned 日志记录框架,并且我们了解如何配置和自定义它。我们重点介绍了如何定义消息模板以及如何避免源生成器出错。

We saw how to use the new provider to serialize logs with the JSON format and create a custom provider. These elements turned out to be very important for mastering the logging tool and customizing it to your liking.
我们了解了如何使用新的提供程序以 JSON 格式序列化日志并创建自定义提供程序。事实证明,这些元素对于掌握日志记录工具并根据您的喜好对其进行自定义非常重要。

Not only was the application log mentioned but also the infrastructure log, which together with Application Insights becomes a key element to monitoring your application. Finally, we understood that there are ready-made tools, such as Serilog, that help us to have ready-to-use functionalities with a few steps thanks to some packages installed by NuGet.
不仅提到了应用程序日志,还提到了基础结构日志,它与 Application Insights 一起成为监视应用程序的关键元素。最后,我们了解到有一些现成的工具,例如 Serilog,由于 NuGet 安装的一些软件包,它们可以帮助我们通过几个步骤获得即用型功能。

In the next chapter, we will present the mechanisms for validating an input object to the API. This is a fundamental feature to return a correct error to the calls and discard inaccurate requests or those promoted by illicit activities such as spam and attacks, aimed at generating load on our servers.
在下一章中,我们将介绍验证 API 的输入对象的机制。这是一项基本功能,可向调用返回正确的错误并丢弃不准确的请求或由非法活动(如垃圾邮件和攻击)推动的请求,旨在在我们的服务器上产生负载。

6 Exploring Validation and Mapping

6 探索验证和映射

In this chapter of the book, we will discuss how to perform data validation and mapping with minimal APIs, showing what features we currently have, what is missing, and what the most interesting alternatives are. Learning about these concepts will help us to develop more robust and maintainable applications.
在本书的这一章中,我们将讨论如何使用最少的 API 执行数据验证和映射,展示我们目前拥有的功能、缺少的功能以及最有趣的替代方案。了解这些概念将有助于我们开发更健壮且可维护的应用程序。

In this chapter, we will be covering the following topics:
在本章中,我们将介绍以下主题:

• Handling validation
处理验证

• Mapping data to and from APIs
将数据映射到 API 或从 API 映射数据

Technical requirements
技术要求

To follow the descriptions in this chapter, you will need to create an ASP.NET Core 6.0 Web API application. Refer to the Technical requirements section in Chapter 2, Exploring Minimal APIs and Their Advantages, for instructions on how to do so.
要按照本章中的描述进行作,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。有关如何执行此作的说明,请参阅第 2 章 “探索最小 API 及其优势”中的“技术要求”部分。

If you’re using your console, shell, or bash terminal to create the API, remember to change your working directory to the current chapter number (Chapter06).
如果您使用控制台、shell 或 bash 终端创建 API,请记住将工作目录更改为当前章节编号 (Chapter06)。

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter06.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter06

Handling validation
处理验证

Data validation is one of the most important processes in any working software. In the context of a Web API, we perform the validation process to ensure that the information passed to our endpoints respects certain rules – for example, that a Person object has both the FirstName and LastName properties defined, an email address is valid, or an appointment date isn’t in the past.
数据验证是任何工作软件中最重要的过程之一。在 Web API 的上下文中,我们执行验证过程以确保传递给终端节点的信息符合某些规则,例如,Person 对象同时定义了 FirstName 和 LastName 属性、电子邮件地址有效或约会日期不是过去的日期。

In controller-based projects, we can perform these checks, also termed model validation, directly on the model, using data annotations. In fact, the ApiController attribute that is placed on a controller makes model validation errors automatically trigger a 400 Bad Request response if one or more validation rules fail. Therefore, in controller-based projects, we typically don’t need to perform explicit model validation at all: if the validation fails, our endpoint will never be invoked.
在基于控制器的项目中,我们可以使用数据注释直接在模型上执行这些检查,也称为模型验证。事实上,放置在控制器上的 ApiController 属性会使模型验证错误在一个或多个验证规则失败时自动触发 400 Bad Request 响应。因此,在基于控制器的项目中,我们通常根本不需要执行显式模型验证:如果验证失败,我们的端点将永远不会被调用。

Note : The ApiController attribute enables the automatic model validation behavior using the ModelStateInvalidFilter action filter.
注意 : ApiController 属性使用 ModelStateInvalidFilter作筛选器启用自动模型验证行为。

Unfortunately, minimal APIs do not provide built-in support for validation. The IModelValidator interface and all related objects cannot be used. Thus, we don’t have a ModelState; we can’t prevent the execution of our endpoint if there is a validation error and must explicitly return a 400 Bad Request response.
遗憾的是,最小 API 不提供对验证的内置支持。不能使用 IModelValidator 接口和所有相关对象。因此,我们没有 ModelState;如果存在验证错误,我们无法阻止终端节点的执行,并且必须显式返回 400 Bad Request 响应。

So, for example, let’s see the following code:
因此,例如,让我们看看以下代码:

app.MapPost("/people", (Person person) =>
{
    return Results.NoContent();
});
public class Person
{
    [Required]
    [MaxLength(30)]
    public string FirstName { get; set; }
    [Required]
    [MaxLength(30)]
    public string LastName { get; set; }
    [EmailAddress]
    [StringLength(100, MinimumLength = 6)]
    public string Email { get; set; }
}

As we can see, the endpoint will be invoked even if the Person argument does not respect the validation rules. There is only one exception: if we use nullable reference types and we don’t pass a body in the request, we effectively get a 400 Bad Request response. As mentioned in Chapter 2, Exploring Minimal APIs and Their Advantages, nullable reference types are enabled by default in .NET 6.0 projects.
正如我们所看到的,即使 Person 参数不遵守验证规则,也会调用端点。只有一个例外:如果我们使用可为 null 的引用类型,并且我们没有在请求中传递正文,我们实际上会得到 400 Bad Request 响应。如第 2 章 探索最小 API 及其优点中所述,在 .NET 6.0 项目中默认启用可为 null 的引用类型。

If we want to accept a null body (if ever there was a need), we need to declare the parameter as Person?. But, as long as there is a body, the endpoint will always be invoked.
如果我们想接受一个 null body(如果有需要),我们需要将参数声明为 Person?。但是,只要有 body,端点就会始终被调用。

So, with minimal APIs, it is necessary to perform validation inside each route handler and return the appropriate response if some rules fail. We can either implement a validation library compatible with the existing attributes so that we can perform validation using the classic data annotations approach, as described in the next section, or use a third-party solution such as FluentValidation, as we will see in the Integrating FluentValidation section.
因此,使用最少的 API,有必要在每个路由处理程序中执行验证,并在某些规则失败时返回相应的响应。我们可以实现与现有属性兼容的验证库,以便我们可以使用经典数据注释方法执行验证,如下一节所述,也可以使用第三方解决方案,例如 FluentValidation,正如我们将在集成 FluentValidation 部分中看到的那样。

Performing validation with data annotations
使用数据注释执行验证

If we want to use the common validation pattern based on data annotations, we need to rely on reflection to retrieve all the validation attributes in a model and invoke their IsValid methods, which are provided by the ValidationAttribute base class.
如果我们想使用基于数据注释的通用验证模式,则需要依靠反射来检索模型中的所有验证属性,并调用它们的 IsValid 方法,这些方法由 ValidationAttribute 基类提供。

This behavior is a simplification of what ASP.NET Core actually does to handle validations. However, this is the way validation in controller-based projects works.
此行为简化了 ASP.NET Core 实际处理验证的作。但是,这就是基于 controller 的 projects 中 validation 的工作方式。

While we can also manually implement a solution of this kind with minimal APIs, if we decide to use data annotations for validation, we can leverage a small but interesting library, MiniValidation, which is available on GitHub (https://github.com/DamianEdwards/MiniValidation) and NuGet (https://www.nuget.org/packages/MiniValidation).
虽然我们也可以使用最少的 API 手动实现此类解决方案,但如果我们决定使用数据注释进行验证,我们可以利用一个小而有趣的库 MiniValidation,该库可在 GitHub (https://github.com/DamianEdwards/MiniValidation) 和 NuGet (https://www.nuget.org/packages/MiniValidation) 上使用。

Important note : At the time of writing, MiniValidation is available on NuGet as a prerelease.
重要提示 : 在撰写本文时,MiniValidation 在 NuGet 上作为预发行版提供。

We can add this library to our project in one of the following ways:
我们可以通过以下方式之一将此库添加到我们的项目中:

• Option 1: If you’re using Visual Studio 2022, right-click on the project and choose the Manage NuGet Packages command to open the Package Manager GUI; then, search for MiniValidation. Be sure to check the Include prerelease option and click Install.
选项 1:如果您使用的是 Visual Studio 2022,请右键单击项目并选择“管理 NuGet 包”命令以打开包管理器 GUI;然后,搜索 MiniValidation。请务必选中 Include prerelease 选项,然后单击 Install。

• Option 2: Open the Package Manager Console if you’re inside Visual Studio 2022, or open your console, shell, or bash terminal, go to your project directory, and execute the following command: dotnet add package MiniValidation --prerelease
选项 2:如果您在 Visual Studio 2022 中,请打开包管理器控制台,或者打开控制台、shell 或 bash 终端,转到您的项目目录,然后执行以下命令:dotnet add package MiniValidation --prerelease

Now, we can validate a Person object using the following code:
现在,我们可以使用以下代码验证 Person 对象:

app.MapPost("/people", (Person person) =>
{
    var isValid = MiniValidator.TryValidate(person, 
      out var errors);
    if (!isValid)
    {
        return Results.ValidationProblem(errors);
    }
    return Results.NoContent();
});

As we can see, the MiniValidator.TryValidate static method provided by MiniValidation takes an object as input and automatically verifies all the validation rules that are defined on its properties. If the validation fails, it returns false and populates the out parameter with all the validation errors that have occurred. In this case, because it is our responsibility to return the appropriate response code, we use Results.ValidationProblem, which produces a 400 Bad Request response with a ProblemDetails object (as described in Chapter 3, Working with Minimal APIs) and also contains the validation issues.
正如我们所看到的,MiniValidation 提供的 MiniValidator.TryValidate 静态方法将对象作为输入,并自动验证在其属性上定义的所有验证规则。如果验证失败,它将返回 false 并使用已发生的所有验证错误填充 out 参数。在这种情况下,由于我们有责任返回适当的响应代码,因此我们使用 Results.ValidationProblem,它生成带有 ProblemDetails 对象的 400 Bad Request 响应(如第 3 章 使用最小 API 中所述),并且还包含验证问题。

Now, as an example, we can invoke the endpoint using the following invalid input:
现在,例如,我们可以使用以下无效输入调用终端节点:

{
  "lastName": "MyLastName",
  "email": "email"
}

This is the response we will obtain:
这是我们将获得的响应:

{
  "type": 
    "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "FirstName": [
      "The FirstName field is required."
    ],
    "Email": [
      "The Email field is not a valid e-mail address.",
      "The field Email must be a string with a minimum
       length of 6 and a maximum length of 100."
    ]
  }
}

In this way, besides the fact that we need to execute validation manually, we can implement the approach of using data annotations on our models in the same way we were accustomed to in previous versions of ASP.NET Core. We can also customize error messages and define custom rules by creating classes that inherit from ValidationAttribute.
这样,除了需要手动执行验证之外,我们还可以像以前版本的 ASP.NET Core 一样,在模型上实现使用数据注释的方法。我们还可以通过创建继承自 ValidationAttribute 的类来自定义错误消息和定义自定义规则。

Note : The full list of validation attributes available in ASP.NET Core 6.0 is published at https://docs.microsoft.com/dotnet/api/system.componentmodel.dataannotations. If you’re interested in creating custom attributes, you can refer to https://docs.microsoft.com/aspnet/core/mvc/models/validation#custom-attributes.
注意 : ASP.NET Core 6.0 中可用的验证属性的完整列表发布在 https://docs.microsoft.com/dotnet/api/system.componentmodel.dataannotations。如果你对创建自定义属性感兴趣,可以参考 https://docs.microsoft.com/aspnet/core/mvc/models/validation#custom-attributes

Although data annotations are the most used solution, we can also handle validations using a so-called fluent approach, which has the benefit of completely decoupling validation rules from the model, as we’ll see in the next section.
尽管数据注释是最常用的解决方案,但我们也可以使用所谓的 Fluent 方法处理验证,其优点是将验证规则与模型完全解耦,我们将在下一节中看到。

Integrating FluentValidation
集成 FluentValidation

In every application, it is important to correctly organize our code. This is also true for validation. While data annotations are a working solution, we should think about alternatives that can help us write more maintainable projects. This is the purpose of FluentValidation – a library, part of the .NET Foundation, that allows us to build validation rules using a fluent interface with lambda expressions. The library is available on GitHub (https://github.com/FluentValidation/FluentValidation) and NuGet (https://www.nuget.org/packages/FluentValidation). This library can be used in any kind of project, but when working with ASP.NET Core, there is an ad-hoc NuGet package (https://www.nuget.org/packages/FluentValidation.AspNetCore) that contains useful methods that help to integrate it.
在每个应用程序中,正确组织我们的代码都很重要。验证也是如此。虽然数据注释是一种有效的解决方案,但我们应该考虑可以帮助我们编写更可维护项目的替代方案。这就是 FluentValidation 的用途 – 一个库,是 .NET Foundation 的一部分,它允许我们使用带有 lambda 表达式的 Fluent 接口构建验证规则。该库在 GitHub (https://github.com/FluentValidation/FluentValidation) 和 NuGet (https://www.nuget.org/packages/FluentValidation) 上提供。此库可用于任何类型的项目,但在使用 ASP.NET Core 时,有一个临时 NuGet 包 (https://www.nuget.org/packages/FluentValidation.AspNetCore) 包含有助于集成它的有用方法。

Note : .NET Foundation is an independent organization that aims to support open source software development and collaboration around the .NET platform. You can learn more at https://dotnetfoundation.org.
注意 : .NET Foundation 是一个独立的组织,旨在支持围绕 .NET 平台的开源软件开发和协作。您可以在 https://dotnetfoundation.org 中了解更多信息。

As stated before, with this library, we can decouple validation rules from the model to create a more structured application. Moreover, FluentValidation allows us to define even more complex rules with a fluent syntax without the need to create custom classes based on ValidationAttribute. The library also natively supports the localization of standard error messages.
如前所述,借助此库,我们可以将验证规则与模型解耦,以创建更加结构化的应用程序。此外,FluentValidation 允许我们使用 Fluent 语法定义更复杂的规则,而无需基于 ValidationAttribute 创建自定义类。该库还原生支持标准错误消息的本地化。

So, let’s see how we can integrate FluentValidation into a minimal API project. First, we need to add this library to our project in one of the following ways:
那么,让我们看看如何将 FluentValidation 集成到一个最小的 API 项目中。首先,我们需要通过以下方式之一将此库添加到我们的项目中:

• Option 1: If you’re using Visual Studio 2022, right-click on the project and choose the Manage NuGet Packages command to open Package Manager GUI. Then, search for FluentValidation.DependencyInjectionExtensions and click Install.
选项 1:如果您使用的是 Visual Studio 2022,请右键单击项目并选择“管理 NuGet 包”命令以打开包管理器 GUI。然后,搜索 FluentValidation.DependencyInjectionExtensions 并单击 Install。

• Option 2: Open Package Manager Console if you’re inside Visual Studio 2022, or open your console, shell, or bash terminal, go to your project directory, and execute the following command: dotnet add package FluentValidation.DependencyInjectionExtensions
选项 2:如果您在 Visual Studio 2022 中,请打开包管理器控制台,或者打开控制台、shell 或 bash 终端,转到您的项目目录,然后执行以下命令:
dotnet add 包 FluentValidation.DependencyInjectionExtensions

Now, we can rewrite the validation rules for the Person object and put them in a PersonValidator class:
现在,我们可以重写 Person 对象的验证规则,并将它们放入 PersonValidator 类中:

public class PersonValidator : AbstractValidator<Person>
{
    public PersonValidator() 
    {
        RuleFor(p =>
          p.FirstName).NotEmpty().MaximumLength(30);
        RuleFor(p => 
          p.LastName).NotEmpty().MaximumLength(30);
        RuleFor(p => p.Email).EmailAddress().Length(6,
          100);
    }
}

PersonValidator inherits from AbstractValidator<T>, a base class provided by FluentValidation that contains all the methods we need to define the validation rules. For example, we fluently say that we have a rule for the FirstName property, which is that it must not be empty and it can have a maximum length of 30 characters.
PersonValidator 继承自 AbstractValidator<T>,后者是 FluentValidation 提供的基类,包含定义验证规则所需的所有方法。例如,我们流畅地说我们有一条 FirstName 属性的规则,即它不能为空,并且最大长度为 30 个字符。

The next step is to register the validator in the service provider so that we can use it in our route handlers. We can perform this task with a simple instruction:
下一步是在 service provider 中注册 validator,以便我们可以在 route handlers 中使用它。我们可以通过一个简单的指令来执行这项任务:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

The AddValidatorsFromAssemblyContaining method automatically registers all the validators derived from AbstractValidator within the assembly containing the specified type. In particular, this method registers the validators and makes them accessible through dependency injection via the IValidator<T> interface, which in turn, is implemented by the AbstractValidator<T> class. If we have multiple validators, we can register them all with this single instruction. We can also easily put our validators in external assemblies.
AddValidatorsFromAssemblyContaining 方法会自动在包含指定类型的程序集中注册从 AbstractValidator 派生的所有验证程序。特别是,此方法注册验证器,并通过 IValidator<T> 接口通过依赖项注入使它们可访问,而 IValidator<T> 接口又由 AbstractValidatorT 类实现。如果我们有多个验证者,我们可以使用这个指令将它们全部注册。我们还可以轻松地将验证器放在外部程序集中。

Now that everything is in place, remembering that with minimal APIs we don’t have automatic model validation, we must update our route handler in this way:
现在一切都已准备就绪,请记住,使用最少的 API 时,我们没有自动模型验证,我们必须以这种方式更新我们的路由处理程序:

app.MapPost("/people", async (Person person, IValidator<Person> validator) =>
{
    var validationResult = 
      await validator.ValidateAsync(person);
    if (!validationResult.IsValid)
    {
        var errors = validationResult.ToDictionary();
        return Results.ValidationProblem(errors);
    }
    return Results.NoContent();
});

We have added an IValidator argument in the route handler parameter list, so now we can invoke its ValidateAsync method to apply the validation rules against the input Person object. If the validation fails, we extract all the error messages and return them to the client with the usual Results.ValidationProblem method, as described in the previous section.
我们在路由处理程序参数列表中添加了 IValidator 参数,因此现在我们可以调用其 ValidateAsync 方法,以对输入 Person 对象应用验证规则。如果验证失败,我们将提取所有错误消息,并使用通常的 Results.ValidationProblem 方法将它们返回给客户端,如上一节所述。

In conclusion, let’s see what happens if we try to invoke the endpoint using the following input as before:
总之,让我们看看如果我们像以前一样尝试使用以下输入调用终端节点会发生什么情况:

{
  "lastName": "MyLastName",
  "email": "email"
}

We’ll get the following response:
我们将收到以下响应:

{
  "type": 
    "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "FirstName": [
      "'First Name' non può essere vuoto."
    ],
    "Email": [
      "'Email' non è un indirizzo email valido.",
      "'Email' deve essere lungo tra i 6 e 100 caratteri.
        Hai inserito 5 caratteri."
    ]
  }
}

As mentioned earlier, FluentValidation provides translations for standard error messages, so this is the response you get when running on an Italian system. Of course, we can completely customize the messages with the typical fluent approach, using the WithMessage method chained to the validation methods defined in the validator. For example, see the following:
如前所述,FluentValidation 为标准错误消息提供翻译,因此这是您在意大利语系统上运行时得到的响应。当然,我们可以使用典型的 Fluent 方法完全自定义消息,使用链接到验证器中定义的验证方法的 WithMessage 方法。例如,请参阅以下内容:

RuleFor(p => p.FirstName).NotEmpty().WithMessage("You must provide the first name");

We’ll talk about localization in further detail in Chapter 9, Leveraging Globalization and Localization.
我们将在第 9 章 利用全球化和本地化 中更详细地讨论本地化。

This is just a quick example of how to define validation rules with FluentValidation and use them with minimal APIs. This library allows many more complex scenarios that are comprehensively described in the official documentation available at https://fluentvalidation.net.
这只是一个快速示例,说明如何使用 FluentValidation 定义验证规则并将其与最少的 API 一起使用。此库允许许多更复杂的场景,这些场景在 https://fluentvalidation.net 上提供的官方文档中进行了全面描述。

Now that we have seen how to add validation to our route handlers, it is important to understand how we can update the documentation created by Swagger with this information.
现在我们已经了解了如何将验证添加到路由处理程序中,了解如何使用此信息更新 Swagger 创建的文档非常重要。

Adding validation information to Swagger
向 Swagger 添加验证信息

Regardless of the solution that has been chosen to handle validation, it is important to update the OpenAPI definition with the indication that a handler can produce a validation problem response, calling the ProducesValidationProblem method after the endpoint declaration:
无论选择哪种解决方案来处理验证,都必须更新 OpenAPI 定义,并指示处理程序可以生成验证问题响应,并在端点声明后调用 ProducesValidationProblem 方法:

app.MapPost("/people", (Person person) =>
{
    //...
})
.Produces(StatusCodes.Status204NoContent)
.ProducesValidationProblem();

In this way, a new response type for the 400 Bad Request status code will be added to Swagger, as we can see in Figure 6.1:
这样,400 Bad Request 状态码的新响应类型就会被添加到 Swagger 中,如图 6.1 所示:

Figure 6.1 – The validation problem response added to Swagger
图 6.1 – 添加到 Swagger 的验证问题响应

Moreover, the JSON schemas that are shown at the bottom of the Swagger UI can show the rules of the corresponding models. One of the benefits of defining validation rules using data annotations is that they are automatically reflected in these schemas:
此外,Swagger UI 底部显示的 JSON 架构可以显示相应模型的规则。使用数据注释定义验证规则的好处之一是,它们会自动反映在这些架构中:

Figure 6.2 – The validation rules for the Person object in Swagger
图 6.2 – Swagger 中 Person 对象的验证规则

Unfortunately, validation rules defined with FluentValidation aren’t automatically shown in the JSON schema of Swagger. We can overcome this limitation by using MicroElements.Swashbuckle.FluentValidation, a small library that, as usual, is available on GitHub (https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation) and NuGet (https://www.nuget.org/packages/MicroElements.Swashbuckle.FluentValidation). After adding it to our project, following the same steps described before for the other NuGet packages we have introduced, we just need to call the AddFluentValidationRulesToSwagger extension method:
遗憾的是,使用 FluentValidation 定义的验证规则不会自动显示在 Swagger 的 JSON 架构中。我们可以通过使用 MicroElements.Swashbuckle.FluentValidation 来克服这一限制,这是一个小型库,通常可在 GitHub (https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation) 和 NuGet (https://www.nuget.org/packages/MicroElements.Swashbuckle.FluentValidation) 上使用。将其添加到我们的项目后,按照之前针对我们介绍的其他 NuGet 包的相同步骤,我们只需调用 AddFluentValidationRulesToSwagger 扩展方法:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddFluentValidationRulesToSwagger();

In this way, the JSON schema shown in Swagger will reflect the validation rules, as with the data annotations. However, it’s worth remembering that, at the time of writing, this library does not support all the validators available in FluentValidation. For more information, we can refer to the GitHub page of the library.
这样,Swagger 中显示的 JSON 架构将反映验证规则,就像数据注释一样。但是,值得记住的是,在撰写本文时,此库并不支持 FluentValidation 中可用的所有验证器。有关更多信息,我们可以参考该库的 GitHub 页面。

This ends our overview of validation in minimal APIs. In the next section, we’ll analyze another important theme of every API: how to correctly handle the mapping of data to and from our services.
我们对最小 API 中的验证的概述到此结束。在下一节中,我们将分析每个 API 的另一个重要主题:如何正确处理进出我们服务的数据。

Mapping data to and from APIs
将数据映射到 API 或从 API 映射数据

When dealing with APIs that can be called by any system, there is one golden rule: we should never expose our internal objects to the callers. If we don’t follow this decoupling idea and, for some reason, need to change our internal data structures, we could end up breaking all the clients that interact with us. Both the internal data structures and the objects that are used to dialog with the clients must be able to evolve independently from one another.
在处理任何系统都可以调用的 API 时,有一条黄金法则:我们永远不应该将我们的内部对象暴露给调用者。如果我们不遵循这种解耦的想法,并且出于某种原因需要改变我们的内部数据结构,我们最终可能会破坏所有与我们交互的客户端。内部数据结构和用于与 Client 端对话的对象都必须能够彼此独立地发展。

This requirement for dialog is the reason why mapping is so important. We need to transform input objects of one type into output objects of a different type and vice versa. In this way, we can achieve two objectives:
这种对对话的要求是映射如此重要的原因。我们需要将一种类型的输入对象转换为不同类型的输出对象,反之亦然。通过这种方式,我们可以实现两个目标:

• Evolve our internal data structures without introducing breaking changes with the contracts that are exposed to the callers
改进我们的内部数据结构,而不会对暴露给调用方的合约引入中断性变更

• Modify the format of the objects used to communicate with the clients without the need to change the way these objects are handled internally
修改用于与 Client 端通信的对象的格式,而无需更改内部处理这些对象的方式

In other words, mapping means transforming one object into another, literally, by copying and converting an object’s properties from a source to a destination. However, mapping code is boring, and testing mapping code is even more boring. Nevertheless, we need to fully understand that the process is crucial and strive to adopt it in all scenarios.
换句话说,映射意味着通过将对象的属性从源复制并转换为目标,将一个对象转换为另一个对象。但是,映射代码很无聊,测试映射代码更无聊。尽管如此,我们需要充分理解这个过程是至关重要的,并努力在所有情况下采用它。

So, let’s consider the following object, which could represent a person saved in a database using Entity Framework Core:
因此,让我们考虑以下对象,它可以表示使用 Entity Framework Core 保存在数据库中的人员:

public class PersonEntity
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime BirthDate { get; set; }
    public string City { get; set; }
}

We have set endpoints for getting a list of people or retrieving a specific person.
我们设置了用于获取人员列表或检索特定人员的端点。

The first thought could be to directly return PersonEntity to the caller. The following code is highly simplified, enough for us to understand the scenario:
第一个想法可能是直接将 PersonEntity 返回给调用方。以下代码经过高度简化,足以让我们理解该场景:

app.MapGet("/people/{id:int}", (int id) =>
{
    // In a real application, this entity could be
    // retrieved from a database, checking if the person
    // with the given ID exists.
    var person = new PersonEntity();
    return Results.Ok(person);
})
.Produces(StatusCodes.Status200OK, typeof(PersonEntity));

What happens if we need to modify the schema of the database, adding, for example, the creation date of the entity? In this case, we need to change PersonEntity with a new property that maps the relevant date. However, the callers also get this information now, which we probably don’t want to be exposed. Instead, if we use a so-called data transformation object (DTO) to expose the person, this problem will be redundant:
如果我们需要修改数据库的架构,例如添加实体的创建日期,会发生什么情况?在这种情况下,我们需要使用映射相关日期的新属性更改 PersonEntity。但是,调用方现在也会获得此信息,我们可能不希望这些信息被公开。相反,如果我们使用所谓的数据转换对象 (DTO) 来公开人员,则此问题将是多余的:

public class PersonDto
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime BirthDate { get; set; }
    public string City { get; set; }
}

This means that our API should return an object of the PersonDto type instead of PersonEntity, performing a conversion between the two objects. At first sight, the exercise appears to be a useless duplication of code, as the two classes contain the same properties. However, if we consider the fact that PersonEntity could evolve with new properties that are necessary for the database, or change structure with a new semantic that the caller shouldn’t know, the importance of mapping becomes clear. An example is storing the city in a separate table and exposing it through an Address property. Or suppose that, for security reasons, we don’t want to expose the exact birth date anymore, only the age of the person. Using an ad-hoc DTO, we can easily change the schema and update the mapping without touching our entity, having a better separation of concerns.
这意味着我们的 API 应返回 PersonDto 类型的对象,而不是 PersonEntity,从而在两个对象之间执行转换。乍一看,该练习似乎是无用的代码重复,因为这两个类包含相同的属性。但是,如果我们考虑到 PersonEntity 可能会使用数据库所需的新属性进行演变,或者使用调用方不应知道的新语义更改结构,则映射的重要性就会变得显而易见。例如,将城市存储在单独的表中,并通过 Address 属性公开它。或者假设,出于安全原因,我们不想再公开确切的出生日期,而只想公开人的年龄。使用临时 DTO,我们可以轻松更改架构并更新映射,而无需接触我们的实体,从而更好地分离关注点。

Of course, mapping can be bidirectional. In our example, we need to convert PersonEntity to PersonDto before returning it to the client. However, we could also do the opposite – that is, convert the PersonDto type that comes from a client into PersonEntity to save it to a database. All the solutions we’re talking about are valid for both scenarios.
当然,映射可以是双向的。在我们的示例中,我们需要先将 PersonEntity 转换为 PersonDto,然后再将其返回给客户端。但是,我们也可以执行相反的作,即,将来自客户端的 PersonDto 类型转换为 PersonEntity 以将其保存到数据库。我们讨论的所有解决方案都适用于这两种情况。

We can either perform mapping manually or adopt a third-party library that provides us with this feature. In the following sections, we’ll analyze both approaches, understanding the pros and cons of the available solutions.
我们可以手动执行映射,也可以采用为我们提供此功能的第三方库。在以下部分中,我们将分析这两种方法,了解可用解决方案的优缺点。

Performing manual mapping
执行手动映射

In the previous section, we said that mapping essentially means copying the properties of a source object into the properties of a destination and applying some sort of conversion. The easiest and most effective way to perform this task is to do it manually.
在上一节中,我们说过映射实质上意味着将源对象的属性复制到目标的属性中,并应用某种转换。执行此任务的最简单、最有效的方法是手动执行。

With this approach, we need to take care of all the mapping code by ourselves. From this point of view, there is nothing much more to say; we need a method that takes an object as input and transforms it into another as output, remembering to apply mapping recursively if a class contains a complex property that must be mapped in turn. The only suggestion is to use an extension method so that we can easily call it everywhere we need.
使用这种方法,我们需要自己处理所有的 map 代码。从这个角度来看,没有什么可说的了;我们需要一个方法,将一个对象作为输入并将其转换为另一个作为输出,如果一个类包含必须依次映射的复杂属性,请记住递归地应用映射。唯一的建议是使用扩展方法,这样我们就可以轻松地在需要的任何地方调用它。

A full example of this mapping process is available in the GitHub repository: https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter06.
GitHub 存储库中提供了此映射过程的完整示例:https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter06

This solution guarantees the best performance because we explicitly write all mapping instructions without relying on an automatic system (such as reflection). However, the manual method has a drawback: every time we add a property in the entity that must be mapped to a DTO, we need to change the mapping code. On the other hand, some approaches can simplify mapping, but at the cost of performance overhead. In the next section, we look at one such approach using AutoMapper.
此解决方案保证了最佳性能,因为我们显式编写了所有映射指令,而无需依赖自动系统(例如反射)。但是,手动方法有一个缺点:每次我们在实体中添加必须映射到 DTO 的属性时,都需要更改映射代码。另一方面,某些方法可以简化映射,但会以性能开销为代价。在下一节中,我们将介绍一种使用 AutoMapper 的此类方法。

Mapping with AutoMapper
使用 AutoMapper 进行映射

AutoMapper is probably one the most famous mapping framework for .NET. It uses a fluent configuration API that works with a convention-based matching algorithm to match source values to destination values. As with FluentValidation, the framework is part of the .NET Foundation and is available either on GitHub (https://github.com/AutoMapper/AutoMapper) or NuGet (https://www.nuget.org/packages/AutoMapper). Again, in this case, we have a specific NuGet package, https://www.nuget.org/packages/AutoMapper.Extensions.Microsoft.DependencyInjection, that simplifies its integration into ASP.NET Core projects.
AutoMapper 可能是最著名的 .NET 映射框架之一。它使用 Fluent 配置 API,该 API 与基于约定的匹配算法配合使用,以将源值与目标值匹配。与 FluentValidation 一样,该框架是 .NET Foundation 的一部分,可在 GitHub (https://github.com/AutoMapper/AutoMapper) 或 NuGet (https://www.nuget.org/packages/AutoMapper) 上使用。同样,在本例中,我们有一个特定的 NuGet 包 https://www.nuget.org/packages/AutoMapper.Extensions.Microsoft.DependencyInjection,它简化了它与 ASP.NET Core 项目的集成。

Let’s take a quick look at how to integrate AutoMapper in a minimal API project, showing its main features. The full documentation of the library is available at https://docs.automapper.org.
让我们快速看一下如何将 AutoMapper 集成到一个最小的 API 项目中,展示它的主要功能。该库的完整文档可在 https://docs.automapper.org 上获得。

As usual, the first thing to do is to add the library to our project, following the same instructions we used in the previous sections. Then, we need to configure AutoMapper, telling it how to perform mapping. There are several ways to perform this task, but the recommended approach is to create classes that are inherited from the Profile base class provided by the library and put the configuration into the constructor:
像往常一样,首先要做的是按照我们在前面部分中使用的相同说明将库添加到我们的项目中。然后,我们需要配置 AutoMapper,告诉它如何执行映射。有多种方法可以执行此任务,但推荐的方法是创建从库提供的 Profile 基类继承的类,并将配置放入构造函数中:

public class PersonProfile : Profile
{
    public PersonProfile()
    {
        CreateMap<PersonEntity, PersonDto>();
    }
}

That’s all we need to start: a single instruction to indicate that we want to map PersonEntity to PersonDto, without any other details. We have said that AutoMapper is convention-based. This means that, by default, it maps properties with the same name from the source to the destination, while also performing automatic conversions into compatible types, if necessary. For example, an int property on the source can be automatically mapped to a double property with the same name on the destination. In other words, if source and destination objects have the same property, there is no need for any explicit mapping instruction. However, in our case, we need to perform some transformations, so we can add them fluently after CreateMap:
这就是我们需要开始的全部内容:一条指令,指示我们想要将 PersonEntity 映射到 PersonDto,没有任何其他细节。我们已经说过 AutoMapper 是基于约定的。这意味着,默认情况下,它将具有相同名称的属性从源映射到目标,同时还会根据需要执行自动转换为兼容类型。例如,源上的 int 属性可以自动映射到目标上具有相同名称的 double 属性。换句话说,如果源对象和目标对象具有相同的属性,则不需要任何显式映射指令。但是,在我们的示例中,我们需要执行一些转换,以便我们可以在 CreateMap 之后流畅地添加它们:

public class PersonProfile : Profile
{
    public PersonProfile()
    {
        CreateMap<PersonEntity, PersonDto>()
            .ForMember(dst => dst.Age, opt =>
           opt.MapFrom(src => CalculateAge(src.BirthDate)))
            .ForMember(dst => dst.City, opt => 
              opt.MapFrom(src => src.Address.City));
    }
    private static int CalculateAge(DateTime dateOfBirth)
    {
        var today = DateTime.Today;
        var age = today.Year - dateOfBirth.Year;
        if (today.DayOfYear < dateOfBirth.DayOfYear)
        {
            age--;
        }
        return age;
    }
}

With the ForMember method, we can specify how to map destination properties, dst.Age and dst.City, using conversion expressions. We still don’t need to explicitly map the Id, FirstName, or LastName properties because they exist with these names at both the source and destination.
使用 ForMember 方法,我们可以指定如何映射目标属性 dst。年龄和 dst。City,使用转换表达式。我们仍然不需要显式映射 Id、FirstName 或 LastName 属性,因为它们与这些名称一起存在于源和目标中。

Now that we have defined the mapping profile, we need to register it at startup so that ASP.NET Core can use it. As with FluentValidation, we can invoke an extension method on IServiceCollection:
现在我们已经定义了映射配置文件,我们需要在启动时注册它,以便 ASP.NET Core 可以使用它。与 FluentValidation 一样,我们可以在 IServiceCollection 上调用扩展方法:

builder.Services.AddAutoMapper(typeof(Program).Assembly);

With this line of code, we automatically register all the profiles that are contained in the specified assembly. If we add more profiles to our project, such as a separate Profile class for every entity to map, we don’t need to change the registration instructions.
使用这行代码,我们会自动注册指定程序集中包含的所有配置文件。如果我们向项目添加更多配置文件,例如要映射的每个实体的单独 Profile 类,则无需更改注册说明。

In this way, we can now use the IMapper interface through dependency injection:
这样,我们现在可以通过依赖注入来使用 IMapper 接口:

app.MapGet("/people/{id:int}", (int id, IMapper mapper) =>
{
    var personEntity = new PersonEntity();
    //...
    var personDto = mapper.Map<PersonDto>(personEntity);
    return Results.Ok(personDto);
})
.Produces(StatusCodes.Status200OK, typeof(PersonDto));

After retrieving PersonEntity, for example, from a database using Entity Framework Core, we call the Map method on the IMapper interface, specifying the type of the resulting object and the input class. With this line of code, AutoMapper will use the corresponding profile to convert PersonEntity into a PersonDto instance.
例如,在使用 Entity Framework Core 从数据库中检索 PersonEntity 后,我们在 IMapper 接口上调用 Map 方法,并指定结果对象的类型和输入类。通过这行代码,AutoMapper 将使用相应的配置文件将 PersonEntity 转换为 PersonDto 实例。

With this solution in place, mapping is now much easier to maintain because, as long as we add properties with the same name on the source and destination, we don’t need to change the profile at all. Moreover, AutoMapper supports list mapping and recursive mapping too. So, if we have an entity that must be mapped, such as a property of the AddressEntity type on the PersonEntity class, and the corresponding profile is available, the conversion is again performed automatically.
有了这个解决方案,映射现在更容易维护,因为只要我们在源和目标上添加具有相同名称的属性,我们就根本不需要更改配置文件。此外,AutoMapper 还支持列表映射和递归映射。因此,如果我们有一个必须映射的实体,例如 PersonEntity 类上 AddressEntity 类型的属性,并且相应的配置文件可用,则转换将再次自动执行。

The drawback of this approach is a performance overhead. AutoMapper works by dynamically executing mapping code at runtime, so it uses reflection under the hood. Profiles are created the first time they are used and then they are cached to speed up subsequent mappings. However, profiles are always applied dynamically, so there is a cost for the operation that is dependent on the complexity of the mapping code itself. We have only seen a basic example of AutoMapper. The library is very powerful and can manage quite complex mappings. However, we need to be careful not to abuse it – otherwise, we can negatively impact the performance of our application.
这种方法的缺点是性能开销。AutoMapper 的工作原理是在运行时动态执行映射代码,因此它在后台使用反射。配置文件在首次使用时创建,然后缓存以加快后续映射的速度。但是,配置文件始终是动态应用的,因此作的成本取决于映射代码本身的复杂性。我们只看到了 AutoMapper 的一个基本示例。该库非常强大,可以管理相当复杂的映射。但是,我们需要小心不要滥用它 - 否则,我们可能会对应用程序的性能产生负面影响。

Summary
总结

Validation and mapping are two important features that we need to take into account when developing APIs to build more robust and maintainable applications. Minimal APIs do not provide any built-in way to perform these tasks, so it is important to know how we can add support for this kind of feature. We have seen that we can perform validations with data annotations or using FluentValidation and how to add validation information to Swagger. We have also talked about the significance of data mapping and shown how to either leverage manual mapping or the AutoMapper library, describing the pros and cons of each approach.
验证和映射是我们在开发 API 以构建更健壮且可维护的应用程序时需要考虑的两个重要功能。Minimal API 不提供任何内置方法来执行这些任务,因此了解如何添加对此类功能的支持非常重要。我们已经看到,我们可以使用数据注释或使用 FluentValidation 执行验证,以及如何向 Swagger 添加验证信息。我们还讨论了数据映射的重要性,并展示了如何利用手动映射或 AutoMapper 库,描述了每种方法的优缺点。

In the next chapter, we will talk about how to integrate minimal APIs with a data access layer, showing, for example, how to access a database using Entity Framework Core.
在下一章中,我们将讨论如何将最小 API 与数据访问层集成,例如,展示如何使用 Entity Framework Core 访问数据库。

7 Integration with the Data Access Layer

与 Data Access Layer 集成

In this chapter, we will learn about some basic ways to add a data access layer to the minimal APIs in .NET 6.0. We will see how we can use some topics covered previously in the book to access data with Entity Framework (EF) and then with Dapper. These are two ways to access a database.
在本章中,我们将了解向 .NET 6.0 中的最小 API 添加数据访问层的一些基本方法。我们将了解如何使用本书前面介绍的一些主题,通过 Entity Framework (EF) 和 Dapper 访问数据。这是访问数据库的两种方法。

In this chapter, we will be covering the following topics:
在本章中,我们将介绍以下主题:

• Using Entity Framework
• Using Dapper

By the end of this chapter, you will be able to use EF from scratch in a minimal API project, and use Dapper for the same goal. You will also be able to tell when one approach is better than the other in a project.
在本章结束时,您将能够在最小的 API 项目中从头开始使用 EF,并将 Dapper 用于相同的目标。您还可以判断在项目中何时一种方法优于另一种方法。

Technical requirements
技术要求

To follow along with this chapter, you will need to create an ASP.NET Core 6.0 Web API application. You can use either of the following options:
要按照本章的学习,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。您可以使用以下任一选项:

• Click on the New Project option in the File menu of Visual Studio 2022, then choose the ASP.NET Core Web API template, select a name and the working directory in the wizard, and be sure to uncheck the Use controllers option in the next step.
单击 Visual Studio 2022 的“文件”菜单中的“新建项目”选项,然后选择 ASP.NET Core Web API 模板,在向导中选择名称和工作目录,并确保在下一步中取消选中“使用控制器”选项。

• Open your console, shell, or Bash terminal, and change to your working directory. Use the following command to create a new Web API application: dotnet new webapi -minimal -o Chapter07
打开您的控制台、shell 或 Bash 终端,然后切换到您的工作目录。使用以下命令创建新的 Web API 应用程序:dotnet new webapi -minimal -o Chapter07

Now, open the project in Visual Studio by double-clicking on the project file or, in Visual Studio Code, type the following command in the already open console:
现在,通过双击项目文件在 Visual Studio 中打开项目,或者在 Visual Studio Code 中,在已打开的控制台中键入以下命令:

cd Chapter07
code.

Finally, you can safely remove all the code related to the WeatherForecast sample, as we don’t need it for this chapter.
最后,您可以安全地删除与 WeatherForecast 示例相关的所有代码,因为本章不需要它。

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter07.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter07

Using Entity Framework
使用Entity Framework

We can absolutely say that if we are building an API, it is very likely that we will interact with data.
我们可以肯定地说,如果我们正在构建一个 API,我们很可能会与数据交互。

In addition, this data most probably needs to be persisted after the application restarts or after other events, such as a new deployment of the application. There are many options for persisting data in .NET applications, but EF is the most user-friendly and common solution for a lot of scenarios.
此外,这些数据很可能需要在应用程序重启后或其他事件(例如应用程序的新部署)之后保留。在 .NET 应用程序中保存数据的选项有很多,但 EF 是适用于许多方案的最用户友好和最常见的解决方案。

Entity Framework Core (EF Core) is an extensible, open source, and cross-platform data access library for .NET applications. It enables developers to work with the database by using .NET objects directly and removes, in most cases, the need to know how to write the data access code directly in the database.
Entity Framework Core (EF Core) 是一个适用于 .NET 应用程序的可扩展、开源和跨平台数据访问库。它使开发人员能够直接使用 .NET 对象来处理数据库,并且在大多数情况下,无需知道如何直接在数据库中编写数据访问代码。

On top of this, EF Core supports a lot of databases, including SQLite, MySQL, Oracle, Microsoft SQL Server, and PostgreSQL.
最重要的是,EF Core 支持许多数据库,包括 SQLite、MySQL、Oracle、Microsoft SQL Server 和 PostgreSQL。

In addition, it supports an in-memory database that helps to write tests for our applications or to make the development cycle easier because you don’t need a real database up and running.
此外,它还支持内存数据库,有助于为我们的应用程序编写测试或简化开发周期,因为您不需要启动和运行真正的数据库。

In the next section, we will see how to set up a project for using EF and its main features.
在下一节中,我们将了解如何设置使用 EF 的项目及其主要功能。

Setting up the project
设置项目

From the project root, create an Icecream.cs class and give it the following content:
从项目根目录中,创建一个 Icecream.cs 类并为其提供以下内容:

namespace Chapter07.Models;
public class Icecream
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public string? Description { get; set; }
}

The Icecream class is an object that represents an ice cream in our project. This class should be called a data model, and we will use this object in the next sections of this chapter to map it to a database table.
Icecream 类是表示我们项目中的Icecream的对象。这个类应该被称为 data model,我们将在本章的后面部分使用这个对象来 Map 它到一个数据库表。

Now it’s time to add the EF Core NuGet reference to the project.
现在,可以将 EF Core NuGet 引用添加到项目。

In order to do that, you can use one of the following methods:
为此,您可以使用以下方法之一:

• In a new terminal window, enter the following code to add the EF Core InMemory package:
在新的终端窗口中,输入以下代码以添加 EF Core InMemory 包:
dotnet add package Microsoft.EntityFrameworkCore.InMemory

• If you would like to use Visual Studio 2022 to add the reference, right-click on Dependencies and then select Manage NuGet Packages. Search for Microsoft.EntityFrameworkCore.InMemory and install the package.
如果要使用 Visual Studio 2022 添加引用,请右键单击 “依赖项”,然后选择 “管理 NuGet 包”。搜索 Microsoft.EntityFrameworkCore.InMemory 并安装该包。

In the next section, we will be adding EF Core to our project.
在下一部分中,我们将 EF Core 添加到我们的项目中。

Adding EF Core to the project
将 EF Core 添加到项目

In order to store the ice cream objects in the database, we need to set up EF Core in our project.
为了将冰淇淋对象存储在数据库中,我们需要在项目中设置 EF Core。

To set up an in-memory database, add the following code to the bottom of the Program.cs file:
要设置内存中数据库,请将以下代码添加到 Program.cs 文件的底部:

class IcecreamDb : DbContext
{
    public IcecreamDb(DbContextOptions options) :
      base(options) { }
    public DbSet<Icecream> Icecreams { get; set; } = null!;
}

DbContext object represents a connection to the database, and it’s used to save and query instances of entities in the database.
DbContext 对象表示与数据库的连接,用于保存和查询数据库中的实体实例。

The DbSet represents the instances of the entities, and they will be converted into a real table in the database.
DbSet 表示实体的实例,它们将转换为数据库中的实际表。

In this case, we will have just one table in the database, called Icecreams.
在本例中,数据库中只有一个名为 Icecreams 的表。

In Program.cs, after the builder initialization, add the following code:
在 Program.cs 中,在生成器初始化后,添加以下代码:

builder.Services.AddDbContext<IcecreamDb>(options => options.UseInMemoryDatabase("icecreams"));

Now we are ready to add some API endpoints to start interacting with the database.
现在我们准备添加一些 API 端点以开始与数据库交互。

Adding endpoints to the project
向项目添加端点

Let’s add the code to create a new item in the icecreams list. In Program.cs, add the following code before the app.Run() line of code:
让我们添加代码以在 icecreams 列表中创建一个新项目。在 Program.cs 中,在app.Run() 之前添加以下代码:

app.MapPost("/icecreams", async (IcecreamDb db, Icecream icecream) =>
{
    await db.Icecreams.AddAsync(icecream);
    await db.SaveChangesAsync();
    return Results.Created($"/icecreams/{icecream.Id}",
                           icecream);
});

The first parameter of the MapPost function is the DbContext. By default, the minimal API architecture uses dependency injection to share the instances of the DbContext.
MapPost 函数的第一个参数是 DbContext。默认情况下,最小 API 体系结构使用依赖项注入来共享 DbContext 的实例。

Dependency injection
依赖关系注入

If you want to know more about dependency injection, go to Chapter 4, Dependency Injection in a Minimal API Project.
如果您想了解有关依赖注入的更多信息,请转到第 4 章 最小 API 项目中的依赖注入。

In order to save an item into the database, we use the AddSync method directly from the entity that represents the object.
为了将项保存到数据库中,我们直接从表示对象的实体中使用 AddSync 方法。

To persist the new item in the database, we need to call the SaveChangesAsync() method, which is responsible for saving all the changes that happen to the database before the last call to SaveChangesAsync().
要在数据库中保留新项,我们需要调用 SaveChangesAsync() 方法,该方法负责保存上次调用 SaveChangesAsync() 之前对数据库发生的所有更改。

In a very similar way, we can add the endpoint to retrieve all the items in the icecreams database.
以非常相似的方式,我们可以添加终端节点来检索 icecreams 数据库中的所有项目。

After the code to add an ice cream, we can add the following code:
在添加冰淇淋的代码之后,我们可以添加以下代码:

app.MapGet("/icecreams", async (IcecreamDb db) => await db.Icecreams.ToListAsync());

Also, in this case, the DbContext is available as a parameter and we can retrieve all the items in the database directly from the entities in the DbContext.
此外,在这种情况下,DbContext 可用作参数,我们可以直接从 DbContext 中的实体检索数据库中的所有项。

With the ToListAsync() method, the application loads all the entities in the database and sends them back as the endpoint result.
使用 ToListAsync() 方法,应用程序加载数据库中的所有实体,并将它们作为终端节点结果发送回去。

Make sure you have saved all your changes in the project and run the app.
确保您已保存项目中的所有更改并运行应用程序。

A new browser window will open, and you can navigate to the /swagger URL:
将打开一个新的浏览器窗口,您可以导航到 /swagger URL:

Figure 7.1 – Swagger browser window
图 7.1 – Swagger 浏览器窗口

Select the POST/icecreams button, followed by Try it out.
选择 POST/icecreams 按钮,然后选择 Try it out。

Replace the request body content with the following JSON:
将请求正文内容替换为以下 JSON:

{
  "id": 0,
  "name": "icecream 1",
  "description": "description 1"
}

Click on Execute:
单击 Execute:

Figure 7.2 – Swagger response
图 7.2 – Swagger 响应

Now we have at least one item in the database, and we can try the other endpoint to retrieve all the items in the database.
现在,数据库中至少有一个项目,我们可以尝试使用另一个端点来检索数据库中的所有项目。

Scroll down the page a little bit and select GET/icecreams, followed by Try it out and then Execute.
向下滚动页面并选择 GET/icecreams,然后选择 Try it out,然后选择 Execute。

You will see the list with one item under Response Body.
您将在 Response Body (响应正文) 下看到带有一个项目的列表。

Let’s see how to finalize this first demo by adding the other CRUD operations to our endpoints:
让我们看看如何通过将其他 CRUD作添加到我们的端点来完成第一个演示:

  1. To get an item by ID, add the following code under the app.MapGet route you created earlier:
    要按 ID 获取项目,请在应用程序下添加app.MapGet路由代码:
app.MapGet("/icecreams/{id}", async (IcecreamDb db, int id) => await db.Icecreams.FindAsync(id));

To check this out, you can launch the application again and use the Swagger UI as before.
要检查这一点,您可以再次启动应用程序并像以前一样使用 Swagger UI。

  1. Next, add an item in the database by performing a post call (as in the previous section).
    接下来,通过执行 post 调用在数据库中添加一个项目(如上一节所示)。

  2. Click GET/icecreams/{id) followed by Try it out.
    单击 GET/icecreams/{id) 后跟 Try it out。

  3. Insert the value 1 in the id parameter field and then click on Execute.
    在 id 参数字段中插入值 1,然后单击 Execute。

  4. You will see the item in the Response Body section.
    您将在 Response Body (响应正文) 部分看到该项目。

  5. The following is an example of a response from the API:
    以下是来自 API 的响应示例:

{
  "id": 1,
  "name": "icecream 1",
  "description": "description 1"
}

This is what the response looks like:
响应如下所示:

Figure 7.3 – Response result
图 7.3 – 响应结果

To update an item by ID, we can create a new MapPut endpoint with two parameters: the item with the entity values and the ID of the old entity in the database that we want to update.
要按 ID 更新项目,我们可以创建一个具有两个参数的新 MapPut 终端节点:具有实体值的项目和数据库中要更新的旧实体的 ID。

The code should be like the following snippet:
代码应类似于以下代码段:

app.MapPut("/icecreams/{id}", async (IcecreamDb db, Icecream updateicecream, int id) =>
{
    var icecream = await db.Icecreams.FindAsync(id);
    if (icecream is null) return Results.NotFound();
    icecream.Name = updateicecream.Name;
    icecream.Description = updateicecream.Description;
    await db.SaveChangesAsync();
    return Results.NoContent();
});

Just to be clear, first of all, we need to find the item in the database with the ID from the parameters. If we don’t find an item in the database, it’s a good practice to return a Not Found HTTP status to the caller.
需要明确的是,首先,我们需要在数据库中找到具有参数中 ID 的项目。如果我们在数据库中找不到项目,最好将 Not Found HTTP 状态返回给调用者。

If we find the entity in the database, we update the entity with the new values and we save all the changes in the database before sending back the HTTP status No Content.
如果我们在数据库中找到实体,我们将使用新值更新实体,并在发回 HTTP 状态 No Content 之前保存数据库中的所有更改。

The last CRUD operation we need to perform is to delete an item from the database.
我们需要执行的最后一个 CRUD作是从数据库中删除一个项目。

This operation is very similar to the update operation because, first of all, we need to find the item in the database and then we can try to perform the delete operation.
此操作与更新作非常相似,因为首先,我们需要在数据库中找到该项目,然后我们可以尝试执行删除作。

The following code snippet shows how to implement a delete operation with the right HTTP verb of the minimal API:
以下代码片段显示了如何使用最小 API 的正确 HTTP 动词实施删除作:

app.MapDelete("/icecreams/{id}", async (IcecreamDb db, int id) =>
{
    var icecream = await db.Icecreams.FindAsync(id);
    if (icecream is null)
    {
        return Results.NotFound();
    }
    db.Icecreams.Remove(icecream);
    await db.SaveChangesAsync();
    return Results.Ok();
});

In this section, we have learned how to use EF in a minimal API project.
在本节中,我们学习了如何在最小 API 项目中使用 EF。

We saw how to add the NuGet packages to start working with EF, and how to implement the entire set of CRUD operations in a minimal API .NET 6 project.
我们了解了如何添加 NuGet 包以开始使用 EF,以及如何在最小的 API .NET 6 项目中实现整套 CRUD作。

In the next section, we will see how to implement the same project with the same logic but using Dapper as the primary library to access data.
在下一节中,我们将了解如何使用相同的逻辑实现相同的项目,但使用 Dapper 作为主库来访问数据。

Using Dapper
使用 Dapper

Dapper is an Object-Relational Mapper (ORM) or, to be more precise, a micro ORM. With Dapper, we can write SQL statements directly in .NET projects like we can do in SQL Server (or another database). One of the best advantages of using Dapper in a project is the performance, because it doesn’t translate queries from .NET objects and doesn’t add any layers between the application and the library to access the database. It extends the IDbConnection object and provides a lot of methods to query the database. This means we have to write queries that are compatible with the database provider.
Dapper 是一个对象关系映射器 (ORM),或者更准确地说,是一个微型 ORM。使用 Dapper,我们可以直接在 .NET 项目中编写 SQL 语句,就像在 SQL Server(或其他数据库)中一样。在项目中使用 Dapper 的最大优势之一是性能,因为它不会转换来自 .NET 对象的查询,也不会在应用程序和库之间添加任何层来访问数据库。它扩展了 IDbConnection 对象,并提供了许多查询数据库的方法。这意味着我们必须编写与数据库提供程序兼容的查询。

It supports synchronous and asynchronous method executions. This is a list of the methods that Dapper adds to the IDbConnection interface:
它支持同步和异步方法执行。以下是 Dapper 添加到 IDbConnection 接口的方法列表:

• Execute
• Query
• QueryFirst
• QueryFirstOrDefault
• QuerySingle
• QuerySingleOrDefault
• QueryMultiple

As we mentioned, it provides an async version for all these methods. You can find the right methods by adding the Async keyword at the end of the method name.
正如我们所提到的,它为所有这些方法提供了一个异步版本。您可以通过在方法名称的末尾添加 Async 关键字来查找正确的方法。

In the next section, we will see how to set up a project for using Dapper with a SQL Server LocalDB.
在下一节中,我们将了解如何设置一个项目,以便将 Dapper 与 SQL Server LocalDB 结合使用。

Setting up the project
设置项目

The first thing we are going to do is to create a new database. You can use your SQL Server LocalDB instance installed with Visual Studio by default or another SQL Server instance in your environment.
我们要做的第一件事是创建一个新数据库。您可以使用默认随 Visual Studio 一起安装的 SQL Server LocalDB 实例,也可以使用环境中的其他 SQL Server 实例。

You can execute the following script in your database to create one table and populate it with data:
您可以在数据库中执行以下脚本来创建一个表并使用数据填充它:

CREATE TABLE [dbo].[Icecreams](
     [Id] [int] IDENTITY(1,1) NOT NULL,
     [Name] [nvarchar](50) NOT NULL,
     [Description] [nvarchar](255) NOT NULL)
GO
INSERT [dbo].[Icecreams] ([Name], [Description]) VALUES ('Icecream 1','Description 1')
INSERT [dbo].[Icecreams] ([Name], [Description]) VALUES ('Icecream 2','Description 2')
INSERT [dbo].[Icecreams] ([Name], [Description]) VALUES ('Icecream 3','Description 3')

Once we have the database, we can install these NuGet packages with the following command in the Visual Studio terminal:
拥有数据库后,我们可以在 Visual Studio 终端中使用以下命令安装这些 NuGet 包:

Install-Package Dapper
Install-Package Microsoft.Data.SqlClient

Now we can continue to add the code to interact with the database. In this example, we are going to use a repository pattern.
现在我们可以继续添加代码以与数据库交互。在此示例中,我们将使用存储库模式。

Creating a repository pattern
创建存储库模式

In this section, we are going to create a simple repository pattern, but we will try to make it as simple as possible so we can understand the main features of Dapper:
在本节中,我们将创建一个简单的存储库模式,但我们将尝试使其尽可能简单,以便我们了解 Dapper 的主要功能:

  1. In the Program.cs file, add a simple class that represents our entity in the database:
    public class Icecream
    在 Program.cs 文件中,添加一个表示数据库中实体的简单类:

    {
    public int Id { get; set; }
    public string? Name { get; set; }
    public string? Description { get; set; }
    }
  2. After this, modify the appsettings.json file by adding the connection string at the end of the file:
    在此之后,通过在文件末尾添加连接字符串来修改 appsettings.json 文件:

    "ConnectionStrings": {
    "SqlConnection":
      "Data Source=(localdb)\\MSSQLLocalDB;
       Initial Catalog=Chapter07;
       Integrated Security=True;
       Connect Timeout=30;
       Encrypt=False;
       TrustServerCertificate=False;"
    }

If you are using LocalDB, the connection string should be the right one for your environment as well.
如果您使用的是 LocalDB,则连接字符串也应适合您的环境。

  1. Create a new class in the root of the project called DapperContext and give it the following code:
    在项目的根目录中创建一个名为 DapperContext 的新类,并为其提供以下代码:

    public class DapperContext
    {
    private readonly IConfiguration _configuration;
    private readonly string _connectionString;
    public DapperContext(IConfiguration configuration)
    {
        _configuration = configuration;
        _connectionString = _configuration
          .GetConnectionString("SqlConnection");
    }
    
    public IDbConnection CreateConnection()
        => new SqlConnection(_connectionString);
    }

We injected with dependency injection the IConfiguration interface to retrieve the connection string from the settings file.
我们通过依赖项注入注入 IConfiguration 接口从设置文件中检索连接字符串。

  1. Now we are going to create the interface and the implementation of our repository. In order to do that, add the following code to the Program.cs file.
    现在,我们将创建接口和存储库的实现。为此,请将以下代码添加到 Program.cs 文件中。
public interface IIcecreamsRepository
{
}
public class IcecreamsRepository : IIcecreamsRepository
{
    private readonly DapperContext _context;
    public IcecreamsRepository(DapperContext context)
    {
        _context = context;
    }
}

In the next sections, we will be adding some code to the interface and to the implementation of the repository.
在接下来的部分中,我们将向接口和存储库的实现添加一些代码。

Finally, we can register the context, the interface, and its implementation as a service.
在接下来的部分中,我们将向接口和存储库的实现添加一些代码。

  1. Let’s put the following code after the builder initialization in the Program.cs file:
    让我们在 builder 初始化后将以下代码放入 Program.cs 文件中:

    builder.Services.AddSingleton<DapperContext>();
    builder.Services.AddScoped<IIcecreamsRepository, IcecreamsRepository>();

Now we are ready to implement the first query.
现在我们已准备好实现第一个查询。

Using Dapper to query the database
使用 Dapper 查询数据库

First of all, let’s modify the IIcecreamsRepository interface by adding a new method:
首先,我们通过添加新方法来修改 IIcecreamsRepository 接口:

public Task<IEnumerable<Icecream>> GetIcecreams();

Then, let’s implement this method in the IcecreamsRepository class:
然后,让我们在 IcecreamsRepository 类中实现此方法:

public async Task<IEnumerable<Icecream>> GetIcecreams()
{
    var query = "SELECT * FROM Icecreams";
    using (var connection = _context.CreateConnection())
    {
        var result = 
          await connection.QueryAsync<Icecream>(query);
        return result.ToList();
    }
}

Let’s try to understand all the steps in this method. We created a string called query, where we store the SQL query to fetch all the entities from the database.
让我们尝试了解此方法中的所有步骤。我们创建了一个名为 query 的字符串,我们在其中存储 SQL 查询以从数据库中获取所有实体。

Then, inside the using statement, we used DapperContext to create the connection.
然后,在 using 语句中,我们使用 DapperContext 创建连接。

Once the connection was created, we used it to call the QueryAsync method and passed the query as an argument.
创建连接后,我们使用它来调用 QueryAsync 方法并将查询作为参数传递。

Dapper, when the results return from the database, converted them into IEnumerable<T> automatically.
当结果从数据库返回时,Dapper 会自动将它们转换为 IEnumerable<T>

The following is the final code of the interface and our first implementation:
以下是接口的最终代码和我们的第一个实现:

public interface IIcecreamsRepository
{
    public Task<IEnumerable<Icecream>> GetIcecreams();
}
public class IcecreamsRepository : IIcecreamsRepository
{
    private readonly DapperContext _context;
    public IcecreamsRepository(DapperContext context)
    {
        _context = context;
    }
    public async Task<IEnumerable<Icecream>> GetIcecreams()
    {
        var query = "SELECT * FROM Icecreams";
        using (var connection =
              _context.CreateConnection())
        {
            var result = 
              await connection.QueryAsync<Icecream>(query);
            return result.ToList();
        }
    }
}

In the next section, we will see how to add a new entity to the database and how to use the ExecuteAsync method to run a query.
在下一节中,我们将了解如何向数据库添加新实体,以及如何使用 ExecuteAsync 方法运行查询。

Adding a new entity in the database with Dapper
使用 Dapper 在数据库中添加新实体

Now we are going to manage adding a new entity to the database for future implementations of the API post request.
现在,我们将管理向数据库添加新实体,以便将来实现 API post 请求。

Let’s modify the interface by adding a new method called CreateIcecream with an input parameter of the Icecream type:
让我们通过添加一个名为 CreateIcecream 的新方法来修改接口,该方法的输入参数为 Icecream 类型:

public Task CreateIcecream(Icecream icecream);

Now we must implement this method in the repository class:
现在我们必须在 repository 类中实现此方法:

public async Task CreateIcecream(Icecream icecream)
{
    var query = "INSERT INTO Icecreams (Name, Description)
      VALUES (@Name, @Description)";
    var parameters = new DynamicParameters();
    parameters.Add("Name", icecream.Name, DbType.String);
    parameters.Add("Description", icecream.Description,
                    DbType.String);
    using (var connection = _context.CreateConnection())
    {
        await connection.ExecuteAsync(query, parameters);
    }
}

Here, we create the query and a dynamic parameters object to pass all the values to the database.
在这里,我们创建查询和动态参数对象,以将所有值传递给数据库。

We populate the parameters with the values from the Icecream object in the method parameter.
我们在 method 参数中使用 Icecream 对象的值填充参数。

We create the connection with the Dapper context and then we use the ExecuteAsync method to execute the INSERT statement.
我们使用 Dapper 上下文创建连接,然后使用 ExecuteAsync 方法执行 INSERT 语句。

This method returns an integer value as a result, representing the number of affected rows in the database. In this case, we don’t use this information, but you can return this value as the result of the method if you need it.
此方法返回一个整数值作为结果,该值表示数据库中受影响的行数。在这种情况下,我们不会使用此信息,但如果需要,可以将此值作为方法的结果返回。

Implementing the repository in the endpoints
在端点中实施存储库

To add the final touch to our minimal API, we need to implement the two endpoints to manage all the methods in our repository pattern:
为了对我们的最小 API 进行最后的润色,我们需要实现两个端点来管理存储库模式中的所有方法:

app.MapPost("/icecreams", async (IIcecreamsRepository repository, Icecream icecream) =>
{
    await repository.CreateIcecream(icecream);
    return Results.Ok();
});
app.MapGet("/icecreams", async (IIcecreamsRepository repository) => await repository.GetIcecreams());

In both map methods, we pass the repository as a parameter because, as usual in the minimal API, the services are passed as parameters in the map methods.
在这两种 map 方法中,我们都将存储库作为参数传递,因为与最小 API 一样,服务在 map 方法中作为参数传递。

This means that the repository is always available in all parts of the code.
这意味着存储库在代码的所有部分中始终可用。

In the MapGet endpoint, we use the repository to load all the entities from the implementation of the repository and we use the result as the result of the endpoint.
在 MapGet 端点中,我们使用存储库加载存储库实现中的所有实体,并将结果用作端点的结果。

In the MapPost endpoint, in addition to the repository parameter, we accept also the Icecream entity from the body of the request and we use the same entity as a parameter to the CreateIcecream method of the repository.
在 MapPost 终端节点中,除了存储库参数之外,我们还接受请求正文中的 Icecream 实体,并将同一实体用作存储库的 CreateIcecream 方法的参数。

Summary
总结

In this chapter, we learned how to interact with a data access layer in a minimal API project with the two most common tools in a real-world scenario: EF and Dapper.
在本章中,我们学习了如何使用实际场景中最常用的两种工具(EF 和 Dapper)与最小 API 项目中的数据访问层进行交互。

For EF, we covered some basic features, such as setting up a project to use this ORM and how to perform some basic operations to implement a full CRUD API endpoint.
对于 EF,我们介绍了一些基本功能,例如设置项目以使用此 ORM,以及如何执行一些基本作来实现完整的 CRUD API 终端节点。

We did basically the same thing with Dapper as well, starting from an empty project, adding Dapper, setting up the project for working with a SQL Server LocalDB, and implementing some basic interactions with the entities of the database.
我们对 Dapper 也做了基本相同的作,从一个空项目开始,添加 Dapper,设置项目以使用 SQL Server LocalDB,并实现与数据库实体的一些基本交互。

In the next chapter, we’ll focus on authentication and authorization in a minimal API project. It’s important, first of all, to protect your data in the database.
在下一章中,我们将重点介绍最小 API 项目中的身份验证和授权。首先,保护数据库中的数据很重要。

Part 3: Advanced Development and Microservices Concepts

第 3 部分:高级开发和微服务概念

In this advanced section of the book, we want to show more scenarios that are typical in backend development. We will also go over the performance of this new framework and understand the scenarios in which it is really useful.
在本书的这个高级部分,我们想展示更多后端开发中的典型场景。我们还将介绍这个新框架的性能,并了解它真正有用的场景。

We will cover the following chapters in this section:
在本节中,我们将介绍以下章节:

Chapter 8, Adding Authentication and Authorization
第 8 章 添加验证和授权

Chapter 9, Leveraging Globalization and Localization
第 9 章 利用全球化和本地化

Chapter 10, Evaluating and Benchmarking the Performance of Minimal APIs
第 10 章 评估最小 API 的性能并对其进行基准测试

8 Adding Authentication and Authorization

8 添加身份验证和授权

Any kind of application must deal with authentication and authorization. Often, these terms are used interchangeably, but they actually refer to different scenarios. In this chapter of the book, we will explain the difference between authentication and authorization and show how to add these features to a minimal API project.
任何类型的应用程序都必须处理身份验证和授权。通常,这些术语可以互换使用,但它们实际上指的是不同的场景。在本书的这一章中,我们将解释身份验证和授权之间的区别,并展示如何将这些功能添加到最小的 API 项目中。

Authentication can be performed in many different ways: using local accounts with external login providers, such as Microsoft, Google, Facebook, and Twitter; using Azure Active Directory and Azure B2C; and using authentication servers such as Identity Server and Okta. Moreover, we may have to deal with requirements such as two-factor authentication and refresh tokens. In this chapter, however, we will focus on the general aspects of authentication and authorization and see how to implement them in a minimal API project, in order to provide a general understanding of the topic. The information and samples that will be provided will show how to effectively work with authentication and authorization and how to customize their behaviors according to our requirements.
可以通过多种不同的方式执行身份验证:使用外部登录提供程序(如 Microsoft、Google、Facebook 和 Twitter)的本地帐户;使用 Azure Active Directory 和 Azure B2C;以及使用 Identity Server 和 Okta 等身份验证服务器。此外,我们可能必须处理双重身份验证和刷新令牌等要求。但是,在本章中,我们将重点介绍身份验证和授权的一般方面,并了解如何在最小的 API 项目中实现它们,以便对该主题有一个大致的理解。将提供的信息和示例将展示如何有效地使用身份验证和授权,以及如何根据我们的要求自定义它们的行为。

In this chapter, we will be covering the following topics:
在本章中,我们将介绍以下主题:

• Introducing authentication and authorization
身份验证和授权简介

• Protecting a minimal API
保护最小 API

• Handling authorization – roles and policies
处理授权 – 角色和策略

Technical requirements
技术要求

To follow the examples in this chapter, you will need to create an ASP.NET Core 6.0 Web API application. Refer to the Technical requirements section in Chapter 2, Exploring Minimal APIs and Their Advantages, for instructions on how to do so.
要遵循本章中的示例,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。有关如何执行此作的说明,请参阅第 2 章 “探索最小 API 及其优势”中的“技术要求”部分。

If you’re using your console, shell, or Bash terminal to create the API, remember to change your working directory to the current chapter number: Chapter08.
如果您使用控制台、shell 或 Bash 终端创建 API,请记住将工作目录更改为当前章节编号:Chapter08。

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter08.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter08

Introducing authentication and authorization
身份验证和授权简介

As said at the beginning, the terms authentication and authorization are often used interchangeably, but they represent different security functions. Authentication is the process of verifying that users are who they say they are, while authorization is the task of granting an authenticated user permission to do something. So, authorization must always follow authentication.
如开头所述,术语 authentication 和 authorization 经常互换使用,但它们代表不同的安全功能。身份验证是验证用户是否是他们所声称的身份的过程,而授权是授予经过身份验证的用户执行某项作的权限的任务。因此,授权必须始终遵循身份验证。

Let’s think about the security in an airport: first, you show your ID to authenticate your identity; then, at the gate, you present the boarding pass to be authorized to board the flight and get access to the plane.
让我们考虑一下机场的安检:首先,您出示您的身份证以验证您的身份;然后,在登机口,您出示登机牌以获得登机和登机权。

Authentication and authorization in ASP.NET Core are handled by corresponding middleware and work in the same way in minimal APIs and controller-based projects. They allow the restriction of access to endpoints depending on user identity, roles, policies, and so on, as we’ll see in detail in the following sections.
ASP.NET Core 中的身份验证和授权由相应的中间件处理,并且在最小 API 和基于控制器的项目中以相同的方式工作。它们允许根据用户身份、角色、策略等限制对终端节点的访问,我们将在以下部分中详细介绍。

You can find a great overview of ASP.NET Core authentication and authorization in the official documentation available at https://docs.microsoft.com/aspnet/core/security/authentication and https://docs.microsoft.com/aspnet/core/security/authorization.
您可以在 https://docs.microsoft.com/aspnet/core/security/authenticationhttps://docs.microsoft.com/aspnet/core/security/authorization 上提供的官方文档中找到 ASP.NET Core 身份验证和授权的精彩概述。

Protecting a minimal API
保护最小 API

Protecting a minimal API means correctly setting up authentication and authorization. There are many types of authentication solutions that are adopted in modern applications. In web applications, we typically use cookies, while when dealing with web APIs, we use methods such as an API key, basic authentication, and JSON Web Token (JWT). JWTs are the most commonly used, and in the rest of the chapter, we’ll focus on this solution.
保护最小 API 意味着正确设置身份验证和授权。现代应用程序中采用的身份验证解决方案有多种类型。在 Web 应用程序中,我们通常使用 cookie,而在处理 Web API 时,我们使用 API 密钥、基本身份验证和 JSON Web 令牌 (JWT) 等方法。JWT 是最常用的,在本章的其余部分,我们将重点介绍此解决方案。

Note : A good starting point to understand what JWTs are and how they are used is available at https://jwt.io/introduction.
注意 : 了解 JWT 是什么以及如何使用 JWT 的良好起点位于 https://jwt.io/introduction

To enable authentication and authorization based on JWT, the first thing to do is to add the Microsoft.AspNetCore.Authentication.JwtBearer NuGet package to our project, using one of the following ways:
要启用基于 JWT 的身份验证和授权,首先要做的是使用以下方法之一将 Microsoft.AspNetCore.Authentication.JwtBearer NuGet 包添加到我们的项目中:

• Option 1: If you’re using Visual Studio 2022, right-click on the project and choose the Manage NuGet Packages command to open Package Manager GUI, then search for Microsoft.AspNetCore.Authentication.JwtBearer and click on Install.
选项 1:如果您使用的是 Visual Studio 2022,请右键单击项目并选择“管理 NuGet 包”命令以打开包管理器 GUI,然后搜索 Microsoft.AspNetCore.Authentication.JwtBearer 并单击“安装”。

• Option 2: Open Package Manager Console if you’re inside Visual Studio 2022, or open your console, shell, or Bash terminal, go to your project directory, and execute the following command:
选项 2:如果您在 Visual Studio 2022 中,请打开包管理器控制台,或者打开控制台、shell 或 Bash 终端,转到您的项目目录,然后执行以下命令:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Now, we need to add authentication and authorization services to the service provider, so that they are available through dependency injection:
现在,我们需要向服务提供商添加身份验证和授权服务,以便它们可以通过依赖项注入使用:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorization();

This is the minimum code that is necessary to add JWT authentication and authorization support to an ASP.NET Core project. It isn’t a real working solution yet, because it is missing the actual configuration, but it is enough to verify how endpoint protection works.
这是向 ASP.NET Core 项目添加 JWT 身份验证和授权支持所需的最少代码。它还不是一个真正的有效解决方案,因为它缺少实际配置,但足以验证 Endpoint Protection 的工作原理。

In the AddAuthentication() method, we specify that we want to use the bearer authentication scheme. This is an HTTP authentication scheme that involves security tokens that are in fact called bearer tokens. These tokens must be sent in the Authorization HTTP header with the format Authorization: Bearer <token>. Then, we call AddJwtBearer() to tell ASP.NET Core that it must expect a bearer token in the JWT format. As we’ll see later, the bearer token is an encoded string generated by the server in response to a login request. After that, we use AddAuthorization() to also add authorization services.
在 AddAuthentication() 方法中,我们指定要使用不记名身份验证方案。这是一种 HTTP 身份验证方案,它涉及实际上称为持有者令牌的安全令牌。这些令牌必须在 Authorization HTTP 标头中以 Authorization: Bearer <token>格式发送。然后,我们调用 AddJwtBearer() 来告诉 ASP.NET Core 它必须需要 JWT 格式的不记名令牌。正如我们稍后将看到的,持有者令牌是服务器为响应登录请求而生成的编码字符串。之后,我们使用 AddAuthorization() 也添加授权服务。

Now, we need to insert authentication and authorization middleware in the pipeline so that ASP.NET Core will be instructed to check the token and apply all the authorization rules:
现在,我们需要在管道中插入身份验证和授权中间件,以便指示 ASP.NET Core 检查令牌并应用所有授权规则:

var app = builder.Build();
//..
app.UseAuthentication();
app.UseAuthorization();
//...
app.Run();

Important Note : We have said that authorization must follow authentication. This means that the authentication middleware must come first; otherwise, the security will not work as expected.
重要提示 : 我们已经说过,授权必须在身份验证之后进行。这意味着身份验证中间件必须放在第一位;否则,安全性将无法按预期工作。

Finally, we can protect our endpoints using the Authorize attribute or the RequireAuthorization() method:
最后,我们可以使用 Authorize 属性或 RequireAuthorization() 方法保护我们的端点:

app.MapGet("/api/attribute-protected", [Authorize] () => "This endpoint is protected using the Authorize attribute");
app.MapGet("/api/method-protected", () => "This endpoint is protected using the RequireAuthorization method")
.RequireAuthorization();

Note : The ability to specify an attribute directly on a lambda expression (as in the first endpoint of the previous example) is a new feature of C# 10.
注意 : 直接在 lambda 表达式上指定属性的功能(如上一个示例的第一个终结点所示)是 C# 10 的一项新功能。

If we now try to call each of these methods using Swagger, we’ll get a 401 unauthorized response, which should look as follows:
如果我们现在尝试使用 Swagger 调用这些方法中的每一个,我们将得到一个 401 未授权的响应,它应该如下所示:

Figure 8.1 – Unauthorized response in Swagger
图 8.1 – Swagger 中未经授权的响应

Note that the message contains a header indicating that the expected authentication scheme is Bearer, as we have declared in the code.
请注意,该消息包含一个标头,指示预期的身份验证方案是 Bearer,正如我们在代码中声明的那样。

So, now we know how to restrict access to our endpoints to authenticated users. But our work isn’t finished: we need to generate a JWT bearer, validate it, and find a way to pass such a token to Swagger so that we can test our protected endpoints.
因此,现在我们知道如何将对终端节点的访问限制为经过身份验证的用户。但我们的工作还没有完成:我们需要生成一个 JWT bearer,验证它,并找到一种方法将这样的令牌传递给 Swagger,以便我们可以测试受保护的端点。

Generating a JWT bearer
生成 JWT 持有者

We have said that a JWT bearer is generated by the server as a response to a login request. ASP.NET Core provides all the APIs we need to create it, so let’s see how to perform this task.
我们已经说过,JWT bearer 是由服务器生成的,作为对登录请求的响应。ASP.NET Core 提供了创建它所需的所有 API,让我们看看如何执行此任务。

The first thing to do is to define the login request endpoint to authenticate the user with their username and password:
首先要做的是定义登录请求端点,以使用用户的用户名和密码对用户进行身份验证:

app.MapPost("/api/auth/login", (LoginRequest request) =>
{
    if (request.Username == "marco" && request.Password == 
        "P@$$w0rd")
    {
        // Generate the JWT bearer...
    }
    return Results.BadRequest();
});

For the sake of simplicity, in the preceding example, we have used hardcoded values, but in a real application, we’d use, for example, ASP.NET Core Identity, the part of ASP.NET Core that is responsible for user management. More information on this topic is available in the official documentation at https://docs.microsoft.com/aspnet/core/security/authentication/identity.
为简单起见,在前面的示例中,我们使用了硬编码值,但在实际应用程序中,我们将使用 ASP.NET Core Identity,这是 Core 中负责用户管理 ASP.NET 部分。有关此主题的更多信息,请参阅 https://docs.microsoft.com/aspnet/core/security/authentication/identity 的官方文档。

In a typical login workflow, if the credentials are invalid, we return a 400 Bad Request response to the client. If, instead, the username and password are correct, we can effectively generate a JWT bearer, using the classes available in ASP.NET Core:
在典型的登录工作流程中,如果凭证无效,我们会向客户端返回 400 Bad Request 响应。相反,如果用户名和密码正确,我们可以使用 ASP.NET Core 中可用的类有效地生成 JWT bearer:

var claims = new List<Claim>()
{
    new(ClaimTypes.Name, request.Username)
};
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("mysecuritystring"));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var jwtSecurityToken = new JwtSecurityToken(
    issuer: "https://www.packtpub.com",
    audience: "Minimal APIs Client",
    claims: claims, expires: DateTime.UtcNow.AddHours(1), 
      signingCredentials: credentials);
var accessToken = new JwtSecurityTokenHandler()
  .WriteToken(jwtSecurityToken);
return Results.Ok(new { AccessToken = accessToken });

JWT bearer creation involves many different concepts, but through the preceding code example, we’ll focus on the basic ones. This kind of bearer contains information that allows verifying the user identity, along with other declarations that describe the properties of the user. These properties are called claims and are expressed as string key-value pairs. In the preceding code, we created a list with a single claim that contains the username. We can add as many claims as we need, and we can also have claims with the same name. In the next sections, we’ll see how to use claims, for example, to enforce authorization.
JWT bearer 创建涉及许多不同的概念,但通过前面的代码示例,我们将重点介绍基本概念。这种类型的 bearer 包含允许验证用户身份的信息,以及描述用户属性的其他声明。这些属性称为声明,表示为字符串键值对。在前面的代码中,我们创建了一个列表,其中包含一个包含用户名的声明。我们可以根据需要添加任意数量的声明,也可以拥有具有相同名称的声明。在接下来的部分中,我们将了解如何使用声明,例如,强制实施授权。

Next in the preceding code, we defined the credentials (SigningCredentials) to sign the JWT bearer. The signature depends on the actual token content and is used to check that the token hasn’t been tampered with. In fact, if we change anything in the token, such as a claim value, the signature will consequentially change. As the key to sign the bearer is known only by the server, it is impossible for a third party to modify the token and sustain its validity. In the preceding code, we used SymmetricSecurityKey, which is never shared with clients.
接下来,在前面的代码中,我们定义了凭证 (SigningCredentials) 来对 JWT 持有者进行签名。签名取决于实际的 Token 内容,用于检查 Token 是否未被篡改。事实上,如果我们更改 Token 中的任何内容,例如声明值,签名也会随之更改。由于对 bearer 进行签名的密钥只有服务器知道,因此第三方无法修改 Token 并维持其有效性。在上面的代码中,我们使用了 SymmetricSecurityKey,它永远不会与客户端共享。

We used a short string to create the credentials, but the only requirement is that the key should be at least 32 bytes or 16 characters long. In .NET, strings are Unicode and therefore, each character takes 2 bytes. We also needed to set the algorithm that the credentials will use to sign the token. To this end, we have specified the Hash-Based Message Authentication Code (HMAC) and the hash function, SHA256, specifying the SecurityAlgorithms.HmacSha256 value. This algorithm is quite a common choice in these kinds of scenarios.
我们使用了一个短字符串来创建凭证,但唯一的要求是密钥应至少为 32 字节或 16 个字符长。在 .NET 中,字符串是 Unicode,因此每个字符占用 2 个字节。我们还需要设置凭证将用于对令牌进行签名的算法。为此,我们指定了基于哈希的消息身份验证代码 (HMAC) 和哈希函数 SHA256,并指定了 SecurityAlgorithms.HmacSha256 值。在这类场景中,这种算法是一个非常常见的选择。

Note : You can find more information about the HMAC and the SHA256 hash function at https://docs.microsoft.com/dotnet/api/system.security.cryptography.hmacsha256#remarks.
注意 : 您可以在 https://docs.microsoft.com/dotnet/api/system.security.cryptography.hmacsha256#remarks 中找到有关 HMAC 和 SHA256 哈希函数的更多信息。

By this point in the preceding code, we finally have all the information to create the token, so we can instantiate a JwtSecurityToken object. This class can use many parameters to build the token, but for the sake of simplicity, we have specified only the minimum set for a working example:
在前面的代码中,到这一点时,我们终于拥有了创建令牌的所有信息,因此我们可以实例化 JwtSecurityToken 对象。这个类可以使用许多参数来构建令牌,但为了简单起见,我们只为工作示例指定了最小集:

Issuer: A string (typically a URI) that identifies the name of the entity that is creating the token
颁发者:一个字符串(通常是 URI),用于标识创建令牌的实体的名称

Audience: The recipient that the JWT is intended for, that is, who can consume the token
受众:JWT 的目标接收者,即可以使用令牌的用户

The list of claims
索赔列表

The expiration time of the token (in UTC)
Token 的过期时间(UTC 单位)

The signing credentials
签名凭证

Tip In the preceding code example, values used to build the token are hardcoded, but in a real-life application, we should place them in an external source, for example, in the appsettings.json configuration file.
提示 : 在前面的代码示例中,用于构建令牌的值是硬编码的,但在实际应用程序中,我们应该将它们放在外部源中,例如,在 appsettings.json 配置文件中。

You can find further information on creating a token at https://docs.microsoft.com/dotnet/api/system.identitymodel.tokens.jwt.jwtsecuritytoken.
您可以在 https://docs.microsoft.com/dotnet/api/system.identitymodel.tokens.jwt.jwtsecuritytoken 中找到有关创建令牌的更多信息。

After all the preceding steps, we could create JwtSecurityTokenHandler, which is responsible for actually generating the bearer token and returning it to the caller with a 200 OK response.
完成上述所有步骤后,我们可以创建 JwtSecurityTokenHandler,它负责实际生成不记名令牌并将其返回给调用方,并给出 200 OK 响应。

So, now we can try the login endpoint in Swagger. After inserting the correct username and password and clicking the Execute button, we will get the following response:
所以,现在我们可以尝试 Swagger 中的登录端点。在插入正确的用户名和密码并单击 Execute 按钮后,我们将得到以下响应:

Figure 8.2 – The JWT bearer as a result of the login request in Swagger
图 8.2 – Swagger 中登录请求的结果 JWT 持有者

We can copy the token value and insert it in the URL of the site https://jwt.ms to see what it contains. We’ll get something like this:
我们可以复制 token 值并将其插入到站点的 URL 中 https://jwt.ms 以查看它包含的内容。我们将得到如下结果:

{
  "alg": "HS256",
  "typ": "JWT"
}.{
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "marco",
  "exp": 1644431527,
  "iss": "https://www.packtpub.com",
  "aud": "Minimal APIs Client"
}.[Signature]

In particular, we see the claims that have been configured:
具体而言,我们会看到已配置的声明:

• name: The name of the logged user
name:已登录用户的名称

• exp: The token expiration time, expressed in Unix epoch
exp:Token 过期时间,以 Unix 纪元表示

• iss: The issuer of the token
iss:令牌的发行者

• aud: The audience (receiver) of the token
aud:令牌的受众(接收者)

This is the raw view, but we can switch to the Claims tab to see the decoded list of all the claims, with a description of their meaning, where available.
这是原始视图,但我们可以切换到 Claims 选项卡,查看所有声明的解码列表,以及其含义的描述(如果可用)。

There is one important point that requires attention: by default, the JWT bearer isn’t encrypted (it’s just a Base64-encoded string), so everyone can read its content. Token security does not depend on the inability to be decoded, but on the fact that it is signed. Even if the token’s content is clear, it is impossible to modify it because in this case, the signature (which uses a key that is known only by the server) will become invalid.
有一点需要注意:默认情况下,JWT bearer 未加密(它只是一个 Base64 编码的字符串),因此每个人都可以读取其内容。令牌安全性不取决于无法解码,而是取决于它是否已签名。即使 Token 的内容很清楚,也无法修改它,因为在这种情况下,签名(使用只有服务器知道的密钥)将失效。

So, it’s important not to insert sensitive data in the token; claims such as usernames, user IDs, and roles are usually fine, but, for example, we should not insert information related to privacy. To give a deliberately exaggerated example, we mustn’t insert a credit card number in the token! In any case, keep in mind that even Microsoft for Azure Active Directory uses JWT, with no encryption, so we can trust this security system.
因此,不要在令牌中插入敏感数据非常重要;用户名、用户 ID 和角色等声明通常没问题,但例如,我们不应插入与隐私相关的信息。举一个故意夸大的例子,我们不能在令牌中插入信用卡号!无论如何,请记住,即使是 Microsoft for Azure Active Directory 也使用 JWT,没有加密,因此我们可以信任这个安全系统。

In conclusion, we have described how to obtain a valid JWT. The next steps are to pass the token to our protected endpoints and instruct our minimal API on how to validate it.
总之,我们已经描述了如何获取有效的 JWT。接下来的步骤是将令牌传递给我们受保护的终端节点,并指示我们的最小 API 如何验证它。

Validating a JWT bearer
验证 JWT 持有者

After creating the JWT bearer, we need to pass it in every HTTP request, inside the Authorization HTTP header, so that ASP.NET Core can verify its validity and allow us to invoke the protected endpoints. So, we have to complete the AddJwtBearer() method invocation that we showed earlier with the description of the rules to validate the bearer:
创建 JWT 不记名后,我们需要在 Authorization HTTP 标头内的每个 HTTP 请求中传递它,以便 ASP.NET Core 可以验证其有效性并允许我们调用受保护的端点。因此,我们必须完成之前展示的 AddJwtBearer() 方法调用,其中包含验证 bearer 的规则说明:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(
          Encoding.UTF8.GetBytes("mysecuritystring")),
        ValidIssuer = "https://www.packtpub.com",
        ValidAudience = "Minimal APIs Client"
    };
});

In the preceding code, we added a lambda expression with which we defined the TokenValidationParameter object that contains the token validation rules. First of all, we checked the issuer signing key, that is, the signature of the token, as shown in the Generating a JWT bearer section, to verify that the JWT has not been tampered with. The security string that has been used to sign the token is required to perform this check, so we specify the same value (mysecuritystring) that we inserted during the login request.
在前面的代码中,我们添加了一个 lambda 表达式,我们用该表达式定义了包含令牌验证规则的 TokenValidationParameter 对象。首先,我们检查了颁发者的签名密钥,即 Token 的签名,如 生成 JWT bearer 部分所示,以验证 JWT 是否未被篡改。执行此检查需要用于对令牌进行签名的安全字符串,因此我们指定了在登录请求期间插入的相同值 (mysecuritystring)。

Then, we specify what valid values for the issuer and the audience of the token are. If the token has been emitted from a different issuer, or was intended for another audience, the validation fails. This is an important security check; we should be sure that the bearer has been issued by someone we expected to issue it and for the audience we want.
然后,我们指定令牌的颁发者和受众的有效值。如果令牌是从其他颁发者发出的,或者是针对其他受众的,则验证将失败。这是一项重要的安全检查;我们应该确保 Bearer 是由我们预期会颁发它的人签发的,并且是针对我们想要的受众。

Tip : As already pointed out, we should place the information used to work with the token in an external source, so that we can reference the correct values during token generation and validation, avoiding hardcoding them or writing their values twice.
提示 : 如前所述,我们应该将用于处理令牌的信息放在外部源中,以便我们可以在令牌生成和验证期间引用正确的值,避免对它们进行硬编码或重复写入它们的值。

We don’t need to specify that we also want to validate the token expiration because this check is automatically enabled. A clock skew is applied when validating the time to compensate for slight differences in clock time or to handle delays between the client request and the instant at which it is processed by the server. The default value is 5 minutes, which means that an expired token is considered valid for a 5-minute timeframe after its actual expiration. We can reduce the clock skew, or disable it, using the ClockSkew property of the TokenValidationParameter class.
我们不需要指定我们还要验证令牌过期,因为此检查是自动启用的。在验证时间时应用 clock skew 以补偿 clock time 的微小差异或处理 Client 端请求与服务器处理请求的时刻之间的延迟。默认值为 5 分钟,这意味着过期的令牌在实际过期后的 5 分钟内被视为有效。我们可以使用 TokenValidationParameter 类的 ClockSkew 属性来减少或禁用时钟偏差。

Now, the minimal API has all the information to check the bearer token validity. In order to test whether everything works as expected, we need a way to tell Swagger how to send the token within a request, as we’ll see in the next section.
现在,最小 API 拥有检查持有者令牌有效性的所有信息。为了测试一切是否按预期工作,我们需要一种方法来告诉 Swagger 如何在请求中发送令牌,我们将在下一节中看到。

Adding JWT support to Swagger
向 Swagger 添加 JWT 支持

We have said that the bearer token is sent in the Authorization HTTP header of a request. If we want to use Swagger to verify the authentication system and test our protected endpoints, we need to update the configuration so that it will be able to include this header in the requests.
我们已经说过,持有者令牌是在请求的 Authorization HTTP 标头中发送的。如果我们想使用 Swagger 来验证身份验证系统并测试受保护的端点,我们需要更新配置,以便它能够在请求中包含此标头。

To perform this task, it is necessary to add a bit of code to the AddSwaggerGen() method:
要执行此任务,必须向 AddSwaggerGen() 方法添加一些代码:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddSwaggerGen(options =>
{
    options.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.ApiKey,
        In = ParameterLocation.Header,
        Name = HeaderNames.Authorization,
        Description = "Insert the token with the 'Bearer ' 
                       prefix"
    });
    options.AddSecurityRequirement(new
      OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = 
                     JwtBearerDefaults.AuthenticationScheme
                }
            },
            Array.Empty<string>()
        }
    });
});

In the preceding code, we defined how Swagger handles authentication. Using the AddSecurityDefinition() method, we described how our API is protected; we used an API key, which is the bearer token, in the header with the name Authorization. Then, with AddSecurityRequirement(), we specified that we have a security requirement for our endpoints, which means that the security information must be sent for every request.
在上面的代码中,我们定义了 Swagger 如何处理身份验证。使用 AddSecurityDefinition() 方法,我们描述了如何保护我们的 API;我们在标头中使用了名为 Authorization 的 API 密钥,即不记名令牌。然后,使用 AddSecurityRequirement(),我们指定了端点的安全要求,这意味着必须为每个请求发送安全信息。

After adding the preceding code, if we now run our application, the Swagger UI will contain something new.
添加上述代码后,如果我们现在运行应用程序,Swagger UI 将包含一些新内容。

Figure 8.3 – Swagger showing the authentication features
图 8.3 – Swagger 显示身份验证功能

Upon clicking the Authorize button or any of the padlock icons at the right of the endpoints, the following window will show up, allowing us to insert the bearer token:
单击 Authorize 按钮或端点右侧的任何挂锁图标后,将显示以下窗口,允许我们插入不记名令牌:

Figure 8.4 – The window that allows setting the bearer token
图 8.4 – 允许设置 bearer token 的窗口

The last thing to do is to insert the token in the Value textbox and confirm by clicking on Authorize. From now on, the specified bearer will be sent along with every request made with Swagger.
最后要做的是将令牌插入 Value 文本框中,然后单击 Authorize 进行确认。从现在开始,指定的 bearer 将与使用 Swagger 发出的每个请求一起发送。

We have finally completed all the required steps to add authentication support to minimal APIs. Now, it’s time to verify that everything works as expected. In the next section, we’ll perform some tests.
我们终于完成了向最小 API 添加身份验证支持所需的所有步骤。现在,是时候验证一切是否按预期工作了。在下一节中,我们将执行一些测试。

Testing authentication
测试身份验证

As described in the previous sections, if we call one of the protected endpoints, we get a 401 Unauthorized response. To verify that token authentication works, let’s call the login endpoint to get a token. After that, click on the Authorize button in Swagger and insert the obtained token, remembering the Bearer prefix. Now, we’ll get a 200 OK response, meaning that we are able to correctly invoke the endpoints that require authentication. We can also try changing a single character in the token to again get the 401 Unauthorized response, because in this case, the signature will not be the expected one, as described before. In the same way, if the token is formally valid but has expired, we will obtain a 401 response.
如前面部分所述,如果我们调用其中一个受保护的终端节点,则会收到 401 Unauthorized 响应。要验证令牌身份验证是否有效,让我们调用登录终端节点以获取令牌。之后,点击 Swagger 中的 Authorize 按钮并插入获取的令牌,记住 Bearer 前缀。现在,我们将收到 200 OK 响应,这意味着我们能够正确调用需要身份验证的终端节点。我们还可以尝试更改令牌中的单个字符以再次获得 401 Unauthorized 响应,因为在这种情况下,签名将不是预期的签名,如前所述。同理,如果 Token 正式有效但已过期,我们将获得 401 响应。

As we have defined endpoints that can be reached only by authenticated users, a common requirement is to access user information within the corresponding route handlers. In Chapter 2, Exploring Minimal APIs and Their Advantages, we showed that minimal APIs provide a special binding that directly provides a ClaimsPrincipal object representing the logged user:
由于我们已经定义了只有经过身份验证的用户才能访问的端点,因此一个常见的要求是访问相应路由处理程序中的用户信息。在第 2 章 探索最小 API 及其优势中,我们展示了最小 API 提供了一个特殊的绑定,该绑定直接提供表示已记录用户的 ClaimsPrincipal 对象:

app.MapGet("/api/me", [Authorize] (ClaimsPrincipal user) => $"Logged username: {user.Identity.Name}");

The user parameter of the route handler is automatically filled with user information. In this example, we just get the name, which in turn is read from the token claims, but the object exposes many properties that allow us to work with authentication data. We can refer to the official documentation at https://docs.microsoft.com/dotnet/api/system.security.claims.claimsprincipal.identity for further details.
路由处理程序的 user 参数会自动填充用户信息。在此示例中,我们只获取 name,而 name 又是从 token 声明中读取的,但该对象公开了许多允许我们处理身份验证数据的属性。有关详细信息,请参阅 https://docs.microsoft.com/dotnet/api/system.security.claims.claimsprincipal.identity 上的官方文档。

This ends our overview of authentication. In the next section, we’ll see how to handle authorization.
我们对身份验证的概述到此结束。在下一节中,我们将了解如何处理授权。

Handling authorization – roles and policies
处理授权 – 角色和策略

Right after the authentication, there is the authorization step, which grants an authenticated user permission to do something. Minimal APIs provide the same authorization features as controller-based projects, based on the concepts of roles and policies.
在身份验证之后,立即执行授权步骤,该步骤授予经过身份验证的用户执行某些作的权限。Minimal API 基于角色和策略的概念,提供与基于控制器的项目相同的授权功能。

When an identity is created, it may belong to one or more roles. For example, a user can belong to the Administrator role, while another can be part of two roles: User and Stakeholder. Typically, each user can perform only the operations that are allowed by their roles. Roles are just claims that are inserted in the JWT bearer upon authentication. As we’ll see in a moment, ASP.NET Core provides built-in support to verify whether a user belongs to a role.
创建身份时,它可能属于一个或多个角色。例如,一个用户可以属于 Administrator 角色,而另一个用户可以属于两个角色:User 和 Slikeholder。通常,每个用户只能执行其角色允许的作。角色只是在身份验证时插入到 JWT 持有者中的声明。正如我们稍后将看到的,ASP.NET Core 提供了内置支持来验证用户是否属于某个角色。

While role-based authorization covers many scenarios, there are cases in which this kind of security isn’t enough because we need to apply more specific rules to check whether the user has the right to perform some activities. In such a situation, we can create custom policies that allow us to specify more detailed authorization requirements and even completely define the authorization logic based on our algorithms.
虽然基于角色的授权涵盖了许多场景,但在某些情况下,这种安全性是不够的,因为我们需要应用更具体的规则来检查用户是否有权执行某些活动。在这种情况下,我们可以创建自定义策略,允许我们指定更详细的授权要求,甚至根据我们的算法完全定义授权逻辑。

In the next sections, we’ll see how to manage both role-based and policy-based authorization in our APIs, so that we can cover all our requirements, that is, allowing access to certain endpoints only to users with specific roles or claims, or based on our custom logic.
在接下来的部分中,我们将了解如何在 API 中管理基于角色和基于策略的授权,以便我们可以满足所有要求,即仅允许具有特定角色或声明的用户访问某些终端节点,或者允许基于我们的自定义逻辑访问某些终端节点。

Handling role-based authorization
处理基于角色的授权

As already introduced, roles are claims. This means that they must be inserted in the JWT bearer token upon authentication, just like any other claims:
如前所述,角色是声明。这意味着,在身份验证时,必须将它们插入到 JWT 不记名令牌中,就像任何其他声明一样:

app.MapPost("/api/auth/login", (LoginRequest request) =>
{
    if (request.Username == "marco" && request.Password == 
        "P@$$w0rd")
    {
        var claims = new List<Claim>()
        {
            new(ClaimTypes.Name, request.Username),
            new(ClaimTypes.Role, "Administrator"),
            new(ClaimTypes.Role, "User")
        };

    //...
}

In this example, we statically add two claims with name ClaimTypes.Role: Administrator and User. As said in the previous sections, in a real-world application, these values typically come from a complete user management system built, for example, with ASP.NET Core Identity.
在此示例中,我们静态添加两个名称为 ClaimTypes.Role 的声明:Administrator 和 User。如前几节所述,在实际应用程序中,这些值通常来自一个完整的用户管理系统,例如,使用 ASP.NET Core Identity 构建。

As in all the other claims, roles are inserted in the JWT bearer. If now we try to invoke the login endpoint, we’ll notice that the token is longer because it contains a lot of information, which we can verify using the https://jwt.ms site again, as follows:
与所有其他声明一样,角色也插入到 JWT 持有者中。如果现在我们尝试调用登录端点,我们会注意到令牌更长,因为它包含大量信息,我们可以再次使用 https://jwt.ms 站点验证这些信息,如下所示:

{
  "alg": "HS256",
  "typ": "JWT"
}.{
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "marco",
  "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": [
    "Administrator",
    "User"
  ],
  "exp": 1644755166,
  "iss": "https://www.packtpub.com",
  "aud": "Minimal APIs Client"
}.[Signature]

In order to restrict access to a particular endpoint only for users that belong to a given role, we need to specify this role as an argument in the Authorize attribute or the RequireAuthorization() method:
为了限制仅属于给定角色的用户访问特定端点,我们需要将此角色指定为 Authorize 属性或 RequireAuthorization() 方法中的参数:

app.MapGet("/api/admin-attribute-protected", [Authorize(Roles = "Administrator")] () => { });
app.MapGet("/api/admin-method-protected", () => { })
.RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator" });

In this way, only users who are assigned the Administrator role can access the endpoints. We can also specify more roles, separating them with a comma: the user will be authorized if they have at least one of the specified roles.
这样,只有分配了 Administrator 角色的用户才能访问终端节点。我们还可以指定更多角色,用逗号分隔:如果用户至少拥有一个指定的角色,则用户将被授权。

Important Note : Role names are case sensitive.
重要提示 : 角色名称区分大小写。

Now suppose we have the following endpoint:
现在假设我们有以下端点:

app.MapGet("/api/stackeholder-protected", [Authorize(Roles = "Stakeholder")] () => { });

This method can only be consumed by a user who is assigned the Stakeholder role. However, in our example, this role isn’t assigned. So, if we use the previous bearer token and try to invoke this endpoint, of course, we’ll get an error. But in this case, it won’t be 401 Unauthorized, but rather 403 Forbidden. We see this behavior because the user is actually authenticated (meaning the token is valid, so no 401 error), but they don’t have the authorization to execute the method, so access is forbidden. In other words, authentication errors and authorization errors lead to different HTTP status codes.
此方法只能由分配了 Stakeholder 角色的用户使用。但是,在我们的示例中,未分配此角色。因此,如果我们使用以前的 bearer token 并尝试调用此 endpoint,我们当然会收到错误。但在这种情况下,它不会是 401 Unauthorized,而是 403 Forbidden。我们看到这种行为是因为用户实际上已经过身份验证(意味着令牌有效,因此没有 401 错误),但他们没有执行该方法的授权,因此禁止访问。换句话说,身份验证错误和授权错误会导致不同的 HTTP 状态代码。

There is another important scenario that involves roles. Sometimes, we don’t need to restrict endpoint access at all but need to adapt the behavior of the handler according to the specific user role, such as when retrieving only a certain type of information. In this case, we can use the IsInRole() method, which is available on the ClaimsPrincipal object:
还有另一个涉及角色的重要方案。有时,我们根本不需要限制端点访问,但需要根据特定的用户角色来调整处理程序的行为,例如当只检索某种类型的信息时。在这种情况下,我们可以使用 IsInRole() 方法,该方法在 ClaimsPrincipal 对象上可用:

app.MapGet("/api/role-check", [Authorize] (ClaimsPrincipal user) =>
{
    if (user.IsInRole("Administrator"))
    {
        return "User is an Administrator";
    }
    return "This is a normal user";
});

In this endpoint, we only use the Authorize attribute to check whether the user is authenticated or not. Then, in the route handler, we check whether the user has the Administrator role. If yes, we just return a message, but we can imagine that administrators can retrieve all the available information, while normal users get only a subset, based on the values of the information itself.
在此终端节点中,我们只使用 Authorize 属性来检查用户是否经过身份验证。然后,在路由处理程序中,我们检查用户是否具有 Administrator 角色。如果是,我们只返回一条消息,但我们可以想象管理员可以检索所有可用信息,而普通用户只能根据信息本身的值获得一个子集。

As we have seen, with role-based authorization, we can perform different types of authorization checks in our endpoints, to cover many scenarios. However, this approach cannot handle all situations. If roles aren’t enough, we need to use authorization based on policies, which we will discuss in the next section.
正如我们所看到的,通过基于角色的授权,我们可以在端点中执行不同类型的授权检查,以涵盖许多场景。但是,此方法无法处理所有情况。如果角色还不够,我们需要使用基于策略的授权,我们将在下一节中讨论。

Applying policy-based authorization
应用基于策略的授权
Policies are a more general way to define authorization rules. Role-based authorization can be considered a specific policy authorization that involves a roles check. We typically use policies when we need to handle more complex scenarios.
策略是定义授权规则的更通用方法。基于角色的授权可被视为涉及角色检查的特定策略授权。当我们需要处理更复杂的场景时,我们通常会使用策略。

This kind of authorization requires two steps:
这种授权需要两个步骤:

  1. Defining a policy with a rule set
    使用规则集定义策略
  2. Applying a certain policy on the endpoints
    在端点上应用特定策略

Policies are added in the context of the AddAuthorization() method, which we saw in the previous section, Protecting a minimal API. Each policy has a unique name, which is used to later reference it, and a set of rules, which are typically described in a fluent manner.
策略是在 AddAuthorization() 方法的上下文中添加的,我们在上一节 保护最小 API 中看到了。每个策略都有一个唯一的名称(用于以后引用它)和一组规则,这些规则通常以流畅的方式进行描述。

We can use policies when role authorization is not enough. Suppose that the bearer token also contains the ID of the tenant to which the user belongs:
当角色授权不足时,我们可以使用策略。假设 bearer token 还包含用户所属租户的 ID:

var claims = new List<Claim>()
{
    // ...
    new("tenant-id", "42")
};

Again, in a real-world scenario, this value could come from a database that stores the properties of the user. Suppose that we want to only allow users who belong to a particular tenant to reach an endpoint. As tenant-id is a custom claim, ASP.NET Core doesn’t know how to use it to enforce authorization. So, we can’t use the solutions shown earlier. We need to define a custom policy with the corresponding rule:
同样,在实际方案中,此值可能来自存储用户属性的数据库。假设我们只想允许属于特定租户的用户访问终端节点。由于 tenant-id 是一个自定义声明,因此 ASP.NET Core 不知道如何使用它来强制实施授权。因此,我们不能使用前面显示的解决方案。我们需要定义一个带有相应规则的自定义策略:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Tenant42", policy =>
    {
        policy.RequireClaim("tenant-id", "42");
    });
});

In the preceding code, we created a policy named Tenant42, which requires that the token contains the tenant-id claim with the value 42. The policy variable is an instance of AuthorizationPolicyBuilder and exposes methods that allow us to fluently specify the authorization rules; we can specify that a policy requires certain users, roles, and claims to be satisfied. We can also chain multiple requirements in the same policy, writing, for example, something such as policy.RequireRole(“Administrator”).RequireClaim(“tenant-id”). The full list of methods is available on the documentation page at https://docs.microsoft.com/dotnet/api/microsoft.aspnetcore.authorization.authorizationpolicybuilder.
在上面的代码中,我们创建了一个名为 Tenant42 的策略,该策略要求令牌包含值为 42 的 tenant-id 声明。policy 变量是 AuthorizationPolicyBuilder 的一个实例,它公开了允许我们流畅地指定授权规则的方法;我们可以指定策略要求满足某些用户、角色和声明。我们还可以将多个需求链接在同一个策略中,例如,编写诸如 policy 之类的内容。RequireRole(“管理员”)。RequireClaim(“tenant-id”)的完整的方法列表可在 https://docs.microsoft.com/dotnet/api/microsoft.aspnetcore.authorization.authorizationpolicybuilder 的文档页面上找到。

Then, in the method we want to protect, we have to specify the policy name, as usual with the Authorize attribute or the RequireAuthorization() method:
然后,在我们想要保护的方法中,我们必须指定策略名称,就像通常使用 Authorize 属性或 RequireAuthorization() 方法一样:

app.MapGet("/api/policy-attribute-protected", [Authorize(Policy = "Tenant42")] () => { });
app.MapGet("/api/policy-method-protected", () => { })
.RequireAuthorization("Tenant42");

If we try to execute these preceding endpoints with a token that doesn’t have the tenant-id claim, or its value isn’t 42, we get a 403 Forbidden result, as happened with the role check.
如果我们尝试使用没有 tenant-id 声明或其值不是 42 的令牌执行这些前面的终结点,则会收到 403 Forbidden 结果,就像角色检查一样。

There are scenarios in which declaring a list of allowed roles and claims isn’t enough: for example, we would need to perform more complex checks or verify authorization based on dynamic parameters. In these cases, we can use the so-called policy requirements, which comprise a collection of authorization rules for which we can provide custom verification logic.
在某些情况下,声明允许的角色和声明列表是不够的:例如,我们需要执行更复杂的检查或根据动态参数验证授权。在这些情况下,我们可以使用所谓的策略要求,它包含一组授权规则,我们可以为其提供自定义验证逻辑。

To adopt this solution, we need two objects:
要采用此解决方案,我们需要两个对象:

• A requirement class that implements the IAuthorizationRequirement interface and defines the requirement we want to manage
实现 IAuthorizationRequirement 接口并定义我们要管理的要求的要求类

• A handler class that inherits from AuthorizationHandler and contains the logic to verify the requirement
一个从 AuthorizationHandler 继承并包含验证要求的逻辑的处理程序类

Let’s suppose we don’t want users who don’t belong to the Administrator role to access certain endpoints during a maintenance time window. This is a perfectly valid authorization rule, but we cannot afford it using the solutions we have seen up to now. The rule involves a condition that considers the current time, so the policy cannot be statically defined.
假设我们不希望不属于 Administrator 角色的用户在维护时段内访问某些终端节点。这是一个完全有效的授权规则,但使用我们目前看到的解决方案,我们无法承受它。该规则涉及考虑当前时间的条件,因此不能静态定义策略。

So, we start by creating a custom requirement:
因此,我们首先创建自定义需求:

public class MaintenanceTimeRequirement : IAuthorizationRequirement
{
    public TimeOnly StartTime { get; init; }
    public TimeOnly EndTime { get; init; }
}

The requirement contains the start and end times of the maintenance window. During this interval, we only want administrators to be able to operate.
该要求包含维护时段的开始和结束时间。在此间隔期间,我们只希望管理员能够进行作。

Note : TimeOnly is a new data type that has been introduced with C# 10 and allows us to store only only the time of the day (and not the date). More information is available at https://docs.microsoft.com/dotnet/api/system.timeonly.
注意 : TimeOnly 是 C# 10 中引入的一种新数据类型,它允许我们只存储一天中的时间(而不是日期)。有关更多信息,请访问 https://docs.microsoft.com/dotnet/api/system.timeonly

Note that the IAuthorizationRequirement interface is just a placeholder. It doesn’t contain any method or property to be implemented; it serves only to identify that the class is a requirement. In other words, if we don’t need any additional information for the requirement, we can create a class that implements IAuthorizationRequirement but actually has no content at all.
请注意,IAuthorizationRequirement 接口只是一个占位符。它不包含任何要实现的方法或属性;它仅用于标识该类是必需的。换句话说,如果我们不需要要求的任何其他信息,我们可以创建一个实现 IAuthorizationRequirement 但实际上根本没有内容的类。

This requirement must be enforced, so it is necessary to create the corresponding handler:
必须强制执行此要求,因此必须创建相应的处理程序:

public class MaintenanceTimeAuthorizationHandler
    : AuthorizationHandler<MaintenanceTimeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MaintenanceTimeRequirement requirement)
    {
        var isAuthorized = true;
        if (!context.User.IsInRole("Administrator"))
        {
            var time = TimeOnly.FromDateTime(DateTime.Now);
            if (time >= requirement.StartTime && time <
                requirement.EndTime)
            {
                isAuthorized = false;
            }
        }
        if (isAuthorized)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

Our handler inherits from AuthorizationHandler<MaintenanceTimeRequirement>, so we need to override the HandleRequirementAsync() method to verify the requirement, using the AuthorizationHandlerContext parameter, which contains a reference to the current user. As said at the beginning, if the user is not assigned the Administrator role, we check whether the current time falls in the maintenance window. If so, the user doesn’t have the right to access.
我们的处理程序继承自 AuthorizationHandler<MaintenanceTimeRequirement>,因此我们需要使用 AuthorizationHandlerContext 参数(包含对当前用户的引用)重写 HandleRequirementAsync() 方法来验证需求。如开头所述,如果未为用户分配 Administrator 角色,我们将检查当前时间是否在维护时段内。如果是这样,则用户无权访问。

At the end, if the isAuthorized variable is true, it means that the authorization can be granted, so we call the Succeed() method on the context object, passing the requirement that we want to validate. Otherwise, we don’t invoke any method on the context, meaning that the requirement hasn’t been verified.
最后,如果 isAuthorized 变量为 true,则表示可以授予授权,因此我们在上下文对象上调用 Succeed() 方法,传递我们要验证的要求。否则,我们不会在上下文中调用任何方法,这意味着需求尚未经过验证。

We haven’t yet finished implementing the custom policy. We still have to define the policy and register the handler in the service provider:
我们尚未完成自定义策略的实施。我们仍然需要定义策略并在服务提供者中注册处理程序:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("TimedAccessPolicy", policy =>
    {
        policy.Requirements.Add(new
          MaintenanceTimeRequirement
        {
            StartTime = new TimeOnly(0, 0, 0),
            EndTime = new TimeOnly(4, 0, 0)
        });
    });
});
builder.Services.AddScoped<IAuthorizationHandler, MaintenanceTimeAuthorizationHandler>();

In the preceding code, we defined a maintenance time window from midnight till 4:00 in the morning. Then, we registered the handler as an implementation of the IAuthorizationHandler interface, which in turn is implemented by the AuthorizationHandler class.
在上面的代码中,我们定义了从午夜到凌晨 4:00 的维护时间窗口。然后,我们将处理程序注册为 IAuthorizationHandler 接口的实现,而该接口又由 AuthorizationHandler 类实现。

Now that we have everything in place, we can apply the policy to our endpoints:
现在我们已经准备好了一切,我们可以将策略应用于我们的端点:

app.MapGet("/api/custom-policy-protected", [Authorize(Policy = "TimedAccessPolicy")] () => { });

When we try to reach this endpoint, ASP.NET Core will check the corresponding policy, find that it contains a requirement, and scan all the registrations of the IAuhorizationHandler interface to see whether there is one that is able to handle the requirement. Then, the handler will be invoked, and the result will be used to determine whether the user has the right to access the route. If the policy isn’t verified, we’ll get a 403 Forbidden response.
当我们尝试访问此终端节点时,ASP.NET Core 将检查相应的策略,发现它包含需求,并扫描 IAuhorizationHandler 接口的所有注册,以查看是否有能够处理该要求的接口。然后,将调用处理程序,结果将用于确定用户是否有权访问路由。如果策略未经过验证,我们将收到 403 Forbidden 响应。

We have shown how powerful policies are, but there is more. We can also use them to define global rules that are automatically applied to all endpoints, using the concepts of default and fallback policies, as we’ll see in the next section.
我们已经展示了政策的强大之处,但还有更多。我们还可以使用 default 和 fallback 策略的概念,使用它们来定义自动应用于所有端点的全局规则,我们将在下一节中看到。

Using default and fallback policies
使用 default 和 fallback 策略

Default and fallback policies are useful when we want to define global rules that must be automatically applied. In fact, when we use the Authorize attribute or the RequireAuthorization() method, without any other parameter, we implicitly refer to the default policy defined by ASP.NET Core, which is set to require an authenticated user.
当我们想要定义必须自动应用的全局规则时,Default 和 fallback 策略非常有用。事实上,当我们使用 Authorize 属性或 RequireAuthorization() 方法时,如果没有任何其他参数,我们隐式引用了 ASP.NET Core 定义的默认策略,该策略设置为需要经过身份验证的用户。

If we want to use different conditions by default, we just need to redefine the DefaultPolicy property, which is available in the context of the AddAuthorization() method:
如果我们想默认使用不同的条件,我们只需要重新定义 DefaultPolicy 属性,该属性在 AddAuthorization() 方法的上下文中可用:

builder.Services.AddAuthorization(options =>
{
    var policy = new AuthorizationPolicyBuilder()
      .RequireAuthenticatedUser()
        .RequireClaim("tenant-id").Build();
    options.DefaultPolicy = policy;    
});

We use AuthorizationPolicyBuilder to define all the security requirements, then we set it as a default policy. In this way, even if we don’t specify a custom policy in the Authorize attribute or the RequireAuthorization() method, the system will always verify whether the user is authenticated, and the bearer contains the tenant-id claim. Of course, we can override this default behavior by just specifying roles or policy names in the authorization attribute or method.
我们使用 AuthorizationPolicyBuilder 定义所有安全要求,然后将其设置为默认策略。这样,即使我们没有在 Authorize 属性或 RequireAuthorization() 方法中指定自定义策略,系统也将始终验证用户是否经过身份验证,并且持有者包含 tenant-id 声明。当然,我们可以通过在 authorization 属性或方法中指定角色或策略名称来覆盖此默认行为。

A fallback policy, on the other hand, is the policy that is applied when there is no authorization information on the endpoints. It is useful, for example, when we want all our endpoints to be automatically protected, even if we forget to specify the Authorize attribute or just don’t want to repeat the attribute for each handler. Let us try and understand this using the following code:
另一方面,回退策略是在终端节点上没有授权信息时应用的策略。例如,当我们希望自动保护所有端点时,即使我们忘记指定 Authorize 属性或只是不想为每个处理程序重复该属性,它也很有用。让我们尝试使用以下代码来理解这一点:

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = options.DefaultPolicy;
});

In the preceding code, FallbackPolicy becomes equal to DefaultPolicy. We have said that the default policy requires that the user be authenticated, so the result of this code is that now, all the endpoints automatically need authentication, even if we don’t explicitly protect them.
在上面的代码中,FallbackPolicy 等于 DefaultPolicy。我们已经说过,默认策略要求对用户进行身份验证,因此此代码的结果是,现在,所有端点都自动需要身份验证,即使我们没有明确保护它们。

This is a typical solution to adopt when most of our endpoints have restricted access. We don’t need to specify the Authorize attribute or use the RequireAuthorization() method anymore. In other words, now all our endpoints are protected by default.
当我们的大多数端点都限制访问时,这是一种典型的解决方案。我们不再需要指定 Authorize 属性或使用 RequireAuthorization() 方法。换句话说,现在我们所有的端点都默认受到保护。

If we decide to use this approach, but a bunch of endpoints need public access, such as the login endpoint, which everyone should be able to invoke, we can use the AllowAnonymous attribute or the AllowAnonymous() method:
如果我们决定使用这种方法,但有大量端点需要公共访问,例如每个人都应该能够调用的登录端点,我们可以使用 AllowAnonymous 属性或 AllowAnonymous() 方法:

app.MapPost("/api/auth/login", [AllowAnonymous] (LoginRequest request) => { });
// OR
app.MapPost("/api/auth/login", (LoginRequest request) => { })
.AllowAnonymous();

As the name implies, the preceding code will bypass all authorization checks for the endpoint, including the default and fallback authorization policies.
顾名思义,前面的代码将绕过终端节点的所有授权检查,包括默认和回退授权策略。

To deepen our knowledge of policy-based authentication, we can refer to the official documentation at https://docs.microsoft.com/aspnet/core/security/authorization/policies.
为了加深我们对基于策略的身份验证的了解,我们可以参考 https://docs.microsoft.com/aspnet/core/security/authorization/policies 的官方文档。

Summary
总结

Knowing how authentication and authorization work in minimal APIs is fundamental to developing secure applications. Using JWT bearer authentication roles and policies, we can even define complex authorization scenarios, with the ability to use both standard and custom rules.
了解身份验证和授权在最小 API 中的工作原理是开发安全应用程序的基础。使用 JWT 不记名身份验证角色和策略,我们甚至可以定义复杂的授权场景,并能够使用标准和自定义规则。

In this chapter, we have introduced basic concepts to make a service secure, but there is much more to talk about, especially regarding ASP.NET Core Identity: an API that supports login functionality and allows managing users, passwords, profile data, roles, claims, and more. We can look further into this topic by checking out the official documentation, which is available at https://docs.microsoft.com/aspnet/core/security/authentication/identity.
在本章中,我们介绍了确保服务安全的基本概念,但还有更多内容要讨论,尤其是关于 ASP.NET 核心身份:一个支持登录功能并允许管理用户、密码、配置文件数据、角色、声明等的 API。我们可以通过查看官方文档来进一步了解这个主题,该文档可在 https://docs.microsoft.com/aspnet/core/security/authentication/identity 上获得。

In the next chapter, we will see how to add multilanguage support to our minimal APIs and how to correctly handle applications that work with different date formats, time zones, and so on.
在下一章中,我们将了解如何为我们的最小 API 添加多语言支持,以及如何正确处理使用不同日期格式、时区等的应用程序。

9 Leveraging Globalization and Localization

9 利用全球化和本地化

When developing an application, it is important to think about multi-language support; a multilingual application allows for a wider audience reach. This is also true for web APIs: messages returned by endpoints (for example, validation errors) should be localized, and the service should be able to handle different cultures and deal with time zones. In this chapter of the book, we will talk about globalization and localization, and we will explain what features are available in minimal APIs to work with these concepts. The information and samples that will be provided will guide us when adding multi-language support to our services and correctly handling all the related behaviors so that we will be able to develop global applications.
在开发应用程序时,考虑多语言支持非常重要;多语言应用程序允许更广泛的受众范围。Web API 也是如此:端点返回的消息(例如,验证错误)应该本地化,并且服务应该能够处理不同的区域性并处理时区。在本书的这一章中,我们将讨论全球化和本地化,并将解释最小 API 中有哪些功能可用于处理这些概念。将提供的信息和示例将指导我们向我们的服务添加多语言支持并正确处理所有相关行为,以便我们能够开发全球应用程序。

In this chapter, we will be covering the following topics:
在本章中,我们将介绍以下主题:

• Introducing globalization and localization
全球化和本地化简介

• Localizing a minimal API application
本地化最小 API 应用程序

• Using resource files
使用资源文件

• Integrating localization in validation frameworks
将本地化集成到验证框架中

• Adding UTC support to a globalized minimal API
向全球化的最小 API 添加 UTC 支持

Technical requirements
技术要求

To follow the descriptions in this chapter, you will need to create an ASP.NET Core 6.0 Web API application. Refer to the Technical requirements section in Chapter 1, Introduction to Minimal APIs, for instructions on how to do so.
要按照本章中的描述进行作,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。有关如何执行此作的说明,请参阅第 1 章 最小 API 简介中的技术要求部分。

If you’re using your console, shell, or Bash terminal to create the API, remember to change your working directory to the current chapter number (Chapter09).
如果您使用控制台、shell 或 Bash 终端创建 API,请记住将工作目录更改为当前章节编号 (Chapter09)。

All the code samples in this chapter can be found in the GitHub repository for this book at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter09.
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter09

Introducing globalization and localization
全球化和本地化简介

When thinking about internationalization, we must deal with globalization and localization, two terms that seem to refer to the same concepts but actually involve different areas. Globalization is the task of designing applications that can manage and support different cultures. Localization is the process of adapting an application to a particular culture, for example, by providing translated resources for each culture that will be supported.
在考虑国际化时,我们必须处理全球化和本地化,这两个术语似乎指的是相同的概念,但实际上涉及不同的领域。全球化的任务是设计能够管理和支持不同区域性的应用程序。本地化是使应用程序适应特定区域性的过程,例如,为将要支持的每种区域性提供翻译资源。

Note : The terms internationalization, globalization, and localization are often abbreviated to I18N, G11N, and L10N, respectively.
注意 : 术语国际化、全球化和本地化通常分别缩写为 I18N、G11N 和 L10N。

As with all the other features that we have already introduced in the previous chapters, globalization and localization can be handled by the corresponding middleware and services that ASP.NET Core provides and work in the same way in minimal APIs and controller-based projects.
与我们在前几章中介绍的所有其他功能一样,全球化和本地化可以由 ASP.NET Core 提供的相应中间件和服务处理,并且在最小的 API 和基于控制器的项目中以相同的方式工作。

You can find a great introduction to globalization and localization in the official documentation available at https://docs.microsoft.com/dotnet/core/extensions/globalization and https://docs.microsoft.com/dotnet/core/extensions/localization, respectively. In the rest of the chapter, we will focus on how to add support for these features in a minimal API project; in this way, we’ll introduce some important concepts and explain how to leverage globalization and localization in ASP.NET Core.
您可以分别在 https://docs.microsoft.com/dotnet/core/extensions/globalizationhttps://docs.microsoft.com/dotnet/core/extensions/localization 上提供的官方文档中找到有关全球化和本地化的精彩介绍。在本章的其余部分,我们将重点介绍如何在最小 API 项目中添加对这些功能的支持;通过这种方式,我们将介绍一些重要的概念,并解释如何在 ASP.NET Core 中利用全球化和本地化。

Localizing a minimal API application
本地化最小 API 应用程序

To enable localization within a minimal API application, let us go through the following steps:
要在最小 API 应用程序中启用本地化,让我们执行以下步骤:

  1. The first step to making an application localizable is to specify the supported cultures by setting the corresponding options, as follows:
    使应用程序可本地化的第一步是通过设置相应的选项来指定受支持的区域性,如下所示:

    var builder = WebApplication.CreateBuilder(args);
    //...
    var supportedCultures = new CultureInfo[] { new("en"), new("it"), new("fr") };
    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
    options.SupportedCultures = supportedCultures;
    options.SupportedUICultures = supportedCultures;
    options.DefaultRequestCulture = new
    RequestCulture(supportedCultures.First());
    });

In our example, we want to support three cultures – English, Italian, and French – so, we create an array of CultureInfo objects.
在我们的示例中,我们希望支持三种区域性 – 英语、意大利语和法语 – 因此,我们创建了一个 CultureInfo 对象数组。

We’re defining neutral cultures, that is, cultures that have a language but are not associated with a country or region. We could also use specific cultures, such as en-US or en-GB, to represent the cultures of a particular region: for example, en-US would refer to the English culture prevalent in the United States, while en-GB would refer to the English culture prevalent in the United Kingdom. This difference is important because, depending on the scenario, we may need to use country-specific information to correctly implement localization. For example, if we want to show a date, we have to know that the date format in the United States is M/d/yyyy, while in the United Kingdom, it is dd/MM/yyyy. So, in this case, it becomes fundamental to work with specific cultures. We also use specific cultures if we need to support language differences across cultures. For example, a particular word may have different spellings depending on the country (e.g., color in the US versus colour in the UK). That said, for our scenario of minimal APIs, working with neutral cultures is just fine.
我们定义的非特定区域性,即具有某种语言但与国家或地区无关的区域性。我们还可以使用特定区域性(如 en-US 或 en-GB)来表示特定区域的区域性:例如,en-US 表示美国流行的英语区域性,而 en-GB 表示英国流行的英语区域性。这种差异很重要,因为根据具体情况,我们可能需要使用特定于国家/地区的信息来正确实施本地化。例如,如果我们想显示一个日期,我们必须知道美国的日期格式是 M/d/yyyy,而在英国是 dd/MM/yyyy。因此,在这种情况下,与特定文化合作变得至关重要。如果我们需要支持跨文化的语言差异,我们也会使用特定区域性。例如,根据国家/地区,特定单词可能具有不同的拼写(例如,美国的 color 与英国的 colour)。也就是说,对于我们的最小 API 方案,使用非特定区域性就很好了。

  1. Next, we configure RequestLocalizationOptions, setting the cultures and specifying the default one to use if no information about the culture is provided. We specify both the supported cultures and the supported UI cultures:
    接下来,我们配置 RequestLocalizationOptions,设置区域性并指定在未提供有关区域性的信息时要使用的默认区域性。我们指定了受支持的区域性和受支持的 UI 区域性:

• The supported cultures control the output of culture-dependent functions, such as date, time, and number format.
支持的区域性控制依赖于区域性的函数(如日期、时间和数字格式)的输出。

• The supported UI cultures are used to choose which translated strings (from .resx files) are searched for. We will talk about .resx files later in this chapter.
支持的 UI 区域性用于选择要搜索的已翻译字符串(从 .resx 文件)。我们将在本章后面讨论 .resx 文件。

In a typical application, cultures and UI cultures are set to the same values, but of course, we can use different options if needed.
在典型的应用程序中,区域性和 UI 区域性设置为相同的值,但当然,如果需要,我们可以使用不同的选项。

  1. Now that we have configured our service to support globalization, we need to add the localization middleware to the ASP.NET Core pipeline so it will be able to automatically set the culture of the request. Let us do so using the following code:
    现在我们已经将服务配置为支持全球化,我们需要将本地化中间件添加到 ASP.NET Core 管道中,以便它能够自动设置请求的区域性。让我们使用以下代码来做到这一点:

    var app = builder.Build();
    //...
    app.UseRequestLocalization();
    //...
    app.Run();

In the preceding code, with UseRequestLocalization(), we’re adding RequestLocalizationMiddleware to the ASP.NET Core pipeline to set the current culture of each request. This task is performed using a list of RequestCultureProvider that can read information about the culture from various sources. Default providers comprise the following:
在前面的代码中,我们使用 UseRequestLocalization() 将 RequestLocalizationMiddleware 添加到 ASP.NET Core 管道,以设置每个请求的当前区域性。此任务是使用 RequestCultureProvider 列表执行的,该列表可以从各种源读取有关区域性的信息。默认提供程序包括以下内容:

• QueryStringRequestCultureProvider: Searches for the culture and ui-culture query string parameters
• QueryStringRequestCultureProvider:搜索 culture 和 ui-culture 查询字符串参数

• CookieRequestCultureProvider: Uses the ASP.NET Core cookie
CookieRequestCultureProvider:使用 ASP.NET Core Cookie

AcceptLanguageHeaderRequestProvider: Reads the requested culture from the Accept-Language HTTP header
AcceptLanguageHeaderRequestProvider:从 Accept-Language HTTP 标头中读取请求的区域性

For each request, the system will try to use these providers in this exact order, until it finds the first one that can determine the culture. If the culture cannot be set, the one specified in the DefaultRequestCulture property of RequestLocalizationOptions will be used.
对于每个请求,系统将尝试按此确切顺序使用这些提供程序,直到找到可以确定区域性的第一个提供程序。如果无法设置区域性,则将使用 RequestLocalizationOptions 的 DefaultRequestCulture 属性中指定的区域性。

If necessary, it is also possible to change the order of the request culture providers or even define a custom provider to implement our own logic to determine the culture. More information on this topic is available at :
如有必要,还可以更改请求文化提供者的顺序,甚至定义自定义提供者来实现我们自己的逻辑来确定文化。有关此主题的更多信息,请访问:
https://docs.microsoft.com/aspnet/core/fundamentals/localization#use-a-custom-provider.

Important note : The localization middleware must be inserted before any other middleware that might use the request culture.
重要提示 : 本地化中间件必须插入到可能使用请求区域性的任何其他中间件之前。

In the case of web APIs, whether using controller-based or minimal APIs, we usually set the request culture through the Accept-Language HTTP header. In the following section, we will see how to extend Swagger with the ability to add this header when trying to invoke methods.
对于 Web API,无论是使用基于控制器的 API 还是最小的 API,我们通常通过 Accept-Language HTTP 标头来设置请求文化。在下一节中,我们将看到如何扩展 Swagger,使其能够在尝试调用方法时添加此标头。

Adding globalization support to Swagger
向 Swagger 添加全球化支持

We want Swagger to provide us with a way to specify the Accept-Language HTTP header for each request so that we can test our globalized endpoints. Technically speaking, this means adding an operation filter to Swagger that will be able to automatically insert the language header, using the following code:
我们希望 Swagger 为我们提供一种方法来为每个请求指定 Accept-Language HTTP 标头,以便我们可以测试我们的全球化端点。从技术上讲,这意味着向 Swagger 添加一个作过滤器,该过滤器将能够使用以下代码自动插入语言标头:

public class AcceptLanguageHeaderOperationFilter : IOperationFilter
{
     private readonly List<IOpenApiAny>? 
     supportedLanguages;
     public AcceptLanguageHeaderOperationFilter 
     (IOptions<RequestLocalizationOptions> 
     requestLocalizationOptions)
     {
           supportedLanguages = 
           requestLocalizationOptions.Value.
           SupportedCultures?.Select(c => 
           newOpenApiString(c.TwoLetterISOLanguageName)).
           Cast<IOpenApiAny>().           ToList();
     }
     public void Apply(OpenApiOperation operation, 
     OperationFilterContext context)
     {
           if (supportedLanguages?.Any() ?? false)
           {
                 operation.Parameters ??= new 
                 List<OpenApiParameter>();
                 operation.Parameters.Add(new 
                 OpenApiParameter
                 {
                       Name = HeaderNames.AcceptLanguage,
                       In = ParameterLocation.Header,
                       Required = false,
                       Schema = new OpenApiSchema
                       {
                             Type = "string",
                             Enum = supportedLanguages,
                             Default = supportedLanguages.
                             First()
                       }
                 });
           }
     }
}

In the preceding code, AcceptLanguageHeaderOperationFilter takes the RequestLocalizationOptions object via dependency injection that we have defined at startup and extracts the supported languages in the format that Swagger expects from it. Then, in the Apply() method, we add a new OpenApiParameter that corresponds to the Accept-Language header. In particular, with the Schema.Enum property, we provide the list of supported languages using the values we have extracted in the constructor. This method is invoked for every operation (that is, every endpoint), meaning that the parameter will be automatically added to each of them.
在前面的代码中,AcceptLanguageHeaderOperationFilter 通过我们在启动时定义的依赖项注入获取 RequestLocalizationOptions 对象,并以 Swagger 期望的格式提取支持的语言。然后,在 Apply() 方法中,我们添加一个对应于 Accept-Language 标头的新 OpenApiParameter。具体而言,对于 Schema.Enum 属性,我们使用在构造函数中提取的值提供支持的语言列表。每个作(即每个端点)都会调用此方法,这意味着参数将自动添加到每个作中。

Now, we need to add the new filter to Swagger:
现在,我们需要将新过滤器添加到 Swagger:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddSwaggerGen(options =>
{
     options.OperationFilter<AcceptLanguageHeaderOperation
     Filter>();
});

As we did with the preceding code, for every operation, Swagger will execute the filter, which in turn will add a parameter to specify the language of the request.
正如我们对前面的代码所做的那样,对于每个作,Swagger 将执行过滤器,而过滤器又会添加一个参数来指定请求的语言。

So, let’s suppose we have the following endpoint:
因此,假设我们有以下端点:

app.MapGet("/culture", () => Thread.CurrentThread.CurrentCulture.DisplayName);

In the preceding handler, we just return the culture of the thread. This method takes no parameter; however, after adding the preceding filter, the Swagger UI will show the following:
在前面的处理程序中,我们只返回线程的区域性。此方法不带参数;但是,在添加上述筛选器后,Swagger UI 将显示以下内容:

Figure 9.1 – The Accept-Language header added to Swagger
图 9.1 – 添加到 Swagger 的 Accept-Language 标头

The operation filter has added a new parameter to the endpoint, allowing us to select the language from a dropdown. We can click the Try it out button to choose a value from the list and then click Execute to invoke the endpoint:
作筛选器已向终端节点添加了一个新参数,允许我们从下拉列表中选择语言。我们可以单击 Try it out 按钮从列表中选择一个值,然后单击 Execute 以调用终端节点:

Figure 9.2 – The result of the execution with the Accept-Language HTTP header
图 9.2 – 使用 Accept-Language HTTP 标头执行的结果

This is the result of selecting it as a language request: Swagger has added the Accept-Language HTTP header, which, in turn, has been used by ASP.NET Core to set the current culture. Then, in the end, we get and return the culture display name in the route handler.
这是选择它作为语言请求的结果:Swagger 添加了 Accept-Language HTTP 标头,而 ASP.NET Core 又使用该标头来设置当前区域性。然后,最后,我们在路由处理程序中获取并返回区域性显示名称。

This example shows us that we have correctly added globalization support to our minimal API. In the next section, we’ll go further and work with localization, starting by providing translated resources to callers based on the corresponding languages.
此示例向我们展示了我们已正确地将全球化支持添加到我们的最小 API 中。在下一节中,我们将进一步讨论本地化,首先根据相应的语言向调用者提供翻译后的资源。

Using resource files
使用资源文件

Our minimal API now supports globalization, so it can switch cultures based on the request. This means that we can provide localized messages to callers, for example, when communicating validation errors. This feature is based on the so-called resource files (.resx), a particular kind of XML file that contains key-value string pairs representing messages that must be localized.
我们的最小 API 现在支持全球化,因此它可以根据请求切换区域性。这意味着我们可以向调用者提供本地化消息,例如,在传达验证错误时。此功能基于所谓的资源文件 (.resx),这是一种特殊类型的 XML 文件,其中包含表示必须本地化的消息的键值字符串对。

Note : These resource files are exactly the same as they have been since the early versions of .NET.
注意 : 这些资源文件与自 .NET 早期版本以来完全相同。

Creating and working with resource files
创建和使用资源文件

With resource files, we can easily separate strings from code and group them by culture. Typically, resource files are put in a folder called Resources. To create a file of this kind using Visual Studio, let us go through the following steps:
使用资源文件,我们可以轻松地将字符串与代码分离,并按区域性对它们进行分组。通常,资源文件放在名为 Resources 的文件夹中。要使用 Visual Studio 创建此类文件,让我们执行以下步骤:

Important note : Unfortunately, Visual Studio Code does not provide support for handling .resx files. More information about this topic is available at https://github.com/dotnet/AspNetCore.Docs/issues/2501.
重要提示 : 遗憾的是,Visual Studio Code 不支持处理 .resx 文件。有关此主题的更多信息,请访问 https://github.com/dotnet/AspNetCore.Docs/issues/2501

  1. Right-click on the folder in Solution Explorer and then choose Add | New Item.
    右键单击“解决方案资源管理器”中的文件夹,然后选择“添加”|”新建项目。

  2. In the Add New Item dialog window, search for Resources, select the corresponding template, and name the file, for example, Messages.resx:
    在 Add New Item 对话框窗口中,搜索 Resources,选择相应的模板,然后将文件命名为 Messages.resx:

Figure 9.3 – Adding a resource file to the project
图 9.3 – 将资源文件添加到项目中

The new file will immediately open in the Visual Studio editor.
新文件将立即在 Visual Studio 编辑器中打开。

  1. The first thing to do in the new file is to select Internal or Public (based on the code visibility we want to achieve) from the Access Modifier option so that Visual Studio will create a C# file that exposes the properties to access the resources:
    在新文件中要做的第一件事是从 Access Modifier 选项中选择 Internal 或 Public (基于我们想要实现的代码可见性),以便 Visual Studio 创建一个 C# 文件,该文件公开属性以访问资源:

Figure 9.4 – Changing the Access Modifier of the resource file
图 9.4 – 更改资源文件的访问修饰符

As soon as we change this value, Visual Studio will add a Messages.Designer.cs file to the project and automatically create properties that correspond to the strings we insert in the resource file.
一旦我们更改了此值,Visual Studio 就会将 Messages.Designer.cs 文件添加到项目中,并自动创建与我们插入到资源文件中的字符串相对应的属性。

Resource files must follow a precise naming convention. The file that contains default culture messages can have any name (such as Messages.resx, as in our example), but the other .resx files that provide the corresponding translations must have the same name, with the specification of the culture (neutral or specific) to which they refer. So, we have Messages.resx, which will store default (English) messages.
资源文件必须遵循精确的命名约定。包含默认区域性消息的文件可以具有任何名称(如 Messages.resx,如本例中所示),但提供相应翻译的其他 .resx 文件必须具有相同的名称,并具有它们所引用的区域性(非特定或特定)的规范。因此,我们有 Messages.resx,它将存储默认(英文)消息。

  1. Since we also want to localize our messages in Italian, we need to create another file with the name Messages.it.resx.
    由于我们还希望将消息本地化为 Italian,因此需要创建另一个名为 Messages.it.resx 的文件。

Note : We don’t create a resource file for French culture on purpose because this way, we’ll see how APS.NET Core looks up the localized messages in practice.
注意 : 我们不会故意为法国文化创建资源文件,因为这样,我们将看到 APS.NET Core 在实践中如何查找本地化的消息。

  1. Now, we can start experimenting with resource files. Let’s open the Messages.resx file and set Name to HelloWorld and Value to Hello World!.
    现在,我们可以开始试验资源文件。让我们打开 Messages.resx 文件,并将 Name 设置为 HelloWorld,将 Value 设置为 Hello World!。

In this way, Visual Studio will add a static HelloWorld property in the Messages autogenerated class that allows us to access values based on the current culture.
通过这种方式,Visual Studio 将在 Messages 自动生成的类中添加一个静态 HelloWorld 属性,该属性允许我们访问基于当前区域性的值。

  1. To demonstrate this behavior, also open the Messages.it.resx file and add an item with the same Name, HelloWorld, but now set Value to the translation Ciao mondo!.
    为了演示此行为,还请打开 Messages.it.resx 文件并添加具有相同名称的项 HelloWorld,但现在将 Value 设置为翻译 Ciao mondo!。

  2. Finally, we can add a new endpoint to showcase the usage of the resource files:
    最后,我们可以添加新的端点来展示资源文件的使用情况:

// using Chapter09.Resources;
app.MapGet("/helloworld", () => Messages.HelloWorld);

In the preceding route handler, we simply access the static Mesasges.HelloWorld property that, as discussed before, has been automatically created while editing the Messages.resx file.
在前面的路由处理程序中,我们只需访问静态 Mesasges.HelloWorld 属性,如前所述,该属性是在编辑 Messages.resx 文件时自动创建的。

If we now run the minimal API and try to execute this endpoint, we’ll get the following responses based on the request language that we select in Swagger:
如果我们现在运行最小 API 并尝试执行此终端节点,我们将根据我们在 Swagger 中选择的请求语言获得以下响应:

Table 9.1 – Responses based on the request language
表 9.1 – 基于请求语言的响应

When accessing a property such as HelloWorld, the autogenerated Messages class internally uses ResourceManager to look up the corresponding localized string. First of all, it looks for a resource file whose name contains the requested culture. If it is not found, it reverts to the parent culture of that culture. This means that, if the requested culture is specific, ResourceManager searches for the neutral culture. If no resource file is still found, then the default one is used.
当访问诸如 HelloWorld 之类的属性时,自动生成的 Messages 类在内部使用 ResourceManager 来查找相应的本地化字符串。首先,它查找其名称包含所请求区域性的资源文件。如果未找到,它将还原为该区域性的父区域性。这意味着,如果请求的区域性是特定的,则 ResourceManager 会搜索非特定区域性。如果仍未找到资源文件,则使用默认资源文件。

In our case, using Swagger, we can select only English, Italian, or French as a neutral culture. But what happens if a client sends other values? We can have situations such as the following:
在我们的示例中,使用 Swagger,我们只能选择英语、意大利语或法语作为非特定区域性。但是,如果客户端发送其他值,会发生什么情况呢?我们可能会遇到以下情况:

• The request culture is it-IT: the system searches for Messages.it-IT.resx and then finds and uses Messages.it.resx.
请求区域性是 it-IT:系统搜索 Messages.it-IT.resx,然后查找并使用 Messages.it.resx。

• The request culture is fr-FR: the system searches for Messages.fr-FR.resx, then Messages.fr.resx, and (because neither are available) finally uses the default, Messages.resx.
请求区域性是 fr-FR:系统搜索 Messages.fr-FR.resx,然后搜索 Messages.fr.resx,最后(因为两者都不可用)使用默认的 Messages.resx。

• The request culture is de (German): because this isn’t a supported culture at all, the default request culture will be automatically selected, so strings will be searched for in the Messages.resx file.
请求区域性为 de (德语) :由于这根本不是受支持的区域性,因此将自动选择默认请求区域性,因此将在 Messages.resx 文件中搜索字符串。

Note : If a localized resource file exists, but it doesn’t contain the specified key, then the value of the default file will be used.
注意 : 如果本地化资源文件存在,但不包含指定的键,则将使用默认文件的值。

Formatting localized messages using resource files
使用资源文件设置本地化消息的格式

We can also use resource files to format localized messages. For example, we can add the following strings to the resource files of the project:
我们还可以使用 resource 文件来格式化本地化的消息。例如,我们可以将以下字符串添加到项目的资源文件中:

Table 9.2 – A custom localized message
表 9.2 – 自定义本地化消息

Now, let’s define this endpoint:
现在,让我们定义这个端点:

// using Chapter09.Resources;
app.MapGet("/hello", (string name) =>
{
     var message = string.Format(Messages.GreetingMessage, 
     name);
     return message;
});

As in the preceding code example, we get a string from a resource file according to the culture of the request. But, in this case, the message contains a placeholder, so we can use it to create a custom localized message using the name that is passed to the route handler. If we try to execute the endpoint, we will get results such as these:
与前面的代码示例一样,我们根据请求的区域性从资源文件中获取字符串。但是,在这种情况下,消息包含一个占位符,因此我们可以使用它来使用传递给路由处理程序的名称创建自定义本地化消息。如果我们尝试执行端点,我们将得到如下结果:

Table 9.3 – Responses with custom localized messages based on the request language
表 9.3 – 使用基于请求语言的自定义本地化消息的响应

The possibility to create localized messages with placeholders that are replaced at runtime using different values is a key point for creating truly localizable services.
创建带有占位符的本地化消息的可能性,这些占位符在运行时使用不同的值替换,这是创建真正可本地化服务的关键点。

In the beginning, we said that a typical use case of localization in web APIs is when we need to provide localized error messages upon validation. In the next section, we’ll see how to add this feature to our minimal API.
一开始,我们说过 Web API 中本地化的一个典型用例是我们需要在验证时提供本地化的错误消息。在下一节中,我们将了解如何将此功能添加到我们的最小 API 中。

Integrating localization in validation frameworks
将本地化集成到验证框架中

In Chapter 6, Exploring Validation and Mapping, we talked about how to integrate validation into a minimal API project. We learned how to use the MiniValidation library, rather than FluentValidation, to validate our models and provide validation messages to the callers. We also said that FluentValidation already provides translations for standard error messages.
在 第 6 章 探索验证和映射 中,我们讨论了如何将验证集成到一个最小的 API 项目中。我们学习了如何使用 MiniValidation 库(而不是 FluentValidation)来验证我们的模型并向调用者提供验证消息。我们还说过,FluentValidation 已经为标准错误消息提供了翻译。

However, with both libraries, we can leverage the localization support we have just added to our project to support localized and custom validation messages.
但是,对于这两个库,我们可以利用刚刚添加到项目中的本地化支持来支持本地化和自定义验证消息。

Localizing validation messages with MiniValidation
使用 MiniValidation 本地化验证消息

Using the MiniValidation library, we can use validation based on Data Annotations with minimal APIs. Refer to Chapter 6, Exploring Validation and Mapping, for instructions on how to add this library to the project.
使用 MiniValidation 库,我们可以使用基于数据注释的验证和最少的 API。有关如何将此库添加到项目中的说明,请参阅第 6 章 探索验证和映射。

Then, recreate the same Person class:
然后,重新创建相同的 Person 类:

public class Person
{
     [Required]
     [MaxLength(30)]
     public string FirstName { get; set; }
     [Required]
     [MaxLength(30)]
     public string LastName { get; set; }
     [EmailAddress]
     [StringLength(100, MinimumLength = 6)]
     public string Email { get; set; }
}

Every validation attribute allows us to specify an error message, which can be a static string or a reference to a resource file. Let’s see how to correctly handle the localization for the Required attribute. Add the following values in resource files:
每个 validation 属性都允许我们指定一条错误消息,它可以是静态字符串或对资源文件的引用。让我们看看如何正确处理 Required 属性的本地化。在资源文件中添加以下值:

Table 9.4 – Localized validation error messages used by Data Annotations
表 9.4 – 数据注释使用的本地化验证错误消息

We want it so that when a required validation rule fails, the localized message that corresponds to FieldRequiredAnnotation is returned. Moreover, this message contains a placeholder, because we want to use it for every required field, so we also need the translation of property names.
我们希望,当必需的验证规则失败时,将返回与 FieldRequiredAnnotation 对应的本地化消息。此外,此消息包含一个占位符,因为我们希望将其用于每个必填字段,因此我们还需要属性名称的翻译。

With these resources, we can update the Person class with the following declarations:
有了这些资源,我们可以使用以下声明更新 Person 类:

public class Person
{
     [Display(Name = "FirstName", ResourceType = 
      typeof(Messages))]
     [Required(ErrorMessageResourceName = 
     "FieldRequiredAnnotation",
      ErrorMessageResourceType = typeof(Messages))]
     public string FirstName { get; set; }
     //...
}

Each validation attribute, such as Required (as used in this example), exposes properties that allow us to specify the name of the resource to use and the type of class that contains the corresponding definition. Keep in mind that the name is a simple string, with no check at compile time, so if we write an incorrect value, we’ll only get an error at runtime.
每个验证属性(如 Required(如本例中所示))都公开了允许我们指定要使用的资源的名称以及包含相应定义的类类型的属性。请记住,名称是一个简单的字符串,在编译时没有检查,因此如果我们写入了不正确的值,我们只会在运行时收到错误。

Next, we can use the Display attribute to also specify the name of the field that must be inserted in the validation message.
接下来,我们还可以使用 Display 属性来指定必须插入到验证消息中的字段的名称。

Note : You can find the complete declaration of the Person class with localized data annotations on the GitHub repository at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L97.
注意 : 您可以在 GitHub 存储库的 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L97 上找到带有本地化数据注释的 Person 类的完整声明。

Now we can re-add the validation code shown in Chapter 6, Exploring Validation and Mapping. The difference is that now the validation messages will be localized:
现在我们可以重新添加第 6 章 探索验证和映射 中所示的验证代码。不同之处在于,现在验证消息将被本地化:

app.MapPost("/people", (Person person) =>
{
     var isValid = MiniValidator.TryValidate(person, out 
     var errors);
     if (!isValid)
     {
           return Results.ValidationProblem(errors, title: 
           Messages.ValidationErrors);
     }
     return Results.NoContent();
});

In the preceding code, the messages contained in the errors dictionary that is returned by the MiniValidator.TryValidate() method will be localized according to the request culture, as described in the previous sections. We also specify the title parameter in the Results.ValidationProblem() invocation because we want to localize this value too (otherwise, it will always be the default One or more validation errors occurred).
在上面的代码中,MiniValidator.TryValidate() 方法返回的 errors 字典中包含的消息将根据请求区域性进行本地化,如前面的部分所述。我们还在 Results.ValidationProblem() 调用中指定了 title 参数,因为我们也希望本地化此值(否则,它将始终为默认的 One or more validation errors occurred)。

If instead of data annotations, we prefer using FluentValidation, we know that it supports localization of standard error messages by default from Chapter 6, Exploring Validation and Mapping. However, with this library, we can also provide our translations. In the next section, we’ll talk about implementing this solution.
如果我们更喜欢使用 FluentValidation 而不是数据注释,那么我们知道它默认支持第 6 章 探索验证和映射 中的标准错误消息的本地化。但是,有了这个库,我们也可以提供我们的翻译。在下一节中,我们将讨论如何实现此解决方案。

Localizing validation messages with FluentValidation
使用 FluentValidation 本地化验证消息

With FluentValidation, we can totally decouple the validation rules from our models. As said before, refer to Chapter 6, Exploring Validation and Mapping, for instructions on how to add this library to the project and how to configure it.
使用 FluentValidation,我们可以将验证规则与我们的模型完全解耦。如前所述,请参阅 第 6 章 探索验证和映射 ,以获取有关如何将此库添加到项目以及如何配置它的说明。

Next, let us recreate the PersonValidator class:
接下来,让我们重新创建 PersonValidator 类:

public class PersonValidator : AbstractValidator<Person>
{
     public PersonValidator()
     {
           RuleFor(p => p.FirstName).NotEmpty().
           MaximumLength(30);
           RuleFor(p => p.LastName).NotEmpty().
           MaximumLength(30);
           RuleFor(p => p.Email).EmailAddress().Length(6, 
           100);
     }
}

In the case that we haven’t specified any messages, the default ones will be used. Let’s add the following resource to customize the NotEmpty validation rule:
如果我们没有指定任何消息,则将使用默认消息。让我们添加以下资源来自定义 NotEmpty 验证规则:

Table 9.5 – The localized validation error messages used by FluentValidation
表 9.5 – FluentValidation 使用的本地化验证错误消息

Note that, in this case, we also have a placeholder that will be replaced by the property name. However, different from data annotations, FluentValidation uses a placeholder with a name to better identify its meaning.
请注意,在本例中,我们还有一个占位符,该占位符将替换为属性名称。但是,与数据注释不同,FluentValidation 使用带有名称的占位符来更好地识别其含义。

Now, we can add this message in the validator, for example, for the FirstName property:
现在,我们可以在验证器中添加以下消息,例如,对于 FirstName 属性:

RuleFor(p => p.FirstName).NotEmpty().
     WithMessage(Messages.NotEmptyMessage).
     WithName(Messages.FirstName);

We use WithMessage() to specify the message that must be used when the preceding rule fails, following which we add the WithName() invocation to overwrite the default property name used for the {PropertyName} placeholder of the message.
我们使用 WithMessage() 指定在前面的规则失败时必须使用的消息,然后我们添加 WithName() 调用以覆盖用于消息的 {PropertyName} 占位符的默认属性名称。

Note : You can find the complete implementation of the PersonValidator class with localized messages in the GitHub repository at https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L129.
注意 : 您可以在 GitHub 存储库中找到 PersonValidator 类的完整实现以及本地化消息,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L129

Finally, we can leverage the localized validator in our endpoint, as we did in Chapter 6, Exploring Validation and Mapping:
最后,我们可以在端点中利用本地化的验证器,就像我们在第 6 章 探索验证和映射中所做的那样:

app.MapPost("/people", async (Person person, IValidator<Person> validator) =>
{
     var validationResult = await validator.
     ValidateAsync(person);
     if (!validationResult.IsValid)
     {
           var errors = validationResult.ToDictionary();
           return Results.ValidationProblem(errors, title: 
           Messages.ValidationErrors);
     }
     return Results.NoContent();
});

As in the case of data annotations, the validationResult variable will contain localized error messages that we return to the caller using the Results.ValidationProblem() method (again, with the definition of the title property).
与数据注释一样,validationResult 变量将包含本地化的错误消息,我们使用 Results.ValidationProblem() 方法(同样,使用 title 属性的定义)将这些错误消息返回给调用者。

Tip : In our example, we have seen how to explicitly assign translations for each property using the WithMessage() method. FluentValidation also provides a way to replace all (or some) of its default messages. You can find more information in the official documentation at https://docs.fluentvalidation.net/en/latest/localization.xhtml#default-messages.
提示 : 在我们的示例中,我们已经看到了如何使用 WithMessage() 方法为每个属性显式分配翻译。FluentValidation 还提供了一种替换其所有(或部分)默认消息的方法。您可以在 https://docs.fluentvalidation.net/en/latest/localization.xhtml#default-messages 的官方文档中找到更多信息。

This ends our overview of localization using resource files. Next, we’ll talk about an important topic when dealing with services that are meant to be used worldwide: the correct handling of different time zones.
我们对使用资源文件的本地化的概述到此结束。接下来,我们将讨论在处理旨在在全球范围内使用的服务时的一个重要话题:正确处理不同的时区。

Adding UTC support to a globalized minimal API
向全球化的最小 API 添加 UTC 支持

So far, we have added globalization and localization support to our minimal API because we want it to be used by the widest audience possible, irrespective of culture. But, if we think about being accessible to a worldwide audience, we should consider several aspects related to globalization. Globalization does not only pertain to language support; there are important factors we need to consider, for example, geographic locations, as well as time zones.
到目前为止,我们已经在我们的最小 API 中添加了全球化和本地化支持,因为我们希望它被尽可能广泛的受众使用,而不受文化影响。但是,如果我们考虑让全世界的受众都能接触到,我们应该考虑与全球化相关的几个方面。全球化不仅与语言支持有关;我们需要考虑一些重要因素,例如地理位置和时区。

So, for example, we can have our minimal API running in Italy, which follows Central European Time (CET) (GMT+1), while our clients can use browsers that execute a single-page application, rather than mobile apps, all over the world. We could also have a database server that contains our data, and this could be in another time zone. Moreover, at a certain point, it may be necessary to provide better support for worldwide users, so we’ll have to move our service to another location, which could have a new time zone. In conclusion, our system could deal with data in different time zones, and, potentially, the same services could switch time zones during their lives.
因此,例如,我们可以在意大利运行我们的最小 API,它遵循中欧时间 (CET) (GMT+1),而我们的客户可以使用执行单页应用程序的浏览器,而不是世界各地的移动应用程序。我们还可以有一个包含我们数据的数据库服务器,它可以在另一个时区。此外,在某个时候,可能需要为全球用户提供更好的支持,因此我们将不得不将我们的服务转移到另一个位置,该位置可能具有新的时区。总之,我们的系统可以处理不同时区的数据,并且相同的服务在其生命周期中可能会切换时区。

In these situations, the ideal solution is working with DateTimeOffset, a data type that includes time zones and that JsonSerializer fully supports, preserving time zone information during serialization and deserialization. If we could always use it, we’d automatically solve any problem related to globalization, because converting a DateTimeOffset value to a different time zone is straightforward. However, there are cases in which we can’t handle the DateTimeOffset type, for example:
在这些情况下,理想的解决方案是使用 DateTimeOffset,这是一种包含时区的数据类型,并且 JsonSerializer 完全支持,在序列化和反序列化期间保留时区信息。如果我们始终可以使用它,我们就会自动解决与全球化相关的任何问题,因为将 DateTimeOffset 值转换为不同的时区非常简单。但是,在某些情况下,我们无法处理 DateTimeOffset 类型,例如:

• When we’re working on a legacy system that relies on DateTime everywhere, updating the code to use DateTimeOffset isn’t an option because it requires too many changes and breaks the compatibility with the old data.
当我们在无处不在都依赖 DateTime 的旧系统上工作时,更新代码以使用 DateTimeOffset 不是一个选项,因为它需要太多更改并破坏与旧数据的兼容性。

• We have a database server such as MySQL that doesn’t have a column type for storing DateTimeOffset directly, so handling it requires extra effort, for example, using two separate columns, increasing the complexity of the domain.
我们有一个数据库服务器,例如 MySQL,它没有用于直接存储 DateTimeOffset 的列类型,因此处理它需要额外的工作,例如,使用两个单独的列,这增加了域的复杂性。

• In some cases, we simply aren’t interested in sending, receiving, and saving time zones – we just want to handle time in a “universal” way.
在某些情况下,我们只是对发送、接收和保存时区不感兴趣——我们只想以 “通用” 的方式处理时间。

So, in all the scenarios where we can’t or don’t want to use the DateTimeOffset data type, one of the best and simplest ways to deal with different time zones is to handle all dates using Coordinated Universal Time (UTC): the service must assume that the dates it receives are in the UTC format and, on the other hand, all the dates returned by the API must be in UTC.
因此,在我们不能或不想使用 DateTimeOffset 数据类型的所有情况下,处理不同时区的最佳和最简单的方法之一是使用协调世界时 (UTC) 处理所有日期:服务必须假定它收到的日期是 UTC 格式,另一方面, API 返回的所有日期都必须采用 UTC 格式。

Of course, we must handle this behavior in a centralized way; we don’t want to have to remember to apply the conversion to and from the UTC format every time we receive or send a date. The well-known JSON.NET library provides an option to specify how to treat the time value when working with a DateTime property, allowing it to automatically handle all dates as UTC and convert them to that format if they represent a local time. However, the current version of Microsoft JsonSerializer used in minimal APIs doesn’t include such a feature. From Chapter 2, Exploring Minimal APIs and Their Advantages, we know that we cannot change the default JSON serializer in minimal APIs, but we can overcome this lack of UTC support by creating a simple JsonConverter:
当然,我们必须以集中的方式处理这种行为;我们不想记住在每次接收或发送日期时都要应用与 UTC 格式之间的转换。众所周知的 JSON.NET 库提供了一个选项,用于指定在使用 DateTime 属性时如何处理时间值,从而允许它自动将所有日期作为 UTC 处理,并在它们表示本地时间时将其转换为该格式。但是,最小 API 中使用的 Microsoft JsonSerializer 的当前版本不包含此类功能。从第 2 章 探索最小 API 及其优势中,我们知道我们无法在最小 API 中更改默认的 JSON 序列化器,但是我们可以通过创建一个简单的 JsonConverter 来克服缺乏 UTC 支持的问题:

public class UtcDateTimeConverter : JsonConverter<DateTime>
{
     public override DateTime Read(ref Utf8JsonReader 
     reader, Type typeToConvert, JsonSerializerOptions  
     options)
     => reader.GetDateTime().ToUniversalTime();
     public override void Write(Utf8JsonWriter writer, 
     DateTime value, JsonSerializerOptions options)
     => writer.WriteStringValue((value.Kind == 
     DateTimeKind.Local ? value.ToUniversalTime() : value)
     .ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'
     fffffff'Z'"));
}

With this converter, we tell JsonSerializer how to treat DateTime properties:
通过这个转换器,我们告诉 JsonSerializer 如何处理 DateTime 属性:

• When DateTime is read from JSON, the value is converted to UTC using the ToUniversalTime() method.
从 JSON 中读取 DateTime 时,将使用 ToUniversalTime() 方法将该值转换为 UTC。

• When DateTime must be written to JSON, if it represents a local time (DateTimeKind.Local), it is converted to UTC before serialization – then, it is serialized using the Z suffix, which indicates that the time is UTC.
当必须将 DateTime 写入 JSON 时,如果它表示本地时间 (DateTimeKind.Local),则会在序列化之前将其转换为 UTC – 然后,它将使用 Z 后缀进行序列化,这表示时间为 UTC。

Now, before using this converter, let’s add the following endpoint definition:
现在,在使用此转换器之前,让我们添加以下端点定义:

app.MapPost("/date", (DateInput date) =>
{
     return Results.Ok(new
     {
           Input = date.Value,
           DateKind = date.Value.Kind.ToString(),
           ServerDate = DateTime.Now
     });
});
public record DateInput(DateTime Value);

Let’s try to call it, for example, with a date formatted as 2022-03-06T16:42:37-05:00. We’ll obtain something similar to the following:
例如,让我们尝试使用格式为 2022-03-06T16:42:37-05:00 的日期来调用它。我们将获得类似于以下内容的内容:

{
  "input": "2022-03-06T22:42:37+01:00",
  "dateKind": "Local",
  "serverDate": "2022-03-07T18:33:17.0288535+01:00"
}

The input date, containing a time zone, has automatically been converted to the local time of the server (in this case, the server is running in Italy, as stated at the beginning), as also demonstrated by the dateKind field. Moreover, serverDate contains a date that is relative to the server time zone.
包含时区的输入日期已自动转换为服务器的本地时间(在本例中,服务器在意大利运行,如开头所述),dateKind 字段也演示了该日期。此外, serverDate 包含相对于服务器时区的日期。

Now, let’s add UtcDateTimeConverter to JsonSerializer:
现在,让我们将 UtcDateTimeConverter 添加到 JsonSerializer 中:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.
JsonOptions>(options =>
{
     options.SerializerOptions.Converters.Add(new 
     UtcDateTimeConverter());
});

With this configuration, every DateTime property will be processed using our custom converters. Now, execute the endpoint again, using the same input as before. This time, the result will be as follows:
使用此配置,每个 DateTime 属性都将使用我们的自定义转换器进行处理。现在,使用与之前相同的输入再次执行终端节点。这一次,结果将如下所示:

{
  "input": "2022-03-06T21:42:37.0000000Z",
  "dateKind": "Utc",
  "serverDate": "2022-03-06T17:40:08.1472051Z"
}

The input is the same, but our UtcDateTimeConverter has now converted the date to UTC and, on the other hand, has serialized the server date as UTC; now, our API, in a centralized way, can automatically handle all dates as UTC, no matter its time zone or the time zones of the callers.
输入是相同的,但是我们的 UtcDateTimeConverter 现在已经将日期转换为 UTC,另一方面,已将服务器日期序列化为 UTC;现在,我们的 API 以集中的方式自动将所有日期处理为 UTC,无论其时区或调用者的时区如何。

Finally, there are two other points to make all the systems correctly work with UTC:
最后,还有另外两点可以使所有系统正确地使用 UTC:

• When we need to retrieve the current date in the code, we always have to use DateTime.UtcNow instead of DateTime.Now
当我们需要在代码中检索当前日期时,我们始终必须使用 DateTime.UtcNow 而不是 DateTime.Now

• Client applications must know that they will receive the date in UTC format and act accordingly, for example, invoking the ToLocalTime() method
客户端应用程序必须知道它们将收到 UTC 格式的日期并采取相应的措施,例如,调用 ToLocalTime() 方法

In this way, the minimal API is truly globalized and can work with any time zone; without having to worry about explicit conversion, all times input or output will be always in UTC, so it will be much easier to handle them.
通过这种方式,最小的 API 是真正全球化的,并且可以在任何时区工作;无需担心显式转换,所有时间 input 或 output 都将始终为 UTC,因此处理它们会容易得多。

Summary
总结

Developing minimal APIs with globalization and localization support in mind is fundamental in an interconnected world. ASP.NET Core includes all the features needed to create services that can react to the culture of the user and provide translations based on the request language: the usage of localization middleware, resource files, and custom validation messages allows the creation of services that can support virtually every culture. We have also talked about the globalization-related problems that could arise when working with different time zones and shown how to solve it using the centralized UTC date time format so that our APIs can seamlessly work irrespective of the geographic location and time zone of clients.
在考虑全球化和本地化支持的情况下开发最少的 API 是互联世界的基础。ASP.NET Core 包括创建服务所需的所有功能,这些服务可以响应用户文化并根据请求语言提供翻译:使用本地化中间件、资源文件和自定义验证消息,可以创建几乎可以支持所有文化的服务。我们还讨论了使用不同时区时可能出现的全球化相关问题,并展示了如何使用集中式 UTC 日期时间格式来解决这个问题,以便我们的 API 可以无缝工作,而不受客户的地理位置和时区的影响。

In Chapter 10, Evaluating and Benchmarking the Performance of Minimal APIs, we will talk about why minimal APIs were created and analyze the performance benefits of using minimal APIs over the classic controller-based approach.
在第 10 章 评估最小 API 的性能并对其进行基准测试中,我们将讨论创建最小 API 的原因,并分析使用最小 API 相对于基于控制器的经典方法的性能优势。

10 Evaluating and Benchmarking the Performance of Minimal APIs

评估最小 API 的性能并对其进行基准测试

The purpose of this chapter is to understand one of the motivations for which the minimal APIs framework was created.
本章的目的是了解创建最小 API 框架的动机之一。

This chapter will provide some obvious data and examples of how you can measure the performance of an ASP.NET 6 application using the traditional approach as well as how you can measure the performance of an ASP.NET application using the minimal API approach.
本章将提供一些明显的数据和示例,说明如何使用传统方法测量 ASP.NET 6 应用程序的性能,以及如何使用最小 API 方法测量 ASP.NET 应用程序的性能。

Performance is key to any functioning application; however, very often it takes a back seat.
性能是任何正常运行的应用程序的关键;然而,它经常退居二线。

A performant and scalable application depends not only on our code but also on the development stack. Today, we have moved on from the .NET full framework and .NET Core to .NET and can start to appreciate the performance that the new .NET has achieved, version after version – not only with the introduction of new features and the clarity of the framework but also primarily because the framework has been completely rewritten and improved with many features that have made it fast and very competitive compared to other languages.
高性能和可扩展的应用程序不仅取决于我们的代码,还取决于开发堆栈。今天,我们已经从 .NET 完整框架和 .NET Core 转向 .NET,并且可以开始欣赏新 .NET 所实现的性能,一个版本又一个版本 - 不仅引入了新功能和框架的清晰度,而且主要是因为该框架已被完全重写和改进,具有许多功能,与其他语言相比,这些功能使其速度更快且非常有竞争力。

In this chapter, we will evaluate the performance of the minimal API by comparing its code with identical code that has been developed traditionally. We’ll understand how to evaluate the performance of a web application, taking advantage of the BenchmarkDotNet framework, which can be useful in other application scenarios.
在本章中,我们将通过将最小 API 的代码与传统开发的相同代码进行比较来评估最小 API 的性能。我们将了解如何利用 BenchmarkDotNet 框架评估 Web 应用程序的性能,该框架在其他应用程序场景中可能很有用。

With minimal APIs, we have a new simplified framework that helps improve performance by leaving out some components that we take for granted with ASP.NET.
通过最少的 API,我们有一个新的简化框架,它通过省略一些我们认为理所当然的组件来帮助提高性能 ASP.NET。

The themes we will touch on in this chapter are as follows:
我们将在本章中讨论的主题如下:

• Improvements with minimal APIs
使用最少的 API 进行改进

• Exploring performance with load tests
通过负载测试探索性能

• Benchmarking minimal APIs with BenchmarkDotNet
使用 BenchmarkDotNet 对最小 API 进行基准测试

Technical requirements
技术要求

Many systems can help us test the performance of a framework.
许多系统可以帮助我们测试框架的性能。

We can measure how many requests per second one application can handle compared to another, assuming equal application load. In this case, we are talking about load testing.
我们可以测量一个应用程序每秒可以处理多少个请求,假设应用程序负载相同。在本例中,我们谈论的是负载测试。

To put the minimal APIs on the test bench, we need to install k6, the framework we will use for conducting our tests.
要将最小的 API 放在测试台上,我们需要安装 k6,我们将用于执行测试的框架。

We will launch load testing on a Windows machine with only .NET applications running.
我们将在仅运行 .NET 应用程序的 Windows 计算机上启动负载测试。

To install k6, you can do either one of the following:
要安装 k6,您可以执行以下任一操作:

• If you’re using the Chocolatey package manager (https://chocolatey.org/), you can install the unofficial k6 package with the following command:
如果您使用的是 Chocolatey 包管理器 (https://chocolatey.org/),您可以使用以下命令安装非官方的 k6 包:

choco install k6

• If you’re using Windows Package Manager (https://github.com/microsoft/winget-cli), you can install the official package from the k6 manifests with this command:
如果您使用的是 Windows Package Manager (https://github.com/microsoft/winget-cli),则可以使用以下命令从 k6 清单安装官方软件包:

winget install k6

• You can also test your application published on the internet with Docker:
您还可以使用 Docker 测试在 Internet 上发布的应用程序:

docker pull loadimpact/k6

• Or as we did, we installed k6 on the Windows machine and launched everything from the command line. You can download k6 from this link: https://dl.k6.io/msi/k6-latest-amd64.msi.
或者,我们在 Windows 计算机上安装了 k6 并从命令行启动所有内容。您可以从以下链接下载 k6:https://dl.k6.io/msi/k6-latest-amd64.msi

In the final part of the chapter, we’ll measure the duration of the HTTP method for making calls to the API.
在本章的最后一部分,我们将测量 HTTP 方法调用 API 的持续时间。

We’ll stand at the end of the system as if the API were a black box and measure the reaction time. BenchmarkDotNet is the tool we’ll be using – to include it in our project, we need to reference its NuGet package:
我们将站在系统的末端,就好像 API 是一个黑匣子一样,并测量反应时间。BenchmarkDotNet 是我们将要使用的工具 - 要将其包含在我们的项目中,我们需要引用其 NuGet 包:

dotnet add package BenchmarkDotNet

All the code samples in this chapter can be found in the GitHub repository for this book at the following link:
本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,链接如下:
https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter10

Improvements with minimal APIs
使用最少的 API 进行改进

Minimal APIs were designed not only to improve the performance of APIs but also for better code convenience and similarity to other languages to bring developers from other platforms closer. Performance has increased both from the point of view of the .NET framework, as each version has incredible improvements, as well as from the point of view of the simplification of the application pipeline. Let’s see in detail what has not been ported and what improves the performance of this framework.
Minimal API 的设计不仅是为了提高 API 的性能,也是为了更好的代码便利性和与其他语言的相似性,从而拉近来自其他平台的开发人员的距离。从 .NET Framework 的角度来看,性能都有所提高,因为每个版本都有令人难以置信的改进,而且从应用程序管道的简化的角度来看也是如此。让我们详细看看哪些内容尚未移植,哪些内容提高了此框架的性能。

The minimal APIs execution pipeline omits the following features, which makes the framework lighter:
最小 API 执行管道省略了以下功能,这使得框架更轻量级:

• Filters, such as IAsyncAuthorizationFilter, IAsyncActionFilter, IAsyncExceptionFilter, IAsyncResultFilter, and IasyncResourceFilter
• Model binding
• Binding for forms, such as IFormFile
• Built-in validation
• Formatters
• Content negotiations
• Some middleware
• View rendering
• JsonPatch
• OData
• API versioning

Performance Improvements in .NET 6
.NET 6 中的性能改进

Version after version, .NET improves its performance. In the latest version of the framework, improvements made over previous versions have been reported. Here’s where you can find a complete summary of what’s new in .NET 6:
一个又一个版本,.NET 提高了其性能。在最新版本的框架中,报告了对以前版本所做的改进。您可以在此处找到 .NET 6 中新增功能的完整摘要:
https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/

Exploring performance with load tests
通过负载测试探索性能

How to estimate the performance of minimal APIs? There are many points of view to consider and in this chapter, we will try to address them from the point of view of the load they can support. We decided to adopt a tool – k6 – that performs load tests on a web application and tells us how many requests per second can a minimal API handle.
如何估算最小 API 的性能?有许多观点需要考虑,在本章中,我们将尝试从它们可以支持的负载的角度来解决这些问题。我们决定采用一种工具 k6,它在 Web 应用程序上执行负载测试,并告诉我们最小 API 每秒可以处理多少个请求。

As described by its creators, k6 is an open source load testing tool that makes performance testing easy and productive for engineering teams. The tool is free, developer-centric, and extensible. Using k6, you can test the reliability and performance of your systems and catch performance regressions and problems earlier. This tool will help you to build resilient and performant applications that scale.
正如其创建者所描述的那样,k6 是一种开源负载测试工具,它使工程团队的性能测试变得简单而高效。该工具是免费的、以开发人员为中心且可扩展的。使用 k6,您可以测试系统的可靠性和性能,并更早地捕获性能回归和问题。此工具将帮助您构建可扩展的弹性和高性能应用程序。

In our case, we would like to use the tool for performance evaluation and not for load testing. Many parameters should be considered during load testing, but we will only focus on the http_reqs index, which indicates how many requests have been handled correctly by the system.
在我们的例子中,我们希望使用该工具进行性能评估,而不是进行负载测试。在负载测试期间应考虑许多参数,但我们只关注 http_reqs 指数,它表示系统正确处理了多少个请求。

We agree with the creators of k6 about the purpose of our test, namely performance and synthetic monitoring.
我们同意 k6 的创建者关于我们测试的目的,即性能和综合监控。

Use cases
使用案例

k6 users are typically developers, QA engineers, SDETs, and SREs. They use k6 for testing the performance and reliability of APIs, microservices, and websites. Common k6 use cases include the following:
k6 用户通常是开发人员、QA 工程师、SDET 和 SRE。他们使用 k6 来测试 API、微服务和网站的性能和可靠性。常见的 k6 使用案例包括:

• Load testing: k6 is optimized for minimal resource consumption and designed for running high load tests (spike, stress, and soak tests).
负载测试:k6 针对最小资源消耗进行了优化,专为运行高负载测试(峰值、压力和浸泡测试)而设计。

• Performance and synthetic monitoring: With k6, you can run tests with a small load to continuously validate the performance and availability of your production environment.
性能和综合监控:使用 k6,您可以运行小负载测试,以持续验证生产环境的性能和可用性。

• Chaos and reliability testing: k6 provides an extensible architecture. You can use k6 to simulate traffic as part of your chaos experiments or trigger them from your k6 tests.
混沌和可靠性测试:k6 提供可扩展的架构。您可以使用 k6 在混沌实验中模拟流量,也可以从 k6 测试中触发流量。

However, we have to make several assumptions if we want to evaluate the application from the point of view just described. When a load test is performed, it is usually much more complex than the ones we will perform in this section. When an application is bombarded with requests, not all of them will be successful. We can say that the test passed successfully if a very small percentage of the responses failed. In particular, we usually consider 95 or 98 percentiles of outcomes as the statistic on which to derive the test numbers.
但是,如果我们想从刚才描述的角度评估应用程序,我们必须做出几个假设。执行负载测试时,它通常比我们将在本节中执行的要复杂得多。当应用程序被请求轰炸时,并非所有请求都会成功。如果极小比例的响应失败,我们可以说测试成功通过。特别是,我们通常将 95 或 98 个百分位数的结果视为得出测试数字的统计数据。

With this background, we can perform stepwise load testing as follows: in ramp up, the system will be concerned with running the virtual user (VU) load from 0 to 50 for about 15 seconds. Then, we will keep the number of users stable for 60 seconds, and finally, ramp down the load to zero virtual users for another 15 seconds.
在此背景下,我们可以按如下方式执行逐步负载测试:在加速过程中,系统将关注从 0 到 50 的虚拟用户 (VU) 负载运行约 15 秒。然后,我们将保持用户数量稳定 60 秒,最后,将负载降低到零虚拟用户,再持续 15 秒。

Each newly written stage of the test is expressed in the JavaScript file in the stages section. Testing is therefore conducted under a simple empirical evaluation.
测试的每个新编写阶段都表示在 JavaScript 文件的 stages 部分中。因此,测试是在简单的实证评估下进行的。

First, we create three types of responses, both for the ASP.NET Web API and minimal API:
首先,我们为 ASP.NET Web API 和最小 API 创建三种类型的响应:

• Plain-text.
• Very small JSON data against a call – the data is static and always the same.
针对调用的非常小的 JSON 数据 – 数据是静态的,并且始终相同。

• In the third response, we send JSON data with an HTTP POST method to the API. For the Web API, we check the validation of the object, and for the minimal API, since there is no validation, we return the object as received.
在第三个响应中,我们使用 HTTP POST 方法将 JSON 数据发送到 API。对于 Web API,我们检查对象的验证,对于最小的 API,由于没有验证,我们返回接收的对象。

The following code will be used to compare the performance between the minimal API and the traditional approach:
以下代码将用于比较最小 API 和传统方法之间的性能:

Minimal API
最小 API

app.MapGet("text-plain",() => Results.Content("response"))
.WithName("GetTextPlain");
app.MapPost("validations",(ValidationData validation) => Results.Ok(validation)).WithName("PostValidationData");
app.MapGet("jsons", () =>
     {
           var response = new[]
           {
                new PersonData { Name = "Andrea", Surname = 
                "Tosato", BirthDate = new DateTime
                (2022, 01, 01) },
                new PersonData { Name = "Emanuele", 
                Surname = "Bartolesi", BirthDate = new 
                DateTime(2022, 01, 01) },
                new PersonData { Name = "Marco", Surname = 
                "Minerva", BirthDate = new DateTime
                (2022, 01, 01) }
           };
           return Results.Ok(response);
     })
.WithName("GetJsonData");

Traditional Approach
传统方法

For the traditional approach, three distinct controllers have been designed as shown here:
对于传统方法,设计了三个不同的控制器,如下所示:

[Route("text-plain")]
     [ApiController]
     public class TextPlainController : ControllerBase
     {
           [HttpGet]
           public IActionResult Get()
           {
                 return Content("response");
           }
     }
[Route("validations")]
     [ApiController]
     public class ValidationsController : ControllerBase
     {
           [HttpPost]
           public IActionResult Post(ValidationData data)
           {
                 return Ok(data);
           }
     }
     public class ValidationData
     {
           [Required]
           public int Id { get; set; }
           [Required]
           [StringLength(100)]
           public string Description { get; set; }
     }
[Route("jsons")]
[ApiController]
public class JsonsController : ControllerBase
{
     [HttpGet]
     public IActionResult Get()
     {
           var response = new[]
           {
              new PersonData { Name = "Andrea", Surname = 
              "Tosato", BirthDate = new 
              DateTime(2022, 01, 01) },
              new PersonData { Name = "Emanuele", Surname = 
              "Bartolesi", BirthDate = new 
              DateTime(2022, 01, 01) },
              new PersonData { Name = "Marco", Surname = 
              "Minerva", BirthDate = new 
              DateTime(2022, 01, 01) }
            };
            return Ok(response);
     }
}
     public class PersonData
     {
           public string Name { get; set; }
           public string Surname { get; set; }
           public DateTime BirthDate { get; set; }
     }

In the next section, we will define an options object, where we are going to define the execution ramp described here. We define all clauses to consider the test satisfied. As the last step, we write the real test, which does nothing but call the HTTP endpoint using GET or POST, depending on the test.
在下一节中,我们将定义一个 options 对象,我们将在其中定义此处描述的执行斜坡。我们定义所有子句以认为满足测试。作为最后一步,我们编写真正的测试,它只使用 GET 或 POST 调用 HTTP 终端节点,具体取决于测试。

Writing k6 tests
编写 k6 测试

Let’s create a test for each case scenario that we described in the previous section:
让我们为上一节中描述的每个 case 场景创建一个测试:

import http from "k6/http";
import { check } from "k6";
export let options = {
     summaryTrendStats: ["avg", "p(95)"],
     stages: [
           // Linearly ramp up from 1 to 50 VUs during 10 
              seconds
              { target: 50, duration: "10s" },
           // Hold at 50 VUs for the next 1 minute
              { target: 50, duration: "1m" },
           // Linearly ramp down from 50 to 0 VUs over the 
              last 15 seconds
              { target: 0, duration: "15s" }
     ],
     thresholds: {
           // We want the 95th percentile of all HTTP 
              request durations to be less than 500ms
              "http_req_duration": ["p(95)<500"],
           // Thresholds based on the custom metric we 
              defined and use to track application failures
              "check_failure_rate": [
          // Global failure rate should be less than 1%
             "rate<0.01",
          // Abort the test early if it climbs over 5%
             { threshold: "rate<=0.05", abortOnFail: true },
           ],
     },
};
export default function () {
    // execute http get call
    let response = http.get("http://localhost:7060/jsons");
    // check() returns false if any of the specified 
       conditions fail
    check(response, {
           "status is 200": (r) => r.status === 200,
    });
}

In the preceding JavaScript file, we wrote the test using k6 syntax. We have defined the options, such as the evaluation threshold of the test, the parameters to be measured, and the stages that the test should simulate. Once we have defined the options of the test, we just have to write the code to call the APIs that interest us – in our case, we have defined three tests to call the three endpoints that we want to evaluate.
在上面的 JavaScript 文件中,我们使用 k6 语法编写了测试。我们已经定义了选项,例如测试的评估阈值、要测量的参数以及测试应模拟的阶段。定义测试选项后,我们只需编写代码来调用我们感兴趣的 API – 在我们的例子中,我们已经定义了三个测试来调用我们想要评估的三个端点。

Running a k6 performance test
运行 k6 性能测试

Now that we have written the code to test the performance, let’s run the test and generate the statistics of the tests.
现在我们已经编写了代码来测试性能,让我们运行测试并生成测试的统计信息。

We will report all the general statistics of the collected tests:
我们将报告所收集测试的所有一般统计数据:

  1. First, we need to start the web applications to run the load test. Let’s start with both the ASP.NET Web API application and the minimal API application. We expose the URLs, both the HTTPS and HTTP protocols.
    首先,我们需要启动 Web 应用程序以运行负载测试。让我们从 ASP.NET Web API 应用程序和最小 API 应用程序开始。我们公开 URL,包括 HTTPS 和 HTTP 协议。

  2. Move the shell to the root folder and run the following two commands in two different shells:
    将 shell 移动到根文件夹,并在两个不同的 shell 中运行以下两个命令:

    dotnet .\MinimalAPI.Sample\bin\Release\net6.0\MinimalAPI.Sample.dll --urls=https://localhost:7059/;http://localhost:7060/
    dotnet .\ControllerAPI.Sample\bin\Release\net6.0\ControllerAPI.Sample.dll --urls="https://localhost:7149/;http://localhost:7150/"
  3. Now, we just have to run the three test files for each project.
    现在,我们只需要为每个项目运行三个测试文件。

• This one is for the controller-based Web API:
此 API 适用于基于控制器的 Web API:
k6 run .\K6\Controllers\json.js --summary-export=.\K6\results\controller-json.json

• This one is for the minimal API:
此 API 适用于最小 API:
k6 run .\K6\Minimal\json.js --summary-export=.\K6\results\minimal-json.json

Here are the results.
以下是结果。

For the test in traditional development mode with a plain-text content type, the number of requests served per second is 1,547:
对于纯文本内容类型的传统开发模式下的测试,每秒提供的请求数为 1547:

Figure 10.1 – The load test for a controller-based API and plain text
图 10.1 – 基于控制器的 API 和纯文本的负载测试

For the test in traditional development mode with a json content type, the number of requests served per second is 1,614:
对于传统开发模式下的 json 内容类型的测试,每秒提供的请求数为 1614:

Figure 10.2 – The load test for a controller-based API and JSON result
图 10.2 – 基于控制器的 API 和 JSON 结果的负载测试

For the test in traditional development mode with a json content type and model validation, the number of requests served per second is 1,602:
对于传统开发模式下的 json 内容类型和模型验证的测试,每秒提供的请求数为 1602:

Figure 10.3 – The load test for a controller-based API and validation payload
图 10.3 – 基于控制器的 API 和验证有效负载的负载测试

For the test in minimal API development mode with a plain-text content type, the number of requests served per second is 2,285:
对于在纯文本内容类型的最小 API 开发模式下的测试,每秒提供的请求数为 2285:

Figure 10.4 – The load test for a minimal API and plain text
图 10.4 – 最小 API 和纯文本的负载测试

For the test in minimal API development mode with a json content type, the number of requests served per second is 2,030:
对于在 json 内容类型的最小 API 开发模式下的测试,每秒提供的请求数为 2030:

Figure 10.5 – The load test for a minimal API and JSON result
图 10.5 – 最小 API 和 JSON 结果的负载测试

For the test in minimal API development mode with a json content type with model validation, the number of requests served per second is 2,070:
对于在最小 API 开发模式下使用具有模型验证的 json 内容类型的测试,每秒提供的请求数为 2070:

Figure 10.6 – The load test for a minimal API and no validation payload
图 10.6 – 最小 API 且无验证有效负载的负载测试

In the following image, we show a comparison of the three tested functionalities, reporting the number of requests served with the same functionality:
在下图中,我们显示了三个测试功能的比较,报告了使用相同功能提供的请求数:

Figure 10.7 – The performance results

As we might have expected, minimal APIs are much faster than controller-based web APIs.
正如我们所料,最小的 API 比基于控制器的 Web API 快得多。

The difference is approximately 30%, and that’s no small feat.
差异约为 30%,这可不是一件小事。

Obviously, as previously mentioned, minimal APIs have features missing in order to optimize performance, the most striking being data validation.
显然,如前所述,为了优化性能,最小的 API 缺少一些功能,最引人注目的是数据验证。

In the example, the payload is very small, and the differences are not very noticeable.
在此示例中,有效负载非常小,差异不是很明显。

As the payload and validation rules grow, the difference in speed between the two frameworks will only increase.
随着有效负载和验证规则的增长,两个框架之间的速度差异只会增加。

We have seen how to measure performance with a load testing tool and then evaluate how many requests it can serve per second with the same number of machines and users connected.
我们已经了解了如何使用负载测试工具测量性能,然后评估在连接相同数量的机器和用户的情况下,它每秒可以处理多少个请求。

We can also use other tools to understand how minimal APIs have had a strong positive impact on performance.
我们还可以使用其他工具来了解最少的 API 如何对性能产生强大的积极影响。

Benchmarking minimal APIs with BenchmarkDotNet
使用 BenchmarkDotNet 对最小 API 进行基准测试

BenchmarkDotNet is a framework that allows you to measure written code and compare performance between libraries written in different versions or compiled with different .NET frameworks.
BenchmarkDotNet 是一个框架,可用于测量编写的代码,并比较以不同版本编写或使用不同 .NET 框架编译的库之间的性能。

This tool is used for calculating the time taken for the execution of a task, the memory used, and many other parameters.
此工具用于计算执行任务所花费的时间、使用的内存和许多其他参数。

Our case is a very simple scenario. We want to compare the response times of two applications written to the same version of the .NET Framework.
我们的情况非常简单。我们想要比较写入同一版本的 .NET Framework 的两个应用程序的响应时间。

How do we perform this comparison? We take an HttpClient object and start calling the methods that we have also defined for the load testing case.
我们如何进行这种比较?我们获取一个 HttpClient 对象,并开始调用我们也为负载测试案例定义的方法。

We will therefore obtain a comparison between two methods that exploit the same HttpClient object and recall methods with the same functionality, but one is written with the ASP.NET Web API and the traditional controllers, while the other is written using minimal APIs.
因此,我们将比较两种利用相同 HttpClient 对象和调用具有相同功能的方法,但一种是使用 ASP.NET Web API 和传统控制器编写的,而另一种是使用最少的 API 编写的。

BenchmarkDotNet helps you to transform methods into benchmarks, track their performance, and share reproducible measurement experiments.
BenchmarkDotNet 可帮助您将方法转换为基准测试,跟踪其性能,并共享可重现的测量实验。

Under the hood, it performs a lot of magic that guarantees reliable and precise results thanks to the perfolizer statistical engine. BenchmarkDotNet protects you from popular benchmarking mistakes and warns you if something is wrong with your benchmark design or obtained measurements. The library has been adopted by over 6,800 projects, including .NET Runtime, and is supported by the .NET Foundation (https://benchmarkdotnet.org/).
在引擎盖下,它执行了很多魔力,由于 perfolizer 统计引擎,保证了可靠和精确的结果。BenchmarkDotNet 可保护您免受常见的基准测试错误的影响,并在基准测试设计或获得的测量值出现问题时向您发出警告。该库已被 6,800 多个项目采用,包括 .NET Runtime,并得到 .NET Foundation (https://benchmarkdotnet.org/) 的支持。

Running BenchmarkDotNet
运行 BenchmarkDotNet

We will write a class that represents all the methods for calling the APIs of the two web applications. Let’s make the most of the startup feature and prepare the objects we will send via POST. The function marked as [GlobalSetup] is not computed during runtime, and this helps us calculate exactly how long it takes between the call and the response from the web application:
我们将编写一个类,该类表示用于调用两个 Web 应用程序的 API 的所有方法。让我们充分利用启动功能并准备将通过 POST 发送的对象。标记为 [GlobalSetup] 的函数在运行时不会计算,这有助于我们准确计算调用和 Web 应用程序的响应之间需要多长时间:

  1. Register all the classes in Program.cs that implement BenchmarkDotNet:
    在 Program.cs 中注册所有实现 BenchmarkDotNet 的类:

    BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);

In the preceding snippet, we have registered the current assembly that implements all the functions that will be needed to be evaluated in the performance calculation. The methods marked with [Benchmark] will be executed over and over again to establish the average execution time.
在前面的代码段中,我们注册了当前程序集,该程序集实现了在性能计算中需要评估的所有函数。标有 [Benchmark] 的方法将一遍又一遍地执行,以确定平均执行时间。

  1. The application must be compiled on release and possibly within the production environment:
    应用程序必须在发布时编译,并且可能在生产环境中编译:

    namespace DotNetBenchmarkRunners
    {
     [SimpleJob(RuntimeMoniker.Net60, baseline: true)]
     [JsonExporter]
     public class Performances
     {
           private readonly HttpClient clientMinimal =
           new HttpClient();
           private readonly HttpClient
           clientControllers = new HttpClient();
           private readonly ValidationData data = new
           ValidationData()
           {
                 Id = 1,
                 Description = "Performance"
           };
           [GlobalSetup]
           public void Setup()
           {
                 clientMinimal.BaseAddress = new
                 Uri("https://localhost:7059");
                 clientControllers.BaseAddress = new
                 Uri("https://localhost:7149");
           }
    
           [Benchmark]
           public async Task Minimal_Json_Get() =>
           await clientMinimal.GetAsync("/jsons");
    
           [Benchmark]
           public async Task Controller_Json_Get() =>
           await clientControllers.GetAsync("/jsons");
    
           [Benchmark]
           public async Task Minimal_TextPlain_Get()
           => await clientMinimal.
           GetAsync("/text-plain");
    
           [Benchmark]
           public async Task
           Controller_TextPlain_Get() => await
           clientControllers.GetAsync("/text-plain");
    
           [Benchmark]
           public async Task Minimal_Validation_Post()
           => await clientMinimal.
           PostAsJsonAsync("/validations", data);
    
           [Benchmark]
           public async Task
           Controller_Validation_Post() => await
           clientControllers.
           PostAsJsonAsync("/validations", data);
    
     }
    
     public class ValidationData
     {
           public int Id { get; set; }
           public string Description { get; set; }
     }
    }
  2. Before launching the benchmark application, launch the web applications:
    在启动基准测试应用程序之前,请启动 Web 应用程序:

Minimal API application
最小 API 应用程序

dotnet .\MinimalAPI.Sample\bin\Release\net6.0\MinimalAPI.Sample.dll --urls="https://localhost:7059/;http://localhost:7060/"

Controller-based application
基于控制器的应用程序

dotnet .\ControllerAPI.Sample\bin\Release\net6.0\ControllerAPI.Sample.dll --urls=https://localhost:7149/;http://localhost:7150/

By launching these applications, various steps will be performed and a summary report will be extracted with the timelines that we report here:
通过启动这些应用程序,将执行各种步骤,并提取一份摘要报告,其中包含我们在此处报告的时间表:

dotnet .\DotNetBenchmarkRunners\bin\Release\net6.0\DotNetBenchmarkRunners.dll --filter *

For each method performed, the average value or the average execution time is reported.
对于执行的每种方法,都会报告平均值或平均执行时间。

Table 10.1 – Benchmark HTTP requests for minimal APIs and controllers
表 10.1 – 针对最小 API 和控制器的 HTTP 请求进行基准测试

In the following table, Error denotes how much the average value may vary due to a measurement error. Finally, the standard deviation (StdDev) indicates the deviation from the mean value. The times are given in μs and are therefore very small to measure empirically if not with instruments with that just exposed.
在下表中,Error 表示平均值可能因测量误差而变化的程度。最后,标准差 (StdDev) 表示与平均值的偏差。时间以 μs 为单位,因此如果不是用刚刚曝光的仪器,实证测量的时间非常小。

Summary
总结

In the chapter, we compared the performance of minimal APIs with that of the traditional approach by using two very different methods.
在本章中,我们使用两种截然不同的方法比较了最小 API 的性能与传统方法的性能。

Minimal APIs were not designed for performance alone and evaluating them solely on that basis is a poor starting point.
最小的 API 不仅仅是为了性能而设计的,仅根据该基础评估它们是一个糟糕的起点。

Table 10.1 indicates that there are a lot of differences between the responses of minimal APIs and that of traditional ASP.NET Web API applications.
表 10.1 表明,最小 API 的响应与传统的 ASP.NET Web API 应用程序的响应之间存在很多差异。

The tests were conducted on the same machine with the same resources. We found that minimal APIs performed about 30% better than the traditional framework.
测试是在同一台机器上以相同的资源进行的。我们发现,minimal API 的性能比传统框架高出约 30%。

We have learned about how to measure the speed of our applications – this can be useful for understanding whether the application will hold the load and what response time it can offer. We can also leverage this on small portions of critical code.
我们已经了解了如何测量应用程序的速度 – 这对于了解应用程序是否能够承受负载以及它可以提供多少响应时间非常有用。我们还可以将它用于关键代码的一小部分。

As a final note, the applications tested were practically bare bones. The validation part that should be evaluated in the ASP.NET Web API application is almost irrelevant since there are only two fields to consider. The gap between the two frameworks increases as the number of components that have been eliminated in the minimal APIs that we have already described increases.
最后要注意的是,测试的应用程序几乎是裸露的。应在 ASP.NET Web API 应用程序中评估的验证部分几乎无关紧要,因为只有两个字段需要考虑。随着我们已经描述的最小 API 中已删除的组件数量的增加,这两个框架之间的差距也会增加。

Other Books You May Enjoy
您可能喜欢的其他书籍

If you enjoyed this book, you may be interested in these other books by Packt:
如果您喜欢这本书,您可能会对 Packt 的这些其他书籍感兴趣:

Customizing ASP.NET Core 6.0 - Second Edition
定制 ASP.NET Core 6.0 - 第二版

Jürgen Gutsch
ISBN: 978-1-80323-360-4

Explore various application configurations and providers in ASP.NET Core 6
Enable and work with caches to improve the performance of your application
Understand dependency injection in .NET and learn how to add third-party DI containers
Discover the concept of middleware and write your middleware for ASP.NET Core apps
Create various API output formats in your API-driven projects
Get familiar with different hosting models for your ASP.NET Core app

ASP.NET Core 6 and Angular - Fifth Edition
ASP.NET Core 6 和 Angular - 第五版

Valerio De Sanctis
ISBN: 978-1-80323-970-5

Use the new Visual Studio Standalone TypeScript Angular template
Implement and consume a Web API interface with ASP.NET Core
Set up an SQL database server using a local instance or a cloud datastore
Perform C# and TypeScript debugging using Visual Studio 2022
Create TDD and BDD unit tests using xUnit, Jasmine, and Karma
Perform DBMS structured logging using providers such as SeriLog
Deploy web apps to Azure App Service using IIS, Kestrel, and NGINX
Learn to develop fast and flexible Web APIs using GraphQL
Add real-time capabilities to Angular apps with ASP.NET Core SignalR
Packt is searching for authors like you
If you’re interested in becoming an author for Packt, please visit authors.packtpub.com and apply today. We have worked with thousands of developers and tech professionals, just like you, to help them share their insight with the global tech community. You can make a general application, apply for a specific hot topic that we are recruiting an author for, or submit your own idea.

C#数据库仓储模式

C#数据库仓储模式

在C#中进行数据库设计时,可以采用以下几种模式来优化和提高代码的可维护性、可扩展性和性能:

1. 实体框架(Entity Framework)

Entity Framework(EF)是一个对象关系映射(ORM)框架,它允许开发人员使用.NET对象来表示数据库中的数据。通过EF,可以将数据库表映射到C#类,从而简化数据库操作。

// 定义一个与数据库表对应的C#类
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

// 使用Entity Framework上下文类来管理数据库操作
public class MyDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("YourConnectionStringHere");
    }
}

2. 仓库模式(Repository Pattern)

仓库模式是一种设计模式,用于将数据访问逻辑从应用程序代码中分离出来。通过仓库模式,可以更容易地更改数据存储方式,而不需要修改应用程序代码。

// 定义一个仓库接口
public interface IProductRepository
{
    IEnumerable<Product> GetAll();
    Product GetById(int id);
    void Add(Product product);
    void Update(Product product);
    void Delete(int id);
}

// 实现仓库接口
public class ProductRepository : IProductRepository
{
    private readonly MyDbContext _context;

    public ProductRepository(MyDbContext context)
    {
        _context = context;
    }

    public IEnumerable<Product> GetAll()
    {
        return _context.Products.ToList();
    }

    // 其他方法的实现...
}

3. 单元工作模式(Unit of Work Pattern)

单元工作模式用于管理事务,确保一组操作要么全部成功,要么全部失败。通过使用单元工作模式,可以更容易地处理数据库事务。

public class UnitOfWork : IDisposable
{
    private readonly MyDbContext _context;
    private IProductRepository _productRepository;

    public UnitOfWork(MyDbContext context)
    {
        _context = context;
    }

    public IProductRepository ProductRepository
    {
        get
        {
            if (_productRepository == null)
            {
                _productRepository = new ProductRepository(_context);
            }
            return _productRepository;
        }
    }

    public void Save()
    {
        _context.SaveChanges();
    }

    // 实现IDisposable接口...
}

4. 服务层模式(Service Layer Pattern)

服务层模式用于将业务逻辑从数据访问代码中分离出来。通过服务层模式,可以更容易地测试和维护业务逻辑。

public class ProductService
{
    private readonly IProductRepository _productRepository;

    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public IEnumerable<Product> GetAllProducts()
    {
        return _productRepository.GetAll();
    }

    // 其他业务逻辑方法的实现...
}

通过使用这些设计模式,可以更好地组织和管理C#中的数据库设计,提高代码的可维护性和可扩展性。

C#数据库设计模式在软件开发和数据库管理中起着重要作用。以下是C#数据库设计模式的一些主要用途:

  1. 代码复用:设计模式提供了一种可重用的解决方案框架,可以帮助开发人员避免重复编写相同的代码。通过使用设计模式,开发人员可以更快地构建应用程序,并减少维护成本。
  2. 提高代码质量:设计模式遵循一定的编程规范和最佳实践,可以帮助开发人员编写出更加健壮、可读和可维护的代码。这有助于提高整个软件系统的质量和稳定性。
  3. 降低系统复杂性:数据库设计模式提供了一种结构化的方法来组织和管理数据库中的数据。通过使用设计模式,开发人员可以更好地理解和处理复杂的数据库结构,从而降低系统的复杂性。
  4. 增强可扩展性:设计模式通常考虑了系统的可扩展性,允许开发人员在未来轻松地添加新功能或修改现有功能。这有助于确保软件系统能够适应不断变化的需求和业务场景。
  5. 促进团队协作:设计模式提供了一种通用的语言和框架,可以帮助开发人员之间更好地沟通和协作。当团队成员都遵循相同的设计模式和编程规范时,可以更容易地理解彼此的代码,并减少误解和冲突。
    在C#中,常见的数据库设计模式包括单例模式、工厂模式、观察者模式等。这些模式可以应用于不同的场景和需求,例如创建数据库连接、管理数据库事务、实现数据绑定等。

请注意,虽然设计模式提供了一种有用的框架和指导原则,但并不是所有情况下都需要严格遵循它们。开发人员应该根据具体的项目需求和团队规范来决定是否使用设计模式,以及如何应用它们。

在C#中进行数据库设计时,选择合适的设计模式对于确保代码的可维护性、可扩展性和性能至关重要。以下是一些建议,可以帮助你选择合适的数据库设计模式:

  1. 单一职责原则(SRP):确保每个类只有一个引起它变化的原因。在设计数据库模式时,这意味着每个表应该只负责一个逻辑实体,并且只包含与该实体直接相关的数据。
  2. 开闭原则(OCP):软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这意味着当需要添加新功能时,应该通过添加新代码来实现,而不是修改现有代码。在数据库设计中,这可以表现为使用视图、存储过程或触发器等机制来扩展功能,而不是修改现有的表结构。
  3. 依赖倒置原则(DIP):高层模块不应该依赖于低层模块,两者都应该依赖于抽象。在数据库设计中,这可以表现为使用接口或抽象类来定义数据访问逻辑,而不是直接依赖于具体的数据库实现。
  4. 实体-关系模型(ER模型):ER模型是一种用于描述现实世界中实体及其之间关系的概念模型。在C#中,可以使用实体框架等ORM工具将ER模型映射到数据库中。
  5. 规范化:规范化是减少数据冗余和提高数据一致性的过程。在设计数据库时,应该遵循规范化原则,将数据分解为多个相关表,以减少数据冗余和插入、更新、删除异常。
  6. 索引优化:索引是提高数据库查询性能的关键。在设计数据库时,应该根据查询需求合理地创建和使用索引,以提高查询性能。
  7. 安全性考虑:在设计数据库时,应该考虑数据的安全性,包括访问控制、加密和备份等机制。
  8. 可维护性和可扩展性:在设计数据库时,应该考虑到代码的可维护性和可扩展性。使用清晰、简洁的命名规范、注释和文档化可以帮助其他开发者更好地理解和维护代码。此外,设计时应考虑到未来可能的需求变化,以便进行适当的扩展。

总之,在选择数据库设计模式时,需要综合考虑项目的具体需求、性能要求、安全性考虑以及可维护性和可扩展性等因素。通过遵循上述原则和建议,你可以设计出高效、可靠且易于维护的数据库模式。

在C#中进行数据库设计时,遵循一些最佳实践可以帮助你创建出高效、可维护和可扩展的系统。以下是一些关键的设计模式和原则,可以帮助你精确地设计数据库:

  1. 规范化:确保你的数据库设计遵循规范化原则,以减少数据冗余和提高数据完整性。规范化通常涉及将数据分解为多个相关表,并通过主键和外键建立关系。
  2. 使用ORM(对象关系映射)工具:ORM工具如Entity Framework或Dapper可以帮助你将数据库表映射到C#对象,从而使数据库操作更加直观和易于管理。ORM工具还可以自动处理许多常见的数据库任务,如 CRUD 操作和查询优化。
  3. 分层架构:采用分层架构(如MVC、MVVM或Clean Architecture)来组织你的代码。这可以帮助你将关注点分离,使代码更易于测试和维护。数据库访问层通常位于底层,负责与数据库进行交互。
  4. 使用存储过程和函数:存储过程和函数可以封装复杂的数据库逻辑,提高代码的可重用性和性能。它们还可以使数据库操作更加安全和可控。
  5. 遵循设计原则:遵循一些基本的设计原则,如单一职责原则(SRP)、开闭原则(OCP)和依赖倒置原则(DIP)。这些原则可以帮助你创建出更加灵活、可扩展和可维护的系统。
  6. 优化查询性能:确保你的查询是高效的,并避免不必要的数据加载和处理。使用索引、分页和缓存等技术来提高查询性能。
  7. 使用事务:事务可以确保数据的完整性和一致性。在需要执行多个相关操作时,使用事务可以确保所有操作要么全部成功,要么全部失败。
  8. 考虑扩展性:在设计数据库时,考虑未来的扩展需求。确保你的设计可以轻松地添加新表、字段和关系,以适应不断变化的业务需求。
  9. 文档化:对你的数据库设计进行文档化,以便其他开发人员可以理解和使用它。记录表结构、关系、存储过程和函数等信息。
  10. 测试:编写单元测试和集成测试来验证你的数据库设计是否正确和可靠。这可以帮助你在开发过程中及早发现和修复问题。

总之,精确的数据库设计需要综合考虑多个方面,包括规范化、ORM工具的使用、分层架构、存储过程和函数的使用、设计原则的遵循、查询性能的优化、事务的使用、扩展性的考虑、文档化和测试等。通过遵循这些最佳实践,你可以创建出高效、可维护和可扩展的C#数据库系统。