Author Archives: usernames

Ultimate ASP.NET Core Web API 1 PROJECT CONFIGURATION

Ultimate ASP.NET Core Web API 2nd Premium Edition

Ultimate ASP.NET Core Web API 1 PROJECT CONFIGURATION
1 项目配置

Ultimate ASP.NET Core Web API 2 Creating the Required Projects
2 创建所需的项目

Ultimate ASP.NET Core Web API 3 ONION ARCHITECTURE IMPLEMENTATION
3 洋葱架构实现

Ultimate ASP.NET Core Web API 4 HANDLING GET REQUESTS
4 处理 GET 请求

Ultimate ASP.NET Core Web API 5 GLOBAL ERROR HANDLING
5 全局错误处理

Ultimate ASP.NET Core Web API 6 GETTING ADDITIONAL RESOURCES
6 获取资源

Ultimate ASP.NET Core Web API 7 CONTENT NEGOTIATION
7 内容协商

Ultimate ASP.NET Core Web API 8 METHOD SAFETY AND METHOD IDEMPOTENCY
8 方法安全和方法幂等性

Ultimate ASP.NET Core Web API 9 CREATING RESOURCES
9 创建资源

Ultimate ASP.NET Core Web API 10 WORKING WITH DELETE REQUESTS
10 使用 DELETE 请求

Ultimate ASP.NET Core Web API 11 WORKING WITH PUT REQUESTS
11 使用 PUT 请求

Ultimate ASP.NET Core Web API 12 WORKING WITH PATCH REQUESTS
12 使用PATCH请求

Ultimate ASP.NET Core Web API 13 VALIDATION
13 验证

Ultimate ASP.NET Core Web API 14 ASYNCHRONOUS CODE
14 异步代码

Ultimate ASP.NET Core Web API 15 ACTION FILTERS
15 动作过滤器

Ultimate ASP.NET Core Web API 16 PAGING
16 分页

Ultimate ASP.NET Core Web API 17 FILTERING
17 过滤

Ultimate ASP.NET Core Web API 18 SEARCHING
18 搜索

Ultimate ASP.NET Core Web API 19 SORTING
19 排序

Ultimate ASP.NET Core Web API 20 DATA SHAPING
20 数据整形

Ultimate ASP.NET Core Web API 21 SUPPORTING HATEOAS
21 支持 HATEOAS

Ultimate ASP.NET Core Web API 22 WORKING WITH OPTIONS AND HEAD REQUESTS
22 使用 OPTIONS 和 HEAD 请求

Ultimate ASP.NET Core Web API 23 ROOT DOCUMENT
23 根文档

Ultimate ASP.NET Core Web API 24 VERSIONING APIS
24 API版本控制

Ultimate ASP.NET Core Web API 25 CACHING
25 缓存

Ultimate ASP.NET Core Web API 26 RATE LIMITING AND THROTTLING
26 速率限制

Ultimate ASP.NET Core Web API 27 JWT, IDENTITY, AND REFRESH TOKEN
27 个 JWT、身份和刷新令牌

Ultimate ASP.NET Core Web API 28 REFRESH TOKEN
28 刷新令牌

Ultimate ASP.NET Core Web API 29 BINDING CONFIGURATION AND OPTIONS PATTERN
29 绑定配置和选项模式

Ultimate ASP.NET Core Web API 30 DOCUMENTING API WITH SWAGGER
30 使用 SWAGGER 编写 API 文档

Ultimate ASP.NET Core Web API 31 DEPLOYMENT TO IIS
31 部署到 IIS

Ultimate ASP.NET Core Web API 32 BONUS 1 - RESPONSE PERFORMANCE IMPROVEMENTS
32 赠送章节 1 - 响应性能改进

Ultimate ASP.NET Core Web API 33 BONUS 2 - INTRODUCTION TO CQRS AND MEDIATR WITH ASP.NET CORE WEB API
33 赠送章节 2 - 使用 ASP.NET 核心 WEB API 的 CQRS 和 MEDIATR 简介

1 Project configuration

1 项目配置

Configuration in .NET Core is very different from what we’re used to in‌ .NET Framework projects. We don’t use the web.config file anymore, but instead, use a built-in Configuration framework that comes out of the box in .NET Core.
配置.NET Core 与我们习惯的 .NET Framework 不同,不再使用 web.config 文件,而是使用开箱即用的内置配置框架。

To be able to develop good applications, we need to understand how to configure our application and its services first.
为了能够开发好的应用程序,需要首先了解如何配置应用程序及其服务。

In this section, we’ll learn about configuration in the Program class and set up our application. We will also learn how to register different services and how to use extension methods to achieve this.
在本节中,将了解 Program 类中的配置并设置应用程序。还将学习注册不同的服务,以及使用扩展方法来实现这一点。

Of course, the first thing we need to do is to create a new project, so,let’s dive right into it.
当然,需要做的第一件事是创建一个新项目,所以,直接开始吧。

1.1 Creating a New Project

1.1 创建新项目

Let's open Visual Studio, we are going to use VS 2022, and create a new ASP.NET Core Web API Application:‌
打开Visual Studio,我们将使用 VS 2022,创建一个新的 ASP.NET Core Web API 应用程序:

alt text

Now let’s choose a name and location for our project:
给项目选择一个名称和位置:

alt text

Next, we want to choose a .NET 6.0 from the dropdown list. Also, we don’t want to enable OpenAPI support right now. We’ll do that later in the book on our own. Now we can proceed by clicking the Create button and the project will start initializing:
接下来,我们要从下拉列表中选择 .NET 6.0。此外,我们现在不想启用 OpenAPI 支持。我们将在本书的后面自己做这件事。现在,我们可以通过单击 Create 按钮继续,项目将开始初始化:

alt text

1.2 launchSettings.json File Configuration

1.2 launchSettings.json 文件配置

After the project has been created, we are going to modify the launchSettings.json file, which can be found in the Properties section of the Solution Explorer window.‌
创建项目后,我们将修改 launchSettings.json 文件,该文件可以在 Solution Explorer 窗口的 Properties 部分找到。

This configuration determines the launch behavior of the ASP.NET Core applications. As we can see, it contains both configurations to launch settings for IIS and self-hosted applications (Kestrel).
此配置决定了 ASP.NET Core 应用程序的启动行为。正如我们所看到的,它包含用于启动 IIS 设置的配置和自托管应用程序 (Kestrel)。

For now, let’s change the launchBrowser property to false to prevent the web browser from launching on application start.
现在,让我们将 launchBrowser 属性更改为 false,以防止 Web 浏览器在应用程序启动时启动。

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:1629",
      "sslPort": 44370
    }
  },
  "profiles": {
    "CompanyEmployees": {
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": false,
        "launchUrl": "weatherforecast",
        "applicationUrl": "https://localhost:5001;http://localhost:5000",
        "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
        }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "launchUrl": "weatherforecast",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

This is convenient since we are developing a Web API project and we don’t need a browser to check our API out. We will use Postman (described later) for this purpose.
这很方便,因为我们正在开发一个 Web API 项目,我们不需要浏览器来检查我们的 API。为此,我们将使用 Postman(稍后介绍)。

If you’ve checked Configure for HTTPS checkbox earlier in the setup phase, you will end up with two URLs in the applicationUrl section — one for HTTPS (localhost:5001), and one for HTTP (localhost:5000).
如果您在创建项目时选中了 Configure for HTTPS 复选框,则 applicationUrl 部分最终会有两个 URL——一个用于 HTTPS (localhost:5001),一个用于 HTTP (localhost:5000)。

You’ll also notice the sslPort property which indicates that our application, when running in IISExpress, will be configured for HTTPS (port 44370), too.
您还会注意到 sslPort 属性,该属性指示我们的应用程序在 IISExpress 中运行时,也将配置为 HTTPS(端口 44370)。

NOTE: This HTTPS configuration is only valid in the local environment. You will have to configure a valid certificate and HTTPS redirection once you deploy the application.
注意:此 HTTPS 配置仅在本地环境中有效。部署应用程序后,您必须配置有效的证书和 HTTPS 重定向。

There is one more useful property for developing applications locally and that’s the launchUrl property. This property determines which URL will the application navigate to initially. For launchUrl property to work, we need to set the launchBrowser property to true. So, for example, if we set the launchUrl property to weatherforecast, we will be redirected to https://localhost:5001/weatherforecast when we launch our application.
还有一个用于在本地开发应用程序的有用属性,即 launchUrl 属性。此属性确定应用程序最初将导航到哪个 URL。要使 launchUrl 属性正常工作,我们需要将 launchBrowser 属性设置为 true。因此,例如,如果我们将 launchUrl 属性设置为 weatherforecast,则当我们启动应用程序时,我们将重定向到 https://localhost:5001/weatherforecast

1.3 Program.cs Class Explanations

1.3 Program.cs 类说明

Program.cs is the entry point to our application and it looks like this:‌
Program.cs 是我们应用程序的入口点,它看起来像这样:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();

var app = builder.Build();

// Configure the HTTP request pipeline.

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Compared to the Program.cs class from .NET 5, there are some major changes. Some of the most obvious are:
与 .NET 5 中的 Program.cs 类相比,有一些重大变化。一些最明显的是:

• Top-level statements
• 顶级语句

• Implicit using directives
• 隐式 using 指令

• No Startup class (on the project level)
• 无 Startup 类(在项目级别)

“Top-level statements” means the compiler generates the namespace, class, and method elements for the main program in our application. We can see that we don’t have the class block in the code nor the Main method. All of that is generated for us by the compiler. Of course, we can add other functions to the Program class and those will be created as the local functions nested inside the generated Main method. Top-level statements are meant to simplify the entry point to the application and remove the extra “fluff” so we can focus on the important stuff instead.
“顶级语句”是指编译器为应用程序中的主程序生成命名空间、类和方法元素。我们可以看到,代码中没有 class 块,也没有 Main 方法。所有这些都是由编译器为我们生成的。当然,我们可以向 Program 类添加其他函数,这些函数将创建为嵌套在生成的 Main 方法中的本地函数。顶级语句旨在简化应用程序的入口点并删除额外的 “绒毛”,以便我们可以专注于重要的东西。

“Implicit using directives” mean the compiler automatically adds a different set of using directives based on a project type, so we don’t have to do that manually. These using directives are stored in the obj/Debug/net6.0 folder of our project under the name CompanyEmployees.GlobalUsings.g.cs:
“隐式 using 指令”意味着编译器会根据项目类型自动添加一组不同的 using 指令,因此我们不必手动执行此作。这些 using 指令存储在项目的 obj/Debug/net6.0 文件夹中,名称为 CompanyEmployees.GlobalUsings.g.cs:

// <auto-generated/>
global using global::Microsoft.AspNetCore.Builder;
global using global::Microsoft.AspNetCore.Hosting;
global using global::Microsoft.AspNetCore.Http;
global using global::Microsoft.AspNetCore.Routing;
global using global::Microsoft.Extensions.Configuration;
global using global::Microsoft.Extensions.DependencyInjection;
global using global::Microsoft.Extensions.Hosting;
global using global::Microsoft.Extensions.Logging;
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Net.Http.Json;
global using global::System.Threading;
global using global::System.Threading.Tasks;

This means that we can use different classes from these namespaces in our project without adding using directives explicitly in our project files. Of course, if you don’t want this type of behavior, you can turn it off by visiting the project file and disabling the ImplicitUsings tag:
这意味着我们可以在项目中使用来自这些命名空间的不同类,而无需在项目文件中显式添加 using 指令。当然,如果您不希望出现此类行为,可以通过访问项目文件并禁用 ImplicitUsings 标签来关闭它:

<ImplicitUsings>disable</ImplicitUsings>

By default, this is enabled in the .csproj file, and we are going to keep it like that.
默认情况下,这在 .csproj 文件中处于启用状态,我们将保持这种状态(不要去修改它)。

Now, let’s take a look at the code inside the Program class. With this line of code:
现在,让我们看看 Program 类中的代码。使用这行代码:

var builder = WebApplication.CreateBuilder(args);

The application creates a builder variable of the type WebApplicationBuilder. The WebApplicationBuilder class is responsible for four main things:
应用程序将创建一个 WebApplicationBuilder 类型的 builder 变量。WebApplicationBuilder 类负责四个主要任务:

• Adding Configuration to the project by using the builder.Configuration property
• 使用builder.Configuration构建器将配置添加到项目中

• Registering services in our app with the builder.Services property
• 使用builder.Services构建器在我们的应用程序中注册服务

• Logging configuration with the builder.Logging property
• 使用builder.Logging生成器进行日志记录配置

• Other IHostBuilder and IWebHostBuilder configuration
• 其他 IHostBuilder 和 IWebHostBuilder 配置

Compared to .NET 5 where we had a static CreateDefaultBuilder class, which returned the IHostBuilder type, now we have the static CreateBuilder method, which returns WebApplicationBuilder type.
与 .NET 5 相比,我们有一个静态 CreateDefaultBuilder 类,它返回 IHostBuilder 类型,现在我们有静态 CreateBuilder 方法,它返回 WebApplicationBuilder 类型。

Of course, as we see it, we don’t have the Startup class with two familiar methods: ConfigureServices and Configure. Now, all this is replaced by the code inside the Program.cs file.
当然,正如我们所看到的,我们没有包含两个熟悉方法的 Startup 类:ConfigureServices 和 Configure。现在,所有这些都被 Program.cs 文件中的代码替换了。

Since we don’t have the ConfigureServices method to configure our services, we can do that right below the builder variable declaration. In the new template, there’s even a comment section suggesting where we should start with service registration. A service is a reusable part of the code that adds some functionality to our application, but we’ll talk about services more later on.
由于我们没有 ConfigureServices 方法来配置我们的服务,因此我们可以在 builder 变量声明的正下方执行此作。在新模板中,甚至还有一个注释部分,建议我们应该从哪里开始服务注册。服务是代码的可重用部分,它为应用程序添加了一些功能,但我们稍后将更多地讨论服务。

In .NET 5, we would use the Configure method to add different middleware components to the application’s request pipeline. But since we don’t have that method anymore, we can use the section below the var app = builder.Build(); part to do that. Again, this is marked with the comment section as well:
在 .NET 5 中,我们将使用 Configure 方法将不同的中间件组件添加到应用程序的请求管道中。但是由于我们不再有该方法,因此我们可以使用 var app = builder.Build(); 部分来做到这一点。同样,这也用评论部分标记:

alt text

NOTE: If you still want to create your application using the .NET 5 way, with Program and Startup classes, you can do that, .NET 6 supports it as well. The easiest way is to create a .NET 5 project, copy the Startup and Program classes and paste it into the .NET 6 project.
注意:如果您仍希望使用 .NET 5 方式创建应用程序,使用 Program 和 Startup 类,您可以这样做,.NET 6 也支持它。最简单的方法是创建一个 .NET 5 项目,复制 Startup 和 Program 类并将其粘贴到 .NET 6 项目中。

Since larger applications could potentially contain a lot of different services, we can end up with a lot of clutter and unreadable code in the Program class. To make it more readable for the next person and ourselves, we can structure the code into logical blocks and separate those blocks into extension methods.
由于较大的应用程序可能包含许多不同的服务,因此我们最终会在 Program 类中得到很多杂乱和不可读的代码。为了让下一个人和我们自己更具可读性,我们可以将代码构建成逻辑块,并将这些块分离到扩展方法中。

1.4 Extension Methods and CORS Configuration

1.4 扩展方法和 CORS 配置

An extension method is inherently a static method. What makes it different from other static methods is that it accepts this as the first parameter, and this represents the data type of the object which will be using that extension method. We’ll see what that means in a moment.‌
扩展方法本质上是一种静态方法。它与其他静态方法的不同之处在于,它接受 this 作为第一个参数,这表示将使用该扩展方法的对象的数据类型。我们稍后会看看这意味着什么。

An extension method must be defined inside a static class. This kind of method extends the behavior of a type in .NET. Once we define an extension method, it can be chained multiple times on the same type of object.
必须在 static 类中定义扩展方法。此方法扩展了 .NET 中类型的行为。一旦我们定义了一个扩展方法,它就可以在同一类型的对象上被多次链接。

So, let’s start writing some code to see how it all adds up.
那么,让我们开始编写一些代码,看看这一切是如何加起来的。

We are going to create a new folder Extensions in the project and create a new class inside that folder named ServiceExtensions. The ServiceExtensions class should be static.
我们将在项目中创建一个新文件夹 Extensions,并在该文件夹中创建一个名为 ServiceExtensions 的新类。ServiceExtensions 类应该是 static 类。

namespace CompanyEmployees.Extensions
{
    public static class ServiceExtensions
    {
    }
}

Let’s start by implementing something we need for our project immediately so we can see how extensions work.
让我们立即开始实现项目所需的内容,以便了解扩展的工作原理。

The first thing we are going to do is to configure CORS in our application. CORS (Cross-Origin Resource Sharing) is a mechanism to give or restrict access rights to applications from different domains.
我们要做的第一件事是在我们的应用程序中配置 CORS。CORS(跨域资源共享)是一种机制,用于授予或限制来自不同域的应用程序的访问权限。

If we want to send requests from a different domain to our application, configuring CORS is mandatory. So, to start, we’ll add a code that allows all requests from all origins to be sent to our API:
如果我们想将请求从其他域发送到我们的应用程序,则必须配置 CORS。因此,首先,我们将添加一个代码,允许来自所有来源的所有请求都发送到我们的 API:

namespace CompanyEmployees.Extensions
{
    public static class ServiceExtensions
    {
        public static void ConfigureCors(this IServiceCollection services) =>
            services.AddCors(options =>
            {
                options.AddPolicy("CorsPolicy", builder =>
                builder.AllowAnyOrigin()
                       .AllowAnyMethod()
                       .AllowAnyHeader());
            });
    }
}

We are using basic CORS policy settings because allowing any origin, method, and header is okay for now. But we should be more restrictive with those settings in the production environment. More precisely, as restrictive as possible.
我们使用的是基本的 CORS 策略设置,因为允许任何源、方法和标头目前是可以的。但是我们应该在生产环境中对这些设置进行更多限制。更准确地说,尽可能严格。

Instead of the AllowAnyOrigin() method which allows requests from any source, we can use the WithOrigins("https://example.com") which will allow requests only from that concrete source. Also, instead of AllowAnyMethod() that allows all HTTP methods, we can use WithMethods("POST", "GET") that will allow only specific HTTP methods. Furthermore, you can make the same changes for the AllowAnyHeader() method by using, for example, the WithHeaders("accept", "content-type") method to allow only specific headers.
我们可以使用 WithOrigins("https://example.com") 方法,而不是允许来自任何源的请求的 AllowAnyOrigin() 方法,它将仅允许来自该具体源的请求。此外,我们可以使用 WithMethods("POST", "GET") 来只允许特定的 HTTP 方法,而不是允许所有 HTTP 方法的 AllowAnyMethod()。此外,您可以对 AllowAnyHeader() 方法进行相同的更改,例如,使用 WithHeaders("accept", "content-type")方法仅允许特定标头。

1.5 IIS Configuration

1.5 IIS 配置

ASP.NET Core applications are by default self-hosted, and if we want to host our application on IIS, we need to configure an IIS integration which will eventually help us with the deployment to IIS. To do that, we need to add the following code to the ServiceExtensions class:‌
默认情况下,ASP.NET Core 应用程序是自托管的,如果我们想在 IIS 上托管应用程序,则需要配置 IIS 集成,这最终将帮助我们部署到 IIS。为此,我们需要将以下代码添加到 ServiceExtensions 类中:

namespace CompanyEmployees.Extensions
{
    public static class ServiceExtensions
    {
        public static void ConfigureCors(this IServiceCollection services) =>
            services.AddCors(options =>
            {
                options.AddPolicy("CorsPolicy", builder =>
                builder.AllowAnyOrigin()
                       .AllowAnyMethod()
                       .AllowAnyHeader());
            });

        public static void ConfigureIISIntegration(this IServiceCollection services) =>
            services.Configure<IISOptions>(options =>
            {

            });
    }
}

We do not initialize any of the properties inside the options because we are fine with the default values for now. But if you need to fine-tune the configuration right away, you might want to take a look at the possible options:
我们没有初始化选项中的任何属性,因为我们现在对默认值没有问题。但是,如果您需要立即微调配置,则可能需要查看可能的选项:

Option Default Setting
AutomaticAuthentication true if true,the authentication middleware sets the HttpContext.User and responds to generic challenges,if false,the authentication middleware only provides an identity(HttpContext.User ) and responds to challenges when explicitly requested by the AuthenticationScheme . Windows Authentication must be enabled in IIS for AutomaticAuthentication to function.
如果为 true,则身份验证中间件设置 HttpContext.User 并响应一般质询,如果为 false,则身份验证中间件仅提供 identity(HttpContext.User ),并在 AuthenticationScheme 显式请求时响应质询。必须在 IIS 中启用 Windows 身份验证,AutomaticAuthentication 才能正常工作。
AuthenticationDisplayName null Sets the display name shown to users on login pages
设置在登录页面上向用户显示的显示名称
ForwardClientCertificate true if true and the MS-ASPNETCORE-CLIENTCERT request header is present,the HttpContext.Connection.ClientCertificate is populated.
如果 true 且存在 MS-ASPNETCORE-CLIENTCERT 请求标头,则填充 HttpContext.Connection.ClientCertificate。

Now, we mentioned extension methods are great for organizing your code and extending functionalities. Let’s go back to our Program class and modify it to support CORS and IIS integration now that we’ve written extension methods for those functionalities. We are going to remove the first comment and write our code over it:
现在,我们提到了扩展方法非常适合组织代码和扩展功能。让我们回到我们的 Program 类并对其进行修改以支持 CORS 和 IIS 集成,因为我们已经为这些功能编写了扩展方法。我们将删除第一个注释并在其上编写我们的代码:

using CompanyEmployees.Extensions;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.ConfigureCors();
builder.Services.ConfigureIISIntegration();

builder.Services.AddControllers();

var app = builder.Build();

// Configure the HTTP request pipeline.

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

And let's add a few mandatory methods to the second part of the Program class (the one for the request pipeline configuration):
让我们向 Program 类的第二部分(用于请求管道配置的那个)添加一些强制性方法:

using CompanyEmployees.Extensions;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.ConfigureCors();
builder.Services.ConfigureIISIntegration();

builder.Services.AddControllers();

var app = builder.Build();

// Configure the HTTP request pipeline.

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.All
});

app.UseCors("CorsPolicy");

app.UseAuthorization();

app.MapControllers();

app.Run();

We’ve added CORS and IIS configuration to the section where we need to configure our services. Furthermore, CORS configuration has been added to the application’s pipeline inside the second part of the Program class.But as you can see, there are some additional methods unrelated to IIS configuration. Let’s go through those and learn what they do.
我们已将 CORS 和 IIS 配置添加到需要配置服务的部分。此外,CORS 配置已添加到 Program 类的第二部分内的应用程序管道中。但正如你所看到的,还有一些与 IIS 配置无关的其他方法。让我们来了解一下这些并了解它们的作用。

• app.UseForwardedHeaders() will forward proxy headers to the current request. This will help us during application deployment. Pay attention that we require Microsoft.AspNetCore.HttpOverrides using directive to introduce the ForwardedHeaders enumeration
•app.UseForwardedHeaders()会将代理标头转发到当前请求。这将在应用程序部署期间帮助我们。请注意,我们需要 Microsoft.AspNetCore.HttpOverrides using 指令来引入 ForwardedHeaders 枚举

• app.UseStaticFiles() enables using static files for the request. If we don’t set a path to the static files directory, it will use a wwwroot folder in our project by default.
•app.UseStaticFiles() 允许对请求使用静态文件。如果我们没有设置静态文件目录的路径,它将默认使用我们项目中的 wwwroot 文件夹。

• app.UseHsts() will add middleware for using HSTS, which adds the Strict-Transport-Security header.
• app.UseHsts() 将添加用于使用 HSTS 的中间件,从而添加 Strict-Transport-Security 标头。

1.6 Additional Code in the Program Class

1.6 Program 类中的其他代码

We have to pay attention to the AddControllers() method. This method registers only the controllers in IServiceCollection and not Views or Pages because they are not required in the Web API project which we are building.‌
我们必须注意 AddControllers() 方法。此方法仅在 IServiceCollection 中注册控制器,而不在 Views 或 Pages 中注册控制器,因为我们正在构建的 Web API 项目中不需要它们。

Right below the controller registration, we have this line of code:
在控制器注册的正下方,我们有这行代码:

var app = builder.Build();

With the Build method, we are creating the app variable of the type WebApplication. This class (WebApplication) is very important since it implements multiple interfaces like IHost that we can use to start and stop the host, IApplicationBuilder that we use to build the middleware pipeline (as you could’ve seen from our previous custom code), and IEndpointRouteBuilder used to add endpoints in our app.
使用 Build 方法,我们将创建 WebApplication 类型的 app 变量。这个类 (WebApplication) 非常重要,因为它实现了多个接口,例如我们可用于启动和停止主机的 IHost、用于构建中间件管道的 IApplicationBuilder(正如您从之前的自定义代码中所看到的那样),以及用于在应用程序中添加端点的 IEndpointRouteBuilder。

The UseHttpRedirection method is used to add the middleware for the redirection from HTTP to HTTPS. Also, we can see the UseAuthorization method that adds the authorization middleware to the specified IApplicationBuilder to enable authorization capabilities.
UseHttpRedirection 方法用于添加中间件,以便从 HTTP 重定向到 HTTPS。此外,我们还可以看到 UseAuthorization 方法,该方法将授权中间件添加到指定的 IApplicationBuilder 以启用授权功能。

Finally, we can see the MapControllers method that adds the endpoints from controller actions to the IEndpointRouteBuilder and the Run method that runs the application and block the calling thread until the host shutdown.
最后,我们可以看到 MapControllers 方法,该方法将控制器中的终结点添加到 IEndpointRouteBuilder,以及 Run 方法,该方法运行应用程序并阻止调用线程,直到主机关闭。

Microsoft advises that the order of adding different middlewares to the application builder is very important, and we are going to talk about that in the middleware section of this book.
Microsoft 建议,向应用程序构建器添加不同中间件的顺序非常重要,我们将在本书的中间件部分讨论这一点。

1.7 Environment-Based Settings

1.7 基于环境的设置

While we develop our application, we use the “development” environment. But as soon as we publish our application, it goes to the “production” environment. Development and production environments should have different URLs, ports, connection strings, passwords, and other sensitive information.‌
当我们开发应用程序时,我们使用 “development” 环境。但是,一旦我们发布应用程序,它就会进入 “production” 环境(生产环境)。开发和生产环境应具有不同的 URL、端口、连接字符串、密码和其他敏感信息。

Therefore, we need to have a separate configuration for each environment and that’s easy to accomplish by using .NET Core-provided mechanisms.
因此,我们需要为每个环境提供单独的配置,这可以通过使用 .NET Core 提供的机制轻松实现。

As soon as we create a project, we are going to see the appsettings.json file in the root, which is our main settings file, and when we expand it we are going to see the appsetings.Development.json file by default. These files are separate on the file system, but Visual Studio makes it obvious that they are connected somehow:
一旦我们创建了一个项目,我们将在根目录中看到 appsettings.json 文件,这是我们的主要设置文件,当我们展开它时,我们将看到 appsetings。Development.json 文件。这些文件在文件系统上是独立的,但 Visual Studio 清楚地表明它们以某种方式连接在一起:

alt text

The apsettings.{EnvironmentSuffix}.json files are used to override the main appsettings.json file. When we use a key-value pair from the original file, we override it. We can also define environment-specific values too.
apsettings.{EnvironmentSuffix}.json 文件用于覆盖主 appsettings.json 文件。当我们使用原始文件中的键值对时,我们会覆盖它。我们也可以定义特定于环境的值。

For the production environment, we should add another file: appsettings.Production.json:
对于生产环境,我们应该添加另一个文件:appsettings.Production.json:

alt text

The appsettings.Production.json file should contain the configuration for the production environment.
appsettings.Production.json文件应包含生产环境的配置。

To set which environment our application runs on, we need to set up the ASPNETCORE_ENVIRONMENT environment variable. For example, to run the application in production, we need to set it to the Production value on the machine we do the deployment to.
要设置应用程序在哪个环境上运行,我们需要设置 ASPNETCORE_ENVIRONMENT 环境变量。例如,要在生产环境中运行应用程序,我们需要在执行部署的机器上将其设置为 Production 值。

We can set the variable through the command prompt by typing set ASPNETCORE_ENVIRONMENT=Production in Windows or export ASPNET_CORE_ENVIRONMENT=Production in Linux.
我们可以通过命令提示符设置变量,方法是在 Windows 中键入 set ASPNETCORE_ENVIRONMENT=Production 或在 Linux 中键入 export ASPNET_CORE_ENVIRONMENT=Production。

ASP.NET Core applications use the value of that environment variable to decide which appsettings file to use accordingly. In this case, that will be appsettings.Production.json.
ASP.NET Core 应用程序使用该环境变量的值来决定相应地使用哪个 appsettings 文件。在本例中,这将是 appsettings.Production.json。

If we take a look at our launchSettings.json file, we are going to see that this variable is currently set to Development.
如果我们查看 launchSettings.json 文件,我们将看到此变量当前设置为 Development。

Now, let’s talk a bit more about the middleware in ASP.NET Core applications.
现在,让我们更多地讨论一下 ASP.NET Core 应用程序中的中间件。

1.8 ASP.NET Core Middleware

1.8 ASP.NET Core 中间件

As we already used some middleware code to modify the application’s pipeline (CORS, Authorization...), and we are going to use the middleware throughout the rest of the book, we should be more familiar with the ASP.NET Core middleware.‌
由于我们已经使用了一些中间件代码来修改应用程序的管道(CORS、Authorization...),并且我们将在本书的其余部分使用中间件,因此我们应该更熟悉 ASP.NET Core 中间件。

ASP.NET Core middleware is a piece of code integrated inside the application’s pipeline that we can use to handle requests and responses. When we talk about the ASP.NET Core middleware, we can think of it as a code section that executes with every request.
ASP.NET Core 中间件是一段集成在应用程序管道中的代码,我们可以使用它来处理请求和响应。当我们谈论 ASP.NET Core 中间件时,我们可以将其视为随每个请求一起执行的代码部分。

Usually, we have more than a single middleware component in our application. Each component can:
通常,我们的应用程序中有多个中间件组件。每个组件都可以:

• Pass the request to the next middleware component in the pipeline and also
• 将请求传递给管道中的下一个中间件组件,以及

• It can execute some work before and after the next component in the pipeline
• 它可以在管道中的下一个元件之前和之后执行一些工作

To build a pipeline, we are using request delegates, which handle each HTTP request. To configure request delegates, we use the Run, Map, and Use extension methods. Inside the request pipeline, an application executes each component in the same order they are placed in the code- top to bottom:
为了构建管道,我们使用请求委托来处理每个 HTTP 请求。要配置请求委托,我们使用 Run、Map 和 Use 扩展方法。在请求管道中,应用程序按照它们在代码中的放置顺序(从上到下)执行每个组件:

alt text

Additionally, we can see that each component can execute custom logic before using the next delegate to pass the execution to another component. The last middleware component doesn’t call the next delegate, which means that this component is short-circuiting the pipeline. This is a terminal middleware because it stops further middleware from processing the request. It executes the additional logic and then returns the execution to the previous middleware components.
此外,我们可以看到,在使用 next()将执行传递给另一个组件之前,每个组件都可以执行自定义 logic。最后一个中间件组件不调用下一个委托,这意味着该组件正在使管道短路。这是一个终端中间件,因为它会阻止进一步的中间件处理请求。它执行额外的 logic,然后将执行返回给前面的中间件组件。

Before we start with examples, it is quite important to know about the order in which we should register our middleware components. The order is important for the security, performance, and functionality of our applications:
在我们开始示例之前,了解我们应该注册中间件组件的顺序非常重要。该顺序对于我们应用程序的安全性、性能和功能非常重要:

alt text

As we can see, we should register the exception handler in the early stage of the pipeline flow so it could catch all the exceptions that can happen in the later stages of the pipeline. When we create a new ASP.NET Core app, many of the middleware components are already registered in the order from the diagram. We have to pay attention when registering additional existing components or the custom ones to fit this recommendation.
正如我们所看到的,我们应该在管道流的早期阶段注册异常处理程序,以便它可以捕获管道后期可能发生的所有异常。当我们创建新的 ASP.NET Core 应用程序时,许多中间件组件已经按照图中的顺序注册了。在注册其他现有组件或自定义组件以适应此建议时,我们必须注意。

For example, when adding CORS to the pipeline, the app in the development environment will work just fine if you don’t add it in this order. But we’ve received several questions from our readers stating that they face the CORS problem once they deploy the app. But once we suggested moving the CORS registration to the required place, the problem disappeared.
例如,在将 CORS 添加到管道时,如果不按此顺序添加,开发环境中的应用程序将正常工作。但是我们收到了读者的几个问题,他们指出,一旦部署了应用程序,他们就会面临 CORS 问题。但是,一旦我们建议将 CORS 注册移动到所需位置,问题就消失了。

Now, we can use some examples to see how we can manipulate the application’s pipeline. For this section’s purpose, we are going to create a separate application that will be dedicated only to this section of the book. The later sections will continue from the previous project, that we’ve already created.
现在,我们可以使用一些示例来了解如何作应用程序的管道。出于本节的目的,我们将创建一个单独的应用程序,该应用程序将专门用于本书的这一部分。后面的部分将从我们已经创建的上一个项目继续。

1.8.1 Creating a First Middleware Component‌

1.8.1 创建第一个 Middleware 组件

Let’s start by creating a new ASP.NET Core Web API project, and name it MiddlewareExample.
让我们首先创建一个新的 ASP.NET Core Web API 项目,并将其命名为 MiddlewareExample。

In the launchSettings.json file, we are going to add some changes regarding the launch profiles:
在 launchSettings.json 文件中,我们将添加一些有关启动配置文件的更改:

{
    "profiles": {
        "MiddlewareExample": {
            "commandName": "Project",
            "dotnetRunMessages": true,
            "launchBrowser": true,
            "launchUrl": "weatherforecast",
            "applicationUrl": "https://localhost:5001;http://localhost:5000",
            "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }
        }
    }
}

Now, inside the Program class, right below the UseAuthorization part, we are going to use an anonymous method to create a first middleware component:
现在,在 Program 类中,在 UseAuthorization 部分的正下方,我们将使用匿名方法创建第一个中间件组件:

app.UseAuthorization();
app.Run(async context => { await context.Response.WriteAsync("Hello from the middleware component."); });
app.MapControllers();

We use the Run method, which adds a terminal component to the app pipeline. We can see we are not using the next delegate because the Run method is always terminal and terminates the pipeline. This method accepts a single parameter of the RequestDelegate type. If we inspect this delegate we are going to see that it accepts a single HttpContext parameter:
我们使用 Run 方法,该方法将终端组件添加到应用程序管道中。我们可以看到我们没有使用 next() 委托,因为 Run 方法始终是 terminal 并终止管道。此方法接受 RequestDelegate 类型的单个参数。如果我们检查这个委托,我们将看到它接受一个 HttpContext 参数:

namespace Microsoft.AspNetCore.Http { 
    public delegate Task RequestDelegate(HttpContext context); 
}

So, we are using that context parameter to modify our requests and responses inside the middleware component. In this specific example, we are modifying the response by using the WriteAsync method. For this method, we need Microsoft.AspNetCore.Http namespace.
因此,我们使用该 context 参数来修改中间件组件内的请求和响应。在此特定示例中,我们将使用 WriteAsync 方法修改响应。对于此方法,我们需要 Microsoft.AspNetCore.Http 命名空间。

Let’s start the app, and inspect the result:
让我们启动应用程序,并检查结果:

alt text

There we go. We can see a result from our middleware.
好了。我们可以看到中间件的结果。

1.8.2 Working with the Use Method‌

1.8.2 使用 Use 方法

To chain multiple request delegates in our code, we can use the Use method. This method accepts a Func delegate as a parameter and returns a Task as a result:
要在代码中链接多个请求委托,我们可以使用 Use 方法。此方法接受 Func 委托作为参数,并返回 Task 作为结果:

public static IApplicationBuilder Use(this IApplicationBuilder app, Func<HttpContext, Func<Task>, Task> middleware);

So, this means when we use it, we can make use of two parameters, context and next:
所以,这意味着当我们使用它时,我们可以使用两个参数,context 和 next:

app.UseAuthorization();
app.Use(async (context, next) =>
{
    Console.WriteLine($"Logic before executing the next delegate in the Use method");
    await next.Invoke();
    Console.WriteLine($"Logic after executing the next delegate in the Use method");
});
app.Run(async context =>
{
    Console.WriteLine($"Writing the response to the client in the Run method");
    await context.Response.WriteAsync("Hello from the middleware component.");
});
app.MapControllers();

As you can see, we add several logging messages to be sure what the order of executions inside middleware components is. First, we write to a console window, then we invoke the next delegate passing the execution to another component in the pipeline. In the Run method, we write a second message to the console window and write a response to the client. After that, the execution is returned to the Use method and we write the third message (the one below the next delegate invocation) to the console window.
如你所见,我们添加了几个日志记录消息,以确保中间件组件内的执行顺序是什么。首先,我们写入控制台窗口,然后调用下一个委托,将执行传递给管道中的另一个组件。在 Run 方法中,我们将第二条消息写入控制台窗口,并将响应写入客户端。之后,执行将返回到 Use 方法,我们将第三条消息(下一个委托调用下面的消息)写入控制台窗口。

The Run method doesn’t accept the next delegate as a parameter, so without it to send the execution to another component, this component short-circuits the request pipeline.
Run 方法不接受下一个委托作为参数,因此如果没有它将执行发送到另一个组件,此组件会使请求管道短路。

Now, let’s start the app and inspect the result, which proves our execution order:
现在,让我们启动应用程序并检查结果,它证明了我们的执行顺序:

alt text

Maybe you will see two sets of messages but don’t worry, that’s because the browser sends two sets of requests, one for the /weatherforecast and another for the favicon.ico. If you, for example, use Postman to test this, you will see only one set of messages.
也许你会看到两组消息,但不要担心,那是因为浏览器发送了两组请求,一组用于 /weatherforecast,另一组用于 favicon.ico。例如,如果您使用 Postman 对此进行测试,您将只看到一组消息。

One more thing to mention. We shouldn’t call the next.Invoke after we send the response to the client. This can cause exceptions if we try to set the status code or modify the headers of the response.
还有一件事要提。我们不应该调用下一个。在将响应发送到客户端后调用。如果我们尝试设置状态代码或修改响应的标头,这可能会导致异常。

For example:
例如:

app.Use(async (context, next) =>
{
    await context.Response.WriteAsync("Hello from the middleware component."); 
    await next.Invoke();
    Console.WriteLine($"Logic after executing the next delegate in the Use method");
});
app.Run(async context =>
{
    Console.WriteLine($"Writing the response to the client in the Run method");
    context.Response.StatusCode = 200; 
    await context.Response.WriteAsync("Hello from the middleware component.");
});

Here we write a response to the client and then call next.Invoke. Of course, this passes the execution to the next component in the pipeline. There, we try to set the status code of the response and write another one. But let’s inspect the result:
在这里,我们向客户端写入响应,然后调用 next。调用。当然,这会将执行传递给管道中的下一个组件。在那里,我们尝试设置响应的状态代码并编写另一个状态代码。但让我们检查一下结果:

alt text

We can see the error message, which is pretty self-explanatory.
我们可以看到错误消息,这是不言自明的。

1.8.3 Using the Map and MapWhen Methods‌

1.8.3 使用 Map 和 MapWhen 方法

To branch the middleware pipeline, we can use both Map and MapWhen methods. The Map method is an extension method that accepts a path string as one of the parameters:
要对中间件管道进行分支,我们可以同时使用 Map 和 MapWhen 方法。该方法 Map 是一种扩展方法,它接受路径字符串作为参数之一:

public static IApplicationBuilder Map(this IApplicationBuilder app, PathString pathMatch, Action<IApplicationBuilder> configuration)

When we provide the pathMatch string, the Map method will compare it to the start of the request path. If they match, the app will execute the branch.
当我们提供 pathMatch 字符串时,Map 方法会将其与请求路径的开头进行比较。如果它们匹配,应用程序将执行分支。

So, let’s see how we can use this method by modifying the Program class:
那么,让我们看看如何通过修改 Program 类来使用此方法:

app.Use(async (context, next) =>
{
    Console.WriteLine($"Logic before executing the next delegate in the Use method");
    await next.Invoke();
    Console.WriteLine($"Logic after executing the next delegate in the Use method");
}); 
app.Map("/usingmapbranch", builder =>
{
    builder.Use(async (context, next) =>
    {
        Console.WriteLine("Map branch logic in the Use method before the next delegate"); 
        await next.Invoke();
        Console.WriteLine("Map branch logic in the Use method after the next delegate");
    });
    builder.Run(async context =>
    {
        Console.WriteLine($"Map branch response to the client in the Run method");
        await context.Response.WriteAsync("Hello from the map branch.");
    });
});
app.Run(async context =>
{
    Console.WriteLine($"Writing the response to the client in the Run method");
    await context.Response.WriteAsync("Hello from the middleware component.");
});

By using the Map method, we provide the path match, and then in the delegate, we use our well-known Use and Run methods to execute middleware components.
通过使用 Map 方法,我们提供路径匹配,然后在委托中,我们使用我们著名的 Use 和 Run 方法来执行中间件组件。

Now, if we start the app and navigate to /usingmapbranch, we are going to see the response in the browser:
现在,如果我们启动应用程序并导航到 /usingmapbranch,我们将在浏览器中看到响应:

alt text

But also, if we inspect console logs, we are going to see our new messages:
但是,如果我们检查控制台日志,我们将看到我们的新消息:

alt text

Here, we can see the messages from the Use method before the branch, and the messages from the Use and Run methods inside the Map branch. We are not seeing any message from the Run method outside the branch. It is important to know that any middleware component that we add after the Map method in the pipeline won’t be executed. This is true even if we don’t use the Run middleware inside the branch.
在这里,我们可以看到分支之前来自 Use 方法的消息,以及来自 Map 分支内的 Use 和 Run 方法的消息。我们没有看到来自分支外部的 Run 方法的任何消息。请务必知道,我们在管道中的 Map 方法之后添加的任何中间件组件都不会被执行。即使我们不在分支中使用 Run 中间件,也是如此。

1.8.4 Using MapWhen Method‌

1.8.4 使用 MapWhen 方法

If we inspect the MapWhen method, we are going to see that it accepts two parameters:
如果我们检查 MapWhen 方法,我们将看到它接受两个参数:

public static IApplicationBuilder MapWhen(this IApplicationBuilder app, Func<HttpContext, bool> predicate, Action<IApplicationBuilder> configuration)

This method uses the result of the given predicate to branch the request pipeline.
此方法使用给定谓词的结果对请求管道进行分支。

So, let’s see it in action:
那么,让我们看看它的实际效果:

app.Map("/usingmapbranch", builder =>
{
    ...
});
app.MapWhen(context => context.Request.Query.ContainsKey("testquerystring"), builder =>
{
    builder.Run(async context =>
    {
        await context.Response.WriteAsync("Hello from the MapWhen branch.");
    });
});
app.Run(async context =>
{
    ...
});

Here, if our request contains the provided query string, we execute the Run method by writing the response to the client. So, as we said, based on the predicate’s result the MapWhen method branch the request pipeline.
在这里,如果我们的请求包含提供的查询字符串,我们将通过将响应写入客户端来执行 Run 方法。因此,正如我们所说,根据谓词的结果,MapWhen 方法对请求管道进行分支。

Now, we can start the app and navigate to:
现在,我们可以启动应用程序并导航到:
https://localhost:5001?testquerystring=test

alt text

And there we go. We can see our expected message. Of course, we can chain multiple middleware components inside this method as well.
好了。我们可以看到预期的消息。当然,我们也可以在此方法中链接多个中间件组件。

So, now we have a good understanding of using middleware and its order of invocation in the ASP.NET Core application. This knowledge is going to be very useful to us once we start working on a custom error handling middleware (a few sections later).
因此,现在我们已经很好地了解了中间件的使用及其在 ASP.NET Core 应用程序中的调用顺序。一旦我们开始开发自定义错误处理中间件,这些知识将对我们非常有用(稍后将介绍几节)。

In the next chapter, we’ll learn how to configure a Logger service because it’s really important to have it configured as early in the project as possible. We can close this app, and continue with the CompanyEmployees app.
在下一章中,我们将学习如何配置 Logger 服务,因为在项目的早期配置它非常重要。我们可以关闭此应用程序,并继续使用 CompanyEmployees 应用程序。

C# 扩展方法

C# 扩展方法

简介

在 C# 中,扩展方法是一种特殊的静态方法,可以像实例方法一样调用,但实际上是静态的。这些方法可以扩展现有类型的功能,而无需修改类型的定义。

定义扩展方法的步骤

  1. 静态类:扩展方法必须定义在一个静态类中。
  2. 静态方法:扩展方法本身必须是静态的。
  3. this 参数:扩展方法的第一个参数前加上 this 关键字,指定要扩展的类型。

示例

扩展字符串类型

扩展 string 类型,添加一个方法 ToReverse 来返回字符串的反转。

public static class StringExtensions
{
    public static string ToReverse(this string str)
    {
        if (string.IsNullOrEmpty(str))
            return str;

        char[] charArray = str.ToCharArray();
        Array.Reverse(charArray);
        return new string(charArray);
    }
}

// 使用扩展方法
class Program
{
    static void Main()
    {
        string text = "hello";
        string reversed = text.ToReverse(); // 调用扩展方法
        Console.WriteLine(reversed); // 输出:olleh
    }
}

扩展集合类型

扩展 IEnumerable,添加一个方法 ToFormattedString,将集合转换为逗号分隔的字符串。

public static class CollectionExtensions
{
    public static string ToFormattedString<T>(this IEnumerable<T> collection)
    {
        return string.Join(", ", collection);
    }
}

// 使用扩展方法
class Program
{
    static void Main()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5 };
        string result = numbers.ToFormattedString(); // 调用扩展方法
        Console.WriteLine(result); // 输出:1, 2, 3, 4, 5
    }
}

常用场景

  1. 扩展框架类型:为系统类型(如 string, int, DateTime)添加新方法。
  2. 简化代码:提供更简洁的调用方式。
  3. 增强 LINQ 功能:许多 LINQ 方法(如 Where, Select)本质上是扩展方法。
  4. 代码复用:为通用操作创建工具方法,提高可读性和复用性。

注意事项

  1. 优先级:如果实例方法和扩展方法同名,实例方法会优先调用。
  2. 命名空间:使用扩展方法时,必须引入包含扩展方法的命名空间。
  3. 泛型支持:扩展方法可以是泛型的,用于更加通用的操作。

扩展方法的局限性

• 无法重写现有方法。
• 无法添加新属性。
• 不能改变类型的行为,只能提供额外的方法。

扩展方法的优势

扩展方法的优势在于代码的可读性、可维护性、和开发体验,虽然其本质与调用静态类中的静态方法类似,但扩展方法带来了显著的便利和代码语义上的改善。

  1. 更直观的调用方式

扩展方法可以像实例方法一样调用,符合自然语言习惯,提升代码的可读性。

// 静态方法调用:
var reversed = StringHelper.Reverse("hello");
// 扩展方法调用:
var reversed = "hello".ToReverse();
  1. 更符合面向对象的设计理念

扩展方法的调用方式让外部操作看起来像是类的实例行为,符合面向对象编程的习惯。这样无需修改类的定义即可扩展其功能。

简化链式调用

扩展方法支持链式调用,简化了操作步骤,让代码更加紧凑。

// 使用扩展方法:
var result = "hello".ToReverse().ToUpper();

// 使用静态方法:
var reversed = StringHelper.Reverse("hello");
var result = StringHelper.ToUpper(reversed);

与 LINQ 紧密集成

许多 LINQ 方法(如 Where, Select, OrderBy)都是扩展方法,调用方式非常流畅。

// 扩展方法:
var result = numbers.Where(x => x > 10).OrderBy(x => x).ToList();

// 静态方法:
var filtered = LinqHelper.Where(numbers, x => x > 10);
var ordered = LinqHelper.OrderBy(filtered, x => x);
var result = LinqHelper.ToList(ordered);
  1. 提高开发体验

扩展方法在调用时支持智能提示,能直接显示可用的扩展方法,让开发更高效。
• 静态方法: 开发者需要记住静态类名称。
• 扩展方法: 开发者只需聚焦于对象,IDE 自动提示可用的方法。

C#中的Lambda表达式

什么是Lambda?

在C#中,一个Lambda表达式就是一个匿名函数。

Lambda的语法结构如下:

(Input Params) => Expression

• 中间的"=>" 是Lambda的操作符,一般读作"goes to"

• 左边的部分"Input Params"是Lambda表达式的输入参数,当且仅当只有一个参数的时候,括号可以忽略,其他情况哪怕参数个数是0个,也不能忽略.

• 右边的部分"Expression"是语句(Expression)或者代码块(Statement),当且仅当只有一行代码时,大括号可以忽略,其他情况均不可忽略,参考以下样例:

参考以下样例:

() => Console.WriteLine("No Params")  // 0 个参数,左边的圆括号不能省略
x => x * x   // 1个参数,左边的圆括号可加可不加
(x, y) => x - y     // 2个参数,左边的圆括号不能省略
(x, y) => { x += y; Console.WriteLine(x); }   // 大括号不能省略

阿隆佐·邱奇(Alonzo Church),美国数学家和逻辑学家,对计算机科学领域做出了重要贡献。邱奇在20世纪30年代提出了lambda演算,这是一种形式化的计算理论,用于研究函数的定义、应用和等价性。他在提出lambda演算时,定义了一种匿名函数的表达方式,即lambda表达式。这种表达方式允许函数没有名字,直接通过参数和表达式来描述,因此得名lambda表达式。邱奇的这一贡献为后来的函数式编程语言奠定了基础。

ASP.NET Core Razor Pages in Action 11 客户端技术和 AJAX

ASP.NET Core Razor Pages in Action 11 客户端技术和 AJAX

本章涵盖

• 选择您的客户端技术
• 从客户端代码调用页面处理程序
• 在 Razor Pages 中使用 JSON
• 基于每个页面隔离 CSS 样式

我们使用的所有代码示例都依赖于在服务器上完整生成的页面的 HTML,除了一个领域:客户端验证。验证错误消息是使用客户端库生成的,特别是 Microsoft 的 jQuery Unobtrusive Validation 库。作为一项功能,客户端验证增强了网站的可用性。它为用户提供有关表单验证错误的即时反馈,而无需等待将整个表单提交到服务器进行处理。

如果您想让用户满意,可用性至关重要,而使用客户端技术的服务器端应用程序的可用性可以显著提高。在本章中,我们将介绍如何在 Razor Pages 应用程序中实现一些使用客户端技术的常见模式。由于它作为默认项目模板的一部分包含在内,因此我们将查看 jQuery 的 DOM作和发出由浏览器发起的异步 HTTP 请求。我们还将探索使用纯 JavaScript 的替代方案,并考虑 jQuery 的未来。

异步 HTTP 请求(通常称为 AJAX)使您能够从服务器获取数据,而无需整页回发,因此用户看不到这些数据。您可以对这些数据执行任何作。您可以使用它来执行计算或更新页面的某些部分。或者,您可以将数据作为文件下载提供。数据本身可以有多种形式。它可以是 HTML,也可以是 XML 结构,或者更常见的是 JSON。在本章中,您将了解如何使用页面处理程序方法从 AJAX 请求生成 HTML 和 JSON 数据,并了解此方法的局限性。您还将了解如何将数据成功发布到页面处理程序。

如果您想广泛使用 JSON,建议的方法是将 Web API 控制器构建到您的应用程序中,这为 RESTful HTTP 服务提供了基础。在本书中,我不介绍 API 控制器,但我们将探索 .NET 6 中引入的新简化的请求处理功能(基于最小的 API),该功能以相当少的仪式提供类似的收益。我们还将介绍 .NET 6 中的另一项新功能,该功能使您能够将 CSS 样式隔离到单个页面的范围,而无需增加 HTTP 请求的数量。在介绍一些代码示例之前,我们将讨论如何进行客户端开发。

11.1 选择客户端技术

毫无疑问,jQuery 库是有史以来使用最多的 JavaScript 库。它于 2006 年推出,很快成为作 DOM、处理事件、管理 CSS 转换和执行异步 HTTP 请求 (AJAX) 的实际方式。当 jQuery 启动时,这些 API 的实现在不同浏览器中差异很大。jQuery 充当适配器,提供一组在所有支持的浏览器中以相同方式工作的 API。

许多其他客户端库都依赖于 jQuery,包括领先的 UI 框架库 Bootstrap,它捆绑到默认的 ASP.NET Core Web 应用程序模板中。从 .NET 6 开始的新模板中包含的最新版本的 Bootstrap(版本 5)消除了对 jQuery 的依赖。如今,大多数浏览器都比以前更严格地遵守标准。他们中的许多人共享相同的底层技术。jQuery 旨在解决的问题已基本消失。

尽管如此,jQuery 仍包含在默认的 Razor Pages 应用程序模板中,以提供对客户端验证的支持。从长远来看,这种情况很可能会改变,因为 GitHub 上有一个未解决的问题,它讨论了客户端验证的潜在替代,以便它不再依赖于 jQuery (https://github.com/dotnet/aspnetcore/issues/8573)。尽管如此,jQuery 仍然被广泛使用,因此在本章中,我们将研究使用 jQuery 和纯 JavaScript 实现客户端解决方案。

存在其他客户端库。其中使用最广泛的可能是 React 和 Angular。Angular 更准确地称为应用程序框架,但两者都主要用于构建单页应用程序 (SPA),其中工作流逻辑在浏览器中执行,而不是在服务器上执行。它们可以用作 Razor Pages 应用程序的一部分,但如果您只想添加客户端功能的隔离实例,那么它们就有点矫枉过正了。

11.2 从 JavaScript 调用页面处理程序

本节重点介绍如何从客户端代码调用命名页面处理程序方法。我们将介绍如何使用部分页面返回可用于更新页面部分的 HTML 片段。然后,我们将探索如何将标准表单发布到页面处理程序和独立数据。执行此作时,我们将特别注意请求验证,请记住,默认情况下,它已融入 Razor Pages 框架中。最后,我们将介绍在与客户端代码中的页面处理程序交互时如何使用 JSON。

11.2.1 使用部分页面返回 HTML

在第一个示例中,您将了解如何将 HTML 代码片段异步加载到页面中。具体来说,当用户单击列表中的属性名称时,您会将属性的详细信息加载到 Bootstrap 模式中。在本练习中,您将使用部分页面。一个部分将包含模态框的大纲 HTML,即 head 和 foot,它们将包含在现有的 City 页面中。当用户单击 city 页面上的属性列表时,将从服务器加载模态主体。您将添加客户端代码,用于标识单击了哪个属性,然后向返回 PartialResult 的页面处理程序发出请求,如果您还记得第 3 章,则表示对部分页面的处理。它非常适合生成 HTML 片段,例如可能用于更新页面区域的片段。

您将创建要开始的模态框的轮廓。将新的 cshtml 文件添加到 Pages\Shared 文件夹。请记住,没有用于分部视图的模板,因此,如果您使用的是 Visual Studio,则可以选择 Razor View > Empty 模板,然后删除默认内容。将部分文件命名为 _PropertyModalPartial.cshtml,并将任何现有内容替换为以下代码。

列表 11.1 基于 Bootstrap 5 模态的 PropertyModalPartial 内容

<div class="modal fade" id="property-modal">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Property Details</h5>
        <button type="button" class="btn-close" 
         ➥ data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">

      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" 
         ➥ data-bs-dismiss="modal">Close</button>
      </div>
    </div>
  </div>
</div>

这段代码或多或少直接摘自 Bootstrap 5 的模态框文档。它是一个标准模态,其正文中没有内容。您已更改标题以使其与您的应用程序更相关,并添加了 id 属性,以便您可以从客户端代码中识别模式。您还向模态框添加了 fade 类,因此它在出现和消失时会发出动画效果。

现在,您需要为模态框提供一些内容。这将放置在另一个名为 _PropertyDetailsPartial.cshtml 的部件中,该部件也位于 Pages\Shared 文件夹中。它的内容很简单。将 Property 作为 Razor 文件的模型传入,并按如下方式呈现属性的详细信息。

清单 11.2 包含将要加载的属性详细信息的 partial

@model Property 
<h3>@Model.Name</h3>
<address>
    @Model.Address
</address>
<p>Availability: @(Model.AvailableFrom < DateTime.Now ? 
➥ "Immediate" : Model.AvailableFrom.ToShortDateString())</p>
<p>Day Rate: @Model.DayRate.ToString("c")</p>
<p>Max Guests: @Model.MaxNumberOfGuests</p>
<p>Smoking permitted? @(Model.SmokingPermitted ? "Yes" : "No")</p>

接下来,您需要一个使用此部分生成 HTML 的页面处理程序。将以下处理程序方法添加到现有 OnGetAsync 方法之后的 City.cshtml.cs 文件中。

清单 11.3 将 HTML 生成为 PartialViewResult 的命名处理程序

public async Task<PartialViewResult> OnGetPropertyDetails(int id)
{
    var property = await _propertyService.FindAsync(id);
    return Partial("_PropertyDetailsPartial", property);
}

这是一个命名的处理程序方法。它采用 int 作为参数,表示所选属性的标识。它使用属性服务从数据库中获取属性详细信息,然后将其传递给部分文件,返回呈现的结果。您需要将属性服务注入到 PageModel 的构造函数中。

清单 11.4 将属性服务注入 CityModel 构造函数

private readonly ICityService _cityService;
private readonly IPropertyService _propertyService;

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

最后的步骤涉及对 City.cshtml 文件的一些更改。迭代所选城市中的属性集合,并在此文件中呈现其详细信息。属性的名称显示在 h5 标题中,该标题分配了 role=“button”,因此当用户将鼠标悬停在标题上时,光标将变为指针。您需要修改标题以添加一些数据属性。一个将帮助您识别特定属性,其他用于触发模态。在下面的清单中,我用几行中断了 h5 元素的结果声明,因此更容易注释代码。

清单 11.5 修改后的 h5 元素旨在触发模态框

<h5 role="button"
data-id="@property.Id"                ❶
data-bs-toggle="modal"                ❷
data-bs-target="#property-modal"      ❸
>@property.Name</h5>

❶ data-id 属性具有分配给它的属性 Id 。您将检索此值并将其传递给您刚刚添加的命名页面处理程序。
❷ 这个 Bootstrap 自定义属性使元素成为模态框的触发器。
❸ 此属性采用目标模态框的 ID。

最后,在页面中包含模态部分(清单 11.6),并使用 scripts 部分添加一个 JavaScript 块。客户端代码使用 jQuery 向所有 h5 元素添加 click 事件处理程序。在单击处理程序中,从 data-id 属性中检索指定属性的 ID。构建一个 URL,其中查询字符串参数表示页面处理程序方法的名称及其 id 参数。这用于 jQuery 加载方法,该方法使用 GET 方法发出 HTTP 请求,然后将响应加载到指定的元素中。

清单 11.6 向页面添加 partial 和 script 块

<partial name="_PropertyModalPartial" />                             ❶
@section scripts{
<script>
$(function(){
    $('h5').on('click', function() {                                 ❷
        const id = $(this).data('id');                               ❸
        $('.modal-body').load(`?handler=propertydetails&id=${id}`);  ❹
    });
});
</script>
}

❶ 使用 partial 标签辅助函数来包含模态 partial。
❷ 为所有 h5 元素添加 click 事件处理程序。
❸ 在处理程序中,从 data-id 属性中检索所选属性的 ID。
❹ jQuery 加载方法使用 GET 方法调用指定的 URL,并将响应插入选择器中指定的元素中。在本例中,这就是模态体。

运行应用程序,单击主页上的一个城市,然后在结果列表中单击该属性的名称。详细信息应显示在模式中。
消除对 jQuery 的依赖,您可以改用 Fetch API,所有现代浏览器都支持它。您唯一需要做的更改是 scripts 部分。

清单 11.7 使用 Fetch API 调用命名处理程序

@section scripts{
<script>
document.querySelectorAll('h5').forEach(el => {                          ❶
    el.addEventListener('click', (event) => {                            ❶
        const id = event.target.dataset.id;                              ❷
        fetch(`?handler=propertydetails&id=${id}`)                       ❸
        .then((response) => {
            return response.text();                                      ❹
        })
        .then((result) => {
            document.querySelector('.modal-body').innerHTML = result;    ❺
        });
    });
});
</script>
}

❶ 为所有 h5 元素添加 click 事件处理程序。
❷ 在处理程序中,从 data-id 属性中检索所选属性的 ID。
❸ 像以前一样向指定的 URL 发出请求。
❹ 在 Response 对象上调用 text() 方法,以字符串形式获取返回的内容。
❺ 将其分配给模态体。

如果您进行这些更改并运行应用程序,则应该不会看到行为有任何差异。Fetch 示例比 jQuery 示例更冗长一些,但一旦您了解了代码中发生的事情,它就没有那么复杂了。

Fetch 与 Promise 一起使用,这类似于 .NET 任务。它们表示异步作的未来结果。Fetch HTTP 调用以流的形式返回响应。使用 text() 方法将流读入字符串,然后使用结果字符串更新模态体。如果你不熟悉它并想了解更多关于 Fetch 的信息,我推荐 Mozilla Developer Network 文档作为一个很好的起点:http://mng.bz/vXO4

在我们继续之前,必须了解进行这些异步调用时幕后发生的事情。您将使用浏览器开发人员工具查看正在发出的实际请求并检查其响应。每当您进行客户端工作时,浏览器开发人员工具都是必不可少的帮助,因为它们揭示了浏览器中发生的情况。您应该参考浏览器的文档以获取有关如何访问和使用它们的更多信息,但对于 Chrome 和 Edge 用户,您只需在打开浏览器时按 F12 键即可。显示工具后,单击 Network 选项卡,其中显示网络流量(请求)的详细信息,如图 11.1 所示。然后导航到其中一个城市页面,并单击属性名称。您应该会看到请求的详细信息出现。

图 11.1 该请求记录在 Network (网络) 选项卡中。

在此示例中,请求 URL 为 berlin?handler=properydetails&id=39。HTTP 响应状态代码为 200,类型指定为 Fetch,因为我的屏幕截图是使用 Fetch 示例获取的。如果我使用 jQuery 代码进行抓取,则类型将记录为 xhr,表示 jQuery 进行 AJAX 调用所依赖的浏览器的 XmlHttpRequest 对象。根据您的配置,您可能有不同的标题,但您可以右键单击任何标题来自定义您的视图。如果点击 Network 选项卡中的请求名称,则可以看到实际请求的更多详细信息(图 11.2),包括请求和响应标头以及请求中传递的任何数据的详细信息。

图 11.2 单击请求可查看更多详细信息。

您可以使用 Response (响应) 选项卡在右侧查看从服务器返回的实际响应。在图 11.3 所示的示例中,您可以立即从 Unicode 字符的存在中看出,除了我在第 3.1.5 节中介绍的默认基本拉丁字符集之外,我没有配置任何其他编码。

图 11.3 在 Network (网络) 选项卡中捕获原始响应。

因此,这是一个成功完成的简单 GET 请求。在下一个练习中,您将尝试将表单的内容发布到页面处理程序。

11.2.2 发布到页面处理程序

在模窗中拥有所选住宿的详细信息后,选择抵达和离开日期、指定同行宾客人数并获取住宿总费用将非常有用。在本节中,您将向模式添加一个表单,以便您执行此作,然后将表单内容发布到另一个处理程序方法,该方法将返回住宿的总成本。

首先,您需要一个输入模型来包装 Property 实例和表单值。将以下清单中的类声明添加到 CityModel 类中,使其嵌套在其中。

列表 11.8 将 BookingInputModel 作为嵌套类添加到 CityModel 中

public class BookingInputModel
{
    public Property Property { get; set; }
    [Display(Name = "No. of guests")]
    public int NumberOfGuests { get; set; }
    [DataType(DataType.Date), Display(Name = "Arrival")]
    public DateTime? StartDate { get; set; }
    [DataType(DataType.Date), Display(Name = "Departure")]
    public DateTime? EndDate { get; set; }
}

除了 Property 之外,此类还包括您将添加到模态框的表单字段的属性。BookingInputModel 将替换 Property 作为模态框的模型。您尚未包含任何 BindProperty 属性,因为您将 BookingInputModel 作为参数传递给处理请求的处理程序方法。handler 方法的代码如清单 11.9 所示。它根据天数、日房价和客人数量计算住宿总费用。这也添加到 CityModel 中。

清单 11.9 OnPostBooking 处理程序方法

public ContentResult OnPostBooking(BookingInputModel model)
{
    Var numberOfDays = (int)(model.EndDate.Value - 
    ➥ model.StartDate.Value).TotalDays;
    var totalCost = numberOfDays * model.Property.DayRate * 
    ➥ model.NumberOfGuests;
    return Content(totalCost.ToString("c"));
}

此处理程序方法返回 ContentResult,这是一种将字符串作为响应返回的方法。它不是您在实际应用程序中可能经常使用的东西。但是,它有助于简化此示例。此外,计算成本的基本逻辑通常属于业务逻辑层中的服务。但同样,我想让这个例子保持简单。下一个列表显示了添加到 _PropertyDetailsPartial.cshtml 文件的表单。同样,为了保持示例简单,我没有包括 validation。

清单 11.10 添加到物业详情部分的预订表单

<form id="booking-form">
    <input type="hidden" asp-for="Property.DayRate" />                ❶
    <div class="form-group">
        <label asp-for="StartDate" class="control-label"></label>
        <input asp-for="StartDate" class="form-control" />            ❷
    </div>
    <div class="form-group">
        <label asp-for="EndDate" class="control-label"></label>
        <input asp-for="EndDate" class="form-control" />              ❷
    </div>
    <div class="form-group">
        <label asp-for="NumberOfGuests" class="control-label"></label>
        <input asp-for="NumberOfGuests" class="form-control" max="@Model.Property.MaxNumberOfGuests" />                       ❸
    </div>
</form>

❶ 包括一个包含所选属性的日价的隐藏字段。
❷ 添加到达日期和离开日期的输入。
❸ 使用 max 属性将数字输入限制为所选住宿可以容纳的最大客人数。

您还需要更改部分的模型类型。目前它是一个 Property。您将将其更改为 BookingInputModel,因此请将 _PropertyDetailsPartial.cshtml 的第一行替换为以下内容:

@model CityModel.BookingInputModel

接下来,向 modal partial 属性添加一个新按钮;充当动态加载属性详细信息的框架的 Partial。将以下清单中的粗线添加到 footer 元素中。

Listing 11.11 在模态框部分添加了 Book 按钮

<div class="modal-footer">
    <button type="button" class="btn btn-secondary" 
     ➥ data-bs-dismiss="modal">Close</button>
    <button type="button" class="btn btn-success" 
     ➥ data-bs-dismiss="modal">Book</button>
</div>

最后,您需要将 new 按钮连接到一个 click 处理程序,该处理程序将表单发布到页面处理程序方法。jQuery 版本如下面的清单所示。

清单 11.12 使用 jQuery 向新按钮添加新的处理程序

$('.btn-success').on('click', function(){
    const data = $('#booking-form').serialize();             ❶
    $.post('?handler=booking', data, function(totalCost){    ❷
        alert(`Your stay will cost ${totalCost}`);           ❸
    });
});

❶ 使用 jQuery serialize 方法将表单字段值转换为 URL 编码的字符串以进行发布。
❷ 发布到页面处理程序并定义一个将响应作为参数的回调函数。
❸ 将响应合并到浏览器警报中显示的字符串中。

您已准备好对此进行测试。在浏览器中获得新版本的 City 页面后,请确保在 network 选项卡上打开开发人员工具。然后点击住宿,输入预订的开始日期和结束日期,然后指定客人人数。单击 Book 按钮。现在,您应该在 Network (网络) 选项卡中看到 400 错误代码(图 11.4)。

图 11.4 请求失败,出现 400 错误码。

400 HTTP 状态代码表示格式错误的客户端请求。在 Razor Pages 中,此错误最常见的原因是 POST 请求缺少请求验证令牌。如果您回想一下第 6 章,当 form 标记帮助程序的方法设置为 post 时,标记将生成为隐藏字段。如果检查在清单 11.10 中添加的代码,则 form 元素上没有 method 属性;因此,未生成隐藏的输入。

在这种情况下,修复方法很简单。您只需将 method=“post” 添加到表单元素中,然后重新运行应用程序。将生成隐藏字段,并将其包含在发布到服务器的序列化值中。但是,当您使用 AJAX 发布值时,完全没有 form 元素的情况并不少见。例如,您可能希望发布计算结果,而不是表单字段的内容。那么在这些情况下,您有什么选择呢?

首先,您可以考虑禁用请求验证。这必须在 PageModel 级别通过在处理程序类上添加 IgnoreAntiforgeryToken 属性来完成。您不能在页面处理程序级别禁用请求验证(与 MVC作方法不同),因此,如果您的页面上有其他处理程序处理 POST 请求,您也将禁用这些处理程序的请求验证。这可能是不可接受的,而且禁用此安全功能通常是不可取的。这样,您就需要生成一个令牌值并将其包含在 AJAX 请求中。

可以使用 Razor 页面的 Html 帮助程序属性上的 AntiForgeryToken 方法呈现具有令牌值的隐藏输入:

@Html.AntiForgeryToken()

或者,您可以使用 @inject 指令将 IAntiforgery 服务注入页面,并使用其 GetAndStoreTokens 方法生成令牌集并访问生成的 RequestToken 属性。

清单 11.13 从 IAntiforgery 服务生成请求验证令牌

@using Microsoft.AspNetCore.Antiforgery
@inject IAntiforgery antiforgery
@{
    var token = antiforgery.GetAndStoreTokens(HttpContext).RequestToken;
}

仅当 JavaScript 代码嵌入到 Razor 页面中时,此方法才适用,因为需要在脚本中呈现 C# 令牌变量。您不能在外部 JavaScript 文件中包含 C# 代码,因此,如果您希望将脚本排除在页面之外,则必须使用 AntiForgeryToken 方法来呈现隐藏字段。

接下来,在请求中包含令牌,作为表单字段(发布表单值)或作为请求标头值。表单字段的默认名称为 __RequestVerificationToken(带有两个前导下划线),请求标头的默认名称为 RequestVerificationToken(没有前导下划线)。重做此示例,而不使用 form 元素进行说明。首先,从属性详细信息中部分删除 form 标记,以便输入不再包含在表单中。接下来,将对 Html.AntiForgeryToken 方法的调用添加到 City Razor 页面。我将我的放在 partial 标签帮助程序的正上方:

@Html.AntiForgeryToken()
<partial name="_PropertyModalPartial" />
@section scripts{

您只需更改在脚本块中分配数据的方式。由于没有表单,因此无法再序列化表单,因此请创建一个对象,其属性将镜像页面处理程序期望作为参数的输入模型。您只需指定页面处理程序所需的属性值。下面的清单显示了 changed 的 button click 事件处理程序。

清单 11.14 更改 button click 事件处理程序以使用对象

$('.btn-success').on('click', function(){
    const data = {                                       ❶
            startdate: $('#StartDate').val(),            ❷
            enddate: $('#EndDate').val(),                ❷
            numberofguests: $('#NumberOfGuests').val(),  ❷
            __requestverificationtoken: $('[name="__RequestVerificationToken"]').val(),          ❸
            property:{                                   ❹
                dayrate: $('#Property_DayRate').val()    ❹
            }                                            ❹
        }
    $.post('?handler=booking', data, function(totalCost){
        alert(`Your stay will cost ${totalCost}`);
    });
});

❶ 创建一个对象。
❷ 使用 jQuery val() 方法获取表单字段值并将其分配给属性。
❸ 令牌的 hidden 字段没有 id 属性,因此您可以使用其名称作为 jQuery 选择器。
❹ 输入模型的 Property 属性是嵌套的。

jQuery 库负责将 data 变量表示的 JavaScript 对象转换为 URL 编码的字符串,以便发布和为请求分配正确的内容类型标头 (x-www-form-urlencoded)。在按钮单击处理程序的纯 JavaScript 版本(在章节下载中提供)中,您可以通过包含顶级 DayRate 属性来拼合输入模型。这样,您就可以使用浏览器的 URLSearchParams 接口 (http://mng.bz/49vj) 生成适合轻松发布的 URL 编码值字符串。此接口无法序列化具有嵌套属性的对象。

11.2.3 使用 JsonResult

到目前为止,您已将 simple values 和 JavaScript 对象传递给页面处理程序。本节将开始研究如何使用 JSON,JSON 已成为 Web 应用程序中客户端和服务器之间交换数据的实际数据格式。在此示例中,您将使用纯 JavaScript,并将您从表单字段构建的 JavaScript 对象序列化为 JSON,然后再将其发布到页面处理程序。然后,您将转换页面处理程序方法以返回 JsonResult 而不是 ContentResult。

当您指定 x-www-form-urlencoded 作为请求的内容类型时,框架知道它应该解析请求正文中构成您发送到服务器的数据的已发布名称-值对。您可以通过将内容类型设置为 application/json 来让框架知道您何时发布 JSON。但你还要告诉页面处理程序从何处获取数据。您可以通过将 FromBody 属性应用于页面处理程序参数来实现此目的。清单 11.15 显示了页面处理程序方法在处理 JSON 所需的更改后的外观。在代码中,您创建一个匿名类型来表示返回的数据。虽然这适用于特殊情况,但更可靠的方法将涉及为返回类型声明类或记录,以便它们是可测试的。

清单 11.15 使用 JsonResult 和 FromBody 属性

public JsonResult OnPostBooking([FromBody]BookingInputModel model)    ❶
{       
    var numberOfDays = (int)(model.EndDate.Value - 
    ➥ model.StartDate.Value).TotalDays;
    var totalCost = numberOfDays * model.Property.DayRate *
    ➥ model.NumberOfGuests;
    var result = new  { TotalCost = totalCost };                      ❷
    return new JsonResult(result);                                    ❸
}

❶ 将返回类型更改为 JsonResult,并将 [FromBody] 属性添加到处理程序参数,以告知框架在何处查找 JSON 数据。
❷ 创建一个对象来表示返回的数据。
❸ 将对象传递给 JsonResult 构造函数,并对其进行适当的序列化。

清单 11.16 显示了按钮点击事件处理程序的纯 JavaScript 版本,该处理程序生成 JSON,将其发布到服务器,并处理结果。有一些要点需要注意。这次必须在标头中传递请求验证令牌。您不能将其包含在请求正文的 JSON 中,因为请求验证是在框架解析 JSON 之前进行的。此外,您必须将请求的内容类型指定为 application/json。最后,当您使用 Fetch API 时,您对响应使用 json() 方法(而不是您之前使用的 text() 方法)来反序列化响应,以便您可以使用它。默认的 JSON 序列化程序使用驼峰式大小写生成属性名称,因此您传递给 JsonResult 构造函数的匿名对象的 TotalCost 属性将变为 totalCost。

清单 11.16 使用 Fetch API 发布 JSON 的按钮点击处理程序

document.querySelector('.btn-success')
    .addEventListener('click', () => {
        const token = document.querySelector(
        ➥ '[name="__RequestVerificationToken"]').value;
        const data = {
                startdate: document.getElementById('StartDate').value,
                enddate: document.getElementById('EndDate').value,
                numberofguests: document.getElementById(
                ➥ 'NumberOfGuests').value,
                property: {
                    dayrate: document.getElementById(
                    ➥ 'Property_DayRate').value
                }
            };
            fetch('?handler=booking', {
                method: 'post',
                headers: {
                    "Content-type": "application/json",              ❶
                    "RequestVerificationToken" : token               ❷
                },
                body: JSON.stringify(data)                           ❸
            }).then((response) => {
                return response.json();                              ❹
            }).then((result) => {
                alert(`Your stay will cost ${result.totalCost}`);    ❺
            });
        });

❶ 您必须将内容类型指定为 application/json。
❷ 您必须将请求验证令牌作为请求标头传递。
❸ 使用 JSON.stringify 方法将 JavaScript 对象序列化为 JSON 格式。
❹ 在响应中使用 json 方法,该方法将返回的 JSON 解析为对象。
❺ 访问结果对象的 totalCost 属性。

请注意,令牌的标头名称没有前导下划线。如果您在使用 JSON 时收到 400 错误代码,请在检查标头是否存在后检查标头的名称。如果您未将内容类型指定为 application/json,它将默认为 text/plain,并且您的处理程序将出错,因为它不会尝试解析请求正文。

jQuery 版本(清单 11.17)使用 ajax 方法而不是速记 post 方法来请求,因为 ajax 方法使您能够设置标头。请求的内容类型是使用设置中的一个 contentType 选项指定的,而不是显式设置标头值。

清单 11.17 使用 jQuery 和页面处理程序发布和处理 JSON

$('.btn-success').on('click', function(){
    const token = $('[name="__RequestVerificationToken"]').val();   ❶
    const data = {
            startdate: $('#StartDate').val(),
            enddate: $('#EndDate').val(),
            numberofguests: $('#NumberOfGuests').val(),
            property:{
                dayrate: $('#Property_DayRate').val()
            }
        }
    $.ajax({
        url: '?handler=booking',
        method: "post",
        contentType: "application/json",                            ❷
        headers: {
            "RequestVerificationToken" : token                      ❸
        },
        data: JSON.stringify(data)                                  ❹
    })
    .done(function(response){
        alert(`Your stay will cost ${response.totalCost}`);
    });
});

❶ 获取 token 值。
❷ 通过 contentType 选项设置内容类型。
❸ 在 header 中传递令牌。
❹ 使用 JSON.stringify 方法将 JavaScript 对象序列化为 JSON 格式。

使用页面处理程序和 JSON 的要点如下:

• 请记住将 FromBody 属性应用于处理程序参数。
• 将请求的内容类型设置为 application/JSON。
• 在请求标头中传递请求验证令牌。
• 使用浏览器开发人员工具中的 Network (网络) 选项卡来诊断问题。

在我看来,使用页面处理程序方法处理和返回 JSON 是可以接受的。请记住,每次执行页面处理程序时,都会实例化 PageModel 的一个实例,并解析其所有依赖项。如果您发现您正在注入仅 JSON 处理页面处理程序所需的其他依赖项,则表明有一点代码异味。此时,或者如果你的需求更广泛,你应该考虑使用最少的请求处理 API,这是 .NET 6 中引入的一项新功能。

11.3 最小请求处理 API

在 .NET 6 之前,在 ASP.NET Core 中通过 HTTP 处理大量服务(例如使用和生成 JSON 的服务)的推荐方法是使用构成 MVC 框架一部分的 Web API 框架。为此,您需要创建从 ApiController 派生的类,向它们添加处理请求所需的作方法,将相关服务添加到您的应用程序,并将控制器作方法映射为终端节点。在您的应用程序中合并 Web API 控制器涉及一定程度的仪式。

如果您还记得第 1 章,引入 Razor Pages 的很大一部分动机是减少现有 MVC 框架在服务器上生成 HTML 所需的仪式。减少仪式的努力在 .NET 6 中继续进行。在第 2 章中,您已经看到了应用程序引导和配置是如何基于新的最小托管 API 简化为一个文件的。作为整体最小 API 功能的一部分,还引入了一种精简的处理请求的方法,允许您将路由映射到函数,并且它确实有效。

使用 WebApplication 上的 Map[HttpMethod] 方法(MapPost、MapGet、MapPut 等)注册最小 API 请求处理程序,其约定与在 PageModel 类中注册页面处理程序的约定相同。回想一下,WebApplication 类型的实例是从生成器返回的。Program.cs 中的 Build 方法调用。传入路由模板和路由处理程序,即在路由匹配时执行的标准 .NET 委托。这可以是命名函数或可以接受参数的 lambda 表达式(图 11.5)。

图 11.5 最小 API 请求处理程序剖析。这是您将构建的实际请求处理程序的非作性、精简版本,纯粹是为了说明移动部件而设计的。

路由处理程序可以配置为返回许多内置响应类型之一,包括 JSON、文本和文件。内置返回类型中明显遗漏的是 HTML。这就是 Razor Pages 的用途。

11.3.1 最小 API 示例

在下一个练习中,您会将当前预订页面处理程序迁移到最小 API 方法。您将在 /api/property/booking 中定义一个响应 POST 请求的终端节点。它将 BookingInputModel 作为参数并返回 JSON 响应。打开 Program.cs 文件,就在 app 之前。Run()中,添加以下清单中的代码行。

清单 11.18 将页面处理程序迁移到最小的 API

app.MapPost("/api/property/booking", (BookingInputModel model) =>    ❶
{
    var numberOfDays = (int)(model.EndDate.Value -
    ➥ model.StartDate.Value).TotalDays;
    var totalCost = numberOfDays * model.Property.DayRate * 
    ➥ model.NumberOfGuests;
    return Results.Ok(new { TotalCost = totalCost });                ❷
});

❶ 使用 MapPost 方法将 POST 请求映射到指定的路由,并接受 BookingInputModel 作为参数。
❷ 使用 Results.Ok 方法将数据序列化为 JSON 并返回。

接下来,您将修改客户端脚本以调用此新终端节点。默认情况下,浏览器不允许脚本向网页中的其他域发出 HTTP 请求。此安全功能旨在减少跨站点请求伪造,因此不需要请求验证,因此不会为 API 启用。清单 11.19 显示了相应地修改的客户端代码。它只包括 Fetch request 部分,其余部分与前面的示例相同。修改请求的 URL 以反映传递给 MapPost 方法的模板,并删除请求验证标头。

清单 11.19 对最小 API 的 Fetch 请求

fetch('/api/property/booking', {               ❶
    method: 'post',
    headers: {
        "Content-type": "application/json",    ❷
    },
    body: JSON.stringify(data)

❶ 将 URL 更改为指向 API 注册中指定的 URL。
❷ 无需包含请求验证令牌

路由处理程序本身的代码与您之前使用的 OnPostBooking 页面处理程序没有太大区别。但是,路由处理程序本身的性能更好,尤其是因为不需要实例化 PageModel。另请注意,您不必告诉请求处理程序在何处查找 BookingInputModel 参数的数据。我们将在下一节中更详细地介绍这些参数。

您传递给请求处理程序的路由模板类似于第 4 章中讨论的路由模板。您可以以相同的方式指定路由参数和使用约束。生成的模板将与您的页面一起添加到终端节点集合中。

11.3.2 最小 API 中的参数

最少的 API 参数来自多个来源。他们是 (按顺序)

• Route values
• Query string
• Headers
• Body
• Services
• Custom

您可以使用其中一个 From* 属性让框架明确知道要以哪个源为目标:

• FromRoute
• FromQuery
• FromHeader
• FromBody
• FromServices

当从正文绑定参数时,最小的 API 期望参数格式为 JSON,并尝试对其进行反序列化。.NET 6 不支持从表单进行绑定。如果您使用的是 .NET 的更高版本,则应查看文档以查看此情况是否已更改,尽管正如您所看到的,在将表单传递给 API 之前,在客户端上将表单序列化为 JSON 非常简单。

在以下示例中,您将把预订计算迁移到您将向依赖关系注入系统注册的服务,然后作为参数添加到请求处理程序中。这是分离应用程序 logic 的推荐方法,因为它可以更轻松地进行调试和测试。首先,向 Models 文件夹添加一个新类来表示预订。该类名为 Booking,其属性表示计算预订所需的数据。

清单 11.20 Booking 类

namespace CityBreaks.Models
{
    public class Booking
    {
        public DateTime StartDate { get; set; }
        public DateTime EndDate { get; set; }
        public int NumberOfGuests { get; set; }
        public decimal DayRate { get; set; }
    }
}

将以下界面添加到 Services 文件夹。它指定一个返回 decimal 并采用 Booking 类实例的方法。

Listing 11.21 IBooking 接口

using CityBreaks.Models;

namespace CityBreaks.Services
{
    public interface IBookingService
    {
        decimal Calculate(Booking booking);
    }
}

将 IBooking 接口的以下实现添加到 Services 文件夹中。Calculate 方法的代码与当前请求处理程序中的代码基本相同。

Listing 11.22 BookingService类

using CityBreaks.Models;

namespace CityBreaks.Services
{
    public class BookingService : IBookingService
    {
        public decimal Calculate(Booking booking)
        {
            var numberOfDays = (int)(booking.EndDate -        
            ➥ booking.StartDate).TotalDays;
            var totalCost = numberOfDays * booking.DayRate * 
            ➥ booking.NumberOfGuests;
            return totalCost;
        }
    }
}

现在,您在 Program.cs 中注册服务:

builder.Services.AddSingleton<IBookingService, BookingService>();

最后,您修改请求处理程序以将服务作为参数,并修改路由处理程序以利用它来执行计算。

Listing 11.23 将BookingService作为参数的请求处理程序

 app.MapPost("/api/property/booking", 
    (BookingInputModel model, IBookingService bookingService) =>   ❶
{
    var booking = new Booking {                                    ❷
        StartDate = model.StartDate.Value,                         ❷
        EndDate = model.EndDate.Value,                             ❷
        NumberOfGuests = model.NumberOfGuests,                     ❷
        DayRate = model.Property.DayRate                           ❷
    };                                                             ❷
    var totalCost = bookingService.Calculate(booking);             ❸
    return Results.Ok(new { TotalCost = totalCost });
});

❶ 处理程序将 IBookingService 作为参数。
❷ 从输入模型实例化 Booking 类的实例。
❸ 它作为参数传递给服务,返回的值被发送回客户端。

在此示例中,将推断绑定源。框架将从所有来源中搜索它们,直到找到匹配的来源。要使它们明确,从而缩小搜索范围,请相应地添加 From* 属性:

app.MapPost("/api/property/booking", 
    ([FromBody]BookingInputModel model, 
     [FromServices]IBookingService bookingService)

11.3.3 最小 API 返回类型

在到目前为止的示例中,您已使用 Results.Ok 方法返回序列化为 JSON 且状态代码为 200 的数据。这是您可以与最小 API 请求处理程序一起使用的几种返回类型之一。在 .NET 6 中引入的静态 Results 类包括用于不同返回类型的其他方法,这些方法都实现 IResult。表 11.1 中详细介绍了 Razor Pages 应用程序中最常使用的 Razor Pages。

表 11.1 Results 类中的常用方法

Method

Response type

Description

Results.Ok

application/json

Produces JSON

Results.Json

application/json

Produces JSON and enables you to set various options including serializer options

Results.Text

text/plain (default)

Produces a text response, with a configurable content type

Results.File

application/octet-stream (default)

Writes a file to the response, with a configurable content type

Results.Bytes

application/octet-stream (default)

Writes a byte array to the response, with a configurable content type

除了这些选项之外,您还可以返回纯字符串或更复杂的类型。例如,您的处理程序可以从服务获取总成本,并简单地返回:

return bookingService.Calculate(booking);

响应将仅包含值,内容类型设置为 text/plain。请注意,您需要在客户端上调整 Fetch 代码以使用 text() 方法而不是 json() 方法,并且您将向警报中的插值字符串提供整个响应:

}).then((response) => {
    return response.text();    
}).then((result) => {
    alert(`Your stay will cost ${result}`);    
});

如果返回复杂类型,则值将序列化为 JSON,并且内容类型将设置为 application/json:

var totalCost = bookingService.Calculate(booking);
return new { TotalCost = totalCost };

这些选项在某些情况下可能很方便,但与使用 IResult 选项相比,它们只能节省几次击键,这些选项是强类型并且适合进行测试。

路由处理程序授权

路由处理程序可以与您的页面一起参与授权。您可以在任何参数之前传入 Authorize 属性。例如

app.MapPost("/api/property/booking", [Authorize](BookingInputModel model) => 

或者,您可以将 RequireAuthorization 方法链接到处理程序:

app.MapPost("/api/property/booking", (BookingInputModel model) => {
    ...
}).RequireAuthorization();

RequireAuthorization 方法将 params string[] 作为参数,使您能够传入应应用于路由处理程序的任何授权策略的名称。

11.4 Razor Pages 中的 CSS 隔离

在第 2 章中,我简要提到了 Shared 文件夹中的 Layout.cshtml.css 文件,其中包含应用于 _Layout.cshtml 文件的 CSS 样式声明。我提到过,样式表的稍微奇怪的名称是 CSS 隔离使用的约定的一部分,该约定已引入 .Net 6 中的 Razor Pages。本节讨论什么是 CSS 隔离及其工作原理。

首先,看看 CSS 隔离旨在缓解的问题类型。在构建 Web 应用程序时,通常会将 CSS 样式声明放在主布局文件中引用的全局样式表中。这样,样式表中的声明对所有使用该布局的页面都可用,无论它们在特定页面中是否需要。随着您继续开发应用程序,将添加与特定页面甚至部分相关的新样式。例如,您可能希望更改单个页面的默认字体,以便向样式表中添加新的 CSS 选择器,该选择器可用于仅定位该页面上的元素,并相应地更新目标元素的类属性。你的全局样式表不断增长。您发现自己越来越依赖编辑器的搜索功能来查找您可能想要更新的特定样式的声明。随着时间的推移,您会忘记哪些样式声明实际上正在使用,哪些可以安全地删除。

例如,假设您希望将一些 CSS 应用于 City 页面上的 h5 标题。它们当前由事件处理程序定位,该事件处理程序侦听 click 事件并使用属性详细信息填充模式对话框。通常,当用户将鼠标悬停在网页上的可点击元素上时,他们希望光标从箭头变为指向手指。因此,将 cursor:pointer 规则应用于页面上的所有 h5 元素是有意义的。您不希望将此更改应用于应用程序中的所有其他 h5 元素,因此您需要缩小 CSS 规则的适用范围。你可以向 h5 元素添加一个 CSS 类,然后使用它有选择地定位标题:

h5.clickable{
    cursor: pointer;
}

将此添加到全局样式表中,您就可以开始工作了。显然,您必须记住 clickable 类的用途,并且必须记住将其应用于页面上的所有 h5 元素。可能是你想修改不同页面上的样式。您可以通过添加更多 CSS 类来充当过滤器来实现此目的。或者,您可以使用 Razor 中的部分来包含特定于页面的样式表:

@section styles{
<link href="/css/city.css" rel="stylesheet">
}

这种方法的缺点是会增加站点的 HTTP 请求数,尤其是在为多个页面添加特定于页面的样式表时。你不能真的使用 bundle 来组合所有这些特定于页面的样式表,因为这会破坏练习的目标。

Razor Pages 中的 CSS 隔离有助于创建特定于页面的样式表,这些样式表不依赖于部分,而是捆绑到一个文件中。该功能在 Razor Pages 中默认启用,因此无需添加其他包或配置任何服务或中间件。你所要做的就是在它要影响的页面旁边放置一个样式表。您只需遵循特定的命名约定:末尾带有 .css 的 Razor 页面文件名。

以上面的示例为例,将名为 City.cshtml.css 的文件添加到 Pages 文件夹,并在其中放置样式声明以影响可点击的 h5 元素:

h5{
    cursor: pointer;
}

对样式表的引用位于布局页面上。您在引用中使用的文件名采用以下格式:[name_of_application].styles.css。您的应用程序的名称是 CityBreaks,您应该已经在布局页面上找到了该引用。它作为项目模板的一部分被放置在那里:

<link href="~/CityBreaks.styles.css" rel="stylesheet" />

当您运行应用程序并导航到 City 页面时,当您将鼠标悬停在属性名称上时,您可以看到光标变为指针(图 11.6)。如果导航到 /claims-manager,则可以看到用于显示用户名的 h5 元素不受同一 CSS 规则的影响,尽管您刚刚添加的样式表在布局文件中被全局引用。

图 11.6 独立的 CSS 样式声明仅适用于此页面上的 3 级标题。

那么它是如何工作的呢?如果查看 City 页面的呈现源代码,可以看到一个附加属性 (b-jbmzjjkv6t) 已注入到 City.cshtml 模板中的每个 HTML 元素中(图 11.7)。

图 11.7 将附加属性 (b-jbmzjjkv6t) 注入到“城市”页面中的每个 HTML 元素中。

此属性(范围标识符)唯一标识 City.cshtml 中的元素。请注意,带有容器 CSS 类的 main 和 div 元素注入了不同的范围标识符。它们属于 _Layout.cshtml 文件。每个具有附带独立样式表的 Razor 文件都会获得其标识符。

在构建时,所有隔离的样式表都合并为一个,并且它们的样式声明被更改,因此它们仅适用于具有与它们所定位的页面关联的标识符的元素。下面是一个代码段,它显示了为我的示例生成的样式表的前几行,其中包括 City.cshtml.css 文件内容以及 _Layout.cshtml.css 内容。

Listing 11.24 在构建时将所有隔离的样式表合并为一个

/* _content/CityBreaks/Pages/City.cshtml.rz.scp.css */               ❶
h5[b-jbmzjjkv6t]{                                                    ❷
    cursor:pointer;
}
/* _content/CityBreaks/Pages/Shared/_Layout.cshtml.rz.scp.css */     ❶

a.navbar-brand[b-wjjjhz4rtp] {                                       ❷
  white-space: normal;
  text-align: center;
  word-break: break-all;
}

❶ 注入一条评论,显示以下样式适用于哪个页面。
❷ 样式与注入了 specific 属性的元素隔离。

范围标识符由框架生成,格式为 b-{10 character string},默认情况下,每个文件都会获得其唯一的字符串,从而保证样式的隔离。但是,您可能希望在少量页面之间共享样式。您可以通过自定义生成的标识符来实现此目的,以便多个页面获得相同的值。这是在项目文件中完成的。以下示例导致 Layout 和 City 页面共享相同的标识符:shared-style。

清单 11.25 自定义所选页面的隔离标识符

<ItemGroup>
    <None Update="Pages/Shared/_Layout.cshtml.css" CssScope="shared-style" />
    <None Update="Pages/City.cshtml.css" CssScope="shared-style" />
</ItemGroup>

鉴于 CSS 隔离是一项构建时功能,因此标记帮助程序不支持它,因为它们在运行时生成输出。

本章总结了我们对 Razor Pages 中的应用程序开发的了解。最后几章更侧重于配置和发布您的应用程序,以及确保它免受恶意活动的影响。我们将在下一章开始介绍减少应用程序中错误的方法,如何在错误发生时妥善处理它们,以及如何使用日志记录来了解应用程序上线后发生的情况。

总结

客户端技术可以帮助您提高应用程序的可用性。
虽然它仍然是一个很棒的库,但随着越来越多的浏览器与标准保持一致,使用 jQuery 来作 DOM 和发出异步 (AJAX) 请求的情况正在减少。
您可以使用 PartialResult 将 HTML 块返回到 AJAX 调用。
在通过 AJAX 将表单值发布到页面处理程序方法时,必须确保包含请求验证令牌。
当请求内容类型为 x-www-form-urlenencoded 时,请求验证令牌可以包含在标头或请求正文中。
在请求正文中发布 JSON 时,必须将令牌作为标头包含在内。
将 JSON 发布到页面处理程序时,必须使用 FromBody 属性标记处理程序参数,以告知框架要使用哪个绑定源。
请求处理程序是 .NET 6 中的一项新功能,是最小 API 的一部分。
请求处理程序将路由映射到函数,并且可以采用参数。
请求处理程序参数绑定源可以是隐式的,也可以使用 From 属性显式地表示源,其中通配符 表示源 — Body、Services、Route 等。
请求处理程序返回序列化为 JSON 的 IResult、字符串或其他类型。
请求处理程序可以通过 Authorize 属性或 RequireAuthorization 方法参与授权。
Razor Pages 的 CSS 隔离是 .NET 6 中的新增功能。
CSS 隔离使您能够在单独的文件中维护范围限定为页面的样式,以便于维护,而不会增加整个应用程序中的 HTTP 请求数。
CSS 隔离在构建时将属性注入 HTML 元素,并使用它们将 CSS 声明的范围限定为页面。
只生成一个样式表,其中包括所有范围的样式,从而减少了 HTTP 请求。
范围样式仅影响 HTML 元素,而不影响标记帮助程序。

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

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

本章涵盖

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

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

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

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

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

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

10.1 Razor Pages 中的基本授权

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

builder.Services.AddAuthorization();

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

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

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

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

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

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

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

10.1.1 应用简单授权

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

清单 10.3 自定义 AccessDenied 路径

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

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

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

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

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

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

public class CreateModel : AdminPageModel

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

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

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

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

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

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

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

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

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

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

 10.1.2 允许匿名访问

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

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

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

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

[AllowAnonymous]
public class LoginModel : PageModel

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

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

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

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

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

10.2 使用角色

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

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

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

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

• CreateAsync
• UpdateAsync
• 删除异步

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

10.2.1 查看角色

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

清单 10.8 RolesManager IndexModel 类

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

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

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

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

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

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

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

10.2.2 添加角色

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

清单 10.10 RolesManager 的 CreateModel

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

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

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

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

清单 10.11 创建角色表单

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

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

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

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

10.2.3 为用户分配角色

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

[email protected]
[email protected]
[email protected]

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

清单 10.12 AssignModel 代码

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

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

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

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

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

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

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

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

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

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

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

重新运行应用程序,这一次,使用 [email protected][email protected] 登录。然后尝试导航到 /roles-manager /assign。您应该会发现您被重定向到 Access Denied 页面。

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

10.2.4 使用策略应用角色检查

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

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

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

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

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

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

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

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

10.3 基于声明的授权

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

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

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

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

表 10.1 常用的声明类型

Claim type

Description

ClaimTypes.Name

Represents the username of the user

ClaimTypes.Email

Used for the user’s email address

ClaimTypes.GivenName

The user’s first name

ClaimTypes.Surname

The user’s last name

ClaimTypes.NameIdentifier

The user’s unique identifier

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

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

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

10.3.1 向用户添加声明

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

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

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

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

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

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

清单 10.16 ClaimsManager 索引页面

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Method

Description

RequireClaim(string claimtype)

The user must have the specified claim.

RequireClaim(string claimtype, params string[] allowedValues)

or

RequireClaim(string claimtype, IEmumerable<string> allowedValues)

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

RequireUserName

The user must have the specified name.

RequireAuthenticatedUser

The user must be authenticated.

RequireAssertion

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

using Microsoft.AspNetCore.Authorization;

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

        public Task HandleAsync(                                       ❸
        ➥ AuthorizationHandlerContext context)                        ❸
        {

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

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

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

清单 10.20 注册自定义需求

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

创建单独的处理程序类

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

Listing 10.21 独立需求类

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

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

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

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

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

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

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

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

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

对需求使用多个处理程序

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

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

清单 10.23 处理授权需求

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

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

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

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

清单 10.24 IsInRoleHandler

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

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

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

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

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

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

10.3.5 角色还是声明?

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

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

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

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

10.4 授权资源

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

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

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

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

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

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

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

 10.4.1 创建需求和处理程序

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

清单 10.25 实现 OperationAuthorizationRequirementss

using Microsoft.AspNetCore.Authorization.Infrastructure;             ❶

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

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

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

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

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

列表 10.26 PropertyAuthorizationHandler 类

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

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

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

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

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

清单 10.27 将 IAuthorizationService 注入 EditModel

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

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

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

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

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

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

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

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

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

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

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

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

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

10.4.2 将授权应用于 UI

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

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

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

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

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

清单 10.30 使用 IAuthorizationService

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

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

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

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

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

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

总结

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

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

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

本章涵盖

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

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

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

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

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

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

9.1 身份验证基础知识

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

9.1.1 身份验证的工作原理

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

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

9.1.2 添加简单身份验证

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

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

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

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

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

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

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

app.UseRouting();

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

app.MapRazorPages();

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

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

清单 9.3 简单的登录表单

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

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

清单 9.4 LoginModel 类所需的 using 指令

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

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

列表 9.5 LoginModel 类

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

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

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

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

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

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

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

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

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

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

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

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

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

示例 9.7 配置默认登录路径

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

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

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

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

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

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

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

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

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

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

9.2 ASP.NET 核心身份

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

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

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

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

dotnet new webapp --auth Individual

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

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

9.2.1 创建用户

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

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

清单 9.9 CityBreaksUser 类

using Microsoft.AspNetCore.Identity;

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

❶ 您的用户类派生自 IdentityUser。

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

9.2.2 配置 DbContext

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

示例 9.10 调整 DbContext 以使用 Identity

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

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

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

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

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

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

配置 Identity 服务

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

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

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

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

 9.2.3 添加迁移

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

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

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

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

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

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

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

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

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

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

<partial name="_LoginPartial" />

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

图 9.5 默认身份登录 UI

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

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

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

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

图 9.6 使用 Identity 登录

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

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

9.3 自定义身份

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

9.3.1 自定义身份选项

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

表 9.1 选择 IdentityOptions 属性

Property

Type

Description

User

UserOptions

Provides access to the options for user validation

Password

PasswordOptions

Enables customization of your application’s password policy

SignIn

SignInOptions

Represents configurable account confirmation options

Lockout

LockoutOptions

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

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

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

示例 9.14 设置身份选项

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

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

配置用户选项

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

表 9.2 UserOptions 属性

Property

Description

AllowedUserNameCharacters

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

RequireUniqueEmail

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

配置密码选项

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

表 9.3 PasswordOptions 属性

Name

Default

Description

RequireDigit

true

Specifies whether the password must include a digit.

RequiredLength

6

Specifies the minimum number of characters in a password.

RequireLowercase

true

Determines whether passwords must contain a lowercase ASCII character.

RequireNonAlphanumeric

true

Indicates whether passwords must contain a non-alphanumeric character.

RequiredUniqueChars

1

Specifies the number of unique characters required in the password.

RequireUppercase

true

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

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

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

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

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

清单 9.15 配置口令选项

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

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

配置 SignInOptions

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

表 9.4 SignInOptions 属性

Name

Default

Description

RequireConfirmedAccount

false

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

RequireConfirmedEmail

false

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

RequireConfirmedPhoneNumber

false

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

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

配置 LockoutOptions

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

表 9.5 LockoutOptions 属性

Name

Default

Description

AllowedForNewUsers

true

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

DefaultLockoutTimeSpan

5

Sets the duration for the lockout (in minutes)

MaxFailedAccessAttempts

5

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

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

9.3.2 自定义用户

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

列表 9.16 自定义 IdentityUser

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

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

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

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

图 9.8 迁移后修改后的 AspNetUser 表

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

9.3.3 基架和自定义 UI

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

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

图 9.9 New Scaffolded Item 对话框

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

图 9.10 Identity UI 基架选择对话框

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

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

dotnet aspnet-codegenerator identity --listFiles

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

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

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

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

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

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

图 9.12 基架文件夹和文件

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

UserManager 和 SignInManager

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

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

表 9.6 常用的 SignInManager 方法

Method

Description

SignInAsync

Signs the user in and assigns an Identity cookie

SignOutAsync

Signs the user out and removes the Identity cookie

PasswordSignInAsync

Attempts to sign the user in with the specified password

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

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

表 9.7 选择 UserManager 方法

Method

Description

CreateAsync

Adds a user to the database

UpdateAsync

Updates the user details in the database

DeleteAsync

Removes the user from the database

GetUserAsync

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

FindByIdAsync

Retrieves the user with the specified Id

FindByNameAsync

Retrieves the user with the specified Name claim

AddClaimAsync

Adds a claim to the user

AddToRoleAsync

Adds the user to the specified Role

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

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

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

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

图 9.13 修改后的 Login 页面

修改 Registration 页面

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

图 9.14 修订后的登记表

9.3.4 启用电子邮件确认

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

清单 9.21 默认的 EmailSender 服务

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

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

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

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

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

清单 9.22 EmailService 类

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

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

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

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

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

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

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

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

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

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

清单 9.23 要求进行账户确认

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

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

图 9.15 Register Confirmation 页面

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

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

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

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

图 9.17 确认邮件

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

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

9.3.5 禁用 UI 功能

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

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

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

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

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

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

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

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

总结

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

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

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

本章涵盖

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

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

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

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

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

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

8.1 什么是 Entity Framework Core?

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

8.1.1 为什么选择 EF Core?

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

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

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

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

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

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

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

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

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

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

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

8.1.3 管理关系

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

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

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

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

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

❶ Country 是一个导航属性。

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

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

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

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

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

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

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

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

示例 8.3 Property 类

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

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

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

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

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

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

8.1.4 安装 Entity Framework Core

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

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

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

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

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

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

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

install-package Microsoft.EntityFrameworkCore.Sqlite

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

8.1.5 创建上下文

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

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

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

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

清单 8.5 CityBreaksContext

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

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

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

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

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

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

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

清单 8.7 配置 CityBreaksContext

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

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

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

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

8.1.6 添加 DbSet

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

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

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

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

8.1.7 配置模型

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

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

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

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

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

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

列表 8.9 CountryConfiguration 类

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

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

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

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

8.2 迁移

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

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

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

8.2.1 种子数据

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

清单 8.10 国家种子数据

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

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

示例 8.11 向 City 类添加 Photo 属性

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

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

列表 8.12 City 配置类

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

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

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

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

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

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

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

8.2.2 添加迁移工具

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

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

dotnet tool install --global dotnet-ef

8.2.3 创建和应用迁移

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

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

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

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

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

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

新文件夹包含三个文件:

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

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

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

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

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

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

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

8.3 查询数据

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

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

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

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

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

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

8.3.1 检索多条记录

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

Listing 8.14 CityService 类

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

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

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

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

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

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

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

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

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

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

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

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

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

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

列表 8.16 更新了主页 Razor 代码

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

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

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

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

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

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

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

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

8.3.2 选择单个记录

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

清单 8.17 向 ICityService 添加新方法

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

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

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

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

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

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

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

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

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

Listing 8.19 修订后的 CityModel 代码

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

namespace CityBreaks.Pages;

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

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

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

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

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

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

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

Listing 8.20 渲染指定 City 的细节

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

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

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

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

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

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

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

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

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

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

8.4 脚手架 CRUD 页面

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

表 8.1 基架工具可用的模板

Template

Description

Create

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

Edit

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

Delete

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

Details

Produces a page that displays details of the selected entity

List

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

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

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

8.4.1 Visual Studio 基架说明

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

图 8.10 Visual Studio 中的基架对话框

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

8.4.2 从命令行搭建基架

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

dotnet tool install --global dotnet-aspnet-codegenerator

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

表 8.2 Razor Pages 生成器选项

Option

Description

--model|-m

Model class to use

--dataContext|-dc

DbContext class to use

--referenceScriptLibraries|-scripts

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

--layout|-l

Custom layout page to use

--useDefaultLayout|-udl

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

--force|-f

Overwrites existing files

--relativeFolderPath|-outDir

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

--namespaceName|-namespace

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

--partialView|-partial

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

--noPageModel|-npm

Switch to prevent generating a PageModel class for an empty template

--useSqlite|-sqlite

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

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

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

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

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

图 8.11 基架式 CRUD 页面

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

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

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

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

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

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

清单 8.22 脚手架 EditModel 构造函数

private readonly CityBreaks.Data.CityBreaksContext _context;

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

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

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

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

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

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

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

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

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

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

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

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

异步代码的原因

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

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

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

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

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

8.5 创建、修改和删除数据

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

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

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

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

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

using CityBreaks.Models;

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

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

清单 8.25 在 PropertyService 中实现 CRUD 方法

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

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

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

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

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

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

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

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

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

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

_context.Properties.Add(property)

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

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

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

await _context.SaveChangesAsync();

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

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

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

8.5.1 修改数据

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

示例 8.26 注入 IPropertyService 代替 DbContext

private readonly IPropertyService _propertyService;
private readonly ICityService _cityService;

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

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

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

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

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

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

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

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

清单 8.29 修改后的 OnGetAsync 方法

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

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

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

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

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

示例 8.30 更新的 OnPostAsync 方法

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

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

    return RedirectToPage("./Index");
}

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

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

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

@page "{id:int}"

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

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

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

Listing 8.31 脚手架选择列表 HTML

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

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

列表 8.32 修改后的选择列表

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

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

图 8.14 原始基架索引页

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

图 8.15 修改后的 Edit 页面

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

8.5.2 删除数据

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

清单 8.33 DeleteModel 中的基架 OnPostAsync 方法

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

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

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

    return RedirectToPage("./Index");
}

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

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

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

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

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

    return RedirectToPage("./Index");
}

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

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

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

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

public DateTime? Deleted { get; set; }

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

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

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

列表 8.35 添加迁移

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

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

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

清单 8.36 新迁移的 Up 方法

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

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

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

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

Task DeleteAsync(int id);

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

列表 8.37 Delete 方法实现

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

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

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

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

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

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

    public Property Property { get; set; }                   ❷

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

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

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

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

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

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

@page "{id:int}"

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

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

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

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

清单 8.40 修改后的 PropertyManager IndexModel

public class IndexModel : PageModel
{
    private readonly IPropertyService _propertyService;

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

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

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

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

图 8.16 “删除”页面

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

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

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

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

总结

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