ASP-NET9-API-最简指南第二版-全-

ASP.NET9 API 最简指南第二版(全)

原文:zh.annas-archive.org/md5/c6ddfde08070c007ba6d4f0f60c6a85f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是一本全面指南,深入探讨了使用最小 API 构建 ASP.NET 的精简和高效 Web 服务的世界。

随着网络开发领域的持续演变,对简洁性、速度和可维护性的需求不断增长。我写这本书是为了向开发者介绍——无论是有经验的还是新接触 ASP.NET 生态系统的——最小 API 的力量,这种方法允许创建轻量级且面向性能的应用程序。

为什么选择最小 API?

随着 2021 年 .NET 6 的发布,微软引入了最小 API 作为一种以更少的仪式和样板代码定义 HTTP API 的新方法。最小 API 专注于减少与较大框架(如 ASP.NET MVC)传统上相关的开销,同时仍然保持 .NET 平台的稳健性。通过去除不必要的复杂性,最小 API 使开发者能够快速原型设计、迭代和部署与需求相匹配的应用程序。

在本书中,我将向您介绍最小 API 的基本要素,从基本的路由和端点结构到高级功能,如依赖注入、身份验证和中间件集成。

我还将向您介绍一些核心设计原则,概述一些旨在保持您的最小 API 随时间保持可维护性、安全性和可扩展性的最佳实践。

每一章都旨在提供动手示例,确保到结束时,您不仅能够理解如何使用最小 API,而且还能理解它们在现代软件开发中的重要性。

在撰写本文时,.NET 9 已经达到了其第一个候选版本阶段(RC1),并且 .NET 9 非常接近全面可用。我在整本书中包含了有关 .NET 9 中新最小 API 功能的信息。

本书面向的对象

本书旨在作为最小 API 的入门指南,使对 C# 和 面向对象编程OOP)有基本了解的开发者能够探索主要概念,并开始他们的最小 API 开发之旅。

无论您是有经验的 ASP.NET 开发者希望利用新工具,还是寻求了解网络开发的新手,本书都将逐步引导您。它非常适合寻求构建微服务、初创公司原型设计新想法,或者甚至寻求以更有效的方式处理特定 API 需求的成熟企业。

本书涵盖的内容

第一章使用最小 API 开发入门,介绍了最小 API 的世界,帮助您了解它们在现代软件开发中的相关性以及它们与传统 API 方法有何不同。它还指导您如何设置其开发环境。

第二章创建您的第一个最小 API,介绍了最小 API 的核心元素,如端点、模型和路由,然后指导您使用不同的 HTTP 方法构建端点。

第三章最小 API 的解剖结构,更详细地探讨了最小 API 的构建块,概述了其中包含的各种组件,以及请求生命周期的概述。

第四章处理 HTTP 方法和路由,重点介绍了如何处理传入的请求以及这如何根据不同的 HTTP 方法而有所不同。它涵盖了路由参数的管理,并介绍了最小 API 端点中的请求验证和错误处理。

第五章中间件管道,解释了 ASP.NET 中中间件的概念,然后指导您如何在最小 API 的上下文中配置和实现它。

第六章参数绑定,讨论了如何将参数发送到最小 API 端点。探讨了各种参数绑定来源,并提供了如何创建自定义绑定的示例。

第七章最小 API 中的依赖注入,在探讨其在最小 API 中的应用之前,介绍了依赖注入作为软件开发概念。还概述了依赖注入的最佳实践。

第八章将最小 API 与数据源集成,帮助您了解如何将数据集成到最小 API 中,示例侧重于 SQL Server 和 MongoDB。

第九章使用 Entity Framework Core 和 Dapper 进行对象关系映射,进一步阐述了第八章的学习要点,介绍了对象关系映射ORM)框架,如 Entity Framework Core 和 Dapper。本章提供了相应框架的配置示例以及如何使用它们来创建 CRUD 操作。

第十章分析和识别瓶颈,重点介绍了管理和优化最小 API 的性能。探讨了各种分析工具,并探索了几个常见的性能瓶颈。

第十一章利用异步编程实现可扩展性,展示了在最小 API 中使用异步编程的优势,提供了各种异步模式的示例。本章还提供了与最小 API 中异步执行相关的常见陷阱和挑战的示例。

第十二章增强性能的缓存策略,通过介绍缓存及其在最小 API 中的位置进一步探讨了性能主题。展示了各种缓存技术,例如使用 ASP.NET 的内存缓存和 Redis。

第十三章最小 API 弹性的最佳实践,将我们的注意力从性能转向弹性,提出了在最小 API 中如何结构化代码以鼓励长期功能的方法。还探讨了诸如错误处理和安全考虑等主题。

第十四章最小 API 的单元测试、兼容性和部署,通过涉及最小 API 开发的后期阶段的话题结束本书。展示了使用 xUnit 进行单元测试和集成测试,概述了特定的兼容性要求,并提供了如何将最小 API 部署到各种托管平台的实际示例。

要充分利用这本书

您需要具备对面向对象编程语言和 C#的基本理解,才能理解本书中的示例。您还需要了解什么是 API,以及关系型数据库(如 SQL)是如何工作的。

本书涵盖的主题 所需技能水平
C# .NET 9 SDK(软件开发工具包)
SQL Microsoft SQL Server
MongoDB Server Microsoft SQL Server Management Studio
MongoDB Compass 无 - 书中涵盖了最小 API 的设置和配置
Visual Studio 2022 基础
Visual Studio Code 基础(如果使用 - 可使用 Visual Studio 作为替代)
面向对象编程 基础

由于.NET 是跨平台的,我们假设您的操作系统是 Windows、MacOS 或 Linux 之一,它们都是兼容的。

使用 MacOS 或 Linux 的读者

对于使用这些操作系统的读者,建议使用 Visual Studio Code 作为 Visual Studio 2022 的替代方案。

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

下载示例代码文件

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

我们还有其他丰富的书籍和视频的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在Employees表中,我们将Id列设置为标识列,这意味着 SQL Server 将在插入任何记录时填充它,每次插入时Id值增加1。”

代码块设置如下:

app.MapPut("/employees", (Employee employee) =>
{
    EmployeeManager.Update(employee);
    return Results.Ok();
});

任何命令行输入或输出都按照以下方式编写:

mongodb://localhost:27017/MyCompany

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“您可以通过前往工具 | 管理 NuGet 包 | 管理控制台 来完成此操作。”

小贴士或重要笔记

看起来像这样。

联系我们

我们读者的反馈总是受欢迎的。

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

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

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并提供材料的链接。

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

分享您的想法

一旦您阅读了Minimal APIs in ASP.NET 9,我们很乐意听听您的想法!请点击此处直接进入本书的亚马逊评论页面并分享您的反馈。

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

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

请放心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

img

packt.link/free-ebook/978-1-80512-912-7

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一部分 - 最小 API 简介

在这部分,我们为理解最小化 API 打下基础。我们将探讨如何快速开始开发,并检查构成最小 API 的基本构建块。无论您是 API 的新手还是有经验,这部分都将确保您对核心概念有牢固的掌握。

本部分包含以下章节:

  • 第一章使用最小 API 开发入门

  • 第二章创建您的第一个最小 API

  • 第三章最小 API 的解剖结构

第一章:使用最小 API 开发快速入门

作为用户,我们将与应用程序的交互与 用户界面UI)联系起来。这个界面由允许代码与用户之间交互的交互元素组成。您可以将它想象成 店面,一个您可以浏览可用商品或请求适当操作的地方,例如预订假期或添加商品到购物车。

如果 UI 是客户与我们的 商店 交互的地方,那么 应用程序编程接口API)就是商店的后方。这是我们接收货物、移动商品、处理订单和管理库存的地方。

大多数开发者都有一些与 API 交互或编写 API 的经验,但是什么让 最小 API 与其他 API 不同呢?

微软于 2021 年随着 .NET 6 的发布引入了最小 API。其目的是让开发者能够以最少的样板代码创建 API,从而让他们能够专注于请求和响应之间使用的业务逻辑的精髓。

它们为 API 开发提供了一个轻量级解决方案,这对于项目来说通常是一个好的起点,因为它们设置起来所需的努力要少得多。当您希望快速启动系统或依赖项数量较少时,这是一个关键优势。这也意味着由于与更传统的 API 格式相比减少了开销,最小 API 的性能可能更好。在本章中,我们将学习如何利用最小 API 的这些优势。

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

  • 理解最小 API

  • 将最小 API 与传统 API 方法进行对比

  • 最小 API 在现代开发中的重要性

  • 安装所需的工具和依赖项

  • 配置开发环境

技术要求

要遵循本章中的说明,您需要在您的 Windows、macOS 或 Linux 机器上安装以下内容:

  • .NET 9.0 软件开发 工具包SDK

  • Visual Studio 或 Visual Studio Code

  • C# 扩展程序用于 Visual Studio Code(如果您正在使用 Visual Studio Code)

如果您在 Windows 上工作,建议您使用 Visual Studio,尽管 Visual Studio Code 仍然可以使用。如果您是 Mac 或 Linux 用户,您应该使用 Visual Studio Code。(在撰写本文时,Visual Studio for Mac 预计将于 2024 年 8 月 31 日退役。)

本章的代码可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

理解最小 API

当涉及到设计和构建 API 时,我们有各种各样的风格、方法和模板可供选择。多年来,.NET 已经证明了自己是通用 API 开发的绝佳选择。现代.NET 为我们提供了两种主要的 API 框架,一种比另一种更传统。其中一种选择当然是最小 API,与它的前辈基于控制器的 API 相比,在.NET 中这仍然是一个相对较新的功能。

最小 API 的目标是简洁。更少的代码、更少的仪式和更少的复杂性。因此,最小 API 非常适合微服务架构,在这种架构中,你有大量的小型组件,所有这些组件都需要一种在彼此之间传输数据的方式。

它们的简洁性也使得它们更容易阅读,因为一小块代码就可以处理 API 的所有经典功能,例如接收 HTTP 请求、路由、利用依赖项、访问服务和向客户端发送响应。

最小 API 的一个值得称赞的方面是它们降低了 API 开发的门槛。它们提供了一种更易于访问、更易于阅读的代码结构方式,在大多数情况下,由于减少了开销,性能也更好。

使用最小 API,只需四行代码就可以创建一个简单的 API。以下是一个经典的hello world示例:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();

恭喜!你刚刚创建了一个 API!让我们更深入地了解示例中发生了什么:

  • 在第一行,我们通过调用CreateBuilder并传递可能需要的任何命令行参数来创建一个WebApplicationBuilder的实例。把这看作是我们创建的 API 的蓝图。最小 API 就像任何其他 ASP.NET 核心应用程序一样,因此它需要一个管道来运行。WebApplicationBuilder为我们提供了这个管道。

  • 然后,我们调用我们创建的这个实例上的Build(),这会产生一个我们称为appWebApplication实例。这是我们 API。

  • 第三行将应用程序根路径上的任何传入 HTTP GET 请求映射:("/")。随后,我们使用 lambda 表达式来指示在接收到请求时应执行的逻辑。在这种情况下,我们返回字符串Hello World!

  • 最后,第四行启动了应用程序,使其能够监听传入的请求。

现在你已经对最小 API 有了高层次的理解,让我们比较一下它们与更传统的 API 格式。

对比最小 API 与传统 API 方法

与最小 API 相比,.NET 中更传统的 API 格式是基于控制器的 API。这些在 ASP.NET 模型-视图-控制器MVC)项目中或 ASP.NET Web API 项目中更为常见。然而,无论你是否在构建 MVC 项目,这两种 API 类型都使用控制器。

控制器只是简单的类,在 API 中有许多职责,如下所示:

  • 通过使用各种 HTTP 方法(如 GETPOSTPUTDELETEPATCH)的 actions 处理传入的请求。

  • 处理请求中发送的数据,这些数据通过查询字符串参数或请求体内部发送。

  • 通过服务与数据模型交互并处理业务逻辑。

  • 生成对调用客户的响应。这些响应可以是 JSON、XML 或许多其他格式。

    将请求路由到应用程序的其他区域,即指向特定 URL 的页面。

当使用基于控制器的 API 时,每个控制器往往专注于一个特定的应用程序域。例如,您可能有一个专门处理所有 employees 的控制器,另一个专门处理 inventory。这对于将业务逻辑分离到相关区域是很好的,但需要很多仪式,例如需要从基 controller 类继承,需要添加属性来定义 HTTP 方法,或者为每个控制器管理文件夹结构。

这里是一个 Employee 控制器的示例。注意控制器类型([ApiController])和路由的属性使用。同时观察类是如何需要从 ControllerBase 继承的,以及通过类构造函数进行依赖注入以获取 IEmployeeRepository,而这仅适用于 employees!我们还需要在为 inventory 控制器创建的单独类中再次执行所有这些操作,仪式就这样继续:

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
namespace EmployeeAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class EmployeesController : ControllerBase
    {
        private readonly IEmployeeRepository
            _employeeRepository;
        public EmployeesController(
            IEmployeeRepository employeeRepository)
        {
            _employeeRepository = employeeRepository;
        }
        [HttpGet]
        public ActionResult<IEnumerable<Employee>>
            GetEmployees()
        {
            var employees =
                _employeeRepository.GetEmployees();
            return Ok(employees);
        }
        [HttpPost]
        public ActionResult<Employee>
            CreateEmployee(Employee employee)
        {
            _employeeRepository.AddEmployee(employee);
            return CreatedAtAction(nameof(GetEmployees),
                new{id = employee.Id }, employee);
        }
    }
}

相比之下,你可以在应用程序的入口点直接创建一个最小的 API 端点,其中路由、依赖注入和处理器都定义在内联,就像以下示例中那样:

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        builder.Services.AddSingleton<IEmployeeRepository,
            EmployeeRepository>();
        var app = builder.Build();
        app.MapGet("/api/employees",
            (IEmployeeRepository employeeRepository) =>
        {
            var employees =
                employeeRepository.GetEmployees();
            return Results.Ok(employees);
        });
        app.Run();
    }
}

如您所见,在一个更简单、更小的代码块中(并且不需要单独的控制器类),我们已经注册了一个用于依赖注入的服务,添加了一个 HTTP GET 端点,注入了我们的服务,运行了所需的逻辑,并返回了结果。

最小 API 与控制器中的依赖注入

我们将在本书的后面部分介绍依赖注入,但重要的是要知道,如示例中所示,依赖注入在基于控制器的 API 中需要更多的配置,因为您通常在 Startup.cs 中在应用程序启动时注册您的依赖项。最小 API 允许您以更轻量、更直接的方式在任何需要的地方注入依赖项,而不需要 Startup.cs。还重要的是要注意,对于所展示的示例,您需要创建自己的 IEmployeeRepository 才能使代码工作。

对最小 API 以及它们与其他开发方法的不同之处有一个整体的理解对于它们的最佳使用至关重要。为了了解更多背景信息,让我们看看最小 API 如何适应现代软件开发的环境。

最小 API 在现代开发中的重要性

创建更轻量级和简单的 API 的概念已经存在了一段时间,但最小化 API 的采用率在过去几年中有所增加。Flask 和 Express.js 分别促进了 Python 和 Node.js 中 API 开发的某些最小化元素,但与竞争对手相比,.NET 最近进入市场是专门设计来利用轻量级、简单 API 的优势。

现在,随着最小化 API 的到来并在主流开发项目中得到应用,开发者们正在享受无需进行大量设置和配置的好处。他们可以在两分钟内拥有一个可工作的 API 并运行它,然后在另外两分钟内将其部署到云端。这对于快速将软件推向市场对成功至关重要的行业来说提供了巨大的优势。

此外,您编写的 API 可以利用.NET 成熟的跨平台生态系统,其中包括强大的库和现成的安全解决方案,用于请求验证、跨站请求伪造CSRF)保护和授权。

到目前为止,我们已经对最小化 API 及其在现代软件开发中的位置进行了通用的探讨。接下来,我们将开始配置我们的环境以构建最小化 API 项目。与大多数项目设置一样,首先要配置的是工具和依赖项。按照下一节中的步骤开始准备您的开发环境。

安装所需工具和依赖项

为了开始使用最小化 API,我们需要安装一些工具。

让我们从安装.NET 9.0 SDK 开始。导航到微软.NET SDK 下载页面dotnet.microsoft.com/en-us/download/dotnet。(如果您已经安装了 SDK,可以跳过此步骤。)

在撰写本文时,SDK 可以通过以下步骤获得:

  1. 选择适合您操作系统和系统架构的适当构建版本。例如,如果您正在运行 64 位 Windows,您将下载x64。同样,如果您正在运行带有 ARM CPU 的 Mac,您将在macOS旁边选择Arm64。Linux 通常有些不同,因为使用包管理器来获取 SDK。

    如果您是 Linux 用户,请遵循针对您特定 Linux 分发的相关微软文档:

图 1.1:选择正确的安装程序

图 1.1:选择正确的安装程序

在我们继续之前,让我们快速区分安装程序和二进制文件。当软件被添加到系统中时,它通常包含多个文件,这些文件包含在程序运行时执行的代码。这些文件是二进制文件:构成整体应用程序的库或代码模块。安装程序会自动将这些组件放置在宿主系统上的相关位置。由于二进制文件由安装程序管理,如果您仅下载二进制文件,它们不会自动放置在特定位置。通常由安装程序执行的配置将不会发生。如果您想以不同于安装程序通常的方式配置应用程序,这有时是必要的。

当您从 Microsoft 的网站下载.NET SDK 时,您通常可以选择下载安装程序或二进制文件。最简单的选项是使用安装程序,因为它会自动为您配置.NET 开发环境。这是本书中示例所使用的版本,我也推荐您使用它。

  1. 一旦安装程序下载完成,打开它并按照提示操作。您需要管理员权限来安装 SDK。

图 1.2:.NET SDK 安装程序

图 1.2:.NET SDK 安装程序

现在我们已经安装了 SDK,是时候安装 Visual Studio(在 Windows 上)或 Visual Studio Code(在 macOS 或 Linux 上)了。您可以从 Visual Studio 网站visualstudio.microsoft.com/downloads获取这两个应用程序中的任何一个。

.NET 版本

安装后显示的.NET 版本和本例中显示的.NET 版本可能不同。

安装 Windows 上的 Visual Studio

按照以下步骤在您的设备上安装 Visual Studio:

  1. 在下载页面,选择您想要的 Visual Studio 版本。如果您没有 Visual Studio 许可证,您可以选择社区版

  2. 就像使用 SDK 的安装程序一样,只需按照 Visual Studio 向导中的提示操作,将其安装到您的机器上的目标位置。

  3. 在设置过程中,您将被提示选择相关的工作负载。至少,您需要选择ASP.NET 和 Web 开发以开发最小 API。之后,您将被告知有关可选添加的内容。这些内容对于最小 API 开发并非至关重要,因此一旦您通过了图 1.3所示的屏幕,您只需简单地点击下一步即可。

图 1.3:安装 Visual Studio 时的工作负载选择

图 1.3:安装 Visual Studio 时的工作负载选择

接下来,让我们安装 Visual Studio Code。

安装 Mac 和 Linux 上的 Visual Studio Code

Visual Studio Code 是一个免费应用程序。只需下载针对您的目标操作系统的相关安装程序。然后,执行以下操作:

  1. 下载完成后,运行安装程序。

  2. 打开 Visual Studio Code 并点击左侧工具栏中的 扩展 按钮(或使用键盘快捷键,Ctrl + Shift + X)。

  3. 在扩展面板顶部的搜索栏中搜索 C#。你会看到一个具有相同名称的扩展出现。这是 Visual Studio Code 的 C# 扩展,你将需要在 Visual Studio Code 中用 C# 进行编程。点击 安装

图 1.4:Microsoft 为 Visual Studio Code 提供的官方 C# 扩展

图 1.4:Microsoft 为 Visual Studio Code 提供的官方 C# 扩展

现在,你已经安装了编写 .NET 最小 API 所需的所有先决工具。接下来,我们将设置我们的开发环境。

配置开发环境

我们将在下一章开始开发我们的第一个最小 API,但在那之前,让我们创建编写代码所需的项目结构。

为了构建最小 API,我们需要在一个 ASP.NET Core 项目中工作。根据你使用的是 Visual Studio 还是 Visual Studio Code,有几种方法可以创建此类项目。

在 Visual Studio 中创建一个项目

让我们从在 Visual Studio 中创建一个项目开始:

  1. 打开 Visual Studio。

  2. 下一个图所示的屏幕为你提供了搜索你希望创建的项目类型的选项。搜索 ASP.NET Core Empty 并从列表中选择它,然后点击 下一步。(确保你选择模板的 C# 版本。不要使用 F# 版本,因为这不在本书的范围之内。)

图 1.5:Visual Studio 的新项目创建界面

图 1.5:Visual Studio 的新项目创建界面

  1. 给你的新项目命名并选择一个文件夹位置来保存它。然后,点击 下一步

图 1.6:Visual Studio 中的项目配置(ASP.NET Core Empty)

图 1.6:Visual Studio 中的项目配置(ASP.NET Core Empty)

  1. 选择你喜欢的 .NET 版本。本书使用 .NET 9,我们刚刚安装了 .NET 9 SDK,因此从列表中选择此版本。配置 HTTPS 应该默认选中。这可以保持选中状态。最后,点击 创建。然后你的项目将被创建。

毫无疑问,Visual Studio 是此类项目最常用的 IDE。然而,在较新的 IDE 中创建最小 API 项目也是可能的,例如 Visual Studio Code。让我们探索如何在 Visual Studio Code 中设置相同的项目。

在 Visual Studio Code 中创建一个项目

在本节中,我们将创建一个 Visual Studio Code 中的项目:

  1. 打开 Visual Studio Code,通过点击 终端 然后点击 新建终端窗口(或使用键盘快捷键,Ctrl + Shift)来打开一个终端窗口。

  2. 将以下命令输入到终端窗口中,将 MyProjectName 替换为你的项目名称:

    dotnet new web -o MyProjectName
    cd MyProjectName
    code -r ../MyProjectName
    
  3. 当出现对话框询问你是否信任作者并向项目添加所需资产时,选择

这两种项目配置都将创建我们在本章早期探索的最小化 API 示例,在 HTTP GET 端点上返回 Hello World!

// A builder is initialized and then built.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// GET endpoint mapped onto the base URL route, with a
// function body that returns a string.
app.MapGet("/", () => "Hello World!");
//The initialized app is started
app.Run();

你现在有了最小化 API 项目的坚实基础。

我们可以通过点击 Visual Studio Code 和 Visual Studio 中的 播放 按钮来获取此示例的输出。这将以调试模式运行项目,打开一个浏览器窗口,在这个例子中,它简单地显示 Hello World! 。这个按钮的位置根据你使用的 IDE 而略有不同。

在 Visual Studio Code 中,它看起来如下所示:

图 1.7:在 Visual Studio Code 中调试项目

图 1.7:在 Visual Studio Code 中调试项目

当前的 Visual Studio 中的按钮看起来像 图 1 .8 :

图 1.8:在 Visual Studio 中调试项目

图 1.8:在 Visual Studio 中调试项目

让我们回顾一下本章所学的内容!

摘要

在本章中,你了解了最小化 API 在现代应用开发中的作用,你现在应该对它们的优点有一个很好的总体理解。

你学习了如何将最小化 API 与基于控制器的 .NET API 区分开来,并了解了这两种格式的优缺点。

你已经安装了基本所需的库和工具,以便开始你的旅程,并学习了如何在 Visual Studio 或 Visual Studio Code 中创建新项目。

本章的关键要点是,最小化 API 促进简单性、高性能和可维护性。

在下一章中,我们将开始编写我们的第一个最小化 API 端点,它将能够处理不同路由上的各种 HTTP 方法。我们将构建最小化响应并发送回客户端,以及调用我们的 API。

第二章:创建你的第一个最小化 API

在上一章中,你采取的步骤充分体现了.NET 生态系统的便利性。.NET 中的最小 API 不仅具有最小逻辑和依赖,而且具有最小设置要求,真正做到了名副其实。

只需创建你的第一个项目,你实际上已经创建了你的第一个最小化 API。它开箱即用,包含一个GET端点,返回响应。

显然,最小 API 的内容远不止我们在Hello World示例中看到的那样。我们需要考虑不同的 HTTP 请求方法、不同的端点以及更高级的响应生成,这些都是简单 API 的一部分。

在本章中,我们将涵盖以下主要内容:

  • 项目结构和组织

  • 定义端点和路由

  • 构建员工管理 API

  • 处理 HTTP 请求

到本章结束时,你将获得定义端点以处理 HTTP 请求的经验。你还将能够实现基本的 HTTP 请求处理和响应生成。

技术要求

要遵循本章的指示,你需要在你的 Windows、macOS 或 Linux 机器上安装以下内容:

  • .NET 9.0 软件开发 工具包 ( SDK )

  • Visual Studio 或 Visual Studio Code

  • Visual Studio Code 的 C#扩展(如果你使用 Visual Studio Code)

如果你遵循了第一章中的设置指南,那么你就可以按照本章的指示进行操作。然而,如果你仍然需要配置前面的工具,那么请在安装所需工具和依赖项部分下遵循第一章的设置说明。本章的代码可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET -9

项目结构和组织

关于项目结构和组织的规则并不完全严格,但确保你以一致和可访问的方式组织项目是很重要的。在一般情况下,我们追求的是简单性。在最小 API 中,项目结构和组织也是如此。因此,以下示例中的最小 API 项目结构可能不会让你感到惊讶,它将非常基础。

在以下三个小节中,我们将探讨简化项目结构所需元素。

端点

端点是 API 的开放门户。每个端点位于你的域(例如,员工或库存)的一个区域,并负责该域内的特定操作,如添加、更新或删除库存项。

在基于 ASP.NET Controller 的项目中,您通常会根据域的每个区域将端点分配到逻辑组中,称为 Controllers。每个 Controller 是一个包含与该 Controller 的域区域相关的端点的类。

然而,在最小化 API 中,端点通常简单地定义在应用程序的入口点,在 Program.cs 中。

模型

就像在 Model-View-ControllerMVC)项目中一样,在最小 API 中,模型可以用来封装域对象。模型通常创建为其自己的类。在即将到来的示例中,我将创建用于管理员工的 API 的逻辑。因此,我将使用 Employee 类作为模型。

使用一个类来表示域对象会带来许多好处,包括以下内容:

  • 关注点分离:模型封装了应用程序的数据,将其与业务逻辑分离,在我们的情况下,业务逻辑将在 API 层找到。

  • 可重用性:模型对象可以在应用程序的业务逻辑中被重用。

  • 松散耦合:由于数据和业务逻辑之间的关注点分离,API 的更改不需要无意中影响数据的结构。这在常见情况下尤为重要,其中模型通过 ORM(如 Entity Framework 或 Dapper)镜像数据库表的结构(见 第八章)。

保持组织

虽然最小 API 项目结构可以保持非常简单,但仍然重要的是要保持它们有组织。良好的做法是将所有模型放在专门的 Models 文件夹中,所有端点放在它们自己的 Endpoints 文件夹中。

对于项目结构拼图中的最后一部分,我们有路由。

路由

如果端点是进入 API 的敞开大门,那么 路由 就是每扇门的位置。通过创建路由,您正在定义您的 API 将响应的 URL,以及将执行哪个逻辑片段。

路由可以是独立的或包含将被传递到结果 API 逻辑中的参数。

在以下页面上的端点中,您将看到以字符串形式定义的路由,指示应附加到应用程序的基本 URL 上的文本,以访问特定端点。

例如,如果我们的应用程序托管在 getInventoryItem 上,该端点的完整 URL 将成为 adventureworks.com/getInventoryItem

我们可以,然而,使我们的路由更加通用。getInventoryItem 确实很清晰,但更好的做法是,在可能的情况下,向通用路由发送不同类型的请求,根据使用的 HTTP 方法触发不同的逻辑。

例如,我们不必将路由命名为getinventoryitem,而可以将通用名称inventoryitems应用于与 HTTP 方法相关的任何路由。这意味着对这个路由的GET请求(我们将在下一节中了解这些方法)将获取一个库存项目,但对该路由的POST请求将创建一个。

这被认为是一种命名路由的最佳实践,原因有几个:

  • 一致性:由于具有通用端点已成为 API 约定,它允许您的 API 符合一个已同意的标准。

  • 直观性:大多数消费 API 的开发人员都会遵循相同的标准。这意味着他们将能够更快地了解您的 API。

  • RESTful 原则:允许 HTTP 方法识别端点的功能而不是路由,使我们能够符合 RESTful 原则,这些原则鼓励良好的CREATE、READ、UPDATE、DELETECRUD)操作、幂等性(在多个相同的请求中,服务器状态不会改变,并且每次对相同请求返回相同的结果),以及需要全面覆盖标准 HTTP 方法(GETPOSTPUTPATCHDELETE)。

与列表相反,如果您有支持 HTTP 方法的 API 端点,这些方法在非常特定的用例中,您仍然可以创建更具体的路由。然而,常规做法是使用我们概述的通用路由命名约定。

理解路由对于最小 API 开发至关重要,除了大多数 API 框架之外。让我们转向定义路由及其端点的方式。

定义端点和路由

与任何其他 RESTful API 框架一样,使用不同的 HTTP 方法访问最少的 API 端点。根据用于联系 API 端点的 HTTP 方法,会产生不同的结果或执行不同的操作。

在接下来的几节中,我们将查看一些端点与不同 HTTP 方法映射的示例。

GET 方法

HTTP GET 方法是一个请求信息。在成功检索后,API 端点返回一个 200 OK 状态码,以及请求的数据。

示例显示一个GET端点映射到"/employees"路由。它获取 URL 中包含的员工 ID,并使用这个 ID 来查找相关的员工数据,然后再返回。

例如,如果这个 API 托管在 constoso.com,对contoso.com/employees/24GET调用将检索 ID 为24的员工:

app.MapGet("/employees/{id}", (int id) =>
{
    var employee = EmployeeManager.Get(id);
    return Results.Ok(employee);
});

让我们来看看POST方法。

POST 方法

HTTP POST 方法是创建某物的请求。在成功执行后,API 端点通常返回一个201 Created状态码(这是最佳实践,尽管一些 API 返回标准的200 OK代码),以及任何相关的数据。

以下示例展示了将 POST 方法映射到 "/employees" 端点。它期望接收一个 JSON 格式的员工负载。端点然后将负载转换为 Employee 类型的对象,在调用一些其他后端代码以使用此对象作为参数创建 Employee 之前。

如果一切按预期工作,端点返回 201 Created 状态码,以及 Employee Created 消息。

例如,如果这个 API 在 constoso.com 上托管,一个 POST 调用将执行以下代码:

app.MapPost("/employees", (Employee employee) =>
{
    EmployeeManager.Create(employee);
    return Results.Created();
});

接下来,我们将查看 PUT 方法。

PUT 方法

HTTP PUT 方法是一个用于更新内容的请求。重要的是要记住,与具有自己更新方式的 PATCH 方法相比,PUT 方法以特定的方式更新资源。

PUT 方法需要一个表示正在更新的整个资源的负载。因此,在我们的 Employee API 的例子中,如果您要使用 PUT 端点更新现有员工,API 端点将期望在请求中发送一个完整的 Employee 对象。然后它会找到现有的员工,并用请求中发送的一个替换它。

在成功执行后,API 端点返回标准的 200 OK 状态码。

示例展示了将 PUT 方法映射到 "/employees" 路由。它期望接收一个 JSON 格式的员工负载。与前面的 POST 示例类似,端点将此 JSON 负载转换为 Employee 类型的对象,然后在找到原始员工并调用一个方法用更新后的对象替换它之前。

例如,如果这个 API 在 constoso.com 上托管,一个 PUT 调用将执行以下代码:

app.MapPut("/employees", (Employee employee) =>
{
    EmployeeManager.Update(employee);
    return Results.Ok();
});

PATCH 方法

PUT 方法类似,HTTP PATCH 方法也是一个用于更新内容的请求。然而,它执行更新的方式不同。PATCH 方法不需要包含整个对象表示的负载,只需将需要更改的个别值作为请求的一部分发送即可。然后 API 可以负责更新现有对象的相关属性。

在成功执行后,API 端点通常返回标准的 200 OK 状态码。

示例展示了将 PATCH 方法映射到 "/updateEmployeeName" 路由。它期望接收一个类似于 POSTPUTEmployee 对象。然而,它只对 NameId 属性感兴趣。这意味着只要发送一个包含这些属性的 JSON 负载,它就会工作。使用这些属性,代码根据给定的 Id 获取正确的 Employee 类型对象。然后只更新检索到的员工的名称属性,而不覆盖整个对象。

例如,如果这个 API 在 constoso.com 上托管,一个 P ATCH 调用将执行以下代码:

app.MapPatch("/updateEmployeeName", (Employee employee) =>
{
    EmployeeManager.ChangeName(employee.Id, employee.Name);
    return Results.Ok();
});

我们将要查看的最后一个方法是 DELETE

DELETE 方法

DELETE 方法在描述其功能方面是自解释的。将资源的 ID 作为参数发送,就像我们在GET端点所做的那样,API 可以定位到该特定资源并将其删除。

在大多数情况下,成功时,DELETE方法通常会返回标准的200 OK状态码,但也可以返回204 No Content,这也是可以的。

以下示例显示了一个映射到"/employees"路由的DELETE方法。端点将使用Id参数来找到要删除的Employee类型的对应对象。删除Employee对象后,它向客户端返回204 No Content状态码。

如果这个 API 托管在 constoso.com 上,对contoso.com/employeesDELETE调用将执行以下代码:

app.MapDelete("/deleteEmployee/{id}", (int id) =>
{
    EmployeeManager.Delete(id);
    return Results.Ok();
});

一旦你觉得你已经理解了我们 API 提供的不同 HTTP 方法,就转到下一节,开始基于一个简单、真实世界的用例构建一个基本的 API。

构建员工管理 API

现在我们已经概述了路由和端点,以及它们如何支持各种 HTTP 方法,让我们开始构建一个新的最小 API 项目,以员工管理为例。

本节的目标是创建一个员工库,我们的 API 可以在其上操作。然后,API 将能够获取、创建、更新和删除员工。

创建 API

按照以下步骤创建员工管理 API。

如果你还没有按照第一章中的步骤在 Visual Studio 或 Visual Studio Code 中创建你的 ASP.NET 项目,请首先遵循这些步骤,然后继续下一步:

  1. 你可能已经在项目中有了这个,如果没有,确保它在Program.cs的顶部。这将构建托管最小 API 的WebApplication实例:

    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
  2. 上一步中的两行足以构建WebApplication实例,但仍然需要启动。将此行添加到类的底部以启动实例:

    app.Run();
    

现在你有一个可运行的程序,但没有端点。在我们定义这些端点之前,我们需要一些数据来工作。我们将在稍后回到Program.cs来定义端点。但在那之前,让我们创建Employee类型的模型:

  1. 在项目的文件夹结构中,创建一个名为Models的新文件夹。

  2. 在新文件夹内,创建一个名为Employee的新类。

  3. Employee类中创建这些属性:

        public class Employee
        {
            public int Id { get; set; }
            public string Name { get; set; }
            public decimal Salary { get; set; }
            public string Address { get; set; }
            public string City { get; set; }
            public string Region { get; set; }
            public string PostalCode { get; set; }
            public string Country { get; set; }
            public string Phone { get; set; }
        }
    

    此模型可以用来表示Employee资源,API 可以在其上执行各种 CRUD 操作。通常,我们会将此类数据保存在数据库中,例如 SQL,但为了简单起见,我们现在将将其保存在一个集合中。为此,该集合需要位于可以被我们即将创建的端点访问的地方。

  4. 在项目顶层(与Program.cs相同的级别),创建一个名为EmployeeManager的新类。

  5. 我们希望这个类在任何时候都可以使用,而不需要实例化它,所以将其设为静态类。

  6. 在类顶部添加一个私有的List类型的Employee。你的类现在应该看起来像这样:

    public static class EmployeeManager
    {
        private static List<Employee> _employees =
            new List<Employee>();
    }
    

现在我们有一个易于访问的类,可以存储员工。由于集合是私有的,我们现在可以添加一组可以公开暴露给端点以执行操作的方法。

我们即将为对集合中每个Employee执行的 CRUD 操作创建逻辑。作为这部分,将需要查找列表中的每个员工对象。让我们添加一个私有的函数,它将为我们找到这些信息,以便在每次 CRUD 操作中重复使用。添加了私有函数后,类现在看起来像这样:

public static class EmployeeManager
{
    private static List<Employee> _employees =
        new List<Employee>();
    private static int getEmployeeIndex(int id)
    {
        var employeeIndex =
            _employees.FindIndex(x => x.Id == id);
        if (employeeIndex == -1)
        {
            throw new ArgumentException(
                $"Employee with Id {id} does not exist");
        }
        return employeeIndex;
    }
}

最后,更新类,使其包含此代码片段中显示的 CRUD 方法和函数:

public static void Create(Employee employee)
{
    _employees.Add(employee);
}
public static void Update(Employee employee)
{
    _employees[_getEmployeeIndex(employee.Id)] =
        employee;
}
public static void ChangeName(int id, string name)
{
    _employees[_getEmployeeIndex(id)].Name = name;
}
public static void Delete(int id)
{
    _employees.RemoveAt(_getEmployeeIndex(id));
}
public static Employee Get(int id)
{
    var employee =
        _employees.FirstOrDefault(x => x.Id == id);
    if (employee == null)
    {
        throw new ArgumentException("Employee Id invalid");
    }
    return employee;
}

EmployeeManager类将使我们的 API 端点能够对集合中的员工执行特定的 CRUD 操作。

创建第一个端点

要添加的第一个端点将是用于通过其 ID 检索特定Employee对象的GET端点。

Program.cs文件中,在之前章节中添加的最终app.Run()行上方创建以下GET端点:

app.MapGet("/employees/{id}", (int id) =>
{
    var employee = EmployeeManager.Get(id);
    return Results.Ok(employee);
});

此代码首先引用了名为appWebApplication实例,调用MapGet方法。在WebApplication中,有基于 HTTP 方法类型的等效映射方法。例如,包括MapPutMapPost等等。

映射方法预期的第一个参数将是您希望监听的路径。在这种情况下,我们正在映射到"/employees/{id}"路由,它使用一个路由参数(更多内容请参阅第四章)来捕获员工 ID。

这随后是一个以 lambda 表达式形式提供的第二个参数,它使用传入的 ID 来执行预期的逻辑。在此阶段,代码在最终将结果返回给客户端之前,调用了EmployeeManager类中定义的Create()函数。

在我们刚刚创建的GET端点下方和app.Run()方法上方,将剩余的端点添加到Program.cs中。现在Program.cs应该看起来像这样:

using System.Text.Json;
using WebApplication2;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/employees/{id}", (int id) =>
{
    var employee = EmployeeManager.Get(id);
    return Results.Ok(employee);
});
app.MapPost("/employees", (Employee employee) =>
{
    EmployeeManager.Create(employee);
    return Results.Created();
});
app.MapPut("/employees", (Employee employee) =>
{
    EmployeeManager.Update(employee);
    return Results.Ok();
});
app.MapPatch("/updateEmployeeName", (Employee employee) =>
{
    EmployeeManager.ChangeName(employee.Id, employee.Name);
    return Results.Ok();
});
app.MapDelete("/employees/{id}", (int id) =>
{
    EmployeeManager.Delete(id);
    return Results.Ok();
});
app.Run();

你会注意到,您映射的所有后续端点都遵循与您添加的第一个GET端点相似的模式。每个端点都指定了正在使用的 HTTP 方法类型,然后是路由,然后是任何参数。然后是一个在返回结果之前执行相关逻辑的正文。

到目前为止,代码应该可以编译。(如果不行,请检查是否所有内容都已正确输入。)因此,运行应用程序(在 Visual Studio 中点击播放按钮或在 Visual Studio Code 中使用 dotnet run 终端命令)并对每个创建的端点进行一些测试请求。POSTPUTPATCH 端点期望一个 Employee 对象作为参数,因此请确保您发送的 JSON 与 Employee 模型的结构相匹配。看看这个例子:

{
  "Id": 3,
  "Name": "Happy McHappyson",
  "Salary": 100000.00,
  "Address": "1 Sunny Lane",
  "City": "Happyville",
  "Region": "The Joyful Mountains",
  "PostalCode": "1234565",
  "Country": "Laughland",
  "Phone": "876542-2345-3242-234"
}

创建端点后,现在是时候测试它了。

使用 OpenAPI 测试您的端点

.NET 9 引入了 OpenAPI 集成,这意味着您只需安装一个包,并在 Program.cs 中更改 API 的配置,就可以生成 API 及其端点的 JSON 表示。这很有用,因为您可以将它导入 API 工具(如 Postman),然后您可以从那里轻松测试您的 API。

如果您想以这种方式进行测试,请按照以下步骤操作:

  1. 通过 NuGet 安装 Microsoft.AspNetCore.OpenApi 包。

  2. 更新 Program.cs 以使用 OpenApi:

    builder.Services.AddOpenApi();
    var app = builder.Build();
    app.MapOpenApi();
    

运行 API 并导航到 openAPI/v1.json 路由。这将为您提供 API 架构的表示,可以将其导入 API 客户端(如 Postman)进行测试。

到目前为止,在本章的这个阶段,您已经映射了具有不同 HTTP 方法的端点,为它们提供了路由和参数,创建了模型,并添加了要执行的逻辑。端点的目标是向客户端返回响应。我们现在需要通过返回响应来处理请求。

处理 HTTP 请求

ASP.NET 提供了一个方便的辅助对象,用于将响应发送回客户端,称为 IResult

IResult 包含可以用于表示许多不同场景的标准 HTTP 响应的属性。例如,我们可以使用 IResult 返回特定的状态码,返回 JSON 数据,甚至触发 ASP.NET Identity 提供程序的功能,如挑战和登录/注销。

我们可以使用 ASP.NET 的 Results 工厂类轻松创建一个新的 IResult。在前面的示例中,您已经看到了对这个工厂类的引用,其中 API 通过调用 Results.OK()Results.Created() 等方式返回状态码。

这些简单的 HTTP 状态码方法中的一些有可选参数,允许您以 JSON 格式返回强类型对象。例如,虽然您可以通过在 Results.OK() 中省略任何参数简单地返回一个 200 结果,但您也可以传递一个对象参数,并将其发送回客户端。我们在 Employee API 端点的 GET 端点中就是这样做的:

app.MapGet("/employees/{id}", (int id) =>
{
    var employee = EmployeeManager.Get(id);
    //RETURN 200 OK RESULT WITH THE EMPLOYEE OBJECT
    return Results.Ok(employee);
});

在辅助方法中将强类型 .NET 对象(如 Employee)传递回客户端的能力是最小 API 最强大的特性之一。

第四章中将有更多详细示例来处理 HTTP 请求。现在,这里有一些示例说明如何使用Results来返回常见的 HTTP 响应:

    //200 OK
    return Results.Ok();
    //201 CREATED
    return Results.Created();
    //202 ACCEPTED
    return Results.Accepted();
    //204 NO CONTENT
    return Results.NoContent();
    //400 BAD REQUEST
    return Results.BadRequest();
    //401 UNAUTHORIZED
    return Results.Unauthorized();
    //403 FORBIDDEN
    return Results.Forbid();
    //404 NOT FOUND
    return Results.NotFound();
    //409 CONFLICT
    return Results.Conflict();

在探索了Results之后,让我们看看它的一个替代方案。

Typed Results

使用Results返回特定 HTTP 状态码的替代方案是TypedResults,它与Results类似,但Results的示例响应每次返回一个IResult,而TypedResults返回一个表示状态码的强类型对象。

TypedResults实现了一个工厂,返回适当的强类型对象,该对象实现了IResult,用于指定的状态(例如,对于200 OK结果返回OK)。

你可以使用TypedResults几乎与使用Results相同的方式。以下是一个示例:

    //200 OK
    return TypedResults.Ok();
    //201 CREATED
    return TypedResults.Created();
    //202 ACCEPTED
    return TypedResults.Accepted();
    //204 NO CONTENT
    return TypedResults.NoContent();

.NET 9 还引入了对500 内部服务器错误响应的支持,在TypedResults中:

    //204 NO CONTENT
    return TypedResults.InternalServerError();

到目前为止,你应该已经对如何处理 HTTP 请求有了相当的了解,包括返回相关的 HTTP 状态码和强类型对象给客户端。让我们总结一下本章所涵盖的所有内容。

总结

在本章中,我们涵盖了创建简单最小 API 的大部分入门内容,并且你创建了你的第一个最小 API 项目。

你学习了如何定义端点,以及它们如何作为进入 API 的门,每个端点都位于它们各自的路径上。以Employee API 为例,你了解了如何构建你的项目结构,将端点和数据分离以实现松耦合和可重用性的好处。

你探讨了模型作为数据结构的概念,这些数据结构描述了 API 使用的域对象,并且你构建了一个简单的 CRUD 系统来处理请求中的数据。

最后,你了解了如何在最小 API 中处理 HTTP 请求,使用 ASP.NET 辅助逻辑来组合并返回响应给客户端。

在掌握了基础知识之后,转向下一章,我们将探讨最小 API 的结构。我们将更深入、更科学地探讨构成最小 API 的关键组件,并介绍一些可以采用以充分发挥其潜力的各种架构和设计模式。

第三章:最小化 API 的解剖结构

要了解最小化 API 是如何工作的,了解它们在 ASP.NET 应用程序的上下文中是如何组合起来的是有意义的。在 ASP.NET 项目类型,如模型-视图-控制器MVC)和Web API中,各种组件被绑定在一起以创建整体应用程序,最小化 API 也不例外。

到本章结束时,你将了解最小化 API 如何在 ASP.NET 生态系统中定位,以及各种组件如何组合起来使其成为可能。

此处的目的是确保你对最小 API 的更广泛背景有更深入的了解,这将指导你在未来的项目中设计和实现它们的方式。

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

  • 最小化 API 的解剖结构

  • 最小化 API 应用程序的组件

  • API 请求生命周期

让我们进入本章内容!

最小化 API 的解剖结构

当提到最小化 API 的解剖结构时,我们真正谈论的是拼图中的各个部分,它们组合在一起以启动应用程序。在 ASP.NET Core 之前,启动涉及两个类:Program.csStartup.cs。前者保持在项目的高层次,在调用启动类以向管道添加组件和功能之前设置 HTTP 管道。

ASP.NET Core 主要通过允许在单个文件中配置应用程序来实现这一变化。这简化了过程,为原生支持最小化 API 铺平了道路。因此,在.NET 的最新迭代中,我们现在只需要Program.cs来启动 ASP.NET 应用程序。

Program.cs创建最小 API 所需的最小操作是构建和运行WebApplication实例。此WebApplication实例使用另一个名为WebApplicationBuilder的类构建。正如你将在下一章的代码示例中看到的那样,WebApplication使用CreateBuilder方法的形式作为工厂来创建一个名为appWebApplication实例。你将在前一章的代码示例中看到此代码:

WebApplicationBuilder builder =
    WebApplication.CreateBuilder(args);
var app = builder.Build();

此代码的结果是一个WebApplication实例,可以使用MapGetMapPost等函数映射端点。

WebApplication体现了整体 API,并通过WebApplicationBuilder对象使用构建器模式实现来创建。此对象允许在构建WebApplication实例时指定配置。例如,可以在通过WebApplicationBuilder对象构建WebApplication实例时注册服务以进行依赖注入(我们将在第七章中学习依赖注入)。

以下代码展示了这种依赖项的初始设置示例,其中我们使用AddScoped在最终使用builder.Build()构建应用程序的代码行之前注册PayrollRunner类型以进行依赖注入:

WebApplicationBuilder builder =
    WebApplication.CreateBuilder(args);
builder.Services.AddScoped<PayrollRunner>();
var app = builder.Build();

最小 API 在 Visual Studio 中没有自己的专用项目模板。将最小 API 视为 ASP.NET 项目中的一个选项,而不是其自己的项目类型。这样做的原因是,最小 API 通常是另一种项目的一部分,尽管确实常见到只有最小 API 端点的小型 ASP.NET 项目。

默认情况下,ASP.NET 项目创建了一组最小 API 端点。您可以使用 Visual Studio 中的ASP.NET Core (Empty)项目模板查看这一点。尽管名称如此,该模板生成的项目会生成一个示例最小 API 端点,正如我们在第一章中的Hello World!示例中看到的那样。

最小 API 应用程序的组件

创建最小 API 项目需要几个组件,其中大部分适用于任何 ASP.NET Web 应用程序。

在顶级上,应用程序由WebApplication的一个实例表示。这个类将构成 API 系统的所有部分组合在一起。将其视为应用程序的主体。

位于WebApplication内部的组件包括以下内容:

  • 应用程序生命周期:随着应用程序的运行,将发生各种事件,例如应用程序启动和关闭,以及抛出的异常。WebApplication包含几个可以用来处理这些事件的钩子。例如,您可以在应用程序启动时执行特定函数或方法,或者在捕获特定异常类型时更改处理这些异常的方式。

  • 服务:您的 API 无疑会有可重用的方面,这些方面可以用于多个用例和应用程序的不同区域。创建服务允许您将这些可重用方面打包成组件,可以通过依赖注入将这些组件传递到 API 的各个部分。例如,您的多个端点可能依赖于从 SQL 数据库检索数据,因此多次编写访问数据库的代码不是好的做法。相反,可以编写一次服务,然后将其注入到任何需要与 SQL 通信的类中。

  • 路由:我们在上一章中讨论了路由。在最小 API 结构中,路由是一个关键组件;它负责确保根据端点 URL 和使用的 HTTP 方法将流量发送到适当的目的地。

  • 中间件:在 ASP.NET 中,中间件是一个管道,允许开发者在请求期间通过执行代码来中断 API 流程。中间件管道是由执行任何所需逻辑的组件链组成。常见的中间件用例包括处理或修改请求、验证客户端、缓存和日志记录。中间件可以是一个可重用的组件,可以添加到管道中或自定义代码。

    一旦中间件组件运行完成,管道中的下一个组件就会执行,直到所有组件都完成。这一点特别有用,因为它可以全局应用,运行在所有传入请求上。需要注意的是,管道在请求到来时和响应发送回客户端时都可以运行,区别在于当响应返回客户端时,管道中中间件组件的顺序会被反转。

  • 配置:大多数应用程序,包括最小的 API,都需要指定配置,通过数据库连接字符串、身份验证令牌、一个标志来指示 API 是否处于开发者模式等等。将这些想象成环境变量。这些变量存储在可访问的位置,以便在整个应用程序的生命周期中使用。例如,如果你有一个 SQL 数据库,你的 API 端点需要与数据交互,它们将需要相关的连接字符串来初始化 SQL 连接。这可以作为任何这些端点的配置设置存储,以便它们在需要时获取。

到目前为止,我们已经从基本层面介绍了 ASP.NET 网络应用程序中的各种组件,包括用于托管最小 API 端点的组件。为了更好地理解最小 API 的结构,了解请求如何通过 ASP.NET API 也很重要。

API 请求生命周期

API 有一个共同点,无论底层技术如何——客户端和服务器之间的对话。这个对话的生命周期在 图 3 .1 中被可视化。

图 3.1:HTTP 请求的旅程

图 3.1:HTTP 请求的旅程

让我们更详细地探讨这个生命周期。特别是对于 ASP.NET 和因此最小的 API,我们下面概述的步骤是从客户端发起请求到收到响应的点:

  1. 请求被解析 – 在收到请求后,ASP.NET 接收传入的数据并提取关键信息,例如正在使用的 HTTP 方法(GET、POST、PUT 等)。提取 URL,以及请求的头部和正文。

  2. 中间件管道执行 – 链中的中间件被处理,每个中间件组件按照配置对请求进行操作。例如,身份验证中间件可以检查请求发送者是否已认证,自定义中间件可以改变请求的结构,而日志记录中间件可以在它写入到各种数据源的日志中引用请求。

  3. 路由 – 现在应用程序已经解析了请求并通过任何相关的中间件进行处理,它可以匹配提取的 URL 和 HTTP 方法与 API 中配置的路由。这允许将请求的内容路由到适当的端点进行处理。路由只是中间件组件的另一个例子。因此,可以在管道中更改其执行顺序。

  4. 依赖注入 – 一旦请求被路由到正确的请求,依赖注入容器将解析处理请求所需的任何依赖项,并将它们注入到包含端点的组件中,使它们在处理过程中可用。

  5. 请求处理 – 请求现在实际上已经位于端点内部,也就是说,它正在由开发者编写在最小 API 端点主体中的逻辑进行处理。传递的参数可以在端点主体中使用以处理所需的逻辑。

  6. 响应生成 – 一旦端点主体中定义的逻辑执行完成(或抛出异常),将生成一个响应。响应包含端点上 HTTP 方法预期的任何数据,例如 JSON 或简单的字符串。它还有一个与处理结果适当的状态码,例如,200 OK400 Bad Request500 Internal Server Error。一旦生成,响应将发送回客户端,HTTP 会话结束。

现在我们已经探讨了请求的旅程,让我们回顾一下本章所涵盖的内容。

摘要

本章详细介绍了 ASP.NET 用于构建能够托管最小 API 端点的应用程序的不同组件。它解释了如何使用WebApplicationBuilder配置WebApplication实例。本章还描述了将路由、服务、依赖注入和中间件等元素集成到应用程序中。此外,它强调了应用程序生命周期的重要性以及如何通过钩子管理生命周期事件。本章还讨论了 HTTP 请求从客户端到最小 API 端点以及返回的整个过程。最后,本章概述了将传入请求与适当的逻辑匹配的步骤,以及处理请求以准备客户端响应的过程。

在下一章中,我们将从概念转向实践,提供更高级的 HTTP 请求和路由处理指南。

第二部分 - 数据和执行流程

这一部分深入探讨了数据如何在最小 API 中流动的关键方面。您将学习如何处理各种 HTTP 方法、设置路由、自定义中间件管道以及与不同的数据源集成。这些章节涵盖了从依赖注入到使用对象关系映射ORM)工具处理数据库的各个方面。

这一部分包含以下章节:

  • 第四章处理 HTTP 方法和路由

  • 第五章中间件管道

  • 第六章参数绑定

  • 第七章在最小 API 中进行依赖注入

  • 第八章将最小 API 与数据源集成

  • 第九章使用 Entity Framework Core 和 Dapper 进行对象关系映射

第四章:处理 HTTP 方法与路由

第二章 中,我们讨论了如何在最小化 API 中定义端点和使用路由的方法。那是一个高层次的观点。

然而,在本章中,我们将更详细地讨论如何配置路由和端点以处理传入的请求。我们将更详细地介绍如何使用路由参数来更具体地说明每个端点接收到的所需参数,我们还将探讨请求验证的示例,确保请求正确形成,并在必要时发出相关响应。

最后,如果一个 API 的端点不能充分地从接收无效数据中恢复,那么它就不能被认为是可靠的,因此我们还将探讨如何优雅地处理验证错误。

为了更好地理解这些主题,我们将使用一个用于管理任务的示例应用程序。该应用程序是生产力套件的一部分,它有一个用于管理待办事项列表和项目的 API。通过构建这个 API 的元素,你将更深入地了解最小化 API 如何接收请求以及如何处理它们。

总结来说,本章将涵盖以下主要主题:

  • 处理请求

  • 在 Todo API 中定义端点

  • 管理路由参数

  • 请求验证和错误处理

技术要求

本章的代码可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

如果你已经安装了带有 .NET 9 的 Visual Studio 2022 / Visual Studio Code,你当然可以边读章节边编写代码。

处理请求

要处理传入的请求,我们首先需要一个最小化的 API 端点集合,以便将这些请求发送到。让我们回顾一下在 第二章 中我们探索的内容,即创建具有不同 HTTP 方法的最小化 API 端点(GETPOSTPUTDELETEPATCH)。我们可以通过创建一些静态的 模拟 数据来刷新我们的记忆,这些数据将代表我们的 API 处理的任务实体。然后,我们可以定义一些简单的端点来操作或查询这些数据。

让我们先创建模拟数据。我们将通过创建一个简单的 TodoItem 类和一个静态列表来实现,该列表将包含这个类的实例:

public class TodoItem
{
    public int Id { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime DueDate { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    public string Assignee { get; set; }
    public int Priority { get; set; }
    public bool IsComplete { get; set; }
}

目前,TodoItem 类可以保持相当简单。随着我们对需求的进一步了解,它可以扩展到具有更具体属性的类。同样的方法也可以用于下一段代码,目前它将是一个 TodoItem 的列表,简单地称为 ToDoItems。在这个列表中,我们存储实例化的 TodoItem,以便在请求期间由端点处理。让我们将这个列表放在 Program.cs 中:

List<TodoItem> ToDoItems = new List<TodoItem>();

现在我们有了以列表形式存在的临时数据存储解决方案,我们可以专注于创建处理请求和管理 todo 项的端点。

在 Todo API 中定义端点

在创建最小 API 时,尽可能简单是明智的。毕竟,最小 API 这个名字本身就意味着简单。但这并不仅仅是为了简单。目前,我们的 API 只需要覆盖一个领域:todo 项。当然,范围可能会在未来进一步扩大,最小 API 仍然可以以可扩展的方式构建,因此具有一定的未来性,但在更多需求变得明显之前(例如,将待办事项分配给用户,将待办事项添加到特定项目等),目标是保持最小化。考虑到这一点,我们现在将保持我们的端点在 Program.cs 中。

我们现在应该问自己一个简单的问题:在这个 API 中,我想做什么?

我的意思是 API 需要促进的动作。例如,获取 todo 项,更新 todo 项,删除 todo 项等。

在理解构成您 API 行动的一部分的 动词 时,我们可以确定所需的 HTTP 方法。考虑对 todo 项的基本操作。我们当然会想要 检索 todo 项。这将需要一个 HTTP GET 方法。此外,我们还将想要 创建 一个 TodoItem 对象。这将需要一个 HTTP POST 方法。让我们从第一个例子开始,检索一些 todo 项,然后在此基础上构建。

获取 todo 项

如果您已经阅读了 第二章,您将已经看到如何在最小 API 中创建 HTTP GET 方法作为端点的示例。对于这个项目,我们首先创建一个端点,它简单地检索我们之前创建的 List 的内容。

为了实现这一点,我们需要将 HTTP GET 方法映射到我们的最小 API 所运行的 WebApplication 实例上。在 WebApplication 中有几个函数可以实现这一点。每个函数都以单词 Map 开头,后面跟着相关的方法动词。在这个例子中,我们将使用 MapGet() :

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

在此代码中,一个 HTTP GET 方法已被映射到 /todoitems 路由,这意味着如果用户请求 API 的基本 URL,然后是 /todoitems,则会到达此端点。

例如,如果我们的 URL 域名是 example.org/reallysimpletodoapi,访问 example.org/reallysimpletodoapi/todoitems 将会到达这个端点。

现在我们可以进入请求的处理,这发生在函数体中。注意,我们创建的端点在路由定义之后有一个 lambda 表达式。当前表达式体是空的。我们将在该表达式体中通过检索所需数据并响应用户来处理请求。

在这种情况下,因为我们只是返回 ToDoItems 列表的内容,数据已经准备好了,但我们如何将数据返回给客户端呢?ASP.NET 提供了一个名为 IResult 的辅助对象,其 Results 对象公开了各种用于响应请求的方法。这处理了返回响应的基本方面,如状态码、响应体等。

对于这个简单的 HTTP GET 方法,我们只需通过返回 Results.OK(ToDoItems) 的结果,就可以返回一个 HTTP 200 OK 的响应,并附带所需的数据。这个函数生成相关的状态码,并接受一个类型为 object 的参数,代表应返回给客户端的数据。一旦添加,端点应该看起来像这样:

app.MapGet("/todoitems", () =>
{
    return Results.Ok(ToDoItems);
});

到目前为止,我们的重点一直是将请求路由到 API 以检索数据。我们还需要在系统中创建新数据;因此,让我们将注意力转向待办事项的创建。

创建待办事项

让我们看看另一个对 API 至关重要的操作:创建实体。要创建一个新的 TodoItem,我们会使用 HTTP POST 方法。

HTTP POST 方法的映射与我们在映射 GET 方法时编写的代码类似。再次使用以 Map 为前缀的方法。这个方法是 MapPost()。然而,与我们的 GET 方法相比,语法上有一点不同,因为我们现在需要接收一个数据结构。在创建 TodoItem 的情况下,我们将需要客户端发送一个类型为 TodoItem 的对象,客户端以 JSON 格式表示。然后 ASP.NET 将负责将 JSON 解析为 TodoItem 的强类型实例,我们可以在处理请求时使用它。

为了让方法能够接收作为传入请求一部分的对象,我们可以在端点体中的 lambda 表达式开头的括号中添加该对象。例如,注意我们之前创建的用于检索 TodoItem 的端点,它有这些空括号:

app.MapGet("/todoitems", () =>

我们对最小 API 端点的处理是通过 lambda 表达式表示的。Lambda 表达式以参数的形式开始,这些参数通过这些空括号传入,如下面的代码所示。这意味着对于我们的 HTTP POST 端点,我们可以在添加的 MapPost() 方法中添加一个类型为 TodoItem 的参数,如下所示:

app.MapPost("/todoitems", (TodoItem item) =>
{
});

现在,我们有一个 HTTP POST 端点,位于 /todoitems 路径上,就像我们的 HTTP GET 端点一样。不同之处在于,它不仅响应不同的 HTTP 动词,而且还需要客户端发送一个与 TodoItem 结构相对应的 JSON 负载。

在端点内的 lambda 表达式中我们还没有任何内容,这意味着当客户端发送请求时,不会发生任何操作。让我们最终通过将传入的 TodoItem 添加到列表中,然后返回相关响应来处理请求:

app.MapPost("/todoitems", (TodoItem item) =>
{
    ToDoItems.Add(item);
    return Results.Created();
});

就像在 GET 示例中一样,我们使用 Results 来构建响应并将其发送回客户端。然而,在这种情况下,我们选择了稍微不同的响应。因为请求的目的是创建一个实体,所以在成功创建后返回一个 HTTP 201 CREATED 状态码是合适的,因此使用了 Results.Created();

更新现有的 Todo 项目

当涉及到更新一个 Todoitem 时,我们有几个 HTTP 方法可供选择。让我们从 HTTP PUT 方法开始。

HTTP PUT 要求客户端发送要更新的实体的完整副本。然后它会用副本替换实体。这是一个 完整更新。因此,我们需要创建一个端点,它接收请求中的 TodoItem 作为对象参数,在找到我们列表中的相关项目并将其替换为传入的 TodoItem 之前:

app.MapPut("/todoitems", (TodoItem item) =>
{
});

接下来,请求应该通过找到我们打算更新的 TodoItem 来处理。我们可以使用一个 Language Integrated Query ( LINQ ) 查询通过其唯一的 ID 来找到该项目,使用 FindIndex();

LINQ 查询

在 C# 中,使用 lambda 表达式的 LINQ 查询可以让你轻松地在集合(如列表)中搜索和操作数据。你首先定义你的数据源,然后使用如 Where 的方法来过滤数据,使用 Select 来选择你想要的数据。每个方法都接受一个 lambda 表达式,这是一个简单的函数,它定义了你的标准。在我们的例子中,我们使用 LINQ 查询来找到列表中与给定项目具有相同 ID 的项目的索引。

一旦找到,TodoItem 可以被替换为传入的项目:

app.MapPut("/todoitems", (TodoItem item) =>
{
    var index = ToDoItems.FindIndex(x => x.Id == item.Id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    ToDoItems[index] = item;
    return Results.NoContent();
});

注意,在这个例子中我们没有再次返回 Results.OK。因为我们只是更新一个资源,客户端不期望返回内容;所以我们通过返回一个 HTTP 204 NO CONTENT 状态码来表示成功,使用 Results.NoContent();

通过 HTTP PATCH更新待办事项的处理方式略有不同。与PUT不同,我们通过再次找到相关项目来处理请求,但这次,我们只更改项目的一些特定属性,这些属性由请求参数指定。这通常用于你希望在路由上创建一个特定更新端点的场景。因此,在这种情况下,我们不再在/todoitems路由上创建端点。相反,我们将通过将PATCH方法映射到/updateTodoItemDueDate路由来具体说明我们想要更改的内容。在这个例子中,我们创建了一个旨在单一目的的端点——更改目标待办事项的截止日期:

app.MapPatch("/updateTodoItemDueDate/{id}",
    (int id, DateTime newDueDate) =>
{
    var index = ToDoItems.FindIndex(x => x.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    ToDoItems[index].DueDate = newDueDate;
    return Results.NoContent();
});

代码看起来像我们创建的PUT方法,但你可以看到参数有所不同。我们不是要求客户端发送完整的ToDoItems对象,而是要求两个参数,一个int参数(通过 ID 查找目标项目)和一个包含新截止日期的DateTime参数。然后可以使用另一个 LINQ 查询找到目标项目,然后只更新其DueDate属性。

到目前为止,我们已经处理了需要获取所有项目、创建项目以及更新项目的场景。接下来,我们将探讨我们打算获取单个项目和删除项目的场景。然而,为了做到这一点,我们首先需要了解路由参数的概念。

管理路由参数

路由参数赋予我们从 API 端点的 URL 中捕获值的能力。这在许多需要针对特定实体进行操作的场景中非常有用,例如在通过 ID 请求待办事项时。

添加路由参数非常简单,并且使用花括号来定义从 URL 中捕获的参数。

让我们以一个GET请求为例。在这个请求中,客户端请求具有以下 ID 的待办事项

app.MapGet("/todoitems/{id}", (int id) =>
{
    var index = ToDoItems.FindIndex(x => x.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    return Results.Ok(ToDoItems[index]);
});

就像我们创建的用于获取所有待办事项的通用GET请求一样,这个端点位于/todoitems路由上。然而,在 URL 中附加了该路由的一个额外部分。客户端预期也会添加一个 ID 值作为额外的 URL 部分。这通过路由中存在的{id}来表示。

这种在花括号中使用参数的方法是 ASP.NET 处理路由 URL 中动态内容的方式。通过模板化的一种形式,它可以替换我们添加{id}的 URL 部分,并用客户端指定的值替换。

这种情况的另一个例子可以在 HTTP DELETE端点中看到。再次强调,当删除待办事项时,我们想要删除特定的项目,因此我们还需要传递一个 ID 来指定要删除的目标。让我们编写这段代码,我们将映射一个新的 HTTP DELETE方法到/todoitems路由。在路由上,我们将添加一个路由参数来传递要删除的待办事项的 ID:

app.MapDelete("/todoitems/{id}", (int id) =>
{
    var index = ToDoItems.FindIndex(x => x.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    ToDoItems.RemoveAt(index);
    return Results.NoContent();
});

在收到对 /todoitems 路由的 DELETE 请求时,如果 URL 上附加了一个 int,它将被剥离并在请求中作为参数使用。参数数据类型的问题非常重要。在 DELETE 示例中,我们传递了一个 int 作为 ID 参数,因为这是在 TodoItem 类(我们的模型)的 ID 属性上使用的数据类型。

如果有人发送了不同的数据类型作为参数,比如一个字符串呢?我们当然需要处理这种情况,但不需要在代码中确保传入的 ID 是一个 int。确保请求只有在发送的参数是正确数据类型时才击中路由的更好方法是:路由参数约束

在路由参数上添加约束会使传入的参数必须以特定方式形成,以便找到路由并接收请求。在我们的 DELETE 端点中,我们可以使用参数约束来规定参数必须是一个整数。

向参数添加约束非常简单。我们只需在参数后追加一个 : 字符,然后跟约束。让我们向我们的 DELETE 端点添加一个约束,以确保只有当 id 参数是 int 类型时才使用该路由:

app.MapDelete("/todoitems/{id:int}", (int id) =>
{
    var index = ToDoItems.FindIndex(x => x.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    ToDoItems.RemoveAt(index);
    return Results.NoContent();
});

现在我们已经设置了约束,如果收到一个无法作为 int 处理的请求,API 将返回 404 NOT FOUND 响应。它这样做是因为约束阻止了 ASP.NET 尝试将参数用作 ID,因为它已经通过约束评估了数据类型。结果是找不到其他合适的路由。(除非在 /todoitems URL 上有一个可以接收字符串并且也是 HTTP DELETE 方法的路由。)

参数约束不仅限于数据类型。参数可以通过字符串长度、数值范围、正则表达式模式等进行约束;列表还在继续。

让我们通过添加范围约束进一步约束 DELETE 范围。我们将使其只能删除前 100 个 ID。我们可以像这样将多个约束添加到单个路由中:

app.MapDelete("/todoitems/{id:int:range(1,100)}",
    (int id) =>
{
    var index = ToDoItems.FindIndex(x => x.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    ToDoItems.RemoveAt(index);
    return Results.NoContent();
});

通过将另一个约束链接到现有的约束上,我们现在强制执行了 Id 参数必须是一个 int,并且其值必须在 1100 之间。

表 4.1 展示了一些其他约束示例:

约束类型 路由示例 约束详情
长度 / users/{username:length(3,20)} 用户名字符串长度必须在三个到二十个字符之间
长度 / users/{username:length(8)} 用户名字符串必须正好有八个字符长
最小长度 / users/{username:minlength(5)} 用户名字符串必须至少有五个字符长
最大长度 / users/{username:maxlength(30)} 用户名字符串长度不得超过三十个字符
正则表达式 / addNewCreditCard/{cardNumber:regex(³[47][0-9]{{13}}$)} 信用卡号码必须符合美国运通卡号的模式

表 4.1:最小 API 中参数约束的示例

现在我们更关注参数如何传递到我们的请求中,我们可以将注意力集中在请求体上,在其中我们主要处理请求。处理任何请求的主要部分是验证。最小 API,就像任何其他 API 一样,将在请求中接收数据,这些数据必须满足处理请求所需的条件。让我们看看我们可以用来管理请求流和处理可能因违反验证规则而出现的任何错误的验证技术。

请求验证和错误处理

我们有几种不同的验证方法可供选择。在本节中,我们将探讨其中的两种:手动验证数据注解以及模型绑定验证

手动验证

这种验证是最简单的,因为您是在路由处理程序(端点内的 lambda 表达式体)内部编写代码来验证请求并决定适当的响应。

我们已经在待办事项 API 的某些部分应用了手动验证。例如,我们创建的用于更新项目截止日期的 PATCH 方法首先检查具有目标 ID 的 Todo 项。它可以假设 TodoItem 已存在于列表中,但相反,我们首先检查它是否存在,如果存在,则返回 404 NOT FOUND 状态码:

app.MapPatch("/updateTodoItemDueDate/{id}",
    (int id, DateTime newDueDate) =>
{
    var index = ToDoItems.FindIndex(x => x.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    ToDoItems[index].DueDate = newDueDate;
    return Results.NoContent();
});

通过添加这个手动检查,我们正在积极验证和处理异常情况。在 API 端点中进行验证不仅是一种最佳实践,而且是至关重要的。手动验证是验证的最基本形式。问题是它依赖于编写代码的开发者的直觉。这是一个有争议的话题,因为许多验证方法都有漏洞,但仅依赖手动验证可能会导致脆弱的 API。

为了减轻这种情况,我们还可以采用一个更标准化的验证框架,由 ASP.NET 提供的:使用数据注解的验证。

使用数据注解和模型绑定进行验证

如本章中我们构建的简单 API 示例所示,模型是最小 API 的重要方面。它们允许我们表示传入请求检索、移动和转换的实体。在待办事项 API 中,我们创建了一个 TodoItem 类作为模型,然后将实体存储在 List 中。

可以通过请求绑定到特定模型的方式验证请求的数据。例如,在 TodoItem 模型中,当创建 TodoItem 时,合理地期望 Title 字段被填充。

我们可以通过注解字段来指定字段的要求。属性是有用的元数据片段,允许我们向代码应用约束。在这种情况下,属性的最常见用途之一是 [ Required] 属性。

我们需要的属性是 System.ComponentModel.DataAnnotations 命名空间的一部分。

验证的必需命名空间

Todo 类一样,System.ComponentModel.DataAnnotations 也必须添加到 Program.cs 中以执行验证。

将此命名空间添加到 TodoItem 类的顶部,然后在 Title 字段上方添加 [Required] 属性:

using System.ComponentModel.DataAnnotations;
namespace TodoApi
{
    public class TodoItem
    {
        public int Id { get; set; }
        public DateTime StartDate { get; set; }
        public DateTime DueDate { get; set; }
        [Required]
        public string Title { get; set; }
        public string Description { get; set; }
        public string Assignee { get; set; }
        public int Priority { get; set; }
        public bool IsComplete { get; set; }
    }
}

仅添加一个 [Required] 属性本身不足以触发验证。我们仍然需要在我们的请求中调用验证。但是,我们可以在请求中调用一次,然后所有需要验证的项目都将根据我们应用的属性进行验证。以下是如何从我们之前创建的 POST 请求中调用我们的模型验证的方法:

app.MapPost("/todoitems", (TodoItem item) =>
{
    var validationResults = new List<ValidationResult>();
    var validationContext = new ValidationContext(item);
    bool isValid = Validator.TryValidateObject(
        item, validationContext, validationResults, true);
    if (!isValid)
    {
        return Results.BadRequest(validationResults);
    }
    ToDoItems.Add(item);
    return Results.Created();
});

在这个例子中,我们初始化了一个新的集合,一个 ValidationResult 的列表。这将包含有关验证成功或失败的信息。如果验证失败,我们将返回此集合给客户端。

我们还创建了一个新的 ValidationContext,传入要验证的项目。因为我们想验证作为有效负载发送的 TodoItem 实例,所以我们将其传递给 ValidationContext

然后,我们可以通过调用 Validator.TryValidateObject() 来执行验证,传入项目作为验证目标,我们将要验证的上下文,以及将保存结果的集合,最后是一个布尔值 true,表示应验证所有属性。

现在,当发送一个请求,从有效负载中省略了 Title 字段时,以下错误 JSON 将自动生成并发送回客户端:

[
    {
        "memberNames": [
            "Title"
        ],
        "errorMessage": "The Title field is required."
    }
]

由于使用了属性和内置验证器,所有这些验证和错误处理都是自动发生的。

显示的错误信息是自动生成的,因为使用了 [Required] 属性。这可以通过向属性添加参数来覆盖。

这里是更新后的 TodoItem 模型代码,包含自定义的错误信息:

    public class TodoItem
    {
        public int Id { get; set; }
        public DateTime StartDate { get; set; }
        public DateTime DueDate { get; set; }
        [Required(ErrorMessage =
            "You need to add a title my dude!")]
        public string Title { get; set; }
        public string Description { get; set; }
        public string Assignee { get; set; }
        public int Priority { get; set; }
        public bool IsComplete { get; set; }
    }

现在,我们可以在生成的错误响应 JSON 中看到自定义的错误信息:

[
    {
        "memberNames": [
            "Title"
        ],
        "errorMessage": "You need to add a title my dude!"
    }
]

[Required] 只是数据注解提供的许多验证属性之一。您还可以添加许多其他约束。以下是一些这些约束的示例:

  • [EmailAddress] : 这确保值符合电子邮件地址的格式。

  • [AllowedValues] : 这强制使用特定的值。

  • [DeniedValues] : 这与 [AllowedValues] 相反,拒绝使用特定的值。

  • [StringLength(x)] : 这要求字符串值具有特定的长度。

  • [CreditCard] : 注释的值必须是有效的信用卡格式。

这些只是可以用来验证传入响应的许多属性中的几个,根据需要返回适当的错误。

管理 HTTP 方法和处理请求是最小 API 的关键方面,就像在任何 API 实现中一样。

使用过滤器进行验证

你还可以使用过滤器应用更具体的验证规则。IEndpointFilter是一个接口,可以用来在请求到达端点内的逻辑之前对传入的请求信息进行验证。

有一个方便的扩展方法,AddEndPointFilter,它允许你将实现IEndpointFilter接口的类附加到端点上。

让我们通过我们的 todo API 上的POST端点来探索这个例子。我们将创建一个规则,其中 todo 项不能分配给任何名为 Joe Bloggs 的人:

  1. 创建一个实现IEndpointFilter接口的类。这个类需要定义一个名为Invoke的函数,返回ValueTask<object?>。该函数接受EndPointFilterInvocationContextEndpointFilterDelegate作为参数,以便执行验证逻辑:

    public class CreateTodoFilter : IEndpointFilter
    {
        public async ValueTask<object?> InvokeAsync(
            EndpointFilterInvocationContext context,
            EndpointFilterDelegate next)
        {
        }
    }
    
  2. EndPointFilterInvocationContext将包含传入的TodoItem,因为它代表了我们要验证的端点的上下文。在InvokeAsync内部,定义逻辑以从端点的上下文中访问传入的TodoItem,然后验证我们不是试图将其分配给 Joe Bloggs。如果是这样,返回适当的响应,使验证失败:

      var todoItem = context.GetArgument<TodoItem>(0);
      if(todoItem.Assignee == "Joe Bloggs")
      {
          return Results.Problem(
              "Joe Bloggs cannot be assigned a todoitem");
      }
    
  3. 最后,对于验证通过的情况,我们希望将执行流程返回到原始端点(或附加到其上的任何其他链式逻辑,就像我们对中间件管道所做的那样)。通过返回对EndpointFilterDelegate的调用,我们作为参数接收,并传入端点上下文来完成此操作:

    return await next(context);
    
  4. 最后,按照以下方式将过滤器验证添加到端点:

          app.MapPost("/todoitems", (TodoItem item) =>
          {
              ToDoItems.Add(item);
              return Results.Created();
          }).AddEndpointFilter<CreateTodoFilter>();
    
  5. 或者,如果你想内联定义端点过滤器,可以在AddEndpointFilter之后传递一个匿名函数而不是类型:

        app.MapPost("/todoitems", (TodoItem item) =>
        {
            ToDoItems.Add(item);
            return Results.Created();
        }).AddEndpointFilter(async (context, next) =>
        {
            var toDoItem =
                context.GetArgument<TodoItem>(0);
            if (toDoItem.Assignee == "Joe Bloggs")
            {
                return Results.Problem(
                    "Joe Bloggs cannot be assigned todo
                    items");
            }
            return await next(context);
        });
    

现在你已经了解了我们可以用来为不同的 API 端点实现验证的各种方法,让我们回顾一下本章学到的内容。

摘要

在本章中,我们以高级视角概述了 HTTP 方法及其处理方式。我们进一步探讨了请求的路由方式,允许通过路由参数从路由定义中提取基本参数。

我们还深入探讨了验证的基础知识,首先是通过在我们的 API 路由上放置约束来确保接收到的数据格式正确。随后,我们学习了如何以不同的方式处理验证,包括手动验证,以及在模型中使用数据注释在端点体内部集中调用验证。

在本章中,我们看到了如何通过验证技术捕捉错误,以及如何将具有信息量的错误响应反馈给客户端,以指导他们的调试策略。

到现在为止,你应该能够构建一个基本但功能齐全的最小 API 项目。在下一章中,我们将学习如何以中间件的形式向我们的应用程序管道中引入自定义功能。

第五章:中间件管道

API 是一组可以在请求上触发的命令。当收到请求时,我们可以执行针对该请求用例定制的逻辑。然而,请求并不是在收到后立即击中我们的端点。在执行逻辑并将请求最终返回给客户端之前,必须首先遍历一个管道。这个管道被称为中间件管道,它是 ASP.NET 中的一个功能集,允许我们以分离关注点、优化性能和促进重用性的方式扩展我们的 API。

在本章中,我们将探讨以下内容:

  • 中间件简介

  • 配置中间件管道

  • 实现自定义中间件

技术要求

鼓励您编写和扩展本章中展示的代码示例,以提高您的实际理解。然而,如果您希望获取源代码,可以从以下 GitHub 链接获取:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

中间件简介

中间件这一概念是在 ASP.NET Core 中引入到 ASP.NET 的。它取代了使用 HTTP 模块和 HTTP 处理程序的较老 HTTP 管道模型,提供了一种更简单、更灵活的方式来管理 API 处理 HTTP 请求的方式。

如果您将 API 应用程序想象为一个请求在其中传递的管道,那么这个概念就变得更加直接。

中间件是一个组件,它位于请求由端点处理之前的数据管道上。它会在每个请求上执行,并作为序列的一部分,组件按照它们注册的顺序执行。

每个中间件组件都有其作用,能够影响请求,而不管它请求的是哪个端点。例如,中间件可以是内置的,如路由,或者为特定目的编写的自定义中间件,例如日志记录或身份验证,仅举几例。

一旦中间件组件完成了其工作,它将请求传递给管道中的下一个中间件组件,直到所有中间件组件都被遍历。然后请求可以击中包含特定端点代码的端点,该端点具有针对该端点的特定逻辑。

中间件进展

值得注意的是,虽然中间件组件确实以链的形式将请求传递给彼此,但只有在有理由终止请求并返回异常的情况下才会这样做。根据上下文,中间件管道可能会设计成提前结束。

正如您在前几章中看到的,端点必须向其客户端发送某种形式的响应。一旦请求处理完成,端点随后通过中间件管道将响应发送回客户端,在这个过程中,它再次以相反的顺序通过每个中间件组件。

下面是一个示例请求管道的可视化:

图 5.1:一个示例中间件管道流程

图 5.1:一个示例中间件管道流程

中间件不仅是最小 API,而且是 ASP.NET 的一般重要方面,在 Web API 和 MVC 项目中得到广泛应用。它为开发者提供了一种在应用级别注册自定义行为的方式,作为一种将此行为与端点特定逻辑解耦的设计模式。

例如,可能需要捕获每次收到请求时的一些日志。这可能是一条日志,说明资源(如 SQL 数据库)作为请求的一部分被访问,或者是一条错误日志,如果请求本不应该被发起。

将日志代码包含在每个端点中,在它们被编写时,这将是低效的,并且开发者必须记住包含捕获日志消息的逻辑。正如你可以想象的那样,这是不可持续的,并且违反了不要重复自己DRY)原则。使用日志中间件组件意味着所需的日志消息将在每次收到请求时被捕获,并且只需要配置一次。

这并不是说中间件组件在执行时必须是通用的。它们和其他类或方法一样,可以以HttpContext对象的形式访问传入的请求。因此,它们可以像端点一样审查请求,并在到达端点之前执行适用于该请求的任何自定义逻辑。

让我们看看一个作为类创建的中间件组件的基本示例。该组件在调用序列中的下一个组件之前简单地写入控制台:

public class MySuperSimpleMiddlewareClass
{
    private readonly RequestDelegate _next;
    public MySuperSimpleMiddlewareClass(
        RequestDelegate next)
    {
        _next = next;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        Console.WriteLine(
            "Request handled by middleware component");
        await _next(context);
        Console.WriteLine(
            "Response handled by middleware component");
    }
}

当类被实例化时,这个中间件类有一个构造函数,它接收一个RequestDelegate类型的对象。这个委托代表了管道中的下一个中间件组件。可以使用_next()委托来调用下一个中间件组件并继续序列。

理解管道如何在每个组件之间传递控制流是至关重要的,但如果我们不知道如何构建自己的组件,那么这就没有意义。现在,我们将继续探讨如何在您的管道中创建和配置中间件。

配置中间件管道

根据其目的,您可以用不同的方式来构建中间件组件。前面的例子展示了使用类创建一个简单的中间件组件。在查看其他构建和注册中间件的方法之前,让我们更详细地探讨这种类型的组件。

中间件类

中间件类需要有一个InvokeInvokeAsync方法,以便在轮到它们时被触发。注意,在前一节中我们看到的示例中,有一个名为_next()的方法,传递了与同一方法接收到的相同的HttpContext对象。这就是中间件组件调用管道中下一个组件的地方。

一旦创建了中间件组件,就需要将其添加到管道中。在最小化 API 中,API 的设置发生在Program.cs文件中,创建WebApplication对象。

记得在之前的章节中,当我们创建了一个名为appWebApplication实例吗?这个app对象有一个名为UseMiddleware()的方法。这允许我们告诉WebApplication对象它应该使用特定类型的中间件组件。如果我们想将我们的MySuperSimpleMiddleware类注册为中间件,我们会在使用app.Run()启动WebApplication对象之前完成它:

WebApplicationBuilder builder =
    WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseMiddleware<MySuperSimpleMiddlewareClass>();
app.Run();

现在中间件已经被添加到我们的WebApplication对象中,它将在管道中被调用。

在类中编写中间件有其优点和缺点。一方面,使用类来保持中间件的整洁并使其与WebApplication对象解耦可能是有意义的。你也可能想使用工厂设计模式来生成和注册适当的中间件类。另一方面,使用类可能过于复杂。毕竟,我们正在构建最小化 API,在这种情况下,大多数时候保持逻辑简单是有利的。

在简约主义的精神下,存在一种替代中间件类的形式,即内联中间件。

内联中间件

这比使用类简单得多。当内联创建中间件时,我们将在一个代码块中创建并注册组件到我们的WebApplication对象。再次考虑到我们的WebApplication实例称为app,我们仍然会传递一个HTTPContext对象和一个RequestDelegate对象,但与构造函数、私有字段和InvokeAsync()方法不同,所有操作都将发生在端点的主体内部。

让我们看看如何将MySuperSimpleMiddlewareClass重写为内联中间件组件:

app.Use(async (context, next) =>
{
    Console.WriteLine(
        "Request handled by inline middleware component");
    await next(context);
    Console.WriteLine(
        "Response handled by inline middleware component");
});

在这个例子中,我们添加了app.UseMiddleware(),并用更通用的app.Use()代替。现在,我们不再指定类型,而是传递一个异步匿名函数,该函数将被注册到管道中。示例中展示的 lambda 表达式的主体相当于在MySuperSimpleMiddlewareClass中找到的InvokeAsync()方法。

就像之前一样,我们在传入的请求上写入控制台消息,然后调用RequestDelegate对象,该对象将传递给下一个组件。然后我们又有另一个控制台消息,它将在请求响应作为它通过管道返回客户端的过程中执行。

注册中间件的内联之美在于它与创建的端点的连贯性。如果你以这种方式注册小的中间件组件,在将它们映射到WebApplication对象之前构建端点,你的项目确实会是最小化 API 设计所期望的那样。

我们在上一节中介绍了配置管道的基本知识,但有一些陷阱你应该注意,以确保你能从中获得中间件的好处。

维护顺序

正如我们在本章的第一部分所讨论的,中间件作为请求在客户端和服务器之间传递的管道中的组件序列而存在。

这些组件的序列是线性的,这意味着各个组件执行顺序至关重要,这取决于它们各自的目标。

执行顺序由组件注册的顺序决定,以及它们是如何注册的——即基于类或内联中间件。

这些组件中的每一个都可以在管道中对请求和响应进行修改。正如你可以想象的那样,如果不小心谨慎,这会很容易产生意外的结果。例如,作为你的管道的一部分,你可能需要向有效载荷中添加一个字段。这是可以的,但如果管道中还有其他引用该新字段的中间件组件,你就已经在组件之间创建了一个依赖关系。

如果引用新字段的组件在创建它的组件之前注册,管道将遇到异常,因为尚未存在的属性已被引用。

因此,在编写中间件时,验证请求是否按正确的顺序击中每个组件是至关重要的。

前往运行包含这些中间件示例的项目。你将在从窗口左下角可访问的输出选项卡中看到的日志中看到中间件执行的顺序。

默认中间件

根据配置方式,ASP.NET 会自动为最小 API 项目注册内置的中间件组件。

如果托管环境设置为开发,将注册UseDeveloperExceptionPage中间件。该组件在发生错误响应时显示一个页面,这对于调试非常有用。

我们在上一章中依赖并使用的路由本身就是一个中间件。如果存在端点,ASP.NET 会自动添加它。如果你手动添加了 UseRouting(),ASP.NET 不会自动添加它。

UseRouting 之后,如果服务提供程序中检测到 IAuthenticationSchemeProvider,ASP.NET 也会添加 UseAuthentication。与 UseRouting 类似,如果你手动添加组件,ASP.NET 将跳过添加 UseAuthentication。对于与 IAuthorizationSchemeProvider 一起使用的 UseAuthorization() 也是如此。大多数默认中间件除非需要覆盖它,否则通常不会被开发者注意到。

现在我们已经探讨了中间件作为一个概念,我们应该继续讨论如何通过编写我们自己的自定义中间件来扩展最小 API。

实现自定义中间件

自定义中间件是指你自己编写的或不是由 ASP.NET 注册的默认中间件组件之一。

自定义中间件为我们提供了很多灵活性,使我们能够在请求端点之外扩展 API 的功能。

自定义中间件的几个示例可能如下:

  • 日志中间件:在接收到请求时捕获事件并存储日志

  • 错误处理中间件:在管道中有特定的错误处理方式

  • 验证中间件:检查数据在接收或响应时是否处于特定状态。

  • 请求计时中间件:记录请求的耗时,用于监控和遥测

  • IP 阻止中间件:检查请求的远程主机的 IP 地址,并检查它是否在禁止列表中

让我们以日志为例编写一些自定义中间件。在这个例子中,我们将通过将中间件编写为内联中间件组件来保持简单和最小化。

打开 Program.cs 文件,首先创建一个新的空白中间件组件;也就是说,创建一个接收一个 HttpContext 对象和一个 RequestDelegate 对象的 Use() 方法,并在伴随的 lambda 表达式的主体中不添加任何内容:

app.Use(async (context, next) =>
{
});

现在我们有一个简单的中间件组件的空白画布,我们可以添加一些逻辑来记录一些内容。在这个例子中,我们将记录内容到控制台。

问题是,我们想要记录什么

将请求作为 HttpContext 实例传递到组件中的好处是,我们可以通过此对象访问请求的各个属性。这意味着我们可以访问目标 HTTP 方法、目标路由等。

让我们先记录一些从请求接收到的内容,然后再将控制流传递到管道中的下一个组件。为此,更新 lambda 表达式的主体,使其反映这里更新的示例:

app.Use(async (context, next) =>
{
    Console.WriteLine(
        $"Request: {context.Request.Method}
        {context.Request.Path}");
    await next(context);
});

现在中间件正在访问请求中的数据,并使用字符串插值,将数据排列成一个可以记录到控制台的字符串。这使得我们的 API 具有可审计性(易于跟踪和审查历史事件)并且更容易维护。除此之外,因为我们使用了自定义中间件,所以我们不需要为每个创建的端点重复编写相同的日志。

记住,管道上的中间件组件不仅为传入的请求执行。出站响应也会在返回客户端的过程中反向遍历中间件管道。

如果我们想在响应通过管道返回时将其内容记录到控制台,我们可以在调用 next() 下方简单地添加另一个 Console.WriteLine() 语句。HttpContex t 对象的 Response 成员应为我们提供最新的出站响应数据,我们可以将其记录,如下例所示:

app.Use(async (context, next) =>
{
    Console.WriteLine(
        $"Request: {context.Request.Method}
        {context.Request.Path}");
    await next(context);
    Console.WriteLine(
        $"Response: {context.Response.StatusCode}");
});

作为提醒,这是一个内联中间件组件,意味着它是使用 Program.cs 中的 lambda 表达式创建的。为了保持一致性,以下是如何以类的方式编写相同的中间件组件的示例:

public class LoggingMiddleware
{
    private readonly RequestDelegate _next;
    public LoggingMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        Console.WriteLine(
            $"Request: {context.Request.Method}
            {context.Request.Path}");
        await _next(context);
        Console.WriteLine(
            $"Response: {context.Response.StatusCode}");
    }
}

在注册基于类的中间件后,Program 类将呈现如下:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseMiddleware<LoggingMiddleware>();
app.MapGet("/", () => "Hello World!");
app.Run();

记录是中间件在路由之前采取行动的简单示例。对于更复杂的使用案例,可能需要短路管道。这将阻止管道中的其他组件执行,可以通过省略对 RequestDelegate 对象的调用轻松实现。

短路操作足够简单,但如果我们的中间件具有复杂的程度,意味着它可能需要阻止路由发生,那会怎样?这意味着中间件会阻止请求到达目标端点,或者任何端点。

要进一步理解这个概念,我们需要查看一种名为终端中间件的中间件组件样式。

终端中间件

本章中我们使用过的经典中间件组件都有一个共同点——在管道中通过它们的请求最终会到达一个端点,然后端点将处理将请求发送回管道到客户端。

然而,有些情况下我们不想让请求到达端点。例如,如果我们实现了一个禁止 IP 列表,其中列出了恶意或可疑主机的 IP 地址,我们希望通过中间件实现以下目标:

  1. 识别发送请求的远程主机的 IP 地址

  2. 确定 IP 地址是否在禁止 IP 列表中

  3. 如果是禁止的 IP 地址,从中间件向客户端发送响应,表明主机被禁止进一步操作

让我们编写自己的中间件组件,该组件检查传入的 IP 地址,并在需要时阻止请求进一步进行。

首先,创建一个中间件类的scaffold

public class IPBlockingMiddleware
{
    private readonly RequestDelegate _next;
    public IPBlockingMiddleware(RequestDelegate next,
        IEnumerable<string> blockedIPs)
    {
        _next = next;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        await _next(context);
    }
}

目前,我们的中间件除了简单地传递控制权给管道中的下一个组件外,不做任何事情。

回顾我们 IP 阻止中间件的三个目标,第一个目标是识别请求主机的 IP 地址。此信息可以从HttpContext对象中检索,如下所示:

var requestIP =
    context.Connection.RemoteIpAddress?.ToString();

接下来,我们需要确定请求的 IP 地址是否为被禁止的 IP。为此,我们需要添加一个集合来存储禁止列表,然后对传入的 IP 地址进行检查。

RequestDelegate字段下添加一个私有的HashSet字段。我们将使用这个作为我们的禁止列表:

private readonly HashSet<string> _blockedIPs;

此列表可以在通过其构造函数注册时传递给中间件。

更新构造函数以反映这一点:

public IPBlockingMiddleware(RequestDelegate next,
    IEnumerable<string> blockedIPs)
    {
        _next = next;
        _blockedIPs = new HashSet<string>(blockedIPs);
    }

现在剩下的只是对违规请求采取行动。我们可以通过HttpContext对象将消息写入客户端的响应。在这里,我们可以包括一条消息通知客户端他们的 IP 地址已被阻止。在此之后,我们可以使用return语句来阻止请求继续进行:

public async Task InvokeAsync(HttpContext context)
    {
        var requestIP =
            context.Connection.RemoteIpAddress?.ToString();
        if (_blockedIPs.Contains(requestIP))
        {
            context.Response.StatusCode = 403;
            Console.WriteLine(
                $"IP {requestIP} is blocked.");
            await context.Response.WriteAsync(
                "Your IP is blocked.");
            return;
        }
        Console.WriteLine($"IP {requestIP} is allowed.");
        await _next(context);
    }

一旦添加了所有这些更改,我们将拥有一个完全功能的自定义中间件类,能够检测到被禁止的 IP 地址,并阻止它们到达我们配置的端点。

您的IPBlockingMiddleware类现在应该看起来像这样:

public class IPBlockingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly HashSet<string> _blockedIPs;
    public IPBlockingMiddleware(RequestDelegate next,
        IEnumerable<string> blockedIPs)
    {
        _next = next;
        _blockedIPs = new HashSet<string>(blockedIPs);
    }
    public async Task InvokeAsync(HttpContext context)
    {
        var requestIP =
            context.Connection.RemoteIpAddress?.ToString();
        if (_blockedIPs.Contains(requestIP))
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsync(
                "Your IP is blocked.");
            return;
        }
        await _next(context);
    }
}

在前面的中间件示例中,我们有一个以阻塞 IP 列表形式存在的构造函数参数。这意味着在Program.cs中注册中间件时,必须事先创建该列表,然后在注册时传递:

//Create the list of blocked Ips
private readonly List<string> _blockedIPs =
    new List<string> { "192.168.1.1", "203.0.113.0" };
app.UseMiddleware<IPBlockingMiddleware>(_blockedIPs);

您可以通过将回环地址添加到黑名单中来测试IPBlocking功能。这应该返回一个带有403状态码的响应:

app.UseMiddleware<IPBlockingMiddleware>(
    new List<string> { "::1" }
);

逐渐地,我们开始在最小 API 中引入更多复杂的逻辑,使用自定义和默认中间件组件。随着复杂性的增加,错误发生的概率也在增加。正如我们所知,所有潜在的错误都必须得到处理,以保持系统连续性。

中间件也可以用来实现这一点。让我们探索如何编写一个组件,它可以捕获和处理在管道中可能发生的意外行为和错误。

在中间件管道中处理错误

在这个例子中,我们将坚持使用基于类的中间件组件结构,因为它提供了一个干净的架构,其中的类型可以根据需要替换。(这更多的是个人偏好,而不是良好的实践。)

一个专门用于错误处理的组件可能很有用,因为它确保您始终能够审查和解决发生的错误,而不是面对问题重重且常常令人尴尬的情况,即未处理的异常导致应用程序完全崩溃。

下面是一个基本的异常处理中间件组件的示例:

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    public ExceptionHandlingMiddleware(
        RequestDelegate next)
    {
        _next = next;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            Console.WriteLine(
                $"Exception caught: {ex.Message}");
            context.Response.StatusCode = 500;
            await context.Response.WriteAsync(
                "An unexpected error occurred.");
        }
    }
}

注意,在这段代码中,InvokeAsync()中的try catch块在try主体中实际上并没有做很多事情。它只是将执行权传递给下一个组件。这是因为这个中间件将是管道中第一个注册的组件。它之所以是第一个,是因为我们对于管道中第一个组件不感兴趣处理错误,但我们对于所有其他组件都感兴趣。通过将我们的逻辑放在catch主体中,任何在管道中后来发生的错误都将冒泡到这个组件并被捕获,这样我们就可以处理它们,并相应地更新客户端的响应。

它还涵盖了在返回旅程(响应)中执行中间件组件时出现的任何异常的处理。因为组件是第一个注册的,所以在返回旅程中将是最后一个执行的。

catch语句中,我们可以执行任何必要的异常处理操作。在这个例子中,我们只是将捕获的异常的Message字段的值写入控制台。然后我们将请求的状态码设置为500 内部服务器错误,并将消息写入响应。所有这些操作都是通过传递通过管道的HttpContext对象来完成的。

如果你想要强制其中一个异常以查看输出示例,你可以创建一个专门的示例端点,该端点简单地抛出一个异常:

app.MapGet("/employees/exceptionexample", () =>
{
    throw new NotImplementedException();
});

调试模式下调用此端点将在浏览器中显示异常详细信息。

图 5.2:浏览器中显示的异常详细信息,用于调试目的

图 5.2:浏览器中显示的异常详细信息,用于调试目的

在这一章中,我们深入探讨了中间件和管道定制在最小 API 中的复杂性。通过理解中间件的概念和实际实现,我们为创建更健壮、可维护和灵活的 API 应用程序奠定了基础。让我们总结一下我们覆盖的关键点和技能。

摘要

在这一章中,我们探讨了中间件在 ASP.NET Core 最小 API 中的基本作用。中间件组件在请求-响应生命周期中起着关键作用,使开发者能够处理系统的关键领域,如日志记录、身份验证、错误处理等。

我们首先介绍了中间件的概念,并解释了它如何适应最小 API 的整体架构。中间件组件作为中介,可以检查、修改或终止 HTTP 请求和响应。这种模块化方法促进了关注点的清晰分离,并提高了应用程序的可维护性。

接下来,我们深入探讨了配置中间件管道,说明了中间件注册的顺序如何影响请求的处理。中间件组件按照它们被添加到管道中的顺序执行,响应则按照相反的顺序通过管道返回。这种顺序处理模型对于确保每个中间件组件正确且高效地运行至关重要。

然后,我们转向了自定义中间件的实现,提供了例如日志记录和 IP 封禁的示例。这些示例展示了如何定制自定义中间件以满足特定的应用需求。

我们强调了错误处理中间件的重要性,展示了如何使用它来集中捕获和管理异常,从而简化错误管理并提高应用的健壮性。

中间件是扩展最小 API 功能的一种绝佳方式,但如果它们的请求没有可以审查、操作和转换的数据,它们就毫无用处。在下一章中,我们将探讨如何将经过 API 传输的数据映射,以便最优和准确地处理。

第六章:参数绑定

上下文是任何 API 的关键方面。客户端对其数据结构有自己的理解,服务器也是如此。例如,作为客户端,你会查看 API 文档,告诉你以 JSON 格式格式化请求有效负载。因此,你将 JSON 有效负载创建为符合 API 要求。

当 API 接收到你的有效负载时,并不能保证你传输的对象会保持为 JSON 格式(尤其是在 ASP.NET API 中),通常会存在一种机制将 JSON 对象绑定到 API 可以在其自身上下文中更容易操作的强类型 .NET 对象。

让我们再举一个例子。你(仍然是客户端)对具有特定 ID 的 作业 所对应的所有日志发起一个 GET 请求。然而,你希望将返回的日志数量限制为 100,API 允许你这样做。根据 API 文档,你可以通过在路径中包含作业 ID,并在查询字符串中指定你希望返回多少日志来实现这一点。

当 API 从你作为客户端接收这个请求时,它需要知道在哪里查找以检索这些参数,以便它们可以在请求处理程序的上下文中使用。这种在客户端和服务器之间在请求期间进行的数据转换称为 参数绑定,我们将在本章中详细探讨它。

在本章中,我们将探讨以下内容:

  • 参数绑定来源

  • 绑定优先级

  • 创建自定义参数绑定

参数绑定来源

默认情况下,ASP.NET 支持多种不同的参数绑定类型。每种绑定类型都有一个可以从中绑定的来源。此外,每个绑定来源在请求中的位置不同,它们可以以不同的方式绑定到 API 中的数据结构。

让我们从最常用的绑定来源之一,即路径值,开始。

路径值

路径值仅仅是 API 路径的一部分。看看一个典型的路由,想象一下它被分割成由 / 字符分隔的部分。这些结果部分都是路径中的值。

以这个例子为例:/todoitems/2。它将被分成两个路径值部分:todoitems2

在此上下文中,2 是我们感兴趣的路径值,因为它是 TodoItem 实体的 ID。那么,我们如何在端点中访问它呢?

app.MapGet("/todoitems/{id}", (int id) =>
{
    TodoItem item = GetById(id);
    if(item != null)
    {
        return Results.Ok(item);
    }
    return Results.NotFound();
});

在此代码中,我们可以看到传入的路由已被修改,添加了一个由大括号包围的值。这个 id 值是一个占位符,用于替换客户端传递的整数值。

在跟随路由的 lambda 表达式参数中,我们声明我们正在传递一个整数参数,称为id。我们可以这样做,多亏了 ASP.NET 的路由模块,它可以检测路由值占位符并解析出实际值,将其转换为 lambda 表达式所需的数据类型,在这种情况下是整数。

在客户端,请求的路由可能看起来像这样:https://myTodoAPI/todoitems/2

重要的是要记住,在这个例子中,如果不存在id值为2的项目,绑定将不会匹配,端点根本不会触发,而是返回404 NOT FOUND

通过在端点中声明路由参数,此路由末尾找到的id值为2将被自动检测并绑定到 lambda 表达式中声明的整数参数,从而允许它在请求中使用。

路由值不必自动绑定到 lambda 表达式参数。它们可以通过传递HttpRequest对象作为参数在请求中手动访问。在HttpRequest上,有一个RouteValues集合,它将包含您用于绑定的值。

如果你有多种类型的参数绑定,并且想要使请求更易读,这很有用。以下是一个传递HttpRequest参数的示例。这不需要客户端进行任何更改,因为它已经存在。通过将其作为参数添加,我们允许端点访问它:

app.MapGet(
    "/todoitems/{id}",
    (HttpRequest request) =>
{
    if(int.TryParse(
        request.RouteValues["id"].ToString(),
        out var id) == false)
    {
        return Results.BadRequest(
            "Could not convert id to integer");
    }
    TodoItem item = GetById(id);
    return Results.Ok(item);
});

当向 API 发出请求时,有时您需要更细致地了解您正在进行的查询的状态或条件。在这些情况下,可以将查询字符串添加到端点 URL 中。这允许您向 API 传递更具体的参数。

查询字符串

在路由值的情况下,它们通常是直接访问数据的一种方式。仅使用路由值通过 ID 请求某物会产生可预测的结果。然而,数据以许多不同的形式存在,这意味着我们经常需要指定特定的条件,这些条件将转换我们正在检索的数据,使其符合所需的形状。查询字符串允许我们实现这一点。它们位于路由的末尾,并且有自己的表示法,以一个?字符开始。

检查查询字符串参数的存在

在使用查询字符串之前检查它们是否存在的优点是,我们可以使查询字符串参数成为可选的(更多内容将在下一节中介绍)。否则,我们的代码将假设客户端总是会发送查询字符串值,因此将它们作为端点的强制参数。当这是预期的时候,这是可以的,但在本章中展示的示例中,目标是确保客户端可以选择是否使用查询字符串值来过滤数据。

正如我们可以在这个路由中看到的那样,https://myTodoAPI/todoitems?pastDue=true&priority=1,查询字符串从路由值的末尾开始,以一个 ? 字符为标志。之后,指定了一系列键值对。

路由中查询字符串的目的是传递一系列变量到 API,这些变量可以用来过滤将要返回的数据。在这种情况下,客户端请求任何已过截止日期且优先级值为 1TodoItem

可选查询字符串参数

您可以使传入的参数成为可选的。这非常简单;您只需通过在端点中添加的查询字符串参数后附加一个 ? 字符来使其可空。然后,在端点的主体中,您可以检查可选值的是否存在。如果可选值不为空,您可以使用它。

以下是一个示例,展示了在通过 ID 获取待办事项的端点上使用可选参数。它允许客户端指定分配者必须具有特定值。如果分配者值不为空且与项目上的分配者值不匹配,您可以发送不同的响应,例如 404 NOT FOUND

  app.MapGet(
      "/todoitems/{id}",
      (int id, string? assignee) =>
  {
      var index = TodoItems.FindIndex(x => x.Id == id);
      if (index == -1)
      {
          return Results.NotFound();
      }
      var todoItem = TodoItems[index];
      if (assignee != null)
      {
          if(todoItem.Assignee != assignee)
          {
              return Results.NotFound();
          }
      }
      return Results.Ok(ToDoItems[index]);
  });

在服务器端,查询字符串参数绑定如下示例所示。在这个例子中,我们还在检查某些查询字符串键值对的存在,并根据指定的查询字符串值构建相关查询,使用 IQueryable

app.MapGet("/todoItems", (HttpRequest request) =>
{
    bool pastDue = false;
    int priority = 0;
    var todoItemsQuery = ToDoItems.AsQueryable();
    if (request.Query.ContainsKey("pastDue"))
    {
        var parsedDueDate = bool.TryParse(
            request.Query["pastDue"],
            out pastDue
        );
        if (parsedDueDate) {
            todoItemsQuery = todoItemsQuery.Where(
                x => x.DueDate <= DateTime.Now
            );
        }
    }
    if (request.Query.ContainsKey("priority"))
    {
        var parsedPriority = int.TryParse(
            request.Query["priority"],
            out priority
        );
        if (parsedPriority) {
            todoItemsQuery = todoItemsQuery.Where(
                x => x.Priority == priority
            );
        }
    }
    var result = todoItemsQuery.ToList();
    return Results.Ok(result);
});

我们到目前为止的大部分时间都花在通过我们请求的 URL 传递参数上。让我们探索存在于这个范围之外的参数,从头部开始。

头部

头部 是通用 API 架构的经典组件,提供了关于请求的重要元数据。在它们像查询字符串一样以键值结构定义的意义上,它们是相似的,但应用它们的语法有所不同。

与查询字符串和路由值一样,它们也可以从 HttpRequest 对象中访问:

app.MapGet("/todoItems", (HttpRequest request) =>
{
    var customHeader = request.Headers["SomeCustomHeader"];
    var result = todoItemsQuery.ToList();
    return Results.Ok(result);
});

另一个存在于 URL 之外的关键参数类型是请求体,有时被称为 payload。ASP.NET 具有一个巧妙的功能,可以将有效负载的内容自动转换为项目中定义的强类型对象。这个功能被称为 对象绑定

强类型对象绑定

当在请求有效负载中表示对象时,该对象在客户端创建请求时通常以 JSON 结构化。ASP.NET 提供了一种方便的方法,可以自动将传入的 JSON 参数绑定到项目中定义的强类型对象,以便在处理请求时易于使用。

我们已经在之前的章节中看到了这个例子,其中我们发送了一个表示 TodoItem 对象的 JSON 有效负载。

如果 JSON 对象可以被解析,ASP.NET 允许您隐式声明请求参数是类型为 x 的对象 – 在我们的例子中是 TodoItem 对象。

要以这种方式使用隐式绑定,只需在你的端点中声明一个参数,其类型是你希望接收的参数类型,如下例所示,我们通过POST方法接收一个要创建的新TodoItem

app.MapPost("/todoitems", (TodoItem item) =>
{
    var validationContext = new ValidationContext(item);
    var validationResults = new List<ValidationResult>();
    var isValid = Validator.TryValidateObject(
        item, validationContext, validationResults, true);
    if (isValid)
    {
        TodoItems.Add(item);
        return Results.Created();
    }
    return Results.BadRequest(validationResults);
});

对象绑定是通过客户端在请求体中发送一个由字符串表示的数据结构来启动的。这通常是开发者在收集数据后格式化有效负载的结果,在将其发送到 API 之前。然而,如果数据是由用户收集的,用户可以通过图形用户界面GUI)中的表单输入数据,这个过程可以通过表单值进一步简化。

表单值

API 端点接收数据并对它进行处理。还有什么比表单更好的数据提交用例吗?最小化 API 支持接收表单值,使它们成为处理 GUI 中表单提交的合适选项。

与先前的参数示例一样,表单值可以从HttpRequest对象中它们自己的专用成员中检索,它们也存在于IFormCollection集合中的键值结构中。以下代码显示了在PATCH请求中检索表单值:

app.MapPatch(
    "/updateTodoItemDueDate",
    async (HttpRequest request) =>
{
    var formData = await request.ReadFormAsync();
    var id = int.Parse(formData["Id"]);
    var newDueDate =
        DateTime.Parse(formData["newDueDate"]);
    var index = TodoItems.FindIndex(x => x.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    TodoItems[index].DueDate = newDueDate;
    return Results.NoContent();
});

到目前为止,我们已经取得了一些进展,探索了可以绑定到 API 端点的各种参数类型。在先前的每个示例中,我们对这些参数应该如何绑定有很多假设。参数的位置,无论是作为 URL 中的查询字符串还是请求体中的内容,是隐式的;它不需要定义,而是自动发生。相反,有时我们需要明确地说明参数的绑定方式。这种显式绑定可以通过属性来实现。

使用属性的显式绑定

所有的前例都有一个共同点——它们都可以通过HttpRequest对象中存在的集合的键来访问。

有另一种绑定这些参数类型的方法,无需注入HttpRequest或其父级HttpContext——属性。

这种方法的优点是我们可以使代码更易于阅读,并且可以说改善了请求的结构,因为参数可以从端点 lambda 表达式的括号内绑定,从而将表达式主体专门用于处理请求。如前所述的示例所示,我们使用括号来接收参数,然后在主体中进行显式绑定,以及执行处理逻辑,这可能会有些混乱。

使用属性进行参数绑定更简单,因为你明确表示你有一个参数应该从请求的特定位置绑定。

以查询字符串为例。在 查询字符串 部分的代码块中,我们通过在 HttpRequest 对象内的 IQueryCollection 上按索引访问查询字符串。

以下代码块演示了如何通过使用属性来重构此端点,以获取查询字符串值,从而减少代码量并使其更容易阅读:

app.MapGet(
    "/todoItems",
    ([FromQuery(Name = "pastDue")] bool pastDue,
     [FromQuery(Name = "priority")] int priority  ) =>
{
    var todoItemsQuery = ToDoItems.AsQueryable();
    if (pastDue)
    {
        todoItemsQuery = todoItemsQuery.Where(
            x => x.DueDate <= DateTime.Now
        );
    }
    if (priority > 0)
    {
        todoItemsQuery = todoItemsQuery.Where(
            x => x.Priority == priority
        );
    }
    var result = todoItemsQuery.ToList();
    return Results.Ok(result);
});

如果你在这段代码中稍微改变一下 [FromQuery] 属性,例如,通过移除参数,将其改为 Name = "priority",那么在假设查询字符串中指定了名称的情况下,API 仍然会匹配查询值。

本章前面演示的所有参数绑定方法都可以以这种方式提取——通过在属性中指定参数应从何处绑定,然后提供一个具有适当数据类型的对象,以便可以将其绑定。

让我们通过这种方式更新我们之前的 POST 示例,检查头部信息。为了演示目的,让我们假设当客户端发布一个新的 TodoItem 时,他们可以通过使用自定义头部来指示是否应在后台触发另一个进程。

可以使用属性显式绑定标题,如下所示:

app.MapPost(
    "/todoitems",
    (TodoItem item,
      [FromHeader(Name ="TriggerBackgroundTask")]
      bool triggerBackgroundTaskHeader ) =>
{
    if ( triggerBackgroundTaskHeader)
    {
        // do something else in the background
    }
    ToDoItems.Add(item);
    return Results.Created();
});

现在,我们已经准备好进入更高级的领域,我们可以探索从先前注册的 services 目录中注入的参数。

通过依赖注入绑定参数

参数可以绑定到客户端不一定发送的端点。一个例子是依赖项。如果一个 API 为注入注册了依赖项,它们可以明确地作为参数绑定。让我们通过重写 使用属性显式绑定 部分的第一个代码块中的 GET 请求示例来探索这个例子。

首先,正如你在 GitHub 上的代码中所看到的,我们创建了一个名为 TodoItemService 的服务:

public class TodoItemService
{
    List<TodoItem> todoItems = new List<TodoItem>();
    public TodoItem GetById(int id)
    {
        return todoItems.FirstOrDefault(x => x.Id == id);
    }
    public List<TodoItem> GetTodoItems(
        bool pastDue, int priority)
    {
        var todoItemsQuery = todoItems.AsQueryable();
        if (pastDue)
        {
            todoItemsQuery = todoItemsQuery.Where(
                x => x.DueDate <= DateTime.Now
            );
        }
        if (priority > 0)
        {
            todoItemsQuery = todoItemsQuery.Where(
                x => x.Priority == priority);
        }
        return todoItemsQuery.ToList();
    }
}

此服务已在 Program.cs 中注册为单例服务用于依赖注入:

builder.Services.AddSingleton<TodoItemService>();

这意味着我们现在可以使用 [ FromServices] 属性在端点内部访问此服务:

app.MapGet(
    "/todoItems",
    ([FromQuery(Name = "pastDue")] bool pastDue,
     [FromQuery(Name = "priority")] int priority,
     [FromServices] TodoService todoItemService) =>
{
    var todoItemsQuery =
        todoItemService.TodoItems.AsQueryable();
    if (pastDue)
    {
        todoItemsQuery = todoItemsQuery.Where(
            x => x.DueDate <= DateTime.Now
        );
    }
    if (priority > 0)
    {
        todoItemsQuery = todoItemsQuery.Where(
            x => x.Priority == priority
        );
    }
    var result =
        todoItemService.GetTodoItems(todoItemsQuery);
    return Results.Ok(result);
});

在此代码中,使用 [FromService] 属性演示了为依赖注入注册的服务绑定。这使得 API 端点能够轻松利用可重用组件。

到目前为止,我们已经查看了一些不同参数类型的示例以及它们如何绑定到 API 端点。希望这表明 ASP.NET 在绑定这些参数之前正在处理繁重的工作。了解参数解析的顺序也很重要。这被称为 绑定优先级

绑定优先级

ASP.NET 有自己的参数绑定顺序,称为其 优先级顺序。使用此顺序保持解析的一致性,并确保 ASP.NET 以可预测的方式解析,从最具体的参数开始,到最不具体的参数结束。

图 5.1 概述了 ASP.NET 使用的官方顺序,这对于开发者来说很有用,因为它可以帮助你根据使用的参数预测任何潜在的绑定问题。

图 6.1:ASP.NET 参数绑定优先级顺序

图 6.1:ASP.NET 参数绑定优先级顺序

让我们看看一个工作示例,说明我们如何创建自定义绑定逻辑,从而让我们对绑定执行的方式有更多的控制。

创建自定义绑定逻辑

在此示例中,我们将改变绑定传入 TodoItem 的方式,添加在绑定点发生的验证逻辑。

要实现这种自定义绑定,我们需要在 TodoItem 中实现一个函数。这个函数是静态的,称为 BindAsync()

BindAsync() 允许我们中断对象的绑定过程并应用自己的逻辑。让我们首先将 BindAsync() 添加到 TodoItem 类中。

TodoItem 中,在属性定义下方添加以下静态函数:

public static async ValueTask<TodoItem> BindAsync(
    HttpContext context, ParameterInfo parameter)
{
}

接下来,我们需要添加一个 try/catch 块,以便我们可以执行 JSON 验证逻辑,捕获过程中的任何错误。我们预计如果验证失败,将看到类型为 JsonException 的异常,因此我们将在我们的 try/catch 块中显式捕获此异常类型:

public static async ValueTask<TodoItem> BindAsync(
    HttpContext context, ParameterInfo parameter)
{
    try
    {
    }
    catch (JsonException)
    {
}
}

现在,我们可以先访问请求体,并将原始 JSON 反序列化为 TodoItem 实例。我们将添加选项以确保不将大小写因素纳入验证,然后检查反序列化是否成功。如果没有成功,则传入的参数无法绑定,请求无效,因此我们将返回 400 Bad Request 响应。更新 try 块中的此代码,如下所示:

var requestBody = await new StreamReader(
    context.Request.Body
).ReadToEndAsync();
var todoItem = JsonSerializer.Deserialize<TodoItem>(
    requestBody,
    new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    }
);
if (todoItem == null)
{
    context.Response.StatusCode = 400;
    await context.Response.WriteAsync("Invalid JSON");
    return new TodoItem();
}

到目前为止,我们已检查要绑定的 JSON 的有效性。在 第五章 中,我们探讨了验证中间件的示例,用于验证对象是否根据特定规则创建。我们使用了 ValidationContext 以及静态类型 Validator 来返回一个 ValidationResult 列表,这将确定模型的合法性。

我们可以在 BindAsync() 中使用相同的逻辑来实现对象验证,作为参数绑定过程的一部分。

将此逻辑添加到 try 块中,以完成自定义绑定逻辑:

var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(
    todoItem,
    serviceProvider: null,
    items: null
);
if (!Validator.TryValidateObject(
    todoItem,
    validationContext,
    validationResults,
    validateAllProperties: true
))
{
    context.Response.StatusCode = 400;
    var errorMessages = string.Join(
        "; ",
        validationResults.Select(x => x.ErrorMessage)
    );
    await context.Response.WriteAsync(errorMessages);
    return new TodoItem();
}
return todoItem;

最后,我们需要在 catch 块中添加以下基本逻辑来处理捕获的任何类型为 JsonException 的异常:

  context.Response.StatusCode = 400;
  await context.Response.WriteAsync("Invalid JSON");
  return new TodoItem();

请参考随附 GitHub 仓库中的本章代码以查看完成的类:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

自定义参数绑定是 ASP.NET 灵活功能集的一个好例子。最小 API 可能被标记为这样的标签,但这并不意味着它们会限制自定义配置。让我们回顾一下本章我们所学到的内容。

摘要

参数绑定是一个相当广泛的主题,我们确实在本章中涵盖了大部分内容。

我们首先研究了不同参数类型的绑定方式,从路由值和查询字符串到头信息和为强类型对象提供的自动绑定。

在查看如何在请求中利用依赖注入添加可能未从客户端接收到的参数之前,我们首先探讨了这些参数类型的一些替代绑定方法,包括显式属性绑定和绑定优先级顺序。

最后,我们通过一个自定义参数绑定的示例,为 TodoItem 模型的绑定逻辑添加了自定义验证。

本章并非我们最后一次看到最小 API 中的依赖注入。在下一章中,我们将更详细地探讨这个主题。

第七章:最小 API 中的依赖注入

在任何软件项目中,开发者很少从头开始构建应用程序。在某个层面上,通用库和工具集将被吸收到应用程序中,以加速和优化项目。ASP.NET作为一个框架也不例外。事实上,它要求开发者承担依赖项;第三方或独立创建的代码,这些代码在系统的平稳运行中起着关键作用。

结果是一个(希望是)精心调整和设计良好的架构,由模块和组件组成,其中一些运行由项目中的开发者编写的代码,其余的是更通用的、模板化的代码,这些代码在项目开始前编写并预编译。

跟踪依赖项是软件开发者面临的一个经典问题,由此产生的问题可能会发展到导致行业所说的依赖地狱——一个噩梦般的场景,其中开发者正在追溯他们的步骤,试图找出依赖项是如何被引入的,并找到克服跨可能庞大的代码库中冲突依赖项挑战的方法。

依赖注入DI)是一种在软件项目中标准化和简化消费依赖项体验的方法。

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

  • 理解 DI

  • 在最小 API 中配置 DI

  • DI 最佳实践

到本章结束时,你将提高对 DI 原则的理解,以及它们可以为最小 API 和 ASP.NET 项目等一般项目带来的好处。

你还将获得配置 DI 容器和服务注册的实际经验。

让我们先从提高我们对 DI 的理解开始。

理解 DI

DI 最初是软件开发中的一个设计模式,旨在集中管理常用依赖项,并以一致的方式提供给消费者。

使用这种方法,常见的开发任务,如测试、替换依赖项、修改依赖项逻辑、集中依赖项等,都可以通过一个简单系统轻松实现。

随着时间的推移,.NET 将 DI 从仅仅是一个设计模式转变为一个功能。在 ASP.NET 中,有一个强大且易于使用和理解的 DI 工具集。

开发者可以在一个集中位置注册他们的依赖项,使它们在实例化时可以作为参数注入到类的构造函数中。

使用 DI,依赖项存在于一个容器中,使得它们对消费类中央可用。但什么是容器呢?

DI 容器

在应用程序启动时,依赖项会在容器中注册。容器只是已经注册用于 DI 的一组依赖项。每个依赖项都有一个生命周期规范,它定义了它们在注入到消费类时是如何实例化的。我们将在本章后面更详细地探讨依赖项的生命周期。

当一个具有依赖项的类被实例化时,它会向容器发出请求,容器会负责解决依赖项并根据在依赖项注册时配置的生命周期设置来实例化它。

这可能听起来像是使用另一个类中的类这样简单的事情的额外复杂性层,但强制使用 DI 作为最佳实践有很好的理由。让我们更详细地探讨这一点。

依赖注入的案例

回想一下你作为一名软件工程师的职业生涯。无论你还在起步阶段,还是已经从事这一行业一段时间,你可能已经花费了大量时间以类的形式“new”依赖项。

假设你正在构建一个需要访问 SQL 数据库的 API 端点。(为了简化,我故意没有使用 Entity Framework。)你可能已经创建了一个抽象化具体细节的类(我们可以称之为 SqlHelper),例如创建一个 SqlConnection 实例、打开连接、构建 SqlCommand 等。当你意识到需要这个 SqlHelper 类时,你认为你需要做什么?

你首先会注意到,每当需要与你的 SQL Server 交互时,你必须创建一个新的 SqlHelper 实例。表面上,这似乎无害,但从设计角度来看,这存在一些问题。让我们更仔细地看看这种方法的潜在陷阱:

  • 紧密耦合:没有依赖注入(DI),每次使用 SqlHelper 时都会创建其具体实现。每当有一个类的具体实现时,如果你需要显著更改 SqlHelper,你就面临着被迫更改每个使用它的类的风险。这意味着你的消费类会紧密耦合到 SqlHelper

  • 测试困难:能够模拟依赖项对于有效的测试至关重要。没有 DI,你将需要更手动地确保依赖项被正确实例化、模拟,并且对每个测试都是可访问的。手动实例化的额外需求增加了设置测试时出现错误的可能性。这是一个问题,因为它可能会使你的测试变得不可靠。

  • 资源管理问题:当依赖使用资源时,例如SqlHelper(它将有一个与 SQL Server 的连接),总存在这些资源没有被有效管理的风险。在类似 SQL 连接的情况下,如果没有适当的释放,随着时间的推移创建大量这些连接可能会导致连接耗尽,从而引发性能问题。

  • 违反单一职责、开闭原则、里氏替换原则、接口隔离原则、依赖倒置原则(SOLID 原则):本书中我们尚未探讨 SOLID 原则,但它们是任何面向对象软件系统的重要组成部分。SOLID 的一个指导原则是单一职责,即我们期望确保类有一个主要职责。对于一个消费SqlHelper的类来说,它们的主要职责是对请求或操作数据的逻辑。迫使类实例化SqlHelper意味着你给它赋予了新的职责;管理其自身依赖的职责。依赖注入(DI)移除了这个附加的职责,简单地在类构造时将依赖传递给类。

希望这次分解已经描绘了不使用 DI 如何使你的代码库不一致和混乱的画面。现在,让我们探讨 ASP.NET 中如何实现 DI。

在最小 API 中配置 DI

作为标准,ASP.NET 提供了一种方法,允许我们声明我们创建的类可以被注册为服务。将类转换为服务意味着它可以通过 DI 进行重用。例如,假设你有一段逻辑,用于计算任何给定员工的加班费。这段逻辑是相同的,但你需要在代码库的许多其他区域使用它。为了避免再次编写相同的逻辑,显然你会调用相同的逻辑,但正如我们之前讨论的,每次需要时都创建类的新的实例以获取这段逻辑是混乱的;因此,通过将类注册为服务,我们可以干净地将其注入到任何需要它的其他类中。

此外,DI 允许我们控制注入时服务的生命周期。本质上,我们可以指定依赖在每个注入中是如何实例化的,以及它应该存在多久。

ASP.NET 中有三种内置的生命周期选项:

  • 单例:服务被创建一次,作为一个单一实例。然后这个实例在代码库中被共享。当你需要在全球范围内维护状态时,这很有用。日志记录是一个很好的用例,因为所有日志条目都可以通过一个单一的服务进行通道,该服务可以访问相关的输出资源。例如,一个在文件中创建日志的日志服务。

  • 作用域:为每个传入的请求创建一次服务。这意味着当客户端向 API 发出请求时,需要时创建服务,该实例在整个请求期间处于使用状态。这在需要管理请求内的状态时是理想的。如果您不希望在不同请求之间共享相同的服务,这也是有利的。

  • 瞬态:每次注入服务时都会创建一个实例。这意味着无论对 API 发出的请求是什么,每次注入服务时,该服务都将是一个新实例。这在不需要维护状态的场景中是理想的。

让我们设置一个新的最小 API 项目,作为我们如何从依赖注入(DI)中受益的示例。

请注意,如果您还没有阅读它们,请参考前两章了解您如何创建一个新的最小 API 项目。这将使您能够跟随本章中的示例。

设置一个范围依赖注入(DI)项目

对于我们的新 API 项目,我们将以订单处理 API 为例。它将包含一系列可以组合成订单的产品或服务。

首先,我们需要模型来表示产品和订单。创建两个类,ProductOrder。在第一段代码中,我们创建Product类:

    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public float RRP { get; set; }
    }

在以下代码中,我们创建Order类:

    public class Order
    {
        public int Id { get; set; }
        public List<Product> Products { get; set; }
        public decimal DiscountAmount { get; set; }
        public DateTime DeliveryDate { get; set; }
    }

我们需要能够引用一组可用的产品。通常,我们会将此信息存储在数据库中,然后使用SqlConnection对象关系映射ORM)框架,例如微软的 Entity Framework,来访问数据库,将数据映射到我们创建的模型(ProductOrder)。然而,数据库连接不在本章的范围内,将在本书的后续章节中介绍。

现在,为了简单起见,我们将简单地创建一个包含对象的 JSON 文件,这些对象可以作为文本读入项目,并反序列化为强类型对象Product。我已经创建了一个包含五个产品的示例,这些产品可以以 JSON 格式保存,以下代码。您可以随意复制我的示例或创建自己的示例。无论您做什么,请将产品保存在名为Products.json的文件中,并确保每个项目都是一个包含在单个 JSON 数组中的 JSON 对象,并且您使用的值与Product属性的数据类型相匹配;否则,将无法反序列化 JSON:

[
    {
        "Id": 1,
        "Name": "Laptop",
        "Description": "A high-performance laptop suitable
                       for all your computing needs.",
        "RRP": 999.99
    },
    {
        "Id": 2,
        "Name": "Smartphone",
        "Description": "A latest generation smartphone with
                       a stunning display and excellent
                       camera.",
        "RRP": 799.99
    },
    {
        "Id": 3,
        "Name": "Headphones",
        "Description": "Noise-cancelling headphones with
                       superior sound quality.",
        "RRP": 199.99
    },
    {
        "Id": 4,
        "Name": "Smartwatch",
        "Description": "A smartwatch with fitness tracking
                       and health monitoring features.",
        "RRP": 299.99
    },
    {
        "Id": 5,
        "Name": "Tablet",
        "Description": "A lightweight tablet with a vibrant
                       display, perfect for entertainment
                       on the go.",
        "RRP": 399.99
    }
]

现在,让我们创建一种方法,在需要时将这些对象带入内存。(再次强调,这不是最有效率的示例,因为我们没有使用数据库,但我们将在本书的后续章节中介绍数据库的使用。)

对于这个示例,我们将通过创建一个名为ProductRepository的类来实现。这个类可以用来访问类型为Product的对象列表。

按照以下示例添加ProductRepository类:

public class ProductRepository
{
    public List<Product> Products { get; private set; }
}

如您所见,这是一个非常简单的类,它只包含一个 Product 列表。我们需要以某种方式填充这个列表,以包含我们已保存为文本的 JSON 对象。我们可以在实例化类时轻松地获取这些项目,但我们要使用注入的服务来做这件事,所以我们会很快回到 ProductRepository。在那之前,让我们创建一个将负责从文本文件中检索产品的服务。我们将称之为 ProductRetrievalService

    public class ProductRetrievalService
    {
        private const string _dataPath =
            @"C:/Products.json";
        public List<Product> LoadProducts()
        {
            var productJson = File.ReadAllText(_dataPath);
            return JsonSerializer
                .Deserialize<List<Product>>(
                    productJson
                );
        }
    }

这个简单的服务读取 JSON 文件的全部内容,并使用 System.Text.Json 中找到的 JsonSerializer 类将 JSON 内容转换或反序列化为强类型的 Product 类型,将每个 Product 放入列表中。

C:/ 的权限

如果你写或读 C:/ 时遇到麻烦,你可能没有权限这样做。你可以通过在具有读写权限的位置创建一个文件夹来解决这个问题,然后更改代码中的路径以匹配新的路径。

到目前为止,产品已被检索。这意味着我们可以简单地调用 LoadProducts(),我们总是会得到最新的数据。然而,我们如何访问 ProductRetrievalService 来做到这一点?我们的 ProductRepository 类需要这个逻辑来填充其 Product 列表。

在这里,依赖注入变得有用。我们可以在使用 ProductRepository 的任何时候注入一个 ProductRetrievalService 实例。为了使这成为可能,我们首先需要将 ProductRetrievalService 注册为服务。

以下代码演示了在 Program.cs 中注册此服务以进行依赖注入的示例:

public static void Main(string[] args)
{
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddScoped<ProductRetrievalService>();
    var app = builder.Build();
    app.Run();
}

通过将 ProductRetrievalService 作为作用域服务添加,将为每个传入请求创建一个实例。现在它已注册,我们可以在实例化时通过其构造函数将 ProductRetrievalService 注入到 ProductRepository 中。让我们通过一个 API 端点示例来看看这个例子。

创建一个新的 HTTP GET 方法,映射到 getProductById 路由,如下面的代码所示:

    app.MapGet("/getProductById/{id}", (int id) =>
    {
    });

端点接受一个整数参数,即产品 ID。我们现在可以使用这个参数来获取具有匹配 ID 的产品。首先,让我们向端点添加一个新的 ProductRepository 实例:

  app.MapGet("/getProductById{id}", (int id) =>
  {
      var productRepository = new ProductRepository();
  });

现在我们有一个 ProductRepository 实例,它有一个 Product 列表,但这个列表是空的。我们需要修改 ProductRepository 以注入 ProductRetreivalService ,以填充这个列表。下面的代码展示了如何通过构造函数将服务注入到 ProductRepository 中,以便在使用之前填充 List 中持有的产品:

public class ProductRepository
{
    public List<Product> Products { get; private set; }
    public ProductRepository(
        ProductRetrievalService productRetrievalService
    )
    {
        Products = productRetrievalService.LoadProducts();
    }
}

现在,我们应该能够在端点中使用一些逻辑来从 ProductRepository 获取相关产品。然而,我们有一个问题。如果我们尝试在端点中实例化一个新的 ProductRepository 实例,我们会看到一个错误。

我们看到错误的原因是我们改变了 ProductRepository 的实例化方式。现在它需要一个 ProductRetrievalService 作为构造函数的参数,但我们应该如何获取这个服务呢?

正是这里,最小化 API 允许我们在端点内部利用 DI 容器中注册的服务。

ProductRetreivalService 可以作为参数传递给端点体内部 lambda 表达式中的参数。这使得它和客户端传入的 ID 参数相同,只不过它不是来自客户端,而是来自 DI 容器。

要实现这一点,您需要将 ProductRetrievalService 参数前缀为一个表示其被注入的属性。这个属性是 [FromServices]

使用此属性注入 ProductRetrievalService 现在将允许我们将所需的 ProductRetrievalService 传递给 ProductRepository 的构造函数,如下所示:

app.MapGet("/getProductById/{id}",
    (
        int id,
        [FromServices] ProductRetrievalService
            productRetrievalService
    ) =>
    {
    var productRepository = new ProductRepository(
        productRetrievalService
    );
    return Results.Ok(
        productRepository.Products
            .FirstOrDefault(x => x.Id == id)
    );
});

值得注意的是,我们在本例中创建的 ProductRepository 实例本身也可以通过 DI 注入到类中。

现在让我们继续下一个例子。

创建一个单例 DI 项目

让我们来看另一个例子,但这次我们将使用不同的依赖生命周期。在这个用例中,我们将创建一个用于创建订单的端点。传入的 Order 对象将包含一个 Product 列表,可以用来将新的客户订单提交到系统中。然而,我们还需要确定一个交货日期。

注意

为了避免重复,在这个例子中,我们可以简单地使用一个集合,例如 List,而不是从 JSON 文件中读取它们。

让我们假设有一个即将可用的日期的流,这些日期是集中管理的。我们可以创建一个服务,它具有选择下一个可用日期的上下文。这将从端点逻辑中解耦,并可以在其他端点中重用。

代码展示了这种类型服务的示例:

public class DeliveryDateBookingService
{
    private ConcurrentQueue<DateTime>
        _availableDates = new ConcurrentQueue<DateTime>();
    public DeliveryDateBookingService()
    {
        _availableDates.Enqueue(DateTime.Now.AddDays(1));
        _availableDates.Enqueue(DateTime.Now.AddDays(2));
        _availableDates.Enqueue(DateTime.Now.AddDays(3));
        _availableDates.Enqueue(DateTime.Now.AddDays(4));
        _availableDates.Enqueue(DateTime.Now.AddDays(5));
    }
    public DateTime GetNextAvailableDate()
    {
        if(_availableDates.Count == 0)
        {
            throw new Exception("No Dates Available");
        }
        var dequeuedDate = _availableDates
            .TryDequeue(out var result);
        if (dequeuedDate == false)
        {
            throw new Exception("An error occured");
        }
        return result;
    }
}

此服务允许请求获取下一个可用日期,如果没有可用日期则抛出异常。我们有一个队列来保存可用日期,以确保在检索时不会重复提供相同的日期。

我们还必须考虑线程安全性。可能会有多个请求都在尝试获取一个可用的日期,这很可能导致竞态条件,其中两个请求最终会出队相同的可用日期。为了避免这种情况,我们正在使用 ConcurrentQueue,它将处理确保请求之间线程安全的事务。

现在,我们需要将其注册为一个可以被注入到发布订单端点的服务。考虑到可能会有多个请求,我们想要确保所有请求都是从同一个列表中检索日期。因此,我们将使用AddSingleton()来注册此服务,这将确保在注入期间线程和请求之间只使用一个服务实例。

一旦以这种方式注册了服务,Program.cs应该看起来像下面的代码所示:

public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication
                .CreateBuilder(args);
            builder.Services
                .AddScoped<ProductRetrievalService>();
            builder.Services
                .AddSingleton<DeliveryDateBookingService>()
                ;
            var app = builder.Build();
            app.MapGet("/getProductById/{id}",
                (int id,
                [FromServices] ProductRetrievalService
                productRetreivalService) =>
            {
                var productRepository = new
                    ProductRepository(
                        productRetreivalService
                );
                return Results.Ok(
                    productRepository.Products
                        .FirstOrDefault(
                            x => x.Id == id)
                );
            });
            app.Run();
        }
    }

现在 API 已经注册了第二个服务,是时候创建创建订单的端点了。

由于我们正在创建一个新的记录,我们应该使用POST方法来实现我们的目标。POST方法将接受一个 JSON 对象,该对象在端点参数内隐式解析为Order对象。

随后,我们指出我们正在将DeliveryDateBookingService注入到请求中。

完成此操作后,我们可以通过向 lambda 表达式的主体中添加相关逻辑来完成端点。

获取下一个交货日期的逻辑端点如下所示:

  app.MapPost(
      "/order",
      (Order order,
       [FromServices] DeliveryDateBookingService
       deliveryDateBookingService) =>
  {
      order.DeliveryDate =
          deliveryDateBookingService.GetNextAvailableDate()
          ;
      // save order to repository
  });

虽然我们为Product创建了一个仓库,但我们还没有为Order创建一个。此外,我们还没有演示将实体保存到各自的仓库中。

在我们探讨设计模式(如仓库模式)和数据源时,这种逻辑将在本书的后续部分进行介绍,但就目前而言,这里有一个例子说明你如何在先前的端点中保存新订单:

  1. 创建一个OrderRetreivalService类,以便我们保持使用服务检索实体的一致性(就像我们为产品所做的那样):

    public class OrderRetrievalService
    {
        private const string _dataPath =
            @"C:/Orders.json";
        public List<Order> LoadOrders()
        {
            var ordersJson = File.ReadAllText(_dataPath);
            return JsonSerializer
                .Deserialize<List<Order>>(ordersJson);
        }
    }
    
  2. Program.cs中注册OrderRetrievalService为一个作用域服务:

                builder.Services
                    .AddScoped<OrderRetrievalService>();
    
  3. 创建一个遵循与ProductRespository相同风格的OrderRespository类。添加的不同之处在于增加了一个SaveOrder()方法,允许从POST端点保存Order。此外,用于存储订单的集合是ConcurrentQueue而不是列表。这是因为我们预计订单将从多个并发请求中保存,并且我们需要允许线程安全:

    public class OrderRepository
    {
        public ConcurrentQueue<Order>
            Orders { get; private set; }
        public OrderRepository(
            OrderRetrievalService orderRetrievalService)
        {
            var retrievedOrders =
                orderRetrievalService.LoadOrders();
            foreach (var order in retrievedOrders)
            {
                Orders.Enqueue(order);
            }
        }
        public void SaveOrder(Order order)
        {
            Orders.Enqueue(order);
        }
    }
    
  4. Program.cs中将OrderRepository注册为单例服务,这样无论保存订单的请求数量有多少,我们都可以始终添加到单个实例中:

                builder.Services
                    .AddSingleton<OrderRepository>();
    
  5. 现在可以将POST端点更新为注入OrderRepository并使用它来保存传入的订单:

      app.MapPost(
          "/order",
          (Order order,
           [FromServices] DeliveryDateBookingService
           deliveryDateBookingService,
           [FromServices] OrderRepository
           orderRepository) =>
          {
              order.DeliveryDate =
                  deliveryDateBookingService
                      .GetNextAvailableDate();
              orderRepository.SaveOrder(order);
      });
    

现在你已经有一些创建依赖项作为服务和注册它们以供注入的经验,让我们回顾一下在最小 API 中使用 DI 的一些基本最佳实践。

DI 最佳实践

DI 对于大多数 ASP.NET 项目至关重要,而最小 API 通常特别依赖于它们。因此,确保我们在依赖关系及其访问方法方面遵循最佳实践非常重要。

在最小化 API 中实现依赖注入(DI)时,有一些简单的经验法则。我们将在接下来的几节中探讨这些规则。

避免使用服务定位器模式

在最小化 API 中存在一种称为服务定位器模式的反模式。在这种模式中,你不是显式注入依赖项,而是注入包含依赖项的IServiceProvider,然后在你的方法或函数体中从其中提取服务。

下面的代码示例展示了服务定位器模式的一个例子,其中我们为创建订单而创建的POST方法被修改为使用IServiceProvider

  app.MapPost(
      "/order",
      (Order order,
       IServiceProvider provider) =>
      {
          var deliveryDateBookingService =
              provider.GetService
                  <DeliveryDateBookingService>();
          order.DeliveryDate =
              deliveryDateBookingService
                  .GetNextAvailableDate();
      // save order to repository in same way we did for
      // Product using ProductRepository
  });

这种做法的一个显著缺点是它使得代码更难阅读。从参数中不明显看出你正在注入特定的服务,你必须编写额外的代码来获取IServiceProvider的服务。

它还使得为你的端点编写单元测试更加困难,因为不清楚你需要实例化哪些对象来进行模拟。

这种反模式最具破坏性的方面可能是运行时故障难以诊断。当注入IServiceProvider时,编译器不知道你实际需要的服务是否已注册,而如果你尝试显式注入你的服务且它未注册,问题将更快地显现出来,从而便于调试。

使用扩展方法注册服务

你可以通过在IServiceCollection上创建扩展方法来使你的代码更具可读性。这意味着在Program.cs中,你可以用一行代码注册所有服务,或者以适当的方式将服务分组在一起并分别注册每个组。

下面是一个如何编写此类扩展方法的示例:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddMyServices(
        this IServiceCollection services)
    {
        services.AddScoped<IMyService, MyService>();
        services.AddSingleton<IOtherService,
            OtherService>();
        return services;
    }
}

实现扩展方法后,你可以在Program.cs中简单地写下以下内容来注册所有服务:

builder.Services.AddMyServices();

使用合理的服务生命周期

在注册服务时,考虑分配给它们的生命周期很重要。以下是一些你可能会使用每种服务生命周期的例子:

  • Transient:如果你的服务轻量级、无状态,并且只用于短时间内,请使用此生命周期。

  • Scoped:当你的服务必须在单个请求中维护状态,并且状态需要对于当前请求是唯一的时候,请使用此生命周期。

  • Singleton:当你的服务必须在整个应用程序中维护状态时,请使用此生命周期。在需要创建成本高昂的重型服务的情况下,这也很有用。一旦创建,就可以减少开销。

在创建和管理依赖项时努力遵循最佳实践是一种长期投资,这是一种无私的行为,确保代码库在未来对其他开发者来说易于维护。

让我们总结一下本章所涵盖的内容。

摘要

我们从高层次开始本章,通过了解它如何通过鼓励良好的设计、松散耦合和代码库中的可重用性,为最小 API 带来好处。

然后,我们通过一个订单处理 API 的例子,探讨了如何以服务的形式创建依赖项,并在注册后进行注入。

已经证明,可以在最小的 API 端点中使用参数属性将服务注入到端点执行的范围中,我们还介绍了服务在注册时可以使用的各种生命周期。

最后,我们概述了一些最佳实践,帮助您确保您的依赖注入(DI)使用是高效、可持续、可测试的,同时对于可能不太熟悉项目的其他开发者来说也易于阅读。

依赖注入(DI)不仅是最小 API 的基本方面,也是软件工程的一般基础。对它的良好理解对于您作为开发者的成功至关重要。

在本章中,我们还使用了相当非正统的方法来存储和读取数据,以便在示例 API 端点中使用。这样做有很好的理由。通常,我们会使用更标准化的数据源来托管和检索实体,这是我们将在下一章中详细探讨的内容。

第八章:将最小化 API 与数据源集成

尽管我们使用的是最小化 API,但为了不与外部状态交互,API 可能需要更加最小化,这并不意味着这种情况不会发生。例如,一个 API 可能仅仅用于执行计算或验证数据,这些操作本身可能并不一定需要某种类型的管理数据。

然而,可以说,广泛的实际生产 API 都包含一些 创建、读取、更新、删除 ( CRUD ) 功能。在前面章节中展示的示例中,例如 Employee API 和 Todo Item API,我们提到了实体或对象,所有这些都有可能被创建、更新、删除或检索。我们探索的示例将这些域对象存储在内存中,这意味着当应用程序停止时,它们会消失。现在是时候将数据移动到外部数据源,在那里它可以被持久化并独立于我们编写的最小化 API 进行管理。

在本书中,我们将探讨在数据源之间移动数据的两种基本方法。首先,在本章中,我们将探索使用 SqlConnection 类型直接与 SQL 和 NoSQL 数据库类型进行数据库连接,以及 MongoDB 驱动。下一章将涵盖第二种方法,即 对象关系映射 ( ORM )。每种方法都有自己的配置要求。

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

  • 理解最小化 API 中的数据集成

  • 使用 SqlConnection 连接到 SQL Server

  • 使用 MongoDB 驱动连接到 NoSQL 数据库

技术要求

本章非常注重实践,使用了多种不同的技术。因此,你需要在你的机器上安装以下内容:

  • Visual Studio 2022

  • Microsoft SQL Server 2022 开发者版

  • Microsoft SQL Server Management Studio

  • MongoDB Community Server

  • MongoDB Compass

你需要下载并安装所有列出的软件。所有产品的安装都是基于向导的,所以按照每个向导的指示操作,直到它们被安装。

如果你一直跟随前几章中的代码示例,你可能已经安装了 Visual Studio 2022。

如果你一直在使用 Visual Studio Code,建议你现在切换到 Visual Studio 2022。

或者,你也可以在云平台上托管你的 SQL 和 MongoDB 服务器,例如 Microsoft Azure 或 Amazon Web Services ( AWS )。请注意,这些数据源在云中的配置和部署超出了本书的范围。

本章的代码可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

理解最小化 API 中的数据集成

在第一章中,我们定义了 API 及其目的。在数据源方面重申这个定义是值得的。API 作为系统的网关,提供对该系统的编程访问。

在许多情况下,客户端通过 API 连接到系统的目的是处理数据。这些数据必须存储在某处——最好是 API 本身之外的源,以便可以外部管理和持久化。

最小 API 为数据源提供各种连接方法。为了本章的目的,我们将重点关注最常见的持久数据存储类型,即 SQLNoSQL

SQL 与 NoSQL

作为本书的读者,你很可能至少对 SQL 和 NoSQL 有一定的了解,但为了简短介绍,SQL 数据库是关系型数据库,这意味着数据存储在一系列表中,每个表中的记录由行和列表示。NoSQL 比 SQL 结构更松散,因为数据可以存储在各种格式中,包括文档、键值对、列族或图。数据以这些各种格式存储在实体集合中。

有许多 SQL 和 NoSQL 产品可供选择,其中主流产品在 表 8.1 中概述:

关系型数据库 NoSQL 数据库
MySQL MongoDB (文档存储)
PostgreSQL Cassandra (列族存储)
微软 SQL Server Redis (键值存储)
Oracle 数据库 DynamoDB (键值存储)
SQLite CouchDB (文档存储)
MariaDB Cosmos DB (多模型数据库)

表 8.1:主流数据库平台的示例

不论数据存储是基于 SQL 还是 NoSQL,都有许多方式可以让最小化 API 访问它们,可以采用不同的设计模式来确保在数据库和 API 之间管理的数据的一致性和完整性。

我们将开始通过最小 API 探索数据源,这些 API 直接连接到 SQL 数据库。

根据你的用例,你选择的数据连接方法对于最佳性能和安全性至关重要,同样重要的是你管理连接生命周期的办法、编写查询的方式以及将参数传递到命令和查询中的方式。

直接 SQL 命令提供了很多灵活性,因为它们的作用方式就像你打开了数据库 IDE 并在其中编写查询一样。与数据库建立连接,执行查询或命令,然后断开连接。

让我们首先探索直接连接方法。我们将继续使用 Employee API 的例子,首先连接到 Microsoft SQL 数据库,然后连接到 MongoDB 实例(NoSQL)。

连接到并集成 SQL 数据库

我们将以一个使用 Microsoft SQL 的示例开始。首先,打开 SQL Server Management Studio,创建一个名为 MyCompany 的数据库,其中包含一个名为 Employees 的表。

你可以使用以下代码块中的 SQL 脚本来创建具有相关列和数据类型的表:

CREATE TABLE dbo.Employees
    (
    Id int NOT NULL IDENTITY (1, 1),
    Name varchar(MAX) NOT NULL,
    Salary decimal(10, 2) NOT NULL,
    Address varchar(MAX) NOT NULL,
    City varchar(50) NOT NULL,
    Region varchar(50) NOT NULL,
    Country varchar(50) NOT NULL,
    Phone varchar(200) NOT NULL,
    PostalCode varchar(10) NOT NULL
    )

Employees 表中,我们将 Id 列设置为标识列,这意味着 SQL Server 将在插入任何记录时填充它,每次插入时 Id 值增加 1

现在我们已经拥有了从我们的最小 API 项目设置数据库连接所需的一切。

让我们回到 Employee API,并设置数据库连接。

配置数据库连接和检索记录

SQL 数据库使用 连接字符串 来允许从代码库访问。在这种情况下,我正在使用 Windows 身份验证和我的本地 SQL 服务器,因此我可以使用一个简单的连接字符串,假设当前登录的 Windows 用户能够访问我的 SQL 服务器。如果你使用的是 SQL 服务器,你需要生成一个稍微不同的连接字符串。形成连接字符串的最简单方法是使用 Microsoft SQL 服务器在 connectionstrings.com 找到的指南,根据你的身份验证类型,你可以生成适当的字符串。以下代码显示了每种身份验证类型的简单连接字符串示例:

//Windows Auth (Trusted Connection)
Server=myServerAddress;
Database=myDataBase;
Trusted_Connection=True;
// SQL Server Authentication
Server=myServerAddress;
Database=myDataBase;
User Id=myUsername;
Password=myPassword;

既然我们已经有了连接字符串,我们就可以将其存储在 API 的某个地方,以便可以轻松检索。一个不错的选择是将它放在配置文件中,默认情况下,它以 appsettings.json 的形式提供给我们。

打开 appsettings.json 并添加你的连接字符串,如下所示(我在我的 JSON 示例中使用 SQL 身份验证,但如果你需要,你需要添加你的 Windows 身份验证连接字符串):

{
  "ConnectionStrings": {
      "DefaultConnection":
          "Server=localhost;Database=MyCompany;
          User Id=your_user;Password=your_password;"
  }
}

当你创建项目时,你的 appsettings.json 文件可能包含额外的模板值。为了简化,在遵循此示例时,最好是将现有 appsettings.json 文件的内容覆盖为前面代码中显示的示例内容。

接下来,我们将创建一个服务来管理与数据库的交互。这个服务将通过依赖注入(见 第七章 了解依赖注入的更多细节)注入到我们的 API 端点。

我们将注册该服务为 单例。这样做可以让我们清楚地指定应该只有一个服务实例,这意味着进入 API 的任何请求都将共享该服务。让我们首先通过创建一个名为 IDatabaseService 的新接口来开始创建这个数据库服务的创建。这个接口将为任何创建用于与数据库通信的服务定义 契约

public interface IDatabaseService
{
    Task<IEnumerable<Employee>> GetEmployeesAsync();
    Task AddEmployeeAsync(Employee employee);
}

如果你在此时看到错误信息,指出 Employee 不是一个已知的类型,请不要担心。当它稍后被创建时,这个错误将会消失。

现在,我们可以创建一个实现 IDatabaseService 的具体类来形成我们的服务。创建这个类,命名为 SQLService

一旦创建了 SQLService 类,添加一个构造函数,它接收 IConfiguration 作为参数,并将它的值保存到一个局部的 readonly 字段中。

更多关于 IConfiguration 的信息

IConfiguration 已经在 ASP.NET 应用程序中注册用于依赖注入。它代表了 appsettings.json 的内容。

这个字段将包含连接字符串,并允许它在服务执行的所有查询和命令中引用,如下所示:

public class SqlService : IDatabaseService
{
    private readonly string _connectionString;
    public SqlService(IConfiguration configuration)
    {
        _connectionString =
             configuration.GetConnectionString(
                 "DefaultConnection"
             );
    }
}

接下来,我们将通过添加添加和检索员工记录的功能来完善这个服务。

我们将使用之前章节示例中添加的相同的 Employee 类。作为一个方便的提醒,这个代码块显示了将作为数据库记录模型的 Employee 类:

    public class Employee
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Salary { get; set; }
        public string Address { get; set; }
        public string City { get; set; }
        public string Region { get; set; }
        public string PostalCode { get; set; }
        public string Country { get; set; }
        public string Phone { get; set; }
    }

到现在为止,你可能会看到一些错误(在 Visual Studio 错误列表中,以及代码下方的红色线条形式)指出类没有完全实现 IDatabaseService 接口。我们应该将接口中指定的两个函数添加到 SqlService 类中,以纠正这个问题。

让我们从 GetEmployeesAsync() 开始。这个函数的目的是返回包含数据库中所有员工的列表。首先创建函数定义,并在主体中实例化一个新的 Employee 列表:

public async Task<IEnumerable<Employee>>
    GetEmployeesAsync()
{
    var employees = new List<Employee>();
}

在继续之前,请确保你已经添加了 Microsoft.Data.SqlClient NuGet 包,因为这将是有必要的。你可以通过前往 工具 | 管理 NuGet 包 | 包管理器控制台,然后输入以下命令来完成此操作:

dotnet add package Microsoft.Data.SqlClient

接下来,我们将使用 SqlConnection 打开一个新的 SQL Server 连接。通过将实例包裹在一个 using 语句中,我们确保一旦控制流离开了 using 语句的主体,连接就会自动释放,这得益于 SqlConnection 实现了 IDisposable 接口:

using (var connection = new
    SqlConnection(_connectionString))
{
    await connection.OpenAsync();
}

在这个阶段,你已经打开了一个将会自动释放的连接。这是好事,因为我们正在负责任地管理外部资源的使用。

接下来,在 using 语句内部,添加另一个 using 语句,但这次是为了创建一个 SqlCommand 对象。这个 SqlCommand 对象代表了我们要执行的查询,针对我们现在打开的连接:

using (var command = new
    SqlCommand("SELECT * FROM Employees", connection))
{
}

接下来,我们在这个语句内部嵌套另一个 using 语句。这创建了 SqlDataReader,读取 SqlCommand 中返回的任何行,为每条记录创建一个新的 Employee 实例,并将其添加到列表中:

using (var reader = await command.ExecuteReaderAsync())
{
    while (await reader.ReadAsync())
    {
        var employee = new Employee
        {
            Id = reader.GetInt32(0),
            Name = reader.GetString(1),
            Salary = reader.GetDecimal(2),
            Address = reader.GetString(3),
            City = reader.GetString(4),
            Region = reader.GetString(5),
            PostalCode = reader.GetString(6),
            Country = reader.GetString(7),
            Phone = reader.GetString(8)
        };
        employees.Add(employee);
    }
}

最后,我们可以通过返回类型为 Employee 的列表来结束这个函数,这意味着最终的函数看起来如下所示:

public async Task<IEnumerable<Employee>>
    GetEmployeesAsync()
{
    var employees = new List<Employee>();
    using (var connection = new
        SqlConnection(_connectionString))
    {
        await connection.OpenAsync();
        using (var command = new SqlCommand(
            "SELECT * FROM Employees", connection))
        {
            using (var reader = await
                command.ExecuteReaderAsync())
            {
                while (await reader.ReadAsync())
                {
                    var employee = new Employee
                    {
                        Id = reader.GetInt32(0),
                        Name = reader.GetString(1),
                        Salary = reader.GetDecimal(2),
                        Address = reader.GetString(3),
                        City = reader.GetString(4),
                        Region = reader.GetString(5),
                        PostalCode = reader.GetString(6),
                        Country = reader.GetString(7),
                        Phone = reader.GetString(8)
                    };
                    employees.Add(employee);
                }
            }
        }
    }
    return employees;
}

看一下前一段代码中SqlCommand的使用。注意我们不是通过连接来构建 SQL 命令字符串,而是将Employee的值作为字符串的一部分传递。相反,最佳实践是使用 SQL 参数。参数化查询使我们能够防止一种称为 SQL 注入的安全漏洞。

在 SQL 注入攻击中,一个恶意值作为值传递,可能会改变命令的原始预期行为。通过传递参数,我们可以避免这种情况,参数在命令字符串中以@字符表示,并在字符串形成后添加到SqlCommand中(我们将在下一节中看到这一点)。

插入员工记录

我们现在已经完成了与 SQL 的第一个连接,并使用事务。有了这些知识,我们也可以创建AddEmployeeAsync()函数。连接方法相同,但命令不同,使用INSERT而不是SELECT

public async Task AddEmployeeAsync(Employee employee)
{
    using (var connection = new
        SqlConnection(_connectionString))
    {
        await connection.OpenAsync();
        using (var command = new SqlCommand(
            "INSERT INTO Employees (Name, Salary, " +
            Address, City, Region, Country, Phone, " +
            PostalCode) VALUES (@Name, @Salary, " +
            @Address, @City, @Region, @Country, " +
            @Phone, @PostalCode)", " +
            connection))
        {
            command.Parameters.AddWithValue(
                "@Name", employee.Name);
            command.Parameters.AddWithValue(
                "@Salary", employee.Salary);
            command.Parameters.AddWithValue(
                "@Address", employee.Address);
            command.Parameters.AddWithValue(
                "@City", employee.City);
            command.Parameters.AddWithValue(
                "@Region", employee.Region);
            command.Parameters.AddWithValue(
                "@Country", employee.Country);
            command.Parameters.AddWithValue(
                "@Phone", employee.Phone);
            command.Parameters.AddWithValue(
                "@PostalCode", employee.PostalCode);
            await command.ExecuteNonQueryAsync();
        }
    }
}

让我们现在将注意力转向最小的 API 端点。

从 API 端点执行数据库事务

这些端点需要注入 SQL 服务,以便在端点体中使用。

返回到Program.cs,将服务注册为单例:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IDatabaseService,
SqlLService>(); var app = builder.Build();

因为我们已经创建了一个用于管理与 SQL 服务器交互的服务,并且我们已经通过依赖注入使其易于使用,所以从最小的 API 端点获取和创建员工非常容易。只需为检索添加一个GET端点,为创建添加一个POST端点,并添加对我们在SqlService中创建的适当函数的调用:

app.MapGet(
    "/employees",
    async (IDatabaseService dbService) =>
{
    var employees = await dbService.GetEmployeesAsync();
    return Results.Ok(employees);
});
app.MapPost(
    "/employees",
    async (IDatabaseService dbService,
           Employee employee) =>
{
    await dbService.AddEmployeeAsync(employee);
    return Results.Created(
        $"/employees/{employee.Id}", employee);
});

尝试新的端点。如果成功,你应该能够以列表形式检索数据源中的员工或通过 API 添加新员工。

如本章前面所述,因为我们创建了一个接口,所以我们应该能够替换为使用不同数据源的服务,而无需更改端点。这很自然地引出了到 NoSQL 数据库的数据库连接示例。

连接到 MongoDB

为了演示目的,我们将连接到广泛使用的 NoSQL 数据库平台MongoDB

在我们创建服务之前,我们应该首先向 MongoDB 数据库中添加一些数据。根据技术要求,你应该已经安装了 MongoDB 服务器,以及MongoDB Compass,它是 MongoDB 的图形用户界面GUI)。

首先打开 MongoDB Compass,并创建与已安装的 MongoDB 服务器实例的连接。如果你没有修改安装就本地安装了 MongoDB,你应该可以直接点击连接

图 8.1:创建新的 MongoDB 连接

图 8.1:创建新的 MongoDB 连接

连接成功后,你将能够看到服务器上现有的数据库:

图 8.2:现有 MongoDB 连接视图

图 8.2:查看现有的 MongoDB 连接

在左侧导航栏中,点击加号图标以添加一个新的数据库。再次,我们将数据库命名为MyCompany。Compass 还会要求你创建一个集合。正如我们在 SQL 数据库中创建了一个Employees表一样,我们将在 MongoDB 中创建一个Employees集合:

图 8.3:在 Compass 中创建 MongoDB 数据库

图 8.3:在 Compass 中创建 MongoDB 数据库

注意我们并没有为Employees集合指定一个模式。这是因为该集合是文档型的。我们可以导入 JSON 格式的数据,这与我们的 API 中的Employee类相对应。

创建几个模拟的员工并将它们保存到本地 JSON 文件中。以下是一些示例 JSON 以供你开始:

[
    {
        "Id": 1,
        "Name": "John Doe",
        "Salary": 55000.75,
        "Address": "123 Elm Street",
        "City": "Springfield",
        "Region": "IL",
        "PostalCode": "62701",
        "Country": "USA",
        "Phone": "555-1234"
    },
    {
        "Id": 2,
        "Name": "Jane Smith",
        "Salary": 62000.50,
        "Address": "456 Oak Avenue",
        "City": "Metropolis",
        "Region": "NY",
        "PostalCode": "10001",
        "Country": "USA",
        "Phone": "555-5678"
    }
]

保存 JSON 文件后,你可以将其导入 MongoDB Compass 中的Employees集合:

图 8.4:在 Compass 中导入 MongoDB 数据库的数据

图 8.4:在 Compass 中导入 MongoDB 数据库的数据

现在我们已经用示例数据设置了 MongoDB 集合。让我们将注意力转回到最小 API,我们将编写一个新的服务来与这个 NoSQL 数据库交互。

首先,我们需要安装 MongoDB 驱动程序以支持与 MongoDB 数据库的交互。你可以在 Visual Studio 的包管理器控制台中这样做:

dotnet add package MongoDB.Driver

创建一个名为MongoDbService的新类,实现IDatabaseService接口。确保你引用了MongoDB.BsonMongoDB.Driver

using MongoDB.Bson;
using MongoDB.Driver;
using System.Collections.Generic;
using System.Threading.Tasks;
public class MongoDbService : IDatabaseService
{
}

接下来,我们将创建一个构造函数,它像之前一样接收一个包含数据库连接字符串的注入的IConfiguration对象,然后使用MongoClient实例初始化连接。

随后,可以检索并存储类型为Employee的集合引用到一个private字段中:

private readonly IMongoCollection<Employee>
    _employeesCollection;
    public MongoDbService(IConfiguration configuration)
    {
        var connectionString =
            configuration.GetConnectionString(
                "MongoDbConnection");
        var mongoClient = new
            MongoClient(connectionString);
        var mongoDatabase =
            mongoClient.GetDatabase("MyCompany");
        _employeesCollection =
            mongoDatabase.GetCollection<Employee>(
                "Employees");
    }

接下来,通过添加所需的函数来完成IDatabaseService接口的实现。这些函数可以简单地利用Employee集合进行查询和插入记录:

public async Task<IEnumerable<Employee>>
    GetEmployeesAsync()
    {
        return await _employeesCollection
            .Find(new BsonDocument())
            .ToListAsync();
    }
    public async Task AddEmployeeAsync(Employee employee)
    {
        await _employeesCollection
            .InsertOneAsync(employee);
    }

需要将连接字符串更改为指向 MongoDB 服务器和数据库。MongoDB 的连接字符串格式相对简单。默认情况下,服务器应该在27017端口上运行。以下是此配置的默认连接字符串示例:

mongodb://localhost:27017/MyCompany

然后,可以将此连接字符串添加到appsettings.json中的ConnectionStrings对象。我们还应该在 JSON 中添加一个布尔标志,以便我们可以指定是否使用 MongoDB,或者是否采用默认的 SQL 连接。

完成后,appsettings.json中的ConnectionStrings部分应该看起来像这里所示的示例:

"UseMongoDB": true,
"ConnectionStrings": {
    "DefaultConnection":
        "Server=.\\SQLEXPRESS;Database=MyCompany;
        Trusted_Connection=True;
        TrustServerCertificate=True;",
    "MongoDbConnection":
        "mongodb://localhost:27017/MyCompany"
  }

添加了新的数据源连接字符串和从默认源切换到 MongoDB 的选项后,我们必须为依赖注入注册新的MongoDbService类。然而,我们还需要根据是否启用了UseMongoDB标志来指定新的依赖项解析规则。

Program.cs中,注册新的MongoDbService

builder.Services.AddSingleton<MongoDbService>();

接下来,添加以下IDatabaseService的单例注册,以及逻辑,在解析要使用的正确数据库服务之前检查appsettings.json中的 MongoDB 标志:

builder.Services.AddSingleton<IDatabaseService>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    var useMongoDB = config.GetValue<bool>("UseMongoDB");
    if (useMongoDB)
    {
        return sp.GetRequiredService<MongoDbService>();
    }
    else
    {
        return sp.GetRequiredService<SqlService>();
    }
});

如果你还没有运行代码,你可能还没有意识到,但我们遇到了一个问题。

使用IDatabaseService接口的目的是确保我们可以通过修改appsettings.json中的布尔标志轻松地在数据源之间切换。

如果两个数据源中的数据结构具有相同模式,这将很好。不幸的是,它们并不相同,因为在 SQL Server 中,Idint类型,而在 MongoDB 中,等效的标识符称为_id,其数据类型是字符串。

这意味着,按照目前的状况,Employee可以在两个源之间互换。这意味着如果我们切换到 MongoDB 并尝试将数据反序列化为Employee,由于数据类型不同,它将因FormatException而失败。

为了解决这个问题,我们应该为不同的数据源创建单独的模型。这听起来似乎与灵活系统的理念相悖,但我们可以通过使用另一个接口来确保我们不必为不同的数据源修改现有的端点。

创建一个新的接口称为IEmployee。目前它不需要任何字段:

public interface IEmployee
{
}

我们可以使用这个接口来泛型地表示员工模型,无论它是 SQL Server 模型还是 MongoDB 模型。

创建一个新的模型称为EmployeeMongoDb,并按以下方式设置它:

using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
{
    public class EmployeeMongoDb : IEmployee
    {
        [BsonId]
        [BsonRepresentation(
            MongoDB.Bson.BsonType.ObjectId)]
        public string Id { get; set; }
        public string Name { get; set; }
        public decimal Salary { get; set; }
        public string Address { get; set; }
        public string City { get; set; }
        public string Region { get; set; }
        public string PostalCode { get; set; }
        public string Country { get; set; }
        public string Phone { get; set; }
    }
}

通过将之前代码中显示的属性添加到Id字段,我们将Id映射到 MongoDB 集合中基于字符串的_id字段。我们同样实现了IEmployee接口。

由于 SQL Server 是appsettings.json中的默认连接字符串,我们将Employee视为默认模型。确保它也实现了IEmployee接口。

现在,我们必须更改 MongoDB 服务或 SQL 服务中返回具体类的任何区域,使其返回IEmployee。你还需要更改任何接收Employee作为参数的代码。这要求服务将IEmployee转换为兼容的具体实现,例如MongoDb服务的EmployeeMongoDb

SqlServerServiceMongoDbService的更改可以在以下代码中看到:

private readonly IMongoCollection<EmployeeMongoDb>
    _employeesCollection;
public MongoDbService(IConfiguration configuration)
{
    var connectionString =
        configuration.GetConnectionString(
            "MongoDbConnection");
    var mongoClient = new MongoClient(connectionString);
    var mongoDatabase =
        mongoClient.GetDatabase("MyCompany");
    _employeesCollection =
        mongoDatabase.GetCollection<EmployeeMongoDb>(
            "Employees");
}

继续之前的代码,我们添加了GetEmployeesAsync()AddEmployeeAsync()方法,使用 MongoDB。注意,在AddEmployeeAsync()方法中,我们仍然可以接受IEmployee,但只需将其转换为EmployeeMongoDb对象,这样 MongoDB 就可以负责生成一个string ID,而不是 SQL Server 中使用的int ID:

public async Task<IEnumerable<IEmployee>>
    GetEmployeesAsync()
{
    var result = await _employeesCollection
        .Find(new BsonDocument())
        .ToListAsync();
    return result;
}
public async Task AddEmployeeAsync(IEmployee employee)
{
    var employeeToAdd = new EmployeeMongoDb
{
    Name = employee.Name,
    Salary = employee.Salary,
    Address = employee.Address,
    City = employee.City,
    Region = employee.Region,
    PostalCode = employee.PostalCode,
    Country = employee.Country,
    Phone = employee.Phone
};
    await _employeesCollection
        .InsertOneAsync(employeeToAdd);
}

以下方法展示了使用 SQL Server 的相同代码:

// GetEmployeesAsync only needs the return type to be
// changed
public async Task<IEnumerable<IEmployee>>
    GetEmployeesAsync()
public async Task AddEmployeeAsync(IEmployee employee)
{
    var employeeToAdd = (Employee)employee;
    using (var connection = new
        SqlConnection(_connectionString))
    {
        await connection.OpenAsync();
        using (var command = new SqlCommand(
            "INSERT INTO Employees (Name, Salary, " +
            Address, City, Region, Country, Phone, " +
            PostalCode) VALUES (@Name, @Salary, " +
            @Address, @City, @Region, @Country, " +
            @Phone, @PostalCode)", " +
            connection))
        {
            command.Parameters.AddWithValue(
                "@Name", employeeToAdd.Name);
            command.Parameters.AddWithValue(
                "@Salary", employeeToAdd.Salary);
            command.Parameters.AddWithValue(
                "@Address", employeeToAdd.Address);
            command.Parameters.AddWithValue(
                "@City", employeeToAdd.City);
            command.Parameters.AddWithValue(
                "@Region", employeeToAdd.Region);
            command.Parameters.AddWithValue(
                "@Country", employeeToAdd.Country);
            command.Parameters.AddWithValue(
                "@Phone", employeeToAdd.Phone);
            command.Parameters.AddWithValue(
                "@PostalCode", employeeToAdd.PostalCode);
            await command.ExecuteNonQueryAsync();
        }
    }
}

由于EmployeeEmployeeMongoDb都实现了IEmployee,端点逻辑不再需要更改。我们在最高抽象级别上保留了通用性,同时在服务中处理抽象层较低的更具体类。

开放封闭原则

我们使用接口所做的更改帮助我们遵循开放封闭原则,我们的目标是允许将来在不侵入性地修改原始代码库的情况下添加新的数据源。我们将在第十三章中更详细地讨论这一原则。

这一章节内容相当密集,所以在继续之前,让我们回顾一下我们已经覆盖的内容。

摘要

在本章中,我们介绍了一些从最小 API 端点直接与数据库通信的基本示例。

我们首先定义了不同类型的数据库,并提供了各种基于 SQL 和 NoSQL 数据库平台的示例。

在此之后,我们讨论了使用具有依赖注入的服务如何允许最小 API 项目无缝提供具有与所选数据源互操作性的最小 API 端点。

我们创建了一个与 SQL Server 数据库交互的服务,使用appsettings.json来定义数据源的具体属性,以便在服务中使用。我们利用SqlConnectionSqlCommand的功能来执行针对包含关系型员工数据的 SQL Server 数据库的命令和查询。

接下来,我们创建了一个与之对应的服务,该服务与 MongoDB 交互,展示了SqlCommand与 MongoDB 驱动程序之间的差异。

最后,我们通过使用接口修改了项目,使得不同的数据源模型可以互换,同时保留了 API 端点代码的通用风格。

在下一章中,我们将通过在Employee API 中实现两个 ORM(对象关系映射)来进一步探索数据源,这两个 ORM 是 Dapper 和 Entity Framework Core。

第九章:使用 Entity Framework Core 和 Dapper 进行对象关系映射

在上一章中,我们使用了直接连接到 SQL 和 NoSQL 数据库来创建和检索数据,这是大多数 API 的基本功能。

实际上,大量基于.NET 的 API 倾向于使用对象关系映射ORM)与数据库进行交互,而不是直接连接方法。这是因为 ORM 在底层数据之上提供了另一层抽象,促进了 SOLID 设计原则,同时有利于可扩展性和易于长期维护。

在本章中,我们将探讨两个主流 ORM 框架——Entity Framework Core 和 Dapper。使用这两种技术,我们将能够映射数据库中的实体,并将它们作为如果数据包含在我们的最小 API 项目中的类来管理。

我们将涵盖以下主要内容:

  • ORMs 简介

  • 在最小 API 项目中配置 Dapper

  • 使用 Dapper 执行 CRUD 操作

  • 在最小 API 项目中配置 Entity Framework

  • 使用 Entity Framework 执行 CRUD 操作

技术要求

您需要在您的机器上安装以下软件:

  • Visual Studio 2022 或 Visual Studio Code

  • Microsoft SQL Server 2022 开发者版

  • Microsoft SQL Server Management Studio

您需要在 SQL Server 中创建一个数据库(MyCompany)。创建所需Employees表的 SQL 语句在上一章中已提供,但我也将包括在本章中。

本章的代码可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

ORMs 简介

ORMs 首次在 20 世纪 90 年代推出,旨在解决关系数据库(如 SQL)中数据建模方式与面向对象编程(OOP)语言之间的不匹配,通常被称为阻抗不匹配

关系型数据库中的数据以一系列表的形式排列,每个表都有多个列定义每条记录的属性,这些属性反过来由表中的一行表示。关系型数据库中实体之间的关系由外键和查询期间发生的连接操作表示。

相比之下,在面向对象的语言中,数据以具有字段、属性和操作逻辑(如方法和函数,可以作用于数据)的对象形式表示。面向对象语言中对象之间的关系更为抽象,通过指针引用和继承、多态等概念表示。

ORM 通过提供一种映射数据的方法来弥合这两种范式之间的差距,使我们能够将数据库记录作为对象来处理。这提供了一层抽象,简化了 SQL 的复杂性,使得执行创建读取更新删除CRUD)操作变得更加容易。

对于各种面向对象的编程语言和框架,有许多广泛使用的 ORM 可供选择。第一个广泛使用的 ORM 是TopLink。它在 1994 年开发,旨在为 Java 应用程序提供映射,影响了今天我们视为理所当然的许多 ORM 技术,包括我们将在本章中探讨的两个——Dapper 和 Entity Framework。

ORM 是任何项目的显著加速器,尤其是对最小 API 项目。它们减少了 ASP.NET 中的样板代码,因为它们可以作为包轻松安装,并且配置可以集中完成,查询和命令比使用SqlConnectionSqlCommand等类的直接 SQL 连接需要更少的仪式。

ORM 最强大的方面之一是它们能够管理底层数据库的模式。当然,对象可以从现有数据库映射,但 ORM 提供了以与代码中配置类相同的方式管理数据库结构的能力。这使得模式管理变得非常简单和高效,因为开发者无论如何都会更改类结构。它防止了双重键入,利用代码中已经完成的工作来自动化数据库中的相关更改。

如果你阅读了上一章,你会记得我们创建了一个名为MyCompany的数据库,作为从我们的 API 直接连接的示例。我们将继续使用这个数据库,但这次我们将使用 ORM 来映射其内的对象。让我们首先使用 ORM,Dapper 来实现这一点。

在最小 API 项目中配置 Dapper

我选择从 Dapper 开始,因为它与 Entity Framework 相比,通常被称为微型 ORM。这是因为它去掉了许多 ORM 功能,如结果缓存、更改跟踪、延迟加载和数据库迁移。相反,Dapper 专注于简单性和性能。作为一个更轻量级的解决方案,它可能更适合你的最小 API 需求。

Dapper 是学习 ORM 的一个好起点,因为它仍然使用 SQL 查询,这使得它在上一章中展示的直接连接方法和 Entity Framework 提供的更冗长的 ORM 功能集之间成为完美的中间地带。

数据库迁移和 Dapper

最后一个区分性功能(数据库迁移)非常重要,因为它与更改数据库模式的能力相关。当我们探索 Entity Framework 时,我们将在本章后面讨论迁移,但在此之前,要知道数据库迁移是通过代码更改数据库模式,而 Dapper 不支持通过迁移来实现这一点。然而,你仍然可以通过 Dapper 发送 SQL 命令来更改表结构。这超出了本章的范围。

Dapper 与许多数据库提供程序兼容,包括 SQL Server、Oracle、SQLite、MySQL 和 PostgreSQL。它使用 ADO.NET,这意味着它将与任何具有使用 ADO.NET 提供程序的数据库平台一起工作。因为我们正在使用上一章中创建的数据库,所以我们将连接到 SQL Server 数据库。

让我们在 Visual Studio 中创建一个新的(空白的)ASP.NET 项目,以便我们有一个干净的平台来工作。

一旦创建了项目,导航到Program.cs,它应该有一个Hello World模板示例。

首先,我们需要一个数据库来连接。如果你跟随上一章的内容,你将已经安装了一个 SQL Server 实例并创建了一个名为MyCompany的数据库。如果你还没有这样做,请安装 SQL Server 并在 SQL Server Management Studio 中创建数据库。

一旦有了数据库,如果你还没有Employees表,你可以使用以下 SQL 来创建一个:

CREATE TABLE dbo.Employees
    (
    Id int NOT NULL IDENTITY (1, 1),
    Name varchar(MAX) NOT NULL,
    Salary decimal(10, 2) NOT NULL,
    Address varchar(MAX) NOT NULL,
    City varchar(50) NOT NULL,
    Region varchar(50) NOT NULL,
    Country varchar(50) NOT NULL,
    Phone varchar(200) NOT NULL
    PostalCode varchar(50) NOT NULL,
)

Dapper 使用提供程序来简化与目标数据库平台的连接。对于 SQL Server,所需的提供程序与从 C#直接连接到 SQL Server 的要求相同:Microsoft.Data.SqlClient

安装Microsoft.Data.SqlClient,无论是从NuGet包管理器 GUI 还是通过在包管理器控制台运行以下命令:

Install-Package Microsoft.Data.SqlClient

当我们在安装NuGet包时,我们还需要安装Dapper包。你可以在包管理器控制台运行以下命令来完成此操作:

Install-Package Dapper

从技术上讲,我们现在可以直接在我们的最小 API 端点中编写查询,但为了保持一致性和良好的实践,我们应该为 Dapper 创建一个新的服务并为其注册依赖注入。创建一个名为DapperService的类。正如我们在上一章中为SQLServiceMongoDbService所做的那样,我们将在这个类中注册单例到Program.cs中。确保在app.Run();之前注册服务:

builder.Services.AddSingleton<DapperService>();

我们现在已经配置了项目,使其使用依赖注入和相关的提供程序在 SQL Server 数据库上使用 Dapper,并且我们已经创建了一个可以工作的数据库,这意味着我们可以从我们的最小 API 开始执行我们的第一个 CRUD 操作。

使用 Dapper 执行 CRUD 操作

让我们通过一些示例来逐步了解 Dapper 中 CRUD 的各个方面。首先,让我们创建一个员工。

创建员工记录

首先,我们将创建一个创建员工的端点。这意味着我们将使用POST方法:

  1. 前往Program.cs并将 POST 端点映射到employees路由。它应该接受一个Employee作为参数,并且应该注入DapperService

    app.MapPost(
        "/employees",
        (Employee employee,
        [FromServices] DapperService dapperService) =>
    {
    });
    
  2. 接下来,我们可以在DapperService内部创建一个方法来处理在数据库中创建员工(这就是我们使用 Dapper 的地方)。

  3. 打开DapperService.cs并创建一个名为AddEmployee的方法:

    public async Task AddEmployee(Employee employee)
    {
    }
    

    如前一章中的直接连接示例,Dapper 使用 SqlConnection 连接到 SQL Server。在该连接的作用域内,您需要编写执行所需操作的适当查询。因为我们正在创建一个 employee,所以我们将向数据库添加一条新记录,因此我们将编写一个 INSERT 语句。

  4. 使用 using 语句来保持对数据库的连接(记住,using 语句允许自动释放连接)并将连接字符串添加到数据库中。

  5. 紧接着,定义一个表示 INSERT 语句的字符串:

    using (var sqlConnection = new
        SqlConnection("YOURCONNECTIONSTRING"))
    {
        var sql = "INSERT INTO Employees " +
                   (Name, Salary, Address, City, " +
                   Region, "Country, Phone) VALUES " +
                   (@Name, @Salary, @Address, @City, " +
                   @Region, @Country, @Phone)";
    }
    

    注意到 INSERT 语句必须包含所有相关列及其值作为参数。

  6. 接下来,我们将使用 Dapper 将数据库事务提交给实例化的 SqlConnection。我们可以使用 ExecuteAsync(),这是一个 Dapper 扩展方法,它将在映射从我们传递到这个 AddEmployee() 方法的 Employee 对象属性时执行语句:

    await sqlConnection.ExecuteAsync(sql, new
    {
        employee.PostalCode,
        employee.Name,
        employee.Salary,
        employee.Address,
        employee.City,
        employee.Region,
        employee.Country,
        employee.Phone
    });
    

    现在我们有一个在 DapperService 上的方法,可以接收一个 Employee 参数并通过 Dapper 将其提交到数据库,如下所示:

    public class DapperService
    {
        public async Task AddEmployee(Employee employee)
        {
            using (var sqlConnection = new
                SqlConnection("YOURCONNECTIONSTRING"))
            {
                var sql = "INSERT INTO Employees " +
                    (Name, Salary, Address, City, " +
                    Region, Country, Phone) VALUES " +
                    (@Name, @Salary, @Address, " +
                    @City, @Region, @Country, @Phone)";
                await sqlConnection.ExecuteAsync(sql, new
                {
                    employee.PostalCode,
                    employee.Name,
                    employee.Salary,
                    employee.Address,
                    employee.City,
                    employee.Region,
                    employee.Country,
                    employee.Phone
                });
            }
        }
    }
    
  7. 现在剩下的只是调用我们在 Program.cs 中开始编写的 POST 端点上的此方法,在返回 HTTP 201 CREATED 状态码给客户端之前:

    app.MapPost(
        "/employees",
        async (Employee employee,
               [FromServices] DapperService dapperService)
               =>
    {
        await dapperService.AddEmployee(employee);
        return Results.Created();
    });
    

这样就处理了 CRUD 的 Create 部分。

存储和引用连接字符串

在上一章中,我演示了如何通过将连接字符串存储在配置文件中并通过 IConfiguration 引用来遵循最佳实践。

如果您还没有这样做,请参考上一章,以便您可以为 Dapper 和 Entity Framework 的使用实现它。

现在让我们继续到 Read 部分。

读取员工记录

Read 部分将包括在数据库上执行一个 SELECT 查询的 GET 端点:

  1. 首先在 Program.cs 中添加一个 GET 端点,映射到 Employees 路由。它应该有一个名为 id 的路由参数,该参数通过端点内部 lambda 表达式的主体传递,并且应该注入 DapperService

    app.MapGet(
        "/employees/{id}",
        (int id,
         [FromServices] DapperService dapperService) =>
    {
    });
    
  2. 接下来,我们将采取与上一个示例相同的方法,在 DapperService 中创建一个新的函数,但这次,而不是插入,它将运行一个 SELECT 查询以获取指定 id 的员工,然后在返回到端点之前:

            public async Task<Employee>
                GetEmployeeById(int id)
            {
                using (var sqlConnection = new
                    SqlConnection("YOURCONNECTIONSTRING"))
                {
                    var sql = "SELECT * FROM Employees
                        WHERE Id = @employeeId";
                    var result = await sqlConnection
                        .QuerySingleAsync<Employee>(
                            sql, new { employeeId = id });
                    return result;
                }
            }
    

    QuerySingleAsync() 是 Dapper 代码介入的地方。和之前一样,这是一个 Dapper 扩展方法,允许我们执行一个 SQL 查询,预期返回一条记录,即一个员工记录。

  3. 注意发送的参数。我们传递了 SQL 查询,然后传递了一个新的对象数组实例。这是我们在 SQL 查询中声明的@employeeId参数的值。Dapper 期望我们以对象数组的形式传递参数值,以便它们的值可以映射到查询中的相关参数。

  4. 我们还调用了一个特定的扩展方法——QuerySingleAsync()。原因很明显——我们只想得到一个记录。如果你需要更多,有如Query()这样的函数将返回包含多个记录的IEnumerable

  5. 最后,我们再次简单地从端点调用DapperService中的函数,将结果返回给客户端:

    app.MapGet(
        "/employees/{id}",
        async (int id,
               [FromServices] DapperService dapperService)
               =>
    {
         return Results.Ok(
             await dapperService.GetEmployeeById(id));
    });
    

接下来,让我们看看我们如何更新员工记录。

更新员工记录

我们现在已经进入了 CRUD 的第二个字母。让我们现在通过采取相同的方法来查看更新——创建一个PUT端点并将其连接到DapperService

app.MapPut(
    "/employees",
     async (Employee employee,
            [FromServices] DapperService dapperService) =>
{
    await dapperService.UpdateEmployee(employee);
    return Results.Ok();
});

然后,我们创建一个函数来对数据库进行更新操作:

        public async Task UpdateEmployee (
            Employee employee)
        {
            using (var sqlConnection = new
                SqlConnection("YOURCONNECTIONSTRING"))
            {
                var sql = "UPDATE Employees SET Name = " +
                @Name, Salary = @Salary, Address = " +
                @Address, City = @City, " +
                Region = @Region WHERE Id = @id";
                var parameters = new
                {
                    employee.Id,
                    employee.Name,
                    employee.Salary,
                    employee.Address,
                    employee.City,
                    employee.Region
                };
                await sqlConnection.ExecuteAsync(
                    sql, parameters);
        }
        }

注意我们添加到DapperService中更新记录的代码与我们添加到创建记录的代码相似。主要区别是 SQL 字符串。否则,我们仍然传递一个Employee对象,并通过 Dapper 将其属性映射到数据库中的记录。

删除员工记录

最后,我们到达了 CRUD 的最后一部分——删除

我们将遵循到目前为止在所有操作中应用的原则,但这次我将首先在DapperService中添加通过 ID 删除记录的功能:

        public async Task DeleteEmployeeById(int id)
        {
            using (var sqlConnection = new
                SqlConnection("YOURCONNECTIONSTRING"))
            {
                var sql = "DELETE FROM Employees WHERE Id =
                    @employeeId";
                await sqlConnection.ExecuteAsync(
                    sql,
                    new { employeeId = id }
                );
            }
        }

现在,我们可以添加一个端点来使用该服务进行记录删除,在成功时向客户端返回无内容的结果:

            app.MapDelete(
                "/employees/{id}",
                async (int id,
                       [FromServices] DapperService
                       dapperService) =>
            {
                await dapperService.DeleteEmployeeById(id);
                return Results.NoContent();
            });

我们已经介绍了足够的 Dapper 知识,你现在应该能够使用它通过这个强大而轻量级的(微)ORM 在 SQL 数据库上执行基本的简单 CRUD 操作。你也可以尝试用支持其他数据库的 SQL 提供程序替换 SQL 提供程序,例如 MySQL 或 PostgreSQL,以改善你使用 Dapper 管理 SQL 数据在最小 API 上的请求体验。

Dapper 有其位置,但 Entity Framework 是一个功能更丰富的替代品,不仅用于交易数据,还可以通过代码管理 SQL 数据库结构。

让我们看看我们如何使用 Entity Framework 构建我们在该部分中探索的相同功能。

在最小 API 项目中配置 Entity Framework

首先,我们需要使用 Microsoft 的 Entity Framework 包来配置连接,以下称为上下文。这种说法已经创建了一个从数据库的抽象层,因为我们开始将数据视为该上下文的成员。

首先,通过 Visual Studio 中的包管理器控制台安装以下包:

Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools

这确保了所有通过 Entity Framework 与 SQL Server 交互所需的库都已就绪。

接下来,我们将从包管理控制台再次使用数据库的连接字符串来scaffold现有的MyCompany数据库:

Scaffold-DbContext "Your_Connection_String" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Models -Context MyCompanyContext

Scaffolding意味着 Entity Framework 会查看数据库的模式并将表映射到你的代码中的对象。结果是生成一个DbContext,它封装了上下文中的所有不同实体。还会创建一系列模型,这些模型是代表每个实体的类。这些模型被放置在由控制台命令使用的OutputDir开关指定的文件夹中。

因为我说了我想将上下文称为MyCompanyContext,所以在Models文件夹中生成了一个名为MyCompanyContext的新类。

在这个类中,你可以看到添加了一个名为EmployeesDbSet。一个DbSet代表给定数据库表中所有当前记录。这是一个表示Employees中每个记录的集合,通过向、从、更新或从这个集合中删除,我们可以间接更改相应的 SQL 表。让我们看看生成的代码,以进一步了解这里发生了什么。

MyCompanyContext是一个从DbContext派生的类。它代表了正在使用的数据源,并提供了一种以更高层次抽象与该源交互的方式。当类被实例化时,DbContextOptions会被注入其中,然后传递给基类DbContext

public partial class MyCompanyContext : DbContext
{
    public MyCompanyContext()
    {
    }
    public MyCompanyContext(
        DbContextOptions<MyCompanyContext> options)
        : base(options)
    {
    }
    public virtual DbSet<Employee> Employees { get; set; }

Entity Framework 随后生成构建上下文所需的代码,将业务对象映射到数据库中的表,从而允许创建模型:

    protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder) =>
# warning To protect potentially sensitive information in
# your connection string, you should move it out of source
# code. You can avoid scaffolding the connection string by
# using the Name= syntax to read it from configuration –
# see https://go.microsoft.com/fwlink/?linkid=2131148\. For
# more guidance on storing connection strings, see
# https://go.microsoft.com/fwlink/?LinkId=723263.

在这个例子中,Entity Framework 被指示使用指定的连接字符串来使用 SQL Server 来根据数据库模式建模实体:

       optionsBuilder.UseSqlServer("YOURCONNECTIONSTRING");
    protected override void OnModelCreating(
        ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Employee>(entity =>
        {
            entity.HasNoKey();
            entity.Property(e => e.Address)
                .IsUnicode(false);
            entity.Property(e => e.City)
                .HasMaxLength(50)
                .IsUnicode(false);
            entity.Property(e => e.Country)
                .HasMaxLength(50)
                .IsUnicode(false);
            entity.Property(e => e.Id)
                .ValueGeneratedOnAdd();
            entity.Property(e => e.Name).IsUnicode(false);
            entity.Property(e => e.Phone)
                .HasMaxLength(200)
                .IsUnicode(false);
            entity.Property(e => e.PostalCode)
                .HasMaxLength(50)
                .IsUnicode(false);
            entity.Property(e => e.Region)
                .HasMaxLength(50)
                .IsUnicode(false);
            entity.Property(e => e.Salary)
                .HasColumnType("decimal(10, 2)");
        });
        OnModelCreatingPartial(modelBuilder);
    }
    partial void OnModelCreatingPartial(
        ModelBuilder modelBuilder);
}

在我们开始通过 API 端点使用 Entity Framework 与数据库通信之前,我们还有另一项配置要探索。

理解 Entity Framework 如何使用迁移来更改数据库模式是很重要的。迁移是一组指令,用于对数据库进行特定的更改。无论是向表中添加列、添加新表还是删除列,通常都可以通过迁移来完成。

除了数据之外,迁移还概述了如何管理数据库的结构变化,从而保持 Entity Framework 作为你数据的单一真相来源。

让我们通过修改我们的Employees表来探索迁移。我们将在表中添加一个Title列。首先,打开在Models文件夹中生成的Employee模型,并将Title添加为一个string属性:

  public string Title { get; set; }

然后,我们回到包管理控制台,输入一个命令来添加迁移。此命令要求 Entity Framework 查找DbContext中任何DbSet对象的变化。然后,它将生成最终会在数据库上运行 SQL 以提交更改的 C#代码。

在包管理控制台中使用 Add-Migration 命令,然后跟一个字符串,为迁移提供一个名称:

PM> Add-Migration "Add_title_to_employees_table"

迁移名称

当命名迁移时,提供这些迁移所做的更改的摘要是一个好的做法——例如,changed_datatype_of_salary_column_to_decimal

Entity Framework 将构建项目,并在 Migrations 文件夹中添加一个新的迁移类,如果尚不存在,则创建该文件夹:

public partial class add_title_field_to_employees :
    Migration
{
    /// <inheritdoc />
    protected override void Up(
        MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "Title",
            table: "Employees",
            type: "nvarchar(max)",
            nullable: false,
            defaultValue: "");
    }
    /// <inheritdoc />
    protected override void Down(
        MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "Title",
            table: "Employees");
    }
}

你可以看到迁移指定要添加一个名为 Title 的列。

要将此提交到数据库,我们可以在包管理控制台编写另一个简单的命令:

Update-Database

如果一切如预期进行,你将看到数据库中的 Employees 表有一个 Title 列。

现在我们已经介绍了其配置的基本知识,让我们更新 Program.cs 中的端点以使用 Entity Framework。

使用 Entity Framework 执行 CRUD 操作

我们有一个新的依赖项,形式为 DbContext。我们应该在 Program.cs 中的 Main 方法中注册它,以便在请求期间使用:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<MyCompanyContext>();
var app = builder.Build();

就像我们在之前的 Dapper 示例中做的那样,我们将创建一个新的服务来管理 Employee 对象的 CRUD 操作。这次,我们将对命名更加具体,并将其称为 EmployeeService

EmployeeService 中,首先添加一个构造函数,我们可以向其中传递已注册的上下文:

public class EmployeeService
{
    private MyCompanyContext _companyContext;
    public EmployeeService(
        MyCompanyContext myCompanyContext)
    {
        _companyContext = myCompanyContext;
    }

然后,定义所有需要的 CRUD 操作函数,使用 MyCompanyContext

    public async Task AddEmployee(Employee employee)
    {
        await _companyContext.Employees
            .AddAsync(employee);
        await _companyContext
            .SaveChangesAsync();
    }
    public async Task<Employee> GetEmployeeById(int id)
    {
        var result =  await _companyContext.Employees
            .FirstOrDefaultAsync(x => x.Id == id);
        if(result == null)
        {
            throw new EmployeeNotFoundException(id);
        }
        return result;
    }
    public async Task UpdateEmployee(Employee employee)
    {
        var employeeToUpdate = await GetEmployeeById(
            employee.Id);
        _companyContext.Employees.Update(employeeToUpdate);
        await _companyContext.SaveChangesAsync();
    }
    public async Task DeleteEmployee(Employee employee)
    {
        _companyContext.Remove(employee);
        await _companyContext.SaveChangesAsync();
    }
}

最后,我们将创建一个名为 EmployeeNotFoundException 的客户异常,当请求的员工不在数据源中时,我们可以抛出这个异常:

public class EmployeeNotFoundException : Exception
{
    public EmployeeNotFoundException(int id)
        : base(
            $"Employee with id {id} could not be found")
    {
    }
}

EmployeeService 中的每个新函数中,我们通过位于 MyCompanyContext 中的 Employees 集合与数据库进行交互。然后,我们使用 SaveChangesAsync(); 将我们对该集合所做的更改提交到数据库。

注意其中一个函数的可重用性,GetEmployeeById。在大多数 CRUD 操作中,我们需要能够定位受影响的 Employee 对象,我们可以重用这个函数。为了防止在集合中找不到员工的可能性,我们可以抛出一个自定义异常。这对于 API 端点很有用,因为它意味着它可以在发生时针对特定的异常进行响应,在这种情况下,如果需要,返回 404 NOT FOUND 状态码。

我们已经确定了在 Entity Framework 中,与数据库表交互意味着与 C# 中的集合交互。这在 EmployeeService 中很明显,其中使用了 LINQ 查询,而不是在直接 SQL 连接或像 Dapper 这样的微 ORM 中使用的 SQL 查询。

随着 EmployeeService 的建立,Program.cs 中的 API 端点可以修改为使用 Entity Framework 而不是之前使用的 Dapper。但它们需要改变到什么程度呢?

这个问题的答案是——并不多,多亏了我们以EmployeeService的形式创建的抽象。我们使用注入的依赖项来管理数据库交互,其中函数具有相同的名称或签名,这意味着我们可以简单地用新的使用 Entity Framework 的EmployeeService替换注入的DapperService

返回到Program.cs并注册EmployeeService以进行依赖注入:

    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddScoped<MyCompanyContext>();
    builder.Services.AddScoped<EmployeeService>();
    var app = builder.Build();

双击当前传递给任何映射端点的DapperService参数。在 Visual Studio 中,您可以通过按两次CtrlR键来重命名此对象为EmployeeService

重命名后,所有其他出现的地方也将被更新。只要每个端点调用的函数签名相同,无论您是使用DapperService还是EmployeeService,您都不应该有任何错误。

以下代码展示了创建员工后更新为使用EmployeeServicePOST端点:

    app.MapPost(
        "/employees",
        async (Employee employee,
               [FromServices] EmployeeService
               employeeService) =>
    {
        await employeeService.AddEmployee(employee);
        return Results.Created();
    });

这是对 Entity Framework 的一个相当快速的介绍,它有更多功能。然而,主要重点是抽象方式下最小 API 与数据库的交互,这个例子足以让您开始 ORM 之旅。

让我们回顾一下本章关于 Dapper 和 Entity Framework 所学到的东西。

摘要

本章介绍了使用 Dapper 和 Entity Framework 来在最小 API 端点和关系型数据库之间提供抽象层。

我们从 ORMs 的介绍开始,定义了它们在简化最小 API 数据库交互中的作用,然后概述了与数据源交互的各种功能。

然后,我们逐步配置了 Dapper,添加了相关库,并提供了专门的DapperService,该服务可以在具有依赖注入的最小 API 端点上使用。一旦我们配置了 Dapper,就在DapperService中创建了 SQL 查询,并将端点链接到服务,通过 Dapper 提供 API 和数据库之间的端到端链接。

在使用 Dapper 在数据库上建立 CRUD 操作后,我们通过配置 Entity Framework 进行了对比,然后执行了完成 CRUD 操作的服务等效设置。

最后,使用 Entity Framework 的EmployeeService替换了原始的DapperService,展示了将抽象作为数据管理依赖项注入的灵活性。

毫无疑问,通过 ORMs 集成数据源是构建最小 API 的一个重要方面。在管理数据时,根据数据请求的方式,性能瓶颈的可能性可能很大。我们将在下一章探讨这个概念以及缓解这些瓶颈的方法。

第三部分 - 最佳最小 API

要构建高性能、可扩展的 API,对系统性能进行微调和利用高级编程技术至关重要。本部分涵盖了如何识别瓶颈、采用异步编程以及实施缓存策略来提高效率和用户体验。

本部分包含以下章节:

  • 第十章分析和识别瓶颈

  • 第十一章利用异步编程实现可扩展性

  • 第十二章增强性能的缓存策略

第十章:性能分析和识别瓶颈

随着您最小的 API 项目不断发展,性能瓶颈的可能性增加。处理数据、在网络中进行连接以及运行业务逻辑和计算——所有这些活动都有性能成本。如果配置不当,这些活动可能会产生比必要更高的成本。

在本章中,我们将探讨分析资源利用率、识别常见瓶颈并实施它们的策略。我们将涵盖以下内容:

  • 性能分析和监控简介

  • 性能分析工具和技术

  • 常见性能瓶颈

技术要求

您至少需要运行 Visual Studio 2022 或 Visual Studio code,并创建一个新的 ASP.NET core 最小 API 项目。建议使用上一章的代码,因为我们将在MyCompany API 项目的上下文中处理性能示例。由于本章的示例是针对第十章的代码示例运行的(见[B20968_10.xhtml#_idTextAnchor154]),您需要按照上一章的说明安装 Entity Framework。(参见第九章的在最小 API 项目中配置 Entity Framework部分[B20968_09.xhtml#_idTextAnchor143]。)

性能分析和监控简介

在本章的开头,我们确定了在最小 API 应用程序中运行的代码将产生性能成本,其中一些可能表现为瓶颈,阻碍整体系统的效率。为了解决这个问题,我们可以利用性能分析工具,也称为分析器。

介绍分析器

分析器是一段测量代码运行成本的软件。当您的 API 应用程序运行时,分析器将提供遥测数据,概述代码库中某些区域的资源使用成本。这使我们能够识别效率低下的区域,这对于优化最小 API 应用程序的性能至关重要。这出于许多原因,不仅限于以下内容:

  • 可扩展性:确保您的代码能够以不断增长的需求率执行的能力。

  • 可用性:外部客户端访问 API 至关重要。如果由于缺乏可用硬件资源,客户端无法访问 API,则应用程序已失去可用性。

  • 安全性:客户端和 API 之间的连接不应比必要的更长。如果您的 API 完成请求需要很长时间,连接就会保持更长时间,这为恶意活动提供了更多机会。

  • 成本:未优化系统可能带来的财务成本可能非常严重。如果生产服务器因软件瓶颈而频繁需要升级,每次硬件升级都会产生财务成本。

  • 用户体验:确保您的 API 尽可能快速运行,这样可以确保人们会有积极的体验,从而确保应用程序的重复使用。

  • 错误检测:虽然分析允许检测瓶颈,但它也可以间接揭示代码库中可能未被单元或集成测试检测到的其他错误。

同样,分析最小 API 的优势是相当可观的:

  • 延迟降低:通过分析,响应时间可以增加,从而使得 API 响应更加迅速。

  • 最佳硬件使用:分析可以帮助您看到您在过度消耗资源(如 CPU、内存和 I/O)的地方。

  • 预防措施:运行分析器以识别潜在的优化,让开发者能够提前解决可能出现的未来问题,使他们能够做出计划性的改变,而不是反应性的改变。

  • 维护:总的来说,性能更好的代码库通常更容易维护。如果最小的 API 应用程序易于维护,它可能会看到更频繁的发布周期。

接下来,让我们看看一些性能指标。

性能指标

在发布应用程序供公共使用之前,不进行压力测试和收集结果数据,很难准确预测 API 在重负载下的表现。性能监控可以通过提供关于代码在特定场景下表现的见解来帮助。这些以指标形式的数据可以告知您在接近发布时如何优化代码。您需要从性能指标中理解的关键一点是,机器上运行的 API 资源如何处理大量请求。

分析可以监控最小 API 及其依赖项内的各种资源,包括以下内容:

  • 响应时间:API 处理请求并返回响应给客户端所需的时间。

  • 吞吐量:在任何给定时间内通过连接流动的数据量。

  • CPU 处理:API 主机 CPU 在请求期间或甚至在其他与客户端请求无关的背景任务中完成的处理量。

  • 内存:在任何给定时间内应用程序消耗的 RAM 量。

获取到指标使我们能够开始识别潜在的瓶颈,这样我们就可以采取措施在最小的 API 代码中解决它们。

例如,分析器可能会显示在特定操作期间 CPU 使用量的突然增加。这可能会表明该操作是以次优的方式编写的(例如,不必要的循环或集合上的迭代)。

在对分析器和性能监控有一些了解之后,让我们来看看一些分析器的工具和技术。

分析工具和技术

可用的分析工具有很多,但为了开发 ASP.NET 中的最小 API,我们将探讨两个示例:Visual Studio 分析器和 BenchmarkDotNet。前者是一个基于 GUI 的工具,而后者是一个库,我们可以将其添加到项目中作为依赖项。

这些工具中的每一个都有其特定的用例优势,如这里所示:

  • Visual Studio 分析器:它集成到 IDE 中,提供实时性能数据和 CPU 以及内存指标。它非常适合快速捕获分析、执行基本性能分析以及识别 CPU 和内存使用量高的区域。

  • BenchmarkDotNet:它可以作为一个包安装到 .NET 项目中,擅长于微基准测试。它用于建立基线性能和代码的微调。

还有其他一些可能的选择不是免费的,例如 JetBrains 的 dotTrace,它是一个非常强大的分析器,可以提供通常的资源消耗指标,以及一些非常深入的对调用树和事件随时间线视图。我当然可以推荐 dotTrace,但由于它不是免费的,我们将通过在 Visual Studio 分析器和 BenchmarkDotNet 中探索分析示例来保持简单。

Visual Studio 中的分析

让我们从在 Visual Studio 中设置分析开始:

  1. 在 Visual Studio 中打开 MyCompany API 示例。然后,点击 调试 ,并选择 性能分析器 。屏幕上将显示针对各种类型分析的不同选项。对于本例,我们将分析 CPU 使用情况以演示分析过程。

  2. 打勾 CPU 使用情况 并点击 开始

图 10.1:分析配置屏幕

图 10.1:分析配置屏幕

目标将是开始分析,以便在我们在与 API 交互的同时,它在后台捕获 CPU 使用数据。一旦应用程序停止,将生成一个报告,它将给出 CPU 使用量随时间的变化分析,然后我们可以将其追踪回特定的代码行。

  1. 在开始分析器(确保它在记录)后,您的 API 项目也将开始运行。通过调用 GetEmployeeById 端点与 API 交互,传递现有员工记录的 ID 值。您会注意到,在发出请求时,分析器会实时更新。您应该会看到 CPU 使用量在请求开始时立即增加,一旦请求完成,又会恢复平静。

  2. 一旦向 API 发送了一些请求,请点击屏幕左上角的 停止收集 按钮,然后点击 CPU 使用情况 选项卡(屏幕左侧大约四分之一的位置)。结果应该是一个诊断报告,显示在分析时间段内 CPU 使用情况的峰值和低谷。

图 10.2:CPU 使用情况的分析报告

图 10.2:CPU 使用情况的分析报告

如你在图 10.2中看到的,在分析会话开始时 CPU 使用量出现了峰值。这是在向employees端点发出GET请求时发生的,API 通过返回具有给定 ID 的Employee对象来处理请求。

在这个视图中可以过滤出大量信息。仅举一例,关于.NET 的分析可以写成一整本书。然而,为了举例说明,点击CPU 使用面板右上角的打开详细信息按钮。

屏幕上显示的详细信息将提供关于在给定时间内消耗各种 CPU 百分比级别的代码的更详细信息。有几个关键视图,例如调用树视图,它显示了函数和方法调用之间的嵌套关系(即什么调用了什么)。

图 10.3: 调用树视图,显示树中各层函数的 CPU 消耗

图 10.3: 调用树视图,显示树中各层函数的 CPU 消耗

函数视图非常有用,因为它可以用来识别哪些特定的代码行消耗了更多的 CPU。通过按总 CPU%降序排序这个视图,你可以快速识别 CPU 消耗量最大的部分。这在优化最小 API 时可能很有益处,因为你可以了解底层函数对端点响应时间的影响,例如。

更重要的是,这个视图中的任何条目都可以双击以显示原始源代码,并在函数签名旁边标注 CPU 使用情况。

调用者/被调用者视图,展示了哪些函数被调用以及它们自身调用了什么,在这个请求中尤其具有洞察力。

图 10.4为例。我们可以看到,对于当前在EmployeeService中的函数GetEmployeeById(),CPU 资源消耗了51.42%。进一步分析,我们可以看到该函数被调用到 Entity Framework Core 中的某个方法。在这种情况下,该函数是FirstOrDefault()

FirstOrDefault()语言集成查询LINQ)的一个特性,它将获取满足给定条件的集合中的第一个项目,或者返回默认值,在这种情况下是null

图 10.4中我们可以看到,GetEmployeeById()消耗了 CPU 使用的51.42%,其中50.34%的使用被FirstOrDefault()占用;如果你得到的相同分析值有所不同,请不要感到惊讶。这个值应该在不同的机器之间有所变化。重要的是,你能看到使用量的峰值。

图 10.4: 调用者/被调用者视图,展示了 GetEmployeeById()函数的 CPU 使用情况分解

图 10.4: 调用者/被调用者视图,展示了 GetEmployeeById()函数的 CPU 使用情况分解

这个结果展示了每个函数的 CPU 使用率百分比。单独来看,这并不一定表明存在瓶颈,但在调试性能或进行一般优化时,这是一条有用的信息。

我们可以通过用我们自己的自定义循环实现替换FirstOrDefault()来进一步研究这个问题,以找到匹配 ID 的第一个员工。

循环看起来就像这里给出的示例:

foreach(var employee in _companyContext.Employees)
{
    if (employee.Id == id)
    {
        return employee;
    }
}
throw new EmployeeNotFoundException(id);

如果我们将这个循环替换FirstOrDefault(),我们可以检查相同的性能分析捕获并比较 CPU 使用率。

在这种情况下,当使用循环而不是FirstOrDefault()运行更新后的代码时,我们看到了 CPU 使用量略有增加。因此,虽然FirstOrDefault()尚未被证实是获取调用 API 端点Employee的最高效方法,但我们的替代方案的性能确实较低。这是一个排除法技术,值得在整个代码库中练习。

图 10.5:使用 foreach 循环而不是 FirstOrDefault()对 GetEmployeeById()的 CPU 使用情况

图 10.5:使用 foreach 循环而不是 FirstOrDefault()对 GetEmployeeById()的 CPU 使用情况

如前所述,我们可以进一步深入 Visual Studio 分析器,但这超出了本书的范围。有了这个基础,你现在应该至少有一个用于优化最小 API 的工具。让我们看看另一个用于分析 API 性能的有用工具——BenchmarkDotNet。

使用 BenchmarkDotNet 进行基准测试

BenchmarkDotNet是一个开源的.NET 库,旨在简化.NET 应用程序中的微基准测试。它是由软件工程师 Andrey Akinshin 创建的,他是.NET 社区的一位杰出成员,专注于软件性能。

该项目始于 2014 年左右,旨在为开发者提供一个易于使用的工具,用于测量和比较不同代码片段的性能。

该库作为 NuGet 包提供,我们需要将其安装到MyCompany API 项目中进行性能基准测试。

为了保持整洁,我们将基准测试放在另一个项目中执行。然而,我们可以通过将新的基准测试项目添加到当前解决方案中,保持在当前的 Visual Studio 设置中。

在 Visual Studio 的解决方案资源管理器中,右键单击当前解决方案,然后选择添加 | 新建项目

新建项目界面中,窗口顶部有一个搜索框,允许你搜索项目模板。使用它来搜索"C#控制台应用程序"。一旦看到它,选择它并点击"下一步"。你将被要求选择.NET 版本。我们使用版本 9,所以保持选中并点击"创建"

接下来,我们需要通过包管理控制台将BenchmarkDotNet库包添加到新项目中:

dotnet add package BenchmarkDotNet

新项目在我们的解决方案中,并且安装了所需的库,但它是如何能够从最小 API 项目中引用依赖项的呢?

解决这个问题的方法是创建一个项目引用。这允许我们在多个 .NET 项目之间引用对象类型。当项目与这个例子一样位于同一个解决方案中时,这尤其容易。

要添加项目引用,右键单击你的新基准测试项目,然后点击 添加 | 项目引用

你将看到一个对话框,你可以浏览到项目位置。你的 ASP.NET 项目应该已经被编译为一个 动态链接库 ( DLL ),因此你应该能够从你的 ASP.NET 项目目录中的 bin 文件夹中选择它。

现在你有了项目引用,你可以添加使用语句来引用该项目中的类型,就像它们是在基准测试项目中创建的一样。

我们现在可以继续设置我们的基准测试并利用我们通常注入的依赖项,但首先,基准测试需要它们自己的类;因此,创建一个名为 EmployeeBenchmarks 的类。

在这个新类中,创建一个私有字段来保存 EmployeeService

public class EmployeeBenchmarks
{
    private EmployeeService _employeeService;
}

接下来,我们可以创建一个方法来访问所需的依赖项。我们将把这个方法命名为 Setup()。我们需要用属性来注释这个方法 – [ GlobalSetup]

有这个属性意味着 BenchmarkDotNet 会在基准测试运行之前运行 Setup() 中的逻辑:

[GlobalSetup]
    public void Setup()
    {
        var services = new ServiceCollection();
        services.AddScoped<MyCompanyContext>();
        services.AddScoped<EmployeeService>();
        var serviceProvider =
            services.BuildServiceProvider();
        _employeeService =
            serviceProvider
                .GetRequiredService<EmployeeService>();
    }

这段代码使用了 ServiceCollection,这将需要安装 Microsoft.Extensions.DependencyInjection 包。

Setup() 中,我们创建了所需的依赖项并将它们添加到 ServiceContainer 中,以便它们在运行时可以使用。

我们还检索了 EmployeeService 并将其存储在私有字段中,以便我们的基准测试可以调用其内部的 GetEmployeeId() 函数。

最后,对于这个类,我们添加了基准测试本身,这是重要的部分。我们想要为在 Entity Framework 中运行的活动创建一个基准测试,因此我们将调用 GetEmployeeId() 函数,以便通过 MyCompanyContext 与数据库交互,并且这个活动将被 BenchmarkDotNet 记录。我们向 GetEmployeeById() 传递了一个硬编码的 ID,因为我们知道 ID 为 6Employee 记录存在,并且这不会改变(显然,你必须确保存在具有此 ID 的记录,或者将值从 6 更改为你知道存在于数据库中的值):

  [Benchmark]
  public void GetEmployeeByIdBenchmark()
  {
      var result = _employeeService
          .GetEmployeeById(6)
          .GetAwaiter()
          .GetResult();
  }

注意到 [Benchmark] 属性装饰了 GetEmployeeByIdBenchmark() 方法。这表明该方法是一个相关的基准测试,应该运行。

现在,拥有一个单独的控制台应用程序在这里真的很有帮助。在基准控制台应用程序的Program.cs类的Main()方法中,我们可以简单地调用静态的BenchmarkRunner,并告诉它运行基准类中的任何基准测试,它将根据[ Benchmark]属性的存在来检测:

static void Main(string[] args)
{
    var result = BenchmarkRunner.Run<EmployeeBenchmarks>();
}

现在可以运行基准测试控制台应用程序,为EmployeeBenchmarks中带有[ Benchmark]属性的任何方法或函数提供结果。

在运行控制台应用程序之前,在解决方案资源管理器中右键单击控制台应用程序项目,并选择设置为 启动项目

控制台应用程序运行完成后,你将在控制台窗口中看到基准输出,以及一系列发布到应用程序bin目录中名为BenchmarkDotNet.Artifacts的文件夹中的文件。在这里,你可以找到控制台输出,以及以 HTML、Markdown 和 Excel 格式组织的结果。

让我们看看输出中最重要的一部分。在控制台显示的结果中,你会注意到一个表格。这个表格包含了GetEmployeeById()的基准测试信息:

| Method                   | Mean     | Error   | StdDev  |
|------------------------- |---------:|--------:|--------:|
| GetEmployeeByIdBenchmark | 171.1 μs | 3.37 μs | 5.63 μs |

此表显示了运行GetEmployeeById()所需的平均时间(以微秒为单位),这是在多次迭代中得出的。

平均耗时以平均值的形式显示,为我们提供了一个可以用于性能分析的基础测量值。建议记录这个平均值,然后使用不同的输入多次运行基准测试,以进一步证实这个平均值。

错误StdDev列提供了一些额外的支持信息。StdDev代表标准偏差,即与平均值的差异量。标准偏差越小,基准时间越一致。如果你看到更高的标准偏差,这意味着平均结果有更大的变异性。

错误列表示平均平均结果的估计标准偏差,这是结果可靠性的一个指标。数字越小,结果越可靠。

再次,运行具有不同输入的几个基准测试是有意义的。如果你看到相似的标准偏差和误差,你可以对结果的准确性有信心。

发布模式

为了使前面的分析工作正常,你的项目需要以发布模式构建。如果你在 Visual Studio 顶部的工具栏下拉菜单中看到单词调试,将其更改为发布,然后重新构建你的项目。

常见性能瓶颈

让我们看看可能导致性能下降的一些常见原因以及如何解决它们。这些方法并不适用于所有情况,但它们是众所周知的问题瓶颈:

  • 数据库访问:瓶颈是由缓慢的数据库查询或数据库连接使用不当引起的。为了减轻这种情况,请执行以下操作:

    • 使用异步数据库操作(async/await)。

    • 优化 SQL 查询并使用适当的索引。查看任何可能对系统造成负担的WHERE子句或JOIN操作。

    • 实现连接池以减少需要打开新连接的次数。

    • 使用缓存系统,如 ASP.NET 的IMemoryCache,来存储频繁访问的数据。

  • I/O 操作:瓶颈是由于阻塞 I/O 操作,如文件或网络访问。为了缓解这种情况,请执行以下操作:

    • 使用异步 I/O 操作。

    • 最小化磁盘和网络 I/O。在可能的情况下,从内存中检索常用数据,而不是从持久存储或通过网络。

    • 使用高效的数据格式(例如,使用 JSON 而不是 XML)。

  • 序列化/反序列化:瓶颈源于数据序列化和反序列化的缓慢或低效。为了缓解这种情况,请执行以下操作:

    • 使用优化的序列化程序,如System.Text.Json,而不是Newtonsoft.Json

    • 最小化序列化数据的尺寸。

  • 中间件管道:瓶颈是由请求管道中过多的或不高效的中间件引起的。为了缓解这种情况,请执行以下操作:

    • 审查和优化中间件组件。

    • 移除不必要的中间件。

    • 使用轻量级中间件。

  • 日志记录:瓶颈源于广泛的或同步的日志记录。为了缓解这种情况,请执行以下操作:

    • 使用异步日志记录。

    • 在生产环境中降低日志的详细程度。

    • 使用高效的日志框架,如 Serilog。

  • 依赖注入(DI):瓶颈是由于依赖注入的不高效使用。为了缓解这种情况,请执行以下操作:

    • 在适当的情况下使用作用域或单例生命周期。

    • 避免不必要的服务注入。如果存在更简单的替代方案,可以避免依赖注入,请使用它。

  • 垃圾回收(GC)压力:瓶颈源于过度的内存分配,导致频繁的垃圾回收。为了缓解这种情况,请执行以下操作:

    • 通过重用对象来减少分配。

    • 在可能的情况下使用值类型而不是引用类型。

    • 优化数据结构,并避免在堆上大量分配对象(尽可能使用值类型而不是引用类型)。

  • 网络延迟:瓶颈是由网络调用中的高延迟引起的。为了缓解这种情况,请执行以下操作:

    • 最小化网络调用的数量。

    • 实现具有指数退避的重试策略。

    • 调查替代网络协议,使用基准测试来查看它们是否资源消耗更少。

通过对最常见的瓶颈及其缓解方法有一个一般性的了解,你将在调试和审查最小 API 中的代码时更加警觉。

现在我们将回顾本章中我们涵盖的各种主题。

摘要

在本章中,我们从高层次探讨了最小 API 中性能问题的各种陷阱和缓解选项。

我们首先概述了性能分析的基本原理以及为什么它在不仅仅是最小化 API,而且在一般软件工程中都很重要。

然后,我们审查了一些不同的工具,在缩小我们使用的工具范围后,将工具聚焦于 Visual Studio 分析器和 BenchmarkDotNet。

然后,我们开始使用 Visual Studio 分析器对MyCompany API 进行性能分析,分析器输出的各种指标被分解到由分析器生成的诊断报告中。这使得我们能够找到代码某一部分的整体 CPU 使用情况,但随后还可以进一步通过调用树中较低层的被调用函数来分解。

接下来转向 BenchMarkDotNet,我们实现了与 Visual Studio 分析器相同的分析示例,这次是对目标方法进行性能基准测试。然后我们审查了输出结果,以了解如何根据错误率的稳定性和标准差来确保基准测试的准确性。

就像本书中的许多主题一样,这只是一个表面的探讨,但它将为最小化 API 的进一步优化提供一个坚实的基础,并为你分析它们的效率提供一个良好的基础。

让我们继续到下一章,我们将探讨如何使用异步编程来扩展最小化 API。

第十一章:利用异步编程实现可伸缩性

每当我们执行一个函数时,我们都期望得到一个结果,但请求和输出结果之间发生了什么?

想象你正在镇上,有一堆任务要完成,但你也很饿,需要吃午餐。你走进一个位于购物中心内的披萨店。这家店会根据订单现做披萨。披萨的准备和烹饪大约需要十五分钟。你可以等在店里直到披萨做好,但你还需要去银行,银行在马路对面。披萨店的老板是你的朋友,并同意在你披萨准备好取时给你发短信。在你披萨烹饪的时候,你可以有机会完成其他事情;这是对时间更好的利用。

这是一个对异步函数的简单类比。走进披萨店是函数的开始,你在它烹饪的时候跑向银行是函数的运行。当你的手机响起,收到披萨准备好的短信时,这是函数返回其输出的时刻。

这个例子展示了异步函数的好处,它允许在等待特定操作完成的同时执行其他任务。烹饪你的披萨不会阻塞你整体的目标,即完成你的任务。

如果披萨店的老板不那么友好,要求你等到披萨做好才能离开,那么这就是一个同步操作的例子,与异步操作相反。同步操作会阻塞你整体目标的进展(完成你的任务)直到当前操作完成。

在可能的情况下,我们希望从异步编程中获取操作执行作为最小 API 一部分的好处。

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

  • 在最小 API 中理解和实现异步模式

  • 常见陷阱和挑战

技术要求

本章的代码可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9 。运行代码需要 Visual Studio 和.NET 9 SDK。

在最小 API 中理解和实现异步模式

开篇的披萨类比可能是一个很好的、高级的说明,展示了异步编程和同步编程之间的区别。异步编程在最小 API 中非常重要,因为它为管理客户端和服务器之间的对话提供了很多灵活性。对于长时间运行的操作尤其有益,因为如果操作以线性方式运行,每个操作都会阻塞其他操作,这会损害请求的整体性能。

异步编程还提供了可扩展性优势,使 API 能够应对高需求。这是通过确保线程不被阻塞来实现的。异步端点的操作可以注册回调,以确保执行线程可以在回调解决之前继续运行其他任务。这带来了其他资源优势,如更好的线程池管理、降低 CPU 消耗和减少内存占用。所有这些对于最小化 API 至关重要,因为它们旨在尽可能简单和高效。

基于任务的异步模式

.NET 在其从.NET Framework 到.NET core 的演变过程中,看到了多种不同的异步编程模式的使用。在.NET Framework 时代,基于任务的异步模式TAP)是管理异步执行的首选方法。它是在.NET 4 的 Task Parallel Library 中引入的,使用TaskTask来表示异步操作,并提供了一种处理它们的结果或异常的方法。现在在.NET 9 中,TAP 的显式实现已经过时,但这个例子有效地演示了异步操作。如果我们在一个最小化 API 中使用它,它将位于端点体中,建立一个Task,这将执行一个长时间运行的任务。然后我们会启动Task,同时告诉它一旦完成应该调用什么逻辑。我们可以在以下示例中看到这一点,该示例运行一个任务从另一个 API 获取数据,然后继续检查结果,在向客户端返回响应之前继续:

app.MapGet("/fetch-data", (HttpContext httpContext) =>
{
    HttpClient client = new HttpClient();
    string url =
        "https://jsonplaceholder.typicode.com/posts/1";
    // Initiate the asynchronous operation and return a
    // continuation task
    return client.GetStringAsync(url).ContinueWith(task =>
    {
        if (task.IsCompletedSuccessfully)
        {
            // Task completed successfully, return the data
            return httpContext.Response.WriteAsJsonAsync(
                new { data = task.Result }
            );
        }
        else if (task.IsFaulted)
        {
            // Task faulted, handle the exception
            var errorMessage =
                task.Exception.Flatten().InnerException
                    ?.Message ?? "An error occurred";
            httpContext.Response.StatusCode =
                StatusCodes.Status500InternalServerError;
            return httpContext.Response.WriteAsJsonAsync(
                new { error = errorMessage }
            );
        }
        else
        {
            // If task was cancelled or some other state,
            // handle accordingly
            httpContext.Response.StatusCode =
                StatusCodes.Status500InternalServerError;
            return httpContext.Response.WriteAsJsonAsync(
                new { error = "Unknown error occurred." }
            );
        }
    });
});

虽然这段代码可以在 API 端点中演示异步执行,但它可以读起来更加清晰。幸运的是,在.NET Framework 4.5 和.NET 5 中,引入了async/await关键字。

基于 async/await 的 TAP

async/await 关键字通过允许我们编写类似于同步代码的异步代码,使得异步编程更加易于访问。这大大提高了异步代码的可读性,因此也更容易理解。在一个最小化 API 中,我们旨在在 IDE 中节省空间,这一点非常有价值。

这是使用async/await而不是使用原始基于 Task 的语法时,上一个示例中的端点看起来像什么:

app.MapGet(
    "/fetch-data-async-await",
    async (HttpContext httpContext) =>
{
    HttpClient client = new HttpClient();
    string url =
        "https://jsonplaceholder.typicode.com/posts/1";
    try
    {
        // Asynchronously fetch data from the external
        // service
        string data = await client.GetStringAsync(url);
        await httpContext.Response.WriteAsJsonAsync(
            new { data}
        );
    }
    catch (HttpRequestException ex)
    {
        // Handle error (e.g., network issues, server
        // problems)
        httpContext.Response.StatusCode =
            StatusCodes.Status500InternalServerError;
        await httpContext.Response.WriteAsJsonAsync(
            new
            {
                error =
                    "Error fetching data: " + ex.Message
            });
    }
    catch (Exception ex)
    {
        // Handle any other exceptions
        httpContext.Response.StatusCode =
            StatusCodes.Status500InternalServerError;
        await httpContext.Response.WriteAsJsonAsync(
            new
            {
                error =
                    "An unexpected error occurred: " +
                        ex.Message
            });
    }
});

希望很明显,第二个示例中启动对其他 API 的异步调用的代码部分,比第一个示例中的对应部分更加简洁和简短。

在最小 API 中,我们不需要做太多就能使 API 端点与async/await兼容。注意,在第二个使用async/await的示例中,端点在路由之后定义的 lambda 表达式之前有async关键字。这,就像在常规.NET 函数和方法中一样,允许在函数体中使用await关键字。如果没有async关键字,await是不支持的。

第一个示例没有使用async关键字,但最终仍然能够创建一个异步操作。这看起来可能像是一种矛盾,直到我们考虑到,除了async之外,await在第一个示例中也明显缺失。因此,重要的是要记住,在最小 API 中,async关键字不是任何异步代码的先决条件,但它允许使用await,因此可以更简单地实现类似于同步的异步操作。

通过使用async/await,我们可以以简化的方式实现 TAP。

异步处理模式

另有一个定义良好的模式,称为异步处理模式,实现了异步执行。

有时被称为延迟处理,与 TAP 相比,这种模式可能相对复杂,但原理是相同的。控制流返回到函数的消费者,同时其他长时间运行的操作完成。然而,在这个模式中,函数的消费者不是 API 应用程序的主线程,而是向 API 端点发出请求的客户端。

图 1 .1 通过延迟处理展示了执行过程:

图 11.1:跨越两个客户端请求的延迟处理

图 11.1:跨越两个客户端请求的延迟处理

我们可以相对容易地将当前示例转换为使用延迟处理的版本。首先,我们需要创建一个端点,该端点开始执行长时间运行的任务,然后立即通过返回状态码来确认调用者。然而,仅状态码本身是不够的。我们必须返回一个客户端的回调 URL。此 URL 将路由到另一个端点,该端点将检查我们的长时间运行操作是否已完成。如果已完成,它将检索相关数据,然后将其作为响应返回给客户端。如果操作尚未完成,它仍然会响应客户端,表明操作仍在运行。

让我们从创建第一个端点开始,该端点将确认客户端请求开始长时间运行操作。我们还将创建一个字典来存储等待客户端通过回调收集的响应:

var results = new ConcurrentDictionary<Guid, string>();
// Endpoint to start the long-running background task
app.MapPost("/start-process", async () =>
{
});

由于字典是线程安全的,意味着.NET 将自动管理多个并发线程访问的场景,因此已将其添加为ConcurrentDictionary。例如,如果有多个请求到 API。

接下来,在POST端点的主体内部,我们生成一个GUID来表示挂起的请求,以及一个可以在回调响应中引用的GUID字符串版本:

var requestId = Guid.NewGuid();
var requestIdStr = requestId.ToString();

现在只剩下启动长运行任务,在将GUID返回给客户端之前,以便他们可以在回调请求中使用它来查看他们的结果是否已准备好检索:

// Start the long-running task
_ = Task.Run(async () =>
{
    await Task.Delay(10000); // Simulate a long-running
                             // task (10 seconds)
    results[requestId] = $"Result for {requestIdStr}";
    // Store result in dictionary
});
// Respond with the request ID
return Results.Ok(new { RequestId = requestIdStr });

现在客户端已经通过返回的GUID获得了唯一标识符,它可以在第二次请求中使用以获取结果。

让我们为此目的创建一个GET端点。该端点将比第一个端点简单得多。它将简单地尝试在字典中找到一个与传入的GUID参数匹配的键。如果字典包含请求的键值对,则原始的长运行操作完成。否则,它可能仍在运行或从未启动。GET端点必须处理这两种情况:

// Endpoint to get the result based on the request ID
app.MapGet("/get-result/{requestId}", (string requestId) =>
{
    if (Guid.TryParse(requestId, out var guid) &&
        results.TryGetValue(guid, out var result))
    {
        return Results.Ok(new { Result = result });
    }
    return Results.NotFound(new { Error =
        "Result not found or not yet completed."
    });
});

接下来,尝试依次调用这两个端点。如果你在第一个端点之后不到十秒内请求第二个端点,你应该得到一个带有Result not found or not yet completed消息的404 NOTFOUND结果,然后在十秒后得到预期的GUID结果。这将以简单的方式演示了延迟处理。

为了扩展你对这种执行模式的实践,你应该尝试更复杂的使用案例,例如在后台运行复杂的数学计算或进行数据库或网络请求。

常见陷阱和挑战

异步编程带来了一系列的陷阱和挑战。让我们看看在最小 API 中编写异步代码时应警惕的一些示例:

  • 死锁:当并发操作由于阻塞而无法完成时,会发生死锁。在最小 API 中,这可以在主线程被阻塞时看到。在以下示例中,使用Task.Run可能导致死锁,因为它阻塞了主线程:

    // Deadlock-prone code
    public async Task<IActionResult> GetData()
    {
        var data = Task.Run(() =>
            GetDataFromDatabase()).Result; // Blocking
                                           // call
        return Ok(data);
    }
    

    避免死锁的简单方法是在运行任务时使用await,以确保调用不会阻塞主线程:

    public async Task<IActionResult> GetData()
    {
        var data = await Task.Run(() =>
            GetDataFromDatabase());
        return Ok(data);
    }
    
  • 资源管理:在可能的情况下,管理数据库连接或文件句柄等资源的最小 API 代码应在异步上下文中适当地释放。

    任何实现IDisposable的资源都可以使用using语句在不再使用时自动释放资源。然而,当为资源编写异步代码时,尽量使用可用的IDisposableAsync。这意味着你将与using语句结合使用await

    public async Task<IActionResult> GetData()
    {
        await using (var dbContext = new DbContext())
        {
            var data = await dbContext.GetDataAsync();
            return Ok(data);
        }
    }
    
  • 竞态条件:竞态条件是多个线程同时访问和修改共享数据的结果。例如,如果你在你的最小 API 中有一个静态字段,并且有一个访问它进行修改的端点,你必须记住请求可以并发执行,多个客户端可能同时运行端点逻辑。这会导致你的 API 中的静态字段变得不一致,因此不准确。你必须确保对共享数据的每个操作都是原子性的——一个操作必须完成,然后另一个操作才能发生:

    private static int _counter = 0;
    public async Task<IResult> IncrementCounter()
    {
        var newCounterValue = _counter + 1;
        await Task.Delay(100); // Simulate async work
        _counter = newCounterValue;
        return Results.Ok(_counter);
    }
    

    在这个例子中,对IncrementCounter的多个请求可能导致_counter的不一致状态。

    解决这个问题的方法是使用同步机制来管理共享值的状态。最常用的同步机制是,它使用一个对象来阻止线程在访问特定值时执行。这意味着锁定它,防止其他线程访问,迫使它们等待轮到它们:

    private static int _counter = 0;
    private static readonly object _counterLock =
        new object();
    public async Task<IActionResult> IncrementCounter()
    {
        lock (_counterLock)
        {
            var newCounterValue = _counter + 1;
            _counter = newCounterValue;
        }
        await Task.Delay(100); // Simulate async work
        return Ok(_counter);
    }
    

    这个例子展示了如何建立和执行一个,以确保_counter一次只由一个线程更新,从而消除在 API 内部发生竞态条件的可能性。

异步编程可以为任何最小 API 项目增加一个新的复杂层,但我们已经在本章中证明,通过仔细的关注,它可以是一个优化 API 效率的强大工具。让我们回顾一下本章涵盖的内容。

概述

我们以披萨店类比作为本章的开端。我们通过将其比作一个外卖食品订单来介绍异步编程,你不必简单地等待,而是继续你的正在进行中的任务,直到披萨准备好让你取走。

我们随后为理解异步代码如何使最小 API 受益奠定了基础,它通过最佳使用硬件资源和应用可扩展性的范围。

我们探讨了常见的异步编程模式,即 TAP 和延迟执行模式,以及如何使用async/await使异步代码更易于阅读,使其看起来更像同步代码的示例。我们探讨了延迟执行如何使 API 在客户端级别异步,允许客户端收到确认,表明他们的请求已被接收,并附带一个唯一的标识符供他们参考,从而将整体端到端执行扩展到多个 API 请求。

最后,我们通过三个常见的例子解决了异步编程带来的常见挑战,尤其是在最小化 API 中。第一个是死锁,由于多个线程或操作之间的竞争,全局执行无法继续。接下来是资源管理不善,代码在释放对外部资源连接时没有考虑到异步上下文。最后,我们探讨了竞争条件,这是多个操作竞争更新共享值或资源状态的经典例子,导致行为不一致和数据的准确性不准确。

没有软件开发者能够轻易逃避管理异步执行的需求,尤其是在.NET 最小化 API 中。因此,保持警惕,结合书中早期学到的良好测试和性能分析技术,可以在尽可能减少痛苦的情况下使体验达到最佳。

接下来,我们将探讨优化任何最小化 API 性能的关键方法——缓存。

第十二章:提高性能的缓存策略

经常提到最小 API 应该是真正的最小化。在很大程度上,这种简约主义是基于最小化空间占用——尽量使我们的代码在页面上的可见足迹尽可能小。但 API 的简约主义也扩展到了资源占用,这意味着,在可能的情况下,我们应该最小化过度使用数据库/网络连接和 CPU 对系统造成的压力。

通过简约主义提高 API 的性能是我们的目标,这可以通过缓存部分实现。

当数据被缓存时,它将按照首次使用后的顺序存储,以便在未来的操作中重复使用。通过这样做,我们可以减少获取该数据时产生的延迟或开销。

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

  • 最小 API 中的缓存介绍

  • 内存缓存技术

  • 分布式缓存策略

  • 响应缓存

技术要求

要运行本章中的代码,需要 Visual Studio 2022 或 Visual Studio Code。您还需要在系统上安装 SQL Server 2022,并配置一个可以查询的工作数据库作为示例。建议您在阅读本章之前完成第九章,以便您可以使用配置好的示例员工数据库。

本章的代码可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

本章将介绍需要内存缓存提供程序的分布式缓存策略——在本例中,是 Redis。安装 Redis 不在本书的范围内,但有关如何在 Azure 中安装 Redis 或托管 Redis 的文档可以在learn.microsoft.com/en-us/azure/azure-cache-for-redis/quickstart-create-redisredis.io/docs/latest/operate/oss_and_stack/install/install-redis/找到。

在您的本地 Windows 机器上使用 Redis 的方法是通过Windows Subsystem for LinuxWSL)安装它,并在您的本地 WSL 实例上托管它。有关安装 WSL 的更多信息,请参阅此处:learn.microsoft.com/en-us/windows/wsl/install

最小 API 中的缓存介绍

API 执行操作,而操作(通常)依赖于数据或状态。数据需要被检索或计算,因为它要么存在于静态(即,在数据库中或在远程文件位置),要么存在于使用中(即,尚未计算以生成其他数据)的数据。

无论我们如何看待它,检索数据都会产生开销,无论是直接检索还是作为计算结果检索。缓存旨在通过利用已从原始来源产生的数据或状态来减少这种开销。

可以争论说,现在的计算速度如此之快,以至于开销应该微乎其微,以至于不再需要缓存。然而,这将是极其不准确的。单独查看一个操作,例如从 SQL 数据库中检索记录,可能看起来非常快,但在规模上,缓存的益处变得更加明显。

让我们用一个实际例子来看看缓存如何有益。一家初创公司构建了一个系统,可以用来通过最小 API 向移动设备发送警报。他们必须确保请求可以通过调用客户端处理,因此他们需要在每次请求的请求头中发送一个 API 密钥以进行验证。

为了验证密钥,初创公司的开发者决定将密钥验证外包给一家管理密钥和要使用的加密算法的云公司——为此自己托管一个 API。初创公司按请求验证密钥收费。

在早期,验证密钥的成本相对不明显,因为它们的请求数量很少且分散。然而,随着他们的业务开始增长,请求的数量也随之增加。很快,他们收到了来自云合作伙伴的令人恐惧的账单,账单上显示了大量按请求收费的 API 验证。

缓存本可以用来减轻验证 API 密钥的成本。可以首先发起一个请求来验证密钥,然后将其结果缓存起来。从那时起,当收到使用该密钥的请求时,首先会检查缓存。如果缓存中有验证密钥的记录,则无需调用付费 API 进行验证。每个缓存的记录都有一个过期日期,这意味着可以通过再次调用付费 API 来刷新它。这大大减少了验证 API 密钥的财务影响。

我们已经确定缓存对性能、减少延迟和整体应用可扩展性有好处,但我们应该使用哪种类型的缓存?为了回答这个问题,我们将探讨在最小 API 开发中可用的三种关键缓存方法:内存缓存、分布式缓存和响应缓存。

内存缓存技术

在 ASP.NET Core 支持的多种缓存技术中,内存缓存可能是最简单的。这种类型的缓存将其内容存储在托管最小 API 的机器的内存中。

缓存的实现基于IMemoryCache,它包含在Microsoft.Extensions.Caching.Memory包中,该包通常默认包含在 ASP.NET Core 项目中。

就像其他核心服务一样,IMemoryCache 可以通过依赖注入来使用,因此我们可以很容易地在最小 API 的各个区域中按需注入它。

使用这种缓存类型,我们可以存储一个对象,这是我们的最小要求,但我们也可以非常容易地指定一个过期时间,这是一个最佳实践,因为定期回收缓存可以使其运行顺畅。

让我们探索一个最小 API 中的简单示例。我将使用来自 第九章(可在 GitHub 上找到)的 API 项目作为这个示例项目的基础。我们的目标是减轻与数据库通信时产生的延迟和开销。

在这个 API 中,我们有一个允许客户端通过特定 ID 获取员工的端点。API 将使用 Entity Framework 对数据库执行 SQL 查询,并将结果返回在请求响应中。

使用内存缓存,我们可以为这个操作添加一些优化逻辑。以下是我们要执行的步骤:

  1. 按照要求运行操作,从数据库中获取数据。

  2. 检查内存缓存以查看是否已缓存具有此 ID 的员工。

  3. 如果没有,将检索到的员工添加到缓存中。

  4. 在请求响应中返回员工。

  5. 为同一员工(相同的 ID)创建一个请求。

  6. 从缓存而不是数据库中获取员工。

  7. 将缓存的员工返回给客户端。

在我们实现这个目标之前,我们需要在项目中引用 IMemoryCache

首先,在 Program.cs 中将 IMemoryCache 添加到依赖注入容器中:

builder.Services.AddMemoryCache();

然后,你可以创建 GET 端点,注入这个 IMemoryCache 对象以及 DapperService

app.MapGet(
    "/employees/{id}",
    async (int id,
           [FromServices] DapperService dapperService,
           IMemoryCache memoryCache) =>
{
});

现在你有了缓存,你可以添加从其中检索值的代码:

if(memoryCache.TryGetValue(id, out var result))
{
    return result;
}

通过首先进行检查,我们可以避免不必要的代码执行,并更快地将所需对象传递给客户端,同时避免通过 Dapper 调用数据库。

假设该项目不存在,我们将使用我们的原始逻辑,通过 DapperService 从数据库中查找 Employee 记录。然而,我们不会立即返回项目,而是首先将其添加到缓存中:

  var employee = await dapperService.GetEmployeeById(id);
  memoryCache.Set<Employee>(employee.Id, employee);

这效果很好,但理想情况下,我们不想让它永远留在缓存中。定期刷新缓存是个好主意,因为数据可能会变化,我们希望确保尽可能多地获取最新数据,同时平衡减少数据库事务的延迟。

我们可以通过对缓存的对象设置过期时间来达到这个平衡。这需要在检索 Employee 对象之后但在将其添加到缓存之前完成。例如,我们可以将其设置为 30 秒的过期时间:

var cacheEntryOptions = new MemoryCacheEntryOptions()
    .SetSlidingExpiration(TimeSpan.FromSeconds(30));

通过创建一个 MemoryCacheEntryOptions 实例,我们定义了一些缓存配置参数,当我们将新对象添加到缓存中时可以传递给缓存。更新 cache.Set() 方法以包含此参数:

memoryCache.Set<Employee>(
    employee.Id,
    employee,
    cacheEntryOptions);

你的端点现在应该看起来像这样:

            app.MapGet("/employees/{id}",
            async (int id,
                   [FromServices] DapperService
                   dapperService, IMemoryCache memoryCache)
                   =>
            {
                if(memoryCache.TryGetValue(id,
                    out var result))
                {
                    return result;
                }
                var employee = await
                    dapperService.GetEmployeeById(id);
                var cacheEntryOptions = new
                    MemoryCacheEntryOptions()
                        .SetSlidingExpiration(
                            TimeSpan.FromSeconds(30));
                memoryCache.Set<Employee>(
                    employee.Id,
                    employee,
                    cacheEntryOptions);
                return Results.Ok(employee);
            });

就这样!你已经成功地将缓存引入到你的最小 API 端点,使用了 IMemoryCache

在启动 API 项目时,内存缓存很可能是默认的缓存策略,但如果你的系统采用率有所增长,那么可扩展性和高可用性将变得越来越重要的成功衡量标准。当考虑扩展时,可以使用可靠的缓存框架采用分布式缓存策略。让我们看看最著名的缓存技术之一,Redis。

分布式缓存策略

分布式缓存策略使用诸如 IMemoryCache 之类的在支持可扩展性的架构中的方法。与 IMemoryCache 相比,分布式缓存涉及 ASP.NET 应用程序(托管你的最小 API)和缓存提供程序之间的连接。

在此示例中,我将使用的缓存提供程序是 Redis

Redis 是一个内存缓存提供程序,也可以用作内存数据库。它作为一个开源产品提供,可以安装在本地或云端。

为了演示目的,我在 Ubuntu 机器上安装了 Redis 作为基本服务。然后我更新了 Redis 配置,使其绑定到 0.0.0.0,监听默认端口 6379。这仅在你像我一样,Redis 服务运行在单独的机器上时才相关。

现在有 Redis 实例可用,我可以向 API 项目添加所需的 NuGet 包,以便与 Redis 作为缓存进行交互。

NRedisStack 包添加到项目中:

dotnet add package NRedisStack

我们仍然会在 Program.cs 中与缓存进行交互,因此在这里我们需要从 NRedisStack 引用命名空间:

using StackExchange.Redis;

现在,我们可以更新通过 Id 获取员工的端点,使用新的缓存,用 Redis 替换 IMemoryCache 实现。

我们首先创建 ConfigurationOptions,这可以在连接到 Redis 实例时作为参数传递:

ConfigurationOptions options = new ConfigurationOptions
{
    EndPoints = { { "REDIS IP", 6379 } },
};
ConnectionMultiplexer redis =
    ConnectionMultiplexer.Connect(options);
IDatabase db = redis.GetDatabase();

接下来,我们现在应该有一个可以由 db 变量引用的 Redis 连接。接下来,我们将添加来自 IMemoryCache 示例的等效缓存逻辑,我们首先检查具有键(在这种情况下是 Employee ID 的字符串)的缓存条目,如果存在则返回它,如果不存在,则从缓存返回 Employee 实例:

var employeeIdKey = id.ToString();
var cachedEmployee = db.StringGet(employeeIdKey);
if (cachedEmployee.HasValue)
{
    return Results.Ok(
       JsonSerializer.Deserialize<Employee>(cachedEmployee)
    );
}

当调用 StringGet() 从 Redis 获取相关条目时,如果它不存在,将返回一个对象,其中 HasValues 设置为 false。假设 Redis 缓存不包含我们正在寻找的 Employee 记录,我们将从数据库中获取它并在返回给客户端之前将其缓存:

var employee = await dapperService.GetEmployeeById(id);
db.StringSet(
    employeeIdKey,
    JsonSerializer.Serialize(employee));

请注意,Redis 本身不支持直接插入强类型 .NET 对象,因此当保存时,我们需要通过序列化将 Employee 对象转换为 JSON 字符串,并在检索时从 JSON 字符串反序列化回 Employee 对象:

你更新的 Redis 连接端点现在应该看起来像这样:

app.MapGet(
    "/employees/{id}",
    async (int id,
           [FromServices] DapperService dapperService) =>
{
    ConfigurationOptions options = new ConfigurationOptions
        {
          EndPoints = { { "192.168.2.8", 6379 } },
        };
        ConnectionMultiplexer redis =
            ConnectionMultiplexer.Connect(options);
        IDatabase db = redis.GetDatabase();
        var employeeIdKey = id.ToString();
        var cachedEmployee = db.StringGet(employeeIdKey);
        if (cachedEmployee.HasValue)
        {
            return Results.Ok(
                JsonSerializer.Deserialize<Employee>(
                    cachedEmployee));
        }
        var employee = await
            dapperService.GetEmployeeById(id);
        return Results.Ok(employee);
});

使用 Redis 等工具在独立托管环境中实现缓存,为我们的最小 API 引入了更多的灵活性。我鼓励你通过创建一个通用的服务来进一步扩展这个简单的示例,该服务可以促进 ASP.NET 和 Redis 缓存之间的交互,从而最终将 API 与其缓存系统解耦。在未来,如果你希望从 Redis 迁移到不同的缓存技术,你需要能够做到这一点而不影响原始 API 代码。

我们已经介绍了两种缓存策略的示例。让我们以第三种技术结束,重点关注请求响应的缓存。

响应缓存

响应缓存与前面两种缓存策略的逻辑原理相同,但不是在内存中缓存数据库对象,而是在 HTTP 级别缓存响应。

IMemoryCache一样,最小 API 可以通过在Program.cs中启用响应缓存作为功能来利用 ASP.NET 的本地中间件:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResponseCaching();
var app = builder.Build();
app.UseResponseCaching();

一旦启用,响应缓存添加到GET端点非常简单。我们可以将HttpContext添加到参数中,然后,每当我们有Employee对象并准备返回它时,我们可以设置响应以缓存一定的时间,这意味着在指定时间内请求相同的数据将直接返回缓存的 HTTP 响应,而不是接触数据库:

    app.MapGet(
        "/employees/{id}",
        async (int id,
               [FromServices] DapperService dapperService,
               HttpContext context) =>
    {
        var employee = await
            dapperService.GetEmployeeById(id);
        context.Response.GetTypedHeaders().CacheControl =
            new Microsoft.Net.Http.Headers
                .CacheControlHeaderValue()
    {
        Public = true,
        MaxAge = TimeSpan.FromSeconds(60)
    };
        context.Response.Headers[Microsoft.Net.Http.Headers
            .HeaderNames.Vary] =
                new string[] { "Accept-Encoding" };
        return Results.Ok(employee);
});

如你所见,这是一种非常直接的方式来缓存频繁的响应,并且过期时间当然可以根据需要进行调整。你甚至可以将缓存方法结合起来,有一个内存缓存来检索数据,然后缓存响应。

在手头有三个最小 API 中的缓存工作示例后,让我们回顾本章学到的内容。

摘要

在本章中,我们探讨了使用三种不同的策略进行缓存:ASP.NET 内存、分布式和响应缓存。

我们首先定义了缓存作为一个概念,将其与最小 API 的上下文相关联,然后探讨了公司希望通过缓存来节省通过 API 检索数据成本的假设场景。

在此之后,我们探讨了 ASP.NET 原生内存缓存方法,了解了IMemoryCache以及如何在端点中实现它以限制数据库事务产生的开销。我们还学习了如何使缓存数据过期。

然后,我们将这些知识扩展,遵循类似缓存原则,在分布式缓存中以 Redis 的形式进行。

最后,我们回顾了一个响应缓存的示例,使我们能够通过重新发送之前发送的 HTTP 请求来绕过数据库,处理频繁发送的请求。

在下一章中,我们将探讨你可以观察到的最佳实践,以增加你最小 API 的可读性、可扩展性和可维护性。

第四部分 - 最佳实践、设计和部署

在最后一部分,我们将关注点转移到稳健的 API 设计和部署原则。您将了解如何将生产就绪的最小 API 投入使用的最佳实践,以及在不同环境中进行测试和维护兼容性的策略。

本部分包含以下章节:

  • 第十三章最小 API 弹性的最佳实践

  • 第十四章最小 API 的单元测试、兼容性和部署

第十三章:最小化 API 弹性最佳实践

就像任何软件系统一样,最小化 API 可以以多种方式构建。通过仔细选择和应用不同的模式和遵循一些既定实践,应用程序可以得到极大的增强。

在你的最小化 API 设计中构建模式有多个很好的理由,首先是可读性。由于我们大多数人是团队的一部分,可能需要将代码委托给其他开发者或移交,因此尽可能使其易于访问至关重要。通过确保你的端点是整洁的,代码尽可能具有自文档性,以及命名约定一致,其他开发者将能够相对容易地支持 API 项目的维护。

接下来是可扩展性。如果请求数量增加,那么优化应用程序的需求也会增加。一致性和良好的设计使得满足需求变得简单。无论是添加负载均衡器来管理流量,还是更改数据存储方法,设计 API 时必须确保对系统的修改——无论是添加还是删除组件——不会破坏应用程序的功能。

最后,安全性同样重要。通过遵循最佳安全实践,如静态和传输中的加密、密码散列和加盐,以及范围访问,可以安全地管理敏感数据,降低数据泄露风险以及随之而来的法律挑战。

最终,实现这些目标取决于应用以下实践:关注代码库的结构以增强可读性,处理意外和致命场景的错误处理方式,以及从网络安全角度考虑的考量。

通过探索一些可以提高代码质量的设计实践和编码约定,让我们在你的最小化 API 项目中实现一些这些好处。

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

  • 代码组织和结构

  • 错误处理

  • 安全性考虑

让我们开始吧!

技术要求

推荐使用 Visual Studio 2022 或最新版本的 Visual Studio code 来运行本章的代码。本章的代码示例可在 GitHub 存储库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

其中一个示例使用了第九章和第十二章的代码,这两章都依赖于 Entity Framework Core。建议你在阅读本章之前完成这些章节。

代码组织和结构

关于在任何系统中组织和结构化代码,可能最重要的事情是要理解没有一种正确的方法。虽然有一些广泛接受的结构模式,但这可能是一个非常个人化的话题,因为结构必须服务于维护者。然而,正如我们之前所确认的,在商业或开源环境中,大多数最小 API 系统都会有多个维护者,因此一个一致的结构将使开发者尽可能容易地在代码库上协作。

我们将探讨两种项目组织方式,它们都共享一个关键主题——模块化。

模块化是将你的代码组织成更小、自包含和可复用的单元或模块的实践。让我们分析一下这种实践的一些好处:

  • 关注点分离:通过将具有相似功能的代码分组在一起,我们在代码库中创建了反映它们所服务的业务域的上下文。例如,仅基于管理用户上下文的代码与仅基于管理产品的代码是分开的。在这些上下文之间建立清晰的边界可以确保依赖性最小化。

  • 可复用性:采用模块化设计允许你创建诸如我们在本书中探讨的组件——例如,服务和中间件。在一个以关注点分离为目标的系统中,拥有可复用组件可以帮助在必要时以减少依赖创建的方式连接上下文。

  • 易于维护:模块可以独立于彼此开发和测试,这使得多个开发者之间的并行开发更容易。模块化还支持开放-封闭原则,该原则指出,“软件实体(类、模块、函数等)应该对扩展开放,但对修改封闭。”

这意味着在一个理想的世界里,每当我们想要通过新功能扩展我们的最小 API 时,我们不需要改变现有的代码库来启用这个变化。

代码的有效组织通常受架构设计模式的影响。虽然这确实很重要,但仅仅重新组织项目的文件夹结构就可以在很大程度上使代码更易于阅读和维护。

让我们探索一些示例文件夹结构。

探索文件夹结构

大多数时候,简单地考虑项目文件夹在项目中的排列方式可以显著提高最小 API 的可读性和可维护性。我们正在寻找一个一致的系统来布局类和接口。让我们看看一些具体的文件夹结构,我们可以将这些结构应用到我们的项目中以实现这一点。

基于功能的模块化结构

在这种结构中,最小 API 项目按功能组织,每个功能都有自己的文件夹,包含与该功能相关的所有内容,无论使用哪种组件。以下是一个此类结构的示例:

/src
  /MyMinimalApiProject
    /Modules
      /Users
        UserEndpoints.cs
        UserService.cs
        UserRepository.cs
        User.cs
        UserDto.cs
        UserValidator.cs
      /Products
        ProductEndpoints.cs
        ProductService.cs
        ProductRepository.cs
        Product.cs
        ProductDto.cs
        ProductValidator.cs
    /Middleware
      ErrorHandlingMiddleware.cs
      AuthenticationMiddleware.cs
    /Configuration
      SwaggerConfig.cs
      DependencyInjectionConfig.cs
    /Utils
      DateTimeHelper.cs
      LoggingHelper.cs
    Program.cs
    appsettings.json

在这种结构中,期望开发者采用基于功能的心态。例如,如果你想添加一个与用户管理相关的端点,你会去一个基于用户的文件夹,而不是一个专门用于端点的文件夹。

如本章前面所述,文件夹结构可能是一个个人化和有争议的话题。有些人可能不喜欢在功能集的旗帜下混合组件类型,而其他人则喜欢这种基于领域的结构特性,并且不太关心每个领域内哪种组件在起作用。

分层模块结构

这种结构是我个人偏好的,因为我倾向于在考虑功能或业务领域之前先考虑组件类型。在分层模块结构中,项目首先按组件(例如,端点和服务)分组,然后进一步细分为业务模块/功能。

如果你像我一样,在考虑它所在的领域之前,倾向于先考虑我要创建或编辑的类或文件类型,这种文件夹结构将更适合你。然而,重要的是要注意,虽然这种结构在创建文件夹时优先考虑组件类型,但仍有一个专门的领域文件夹,用于存放实体模型和数据传输对象DTO),这些对象描述了业务领域。以下是一个最小 API 项目中的分层模块文件结构示例:

/src
  /MyMinimalApiProject
    /Endpoints
      /Users
        UserEndpoints.cs
        UserValidator.cs
      /Products
        ProductEndpoints.cs
        ProductValidator.cs
    /Services
      /Users
        UserService.cs
      /Products
        ProductService.cs
    /Repositories
      /Users
        UserRepository.cs
      /Products
        ProductRepository.cs
    /Domain
      /Entities
        User.cs
        Product.cs
      /DTOs
        UserDto.cs
        ProductDto.cs
    /Middleware
      ErrorHandlingMiddleware.cs
      AuthenticationMiddleware.cs
    /Configuration
      SwaggerConfig.cs
      DependencyInjectionConfig.cs
    /Utils
      DateTimeHelper.cs
      LoggingHelper.cs
    Program.cs
    appsettings.json

现在我们已经探讨了如何在最小 API 项目中组织文件夹的一些简单示例,让我们看看在组织项目代码时可以采用的重复模式。这些模式被称为设计模式,就像文件夹结构一样,关于哪些模式构成最佳实践存在争议。

设计模式

这本书不是要告诉你哪些模式是最好的;相反,它旨在为你提供一些指导,告诉你如何一致地组织你的代码,以创建一个一致的 API 系统。以下是一些示例模式。

工厂模式

工厂模式旨在在不指定将要创建的确切对象类的情况下创建对象。之前我提到了开闭原则,工厂模式通过关闭代码以供修改,同时使其易于扩展,帮助最小 API 遵守这一原则。

让我们考虑一个示例用例,在这个用例中,你想要在不同的位置创建日志。一个位置是通过数据库,另一个是在文本文件中。

在未来,你可能希望添加更多的日志源,例如 Webhook 或第三方 API。工厂可以帮助你检索适用于你的用例的正确记录器,同时使添加新记录器变得简单,而无需更改旧记录器。

让我们看看如何通过实现工厂模式来改进日志记录的一个例子:

  1. 首先,创建一个名为ILogger的接口,它将由所有记录器实现,无论它们在将日志保存到各自源时执行的具体日志是什么。ILogger是一个接口,它将代表一个实现将日志写入不同源逻辑的对象:

    public interface ILogger
    {
        void Log(string message);
    }
    
  2. 接下来,创建两个实现ILogger的类。其中一个类,FileLogger,将用于将日志记录到文件,另一个类,DatabaseLogger,将记录到数据库:

    public class FileLogger : ILogger
    {
        public void Log(string message)
        {
            // Logic to log to a file
        }
    }
    public class DatabaseLogger : ILogger
    {
        public void Log(string message)
        {
            // Logic to log to a database
        }
    }
    

    这些类可能有不同的名称,但它们都是ILogger对象,这意味着它们必须实现Log()方法。

  3. 此外,我们可以创建一个函数,它返回ILogger,如下所示:

    public static class LoggerFactory
    {
        public static ILogger CreateLogger(
            string loggerType
        )
        {
            return loggerType switch
            {
                "File" => new FileLogger(),
                "Database" => new DatabaseLogger(),
                _ => throw new ArgumentException(
                "Invalid logger type")
            };
        }
    }
    

在这个例子中,我们创建了一个LoggerFactory类,其中包含一个根据调用者输入的字符串内容返回相关日志类函数。如果loggerType参数无效,则会抛出异常,以便处理错误。

这样做的最大好处是,要添加另一个记录器,我们只需在CreateLogger()中的switch语句中添加新条目前创建一个新的实现ILogger的类。我们无需引入任何破坏性更改来扩展 API 中支持的记录器类型。

存储库模式

此模式为数据访问逻辑创建了一个抽象层,为访问数据库数据提供了更通用的 API,以最小的 API 实现。

在本书早期,我们探讨了 Entity Framework Core 以从数据库中访问数据。只需使用 Entity Framework Core,你的代码就已经使用了存储库模式,因为它提供了一个利用内置DBContext的存储库模式实现。

然而,仍然值得实现一个自定义存储库模式来处理数据,这样你可以进一步泛化解决方案,这意味着 Entity Framework Core 可以从最小 API 应用程序中替换出来,而不会影响整体的数据访问逻辑。

要在 Entity Framework Core 之上创建存储库模式,我们可以简单地为数据库中的每个实体创建一个类。每个存储库类通过依赖注入接收 Entity Framework 上下文,然后可以添加针对此实体的通用创建、读取、更新、删除CRUD)操作。随后,每个存储库也可以注册为依赖注入,以便在应用程序的其他地方使用。

在以下示例中,EmployeeRepository反映了员工实体可用的数据操作。如描述,Entity Framework 上下文被注入为在仓库中使用的数据访问层:

public class EmployeeRepository : IEmployeeRepository
{
    private readonly MyCompanyContext _context;
    public EmployeeRepository(MyCompanyContext context)
    {
        _context = context;
    }
    public async Task<Employee> GetByIdAsync(int id)
    {
        return await _context.Employees.FindAsync(id);
    }
    public async Task<IEnumerable<Employee>> GetAllAsync()
    {
        return await _context.Employees.ToListAsync();
    }
    public async Task AddAsync(Employee employee)
    {
        await _context.Employees.AddAsync(employee);
        await _context.SaveChangesAsync();
    }
    public async Task UpdateAsync(Employee employee)
    {
        _context.Employees.Update(employee);
        await _context.SaveChangesAsync();
    }
    public async Task DeleteAsync(int id)
    {
        var employee = await
            _context.Employees.FindAsync(id);
        if (employee != null)
        {
            _context.Employees.Remove(employee);
            await _context.SaveChangesAsync();
        }
    }
}

从现在开始,如果决定替换 Entity Framework Core,唯一需要改变的是仓库。仓库的消费者将不会受到影响,因为他们调用仓库中的方法和函数将保持其原始签名,尽管其底层逻辑发生了变化。

策略模式

策略模式允许我们定义一组算法,每个算法由一个类表示。在存在多种执行操作方式的情况下,这种模式非常强大,因为我们可以无缝地在它们之间动态切换。

让我们看看一个涉及最小 API 端点的示例,该端点计算员工有多少年假。在这个例子中,根据不同的因素(如员工所在的国家、他们是否处于试用期以及他们在公司服务了多少年)有不同的计算休假的方法。

这里是一个计算休假的逻辑示例大纲(与任何国家的特定劳动法无关!):

  • 如果员工处于试用期,以下适用:

    • 奖励的最少休假天数是 10 天

    • 如果员工在英国,他们将额外获得三天

  • 如果员工不在试用期,以下适用:

    • 奖励的最少休假天数是 16 天

    • 如果员工在英国,他们将额外获得三天

    • 对于每一年服务,将额外奖励一天

这里有很多因素在起作用,但首先,我们可以根据员工是否处于试用期来缩小计算休假的所需操作。这意味着我们有两种计算休假的策略,我们可以自动切换。我们如何实现这一点?

  1. 首先,我们创建一个接口来表示一个策略。它规定我们需要一个CalculateLeaveAllowance()函数,该函数接受一个类型为Employee的参数并返回一个整数:

    public interface IAnnualLeaveStrategy
    {
        int CalculateLeaveAllowance(
            Models.Employee employee
        );
    }
    
  2. 然后,我们将创建ProbationaryAnnualLeaveStrategy,该类实现了接口。在这个类中,CalculateLeaveAllowance将封装计算试用期员工可用的总休假的逻辑:

    public class ProbationaryAnnualLeaveStrategy
        : IAnnualLeaveStrategy
    {
        public int CalculateLeaveAllowance(
            Models.Employee employee
        )
        {
            var leaveTotal = 10;
            if(employee.Country == "United Kingdom")
            {
                leaveTotal += 3;
            }
            return leaveTotal;
        }
    }
    
  3. 然后,同样可以为不在试用期的员工做同样的事情:

    public class PostProbationaryAnnualLeaveStrategy
        : IAnnualLeaveStrategy
    {
        public int CalculateLeaveAllowance(
            Models.Employee employee
        )
        {
            var leaveTotal = 16;
            if(employee.Country == "United Kingdom")
            {
                leaveTotal += 3;
            }
        leaveTotal += employee.YearsOfService;
            return leaveTotal;
        }
    }
    
  4. 这两种策略应在Program.cs中进行注册以供依赖注入:

                builder.Services
                  .AddScoped<
                    ProbationaryAnnualLeaveStrategy
                  >();
                builder.Services
                  .AddScoped<
                    PostProbationaryAnnualLeaveStrategy
                  >();
    
  5. 最后,可以创建一个端点来计算给定Employee ID 的年假。在端点内部,我们根据员工是否处于试用期来创建策略。然后我们使用客户端发送的 ID 检索Employee,并调用CalculateLeaveAllowance()函数来获取结果。这样,根据客户端在请求中发送的数据,自动使用适当的策略来执行正确的逻辑:

    app.MapGet(
        "/calculate-employee-leave-allowance/
            {employeeId}",
        async (int employeeId,
            bool employeeOnProbation,
            [FromServices]
            EmployeeService employeeService) =>
    {
        IAnnualLeaveStrategy annualLeaveStrategy =
            employeeOnProbation
              ? new ProbationaryAnnualLeaveStrategy()
              : new PostProbationaryAnnualLeaveStrategy();
        var employee = await
            employeeService.GetEmployeeById(employeeId);
        return annualLeaveStrategy
            .CalculateLeaveAllowance(employee);
    });
    

通过将逻辑分离成单独的策略,策略模式允许最小化 API 符合开闭原则。它是通过允许我们通过添加新功能来扩展代码库,而不是修改现有代码来实现的。

它还提供了自包含性,意味着执行类似任务的方式不会相互污染,这减少了出现错误的可能性。

单独的设计模式并不能构建一个健壮的系统。健壮性是通过良好地理解错误发生的位置和方式来实现的。

需要服务年数属性

如果你在第九章中使用的原始Employee对象上添加一个名为YearsOfServiceint属性,本节中的示例将能够工作。我们假设你在尝试遵循这个策略模式示例之前已经完成了这项工作。

考虑到这一点,让我们继续探讨在最小化 API 中关于错误处理的最佳实践。

错误处理

当谈到错误处理和健壮性的话题时,第一个是方法,第二个是结果。通过实施有效的错误处理,我们实现了健壮性。

因此,虽然在整个代码库中使用try/catch很重要,但在顶层以标准化的方式处理错误仍然至关重要。对于最小化 API,中间件是处理顶层错误的有效方式。让我们通过一个例子来探讨。

注意

本节假设你已经阅读了第五章或者你已经对如何在 ASP.NET 中编写中间件有深入的了解。

实现中间件确保我们在最小化 API 中有一个全局的错误处理解决方案。把它想象成一个巨大的try/catch,它围绕了所有最小化 API 的端点。

由于我们在第五章中广泛探讨了中间件,我们不需要详细说明中间件是如何构建的,因此我们将直接进入一个错误处理中间件类的例子,如即将到来的代码块所示。

首先,我们为中间件创建一个类,包括一个构造函数和一个可以用来启动中间件逻辑的InvokeAsync方法:

public class ErrorHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ErrorHandlingMiddleware>
        _logger;
    public ErrorHandlingMiddleware(
        RequestDelegate next,
        ILogger<ErrorHandlingMiddleware> logger
    )
    {
        _next = next;
        _logger = logger;
    }
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex, "An unhandled exception occurred."
            );
            await HandleExceptionAsync(context, ex);
        }
    }

在此之后,我们可以添加一个接受当前HttpContext的方法来处理任何检测到的错误:

    private static Task HandleExceptionAsync(
        HttpContext context, Exception exception)
    {
    If (context.Response.HasStarted) return;
    context.Response.ContentType = "application/json";
    context.Response.StatusCode =
        (int)HttpStatusCode.InternalServerError;
        var response = new
        {
            message =
                "An unexpected error occurred. Please try
                again later.",
            details = exception.Message
        };
        return context.Response.WriteAsJsonAsync(response);
    }
}

值得注意的是,这个用于错误处理的中间件示例可以很容易地替换第五章中的类似错误处理示例。

此中间件将捕获请求管道中更高层抛出的异常,确保错误通过HttpContext返回给请求客户端。这确保了无论调用的是哪个端点,客户端都能收到一致的错误响应。

如您从本书前面的内容中了解到的,中间件,例如依赖注入服务,必须在Program.cs中进行注册。将此中间件类注册为第一个要注册的中间件,然后创建一个示例端点,该端点会抛出异常:

app.UseMiddleware<ErrorHandlingMiddleware>();
app.MapGet(
    "/error",
    () => {
        throw new InvalidOperationException(
            "This is a test exception");
    });

您应该会发现,不仅异常的消息被返回,而且还有一个一致性的通用消息,就像这里显示的那样:

{
    "message": "An unexpected error occurred. Please try
        again later.",
    "details": "This is a test exception"
}

在开发任何 API 时,还应保持一致的实践是安全开发。让我们探讨一些您可以在授权请求到您的最小化 API 时应用的良好安全实践。

安全性考虑

在最小化 API 中,有两个关键的安全领域——身份验证和授权。无论它们之间的差异如何,对其实施的态度应该大致相同——不要自行实现

这个格言作为一个警告,表明一个经过验证的安全框架通常比你自己设计的更安全。

让我们首先看看身份验证和授权之间的区别,以及您如何使用知名技术JSON Web TokensJWTs)实现良好的安全级别。

身份验证

身份验证验证访问您的 API 的用户或系统的身份。它允许您只允许合法请求进入系统。

JWT 因其最小化 API 中的无状态身份验证功能而被广泛使用。用户进行一次身份验证并接收一个令牌,该令牌包含在随后的请求中以访问受保护资源。

授权

授权是一种检查已验证用户是否在其特定权限内访问资源的手段。在 JWT 中,这些权限被称为声明

一个声明可以是资源的名称或用户类型,也可以是一个角色。无论如何,JWT 都内置了在最小化 API 中针对特定端点定义和验证声明的功能:

  1. 要开始使用此授权框架,我们首先需要添加Microsoft.AspNetCore.Authentication.JwtBearer NuGet 包。通过工具 | NuGet 包管理器 | 包管理器控制台进行此操作,该控制台可通过工具菜单访问:

    dotnet add package Microsoft
        .AspNetCore.Authentication.JwtBearer
    
  2. 然后,我们需要确保Program.cs有相关的命名空间:

    using Microsoft.AspNetCore.Authentication.JwtBearer;
    using Microsoft.IdentityModel.Tokens;
    using System.Text;
    using System.IdentityModel.Tokens.Jwt;
    using System.Security.Claims;
    using Microsoft.AspNetCore.Authorization;
    
  3. JWT 可以作为中间件实现,因此首先应在Program.cs中设置:

    builder.AddAuthorization();
    builder.Services.AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters =
                new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                ValidIssuer = "https://yourdomain.com",
                ValidAudience = "https://yourdomain.com",
                IssuerSigningKey =
                    new SymmetricSecurityKey(
                        Encoding.UTF8.GetBytes(
                            "A_Not_Very_Secret_Key_1234567
                                890"
                        )
                    )
            };
        });
    var app = builder.Build();
    app.UseAuthentication();
    app.UseAuthorization();
    
  4. 此注册指定了如何验证您的令牌。接下来,我们需要提供一种生成令牌的方法。我们可以通过创建一个专门的 API 端点来实现:

        app.MapGet("/generate-token", () =>
        {
            var tokenHandler =
                new JwtSecurityTokenHandler();
            var key = Encoding.UTF8.GetBytes(
                " A_Not_Very_Secret_Key_1234567890"
            );
            var tokenDescriptor =
                new SecurityTokenDescriptor
            {
                Subject = new ClaimsIdentity(new[]
                {
        new Claim(ClaimTypes.Name, "TestUser"),
        new Claim(ClaimTypes.Role, "Admin")
    }),
                Expires = DateTime.UtcNow.AddHours(1),
                Issuer = "https://yourdomain.com",
                Audience = "https://yourdomain.com",
                SigningCredentials =
                    new SigningCredentials(
                        new SymmetricSecurityKey(key),
                            SecurityAlgorithms
                                .HmacSha256Signature
                    )
            };
            var token =
                tokenHandler.CreateToken(tokenDescriptor);
            var tokenString =
                tokenHandler.WriteToken(token);
            return Results.Ok(tokenString);
        });
    
  5. 最后,既然我们已经有了创建 JWT 令牌的方法,我们可以创建一个需要它的端点。让我们再次创建一个简单的GET端点,但这次,我们添加一个[Authorize]属性并将RequireAuthorization()链接到它:

    app.MapGet(
        "/secure",
        [Authorize] () => "This is a secure endpoint")
        .RequireAuthorization();
    
  6. 为了测试这个,我们可以对这个端点发起一个GET请求,并将返回的 JWT 令牌作为承载令牌添加。使用一个示例 JWT 令牌,头部看起来可能如下所示:

    "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6IlRlc3RVc2VyIiwicm9sZSI6IkFkbWluIiwibmJmIjoxNjQyNzE2MzY0LCJleHAiOjE2NDI3MTk5NjQsImlhdCI6MTY0MjcxNjM2NCwiaXNzIjoiaHR0cHM6Ly95b3VyZG9tYWluLmNvbSIsImF1ZCI6Imh0dHBzOi8veW91cmRvbWFpbi5jb20ifQ.-Ym30PjdvWl5eYdltZd0yA5XQ1ikf5D4KrDlmHMIj0s"
    

    ASP.NET core 将根据你在注册 JWT 中件间时指定的验证参数对请求进行身份验证。

  7. 进一步来说,如果我们想创建一个更受限制的端点,只有具有角色声明Admin的用户才能访问,我们可以在[Authorize]属性中添加一个参数:

    app.MapGet(
        "/admin",
        [Authorize(Roles = "Admin")] () =>
    {
        return Results.Ok("Welcome, Admin!");
    });
    

使用 JWT 令牌,我们的最小 API 提供了对未经授权活动的保护。然而,仅此并不能保证授权使用不会被滥用。

实际上,这是无法保证的,但你可以采取的一个额外步骤来确保最小 API 不会被滥用,就是在指定时间段内限制可以发出的请求数量。这种做法被称为速率限制

速率限制

通过控制客户端在特定时间段内对最小 API 发出的请求数量,速率限制可以帮助防止系统因请求过多而超负荷,无论请求是否合法。

让我们探索一个 ASP.NET core 最小 API 中速率限制的简单示例。

首先,通过 NuGet 包管理器控制台添加AspNetCoreRateLimit包。您可以通过在 Visual Studio 中点击工具 | 管理 NuGet 包 | 包管理器控制台来打开它。这将打开控制台,您可以在其中输入以下内容:

dotnet add package AspNetCoreRateLimit

然后,通过Program.cs添加速率限制(以及内存缓存):

using AspNetCoreRateLimit;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.Configure<IpRateLimitOptions>(options =>
{
    options.GeneralRules = new List<RateLimitRule>
    {
        new RateLimitRule
        {
            Endpoint = "*",
            Limit = 10,
            Period = "1m"
        }
    };
});
builder.Services
    .AddSingleton<IRateLimitConfiguration,
        RateLimitConfiguration>();
builder.Services.AddInMemoryRateLimiting();
var app = builder.Build();
app.UseIpRateLimiting();
app.MapGet("/", () => "Hello, World!");
app.Run();

此配置将限制所有端点每分钟每个 IP 地址最多 100 个请求。如果超过限制,客户端将收到一个429 Too Many Requests的响应。

要尝试这个示例,将每分钟的请求数量最大值更改为2,看看结果如何。

AspNetCoreRateLimitMicrosoft.AspNetCore.RateLimiting

除了AspNetCoreRateLimit之外,还有另一种速率限制的选项,如前例所示。Microsoft.AspNetCore.RateLimiting也可以用来管理允许请求的速率。AspNetCoreRateLimit是一个第三方库,而Microsoft.AspNetCore.RateLimiting是一个内置的中件间。对于配置,AspNetCoreRateLimit使用 JSON 或程序性配置,而Microsoft.AspNetCore.RateLimiting使用带有RateLimiterOptions的程序性配置。

AspNetCoreRateLimit提供了不同的策略和分布式速率限制的更多灵活性,而Microsoft.AspNetCore.RateLimiting则专注于内置算法和端点特定策略。

我们只是触及了可以实施以提高最小 API 弹性的一些实践,但我们所探讨的例子是改进它们设计的一个良好起点。让我们回顾一下本章中我们看到的实践。

摘要

我们首先观察了实施有效设计实践的合理性——即,从维护的可扩展性和安全性角度来看,增加弹性。

然后,我们讨论了文件夹结构,概述了几个文件夹结构示例,用于在新的项目中使用。

我们随后以工厂、仓库和策略三种设计模式的形式探讨了三个设计模式,为以可扩展的方式安排最小 API 代码提供了坚实的基础。

然后,我们简要回顾了一个示例,说明中间件如何充当“全局捕获”,标准化向客户端返回错误响应的方式,最后探索了一些简单的请求认证和限制它们处理速率的方法。

我们正接近本书的结尾,这意味着我们需要学习如何管理一个在用户群体中部署和活跃的最小 API。考虑到这一点,下一章将涵盖在我们将最小 API 提供给更广泛的世界时需要考虑的最重要的事情——测试、部署和文档。

第十四章:最小 API 的单元测试、兼容性和部署

当你准备将应用程序部署到生产环境时,这是一个令人兴奋的时刻。在那之前,有许多问题需要回答,主要是:“这是高质量的代码吗?”“一切都会按预期工作吗?”“它将在长时间内保持可持续性吗?”

为了帮助我们的最小 API 成功,在它们进入用户手中或负责任何关键业务操作之前,需要进行测试。

这个声明非常明显。当然我们需要测试,但在某些情况下,不那么明显的是我们将如何测试。单元和集成测试可以帮助我们在这方面,为我们提供自动化的解决方案来测试我们的验收标准,检查代码更改引入的新错误,通常被称为回归,并在某些 IDE 中(字面上)为我们提供部署的红绿灯。

在部署之前,也需要考虑兼容性问题。我们将部署到哪个操作系统?将使用哪种类型的 Web 服务器?我们是托管在云端还是本地?

最后,适当的部署方法可以由所有前面的考虑因素决定。虽然看起来很多,但当我们不可避免地自信地部署我们的最小 API,并希望它们带来预期的价值,甚至更多时,这一切都是值得的。

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

  • 最小 API 的单元测试和集成测试

  • 兼容性和将最小 API 迁移到 .NET 9

  • 部署最小 API

技术要求

为了运行本章中的代码,建议使用 Visual Studio 2022 或最新版本的 Visual Studio Code。本章的代码示例可在 GitHub 仓库中找到:github.com/PacktPublishing/Minimal-APIs-in-ASP.NET-9

要跟随本章中所有部署方法,您需要能够访问 Azure 订阅并安装 Docker。

最小 API 的单元测试和集成测试

很可能你已经遇到过单元测试和集成测试这两个术语,但为了复习,让我们简要地定义它们。

单元测试涉及在隔离状态下测试代码库中函数的各个组件,而集成测试则检查系统模块的不同组件之间的交互。在最小 API 中,单元测试可能只是测试一个服务是否按预期工作,而集成测试将确认对端点的 HTTP 请求是否正确地一起使用了服务和其它组件。

简而言之,你要么在测试单个代码单元,要么在测试不同的单元如何相互交互。

让我们为执行非常简单的操作的服务创建一个单元测试:计算给定数值的总和。以下是它在 Program.cs 中的样子,其中它被注册为依赖注入并用作 POST 端点的一部分:

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        builder.Services.AddTransient<CalculatorService>();
        var app = builder.Build();
        app.MapPost(
            "/SumIntegers",
            (int[] integers,
             CalculatorService calculatorService) =>
        {
            var result = calculatorService.Sum(integers);
            return Results.Ok(result);
        });
        app.Run();
    }
}

我们可以实现几个测试框架,但为了保持简单,我将使用 xUnit:

  1. 在 Visual Studio 中右键单击您的解决方案,然后选择 添加 | 新建项目…

图 14.1:在解决方案资源管理器中添加新项目

图 14.1:在解决方案资源管理器中添加新项目

  1. 然后搜索 xUnit(或者如果您不想完全跟随,您首选的测试框架)。

图 14.2:创建一个新的 xUnit 项目

图 14.2:创建一个新的 xUnit 项目

  1. 如果显示 不使用顶级语句 选项,我建议取消选中它,这样您可以清楚地看到每个类中使用的命名空间。这是一个个人偏好,但它会使您更容易跟随这个示例。

图 14.3:取消选中“不使用顶级语句”复选框

图 14.3:取消选中“不使用顶级语句”复选框

  1. 一旦在解决方案中创建了您的测试项目,右键单击测试项目的 依赖项,然后添加对 API 项目的项目引用。

图 14.4:将引用添加到另一个项目

图 14.4:将引用添加到另一个项目

您现在在解决方案中有两个项目。一个是最小化的 API 项目,另一个是与前者链接的测试项目。

现在我们可以开始编写一些测试了。让我们从一个针对 CalculatorService 的简单单元测试开始。

在我们的测试项目中,创建一个名为 CalculatorTests 的新类。然后,更新代码,使得一个名为 Sum_Test() 的方法存在,并在方法签名上方有一个属性 [Fact]

[Fact]
public void Sum_Test()
{
}

[Fact] 属性是 xUnit 用于标记方法为测试的属性。我喜欢这样想:我们是在声明该方法代表的是事实;它应该是客观的。在这种情况下,我们想要一个测试来证明给定整数集合的总和等于我们期望它等于的值。让我们通过编写测试逻辑来更详细地探讨这一点。

Sum_Test() 的主体中,实例化一个新的 CalculatorService 实例,并创建一个整数数组,我们可以在测试中使用它:

var calculatorService = new CalculatorService();
int[] integers = [ 1, 1, 8 ];

由于我们保持了简单性,因此可以一眼看出,integers 中值的总和的预期结果必须是 10

CalculatorService 中添加对 Sum() 函数的调用,并将其存储在一个变量中。同时,添加一个硬编码的变量 10 作为预期结果:

var result = calculatorService.Sum(integers);
var expectedResult = 10;

在单元测试中,有一个被称为 三 A 原则的原则,代表 arrange(准备)、act(执行)和 assert(断言):

  1. 第一步,安排,强迫我们收集数据和资源到特定的状态,以便进行测试。我们通过创建CalculatorService的一个实例,一个我们知道应该加起来等于 10 的整数数组,我们还将它硬编码到一个变量中作为参考,实现了这一点。最后,我们调用Sum()函数以获得实际结果。

  2. 第二步是行动。这仅仅意味着采取行动以使测试可以被评估。例如,如果你正在测试两个整数值之间的计算,实际的计算将在这一点上进行。

  3. 最后一步是断言。断言就是测试本身。在这里,我们将断言我们的[Fact]是真实的。如果断言是正确的,[Fact]就是真实的,测试将通过。如果断言是错误的,测试将失败。

不同的测试框架都有自己的断言实现,但原则是相同的。在 xUnit 中,一个静态类Assert包含各种类型的断言,可以在测试期间使用。例如,一个断言某物为 null 或非 null 是通过Assert.Null()Assert.NotNull()分别表示的。同样,我们可以使用Assert.True()来断言一个语句为真。

我们想要断言Sum()的预期结果与实际结果相等。为此,我们可以使用Assert.Equal()

[Fact]
public void Sum_Test()
{
    var calculatorService = new CalculatorService();
    int[] integers = { 1, 1, 8 };
    var result = calculatorService.Sum(integers);
    var expectedResult = 10;
    Assert.Equal(result, expectedResult);
}

运行此测试就像在方法签名上右键单击并选择运行测试一样简单。测试将运行,并且测试结果将通过 Visual Studio 在测试资源管理器中显示。你应该在测试旁边看到一个绿色圆圈,表示成功。

图 14.5:测试资源管理器屏幕,显示可用的测试及其结果

图 14.5:测试资源管理器屏幕,显示可用的测试及其结果

对于集成测试,可以采用与编写测试类似的方法,主要区别在于测试的作用域。在这个例子中,一个最小 API,一个简单的集成测试的作用域可以覆盖整个端点。让我们通过编写一个针对/sumintegers API 端点的集成测试来检查结果状态码,将其付诸实践。

要运行此测试,我们需要能够访问HttpClient对象并在测试项目中运行WebApplication,因为测试需要针对端点发出请求。为此,你可以让你的测试类实现WebApplicationFactory类型的IClassFixture

在测试类中使用 IClassFixture

IClassFixture是一个接口,允许对象在类级别上共享作用域。在这种情况下,我们想要共享WebApplicationFactory对象的作用域,以便在类内部为测试创建HttpClient实例。

因为WebApplicationFactory需要安装Microsoft.AspNetCore.Mvc.Testing,所以使用 NuGet 包管理器控制台安装此包:

dotnet add package Microsoft.AspNetCore.Mvc.Testing

更新CalculatorTests类,使其实现IClassFixture

public class CalculatorTests :
    IClassFixture<WebApplicationFactory<Program>>

这将需要你为类添加一个构造函数,在其中你可以注入WebApplicationFactory。你还可以使用这个WebApplicationFactory在测试期间创建一个新的HttpClient。让我们将其存储在一个readonly字段中,这样我们就可以通过在构造函数运行后不重新初始化它来保持事物的整洁:

private readonly HttpClient _httpClient;
public CalculatorTests(
    WebApplicationFactory<Program> applicationFactory)
{
    _httpClient = applicationFactory.CreateClient();
}

最后,我们可以编写我们的测试。让我们通过创建一个整数数组作为所需的参数并序列化为 JSON 字符串来安排测试数据,以便它们可以添加到请求体中:

[Fact]
public async Task SumIntegers_ShouldReturnOk()
{
    //Arrange
    var integers = new[] { 2, 4, 4 };
    var jsonContent = new
        StringContent(JsonSerializer.Serialize(integers),
        Encoding.UTF8,
        "application/json"
    );
}

接下来,我们可以通过向目标端点发送 POST 请求来执行操作:

// Act
var response = await _httpClient.PostAsync(
    "/SumIntegers", jsonContent);

最后,我们可以断言响应代码是我们预期的,在这种情况下,200 OK

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

你还可以通过检查响应内容来断言结果是预期的:

Assert.Equal(10, JsonSerializer.Deserialize<int>(await response.Content.ReadAsStringAsync()));

这个集成测试可能看起来与我们在本章早期编写的原始单元测试非常相似,但它有所不同,因为它具有更广泛的范围。

通过调用这个测试,我们不仅向目标端点发送请求,还覆盖了对封装的CalculatorService的测试。

在这两个例子中,对现有的逻辑进行了集成和单元测试。如果我们采用测试驱动开发TDD),我们会在任何逻辑之前编写测试来增强我们的实践。显然,我们预计测试最初会失败,但我们的目标是编写允许测试通过的代码。假设你的测试与 API 需求方面的验收标准具有相同的断言,TDD 通常被认为是将自动化测试应用于代码库的最佳方式。

不论是 TDD 还是其他方式,在单元和集成测试中,拥有测试覆盖率将有助于提高你的最小 API 代码质量,并有望减少发布后报告的 bug 数量。

当谈到 API 的质量和稳定性时,测试代码的逻辑是一个方面;但在迁移到部署之前,还有一个方面需要考虑:兼容性。让我们探讨兼容性,特别是关注撰写本书时的最新.NET 版本——.NET 9。

兼容性和将最小 API 迁移到.NET 9

假设你已经创建了一个最小化的 API 项目,但.NET 版本不是最新的。你希望部署 API,但在这样做之前,你打算将代码库升级到最新的.NET 版本,在撰写本书时是 9.我们需要考虑哪些因素才能有信心确保我们的应用程序在新版本上运行高效,以及我们如何迁移到它?

在进行任何.NET 迁移之前,咨询微软的文档至关重要,其中概述了任何已知的破坏性更改。

.NET 破坏性更改分为三类:

  • 二进制不兼容:现有的二进制文件可能无法加载,可能需要重新编译。

  • 源不兼容:代码可能需要更改才能编译。

  • 行为变化:更新后,代码和二进制文件可能表现不同,这意味着需要代码更改。

虽然微软通常在避免重大破坏性变化方面相当有效,但在更新之前查看learn.microsoft.com/en-us/dotnet/core/compatibility/9.0的文档将为你提供关于可能遇到的问题的宝贵见解。除此之外,如果破坏性变化确实出现在最小 API 更新后,单元和集成测试将增加发现破坏性变化的机会。

微软概述了.NET 不同领域的破坏性变化。对于最小 API,你将最关注 ASP.NET 领域的更改,但确保你审查其他领域,例如核心.NET 库、部署、网络、SDK、MSBuild 和序列化,因为这些是所有与最小 API 相关的次要领域。

在撰写本文时,ASP.NET 为.NET 9 概述了两个破坏性变化,这里简要总结如下:

  • DefaultKeyResolution.ShouldGenerateNewKey:在先前版本中返回的布尔值(true/false)结果背后有不同的含义。这种破坏性变化是由ShouldGenerateNewKey返回的true/false结果的重定义引起的。让我们更详细地探讨这个问题:

    • 当你在最小 API 中管理密钥时,如果你使用 ASP.NET 的默认实现来生成密钥,会提供一个布尔值来告诉你是否应该生成新的密钥。

    • 之前,布尔值的结果是基于默认密钥是否在到期前两天。

    • 决定“在到期前两天”本身并不合适,因为还有其他因素可能会影响是否需要重新生成密钥。现在,ShouldGenerateNewKey的结果基于与IdefaultKeyResolver相关的几个因素,以及是否最初存在默认密钥,而不是基于密钥是否在到期前两天的一个任意含义。

  • 当一个最小化 API 应用程序启动时,你可以在UseDefaultServiceProvider中指定是否应该在注册的服务和服务作用域上运行验证。之前,验证默认是关闭的。让我们进一步分析这个变化,看看它是否会影响你的现有代码:

    • 服务的验证确保所有服务都可以在启动时创建。

    • 作用域的验证检查作用域服务是否不是从根提供程序解析的,这会违反其作用域。

    • 总的来说,这意味着默认情况下没有进行验证。你必须打开之前的验证才能在应用程序加载时执行它。然而,现在默认情况下会执行验证。

基于此,将最小化 API 迁移到.NET 9 时没有重大破坏性变化(至少在 ASP.NET Core 方面是这样)。然而,仍然重要的是要为任何潜在的变化做好准备。考虑到这一点,我们如何减轻这些变化?

  • 对于影响密钥解析的DefaultKeyResolution.ShouldGenerateNewKey,你只有在已经编写了现在由于.NET 9 检查过期密钥的方式而变得冗余的逻辑时才真正需要采取行动。

    例如,如果之前你需要检查是否存在默认密钥,现在你不再需要这样做,因为.NET 9 会为你完成这项工作。因此,这种变化的冲击力相对较小。

  • 对于UseDefaultServiceProvider,所需更改很简单。如果你通过将你的最小化 API 更新到.NET 9 开始看到启动时的错误,这可能是由于服务验证和作用域现在默认启用,你必须解决输出的验证错误。

对于大多数基本场景,你的最小化 API 不太可能配置得无法解析服务或从根提供程序中错误地解析作用域服务,但仍然重要的是要意识到这些因素。

编写时的准确性和.NET 的其他受影响区域

重要的是要强调,这些变化是在.NET 9 发布前,在其预览期间报告的。在迁移之前,开发者必须查阅微软的文档,以查看是否还有进一步的破坏性变化被报告。此外,本书中提供的摘要仅针对 ASP.NET Core,这是我们编写最小化 API 时主要关注的领域。然而,提到的其他区域,如.NET SDK、网络和序列化,都可能根据用例影响最小化 API 项目。

现在我们已经探讨了将最小化 API 应用程序发布到.NET 9 时可能遇到的兼容性问题,让我们来看看我们可用的各种部署方法。

部署最小化 API

部署最小化 API 项目有众多不同的方式,探索所有这些方式超出了本书的范围。然而,我们可以查看一些最常见的部署目标。

将应用部署到微软 Azure App Service(云部署)

将应用部署到 Azure App Service 非常简单,可以使用 Visual Studio 中的发布配置文件来实现。发布配置文件是一个配置对象,它指定了项目应该如何部署。它包含有关正在运行的运行时类型、目标架构(x86、ARM 等)和目标主机(在这个例子中是 Azure)的元数据。

在这个示例中,我假设你的 Azure 订阅中已经存在一个 Azure App Service 实例,并且你有权部署。如果 Azure 中没有 App Service 实例,你需要创建一个。

Azure 应用服务的定价可以通过 Microsoft 的定价计算器在 https://azure.microsoft.com/en-gb/pricing/calculator/ 计算得出。在撰写本文时,有一个免费层允许进行基本测试,这可能适合本书的大多数读者进行部署练习。基本层应用服务的当前费用约为每月 55-60 美元,但根据用例和所需规格,费用可能会增加。

创建 App Service 实例超出了本书的范围:

  1. 首先,在 Visual Studio 的 解决方案资源管理器 中右键单击您要部署的项目,然后点击 发布…

图 14.6:在 Visual Studio 的解决方案资源管理器中发布

图 14.6:在 Visual Studio 的解决方案资源管理器中发布

如果您尚未设置任何发布配置文件,您将看到 发布 对话框。如果您没有看到这个对话框,那是因为您已经为另一个部署设置了发布配置文件。如果这种情况发生,您可以选择 添加发布配置文件 来打开 发布 对话框。

  1. 一旦您看到 发布 对话框,请选择 Azure

图 14.7:在 Visual Studio 中选择发布目标

图 14.7:在 Visual Studio 中选择发布目标

  1. 然后选择 Azure App Service,确保您选择了在 Azure 目标 App Service 上运行的正确操作系统(Windows 或 Linux)。

    下一个屏幕将要求您选择您的 Azure 订阅。如果您尚未登录 Azure,可以使用对话框右上角的选项进行连接。

  2. 连接成功后,您应该在下拉菜单中看到您的 Azure 订阅,以及可部署到的可用 App Service 实例。选择您希望针对的服务,然后点击 下一步

图 14.8:选择目标 Azure App Service 资源

图 14.8:选择目标 Azure App Service 资源

  1. 最后,系统会询问您是否希望使用 .pubxml 文件或通过 GitHub Actions 进行发布。本书不会涵盖像 GitHub Actions 这样的持续集成/持续交付管道,因此请选择 发布

图 14.9:选择发布输出类型

图 14.9:选择发布输出类型

完成此操作后,对话框将关闭,并创建新的发布配置文件。从那里,您可以查看和更改发布设置,例如您将发布什么配置(几乎总是 发布),框架(在我们的示例中是 .NET9),部署模式,是 框架依赖 还是 自包含(关于这一点将在下面详细介绍),以及目标运行时,在我的示例中是 64 位 Linux

图 14.10:新创建的发布配置文件

图 14.10:新创建的发布配置文件

执行此操作后,你的应用程序将在部署到目标 App Service 之前构建。完成后,Visual Studio 将自动打开浏览器窗口并导航到最小 API 的 URL。

框架依赖与自包含部署模式

你有两种部署模式选择。框架依赖要求在目标机器上安装 .NET 9(或你正在使用的任何版本)。自包含将运行时与应用程序打包在一起。前者生成更小的输出文件集,但缺点是需要在目标机器上安装特定的 .NET 版本,而后者输出文件更大,但目标机器对应用程序运行的要求更少,使其更便携。

接下来,让我们继续部署到 Docker 容器。

部署到 Docker 容器

ASP.NET 和 .NET Core 已经很好地定位了提供跨平台功能,但根据主机操作系统,配置上仍有细微的差异。使用 Docker 容器化你的最小 API 应用程序可以使它变得无差别,这意味着它不关心它在哪个操作系统上运行。

首先,你必须确保 Docker 已安装在你的系统上。有关此内容的文档可在docs.docker.com/engine/install/找到。对于 Windows,你需要安装 Docker Desktop,相关文档可在此处找到docs.docker.com/desktop/install/windows-install/,而对于 Linux,只需运行 Docker Engine 就足够了。

安装完成后,你需要创建一个 Dockerfile,该文件将描述你的最小 API 项目应该如何打包到 Docker 容器中,以及它应该如何在主机机器上运行。

你可以在 Visual Studio 中创建此文件,作为项目中的新项目:

  1. 选择 解决方案资源管理器左上角的按钮以更改视图:

图 14.11:在解决方案资源管理器中更改视图选项

图 14.11:在解决方案资源管理器中更改视图选项

  1. 选择 文件夹视图

图 14.12:切换到文件夹视图

图 14.12:切换到文件夹视图

  1. 然后,右键单击你的项目文件夹,选择 添加 | 新建文件

图 14.13:在文件夹视图中创建项目中的新文件

图 14.13:在文件夹视图中创建项目中的新文件

  1. Docker 文件没有名称,只有一个 .dockerfile 扩展名。创建此文件。然后它应该作为选项卡在 Visual Studio 中打开。(有报道称,如果 Docker 文件不命名为 Dockerfile,则可能无法正常工作,所以如果你遇到类似问题,可以尝试这样做。)

现在我们可以编写 Docker 文件。

首先,我们需要指定用于最小 API 应用程序运行时环境的基本镜像。我们可以像这样从微软的容器注册库拉取官方 .NET 9 ASP.NET 运行时镜像:

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base

然后,我们可以告诉 Docker 我们想要将容器内的工作目录设置为/app,这样任何后续命令都将相对于此目录执行。我们还将指定我们希望容器上暴露端口80

WORKDIR /app
EXPOSE 80

接下来,我们添加一个部分,下载所需的 .NET 9 SDK,这是编译最小 API 应用程序所需的全局运行时。然后我们再次设置工作目录,这次选择一个我们选择的文件夹;我们将选择/src

之后,当前目录的整个内容被复制到容器内的/src目录中,以便进行构建,执行dotnet restore以添加所需的任何 NuGet 包作为依赖项,并将应用程序以发布模式编译到/ app文件夹中:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app

在执行的这个阶段,容器已经被构建。最后,我们可以在 Dockerfile 中添加一个部分,通过复制容器组件并在编译后的 .NET 生成的 DLL 上运行来运行构建的容器:

FROM base AS final
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyminimalAPIProject.dll"]

到目前为止,我们已经指定了容器镜像应该如何构建,但我们还没有触发构建。为此,打开从您的项目目录运行的控制台或命令提示符窗口,并运行以下命令,将MYAPINAME替换为合适的镜像名称:

docker build -t MYAPINAME .

在这个命令中,我们正在为正在构建的镜像创建一个带有给定名称的标签,然后我们使用.来表示镜像的构建上下文是 Dockerfile 所在的当前目录。

构建完成后,您可以运行容器以启动应用程序,该应用程序应该可以在您选择的端口上接收请求:

docker run -d -p 8080:80 --name MYCONTAINERNAME MYAPINAME

在这个命令中,我们已告诉 Docker 引擎以-d(分离模式)运行容器,这允许它在后台运行。然后我们使用-p指定容器上的端口80应映射到主机机器上的端口8080。这意味着您的最小 API 将在http://localhost:8080上可用。

最后,还有一种非常简单的方法可以使用:部署到内置的 Kestrel 网络服务器。

使用 Kestrel 在本地部署

如果您只想在 Windows 机器上将您的最小 API 作为 ASP.NET Core 应用程序托管,您可以创建一个新的发布配置文件,然后按照本章前面概述的 Visual Studio 中的发布配置文件向导进行操作。然而,不要选择 Azure 作为目标,而是选择文件夹

图 14.14:选择文件夹作为发布目标

图 14.14:选择文件夹作为发布目标

选择后,向导将要求您指定要部署到的目标文件夹。这个文件夹可以是本地的,也可以是远程服务器上的网络路径。

一旦发布,生成的.EXE文件可以双击运行,应用程序将使用默认监听端口5000的 Kestrel 网络服务器运行。

如果您想更改端口,您可以通过项目中的appsettings.json文件进行更改,添加以下内容(例如,更改为端口8080):

"Kestrel": {
    "Endpoints": {
        "Http": {
            "Url": "http://*:8080"
        }
    }
}

我们探讨了如何为我们的最小 API 准备部署和最终用户消费的高级概述。现在,我们即将结束这次旅程。让我们总结一下在本章中我们学到了什么。

摘要

在本章中,我们探讨了准备最小 API 投入生产所需的必要步骤,重点关注测试、与.NET 9 的兼容性和部署策略。这些方面的每一个都在确保您的 API 健壮、兼容并无缝交付给最终用户中发挥着关键作用。

我们探讨了单元测试,它隔离和验证单个组件,以及集成测试,它确保系统的不同部分能正确协同工作。通过 xUnit 的实际示例,我们展示了如何设置和执行这些测试以验证功能和性能。关键要点是,彻底的测试——无论是通过单元测试还是集成测试——都有助于早期发现潜在问题,降低错误滑入生产的可能性,并确保您的 API 满足其验收标准。

与.NET 9 的兼容性对于保持最小 API 的长期性和效率至关重要。我们讨论了理解破坏性变化并相应调整代码库的重要性。通过关注 Microsoft 关于.NET 9 变化的文档,并利用单元和集成测试,您可以减轻任何破坏性变化的影响。这种主动方法确保您的 API 能够与最新的.NET 版本正确运行,并在迁移期间提供更平滑的过渡。

部署包括针对不同环境和需求定制的方法。我们介绍了部署到 Microsoft Azure App Service,它为云环境提供了一个简单、可扩展的解决方案。我们还探讨了使用 Docker 的容器化,提供了一种便携、跨平台的部署选项。对于本地部署,直接使用 Kestrel 运行最小 API 提供了一种简单有效的方法。每种部署方法都有自己的配置和考虑因素,例如在框架依赖和自包含部署之间进行选择,或管理容器端口和环境设置。

确保你的最小 API 经过良好的测试,与最新的.NET 版本兼容,并使用最合适的方法部署,这将使你能够交付高质量、可靠的软件。通过应用本章中概述的实践,你为 API 的成功部署和长期维护奠定了坚实的基础,这有助于即时的运营成功和未来的可扩展性。

我们现在已经到达了最小 API 之旅的终点,随着本书的结束,我希望你已经对如何在各种用例中创建最小 API 有了坚实的理解,并且你发现这个过程是愉快的。

对于一个热衷于在不同用例和环境中使用最小 API 的人来说,写作这本书是一种乐趣。API 对于几乎所有现代软件系统都至关重要,我认为对最小 API 及其优势的良好了解将使任何.NET 开发者在他们的编程生涯中占据优势。

感谢阅读。现在去构建更多最小 API 吧!

posted @ 2025-10-21 10:42  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报