精通-ASP-NET-Core-最简-API-全-

精通 ASP.NET Core 最简 API(全)

原文:zh.annas-archive.org/md5/031ed02418edc191294626a4252c1b2f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

代码的简化是每位开发者的梦想。最小化 API 是.NET 6 中的一个新特性,旨在简化代码。它们用于在 ASP.NET Core 中构建具有最小依赖关系的 API。最小化 API 通过使用更紧凑的代码语法来简化 API 开发。

使用最小化 API 的开发者将能够在某些情况下利用这种语法,以更少的代码和更少的文件维护来更快地工作。在这里,您将了解.NET 6 的主要新特性,并理解最小化 API 的基本主题,这些在.NET 5 和先前版本中是不可用的。您将了解如何启用 Swagger 进行 API 文档,以及如何处理 CORS 和应用程序错误。您将学习如何使用微软的新.NET 框架——依赖注入来更好地组织代码。最后,您将看到最小化 API 在.NET 6 中引入的性能和基准测试改进。

在本书结束时,您将能够利用最小化 API,并理解它们与经典 Web API 开发的相关性。

本书面向对象

本书面向希望构建.NET 和.NET Core API 并希望研究.NET 6 新特性的.NET 开发者。假设您对 C#、.NET、Visual Studio 和 REST API 有基本了解。

本书涵盖的内容

第一章最小化 API 简介,向您介绍在.NET 6 中引入最小化 API 的动机。我们将解释.NET 6 的主要新特性以及.NET 团队正在进行的这项最新版本的工作。您将了解我们决定编写本书的原因。

第二章探索最小化 API 及其优势,向您介绍最小化 API 与.NET 5 及所有先前版本的基本区别方式。我们将详细探讨使用 System.Text.JSON 进行路由和序列化。最后,我们将讨论一些与编写第一个 REST API 相关的概念。

第三章使用最小化 API,向您介绍最小化 API 与.NET 5 及所有先前版本的高级区别方式。我们将详细探讨如何启用 Swagger 进行 API 文档。我们将看到如何启用 CORS 以及如何处理应用程序错误。

第四章在最小化 API 项目中使用依赖注入,向您介绍依赖注入,并介绍如何与最小化 API 一起使用它。

第五章使用日志记录来识别错误,向您介绍.NET 提供的日志工具。记录器是开发者用来调试应用程序或理解其生产中失败原因的工具之一。日志库已经内置到 ASP.NET 中,并启用了设计中的几个功能。

第六章探索验证和映射,将教会您如何验证 API 接收到的数据,以及如何返回任何错误或消息。一旦数据得到验证,它就可以映射到一个模型,然后用于处理请求。

第七章与数据访问层的集成,帮助您了解在最小 API 中访问和使用数据的最佳实践。

第八章添加身份验证和授权,探讨了如何通过利用我们自己的数据库或像 Azure Active Directory 这样的云服务来编写身份验证和授权系统。

第九章利用全球化和本地化,展示了如何在最小 API 项目中利用翻译系统,并以客户端相同的语言提供错误。

第十章评估和基准测试最小 API 的性能,展示了.NET 6 的改进以及将随着最小 API 引入的特性。

为了充分利用本书

您需要在计算机上安装 Visual Studio 2022 和 ASP.NET 以及 Web 开发工作负载,或者安装 Visual Studio Code 和 K6。

所有代码示例都已使用 Windows 操作系统上的 Visual Studio 2022 和 Visual Studio Code 进行测试。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

要完全理解本书,需要具备 Microsoft 网络技术的基本开发技能。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6。如果代码有更新,它将在 GitHub 仓库中更新。

我们在github.com/PacktPublishing/的丰富图书和视频目录中也有其他代码包可供选择。查看它们吧!

下载彩色图像

我们还提供了一份 PDF 文件,其中包含本书中使用的截图和图表的彩色图像。

您可以在此处下载:packt.link/GmUNL

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在最小 API 中,我们使用WebApplication对象的Map*方法定义路由模式。”

代码块设置如下:

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

当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

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

任何命令行输入或输出都应如下编写:

dotnet new webapi -minimal -o Chapter01

粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的词汇以粗体显示。以下是一个示例:“打开 Visual Studio 2022,从主屏幕点击创建新项目。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上发现我们作品的任何非法副本,我们将非常感激您提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或为本书做出贡献感兴趣,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《精通 ASP.NET Core 中的最小 API》,我们非常乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

前言

前言

第一部分:简介

在本书的第一部分,我们希望向您介绍本书的背景。我们将解释最小 API 的基础知识以及它们是如何工作的。我们希望一块一块地添加所需的知识,以便充分利用最小 API 所能赋予我们的所有功能。

我们在本部分将涵盖以下章节:

  • 第一章最小 API 简介

  • 第二章探索最小 API 及其优势

  • 第三章使用最小 API

第一章:最小 API 简介

在本书的这一章中,我们将介绍一些与.NET 6.0 中的最小 API 相关的基本主题,展示如何设置.NET 6 的开发环境,以及更具体地,如何使用 ASP.NET Core 开发最小 API。

我们将首先简要介绍最小 API 的历史。然后,我们将使用 Visual Studio 2022 和 Visual Code Studio 创建一个新的最小 API 项目。最后,我们将查看我们的项目结构。

到本章结束时,你将能够创建一个新的最小 API 项目,并开始使用这个新的 REST API 模板进行工作。

在本章中,我们将涵盖以下主题:

  • 微软 Web API 的简要历史

  • 创建一个新的最小 API 项目

  • 查看项目结构

技术要求

要使用 ASP.NET Core 6 最小 API,首先需要在你的开发环境中安装.NET 6。

如果你还没有安装它,我们现在就安装:

  1. 导航到以下链接:dotnet.microsoft.com

  2. 点击下载按钮。

  3. 默认情况下,浏览器会为你选择正确的操作系统,但如果不是,请在页面顶部选择你的操作系统。

  4. 下载.NET 6.0 SDK 的 LTS 版本。

  5. 启动安装程序。

  6. 重新启动机器(这不是强制性的)。

你可以使用以下命令在终端中查看你的开发机器上安装的 SDK:

dotnet –list-sdks

在开始编码之前,你需要一个代码编辑器或一个集成开发环境IDE)。你可以从以下列表中选择你喜欢的:

  • Visual Studio Code for Windows、Mac 或 Linux

  • Visual Studio 2022

  • Visual Studio 2022 for Mac

在过去几年中,Visual Studio Code 不仅在开发者社区中,而且在微软社区中都非常受欢迎。即使你使用 Visual Studio 2022 进行日常工作,我们也建议下载并安装 Visual Studio Code 并尝试使用它。

让我们下载并安装 Visual Studio Code 和一些扩展:

  1. 导航到code.visualstudio.com

  2. 下载稳定版内部预览版

  3. 启动安装程序。

  4. 启动 Visual Studio Code。

  5. 点击扩展图标。

你将在列表顶部看到 C#扩展。

  1. 点击安装按钮并等待。

你可以安装其他推荐的用于 C#和 ASP.NET Core 开发的扩展。如果你想安装它们,请查看以下表格中的我们的建议:

此外,如果你想使用最广泛使用的.NET 开发者的 IDE,你可以下载并安装 Visual Studio 2022。

如果您没有许可证,请检查您是否可以使用社区版。获取许可证有一些限制,但如果您是学生、拥有开源项目或想作为个人使用,则可以使用它。以下是下载和安装 Visual Studio 2022 的步骤:

  1. 导航到visualstudio.microsoft.com/downloads/

  2. 选择 Visual Studio 2022 版本 17.0 或更高版本并下载。

  3. 启动安装程序。

  4. 工作负载选项卡上,选择以下内容:

    • ASP.NET 和 Web 开发

    • Azure 开发

  5. 单独组件选项卡上,选择以下内容:

    • Git for Windows

本章中的所有代码示例都可以在本书的 GitHub 存储库中找到,网址为github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter01

现在,您有一个环境,可以跟随并尝试本书中使用的代码。

微软 Web API 简史

几年前,在 2007 年,随着 ASP.NET MVC 的引入,.NET Web 应用程序经历了一次演变。从那时起,.NET 为在其他语言中常见的模型-视图-控制器模式提供了原生支持。

五年后,在 2012 年,RESTful API 成为了互联网上的新趋势,.NET 通过一种名为 ASP.NET Web API 的新方法来响应这一趋势,这种方法比Windows Communication FoundationWCF)更容易开发面向 Web 的服务。后来,在 ASP.NET Core 中,这些框架在 ASP.NET Core MVC 的名称下统一:一个用于开发 Web 应用程序和 API 的单个框架。

在 ASP.NET Core MVC 应用程序中,控制器负责接受输入、协调操作,并在最后返回响应。开发者可以通过过滤器、绑定、验证等功能扩展整个管道。这是一个功能齐全的框架,用于构建现代 Web 应用程序。

但在现实世界中,也存在一些场景和用例,您不需要 MVC 框架的所有功能,或者您必须考虑性能限制。ASP.NET Core 实现了很多中间件,您可以随意从您的应用程序中删除或添加,但在这个场景中,您需要自己实现许多常见功能。

最后,ASP.NET Core 6.0 通过最小 API 填补了这些空白。

现在我们已经概述了最小 API 的简史,接下来我们将开始创建一个新的最小 API 项目。

创建一个新的最小 API 项目

让我们从我们的第一个项目开始,尝试分析在编写 RESTful API 时的新模板。

在本节中,我们将创建我们的第一个最小 API 项目。我们将首先使用 Visual Studio 2022,然后我们将展示您也可以使用 Visual Studio Code 和 .NET CLI 创建项目。

使用 Visual Studio 2022 创建项目

按照以下步骤在 Visual Studio 2022 中创建新项目:

  1. 打开 Visual Studio 2022 并在主屏幕上,点击 创建新项目

图 1.1 – Visual Studio 2022 启动屏幕

图 1.1 – Visual Studio 2022 启动屏幕

  1. 在下一屏幕上,在窗口顶部的文本框中输入 API 并选择名为 ASP.NET Core Web API 的模板:

图 1.2 – 创建新项目屏幕

图 1.2 – 创建新项目屏幕

  1. 接下来,在 配置新项目 屏幕上,为新项目输入名称并选择新解决方案的根文件夹:图 1.3 – 配置新项目屏幕

图 1.3 – 配置新项目屏幕

在此示例中,我们将使用名称 Chapter01,但您可以选择任何您喜欢的名称。

  1. 在以下 其他信息 屏幕上,请确保从 框架 下拉菜单中选择 .NET 6.0(长期支持)。最重要的是,取消选中 使用控制器(取消选中以使用最小 API) 选项。

图 1.4 – 其他信息屏幕

图 1.4 – 其他信息屏幕

  1. 点击 创建,几秒钟后,您将看到您的新最小 API 项目代码。

现在我们将展示如何使用 Visual Studio Code 和 .NET CLI 创建相同的项目。

使用 Visual Studio Code 创建项目

使用 Visual Studio Code 创建项目比使用 Visual Studio 2022 更容易、更快,因为您不需要使用 UI 或向导,而是只需使用终端和 .NET CLI。

您不需要安装任何新东西,因为 .NET CLI 已包含在 .NET 6 安装中(如之前的 .NET SDK 版本)。按照以下步骤使用 Visual Studio Code 创建项目:

  1. 打开您的控制台、shell 或 Bash 终端,并切换到您的当前工作目录。

  2. 使用以下命令创建新的 Web API 应用程序:

    dotnet new webapi -minimal -o Chapter01
    

如您所见,我们在前面的命令中插入了 -minimal 参数,以使用最小 API 项目模板而不是带有控制器的 ASP.NET Core 模板。

  1. 现在请使用以下命令在 Visual Studio Code 中打开新项目:

    cd Chapter01
    code.
    

现在我们已经知道了如何创建新的最小 API 项目,我们将快速查看这个新模板的结构。

查看项目结构

无论您使用的是 Visual Studio 还是 Visual Studio Code,您都应该在 Program.cs 文件中看到以下代码:

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

首先,使用最小 API 方法,所有代码都将位于 Program.cs 文件中。如果你是一位资深的 .NET 开发者,理解前面的代码很容易,你会发现它与使用控制器方法时使用的一些东西很相似。

最终,它只是另一种编写 API 的方式,但基于 ASP.NET Core。

然而,如果你是 ASP.NET 的新手,这种单文件方法很容易理解。它很容易理解如何在模板中扩展代码并添加更多功能到这个 API 中。

不要忘记,最小意味着它包含构建 HTTP API 所需的最小组件集,但这并不意味着你将要构建的应用程序将是简单的。它将需要一个良好的设计,就像任何其他 .NET 应用程序一样。

最后一点,最小 API 方法不是 MVC 方法的替代品。它只是另一种编写相同内容的方式。

让我们回到代码。

即使是最小 API 的模板也使用了 .NET 6 网络应用程序的新方法:顶级语句。

这意味着项目只有一个 Program.cs 文件,而不是使用两个文件来配置应用程序。

如果你不喜欢这种编码风格,你可以将你的应用程序转换为 ASP.NET Core 3.x/5. 的旧模板。这种方法在 .NET 中仍然有效。

重要提示

我们可以在 docs.microsoft.com/dotnet/core/tutorials/top-level-templates 找到更多关于 .NET 6 顶级语句 模板的信息。

默认情况下,新模板包括对 OpenAPI 规范的支持,特别是 Swagger。

假设我们的端点文档和游乐场已经设置好,无需任何额外配置即可直接使用。

你可以在以下两行代码中看到 Swagger 的默认配置:

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

很常见,你不想将 Swagger 和所有端点暴露给生产或预发布环境。默认模板仅通过以下代码在开发环境中启用 Swagger:

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

如果应用程序正在开发环境中运行,你必须包括 Swagger 文档,否则则不需要。

注意

我们将在 第三章 使用最小 API 中详细讨论 Swagger。

在模板的最后几行代码中,我们为 .NET 6 网络应用程序引入了另一个通用概念:环境。

通常情况下,当我们开发一个专业应用程序时,应用程序会经历许多阶段,包括开发、测试,最终发布给最终用户。

按照惯例,这些阶段被规范为开发、预发布和发布。作为开发者,我们可能希望根据当前环境改变应用程序的行为。

有几种方法可以访问这些信息,但在现代 .NET 6 应用程序中检索实际环境的最典型方法是使用环境变量。您可以直接从 Program.cs 文件中的 app 变量访问环境变量。

以下代码块展示了如何直接从应用程序的启动点检索所有关于环境的信息:

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

在许多情况下,您可以定义额外的环境,并且您可以使用以下代码检查您的自定义环境:

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

在最小 API 中定义路由和处理程序时,我们使用 MapGetMapPostMapPutMapDelete 方法。如果您习惯于使用 HTTP 动词,您会注意到动词 Patch 不存在,但您可以使用 MapMethods 定义任何一组动词。

例如,如果您想创建一个新的端点将一些数据发送到 API,您可以编写以下代码:

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

如您在前面简短的代码中看到的,使用新的最小 API 模板添加新的端点非常容易。

以前这更加困难,尤其是对于一个新开发者来说,要编码一个新的端点并使用绑定参数以及依赖注入,这更加困难。

重要注意事项

我们将在 第二章 探索最小 API 及其优势第四章 最小 API 项目中的依赖注入 中详细讨论路由和依赖注入。

摘要

在本章中,我们首先简要介绍了最小 API 的历史。接下来,我们看到了如何使用 Visual Studio 2022 以及 Visual Studio Code 和 .NET CLI 创建项目。然后,我们检查了新模板的结构,如何访问不同的环境,以及如何开始与 REST 端点交互。

在下一章中,我们将看到如何绑定参数、新的路由配置以及如何自定义响应。

第二章:探索最小 API 及其优势

在本书的这一章中,我们将介绍一些与.NET 6.0 中的最小 API 相关的基本主题,展示它们与我们之前在.NET 的旧版本中编写的基于控制器的 Web API 有何不同。我们还将尝试强调这种新的 API 编写方法的优势和劣势。

在本章中,我们将涵盖以下主题:

  • 路由

  • 参数绑定

  • 探索响应

  • 控制序列化

  • 构建最小 API 项目

技术要求

要遵循本章的描述,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。您可以选择以下任一选项:

  • 选项 1:在 Visual Studio 2022 的文件菜单中点击新建 | 项目命令,然后,在向导中选择ASP.NET Core Web API模板。在向导中选择一个名称和工作目录,并确保在下一步中取消选中使用控制器(取消选中以使用最小 API)选项。

  • 选项 2:打开您的控制台、shell 或 Bash 终端,切换到您的工作目录。使用以下命令创建一个新的 Web API 应用程序:

    dotnet new webapi -minimal -o Chapter02
    

现在,通过双击项目文件或在 Visual Studio Code 中在已打开的控制台中输入以下命令来打开项目:

cd Chapter02
code.

最后,您可以安全地删除与WeatherForecast示例相关的所有代码,因为我们不需要它本章。

本章中的所有代码示例都可以在本书的 GitHub 存储库github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter02中找到。

路由

根据官方 Microsoft 文档docs.microsoft.com/aspnet/core/fundamentals/routing中的定义,路由如下所述:

路由负责匹配传入的 HTTP 请求并将这些请求调度到应用程序的可执行端点。端点是应用程序的执行请求处理代码的单元。端点在应用程序中定义,并在应用程序启动时进行配置。端点匹配过程可以从请求的 URL 中提取值,并将这些值提供给请求处理。使用应用程序中的端点信息,路由还可以生成映射到端点的 URL。

在基于控制器的 Web API 中,路由通过Startup.cs中的UseEndpoints()方法或使用RouteHttpGetHttpPostHttpPutHttpPatchHttpDelete等数据注释直接在操作方法上定义。

第一章中所述,在最小 API 的介绍最小 API中,我们使用WebApplication对象的Map*方法定义路由模式。以下是一个示例:

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

在此代码中,我们定义了四个端点,每个端点都有不同的路由和方法。当然,我们可以使用相同的路由模式与不同的 HTTP 动词。

注意

一旦我们将端点添加到我们的应用程序中(例如,使用MapGet()),UseRouting()将自动添加到中间件管道的起始处,而UseEndpoints()将添加到管道的末尾。

如此所示,ASP.NET Core 6.0 为最常见的 HTTP 动词提供了Map*方法。如果我们需要使用其他动词,我们可以使用通用的MapMethods

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

在接下来的章节中,我们将详细展示路由如何有效工作以及我们如何控制其行为。

路由处理程序

当路由 URL 匹配(根据参数和约束,如以下章节所述)时执行的方法称为路由处理程序。路由处理程序可以是 lambda 表达式、局部函数、实例方法或静态方法,无论是同步还是异步:

  • 下面是一个 lambda 表达式的例子(内联或使用变量):

    app.MapGet("/hello-inline", () => "[INLINE LAMBDA] 
                 Hello World!");
    var handler = () => "[LAMBDA VARIABLE] Hello World!";
    app.MapGet("/hello", handler);
    
  • 下面是一个局部函数的例子:

    string Hello() => "[LOCAL FUNCTION] Hello World!";
    app.MapGet("/hello", Hello);
    
  • 以下是一个实例方法的例子:

    var handler = new HelloHandler();
    app.MapGet("/hello", handler.Hello);
    class HelloHandler
    {
        public string Hello()
          => "[INSTANCE METHOD] Hello 
               World!";
    }
    
  • 这里,我们可以看到一个静态方法的例子:

    app.MapGet("/hello", HelloHandler.Hello);
    class HelloHandler
    {
        public static string Hello()
          => "[STATIC METHOD] Hello World!";
    }
    

路由参数

与.NET 的先前版本一样,我们可以创建带有参数的路由模式,这些参数将由处理程序自动捕获:

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

一个路由可以包含任意数量的参数。当对这个路由发出请求时,参数将被捕获、解析并作为参数传递给相应的处理程序。这样,处理程序将始终接收到类型化的参数(在先前的示例中,我们确信用户名是string,产品 ID 是int)。

如果路由值无法转换为指定的类型,则会抛出BadHttpRequestException类型的异常,并且 API 将以400 Bad Request消息响应。

路由约束

路由约束用于限制路由参数的有效类型。典型的约束允许我们指定参数必须是数字、字符串或 GUID。要指定路由约束,我们只需在参数名称后添加一个冒号,然后指定约束名称:

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

最小 API 支持在 ASP.NET Core 的先前版本中已经可用的所有路由约束。您可以在以下链接中找到完整的路由约束列表:docs.microsoft.com/aspnet/core/fundamentals/routing#route-constraint-reference

如果根据约束,没有路由与指定的路径匹配,我们不会得到异常。相反,我们获得一个404 Not Found消息,因为实际上,如果约束不匹配,路由本身是无法到达的。因此,例如,在以下情况下我们会得到 404 响应:

表 2.1 – 根据路由约束的无效路径示例

表 2.1 – 根据路由约束的无效路径示例

处理器中未声明为路由约束的每个其他参数默认期望在查询字符串中。例如,参见以下:

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

在下一节 参数绑定 中,我们将更深入地探讨如何使用绑定来进一步自定义路由,例如指定搜索路由参数的位置、更改它们的名称以及如何有可选的路由参数。

参数绑定

参数绑定是将请求数据(即 URL 路径、查询字符串或正文)转换为强类型参数的过程,这些参数可以被路由处理器消费。ASP.NET Core 最小 API 支持以下绑定来源:

  • 路由值

  • 查询字符串

  • 头部

  • 正文(作为 JSON,默认支持的唯一格式)

  • 服务提供者(依赖注入)

我们将在 第四章 实现依赖注入 中详细讨论依赖注入。

正如我们在本章后面将要看到的,如果需要,我们可以自定义特定输入的绑定方式。不幸的是,在当前版本中,最小 API 中没有原生支持从 Form 进行绑定。这意味着,例如,IFormFile 也不受支持。

为了更好地理解参数绑定的工作原理,让我们看一下以下 API:

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

传递给处理器的参数以以下方式解决:

表 2.2 – 参数绑定来源

表 2.2 – 参数绑定来源

如我们所见,ASP.NET Core 能够根据路由模式和参数本身的类型自动理解绑定参数的位置。例如,期望在请求体中有一个复杂类型,如 Person 类。

如果需要,就像在 ASP.NET Core 的早期版本中一样,我们可以使用属性来显式指定参数绑定来源,并且可以选择性地使用不同的名称。参见以下端点:

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

API 可以通过 /search?q=text 来调用。然而,使用 q 作为参数名称不是一个好主意,因为它的含义不是不言自明的。因此,我们可以使用 FromQueryAttribute 修改处理器:

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

以这种方式,API 仍然期望一个名为 q 的查询字符串参数,但在处理器中,其值现在绑定到 searchText 参数。

注意

根据标准,GETDELETEHEADOPTIONS HTTP 选项不应有正文。如果无论如何你想使用它,你需要显式地将 [FromBody] 属性添加到处理器参数中;否则,你会得到一个 InvalidOperationException 错误。然而,请记住,这是一个不好的做法。

默认情况下,路由处理程序中的所有参数都是必需的。因此,如果根据路由,ASP.NET Core 找到一个有效的路由,但不是所有必需的参数都提供,我们将得到一个错误。例如,让我们看看以下方法:

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

如果我们调用端点时不包含 pageIndexitemsPerPage 查询字符串值,我们将获得 BadHttpRequestException 错误,并且响应将是 400 Bad Request

要使参数可选,我们只需将它们声明为可空或提供默认值。后者是最常见的情况。然而,如果我们采用这种解决方案,我们无法使用 lambda 表达式作为处理程序。我们需要另一种方法,例如,局部函数:

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

在这种情况下,我们处理的是查询字符串,但相同的规则适用于所有绑定源。

请记住,如果我们使用 null,我们需要再次将其声明为 BadHttpRequestException 错误。以下示例正确地将 orderBy 查询字符串参数定义为可选的:

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

特殊绑定

在基于控制器的 Web API 中,继承自 Microsoft.AspNetCore.Mvc.ControllerBase 的控制器可以访问一些属性,允许它获取请求和响应的上下文:HttpContextRequestResponseUser。在最小 API 中,我们没有基类,但我们仍然可以访问这些信息,因为它们被视为始终可用的特殊绑定:

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

小贴士

我们也可以使用 IHttpContextAccessor 接口访问所有这些对象,就像我们在之前的 ASP.NET Core 版本中所做的那样。

自定义绑定

在某些情况下,参数绑定的默认方式可能不足以满足我们的需求。在最小 API 中,我们没有 IModelBinderProviderIModelBinder 接口的支持,但我们可以通过两种替代方案来实现自定义模型绑定。

重要提示

基于控制器的项目中 IModelBinderProviderIModelBinder 接口允许我们定义请求数据和应用程序模型之间的映射。ASP.NET Core 提供的默认模型绑定器支持大多数常见的数据类型,但如果需要,我们可以通过创建自己的提供者来扩展系统。更多信息请参阅以下链接:docs.microsoft.com/aspnet/core/mvc/advanced/custom-model-binding

如果我们想将来自路由、查询字符串或头部的参数绑定到自定义类型,我们可以在该类型中添加一个静态的 TryParse 方法:

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

TryParse 方法中,我们可以尝试分割输入参数并检查它是否包含两个十进制值:在这种情况下,我们将数字解析为构建 Location 对象,并返回 true。否则,我们返回 false,因为 Location 对象无法初始化。

重要提示

当最小 API 发现一个类型包含一个静态的TryParse方法时,即使它是一个复杂类型,它也会假设它是根据路由模板传递进来的,或者是在查询字符串中。我们可以使用[FromHeader]属性来更改绑定源。在任何情况下,TryParse都不会被用于请求体。

如果我们需要完全控制绑定是如何执行的,我们可以在类型上实现一个静态的BindAsync方法。这不是一个非常常见的解决方案,但在某些情况下,它可能很有用:

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

正如我们所见,BindAsync方法接受整个HttpContext作为参数,因此我们可以读取所有我们需要创建实际Location对象的信息,该对象被传递给路由处理程序。在这个例子中,我们读取了两个查询字符串参数(latlon),但在POSTPUTPATCH方法的情况下),我们也可以读取整个请求体并手动解析其内容。这可能很有用,例如,如果我们需要处理格式不是 JSON 的请求(正如之前所说,这是默认支持的唯一格式)。

如果BindAsync方法返回null,而相应的路由处理程序参数不能假设这个值(如前例所示),我们将得到一个HttpBadRequestException错误,通常,这个错误会被包裹在一个400 Bad Request响应中。

重要提示

我们不应该使用一个类型来定义TryParseBindAsync方法;如果两者都存在,BindAsync总是有优先级(也就是说,TryParse永远不会被调用)。

现在我们已经了解了参数绑定以及如何使用它和自定义其行为,让我们看看如何在最小 API 中处理响应。

探索响应

就像基于控制器的项目一样,对于最小 API 的路由处理程序,我们也可以直接返回一个字符串或一个类(无论是同步还是异步):

  • 如果我们返回一个字符串(如前一部分的例子所示),框架会直接将字符串写入响应,将其内容类型设置为text/plain,状态码设置为200 OK

  • 如果我们使用一个类,对象将被序列化为 JSON 格式,并通过application/json内容类型和200 OK状态码发送到响应中

然而,在实际应用中,我们通常需要控制响应类型和状态码。在这种情况下,我们可以使用静态的Results类,它允许我们返回IResult接口的一个实例,在最小 API 中,它表现得就像控制器中的IActionResult一样。例如,我们可以用它来返回201 Created响应,而不是400 Bad Request404 Not Found消息。让我们看看一些例子:

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

Results类的每个方法都负责设置与该方法本身含义相对应的响应类型和状态码(例如,Results.NotFound()方法返回一个404 Not Found响应)。请注意,即使我们通常需要在200 OK响应的情况下返回一个对象(使用Results.Ok()),这并不是唯一允许这样做的方法。许多其他方法允许我们包含自定义响应;在这些所有情况下,响应类型将设置为application/json,对象将自动进行 JSON 序列化。

当前版本的最小 API 不支持内容协商。我们只有少数几个方法允许我们显式设置内容类型,当使用Results.Bytes()Results.Stream()Results.File()获取文件,或使用Results.Text()Results.Content()时。在其他所有情况下,当我们处理复杂对象时,响应将以 JSON 格式。这是一个精确的设计选择,因为大多数开发者很少需要支持其他媒体类型。通过仅支持 JSON 而不执行内容协商,最小 API 可以非常高效。

然而,这种方法并不适用于所有场景。在某些情况下,我们可能需要创建一个自定义响应类型,例如,如果我们想返回 HTML 或 XML 响应而不是标准的 JSON。我们可以手动使用Results.Content()方法(它允许我们指定特定内容类型的简单字符串),但如果我们有这种需求,最好实现一个自定义的IResult类型,这样解决方案就可以重用。

例如,假设我们想以 XML 而不是 JSON 序列化对象。然后我们可以定义一个实现IResult接口的XmlResult类:

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

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

IResult接口要求我们实现ExecuteAsync方法,该方法接收当前的HttpContext作为参数。我们使用XmlSerializer类序列化值,然后将其写入响应,指定正确的响应类型。

现在,我们可以在我们的路由处理程序中直接使用新的XmlResult类型。然而,最佳实践建议我们为IResultExtensions接口创建一个扩展方法,如下所示:

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

这样,我们就在Results.Extensions属性上获得了一个新的Xml方法:

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

这种方法的优点是,我们可以在需要处理 XML 的地方重用它,而无需手动处理序列化和响应类型(正如我们应该使用Result.Content()方法所做的那样)。

小贴士

如果我们要执行内容验证,我们需要手动检查HttpRequest对象的Accept头,我们可以将其传递给我们的处理程序,然后根据需要创建正确的响应。

在分析如何正确处理最小 API 的响应之后,我们将在下一节中看到如何控制我们的数据在序列化和反序列化过程中的方式。

控制序列化

如前几节所述,最小化 API 只提供对 JSON 格式的内置支持。特别是,框架使用 System.Text.Json 进行序列化和反序列化。在基于控制器的 API 中,我们可以更改此默认设置并使用 JSON.NET。在最小化 API 的工作中这是不可能的:我们根本无法替换序列化器。

内置序列化器使用以下选项:

  • 序列化期间不区分大小写的属性名称

  • 驼峰式属性命名策略

  • 支持引号数字(数字属性的 JSON 字符串)

注意

我们可以在以下链接中找到有关 System.Text.Json 命名空间及其提供的所有 API 的更多信息:docs.microsoft.com/dotnet/api/system.text.json

在基于控制器的 API 中,我们可以通过在 AddControllers() 后流畅地调用 AddJsonOptions() 来自定义这些设置。在最小化 API 中,我们不能使用这种方法,因为我们根本没有任何控制器,因此我们需要显式调用 Configure 方法来配置 JsonOptions。所以,让我们考虑以下处理程序:

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

使用默认的 JSON 选项,我们得到以下结果:

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

现在,让我们配置 JsonOptions

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

再次调用 /product 端点,我们现在将得到以下结果:

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

如预期的那样,Description 属性没有被序列化,因为它为 null,同样,TotalPrice 也没有包含在响应中,因为它只读。

JsonOptions 的另一个典型用例是我们想要添加将在每个序列化或反序列化过程中自动应用的转换器,例如,JsonStringEnumConverter 将枚举值转换为字符串或从字符串转换。

重要提示

注意,最小化 API 使用的 JsonOptions 类是在 Microsoft.AspNetCore.Http.Json 命名空间中可用的。不要与在 Microsoft.AspNetCore.Mvc 命名空间中定义的类混淆;对象的名称相同,但后者仅对控制器有效,因此在最小化 API 项目中设置时没有效果。

由于仅支持 JSON,如果我们没有明确添加对其他格式的支持,如前几节所述(例如,在自定义类型上使用 BindAsync 方法),最小化 API 将自动对正文绑定源执行一些验证并处理以下场景:

表 2.3 – 正文绑定问题的响应状态码

表 2.3 – 正文绑定问题的响应状态码

在这些情况下,由于正文验证失败,我们的路由处理程序将永远不会被调用,我们将直接得到前面表格中显示的响应状态码。

现在,我们已经涵盖了我们需要开始开发最小化 API 的所有支柱。然而,还有另一个重要的事情要讨论:正确设计真实项目的方法,以避免在架构中常见的错误。

构建最小化 API 项目架构

到目前为止,我们已经在 Program.cs 文件中直接编写了路由处理器。这是一个完全支持的场景:使用最小 API,我们可以在单个文件中编写所有代码。事实上,几乎所有示例都展示了这种解决方案。然而,虽然这是允许的,我们很容易想象这种方法如何导致项目无结构化和难以维护。如果我们有较少的端点,这没问题——否则,最好将我们的处理器组织在单独的文件中。

假设我们在 Program.cs 文件中直接有以下的代码,因为我们必须处理 CRUD 操作:

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

很容易想象,如果我们在这里有所有实现(即使我们使用 PeopleService 来提取业务逻辑),这个文件很容易变得庞大。因此,在实际场景中,内联 lambda 方法并不是最佳实践。我们应该使用我们在 路由 部分中介绍的其他方法来定义处理器。因此,创建一个外部类来保存所有路由处理器是一个好主意:

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

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

我们已经将所有端点定义分组在 PeopleHandler.MapEndpoints 静态方法中,该方法接受 IEndpointRouteBuilder 接口作为参数,该接口由 WebApplication 类实现。然后,我们不是使用 lambda 表达式,而是为每个处理器创建了单独的方法,这样代码就更加清晰。这样,为了在我们的最小 API 中注册所有这些处理器,我们只需要在 Program.cs 中添加以下代码:

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

向前发展

这种刚刚展示的方法使我们能够更好地组织最小 API 项目,但仍然需要我们为每个要定义的处理器在 Program.cs 中显式添加一行。使用接口和一些 反射,我们可以创建一个简单且可重用的解决方案,以简化我们对最小 API 的工作。

那么,让我们先定义以下接口:

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

如其名所示,我们需要让所有我们的处理器(就像之前的 PeopleHandler 一样)实现它:

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

注意

MapEndpoints 方法不再静态,因为它现在是实现 IEndpointRouteHandler 接口的一个实现。

现在我们需要一个新扩展方法,它使用反射扫描程序集,查找实现此接口的所有类,并自动调用它们的 MapEndpoints 方法:

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

小贴士

如果你想深入了解反射及其在 .NET 中的工作方式,你可以从浏览以下页面开始:docs.microsoft.com/dotnet/csharp/programming-guide/concepts/reflection

在所有这些组件就绪之后,最后一步是在 Program.cs 文件中的 Run() 方法之前调用扩展方法:

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

这样,当我们添加新的处理器时,我们只需要创建一个新的类来实现 IEndpointRouteHandler 接口。在 Program.cs 中添加新的端点不需要进行其他更改。

在外部文件中编写路由处理程序,并思考一种自动化端点注册的方法,以便在添加每个功能时Program.cs文件不会增长,这是构建最小化 API 项目的正确方法。

摘要

ASP.NET Core 最小化 API 代表了在.NET 世界中编写 HTTP API 的新方式。在本章中,我们涵盖了我们需要开始开发最小化 API 的所有支柱,如何有效地处理它们,以及决定遵循此架构时需要考虑的最佳实践。

在下一章中,我们将关注一些高级概念,例如使用 Swagger 记录 API、定义正确的错误处理系统,以及将最小化 API 与单页应用程序集成。

第三章:使用最小 API 进行工作

在本章中,我们将尝试应用在 .NET 早期版本中可用的某些高级开发技术。我们将涉及四个相互独立的话题。

我们将涵盖前端接口和配置管理的前沿主题和最佳实践。

每个开发者,迟早都会遇到本章中描述的问题。程序员将不得不为 API 编写文档,将不得不使 API 与 JavaScript 前端通信,将不得不处理错误并尝试修复它们,以及根据参数配置应用程序。

本章我们将涉及的主题如下:

  • 探索 Swagger

  • 支持 CORS

  • 使用全局 API 设置

  • 错误处理

技术要求

如前几章所述,需要具备 .NET 6 开发框架;您还需要使用 .NET 工具来运行内存中的 Web 服务器。

为了验证跨源资源共享CORS)的功能,我们应该利用一个位于不同 HTTP 地址的前端应用程序,该地址与我们将托管 API 的地址不同。

为了测试本章中我们将提出的 CORS 示例,我们将利用内存中的 Web 服务器,这将使我们能够托管一个简单的静态 HTML 页面。

因此,为了托管网页(HTML 和 JavaScript),我们将使用 LiveReloadServer,您可以使用以下命令将其作为 .NET 工具安装:

dotnet tool install -g LiveReloadServer

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter03

探索 Swagger

Swagger 以一种重大的方式进入了 .NET 开发者的生活;它已经在 Visual Studio 的多个版本中出现在项目架子上。

Swagger 是一个基于 OpenAPI 规范的工具,允许您使用 Web 应用程序来记录 API。根据官方文档(oai.github.io/Documentation/introduction.xhtml):

“OpenAPI 规范允许通过 HTTP 或类似 HTTP 协议描述可远程访问的 API。

一个 API 定义了两个软件组件之间允许的交互方式,就像用户界面定义了用户与程序交互的方式一样。

一个 API 由可以调用的可能方法列表(要发出的请求)、它们的参数、返回值以及它们所需的数据格式(以及其他事项)组成。这相当于用户与手机应用程序的交互仅限于应用程序用户界面中的按钮、滑块和文本框。”

Visual Studio 模板中的 Swagger

因此,我们理解,Swagger,正如我们在 .NET 世界中所知,只不过是为所有公开基于 Web 的 API 的应用程序定义的一组规范:

图 3.1 – Visual Studio 模板

图 3.1 – Visual Studio 模板

通过选择 Swashbuckle.AspNetCore 并在 Program.cs 文件中自动配置它。

我们展示了在新项目中添加的几行代码。凭借这些少量的信息,Web 应用程序仅启用开发环境,这允许开发者测试 API 而无需生成客户端或使用应用程序外部的工具:

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

Swagger 生成的图形部分大大提高了生产力,并允许开发者与那些将与应用程序接口的人共享信息,无论是前端应用程序还是机器应用程序。

注意

我们提醒您,在生产环境中启用 Swagger 是 强烈不建议 的,因为敏感信息可能会在网络上或应用程序所在的网络中被公开暴露。

我们已经看到了如何将 Swagger 引入我们的 API 应用程序;此功能使我们能够记录我们的 API,并允许用户生成一个客户端来调用我们的应用程序。让我们看看我们可以快速将应用程序与使用 OpenAPI 描述的 API 接口的方法。

OpenAPI 生成器

使用 Swagger,尤其是使用 OpenAPI 标准,您可以自动生成连接到 Web 应用程序的客户端。可以为许多语言生成客户端,也可以为开发工具生成。我们知道编写客户端以访问 Web API 是多么繁琐和重复。Open API Generator 帮助我们自动化代码生成,检查 Swagger 和 OpenAPI 制作的 API 文档,并自动生成与 API 接口的代码。简单、容易,最重要的是,快速。

@openapitools/openapi-generator-cli npm 包是 OpenAPI 生成器的一个非常著名的包包装器,您可以在 openapi-generator.tech/ 找到它。

使用此工具,您可以生成编程语言的客户端以及负载测试工具,如 JMeterK6

您不需要在您的机器上安装此工具,但如果应用程序的 URL 可从机器访问,您可以使用以下命令描述的 Docker 镜像:

docker run --rm \
    -v ${PWD}:/local openapitools/openapi-generator-cli generate \
    -i /local/petstore.yaml \
    -g go \
    -o /local/out/go

该命令允许您生成一个挂载在 Docker 卷上的 petstore.yaml 文件。

现在,让我们深入了解如何在 .NET 6 项目和最小 API 中利用 Swagger。

Swagger 在最小 API 中

在 ASP.NET Web API 中,正如以下代码片段所示,我们看到一个使用 C# 语言注释(///)进行文档化的方法。

文档部分被用来为 API 描述添加更多信息。此外,ProducesResponseType注解帮助 Swagger 识别客户端必须处理的方法调用结果的可能代码:

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

Swagger 除了对单个方法的注解外,还受到语言文档的指导,为将使用 API 应用程序的人提供更多信息。参数方法的描述总是受到那些必须进行接口的人的欢迎;不幸的是,在最小 API 中无法利用此功能。

让我们按顺序来看,看看如何在一个单独的方法上开始使用 Swagger:

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

通过这个第一个例子,我们已经配置了 Swagger 和一般的 Swagger 信息。我们包括了丰富 Swagger UI 的附加信息。唯一必需的信息是标题,而版本、联系信息、描述、许可和服务条款是可选的。

UseSwaggerUI()方法自动配置了 UI 的放置位置以及用 OpenAPI 格式描述 API 的 JSON 文件。

这是图形层面的结果:

图 3.2 – Swagger UI

图 3.2 – Swagger UI

我们可以立即看到 OpenAPI 合同信息已经被放置在/swagger/v1/swagger.json路径上。

联系信息已填充,但由于我们尚未输入任何操作,因此没有报告任何操作。API 应该有版本控制吗?在右上角,我们可以选择每个版本的可用操作。

我们可以自定义 Swagger 的 URL 并在新的路径上插入文档;重要的是要重新定义SwaggerEndpoint,如下所示:

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

现在我们继续添加描述业务逻辑的端点。

定义RouteHandlerBuilder非常重要,因为它允许我们描述我们在代码中编写的端点的所有属性。

Swagger 的 UI 必须尽可能丰富;我们必须尽可能详细地描述最小 API 允许我们指定的内容。不幸的是,并非所有功能都可用,就像在 ASP.NET Web API 中一样。

最小 API 中的版本控制

在最小 API 中,版本控制不是由框架功能处理的;因此,甚至 Swagger 也无法处理 UI 端的 API 版本控制。所以,我们观察到当我们进入图 3.2中显示的选择定义部分时,只能看到 API 当前版本的条目。

Swagger 功能

我们刚刚意识到 Swagger 中并非所有功能都可用;现在让我们探索一下可用的功能。为了描述端点的可能输出值,我们可以调用在处理程序之后可以调用的函数,例如ProducesWithTags函数,我们现在将要探讨这些函数。

Produces 函数用客户端应该能够管理的所有可能响应装饰端点。我们可以添加操作 ID 的名称;此信息不会出现在 Swagger 屏幕上,但它将是客户端创建调用端点的方法的名称。OperationId 是处理程序提供的操作的唯一名称。

要将端点从 API 描述中排除,需要调用 ExcludeFromDescription()。此函数很少使用,但在您不想向开发前端程序员的程序员暴露特定端点时,它非常有用。

最后,我们可以添加和标记各种端点,并将它们分段以更好地进行客户端管理:

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

这是 Swagger 的图形结果;正如我之前所预料的,标签和操作 ID 不会被 Web 客户端显示:

图 3.3 – Swagger UI 方法

图 3.3 – Swagger UI 方法

另一方面,端点描述非常有用,可以包含在内。它非常容易实现:只需在方法中插入 C# 注释(只需在方法中插入三个斜杠,///)。Minimal APIs 没有我们习惯于在基于 Web 的控制器中的方法,因此它们不是原生支持的。

Swagger 不仅仅是我们所习惯看到的 GUI。首先,Swagger 是支持 OpenAPI 规范的 JSON 文件,其中最新版本是 3.1.0。

在下面的代码片段中,我们展示了包含我们插入 API 的第一个端点描述的章节。我们可以推断出标签和操作 ID;这些信息将由与 API 接口的人使用:

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

在本节中,我们看到了如何配置 Swagger 以及目前尚未支持的内容。

在接下来的章节中,我们将看到如何配置 OpenAPI,包括 OpenID Connect 标准和通过 API 密钥进行认证。

在前面的 Swagger UI 代码片段中,Swagger 使涉及的对象的规范图可用,无论是输入到各个端点还是从它们输出。

图 3.4 – 输入和输出数据规范

图 3.4 – 输入和输出数据规范

我们将学习如何处理这些对象,以及如何在 第六章 探索验证和映射 中验证和定义它们。

Swagger OperationFilter

操作过滤器允许您向 Swagger 显示的所有操作添加行为。在下面的示例中,我们将向您展示如何通过 OperationId 过滤器向特定调用添加 HTTP 头。

当您定义操作过滤器时,您还可以根据路由、标签和操作 ID 设置过滤器:

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

要定义一个操作过滤器,必须实现 IOperationFilter 接口。

在构造函数中,您可以定义所有之前已在依赖注入引擎中注册的接口或对象。

然后,过滤器由一个名为Apply的单个方法组成,它提供了两个对象:

  • OpenApiOperation:一个可以添加参数或检查当前调用操作 ID 的操作

  • OperationFilterContext:允许你读取ApiDescription的过滤器上下文,在那里你可以找到当前端点的 URL

最后,为了在 Swagger 中启用操作过滤器,我们需要在SwaggerGen方法内部注册它。

在这个方法中,我们应该添加过滤器,如下所示:

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

这里是 UI 级别的结果;在端点和特定操作 ID 的情况下,我们会有一个新的强制头,默认参数在开发中不需要插入:

图 3.5 – API 密钥部分

图 3.5 – API 密钥部分

当我们需要设置 API 密钥但又不想在每次调用中都插入它时,这个案例研究对我们帮助很大。

生产环境中的操作过滤器

由于 Swagger 不应该在生产环境中启用,因此过滤器及其默认值不会创建应用程序安全问题。

我们建议你在生产环境中禁用 Swagger。

在本节中,我们了解了如何启用一个描述 API 并允许我们测试它的 UI 工具。在下一节中,我们将看到如何通过 CORS 启用单页应用程序SPAs)和后端之间的调用。

启用 CORS

CORS 是一种安全机制,如果 HTTP/S 请求来自与应用程序托管域不同的域,则会阻止请求。更多信息可以在 Microsoft 文档或 Mozilla 开发者网站上找到。

浏览器阻止网页向除提供该网页的域以外的域发送请求。一个网页、SPA 或服务器端网页可以向托管在不同源的不同后端 API 发送 HTTP 请求。

这种限制被称为同源策略。同源策略阻止恶意网站从另一个网站读取数据。浏览器不会阻止 HTTP 请求,但会阻止响应数据。

因此,我们理解,与安全相关的 CORS 资格必须谨慎评估。

最常见的场景是,在发布于与托管最小 API 的 Web 服务器不同地址的 Web 服务器上发布的 SPAs:

图 3.6 – SPA 和最小 API

图 3.6 – SPA 和最小 API

类似的场景是微服务,它们需要相互通信。每个微服务将驻留在特定的 Web 地址上,这个地址将与其他的不同。

图 3.7 – 微服务和最小 API

图 3.7 – 微服务和最小 API

因此,在这些所有情况下,都会遇到 CORS 问题。

我们现在理解了 CORS 请求可能发生的情况。现在让我们看看正确的 HTTP 请求流程以及浏览器如何处理请求。

从 HTTP 请求到 CORS 流程

当调用离开浏览器,前往除前端托管地址之外的其他地址时会发生什么?

HTTP 调用被执行,并且一直传递到后端代码,后端代码执行正确。

带有正确数据的响应被浏览器阻止。这就是为什么当我们使用 Postman、Fiddler 或任何 HTTP 客户端执行调用时,响应能够正确地到达我们。

图 3.8 – CORS 流程

图 3.8 – CORS 流程

在下面的图中,我们可以看到浏览器使用OPTIONS方法发出第一次调用,后端正确地以204状态码响应:

图 3.9 – CORS 调用的第一次请求(204 无内容结果)

图 3.9 – CORS 调用的第一次请求(204 无内容结果)

在浏览器发出的第二次调用中,发生了一个错误;在Referrer Policy中显示了strict-origin-when-cross-origin的值,这表明浏览器拒绝接受来自后端的数据:

图 3.10 – CORS 调用的第二次请求(被浏览器阻止)

图 3.10 – CORS 调用的第二次请求(被浏览器阻止)

当 CORS 启用时,在OPTIONS方法调用的响应中,插入三个具有后端愿意遵守的特征的头:

图 3.11 – CORS 调用的请求(已启用 CORS)

图 3.11 – CORS 调用的请求(已启用 CORS)

在这种情况下,我们可以看到添加了三个头,定义了Access-Control-Allow-HeadersAccess-Control-Allow-MethodsAccess-Control-Allow-Origin

拥有这些信息的浏览器可以接受或阻止对这一 API 的响应。

使用策略设置 CORS

在.NET 6 应用程序中,有许多配置可以激活 CORS。我们可以定义授权策略,其中可以配置四个可用的设置。CORS 也可以通过添加扩展方法或注解来激活。

但让我们按顺序进行。

CorsPolicyBuilder类允许我们定义在 CORS 接受策略中允许或不允许的内容。

因此,我们有设置不同方法的可能性,例如:

  • AllowAnyHeader

  • AllowAnyMethod

  • AllowAnyOrigin

  • AllowCredentials

虽然前三种方法都是描述性的,允许我们分别启用与 HTTP 调用头、方法和源相关的任何设置,但AllowCredentials允许我们包含带有认证凭据的 cookie。

CORS 策略建议

我们建议您不要使用AllowAny方法,而是过滤出必要的信息以提供更高的安全性。作为最佳实践,在启用 CORS 时,我们建议使用这些方法:

  • WithExposedHeaders

  • WithHeaders

  • WithOrigins

为了模拟 CORS 的场景,我们创建了一个简单的前端应用程序,有三个不同的按钮。每个按钮允许你测试最小 API 中 CORS 的可能配置之一。我们将在几行中解释这些配置。

要启用 CORS 场景,我们创建了一个单页应用程序,它可以在内存中的 Web 服务器上启动。我们使用了 LiveReloadServer 工具,这是一个可以使用 .NET CLI 安装的工具。我们在本章开头提到了它,现在是我们使用它的时候了。

安装后,你需要使用以下命令启动 SPA:

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

在这里,BasePath 是你将要下载 GitHub 上可用的示例的文件夹。

然后你必须启动应用程序的后端,无论是通过 Visual Studio 还是 Visual Studio Code,或者通过以下命令使用 .NET CLI:

dotnet run .\Backend\CorsSample.csproj

我们已经找到了如何启动一个突出 CORS 问题的示例;现在我们需要配置服务器以接受请求并通知浏览器它知道请求来自不同的源。

接下来,我们将讨论策略配置。我们将了解默认策略的特点以及如何创建一个自定义策略。

配置默认策略

要配置单个启用 CORS 的策略,需要在 Program.cs 文件中定义行为并添加所需的配置。让我们实现一个策略并将其定义为 Default

然后,为了使整个应用程序启用该策略,只需在定义处理程序之前添加 app.UseCors();

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

配置自定义策略

我们可以在应用程序中创建多个策略;每个策略可能有它自己的配置,每个策略可能关联一个或多个端点。

在微服务的情况下,拥有几个策略有助于精确地分割来自不同源的对访问。

为了配置一个新的策略,必须添加它并给它一个名称;这个名称将赋予策略访问权限,并允许它与端点关联。

如前例所示,自定义策略被分配给整个应用程序:

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

接下来,我们将探讨如何将单个策略应用于特定的端点;为此,有两种方法。第一种是通过 IEndpointConventionBuilder 接口的扩展方法。第二种方法是在方法中添加 EnableCors 注解,后跟要启用的策略名称。

使用扩展设置 CORS

必须使用 RequireCors 方法后跟策略的名称。

使用这种方法,然后可以为一个端点启用一个或多个策略:

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

使用注解设置 CORS

第二种方法是添加 EnableCors 注解,后跟要为该方法启用的策略名称:

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

关于控制器编程,很快就会很明显,无法将策略应用于特定控制器的所有方法。也无法将控制器分组并启用策略。因此,有必要将单个策略应用于方法或整个应用程序。

在本节中,我们了解到如何为托管在不同域上的应用程序配置浏览器保护。

在下一节中,我们将开始配置我们的应用程序。

与全局 API 设置一起工作

我们刚刚定义了如何在 ASP.NET 应用程序中使用options模式加载数据。在本节中,我们想要描述如何配置应用程序并利用上一节中看到的一切。

随着从Web.config文件到appsettings.json文件的诞生。配置也可以从其他来源读取,例如其他文件格式,如旧的.ini文件或位置文件。

在最小 API 中,options模式功能保持不变,但在接下来的几段中,我们将看到如何重用接口或appsettings.json文件结构。

.NET 6 中的配置

.NET 提供的对象是IConfiguration,它允许我们读取appsettings文件中的某些特定配置。

但是,如前所述,此接口的功能远不止访问文件进行读取。

以下是从官方文档中摘录的内容,有助于我们了解接口是如何成为通用的访问点,使我们能够访问各种服务中插入的数据:

ASP.NET Core 中的配置是通过一个或多个配置提供程序来执行的。配置提供程序使用各种配置来源从键值对中读取配置数据。

以下是一个配置来源列表:

  • 设置文件,如appsettings.json

  • 环境变量

  • Azure Key Vault

  • Azure App Configuration

  • 命令行参数

  • 自定义提供程序,已安装或创建

  • 目录文件

  • 内存中的.NET 对象

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

我们将在下一章中看到的IConfigurationIOptions接口旨在从各种提供程序中读取数据。这些接口不适合在程序运行时读取和编辑配置文件。

IConfiguration接口通过builder对象,builder.Configuration提供,它提供了读取值、对象或连接字符串所需的所有方法。

在查看我们将用于配置应用程序的最重要接口之一后,我们希望定义良好的开发实践,并使用任何开发者都应具备的基本构建块:即类。将配置复制到类中将使我们能够在代码的任何地方更好地享受内容。

我们定义包含属性的类和对应于appsettings文件的类:

配置类

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

在这里,我们重新引入我们刚才看到的 C#类的相应 JSON:

appsettings.json 定义

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

接下来,我们将执行几个操作。

我们执行的第一个操作是创建一个startupConfig对象的实例,该实例将是MyCustomStartupObject类型。为了填充此对象的实例,通过IConfiguration,我们将从名为MyCustomStartupObject的部分读取数据:

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

新创建的对象可以用于最小 API 的各种处理器中。

相反,在这个第二个操作中,我们使用依赖注入引擎来请求IConfiguration对象的实例:

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

使用IConfiguration对象,我们将以类似上述操作的方式检索数据。我们选择GetSection(nameof(MyCustomObject))部分,并使用Get<T>()方法输入对象。

最后,在这最后两个例子中,我们读取一个位于appsettings文件根级别的单个键:

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

configuration.GetValue<T>("JsonRootKey")方法提取键的值并将其转换为对象;此方法用于从根级属性读取字符串或数字。

在下一行中,我们可以看到如何利用IConfiguration方法读取ConnectionString

appsettings文件中,连接字符串放置在特定的ConnectionStrings部分中,允许你命名字符串并读取它。可以在该部分放置多个连接字符串,以便在不同的对象中利用它。

在 Azure App Service 的配置提供程序中,连接字符串应以一个前缀输入,该前缀也指示你试图使用的 SQL 提供程序,如以下链接中所述:docs.microsoft.com/azure/app-service/configure-common#configure-connection-strings

在运行时,连接字符串作为以下连接类型的前缀的环境变量可用:

  • SQLServer: SQLCONNSTR_

  • MySQL: MYSQLCONNSTR_

  • SQLAzure: SQLAZURECONNSTR_

  • Custom: CUSTOMCONNSTR_

  • PostgreSQL: POSTGRESQLCONNSTR_

为了完整性,我们将重新引入上述描述的整个代码,以便更好地了解如何在代码中利用IConfiguration对象:

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

我们已经看到了如何利用包含连接字符串的appsettings文件,但很多时候,我们为每个环境有许多不同的文件。让我们看看如何为每个环境利用一个文件。

appsettings 文件中的优先级

appsettings文件可以根据应用程序所在的环境进行管理。在这种情况下,实践是将该环境的密钥信息放置在appsettings.{ENVIRONMENT}.json文件中。

根文件(即appsettings.json)应仅用于生产环境。

例如,如果我们在这两个文件中为“Priority”键创建了这些示例,我们会得到什么?

appsettings.json

"Priority": "Root"

appsettings.Development.json

"Priority":    "Dev"

如果是开发环境,键的值将导致Dev,而在生产环境中,值将导致Root

如果环境不是生产开发,会发生什么?例如,如果它被称作阶段?在这种情况下,由于没有指定任何appsettings.Stage.json文件,读取的值将是appsettings.json文件中的一个,因此,Root

然而,如果我们指定了appsettings.Stage.json文件,值将从该文件中读取。

接下来,让我们看看选项模式。框架提供了一些对象,用于在启动时或系统部门进行更改时加载配置信息。让我们来看看如何操作。

选项模式

选项模式使用类来提供对相关设置组的强类型访问,即当配置设置通过场景隔离到不同的类中时。

选项模式将以不同的接口和不同的功能来实现。每个接口(见以下小节)都有其自身的功能,帮助我们实现某些目标。

但让我们按顺序开始。我们为每种类型的接口定义一个对象(我们将这样做以更好地表示示例),但同一个类可以用于在配置文件中注册更多选项。保持文件结构相同是很重要的:

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

每个选项都通过Configure方法在依赖注入引擎中注册,该方法还要求注册方法签名中存在的T类型。如您所见,在注册阶段,我们声明了类型和文件中的部分,以及更多:

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

我们尚未定义如何读取对象,多久读取一次,以及使用哪种类型的接口。

唯一改变的是参数,如前代码片段的最后两个示例所示。此参数允许您为选项类型添加一个名称。该名称必须与方法签名中使用的类型匹配。此功能称为命名选项

不同的选项接口

不同的接口可以利用您刚刚定义的录制。一些支持命名选项,而另一些则不支持:

  • IOptions<TOptions>:

    • 不支持以下:

      • 在应用程序启动后读取配置数据

      • 命名选项

    • 注册为单例,可以注入到任何服务生命周期中

  • IOptionsSnapshot<TOptions>:

    • 在需要为每个请求重新计算选项的场景中很有用

    • 注册为作用域,因此不能注入到单例服务中

    • 支持命名选项

  • IOptionsMonitor<TOptions>:

    • 用于检索TOptions实例的选项和管理选项通知

    • 作为单例注册,并且可以被注入到任何服务生命周期中

    • 支持以下内容:

      • 变更通知

      • 命名选项

      • 可重载的配置

      • 选择性选项无效化(IOptionsMonitorCache<TOptions>

我们想要指出IOptionsFactory<TOptions>的使用,它负责创建选项的新实例。它有一个单一的Create方法。默认实现首先执行所有注册的IConfigureOptions<TOptions>IPostConfigureOptions<TOptions>配置,然后是后配置(docs.microsoft.com/aspnet/core/fundamentals/configuration/options#options-interfaces)。

Configure方法后面还可以跟配置管道中的另一个方法。这个方法被称为PostConfigure,它的目的是在每次配置或重新读取配置时修改配置。以下是如何记录此行为的示例:

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

将所有内容整合在一起

在定义了这些众多接口的理论之后,我们还需要通过一个具体的例子来看看IOptions是如何工作的。

让我们看看前面描述的三个接口的使用,以及IOptionsFactory的使用,它通过Create方法和命名选项功能检索对象的正确实例:

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

在前面的代码片段中,我们想要引起大家对可用不同接口使用的注意。

在前面的代码片段中使用的每个单独的接口都有其特定的生命周期,这决定了其行为。最后,每个接口在方法上都有细微的差异,正如我们在前面的段落中已经描述的那样。

IOptions 和验证

最后但同样重要的是配置中存在的验证功能。当必须发布应用程序的团队仍然执行需要至少通过代码验证的手动或精细操作时,这非常有用。

在.NET Core 出现之前,应用程序经常因为配置错误而无法启动。现在,有了这个功能,我们可以验证配置中的数据并抛出错误。

这里有一个例子:

注册具有验证的选项

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

这是报告错误的配置文件:

配置验证的应用设置部分

"ConfigWithValidation": {
         "Email": "andrea.tosato@hotmail.it",
         "NumericRange": 1001
    }

下面是这个包含验证逻辑的类的示例:

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

应用程序在使用特定配置时遇到错误,而不是在启动时。这也是因为我们之前看到,IOptions可以在appsettings更改后重新加载信息:

错误验证选项

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

在 IOptions 中使用验证的最佳实践

这个设置并不适合所有应用程序场景。只有一些选项可以进行正式验证;如果我们考虑连接字符串,它可能并不一定是形式上不正确的,但连接可能无法工作。

在应用此功能时要谨慎,特别是因为它在运行时而不是在启动时报告错误,并给出内部服务器错误,这在应该处理的场景中不是最佳实践。

到目前为止我们所看到的一切都是关于配置appsettings.json文件,但如果我们想使用其他源进行配置管理怎么办?我们将在下一节中探讨这个问题。

配置源

如本节开头所述,IConfiguration接口和所有IOptions变体不仅与appsettings文件一起工作,而且与不同的源一起工作。

每个源都有其自身的特性,并且在不同提供者之间访问对象的语法非常相似。主要问题在于我们必须定义一个复杂对象或对象数组时;在这种情况下,我们将看到如何表现并能够复制 JSON 文件的动态结构。

让我们看看两个非常常见的用例。

在 Azure App Service 中配置应用程序

让我们从 Azure 开始,特别是 Azure Web Apps 服务。

配置页面,有两个部分:应用程序设置连接字符串

在第一部分,我们需要插入之前示例中看到的键和值或 JSON 对象。

appsettings.json文件中。在本节中,除了文本字符串外,还需要设置连接类型,正如我们在配置.NET 6部分中看到的。

图 3.12 – Azure App Service 应用程序设置

图 3.12 – Azure App Service 应用程序设置

插入对象

要插入一个对象,我们必须为每个键指定父对象。

格式如下:

parent__key

注意,这里有两个下划线。

JSON 文件中的对象定义如下:

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

因此,我们应该编写MyCustomObject__CustomProperty

插入数组

插入数组要冗长得多。

格式如下:

parent__child__ArrayIndexNumber_key

JSON 文件中的数组定义如下:

{
    "MyCustomArray": {
       "CustomPropertyArray": [
         { "CustomKey": "ValueOne" },
         { "CustomKey ": "ValueTwo" }
     ]
    }
}

因此,要访问ValueOne值,我们应该编写以下内容:MyCustomArray__CustomPropertyArray__0__CustomKey

在 Docker 中配置应用程序

如果我们正在为容器和 Docker 开发,appsettings文件通常在docker-compose文件中替换,并且在override文件中非常常见,因为它与环境分隔的设置文件行为类似。

我们希望提供一个关于通常用于配置托管在 Docker 中的应用程序的功能的简要概述。让我们详细了解如何定义根键和对象,以及如何设置连接字符串。以下是一个示例:

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

使用配置的最小 API

docker-compose.override.yaml文件如下:

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

本例中只有一个应用程序容器,实例化它的服务称为dockerenvironment

在配置部分,我们可以看到三个我们将逐行分析的特定之处。

我们想要展示的代码片段有几个非常有趣的部分:配置根中的一个属性、由单个属性组成的对象以及数据库的连接字符串。

在这个第一个配置中,你需要设置一个作为配置根的属性。在这种情况下,它是一个简单的字符串:

# First configuration
- RootProperty=minimalapi-root-value

在这个第二个配置中,我们将设置一个对象:

# Second configuration
- RootSettings__SampleVariable=minimalapi-variable-value

该对象被命名为 RootSettings,而它所包含的唯一属性被命名为 SampleVariable。这个对象可以通过不同的方式读取。我们建议使用我们之前广泛使用过的 Ioptions 对象。在先前的示例中,我们展示了如何通过代码访问对象中存在的单个属性。

在这种情况下,通过代码,你需要使用以下记法来访问值:RootSettings:SampleVariable。这种方法在需要读取单个属性时很有用,但我们建议使用 Ioptions 接口来访问对象。

在这个最后的示例中,我们向您展示如何设置名为 SqlConnection 的连接字符串。这样,将很容易从 Iconfiguration 上可用的基方法中检索信息:

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

要读取信息,必须利用此方法:GetConnectionString("SqlConnection")

配置我们的应用程序有很多场景;在下一节中,我们还将看到如何处理错误。

错误处理

错误处理是每个应用程序必须提供的功能之一。错误的表示允许客户端理解错误,并可能相应地处理请求。非常常见的是,我们有自己的自定义错误处理方法。

由于我们描述的是应用程序的关键功能,我们认为查看框架提供的内容以及更正确的使用方式是公平的。

传统方法

.NET 为最小 API 提供了与我们在传统开发中可以实现的相同工具:开发者异常页面。这只是一个以纯文本格式报告错误的中间件。这个中间件不能从 ASP.NET 管道中移除,并且仅在开发环境中工作 (docs.microsoft.com/aspnet/core/fundamentals/error-handling)。

图 3.13 – 最小 API 管道,ExceptionHandler

图 3.13 – 最小 API 管道,ExceptionHandler

如果我们的代码中抛出了异常,在应用层中捕获它们的唯一方法是通过在向客户端发送响应之前激活的中间件。

错误处理中间件是标准的,可以按照以下方式实现:

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

我们在这里展示了中间件的可能实现。为了实现它,必须利用 UseExceptionHandler 方法,允许编写整个应用程序的管理代码。

通过名为 exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>()!;var 功能,我们可以访问错误堆栈,并在输出中返回对调用者有用的信息:

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

当代码中发生异常时,就像前面的例子一样,中间件介入并处理返回给客户端的消息。

如果异常发生在内部应用程序堆栈中,中间件仍然会介入,向客户端提供正确的错误和适当的指示。

问题详情与 IETF 标准

HTTP API 的问题详情 是一个于 2016 年获得批准的 IETF 标准。此标准允许通过标准字段和 JSON 注释返回一组信息,这些注释有助于识别错误。

HTTP 状态代码有时不足以传达足够的信息来描述错误,使其有用。虽然浏览器背后的人类可以通过 HTML 响应体了解问题的性质,但非人类消费者,如机器、PC 和服务器,通常无法从所谓的 HTTP API 中获得这些信息。

本规范定义了简单的 JSON 和 XML 文档格式,以满足此目的。它们被设计成可以被 HTTP API 重用,以识别特定于其需求的独特 问题类型

因此,API 客户端可以了解高级错误类和问题的更详细细节(datatracker.ietf.org/doc/html/rfc7807)。

在.NET 中,有一个符合 IETF 标准的所有功能的包。

该包名为 Hellang.Middleware.ProblemDetails,您可以从以下地址下载它:www.nuget.org/packages/Hellang.Middleware.ProblemDetails/

现在我们来看看如何将包插入项目并配置它:

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

如您所见,只需两条指令即可使此包工作:

  • builder.Services.AddProblemDetails

  • app.UseProblemDetails();

由于在最小 API 中,IActionResultExecutor 接口不在 ASP.NET 管道中,因此有必要添加一个自定义类来处理错误情况下的响应。

要做到这一点,您需要添加一个类(如下所示)并在依赖注入引擎中注册它:builder.Services.TryAddSingleton<IActionResultExecutor<ObjectResult>, ProblemDetailsResultExecutor>();

这里是支持该包的类,也位于最小 API 之下:

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

如前所述,处理错误消息的标准已经在 IETF 标准中存在了几年,但对于 C#语言来说,有必要添加前面提到的包。

现在,让我们看看这个包是如何处理我们在这里报告的一些端点错误的:

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

我们使用此端点抛出一个应用程序级别的异常。在这种情况下,ProblemDetails中间件会返回一个与错误一致的 JSON 错误。然后我们免费处理未处理的异常:

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

通过在Program文件中插入额外的配置,你可以将一些特定的异常映射到 HTTP 错误。以下是一个示例:

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

包含NotImplementedException异常的代码被映射到 HTTP 错误代码501

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

最后,我们可以通过添加额外的字段或通过添加自定义文本调用base方法来扩展框架中ProblemDetails类的功能。

这里是MapGet端点处理程序的最后两个示例:

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

摘要

在本章中,我们看到了关于最小 API 实现的一些高级方面。我们探讨了 Swagger,它用于记录 API 并为开发者提供一个方便、实用的调试环境。我们看到了如何处理除当前 API 之外托管在不同地址上的应用程序的问题。最后,我们看到了如何加载配置信息和处理应用程序中的意外错误。

我们探讨了在短时间内提高生产力的关键要素。

在下一章中,我们将添加一个用于 SOLID 面向模式编程的基本构建块,即依赖注入引擎,这将帮助我们更好地管理散布在各种层中的应用代码。

第二部分:.NET 6 的新特性是什么?

在本书的第二部分,我们希望向您展示.NET 6 框架的特性以及它们如何在最小 API 中使用。

在本节中,我们将涵盖以下章节:

  • 第四章最小 API 项目中的依赖注入

  • 第五章使用日志识别错误

  • 第六章探索验证和映射

  • 第七章与数据访问层的集成

第四章:在最小 API 项目中实现依赖注入

在本书的这一章中,我们将讨论.NET 6.0 中最小 API 的一些基本主题。我们将学习它们如何与我们之前在.NET 的旧版本中使用的基于控制器的 Web API 不同。我们还将尝试强调这种新的 API 编写方法的优势和劣势。

在本章中,我们将涵盖以下主题:

  • 什么是依赖注入?

  • 在最小 API 项目中实现依赖注入

技术要求

为了跟随本章的解释,你需要创建一个 ASP.NET Core 6.0 Web API 应用程序。你可以参考第二章**,探索最小 API 及其优势的技术要求部分,了解如何操作。

本章中所有的代码示例都可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter04

什么是依赖注入?

一段时间以来,.NET 已经原生支持依赖注入(通常称为DI)软件设计模式。

依赖注入是.NET 中实现服务类及其依赖项之间控制反转IoC)模式的一种方式。顺便说一句,在.NET 中,许多基本服务都是使用依赖注入构建的,例如日志记录、配置和其他服务。

让我们通过一个实际例子来了解它是如何工作的。

一般而言,依赖是一个依赖于另一个对象的对象。在以下示例中,我们有一个名为LogWriter的类,其中只有一个方法,称为Log

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

项目中的其他类或另一个项目中的类可以创建LogWriter类的实例并使用Log方法。

看看以下示例:

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

这个类直接依赖于LogWriter类,并且在你的项目的每个类中都是硬编码的。

这意味着如果你想要更改Log方法;例如,你将不得不替换解决方案中每个类的实现。

如果你想在你的解决方案中实现单元测试,前面的实现有一些问题。创建LogWriter类的模拟并不容易。

通过对我们代码的一些更改,依赖注入可以解决这些问题:

  1. 使用接口来抽象依赖。

  2. 在.NET 中注册依赖注入到内置的服务连接。

  3. 将服务注入到类的构造函数中。

前面的内容可能看起来需要你对代码进行大的改动,但实际上它们很容易实现。

让我们看看我们如何可以通过之前的例子实现这个目标:

  1. 首先,我们将创建一个具有我们日志抽象的ILogWriter接口:

    public interface ILogWriter
    {
        void Log(string message);
    }
    
  2. 接下来,在一个名为ConsoleLogWriter的实类中实现这个ILogWriter接口:

    public class ConsoleLogWriter : ILogWriter
    {
        public void Log(string message)
        {
            Console.WriteLine($"ConsoleLogWriter.
            Write(message: \"{message}\")");
        }
    }
    
  3. 现在,修改Worker类,将显式的LogWriter类替换为新的ILogWriter接口:

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

如你所见,以这种方式工作非常简单,优势很大。以下是依赖注入的一些优点:

  • 可维护性

  • 可测试性

  • 可重用性

现在我们需要执行最后一步,即在应用程序启动时注册依赖项。

  1. Program.cs文件的顶部添加以下代码行:

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

在下一节中,我们将讨论依赖注入生命周期之间的差异,这是在使用最小 API 项目进行依赖注入之前你需要理解的概念。

理解依赖注入的生命周期

在上一节中,我们学习了在项目中使用依赖注入的好处以及如何将我们的代码转换为使用它。

在最后一段中,我们将我们的类作为服务添加到.NET 的ServiceCollection中。

在本节中,我们将尝试理解每种依赖注入的生命周期差异。

服务生命周期定义了对象在容器创建之后将存活多长时间。

当它们被注册时,依赖项需要生命周期定义。这定义了何时创建新的服务实例。

在以下列表中,你可以找到.NET 中定义的生命周期:

  • 瞬态:每次请求时都会创建该类的新实例。

  • 作用域:每个作用域创建该类的一个新实例,例如,对于同一个 HTTP 请求。

  • 单例:仅在第一次请求时创建该类的新实例。下一次请求将使用相同类的相同实例。

在网络应用程序中,你通常只找到前两种生命周期,即瞬态和作用域。

如果你有一个需要单例的特殊用例,这并不被禁止,但为了最佳实践,建议在 Web 应用程序中避免使用它们。

在前两种情况下,即瞬态和作用域内,服务在请求结束时被销毁。

在下一节中,我们将看到如何通过一个简短的演示来实现我们在上一节中提到的所有概念(依赖注入的定义及其生命周期),你可以将其作为你下一个项目的起点。

在最小 API 项目中实现依赖注入

在理解了如何在 ASP.NET Core 项目中使用依赖注入之后,让我们尝试理解如何在我们的最小 API 项目中使用依赖注入,从使用WeatherForecast端点的默认项目开始。

这是WeatherForecast GET 端点的实际代码:

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

正如我们之前提到的,这段代码可以工作,但它不容易测试,尤其是天气的新值的创建。

最佳选择是使用一个服务来创建假值,并使用依赖注入来使用它。

让我们看看我们如何更好地实现我们的代码:

  1. 首先,在Program.cs文件中,添加一个名为IWeatherForecastService的新接口,并定义一个返回WeatherForecast实体数组的函数:

    public interface IWeatherForecastService
    {
               WeatherForecast[] GetForecast();
    }
    
  2. 下一步是创建从接口继承的类的实际实现。

代码应该看起来像这样:

public class WeatherForecastService : IWeatherForecastService
{
}
  1. 现在将项目模板中的代码复制粘贴到我们新实现的服务中。最终的代码如下所示:

    public class WeatherForecastService : IWeatherForecastService
    {
        public WeatherForecast[] GetForecast()
        {
            var summaries = new[]
            {
                "Freezing", "Bracing", "Chilly", "Cool", 
                "Mild", "Warm", "Balmy", "Hot", "Sweltering", 
                "Scorching"
            };
            var forecast = Enumerable.Range(1, 5).
            Select(index =>
            new WeatherForecast
            (
                DateTime.Now.AddDays(index),
                Random.Shared.Next(-20, 55),
                summaries[Random.Shared.Next
                (summaries.Length)]
            ))
            .ToArray();
            return forecast;
        }
    }
    
  2. 现在我们已经准备好将WeatherForecastService的实现添加到我们的项目中作为依赖注入。为此,在Program.cs文件的第一行代码下方插入以下行:

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

当应用程序启动时,将我们的服务插入到服务集合中。我们的工作还没有完成。

我们需要在WeatherForecast端点的默认MapGet实现中使用我们的服务。

最小 API 有自己的参数绑定实现,并且非常容易使用。

首先,为了使用依赖注入实现我们的服务,我们需要从端点中移除所有旧代码。

在移除代码后,端点的代码看起来像这样:

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

我们可以通过简单地用新代码替换旧代码来轻松改进我们的代码并使用依赖注入:

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

在最小 API 项目中,服务集合中服务的实际实现被作为参数传递给函数,并且可以直接使用它们。

有时,在启动阶段,你可能需要在主函数中直接使用依赖注入中的服务。在这种情况下,你必须直接从服务集合中检索实现实例,如下面的代码片段所示:

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

在本节中,我们从默认模板开始,在最小 API 项目中实现了依赖注入。

我们重用了现有的代码,但用更符合未来维护和测试的架构逻辑来实现它。

摘要

依赖注入是实现现代应用的重要方法之一。在本章中,我们学习了依赖注入是什么,并讨论了其基本原理。然后,我们看到了如何在最小 API 项目中使用依赖注入。

在下一章中,我们将关注现代应用的另一个重要层,并讨论如何在最小 API 项目中实现日志策略。

第五章:使用日志来识别错误

在本章中,我们将开始了解.NET 为我们提供的日志工具。日志器是开发者必须使用来调试应用程序或理解其在生产中的失败的工具之一。日志库已经内置到 ASP.NET 中,并启用了设计中的几个功能。本章的目的是深入探讨我们视为理所当然的事情,并在过程中添加更多信息。

本章我们将涉及的主题如下:

  • 探索.NET 中的日志记录

  • 利用日志框架

  • 使用 Serilog 存储结构化日志

技术要求

如前几章所述,将需要 .NET 6 开发框架。

本章开始测试所描述的示例没有特殊要求。

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter05

探索.NET 中的日志记录

ASP.NET Core 模板创建了一个 WebApplicationBuilder 和一个 WebApplication,这提供了一种简化配置和运行 Web 应用程序的方法,无需启动类。

如前所述,在 .NET 6 中,为了替代现有的 Program.cs 文件,删除了 Startup.cs 文件。所有启动配置都放置在这个文件中,在 最小 API 的情况下,端点实现也放置在这里。

我们刚才描述的是每个.NET 应用程序及其各种配置的起点。

将日志记录到应用程序中意味着跟踪代码不同点的证据,以检查其是否按预期运行。日志的目的是在时间上跟踪导致应用程序中出现意外结果或事件的全部条件。在开发期间以及应用程序在生产中时,应用程序中的日志记录都可能非常有用。

然而,对于日志记录,为了跟踪应用程序信息,添加了多达四个提供者:

  • 控制台:Console 提供者将输出记录到控制台。这种日志在生产中不可用,因为 Web 应用程序的控制台通常不可见。这种类型的日志在开发期间很有用,当你在你桌面上运行应用程序时,可以在应用程序控制台窗口中快速记录日志。

  • System.Diagnostics.Debug 类。当我们开发时,我们习惯于在 Visual Studio 输出窗口中看到这个部分。

在 Linux 操作系统下,信息根据发行版在以下位置进行跟踪:/var/log/message/var/log/syslog

  • EventSource:在 Windows 上,此信息可以在 EventTracing 窗口中查看。

  • EventLog(仅在 Windows 上运行时):此信息显示在原生 Windows 窗口中,因此只有当你在 Windows 操作系统上运行应用程序时才能看到它。

最新.NET 版本中的新功能

在.NET 的最新版本中添加了新的日志提供程序。然而,这些提供程序在框架内部并未启用。

使用这些扩展来启用新的日志场景:AddSystemdConsoleAddJsonConsoleAddSimpleConsole

您可以在此链接中找到有关如何配置日志和基本 ASP.NET 设置更多详细信息:docs.microsoft.com/aspnet/core/fundamentals/host/generic-host

我们已经开始看到框架为我们提供的内容;现在我们需要了解如何在我们的应用程序中利用它。在继续之前,我们需要了解什么是日志层。这是一个基本概念,它将帮助我们将信息分解为不同的层次,并在需要时启用它们:

表 5.1 – 日志级别

表 5.1 – 日志级别

表 5.1显示了从最详细到最不详细的日志级别。

要了解更多信息,您可以阅读题为在.NET Core 和 ASP.NET Core 中的日志记录的文章,该文章详细解释了这里的日志记录过程:docs.microsoft.com/aspnet/core/fundamentals/logging/。

如果我们将日志级别设置为Information,则此级别的所有内容都将追踪到Critical级别,跳过DebugTrace

我们已经看到了如何利用日志层;现在,让我们继续编写一个可以记录信息并允许我们将有价值的内容插入跟踪系统的单个语句。

配置日志

要开始使用日志组件,您需要了解一些信息以开始跟踪数据。每个日志对象(ILogger<T>)都必须有一个关联的类别。日志类别允许您以高分辨率分割跟踪层。例如,如果我们想跟踪某个类或 ASP.NET 控制器中发生的所有事件,而无需重写所有代码,我们需要启用我们感兴趣的类别或类别。

类别是一个T类。没有什么比这更简单了。您可以在注入日志方法所在的类中重用该类的类型化对象。例如,如果我们正在实现MyService,并且我们想使用相同的类别跟踪服务中发生的所有事件,我们只需从依赖注入引擎请求一个ILogger<MyService>对象实例。

一旦定义了日志类别,我们需要调用ILogger<T>对象并利用对象的公共方法。在前一节中,我们探讨了日志层。每个日志层都有自己的信息跟踪方法。例如,LogDebug是用于跟踪Debug层信息的指定方法。

现在让我们看一个例子。我在Program.cs文件中创建了一个记录:

internal record CategoryFiltered();

此记录用于定义我想要仅在必要时跟踪的特定日志类别。为此,建议定义一个类或记录作为目的本身,并启用必要的跟踪级别。

Program.cs 文件中定义的记录没有命名空间;当我们定义包含所有必要信息的 appsettings 文件时,我们必须记住这一点。

如果日志类别在命名空间内,我们必须考虑类的全名。在这种情况下,它是 LoggingSamples.Categories.MyCategoryAlert

namespace LoggingSamples.Categories
{
    public class MyCategoryAlert
    {
    }
}

如果我们没有指定类别,如以下示例所示,则选定的日志级别是默认的:

  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "CategoryFiltered": "Information",
      "LoggingSamples.Categories.MyCategoryAlert": "Debug"
    }
  }

包括基础设施日志(如 Microsoft 日志)在内的任何内容都保留在特殊类别中,例如 Microsoft.AspNetCoreMicrosoft.EntityFrameworkCore

Microsoft 日志类别的完整列表可以在以下链接中找到:

docs.microsoft.com/aspnet/core/fundamentals/logging/#aspnet-core-and-ef-core-categories

有时,我们需要根据跟踪提供者定义某些日志级别。例如,在开发期间,我们希望在日志控制台中看到所有信息,但只想在日志文件中看到错误。

要做到这一点,我们不需要更改配置代码,只需为每个提供者定义其级别即可。以下是一个示例,展示了在 Microsoft 类别中跟踪的所有内容是如何从 Information 层级显示到其下层的:

{
  "Logging": {      // Default, all providers.
    "LogLevel": {
      "Microsoft": "Warning"
    },
    "Console": { // Console provider.
      "LogLevel": {
        "Microsoft": "Information"
      }
    }
  }
}

现在我们已经了解了如何启用日志记录以及如何过滤各种类别,剩下要做的就是将这些信息应用到最小 API 中。

在以下代码中,我们注入了两个不同类别的 ILogger 实例。这不是一个常见的做法,但我们这样做是为了使示例更加具体,并展示日志记录器的工作原理:

app.MapGet("/first-log", (ILogger<CategoryFiltered> loggerCategory, ILogger<MyCategoryAlert> loggerAlertCategory) =>
{
    loggerCategory.LogInformation("I'm information 
      {MyName}", "My Name Information");
    loggerAlertCategory.LogInformation("I'm information
      {MyName}", "Alert Information");
    return Results.Ok();
})
.WithName("GetFirstLog");

在前面的代码片段中,我们注入了两个具有不同类别的日志记录器实例;每个类别跟踪一条信息。信息是按照我们将简要描述的模板编写的。这个示例的效果是,基于级别,我们可以显示或禁用单个类别的信息显示,而无需更改代码。

我们开始根据级别和类别过滤日志。现在,我们想向您展示如何定义一个模板,它将允许我们定义消息,并在其某些部分使其动态化。

自定义日志消息

日志方法请求的消息字段是一个简单的字符串对象,我们可以通过日志框架以适当的结构丰富和序列化它。因此,消息对于识别故障和错误至关重要,在其中插入对象可以显著帮助我们识别问题:

string apples = "apples";
string pears = "pears";
string bananas = "bananas";
logger.LogInformation("My fruit box has: {pears}, {bananas}, {apples}", apples, pears, bananas);

消息模板包含占位符,可以将内容插入到文本消息中。

除了文本外,还需要传递参数以替换占位符。因此,参数的顺序是有效的,但不是替换占位符的名称。

结果将考虑位置参数,而不是占位符名称:

My fruit box has: apples, pears, bananas

现在您已经知道如何自定义日志消息。接下来,让我们了解基础设施日志记录,这在处理更复杂的场景时至关重要。

基础设施日志记录

在本节中,我们想向您介绍 ASP.NET 应用程序中一个鲜为人知且很少使用的主题:W3C 日志

此日志是一个所有网络服务器都使用的标准,不仅包括互联网信息服务IIS)。它也适用于 NGINX 和许多其他网络服务器,并且可以在 Linux 上使用。它还用于跟踪各种请求。然而,日志无法理解调用内部发生了什么。

因此,此功能侧重于基础设施,即调用次数以及调用哪些端点。

在本节中,我们将了解如何启用跟踪,默认情况下,跟踪数据存储在文件中。该功能需要一点时间来查找,但可以启用更复杂的场景,这些场景必须使用适当的实践和工具来管理,例如 OpenTelemetry

OpenTelemetry

OpenTelemetry 是一系列工具、API 和 SDK 的集合。我们使用它来对软件性能和行为进行仪表化、生成、收集和导出遥测数据(指标、日志和跟踪),以帮助分析软件性能和行为。您可以在 OpenTelemetry 官方网站上了解更多信息:opentelemetry.io/

要配置 W3C 日志记录,您需要注册 AddW3CLogging 方法并配置所有可用选项。

要启用日志记录,您只需添加 UseW3CLogging

日志的编写方式没有改变;这两种方法启用了前面描述的场景,并开始将数据写入 W3C 日志标准:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddW3CLogging(logging =>
{
    logging.LoggingFields = W3CLoggingFields.All;
});
var app = builder.Build();
app.UseW3CLogging();
app.MapGet("/first-w3c-log", (IWebHostEnvironment webHostEnvironment) =>
{
    return Results.Ok(new { PathToWrite = 
      webHostEnvironment.ContentRootPath });
})
.WithName("GetW3CLog");

我们报告创建的文件头(信息头将在稍后跟踪):

#Version: 1.0
#Start-Date: 2022-01-03 10:34:15
#Fields: date time c-ip cs-username s-computername s-ip s-port cs-method cs-uri-stem cs-uri-query sc-status time-taken cs-version cs-host cs(User-Agent) cs(Cookie) cs(Referer)

我们已经了解了如何跟踪关于托管我们应用程序的基础设施的信息;现在,我们希望使用 .NET 6 中的新功能来提高日志性能,这些功能可以帮助我们设置标准日志消息并避免错误。

源生成器

.NET 6 的一个新特性是源生成器;它们是性能优化工具,在编译时生成可执行代码。因此,在编译时生成可执行代码,从而提高了性能。在程序的执行阶段,所有结构都相当于编译前程序员编写的代码。

使用 $”” 进行字符串插值通常很好,它比 string.Format() 编写的代码可读性更高,但您几乎永远不应该在编写日志消息时使用它:

logger.LogInformation($"I'm {person.Name}-{person.Surname}")

此方法向控制台输出的结果在使用字符串插值或结构化日志记录时相同,但存在几个问题:

  • 你会失去结构化日志,并且无法通过格式值进行筛选,也无法在 NoSQL 产品的自定义字段中存档日志消息。

  • 同样,你不再有一个常量消息模板来查找所有相同的日志。

  • 在将字符串传递到LogInformation之前,会提前进行人员的序列化。

  • 即使日志过滤器未启用,序列化操作仍然会进行。为了避免处理日志,有必要检查层是否处于活动状态,这将使代码的可读性大大降低。

假设你决定更新日志消息以包含Age以明确为什么写入日志:

logger.LogInformation("I'm {Name}-{Surname} with {Age}", person.Name, person.Surname);

在前面的代码片段中,我在消息模板中添加了Age,但没有在方法签名中添加。在编译时没有编译错误,但执行此行时,由于缺少第三个参数,会抛出异常。

.NET 6 中的LoggerMessage`为我们提供了帮助,自动生成记录必要数据的代码。这些方法将需要正确数量的参数,文本将以标准方式格式化。

要使用LoggerMessage语法,你可以利用部分类或静态类。在类内部,将可以定义具有所有各种日志情况的方法或方法:

public partial class LogGenerator
    {
        private readonly ILogger<LogGeneratorCategory> 
          _logger;
        public LogGenerator(ILogger<LogGeneratorCategory>
          logger)
        {
            _logger = logger;
        }
        [LoggerMessage(
            EventId = 100,
            EventName = "Start",
            Level = LogLevel.Debug,
            Message = "Start Endpoint: {endpointName} with
              data {dataIn}")]
        public partial void StartEndpointSignal(string 
          endpointName, object dataIn);
        [LoggerMessage(
           EventId = 101,
           EventName = "StartFiltered",
           Message = "Log level filtered: {endpointName} 
             with data {dataIn}")]
        public partial void LogLevelFilteredAtRuntime(
          LogLevel, string endpointName, object dataIn);
    }
    public class LogGeneratorCategory { }

在前面的示例中,我们创建了一个部分类,注入了日志及其类别,并实现了两个方法。这些方法在以下代码中使用:

app.MapPost("/start-log", (PostData data, LogGenerator logGenerator) =>
{
    logGenerator.StartEndpointSignal("start-log", data);
    logGenerator.LogLevelFilteredAtRuntime(LogLevel.Trace,
      "start-log", data);
})
.WithName("StartLog");
internal record PostData(DateTime Date, string Name);

注意在第二个方法中,我们也有可能在运行时定义日志级别。

在幕后,[LoggerMessage]源生成器会生成LoggerMessage.Define()代码以优化你的方法调用。以下输出显示了生成的代码:

[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "6.0.5.2210")]
        public partial void LogLevelFilteredAtRuntime(
          global::Microsoft.Extensions.Logging.LogLevel 
          logLevel, global::System.String endpointName,
          global::System.Object dataIn)
        {
            if (_logger.IsEnabled(logLevel))
            {
                _logger.Log(
                    logLevel,
                    new global::Microsoft.Extensions.
                     Logging.EventId(101, "StartFiltered"),
                    new __LogLevelFilteredAtRuntimeStruct(
                      endpointName, dataIn),
                    null,
                      __LogLevelFilteredAtRuntimeStruct.
                          Format);
            }
        }

在本节中,你了解了一些日志提供程序、不同的日志级别、如何配置它们、要修改的消息模板部分、启用日志以及源生成器的优势。在下一节中,我们将更多地关注日志提供程序。

利用日志框架

如本章开头所述,日志框架已经设计了一系列不需要添加任何额外包的提供程序。现在,让我们探索如何使用这些提供程序以及如何构建自定义提供程序。我们将仅分析控制台日志提供程序,因为它具有复制到其他日志提供程序上的相同推理所需的所有足够元素。

控制台日志

Console日志提供程序是最常用的一个,因为在开发过程中,它为我们提供了大量信息,并收集了所有应用程序错误。

自.NET 6 以来,此提供程序已由AddJsonConsole提供程序加入,除了像控制台一样跟踪错误之外,它还将它们序列化为人类可读的 JSON 对象。

在以下示例中,我们展示了如何配置JsonConsole提供程序,并在写入 JSON 有效负载时添加缩进:

builder.Logging.AddJsonConsole(options =>
        options.JsonWriterOptions = new JsonWriterOptions()
        {
            Indented = true
        });

正如我们在之前的示例中所看到的,我们将使用消息模板来跟踪信息:

app.MapGet("/first-log", (ILogger<CategoryFiltered> loggerCategory, ILogger<MyCategoryAlert> loggerAlertCategory) =>
{
    loggerCategory.LogInformation("I'm information 
      {MyName}", "My Name Information");
    loggerCategory.LogDebug("I'm debug {MyName}",
      "My Name Debug");
    loggerCategory.LogInformation("I'm debug {Data}", 
      new PayloadData("CategoryRoot", "Debug"));
    loggerAlertCategory.LogInformation("I'm information 
      {MyName}", "Alert Information");
    loggerAlertCategory.LogDebug("I'm debug {MyName}",
      "Alert Debug");
    var p = new PayloadData("AlertCategory", "Debug");
    loggerAlertCategory.LogDebug("I'm debug {Data}", p);
    return Results.Ok();
})
.WithName("GetFirstLog");

最后,一个重要的注意事项:ConsoleJsonConsole提供程序不会序列化通过消息模板传递的对象,而只写入类名。

var p = new PayloadData("AlertCategory", "Debug");
loggerAlertCategory.LogDebug("I'm debug {Data}", p);

这确实是提供程序的一个限制。因此,我们建议使用结构化日志工具,如NLoglog4netSerilog,我们将在稍后讨论。

我们展示了之前几行中两个提供程序的输出:

图 5.1 – AddJsonConsole 输出

图 5.1 – AddJsonConsole 输出

图 5.1 展示了格式化为 JSON 的日志,与传统控制台日志相比,包含了一些额外的细节。

图 5.2 – 默认日志提供程序 Console 输出

图 5.2 – 默认日志提供程序 Console 输出

图 5.2 展示了默认日志提供程序Console的输出。

在默认提供程序的情况下,我们想向您展示如何创建一个符合您应用程序需求的自定义提供程序。

创建自定义提供程序

微软设计的日志框架可以轻松地进行定制。因此,让我们学习如何创建一个自定义提供程序

为什么要创建自定义提供程序?简单来说,是为了避免与日志库的依赖关系,并更好地管理应用程序的性能。最后,它还封装了特定场景的一些自定义逻辑,使代码更易于管理和阅读。

在以下示例中,我们将使用场景简化,以向您展示创建一个用于盈利的日志提供程序所需的最小组件。

提供程序的一个基本部分是能够配置其行为。让我们创建一个可以在应用程序启动时进行自定义或从appsettings中检索信息的类。

在我们的示例中,我们定义了一个固定的EventId来验证每日滚动文件逻辑以及文件的写入路径:

public class FileLoggerConfiguration
{
        public int EventId { get; set; }
        public string PathFolderName { get; set; } = 
          "logs";
        public bool IsRollingFile { get; set; }
}

我们正在编写的自定义提供程序将负责将日志信息写入文本文件。我们通过实现名为FileLogger的日志类,该类实现了ILogger接口,来实现这一点。

在类逻辑中,我们所做的只是实现日志方法并检查将信息放入哪个文件。

我们将目录验证放在下一个文件中,但更正确的方法是将所有控制逻辑放在这个方法中。我们还需要确保日志方法不会在应用程序级别抛出异常。日志记录器永远不应该影响应用程序的稳定性:

    public class FileLogger : ILogger
    {
        private readonly string name;
        private readonly Func<FileLoggerConfiguration> 
          getCurrentConfig;
        public FileLogger(string name,
          Func<FileLoggerConfiguration> getCurrentConfig)
        {
            this.name = name;
            this.getCurrentConfig = getCurrentConfig;
        }
        public IDisposable BeginScope<TState>(TState state)
          => default!;
        public bool IsEnabled(LogLevel logLevel) => true;
        public void Log<TState>(LogLevel logLevel, EventId
          , TState state, Exception? exception, 
          Func<TState, Exception?, string> formatter)
        {
            if (!IsEnabled(logLevel))
            {
                return;
            }
            var config = getCurrentConfig();
            if (config.EventId == 0 || config.EventId ==
                eventId.Id)
            {
                string line = $"{name} - {formatter(state,
                  exception)}";
                string fileName = config.IsRollingFile ? 
                  RollingFileName : FullFileName;
                string fullPath = Path.Combine(
                  config.PathFolderName, fileName);
                File.AppendAllLines(fullPath, new[] { line });
            }
        }
        private static string RollingFileName => 
          $"log-{DateTime.UtcNow:yyyy-MM-dd}.txt";
        private const string FullFileName = "logs.txt";
    }

现在,我们需要实现ILoggerProvider接口,该接口旨在创建一个或多个之前讨论过的日志类实例。

在本节课中,我们检查了上一段提到的目录,同时也检查了appsettings文件中的设置是否发生变化,这是通过IOptionsMonitor<T>实现的:

public class FileLoggerProvider : ILoggerProvider
{
    private readonly IDisposable onChangeToken;
    private FileLoggerConfiguration currentConfig;
    private readonly ConcurrentDictionary<string,
      FileLogger> _loggers = new();
    public FileLoggerProvider(
      IOptionsMonitor<FileLoggerConfiguration> config)
    {
        currentConfig = config.CurrentValue;
        CheckDirectory();
        onChangeToken = config.OnChange(updateConfig =>
        {
            currentConfig = updateConfig;
            CheckDirectory();
        });
    }
    public ILogger CreateLogger(string categoryName)
    {
        return _loggers.GetOrAdd(categoryName, name => new 
          FileLogger(name, () => currentConfig));
    }
    public void Dispose()
    {
        _loggers.Clear();
        onChangeToken.Dispose();
    }
    private void CheckDirectory()
    {
        if (!Directory.Exists(currentConfig.PathFolderName))
            Directory.CreateDirectory(currentConfig.
            PathFolderName);
    }
}

最后,为了简化在应用程序启动阶段的使用和配置,我们还定义了一个扩展方法来注册前面提到的各种类。

AddFile方法将注册ILoggerProvider并将其与其配置(作为一个例子非常简单,但它封装了配置和使用自定义提供者的几个方面)相关联:

public static class FileLoggerExtensions
    {
        public static ILoggingBuilder AddFile(
        this ILoggingBuilder builder)
        {
            builder.AddConfiguration();
           builder.Services.TryAddEnumerable(
             ServiceDescriptor.Singleton<ILoggerProvider,
             FileLoggerProvider>());
            LoggerProviderOptions.RegisterProviderOptions<
              FileLoggerConfiguration, FileLoggerProvider>
              (builder.Services);
            return builder;
        }
        public static ILoggingBuilder AddFile(
            this ILoggingBuilder builder,
            Action<FileLoggerConfiguration> configure)
        {
            builder.AddFile();
            builder.Services.Configure(configure);
            return builder;
        }
    }

我们使用AddFile扩展记录了Program.cs文件中看到的所有内容,如下所示:

builder.Logging.AddFile(configuration =>
{
    configuration.PathFolderName = Path.Combine(
      builder.Environment.ContentRootPath, "logs");
    configuration.IsRollingFile = true;
});

输出显示在图 5.3中,我们可以看到前五行中的 Microsoft 日志类别(这是经典的应用程序启动信息):

图 5.3 – 文件日志提供者输出

图 5.3 – 文件日志提供者输出

然后,调用前面章节中报告的最小 API 的处理程序。正如你所看到的,没有异常数据或传递给记录器的数据被序列化。

要添加此功能,还需要重写ILogger formatter并支持对象的序列化。这将为你提供一个有用的日志框架,适用于生产场景。

我们已经看到了如何配置日志以及如何自定义提供者对象以创建结构化日志发送到服务或存储。

在下一节中,我们想要描述 Azure Application Insights 服务,这对于日志记录和应用程序监控都非常有用。

Application Insights

除了已经看到的提供者之外,最常用的之一是Azure Application Insights。这个提供者允许你发送 Azure 服务中的每一个日志事件。为了将提供者插入到我们的项目中,我们只需要安装以下 NuGet 包:

<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.20.0" />

注册提供者非常简单。

我们首先注册 Application Insights 框架,AddApplicationInsightsTelemetry,然后在其AddApplicationInsights日志框架上注册其扩展。

在前面描述的 NuGet 包中,用于将组件记录到日志框架的那个包也作为参考存在:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplicationInsightsTelemetry();
builder.Logging.AddApplicationInsights();

要注册仪表化密钥,这是在 Azure 上注册服务后发放的密钥,你需要将此信息传递给注册方法。我们可以通过将信息放在appsettings.json文件中以以下格式来避免硬编码此信息:

"ApplicationInsights": {
    "InstrumentationKey": "your-key"
  },

此过程也在文档中有所描述(docs.microsoft.com/it-it/azure/azure-monitor/app/asp-net-core#enable-application-insights-server-side-telemetry-no-visual-studio)。

通过启动前面章节中讨论的方法,我们已经将所有信息连接到 Application Insights。

Application Insights 将日志分组在特定的跟踪之下。跟踪是对 API 的调用,因此在该调用中发生的所有事情在逻辑上都被分组在一起。这个功能利用了 WebServer 信息,特别是 W3C 标准为每个调用发布的 TraceParentId

这样,Application Insights 就可以绑定各种最小 API 之间的调用,无论是我们处于微服务应用程序中还是多个服务相互协作。

图 5.4 – 使用标准日志提供程序的 Application Insights

图 5.4 – 使用标准日志提供程序的 Application Insights

我们注意到,日志框架的默认格式化程序并没有序列化 PayloadData 对象,而只是写出了对象的文本。

在我们将投入生产的应用程序中,还需要跟踪对象的序列化。及时了解对象的状态对于分析在数据库中运行查询或读取从同一数据源读取的数据时发生的特定调用中的错误至关重要。

使用 Serilog 存储结构化日志

正如我们刚才讨论的,在日志中跟踪结构化对象极大地帮助我们理解错误。

因此,我们建议使用众多日志框架之一:Serilog

Serilog 是一个综合性的库,已经编写了许多 接收器,允许您存储日志数据并在以后搜索它。

Serilog 是一个日志库,允许您跟踪多个数据源上的信息。在 Serilog 中,这些源被称为接收器,它们允许您在日志中写入结构化数据,并应用传递给日志系统的数据的序列化。

让我们看看如何开始使用 Serilog 为最小 API 应用程序。让我们安装这些 NuGet 包。我们的目标将是跟踪我们迄今为止一直在使用的信息,具体是 ConsoleApplicationInsights

<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.20.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
<PackageReference Include="Serilog.Sinks.ApplicationInsights" Version="3.1.0" />

第一个包是应用程序中 ApplicationInsights SDK 所需的包。第二个包允许我们在 ASP.NET 管道中注册 Serilog 并能够利用 Serilog。第三个包允许我们在 appsettings 文件中配置框架,而无需重写应用程序来更改参数或代码。最后,我们有添加 ApplicationInsights 接收器的包。

appsettings 文件中,我们创建一个新的 Serilog 部分,在 Using 部分中注册各种接收器。我们注册了日志级别、接收器、丰富每个事件信息的丰富器以及如应用程序名称之类的属性:

"Serilog": {
    "Using": [ "Serilog.Sinks.Console",
      "Serilog.Sinks.ApplicationInsights" ],
    "MinimumLevel": "Verbose",
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "ApplicationInsights",
        "Args": {
          "restrictedToMinimumLevel": "Information",
          "telemetryConverter": "Serilog.Sinks.
           ApplicationInsights.Sinks.ApplicationInsights.
           TelemetryConverters.TraceTelemetryConverter, 
           Serilog.Sinks.ApplicationInsights"
        }
      }
    ],
    "Enrich": [ "FromLogContext"],   
    "Properties": {
      "Application": "MinimalApi.Packt"
    }
  }

现在,我们只需在 ASP.NET 管道中注册 Serilog

using Microsoft.ApplicationInsights.Extensibility;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddSerilog();
builder.Services.AddApplicationInsightsTelemetry();
var app = builder.Build();
Log.Logger = new LoggerConfiguration()
.WriteTo.ApplicationInsights(app.Services.GetRequiredService<TelemetryConfiguration>(), TelemetryConverter.Traces)
.CreateLogger();

使用builder.Logging.AddSerilog()语句,我们将 Serilog 注册到日志框架中,所有已记录的事件将通过常规的ILogger接口传递。由于框架需要注册TelemetryConfiguration类以注册ApplicationInsights,我们被迫将配置钩子连接到 Serilog 的静态Logger对象。这一切都是因为 Serilog 会将来自 Microsoft 日志框架的信息转换为 Serilog 框架,并添加所有必要的信息。

使用方法与之前类似,但这次我们在消息模板中添加了一个@(在)符号,这将告诉 Serilog 序列化发送的对象。

通过这个非常简单的{@Person}表述,我们将能够实现序列化对象并将其发送到ApplicationInsights服务的目标:

app.MapGet("/serilog", (ILogger<CategoryFiltered> loggerCategory) =>
{
    loggerCategory.LogInformation("I'm {@Person}", new
      Person("Andrea", "Tosato", new DateTime(1986, 11, 
      9)));
    return Results.Ok();
})
.WithName("GetFirstLog");
internal record Person(string Name, string Surname, DateTime Birthdate);

最后,我们必须在 Application Insights 服务中找到完整的数据,这些数据以 JSON 格式序列化。

图 5.5 – 带有结构化数据的 Application Insights

图 5.5 – 带有结构化数据的 Application Insights

摘要

在本章中,我们看到了最小 API 实现中的一些日志方面。

我们开始欣赏 ASP.NET 生成的日志框架,并了解了如何配置和自定义它。我们关注了如何定义消息模板以及如何避免使用源生成器时的错误。

我们看到了如何使用新的提供程序以 JSON 格式序列化日志并创建自定义提供程序。这些元素最终证明对于掌握日志工具并按您的喜好进行自定义非常重要。

不仅提到了应用程序日志,还提到了基础设施日志,这两者与 Application Insights 结合,成为监控应用程序的关键元素。最后,我们了解到有一些现成的工具,如 Serilog,通过 NuGet 安装的一些包,只需几步就能提供现成的功能。

在下一章中,我们将介绍验证 API 输入对象的机制。这是返回正确错误给调用并丢弃不准确请求或由垃圾邮件和攻击等非法活动推动的请求的基本功能,这些活动旨在对我们的服务器产生负载。

第六章:探索验证和映射

在本书的本章中,我们将讨论如何使用最少的 API 执行数据验证和映射,展示我们目前拥有的功能,缺少什么,以及最有趣的替代方案是什么。了解这些概念将帮助我们开发更健壮和可维护的应用程序。

在本章中,我们将涵盖以下主题:

  • 处理验证

  • 将数据映射到和从 API

技术要求

要遵循本章的描述,你需要创建一个 ASP.NET Core 6.0 Web API 应用程序。请参考第二章中的技术要求部分,探索最小 API 及其优势,以获取如何创建的说明。

如果你正在使用你的控制台、shell 或 bash 终端创建 API,请记住将你的工作目录更改为当前章节号(Chapter06)。

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter06

处理验证

Person对象定义了FirstNameLastName属性,电子邮件地址有效,或者预约日期不是过去的。

在基于控制器的项目中,我们可以执行这些检查,也称为放置在控制器上的ApiController属性,如果验证规则失败,将自动触发400 Bad Request响应。因此,在基于控制器的项目中,我们通常根本不需要执行显式的模型验证:如果验证失败,我们的端点将永远不会被调用。

注意

ApiController属性通过使用ModelStateInvalidFilter操作过滤器启用自动模型验证行为。

不幸的是,最小 API 没有提供内置的验证支持。IModelValidator接口及其所有相关对象都不能使用。因此,我们没有ModelState;如果存在验证错误,我们无法阻止端点的执行,必须显式返回400 Bad Request响应。

例如,让我们看看以下代码:

app.MapPost("/people", (Person person) =>
{
    return Results.NoContent();
});
public class Person
{
    [Required]
    [MaxLength(30)]
    public string FirstName { get; set; }
    [Required]
    [MaxLength(30)]
    public string LastName { get; set; }
    [EmailAddress]
    [StringLength(100, MinimumLength = 6)]
    public string Email { get; set; }
}

如我们所见,即使Person参数不遵守验证规则,端点也会被调用。只有一个例外:如果我们使用400 Bad Request响应。如第二章中所述,探索最小 API 及其优势,在.NET 6.0 项目中默认启用了可空引用类型。

如果我们想要接受null体(如果真的有需要的话),我们需要将参数声明为Person?。但是,只要存在体,端点总是会调用。

因此,在使用最小 API 时,在路由处理程序内部执行验证并在某些规则失败时返回适当的响应是必要的。我们可以实现一个与现有属性兼容的验证库,以便我们可以使用经典的数据注释方法进行验证,如下一节所述,或者使用第三方解决方案,如我们将在 集成 FluentValidation 节中看到的。

使用数据注释进行验证

如果我们想使用基于数据注释的通用验证模式,我们需要依赖于由 ValidationAttribute 基类提供的 IsValid 方法。

这种行为是对 ASP.NET Core 实际处理验证的简化的描述。然而,这是基于控制器项目的验证方式。

虽然我们也可以使用最小 API 手动实现此类解决方案,但如果我们决定使用数据注释进行验证,我们可以利用一个虽小但有趣的库,MiniValidation,它可在 GitHub (github.com/DamianEdwards/MiniValidation) 和 NuGet (www.nuget.org/packages/MiniValidation) 上找到。

重要提示

在撰写本文时,MiniValidation 作为预发布版本在 NuGet 上提供。

我们可以通过以下方式之一将此库添加到我们的项目中:

  • MiniValidation。请确保检查包含预发布版本选项,然后点击安装

  • 选项 2:如果您在 Visual Studio 2022 中,请打开包管理器控制台;或者打开您的控制台、shell 或 bash 终端,转到您的项目目录,并执行以下命令:

    dotnet add package MiniValidation --prerelease
    

现在,我们可以使用以下代码验证一个 Person 对象:

app.MapPost("/people", (Person person) =>
{
    var isValid = MiniValidator.TryValidate(person, 
      out var errors);
    if (!isValid)
    {
        return Results.ValidationProblem(errors);
    }
    return Results.NoContent();
});

如我们所见,MiniValidation 提供的 MiniValidator.TryValidate 静态方法接受一个对象作为输入,并自动验证其属性上定义的所有验证规则。如果验证失败,它将返回 false 并将所有发生的验证错误填充到 out 参数中。在这种情况下,因为返回适当的响应代码是我们的责任,所以我们使用 Results.ValidationProblem,它生成一个带有 ProblemDetails 对象的 400 Bad Request 响应(如第三章使用最小 API)并包含验证问题。

现在,作为一个例子,我们可以使用以下无效输入调用端点:

{
  "lastName": "MyLastName",
  "email": "email"
}

这是我们将获得的结果:

{
  "type": 
    "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "FirstName": [
      "The FirstName field is required."
    ],
    "Email": [
      "The Email field is not a valid e-mail address.",
      "The field Email must be a string with a minimum
       length of 6 and a maximum length of 100."
    ]
  }
}

这样,除了我们需要手动执行验证之外,我们还可以以与之前版本的 ASP.NET Core 中相同的方式,在我们的模型上实现使用数据注释的方法。我们还可以通过创建继承自 ValidationAttribute 的类来自定义错误消息和定义自定义规则。

注意

ASP.NET Core 6.0 中可用的完整验证属性列表发布在 docs.microsoft.com/dotnet/api/system.componentmodel.dataannotations。如果您对创建自定义属性感兴趣,可以参考 docs.microsoft.com/aspnet/core/mvc/models/validation#custom-attributes

虽然数据注解是最常用的解决方案,但我们也可以使用所谓的流畅方法来处理验证,这种方法的好处是完全解耦验证规则与模型,正如我们将在下一节中看到的。

集成 FluentValidation

在每个应用程序中,正确组织我们的代码都很重要。对于验证来说也是如此。虽然数据注解是一个可行的解决方案,但我们应该考虑其他可以帮助我们编写更易于维护的项目的方法。这就是 FluentValidation 的目的——这是一个库,它是 .NET 基金会 的一部分,允许我们使用 lambda 表达式通过流畅接口构建验证规则。该库可在 GitHub (github.com/FluentValidation/FluentValidation) 和 NuGet (www.nuget.org/packages/FluentValidation) 上找到。该库可用于任何类型的项目,但在使用 ASP.NET Core 时,有一个专门的 NuGet 包 (www.nuget.org/packages/FluentValidation.AspNetCore),其中包含有助于集成的有用方法。

注意

.NET 基金会是一个旨在支持围绕 .NET 平台的开源软件开发和协作的独立组织。您可以在 dotnetfoundation.org 上了解更多信息。

如前所述,使用此库,我们可以将验证规则从模型中解耦,以创建更结构化的应用程序。此外,FluentValidation 允许我们使用流畅语法定义更复杂的规则,而无需基于 ValidationAttribute 创建自定义类。该库还原生支持标准错误消息的本地化。

因此,让我们看看如何将 FluentValidation 集成到最小 API 项目中。首先,我们需要以下方式之一将此库添加到我们的项目中:

  • 点击 安装

  • 选项 2:如果您在 Visual Studio 2022 中,请打开 包管理器控制台;否则,打开您的控制台、shell 或 bash 终端,进入您的项目目录,并执行以下命令:

    dotnet add package FluentValidation.DependencyInjectionExtensions
    

现在,我们可以重写 Person 对象的验证规则,并将它们放入 PersonValidator 类中:

public class PersonValidator : AbstractValidator<Person>
{
    public PersonValidator() 
    {
        RuleFor(p =>
          p.FirstName).NotEmpty().MaximumLength(30);
        RuleFor(p => 
          p.LastName).NotEmpty().MaximumLength(30);
        RuleFor(p => p.Email).EmailAddress().Length(6,
          100);
    }
}

PersonValidator 继承自 AbstractValidator<T>,这是 FluentValidation 提供的一个基类,其中包含我们定义验证规则所需的所有方法。例如,我们 流畅地 声明我们有一个针对 FirstName 属性的规则,即它不能为空,并且它的最大长度可以是 30 个字符。

下一步是将验证器注册到服务提供程序中,以便我们可以在我们的路由处理器中使用它。我们可以通过一个简单的指令来完成此任务:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

AddValidatorsFromAssemblyContaining 方法会自动注册指定类型所在的程序集内所有从 AbstractValidator 派生的验证器。特别是,此方法注册了验证器,并通过 IValidator<T> 接口使它们可通过依赖注入访问,而 AbstractValidator<T> 类实现了该接口。如果我们有多个验证器,我们可以使用此单一指令将它们全部注册。我们还可以轻松地将我们的验证器放入外部程序集。

现在一切都已经就绪,记住,由于我们使用的是最小化 API,我们没有自动模型验证,因此我们必须以这种方式更新我们的路由处理器:

app.MapPost("/people", async (Person person, IValidator<Person> validator) =>
{
    var validationResult = 
      await validator.ValidateAsync(person);
    if (!validationResult.IsValid)
    {
        var errors = validationResult.ToDictionary();
        return Results.ValidationProblem(errors);
    }
    return Results.NoContent();
});

我们在路由处理器参数列表中添加了一个 IValidator<Person> 参数,因此现在我们可以调用它的 ValidateAsync 方法来对输入的 Person 对象应用验证规则。如果验证失败,我们将提取所有错误消息,并使用通常的 Results.ValidationProblem 方法将它们返回给客户端,如前文所述。

总结来说,让我们看看如果我们尝试使用之前相同的输入调用端点会发生什么:

{
  "lastName": "MyLastName",
  "email": "email"
}

我们将得到以下响应:

{
  "type": 
    "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "FirstName": [
      "'First Name' non può essere vuoto."
    ],
    "Email": [
      "'Email' non è un indirizzo email valido.",
      "'Email' deve essere lungo tra i 6 e 100 caratteri.
        Hai inserito 5 caratteri."
    ]
  }
}

如前所述,FluentValidation 为标准错误消息提供翻译,因此这是在意大利系统上运行时得到的响应。当然,我们可以使用典型的流畅方法完全自定义消息,使用 WithMessage 方法将消息链式连接到验证器中定义的验证方法。例如,请参见以下内容:

RuleFor(p => p.FirstName).NotEmpty().WithMessage("You must provide the first name");

我们将在 第九章 利用全球化和本地化 中更详细地讨论本地化问题。

这只是一个快速示例,说明如何使用 FluentValidation 定义验证规则,并使用最小化 API 来使用它们。这个库允许许多更复杂的场景,这些场景在官方文档中有详细描述,官方文档可在 fluentvalidation.net 找到。

现在我们已经了解了如何向我们的路由处理器添加验证,了解我们如何使用这些信息更新由 Swagger 创建的文档是很重要的。

在 Swagger 中添加验证信息

无论选择哪种解决方案来处理验证,重要的是要更新 OpenAPI 定义,以表明处理程序可以生成验证问题响应,在端点声明后调用ProducesValidationProblem方法:

app.MapPost("/people", (Person person) =>
{
    //...
})
.Produces(StatusCodes.Status204NoContent)
.ProducesValidationProblem();

这样,将为400 Bad Request状态码添加一个新的响应类型到 Swagger,正如我们在图 6.1中可以看到的那样:

图 6.1 – 添加到 Swagger 中的验证问题响应

图 6.1 – 添加到 Swagger 中的验证问题响应

此外,Swagger UI 底部显示的JSON 模式可以显示相应模型的规则。使用数据注释定义验证规则的一个好处是它们会自动反映在这些模式中:

图 6.2 – Swagger 中 Person 对象的验证规则

图 6.2 – Swagger 中 Person 对象的验证规则

不幸的是,使用FluentValidation定义的验证规则在 Swagger 的 JSON 模式中不会自动显示。我们可以通过使用MicroElements.Swashbuckle.FluentValidation这个小型库来克服这一限制,这个库通常可以在 GitHub(github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation)和 NuGet(www.nuget.org/packages/MicroElements.Swashbuckle.FluentValidation)上找到。将其添加到我们的项目中后,按照之前为其他 NuGet 包所描述的相同步骤,我们只需要调用AddFluentValidationRulesToSwagger扩展方法:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddFluentValidationRulesToSwagger();

这样,Swagger 中显示的 JSON 模式将反映验证规则,就像数据注释一样。然而,值得注意的是,在撰写本文时,这个库并不支持FluentValidation中所有可用的验证器。更多信息,我们可以参考库的 GitHub 页面。

这就结束了我们对最小 API 中验证的概述。在下一节中,我们将分析每个 API 的一个重要主题:如何正确处理数据到和从我们的服务映射。

数据到和从 API 映射

当处理任何系统都可以调用的 API 时,有一条黄金法则:我们绝不应该向调用者暴露我们的内部对象。如果我们不遵循这种解耦思想,并且出于某种原因需要更改我们的内部数据结构,我们可能会破坏所有与我们交互的客户端。内部数据结构和用于与客户端对话的对象必须能够独立于彼此进化。

这种对话需求是映射之所以如此重要的原因。我们需要将一种类型的输入对象转换为另一种类型的输出对象,反之亦然。这样,我们可以实现两个目标:

  • 在不引入对调用者暴露的合约的破坏性更改的情况下,改进我们的内部数据结构

  • 修改用于与客户端通信的对象的格式,而无需更改这些对象内部处理的方式

换句话说,映射意味着通过复制和转换对象属性从源到目的地来将一个对象转换为另一个对象。然而,映射代码很无聊,测试映射代码更是无聊。尽管如此,我们需要完全理解这个过程的重要性,并努力在所有场景中采用它。

因此,让我们考虑以下对象,它可能代表使用 Entity Framework Core 保存到数据库中的人员:

public class PersonEntity
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime BirthDate { get; set; }
    public string City { get; set; }
}

我们已经设置了获取人员列表或检索特定人员的端点。

第一个想法可能是直接将PersonEntity返回给调用者。以下代码高度简化,足以让我们理解这个场景:

app.MapGet("/people/{id:int}", (int id) =>
{
    // In a real application, this entity could be
    // retrieved from a database, checking if the person
    // with the given ID exists.
    var person = new PersonEntity();
    return Results.Ok(person);
})
.Produces(StatusCodes.Status200OK, typeof(PersonEntity));

如果我们需要修改数据库的架构,例如添加实体的创建日期,会发生什么?在这种情况下,我们需要将PersonEntity更改为具有映射相关日期的新属性。然而,调用者现在也获得了这些信息,而我们可能不想公开这些信息。相反,如果我们使用所谓的数据转换对象(DTO)来公开人员,这个问题将变得多余:

public class PersonDto
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime BirthDate { get; set; }
    public string City { get; set; }
}

这意味着我们的 API 应该返回PersonDto类型的对象而不是PersonEntity,在两个对象之间执行转换。乍一看,这个练习似乎是无用的代码重复,因为这两个类包含相同的属性。然而,如果我们考虑到PersonEntity可能会随着数据库所需的新属性而演变,或者改变结构以适应调用者不应了解的新语义,映射的重要性就变得明显了。例如,将城市存储在单独的表中并通过Address属性公开。或者假设出于安全原因,我们不再想公开确切的出生日期,而只公开人的年龄。使用专门的 DTO,我们可以轻松地更改架构并更新映射,而无需触及我们的实体,从而实现更好的关注点分离。

当然,映射可以是双向的。在我们的例子中,我们需要在将数据返回给客户端之前将PersonEntity转换为PersonDto。然而,我们也可以做相反的操作——即将来自客户端的PersonDto类型转换为PersonEntity以将其保存到数据库中。我们讨论的所有解决方案都适用于这两种情况。

我们可以选择手动执行映射,或者采用提供此功能的第三方库。在接下来的章节中,我们将分析这两种方法,了解现有解决方案的优缺点。

执行手动映射

在上一节中,我们提到映射本质上意味着将源对象的属性复制到目标对象的属性中,并应用某种类型的转换。执行此任务最简单、最有效的方法是手动进行。

采用这种方法,我们需要自己处理所有的映射代码。从这个角度来看,没有太多可说的;我们需要一个方法,它接受一个对象作为输入并将其转换为输出,同时记得如果类包含一个必须依次映射的复杂属性,则应用递归映射。唯一的建议是使用扩展方法,这样我们就可以在需要的地方轻松调用它。

此映射过程的完整示例可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter06

此解决方案保证了最佳性能,因为我们明确编写了所有映射指令,而不依赖于自动系统(如反射)。然而,手动方法有一个缺点:每次我们在实体中添加一个必须映射到 DTO 的属性时,都需要更改映射代码。另一方面,一些方法可以简化映射,但会以性能开销为代价。在下一节中,我们将探讨一种使用AutoMapper的方法。

使用 AutoMapper 进行映射

AutoMapper可能是.NET 中最著名的映射框架之一。它使用基于约定的匹配算法的流畅配置 API,将源值匹配到目标值。与FluentValidation一样,该框架是.NET 基金会的组成部分,可在 GitHub (github.com/AutoMapper/AutoMapper) 或 NuGet (www.nuget.org/packages/AutoMapper) 上获得。同样,在这种情况下,我们有一个特定的 NuGet 包,www.nuget.org/packages/AutoMapper.Extensions.Microsoft.DependencyInjection,它简化了其集成到 ASP.NET Core 项目中的过程。

让我们快速看一下如何在最小 API 项目中集成AutoMapper,展示其主要功能。该库的完整文档可在docs.automapper.org找到。

如同往常,首先要做的是将库添加到我们的项目中,遵循我们在前几节中使用的相同指令。然后,我们需要配置 AutoMapper,告诉它如何执行映射。有几种方法可以完成这项任务,但推荐的方法是创建继承自库提供的 Profile 基类的新类,并将配置放入构造函数中:

public class PersonProfile : Profile
{
    public PersonProfile()
    {
        CreateMap<PersonEntity, PersonDto>();
    }
}

这就是我们开始所需的所有内容:一条指令,表示我们想要将 PersonEntity 映射到 PersonDto,而不需要其他任何细节。我们说过 AutoMapper 是基于约定的。这意味着默认情况下,它会将源和目标中具有相同名称的属性进行映射,并在必要时执行自动类型转换。例如,源上的 int 属性可以自动映射到目标上具有相同名称的 double 属性。换句话说,如果源和目标对象具有相同的属性,则不需要任何显式的映射指令。然而,在我们的情况下,我们需要执行一些转换,因此我们可以在 CreateMap 之后流畅地添加它们:

public class PersonProfile : Profile
{
    public PersonProfile()
    {
        CreateMap<PersonEntity, PersonDto>()
            .ForMember(dst => dst.Age, opt =>
           opt.MapFrom(src => CalculateAge(src.BirthDate)))
            .ForMember(dst => dst.City, opt => 
              opt.MapFrom(src => src.Address.City));
    }
    private static int CalculateAge(DateTime dateOfBirth)
    {
        var today = DateTime.Today;
        var age = today.Year - dateOfBirth.Year;
        if (today.DayOfYear < dateOfBirth.DayOfYear)
        {
            age--;
        }
        return age;
    }
}

使用 ForMember 方法,我们可以指定如何使用转换表达式映射目标属性,例如 dst.Agedst.City。我们仍然不需要显式映射 IdFirstNameLastName 属性,因为它们在源和目标中都存在这些名称。

现在我们已经定义了映射配置文件,我们需要在启动时注册它,以便 ASP.NET Core 可以使用它。与 FluentValidation 类似,我们可以在 IServiceCollection 上调用扩展方法:

builder.Services.AddAutoMapper(typeof(Program).Assembly);

使用这一行代码,我们自动注册了指定程序集中包含的所有配置文件。如果我们向项目中添加更多配置文件,例如为每个要映射的实体创建一个单独的 Profile 类,我们不需要更改注册指令。

以这种方式,我们现在可以通过依赖注入使用 IMapper 接口:

app.MapGet("/people/{id:int}", (int id, IMapper mapper) =>
{
    var personEntity = new PersonEntity();
    //...
    var personDto = mapper.Map<PersonDto>(personEntity);
    return Results.Ok(personDto);
})
.Produces(StatusCodes.Status200OK, typeof(PersonDto));

例如,在从数据库中检索 PersonEntity 后,我们可以调用 IMapper 接口上的 Map 方法,指定结果对象的类型和输入类。使用这一行代码,AutoMapper 将使用相应的配置文件将 PersonEntity 转换为 PersonDto 实例。

采用这种解决方案后,映射现在更容易维护,因为只要我们在源和目标上添加具有相同名称的属性,我们就不需要更改配置文件。此外,AutoMapper 还支持列表映射和递归映射。因此,如果我们有一个必须映射的实体,例如 PersonEntity 类上的 AddressEntity 类型的属性,并且相应的配置文件可用,转换将再次自动执行。

这种方法的缺点是性能开销。AutoMapper通过在运行时动态执行映射代码来工作,因此它在底层使用反射。配置文件在第一次使用时创建,然后被缓存以加快后续映射的速度。然而,配置文件始终是动态应用的,因此操作成本取决于映射代码本身的复杂性。我们只看到了AutoMapper的一个基本示例。这个库非常强大,可以管理相当复杂的映射。然而,我们需要小心不要滥用它——否则,我们可能会对我们的应用程序的性能产生负面影响。

摘要

验证和数据映射是在开发 API 时需要考虑的两个重要特性,以构建更健壮和可维护的应用程序。最小化 API 不提供任何内置的方式来执行这些任务,因此了解我们如何添加对这类特性的支持是很重要的。我们已经看到,我们可以使用数据注释或FluentValidation来执行验证,以及如何将验证信息添加到 Swagger 中。我们还讨论了数据映射的重要性,并展示了如何利用手动映射或AutoMapper库,描述了每种方法的优缺点。

在下一章中,我们将讨论如何将最小化 API 与数据访问层集成,例如展示如何使用 Entity Framework Core 访问数据库。

第七章:与数据访问层的集成

在本章中,我们将学习一些基本方法,将这些方法添加到.NET 6.0 中最小 API 的数据访问层。我们将看到如何使用本书中之前覆盖的一些主题,使用Entity FrameworkEF)和 Dapper 来访问数据。这是访问数据库的两种方式。

在本章中,我们将涵盖以下主题:

  • 使用 Entity Framework

  • 使用 Dapper

到本章结束时,你将能够在最小 API 项目中从头开始使用 EF,并使用 Dapper 达到相同的目的。你还将能够判断在项目中哪种方法比另一种方法更好。

技术要求

要跟随本章内容,你需要创建一个 ASP.NET Core 6.0 Web API 应用程序。你可以选择以下任一选项:

  • 在 Visual Studio 2022 的文件菜单中点击新建项目选项,然后选择ASP.NET Core Web API模板,在向导中选择名称和工作目录,并确保在下一步中取消选中使用控制器选项。

  • 打开你的控制台、shell 或 Bash 终端,切换到你的工作目录。使用以下命令创建一个新的 Web API 应用程序:

    dotnet new webapi -minimal -o Chapter07
    

现在,通过双击项目文件或在 Visual Studio Code 中输入以下命令打开项目:

cd Chapter07
code.

最后,你可以安全地删除与WeatherForecast示例相关的所有代码,因为本章我们不需要它。

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter07

使用 Entity Framework

我们可以绝对地说,如果我们正在构建一个 API,我们很可能需要与数据交互。

此外,这些数据很可能需要在应用程序重启或其他事件(如应用程序的新部署)后持久化。在.NET 应用程序中持久化数据有许多选项,但 EF 对于许多场景来说是最用户友好的和最常用的解决方案。

Entity Framework CoreEF Core)是一个可扩展的、开源的、跨平台的数据访问库,用于.NET 应用程序。它使开发者能够通过直接使用.NET 对象与数据库交互,并在大多数情况下消除了直接在数据库中编写数据访问代码的需要。

此外,EF Core 支持许多数据库,包括 SQLite、MySQL、Oracle、Microsoft SQL Server 和 PostgreSQL。

此外,它支持内存数据库,这有助于编写我们应用程序的测试或使开发周期更容易,因为你不需要一个真实数据库运行起来。

在下一节中,我们将看到如何设置使用 EF 的项目及其主要功能。

设置项目

从项目根目录创建一个 Icecream.cs 类,并给它以下内容:

namespace Chapter07.Models;
public class Icecream
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public string? Description { get; set; }
}

Icecream 类是我们项目中代表冰淇淋的对象。这个类应该被称为数据模型,我们将在本章的下一部分中使用这个对象将其映射到数据库表。

现在是时候将 EF Core NuGet 引用到项目中了。

为了做到这一点,你可以使用以下方法之一:

  • 在一个新的终端窗口中,输入以下代码以添加 EF Core InMemory 包:

    dotnet add package Microsoft.EntityFrameworkCore.InMemory
    
  • 如果你想要使用 Visual Studio 2022 来添加引用,右键单击 Microsoft.EntityFrameworkCore.InMemory 并安装包。

在下一节中,我们将向项目中添加 EF Core。

向项目中添加 EF Core

为了将冰淇淋对象存储在数据库中,我们需要在我们的项目中设置 EF Core。

为了设置内存数据库,将以下代码添加到 Program.cs 文件底部:

class IcecreamDb : DbContext
{
    public IcecreamDb(DbContextOptions options) :
      base(options) { }
    public DbSet<Icecream> Icecreams { get; set; } = null!;
}

DbContext 对象代表与数据库的连接,并用于在数据库中保存和查询实体的实例。

DbSet 代表实体的实例,它们将被转换为数据库中的实际表。

在这种情况下,我们将只有一个名为 Icecreams 的表。

Program.cs 中,在初始化构建器之后,添加以下代码:

builder.Services.AddDbContext<IcecreamDb>(options => options.UseInMemoryDatabase("icecreams"));

现在我们已经准备好添加一些 API 端点以开始与数据库交互。

向项目中添加端点

让我们在 Program.cs 中添加创建 icecreams 列表中新项目的代码。在 app.Run() 代码行之前添加以下代码:

app.MapPost("/icecreams", async (IcecreamDb db, Icecream icecream) =>
{
    await db.Icecreams.AddAsync(icecream);
    await db.SaveChangesAsync();
    return Results.Created($"/icecreams/{icecream.Id}",
                           icecream);
});

MapPost 函数的第一个参数是 DbContext。默认情况下,最小 API 架构使用依赖注入来共享 DbContext 的实例。

依赖注入

如果你想要了解更多关于依赖注入的信息,请参阅*第四章**,最小 API 项目中的依赖注入。

为了将项目保存到数据库中,我们直接从表示对象的实体中使用 AddSync 方法。

为了将新项目持久化到数据库中,我们需要调用 SaveChangesAsync() 方法,该方法负责在最后一次调用 SaveChangesAsync() 之前保存数据库中发生的所有更改。

以非常相似的方式,我们可以添加端点以检索 icecreams 数据库中的所有项目。

在添加冰淇淋的代码之后,我们可以添加以下代码:

app.MapGet("/icecreams", async (IcecreamDb db) => await db.Icecreams.ToListAsync());

此外,在这种情况下,DbContext 作为参数可用,我们可以直接从 DbContext 中的实体检索数据库中的所有项目。

使用 ToListAsync() 方法,应用程序将加载数据库中的所有实体并将它们作为端点结果发送回去。

确保你已经保存了项目中的所有更改并运行了应用程序。

将打开一个新的浏览器窗口,你可以导航到 /swagger URL:

图 7.1 – Swagger 浏览器窗口

图 7.1 – Swagger 浏览器窗口

选择POST/icecreams按钮,然后点击尝试操作

将请求体内容替换为以下 JSON:

{
  "id": 0,
  "name": "icecream 1",
  "description": "description 1"
}

点击执行

图 7.2 – Swagger 响应

图 7.2 – Swagger 响应

现在我们数据库中至少有一个条目,我们可以尝试其他端点来检索数据库中的所有条目。

将页面向下滚动一点,然后选择GET/icecreams,接着点击尝试操作,然后执行

你将在响应体下看到包含一个条目的列表。

让我们看看如何通过向我们的端点添加其他 CRUD 操作来最终完成这个第一个演示。

  1. 要通过 ID 获取条目,请将以下代码添加到您之前创建的app.MapGet路由下:

    app.MapGet("/icecreams/{id}", async (IcecreamDb db, int id) => await db.Icecreams.FindAsync(id));
    

要查看这一点,你可以再次启动应用程序,并像以前一样使用 Swagger UI。

  1. 接下来,通过执行一个 post 调用(如前节所述)在数据库中添加一个条目。

  2. 点击GET/icecreams/{id},然后点击尝试操作

  3. id参数字段中插入值1,然后点击执行

  4. 你将在响应体部分看到条目。

  5. 以下是从 API 返回的示例响应:

    {
      "id": 1,
      "name": "icecream 1",
      "description": "description 1"
    }
    

这就是响应的样子:

图 7.3 – 响应结果

图 7.3 – 响应结果

要通过 ID 更新条目,我们可以创建一个新的MapPut端点,包含两个参数:具有实体值的条目和数据库中要更新的旧实体的 ID。

代码应该像以下片段一样:

app.MapPut("/icecreams/{id}", async (IcecreamDb db, Icecream updateicecream, int id) =>
{
    var icecream = await db.Icecreams.FindAsync(id);
    if (icecream is null) return Results.NotFound();
    icecream.Name = updateicecream.Name;
    icecream.Description = updateicecream.Description;
    await db.SaveChangesAsync();
    return Results.NoContent();
});

为了明确起见,首先,我们需要使用参数中的 ID 在数据库中找到条目。如果我们没有在数据库中找到条目,向调用者返回Not Found HTTP 状态是一个好的做法。

如果我们在数据库中找到实体,我们将使用新值更新实体,并在发送回 HTTP 状态No Content之前将所有更改保存到数据库中。

我们最后需要执行的 CRUD 操作是从数据库中删除条目。

这个操作与更新操作非常相似,因为首先我们需要在数据库中找到该条目,然后我们才能尝试执行删除操作。

以下代码片段展示了如何使用最小 API 的正确 HTTP 动词实现删除操作:

app.MapDelete("/icecreams/{id}", async (IcecreamDb db, int id) =>
{
    var icecream = await db.Icecreams.FindAsync(id);
    if (icecream is null)
    {
        return Results.NotFound();
    }
    db.Icecreams.Remove(icecream);
    await db.SaveChangesAsync();
    return Results.Ok();
});

在本节中,我们学习了如何在最小 API 项目中使用 EF。

我们看到了如何添加 NuGet 包以开始使用 EF,以及如何在最小 API .NET 6 项目中实现整个 CRUD 操作集。

在下一节中,我们将看到如何使用 Dapper 作为主要库来访问数据,以实现具有相同逻辑的相同项目。

使用 Dapper

Dapper 是一个IDbConnection对象,并提供了许多查询数据库的方法。这意味着我们必须编写与数据库提供程序兼容的查询。

它支持同步和异步方法执行。以下是 Dapper 添加到IDbConnection接口的方法列表:

  • Execute

  • Query

  • QueryFirst

  • QueryFirstOrDefault

  • QuerySingle

  • QuerySingleOrDefault

  • QueryMultiple

正如我们提到的,它为所有这些方法提供了一个异步版本。你可以在方法名末尾添加Async关键字来找到正确的方法。

在下一节中,我们将看到如何设置一个项目以使用 Dapper 和 SQL Server LocalDB。

设置项目

我们将要做的第一件事是创建一个新的数据库。你可以使用默认安装的 Visual Studio SQL Server LocalDB 实例或你环境中另一个 SQL Server 实例。

你可以在你的数据库中执行以下脚本以创建一个表并填充数据:

CREATE TABLE [dbo].Icecreams NOT NULL,
     [Name] nvarchar NOT NULL,
     [Description] nvarchar NOT NULL)
GO
INSERT [dbo].[Icecreams] ([Name], [Description]) VALUES ('Icecream 1','Description 1')
INSERT [dbo].[Icecreams] ([Name], [Description]) VALUES ('Icecream 2','Description 2')
INSERT [dbo].[Icecreams] ([Name], [Description]) VALUES ('Icecream 3','Description 3')

一旦我们有了数据库,我们就可以使用以下 Visual Studio 终端中的命令安装这些 NuGet 包:

Install-Package Dapper
Install-Package Microsoft.Data.SqlClient

现在,我们可以继续添加与数据库交互的代码。在这个例子中,我们将使用存储库模式。

创建存储库模式

在本节中,我们将创建一个简单的存储库模式,但我们会尽量让它尽可能简单,这样我们就可以理解 Dapper 的主要功能:

  1. Program.cs文件中,添加一个简单的类来表示数据库中的实体:

    public class Icecream
    {
        public int Id { get; set; }
        public string? Name { get; set; }
        public string? Description { get; set; }
    }
    
  2. 然后,通过在文件末尾添加连接字符串来修改appsettings.json文件:

    "ConnectionStrings": {
        "SqlConnection": 
          "Data Source=(localdb)\\MSSQLLocalDB;
           Initial Catalog=Chapter07;
           Integrated Security=True;
           Connect Timeout=30;
           Encrypt=False;
           TrustServerCertificate=False;"
    }
    

如果你使用 LocalDB,连接字符串应该适合你的环境。

  1. 在项目的根目录中创建一个名为DapperContext的新类,并给它以下代码:

    public class DapperContext
    {
        private readonly IConfiguration _configuration;
        private readonly string _connectionString;
        public DapperContext(IConfiguration configuration)
        {
            _configuration = configuration;
            _connectionString = _configuration
              .GetConnectionString("SqlConnection");
        }
        public IDbConnection CreateConnection()
            => new SqlConnection(_connectionString);
    }
    

我们通过依赖注入注入了IConfiguration接口来从设置文件中检索连接字符串。

  1. 现在,我们将创建我们的存储库的接口和实现。为了做到这一点,请将以下代码添加到Program.cs文件中。

    public interface IIcecreamsRepository
    {
    }
    public class IcecreamsRepository : IIcecreamsRepository
    {
        private readonly DapperContext _context;
        public IcecreamsRepository(DapperContext context)
        {
            _context = context;
        }
    }
    

在下一节中,我们将向接口及其存储库的实现中添加一些代码。

最后,我们可以将上下文、接口及其实现注册为服务。

  1. 让我们在Program.cs文件中初始化构建器之后放置以下代码:

    builder.Services.AddSingleton<DapperContext>();
    builder.Services.AddScoped<IIcecreamsRepository, IcecreamsRepository>();
    

现在,我们准备实现第一个查询。

使用 Dapper 查询数据库

首先,让我们通过添加一个新方法来修改IIcecreamsRepository接口:

public Task<IEnumerable<Icecream>> GetIcecreams();

然后,让我们在IcecreamsRepository类中实现这个方法:

public async Task<IEnumerable<Icecream>> GetIcecreams()
{
    var query = "SELECT * FROM Icecreams";
    using (var connection = _context.CreateConnection())
    {
        var result = 
          await connection.QueryAsync<Icecream>(query);
        return result.ToList();
    }
}

让我们尝试理解这个方法中的所有步骤。我们创建了一个名为query的字符串,其中存储了从数据库获取所有实体的 SQL 查询。

然后,在using语句内部,我们使用了DapperContext来创建连接。

一旦创建了连接,我们就用它来调用QueryAsync方法,并将查询作为参数传递。

当结果从数据库返回时,Dapper 会自动将它们转换为IEnumerable<T>

以下是我们接口和第一次实现的最终代码:

public interface IIcecreamsRepository
{
    public Task<IEnumerable<Icecream>> GetIcecreams();
}
public class IcecreamsRepository : IIcecreamsRepository
{
    private readonly DapperContext _context;
    public IcecreamsRepository(DapperContext context)
    {
        _context = context;
    }
    public async Task<IEnumerable<Icecream>> GetIcecreams()
    {
        var query = "SELECT * FROM Icecreams";
        using (var connection =
              _context.CreateConnection())
        {
            var result = 
              await connection.QueryAsync<Icecream>(query);
            return result.ToList();
        }
    }
}

在下一节中,我们将看到如何向数据库添加新实体以及如何使用 ExecuteAsync 方法来运行查询。

使用 Dapper 在数据库中添加新实体

现在我们将管理添加新实体到数据库,以供 API 带有未来实现请求。

让我们通过添加一个名为 CreateIcecream 的新方法来修改接口,该方法接受 Icecream 类型的输入参数:

public Task CreateIcecream(Icecream icecream);

现在我们必须在仓储类中实现此方法:

public async Task CreateIcecream(Icecream icecream)
{
    var query = "INSERT INTO Icecreams (Name, Description)
      VALUES (@Name, @Description)";
    var parameters = new DynamicParameters();
    parameters.Add("Name", icecream.Name, DbType.String);
    parameters.Add("Description", icecream.Description,
                    DbType.String);
    using (var connection = _context.CreateConnection())
    {
        await connection.ExecuteAsync(query, parameters);
    }
}

在这里,我们创建查询和一个动态参数对象,将所有值传递到数据库。

我们在方法参数中用 Icecream 对象的值填充参数。

我们使用 Dapper 上下文创建连接,然后使用 ExecuteAsync 方法执行 INSERT 语句。

此方法返回一个整数值作为结果,表示数据库中受影响的行数。在这种情况下,我们不使用此信息,但如果你需要,你可以将此值作为方法的结果返回。

在端点中实现仓储

为了给我们的最小 API 添加最后的润色,我们需要实现两个端点来管理仓储模式中的所有方法:

app.MapPost("/icecreams", async (IIcecreamsRepository repository, Icecream icecream) =>
{
    await repository.CreateIcecream(icecream);
    return Results.Ok();
});
app.MapGet("/icecreams", async (IIcecreamsRepository repository) => await repository.GetIcecreams());

在这两个映射方法中,我们传递仓储作为参数,因为在最小 API 中,服务通常作为参数传递给映射方法。

这意味着仓储始终在代码的所有部分中可用。

MapGet 端点中,我们使用仓储从仓储的实现中加载所有实体,并将结果用作端点的结果。

MapPost 端点中,除了仓储参数外,我们还接受来自请求体的 Icecream 实体,并使用相同的实体作为仓储中 CreateIcecream 方法的参数。

摘要

在本章中,我们学习了如何在最小 API 项目中使用最常用的工具与数据访问层交互:EF 和 Dapper。

对于 EF,我们介绍了一些基本功能,例如设置项目以使用此 ORM 以及如何执行一些基本操作以实现完整的 CRUD API 端点。

我们也用 Dapper 做了基本上相同的事情,从一个空项目开始,添加 Dapper,设置项目以与 SQL Server LocalDB 一起工作,并实现与数据库实体的一些基本交互。

在下一章中,我们将关注最小 API 项目中的身份验证和授权。首先,保护数据库中的数据非常重要。

第三部分:高级开发与微服务概念

在本书的高级部分,我们希望展示更多在后端开发中典型的场景。我们还将讨论这个新框架的性能,并了解它在哪些场景中非常有用。

在本节中,我们将涵盖以下章节:

  • 第八章, 添加身份验证和授权

  • 第九章, 利用全球化和本地化

  • 第十章, 评估和基准测试最小 API 的性能

第八章:添加身份验证和授权

任何类型的应用程序都必须处理身份验证授权。通常,这些术语被交替使用,但实际上它们指的是不同的场景。在本章中,我们将解释身份验证和授权之间的区别,并展示如何将这些功能添加到最小化 API 项目中。

身份验证可以通过多种方式执行:使用本地账户和外部登录提供者,如 Microsoft、Google、Facebook 和 Twitter;使用 Azure Active Directory 和 Azure B2C;以及使用身份验证服务器,如 Identity Server 和 Okta。此外,我们可能还需要处理如双因素身份验证和刷新令牌等要求。然而,在本章中,我们将关注身份验证和授权的一般方面,并了解如何在最小化 API 项目中实现它们,以便提供一个对该主题的总体理解。提供的信息和示例将展示如何有效地处理身份验证和授权,以及如何根据我们的需求自定义它们的行为。

本章将涵盖以下主题:

  • 介绍身份验证和授权

  • 保护最小化 API

  • 处理授权 - 角色和政策

技术要求

要遵循本章的示例,您需要创建一个 ASP.NET Core 6.0 Web API 应用程序。请参考第二章**,探索最小化 API 及其优势中的技术要求部分,了解如何创建应用程序的说明。

如果您正在使用控制台、Shell 或 Bash 终端创建 API,请记住将工作目录更改为当前章节号:Chapter08

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter08

介绍身份验证和授权

如开头所述,术语身份验证和授权经常被交替使用,但它们代表不同的安全功能。身份验证是验证用户是否是他们所说的那人的过程,而授权是授予经过身份验证的用户执行某事的权限的任务。因此,授权必须始终跟在身份验证之后。

让我们思考一下机场的安全问题:首先,您需要出示身份证以验证您的身份;然后,在登机口,您需要出示登机牌以获得登机授权并进入飞机。

ASP.NET Core 中的身份验证和授权由相应的中间件处理,在最小化 API 和基于控制器的项目中工作方式相同。它们允许根据用户身份、角色、策略等限制对端点的访问,正如我们将在以下部分中详细了解的那样。

你可以在官方文档中找到关于 ASP.NET Core 身份验证和授权的精彩概述,这些文档可在以下网址找到:docs.microsoft.com/aspnet/core/security/authenticationdocs.microsoft.com/aspnet/core/security/authorization

保护最小化 API

保护最小化 API 意味着正确设置身份验证和授权。现代应用程序中采用了许多类型的身份验证解决方案。在 Web 应用程序中,我们通常使用 cookies,而在处理 Web API 时,我们使用 API 密钥、基本身份验证以及 JSON Web TokenJWT)等方法。JWT 是最常用的,在接下来的章节中,我们将重点介绍这个解决方案。

注意

了解 JWT 是什么以及如何使用的良好起点是 jwt.io/introduction

要启用基于 JWT 的身份验证和授权,首先需要将 Microsoft.AspNetCore.Authentication.JwtBearer NuGet 包添加到我们的项目中,可以使用以下方法之一:

  • Microsoft.AspNetCore.Authentication.JwtBearer 并点击 安装

  • 选项 2:如果你在 Visual Studio 2022 中,请打开 包管理器控制台,或者打开你的控制台、shell 或 Bash 终端,转到你的项目目录,并执行以下命令:

    dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
    

现在,我们需要将身份验证和授权服务添加到服务提供程序中,以便它们可以通过依赖注入使用:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorization();

这是将 JWT 身份验证和授权支持添加到 ASP.NET Core 项目的最小代码。它还不是真正的解决方案,因为它缺少实际的配置,但它足以验证端点保护的工作方式。

AddAuthentication() 方法中,我们指定我们想要使用格式为 Authorization: Bearer <token>Authorization HTTP 标头。然后,我们调用 AddJwtBearer() 来告诉 ASP.NET Core 它必须期望一个 JWT 格式的承载令牌。正如我们稍后将会看到的,承载令牌是服务器在响应登录请求时生成的编码字符串。之后,我们使用 AddAuthorization() 来添加授权服务。

现在,我们需要在管道中插入身份验证和授权中间件,以便 ASP.NET Core 将被指示检查令牌并应用所有授权规则:

var app = builder.Build();
//..
app.UseAuthentication();
app.UseAuthorization();
//...
app.Run();

重要提示

我们已经说过,授权必须跟随认证。这意味着认证中间件必须首先执行;否则,安全机制将无法按预期工作。

最后,我们可以使用Authorize属性或RequireAuthorization()方法来保护我们的端点:

app.MapGet("/api/attribute-protected", [Authorize] () => "This endpoint is protected using the Authorize attribute");
app.MapGet("/api/method-protected", () => "This endpoint is protected using the RequireAuthorization method")
.RequireAuthorization();

注意

在上一个示例的第一个端点中,直接在 lambda 表达式中指定一个属性(如 C# 10 的新特性)。

如果我们现在尝试使用 Swagger 调用这些方法中的每一个,我们将得到一个401 unauthorized响应,其外观应如下所示:

图 8.1 – Swagger 中的未授权响应

图 8.1 – Swagger 中的未授权响应

注意,消息中包含一个标题,指示预期的认证方案是Bearer,正如我们在代码中所声明的。

因此,现在我们知道了如何限制对端点的访问,只允许经过认证的用户。但我们的工作还没有完成:我们需要生成 JWT 载体,验证它,并找到一种方法将此类令牌传递给 Swagger,以便我们可以测试受保护的端点。

生成 JWT 载体

我们已经说过,JWT 载体是由服务器在响应登录请求时生成的。ASP.NET Core 提供了我们创建 JWT 载体的所有 API,所以让我们看看如何执行这个任务。

首先要做的事情是定义登录请求端点,使用用户名和密码来验证用户:

app.MapPost("/api/auth/login", (LoginRequest request) =>
{
    if (request.Username == "marco" && request.Password == 
        "P@$$w0rd")
    {
        // Generate the JWT bearer...
    }
    return Results.BadRequest();
});

为了简化起见,在前面的示例中,我们使用了硬编码的值,但在实际应用中,我们会使用例如ASP.NET Core Identity,这是 ASP.NET Core 中负责用户管理的部分。有关此主题的更多信息,请参阅官方文档docs.microsoft.com/aspnet/core/security/authentication/identity

在典型的登录流程中,如果凭证无效,我们向客户端返回一个400 Bad Request响应。如果用户名和密码正确,我们可以使用 ASP.NET Core 中可用的类有效地生成 JWT 载体:

var claims = new List<Claim>()
{
    new(ClaimTypes.Name, request.Username)
};
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("mysecuritystring"));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var jwtSecurityToken = new JwtSecurityToken(
    issuer: "https://www.packtpub.com",
    audience: "Minimal APIs Client",
    claims: claims, expires: DateTime.UtcNow.AddHours(1), 
      signingCredentials: credentials);
var accessToken = new JwtSecurityTokenHandler()
  .WriteToken(jwtSecurityToken);
return Results.Ok(new { AccessToken = accessToken });

JWT 载体的创建涉及许多不同的概念,但通过前面的代码示例,我们将关注基本概念。这种载体包含允许验证用户身份的信息,以及描述用户属性的其他声明。这些属性被称为声明,并以字符串键值对的形式表示。在前面的代码中,我们创建了一个包含用户名的单个声明的列表。我们可以添加我们需要的任何数量的声明,也可以有具有相同名称的声明。在下一节中,我们将看到如何使用声明,例如,来执行授权。

在前面的代码中,接下来我们定义了用于签名 JWT 承载的凭证(SigningCredentials)。签名取决于实际的令牌内容,并用于检查令牌是否被篡改。实际上,如果我们更改令牌中的任何内容,例如声明值,签名将相应地更改。由于承载签名的密钥只有服务器知道,第三方无法修改令牌并保持其有效性。在前面的代码中,我们使用了 SymmetricSecurityKey,这个密钥永远不会与客户端共享。

我们使用了一个简短的字符串来创建凭证,但唯一的要求是密钥至少为 32 字节或 16 个字符长。在.NET 中,字符串是 Unicode,因此每个字符占用 2 个字节。我们还需要设置凭证将用于签名令牌的算法。为此,我们指定了 SecurityAlgorithms.HmacSha256 值。在这个场景中,这是一个相当常见的选项。

注意

你可以在 docs.microsoft.com/dotnet/api/system.security.cryptography.hmacsha256#remarks 找到有关 HMAC 和 SHA256 哈希函数的更多信息。

到此为止,在前面代码的这一部分,我们终于拥有了创建令牌所需的所有信息,因此我们可以实例化一个 JwtSecurityToken 对象。这个类可以使用许多参数来构建令牌,但为了简单起见,我们只为工作示例指定了最小集:

  • 发行者:一个字符串(通常是 URI),用于标识创建令牌的实体的名称

  • 受众:JWT 旨在接收的接收者,即谁可以消费令牌

  • 声明的列表

  • 令牌的过期时间(UTC 时间)

  • 签名凭证

小贴士

在前面的代码示例中,用于构建令牌的值是硬编码的,但在实际应用中,我们应该将它们放置在外部源中,例如在 appsettings.json 配置文件中。

你可以在 docs.microsoft.com/dotnet/api/system.identitymodel.tokens.jwt.jwtsecuritytoken 找到有关创建令牌的更多信息。

在所有前面的步骤之后,我们可以创建 JwtSecurityTokenHandler,它负责实际生成承载令牌并将其以 200 OK 响应返回给调用者。

因此,现在我们可以尝试 Swagger 中的 login 端点。在插入正确的用户名和密码并点击 执行 按钮后,我们将得到以下响应:

图 8.2 – Swagger 中登录请求的结果 JWT 承载

图 8.2 – Swagger 中登录请求的结果 JWT 承载

我们可以复制令牌值并将其插入到网站的 URL 中 jwt.ms 以查看其内容。我们会得到类似以下的内容:

{
  "alg": "HS256",
  "typ": "JWT"
}.{
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "marco",
  "exp": 1644431527,
  "iss": "https://www.packtpub.com",
  "aud": "Minimal APIs Client"
}.[Signature]

尤其是我们看到已经配置的声明:

  • name:登录用户的名称

  • exp:令牌过期时间,以 Unix 纪元表示

  • iss:令牌的发行者

  • aud:令牌的受众(接收者)

这是原始视图,但我们可以切换到 Claims 选项卡来查看所有声明的解码列表,以及它们的含义描述(如果有的话)。

有一个重要的问题需要注意:默认情况下,JWT 持证人未加密(它只是一个 Base64 编码的字符串),因此任何人都可以读取其内容。令牌的安全性不取决于无法解码,而在于它是经过签名的。即使令牌的内容是透明的,也无法修改它,因为在这种情况下,签名(使用只有服务器才知道的密钥)将变得无效。

因此,不要在令牌中插入敏感数据是很重要的;例如,用户名、用户 ID 和角色这样的声明通常是安全的,但例如,我们不应插入与隐私相关的信息。为了给出一个故意夸张的例子,我们绝对不能在令牌中插入信用卡号码!在任何情况下,请记住,即使是微软的 Azure Active Directory 也使用 JWT,没有加密,因此我们可以信任这个安全系统。

总之,我们已经描述了如何获取有效的 JWT。下一步是将令牌传递到我们的受保护端点,并指导我们的最小 API 如何验证它。

验证 JWT 持证人

在创建 JWT 持证人之后,我们需要在每次 HTTP 请求中将其传递,在 Authorization HTTP 标头内部,以便 ASP.NET Core 可以验证其有效性并允许我们调用受保护的端点。因此,我们必须完成之前展示的 AddJwtBearer() 方法调用,并描述验证持证人的规则:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(
          Encoding.UTF8.GetBytes("mysecuritystring")),
        ValidIssuer = "https://www.packtpub.com",
        ValidAudience = "Minimal APIs Client"
    };
});

在前面的代码中,我们添加了一个 lambda 表达式,用它定义了包含令牌验证规则的 TokenValidationParameter 对象。首先,我们检查了发行者签名密钥,即令牌的签名,如 生成 JWT 持证人 部分所示,以验证 JWT 是否被篡改。用于签名的安全字符串是执行此检查所必需的,因此我们指定了与登录请求期间插入的相同的值(mysecuritystring)。

然后,我们指定令牌发行者和受众的有效值。如果令牌是由不同的发行者发出的,或者是为另一个受众准备的,验证将失败。这是一个重要的安全检查;我们应该确保持证人是由我们期望发行它的人发行的,并且是为我们想要的受众。

提示

正如已经指出的,我们应该将用于处理令牌的信息放置在外部源中,这样我们就可以在令牌生成和验证期间引用正确的值,避免硬编码它们或重复写入它们的值。

我们不需要指定我们还想验证令牌过期,因为这个检查是自动启用的。在验证时间时应用时钟偏移,以补偿时钟时间的微小差异或处理客户端请求与服务器处理该请求的瞬间之间的延迟。默认值是 5 分钟,这意味着过期令牌在其实际过期后的 5 分钟时间内被视为有效。我们可以通过使用TokenValidationParameter类的ClockSkew属性来减少时钟偏移,或禁用它。

现在,最小化的 API 已经包含了检查携带令牌有效性的所有信息。为了测试是否一切按预期工作,我们需要一种方法告诉 Swagger 如何在请求中发送令牌,正如我们将在下一节中看到的。

将 JWT 支持添加到 Swagger

我们已经说过,携带令牌被发送在请求的Authorization HTTP 头部中。如果我们想使用 Swagger 验证认证系统并测试我们的受保护端点,我们需要更新配置,使其能够将此头部包含在请求中。

要执行此任务,需要在AddSwaggerGen()方法中添加一些代码:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddSwaggerGen(options =>
{
    options.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.ApiKey,
        In = ParameterLocation.Header,
        Name = HeaderNames.Authorization,
        Description = "Insert the token with the 'Bearer ' 
                       prefix"
    });
    options.AddSecurityRequirement(new
      OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = 
                     JwtBearerDefaults.AuthenticationScheme
                }
            },
            Array.Empty<string>()
        }
    });
});

在前面的代码中,我们定义了 Swagger 如何处理认证。使用AddSecurityDefinition()方法,我们描述了我们的 API 是如何受到保护的;我们使用了一个 API 密钥,即携带令牌,在名为Authorization的头部中。然后,通过AddSecurityRequirement(),我们指定了我们端点有一个安全要求,这意味着必须为每个请求发送安全信息。

在添加了前面的代码之后,如果我们现在运行我们的应用程序,Swagger UI 将包含一些新的内容。

图 8.3 – Swagger 显示的认证功能

图 8.3 – Swagger 显示的认证功能

点击授权按钮或端点右侧的任何锁形图标时,将显示以下窗口,允许我们插入携带令牌:

图 8.4 – 允许设置携带令牌的窗口

图 8.4 – 允许设置携带令牌的窗口

最后要做的事情是将令牌插入到文本框中,并通过点击授权来确认。从现在起,指定的携带者将随 Swagger 发出的每个请求一起发送。

我们终于完成了添加到最小 API 的认证支持所需的所有步骤。现在,是时候验证一切是否按预期工作了。在下一节中,我们将进行一些测试。

测试认证

如前几节所述,如果我们调用受保护的端点之一,我们会得到一个401 未授权响应。为了验证令牌认证是否工作,让我们调用login端点以获取令牌。之后,点击Bearer<空格>前缀。现在,我们将得到一个200 OK响应,这意味着我们能够正确调用需要认证的端点。我们还可以尝试更改令牌中的一个字符,再次得到401 未授权响应,因为在这种情况下,签名将不会是预期的,如之前所述。同样,如果令牌形式上有效但已过期,我们也会得到一个401响应。

正如我们在前面定义了只能由认证用户访问的端点,一个常见的需求是在相应的路由处理程序中访问用户信息。在第二章探索 Minimal APIs 及其优势中,我们展示了 Minimal APIs 提供了一个特殊的绑定,该绑定直接提供了一个表示已登录用户的ClaimsPrincipal对象:

app.MapGet("/api/me", [Authorize] (ClaimsPrincipal user) => $"Logged username: {user.Identity.Name}");

路由处理程序的user参数会自动填充用户信息。在这个例子中,我们只获取名称,该名称反过来是从令牌声明中读取的,但该对象公开了许多属性,使我们能够处理认证数据。我们可以参考docs.microsoft.com/dotnet/api/system.security.claims.claimsprincipal.identity的官方文档以获取更多详细信息。

这就结束了我们对认证的概述。在下一节中,我们将看到如何处理授权。

处理授权 - 角色和政策

在认证之后,紧接着是授权步骤,它授予认证用户执行某些操作的权限。Minimal APIs 提供了与基于控制器的项目相同的授权功能,基于角色策略的概念。

当创建一个身份时,它可能属于一个或多个角色。例如,一个用户可以属于管理员角色,而另一个用户可以是两个角色:用户利益相关者。通常,每个用户只能执行其角色允许的操作。角色只是在认证时插入到 JWT 载体中的声明。正如我们稍后将看到的,ASP.NET Core 提供了内置支持来验证用户是否属于某个角色。

虽然基于角色的授权覆盖了许多场景,但有些情况下这种安全措施是不够的,因为我们需要应用更具体的规则来检查用户是否有权执行某些活动。在这种情况下,我们可以创建自定义策略,使我们能够指定更详细的授权要求,甚至可以根据我们的算法完全定义授权逻辑。

在接下来的章节中,我们将看到如何在我们的 API 中管理基于角色和基于策略的授权,以便我们可以覆盖所有要求,即仅允许具有特定角色或声明的用户或基于我们自定义逻辑的用户访问某些端点。

处理基于角色的授权

如已介绍,角色是声明。这意味着它们必须在认证时插入到 JWT 携带者令牌中,就像任何其他声明一样:

app.MapPost("/api/auth/login", (LoginRequest request) =>
{
    if (request.Username == "marco" && request.Password == 
        "P@$$w0rd")
    {
        var claims = new List<Claim>()
        {
            new(ClaimTypes.Name, request.Username),
            new(ClaimTypes.Role, "Administrator"),
            new(ClaimTypes.Role, "User")
        };

    //...
}

在这个例子中,我们静态地添加了两个名为 ClaimTypes.Role 的声明:AdministratorUser。正如前几节所述,在现实世界的应用中,这些值通常来自一个完整的用户管理系统,例如使用 ASP.NET Core Identity 构建的系统。

正如所有其他声明一样,角色被插入到 JWT 携带者中。如果我们现在尝试调用 login 端点,我们会注意到令牌变长了,因为它包含了很多信息,我们可以再次使用 jwt.ms 网站来验证这些信息,如下所示:

{
  "alg": "HS256",
  "typ": "JWT"
}.{
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "marco",
  "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": [
    "Administrator",
    "User"
  ],
  "exp": 1644755166,
  "iss": "https://www.packtpub.com",
  "aud": "Minimal APIs Client"
}.[Signature]

为了仅允许属于特定角色的用户访问特定端点,我们需要在 Authorize 属性或 RequireAuthorization() 方法中将此角色指定为参数:

app.MapGet("/api/admin-attribute-protected", [Authorize(Roles = "Administrator")] () => { });
app.MapGet("/api/admin-method-protected", () => { })
.RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator" });

这样,只有被分配了 Administrator 角色的用户才能访问端点。我们还可以指定更多角色,用逗号分隔它们:如果用户至少有一个指定的角色,则他们将获得授权。

重要提示

角色名称是区分大小写的。

现在假设我们有以下端点:

app.MapGet("/api/stackeholder-protected", [Authorize(Roles = "Stakeholder")] () => { });

此方法只能由分配了 Stakeholder 角色的用户消费。然而,在我们的例子中,这个角色没有被分配。因此,如果我们使用之前的携带令牌并尝试调用此端点,当然我们会得到一个错误。但在这个情况下,它不会是 401 未授权,而是 403 禁止访问。我们观察到这种行为是因为用户实际上是经过认证的(这意味着令牌是有效的,因此没有 401 错误),但他们没有执行方法的授权,因此访问被禁止。换句话说,认证错误和授权错误会导致不同的 HTTP 状态码。

另一个重要的场景涉及到角色。有时,我们根本不需要限制端点的访问,但需要根据特定的用户角色调整处理器的行为,例如在检索特定类型的信息时。在这种情况下,我们可以使用 IsInRole() 方法,该方法在 ClaimsPrincipal 对象上可用:

app.MapGet("/api/role-check", [Authorize] (ClaimsPrincipal user) =>
{
    if (user.IsInRole("Administrator"))
    {
        return "User is an Administrator";
    }
    return "This is a normal user";
});

在此端点中,我们只使用 Authorize 属性来检查用户是否经过认证。然后,在路由处理器中,我们检查用户是否有 Administrator 角色。如果有,我们只返回一条消息,但我们可以想象管理员可以检索所有可用的信息,而普通用户只能根据信息的值获取子集。

正如我们所见,通过基于角色的授权,我们可以在端点中执行不同类型的授权检查,以覆盖许多场景。然而,这种方法并不能处理所有情况。如果角色不足以满足需求,我们需要使用基于策略的授权,我们将在下一节中讨论。

应用基于策略的授权

策略是定义授权规则的一种更通用的方式。基于角色的授权可以被视为涉及角色检查的特定策略授权。我们通常在需要处理更复杂场景时使用策略。

这种授权需要两个步骤:

  1. 定义包含规则集的策略

  2. 在端点上应用特定策略

策略是在上一节中提到的AddAuthorization()方法上下文中添加的,即保护最小 API。每个策略都有一个唯一的名称,用于稍后引用,以及一组规则,这些规则通常以流畅的方式描述。

当角色授权不足以满足需求时,我们可以使用策略。假设携带令牌的用户所属的租户 ID 也包含在内:

var claims = new List<Claim>()
{
    // ...
    new("tenant-id", "42")
};

再次强调,在现实世界的场景中,这个值可能来自存储用户属性的数据库。假设我们只想允许属于特定租户的用户访问端点。由于tenant-id是一个自定义声明,ASP.NET Core 不知道如何使用它来执行授权。因此,我们无法使用之前展示的解决方案。我们需要定义一个包含相应规则的定制策略:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Tenant42", policy =>
    {
        policy.RequireClaim("tenant-id", "42");
    });
});

在前面的代码中,我们创建了一个名为Tenant42的策略,该策略要求令牌包含值为42tenant-id声明。policy变量是AuthorizationPolicyBuilder的一个实例,它公开了允许我们流畅地指定授权规则的方法;我们可以指定策略需要满足某些用户、角色和声明。我们还可以在同一个策略中链式添加多个要求,例如,可以编写policy.RequireRole("Administrator").RequireClaim("tenant-id")这样的代码。完整的方法列表可以在文档页面docs.microsoft.com/dotnet/api/microsoft.aspnetcore.authorization.authorizationpolicybuilder上找到。

然后,在我们要保护的函数中,我们必须指定策略名称,就像通常使用Authorize属性或RequireAuthorization()方法一样:

app.MapGet("/api/policy-attribute-protected", [Authorize(Policy = "Tenant42")] () => { });
app.MapGet("/api/policy-method-protected", () => { })
.RequireAuthorization("Tenant42");

如果我们尝试使用没有tenant-id声明或其值不是42的令牌执行这些前面的端点,我们将得到一个403 Forbidden的结果,就像角色检查发生时一样。

有一些场景,仅仅声明允许的角色和声明是不够的:例如,我们需要执行更复杂的检查或根据动态参数验证授权。在这些情况下,我们可以使用所谓的策略要求,它包含一组授权规则,我们可以为这些规则提供自定义验证逻辑。

为了采用这种解决方案,我们需要两个对象:

  • 一个实现IAuthorizationRequirement接口并定义我们想要管理的需求的要求类

  • 一个继承自AuthorizationHandler并包含验证要求逻辑的处理器类

假设我们不想让不属于Administrator角色的用户在维护时间窗口期间访问某些端点。这是一个完全有效的授权规则,但我们不能使用我们迄今为止看到的解决方案来实现它。该规则涉及一个考虑当前时间的条件,因此策略不能静态定义。

因此,我们首先创建一个自定义要求:

public class MaintenanceTimeRequirement : IAuthorizationRequirement
{
    public TimeOnly StartTime { get; init; }
    public TimeOnly EndTime { get; init; }
}

要求包含维护窗口的开始和结束时间。在此期间,我们只希望管理员能够操作。

注意

TimeOnly是 C# 10 中引入的新数据类型,它允许我们只存储一天中的时间(而不是日期)。更多信息请参阅docs.microsoft.com/dotnet/api/system.timeonly

注意,IAuthorizationRequirement接口只是一个占位符。它不包含任何需要实现的方法或属性;它仅用于标识该类是一个要求。换句话说,如果我们不需要任何额外的信息来满足要求,我们可以创建一个实现IAuthorizationRequirement但没有内容的类。

这个要求必须得到强制执行,因此有必要创建相应的处理器:

public class MaintenanceTimeAuthorizationHandler
    : AuthorizationHandler<MaintenanceTimeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MaintenanceTimeRequirement requirement)
    {
        var isAuthorized = true;
        if (!context.User.IsInRole("Administrator"))
        {
            var time = TimeOnly.FromDateTime(DateTime.Now);
            if (time >= requirement.StartTime && time <
                requirement.EndTime)
            {
                isAuthorized = false;
            }
        }
        if (isAuthorized)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

我们的处理器继承自AuthorizationHandler<MaintenanceTimeRequirement>,因此我们需要重写HandleRequirementAsync()方法来验证要求,使用AuthorizationHandlerContext参数,它包含对当前用户的引用。正如一开始所说的,如果用户没有被分配Administrator角色,我们检查当前时间是否在维护窗口内。如果是这样,用户没有访问权限。

最后,如果isAuthorized变量为true,则表示授权可以被授予,因此我们在context对象上调用Succeed()方法,并传递我们想要验证的要求。否则,我们不在上下文中调用任何方法,这意味着要求尚未被验证。

我们还没有完成自定义策略的实现。我们仍然需要定义策略并在服务提供程序中注册处理器:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("TimedAccessPolicy", policy =>
    {
        policy.Requirements.Add(new
          MaintenanceTimeRequirement
        {
            StartTime = new TimeOnly(0, 0, 0),
            EndTime = new TimeOnly(4, 0, 0)
        });
    });
});
builder.Services.AddScoped<IAuthorizationHandler, MaintenanceTimeAuthorizationHandler>();

在前面的代码中,我们定义了一个从午夜到早上 4:00 的维护时间窗口。然后,我们将处理器注册为IAuthorizationHandler接口的实现,该接口反过来由AuthorizationHandler类实现。

现在我们已经准备好了所有东西,我们可以将策略应用到我们的端点:

app.MapGet("/api/custom-policy-protected", [Authorize(Policy = "TimedAccessPolicy")] () => { });

当我们尝试访问此端点时,ASP.NET Core 将检查相应的策略,发现其中包含一个要求,并扫描所有IAuhorizationHandler接口的注册,以查看是否存在能够处理该要求的处理器。然后,将调用处理器,并使用结果来确定用户是否有权访问路由。如果策略未经验证,我们将得到一个403 Forbidden响应。

我们已经展示了策略是多么强大,但还有更多。我们还可以使用它们来定义应用于所有端点的全局规则,使用默认和回退策略的概念,正如我们将在下一节中看到的。

使用默认和回退策略

当我们想要定义必须自动应用的全局规则时,默认和回退策略是有用的。实际上,当我们使用Authorize属性或RequireAuthorization()方法,并且没有其他参数时,我们隐式地引用 ASP.NET Core 定义的默认策略,该策略设置为需要认证用户。

如果我们想使用默认的不同条件,我们只需重新定义DefaultPolicy属性,该属性在AddAuthorization()方法的上下文中可用:

builder.Services.AddAuthorization(options =>
{
    var policy = new AuthorizationPolicyBuilder()
      .RequireAuthenticatedUser()
        .RequireClaim("tenant-id").Build();
    options.DefaultPolicy = policy;    
});

我们使用AuthorizationPolicyBuilder来定义所有安全要求,然后将其设置为默认策略。这样,即使我们没有在Authorize属性或RequireAuthorization()方法中指定自定义策略,系统也会始终验证用户是否已认证,以及携带者是否包含tenant-id声明。当然,我们只需在授权属性或方法中指定角色或策略名称即可覆盖此默认行为。

另一方面,回退策略是在端点没有授权信息时应用的策略。例如,当我们希望所有端点自动受到保护,即使我们忘记指定Authorize属性或者不想为每个处理器重复属性时,它是有用的。让我们通过以下代码来尝试理解这一点:

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = options.DefaultPolicy;
});

在前面的代码中,FallbackPolicy等于DefaultPolicy。我们说过默认策略要求用户必须认证,所以这段代码的结果是现在,所有端点自动需要认证,即使我们没有明确保护它们。

这是在我们的大多数端点都有受限访问时采用的一种典型解决方案。我们不再需要指定Authorize属性或使用RequireAuthorization()方法。换句话说,现在所有我们的端点都默认受到保护。

如果我们决定使用这种方法,但有一堆端点需要公开访问,例如login端点,每个人都应该能够调用,我们可以使用AllowAnonymous属性或AllowAnonymous()方法:

app.MapPost("/api/auth/login", [AllowAnonymous] (LoginRequest request) => { });
// OR
app.MapPost("/api/auth/login", (LoginRequest request) => { })
.AllowAnonymous();

如其名所示,前面的代码将绕过端点的所有授权检查,包括默认和回退授权策略。

要深化我们对基于策略的身份验证的了解,我们可以参考官方文档,网址为docs.microsoft.com/aspnet/core/security/authorization/policies

摘要

了解最小化 API 中的身份验证和授权工作原理对于开发安全的应用程序是基本的。使用 JWT 载体身份验证角色和政策,我们甚至可以定义复杂的授权场景,并能够使用标准和自定义规则。

在本章中,我们介绍了使服务安全的基本概念,但还有更多内容要讨论,特别是关于 ASP.NET Core Identity:一个支持登录功能并允许管理用户、密码、个人资料数据、角色、声明等的 API。我们可以通过查看官方文档来进一步了解这个主题,该文档可在docs.microsoft.com/aspnet/core/security/authentication/identity找到。

在下一章中,我们将看到如何为我们的最小化 API 添加多语言支持,以及如何正确处理使用不同日期格式、时区等的应用程序。

第九章:利用全球化和本地化

在开发应用程序时,考虑多语言支持非常重要;多语言应用程序可以扩大受众范围。这对于 Web API 也是如此:端点返回的消息(例如,验证错误)应该本地化,并且服务应该能够处理不同的文化并处理时区。在本章中,我们将讨论 全球化本地化,并解释最小 API 中可用于处理这些概念的功能。提供的信息和示例将指导我们在服务中添加多语言支持并正确处理所有相关行为,以便我们能够开发全球应用程序。

在本章中,我们将涵盖以下主题:

  • 介绍全球化和本地化

  • 本地化最小 API 应用

  • 使用资源文件

  • 在验证框架中集成本地化

  • 向全球化的最小 API 添加 UTC 支持

技术要求

要遵循本章中的描述,你需要创建一个 ASP.NET Core 6.0 Web API 应用程序。有关如何操作的说明,请参阅 第一章 最小 API 简介 中的 技术要求 部分。

如果你正在使用控制台、shell 或 Bash 终端创建 API,请记住将你的工作目录更改为当前章节号(Chapter09)。

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter09

介绍全球化和本地化

当考虑国际化时,我们必须处理全球化和本地化这两个看似指代相同概念但实际上涉及不同领域的术语。全球化是设计能够管理和支持不同文化的应用程序的任务。本地化是将应用程序适应特定文化的过程,例如,通过为每个将得到支持的文化提供翻译资源。

注意

国际化、全球化和本地化这些术语通常分别缩写为 I18NG11NL10N

与我们在前几章中介绍的所有其他功能一样,全球化和本地化可以通过 ASP.NET Core 提供的相应中间件和服务来处理,并在最小 API 和基于控制器的项目中以相同的方式工作。

您可以在官方文档中找到关于全球化和本地化的详细介绍,这些文档分别可在docs.microsoft.com/dotnet/core/extensions/globalizationdocs.microsoft.com/dotnet/core/extensions/localization找到。在本章的其余部分,我们将重点介绍如何在最小 API 项目中添加对这些功能的支持;这样,我们将介绍一些重要概念,并解释如何在 ASP.NET Core 中利用全球化和本地化。

本地化最小 API 应用程序

要在最小化 API 应用程序中启用本地化,让我们按照以下步骤进行:

  1. 使应用程序可本地化的第一步是设置相应的选项来指定支持的文化,如下所示:

    var builder = WebApplication.CreateBuilder(args);
    //...
    var supportedCultures = new CultureInfo[] { new("en"), new("it"), new("fr") };
    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
       options.SupportedCultures = supportedCultures;
       options.SupportedUICultures = supportedCultures;
       options.DefaultRequestCulture = new 
       RequestCulture(supportedCultures.First());
    });
    

在我们的示例中,我们想要支持三种文化——英语、意大利语和法语——因此,我们创建了一个CultureInfo对象的数组。

我们正在定义中立文化,即具有语言但不与国家或地区关联的文化。我们也可以使用特定的文化,如en-USen-GB,来表示特定地区的文化:例如,en-US将指代在美国普遍存在的英语文化,而en-GB将指代在英国普遍存在的英语文化。这种差异很重要,因为根据场景的不同,我们可能需要使用特定国家的信息来正确实现本地化。例如,如果我们想显示日期,我们必须知道美国的日期格式是M/d/yyyy,而英国的日期格式是dd/MM/yyyy。因此,在这种情况下,与特定文化一起工作变得至关重要。如果我们需要支持跨文化之间的语言差异,我们也会使用特定的文化。例如,一个特定的单词可能因国家而异(例如,美国的color与英国的colour)。话虽如此,对于我们的最小 API 场景,使用中立文化就足够了。

  1. 接下来,我们配置RequestLocalizationOptions,设置文化并指定在没有提供文化信息时使用的默认文化。我们指定支持的文化和支持的 UI 文化:

    • 支持的文化控制着文化相关函数的输出,例如日期、时间和数字格式。

    • 支持的 UI 文化用于选择从.resx文件中搜索的翻译字符串。我们将在本章后面讨论.resx文件。

在典型应用程序中,文化和 UI 文化设置为相同的值,但当然,如果需要,我们可以使用不同的选项。

  1. 现在我们已经配置了我们的服务以支持全球化,我们需要将本地化中间件添加到 ASP.NET Core 管道中,以便它能够自动设置请求的文化。让我们使用以下代码来完成:

    var app = builder.Build();
    //...
    app.UseRequestLocalization();
    //...
    app.Run();
    

在前面的代码中,通过 UseRequestLocalization(),我们向 ASP.NET Core 管道添加了 RequestLocalizationMiddleware 以设置每个请求的当前文化。这项任务是通过一个可以读取有关文化信息的 RequestCultureProvider 列表来执行的。默认提供者包括以下内容:

  • QueryStringRequestCultureProvider: 搜索 cultureui-culture 查询字符串参数

  • CookieRequestCultureProvider: 使用 ASP.NET Core 的 cookie

  • AcceptLanguageHeaderRequestProvider: 从 Accept-Language HTTP 头读取请求的文化

对于每个请求,系统将按照此确切顺序尝试使用这些提供者,直到找到第一个可以确定文化的提供者。如果无法设置文化,则将使用 RequestLocalizationOptionsDefaultRequestCulture 属性中指定的文化。

如果需要,也可以更改请求文化提供者的顺序,甚至可以定义一个自定义提供者以实现我们自己的逻辑来确定文化。有关此主题的更多信息,请参阅 docs.microsoft.com/aspnet/core/fundamentals/localization#use-a-custom-provider

重要提示

本地化中间件必须插入到任何可能使用请求文化的其他中间件之前。

在 Web API 的情况下,无论是使用基于控制器的 API 还是最小 API,我们通常通过 Accept-Language HTTP 头设置请求文化。在下一节中,我们将看到如何扩展 Swagger 以添加在尝试调用方法时添加此头部的功能。

将全球化支持添加到 Swagger

我们希望 Swagger 能够提供一种方式来指定每个请求的 Accept-Language HTTP 头,以便我们可以测试我们的全球化端点。从技术上讲,这意味着向 Swagger 添加一个 操作过滤器,该过滤器能够自动插入语言头,使用以下代码:

public class AcceptLanguageHeaderOperationFilter : IOperationFilter
{
     private readonly List<IOpenApiAny>? 
     supportedLanguages;
     public AcceptLanguageHeaderOperationFilter 
     (IOptions<RequestLocalizationOptions> 
     requestLocalizationOptions)
     {
           supportedLanguages = 
           requestLocalizationOptions.Value.
           SupportedCultures?.Select(c => 
           newOpenApiString(c.TwoLetterISOLanguageName)).
           Cast<IOpenApiAny>().           ToList();
     }
     public void Apply(OpenApiOperation operation, 
     OperationFilterContext context)
     {
           if (supportedLanguages?.Any() ?? false)
           {
                 operation.Parameters ??= new 
                 List<OpenApiParameter>();
                 operation.Parameters.Add(new 
                 OpenApiParameter
                 {
                       Name = HeaderNames.AcceptLanguage,
                       In = ParameterLocation.Header,
                       Required = false,
                       Schema = new OpenApiSchema
                       {
                             Type = "string",
                             Enum = supportedLanguages,
                             Default = supportedLanguages.
                             First()
                       }
                 });
           }
     }
}

在前面的代码中,AcceptLanguageHeaderOperationFilter 通过依赖注入获取我们在启动时定义的 RequestLocalizationOptions 对象,并从中提取 Swagger 所期望的格式化的支持语言。然后,在 Apply() 方法中,我们添加一个新的 OpenApiParameter,它对应于 Accept-Language 头。特别是,通过 Schema.Enum 属性,我们使用在构造函数中提取的值提供支持语言列表。此方法对每个操作(即每个端点)都会被调用,这意味着参数将自动添加到每个端点中。

现在,我们需要将新的过滤器添加到 Swagger 中:

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.AddSwaggerGen(options =>
{
     options.OperationFilter<AcceptLanguageHeaderOperation
     Filter>();
});

正如我们在前面的代码中所做的那样,对于每个操作,Swagger 都会执行过滤器,该过滤器会添加一个参数来指定请求的语言。

因此,让我们假设我们有以下端点:

app.MapGet("/culture", () => Thread.CurrentThread.CurrentCulture.DisplayName);

在前面的处理程序中,我们只是返回线程的文化。这种方法不接受任何参数;然而,在添加前面的过滤器后,Swagger UI 将显示以下内容:

图 9.1 – 添加到 Swagger 的 Accept-Language 头

图 9.1 – 添加到 Swagger 的 Accept-Language 头

操作过滤器已向端点添加了一个新参数,允许我们从下拉列表中选择语言。我们可以点击尝试按钮从列表中选择一个值,然后点击执行来调用端点:

图 9.2 – 带有 Accept-Language HTTP 头的执行结果

图 9.2 – 带有 Accept-Language HTTP 头的执行结果

这是选择it作为语言请求的结果:Swagger 已添加Accept-Language HTTP 头,该头随后被 ASP.NET Core 用于设置当前文化。然后,在最后,我们在路由处理程序中获取并返回文化显示名称。

这个例子向我们展示了我们已经正确地将全球化支持添加到我们的最小 API 中。在下一节中,我们将进一步探讨并处理本地化,首先根据相应的语言向调用者提供翻译过的资源。

使用资源文件

我们的最小 API 现在支持全球化,因此可以根据请求切换文化。这意味着我们可以向调用者提供本地化消息,例如,在通信验证错误时。这个功能基于所谓的.resx文件,这是一种特定的 XML 文件,包含表示必须本地化的消息的键值字符串对。

注意

这些资源文件与.NET 早期版本以来完全相同。

创建和使用资源文件

使用资源文件,我们可以轻松地将字符串与代码分离,并按文化分组。通常,资源文件放在名为Resources的文件夹中。要使用 Visual Studio 创建此类文件,请按照以下步骤进行:

重要提示

不幸的是,Visual Studio Code 不支持处理.resx文件。有关此主题的更多信息,请参阅github.com/dotnet/AspNetCore.Docs/issues/2501

  1. 解决方案资源管理器中右键单击文件夹,然后选择添加 | 新项

  2. Resources中,选择相应的模板,并为文件命名,例如,Messages.resx

图 9.3 – 将资源文件添加到项目中

图 9.3 – 将资源文件添加到项目中

新文件将立即在 Visual Studio 编辑器中打开。

  1. 在新文件中要做的第一件事是从 访问修饰符 选项中选择 内部公共(根据我们想要达到的代码可见性),这样 Visual Studio 就会创建一个 C# 文件,该文件将创建暴露属性以访问资源:

图 9.4 – 修改资源文件访问修饰符

图 9.4 – 修改资源文件访问修饰符

一旦我们更改此值,Visual Studio 将向项目中添加一个 Messages.Designer.cs 文件,并自动创建与我们在资源文件中插入的字符串相对应的属性。

资源文件必须遵循精确的命名约定。包含默认文化消息的文件可以具有任何名称(例如,在我们的例子中为 Messages.resx),但提供相应翻译的其他 .resx 文件必须具有相同的名称,并指定它们所引用的文化(中性或特定)。因此,我们有 Messages.resx,它将存储默认(英语)消息。

  1. 由于我们还想将我们的消息本地化为意大利语,我们需要创建另一个名为 Messages.it.resx 的文件。

注意

我们故意没有为法语文化创建资源文件,这样我们就可以看到 APS.NET Core 在实际中是如何查找本地化消息的。

  1. 现在,我们可以开始对资源文件进行实验。让我们打开 Messages.resx 文件,并设置 HelloWorldHello World!

以这种方式,Visual Studio 将在 Messages 自动生成的类中添加一个静态 HelloWorld 属性,允许我们根据当前文化访问值。

  1. 为了演示这种行为,也打开 Messages.it.resx 文件,并添加一个具有相同 HelloWorld 的项,但现在设置为 Ciao mondo!

  2. 最后,我们可以添加一个新的端点来展示资源文件的使用:

    // using Chapter09.Resources;
    app.MapGet("/helloworld", () => Messages.HelloWorld);
    

在前面的路由处理程序中,我们简单地访问了静态 Mesasges.HelloWorld 属性,正如之前讨论的那样,在编辑 Messages.resx 文件时,该属性已被自动创建。

如果我们现在运行最小化 API 并尝试执行此端点,我们将根据在 Swagger 中选择的请求语言获得以下响应:

表 9.1 – 根据请求语言生成的响应

表 9.1 – 根据请求语言生成的响应

当访问像 HelloWorld 这样的属性时,自动生成的 Messages 类内部使用 ResourceManager 来查找相应的本地化字符串。首先,它会寻找一个名称包含请求文化的资源文件。如果找不到,它将回退到该文化的父文化。这意味着,如果请求的文化是特定的,ResourceManager 将搜索中性文化。如果仍然找不到资源文件,则使用默认的文件。

在我们的情况下,使用 Swagger,我们可以选择仅英语、意大利语或法语作为中性文化。但如果客户端发送其他值会怎样?我们可以有如下情况:

  • 请求文化是 it-IT:系统搜索 Messages.it-IT.resx,然后找到并使用 Messages.it.resx

  • 请求文化是 fr-FR:系统搜索 Messages.fr-FR.resx,然后搜索 Messages.fr.resx,并且(因为两者都不可用)最后使用默认的 Messages.resx

  • 请求文化是 de(德语):因为这不是一个受支持的文化,所以默认请求文化将被自动选择,因此字符串将在 Messages.resx 文件中搜索。

注意

如果存在本地化资源文件,但它不包含指定的键,则将使用默认文件中的值。

使用资源文件格式化本地化消息

我们还可以使用资源文件来格式化本地化消息。例如,我们可以将以下字符串添加到项目的资源文件中:

表 9.2 – 一个自定义的本地化消息

表 9.2 – 一个自定义的本地化消息

现在,让我们定义这个端点:

// using Chapter09.Resources;
app.MapGet("/hello", (string name) =>
{
     var message = string.Format(Messages.GreetingMessage, 
     name);
     return message;
});

如前述代码示例所示,我们根据请求的文化从资源文件中获取一个字符串。但是,在这种情况下,消息包含一个占位符,因此我们可以使用它通过传递给路由处理器的名称来创建一个自定义的本地化消息。如果我们尝试执行端点,我们将得到如下结果:

表 9.3 – 基于请求语言的定制本地化消息的响应

表 9.3 – 基于请求语言的定制本地化消息的响应

能够创建在运行时使用不同值替换占位符的本地化消息,这对于创建真正可本地化的服务是一个关键点。

在一开始,我们提到在 Web API 中本地化的典型用例是在验证时需要提供本地化错误消息。在下一节中,我们将看到如何将此功能添加到我们的最小 API 中。

在验证框架中集成本地化

第六章探索验证和映射中,我们讨论了如何将验证集成到最小 API 项目中。我们学习了如何使用 MiniValidation 库,而不是 FluentValidation,来验证我们的模型并向调用者提供验证消息。我们还提到 FluentValidation 已经为标准错误消息提供了翻译。

然而,使用这两个库,我们可以利用我们刚刚添加到项目中的本地化支持来支持本地化和自定义验证消息。

使用 MiniValidation 本地化验证消息

使用 MiniValidation 库,我们可以使用基于 数据注释 的验证与最小 API。有关如何将此库添加到项目的说明,请参阅 第六章探索验证和映射

然后,重新创建相同的 Person 类:

public class Person
{
     [Required]
     [MaxLength(30)]
     public string FirstName { get; set; }
     [Required]
     [MaxLength(30)]
     public string LastName { get; set; }
     [EmailAddress]
     [StringLength(100, MinimumLength = 6)]
     public string Email { get; set; }
}

每个验证属性都允许我们指定一个错误消息,它可以是静态字符串或对资源文件的引用。让我们看看如何正确处理Required属性的本地化。在资源文件中添加以下值:

表 9.4 – 数据注释使用的本地化验证错误消息

表 9.4 – 数据注释使用的本地化验证错误消息

我们希望当必需的验证规则失败时,返回与FieldRequiredAnnotation对应的本地化消息。此外,此消息包含一个占位符,因为我们希望将其用于每个必需字段,因此我们还需要属性名称的翻译。

使用这些资源,我们可以使用以下声明更新Person类:

public class Person
{
     [Display(Name = "FirstName", ResourceType = 
      typeof(Messages))]
     [Required(ErrorMessageResourceName = 
     "FieldRequiredAnnotation",
      ErrorMessageResourceType = typeof(Messages))]
     public string FirstName { get; set; }
     //...
}

每个验证属性,如Required(如本例中所示),都公开了允许我们指定要使用的资源名称和包含相应定义的类的类型的属性。请记住,名称是一个简单的字符串,编译时没有检查,所以如果我们写了一个错误值,我们只有在运行时才会得到错误。

接下来,我们可以使用Display属性来指定必须插入验证消息中的字段名称。

注意

您可以在 GitHub 仓库 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L97 上找到带有本地化数据注释的Person类的完整声明。

现在,我们可以重新添加第六章探索验证和映射中显示的验证代码。不同的是,现在验证消息将是本地化的:

app.MapPost("/people", (Person person) =>
{
     var isValid = MiniValidator.TryValidate(person, out 
     var errors);
     if (!isValid)
     {
           return Results.ValidationProblem(errors, title: 
           Messages.ValidationErrors);
     }
     return Results.NoContent();
});

在前面的代码中,MiniValidator.TryValidate()方法返回的errors字典中包含的消息将根据请求文化进行本地化,如前几节所述。我们还指定了Results.ValidationProblem()调用中的title参数,因为我们还想本地化这个值(否则,它将始终是默认的One or more validation errors occurred)。

如果我们更喜欢使用FluentValidation而不是数据注释,我们知道它默认支持标准错误消息的本地化第六章探索验证和映射。然而,使用这个库,我们还可以提供我们的翻译。在下一节中,我们将讨论实现此解决方案的方法。

使用 FluentValidation 本地化验证消息

使用FluentValidation,我们可以完全解耦验证规则和我们的模型。如前所述,请参阅第六章探索验证和映射,了解如何将此库添加到项目中以及如何配置它。

接下来,让我们重新创建PersonValidator类:

public class PersonValidator : AbstractValidator<Person>
{
     public PersonValidator()
     {
           RuleFor(p => p.FirstName).NotEmpty().
           MaximumLength(30);
           RuleFor(p => p.LastName).NotEmpty().
           MaximumLength(30);
           RuleFor(p => p.Email).EmailAddress().Length(6, 
           100);
     }
}

如果我们没有指定任何消息,将使用默认消息。让我们添加以下资源来自定义NotEmpty验证规则:

表 9.5 – FluentValidation 使用的本地化验证错误消息

表 9.5 – FluentValidation 使用的本地化验证错误消息

注意,在这种情况下,我们还有一个占位符,它将被属性名称替换。然而,与数据注释不同,FluentValidation使用带有名称的占位符来更好地识别其含义。

现在,我们可以在验证器中添加这条消息,例如,对于FirstName属性:

RuleFor(p => p.FirstName).NotEmpty().
     WithMessage(Messages.NotEmptyMessage).
     WithName(Messages.FirstName);

我们使用WithMessage()来指定在之前的规则失败时必须使用的消息,之后我们添加WithName()调用以覆盖消息的{PropertyName}占位符所使用的默认属性名称。

注意

你可以在 GitHub 仓库中找到PersonValidator类的完整实现以及本地化消息,网址为 https://github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/blob/main/Chapter09/Program.cs#L129。

最后,我们可以在我们的端点中利用本地化的验证器,就像我们在第六章中做的那样,探索验证和映射

app.MapPost("/people", async (Person person, IValidator<Person> validator) =>
{
     var validationResult = await validator.
     ValidateAsync(person);
     if (!validationResult.IsValid)
     {
           var errors = validationResult.ToDictionary();
           return Results.ValidationProblem(errors, title: 
           Messages.ValidationErrors);
     }
     return Results.NoContent();
});

与数据注释的情况一样,validationResult变量将包含本地化错误消息,我们使用Results.ValidationProblem()方法(再次,使用title属性的定义)将其返回给调用者。

小贴士

在我们的例子中,我们已经看到如何使用WithMessage()方法显式地为每个属性分配翻译。FluentValidation还提供了一种替换所有(或部分)其默认消息的方法。你可以在官方文档中找到更多信息,网址为docs.fluentvalidation.net/en/latest/localization.xhtml#default-messages

这就结束了我们使用资源文件进行本地化的概述。接下来,我们将讨论处理旨在全球使用的服务时的重要话题:正确处理不同的时区。

为全局化的最小 API 添加 UTC 支持

到目前为止,我们已经为我们的最小 API 添加了全球化和本地化支持,因为我们希望它能够被尽可能广泛的受众使用,无论文化如何。但是,如果我们考虑让全球受众能够访问,我们应该考虑与全球化相关的几个方面。全球化不仅涉及语言支持;还有一些重要的因素我们需要考虑,例如地理位置以及时区。

例如,我们可以让我们的最小 API 在意大利运行,意大利遵循中欧时间(CET)(GMT+1),而我们的客户可以使用全球范围内的浏览器来执行单页应用程序,而不是移动应用。我们还可以有一个包含我们数据的数据库服务器,这可能是另一个时区。此外,在某个时刻,可能需要为全球用户提供更好的支持,因此我们必须将我们的服务迁移到另一个位置,这可能具有新的时区。总之,我们的系统可以处理不同时区的数据,并且,潜在地,同一服务在其生命周期中可能需要切换时区。

在这种情况下,理想的解决方案是使用DateTimeOffset数据类型,它包含时区信息并且JsonSerializer完全支持,在序列化和反序列化过程中保留时区信息。如果我们总能使用它,我们就能自动解决与全球化相关的任何问题,因为将DateTimeOffset值转换为不同的时区是直接的。然而,有些情况下我们无法处理DateTimeOffset类型,例如:

  • 当我们在一个依赖于DateTime的遗留系统上工作时,更新代码以使用DateTimeOffset不是一个选择,因为它需要太多的更改,并且与旧数据不兼容。

  • 我们有一个数据库服务器,例如 MySQL,它没有直接存储DateTimeOffset的列类型,因此处理它需要额外的努力,例如使用两个单独的列,增加了领域的复杂性。

  • 在某些情况下,我们可能对发送、接收和保存时区并不感兴趣——我们只想以“通用”的方式处理时间。

因此,在所有我们无法或不想使用DateTimeOffset数据类型的情况下,处理不同时区的最佳和最简单的方法之一是使用协调世界时(UTC)处理所有日期:服务必须假设它接收的日期是 UTC 格式,另一方面,API 返回的所有日期都必须是 UTC 格式。

当然,我们必须集中处理这种行为;我们不希望每次接收或发送日期时都要记住应用 UTC 格式的转换。知名的 JSON.NET 库提供了一个选项,用于指定在处理DateTime属性时如何处理时间值,允许它自动将所有日期视为 UTC,并在它们表示本地时间时将它们转换为该格式。然而,在最小 API 中使用的 Microsoft JsonSerializer的当前版本不包括此功能。从第二章探索最小 API 及其优势,我们知道我们无法更改最小 API 中的默认 JSON 序列化器,但我们可以通过创建一个简单的JsonConverter来克服对 UTC 支持的缺乏:

public class UtcDateTimeConverter : JsonConverter<DateTime>
{
     public override DateTime Read(ref Utf8JsonReader 
     reader, Type typeToConvert, JsonSerializerOptions  
     options)
     => reader.GetDateTime().ToUniversalTime();
     public override void Write(Utf8JsonWriter writer, 
     DateTime value, JsonSerializerOptions options)
     => writer.WriteStringValue((value.Kind == 
     DateTimeKind.Local ? value.ToUniversalTime() : value)
     .ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'
     fffffff'Z'"));
}

使用此转换器,我们告诉 JsonSerializer 如何处理 DateTime 属性:

  • 当从 JSON 中读取 DateTime 时,值将使用 ToUniversalTime() 方法转换为 UTC。

  • DateTime 必须写入 JSON 时,如果它表示本地时间(DateTimeKind.Local),则在序列化之前将其转换为 UTC – 然后,使用 Z 后缀进行序列化,表示时间是 UTC。

现在,在使用此转换器之前,让我们添加以下端点定义:

app.MapPost("/date", (DateInput date) =>
{
     return Results.Ok(new
     {
           Input = date.Value,
           DateKind = date.Value.Kind.ToString(),
           ServerDate = DateTime.Now
     });
});
public record DateInput(DateTime Value);

让我们尝试使用一个格式为 2022-03-06T16:42:37-05:00 的日期来调用它。我们将得到以下类似的结果:

{
  "input": "2022-03-06T22:42:37+01:00",
  "dateKind": "Local",
  "serverDate": "2022-03-07T18:33:17.0288535+01:00"
}

包含时区的输入日期已自动转换为服务器的本地时间(在本例中,服务器位于意大利,如开头所述),这也可以通过 dateKind 字段得到证明。此外,serverDate 包含一个相对于服务器时区的日期。

现在,让我们将 UtcDateTimeConverter 添加到 JsonSerializer

var builder = WebApplication.CreateBuilder(args);
//...
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.
JsonOptions>(options =>
{
     options.SerializerOptions.Converters.Add(new 
     UtcDateTimeConverter());
});

使用此配置,每个 DateTime 属性都将使用我们的自定义转换器进行处理。现在,再次执行端点,使用之前相同的输入。这次,结果将如下所示:

{
  "input": "2022-03-06T21:42:37.0000000Z",
  "dateKind": "Utc",
  "serverDate": "2022-03-06T17:40:08.1472051Z"
}

输入相同,但我们的 UtcDateTimeConverter 现在已将日期转换为 UTC,另一方面,已将服务器日期序列化为 UTC;现在,我们的 API 以集中的方式可以自动处理所有日期作为 UTC,无论其时区或调用者的时区如何。

最后,还有两个其他要点以确保所有系统都能正确地使用 UTC:

  • 当我们需要在代码中获取当前日期时,我们总是必须使用 DateTime.UtcNow 而不是 DateTime.Now

  • 客户端应用程序必须知道它们将接收到 UTC 格式的日期,并相应地操作,例如,调用 ToLocalTime() 方法

这样,最小化 API 真正实现了全球化,可以与任何时区一起工作;无需担心显式转换,所有输入或输出的时间都将始终是 UTC,因此处理它们将变得更加容易。

摘要

在一个互联互通的世界中,考虑到全球化和本地化支持来开发最小化 API 是至关重要的。ASP.NET Core 包含创建能够根据用户文化做出反应并提供基于请求语言翻译的服务所需的所有功能:使用本地化中间件、资源文件和自定义验证消息可以创建几乎支持所有文化的服务。我们还讨论了在处理不同时区时可能出现的全球化相关问题,并展示了如何使用集中的 UTC 日期时间格式来解决这些问题,以便我们的 API 可以无缝地在任何地理位置和时间区工作。

第十章评估和基准测试最小 API 的性能,我们将讨论为什么创建最小 API,并分析使用最小 API 相对于经典基于控制器的方法的性能优势。

第十章:评估和基准测试最小 API 的性能

本章的目的是理解创建最小 API 框架的动机之一。

本章将提供一些明显的数据和示例,说明您如何使用传统方法以及如何使用最小 API 方法来测量 ASP.NET 6 应用程序的性能。

性能是任何运行中的应用的关键;然而,它往往被放在次要位置。

一个高性能和可扩展的应用程序不仅取决于我们的代码,还取决于开发栈。今天,我们已经从 .NET 全框架和 .NET Core 过渡到 .NET,并开始欣赏新 .NET 在版本更新后所取得的性能——这不仅是因为新特性的引入和框架的清晰性,而且主要是因为框架已被完全重写并改进了许多特性,使其与其他语言相比既快速又具有竞争力。

在本章中,我们将通过比较其代码与传统开发方式开发的相同代码来评估最小 API 的性能。我们将了解如何利用 BenchmarkDotNet 框架评估 Web 应用程序的性能,这在其他应用场景中也可能很有用。

使用最小 API,我们有一个新的简化框架,通过省略一些我们在 ASP.NET 中视为理所当然的组件来提高性能。

本章我们将涉及的主题如下:

  • 最小 API 的改进

  • 通过负载测试探索性能

  • 使用 BenchmarkDotNet 基准测试最小 API

技术要求

许多系统可以帮助我们测试框架的性能。

我们可以测量一个应用每秒可以处理多少请求,与另一个应用相比,假设应用负载相等。在这种情况下,我们谈论的是负载测试。

为了将最小 API 放上测试台,我们需要安装 k6,这是我们进行测试将使用的框架。

我们将在仅运行 .NET 应用程序的 Windows 机器上启动负载测试。

要安装 k6,您可以选择以下两种方法之一:

  • 如果您使用的是 Chocolatey 包管理器 (chocolatey.org/),您可以使用以下命令安装非官方的 k6 包:

    choco install k6
    
  • 如果您使用 Windows 包管理器 (github.com/microsoft/winget-cli),您可以使用以下命令从 k6 清单中安装官方包:

    winget install k6
    
  • 您还可以使用 Docker 测试您在互联网上发布的应用程序:

    docker pull loadimpact/k6
    
  • 或者,我们像这样做,我们在 Windows 机器上安装了 k6,并从命令行启动了一切。您可以从以下链接下载 k6:dl.k6.io/msi/k6-latest-amd64.msi

在本章的最后部分,我们将测量调用 API 的 HTTP 方法的持续时间。

我们将站在系统的末端,就像 API 是一个黑盒一样,并测量反应时间。我们将使用 BenchmarkDotNet 工具——为了将其包含到我们的项目中,我们需要引用其NuGet包:

dotnet add package BenchmarkDotNet

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到,链接如下:

github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-Core-6/tree/main/Chapter10

最小 API 的改进

最小 API 不仅旨在提高 API 的性能,还旨在提高代码的便利性和与其他语言的相似性,以便将其他平台上的开发者更接近。从.NET 框架的角度来看,性能有所提升,因为每个版本都有令人难以置信的改进,从简化应用程序管道的角度来看也是如此。让我们详细看看哪些内容没有被移植,以及哪些内容提高了这个框架的性能。

最小 API 执行管道省略了以下功能,这使得框架更轻量:

  • 过滤器,例如IAsyncAuthorizationFilterIAsyncActionFilterIAsyncExceptionFilterIAsyncResultFilterIasyncResourceFilter

  • 模型绑定

  • 表单绑定,例如IFormFile

  • 内置验证

  • 格式化器

  • 内容协商

  • 一些中间件

  • 视图渲染

  • JsonPatch

  • OData

  • API 版本控制

.NET 6 的性能改进

版本迭代中,.NET 不断提升其性能。在框架的最新版本中,相较于之前版本的改进已经有所报道。以下是可以找到.NET 6 中所有新功能的完整总结:

devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/

使用负载测试探索性能

如何估算最小 API 的性能?有许多观点需要考虑,在本章中,我们将尝试从它们能够支持的负载角度来探讨。我们决定采用一个工具——k6,该工具对 Web 应用程序进行负载测试,并告诉我们最小 API 每秒可以处理多少请求。

如其创造者所述,k6 是一个开源的负载测试工具,它使性能测试对工程团队来说既简单又高效。该工具免费、以开发者为中心且可扩展。使用 k6,您可以测试您系统的可靠性和性能,并尽早捕捉到性能回归和问题。这个工具将帮助您构建健壮且可扩展的应用程序。

在我们的案例中,我们希望使用这个工具进行性能评估,而不是进行负载测试。在负载测试期间应考虑许多参数,但我们将只关注http_reqs指标,该指标表示系统正确处理了多少个请求。

我们同意 k6 创建者的测试目的,即性能合成监控

用例

k6 用户通常是开发者、QA 工程师、SDETs 和 SREs。他们使用 k6 来测试 API、微服务和网站的性能和可靠性。常见的 k6 用例包括以下内容:

  • 负载测试:k6 针对最小资源消耗进行了优化,并设计用于运行高负载测试(峰值、压力和浸泡测试)。

  • 性能和合成监控:使用 k6,您可以运行小负载测试以持续验证生产环境的性能和可用性。

  • 混沌和可靠性测试:k6 提供了一个可扩展的架构。您可以使用 k6 作为混沌实验的一部分来模拟流量,或者从您的 k6 测试中触发它们。

然而,如果我们想从上述角度评估应用程序,我们必须做出几个假设。当进行负载测试时,它通常比我们在本节中进行的测试要复杂得多。当应用程序被大量请求轰炸时,并非所有请求都会成功。我们可以这样说,如果响应失败的比例非常小,则测试成功。特别是,我们通常将 95 或 98 个百分位数的成果视为推导测试数字的统计数据。

在这个背景下,我们可以按以下步骤进行逐步负载测试:在爬坡阶段,系统将关注从 0 到 50 的虚拟用户VU)负载,大约 15 秒。然后,我们将保持用户数量稳定 60 秒,最后,将负载降至零虚拟用户,再持续 15 秒。

每个新编写的测试阶段都在stages部分的 JavaScript 文件中表达。因此,测试是在简单的经验评估下进行的。

首先,我们为 ASP.NET Web API 和最小 API 创建了三种类型的响应:

  • 纯文本

  • 非常小的JSON数据与调用相对比——数据是静态的,始终相同。

  • 在第三个响应中,我们使用 HTTP POST方法向 API 发送 JSON 数据。对于 Web API,我们检查对象的验证,而对于最小 API,由于没有验证,我们返回接收到的对象。

以下代码将用于比较最小 API 和传统方法之间的性能:

最小 API

app.MapGet("text-plain",() => Results.Content("response"))
.WithName("GetTextPlain");
app.MapPost("validations",(ValidationData validation) => Results.Ok(validation)).WithName("PostValidationData");
app.MapGet("jsons", () =>
     {
           var response = new[]
           {
                new PersonData { Name = "Andrea", Surname = 
                "Tosato", BirthDate = new DateTime
                (2022, 01, 01) },
                new PersonData { Name = "Emanuele", 
                Surname = "Bartolesi", BirthDate = new 
                DateTime(2022, 01, 01) },
                new PersonData { Name = "Marco", Surname = 
                "Minerva", BirthDate = new DateTime
                (2022, 01, 01) }
           };
           return Results.Ok(response);
     })
.WithName("GetJsonData");

传统方法

对于传统方法,已经设计了三个不同的控制器,如下所示:

[Route("text-plain")]
     [ApiController]
     public class TextPlainController : ControllerBase
     {
           [HttpGet]
           public IActionResult Get()
           {
                 return Content("response");
           }
     }
[Route("validations")]
     [ApiController]
     public class ValidationsController : ControllerBase
     {
           [HttpPost]
           public IActionResult Post(ValidationData data)
           {
                 return Ok(data);
           }
     }
     public class ValidationData
     {
           [Required]
           public int Id { get; set; }
           [Required]
           [StringLength(100)]
           public string Description { get; set; }
     }
[Route("jsons")]
[ApiController]
public class JsonsController : ControllerBase
{
     [HttpGet]
     public IActionResult Get()
     {
           var response = new[]
           {
              new PersonData { Name = "Andrea", Surname = 
              "Tosato", BirthDate = new 
              DateTime(2022, 01, 01) },
              new PersonData { Name = "Emanuele", Surname = 
              "Bartolesi", BirthDate = new 
              DateTime(2022, 01, 01) },
              new PersonData { Name = "Marco", Surname = 
              "Minerva", BirthDate = new 
              DateTime(2022, 01, 01) }
            };
            return Ok(response);
     }
}
     public class PersonData
     {
           public string Name { get; set; }
           public string Surname { get; set; }
           public DateTime BirthDate { get; set; }
     }

在下一节中,我们将定义一个options对象,其中我们将定义这里描述的执行斜坡。我们定义所有条款以考虑测试满足。作为最后一步,我们编写实际的测试,该测试只是使用GETPOST调用 HTTP 端点,具体取决于测试。

编写 k6 测试

让我们为之前章节中描述的每个场景创建一个测试:

import http from "k6/http";
import { check } from "k6";
export let options = {
     summaryTrendStats: ["avg", "p(95)"],
     stages: [
           // Linearly ramp up from 1 to 50 VUs during 10 
              seconds
              { target: 50, duration: "10s" },
           // Hold at 50 VUs for the next 1 minute
              { target: 50, duration: "1m" },
           // Linearly ramp down from 50 to 0 VUs over the 
              last 15 seconds
              { target: 0, duration: "15s" }
     ],
     thresholds: {
           // We want the 95th percentile of all HTTP 
              request durations to be less than 500ms
              "http_req_duration": ["p(95)<500"],
           // Thresholds based on the custom metric we 
              defined and use to track application failures
              "check_failure_rate": [
          // Global failure rate should be less than 1%
             "rate<0.01",
          // Abort the test early if it climbs over 5%
             { threshold: "rate<=0.05", abortOnFail: true },
           ],
     },
};
export default function () {
    // execute http get call
    let response = http.get("http://localhost:7060/jsons");
    // check() returns false if any of the specified 
       conditions fail
    check(response, {
           "status is 200": (r) => r.status === 200,
    });
}

在前面的 JavaScript 文件中,我们使用 k6 语法编写了测试。我们定义了选项,例如测试的评估阈值、要测量的参数以及测试应模拟的阶段。一旦我们定义了测试的选项,我们只需编写调用我们感兴趣的 API 的代码 – 在我们的案例中,我们定义了三个测试来调用我们想要评估的三个端点。

运行 k6 性能测试

现在我们已经编写了测试性能的代码,让我们运行测试并生成测试的统计数据。

我们将报告收集到的所有测试的一般统计数据:

  1. 首先,我们需要启动 Web 应用程序以运行负载测试。让我们从 ASP.NET Web API 应用程序和最小 API 应用程序开始。我们公开了 URL,包括 HTTPS 和 HTTP 协议。

  2. 将 shell 移动到根文件夹,并在两个不同的 shell 中运行以下两个命令:

    dotnet .\MinimalAPI.Sample\bin\Release\net6.0\MinimalAPI.Sample.dll --urls=https://localhost:7059/;http://localhost:7060/
    dotnet .\ControllerAPI.Sample\bin\Release\net6.0\ControllerAPI.Sample.dll --urls="https://localhost:7149/;http://localhost:7150/"
    
  3. 现在,我们只需为每个项目运行三个测试文件。

    • 这一个是针对基于控制器的 Web API 的:

      k6 run .\K6\Controllers\json.js --summary-export=.\K6\results\controller-json.json
      
    • 这一个是针对最小 API 的:

      k6 run .\K6\Minimal\json.js --summary-export=.\K6\results\minimal-json.json
      

这里是结果。

在传统开发模式且内容类型为plain-text的测试中,每秒处理的请求数为 1,547:

图 10.1 – 基于控制器的 API 和纯文本的负载测试

图 10.1 – 基于控制器的 API 和纯文本的负载测试

在传统开发模式且内容类型为json的测试中,每秒处理的请求数为 1,614:

图 10.2 – 基于控制器的 API 和 JSON 结果的负载测试

图 10.2 – 基于控制器的 API 和 JSON 结果的负载测试

在传统开发模式且内容类型为json且包含模型验证的测试中,每秒处理的请求数为 1,602:

图 10.3 – 基于控制器的 API 和验证负载的负载测试

图 10.3 – 基于控制器的 API 和验证负载的负载测试

在最小 API 开发模式且内容类型为plain-text的测试中,每秒处理的请求数为 2,285:

图 10.4 – 最小 API 和纯文本的负载测试

图 10.4 – 最小 API 和纯文本的负载测试

在最小 API 开发模式且内容类型为json的测试中,每秒处理的请求数为 2,030:

图 10.5 – 最小 API 和 JSON 结果的负载测试

图 10.5 – 最小 API 和 JSON 结果的负载测试

对于具有模型验证的 json 内容类型的最小 API 开发模式下的测试,每秒处理的请求数为 2,070:

图 10.6 – 最小 API 和无验证负载的负载测试

图 10.6 – 最小 API 和无验证负载的负载测试

在以下图像中,我们展示了三个测试功能的比较,报告了具有相同功能的请求数量:

图 10.7 – 性能结果

图 10.7 – 性能结果

正如我们所预期的,最小 API 比基于控制器的 Web API 快得多。

差异大约为 30%,这可不是一个小成就。

显然,正如之前提到的,最小 API 为了优化性能而缺少了一些功能,最显著的是数据验证。

在示例中,有效负载非常小,差异并不明显。

随着有效负载和验证规则的增加,两个框架之间的速度差异将只会增加。

我们已经看到如何使用负载测试工具来衡量性能,然后评估在相同数量的机器和用户连接的情况下,它每秒可以处理多少请求。

我们还可以使用其他工具来了解最小 API 对性能产生了强烈的积极影响。

使用 BenchmarkDotNet 基准测试最小 API

BenchmarkDotNet 是一个框架,允许您测量编写的代码,并比较不同版本或使用不同 .NET 框架编译的库之间的性能。

此工具用于计算任务执行所需的时间、使用的内存以及许多其他参数。

我们的案例是一个非常简单的场景。我们想要比较两个针对相同版本的 .NET Framework 编写的应用程序的响应时间。

我们如何进行这个比较?我们取一个 HttpClient 对象,并开始调用我们也为负载测试案例定义的方法。

因此,我们将获得两种方法的比较,这两种方法都利用了相同的 HttpClient 对象和具有相同功能的方法,但一个是用 ASP.NET Web API 和传统控制器编写的,而另一个是使用最小 API 编写的。

BenchmarkDotNet 帮助您将方法转换为基准测试,跟踪其性能,并共享可重复的测量实验。

在底层,它执行了许多魔法般的功能,这得益于 perfolizer 统计引擎,保证了可靠和精确的结果。BenchmarkDotNet 可以保护您免受流行的基准测试错误的影响,并在您的基准设计或获得的测量结果出现问题时发出警告。该库已被超过 6,800 个项目采用,包括 .NET Runtime,并得到 .NET 基金会的支持 (benchmarkdotnet.org/)。

运行 BenchmarkDotNet

我们将编写一个类,用于表示调用两个网络应用 API 的所有方法。让我们充分利用启动功能,并准备我们将通过POST发送的对象。标记为[GlobalSetup]的函数在运行时不会被计算,这有助于我们精确计算调用和从网络应用返回响应所需的时间:

  1. 注册Program.cs中实现 BenchmarkDotNet 的所有类:

    BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
    

在前面的代码片段中,我们已经注册了当前程序集,该程序集实现了在性能计算中需要评估的所有函数。标记为[Benchmark]的方法将被反复执行,以确定平均执行时间。

  1. 应用必须在发布时编译,可能还在生产环境中编译:

    namespace DotNetBenchmarkRunners
    {
         [SimpleJob(RuntimeMoniker.Net60, baseline: true)]
         [JsonExporter]
         public class Performances
         {
               private readonly HttpClient clientMinimal = 
               new HttpClient();
               private readonly HttpClient 
               clientControllers = new HttpClient();
               private readonly ValidationData data = new 
               ValidationData()
               {
                     Id = 1,
                     Description = "Performance"
               };
               [GlobalSetup]
               public void Setup()
               {
                     clientMinimal.BaseAddress = new 
                     Uri("https://localhost:7059");
                     clientControllers.BaseAddress = new 
                     Uri("https://localhost:7149");
               }
               [Benchmark]
               public async Task Minimal_Json_Get() => 
               await clientMinimal.GetAsync("/jsons");
               [Benchmark]
               public async Task Controller_Json_Get() => 
               await clientControllers.GetAsync("/jsons");
               [Benchmark]
               public async Task Minimal_TextPlain_Get() 
               => await clientMinimal.
               GetAsync("/text-plain");
               [Benchmark]
               public async Task 
               Controller_TextPlain_Get() => await 
               clientControllers.GetAsync("/text-plain");
    
               [Benchmark]
               public async Task Minimal_Validation_Post() 
               => await clientMinimal.
               PostAsJsonAsync("/validations", data);
    
               [Benchmark]
               public async Task 
               Controller_Validation_Post() => await 
               clientControllers.
               PostAsJsonAsync("/validations", data);
         }
         public class ValidationData
         {
               public int Id { get; set; }
               public string Description { get; set; }
         }
    }
    
  2. 在启动基准应用之前,启动网络应用:

最小 API 应用

dotnet .\MinimalAPI.Sample\bin\Release\net6.0\MinimalAPI.Sample.dll --urls="https://localhost:7059/;http://localhost:7060/"

基于控制器的应用

dotnet .\ControllerAPI.Sample\bin\Release\net6.0\ControllerAPI.Sample.dll --urls=https://localhost:7149/;http://localhost:7150/

通过启动这些应用,将执行各种步骤,并提取出我们在此处报告的时间线总结报告:

dotnet .\DotNetBenchmarkRunners\bin\Release\net6.0\DotNetBenchmarkRunners.dll --filter *

对于每个执行的方法,报告平均值或平均执行时间。

表 10.1 – 最小 API 和控制器基准 HTTP 请求

表 10.1 – 最小 API 和控制器基准 HTTP 请求

在以下表格中,Error表示平均值的可能变化量,这是由于测量误差造成的。最后,标准差(StdDev)表示与平均值之间的偏差。时间以μs为单位给出,因此如果没有使用具有该特性的仪器,很难从经验上测量这些时间。

摘要

在本章中,我们通过使用两种非常不同的方法,比较了最小 API 与传统方法的性能。

最小 API 并非仅为了性能而设计,仅基于这一点来评估它们是一个糟糕的起点。

表 10.1表明,最小 API 的响应与传统 ASP.NET Web API 应用的响应之间存在许多差异。

测试在同一台机器上使用相同的资源进行。我们发现最小 API 的性能比传统框架提高了大约 30%:

我们了解了如何测量我们应用的速度——这有助于了解应用是否能够承受负载以及它可以提供多少响应时间。我们还可以利用这一点来优化关键代码的小部分。

作为最后的注意事项,测试的应用实际上非常简单。在 ASP.NET Web API 应用中应该评估的验证部分几乎无关紧要,因为只有两个字段需要考虑。随着我们在最小 API 中已经描述的已消除组件数量的增加,两个框架之间的差距也在增加。

posted @ 2025-10-26 08:52  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报