ASP-NET-Core3-REST-Web-服务实用指南-全-

ASP.NET Core3 REST Web 服务实用指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

.NET Core 为所有 Microsoft 生态系统消费者带来了一股清新的空气。旧的 ASP.NET 和 .NET 框架都有着悠久的历史。多年来,.NET 框架的增长和长期支持限制导致了奇特的实现和难以维护的 Web 应用程序和 Web 服务。

此外,.NET 框架与 Windows 操作系统之间的紧密依赖导致了在云计算技术世界中存在重大限制。

.NET Core 和 ASP.NET Core 都旨在不断发展。它们使用包括开源、社区导向的方法以及持续改进概念在内的抽象软件开发实践。如今,ASP.NET Core 比以往任何时候都更加灵活、快速和强大。开发者面临的 .NET 框架的所有限制都已消除,并从头开始重写。因此,现在可以在每个平台上无限制地运行 .NET Core。该框架直接与 .NET Core 应用程序一起发货。因此,可以使用容器化方法运行它。文档、问题和路线图都可在 GitHub 上找到。现在,Microsoft 默认采用开源的工作方式。

自从旧 DNX 运行时的首次发布以来,我就对 .NET Core 感兴趣。这本书将向您介绍 ASP.NET Core 的强大功能以及如何利用其力量和灵活性来运行 Web 服务。此外,本书旨在消除人们对 .NET 生态系统的偏见,并寻求提高 .NET Core 的采用率。

这本书面向谁

本书旨在为那些想学习如何使用 ASP.NET Core 构建 RESTful Web 服务的人。为了最大限度地利用书中包含的代码示例,您应该具备基本的 C# 知识。

这本书涵盖的内容

第一章,REST 101 和 ASP.NET Core 入门,解释了 RESTful API 的基本原理以及它们在构建应用程序时如何有用。

第二章,ASP.NET Core 概述,展示了 .csproj 文件的基本组件。它说明了项目的主要组件:Startup 类和 Program.cs 文件。

第三章,与中间件管道一起工作,探讨了中间件,这是 ASP.NET Core 的核心部分。本章将带您了解中间件管道,并解释它如何处理请求并根据它们初始化不同的服务。此外,本章还涵盖了 ASP.NET Core 提供的不同内置中间件以及如何构建自定义中间件。

第四章,依赖注入系统,介绍了依赖注入原理以及依赖注入背后的概念。它展示了如何使用依赖注入初始化应用程序内的组件和选项,以及如何在控制器内使用它们。

第五章,ASP.NET Core 中的 Web 服务堆栈,描述了如何在 ASP.NET Core 中创建 Web 服务堆栈。它深入探讨了控制器、操作方法、操作结果、模型绑定和模型验证等问题。

第六章,路由系统,深入探讨了处理 HTTP 请求的路由系统。本章展示了如何处理 ASP.NET Core 的默认路由系统。

第七章,过滤器管道,涵盖了 ASP.NET Core 中的另一个重要主题:过滤器。过滤器是我们服务中实现横切实现的关键组件。本章介绍了它们;展示了如何实现我们的过滤器,并探讨了某些具体用例。

第八章,构建数据访问层,介绍了领域模型部分。主要话题涉及如何构建领域模型以及如何使用对象关系映射ORM)来访问数据。

第九章,实现领域逻辑,描述了将逻辑与其他应用程序组件隔离的调解器模式方法。调解器模式是处理和管理我们逻辑的一种方式。

第十章,实现 RESTful HTTP 层,解释了如何从中介中检索数据并在我们的控制器中使用它。

第十一章,构建 API 的高级概念,介绍了 ASP.NET Core 中构建 API 的一些高级概念。本章将涵盖关于资源软删除的话题,并介绍了在 ASP.NET Core 中处理异步代码的一些良好实践。

第十二章,服务的容器化,为您快速介绍了容器以及它们在本地沙盒环境中运行应用程序时的有用性。

第十三章,服务生态系统模式,专注于当多个服务属于同一生态系统时的相关模式。

第十四章,使用.NET Core 实现工作服务,专注于.NET Core 的新工作模板。工作服务提供了一种实现小型服务或守护进程的方式,这些服务或守护进程可以用于执行后台操作。

第十五章,保护您的服务,讨论了保护服务或 API 的方法。除此之外,它还涵盖了包括安全套接字层SSL)、跨源资源共享CORS)和身份验证在内的概念。

第十六章,缓存 Web 服务响应,涵盖了 ASP.NET Core 提供的所有缓存选项。

第十七章,日志记录、监控和健康检查,展示了日志记录和监控应用程序的一些最佳实践。

第十八章,在 Azure 上部署服务,展示了如何在云中托管 Web 服务的示例。

第十九章,使用 Swagger 记录您的 API,介绍了 OpenAPI 标准以及如何在 ASP.NET Core 应用程序中实现它。

第二十章,使用 Postman 测试服务,展示了如何使用 Postman 测试 Web 服务。

要充分利用这本书

本书假设您对 C#(或类似面向对象的编程语言,如 Java)有一定的了解,并且您在构建 Web 应用程序方面有一些经验。

这本书不需要任何特定的工具,但您需要在您的机器上安装.NET Core(dotnet.microsoft.com/download)。我强烈建议您至少安装一个代码编辑器,例如 Visual Studio Code,可能还带有 OmniSharp,或者一个集成开发环境IDE)如 Visual Studio(在 Windows 或 Mac 上),或者 Rider IDE(在 Windows、Mac 或 Linux 上)。

我使用 Rider IDE 和 Bash 在 macOS X 上编写了所有示例。

下载示例代码文件

您可以从www.packt.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”标签。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并按照屏幕上的说明操作。

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供在 github.com/PacktPublishing/ 上获取。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781789537611_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个例子:“头部部分告诉客户端应该使用特定的 content-type 处理响应;在这种情况下,application/json。”

代码块设置如下:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>netcoreapp3.0;net461</TargetFrameworks>
  </PropertyGroup>
</Project>

当我们希望您注意代码块中的特定部分时,相关的行或项目会设置为粗体:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
 <TargetFrameworks>netcoreapp3.0;net461</TargetFrameworks>
  </PropertyGroup>
</Project>

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

dotnet build
dotnet run

粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的文字会像这样显示。以下是一个例子:“如您所见,它正在运行 Hello, World!

警告或重要注意事项看起来像这样。

小技巧和技巧看起来像这样。

联系我们

我们欢迎读者的反馈。

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

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

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

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

评论

请留下评论。一旦您阅读并使用了这本书,为什么不在这本书购买的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

想了解更多关于 Packt 的信息,请访问 packt.com.

第一部分:入门

在本节中,我们将向您介绍 REST API 和 ASP.NET Core 的所有宏观概念。您还将了解本地开发。

本节包括以下章节:

  • 第一章,REST 101 及 ASP.NET Core 入门

第一章:REST 101 和 ASP.NET Core 入门

现在,几乎所有的应用程序都依赖于网络服务。其中许多使用 RESTful 方法操作。以资源为中心的方法和 REST 风格的简单性已成为行业标准。因此,了解 REST 工作方式背后的理论以及为什么它很重要是至关重要的。本章将向你介绍表征状态转移REST)方法。我们将看到 REST 的定义以及如何识别符合 REST 的网络服务。我们还将介绍.NET Core 3.1 和 ASP.NET Core,这是由微软提供的开源、跨平台框架的最新版本。

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

  • REST 架构元素的概述

  • .NET 生态系统的简要介绍

  • 为什么你应该选择.NET 来构建 RESTful 网络服务

到本章结束时,你将了解一些有用的工具和 IDE,你可以使用它们开始开发.NET Core。

本章将涵盖.NET Core 3.1 和 ASP.NET Core 的一些基本概念。你需要安装 Windows、Linux 或 macOS。设置过程将取决于你使用的操作系统。我们将探讨可以用于在.NET Core 中开发应用程序和网络服务的不同工具。

REST

什么是 REST?表征状态转移REST)通常被定义为一种软件架构风格,它对网络服务施加了一些约束。它确定了一组以资源为中心的规则,这些规则描述了分布式超媒体系统中约束的角色和交互,而不是关注组件的实现。尽管找到不使用 HTTP 的 REST 服务相当罕见,但定义并未提及这些主题,而是将 REST 描述为媒体和协议无关的。

以下定义可以通过一个示例进一步解释。考虑一个电子商务网站。当你浏览产品列表并点击其中一个时,浏览器将你的点击解释为对特定资源的请求;在这种情况下,你点击的产品详情。浏览器通过 HTTP 调用 URI,该 URI 对应于产品的详情,并使用 URI 请求特定的资源。这个过程在以下图中显示:

图片

REST 的概念相当相似:客户端向服务器请求特定的资源,这使得它们可以导航并获得关于服务器上存储的其他资源的其他信息。

严格遵守 REST 的重要性

在我们讨论 REST 之前,我们应该了解在现代应用程序开发世界中网络服务的重要性。

典型的现代应用程序使用网络服务来获取和查询数据。任何开发产品或解决方案的公司都使用网络服务来交付和跟踪信息。这是因为很难复制所有客户使用您的应用程序所需的所有数据和每个行为。网络服务对于提供第三方客户端和服务的访问也非常有用。例如,考虑 Google Maps 或 Instagram 的 应用程序编程接口APIs):这些平台通过 HTTP 暴露信息,与其他公司和服务的共享。

由于其简单明了,REST 对网络服务架构的方法越来越受欢迎。与一些旧方法(如 简单对象访问协议SOAP)或 Windows Communication FoundationWCF))不同,REST 服务提供了一种查询数据的方式,无需在请求中添加任何开销。不同的信息可以通过不同的 URI 获取。

现在,比以往任何时候,我们的服务都需要准备好扩展,以便能够适应消费者。在一个充满不同技术和全球分布式的团队的世界里,我们应该能够调整我们的架构来解决复杂的商业挑战。HTTP 和 REST 帮助我们应对这些挑战。

REST 要求

Roy Thomas Fielding 是 HTTP 协议和 REST 风格正式化的主要人物:他在自己的论文《架构风格和网络软件架构设计》(Architectural Styles and the Design of Network-based Software Architectures)中描述了它们(www.ics.uci.edu/~fielding/pubs/dissertation/top.htm)。此外,Fielding 的论文明确定义了 REST 系统的约束,如下所示:

  • 统一接口

  • 无状态

  • 可缓存

  • 客户端-服务器

  • 分层系统

  • 需求代码

除了需求代码之外,所有这些都是在我们将网络服务定义为 REST 兼容时必不可少的。为了解释这些概念,我们将使用纽约时报提供的 API:developer.nytimes.com/。要使用这些 API,您需要通过预认证过程。您可以从以下链接获取 API 密钥:developer.nytimes.com/signup

统一接口

统一接口指的是客户端和服务器之间的分离。这很有优势,因为它意味着这两个系统是独立的。统一接口的第一个原则是它应该是基于资源的,这意味着我们应该开始以资源导向的方式思考。因此,我们系统中的每个对象或实体都是一个资源,每个资源都有一个 URI 唯一标识。此外,如果我们考虑 HTTP 协议,每个资源都以 XML 或 JSON 的形式呈现给客户端,以解耦客户端和服务器。

让我们使用《纽约时报》的 API 来了解这个主题。考虑以下 URI:

http://api.nytimes.com/svc/archive/v1/2018/6.json?api-key={your_api_key}

它包含了一些关于我们请求的资源的确切信息,包括以下内容:

  • 我们从报纸数据的存档部分获取信息的事实

  • 我们请求的具体月份;在这种情况下,2018 年 6 月

  • 事实是资源将使用JSON 格式进行序列化

通过表示操纵资源

让我们看看《纽约时报》提供的另一个 API 请求:

https://api.nytimes.com/svc/books/v3/lists.json?api-key={your_api_key}&list=hardcover-fiction

这提供了一个基于类别的书籍列表;在这种情况下,这个类别是精装小说。让我们分析这个响应:

{
    "status": "OK",
    "copyright": "Copyright (c) 2018 The New York Times Company. All Rights 
      Reserved.",
    "num_results": 15,
    "last_modified": "2018-06-28T02:38:01-04:00",
    "results": [
        {
            "list_name": "Hardcover Fiction",
            "display_name": "Hardcover Fiction",
            "published_date": "2018-07-08",
            "isbns": [
                {
                    "isbn10": "1780898401",
                    "isbn13": "9780316412698"
                }
            ],
            "book_details": [
                {
                    "title": "THE PRESIDENT IS MISSING",
                    "contributor": "by Bill Clinton and James Patterson",
                    "author": "Bill Clinton and James Patterson",
                    "contributor_note": "",
                    "price": 0,
                    "age_group": "",
                    "publisher": "Little, Brown and Knopf"
                }
            ],
            "reviews": [
                {
                    "book_review_link": "",
                    "first_chapter_link": ""
                }
            ]
        }
        ....
    ]
}

如您所见,这是资源的清晰表示。客户端拥有所有必要的信息来使用 API(如果 API 允许)处理和修改数据。

自描述消息

让我们看看以下调用的响应:

https://api.nytimes.com/svc/books/v3/lists.json?api-key={your_api_key}&list=hardcover-fiction

如我们之前提到的,响应是数据的表示,无论是存储在数据源中还是从另一个系统获取。在任何情况下,一些信息是缺失的:客户端如何知道响应的格式?这类信息通常写在响应头部。例如,以下是之前请求的所有头部:

accept-ranges: bytes
access-control-allow-headers:Accept, Content-Type, X-Forwarded-For, X-Prototype-Version, X-Requested-With
access-control-allow-methods: GET, OPTIONS
access-control-allow-origin: *
access-control-expose-headers: Content-Length, X-JSON
age: 0
connection: keep-alive
content-length: 14384
content-type: application/json; charset=UTF-8
date: Tue, 03 Jul 2018 12:47:08 GMT
server: Apache/2.2.15 (CentOS)
vary: Origin
via: kong/0.9.5
x-cache: MISS
x-kong-proxy-latency: 4
x-kong-upstream-latency: 29
x-ratelimit-limit-day: 1000
x-ratelimit-limit-second: 5
x-ratelimit-remaining-day: 988
x-ratelimit-remaining-second: 4
x-varnish: 63737329

头部部分告知客户端响应应使用特定的content-type进行处理,在这种情况下,application/json. 它还提供了有关编码、缓存和相关元信息的信息,例如包含对象在代理缓存中存在时间的age头部。

超媒体作为应用状态引擎

服务通常通过正文内容、响应代码和响应头部将状态传递给客户端。最重要的是,超媒体驱动的服务(HATEOAS)在其响应中包含其他资源的 URI。以下示例描述了 HATEOAS 的概念:

{
    "links": {
        "self": { "href": "http://example.com/people" },
        "item": [
            { "href": "http://example.com/people/1", "title": "Kendrick 
             West" },
            { "href": "http://example.com/people/2", "title": "Anderson 
             Rocky" }
        ]
    },
}

之前的响应提供了一个人员列表,以及指定每个人员详细信息的 URI。因此,客户端知道正确的请求 URI,以便获取有关每个资源的详细信息。

无状态

无状态是 REST 服务的一个关键特性。确实,HTTP 作为一个无状态协议,一旦通信结束,就不会跟踪客户端和服务器之间连接的所有信息。

无状态协议迫使客户端每次需要从服务器获取信息时,都必须提供所有必要的信息。让我们看看之前的一个 URI:

https://api.nytimes.com/svc/books/v3/lists.json?api-key={your_api_key}&list=hardcover-fiction

客户端必须在每个请求中发送API 密钥以供服务器进行身份验证。此外,它必须存储 API 密钥信息。

如果我们希望利用 REST 服务,无状态性非常重要。如今,随着高度分布式系统的兴起,处理有状态服务变得困难,因为这需要在不同的服务器上管理和复制状态。无状态方法有助于将状态管理委托给客户端。

客户端-服务器分离

REST 服务的首要目标是解耦服务器和客户端。这非常重要,因为它有助于保持每个客户端应用程序独特的业务逻辑和数据存储。应用程序通常分布在众多不同的客户端上,包括 Web、智能手机、智能电视和物联网。REST 方法帮助我们防止在客户端之间复制逻辑。这意味着客户端没有任何业务逻辑或存储,服务器也不处理用户界面或表示层。

层次化系统

层次化系统的概念与我们的应用程序基础设施的结构密切相关。RESTful 服务允许松散耦合的方法,因为信息是通过协议传输的——在大多数情况下,是通过 HTTP——并且每个服务器都有一个单一的高级目的。代理服务器、Web 服务器和数据库服务器通常是隔离的,它们在我们的功能中覆盖一个目的,如果你有一个提供所有必需功能的单一服务器,那么维护和扩展通常很困难。

理查德森成熟度模型

理查德森成熟度模型是由伦纳德·理查德森开发的模型,其目的是通过提供一些一般性标准来衡量 API 的成熟度。该模型有四个分类步骤,从级别 0级别 3。最高级别对应于更合规的服务。这个模型不仅仅是为了理论目的;它还帮助我们理解一些推荐的 Web 服务开发方法。让我们来看看这些不同级别的概述。以下图表显示了理查德森成熟度模型中各级别的结构:

图片

当一个通用服务在表面上使用通用协议(在 Web 服务的情况下,这是 HTTP)时,它处于级别 0:普通旧 XML 的沼泽。一个例子是重量级的 SOAP Web 服务。SOAP 实现只使用一个 URI 和一个 HTTP 动词,并且它们在每个请求消息中包裹一个巨大的信封。

正如我们之前提到的,从资源的角度思考是理解和设计 API 的最佳方式。因此,处于级别 1的通用服务使用与不同资源相关联的多个 URI。例如,如果我们考虑一个普通商店的 API,我们可以通过调用以下示例 URI 来获取产品类别的完整列表:

GET https://api.mystore.com/v1/categories 

同时,我们可以通过调用以下 URI 来获取单个类别的详细信息:

GET https://api.mystore.com/v1/categories/{category_id}

另一方面,我们可以通过调用以下 URI 来获取与单个类别相关的产品列表:

GET https://api.mystore.com/v1/categories/{category_id}/products

如您所见,我们可以通过调用不同的 URI 来获取不同的信息。没有封装,所有请求的信息都包含在 URI 中。

第二级,与 HTTP 动词相关,介绍了使用 HTTP 动词来增强请求上传输的信息。让我们以前一个请求 URI 为例:

https://api.mystore.com/v1/categories/{category_id}/products

这可以产生不同的结果,具体取决于 HTTP 动词。以下表格显示了各种 HTTP 动词的含义:

HTTP 动词 执行的操作 示例
GET 获取有关资源的信息
GET /v1/categories/ HTTP/1.1
Host: api.mystore.com
Content-Type: application/json

|

POST 创建与资源相关的新项目
POST /v1/categories/ HTTP/1.1
Host: api.mystore.com
Content-Type: application/json

{
 "categoryId": 1,
 "categoryDescription": "Vegetables"
}

|

PUT 替换与资源相关的项目
PUT /v1/categories/1 HTTP/1.1
Host: api.mystore.com
Content-Type: application/json

{
  "categoryId": 1,
  "categoryDescription": "Fruits and Vegetables"
}

|

PATCH 更新与资源相关的项目
PUT /v1/categories/1 HTTP/1.1
Host: api.mystore.com
Content-Type: application/json

{
  "categoryDescription": "Fruits and Vegetables"
}

|

DELETE 删除与资源相关的项目
DELETE /v1/categories/1 HTTP/1.1
Host: api.mystore.com
Content-Type: application/json

|

不同的 HTTP 动词对应着不同的数据操作。与不使用任何 HTTP 规范来传递信息的0 级服务相反,2 级服务利用 HTTP 规范尽可能多地传递信息。最终,具有3 级服务的系统实现了 HATEOAS 的概念。正如我们在上一节中讨论的,HATEOAS 在其响应中提供了资源的 URI。这种方法的明显优势是客户端不需要任何信息就可以导航 Web 服务资源。最重要的是,如果我们的 Web 服务添加了资源的 URI,客户端立即就拥有了他们所需的所有信息。

介绍 ASP.NET Core

在撰写本书时,.NET Core 3.1 是由 Microsoft 和社区支持的框架的LTS(长期支持)版本。ASP.NET Core 是一个高度模块化的 Web 框架,它运行在 .NET Core 平台上:它可以用来开发各种 Web 解决方案,例如 Web 应用程序、Web 程序集客户端应用程序和 Web API 项目。

要了解 ASP.NET Core 的一些基本概念,我们需要理解 ASP.NET Core 中实现的模型视图控制器(MVC)模式。

MVC 通过将实现分组到三个不同的区域来分离我们的 Web 应用程序。在 Web 环境中,起点通常是客户端或通用用户发起的 Web 请求。请求通过中间件管道,然后控制器处理它。控制器还执行一些逻辑操作并填充我们的 模型

模型是应用程序状态的表示。当它与视图相关联时,它被称为视图模型。一旦模型被填充,控制器根据请求返回一个特定的视图。视图的目的是通过 HTML 页面展示数据。

在 Web API 堆栈的情况下,这是在 ASP.NET Core 中构建 Web 服务的典型方式,过程与视图部分相同。而不是这个,控制器在响应中将模型序列化。

要了解 ASP.NET Core 如何帮助开发者构建 Web 服务,让我们回顾一下 ASP.NET 框架的历史。

ASP.NET 的演变

ASP.NET 的第一个版本于 2002 年发布,当时微软决定投资 Web 开发。他们发布了 ASP.NET Web Forms,这是一个我们可以用来构建 Web 界面的 UI 组件集合。这种方法的核心思想是提供一个非常高级的抽象工具,可以生成 Web 的 GUI。提供这种级别的抽象是一个好主意,因为开发者对 Web 不熟悉。然而,ASP.NET Web Forms 带来了很多缺点。首先,开发者对 HTML 的控制有限,组件必须将信息存储在 视图状态 中,这些状态在客户端和服务器之间传输和更新。此外,组件没有正确分离,开发者倾向于将表示代码与业务逻辑代码混合。

为了提升开发 Web 应用程序的经验,微软在 2007 年宣布了 ASP.NET MVC 的到来。这个新的开发平台运行在 ASP.NET 框架上,并采用了其他实现了 MVC 模式的开发平台的概念,例如 Ruby on Rails。ASP.NET MVC 框架仍然存在一些弱点。它建立在 ASP.NET 之上,这意味着它必须保持与旧的网络表单和网络服务框架(如 WCF)的向后兼容性。此外,它只能在 Windows 服务器上运行,与 Internet Information ServicesIIS)结合使用。

微软最新开发的 Web 框架是 ASP.NET Core。它运行在 .NET Core 上,这是一个跨平台和开源的平台。通过 ASP.NET Core,微软选择了发布一个没有从前一个版本的 ASP.NET 派生的任何向后兼容组件的新轻量级框架。

新的 .NET 生态系统

让我们概述一下 .NET 生态系统,以了解作为 ASP.NET Core 基础的不同框架。这里提供的一些信息可能听起来很显然,但澄清不同运行时和框架之间的差异是至关重要的:

图片

第一个块与 桌面包 相关,它为从 .NET Core 3.0 开始的桌面应用程序开发提供了工具。在这本书中,我们不会使用这些工具,因为它们严格与桌面开发相关。

第二部分与.NET Core 的跨平台部分相关。这组工具允许开发者构建WEBDATAAI/ML领域的应用程序。WEB部分指的是 ASP.NET Core,它是随.NET Core 一起提供的库集合。通常,ASP.NET Core 会与DATA访问部分结合使用。在本书的后面部分,我们将看到如何使用EF Core来访问数据库层。最后,AI/ML部分,本书将不会讨论,在机器学习领域提供了有用的工具。在前面的图例底部,我们有一个共同的层,即.NET STANDARD。它允许开发者构建第三方库,这些库可以被Desktop Packs以及WEBDATAAI/ML部分使用。

总之,新的.NET 生态系统可以被任何开发者使用,包括云开发者、Web 开发者和桌面开发者。正如我们之前提到的,它可以在任何地方和任何平台上运行。本书中的所有示例都将基于.NET Core。

.NET STANDARD

.NET STANDARD是与.NET Core 一起引入的。.NET STANDARD的目标是为.NET Core 和.NET Framework 提供一个共同的 API 界面。它作为我们.NET Framework 和.NET Core 应用程序的唯一基础类库BCL)工作。.NET Standard 2.0 的发布引入了 32,000 个兼容 API,并支持以下框架版本:

  • .NET Framework 4.6.1 +

  • .NET Core 2.0 +

  • Mono 5.4 +

最近,微软推出了.NET Standard 2.1。这个新版本提供了作为.NET Core 3.0 开源开发一部分引入的新 API。从 3.0 版本开始,.NET Standard 2.1 将成为.NET Core 新版本和其他即将到来的框架版本(如 Mono)的共同点。

您可能出于不同的原因选择.NET Standard:

  • 要构建与.NET Core 和.NET Framework 都兼容的第三方库。在这种情况下,您的包将针对.NET Standard 2.0。最终,如果您想使用我们之前描述的新优化的 API,可以使用多目标来同时针对.NET Standard 2.0 和.NET Standard 2.1。

  • 通过将逻辑隔离在.NET Standard 项目上来逐步迁移您的.NET Framework 代码库。

例如,考虑一个由不同版本的.NET 使用的类库项目。该库可能运行在.NET Core 或.NET Framework 上。为了避免维护陷阱,库包可以编译为多个.NET Standard 版本,并且可以被.NET Core.NET Framework解决方案使用

为什么使用 ASP.NET Core 来构建 RESTful Web 服务?

有大量的 Web 框架可供开发者构建 RESTful Web 服务。其中一个这样的框架是建立在 .NET Core 3.1 上的 ASP.NET Core。.NET Core 3.1 提供了一种新的、轻量级、跨平台和开源的方式来构建 Web 应用程序。最重要的是,它被设计为云就绪:与 .NET Framework 不同,该框架不再是服务器的一部分;相反,它与应用程序一起分发。

另一个关键点是 .NET Core 保持 高度模块化,遵循 Unix 哲学,并允许你在定制应用程序中仅使用所需的部分。ASP.NET Core 还为 Web 应用程序和 Web 服务引入了两种新的托管解决方案:

  • Kestrel:ASP.NET Core 的默认 HTTP 服务器。它支持 HTTPS 和 WebSockets,并在 Windows、Linux 和 macOS 上运行。Kestrel 通常与 反向代理 结合使用,例如 NGINX、IIS 或 Apache。

  • HTTP.sys:一个仅适用于 Windows 的 HTTP 服务器,可以用作 Windows 上 Kestrel 的替代品。

ASP.NET Core 和 .NET Core 是由微软和社区共同开发的,它们是 开源项目。在 ASP.NET Core 的情况下,开源不仅仅是一个口号;所有功能都是由社区驱动的,并且 ASP.NET 团队每周在 YouTube 上发布社区站立会议视频,其中讨论路线图、截止日期和问题。所有 .NET Core 代码都可以在以下链接的 GitHub 上找到:

所有存储库通常都附带路线图和一些贡献指南。你可以打开问题并为代码库做出贡献。微软还成立了 .NET 基金会,这是一个独立的组织,旨在促进围绕 .NET 生态系统的开放开发和协作。

ASP.NET Core 团队也专注于框架的性能。所有基准测试结果都可以在 GitHub 上找到:github.com/aspnet/benchmarks

准备你的开发环境

在本节中,我们将向您展示如何设置您的开发环境,以便您可以使用 ASP.NET Core 开发 Web 服务。正如我们之前提到的,.NET Core 是跨平台的,因此它可以在最常用的操作系统上运行。我们还将探讨如何与 .NET Core CLI 交互,这是构建、运行、开发和测试我们的服务的起点。

首先,让我们从 www.microsoft.com/net/download/ 下载 .NET Core 3.1 开始。在我们的案例中,我们将安装 SDK 版本,它包含我们开发环境所需的所有组件,包括 ASP.NET Core。

.NET Core CLI

与.NET 框架不同,.NET Core 提供了一个易于使用的命令行界面(CLI),它暴露了我们用于构建应用程序和服务的所有必要功能。一旦.NET Core 安装到您的机器上,运行dotnet --help命令。您将看到以下结果:

.NET Core SDK (3.1.100)
Usage: dotnet [runtime-options] [path-to-application] [arguments]

Execute a .NET Core application.

runtime-options:
  --additionalprobingpath <path> Path containing probing policy and
    assemblies to probe for.
  --additional-deps <path> Path to additional deps.json file.
  --fx-version <version> Version of the installed Shared Framework to 
    use to run the application.
  --roll-forward <setting> Roll forward to framework version (LatestPatch, 
    Minor, LatestMinor, Major, LatestMajor, Disable).

path-to-application:
  The path to an application .dll file to execute.

Usage: dotnet [sdk-options] [command] [command-options] [arguments]

Execute a .NET Core SDK command.

sdk-options:
  -d|--diagnostics Enable diagnostic output.
  -h|--help Show command line help.
  --info Display .NET Core information.
  --list-runtimes Display the installed runtimes.
  --list-sdks Display the installed SDKs.
  --version Display .NET Core SDK version in use.

SDK commands:
  add Add a package or reference to a .NET project.
  build Build a .NET project.
  build-server Interact with servers started by a build.
  clean Clean build outputs of a .NET project.
  help Show command line help.
  list List project references of a .NET project.
  migrate Migrate a project.json project to an MSBuild project.
  msbuild Run Microsoft Build Engine (MSBuild) commands.
  new Create a new .NET project or file.
  nuget Provides additional NuGet commands.
  pack Create a NuGet package.
  publish Publish a .NET project for deployment.
  remove Remove a package or reference from a .NET project.
  restore Restore dependencies specified in a .NET project.
  run Build and run a .NET project output.
  sln Modify Visual Studio solution files.
  store Store the specified assemblies in the runtime package store.
  test Run unit tests using the test runner specified in a .NET project.
  tool Install or manage tools that extend the .NET experience.
  vstest Run Microsoft Test Engine (VSTest) commands.

Additional commands from bundled tools:
  dev-certs Create and manage development certificates.
  fsi Start F# Interactive / execute F# scripts.
  sql-cache SQL Server cache command-line tools.
  user-secrets Manage development user secrets.
  watch Start a file watcher that runs a command when files change.

Run 'dotnet [command] --help' for more information on a command.

首先要注意的是.NET Core 的版本,即.NET Core 的版本,也就是.NET Core SDK (3.1.100),后面跟着一个软件开发工具包(SDK)命令列表。这包含了在开发阶段通常执行的命令,如dotnet builddotnet restoredotnet run。这些用于构建我们的项目、恢复 NuGet 依赖项以及运行我们的项目。另一个相关的部分是附加工具,它包含了我们将需要的所有第三方命令行界面(CLI)包,例如 EF Core。实际上,.NET Core CLI 允许您通过添加特定工具的 NuGet 包来扩展其功能。

ASP.NET Core 中的 IDE 和开发工具

.NET Core CLI 是构建在 IDE、代码编辑器和持续集成(CI)工具等高级工具之上的基础。尽管.NET Core 是一个跨平台框架,但仍有各种工具可用于在不同平台上构建 Web 应用程序和服务。以下表格总结了可用于构建 ASP.NET Core 的不同 IDE 和编辑器:

软件 Windows Linux macOS X
Visual Studio 2019(社区版、专业版和企业版) 支持 不支持 支持
Visual Studio Code 和 OmniSharp 支持 支持 支持
Rider 支持 支持 支持
Visual Studio for Mac 不支持 不支持 支持

如您所见,针对不同的平台,可以使用不同的集成开发环境(IDE)和代码编辑器。您所做的选择通常取决于不同的因素。让我们来看看不同编辑器的概述:

  • Visual Studio 2019(社区版、专业版和企业版):对于已经在.NET 生态系统上开发过的人来说,这是一个众所周知的编辑器。该产品的社区版完全免费,您可以在visualstudio.microsoft.com/it/downloads/找到它。如果您想在 Windows 上开始构建,Visual Studio 2019 是最舒适的选择。

  • Visual Studio Code 和 OmniSharp:由 Microsoft 和社区提供支持的一个流行且开源的编辑器。它是跨平台的,基于 Electron 构建。OmniSharp 是 Visual Studio Code 和其他代码编辑器的一个有用的第三方包,它为.NET Core 项目提供了一些集成。它还提供了一个智能感知(IntelliSense)功能。

  • Rider:由 JetBrains 提供支持,基于 IntelliJ 平台和 ReSharper 的新 IDE。它与所有平台兼容,但不是免费的。我在大型项目中尝试过它,它运行良好,主要因为它提供了开箱即用的 ReSharper 集成。

  • Visual Studio for Mac:由微软推出的一款新 IDE。它仅兼容 macOS,并提供了一些我们可以用于在.NET Core 生态系统中编写 C#或 F#代码的功能。这个 IDE 仍处于早期阶段,但它拥有许多高级功能。

总之,Visual Studio 2019、Rider 和 Visual Studio for Mac 等工具与.NET Core 结合使用时,提供了极佳的体验。另一方面,Visual Studio Code 是最轻便且最快的编辑器。在接下来的章节和代码演示中,我将使用.NET Core CLI 在不同的操作系统上重现相同的步骤。

摘要

在本章中,我们通过考虑一些具体的例子,对 REST 风格进行了概述。我们还了解了一些.NET 生态系统的基本概念,包括其结构以及为什么如果我们想构建网络服务,ASP.NET Core 是一个极佳的选择。我们还概述了.NET Core CLI 以及与.NET 生态系统相关的 IDE 和代码编辑器。

本章中我们讨论的主题为我们提供了对 REST 的含义以及为什么在开发网络服务时遵循此类原则很重要的良好理解。此外,我们还探讨了在本地环境中设置.NET Core 的基本原则。

下一章将专注于 ASP.NET Core 和 ASP.NET Core MVC。你将学习如何使用.NET CLI 设置项目,并探索 ASP.NET Core 的一些基本概念。

第二部分:ASP.NET Core 概述

在本节中,你将了解 ASP.NET Core 的方方面面,并得到 ASP.NET Core 应用程序关键组件的概述。

本部分包括以下章节:

  • 第二章,ASP.NET Core 概述

  • 第三章,与中间件管道协同工作

  • 第四章,依赖注入系统

  • 第五章,ASP.NET Core 中的 Web 服务栈

  • 第六章,路由系统

  • 第七章,过滤器管道

第二章:ASP.NET Core 概述

在本章中,我们将探讨 ASP.NET Core 的一些基本概念。.NET Core 是跨平台的,但与其一起使用的 IDE 和代码编辑器可能会根据它们运行的操作系统而有所不同。为了避免重复并涵盖所有操作系统变体,我在本书中提供的示例中始终使用 CLI。此外,dotnet 指令是唯一的入口点,并且也被代码编辑器和 IDE 在幕后使用。

本章将涵盖以下主题:

  • 设置 .NET Core 3.1 和 ASP.NET Core 项目

  • .NET Core 项目模板的文件结构

设置我们的 .NET Core 项目

本章假设您已经在您的机器上安装了 .NET Core 3.1 或更高版本。首先,让我们在我们的控制台中启动以下命令:

dotnet new

输出将如下所示:

dotnet new 指令的结果

上述输出显示了本地机器上可用的所有 .NET Core 项目模板。每个模板都有一个用户友好的名称、简短名称和标签。它们支持 C#、F# VB;默认为 C#。

要创建一个新的模板,我们将使用简短名称。例如,为了创建一个控制台应用程序,我们应该运行以下指令:

dotnet new console -n HelloWorld

上述指令将在当前文件夹中创建一个新的项目,其结构如下:

.
├── HelloWorld.csproj
├── Program.cs
└── obj
    ├── ...

HelloWorld.csproj 文件包含有关项目的所有元信息。与之前版本的 .NET Framework 中的 .csproj 文件相比,.NET Core 版本的 .csproj 文件更轻量。我们将在本章中讨论此项目文件的新结构。Program.cs 文件是应用程序的入口点。

要构建和执行我们的项目,我们可以在项目文件夹中运行以下命令:

dotnet build
dotnet run

如预期,我们得到以下结果:

Hello World!

与旧 .NET Framework 项目不同,构建和运行步骤是轻量级过程,并且不需要任何额外的工具或配置。实际上,.NET Core 并不是像 .NET Framework 那样严格绑定到开发机器。最终,开发者可以编写代码而无需其他 IDE 或代码编辑器。然而,出于明显的原因,始终建议您使用它们以简化开发过程。

还必须注意的是,一旦我们执行 dotnet build 命令,项目文件将按以下方式更改:

.
├── HelloWorld.csproj
├── Program.cs
├── bin
│   └── Debug
│       └── netcoreapp3.1
│           ├── ...
└── obj
├── Debug
│   └── netcoreapp3.1
│       ├── ...

bin/Debug/ 文件夹包含所有应用程序的 DLL 文件。在其下方,我们可以看到 netcoreapp3.1 文件夹,它指的是当前的目标框架。因此,如果你使用多目标方法构建项目,你将找到一个针对你指定的每个目标框架的文件夹。现在我们已经能够运行一个简单的控制台应用程序,让我们更仔细地看看项目中的 csproj 文件。

.csproj 概述

如前所述,在纯控制台应用程序模板中,有两个基本文件:ProjectName.csprojProgram.cs。首先,让我们看看 .csproj 文件:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.0</TargetFramework>
    </PropertyGroup>
</Project>

.csproj 文件的格式是 XML,就像 .NET Framework 的早期版本一样。以下内容省略,因为它是无意义的空行。

Sdk="Microsoft.NET.Sdk" 命名空间指的是我们想要用于构建项目的 SDK。PropertyGroup 节点包含一组属性,并且可以与一些条件行为相关联。ItemGroup 是一个通常包含包引用的节点。在 .NET Core 中,我们可以指定 TargetFramework 属性为项目分配目标框架。为了将我们的应用程序设置为 多目标应用程序,因此,我们可以将我们的 TargetFramework 节点更改为以下内容:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
<TargetFrameworks>netcoreapp3.1;netstandard2.0</TargetFrameworks>
    </PropertyGroup>
</Project>

注意,XML 节点已从 TargetFramework 更改为 TargetFrameworks,此外我们的项目将在 .NET Core 3.1 和 .NET Standard 2.0 上构建。

根据 MSBuild 文档(docs.microsoft.com/en-us/visualstudio/msbuild/msbuild?view=vs-2019),可以为每个目标框架定义不同的包。例如,在一个双目标框架项目中,如前所述,我们可能为每个目标定义各种依赖项,如下所示:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>netcoreapp3.1;netstandard2.0</TargetFrameworks>
  </PropertyGroup> 
 ...

  <ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' ">
    <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Core" Version="2.2.0" />
  </ItemGroup>
</Project>

在这个例子中,我们将为每个目标设置单独的引用:在编译时,框架生成两个目标版本,netstandard2.0 生成的输出将引用 Microsoft.AspNetCore.Server.Kestrel.Core 包。这是一种不寻常的配置类型,但如果我们项目有高度定制化,或者如果我们的项目是一个由不同版本的 .NET 消耗的库,它就很有用。dotnet new 命令还根据你创建的项目类型设置一个特定的 OutputType 属性:OutputType 属性定义项目是可执行的(Exe)还是库(Library)。显著的区别在于,在前一种情况下它可以被执行,而在后一种情况下它不包含任何运行应用程序的入口点。因此,我们无法在 <OutputType>Library</OutputType> 项目类型上执行 dotnet run 命令。另一方面,如果我们有一个 <OutputType>Exe</OutputType> 项目,我们确实需要指定 static void Main 入口点方法。让我们通过查看标准控制台模板的 Program.cs 文件来继续讨论可执行项目的领域。

Program.cs 文件详细说明

Program.cs 文件是应用程序的主要入口点。它设置并运行我们需要的所有组件。默认情况下,控制台应用程序模板执行单个语句:

using System;

namespace HelloWorld 
{
    class Program
    {
        static void Main(string[] args)
        {
 Console.WriteLine("Hello World!");
        }
    }
}

前面的代码片段是一个普通的.NET Core 应用程序,它运行Console.WriteLine在控制台打印一条消息。在一个 ASP.NET Core 应用程序中,Program.cs文件通常用于初始化和运行 Web 宿主。

C# 7.1 版本引入了async void Main方法。这个特性是为了避免运行异步代码时涉及到的解决方案:

using System;
using System.Threading.Tasks;

namespace HelloWorld
{
    class Program
    {
         static async Task Main(string[] args)
         {
             await Task.Delay(10);
             Console.WriteLine("Hello World!");
         }
     }
}

总结来说,Program.cs文件是建立在.NET Core 3.1 之上的应用程序的主要执行根。它通常运行一系列语句以启动我们的应用程序。一般来说,我们应该尽可能保持Program.cs的简洁,以提高我们类的可重用性。在下一节中,我们将看到如何结合csproj结构和Program.cs文件来构建一个简单的 API 项目。

设置 ASP.NET Core 项目

如第一章中提到的,“REST 101 和 ASP.NET Core 入门”,MVC 模式的核心是分离关注点。它的目的是为开发者提供一些指导原则,以确保 Web 应用程序的不同组件不会混淆。以下是对 MVC 模式的复习:

  • 模型旨在定义我们应用程序的领域模型。还应注意的是,模型不包含对我们数据源和数据库的任何引用。它们描述了我们的应用程序中的实体。

  • 视图部分以 HTML 页面的形式呈现数据。在 Web 服务中,视图不包括在内,因为模型以 JSON、HTML 或其他类似格式序列化。关键点是视图不应包含逻辑。它们难以测试和难以维护。在过去的几年里,视图变得越来越强大。Razor 引擎,ASP.NET Core 提供的默认视图渲染引擎,最近提供了一些新功能。开发者很容易在视图中实现逻辑,但应尽量避免这样做。

  • MVC 的控制器部分处理来自用户的请求。它们从请求中获取信息并更新模型。在实际的商业应用程序中,控制器通常由服务或存储库类支持,这为领域模型层增加了另一个层次。

让我们详细了解一下默认的 ASP.NET Core Web API 项目模板。该项目使用 MVC 模式的模型和控制器部分来提供简单的 HTTP 响应,内容以 JSON 序列化。

首先,让我们使用以下命令创建一个新的项目:

dotnet new webapi -n SampleAPI

执行前面的命令将创建以下文件夹结构:

.
├── Controllers
│ └── WeatherForecastController.cs
├── Program.cs
├── Properties
│ └── launchSettings.json
├── SampleAPI.csproj
├── Startup.cs
├── WeatherForecast.cs
├── appsettings.Development.json
├── appsettings.json
└── obj

执行dotnet new webapi命令将在同名文件夹内创建一个名为SampleAPI的新项目文件。以下是dotnet new webapi命令生成的SampleAPI.csproj文件:

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
 <TargetFramework>netcoreapp3.1</TargetFramework>
    </PropertyGroup>
</Project>

首先要注意的是,该项目使用 Microsoft.NET.Sdk.Web SDK,它指的是网页应用程序 SDK。此外,.NET Core 框架根据我们即将创建的项目目的提供不同的 SDK。例如,在桌面应用程序的情况下,项目将指定另一个 SDK:Microsoft.NET.Sdk.WindowsDesktop。在不同的 SDK 之间进行选择确保了开发者能够获得优秀的模块化水平。其次,项目文件没有指定任何特定的依赖项,除了应用程序使用的 netcoreapp 目标框架。

项目结构

所有 ASP.NET Core 网页模板都有类似的结构。主要区别在于 views 文件夹,在网页 API 项目中不存在。

在继续之前,让我们更详细地查看 SampleAPI 文件夹的结果内容:

  • Program.cs 是应用程序的主要入口点,它运行 API 使用的默认网页服务器。

  • Startup.cs 定义并配置我们的应用程序管道和服务。

  • Controllers 文件夹包含我们应用程序的所有控制器。根据默认的命名约定,ASP.NET Core 会在这个文件夹中搜索我们应用程序的控制器。

  • Properties/launchSettings.json 文件代表我们项目的设置。当您尝试更改项目的任何属性时,该文件会被创建,并且通常存储我们的服务或应用程序的应用程序 URL。此外,如果我们快速查看文件内容,我们可以注意到两个不同的配置文件:一个以创建的项目名称命名,另一个以 IISExpress 命名。每个项目都可以与多个配置文件相关联。它们可以用来指定一些启动设置和应用程序使用的环境变量。因此,可以通过使用 dotnet run 命令并指定配置文件使用 --launch-profile 标志来运行应用程序;

  • appsettings.jsonappsettings.{Environment}.json 包含基于我们环境的设置。它们是 web.config 文件中设置部分的替代品。

Program.cs 和 Startup.cs 文件

让我们继续通过检查网页 API 项目的 Program.cs 文件:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace SampleAPI
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] 
        args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }
}

以下代码导入了 Microsoft.AspNetCore.HostingMicrosoft.Extensions.Hosting 命名空间。它们为在 CreateHostBuilder 函数中创建的新 IHostBuilder 实例的初始化提供了必要的引用。CreateHostBuilder 函数执行 Host.CreateDefaultBuilder 方法,该方法初始化我们的 API 的网页宿主。此外,我们还应该注意,CreateDefaultBuilder 方法返回的 IHostBuilder 实例指向项目的 Startup 类。Main 方法调用 CreateHostBuilder 函数并执行 IHostBuilder 接口公开的 BuildRun 方法。

让我们检查Startup类(在Startup.cs文件中定义),它用于配置应用程序堆栈:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace SampleAPI
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();
        }

        public void Configure(IApplicationBuilder app, 
        IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

Startup类通过依赖注入初始化IConfiguration属性。IConfiguration对象代表一个键/值对象,其中包含应用程序的配置。默认情况下,Program.cs文件中声明的CreateDefaultBuilder方法将appsettings.json设置为默认配置文件。

Startup类有两个不同的方法,它们的行为如下:

  • ConfigureServices方法通过依赖注入配置我们应用程序中的服务。默认情况下,它通过执行.AddControllers扩展方法来添加控制器。在 ASP.NET Core 中,术语服务通常指任何为我们应用程序提供功能和功能的组件或类。正如我们将在下一章中看到的,ASP.NET Core 经常使用依赖注入来维护良好的设计和松散耦合的类。

  • Configure方法用于配置应用程序的中间件管道。它接受两个参数:IApplicationBuilderIWebHostEnvironment。第一个包含我们应用程序的所有管道并公开扩展方法来构建带有中间件的应用程序。我们将在第三章,与中间件管道一起工作中详细查看中间件。IWebHostEvironment接口提供了有关应用程序当前托管环境的某些信息,例如其类型和名称。在一个 Web API 项目中,Configure方法执行一系列扩展方法。其中最重要的是UseRoutingUseEndpoints扩展方法。UseRouting方法的执行定义了管道中路由决策的点。UseEndpoints扩展方法定义了之前选择的端点的实际执行。在 Web API 项目中,涉及的端点只有控制器。因此,UseEndpoints方法执行MapControllers扩展方法来初始化由.NET Core 提供的控制器类的默认路由约定。

应该注意的是,ASP.NET Core 的Startup类通过依赖注入提供了一种高级、代码优先的方式来配置应用程序的依赖项,这意味着它只初始化你所需要的。此外,.NET Core 具有强烈的模块化导向;这也是它比.NET Framework 表现更好的原因之一。

由于所有管道和依赖项都在上述类中初始化,你知道它们可以更改的位置。在具有许多不同组件的大型应用程序和服务中,建议创建自定义扩展方法来处理应用程序特定部分的初始化。

控制器概述

控制器是 ASP.NET Core 项目中 Web API 的基本部分。它们处理传入的请求并作为我们应用程序的入口点。我们将在第四章依赖注入中更详细地探讨控制器,但就目前而言,让我们检查 Web API 模板提供的默认 WeatherForecastController

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace SampleAPI.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", 
            "Balmy", "Hot", "Sweltering", "Scorching"
        };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> 
        logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => 
            new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}

WeatherForecastController 包含基本方法。默认情况下,它不使用任何数据源;它只是返回一些模拟值。让我们通过查看 WeatherForecastController 类的主要元素来继续:

  • ApiController 属性表示控制器及其所有扩展控制器提供 HTTP API 响应。它在 ASP.NET Core 2.1 版本中引入,通常与 ControllerBase 类结合使用。

  • Route("api/[controller]") 属性定义了我们的控制器的路由。例如,在这种情况下,控制器将响应以下 URI:https://myhostname:myport/api/weatherforecast[controller] 占位符用于表示控制器的名称。

  • ControllerBase 类通常与 ApiController 属性结合使用,并在 Microsoft.AspNetCore.Mvc 命名空间中定义。ControllerBase 类表示一个不支持视图部分的控制器。它提供了一些基本方法,例如 CreatedCreatedAtActionNotFound*。它还提供了一些属性,例如 HttpContext,它包含我们 Web 服务的请求和响应。

  • HttpGet 属性是 Microsoft.AspNetCore.Mvc 命名空间的一部分。它标识了动作接受的 HTTP 方法类型。它还接受一个额外的参数,例如 [HttpGet("{id}")],该参数定义了动作的路由模板。ASP.NET Core 为每个 HTTP 动词暴露了一个 HTTP 属性,例如 HttpPost HttpPutHttpDelete

最后,我们可以简要地看一下 WeatherForecastController() 构造方法以及 Get() 方法的实现。第一个初始化控制器类的所有依赖项,它是我们类的依赖注入入口点;所有与控制器相关的依赖项都在构造函数中解决。Get() 方法实现逻辑并返回一个将被序列化然后传递到 Web API HTTP 响应中的元素集合。

摘要

在本章中,我们介绍了 .NET Core 的一些核心概念。我们探讨了控制台应用程序和 Web API 模板,以及 ASP.NET Core Web API 结构和控制器类的结构。

本章涵盖的主题提供了在 .NET Core 上开始控制台应用程序项目所需的技能,并且它们还提供了有关 ASP.NET Core 项目安排的基本知识。

在下一章中,我们将探讨 ASP.NET Core 的一个核心概念——中间件,以及如何使用它来拦截请求并增强我们的应用程序堆栈。

第三章:与中间件管道一起工作

上一章提供了 ASP.NET Core 项目的概述。我们了解了如何创建 ASP.NET Core 项目以及如何处理相关的文件和结构。我们还学习了 MVC 堆栈和 ASP.NET Core 背后的某些基本概念。现在,让我们深入探讨中间件的概念。中间件是 ASP.NET Core 平台的重要组成部分:它帮助我们处理进入的请求和发出的响应。最重要的是,这些类型的组件可以用来监控性能和实现横切功能。本章从中间件概念的介绍开始。接着展示如何实现自定义中间件,并以 ASP.NET Core 内置中间件的概述结束。

本章涵盖了以下主题:

  • 介绍中间件及其不同方面

  • ASP.NET Core 项目中中间件的具体用例

  • ASP.NET Core 内置中间件的概述

  • 实现自定义中间件

介绍中间件

如其名所示,中间件是放置在我们应用程序和进入请求之间的组件。进入请求在到达我们应用程序中实现的有效逻辑之前会击中中间件的管道。因此,中间件被认为是 ASP.NET Core 的基本概念之一,因为它是我们应用程序前面的第一层,通常与日志记录错误处理身份验证验证等横切概念相关联。它还可以根据请求执行诸如条件服务初始化等高级任务。

通常,中间件可以:

  • 处理、处理和修改进入的HTTP 请求

  • 处理、处理和更改发出的HTTP 响应

  • 通过返回早期响应来中断中间件管道

此外,整个 ASP.NET Core 堆栈由中间件组成。中间件也符合一些清洁代码的基本概念:

  • 每个中间件专注于单一目的:接收一个请求并增强它。建议为每个目标实现新的中间件,以符合单一职责原则

  • 中间件还使用了链式的概念。它接收一个进入的请求,并将其传递给下一个中间件。因此,每个中间件都会增强请求,并决定是否中断或继续中间件管道。

如前所述,中间件可以短路管道,这意味着它可以阻止我们的请求并跳过管道的其余部分。这个短路概念不应被低估,因为它是我们服务提高性能的绝佳方式。此外,如果出现问题或用户没有权限继续操作,请求将不会击中我们的控制器。此外,中间件管道通常与如下所示的架构相关联:

图片

单个中间件可以在请求上执行操作并执行逻辑,也可以在响应上执行。此外,理解中间件顺序的重要性至关重要——实际上,当我们将其绑定到管道时,我们声明了顺序。

实际中的中间件管道

我们已经探讨了中间件管道背后的理论以及它在短路和单一责任原则方面的有用性。现在让我们在 ASP.NET Core 中具体化这一点。在前一章中,我们研究了.NET Core 提供的默认 Web API 模板。让我们通过用以下代码片段替换Startup类的内容来继续:

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
        }
        public void Configure(IApplicationBuilder app, IWebHostEnvironment 
        env)
        {
            app.Run(async context =>
 {
 await context.Response.WriteAsync("Hello, World!");
 });
        }
    }

我们可以通过在SampleAPI文件夹中执行以下 CLI 命令来运行此项目:

dotnet run

上述命令使用http://localhost:5000地址启动我们的应用程序。我们可以使用浏览器调用它:

图片

正如你所见,使用中间件策略运行“Hello, World!”很简单。它需要实现Configure方法,这是中间件通常被定义的方法。app.Run执行一个委托方法,这是我们的中间件的表示。在我们的例子中,它接受请求的HttpContext并在上下文的响应中写入内容。

理解 ASP.NET Core 框架如何实现Run方法至关重要。让我们通过检查Microsoft.AspNetCore.Builder命名空间中的代码来更仔细地看看Run方法的实现:

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Builder
{
    /// <summary>
    /// Extension methods for adding terminal middleware.
    /// </summary>
    public static class RunExtensions
    {
        /// <summary>
        /// Adds a terminal middleware delegate to the application's 
            request pipeline.
        /// </summary>
        /// <param name="app">The <see cref="IApplicationBuilder"/> 
            instance.</param>
        /// <param name="handler">A delegate that handles the 
            request.</param>
        public static void Run(this IApplicationBuilder app, 
        RequestDelegate handler)
        {
            if (app == null)
            {
                throw new ArgumentNullException(nameof(app));
            }

            if (handler == null)
            {
                throw new ArgumentNullException(nameof(handler));
            }

            app.Use(_ => handler);
        }
    }
}

上述代码提供了我们正在执行操作的更多细节。我们注意到RequestDelegate处理器不能为null,如果我们深入到调用栈中,我们可以看到我们的委托将通过app.Use扩展方法被添加到管道的末尾

理解中间件顺序的重要性。中间件管道的顺序在Startup类的Configure方法中隐式定义。此外,MVC 中间件通常是被请求击中的最后一个;另一方面,授权中间件通常被放置在其他中间件之前,以确保正确的安全级别(将授权中间件放在MVC 中间件之后可能会损害我们的服务并使其不安全).

ASP.NET Core 中的 HttpContext

在上一个示例中,我们看到了如何使用 app.Run 扩展方法创建中间件。该实现中涉及的一个关键概念是 HttpContext 类型,它是获取所有 HTTP 属性信息的唯一入口点;它通常与传入的请求相关。HttpContext 属性公开了从请求中获取信息和在响应中更新信息的方法和属性。响应和请求信息由以下属性表示:HttpContext.ResponseHttpContext.Request。例如,在上一个案例中,我们使用了 WriteAsync 方法,该方法将 Hello World! 字符串写入到当前的 HttpContext 响应中。

依赖注入是 ASP.NET Core 的核心部分。HttpContext 包含了当前请求中实例化的所有服务的引用。具体来说,它提供了一个 RequestServices 属性,该属性指向服务容器。我们将在下一章中更详细地探讨依赖注入。使用 app.Run 方法声明一段 内联 中间件不是定义新中间件的唯一方式。此外,在下面的子节中,我们将看到如何使用 基于类的 方法构建中间件逻辑。

基于类的中间件

中间件也可以通过使用 基于类的 方法来实现。这种方法增加了中间件的 可重用性可测试性可维护性。基于类的方法涉及定义一个新的类型,例如。让我们看看基于类的中间件:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace Demo.WebAPI
{
    public class SampleMiddleware
    {
        private readonly RequestDelegate _next;

        public RequestCultureMiddleware(RequestDelegate next)
        {
            _next = next;
        }

        public async Task InvokeAsync(HttpContext context)
        {
            //DO STUFF

            // Call the next delegate/middleware in the pipeline
            await _next(context);
        }
    }
}

让我们考察这个类的一些关键点:

  • RequestDelegate 代表对管道中下一个元素的引用。这可能是委托或其他基于类的中间件。

  • InvokeAsync 是我们中间件的核心部分。这包含了中间件的实现并调用管道中的 _next 元素。在这个点上,我们的实现必须在继续管道或仅向客户端返回结果之间做出选择。例如,在 未授权 消息的情况下,中间件将中断管道。

在定义我们的中间件类之后,我们需要将其添加到我们的管道中。一种很好的方法是创建一个新的扩展方法,如下所示:

public static class SampleMiddlewareExtensions
{
    public static IApplicationBuilder UseSampleMiddleware(
        this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<SampleMiddleware>();
    }
}

在此之后,我们可以在 Startup 类中通过执行之前定义的扩展方法将我们的中间件添加到管道中:


    public class Startup
    {

        //  ...

        public void Configure(IApplicationBuilder app, 
        IHostingEnvironment env)
        {
            app.UseSampleMiddleware();

            app.Run(async context =>
            {
                await context.Response.WriteAsync("Hello, World!");
            });
        } 
     }

上述实现提供了一种将中间件的逻辑封装在SampleMiddleware类中的方法。出于各种原因,这种方法是首选的。首先,中间件类和逻辑可以通过单元测试进行验证和测试。其次,在企业环境中,创建包含由 Web 服务使用的常见中间件的专用库项目,并通过公司的 NuGet 仓库进行分发可能很有用。最后,基于类的方法提供了使用构造函数注入突出显示中间件依赖关系的一种清晰方式。我们将在第四章,依赖注入中更深入地探讨这个主题。现在我们已经看到了如何在 ASP.NET Core 管道中声明和添加中间件,有必要更深入地讨论中间件的条件初始化。

条件管道

ASP.NET Core 提供了一些有用的操作符,允许我们将条件初始化逻辑放入中间件管道中。这些类型的操作符可能有助于为我们的服务和应用程序提供额外的性能优势。让我们看看这些操作符的一些例子。

IApplicationBuilder Map (this IApplicationBuilder app, PathString pathMatch, Action<IApplicationBuilder> configuration)扩展方法帮助我们通过映射 URI 路径来初始化我们的中间件;例如:

public static class SampleMiddlewareExtensions
{
    public static IApplicationBuilder UseSampleMiddleware(
        this IApplicationBuilder builder)
    {
       return builder.Map("/test/path", _ => 
       _.UseMiddleware<SampleMiddleware>());
    }
}

在这种情况下,只有当SampleMiddleware作为一个具有指定路径的 URI 被调用时,它才会被添加到我们的管道中。请注意,Map操作符也可以嵌套在其他操作符内部:这种方法提供了一种更高级的初始化条件的方法。

另一个有用的操作符是MapWhen,它只有在谓词函数返回true时才会初始化提供的中间件;例如:

public static class SampleMiddlewareExtensions
{
    public static IApplicationBuilder UseSampleMiddleware(
        this IApplicationBuilder builder)
    {
      return  builder.MapWhen(context => context.Request.IsHttps, 
      _ => _.UseMiddleware<SampleMiddleware>());
    }
}

在这种情况下,如果请求是 HTTPS,我们将初始化SampleMiddleware类。当需要针对特定类型的请求采取行动时,条件中间件初始化可能非常有用。通常,当需要在 HTTP 请求类型上强制执行某些逻辑时,例如请求中存在特定头或使用特定协议时,这通常成为必要。

总之,基于类的中间件在需要在中间件管道中实现自定义逻辑时非常有用,条件初始化提供了一种更干净的方式来初始化我们的中间件集合。在 ASP.NET Core 中,中间件是框架基本逻辑的一等公民;因此,下一节将涵盖一些用例和一些与 ASP.NET Core 一起提供的中间件。

理解内置中间件

那么,中间件有哪些用例呢?如前所述,它们通常与跨切面关注点相关,例如日志记录身份验证异常处理。ASP.NET Core 本身提供了一些内置中间件,它们代表了解决问题的标准方式:*

  • UseStaticFiles(): 提供了一种处理应用程序内部静态文件和资源的方法。当客户端请求静态资源时,此中间件会过滤请求并返回请求的文件,而无需触及管道的其余部分。

  • AddResponseCaching(): 帮助开发者配置应用程序的缓存系统。此中间件还添加了所有与缓存相关的 HTTP 兼容信息。

  • UseHttpsRedirection(): 这个新的内置 ASP.NET Core 2.1 中间件提供了一种强制 HTTPS 重定向的方法。

  • UseDeveloperExceptionPage(): 在发生异常的情况下,此功能会显示详细的错误页面(新的 YSOD)。这通常根据环境条件进行初始化。

这些是 ASP.NET Core 提供的内置中间件的一部分。正如你所见,所有中间件都为你的应用程序提供了跨切面功能。这里重要的是中间件初始化的顺序反映了我们的管道顺序;例如:

     public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            // ...
            app.UseHttpsRedirection();
            app.UseStaticFiles();
        }

在这种情况下,UseStaticFiles中间件将不会收到静态文件的请求,因为 MVC 中间件首先处理它们。一个一般规则是将UseHttpsRedirection()作为管道中的最后一个中间件;否则,其他中间件将不会拦截请求。

摘要

中间件是处理跨切面问题的开发者有用的工具。这是因为它拦截并增强每个进入请求输出响应,并且可以通过早期返回请求来提高性能。从日志记录到身份验证的概念都应通过使用中间件来处理。本章涵盖的主题为你理解 ASP.NET Core 框架采用的中间件优先方法提供了必要的知识。此外,本章还概述了 ASP.NET Core 的内置中间件,并描述了如何创建自定义中间件。

在下一章中,我们将探讨另一个核心主题,即提高我们代码的可维护性和可测试性:依赖注入。ASP.NET Core 提供了开箱即用的依赖注入,我们还将探讨如何解决依赖关系以及如何处理不同生命周期类型。

第四章:依赖注入系统

依赖注入是 ASP.NET Core 的基本构建块。本章展示了如何使用依赖注入来解决 ASP.NET Core 应用程序内部类的依赖关系。它还描述了如何处理依赖注入的生命周期,并提供了一些如何保持类松耦合的示例。本章的第一部分将向您介绍一些关于依赖注入的基本理论概念,而第二部分将展示如何在任何 ASP.NET Core 项目中使用它。

本章将涵盖以下主题:

  • 什么是依赖注入?

  • 为什么在现实世界的应用程序中实现依赖注入模式?

  • 依赖注入生命周期的概述

  • 如何在 ASP.NET Core 中实现依赖注入

依赖倒置原则

依赖倒置原则 是由罗伯特·C·马丁(Robert C. Martin)确立的 SOLID 原则的一部分。SOLID 原则的目的是为开发者提供一些指导,以帮助他们在设计代码时使其更易于理解、灵活和可维护。特别是,依赖倒置原则断言,高级组件不应直接依赖于专注于特定过程(低级组件)的个别组件;相反,它们应该依赖于抽象。因此,抽象不应依赖于任何实现细节。

低级组件通常执行简单的操作并提供简单的功能。另一方面,高级组件通过协调它们来管理一组个别组件。现实世界系统通常具有超过两个抽象级别。当我们谈论 SOLID 原则时,抽象 的概念尤其普遍。一个抽象组件通常是一个接口或抽象类。因此,它是一个没有具体实现的元素。总之,依赖倒置原则 声明,我们应用程序中的每个元素都应该只引用 抽象。让我们看看一个具体的应用了 依赖倒置原则 的系统示例。该图描述了一个添加到购物袋的电子商务过程。

它由三个不同的类别组成:

  • AddToShoppingBagHandler 处理来自客户端的请求并将信息发送到 PaymentService

  • PaymentService 管理有关支付方式的信息。

  • CurrencyConverter 组件提供不同货币之间的转换。

以下图表描述了处理前面提到的类标准的处理顺序:

如果我们应用依赖倒置原则,依赖的方向将改变。为了符合依赖倒置原则,我们应该在组件之间引入一些抽象,如下所示:

图片

通过比较这两个架构,我们得出结论,依赖的方向被反转了。AddToShoppingBagHandler类现在使用IPaymentService接口,而PaymentService类型是IPaymentService接口的具体实现。

然而,这个架构还不完整。此外,它还没有符合依赖倒置原则的第二条陈述。我们应该确保我们的抽象不依赖于实现。

因此,如果我们从架构边界的角度思考,我们的架构将如下改变:

图片

每个圆形矩形代表一个边界。同一矩形内的类和抽象是同一边界的一部分。在.NET 生态系统中,每个边界都是一个项目,我们的接口是高级和低级类之间的桥梁。

一个常见的错误是将接口和实现类放在同一个边界内。在.NET 中,这意味着将IPaymentService接口和PaymentService放在同一个项目中。这种方法并不一定错误,但它并不尊重依赖倒置原则

总之,依赖倒置原则用于构建非常灵活的系统,它帮助我们设计出更易读、灵活和可维护的代码。

依赖倒置原则经常与依赖注入的概念混淆,因为这两个概念密切相关。如果依赖倒置定义了一个改进我们系统的原则,那么依赖注入就是这个原则的具体实现。

当我们想要测试我们的代码时,特别是对于单元测试技术,依赖倒置原则变得非常有用。单元测试通常覆盖我们应用程序中的特定功能,因此我们需要隔离我们的类和方法。依赖倒置非常有用,因为我们可以在测试中模拟我们的抽象并隔离正在测试的主题。

依赖注入的好处

依赖注入的描述如下:

"一套软件设计模式,使我们能够开发出松耦合的代码。"

依赖注入的目标是实现松散耦合的代码,并因此编写可维护的代码。在《.NET 中的依赖注入》这本书中,Mark Seemann 描述了一个明亮的、现实生活中的松散耦合代码的例子。他将紧密耦合的代码与便宜的酒店吹风机进行比较:一些旅舍、酒店和更衣室将吹风机直接绑定在墙上,没有插头以防止客人偷走。如果吹风机停止工作,所有者必须切断电源并叫技术人员,技术人员必须断开吹风机并更换一个新的。这种方法是一个非常繁琐的程序。

另一方面,如果吹风机插在墙上,所有者必须更换一个新的。这是一个关于依赖注入的隐喻。

第一个案例是一个紧密耦合的代码:我们的高级组件(墙壁)直接使用低级部分(吹风机)。在第二个案例中,我们有一个第三方演员,即插头:高级元素(墙壁)直接使用插头(抽象)。我们的低级组件吹风机也使用插头。

第二个案例更灵活且易于维护,因为我们可以将任何东西插入插头,如果吹风机坏了,我们可以轻松地更换它。

这就是依赖注入的全部内容。它为我们的代码带来了许多好处:

  • 后期绑定:第三方服务可以被插入并与其他服务交换。当你更换一个第三方依赖项时,这可能很有用。

  • 并行开发:不同的团队可以通过定义组件之间的交互契约(接口)同时开发代码。

  • 可维护性:代码易于维护和管理。

  • 可测试性:如前所述,依赖注入帮助我们处理单元测试的依赖项隔离。

随着我们的代码库不断增长,这些好处就越有用。对于小型代码库,依赖注入可能看起来是一个无用的开销,但当我们处理分布式和大型代码库时,它变得至关重要。在下一节中,我们将看到如何将依赖注入的概念应用到

ASP.NET Core 中的依赖注入

依赖注入的概念是 ASP.NET Core 的一个基本组成部分。依赖注入系统是 ASP.NET Core 框架自带的功能,并且是我们应用程序中实例化组件的首选方式。

ASP.NET Core 通常将依赖注入容器管理的类型描述为服务。因此,所有服务都存储在由IServiceProvider接口表示的内置容器中。

在本章的下一部分,我们将看到一些依赖注入的示例。作为第一步,让我们在SampleAPI项目中的Controllers文件夹内创建一个新的类,命名为ValuesController.cs

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;

namespace SampleAPI.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ValuesController : ControllerBase
    {
        public string Get()
        {
            return string.Empty;
        }
    }
}

上述代码片段声明了一个 ValuesController 类,其中包含一个简单的 Get 方法。可以通过执行以下 CLI 命令来调用路由:

dotnet run

这也可以通过调用以下端点来实现:https://localhost:5001/values。作为第二步,我们需要创建一个新的 PaymentService.cs 文件,包含以下代码:

namespace SampleAPI
{
    public interface IPaymentService
    {
        string GetMessage();
    }

    public class PaymentService : IPaymentService
    {
        public string GetMessage() => "Pay me!";
    }

    public class ExternalPaymentService : IPaymentService
    {
        public string GetMessage() => "Pay me!, I'm an external service!";
    }
}

PaymentService.cs 文件定义了 IPaymentService 接口,该接口描述了一个 GetMessage 签名。此外,IPaymentService 接口由返回字符串的 PaymentService 类实现。同样,我们定义了一个 ExternalPaymentService 类,该类以不同的行为实现了 IPaymentService 接口。下一节将描述如何注册 IPaymentService 接口以使用 PaymentService 类。

使用依赖注入容器注册服务

我们可以在 Startup 类的 ConfigureServices 中注册 IPaymentService 接口,添加以下代码:

      public class Startup
     {
           // ...

         public void ConfigureServices(IServiceCollection services)
         {
 services
                    .AddTransient<IPaymentService, PaymentService>() .AddControllers();       
         }

           // ...

     }

上述代码展示了使用 ASP.NET Core 容器实例化服务的一个简单示例。为了使代码更易读,我省略了 Startup 类的一些部分。运行时执行 services.AddTransient<IPaymentService, PaymentService>() 方法,以便将 IPaymentService 接口与在 PaymentService 类中描述的具体实现进行映射。AddTransient 方法还定义了我们的服务的作用域。我们将在本章后面详细讨论作用域。

我们还应该注意,依赖注入容器需要具体的类(PaymentService)来实现抽象(IPaymentService)并将其实例添加到容器中。

有条件地注册服务

在实际应用中,根据环境变量有条件地注册一些服务是一种常见的做法。当我们想要以不同的方式初始化第三方依赖项,例如数据源时,这种做法非常有用。以下代码展示了如何根据环境有条件地注册服务:

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace SampleAPI
{
    public class Startup
    {

        public Startup(IConfiguration configuration, 
 IWebHostEnvironment env)
        {
            Configuration = configuration;
            Environment = env;
        }

        public IConfiguration Configuration { get; }

        public IWebHostEnvironment Environment { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            if (Environment.IsDevelopment())
            {
                services.AddTransient<IPaymentService, PaymentService>();
            }
            else
            {
                services.AddTransient<IPaymentService, 
 ExternalPaymentService>();
            }
        }

        // ...
    }
}

示例使用IWebHostEnvironment接口来检测IsDevelopment()环境。在这种情况下,它会初始化PaymentService。否则,它会初始化ExternalPaymentService实现。这种做法在测试环境中很常见,尤其是在初始化测试服务或数据源时。在广泛的企业应用中,为了测试和开发目的,通常会在条件注册服务。在第十章 Implementing the RESTful HTTP Layer 中,我们将看到一些具体的应用实例,这些实例已经应用于集成测试。保持测试环境隔离对于避免假阳性结果至关重要。此外,条件注册服务也有助于我们提高代码的灵活性。在接下来的小节中,我们将看到如何使用构造函数注入和操作注入来解决控制器类的依赖关系。

构造函数注入

我们刚刚看到了如何在我们的Startup类中初始化服务,但如何消费这些服务呢?默认情况下,ASP.NET Core 内置的依赖注入容器使用构造函数注入模式来检索服务*。我们可以通过将接口作为控制器构造函数的参数来修改ValueController以使用IPaymentServicespublic ValuesController(IPaymentService paymentService, string[] paymentTypes = new string[] { 1, 2, 3 })

using Microsoft.AspNetCore.Mvc;

namespace SampleAPI.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ValuesController : ControllerBase
    {
        private IPaymentService paymentService { get; set; }

        public ValuesController(IPaymentService paymentService)
        {
            this.paymentService = paymentService;
        }

        public string Get()
        {
            return paymentService.GetMessage();
        }
    }
}

如您所见,我们可以在我们类的构造函数中注入IPaymentService接口。需要注意的是,为了符合构造函数注入规范,构造函数必须遵守以下规则:

  • 构造函数应该是公共的:如果我们的构造函数不是公共的,反射过程无法访问构造函数。

  • 应该只有一个合适的构造函数:例如,如果我们在我们ValuesController类中声明了多个构造函数,比如public ValuesController(IPaymentService paymentService)或者public ValuesController(IPaymentService paymentService, string[] paymentTypes = new string[] { 1, 2, 3 }),运行时会抛出InvalidOperationException。应该只有一个适合依赖注入的构造函数。

  • 只有当它们有默认值时,您才能传递未由依赖注入提供的参数。例如,以下构造函数适合构造函数注入:public ValuesController(IPaymentService paymentService, string[] paymentTypes = new string[] { 1, 2, 3 })

依赖关系的解析发生在运行时执行过程中;因此,我们需要遵守这些规则,以避免在更改控制器类的依赖关系时遇到陷阱。

总结来说,依赖注入提供了一种智能的方式来解决类的依赖问题。你也应该尽量遵守单一职责原则SRP)。SRP 指出,一个类应该只负责功能的一部分。拥有大量注入依赖的类可能不符合 SRP。避免这些不良的设计实践可以提高我们代码的可维护性,并避免我们的类与静态功能紧密耦合,这会阻止它们被测试。接下来,我们将继续下一节,该节将介绍动作方法注入技术。

动作方法注入

构造函数注入的一个有效替代方案是动作方法注入。有时,控制器只在一个动作方法中使用一些依赖项。在这些情况下,将我们的依赖项仅注入到这个动作方法中,可能有助于提高我们代码的性能。要执行动作方法注入,我们应该使用[FromServices]属性。例如,看看以下代码片段:

using Microsoft.AspNetCore.Mvc;

namespace SampleAPI.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class ValuesController : ControllerBase
    {
        [HttpGet]
        public ActionResult<string> Get(
            [FromServices]IPaymentService paymentService)
        {
            return paymentService.GetMessage();
        }
    }
}

如前所述,该示例使用了动作方法注入。我们将服务注入到Get动作方法中,这是唯一的依赖项消费者。尽管构造函数注入被广泛采用,但当你在整个控制器中不使用依赖项时,动作方法注入技术变得很有用。这仅保证了在动作方法被调用时依赖项的延迟解析。我们还应该注意,这种方法严格依赖于 MVC 堆栈,因为服务的解析是在执行过程中的模型绑定阶段完成的;因此,它仅支持在动作方法和过滤器类中。下一节将专注于 ASP.NET Core 提供的服务生命周期类型。

服务生命周期

在处理依赖注入时,我们需要掌握的一个关键点是服务生命周期。服务生命周期是关于性能的一个基本概念,因为错误的服务生命周期可能会导致复杂的性能下降。

在.NET 中,对象的生命周期很简单:对象被实例化使用,最后由垃圾回收器销毁。在性能方面,销毁阶段是最相关的。在依赖注入过程中,特定依赖项的消费者不控制其生命周期。实际上,依赖项通常由依赖注入容器初始化,并且它们会一直存在,直到所有消费者都持有它们。

在大型应用程序中,工程师面临的一个典型性能问题是 内存泄漏。垃圾收集器无法清理对象,因为它们仍然被消费者引用。结果,服务器的内存不断增加,直到达到饱和。找到和解决这类性能问题并不容易。在.NET 生态系统中,像 dotMemory 这样的工具可以帮助您分析应用程序创建的对象实例,并最终检测到这类性能问题。

说到依赖注入,ASP.NET Core 中的默认生命周期类型是 瞬态作用域单例。让我们更详细地讨论它们。

瞬态生命周期

服务通过使用 .AddTransient() 方法定义了瞬态生命周期。每次消费者需要初始化瞬态服务时,依赖注入容器都会返回一个新的实例。瞬态生命周期是最安全的生命周期,因为它每次都返回一个新的实例,实例之间不会在消费者之间共享。然而,它也是最不高效的,因为它可能会创建大量的实例,尤其是在网络环境中。

作用域生命周期

服务定义了作用域生命周期 .AddScoped() 方法。作用域实例在每个请求中只创建一次。与瞬态生命周期相比,作用域生命周期在性能上更可取,但它的效率低于单例生命周期。通常将作用域方法应用于仓库类和服务,每次对服务器的请求都会导致创建一个新的实例。

单例生命周期

在单例生命周期中,每次消费者请求一个新实例时,都会提供相同的实例。这是最有效的生命周期,因为只有一个实例,所以消耗的内存量最小。然而,建议您仅对线程安全组件使用单例生命周期。

生命周期疯狂

“生命周期疯狂”这个术语来自 Jeffrey Richter 的《CLR via C#》,其中关于线程的章节。理解依赖项的生命周期对于避免我们应用程序中的性能问题非常重要。首先,我们应该避免以下情况:

  • 在单例消费者中消费作用域依赖项:如前所述,作用域生命周期意味着每个请求都会创建一个新的实例。当我们尝试在单例生命周期中消费作用域实例时,运行时会抛出以下异常:InvalidOperationException: Cannot consume scoped service 'Services.MyScopedService' from singleton 'Services.MySingletonService'。这是因为当单例实例引用它时,运行时无法为每个请求创建作用域服务。

  • 在单例消费者中消费瞬态依赖项:同样,如果我们在一个单例实例中使用瞬态依赖项,运行时不会每次都创建瞬态服务的新的实例。此外,由于瞬态服务是在单例中声明的,它将只初始化一次。而且,运行时不会抛出异常,因为单例始终使用同一个实例。

为了防止可能的错误和运行时错误,避免单例引用 scoped 或瞬态服务的情况非常重要。在第一种情况下,运行时会抛出异常,而在第二种情况下,单例消费者将始终使用同一个实例。这些行为必须避免,以防止在我们的 API 内部出现内存问题和性能下降。以下小节将解释如何在中间件类中使用依赖注入。

将服务注入中间件

如前所述,中间件可以通过依赖注入容器实例化依赖项。我们应该考虑中间件的寿命:它们在每个应用程序生命周期中只初始化一次。因此,如果我们尝试在我们的中间件中消费scoped瞬态实例,我们不应该通过中间件的构造函数注入它们,因为这会导致一些依赖项解析问题。避免这种情况的一个好方法是使用InvokeInvokeAsync方法中的参数注入:

  namespace Middleware
 {
     public class MyMiddleware
     {
         readonly RequestDelegate _next;

         public MyMiddleware(RequestDelegate next)
         {
             _next = next;
         }

         public async Task InvokeAsync(HttpContext context, 
 IPaymentService paymentService)
         {
             Console.WriteLine(paymentService.GetMessage());

             await _next(context);
         }
     }
 } 

另一个中间件实现将IPaymentService注入到InvokeAsync方法中。与中间件构造函数不同,InvokeAsync方法对每个请求都会被调用。因此,它既适用于scoped 生命周期也适用于瞬态生命周期

当你想要将一个 transientservice 或 scoped service 注入到中间件中时,你应该在InvokeInvokeAsync方法中注入它们,以避免生命周期问题。此外,中间件是一个横切组件,这意味着应用程序在每次请求时都会运行它们。因此,在实现中间件时,你必须格外小心,以避免将性能问题传播到所有应用程序中。

摘要

本章向我们展示了如何处理 ASP.NET Core 默认的依赖注入引擎。本章提供了与依赖注入相关的各种示例,包括如何在控制器和中间件中使用依赖注入,以及描述已注册服务的生命周期概念。下一章将详细讨论控制器和操作方法。它将向您展示如何使用这些方法来序列化数据并将其作为 Web 服务公开。

第五章:ASP.NET Core 中的 Web 服务栈

本章介绍了如何在 Web 服务栈中处理控制器和操作。控制器是 ASP.NET Core 的一个基本部分;它们是 HTTP 请求的入口点。在本章中,我们将仔细研究控制器类的机制以及它们如何使用 HTTP 协议将信息传输到客户端。

本章涵盖了以下主题:

  • 控制器是什么?

  • 使用控制器和操作处理请求

  • 如何处理 DTO 对象

  • 实现验证

到本章结束时,读者将对该章提供的 Web 栈有一个总体概述,并将知道如何使用控制器和操作处理传入的 HTTP 请求。

控制器是什么?

控制器是 MVC 模式中的 C 部分。它们是一组通常处理来自客户端请求的操作。你应该记住,本章所讨论的内容是指由 ASP.NET Core 定义的 MVC 栈。此外,如果我们以传入的请求为参考,请记住它们已经通过了中间件管道中的其他中间件,并且已经触发了 MVC 中间件

下图显示了请求通常是如何处理的:

图片

如我们在 第一章 中所讨论的,“REST 101 和 ASP.NET Core 入门”,传入的请求通常由客户端生成:浏览器、另一个 API 或外部系统。请求由一个 HTTP 动词、一个 URI请求体有效载荷和其他附加信息组成。路由引擎处理请求并将其传递到我们 控制器 中的一个 操作方法操作方法通常通过提供响应来继续执行。此外,控制器通常通过其他类(如数据库或其他服务)与第三方系统交互。最后,它们以特定格式提供服务。在 MVC 应用程序的情况下,它们通常返回一个视图,而在 Web API 的情况下,它们以 JSON/XML 等格式返回结果:

图片

上述图示显示了传入请求通过模型-控制器栈的流程。正如你所见,流程省略了 MVC 栈中的视图部分,因为它对于构建 Web 服务没有太大用处。下一部分将重点介绍控制器,并解释如何识别控制器。

识别控制器

控制器和操作通常以某种元编程风格装饰属性和过滤器,这允许开发者理解实现代码的目的。ASP.NET Core 遵循一系列标准来在我们的项目中查找控制器,通常是通过使用文件系统约定。控制器通常存储在 Controllers 文件夹中。

为了被路由系统识别,控制器类需要遵守以下规则之一:

  • 类以 Controller 后缀结尾,或者它继承自具有 Controller 后缀的类

  • 类被装饰了 [Controller][ApiController] 属性,这表明它是一个控制器类

如我们在第四章中提到的依赖注入,建议显式使用构造函数或操作注入来定义控制器依赖项。依赖注入方法提高了控制器的可测试性和可维护性。

现在,让我们看看如何将像继承这样的广泛概念应用于控制器以扩展其功能,以及 ASP.NET Core 如何使用这种技术为控制器类提供一些基本属性。

扩展控制器

如前所述,控制器是类,因此它们可以扩展其他类型,包括其他控制器。这种技术可以应用,以便我们可以重用特定的实现或功能。通常,控制器扩展 ControllerControllerBase 类,这些类是 ASP.NET Core 框架的一部分。这些基类为控制器提供了管理请求和响应的一些工具。首先,让我们分析 ControllerControllerBase 类之间的区别:

  • ControllerBase 代表一个没有视图支持的 MVC 控制器的基类。它为子类提供了一些基本属性,例如 HttpContextRequestResponse 属性。

  • Controller 类扩展了 ControllerBase 类,但它还添加了一些用于管理视图的属性和方法,例如 ViewData 属性和 View() 以及 PartialView() 方法。

当我们处理 RESTful API 和一般意义上的 Web 服务时,ControllerBase 类提供了足够的工具。然而,如果我们处理视图,则应扩展 Controller 类。

ApiController 属性

从 2.1 版本开始,ASP.NET Core 引入了一个新的属性,即 ApiController 属性:

using Microsoft.AspNetCore.Mvc;

namespace SampleAPI.API.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
      // ...
    }
{

ApiController 属性通常与 ControllerBase 类结合使用,以使控制器能够实现 REST 特定的行为,并允许我们构建 HTTP API。首先,它提供了隐式模型状态验证,这意味着我们不需要在每个操作中显式检查 ModelState.IsValid 属性。其次,它还隐式定义了模型绑定属性,这意味着我们不需要为每个参数指定 [FromBody][FromForm][FromHeader][FromQuery][FromRoute] 属性。ASP.NET Core 将根据以下标准为我们定义这些属性:

  • [FromBody] 用于复杂类型参数,例如自定义类或内置对象。

  • [FromForm] 用于推断 IFormFileIFormFileCollection 类型的操作参数。

  • [FromRoute] 用于任何名称与路由模板中设置匹配的操作参数。

  • [FromQuery] 用于推断任何其他操作参数。

让我们检查以下通用操作方法:

    [Route("api/[controller]")]
    public class ValuesController : ControllerBase
    {
        // ...

        [HttpPost]
        public IActionResult Post([FromBody]ValueRequest request)
        {
            if (ModelState.IsValid) 
            {
                return BadRequest(ModelState);
            }

            return Ok();
        }

        // ..
    }

在应用了 ApiController 属性之后,操作方法可以最小化,如下所示:

    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        // ...

        [HttpPost]
        public IActionResult Post(ValueRequest request)
 {
 return Ok();
 }

        // ..
    }

[FromBody] 属性由于 ValueRequest 复杂类型而被隐式指定。同样,ModelState.IsValid 检查也是隐式的:如果客户端传递了一个对操作无效的模型,它将返回 400 bad requests。在下一节中,我们将探讨一个简单控制器的实现,该控制器可以使用仓库类处理和执行一些逻辑。

使用控制器和操作处理请求

操作方法的目的在于处理和响应传入的请求。本节中描述的示例将向您展示如何使用控制器处理 HTTP 请求。我们将应用我们在前几章中探讨的一些概念,例如依赖注入。以下示例将使用我们在 第二章 的 设置 ASP.NET Core 项目 部分中创建的相同项目结构,ASP.NET Core 概述

本节的源代码可在 GitHub 上找到:github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3

下一个子节介绍了一个简单的内存中仓库,它将被用来存储一些数据并通过我们的控制器堆栈检索它。这种类型仓库的目的是设置一个快速存储系统,而不引入任何额外的应用程序复杂性。

创建内存中仓库

创建一个 内存中 仓库的最简单方法是通过定义一个具有私有属性的单一实例类,该属性代表一个元素集合。仓库将被初始化为 单一实例类型;因此,这种特定的生命周期保证了数据将持续到应用程序重启。

在这个阶段,我们不需要使用带有真实数据源的仓库,因为我们只需要关注示例中的 HTTP 部分,而不是数据的存储方式。在本书的后续部分,我们将更详细地探讨数据访问部分。

首先,我们需要一个模型来表示我们想要使用仓库存储的数据。让我们创建一个名为 Models 的新文件夹,并创建一个名为 Order.cs 的新类:

using System;
using System.Collections.Generic;

namespace SampleAPI.Models
 {
  public class Order
     {
         public Guid Id { get; set; }
         public IEnumerable<string> ItemsIds { get; set; }
     }
 }

现在,我们需要定义一个新的接口,称为 IOrderRepository。该接口表示我们的订单仓库,它将位于一个名为 Repositories 的新文件夹中:

using System;
using System.Collections.Generic;
using SampleAPI.Models;

namespace SampleAPI.Repositories
{
    public interface IOrderRepository
    {
        IEnumerable<Order> Get();
        Order Get(Guid orderId);
        void Add(Order order);
        void Update(Guid orderId, Order order);
        Order Delete(Guid orderId);
    }
}

我们接口由 MemoryOrderRepository 类实现,它提供了我们接口的具体逻辑:

using System;
using System.Collections.Generic;
using System.Linq;
using SampleAPI.Models;

namespace SampleAPI.Repositories
{
    public class MemoryOrderRepository : IOrderRepository
    {
        private IList<Order> _orders { get; set; }

        public MemoryOrderRepository()
        {
            _orders = new List<Order>();
        }
        public IEnumerable<Order> Get() => _orders;

        public Order Get(Guid orderId)
        {
            return _orders.FirstOrDefault(o => o.Id == orderId);
        }
        public void Add(Order order)
        {
            _orders.Add(order);
        }

        public void Update(Guid orderId, Order order)
        {
            var result = _orders.FirstOrDefault(o => o.Id == orderId);

            if (result != null) result.ItemsIds = order.ItemsIds;
        }
        public Order Delete(Guid orderId)
        {
            var target = _orders.FirstOrDefault(o => o.Id == orderId);
            _orders.Remove(target);

            return target;
        }
    }
}

MemoryOrderRepository 类初始化一个私有的 Order 类型列表。此外,它还定义了一些我们可以用来操作订单列表的操作,即 GetAddUpdateDelete 方法。这些方法使用 LINQ 语法对列表元素进行操作。此外,由 _orders 属性表示的主要集合被声明为私有,以防止任何外部访问。

注意,每个命名空间路径反映了文件系统的结构。例如,SampleAPI.Repositories 命名空间反映了 Sample.API/Repositories 文件系统路径。

最后,我们可以通过初始化 MemoryOrderRepository 实现作为 单例 来继续。为此,我们需要修改 Startup 类,并使用 AddSingleton 方法将我们的服务添加到 服务集合 中:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SampleAPI.Repositories;

namespace SampleAPI
{
    public class Startup
    {

        public IConfiguration Configuration { get; }

        // ..

        public void ConfigureServices(IServiceCollection services)
        {
            services
                .AddSingleton<IOrderRepository, MemoryOrderRepository>()
                .AddControllers();
        }
        // ...
    }
}

以下示例使用 IOrderRepository 接口进行演示和学习目的。我强烈建议您避免使用单例实例在内存中存储数据,因为单例实例不是持久存储,这也导致我们的应用程序性能下降。

总结来说,我们现在有一个描述单个订单的 Order 类。IOrderRepository 接口允许我们存储和读取数据,并且由 MemoryOrderRepository 类型提供的内存实现,它使用内存作为数据存储。现在,我们拥有了处理数据和通过控制器处理客户端请求的所有必要组件。

处理客户端请求

每次我们实现一个类时,我们都应该始终牢记 单一 责任 原则。因此,*我们的控制器中的动作方法应该是简单的处理程序,调用作为数据操作的方法。ASP.NET Core 中的控制器通常表现如下:

  • 他们调用其他类以获取或更新存储在,例如,仓库类中的数据。

  • 他们处理异常。动作方法通常封装对其他对象的调用以捕获异常。然后,这些异常被呈现给客户端。

  • 他们使用所需的 HTTP 规范增强返回的数据。

让我们继续前进,通过在 Controllers 文件夹内创建一个新的 OrderController 类型来构建我们的控制器类。这段代码包括 ApiController 属性和对 ControllerBase 类的扩展:

...
[Route("api/order")]
[ApiController]
public class OrderController : ControllerBase {}
...

ASP.NET Core 提供两种处理路由的方式:

  • 使用属性装饰控制器

  • 扩展默认的路由系统(例如,使用 MapRoute 方法)

使用属性定义路由可以覆盖很多情况,并且适用于各种业务需求。它也使得我们代码的维护性和可读性更加直接。[Route("api/order")] 属性将一个特定的控制器映射到一条特定的路由。在这种情况下,OrderController 将响应于 //hostname/api/order URI。此外,ASP.NET Core 框架还提供了两个占位符:[controller][action]。它们可以用来引用当前控制器或直接的动作。例如,前面的代码片段也可以写成如下形式:

...
[Route("api/[controller]")]
[ApiController]
public class OrderController : ControllerBase
...

我强烈建议避免使用 [controller][action] 占位符。如果你重构控制器或动作的名称,你也会改变服务的路由而不会抛出任何错误。因此,它可能会在相互依赖的服务系统中引起问题。

让我们初始化并解析 OrderController 类的依赖项。此外,我们将看到如何将 IOrderRepository 接口注入并初始化到控制器中。

使用动作处理 HTTP 方法

现在我们有了 OrderController 的定义,并且 IOrderRepository 接口已经通过依赖注入引擎注册,我们可以通过定义显式依赖项来继续,使用 构造函数注入

using System;
using Microsoft.AspNetCore.Mvc;
using SampleAPI.Models;

namespace SampleAPI.Controllers
 {
     [Route("api/order")]
     [ApiController]
     public class OrderController : ControllerBase
     {
         private readonly IOrderRepository _orderRepository;

         public OrderController(IOrderRepository orderRepository)
 {
 _orderRepository = orderRepository;
 }

        ...
    }
 }

OrderController 类依赖于 IOrderRepository 类,并且它使用构造函数注入来解析依赖。如果我们检查控制器的构造函数签名,这种依赖关系将非常明显。在大多数情况下,你可以通过计算注入到 构造函数 中的依赖项数量来了解一个类的复杂程度。因此,作为一般规则,当你看到一个有很多依赖项注入到其中的类时,它可能不符合 单一职责原则

控制器类通常将一组动作方法分组在一起。正如我们在 第一章 中讨论的,REST 101 和 ASP.NET Core 入门,HTTP 动词在 Web API 和 REST 服务中至关重要。它们用于指示我们对数据执行的具体操作。例如,HTTP GET 对应于读取操作,而 HTTP POST 对应于创建动作。让我们继续实现 HTTP GET 动作:

[Route("api/order")]
[ApiController]
public class OrderController : ControllerBase
     {
  private readonly IOrderRepository _orderRepository;

  public OrderController(IOrderRepository orderRepository)
  {
     _orderRepository = orderRepository;
  }

  [HttpGet]
 public IActionResult Get()
 {
 return  Ok(_orderRepository.Get());
 }

  [HttpGet("{id:guid}")]
 public IActionResult GetById(Guid id)
 {
 return Ok(_orderRepository.Get(id));
 } ... 

实现描述了两个动作,这意味着存在两条不同的路由:

Http verb URI Action
GET hostname/api/order
[HttpGet]
IActionResult Get()

|

GET hostname/api/order/<guid>
[HttpGet("{id:guid}")]
IActionResult GetById(Guid id)

|

正如我们之前提到的,ASP.NET Core 通过路由中间件处理传入的请求并将它们映射到动作。路由既在启动代码中定义,也在属性中定义。每个 HTTP 动词都有一个对应的属性:HttpGet 对应于 GET 方法,HttpPost 对应于 POST 方法,依此类推。

通常,HTTP 动词属性具有以下签名:

[HttpVerbAttribute(string template, [Name = string], [Order = string]]

模板是一个字符串参数,表示特定操作的 URL。它也可以接受一些路由约束。例如,[HttpGet("{id:guid}")]将接收一个以字符串形式出现的 GUID 标识符:

https://localhost:5001/api/order/7719c8d3-79f4-4fbd-b99a-2ff54c5783d2

我们将在第六章路由系统中更详细地探讨路由约束。

必须牢记,路由约束并不是为了成为一个验证系统。如果我们有一个无效的路由,我们的服务将返回404 Not Found而不是400 Bad Request

属性的Name参数表示标识该操作方法的路由名称。通常,它对路由系统没有影响。此外,它用于在生成 URL 时引用路由规则,并且在整个代码库中必须是唯一的。遵循前面的规范,我们很容易在我们的控制器中实现其他 CRUD 操作。结果如下所示:

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using SampleAPI.Models;
using SampleAPI.Repositories;

namespace SampleAPI.Controllers
{
    [Route("api/order")]
    [ApiController]
    public class OrderController : ControllerBase
    {
        private readonly IOrderRepository _orderRepository;

        public OrderController(IOrderRepository ordersRepository)
        {
            _orderRepository = ordersRepository;
        }

        [HttpGet]
        public IActionResult Get()
        {
            return Ok(_orderRepository.Get());
        }

        [HttpGet("{id:guid}")]
        public IActionResult GetById(Guid id)
        {
            return Ok(_orderRepository.Get(id));
        }

        [HttpPost]
        public IActionResult Post(Order request)
        {
            var order = new Order()
            {
                Id = Guid.NewGuid(),
                ItemsIds = request.ItemsIds
            };

            _orderRepository.Add(order);
            return Ok();
        }

        [HttpPut("{id:guid}")]
        public IActionResult Put(Guid id, Order request)
        {
            var order = new Order
            {
                Id = id,
                ItemsIds = request.ItemsIds
            };

            _orderRepository.Update(id, order);
            return Ok();
        }

        [HttpDelete("{id:guid}")]
        public IActionResult Delete(Guid id)
        {
            _orderRepository.Delete(id);
            return Ok();
        }
    }
}

现在我们已经快速浏览了实现控制器,让我们更仔细地看看由它定义的操作:

HTTP 动词 URI 操作
GET hostname/api/order
[HttpGet]
IActionResult Get()

|

GET hostname/api/order/<guid>
[HttpGet("{id:guid}")]
IActionResult GetById(Guid id)

|

POST hostname/api/order
 [HttpPost]
 IActionResult Post(Order request)

|

PUT hostname/api/order/<guid>
[HttpPut("{id:guid}")]
IActionResult Put(Guid id, Order request)

|

DELETE hostname/api/order/<guid>
[HttpDelete("{id:guid}")]
IActionResult Delete(Guid id)

|

我们还应该注意到,控制器不对输入数据进行任何验证。此外,当[ApiController]属性应用于类之上时,它提供了 ASP.NET Core 的开箱即用验证和模型绑定。因此,所有相关的对象,例如Order 请求参数,如果需要,必须从请求体中传递。

要运行 ASP.NET Core api,我们应该在项目文件夹中执行dotnet run来运行我们的应用程序,并使用curl或其他客户端执行 HTTP 请求,如下所示:

curl -X GET  https://localhost:5001/api/order  -H 'Content-Type: application/json' -k

以下命令在/api/order URL 上执行一个 GET 请求,使用Content-Type: application/json。由于 ASP.NET Core 默认提供 HTTPS,我们可以使用-k标志忽略证书验证。本书后面我们将看到如何在本地上安装证书。

输出将如下所示:

[]

对于 Windows 用户,从 Windows 10 版本 17063 开始(devblogs.microsoft.com/commandline/tar-and-curl-come-to-windows/),操作系统自带了一个已经设置好并准备好使用的curl副本。然而,您也可以从curl.haxx.se/下载并安装curl。另一个选项是使用 Chocolatey 包管理器通过在命令行中执行choco install curl来安装curl

此外,如果我们尝试执行一个带有空有效载荷的curl命令如下:

curl -X POST   https://localhost:5001/api/order -H 'Content-Type: application/json' -d '' -k

输出将如下所示:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "|7c58576e-47baf080f74cf2ab.",
  "errors": {
    "": [
      "A non-empty request body is required."
    ]
  }
}

在这种情况下,我们使用 -d 空标志传递了一个空的正文有效载荷。因此,默认的模型验证在请求的响应中返回错误消息,并返回一个 HTTP 400 Bad request 消息。

在现实世界的应用程序中,这类验证是不够的。通常会被一些自定义验证所取代,例如 数据注释流畅验证。我们将在本章的后面部分更详细地探讨这些技术。

响应请求

现在我们有一个能够处理我们请求的控制器,我们应该关注响应部分。从响应的角度来看 OrderController,我们可能会注意到它不符合 REST 规范。没有任何动作方法考虑任何失败状态。如果我们的数据源出现故障会发生什么?如果请求的订单不在我们的存储库中,会发生什么?

让我们先检查一下当我们请求一个不存在的订单时会发生什么。为了进行这项检查,我们只需要使用一个不存在的 GUID 进行 curl 请求:

curl -X GET https://localhost:5001/api/order/a54f58bc-216d-4a40-8040-bafaec68f2de -H 'Content-Type: application/json' -i -k

之前的命令行指令将产生以下输出:

HTTP/1.1 204 No Content
Date: Fri, 17 Aug 2018 14:37:58 GMT
Server: Kestrel
Content-Length: 0

从响应中我们可以看到,ASP.NET Core 自动处理了空结果,并返回了 HTTP 状态 204 No Content。需要注意的是,所有这些行为都是默认提供的。此外,还可以覆盖默认组件,并添加我们自己的自定义验证实现和响应处理。

CreateAt 响应

Post 动作方法负责创建资源。Post 动作方法的另一个主要责任是告诉客户端资源创建的位置以及如何访问它。这种责任通常在动作方法中实现。ASP.NET Core 提供了两种可以向客户端提供这种信息的方法,即 CreatedAtActionCreatedAtRoute。以下示例展示了如何在我们的 Post 动作中使用 CreatedAtAction 方法:

...
        [HttpPost]
        public IActionResult Post(Order request)
        {
            var order = new Order()
            {
                Id = Guid.NewGuid(),
                ItemsIds = request.ItemsIds
            };

            _orderRepository.Add(order);
            return CreatedAtAction(nameof(GetById), new { id = order.Id }, null);
        }
...

之后,对以下 POST 请求的响应将如下所示:

curl -X POST https://localhost:5001/api/order/ -H 'Content-Type: application/json' -d '{"itemsIds": ["1","4"]}' -i -k

HTTP/1.1 201 Created
Date: Mon, 20 Aug 2018 11:19:49 GMT
Server: Kestrel
Content-Length: 0
Location: https://localhost:5001/api/orders/372459c7-6e16-4276-b286-f341d7009c43

如您所见,响应包含 201 Created 标头。它还提供了资源的 Location。这类信息对客户端很有用,我们可以获取更多关于资源的信息。

CreateAtAction 方法接受三个参数:

  • actionName 表示用于生成 URL 的操作名称。

  • routeValues 是一个包含动作所有参数的对象。

  • value 是一个表示响应内容的对象。

CreateAtAction 的另一种选择是 CreateAtRoute,它接受 routeName 并生成与 CreateAtAction 相同的结果。

更新资源

Put操作处理资源的更新方式。我们应该记住,PUT动词的目的是完全替换特定的资源。因此,当我们使用PUT动词调用 API 时,所有资源字段都将被body payload替换。正如我们将在本章后面看到的那样,为了对实体的一些特定字段进行更精确的更新,最好使用PATCH动词。

在我们实施Put操作时,需要记住的另一件重要的事情是我们需要处理不存在的 Ids。API 通常在Put操作中使用两种不同的方法来管理不存在的 Ids:

  • 当客户端对一个不存在的 ID 发起更新请求时,API 会使用该资源创建一个新的记录。

  • 当客户端对一个不存在的 ID 发起更新请求时,API 会通过一个404 Not Found错误通知客户端该资源不存在。

我并不是CreateOrUpdate方法的强烈支持者。我更喜欢让Put操作符合单一职责原则。因此,将创建更新操作分别放入两个不同的操作方法中。

让我们看看我们如何实现我们的Put操作:

...
        [HttpPut("{id:guid}")]
        public IActionResult Put(Guid id, Order request)
        {
            var order = _orderRepository.Get(id);

           if (order == null) 
 return NotFound(new { Message = $"Item with id {id} not exist." }); 

            order.ItemsIds = request.ItemsIds;

            _orderRepository.Update(id, order);
            return Ok();
        }
...

第一步是检查是否存在具有相应id的订单。如果不存在,应用程序将返回一个404 Not Found错误。否则,它将执行更新操作并返回200 Ok

注意,NotFound结果还包含一条消息。在实际应用中,这条消息通常与一段代码相关联,即自定义错误,并将其序列化为 JSON 或 XML 格式。

考虑到请求中itemsIds包含null的情况也是非常重要的。理解null请求对象和空请求对象之间的区别是至关重要的。在前一种情况下,客户端可能意外地调用我们的 API 而没有传递任何值。在后一种情况下,客户端明确请求我们的资源被空值替换。让我们修改我们的代码,通过在Put方法的第一个语句中添加以下保护措施来避免null请求值:

...
  [HttpPut("{id:guid}")]
  public IActionResult Put(Guid id, Order request)
        {
            if (request.ItemsIds == null) 
 return BadRequest();
...

前面的if语句会在ItemsIds字段为null时返回一个BadRequest消息。因此,发送请求的客户端现在会知道问题的来源。在下一节中,我们将发现 RESTful Web 服务中实施的一种广泛使用的更新技术。

部分更新

Put操作用于用另一个资源替换资源。因此,客户端必须在请求的body payload中添加整个实体。如果我们的实体是一个复杂对象,将整个实体保留在内存中可能会导致性能问题。通过实现Patch操作可以避免这些问题。Patch操作通常在不替换资源的情况下修改现有的资源,因此您只能指定要更新的字段之一。让我们看看我们如何在 ASP.NET Core 中执行这种类型的操作。

首先,让我们在我们的 Order.cs 类中添加一个新字段:

using System;
using System.Collections.Generic;

namespace SampleAPI.Models
 {
  public class Order
     {
         public Guid Id { get; set; }
         public IEnumerable<string> ItemsIds { get; set; }
         public string Currency { get; set; }
     }
 }

现在,Order 类包含了一个表示我们订单货币的额外字段。让我们在我们的 OrderController 中创建一个 Patch 动作。我们将要实现的代码使用了两个 NuGet 包,这些包提供了对 PATCH 方法的支持以及所有帮助我们执行与该类型 HTTP 动词相关的操作的类型。我们可以通过在 SampleAPI 项目文件夹中运行以下指令将包添加到我们的项目中:

dotnet add package Microsoft.AspNetCore.JsonPatch
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson 

第一个 NuGet 包提供了 JsonPatchDocument 类类型,第二个包启用了 PATCH 操作支持所需的 NewtonsoftJson 序列化器。此外,我们还应该在 Startup 类中添加以下扩展方法来将 NewtonsoftJson 序列化器启用到应用程序中:

public class Startup
{
    ...

    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddSingleton<IOrderRepository, MemoryOrderRepository>()
            .AddControllers()
            .AddNewtonsoftJson();
    }

此外,还可以以下方式实现 Patch 动作方法:

[HttpPatch("{id:guid}")] 
public IActionResult Patch(Guid id, JsonPatchDocument<Order> requestOp)
{
    var order = _orderRepository.Get(id);
    if (order == null)
    {
        return NotFound(new { Message = $"Item with id {id} not exist." });
    }

    requestOp.ApplyTo(order);
    _orderRepository.Update(id, order);

    return Ok();
}

上述代码有三个关键点:

  • 动作方法响应 HttpPatch 动词请求。就像 Put 动作一样,它接受一个 Guid 作为输入参数,用于标识目标资源。

  • 动作方法还接受一个 JsonPatchDocument 作为请求体的有效载荷。JsonPatchDocument 类是一个泛型类,它是 ASP.NET Core 框架的一部分。更具体地说,我们的动作使用 JsonPatchDocument<Order> 类型来对 Order 类执行操作。

  • 动作方法使用 ApplyTo 方法应用 JsonPatchDocument 类,该方法将请求中的更改合并到我们的目标资源中。最后,它更新了存储库。

JsonPatchDocument 类接受特定的请求模式。例如,以下 curl 操作通过 PATCH 动词执行部分更新:

curl -X PATCH \
 https://localhost:5001/api/order/5749c944-239c-4c0c-8549-2232cf585473 \
 -H 'Content-Type: application/json' \
 -d '[
 {
 "op": "replace", "path": "/itemsIds", "value" : [1,2]
 }
]' -k

在这种情况下,请求体有效载荷是一个对象的 JSON 数组:每个对象由一个 op 字段、一个 path 和一个 value 组成。

op 字段描述了对目标要执行的操作,path 指的是我们字段的名称,而 value 是目标的替换值。在这种情况下,请求将用值 [1,2] 替换 itemsIds 字段。此外,op 字段接受对数据的一组操作,包括 addremove。正如我们之前看到的,语法几乎与上一个示例相同:

[{
    "op": "add", "path": "/itemsIds", "value" : [3]
},
{
    "op": "remove", "path": "/itemsIds"
}]

JsonPatchDocument 符合 互联网工程任务组IETF)的规范,这是一个推广互联网标准的组织。您可以在以下链接的标准声明中找到有关 Patch 文档语法的更多信息:tools.ietf.org/html/rfc5789。本章中讨论的所有其他关于 HTTP 方法的规范也可以在这里找到。

在使用 JsonPatchDocument 对象时,你应该注意。客户端可能请求更新只读字段或不存在字段。此外,JsonPatchDocument 类型需要一个验证步骤。这个问题通常通过为这种请求创建一个自定义的数据传输对象DTO)模型来解决。

让我们继续到下一个子节,它描述了资源的删除过程以及如何在控制器类中实现产生的动作方法。

删除资源

Delete 动作方法被标记为 HttpDelete 属性。它通常接受要删除的资源标识符。

在现实世界的应用中,Delete 动作方法不会在数据库上执行物理删除操作。它们实际上执行更新操作。现实世界的应用和网络服务通常执行软删除而不是物理删除,因为这对于跟踪我们服务存储的历史数据和信息至关重要。如果我们考虑一个电子商务应用,例如,跟踪我们系统分发的订单就至关重要。软删除通常使用一个 isDeleted 标志或表示我们资源状态的对象来实现。我们 API 中实现的所有其他路由都应该实现逻辑来过滤掉所有处于删除状态的资源。例如,一个 Get 路由应该在将它们呈现给客户端之前排除所有已删除资源。

在我们的情况下,我们将在 IOrderRepository 上实现一个真正的删除操作。Delete 动作方法通常返回一个 204 No Content 错误以确认删除,或者如果客户端传递一个不存在的标识符,则返回一个 404 Not Found 错误:

[HttpDelete("{id:guid}")]
public IActionResult Delete(Guid id)
{
    var order = _orderRepository.Get(id);

    if (order == null)
    {
        return NotFound(new { Message = $"Item with id {id} not exist." });
    }

    _orderRepository.Delete(id);
    return NoContent();
}

实现从 IOrderRepository 接口获取具有相应 id 的资源。它通过检查资源是否为 null 来继续,在这种情况下,它将产生一个未找到错误。如果订单实体存在于数据源中,它将继续删除过程,并返回一个无内容结果。现在,让我们通过查看使用网络服务的异步过程来继续。

异步处理和接受状态

有时,我们对资源的操作不会立即应用。考虑一个电子商务网站上的订单:它需要时间来分发并发送到我们的存储系统。因此,数据被放入队列或类似的东西,并尽快处理。

这类异步处理结构也可能存在于一个网络服务中。当这些过程启动时,我们还需要表示它们并与客户端进行通信。

通常,这类过程具有以下工作流程:

之前的架构展示了客户端如何与异步过程交互:

  1. 客户端向我们的服务发送 POST 请求。POST 请求触发异步操作,更新并添加订单到我们的存储中。

  2. 动作方法从客户端接收请求,如果请求结构符合预期,则触发异步过程并向客户端返回 202 已接受。否则,它返回 403 请求错误202 接受 代码表示异步操作正在进行,但尚未完成。

  3. 客户端不知道异步过程何时结束,但一旦信息更新,它就有责任调用我们的服务。通常与包含一些信息(如估计时间或进程代码)的 JSON 消息一起使用的 202 已接受 消息。

注意,本书中出现的所有示例 URI 都不包含动词。例如,前面的实现描述了生成新订单的过程。如您所见,URI 是 <hostname>/api/orderrequest,唯一包含动词的元素是 HTTP 方法,即 POST。我们不应该在 URI 中使用动词,因为它们用于标识资源,因此必须是名词。

我们 Web 服务堆栈之旅的下一步是了解我们如何将请求和响应对象与我们的应用程序使用的核心实体解耦。当 Web 服务的复杂性增加时,这种方法变得非常有用。

数据传输对象

在上一节中,我们探讨了如何使用控制器管理 HTTP 请求和响应。在本节中,我们将探讨如何管理请求和响应中的复杂对象。本节更侧重于 MVC 模式中的 M 部分。首先,让我们区分通常存在于 Web 服务中的三种不同类型的模型:

  • 领域模型描述了我们 Web 服务中的实体和资源。它通常反映了我们的数据源模式,并且非常接近应用的底层和业务逻辑。

  • 请求模型是请求模型的表示。我们控制器中的每个动作方法通常都有自己的请求模型。正如我们将在本章后面看到的那样,这也是与请求验证关联的模型。

  • 响应模型是 Web 服务的视图模型或表示模型。它表示请求的响应,通常每个动作方法都有一个响应模型

下面的工作流程图展示了我们应用程序中的模型是如何布局的:

图片

如您所见,请求模型是我们服务的最前端;它表示请求。领域模型用于描述我们存储的数据。最后,响应模型将数据呈现给客户端。请求和响应模型也被定义为数据传输对象DTOs)。它们根本不是模型类;它们在客户端和服务器之间传输信息。

实现请求模型

现在,让我们看看一些 DTOs 的实际实现。Order实体描述了一些接受List<string>作为输入的操作。假设实现变得越来越复杂,我们的服务需要存储有关我们订单的额外信息:

using System;
using System.Collections.Generic;

namespace SampleAPI.Models
 {
  public class Order
     {
         public Guid Id { get; set; }
         public IEnumerable<string> ItemsIds { get; set; }
         public string Currency { get; set; }
     }
 }

领域模型是我们应用程序的关键部分。将其与请求/响应模型分离至关重要。因此,我们应该避免将领域模型请求响应模型紧密耦合。让我们从实现OrderRequest类开始,它将代表创建请求模型。它将被Post动作方法用于创建新实体:

using System.Collections.Generic;

namespace SampleAPI.Requests
{
    public class OrderRequest
    {
        public IEnumerable<string> ItemsIds { get; set; }

        public string Currency { get; set; }
    }
}

OrderRequest类包含与Order 领域模型相同的字段,除了Id字段。这是因为 API 的客户端不应该插入Id信息。请求模型使我们的文件系统在单独的文件夹中。在这种情况下,正如您从命名空间中可以看到的,OrderRequest存储在Requests文件夹中。我们的OrderController可以使用OrderRequest如下:

     [Route("api/order")]
     [ApiController]
     public class OrderController : ControllerBase
     {

         ...

        [HttpPost]
        public IActionResult Post(OrderRequest request)
        {
            var order = Map(request);

            _orderRepository.Add(order);

            return CreatedAtAction(nameof(GetById), new { id = order.Id }, 
            null);
        }

        ...

 private Order Map(OrderRequest request)
 {

 return new Order
 {
 Id = Guid.NewGuid(),
 ItemsIds = request.ItemsIds,
 Currency = request.Currency
 };
 }
         ...

Post动作方法接受一个OrderRequest类型的参数,这是我们想要创建的订单请求的表示。为了将传入的数据与领域模型关联起来,我们应该创建一个Map方法,该方法初始化一个新的领域模型,并用请求模型填充。此外,我们还应该创建一个新的Order对象实例,并用请求的数据填充,然后与IOrderRepository结合。同样的概念也可以应用于我们控制器的Put动作方法:

         ...

         [HttpPut("{id:guid}")]
         public IActionResult Put(Guid id, OrderRequest request)
         {
             var order = _orderRepository.Get(id);

             if (order == null)
             {
                 return NotFound(new { Message = $"Item with id {id} not exist." });
             }

             order = Map(request, order);

             _orderRepository.Update(id, order);

             return Ok();
         }

 private Order Map(OrderRequest request, Order order)
         {
             order.ItemsIds = request.ItemsIds;
             order.Currency = request.Currency;

             return order;
         } 
         ...

在这种情况下,我们创建一个新的Map方法,它接受两个输入参数:OrderRequestOrder,然后我们可以通过将请求对象的每个属性分配给订单来继续操作。

实现响应模型

如我们之前提到的,我们 API 的另一个关键部分是响应模型。响应模型类在领域模型客户端之间充当过滤器。例如,让我们考虑领域模型中的一个特定字段,出于某种原因,它必须不包含在我们的响应中。响应模型帮助我们处理这类情况。

假设我们需要在我们的 API 中实现软删除。正如我们之前提到的,软删除是一种标记记录以进行删除或暂时防止其被选择的方法。要在MemoryOrderRepository上执行软删除,我们应该添加一个IsInactive标志,该标志将目标记录标记为已删除:

public class Order
{
     public Guid Id { get; set; }

     public IEnumerable<string> ItemsIds { get; set; }

     public string Currency;

     public bool IsInactive { get; set; }
 }

IsInactive 标志表示 Order 是否处于非活动状态。为了完成 软删除 的实现,我们应该更改 MemoryOrderRepository。现在它应该使用 IsInactive 标志来取消订单。

此外,仓库内的 Get 方法应该通过排除非活动订单来过滤所有记录:

...
public class MemoryOrderRepository : IOrderRepository
 {

        public IEnumerable<Order> Get() => _orders.Where(o => !o.IsInactive).ToList();

        public Order Get(Guid orderId)
        {
            return _orders
                .Where(o => !o.IsInactive)
                .FirstOrDefault(o => o.Id == orderId);
        }

        public Order Delete(Guid orderId)
        {
            var target = _orders.FirstOrDefault(o => o.Id == orderId);

            target.IsInactive = true;
            Update(orderId, target);

            return target;
        }
...

这些更改消除了我们 API 响应中的非活动订单。由于 IsInactive 标志在我们的 API 响应中是隐含的,我们不需要在响应 JSON 中序列化 IsInactive 标志。因此,我们可以通过添加一个名为 OrderResponse 的新响应类来解耦 Get 操作方法的响应和我们的领域模型。它可以按以下方式定义:

using System;
using System.Collections.Generic;

namespace SampleAPI
{
    public class OrderResponse
    {
        public Guid Id { get; set; }
        public IEnumerable<string> ItemsIds { get; set; }
        public string Currency { get; set; }
    }
}

如您所见,OrderResponse 类模型公开了除了 IsInactive 标志之外的所有字段。在这个时候,我们可以通过编辑 OrderController 类来继续操作,使其以以下方式将 Order 实体映射到 OrderResponse 模型:

 ...

 public class OrderController : ControllerBase
 {

     ...

     [HttpGet]
     public IActionResult Get()
     {

         return Ok(Map(_orderRepository.Get()));
     }

     [HttpGet("{id:guid}")]
     public IActionResult GetById(Guid id)
     {
         return Ok(Map(_orderRepository.Get(id)));
     }

    ...

     private IEnumerable<OrderResponse> Map(IEnumerbale<Order> orders)
 {
 return orders.Select(Map).ToList();
 }

 private OrderResponse Map(Order order)
 {
 return new OrderResponse
 {
 Id = order.Id,
 ItemsIds = order.ItemsIds,
 Currency = order.Currency
 };
 }
 } 

这种方法允许我们将领域模型与我们的 API 响应解耦。Map 方法是我们决定应该显示哪些字段的地方。

实现请求验证

我们现在可以创建专门的数据类型来表示我们的请求,但我们也应该考虑添加验证。如果我们希望防止数据混乱和可能出现的异常,验证是非常重要的。ASP.NET Core 提供了一种现成的方式,让我们在我们的控制器和服务中实现验证:System.ComponentModel.DataAnnotations。该命名空间提供了一组属性,可以用来描述模型字段的验证。例如,考虑以下代码片段:

using System.Collections.Generic;

namespace SampleAPI.Requests
{
    public class OrderRequest
    {
        public IEnumerable<string> ItemsIds { get; set; }
        public string Currency { get; set; }
    }
}

在这种情况下,[Required] 属性指定 ItemsIdsCurrency 属性都不应该是 null 或空;否则,API 将返回以下验证消息:

{
    "ItemsIds": [
        "The ItemsIds field is required."
    ]
}

通过简单地阅读模型,我们可以理解为模型定义的约束,因此这种方法提高了我们代码的可读性和可维护性。此外,ASP.NET Core 提供了几个流行的内置验证属性:[EmailAddress][StringLength][Url][CreditCard][RegularExpression]

Currency 字段中,我们可以添加以下约束:

   public class OrderRequest
     {
         [Required]
         public IEnumerbale<string> ItemsIds { get; set; }
         [Required]
 [StringLength(3)]
         public string Currency {get; set;} 
     }

在这种情况下,Currency 属性将包含货币代码,例如 EUR 或 USD。我们可以添加一个限制,以限制字段的长度,使其限于三个字符。

自定义验证属性

ASP.NET Core 提供了一种通过扩展 ValidationAttribute 来创建我们请求的自定义验证的方法,这意味着我们可以为我们的类型创建自定义验证器。让我们为我们的 Currency 属性创建一个更合适的验证:

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;

namespace SampleAPI.Requests
{
    public class CurrencyAttribute : ValidationAttribute
    {
        private readonly IList<string> _acceptedCurrencyCodes = 
        new List<string>{
            "EUR",
            "USD",
            "GBP"
        };

        protected override ValidationResult IsValid(object value, 
        ValidationContext validationContext)
        {
            return _acceptedCurrencyCodes.Any(c => c == value.ToString()) ?
                ValidationResult.Success 
                : new ValidationResult($"{validationContext.MemberName} is 
                not an accepted currency");
        }
    }
}

前面的实现将请求模型货币与_acceptedCurrencyCodes列表进行匹配。如果匹配成功,则返回ValidationResult.Success;否则,返回一个包含验证信息的新的验证结果。MemberName属性提供了与自定义验证属性关联的属性的名称。在实现涉及第三方服务或对验证主题进行聚合操作的更复杂验证时,创建自定义验证属性可能很有用。

摘要

在本章中,我们看到了如何使用 ASP.NET Core 构建一个 Web 服务堆栈。我们还探讨了如何在存储库上实现 CRUD 操作,以及如何处理数据传输对象和验证。本章提供了一个起点和构建一个非常简单的 ASP.NET Core Web 服务所需的知识,包括如何处理请求、如何使用模型绑定以及如何验证请求。

在下一章中,我们将通过探索如何扩展和自定义它来更详细地介绍 ASP.NET Core 的路由系统。

第六章:路由系统

本章介绍了 ASP.NET Core 的路由功能。框架的路由部分旨在提供一个完全可定制和可覆盖大多数网络服务用例的可动态路由系统。在这里,我们将了解如何使用传统属性路由方法,然后我们将深入研究使用路由约束来匹配复杂规则和提供更高定制的使用方法。

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

  • 路由系统简要概述

  • 传统路由与属性路由的比较

  • 绑定路由参数

  • 路由约束

  • 自定义属性路由和自定义路由约束

本章涵盖的主题为 ASP.NET Core 的路由系统提供了一些基本知识,以及如何使用 ASP.NET Core 的路由引擎来覆盖我们网络服务所需的所有用例。

路由系统概述

ASP.NET Core 的路由系统将传入请求映射到一个路由处理程序。在 ASP.NET Core 中,Startup 类负责配置应用程序需要的路由。此外,ASP.NET Core 的路由功能是通过中间件方法实现的。让我们更详细地看看 Startup 类以及它是如何初始化路由系统的:

public class Startup
{
    ...

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseRouting();
        ...
        app.UseEndpoints(endpoints =>
 {
 endpoints.MapControllers();
 });
    }
}

上述代码使用了两个扩展方法:UseRoutingUseEndpoints。这些方法是在 ASP.NET Core 的最新版本中引入的。在框架的先前版本中,路由系统是通过 UseMvc 扩展方法初始化的,现在已弃用。UseRouting 扩展方法用于定义在中间件管道中路由决策的位置。另一方面,UseEndpoints 扩展方法声明了有效路由的映射。例如,在前面的代码片段中,Startup 类使用 MapControllers() 扩展方法映射控制器路由并声明了 ASP.NET Core 实现的默认路由约定。

总结来说,当 Startup 类执行 UseRoutingUseEndpoints 扩展方法时,ASP.NET Core 在管道中添加了一个新的 EndpointRoutingMiddleware 类来标记路由点,以及 EndpointMiddleware 来描述我们的路由。前面的调用可以总结如下:

图片

此外,我们还可以使用以下语法在 Startup 类内部定义新的路由模板

...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute("default", "{controller}/{action}/{id?}");
    });
}
...

此实现创建了一个新的路由模板,将一个通用的路由,如https://myhostname/mycontroller/myaction,映射到一个名为MyController的控制器和一个名为MyAction的操作。这种定义路由的方式被称为传统路由,在这种意义上,它在我们处理程序(控制器)和 URI 系统之间建立了一种约定。我们将在下一节中更详细地讨论传统路由。

传统路由

传统 路由是 ASP.NET Core 中的默认路由方法. 如我们所见,这种方法使用Startup类中的app.UseEndpoints扩展方法来声明路由模板:

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute("default", "{controller}/{action}/{id?}");
    });

默认情况下,ASP.NET Core 将default 路由模板应用于路由引擎,将我们的 URL 的每个段映射到controller名称、action名称和id名称,分别对应. 此外,我们可以在Startup类中的路由构建器中添加多个路由,以在我们的应用程序中定义多个路由:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute("default", "{controller}/{action}/{id?}");

    endpoints.MapControllerRoute("order", "order/givemeorders", new { controller = "Order", action = "Get" });
});

在这种情况下,https://myhostname/order/givemeorders路由将被映射到OrderControllerGet操作。我们应该注意到,我们在前面代码中定义的路由模板示例不符合 REST 架构风格。因此,它不尊重理查森成熟度模型的第 2 级,如第一章中提到的,REST 101 和 ASP.NET Core 入门。此外,如果我们将default路由模板应用于前面章节中讨论的OrderControllerGet操作方法将响应以下 URI:https://localhost/order/get

为了使我们的路由模板符合理查森成熟度模型,让我们引入 ASP.NET Core 提供的Map方法。可以使用路由模板映射不同的 HTTP 动词,如下所示:

app.UseEndpoints(endpoints =>
{
 endpoints.MapGet("order", context => context.Response.WriteAsync("Hi, from GET verb!"));
 endpoints.MapPost("order", context => context.Response.WriteAsync("Hi, from POST verb!"));
});

MapGetMapPostMapPutMapDelete方法接受路由模板作为第一个参数和一个RequestDelegate方法,它提供了一种处理当前请求的HttpContext的方式。然而,在RequestDelegate中调用OrderController逻辑是不可能的,因为没有一种轻松访问控制器实例的方法。因此,没有简单的方法来实现符合 REST 的路由系统。一般来说,传统路由主要设计用于提供视图和 HTML 的 Web 应用程序。一个替代方案是使用属性路由*技术,这是在 Web 服务环境中实现控制器路由的最可靠方式。

属性路由

属性路由技术是另一种在 ASP.NET Core 中实现路由的方法。它通过使用属性以元编程方式描述路由,将路由的声明移动到控制器实现内部:

 [Route("api/order")]
     [ApiController]
     public class OrderController : ControllerBase
     {
         private readonly IOrderRepository _orderRepository;

         public OrderController(IOrderRepository orderRepository)
         {
             _orderRepository = orderRepository;

         } 
     ...

Route属性在控制器或操作中声明路由模板。路由属性是 ASP.NET Core 中 Web API 模板的默认方法。另一个需要注意的关键点是,这种做法不需要在Startup类中定义任何路由定义;因此,app.MapControllers()在没有路由参数的情况下被调用。

此外,这种方法在将每个操作方法绑定到特定路由时也提供了更多的灵活性:

    [Route("api/order")]
    [ApiController]
    public class OrderController : ControllerBase
    {
        [HttpGet]
        public IActionResult Get() {
            ...
        }

        [HttpGet("{id:guid}")]
        public IActionResult GetById(Guid id) {
            ...
        }

        [HttpPost]
        public IActionResult Post(OrderRequest request) {
            ...
        }

        [HttpPut("{id:guid}")]
        public IActionResult Put(Guid id, OrderRequest request) {
            ...
        }

        [HttpPatch("{id:guid}")]
        public IActionResult Patch(Guid id, JsonPatchDocument<Order> 
        requestOp) {
            ...
        }

        [HttpDelete("{id:guid}")]
        public IActionResult Delete(Guid id) {
            ...
        }
    }

前面的控制器使用 HttpVerb 将每个操作方法映射到特定的 HTTP 动词。此外,它还使用 HttpVerb 属性来定义 URI 的最后一个段,这通常包含我们目标资源的参数。

Route("api/order") 属性定义了一个静态路由段。ASP.NET Core 提供了一些保留占位符,即 [controller][action][area],它们在运行时会被相应的 controlleractionarea 替换。例如,我们可以通过使用 Route("api/[controller]") 来达到相同的结果,因为 OrderController 名称将替换 [controller] 占位符。正如我在上一章中提到的,我强烈建议您避免这种做法,因为在实际应用中,您可能仅仅通过重构控制器名称就意外地更改了 API 的路由。

接下来,让我们看看自定义属性路由。

自定义属性路由

ASP.NET Core 的路由引擎还提供了一种方法,让我们创建自己的路由属性。这种技术在复杂的路由系统中非常有用,因为它对于保持不同路由之间的概念顺序至关重要。一个自定义路由定义的例子如下:

using System;
using Microsoft.AspNetCore.Mvc.Routing;

namespace SampleAPI.CustomRouting
{
    public class CustomOrdersRoute : Attribute, IRouteTemplateProvider
    {
        public string Template => "api/orders";

        public int? Order { get; set; }

        public string Name => "Orders_route";
    }
}

该类扩展了 Attribute 抽象类,它将被用作属性。它实现了 IRouteTemplateProvider 接口以获取 路由模板工作流程 的属性。因此,属性的运用如下所示:

[CustomOrdersRoute]
[ApiController]
public class OrderController : ControllerBase
{
    ...

当我们想要实现更复杂的路由系统时,这种方法非常有用。因此,我们可以应用诸如继承等概念来提高实现的路由规则的复用性。

本节提供了对 ASP.NET Core 不同路由方法的理解:传统路由属性路由。在下一节中,我们将了解如何使用框架提供的 路由约束 规则。

路由约束

路由约束 是 ASP.NET Core 模板路由系统的一部分。它们提供了一种方式,让我们可以匹配一个参数类型或一组值的路由,如下所示:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute("default", "{controller}/{action}/{id:guid?}");
});

在这个例子中,我们的路由模板将匹配所有 https://myhostname/mycontroller/myaction 调用以及所有呈现有效 Guid 作为 id 参数的调用,例如,https://myhostname/mycontroller/myaction/4e10de48-9590-4531-9805-799167d58a44{id:guid?} 表达式给我们提供了关于约束的两条信息:首先,参数必须具有 guid 类型,其次,它使用 ? 字符指定为可选。

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute("default", "{controller}/{action}/{id:int:min(1)}");
});

在此情况下,我们将 int 约束与 min(1) 约束相结合。因此,我们可以覆盖大量用例和业务规则。此外,我们可以通过为接收不同类型数据的同一 URI 提供不同的操作方法来改进我们的路由匹配逻辑。值得注意的是,相同的 路由约束 也可以应用于属性路由部分:

    [Route("api/mycontroller")]
    [ApiController]
    public class MyControllerController : ControllerBase
    {
        [HttpGet({id:int:min(1)})]
        public IActionResult Get() {
            ...
    }

ASP.NET Core 提供了一组丰富的默认路由约束,可以直接使用。以下链接列出了 ASP.NET Core 的所有附加默认路由约束:docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-3.0#route-constraint-reference

自定义约束

如果默认约束无法覆盖应用程序的所有业务规则,ASP.NET Core 提供了所有必要的组件来扩展路由约束的行为,以便您可以定义自己的规则。可以通过实现 Microsoft.AspNetCore.Routing 提供的 IRouteConstraint 接口来扩展路由约束,如下所示:

using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace SampleAPI.CustomRouting
{
    public class CurrencyConstraint : IRouteConstraint
    {
        private static readonly IList<string> _currencies =  
            new List<string> { "EUR", "USD", "GBP" };

        public bool Match(HttpContext httpContext, IRouter route,
            string routeKey, RouteValueDictionary values,
            RouteDirection routeDirection)
        {
            return  _currencies.Contains(values[routeKey]?.ToString().ToLowerInvariant());
        }
    }
}

上一段代码展示了 IRouteConstraint 实现的示例。该接口公开了 Match 方法,它允许我们将传入的路由值与一组自定义值进行匹配。在这种情况下,约束匹配一组货币。为了使用 CurrencyConstraint,必须在 ConfigureServices 方法中对其进行配置:

public void ConfigureServices(IServiceCollection services)
{
   ...
    services.Configure<RouteOptions>(options => {
 options.ConstraintMap.Add("currency", typeof(CurrencyConstraint));
 });
    ...
}

可以使用常规语法使用自定义的 CurrencyConstraint 路由约束:

endpoints.MapControllerRoute("default", "{controller}/{action}/{currency}");

在这种情况下,default 路由将仅匹配使用 CurrencyConstraint 类中实现的逻辑的参数。因此,它将匹配 https://localhost/controller/action/eurhttps://localhost/controller/action/usdhttps://localhost/controller/action/gbp URI。

摘要

ASP.NET Core 路由系统可以扩展并用于覆盖大量用例。通常,它提供所有必要的功能。重要的是要理解,传统路由 通常用于 Web 应用程序,而 Web 服务路由通常是通过应用 属性路由 来实现的。本章介绍了如何处理这两种方法,如何使用 ASP.NET Core 提供的内置约束,以及如何实现我们自己的路由约束。在下一章中,我们将探讨如何处理 ASP.NET Core 的过滤器管道以及它们与中间件类实现的区别。

第七章:过滤器管道

在本章中,我们将介绍 ASP.NET Core 中的另一个重要主题:过滤器管道。过滤器是我们可以在服务中实现横切实现的一个关键组件。尽管我们已经看到了如何使用中间件管道实现横切功能,但过滤器是更专业的组件,它们与 MVC 管道相关联。因此,过滤器可以用来实现更具体的逻辑,与控制器的执行相关。本章将向您展示如何实现这些过滤器,并描述一些具体用例。

本章将涵盖以下主题:

  • ASP.NET Core 中过滤器简介

  • 如何实现和应用过滤器到过滤器管道

  • 过滤器的具体用例

  • 如何在过滤器管道中使用解析依赖项

本章提供了有关.NET Core 中过滤器堆栈的信息以及如何使用过滤器来增强我们 Web 服务的功能。

过滤器简介

当我们希望在 ASP.NET Core 的 MVC 堆栈中构建横切概念时,过滤器非常有用。当我们需要实现如授权或缓存等功能时,它们非常有用。ASP.NET Core 提供了一些现成的过滤器类型。每个都可以在我们的服务中用于特定目的:

过滤器类型 类型描述
授权 这种过滤器与用户的授权相关。它是过滤器管道中执行的第一个过滤器,并且可以短路请求管道。
资源 资源过滤器在授权过滤器之后以及管道中的其余部分完成后立即运行。当我们需要实现缓存或性能实现时,它们非常有用。
动作 动作过滤器专注于动作方法的生命周期。它们拦截并更改动作方法的参数和返回结果。
异常 异常过滤器用于拦截并应用跨切面策略到未处理的异常。
结果 结果过滤器在动作结果执行之前和之后立即执行。它们通常被实现来更改结果的格式。重要的是要注意,它们仅在动作方法成功完成后才会执行。

重要的是要注意,过滤器在 MVC 中间件的领域中起作用,这意味着动作过滤器无法在 MVC 上下文之外操作。因此,过滤器比中间件更具体;它们可以应用于请求的子集,并且可以访问一些 MVC 组件,例如,ModelState

以下图显示了不同类型的动作过滤器在请求-响应工作流程中的工作流程:

如您所见,在请求-响应管道中,不同类型的过滤器在各个阶段发挥作用。授权过滤器在所有其他操作之前执行,并在任何权限错误的情况下阻止请求。资源过滤器在请求的模型验证和模型绑定之前以及我们的请求结果从服务器返回时操作。操作过滤器类型在操作调用之前和之后执行。此外,如果操作抛出异常,则触发异常过滤器。在管道的末尾,结果过滤器作用于IActionResult最终对象实例。现在我们了解了 ASP.NET Core 提供的不同过滤器类型,我们将查看一些具体的实现示例。

具体过滤器实现

通常,可以通过扩展 ASP.NET Core 提供的内置类型来实现过滤器。让我们通过一个简单的自定义操作过滤器声明来了解一下:

using Microsoft.AspNetCore.Mvc.Filters;

namespace SampleAPI.Filters
{
        public class CustomActionFilter : IActionFilter
        {
            public void OnActionExecuting(ActionExecutingContext context)
            {
                // do something before the action executes
            }

            public void OnActionExecuted(ActionExecutedContext context)
            {
                // do something after the action executes
            }
        }
}

CustomActionFilter类实现了IActionFilter接口类型,该接口提供了两种不同的方法:

  • OnActionExecuting方法在操作执行之前触发。

  • OnActionExecuted方法在操作之后执行。

两种方法都将执行上下文作为回调参数,该参数提供有关过滤器堆栈元数据控制器操作参数路由数据的一些有用信息。此外,上下文参数还提供了访问我们在前几章中查看的HttpContext属性。HttpContext提供了所有必要的属性,以便我们可以访问依赖注入服务和请求/响应数据。ASP.NET Core 大量使用异步堆栈。这意味着它还提供了一个接口,我们可以用它来实现异步过滤器。

异步过滤器

过滤器支持异步和同步行为。正如我们在前面的示例中所见,CustomActionFilter实现了两个同步方法:OnActionExecutingOnActionExecuted。对于异步过滤器,实现方式不同:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Filters;

namespace SampleAPI.Filters
{
    public class CustomActionFilterAsync : IAsyncActionFilter
    {
        public async Task OnActionExecutionAsync(ActionExecutingContext 
         context, ActionExecutionDelegate next)
        {
            //Before 

            var resultContext = await next();

            //After
        }
    }
}

CustomActionFilterAsync实现了操作过滤器类的异步版本,即IAsyncActionFilter,并且只实现了一个方法,即OnActionExecutionAsync。这个模板与中间件实现类似;next()方法触发管道其余部分的执行。因此,在await next()语句之前执行的所有内容都在操作过滤器的执行之前执行,而在await next()语句之后执行的所有内容都在操作执行之后执行。重要的是要注意,框架首先搜索异步实现,然后搜索同步实现。

过滤器的作用域

过滤器根据我们在代码库中的初始化继承不同的作用域。在实践中,过滤器可能有三个不同的作用域:

  • 全局作用域:这意味着过滤器覆盖了整个 MVC 管道。每次对特定 MVC 路由的调用都会通过该过滤器。

  • 控制器作用域:在这种情况下,过滤器被初始化为单个或多个控制器类中的一个属性。它只会在请求被定向到目标控制器时起作用。

  • 操作作用域:过滤器被初始化为单个或多个操作方法中的一个属性。它只会在请求被定向到目标方法时起作用。

同样重要的是要理解,根据作用域,过滤器会按照特定的顺序执行:首先,所有全局作用域过滤器运行,然后是控制器作用域过滤器,最后是操作作用域过滤器。在下一小节中,我们将更详细地探讨不同作用域的实现。

过滤器的使用

如我们之前提到的,过滤器可以有三种不同的作用域:全局控制器操作。在第一种情况下,过滤器在Startup类中全局应用。在其他两种情况下,过滤器以属性的形式使用,通常应用于控制器类定义或操作方法定义。让我们更详细地看看这些不同的方法。在Startup类中应用过滤器意味着过滤器覆盖了 MVC 管道中的所有路由,如下所示:

    public class Startup
    {
        ...

        public void ConfigureServices(IServiceCollection services)
        {
            services
                .AddControllers(config => config.Filters.Add(new 
                 CustomFilter()));
        }

        ...
    }

在这种情况下,CustomFilter具有全局作用域config.Filters属性是一个IFilterMetadata接口的集合。该接口用于描述我们 MVC 管道中存在的过滤器。需要注意的是,该集合不检查重复项,这意味着我们可能潜在地添加了两个相同类型的过滤器。

由于FilterCollection不考虑重复项,因此在大型的代码库中,一个过滤器类型可能会意外地初始化多次,这可能会影响我们服务的性能。在分布式团队中,注意代码合并至关重要。通过使用拉取请求和举行代码审查会议,可以避免这类静默问题。

另一方面,控制器作用域操作作用域仅限于特定的控制器或操作。在特定控制器或操作上使用过滤器的最佳方式是通过扩展过滤器属性。ASP.NET Core 提供了一些内置的过滤器属性。对于每种过滤器类型,框架提供了一个相应的类,该类提供了重写方法。例如,这是ActionFilterAttribute类型的情况:

using Microsoft.AspNetCore.Mvc.Filters;

namespace SampleAPI.Filters
{
        public class CustomControllerFilter : ActionFilterAttribute
        {
            public override void OnActionExecuting(ActionExecutingContext 
             context)
            {
                // do something before the action executes
            }

            public override void OnActionExecuted(ActionExecutedContext 
             context)
            {
                // do something after the action executes
            }
        }
}

CustomControllerFilter扩展了ActionFilterAttribute类型,它包含OnActionExecutingOnActionExecuted方法。可以使用属性的语法将过滤器应用于特定的控制器类或操作方法:

...
     [Route("api/order")]
     [CustomControllerFilter]
     public class OrderController : ControllerBase
     { 
...

在底层,ActionFilterAttribute 是一个抽象类,并实现了之前我们提到的 IActionFilter 接口类型。因此,通过快速查看 ActionFilterAttribute 类,我们可以假设这个抽象类也提供了 IAsyncActionFilterIResultFilterIAsyncResultFilter 方法:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public abstract class ActionFilterAttribute : 
Attribute, IActionFilter, IFilterMetadata, IAsyncActionFilter, IResultFilter, IAsyncResultFilter, IOrderedFilter
{
    ...
}

之前的代码片段描述了 ASP.NET Core 总是提供一种简单的方法来让我们自定义和扩展框架的行为。此外,用户对框架提供的代码和接口有完全的控制权。最终,我们可以轻松创建一个自定义抽象类型,该类型实现了过滤器的自定义行为,并且可以被其他具体的过滤器类扩展。

生命周期和依赖注入

正如我们之前所说的,依赖注入是 ASP.NET Core 的核心技术。过滤器通常依赖于其他组件来提供过滤器逻辑。在讨论在过滤器中注入依赖项之前,我们需要了解生命周期过程。一般来说,当我们将过滤器作为属性应用时,过滤器的生命周期被限制在请求范围内,这意味着它为每个请求重新初始化。ServiceFilter 属性提供了对这种行为的有效替代。因此,ServiceFilter 属性使用 服务提供者 来创建过滤器对象,这意味着我们的过滤器就像通过 ASP.NET Core 的依赖注入系统声明的任何其他服务一样被管理。

例如,让我们考虑之前定义的 CustomActionFilter 类的实现:

using Microsoft.AspNetCore.Mvc.Filters;

namespace SampleAPI.Filters
{
        public class CustomActionFilter : IActionFilter
        {
            public void OnActionExecuting(ActionExecutingContext context)
            {
                // do something before the action executes
            }

            public void OnActionExecuted(ActionExecutedContext context)
            {
                // do something after the action executes
            }
        }
}

可以使用 Startup 类中的 AddSingleton 扩展方法来初始化 CustomActionFilter 类型:

...
public void ConfigureServices(IServiceCollection services)
{
    services
        .AddSingleton<IOrderRepository, MemoryOrderRepository>()
        .AddSingleton<CustomActionFilter>()
 ...
}
...

然后,我们可以在我们的控制器或操作方法中使用它,如下所示:

...
[ServiceFilter(typeof(CustomActionFilter))]
public class OrderController : ControllerBase
{
...

这种方法通过在 服务提供者 中显式定义生命周期类型来保证我们覆盖了过滤器的生命周期。因此,过滤器管道是通过 ASP.NET Core 的依赖注入引擎进行集成和初始化的。此外,还可以使用依赖注入引擎解析过滤器依赖项。与过滤器相关的有两种注入技术:

  • 使用 ServiceFilter 技术方法

  • 使用 TypeFilterAttribute 技术方法

正如我们之前所看到的,ServiceFilter 类型将过滤器实例添加到服务提供者中。可以通过将其添加到构造函数中来将依赖项注入到过滤器中。我们可以通过 构造函数注入 来实现这一点:

using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;

namespace SampleAPI.Filters
{
    public class CustomActionFilter: IActionFilter
    {
        private readonly ILogger _logger;

        public CustomActionFilter(ILogger logger)
        {
            _logger = logger;
        }

        public void OnActionExecuting(ActionExecutingContext context)
        {
            _logger.LogInformation("Logging OnActionExecuting");
        }

        public void OnActionExecuted(ActionExecutedContext context)
        {
            _logger.LogInformation("Logging OnActionExecuted");
        }
}

之前的代码将 ILogger 接口注入到构造函数中,并使用暴露的 LogInformation 扩展方法。也可以通过使用 TypeFilterAttribute 并通过类型而不是实例来引用我们的过滤器来不通过 服务提供者 传递。我们通过声明另一个扩展 TypeFilterAttribute 的类来实现这一点,该类将我们的过滤器类型传递给基类:

public class CustomActionFilterAttribute : TypeFilterAttribute
{
    public CustomActionFilterAttribute() : base(typeof(CustomActionFilter))
    {
    }
}

然后将属性应用到目标控制器:

...
[CustomActionFilterAttribute]
public class OrderController : ControllerBase
{
...

CustomActionFilterAttribute类扩展了TypeFilterAttribute基类,并通过引用CustomActionFilter类型调用基构造函数。这种方法增强了依赖于其他类的过滤器属性的可用性。现在,我们已经完全理解了过滤器,并知道如何将它们应用到过滤器管道中,我们可以看看一些具体用例。

过滤器用例

本节将展示一些过滤器的具体用例。一般来说,每次您需要在动作或控制器中复制行为时,您都可以使用过滤器来集中逻辑。过滤器还提供了一种声明式方法,这有助于我们保持代码的整洁和可读性。

现有实体约束

控制器的动作方法通常对传入的数据执行约束。一种常见的做法是将这种逻辑集中到过滤器中。以我们在上一章中讨论的OrderController为例:

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Mvc;
using SampleAPI.Filters;
using SampleAPI.Models;
using SampleAPI.Repositories;
using SampleAPI.Requests;

namespace SampleAPI.Controllers
{
    [Route("api/order")]
    [ApiController]
    public class OrderController : ControllerBase
    {
        private readonly IOrderRepository _orderRepository;

        public OrderController(IOrderRepository ordersRepository)
        {
            _orderRepository = ordersRepository;
        }

        ...

        [HttpPut("{id:guid}")]
        [OrderExists]
        public IActionResult Put(Guid id, OrderRequest request)
        {
            if (request.ItemsIds == null)
            {
                return BadRequest();
            }

            var order = _orderRepository.Get(id);

 if (order == null)
 {
 return NotFound(new { Message = $"Item with id {id} 
                 not exist." });
 }

            order = Map(request, order);

            _orderRepository.Update(id, order);
            return Ok();
        }

        [HttpPatch("{id:guid}")]
        [OrderExists]
        public IActionResult Patch(Guid id, JsonPatchDocument<Order> 
         requestOp)
        {
            var order = _orderRepository.Get(id);

 if (order == null)
 {
 return NotFound(new { Message = $"Item with id {id} not 
                 exist." });
 }

            requestOp.ApplyTo(order);
            _orderRepository.Update(id, order);

            return Ok();
        }

        [HttpDelete("{id:guid}")]
        [OrderExists]
        public IActionResult Delete(Guid id)
        {
            var order = _orderRepository.Get(id);

 if (order == null)
 {
 return NotFound(new { Message = $"Item with id {id} not 
                 exist." });
 }

            _orderRepository.Delete(id);
            return NoContent();
        }

        ...
    }
}

五个动作方法中有三个通过调用_orderRepository执行相同的存在检查

var order = _orderRepository.Get(id);

 if (order == null)
 {
   return NotFound(new { Message = $"Item with id {id} not exist." });
 }

一种推荐的做法是将这种逻辑提取出来并放在其他地方,可能是一个动作过滤器,这样它就可以在动作方法中使用。它足够具体,只有在必要时才使用。让我们首先设置我们的过滤器并添加IOrderRepository的依赖项:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using SampleAPI.Repositories;

namespace SampleAPI.Filters
{
    public class OrderExistsAttribute : TypeFilterAttribute
    {
        public OrderExistsAttribute() : base(typeof
            (OrderExistsFilterImpl)) { }

        private class OrderExistsFilterImpl : IAsyncActionFilter
        {
            private readonly IOrderRepository _orderRepository;

            public OrderExistsFilterImpl(IOrderRepository orderRepository)
            {
                _orderRepository = orderRepository;
            }

            public async Task OnActionExecutionAsync(ActionExecutingContext 
            context, ActionExecutionDelegate next)
            {
                ...
            }
        }
    }
}

OrderExistsFilterImpl类为动作过滤器提供了基本设置。它接受IOrderRepository作为依赖项并实现OnActionExecutionAsync。这个实现类包含在一个实现TypeFilterAttribute的属性类中。

在声明属性类之后,我们可以通过实现逻辑来继续。OrderExistsAttribute有三个目的:

  • 检查传入的请求是否包含id

  • 检查请求的id是否为Guid

  • 查询IOrderRepository以检查实体是否存在

让我们继续描述之前逻辑的可能实现:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using SampleAPI.Repositories;

namespace SampleAPI.Filters
{
    public class OrderExistsAttribute : TypeFilterAttribute
    {
        public OrderExistsAttribute() : base(typeof(OrderExistsFilterImpl))
        {
        }

        private class OrderExistsFilterImpl : IAsyncActionFilter
        {
            private readonly IOrderRepository _orderRepository;

            public OrderExistsFilterImpl(IOrderRepository orderRepository)
            {
                _orderRepository = orderRepository;
            }

            public async Task OnActionExecutionAsync(ActionExecutingContext 
            context, ActionExecutionDelegate next)
            {
                if (!context.ActionArguments.ContainsKey("id"))
 {
 context.Result = new BadRequestResult();
 return;
 }

                if (!(context.ActionArguments["id"] is Guid id))
                {
                    context.Result = new BadRequestResult();
                    return;
                }

               var result = _orderRepository.Get(id);

                if (result == null)
                {
                    context.Result = 
                     new NotFoundObjectResult(
                     new {Message = $"Item with id {id} not exist."});
                    return;
                }

                await next();
            }
        }
    }
} 

首先,代码通过使用!context.ActionArguments.ContainsKey("id")语句检查我们的动作参数(由模型绑定填充)是否包含任何键。如果检查结果为假,动作过滤器通过向响应中添加BadRequestResult并退出方法来中断管道。其次,代码使用!(context.ActionArguments["id"] is Guid id)检查请求的id是否为Guid。在这种情况下,如果条件失败,它返回一个BadRequestResult并中断管道。最后,动作过滤器调用IOrderRepository并检查请求的实体是否存在。如果测试结果为正,它通过调用await next();方法继续管道;否则,它返回一个BadRequestResult

总之,我们可以在执行实际检查的方法上添加我们的属性,并删除之前在每个动作方法中复制的代码:

[Route("api/order")]
[ApiController]
public class OrderController : ControllerBase
{
    ...

    [HttpGet("{id:guid}")]
    [OrderExists]
    public IActionResult GetById(Guid id) { ... }

    [HttpPut("{id:guid}")]
    [OrderExists]
    public IActionResult Put(Guid id, UpdateOrderRequest request) { ... }

    [HttpPatch("{id:guid}")]
    [OrderExists]
    public IActionResult Patch(Guid id, JsonPatchDocument<Order> requestOp) 
    { ... }

    [HttpDelete("{id:guid}")]
    [OrderExists]
    public IActionResult Delete(Guid id) { ... }

    ...
}

这种方法符合DRY 原则。此外,我们可以重用过滤器,并在一个独特的入口点处理逻辑。

在 ASP.NET Core 2.1 之前,同样的方法被用来检查模型是否有效。而不是在每个操作中复制Model.IsValid 检查,逻辑被集中在一个操作过滤器中。随着内置的ApiController属性的引入,约束现在已成为隐式约束。

接下来,让我们看看如何修改异常。

修改异常

过滤器的另一个常见用途是改变特定请求的响应。在这些情况下,过滤器很方便,因为它们只能应用于特定的操作方法。一些服务需要向客户端返回自定义格式,例如,当它们被一个只接受特定格式的旧系统使用时,或者当它们需要提供一个被特定信封包装的响应时。ASP.NET Core 提供了IExceptionFilter接口来实现这一点,它允许我们重载异常并向客户端发送自定义响应。

此外,如果抛出异常,根据环境的不同,会有两种不同的行为。如果 API 触发异常并且它正在开发环境中运行,它将返回一个当前详细的异常页面,如下所示:

如果我们处于生产环境,它仅仅返回一个通用的500 Internal Server Error。这两种行为默认由 ASP.NET Core 的 Web API 模板定义:

public class Startup
{
    ....
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
 app.UseDeveloperExceptionPage();
 else
            app.UseHsts();

       ...
    }

在实际应用中,通常需要向客户端提供详细信息,从而确保服务器和客户端之间可靠的通信。

在实际应用中,错误是服务的重要组成部分。在某些情况下,公司会制定内部错误代码定义,以便它们可以构建更灵活的 API,更好地处理错误,并在彼此之间建立弹性的通信。

要实现自定义异常,我们应该扩展IExceptionFilter接口。以下代码是这种实现的可能示例:

using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace SampleAPI.Filters
{
    public class CustomExceptionAttribute : TypeFilterAttribute
    {
        public CustomExceptionAttribute() : base(typeof
            (HttpCustomExceptionFilterImpl))
        {
        }

        private class HttpCustomExceptionFilterImpl : IExceptionFilter
        {
            private readonly IWebHostEnvironment _env;
            private readonly ILogger<HttpCustomExceptionFilterImpl> 
             _logger;
            public HttpCustomExceptionFilterImpl(IWebHostEnvironment env,    
              ILogger<HttpCustomExceptionFilterImpl> logger)
            {
                _env = env;
                _logger = logger;
            }

            public void OnException(ExceptionContext context)
            {
                _logger.LogError(new EventId(context.Exception.HResult),
                    context.Exception,
                    context.Exception.Message);

                var json = new JsonErrorPayload
                {
                    Messages = new[] {"An error occurred. Try it again."}
                };

                if (_env.IsDevelopment())
                {
                    json.DetailedMessage = context.Exception;
                }

                var exceptionObject = new ObjectResult(json) 
                {StatusCode = 500};

                context.Result = exceptionObject;
                context.HttpContext.Response.StatusCode = 
                 (int) HttpStatusCode.InternalServerError;
            }
        }
    }

    public class JsonErrorPayload
    {
        public string[] Messages { get; set; }

        public object DetailedMessage { get; set; }
    }
}

每次抛出异常时,框架都会调用HttpCustomExceptionFilterImpl类。该类有两个依赖项:ILoggerIWebHostEnvironmentOnException方法使用ILogger类记录异常,并创建一个新的JsonErrorPayload实例,其中包含一个通用消息和一些关于异常的详细信息。最后,OnException方法返回500 Internal server error状态码,以及刚刚创建的exceptionObject

详细异常消息可能具有一些价值,这取决于IWebHostEvinronment。这种在生产环境中避免泄露关于服务敏感信息的方法是有用的。

总之,如果我们的服务抛出异常,我们的IExceptionFilter将其转换成一个新的 JSON 响应:

{
    "messages": [
        "An error occurred. Try it again."
    ],
    "detailedMessage": {
        "ClassName": "System.Exception",
        "Message": "My custom exception",
        "Data": null,
        "InnerException": null,
        "HelpURL": null,
        "StackTraceString": " at 
         Sample.API.Filters.Controllers.OrderController.Get() in               
         /Projects/Sample.API.Filters/
         Controllers/OrderController.cs:line 30\n at 
         lambda_method(Closure , Object , Object[] )\n ",
        "RemoteStackTraceString": null,
        "RemoteStackIndex": 0,
        "ExceptionMethod": null,
        "HResult": -2146233088,
        "Source": "HandsOn.API.Filters",
        "WatsonBuckets": null
    }
}

摘要

在本章中,我们探讨了 ASP.NET Core 中的一些过滤器概念。我们介绍了不同类型的过滤器,它们的工作方式以及每种类型在 MVC 管道中的具体用途。我们还看到了如何实现过滤器,并探讨了某些具体用例,以理解和发现过滤器的强大功能,以便我们能够实现横切关注点。

下一章将专注于 ASP.NET Core 中的数据访问层方法。您将了解到仓储模式,它将描述使用 EF Core 和 Dapper 实现数据访问层的方法。此外,我们还将描述一些测试技术,这些技术用于验证 ASP.NET Core 应用程序的数据访问层部分。

第三部分:构建现实世界的 RESTful API

在本节中,我们将逐步介绍一些具体实现的 RESTful Web 服务的实现。本节提供了在实现和测试 Web 服务方面的具体方法。在第一阶段,我们将重点关注通过区分三个层次来实现 Web 服务的实现:数据访问层、领域层和 HTTP 层。此外,我们将了解如何使用一些容器化技术本地运行服务,并发现一些多个 Web 服务之间通信的具体模式。最后,我们将概述如何通过实现令牌认证来保护服务。

正如我们在第一章,REST 101 和 ASP.NET Core 入门中提到的,以下示例需要您在操作系统上安装.NET Core 3.1。还建议您安装支持.NET Core 的 IDE,例如 Visual Studio 2019、Visual Studio for Mac 或 Rider IDE。您也可以使用 Visual Studio Code 作为替代,但请注意,您将不会获得其他编辑器提供的 IntelliSense 和代码分析舒适度。

本节中描述的所有代码均可在 GitHub 上找到,链接为github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3

本节包括以下章节:

  • 第八章,构建数据访问层

  • 第九章,实现领域逻辑

  • 第十章,实现 RESTful HTTP 层

  • 第十一章,构建 API 的高级概念

  • 第十二章,服务的容器化

  • 第十三章,服务生态系统模式

  • 第十四章,使用.NET Core 实现工作服务

  • 第十五章,保护您的服务

第八章:构建数据访问层

从本章开始,我们将通过使用 .NET Core 来具体实现 Web 服务部分。我们将涵盖开发真实 Web 服务的一些关键方面——从数据访问层的设计到 HTTP 路由的实现。

在本章中,我们将首先定义数据访问部分。数据访问部分是必要的,用于在数据库或数据源中存储信息,并且通常是应用程序中最微妙的部分之一。我们将专注于实现一个目录 Web 服务。此外,我们还将探索不同的第三方工具来访问我们的数据,并解释如何设置项目和实现数据域。

本章将涵盖以下主题:

  • 设计项目实体

  • 选择合适的工具

  • 使用 EF Core 实现数据访问层

  • 使用 Dapper 实现数据访问层

  • 使用内存数据库测试数据层

本章中的代码可以从以下 GitHub 仓库获取:github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3.

设置项目

就像之前的章节一样,我们可以通过使用 Web API 模板来创建一个新的项目。让我们打开终端并执行以下命令:

mkdir Catalog.API
cd Catalog.API
dotnet new sln -n Catalog.API
mkdir src
cd src
dotnet new webapi -n Catalog.API
dotnet sln ../Catalog.API.sln add Catalog.API

第一个 dotnet new 命令创建了一个名为 Catalog.API 的新解决方案文件。第二个 dotnet new 指令在 src 文件夹中创建了一个新的 Web API 项目。最后,最后一个 dotnet sln 命令将项目添加到我们的解决方案中。

结果的文件系统结构如下所示:

.
├── Catalog.API.sln
├── src
│   ├── Catalog.API
│   │   ├── Controllers
│   │   ├── Program.cs
│   │   ├── Properties
│   │   │   └── launchSettings.json
│   │   ├── Startup.cs
│   │   ├── Catalog.API.csproj
│   │   ├── appsettings.Development.json
│   │   ├── appsettings.json
│   │   ├── bin
│   │   ├── obj
│   │   └── wwwroot

src 文件夹将包含我们所有的代码以及本书中我们将添加的附加项目。在本章的后面部分,我们还将添加一个 tests 文件夹,其中将包含我们项目的所有测试。

实现域模型

如第五章中在 数据传输 部分讨论的,ASP.NET Core 中的 Web 服务堆栈,域模型 是我们服务处理的数据的表示。考虑一个 音乐商店的目录 Web 服务,我们需要处理的主要数据包括 API 使用的 实体

为了保证可重用性和松散耦合,我们将定义服务域模型为一个独立的项目。首先,让我们在 src 文件夹中通过执行以下命令创建一个新的 Catalog.Domain 项目:

dotnet new classlib -n Catalog.Domain -f netstandard2.1

上述命令还指定了 netstandard2.1 版本为目标框架。此外,在创建 Catalog.Domain 项目后,我们需要将其添加到我们的解决方案中:

dotnet sln ../Catalog.API.sln add Catalog.Domain

上述指令将 Catalog.Domain 项目添加到 Catalog.API.sln 文件中。因此,我们现在已经准备好设计和实现我们 Web 服务的实体。

设计实体

现在我们可以继续进行我们需要实现实体的设计阶段。让我们从 Item 类开始,它将成为我们领域模型中的中心实体。它代表一张音乐专辑,包含与专辑相关的所有属性和特征,包括 描述名称发行 日期格式。实体还将提供一些通常出现在目录中的附加信息,例如可用库存、图片和价格。

让我们先设计一个图来描述我们的代码:

图片

此图定义了服务中涉及的实体:

  • Item 是我们声明中的主要实体。它包含关于专辑的所有信息以及指向 艺术家类型 的引用。

  • Artist 实体表示与专辑相关的艺术家。

  • Genre 实体表示与专辑相关的音乐类型。

  • Money 实体表示专辑的价格。这是一个复杂类型,包含金额和货币单位。

实现实体

现在我们已经定义了目录服务中包含哪些属性和对象,让我们首先实现实体作为具体类型。所有领域模型都传统上存储在 Catalog.Domain 项目的 Entities 文件夹中。我们正在创建的第一个类型是 Item 类:

using System;

namespace Catalog.Domain.Entities
{
    public class Item
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public string LabelName { get; set; }
        public Price Price { get; set; }
        public string PictureUri { get; set; }
        public DateTimeOffset ReleaseDate { get; set; }
        public string Format { get; set; }
        public int AvailableStock { get; set; }
        public Genre Genre { get; set; }
        public Artist Artist { get; set; }
    }
}

首先,我们可以看到 Item 类的定义中包含了对其他类型的引用。需要注意的是,该实现使用 Guid 类型来指定 Id。这种方法的目的是在两个不同的数据源或目录合并时避免冲突。

让我们继续定义我们领域模型的相关实体:

using System;

namespace Catalog.Domain.Entities
{
    //Artist.cs file
    public class Artist
    {
        public Guid ArtistId { get; set; }
        public string ArtistName { get; set; }
    }
    //Genre.cs file
    public class Genre
    {
        public Guid GenreId { get; set; }
        public string GenreDescription { get; set; }
    }
    //Price.cs file
    public class Price
    {
        public decimal Amount { get; set; }
        public string Currency { get; set; }
    }
}

为了表示目的,以下类在相同的代码片段中实现。请注意,它们定义在不同的文件中:Artist.csGenre.csPrice.cs

上述代码定义了领域模型中包含的相关实体. Artist 类表示与专辑相关的艺术家。它包括 Guid idArtistName。同样,Genre 类是另一种类别类型,表示特定专辑的类型。最后,Price 类表示产品的价格(以及货币单位)。

使用 ORM 进行数据访问

数据访问是我们服务的一部分,帮助我们执行对数据源进行读取或写入操作。数据访问部分通常与 ORM 结合使用。一般来说,我们可以将 ORM 定义为一种面向对象编程方法,用于在互不兼容的类型系统之间转换关系数据。

ORM 工具或包是数据源和 Web 应用程序之间的桥梁。它们将关系表中的信息映射到类中,从而映射到对象中。

在 .NET 生态系统中,我们可以选择大量的不同 ORM。由微软官方维护的是 EF Core。

EF Core 是由微软和社区支持的开放源代码 ORM。它是 .NET Core 应用程序和 Web 服务中使用的默认 ORM。在本章中,我们还将概述由 Stack Exchange 和社区支持的开放源代码微 ORM Dapper。EF Core 和 Dapper 都作为 NuGet 包分发,并且通常与 .NET Core 框架非常良好地集成。

寻找适合工作的正确工具

EF Core 和 Dapper 都为我们提供了数据源的高级别抽象。尽管如此,它们都有一些优缺点。我们必须牢记,对于我们在做的每一个项目,我们都应该寻找适合这项工作的正确工具。

让我们分析一下这两个库的优缺点。在此之前,我们应该快速查看一些示例查询,以便了解两者之间的差异。以下代码片段描述了一个使用 EF Core 的示例查询:

using (var context = new CatalogContext()) {
     var items = context.Items         
                        .Where(b => b.Description.Contains("various 
                         artists"))
                        .ToList();
}

在前面的代码中,我们搜索每个具有相应描述的 Item 实体。让我们以一个 Dapper 查询为例继续进行:

connection.Query<Item>("select * from (select Id from dbo.Catalog where Description like '%@searchTerm%', new { searchTerm = "various artists" });

如您所见,EF Core 在我们的数据源上提供了高级别的抽象。

此外,EF Core 默认允许开发者使用集合(与 LINQ 集成)查询数据。这种方法快速且简单,但代价是:EF Core 将查询转换为 SQL 语言,有时生成的 SQL 查询没有优化。EF Core 还鼓励代码优先的方法,这意味着数据库侧的所有实体都使用 C# 代码生成。当你只有一个对象时,这可能看起来很简单,但当实体复杂时,可能会出现可维护性问题。

在复杂实体或使用代码优先方法的程序中,生成数据库实体的代码通常在单独的项目和存储库中实现,以避免数据库与整个解决方案之间的紧密耦合。

然而,EF Core 的一个常见问题是资源的早期获取。例如,考虑以下查询:

 List<Item> items = db.Items.ToList();
 List<Item> variousArtistItems = items
                     .Where(s => s.Description.Contains("various 
                          artist") == city).ToList();

它生成一个类似于以下查询的 SQL 查询:

SELECT [i].[Id],
       [i].[Description],
       [i].[ArtistId], 
       [i].[GenreId],
FROM   [dbo].[Items] as [i]

如您所见,尽管我们使用了 Where 子句,但之前 ToList 方法在评估查询时没有考虑 Where 子句。为了得到更好的结果,我们应该在评估之前执行 Where 语句:

 List<Item> variousArtistItems = db.Items
                     .Where(s => s.Description.Contains("various 
                          artist") == city).ToList();

这种程序在性能和网络方面都更好。它可能看起来足够简单,但在分布式团队中,这些错误在代码库中很常见,而且在代码审查过程中很难发现。

在 Dapper 的层面上,这是一个微型 ORM,抽象级别发生了变化。Dapper 提供了对数据源更透明的访问。它默认保证通过使用纯 SQL 或存储过程以清晰的方式查询数据。因此,它也确保了更好的性能。另一方面,它与数据源紧密耦合,因为它使用数据源查询语言执行查询。

总结来说,本章将涵盖 EF Core 和 Dapper 库。在选择它们之间,你应该考虑你团队已有的技能以及如何优化服务的性能。如果你的团队对 SQL 有很强的了解,你可以通过实现存储过程而不是使用 EF Core 来进行。另一方面,如果你的团队还没有 SQL 技能,你应该考虑使用 EF Core。

使用 EF Core 实现数据访问层

在本节中,我们将探讨如何使用仓储模式和 EF Core 构建数据访问层。仓储模式是在我们的数据源之上提供的一个额外抽象。它提供了对数据的读取和写入操作。

定义仓储模式和单元工作

在我们开始之前,我们需要在 Catalog.Domain 项目中定义一些接口。让我们通过设置一个泛型接口来确定我们仓储的单元工作:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Catalog.Domain.Repositories
{
    public interface IUnitOfWork : IDisposable
    {
        Task<int> SaveChangesAsync(CancellationToken cancellationToken = 
         default(CancellationToken));
        Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = 
         default(CancellationToken));
    }
}

IUnitOfWork 定义了两个方法:SaveChangesAsyncSaveEntitiesAsync。这两个方法用于有效地将我们的集合更改保存到数据库中。这两个方法都是异步的:它们返回 Task 类型,并接受一个 CancellationToken 类型的参数。CancellationToken 参数提供了一种停止挂起的异步操作的方法。

在某些情况下,仓储模式被实现为,当你更新或创建集合中的新元素时,这些更改会自动保存到数据库中。我更喜欢将有效的保存操作与读取和写入部分分开。这可以通过使用单元工作方法来实现。因此,仓储允许高层在内存集合上执行获取、创建和更新操作,而单元工作实现了一种将这些更改传输到数据库的方法。

让我们继续,通过在之前定义的 IUnitOfWork 接口相同的文件夹级别定义一个 IRepository 接口:

namespace Catalog.Domain.Repositories
{
    public interface IRepository
    {
        IUnitOfWork UnitOfWork { get; }
    }
}

如您所见,IRepository 并没有隐式使用 IUnitOfWork 接口。此外,它将 UnitOfWork 实例作为类的属性暴露出来。这种做法保证了所有 IRepository 接口的消费者都必须显式地通过调用 SaveChangesAsyncSaveEntitiesAsync 方法来更新数据库。

最后一步是定义 IItemRepository 接口,如下所示:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Catalog.Domain.Entities;

namespace Catalog.Domain.Repositories
{
    public interface IItemRepository : IRepository
    {
        Task<IEnumerable<Item>> GetAsync();
        Task<Item> GetAsync(Guid id);
        Item Add(Item item);
        Item Update(Item item);
    }
}

该接口扩展了 IRepository 类,并引用了之前定义的 Item 实体。IItemRepository 定义了对数据源进行读取和写入操作。你可能注意到 AddUpdate 等,这是因为它们只作用于应用程序内存中存储的集合,而有效的保存操作是由工作单元执行的。

将我们的存储库连接到数据库

一旦我们定义了 IItemRepository 接口和领域项目中所有的抽象,我们应该继续创建代表之前定义的抽象的具体实现的类。我们还将创建一个新的 Catalog.Infrastructure 项目,其中包含我们存储库的所有实现以及代表我们服务和数据库之间层的类。让我们通过在 src 文件夹中执行以下命令来创建 Catalog.Infrastructure 项目:

dotnet new classlib -n Catalog.Infrastructure -f netstandard2.1 dotnet sln ../Catalog.API.sln add Catalog.Infrastructure

完成后,我们的解决方案的文件夹结构如下所示:

.
├── Catalog.API.sln
└── src
    ├── Catalog.API
    │ ...
    ├── Catalog.Domain
    │ ├── Entities
    │ │ ├── Artist.cs
    │ │ ├── Genre.cs
    │ │ ├── Item.cs
    │ │ └── Money.cs
    │ │ └── Repositories
    │ │ ├── IItemRepository.cs
    │ │ ├── IRepository.cs
    │ │ └── IUnitOfWork.cs
    │ ├── Catalog.Domain.csproj
    └── Catalog.Infrastructure
        ├── Catalog.Infrastructure.csproj

在开始实现 IItemRepository 之前,我们需要通过在项目文件夹内执行以下命令将 Microsoft.EntityFrameworkCore NuGet 包添加到 Catalog.Infrastructure 项目中:

dotnet add package Microsoft.EntityFrameworkCore

之后,我们可以通过使用以下命令将 Catalog.Domain 项目的引用添加到基础设施项目中继续操作:

 dotnet add reference ../Catalog.Domain

DbContext 定义

DbContext 是我们应用程序和数据库之间的一种抽象。它使我们能够与数据交互,并对数据进行操作。DbContext 的实现也是我们应用程序和数据库之间会话的表示,我们可以用它来查询并将应用程序实体的实例保存到我们的数据源中。让我们快速看一下 Catalog.Infrastructure 项目中的 DbContext 实现:

using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Infrastructure
{
    public class CatalogContext : DbContext, IUnitOfWork
    {
        public const string DEFAULT_SCHEMA = "catalog";

        public DbSet<Item> Items { get; set; }

        public CatalogContext(DbContextOptions<CatalogContext> options) : 
         base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
        }

        public async Task<bool> SaveEntitiesAsync(CancellationToken 
         cancellationToken = default(CancellationToken))
        {
            await SaveChangesAsync(cancellationToken);
            return true;
        }
    }
}

在快速查看代码后,我们应该提到以下几点:

  • CatalogContext 类代表我们的工作单元;因此,它实现了 IUnitOfWork 接口。

  • 它使用 DbSet<Item> 类型来表示 Item 实例的集合。

  • CatalogContext 类的构造函数接受一个强制参数,它代表 DbContextOptions。这些选项用于指定有关数据库连接的一些关键信息。这包括要使用的数据库提供程序、数据库的连接字符串以及 ORM 所使用的所有跟踪策略。

  • CatalogContext 类还实现了 SaveEntitiesAsync,它调用由 DbContext 类派生的 SaveChangesAsync 方法。

一旦我们有了 CatalogContext,我们就可以继续实现 IItemRepository 接口。

实现存储库

下一个子节重点介绍 IItemRepository 接口的具体实现。重要的是要注意,IItemRepository 接口位于 Catalog.Domain 项目中,而 ItemRepository 的实现位于 Catalog.Infrastructure 项目中.

一旦我们构建了 DbContext 类,我们就可以通过实现具体的 ItemRepository 类来继续操作:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Infrastructure.Repositories
{
    public class ItemRepository
        : IItemRepository
    {
        private readonly CatalogContext _context;

        public IUnitOfWork UnitOfWork => _context;

        public ItemRepository(CatalogContext context)
        {
            _context = context ?? throw new 
             ArgumentNullException(nameof(context));
        }

        public async Task<IEnumerable<Item>> GetAsync()
        {
            return await _context
                .Items
                .AsNoTracking()
                .ToListAsync();
        }

        public async Task<Item> GetAsync(Guid id)
        {
            var item = await _context.Items
                .AsNoTracking()
                .Where(x => x.Id == id)
                .Include(x => x.Genre)
                .Include(x => x.Artist).FirstOrDefaultAsync();

            return item;
        }

        public Item Add(Item order)
        {
            return _context.Items
                .Add(order).Entity;
        }

        public Item Update(Item item)
        {
            _context.Entry(item).State = EntityState.Modified;
            return item;
        }
    }
}

上述代码实现了在 IItemRepository 接口中先前定义的 CRUD 操作。它还通过 IUnitOfWork 接口公开了 CategoryContext。这种方法的保证是,IItemRepository 的消费者可以修改和查询我们的集合,并使用相应的更改更新数据源。让我们回顾一下上述代码中实现的方法:

  • GetAsync() 方法使用上下文检索 Items 实体的集合。该方法显式使用 AsNoTracking() 方法以防止跟踪实体。此扩展方法可以在您不需要对实体执行写入操作时使用,并且它适用于只读数据。

  • GetAsync(Guid id) 重载了之前提到的方法,并使用 AsNoTracking() 实现了之前描述的相同目的。此方法还通过使用 EF Core 提供的 Include() 扩展方法获取相关实体的详细信息(GenreArtist)。

  • Add(Item entity) 方法使用上下文添加作为参数传递的实体,并将添加的实体返回给调用者。

  • Edit 方法从上下文中更新目标实体,并将 EntityState.Modified 设置为实体状态。这种方法保证了,一旦实体处于修改状态,它将在保存步骤中被更新。

上述代码没有实现 Delete 方法。这是因为删除过程将在本书的后续部分实现。我们将执行软删除我们的数据。

将实体转换为 SQL 架构

EF Core 鼓励我们在服务中使用代码优先的方法。代码优先技术包括在 C# 中定义一些实体类,并使用它们在数据库端生成表。同样的方法应用于通常存在于 SQL 生态系统中的所有关系和约束,例如索引、主键和外键。本节演示了如何使用这种方法生成数据库架构。

首先,让我们从我们的 Item.cs 实体开始,并添加一个 ID 来表示与其他实体的关系:

using System;

namespace Catalog.Domain.Entities
{
 //Item.cs file
   public class Item
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public string LabelName { get; set; }
        public Price Price { get; set; }
        public string PictureUri { get; set; }
        public DateTimeOffset ReleaseDate { get; set; }
        public string Format { get;set; }
        public int AvailableStock { get; set; }
        public Guid GenreId { get; set; }
        public Genre Genre { get; set; }
        public Guid ArtistId { get; set; }
        public Artist Artist { get; set; }
    }

    //Artist.cs file
    public class Artist
    {
        public Guid ArtistId { get; set; }
        public string ArtistName { get; set; }
        public ICollection<Item> Items {get; set;}
    }

 //Genre.cs file
    public class Genre
    {
        public Guid GenreId { get; set; }
        public string GenreDescription { get; set; }
        public ICollection<Item> Items {get; set;}
    }
}

上述代码描述了 Item 类与 Artist 类以及 Item 类与 Genre 类之间的 多对一 关系。此外,ArtistGenre 实体都有一个集合,该集合引用了 Item 实体的集合。

让我们继续通过使用 Fluent API 方法实现我们的Item实体约束。一般来说,EF Core ORM 实现了 Fluent API 技术,帮助我们处理约束定义。

通常,Fluent API,也称为Fluent 接口,是一种组合面向对象 API 的方法,这些 API 本质上基于方法链。方法之间的链产生接近书面语法的源代码;例如,myList.First().Items.Count().ShouldBe(2). 你可以看看这个例子有多易读;任何人都能理解。大多数 EF Core 约束通常都是使用这种方法构建的。

让我们在Catalog.Infrastructure项目中添加一个名为SchemaDefinitions的新文件夹。该文件夹将包含为应用程序实现的所有架构定义以及实体之间约束的所有定义。例如,在Item实体的情况下,我们需要创建一个新的ItemEntitySchemaDefinition类:

using System;
using Catalog.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Catalog.Infrastructure.SchemaDefinitions
{
    public class ItemEntitySchemaDefinition :
        IEntityTypeConfiguration<Item>
    {
        public void Configure(EntityTypeBuilder<Item> builder)
        {
            builder.ToTable("Items", CatalogContext.DEFAULT_SCHEMA);
            builder.HasKey(k => k.Id);

            builder.Property(p => p.Name)
                .IsRequired();

            builder.Property(p => p.Description)
                .IsRequired()
                .HasMaxLength(1000);

            builder
                .HasOne(e => e.Genre)
                .WithMany(c => c.Items)
                .HasForeignKey(k => k.GenreId);

            builder
                .HasOne(e => e.Artist)
                .WithMany(c => c.Items)
                .HasForeignKey(k => k.ArtistId);

            builder.Property(p => p.Price).HasConversion(
                p => $"{p.Amount}:{p.Currency}",
                p => new Price
                {
                    Amount = Convert.ToDecimal(
                     p.Split(':', StringSplitOptions.None)[0]),
                     Currency = p.Split(':', StringSplitOptions.None)[1]
                });
        }
    }
}

这是Item实体约束的架构定义。该类实现了由Microsoft.EntityFrameworkCore.SqlServer包公开的IEntityTypeConfiguration<T>接口。必须通过以下命令向Catalog.Infrastructure项目添加一个新的引用:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer

该包提供了一个扩展方法来与 SQL 服务器数据库交互:Configure方法实现定义了规则,这些规则将应用于Item实体。由于 Fluent API 方法,这些规则很容易理解:

  • ToTable方法用于显式定义 SQL 表名。

  • HasKey方法将属性设置为该实体类型的键。

  • IsRequired方法用于标记所有必需的功能。

EF Core 为我们提供了不同的开箱即用配置选项;完整的列表可在docs.microsoft.com/en-us/ef/core/modeling/找到。这些属性可以组合起来,以获得关于正确表示我们的领域模型更好的结果。

Configure方法还向Item实体与Artist实体和Genre实体之间的一对多关系添加了一些额外的约束:

      ...            
            builder
                .HasOne(e => e.Genre)
                .WithMany(c => c.Items)
                .HasForeignKey(k => k.GenreId);

            builder
                .HasOne(e => e.Artist)
                .WithMany(c => c.Items)
                .HasForeignKey(k => k.ArtistId);
      ...

此代码片段指定了Item实体的关系。请注意,我们遵循了 Fluent 方法。在这种情况下,我们通过指定GenreIdArtistId作为外键,在Item类和Artist类以及Genre类之间定义了一个 1-N 关系。

使用 Fluent API 进行自定义转换。

EF Core 还提供了一种添加自定义转换的方法。这种方法可能对于提供复杂实体的自定义表示很有用。例如,让我们看看在ItemEntitySchemaDefinition中声明的以下代码片段:

            builder.Property(p => p.Price).HasConversion(
                p => $"{p.Amount}:{p.Currency}",
                p => new Price
                {
                    Amount = Convert.ToDecimal(p.Split(':', 
                     StringSplitOptions.None)[0]),
                    Currency = p.Split(':', StringSplitOptions.None)[1]
                });

HasConversion方法提供了一种自定义数据库中插入数据的方式。此方法通过以下格式将Price字段(Price类型)序列化为字符串:34.05:EUR。另一方面,当从数据库中读取Price数据时,字符串被反序列化为Price类型。

在当前数据上下文中应用架构定义

要利用ItemEntitySchemaDefinition类中实现的架构,我们应该将其应用于CatalogContext类中包含的OnModelCreating方法:

using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;
using Catalog.Infrastructure.SchemaDefinitions;

namespace Catalog.Infrastructure
{
    public class CatalogContext : DbContext, IUnitOfWork
    {
        public const string DEFAULT_SCHEMA = "catalog";
        public DbSet<Item> Items { get; set; }

        public CatalogContext(DbContextOptions<CatalogContext> options) 
            : base (options) { }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
 {
 modelBuilder.ApplyConfiguration(new ItemEntitySchemaDefinition());
 base.OnModelCreating(modelBuilder);
 }

        public async Task<bool> SaveEntitiesAsync(CancellationToken 
            cancellationToken = default(CancellationToken))
        {
            await SaveChangesAsync(cancellationToken);
            return true;
        }        
    }
}

上述代码使用ApplyConfiguration扩展方法在运行时执行期间将配置应用到 SQL 架构。需要注意的是,类中实现的OnModelCreating方法始终调用父类的base.OnModelCreating方法,以保留扩展类的行为。

为艺术家和流派实体生成架构

上述过程也可以应用于ArtistGenre实体。以下代码显示了Catalog.Domain.Entities命名空间中两个实体的定义:

using System.Collections.Generic;

namespace Catalog.Domain.Entities
{
    //Artist.cs
    public class Artist
    {
        public Guid ArtistId { get; set; }
        public string ArtistName { get; set; }
        public ICollection<Item> Items { get; set; }
    }

    //Genre.cs
    public class Genre
    {
        public Guid GenreId { get; set; }
        public string GenreDescription { get; set; }
        public ICollection<Item> Items { get; set; }
    }
}

因此,我们可以将两个文件添加到Catalog.Infrastructure项目中,如下所示:

 //SchemaDefinitions/ArtistEntitySchemaConfiguration.cs using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Catalog.Domain.Entities;

namespace Catalog.Infrastructure.SchemaDefinitions
{
    public class ArtistEntitySchemaConfiguration : 
        IEntityTypeConfiguration<Artist>
    {
        public void Configure(EntityTypeBuilder<Artist> builder)
        {
            builder.ToTable("Artists", CatalogContext.DEFAULT_SCHEMA);
            builder.HasKey(k => k.ArtistId);

            builder.Property(p => p.ArtistId);

            builder.Property(p => p.ArtistName)
                .IsRequired()
                .HasMaxLength(200);
        }
    }
}

GenreEntitySchemaConfiguration.cs文件如下所示:

 //SchemaDefinitions/GenreEntitySchemaConfiguration.cs using Catalog.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Catalog.Infrastructure.SchemaDefinitions
{
    public class GenreEntitySchemaConfiguration : 
        IEntityTypeConfiguration<Genre>
    {
        public void Configure(EntityTypeBuilder<Genre> builder)
        {
            builder.ToTable("Genres", CatalogContext.DEFAULT_SCHEMA);
            builder.HasKey(k => k.GenreId);

            builder.Property(p => p.GenreId);

            builder.Property(p => p.GenreDescription)
                .IsRequired()
                .HasMaxLength(1000);
        }
    }
}

GenreEntitySchemaConfigurationArtistEntitySchemaConfiguration都使用HasKey方法定义了我们表的主键。正如我们之前讨论的,它们使用了应用于先前定义的ItemEntitySchemaConfiguration类的相同流畅方法。此外,我们还需要将GenreEntitySchemaConfigurationArtistEntitySchemaConfiguration包含在CatalogContext类的OnModelCreating方法中:


    public class CatalogContext : DbContext, IUnitOfWork
    {
...
        protected override void OnModelCreating(ModelBuilder modelBuilder)
 {
            modelBuilder.ApplyConfiguration(new ItemEntitySchemaDefinition()); modelBuilder.ApplyConfiguration(new GenreEntitySchemaConfiguration());
            modelBuilder.ApplyConfiguration(new ArtistEntitySchemaConfiguration());            base.OnModelCreating(modelBuilder);
 }
...     
    }

为了简洁,我省略了CatalogContext类的完整定义。显著的变化是扩展了OnModelCreating方法,通过应用GenreEntitySchemaConfigurationArtistEntitySchemaConfiguration类的配置。

执行迁移

使用 EF Core 实现数据访问的最后一个步骤是将DbContext实例连接到数据库,并使用.NET CLI 公开的命令运行迁移。在这样做之前,我们需要在我们的本地环境中有一个可工作的数据库。为了使我们的本地开发环境尽可能轻量,此示例将使用 Linux 上的 Microsoft SQL Server Docker 镜像。您可以从这里获取 Docker 镜像:hub.docker.com/r/microsoft/mssql-server-linux/。如果您没有任何 Docker 的先前经验,可以遵循此指南在您的本地机器上安装和设置它:docs.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker?view=sql-server-2017

容器是快速设置本地环境的一种极好方式,无需配置大量不同的工具和系统。如今,微软在简化他们的系统和流程方面投入了大量资金,无论是针对开发者还是云系统。

在运行我们的 SQL 实例后,让我们通过以下命令创建一个名为 Store 的新数据库:

docker exec -it sql1 "bash" 
/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P '<YOUR_PASSWORD>' 
1> CREATE LOGIN catalog_srv WITH PASSWORD = 'P@ssw0rd';
2> CREATE DATABASE Store;
3> GO
1> USE Store;
2> CREATE USER catalog_srv;
3> GO
1> EXEC sp_addrolemember N'db_owner', N'catalog_srv';
2> GO

CLI 的一个有效替代方案是使用 SQL 编辑器。一个推荐的工具是 VS Code 的 mssql 扩展:docs.microsoft.com/en-us/sql/linux/sql-server-linux-develop-use-vscode?view=sql-server-2017。否则,您可以下载基于 VS Code 的跨平台 SQL 编辑器:docs.microsoft.com/en-us/sql/azure-data-studio/download?view=sql-server-2017

一旦我们在本地环境中使 Microsoft SQL Server 运行起来,我们就可以通过将我们的服务与数据库连接来继续操作。Catalog.API 项目中已经存在的 Startup 类将定义我们的服务使用的连接字符串和提供者。正如我们将看到的,所有迁移类也将存储在同一个项目中。这种方法保证了我们的 .NET CLI 指令有一个唯一的入口点,即 Catalog.API,而不与数据库逻辑(Catalog.Infrastructure)紧密耦合。

在继续之前,我们需要在 API 项目文件夹中使用以下命令将 Catalog.Infrastructure 项目添加为 API 项目的引用:

dotnet add reference ../Catalog.Infrastructure

API 项目还要求您引用 Microsoft.EntityFrameworkCore.Design NuGet 包,该包共享 EF Core 工具的设计时组件。我们可以通过在 Catalog.API 项目文件夹中执行以下 CLI 指令来添加包的最新版本:

dotnet add package Microsoft.EntityFrameworkCore.Design

之后,我们可以在 Startup 类中添加数据库连接:

using System;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Catalog.Infrastructure; 
namespace Catalog.API
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
          ...
        }

        ...
        public void ConfigureServices(IServiceCollection services)
        {
             services
                .AddEntityFrameworkSqlServer()
                .AddDbContext<CatalogContext>(contextOptions =>
                {
                    contextOptions.UseSqlServer(
 "Server=localhost,1433;Initial Catalog=Store;User Id=<SA_USER>;Password=<PASSWORD>",
                        serverOptions => {             
                           serverOptions.MigrationsAssembly
                           (typeof(Startup).Assembly.FullName); });
                });
             ...
        }

        public void Configure(IApplicationBuilder app, 
            IHostingEnvironment env)
        {
          ...
        }
    }
}

ConfigureServices 方法包含了 SQL 连接的初始化。首先,它使用 AddEntityFameworkSqlServer 添加了 SQL 提供者所需的服务。随后,它添加了 CatalogContext,通过传递 Action<DbContextOptionsBuilder> 类型的动作方法来利用 AddContext<T> 泛型方法。

最后,动作方法通过使用 UseSqlServer 扩展方法和传递数据库的连接字符串来配置 SQL Server 提供者。MigrationsAssembly 方法定义了组件应该存储的位置。在这种情况下,它指定所有迁移都将存储在我们的 Catalog.API 项目中。

为了使我们的Startup类保持整洁和可读,我们可能需要创建一个自定义扩展方法来初始化对Catalog数据库的连接。让我们在Catalog.API项目中创建一个新的文件夹名为Extensions,添加一个新的DatabaseExtension类,并将我们的代码移动到一个新的AddCatalogContext方法中:

using Catalog.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Catalog.API.Extensions
{
        public static class DatabaseExtensions
        {
            public static IServiceCollection AddCatalogContext(this 
                IServiceCollection services)
            {
                return services
                    .AddEntityFrameworkSqlServer()
                    .AddDbContext<CatalogContext>(contextOptions =>
                    {
                        contextOptions.UseSqlServer(
                            "Server=localhost,1433;Initial Catalog=Store;User Id=<SA_USER>;Password=<PASSWORD>",
                            serverOptions => { 
                                serverOptions.MigrationsAssembly
 (typeof(Startup).Assembly.FullName); });
                    });
            }
        }
}

我们可以简化Startup类如下:

   public class Startup
    {
       ...

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCatalogContext();
            ...
        }
    }

现在Startup类已经准备好了,请在Catalog.API项目文件夹中使用以下命令执行migrations

dotnet ef migrations add InitMigration dotnet ef database update

第一个命令生成了Migration文件夹以及其中的两个不同文件:

  • {timestamp}_InitMigration.cs:此类创建了数据库中存在的表、约束和索引。

  • CatalogContextModelSnapshot.cs:这个文件仅在第一次迁移命令中生成,并代表服务中实体的当前状态。

每个迁移类,包括我们刚刚生成的类,都具有以下结构:

using Microsoft.EntityFrameworkCore.Migrations;

namespace Catalog.API.Migrations
{
    public partial class InitMigration : Migration
    {
        protected override void Up(MigrationBuilder migrationBuilder)
        {
          ...
        }

        protected override void Down(MigrationBuilder migrationBuilder)
        {
          ...
        }
    }
}

该类包含两个方法:UpDownUp方法在生成数据库模式时被调用。Down方法在删除模式时被调用。

生成的表和 SQL 实体位于catalog模式之下。每次我们执行以下命令时,dotnet ef CLI 工具都会创建一个新的迁移类:

dotnet ef migrations add <migration_name>

每次我们在项目文件夹中运行 EF Core 更新过程时,数据库的模式都会被刷新。因此,我们可以在Catalog.API项目文件夹中执行以下 CLI 命令:

dotnet ef database update 

前一个命令使用存储在项目Migration文件夹中的迁移创建了 SQL 模式:它将连接到AddCatalogContext()扩展方法中指定的数据库。在下一节中,我们将探讨如何将指定的连接字符串移动到appsettings.json文件中。

定义配置部分

如第二章“ASP.NET Core 概述”中所述,appsettings.json文件通常包含应用程序设置。连接字符串通常存储在该文件中。因此,这种做法使我们的服务更具可重用性和可配置性,尤其是在它已经在预发布或生产环境中运行时。让我们以下述方式将连接字符串从AddCatalogContext方法移动到appsettings.json文件中:

{
...
  "DataSource": {
    "ConnectionString": "Server=localhost,1433;Initial Catalog=Store;User Id=catalog_srv;Password=P@ssw0rd"
  }
}

这样,我们可以使用以下语法读取连接字符串并将其作为参数传递给AddCatalogContext

..
public void ConfigureServices(IServiceCollection services)
{
..    services.AddCatalogContext(Configuration.GetSection("DataSource:ConnectionString").Value);
  ...
}
..

因此,我们需要通过添加一个connectionString参数来更改AddCatalogContext扩展方法的签名,如下所示:

public static IServiceCollection AddCatalogContext(this IServiceCollection services, string connectionString)

我们可以将新定义的connectionString参数传递给UseSqlServer扩展方法。在下一节中,我们将继续测试本节中实现的仓库逻辑。

测试 EF Core 仓库

本节涵盖了用于测试.NET Core 应用程序的一些常见测试实践。更具体地说,它侧重于测试应用程序的存储库部分。首先,让我们在项目根目录(与.sln文件相同的文件夹)中执行以下命令来创建一个新的测试项目:

mkdir tests
cd tests

dotnet new xunit -n Catalog.Infrastructure.Tests
dotnet sln ../Catalog.API.sln add Catalog.Infrastructure.Tests

因此,我们创建了一个新的tests目录,它将包含服务中所有的测试项目。我们还使用xunit模板创建了一个新的Catalog.Infrastructure.Tests项目。

xunit是.NET 生态系统中的一个非常流行的测试框架,并且是.NET Core 框架模板中的默认选择。由于我们使用xunit模板创建了项目,因此Catalog.Infrastructure.Tests.csproj文件将包含对xunit包的引用:

<ItemGroup>
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version=".." />
  <PackageReference Include="xunit" Version=".." />
  <PackageReference Include="xunit.runner.visualstudio" Version=".." />
  <DotNetCliToolReference Include="dotnet-xunit" Version=".." />
</ItemGroup

这些包允许我们通过在解决方案级别的测试项目文件夹中使用dotnet test CLI 指令或在我们的首选 IDE(如 Visual Studio 或 Rider)中集成的测试运行器工具来运行单元测试。

使用 DbContext 进行数据种子

让我们继续探讨另一个 EF Core 功能,它允许我们进行数据种子。数据种子技术简化了测试环境,以便获取集成测试数据库的默认快照。

让我们通过一个.NET Core 数据库种子示例来了解。首先,让我们创建一个新的Data文件夹,并添加包含测试记录的 JSON 文件。为了简洁,我在同一代码片段中包含了artist.json文件和genre.json文件。

// Data/artist.json
[
    {
        "ArtistId": "3eb00b42-a9f0-4012-841d-70ebf3ab7474",
        "ArtistName": "Kendrick Lamar",
        "Items": null
    },
    {
        "ArtistId": "f08a333d-30db-4dd1-b8ba-3b0473c7cdab",
        "ArtistName": "Anderson Paak.",
        "Items": null
    }
]

// Data/genre.json
[
    {
        "GenreId": "c04f05c0-f6ad-44d1-a400-3375bfb5dfd6",
        "GenreDescription": "Hip-Hop",
        "Items": null
    }
]

上述文件包含与GenreArtist实体相关的数据。同样地,我们可以通过创建一个新的item.json文件来包含关于Item实体的信息:

//item.json
[
    {
        "Id": "86bff4f7-05a7-46b6-ba73-d43e2c45840f",
        "Name": "DAMN.",
        "Description": "DAMN. by Kendrick Lamar",
        "LabelName": "TDE, Top Dawg Entertainment",
        "Price": {
            "Amount": 34.5,
            "Currency": "EUR"
        },
        "PictureUri": "https://mycdn.com/pictures/45345345",
        "ReleaseDate": "2017-01-01T00:00:00+00:00",
        "Format": "Vinyl 33g",
        "AvailableStock": 5,
        "GenreId": "c04f05c0-f6ad-44d1-a400-3375bfb5dfd6",
        "Genre": null,
        "ArtistId": "3eb00b42-a9f0-4012-841d-70ebf3ab7474",
        "Artist": null
    },
    {
        "Id": "b5b05534-9263-448c-a69e-0bbd8b3eb90e",
        "Name": "GOOD KID, m.A.A.d CITY",
        "Description": "GOOD KID, m.A.A.d CITY. by Kendrick Lamar",
        "LabelName": "TDE, Top Dawg Entertainment",
        "Price": {
            "Amount": 23.5,
            "Currency": "EUR"
        },
        "PictureUri": "https://mycdn.com/pictures/32423423",
        "ReleaseDate": "2016-01-01T00:00:00+00:00",
        "Format": "Vinyl 33g",
        "AvailableStock": 6,
        "GenreId": "c04f05c0-f6ad-44d1-a400-3375bfb5dfd6",
        "Genre": null,
        "ArtistId": "3eb00b42-a9f0-4012-841d-70ebf3ab7474",
        "Artist": null
    },
    {
        "Id": "be05537d-5e80-45c1-bd8c-aa21c0f1251e",
        "Name": "Malibu",
        "Description": "Malibu. by Anderson Paak",
        "LabelName": "Steel Wool/OBE/Art Club",
        "Price": {
            "Amount": 23.5,
            "Currency": "EUR"
        },
        "PictureUri": "https://mycdn.com/pictures/32423423",
        "ReleaseDate": "2016-01-01T00:00:00+00:00",
        "Format": "Vinyl 43",
        "AvailableStock": 3,
        "GenreId": "c04f05c0-f6ad-44d1-a400-3375bfb5dfd6",
        "Genre": null,
        "ArtistId": "f08a333d-30db-4dd1-b8ba-3b0473c7cdab",
        "Artist": null
    }
]

这些文件包含一些种子数据,需要在每次测试之前添加到我们的数据库中。为了读取它们,我们需要在Catalog.Infrastructure.Tests项目中包含Newtonsoft.Json包,使用以下命令在项目文件夹中执行:

dotnet add package Newtonsoft.Json

我们还应该确保在编译步骤中将 JSON 文件复制到bin文件夹中,通过在Catalog.Infrastructure.Tests.csproj中添加以下代码来实现:

...
<ItemGroup>
  <None Update="Data\artist.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
  <None Update="Data\genre.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
  <None Update="Data\item.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>
..

下一步是实现一个从 JSON 读取数据并将其序列化到我们的数据库上下文中的方法。我们还应该在测试项目中添加Microsoft.EntityFrameworkCore NuGet 包,使用以下 CLI 命令:

dotnet add package Microsoft.EntityFrameworkCore

上述包将提供 EF Core 的ModelBuilder类型,该类型用于生成我们测试中使用的模拟数据。由于我们将使用Catalog.Infrastructure项目中实现的一些代码,我们应在解决方案根目录下使用以下命令将测试项目添加到引用中:

dotnet add ./tests/Catalog.Infrastructure.Tests reference ./src/Catalog.Infrastructure

之后,我们可以在Catalog.Infrastructure.Tests项目中的新Extensions文件夹内创建一个新的扩展方法,命名为Seed<T>

using System.IO;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;

namespace Catalog.Infrastructure.Tests.Extensions
{
    public static class ModelBuilderExtensions
    {
        public static ModelBuilder Seed<T>(this ModelBuilder 
            modelBuilder, string file) where T : class
        {
            using (var reader = new StreamReader(file))
            {
                var json = reader.ReadToEnd();
                var data = JsonConvert.DeserializeObject<T[]>(json);
                modelBuilder.Entity<T>().HasData(data);
            }

            return modelBuilder;
        }
    }
}

EF Core 2.1 通过公开 HasData<T> 方法引入了一种在数据库中执行数据播种的新方法。前面的代码允许我们读取 JSON 文件并将其序列化为由 modelBuilder 引用的实体。这种方法提供了一种使用 JSON 文件中写入的数据对模拟数据库进行播种的方法。

最后,我们可以在 Catalog.Infrastructure.Tests 项目中创建一个新的上下文,命名为 TestCatalogContext

using Microsoft.EntityFrameworkCore;
using Catalog.Domain.Entities;
using Catalog.Infrastructure.Tests.Extensions;

namespace Catalog.Infrastructure.Tests
{
    public class TestCatalogContext : CatalogContext
    {
        public TestCatalogContext(DbContextOptions<CatalogContext> options) : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder 
            modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            modelBuilder.Seed<Artist>("./Data/artist.json");
 modelBuilder.Seed<Genre>("./Data/genre.json");
 modelBuilder.Seed<Item>("./Data/item.json");
        }
    }
}

在这里,TestCatalogContext 类扩展了位于 Catalog.Infrastructure 项目的 CatalogContext 类,并重写了 OnModelCreating 方法以在实体上调用 Seed<T> 扩展方法。因此,当消费者使用 TestCatalogContext 初始化数据库时,它将具有在 JSON 中编写的所有预填充数据。

注意这里,TestCatalogContext 在构造函数中扩展了 DbContextOptions<CatalogContext> 选项,以便初始化 CatalogContext 基类。

初始化测试类

让我们在 Catalog.Infrastructure.Tests 项目中创建一个新的测试类,命名为 ItemRepositoryTests

using Xunit;

namespace Catalog.Infrastructure.Tests
{
    public class ItemRepositoryTests
    {
        [Fact]
        public void should_get_data()
        {
            Assert.True(true);
        }
    }
}

Xunit 框架使用 Fact 属性识别测试类。每个包含具有 Fact 属性的方法或,如本节稍后所示,具有 Theory 属性的类的类都将被视为测试,由单元测试运行器执行。

让我们继续添加我们的第一个测试方法。这个方法检查 ItemRepository 类的 GetAsync 方法:

using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Catalog.Infrastructure.Repositories;
using Xunit;

namespace Catalog.Infrastructure.Tests
{
    public class ItemRepositoryTests
    {
        [Fact]
        public async Task should_get_data()
        {
            var options = new DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase(databaseName: "should_get_data")
                .Options;

            await using var context = new TestCatalogContext(options);
            context.Database.EnsureCreated();

            var sut = new ItemRepository(context);
            var result = await sut.GetAsync();

            result.ShouldNotBeNull();
        }
    }
}

此代码使用 DbContextOptionsBuilder<T> 初始化一个新的 Options 对象,其类型为 CatalogContext。它还使用 UseInMemoryDatabase 扩展方法创建一个新的具有给定名称的内存数据库实例。由于 DbContext 由实现 IAsyncDisposable 类型的 CatalogContext 类扩展,因此可以使用 await using var 关键字。这种方法避免了任何类型的嵌套,并通过避免使用嵌套来提供更清晰的代码阅读方式:

...
    using (var context = new TestCatalogContext(options))
    {
        context.Database.EnsureCreated();
        var sut = new ItemRepository(context);

        var result = await sut.GetAsync();

        result.ShouldNotBeNull();
    }
...

要构建代码,需要在 Catalog.Infrastructure.Tests 项目中添加以下包:

dotnet add package Microsoft.EntityFrameworkCore.InMemory

UseInMemoryDatabase 扩展方法对于配置新的内存数据库实例很有用。需要注意的是,它不是设计为关系型数据库。此外,它不执行任何数据库完整性检查或约束检查。为了更合适的测试,我们应该使用 SQLite 的内存版本。您可以在以下文档中找到有关 SQLite 提供程序的更多信息:docs.microsoft.com/en-us/ef/core/miscellaneous/testing/sqlite

在创建新的 Options 对象之后,should_get_data 方法创建了一个新的 TestCatalogContext 实例,并调用了 EnsureCreated() 方法,该方法确保上下文存在于内存数据库中。EnsureCreate 方法还隐式地调用了 OnModelCreating 方法。之后,测试通过上下文初始化了一个新的 ItemRepository,并执行了 GetAsync 方法。最后,它使用 result.ShouldNotBeNull() 检查结果。

注意,本书中的所有测试示例都使用了 Shouldly 作为断言框架。Shouldly 专注于在断言失败时提供简洁明了的错误信息。可以通过使用 .NET Core 内置的默认断言框架来避免使用 Shouldly。有关 Shouldly 的更多信息,请参阅以下链接:github.com/shouldly/shouldly。您可以在 Catalog.Infrastructure.Tests 项目中执行以下 CLI 指令来添加 Shouldly 包:dotnet add package Shouldly

让我们继续实现 ItemRepository 类中所有方法的测试:

using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Catalog.Infrastructure.Repositories;
using Xunit;

namespace Catalog.Infrastructure.Tests
{
    public class ItemRepositoryTests
    {
        [Fact]
        public async Task should_get_data()
        {
            var options = new DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase("should_get_data")
                .Options;

            await using var context = new TestCatalogContext(options);
            context.Database.EnsureCreated();

            var sut = new ItemRepository(context);
            var result = await sut.GetAsync();

            result.ShouldNotBeNull();
        }

        [Fact]
        public async Task should_returns_null_with_id_not_present()
        {
            var options = new DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase(databaseName: 
                    "should_returns_null_with_id_not_present")
                .Options;

            await using var context = new TestCatalogContext(options);
            context.Database.EnsureCreated();

            var sut = new ItemRepository(context);
            var result = await sut.GetAsync(Guid.NewGuid());

            result.ShouldBeNull();
        }

        [Theory]
        [InlineData("b5b05534-9263-448c-a69e-0bbd8b3eb90e")]
        public async Task should_return_record_by_id(string guid)
        {
            var options = new DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase(databaseName: 
                    "should_return_record_by_id")
                .Options;

            await using var context = new TestCatalogContext(options);
            context.Database.EnsureCreated();

            var sut = new ItemRepository(context);
            var result = await sut.GetAsync(new Guid(guid));

            result.Id.ShouldBe(new Guid(guid));
        }
...

前面的代码片段定义了覆盖 GetAsync 方法的测试。第一个方法 should_get_data 测试了没有参数的 GetAsync() 重载,而第二个方法测试了 GetAsync(guid id) 重载。在这两种情况下,我们使用 InMemoryDatabase 来模拟底层数据源。在同一个 ItemRepositoryTests 类中,也可以定义与创建/更新操作相关的测试用例:

...
        [Fact]
        public async Task should_add_new_item()
        {
            var testItem = new Item
            {
                Name = "Test album",
                Description = "Description",
                LabelName = "Label name",
                Price = new Price { Amount = 13, Currency = "EUR" },
                PictureUri = "https://mycdn.com/pictures/32423423",
                ReleaseDate = DateTimeOffset.Now,
                AvailableStock = 6,
                GenreId = new Guid("c04f05c0-f6ad-44d1-a400-3375bfb5dfd6"),
                ArtistId = new Guid("f08a333d-30db-4dd1-b8ba-3b0473c7cdab")
            };

            var options = new DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase("should_add_new_items")
                .Options;

            await using var context = new TestCatalogContext(options);
            context.Database.EnsureCreated();

            var sut = new ItemRepository(context);

            sut.Add(testItem);
            await sut.UnitOfWork.SaveEntitiesAsync();

            context.Items
                .FirstOrDefault(_ => _.Id == testItem.Id)
                .ShouldNotBeNull();
        }

        [Fact]
        public async Task should_update_item()
        {
            var testItem = new Item
            {
                Id = new Guid("b5b05534-9263-448c-a69e-0bbd8b3eb90e"),
                Name = "Test album",
                Description = "Description updated",
                LabelName = "Label name",
                Price = new Price { Amount = 50, Currency = "EUR" },
                PictureUri = "https://mycdn.com/pictures/32423423",
                ReleaseDate = DateTimeOffset.Now,
                AvailableStock = 6,
                GenreId = new Guid("c04f05c0-f6ad-44d1-a400-3375bfb5dfd6"),
                ArtistId = new Guid("f08a333d-30db-4dd1-b8ba-3b0473c7cdab")
            };

            var options = new DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase("should_update_item")
                .Options;

            await using var context = new TestCatalogContext(options);
            context.Database.EnsureCreated();

            var sut = new ItemRepository(context);
            sut.Update(testItem);

            await sut.UnitOfWork.SaveEntitiesAsync();

            context.Items
                .FirstOrDefault(x => x.Id == testItem.Id)
                ?.Description.ShouldBe("Description updated");
        }
...
}

最后,ItemRepositoryTests 类为 ItemRepository 类实现的全部 CRUD 方法提供了测试覆盖率。should_get_datashould_returns_null_with_id_not_presentshould_return_record_by_id 方法执行 GetAsync 方法并检查结果是否符合预期。should_add_new_itemshould_update_item 测试用例为 ItemRepository.AddItemRepository.Update 方法提供了测试覆盖率。这两个测试都初始化了一个新的 Item 类型的记录,并通过 ItemRepository 类型公开的方法更新数据库。

因此,我们可以在 Catalog.Infrastructure.Tests 文件夹中执行以下命令来运行我们的测试:

 dotnet test 

前面的命令执行了项目中实现的所有测试。因此,结果将是一个包含成功测试列表的报告。作为替代,我们也可以选择使用 IDE 提供的测试运行器来运行测试。现在我们已经使用 EF Core 和代码优先方法完成了数据访问部分的实现,我们也可以快速了解一下 Dapper,以及它如何通过提供一种更轻量级的数据访问方式来发挥作用。

使用 Dapper 实现数据访问层

另一个提供实现数据访问层方法的标准化工具是 Dapper。我们已经对 Dapper 进行了概述,但本节将更详细地介绍如何处理这个包以及如何使用它来实现数据访问层。以下过程将更加侧重于 SQL。我们还将演示如何处理一些存储 CRUD 过程。

注意,EF Core 还提供了一种通过存储过程查询数据源的方法。此外,它公开了DbSet<TEntity>.FromSql()DbContext.Database.ExecuteSqlCommand()等方法。那么,为什么使用 Dapper 呢?如前所述,Dapper 是一个简单且比 EF Core 更快的微型 ORM。EF 更像是一个多用途 ORM,它在每个操作上都会增加一些额外的开销。

在开始之前,让我们在src文件夹内创建另一个名为Catalog.InfrastructureSP的项目,通过在src文件夹内运行以下命令:

dotnet new classlib -n Catalog.InfrastructureSP

在创建Catalog.InfrastructureSP项目之后,我们需要将其添加到我们的解决方案中:

dotnet sln ../Catalog.API.sln add Catalog.InfrastructureSP

上述命令将Catalog.InfrastructureSP项目包含到解决方案中。一旦我们设置了包含所有数据访问层替代实现的新项目,我们就可以通过使用 SQL-first 方法来实现项目的核心部分。

创建存储 CRUD 过程

在当前示例中,我们使用了一些实现创建、读取和更新操作的存储过程。在这本书中,我们不会深入探讨 SQL 服务器编程模型,但理解代码-first 方法不是唯一的方法是至关重要的。存储过程是实现服务与数据库之间交互的一种优秀方式。

存储过程是与数据库交互的最佳方式。开发者可以通过执行复杂查询并调用过程名称来继续操作。这种模块化方法在权限配置、更快的网络流量和更快的执行速度方面提供了一些好处。

首先,让我们创建用于读取数据的存储过程:

create procedure [catalog].[GetAllItems] 
as
begin
   select [Id]
       [Name]
      ,[Description]
      ,[LabelName]
      ,[Price]
      ,[PictureUri]
      ,[ReleaseDate]
      ,[Format]
      ,[AvailableStock]
      ,[GenreId]
      ,[ArtistId]
  from [catalog].[Items]
end

代码的第一个片段定义了GetAllItems存储过程。它返回整个项目集合。出于演示目的,该过程不包括任何性能优化。当我们对一个包含大量记录的大表执行select查询时,至少需要插入一个 top 语句以避免长时间运行的查询和超时问题。此外,在现实世界的应用程序中,很少看到没有特定过滤器的查询。让我们继续创建GetItemById过程:

create procedure [catalog].[GetItemById] 
   @Id uniqueidentifier
as
begin
   select [Id]
       [Name]
      ,[Description]
      ,[LabelName]
      ,[Price]
      ,[PictureUri]
      ,[ReleaseDate]
      ,[Format]
      ,[AvailableStock]
      ,[GenreId]
      ,[ArtistId]
  from [catalog].[Items] 
  where Id = @Id
end

这两个过程相当简单。第一个过程从catalog.Item表中选取所有记录。第二个过程接受一个Id作为参数,并允许我们检索相应的记录。

下一步是实现与创建和更新记录相关的操作。两种实现都非常简单——InsertItemUpdateItem 存储过程封装了 insertupdate SQL 语句:

create procedure [catalog].[InsertItem] (
 @Id uniqueidentifier,
 @Name nvarchar(max),
 @Description nvarchar(1000),
 @LabelName nvarchar(max) NULL,
 @Price nvarchar(max) NULL,
 @PictureUri nvarchar(max) NULL,
 @ReleaseDate datetimeoffset(7),
 @Format nvarchar(max) ,
 @AvailableStock int,
 @GenreId uniqueidentifier,
 @ArtistId uniqueidentifier
)
as
begin
  insert into  [catalog].[Items]  (Id, Name, Description,LabelName,Price,PictureUri, ReleaseDate,
  Format,AvailableStock, GenreId,ArtistId)
  output inserted.*
  values   (@Id,
            @Name,
            @Description,
            @LabelName,
            @Price,
            @PictureUri,
            @ReleaseDate,
            @Format,
            @AvailableStock,
            @GenreId,
            @ArtistId)
end

InsertItem 存储过程通过接受存储过程的参数来在数据库上执行简单的 insert 语句。让我们通过定义 UpdateItem 存储过程来继续:

create procedure [catalog].[UpdateItem] (
 @Id uniqueidentifier,
 @Name nvarchar(max),
 @Description nvarchar(1000),
 @LabelName nvarchar(max) NULL,
 @Price nvarchar(max),
 @PictureUri nvarchar(max) NULL,
 @ReleaseDate datetimeoffset(7) NULL,
 @Format nvarchar(max) ,
 @AvailableStock int,
 @GenreId uniqueidentifier,
 @ArtistId uniqueidentifier
)
as
begin
  update [catalog].[Items]
  set Name = @Name,
      Description = @Description,
      LabelName = @LabelName,
      Price = @Price,
      PictureUri = @PictureUri,
      ReleaseDate = @ReleaseDate,
      Format = @Format,
      AvailableStock = @AvailableStock,
      GenreId = @GenreId,
      ArtistId = @ArtistId
   output inserted.*
   where Id = @Id
end

注意,这两个操作都使用 output 语句来检索执行结果中插入或更新的记录。这样,我们可以从我们的存储库模式中检索更新记录而无需额外努力。

Microsoft SQL Server 提供了一种使用 output 操作符返回插入或删除数据的方法。它返回由 INSERTUPDATEDELETEMERGE 语句影响的每一行信息。

最后,为了使这些脚本正常工作,必须在我们的数据库中执行它们。我建议使用之前提到的 SQL Operations Studio 工具或另一个 SQL 客户端来在 Catalog 数据库中运行这些脚本。

实现 IItemRepository 接口

使用 EF Core 实现数据访问层 部分,我们使用了两个不同的接口来完成工作:IItemRepository,它包含所有 CRUD 操作,以及 IUnitOfWork,它涵盖了工作单元模式。对于每个 CRUD 操作,我们需要调用 IUnitOfWork 接口来将我们的更改保存到数据库中。另一方面,作为微 ORM 的 Dapper 应用不需要提供工作单元接口,因为 ORM 直接使用存储过程在数据库上执行查询。因此,我们不再需要实现 IRepository 接口,相应地,我们也不再实现 IUnitOfWork 接口。

因此,作为第一步,我们应该从我们的 IItemRepository 接口中移除 IRepository 接口的实现。此外,在这种情况下,我们可以看到依赖反转的真正力量:Catalog.Domain 不依赖于 Catalog.Infrastructure. 它也可以更改合同和需求,并迫使 Catalog.Infrastructure 改变其行为:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Catalog.Domain.Infrastructure.Entities;

namespace Catalog.Domain.Infrastructure.Repositories
{
    public interface IItemsRepository  
    {
        Task<IEnumerable<Item>> GetAsync();
        Task<Item> GetAsync(Guid id);
        Item Add(Item order);
        Item Update(Item item);
        Item Delete(Item item);
    }
}

下一步是将 Dapper 添加到我们的 Catalog.InfrastructureSP 项目中,通过执行以下命令:

 dotnet add package Dapper

让我们通过在 Catalog.InfrastructureSP 项目中使用 ItemRepository 类来实现 IItemRepository 接口:

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Threading.Tasks;
using Dapper;
using Catalog.Domain.Entities;
using Catalog.Domain.Infrastructure.Repositories;

namespace Catalog.InfrastructureSP
{
    public class ItemRepository : IItemRepository
    {
        private readonly SqlConnection _sqlConnection;

        public ItemRepository(string connectionString)
        {

            _sqlConnection = new SqlConnection(connectionString);
        }

        public async Task<IEnumerable<Item>> GetAsync()
        {
            var result = await _sqlConnection.QueryAsync<Item>
                ("GetAllItems",  commandType: 
                CommandType.StoredProcedure);
            return result.AsList();
        }

        public async Task<Item> GetAsync(Guid id)
        {
            return await _sqlConnection.ExecuteScalarAsync<Item>
                ("GetAllItems", new {Id = id.ToString()}, commandType: 
                CommandType.StoredProcedure);
        }

        public Item Add(Item order)
        {
            var result = _sqlConnection.ExecuteScalar<Item>
            ("InsertItem", order, commandType:CommandType.StoredProcedure);
            return result;
        }

        public Item Update(Item item)
        {
            var result = _sqlConnection.ExecuteScalar<Item>
                ("UpdateItem", item, commandType: 
                CommandType.StoredProcedure);
            return result;
        }

        public Item Delete(Item item)
        {
            throw new NotImplementedException();
        }
    }
}

为了初始化我们的具体类,必须在 ItemRepository 类的构造函数中将 connectionString 传递给 SQL 数据库。

如您所见,Dapper 方法与 EF Core 完全不同。它不会给我们的数据源添加任何特定的开销;它只是通过填充请求的参数来执行上述存储过程。

摘要

本章描述了如何使用 EF Core 和 Dapper 构建数据访问层。它还展示了如何使用内存数据库构建单元测试,以及如何使用 EF Core 执行迁移。我想重申,EF Core 和 Dapper 之间的选择取决于不同的参数:我们正在构建的服务类型、团队成员的技能以及我们使用的类型基础设施。

本章涵盖的主题提供了使用代码优先和存储过程方法访问.NET Core 数据源所需的知识。本章介绍了 EF Core 和 Dapper 等技术的使用。此外,它还展示了如何使用内存方法测试数据访问层。

在下一章中,我们将演示如何实现处理程序和我们的服务逻辑。

第九章:实现领域逻辑

本章重点介绍目录 Web 服务的逻辑层。如前所述,逻辑将被封装在 Catalog.Domain 项目中。本章展示了如何使用服务类方法实现应用程序逻辑。这些类的目的是在请求和数据源层实际使用的实体之间执行映射逻辑,并提供我们应用程序所需的所有附加逻辑。此外,我们还将看到如何测试实现代码以验证行为。

本章将涵盖以下主题:

  • 如何实现我们应用程序的服务类

  • 如何实现请求 DTO 和相关的验证系统

  • 如何应用测试来验证实现的逻辑

以下章节中的代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3

实现服务类

让我们通过实现服务类来继续本章的具体部分。这一层抽象将定义查询数据层的各种方法,包括 IItemRepository 接口,并将结果数据映射。

如前所述,我们的服务实现将使用 DTO 类来通过堆栈传递数据。首先,让我们在 Catalog.Domain 项目中创建一个新的 Requests/Item 文件夹结构,并在文件夹中添加一个新的 AddItemRequest.cs 文件:

using System;
using Catalog.Domain.Entities;

namespace Catalog.Domain.Requests.Item
{
    public class AddItemRequest 
    {
        public string Name { get; set; }
        public string Description { get; set; }
        public string LabelName { get; set; }
        public Price Price { get; set; }
        public string PictureUri { get; set; }
        public DateTimeOffset ReleaseDate { get; set; }
        public string Format { get; set; }
        public int AvailableStock { get; set; }
        public Guid GenreId { get; set; }
        public Guid ArtistId { get; set; }
    } 
}

前面的代码定义了添加项目请求。该类与 Item 实体类非常相似,除了 Id 字段、Artist 字段和 Genre 字段不存在。此外,Id 字段将由 EF Core 实现,ArtistGenre 字段由 ORM 处理,以表示实体之间的关系。

同样,我们可以在同一文件夹中定义 EditItemRequest 类:

using System;
using Catalog.Domain.Entities;

namespace Catalog.Domain.Requests.Item
{
    public class EditItemRequest
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public string LabelName { get; set; }
        public Price Price { get; set; }
        public string PictureUri { get; set; }
        public DateTimeOffset ReleaseDate { get; set; }
        public string Format { get; set; }
        public int AvailableStock { get; set; }
        public Guid GenreId { get; set; }
        public Guid ArtistId { get; set; }
    }
}

在前面的代码片段中,该类包含与 Item 实体相同的字段,除了 ArtistGenre 字段,原因与前面描述的相同。正如您可以从类名中理解的那样,它代表更新项目操作。同样的方法也可以用于以下所示获取项目操作:

using System;

namespace Catalog.Domain.Requests.Item
{
    public class GetItemRequest
    {
        public Guid Id { get; set; }
    }
}

可能看起来有点冗余,为单个字段定义一个请求类。尽管如此,我们应该考虑我们的服务收到的 HTTP 请求可能会随时间变化。因此,这种方法确保我们能够在不向服务类的各种方法添加大量参数的情况下,使我们的请求能够进化。此外,将我们的传入请求表示为类提供了一个轻松的方式来版本化随时间演变的不同的请求类型。

让我们继续定义我们的服务类所使用的响应类。此外,在响应类的案例中,理解这一点至关重要:这种方法确保我们有一种避免向我们的网络服务客户端暴露所有字段的方法。作为第一步,我们需要在Catalog.Domain项目中定义一个新的Responses文件夹,并创建以下类:

// /Responses/Item/PriceResponse.cs
namespace Catalog.Domain.Responses
{
    public class PriceResponse
    {
        public decimal Amount { get; set; }
        public string Currency { get; set; }
    }
}

// /Response/Item/ArtistResponse.cs
using System;

namespace Catalog.Domain.Responses
{
    public class ArtistResponse
    {
        public Guid ArtistId { get; set; }
        public string ArtistName { get; set; }
    }
}

// /Response/Item/GenreResponse.cs
using System;

namespace Catalog.Domain.Responses
{
    public class GenreResponse
    {
        public Guid GenreId { get; set; }
        public string GenreDescription { get; set; }
    }
}

为了简洁起见,我已将PriceResponseGenreResponseArtistResponse类的实现定义在一个代码片段中。这些类定义了在数据库端使用的相同实体类所使用的字段。除此之外,我们还将定义一个ItemReposonse类,它代表我们的服务响应:

using System;

namespace Catalog.Domain.Responses
{
    public class ItemResponse
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public string LabelName { get; set; }
        public PriceResponse Price { get; set; }
        public string PictureUri { get; set; }
        public DateTimeOffset ReleaseDate { get; set; }
        public string Format { get; set; }
        public int AvailableStock { get; set; }
        public Guid GenreId { get; set; }
        public GenreResponse Genre { get; set; }
        public Guid ArtistId { get; set; }
        public ArtistResponse Artist { get; set; }
    }
}

ItemResponse类引用其他响应类,以避免相关实体中包含的响应数据不匹配。此外,IItemRepository实现将使用我们在上一章中查看的Include扩展方法加载相关实体的所有数据,并且,正如我们稍后将看到的,数据将被映射到响应类型中。

服务类接口

由于我们已经定义了我们的服务所需的所有请求和响应类型,我们现在可以通过定义IItemService接口及其实现来继续前进。作为第一步,我们可以在Catalog.Domain项目中创建一个新的Services文件夹,并定义以下IItemService接口:

using System.Collections.Generic;
using System.Threading.Tasks;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Services
{
    public interface IItemService
    {
        Task<IEnumerable<ItemResponse>> GetItemsAsync();
        Task<ItemResponse> GetItemAsync(GetItemRequest request);
        Task<ItemResponse> AddItemAsync(AddItemRequest request);
        Task<ItemResponse> EditItemAsync(EditItemRequest request);
        Task<ItemResponse> DeleteItemAsync(DeleteItemRequest request);
    }
}

上述定义暴露了我们的应用程序所需的方法。首先,我们应该注意到所有函数都返回一个Task<T>泛型类型。我们还可以看到所有方法都以Async前缀结尾,这表明实现将是异步的。

实现映射层

本小节和下一小节描述了我们可以在我们应用程序中使用的两种映射方法的实现:手动方法反射方法

手动方法涉及定义和实现我们自己的映射类:

using Catalog.Domain.Entities;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Mappers
{
    public interface IItemMapper
    {
        Item Map(AddItemRequest request);
        Item Map(EditItemRequest request);
        ItemResponse Map(Item item);
    }
}

上述代码定义了一个IItemMapper接口,它提供了两个方法来映射Item类型中的AddItemRequestEditItemRequest。此外,它还定义了将Item类型转换为ItemResponse实例的映射方法签名。这种策略可以通过以下ItemMapper类实现:

using Catalog.Domain.Entities;
using Catalog.Domain.Requests.Item;

namespace Catalog.Domain.Mappers
{
    public class ItemMapper : IItemMapper
    {
        public Item Map(AddItemRequest request)
        {
            if (request == null) return null;

            var item = new Item
            {
                Name = request.Name,
                Description = request.Description,
                LabelName = request.LabelName,
                PictureUri = request.PictureUri,
                ReleaseDate = request.ReleaseDate,
                Format = request.Format,
                AvailableStock = request.AvailableStock,
                GenreId = request.GenreId,
                ArtistId = request.ArtistId,
            };

            if (request.Price != null)
            {
                item.Price = new Price { Currency = request.Price.Currency, 
                 Amount = request.Price.Amount };
            }

            return item;
        }

        public Item Map(EditItemRequest request)
        {
            if (request == null) return null;

            var item = new Item
            {
                Id = request.Id,
                Name = request.Name,
                ...
                Format = request.Format,
                AvailableStock = request.AvailableStock,
                GenreId = request.GenreId,
                ArtistId = request.ArtistId,
            };

            if (request.Price != null)
            {
                item.Price = new Price { Currency = request.Price.Currency, 
                 Amount = request.Price.Amount };
            }

            return item;
        }
    }
}

请注意,为了简洁,我已经省略了映射中定义的实体所有字段,你可以在以下存储库中找到完整的映射类文件github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3/tree/master/Chapter09/CatalogIItemMapper接口和ItemMapper类都位于Catalog.Domain项目的Mappers文件夹中。ItemMapper实现需要在开发方面投入一些开销,但它可以精确地完成你所需要的功能,而不产生任何运行时成本,例如反射。除此之外,逻辑被封装在单独的类中。同样的方法也可以应用于ItemResponse映射——在这种情况下,我们还需要为ArtistGenre实体创建一些隔离的映射器:

using Catalog.Domain.Entities;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Mappers
{
    public interface IArtistMapper
    {
        ArtistResponse Map(Artist artist);
    }

    public class ArtistMapper : IArtistMapper
    {
        public ArtistResponse Map(Artist artist)
 {
           if (artist == null) return null; return new ArtistResponse
            {
 ArtistId = artist.ArtistId,
 ArtistName = artist.ArtistName
            };
 }
    }
}

为了简洁起见,我将接口和具体实现包含在一个独特的代码片段中。IArtistMapper公开了一个名为Map的方法,它根据Artist实体类初始化一个新的ArtistResponse。对于Genre实体,这种方法将是相同的:

using Catalog.Domain.Entities;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Mappers
{
    public interface IGenreMapper
    {
        GenreResponse Map(Genre genre);
    }

    public class GenreMapper : IGenreMapper
    {
        public GenreResponse Map(Genre genre)
 {
           if (genre == null) return null; return new GenreResponse
            {
 GenreId = genre.GenreId,
 GenreDescription = genre.GenreDescription
            };
 }
    }
}

此外,在这种情况下,我们将Genre定义为GenreResponse映射。这两个映射类都可以独立使用或被其他映射器引用。一旦我们实现了ArtistGenre的映射逻辑,我们就可以将它们引用到IItemMapper中,以便定义ItemReponse Map(Item item)映射方法的实现:

using Catalog.Domain.Entities;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Mappers
{
    public class ItemMapper : IItemMapper
    {
        private readonly IArtistMapper _artistMapper;
 private readonly IGenreMapper _genreMapper; 
        public ItemMapper(IArtistMapper artistMapper, IGenreMapper 
            genreMapper)
        {
            _artistMapper = artistMapper;
 _genreMapper = genreMapper;
        }

        ...

      public ItemResponse Map(Item item)
 {
            if (request == null) return null;
            var response = new ItemResponse
            {
                Id = request.Id,
                Name = request.Name,
                ...
                GenreId = request.GenreId,
                Genre = _genreMapper.Map(request.Genre),
                ArtistId = request.ArtistId,
                Artist = _artistMapper.Map(request.Artist),
            };

            if (request.Price != null)
            {
                response.Price = new PriceResponse { Currency = 
                 request.Price.Currency, Amount = request.Price.Amount };
            }

            return response;
        }
   }
}

我们已经更改了ItemMapper实现类,并将依赖项与IArtistMapperIGenreMapper接口相结合。此外,我们可以使用我们刚刚定义的Map方法来根据Item实体创建ItemResponse实例。你可能已经注意到,我没有为PriceResponse实现映射类。这是因为像Price这样的实体不太可能发生变化。另一个需要注意的关键部分是,我们在映射接口及其实现之间缺少依赖注入的初始化;这部分将在本章后面进行介绍。

最后,我想明确指出,这并不是实现映射层在我们应用程序中的唯一方法。实际上,还有其他模式,例如使用扩展方法。让我们以ArtistArtistResponse的映射为例:

public static class MappingExtensions
{
    public static ArtistResponse MapToResponse(this Artist artist)
    {
        return new ArtistResponse
        {
            ArtistId = artist.ArtistId,
            ArtistName = artist.ArtistName
        };
    }
}

上述代码定义了一个新的MappingExtensions静态类,它可以作为所有我们需要用于映射逻辑的扩展方法的容器。此外,我们还可以定义一个MapToResponse扩展方法,可以应用于以下方式中的Artist实体:

ArtistResponse artistResponse = artistEntity.MapToResponse();

扩展方法方法可以应用于领域模型的所有实体。尽管这种方法看起来更加直接,但它没有突出显示服务类与映射逻辑之间的依赖关系。因此,我更喜欢通过使用单独的类来实现映射,因为它提供了更好地理解应用程序依赖流的方式。

使用 Automapper 的映射逻辑

另一种方法是使用 Automapper NuGet 包来实现映射。如第五章中所述,“ASP.NET Core 中的 Web 服务堆栈”,这种方法利用.NET 提供的反射系统来匹配和映射我们类中的字段。可以使用以下 CLI 指令将 Automapper 包添加到 Catalog.Domain 项目中:

dotnet add package Automapper

Automapper 使用基于配置的结构来定义我们类的映射行为。让我们继续在 Mappers 文件夹中定义一个新的 CatalogProfile 类:

using AutoMapper;
using Catalog.Domain.Entities;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Responses.Item;

namespace Catalog.Domain.Mapper
{
    public class CatalogProfile : Profile
    {
        public CatalogProfile()
        {
            CreateMap<ItemResponse, Item>().ReverseMap();
            CreateMap<GenreResponse, Genre>().ReverseMap();
            CreateMap<ArtistResponse, Artist>().ReverseMap();
            CreateMap<Price, PriceResponse>().ReverseMap();
            CreateMap<AddItemRequest, Item>().ReverseMap();
            CreateMap<EditItemRequest, Item>().ReverseMap();
        }
    }
}

上述配置将由依赖注入引擎用于定义映射行为的列表。Profile 基类提供的 CreateMap 方法匹配两个泛型类型:TSourceTDestination。通过链式调用 ReverseMap() 扩展方法,也可以执行反向过程。这可以应用于我们在应用程序中定义的每个请求和响应类型。为了在我们的方法中使用映射逻辑,必须将 IMapper 类型注入到目标类中,并按以下方式执行 Map 方法:

_mapper.Map<ItemResponse>(new Item());

重要的是要注意,在以下情况下,Map 方法将抛出运行时异常:

  • 映射的源和目标类型不对应

  • 对应的源和目标映射在配置中未显式定义

  • 实体中存在一些未映射的成员(这防止了映射目标中的意外 null 字段)

最后,Automapper 还需要使用 .NET Core 的依赖注入进行初始化。我们将在本章后面看到如何将 Automapper 添加到 DI 引擎中。

服务类实现

一旦完成映射层,我们就可以继续实现服务层。让我们首先在 Catalog.Domain 项目的 Services 文件夹中定义 ItemService.cs 文件。以下代码描述了构造函数方法和读取操作:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Mappers;
using Catalog.Domain.Repositories;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Services
{
    public class ItemService : IItemService
    {
        private readonly IItemRepository _itemRepository;
        private readonly IItemMapper _itemMapper;

        public ItemService(IItemRepository itemRepository, 
 IItemMapper itemMapper)
        {
 _itemRepository = itemRepository;
 _itemMapper = itemMapper;
        }

        public async Task<IEnumerable<ItemResponse>> GetItemsAsync()
        {
            var result = await _itemRepository.GetAsync();

            return result.Select(x => _itemMapper.Map(x));
        }

        public async Task<ItemResponse> GetItemAsync(GetItemRequest 
         request)
        {
            if (request?.Id == null) throw new ArgumentNullException();

            var entity = await _itemRepository.GetAsync(request.Id);

            return _itemMapper.Map(entity);
        }
    }
}

首先,我们可以看到该类同时引用了 IItemRepositoryIItemMapper 接口,这些接口是通过构造函数注入技术注入的。这个小节还描述了 GetItemsAsyncGetItemAsync 函数的实现。这两个方法都使用 IItemRepository 接口从数据源检索数据,并使用 IItemMapper 接口在 Item 实体和 ItemResponse 之间进行映射。编写操作也可以采用相同的方法,具体实现如下:


namespace Catalog.Domain.Services
{
    public class ItemService : IItemService
    {
        ...

        public async Task<ItemResponse> AddItemAsync(AddItemRequest 
         request)
        {
            var item = _itemMapper.Map(request);
            var result = _itemRepository.Add(item);

            await _itemRepository.UnitOfWork.SaveChangesAsync();

            return _itemMapper.Map(result);
        }

        public async Task<ItemResponse> EditItemAsync(EditItemRequest 
         request)
        {
            var existingRecord = await 
             _itemRepository.GetAsync(request.Id);

            if (existingRecord == null)
            {
                throw new ArgumentException($"Entity with {request.Id} 
                 is not present");
            }

            var entity = _itemMapper.Map(request);
            var result = _itemRepository.Update(entity);

            await _itemRepository.UnitOfWork.SaveChangesAsync();

            return _itemMapper.Map(result);
        }
    }
}

此外,在写入操作的情况下,它们使用 IItemMapper 实例将请求的类型与 Item 实体类型进行映射,并检索 ItemResponse 类型。此外,它们通过调用 IItemRepository 实例执行操作,随后调用 SaveChangesAsync 方法将那些更改应用到数据库中。一旦我们实现了服务层,我们就可以继续测试该类并验证实现。

测试服务层

本节涵盖了之前实现的服务层部分的测试。正如我们在第八章,“构建数据访问层”中所做的那样,我们需要设置一个模拟的目录上下文,为测试服务类提供必要的数据。Catalog.Infrastructure 项目已经实现了自己的 TestCatalogContext 类和 ModelBuilderExtensions 类。此外,我们可以使用这两个相同的类来实现服务层的测试。我们需要的只是对 Catalog.Infrastructure 项目进行一点重构和优化。

重构测试类

在实现了 ItemsRepositoryTests 类型后,在第八章,“构建数据访问层”中,你可能会注意到我们在 ItemRepositoryTests 类中使用了重复的模式:

... 
  var options = new DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase(databaseName: "should_get_data")
                .Options;

            await using var context = new TestCatalogContext(options);
            context.Database.EnsureCreated();

            var sut = new ItemRepository(context);

...

之前的小节内容已经复制到迄今为止编写的每一个测试方法中。我们可以通过将实现提取到不同的类型来改进我们的测试代码。xunit 框架通过提供一个名为 IClassFixture 的接口,提供了一种在同一个测试类的方法之间共享测试上下文的方法。

IClassFixture 是一个泛型类型,它构成一个单独的测试上下文,并在类中的所有测试方法之间共享。因此,xunit 框架在类中的所有测试完成后清理固定装置。我们将要实现的 IClassFixture 接口将被 Catalog.Infrastructure.Tests 项目和 Catalog.Domain.Tests 项目使用。因此,我们可以在一个独特的 Catalog.Fixtures 项目中通用化实现。

让我们从在 tests 文件夹中创建新的项目开始:

dotnet new xunit -n Catalog.Fixtures -f netcoreapp3.1

dotnet sln ../Catalog.API.sln add  ./Catalog.Fixtures/Catalog.Fixtures.csproj

上述指令创建了一个新的 Catalog.Fixtures 项目并将其添加到解决方案中。之后,我们可以继续添加依赖项:

dotnet add ./Catalog.Fixtures reference ../src/Catalog.Domain/ dotnet add ./Catalog.Fixtures reference ../src/Catalog.Infrastructure/

最后,我们可以将之前在 Catalog.Infrastructure.Tests 项目中实现的全部类移动到刚刚创建的 Catalog.Fixtures 项目中:TestCatalogContext.csExtensions/ModelBuilderExtensions.cs 以及所有 .json 文件。

让我们继续创建一个新的 CatalogContextFactory 类,该类将被 IClassFixture 接口引用:

using System;
using Catalog.Domain.Mappers;
using Catalog.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Fixtures
{
    public class CatalogContextFactory
    {
        public readonly TestCatalogContext ContextInstance;
        public readonly IGenreMapper GenreMapper;
        public readonly IArtistMapper ArtistMapper;
        public readonly IItemMapper ItemMapper;

        public CatalogContextFactory()
        {
            var contextOptions = new 
                DbContextOptionsBuilder<CatalogContext>()
                .UseInMemoryDatabase(Guid.NewGuid().ToString())
                .EnableSensitiveDataLogging()
                .Options;

            EnsureCreation(contextOptions);
            ContextInstance = new TestCatalogContext(contextOptions);

            GenreMapper = new GenreMapper();
            ArtistMapper = new ArtistMapper();
            ItemMapper = new ItemMapper(ArtistMapper, GenreMapper);
        }

        private void EnsureCreation(DbContextOptions<CatalogContext> 
         contextOptions)
        {
            using var context = new TestCatalogContext(contextOptions);
            context.Database.EnsureCreated();
        }
    }
}

CatalogContextFactory 类使用先前分配的 contextOptions 对象定义了一个新的 TestCatalogContext 实例。需要注意的是,我们正在使用 Guid.NewGuid().ToString() 属性作为数据库名来构建 ContextOptions,以便为每个测试类提供一个新的、干净的内存实例。此外,该类还初始化了三个类型为 IGenreMapperIArtistMapperIItemMapper 的属性,这些属性将被服务层测试用于执行字段的映射。

因此,我们可以在测试中使用以下构造函数注入方法来访问工厂类的实例:

using System;
using System.Linq;
using System.Threading.Tasks;
using Catalog.Domain.Entities;
using Catalog.Fixtures;
using Catalog.Infrastructure.Repositories;
using Newtonsoft.Json;
using Shouldly;
using Xunit;

namespace Catalog.Infrastructure.Tests
{
    public class ItemRepositoryTests :
        IClassFixture<CatalogContextFactory>
    {
        private readonly ItemRepository _sut;
 private readonly TestCatalogContext _context;

        public ItemRepositoryTests(CatalogContextFactory catalogContextFactory)
        {
            _context = catalogContextFactory.ContextInstance;
 _sut = new ItemRepository(_context);
        }
...

IClassFixture 接口包含对刚刚创建的工厂类的引用。依赖关系将在测试类的构造函数中运行时解析。请注意,整个实例在各个唯一的测试类之间是共享的。因此,测试类中的每个测试方法都将与其他方法共享相同的数据库快照。

最后,我们可以重构 ItemRepositoryTests 类以使用新的 CatalogContextFactory 实现。例如,如果我们以 should_add_new_item 测试方法为参考,我们可以按以下方式进行:


    public class ItemRepositoryTests : IClassFixture<CatalogContextFactory>
    {
        private readonly ItemRepository _sut;
 private readonly TestCatalogContext _context;

 public ItemRepositoryTests(CatalogContextFactory 
            catalogContextFactory)
 {
 _context = catalogContextFactory.ContextInstance;
 _sut = new ItemRepository(_context);
 }

        [Fact]
        public async Task should_add_new_item()
        {
            var testItem = new Item
            {
                Name = "Test album",
                Price = new Price { Amount = 13, Currency = "EUR" },
                GenreId = new Guid("c04f05c0-f6ad-44d1-a400-
                    3375bfb5dfd6"),
                ArtistId = new Guid("f08a333d-30db-4dd1-b8ba-
                    3b0473c7cdab"),
                ...
            };

            _sut.Add(testItem);
            await _sut.UnitOfWork.SaveEntitiesAsync();

            _context.Items
                .FirstOrDefault(item => item.Id == testItem.Id)
                .ShouldNotBeNull();
        }
    }

_sut 类属性用于执行我们想要测试的实际操作。例如,在上面的测试用例中,我们正在验证 ItemRepository 类公开的 Add 方法。_context 属性用于验证结果。这种方法通过为测试提供更好的可维护性,确保了我们的测试代码在不同测试类之间的可重用性。所有数据都由 CatalogContextFactory 类型提供,该类型使用 ASP.NET Core 提供的内存数据库技术将数据存储在内存中,并模拟对真实数据库的数据操作。

正如我们在 ItemRepositoryTests 类中所做的那样,我们还将看到如何在服务层测试中使用 CatalogContextFactory 类。

本节中的代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3

实现 ItemService 测试

让我们继续通过实现 ItemService 测试部分来继续。首先,我们应该通过在 tests 文件夹中使用以下 CLI 指令创建一个新的 Catalog.Domain.Tests 项目来开始:

dotnet new xunit -n Catalog.Domain.Tests -f netcoreapp3.1

上述命令在tests文件夹中创建了一个新的Catalog.Domain.Tests项目。因此,我们可以通过以下指令将新项目添加到解决方案中:

dotnet sln ../Catalog.API.sln add Catalog.Domain.Tests 

此外,测试项目还有一些依赖项。此外,我们可以通过以下命令将引用添加到Catalog.Domain.Tests文件夹中:

dotnet add reference ../Catalog.Fixtures
dotnet add reference ../../src/Catalog.Domain
dotnet add package Shouldly

之后,我们创建一个新的ItemServiceTests.cs文件,其实现如下:

using System;
using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Entities;
using Catalog.Domain.Mappers;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Services;
using Catalog.Fixtures;
using Catalog.Infrastructure.Repositories;
using Shouldly;
using Xunit;

namespace Catalog.Domain.Tests.Services
{
    public class ItemServiceTests : 
        IClassFixture<CatalogContextFactory>
    {
 private readonly ItemRepository _itemRepository;
 private readonly IItemMapper _mapper;

 public ItemServiceTests(CatalogContextFactory catalogContextFactory)
 {
 _itemRepository = new ItemRepository(catalogContextFactory.ContextInstance);
 _mapper = catalogContextFactory.ItemMapper;
 }

        [Fact]
        public async Task getitems_should_return_right_data()
        {
            ItemService sut = new ItemService(_itemRepository, _mapper);

            var result = await sut.GetItemsAsync();
            result.ShouldNotBeNull();
        }

        [Theory]
        [InlineData("b5b05534-9263-448c-a69e-0bbd8b3eb90e")]
        public async Task getitem_should_return_right_data(string guid)
        {
            ItemService sut = new ItemService(_itemRepository, _mapper);

            var result = await sut.GetItemAsync(new GetItemRequest { Id = new Guid(guid) });

            result.Id.ShouldBe(new Guid(guid));
        }

        [Fact]
        public void getitem_should_thrown_exception_with_null_id()
        {
            ItemService sut = new ItemService(_itemRepository, _mapper);

            sut.GetItemAsync(null).ShouldThrow<ArgumentNullException>();
        }
    }
}

上述代码测试了ItemService类读取操作的实现。ItemServiceTests类使用CatalogContextFactory类型来初始化并获取服务所使用的基本数据。每个测试方法都使用_itemRepository类属性和_mapper实例来初始化一个新的ItemService并验证由服务类提供的GetItemAsyncGetItemsAsync方法。

同样,我们可以使用相同的技巧来实现写入操作的测试:


    public class ItemServiceTests : 
        IClassFixture<CatalogContextFactory>
    {
        private readonly ItemRepository _itemRepository;
        private readonly IItemMapper _mapper;

        public ItemServiceTests(CatalogContextFactory catalogContextFactory)
        {
            _itemRepository = new ItemRepository(catalogContextFactory.ContextInstance);
            _mapper = catalogContextFactory.ItemMapper;
        }

        ...

        [Fact]
 public async Task additem_should_add_right_entity()
        {
            var testItem = new AddItemRequest
            {
                Name = "Test album",
                GenreId = new Guid("c04f05c0-f6ad-44d1-a400-3375bfb5dfd6"),
                ArtistId = new Guid("f08a333d-30db-4dd1-b8ba-3b0473c7cdab"),
                Price = new Price { Amount = 13, Currency = "EUR" }
                ...
            };

            IItemService sut = new ItemService(_itemRepository, _mapper);

            var result = await sut.AddItemAsync(testItem);

            result.Name.ShouldBe(testItem.Name);
            result.Description.ShouldBe(testItem.Description);
            result.GenreId.ShouldBe(testItem.GenreId);
            result.ArtistId.ShouldBe(testItem.ArtistId);
            result.Price.Amount.ShouldBe(testItem.Price.Amount);
            result.Price.Currency.ShouldBe(testItem.Price.Currency);
        }

        [Fact]
 public async Task edititem_should_add_right_entity()
        {
            var testItem = new EditItemRequest
            {
                Id = new Guid("b5b05534-9263-448c-a69e-0bbd8b3eb90e"),
                Name = "Test album",
                GenreId = new Guid("c04f05c0-f6ad-44d1-a400-3375bfb5dfd6"),
                ArtistId = new Guid("f08a333d-30db-4dd1-b8ba-3b0473c7cdab"),
                Price = new Price { Amount = 13, Currency = "EUR" }
                ...
            };

            ItemService sut = new ItemService(_itemRepository, _mapper);

            var result = await sut.EditItemAsync(testItem);

            result.Name.ShouldBe(testItem.Name);
            result.Description.ShouldBe(testItem.Description);
            result.GenreId.ShouldBe(testItem.GenreId);
            result.ArtistId.ShouldBe(testItem.ArtistId);
            result.Price.Amount.ShouldBe(testItem.Price.Amount);
            result.Price.Currency.ShouldBe(testItem.Price.Currency);
        }
    }
}

additem_should_add_the_entityedititem_should_edit_the_entity方法正在验证IItemMapper逻辑实现以及IItemService实现。这种方法可以用来测试服务类层的逻辑。在这种情况下,映射逻辑并不复杂。此外,我还建议在映射逻辑更复杂的情况下实现单独的测试。

最后,我们可以在解决方案文件夹中通过执行dotnet test CLI 指令或在首选的 IDE 测试运行器中运行我们刚刚实现的测试用例。CLI 结果应该类似于以下内容:

图片

上述报告列出了由dotnet test命令执行的测试,并提供了成功和失败测试的概述。此外,还可以通过在dotnet test -v <q[uiet], m[inimal], n[ormal], d[etailed], and diag[nostic]>命令旁边附加-v选项来指定测试的详细程度。

实现请求模型验证

Catalog.Domain项目也拥有我们请求模型的验证逻辑。在本节中,我们将看到如何实现请求验证逻辑部分,这部分也将被我们的控制器用来验证传入的数据。在这里,我通常依赖于FluentValidation包,它提供了一种非常易于阅读的方式来对每种类型的对象和数据结构执行验证检查。

为了将FluentValidation包添加到我们的Catalog.Domain项目中,我们可以在项目文件夹中执行以下命令:

dotnet add package FluentValidation
dotnet add package FluentValidation.AspNetCore

FluentValidation包公开了AbstractValidation类,它可以被扩展以实现针对请求模型类的自定义验证标准:

//Requests/Item/Validators/AddItemRequestValidator.cs using FluentValidation;

namespace Catalog.Domain.Requests.Item.Validators
{
    public class AddItemRequestValidator : AbstractValidator<AddItemRequest>
    {
        public AddItemRequestValidator()
        {
            RuleFor(x => x.GenreId).NotEmpty();
            RuleFor(x => x.ArtistId).NotEmpty();
            RuleFor(x => x.Price).NotEmpty();
            RuleFor(x => x.ReleaseDate).NotEmpty();
            RuleFor(x => x.Name).NotEmpty();
            RuleFor(x => x.Price).Must(x => x?.Amount > 0);
            RuleFor(x => x.AvailableStock).Must(x => x > 0);
        }
    }
}
//Requests/Item/Validators/EditItemRequestValidator.cs

using FluentValidation;

namespace Catalog.Domain.Requests.Item.Validators
{
    public class EditItemRequestValidator : AbstractValidator<EditItemRequest>
    {
        public EditItemRequestValidator()
        {
            RuleFor(x => x.Id).NotEmpty();
            RuleFor(x => x.GenreId).NotEmpty();
            RuleFor(x => x.ArtistId).NotEmpty();
            RuleFor(x => x.Price).NotEmpty();
            RuleFor(x => x.Price).Must(x => x?.Amount > 0);
            RuleFor(x => x.ReleaseDate).NotEmpty();
            RuleFor(x => x.Name).NotEmpty();
        }
    }
}

这些验证类位于Requests/Item/Validators路径。让我们通过分析AddItemRequestValidator类中实现的一些验证标准来继续:

  • GenreIdArtistId字段是必需的,因为黑胶唱片总是有这类信息。

  • 该类在NameReleaseDatePrice字段上使用相同的NotEmpty方法。

  • Price类的Amount字段始终应该大于 0。验证器类使用Must方法应用此规则。

对于EditItemRequestValidator类,采用相同的方法,除了Id字段,它在实体的更新过程中被定义为必需的。流畅的工作方式确实非常有用,原因有很多:它易于阅读,易于维护,并且在我们想要测试逻辑时非常有帮助。

测试请求模型验证

如前所述,FluentValidation包提供了一种出色的方式来测试我们的验证标准。

AddItemRequestValidatorEditItemRequestValidator类实现了基本检查。此外,可能有用的是用一些测试来覆盖它们,以记录这些类中实现的逻辑。FluentValidation提供了一个TestHelper类,它提供了验证我们验证逻辑行为的必要断言条件。

让我们看看如何为AddItemRequestValidator类进行一些单元测试:

using Catalog.Domain.Entities;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Requests.Item.Validators;
using FluentValidation.TestHelper;
using Xunit;

namespace Catalog.Domain.Tests.Requests.Item.Validators
{
    public class AddItemRequestValidatorTests
    {
        private readonly AddItemRequestValidator _validator;

        public AddItemRequestValidatorTests()
        {
            _validator = new AddItemRequestValidator();
        }

        [Fact]
        public void should_have_error_when_ArtistId_is_null()
        {
            var addItemRequest = new AddItemRequest { Price = new Price() };
            _validator.ShouldHaveValidationErrorFor(x => x.ArtistId, addItemRequest);
        }

        [Fact]
        public void should_have_error_when_GenreId_is_null()
        {
            var addItemRequest = new AddItemRequest { Price = new Price() };
            _validator.ShouldHaveValidationErrorFor(x => x.GenreId, addItemRequest);
        }
    }
}

前述代码中定义的测试类验证了如果GenreIdArtistId字段为空,则AddItemRequestValidator会触发验证错误。它使用TestHelper类公开的ShouldHaveValidationErrorFor扩展方法来验证行为。ShouldHaveValidationErrorFor方法还公开了一个IEnumerableValidationError,可以用来检查类型为ValidationError的每个消息的详细信息。

依赖项注册

在本章中,我们看到了如何实现映射类、验证器和服务类。所有这些类型都通过.NET Core 的依赖注入一起工作。依赖项注册通常通过使用按某些标准分组注册类的扩展方法来完成。在这种情况下,我将按以下方式分组注册的类:

  • 服务指的是在Catalog.Domain项目中定义的所有服务接口和类。

  • Mappers 指的是在Catalog.Domain项目中定义的所有映射类。

  • 验证指的是应用程序使用的所有流畅验证要求和依赖项。

现在我们已经定义了依赖项分离背后的逻辑,我们可以在Catalog.Domain项目的Extensions文件夹中定义一个新的DependencyRegistration静态类来继续:

using System.Reflection;
using Catalog.Domain.Mappers;
using Catalog.Domain.Services;
using FluentValidation.AspNetCore;
using Microsoft.Extensions.DependencyInjection;

namespace Catalog.Domain.Extensions
{
    public static class DependenciesRegistration
    {
        public static IServiceCollection AddMappers(this IServiceCollection services)
        {
            services
                .AddSingleton<IArtistMapper, ArtistMapper>()
                .AddSingleton<IGenreMapper, GenreMapper>()
                .AddSingleton<IItemMapper, ItemMapper>();

            return services;
        }

        public static IServiceCollection AddServices(this IServiceCollection services)
        {
            services
                .AddScoped<IItemService, ItemService>();

            return services;
        }

        public static IMvcBuilder AddValidation(this IMvcBuilder builder)
        {
            builder
                .AddFluentValidation(configuration =>
                    configuration.RegisterValidatorsFromAssembly
                        (Assembly.GetExecutingAssembly()));

            return builder;
        }
    }
}

上述代码定义了三个扩展方法,每个组一个:AddMappersAddServicesAddValidation

AddMappers 扩展方法使用 单例 生命周期来注册映射实例,因此,映射器没有任何依赖,也不执行任何与请求相关的操作。另一方面,AddServices 扩展方法使用作用域生命周期,因为服务类依赖于在数据库上执行 I/O 操作的仓储。最后,AddValidation 扩展方法与 IMvcBuilder 链接,并且严格依赖于 MVC 堆栈。

此外,它使用 FluentValidation 包提供的 AddFluentValidation 方法来注册所有验证类。

总之,我们可以以下方式注册应用程序的依赖项:

using Catalog.API.Extensions;
...

namespace Catalog.API
{
    public class Startup
    {
        ...
        public void ConfigureServices(IServiceCollection services)
        {
            services
                .AddCatalogContext(Configuration.GetSection("DataSource:ConnectionString").Value)
                .AddScoped<IItemRepository, ItemRepository>()
                .AddMappers()
 .AddServices()
 .AddControllers()
 .AddValidation();
        }
...

最后,我们可以通过在解决方案文件夹中再次运行 dotnet test 命令,或者执行我们首选 IDE 的测试运行器来验证本章编写的实现。

摘要

Catalog.Domain 项目现在包含整个应用程序的核心逻辑。尽管在 Domain 项目中实现的逻辑仍然相当简单,但在这本书的后续部分,它将变得更加复杂。

本章涵盖的主题包括一些与 Web 服务领域逻辑实现相关的概念:如何实现服务和映射类,如何使用流畅的方法实现请求验证过程,以及最后如何使用一些单元测试技术来测试我们的代码。

下一章将探讨应用程序的所有 HTTP 部分。此外,我们将关注 Catalog.API 项目,以及如何组合数据访问、服务和 API 层。

第十章:实现 RESTful HTTP 层

在上一章中,我们学习了如何在 Catalog.Domain 项目中处理我们网络服务的逻辑。本章将向您介绍网络服务的 HTTP 部分,以及 Catalog.API 项目中的所有组件。

我们还将演示如何实现和测试网络服务的控制器部分。到本章结束时,您将能够使用 ASP.NET Core 实现、测试和验证 HTTP 路由。我们将涵盖以下主题:

  • 实现服务的 HTTP 层

  • 使用 ASP.NET Core 提供的工具进行测试

  • 提高 HTTP 层的弹性

本章中展示的代码可以从以下 GitHub 仓库获取:github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3.

实现项目控制器

本节重点介绍构建读取、写入目录数据和使用 HTTP 协议公开我们在领域层中已构建的功能的路线。我们的控制器将包括以下路由表中列出的动词:

动词 路径 描述
GET /api/items 获取我们目录中存在的所有项目
GET /api/items/{id} 获取具有相应 ID 的项目
POST /api/items/ 通过请求体有效载荷创建一个新的项目
PUT /api/items/{id} 更新具有相应 ID 的项目

上述路由允许网络服务消费者获取、添加和更新 Item 实体。在开始实现之前,让我们看一下我们将要实现的解决方案架构概述:

在 第八章,构建数据访问层 和 第九章,实现领域逻辑 中,我们分别实现了并测试了 Catalog.InfrastructureCatalog.Domain 项目。本章重点介绍 Catalog.API 项目。我们将构建并测试将调用在 Catalog.Domain 项目中构建的服务层的动作方法。让我们首先在 Catalog.API 项目的 Controllers 文件夹中定义一个新的控制器,命名为 ItemController

using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    public class ItemController : ControllerBase
    {
    }
}

ItemController 类将反映我们之前定义的路由。我们应该注意,我们使用 RouteApiController 属性装饰了控制器类:第一个指定了控制器的基 URL,第二个为动作方法产生的响应类型提供了一些实用工具和约定。控制器还将使用 IItemService 接口来查询和写入我们的数据源。我们可以通过构造函数注入将 IItemService 接口用于 ItemController 类:

using Catalog.Domain.Services;
using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    public class ItemController : ControllerBase
    {
        private readonly IItemService _itemService;

        public ItemController(IItemService itemService)
        {
            _itemService = itemService;
        }
    }
}

之前的代码使用依赖注入将 IItemService 类添加为 ItemController 类的依赖项。一旦我们添加了 IItemService 接口,我们就可以通过实现控制器中的操作方法来继续进行。

实现操作方法

我们已经在 第五章 中处理了操作方法,ASP.NET Core 中的网络服务堆栈。在以下实现中,我们将在操作方法中使用 IItemService 接口,如下所示:*

using System;
using System.Threading.Tasks;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    public class ItemController : ControllerBase
    {
        private readonly IItemService _itemService;

        public ItemController(IItemService itemService)
        {
            _itemService = itemService;
        }
        [HttpGet]
        public async Task<IActionResult> Get()
        {
            var result = await _itemService.GetItemsAsync();
            return Ok(result);
        }

        [HttpGet("{id:guid}")]
        public async Task<IActionResult> GetById(Guid id)
        {
            var result = await _itemService.GetItemAsync(new GetItemRequest 
             { Id = id });
            return Ok(result);
        }
    }
}

GetGetById 操作方法通过引用 IItemService 接口并调用底层服务层(在这种情况下是 IItemService 接口)的 GetItemsAsyncGetItemAsync 方法来执行读取操作。让我们继续使用相同的方法来实现控制器的 PostPut 操作方法:

using System;
using System.Threading.Tasks;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    public class ItemController : ControllerBase
    {
        ...
        [HttpPost]
        public async Task<IActionResult> Post(AddItemRequest request)
        {
            var result = await _itemService.AddItemAsync(request);
            return CreatedAtAction(nameof(GetById), new { id = result.Id }, 
             null);
        }

        [HttpPut("{id:guid}")]
        public async Task<IActionResult> Put(Guid id, EditItemRequest 
         request)
        {
            request.Id = id;
            var result = await _itemService.EditItemAsync(request);

            return Ok(result);
        }
    }
}

PostPut 动作分别使用 AddItemRequestEditItemRequest 来绑定来自 HTTP 请求的数据,并通过 IItemService 接口传递。在底层,IItemService 实现引用 IItemMapper 来从请求类型获取实体,并通过 IItemRepository 实现发送。借助依赖注入,我们可以轻松地将不同组件之间的依赖解耦。我们还应该注意,Post 操作方法使用 ControllerBase 提供的 CreatedAtAction() 方法来检索创建资源的位置作为响应的一部分。一旦我们将 IItemService API 绑定到 ItemController 操作方法中,我们就可以继续测试实现。

使用 WebApplicationFactory 类测试控制器

ASP.NET Core 框架提供了一个使用 WebApplicationFactory<T> 类执行 集成测试 的方法。这个类允许我们创建一个新的 TestServer,它在一个单独的进程中模拟真实的 HTTP 服务器。因此,我们可以通过调用由工厂提供的 HttpClient 实例来测试我们的 ItemController。需要注意的是,WebApplicationFactory 是一个泛型类,它接受一个 TEntryPoint 类型,这由我们的网络服务的 Startup 类表示。在继续实现测试类之前,让我们在 tests 文件夹中创建一个新的项目,该项目将包含与 Catalog.API 项目相关的所有测试。因此,我们可以在 tests 文件夹中执行以下命令:

dotnet new xunit -n Catalog.API.Tests
cd Catalog.API.Tests dotnet add reference ../Catalog.Fixtures
dotnet add reference ../../src/Catalog.API
dotnet sln ../../Catalog.API.sln add .

之前的命令在解决方案的 tests 文件夹中添加了一个新的 Catalog.API.Tests 项目,它引用了 Catalog.FixturesCatalog.API 项目。该项目包含在项目的解决方案文件中。下一节将描述如何扩展 WebApplicationFactory 类以支持执行网络服务。

扩展 WebApplicationFactory

WebApplicationFactory类公开了用于配置TestServer实例和为我们的控制器创建适当测试固定值的属性和方法。此外,可以通过覆盖ConfigureWebHost方法并替换Catalog.API项目原始Startup类中声明的依赖注入服务的行为来扩展WebApplicationFactoryWebApplicationFactory类是Microsoft.AspNetCore.Mvc.Testing包的一部分;因此,有必要通过在项目的测试文件夹中运行以下命令将 NuGet 包添加到Catalog.Fixture项目和Catalog.API.Tests项目:

dotnet add Catalog.Fixtures package Microsoft.AspNetCore.Mvc.Testing
dotnet add Catalog.API.Tests package Microsoft.AspNetCore.Mvc.Testing

让我们继续在Catalog.Fixtures项目中创建一个新的InMemoryWebApplicationFactory类。该类将由测试类用于实例化一个新的TestServer对象。因此,下一步是创建一个新的InMemoryWebApplicationFactory类,它扩展了WebApplicationFactory基类并覆盖了ConfigureWebHost方法以注入自定义的内存数据库提供程序:

using System;
using Catalog.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Catalog.Fixtures
{
    public class InMemoryApplicationFactory<TStartup>
        : WebApplicationFactory<TStartup> where TStartup : class
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder
                .UseEnvironment("Testing")
                .ConfigureTestServices(services =>
                {
                    var options = new 
                     DbContextOptionsBuilder<CatalogContext>()
                        .UseInMemoryDatabase(Guid.NewGuid().ToString())
                        .Options;

                    services.AddScoped<CatalogContext>(serviceProvider => 
                     new TestCatalogContext(options));

                    var sp = services.BuildServiceProvider();

                    using var scope = sp.CreateScope();

                    var scopedServices = scope.ServiceProvider;
                    var db = scopedServices.GetRequiredService
                     <CatalogContext>();
                    db.Database.EnsureCreated();
                });
        }
    }
}

之前的InMemoryApplicationFactory类实现了ConfigureWebHost方法,并使用UseInMemoryDatabase扩展方法初始化内存数据库。它还在使用依赖注入注册的CatalogContext服务中插入TestCatalogContext类的新实例。因此,测试将使用我们在Catalog.Infrastructure.TestsCatalog.Domain.Tests项目中实现的测试用例所使用的相同内存数据库基础设施。

此外,InMemoryApplicationFactory实现创建了一个新作用域,该作用域将用于执行EnsureCreated方法。因此,每个新的InMemoryApplicationFactory实例将生成来自相同数据快照的数据库。

最后,整个实现都在ConfigureTestServices方法中执行,该方法提供了一种覆盖Catalog.API项目Startup类中定义的依赖注入服务的方法。

如第八章中所述,构建数据访问层,内存数据库并不总是首选的替代方案,有两个原因。首先,它不能反映具有真实数据约束的真实关系数据库。其次,当多个测试方法使用相同的实例时,处理内存数据库很棘手,因为它们可能会生成不一致的数据。因此,我们为每个测试类创建一个新的实例,使用UseInMemoryDatabase(Guid.NewGuid().ToString());语句。Guid.NewGuid()指令保证了实例之间的唯一性。在实际应用中,另一种常见的方法是创建一个临时数据源的新实例,并在每次测试后重新创建它。

测试控制器

一旦我们实现了 InMemoryApplicationFactory 类,就可以通过在测试类中实现 IClassFixture 接口来利用它。因此,让我们首先在 Catalog.API.Tests 项目中初始化一个新的 ItemControllerTests 类:

using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Shouldly;
using Catalog.Domain.Infrastructure.Entities;
using Catalog.Fixtures;
using Xunit;

namespace Catalog.API.Tests.Controllers
{
    public class ItemControllerTests : IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        private readonly InMemoryApplicationFactory<Startup> _factory;
        public ItemControllerTests(InMemoryApplicationFactory<Startup> 
         factory)
        {
            _factory = factory;
        }
        ....
    }
}

ItemControllerTests 类为操作方法提供了显著的测试覆盖率。首先,测试类实现了由 xUnit.Sdk 包提供的通用 IClassFixture 接口。IClassFixture 接口引用了之前定义的 InMemoryApplicationFactory<Startup>,并将 factory 类的新实例注入到测试类的构造函数中。因此,对于每个执行的测试类,都将提供一个 factory 的新实例。

让我们看看覆盖 ItemController 获取操作的测试方法:

..
[Theory]
[InlineData("/api/items/")]
public async Task get_should_return_success(string url)

{
    var client = _factory.CreateClient();
    var response = await client.GetAsync(url);

    response.EnsureSuccessStatusCode();
}

[Fact]
public async Task get_by_id_should_return_item_data()
{
    const string id = "86bff4f7-05a7-46b6-ba73-d43e2c45840f";
    var client = _factory.CreateClient();
    var response = await client.GetAsync($"/api/items/{id}");

    response.EnsureSuccessStatusCode();
    var responseContent = await response.Content.ReadAsStringAsync();
    var responseEntity = JsonConvert.
     DeserializeObject<Item>(responseContent);

    responseEntity.ShouldNotBeNull();
}

之前实现的代码使用了由 InMemoryApplicationFactory<Startup> 提供的 CreateClient 方法来初始化一个新的 HttpClient 实例。因此,如果我们以 get_by_id_should_return_item_data 方法为例,它使用客户端调用 /api/items/{id} 路由,并检查返回的信息不是 null。我们可以通过向 ItemControllerTests 类中添加以下测试方法来继续测试添加项目操作:

[Fact]
public async Task add_should_create_new_record()
{
    var request = new AddItemRequest
    {
        Name = "Test album",
        Description = "Description",
        LabelName = "Label name",
        Price = new Price { Amount = 13, Currency = "EUR" },
        PictureUri = "https://mycdn.com/pictures/32423423",
        ReleaseDate = DateTimeOffset.Now,
        AvailableStock = 6,
        GenreId = new Guid("c04f05c0-f6ad-44d1-a400-3375bfb5dfd6"),
        ArtistId = new Guid("f08a333d-30db-4dd1-b8ba-3b0473c7cdab")
    };

    var client = _factory.CreateClient();

    var httpContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
    var response = await client.PostAsync($"/api/items", httpContent);

    response.EnsureSuccessStatusCode();
    response.Headers.Location.ShouldNotBeNull();
}

因此,我们可以为控制器中实现的 Put 操作方法选择类似的方法:

[Fact]
public async Task update_should_modify_existing_item()
{
    var client = _factory.CreateClient();

    var request = new EditItemRequest
    {
        Id = new Guid("b5b05534-9263-448c-a69e-0bbd8b3eb90e"),
        Name = "Test album",
        Description = "Description updated",
        LabelName = "Label name",
        Price = new Price { Amount = 50, Currency = "EUR" },
        PictureUri = "https://mycdn.com/pictures/32423423",
        ReleaseDate = DateTimeOffset.Now,
        AvailableStock = 6,
        GenreId = new Guid("c04f05c0-f6ad-44d1-a400-3375bfb5dfd6"),
        ArtistId = new Guid("f08a333d-30db-4dd1-b8ba-3b0473c7cdab")
    };

    var httpContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
    var response = await client.PutAsync($"/api/items/{request.Id}", httpContent);

    response.EnsureSuccessStatusCode();

    var responseContent = await response.Content.ReadAsStringAsync();
    var responseEntity = JsonConvert.DeserializeObject<Item>(responseContent);

    responseEntity.Name.ShouldBe(request.Name);
    responseEntity.Description.ShouldBe(request.Description);
    responseEntity.GenreId.ShouldBe(request.GenreId);
    responseEntity.ArtistId.ShouldBe(request.ArtistId);
}

add_should_create_new_record 测试方法和 update_should_modify_existing_item 方法采用了相应的策略来测试 PostPut 请求以及相应的操作方法。在这种情况下,我们使用了为 ItemServiceTestsItemRepositoryTests 类定义的相同请求对象。

我们可以通过在解决方案文件夹中运行 dotnet test 命令或使用我们首选 IDE 的测试运行器来执行之前实现的测试。在接下来的下一小节中,我们将探讨如何优化请求的初始化并在一个独特的点保持测试数据。

使用 IClassFixture 意味着相同的 InMemoryApplicationFactory 实例将由所有测试方法共享。因此,每个测试方法将具有相同的基本数据。如果我们想完全隔离测试,我们可以避免使用类固定装置,并在测试类的构造函数中初始化一个新的 InMemoryApplicationFactory 实例:

 public class ItemControllerTests
    {
        private readonly InMemoryApplicationFactory<Startup> _factory;

        public ItemControllerTests()
        {
            _factory = new InMemoryApplicationFactory<Startup>();
        }
        ....
    }

这种方法还保证了测试类中每个测试方法之间的隔离。此外,构造函数将在每次调用时提供一个新实例。

接下来,让我们看看如何使用 xUnit 数据属性来加载测试数据。

使用 xUnit 数据属性加载测试数据

xUnit 框架是测试 .NET 应用程序和服务的首选选择。该框架还提供了一些工具来扩展其功能并实现更易于维护的测试代码。可以扩展 xUnit.Sdk 命名空间公开的 DataAttribute 类以在属性内部执行自定义操作。例如,假设我们创建了一个新的自定义 DataAttribute 来从文件中加载测试数据,如下所示:


namespace Catalog.API.Tests.Controllers
{
    public class ItemControllerTests : IClassFixture<InMemoryApplicationFactory<Startup>>
    {
       ...

        [Theory]
        [LoadData( "item")]
        public async Task get_by_id_should_return_right_data(Item request)
        {
            var client = _factory.CreateClient();
            var response = await client.GetAsync($"/api/items/{request.Id}");

            response.EnsureSuccessStatusCode();

            var responseContent = 
             await response.Content.ReadAsStringAsync();
            var responseEntity = JsonConvert.DeserializeObject
             <ItemResponse>(responseContent);

            responseEntity.Name.ShouldBe(request.Name);
            responseEntity.Description.ShouldBe(request.Description);
            responseEntity.Price.Amount.ShouldBe(request.Price.Amount);
            responseEntity.Price.Currency.ShouldBe(request.Price.Currency);
            responseEntity.Format.ShouldBe(request.Format);
            responseEntity.PictureUri.ShouldBe(request.PictureUri);
            responseEntity.GenreId.ShouldBe(request.GenreId);
            responseEntity.ArtistId.ShouldBe(request.ArtistId);
        }

        ...
    }
}

在这种情况下,实现使用 LoadData 属性装饰测试方法,该属性从文件中读取一个 item 部分。因此,我们将有一个包含所有测试记录的 JSON 文件,我们将使用 LoadData 属性加载其中之一。为了为 ItemControllerTests 类自定义行为,我们应该创建一个新的类并扩展由 xUnit 提供的 DataAttribute 类:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit.Sdk;

namespace Catalog.Fixtures
{
    public class LoadDataAttribute : DataAttribute
    {
        private readonly string _fileName;
        private readonly string _section;
        public LoadDataAttribute(string section)
        {
            _fileName = "record-data.json";
            _section = section;
        }
        public override IEnumerable<object[]> GetData(MethodInfo testMethod)
        {
            if (testMethod == null) throw new ArgumentNullException(nameof(testMethod));

            var path = Path.IsPathRooted(_fileName)
                ? _fileName
                : Path.GetRelativePath(Directory.GetCurrentDirectory(), _fileName);

            if (!File.Exists(path)) throw new ArgumentException
             ($"File not found: {path}");

            var fileData = File.ReadAllText(_fileName);

            if (string.IsNullOrEmpty(_section)) return 
             JsonConvert.DeserializeObject<List<string[]>>(fileData);

            var allData = JObject.Parse(fileData);
            var data = allData[_section];
            return new List<object[]> { new[] {              
             data.ToObject(testMethod.GetParameters()
             .First().ParameterType) } };
        }
    }
}

LoadDataAttribute 类重写了由 DataAttribute 类提供的 GetData(MethodInfo testMethod); 方法,并返回测试方法使用的数据。GetData 方法的实现读取由 _filePath 属性定义的文件内容;它尝试将文件指定的 section 的内容序列化为一个泛型 object。最后,实现调用 ToObject 方法将泛型 JObject 转换为与测试方法第一个参数关联的类型。该过程最后一步是在 Catalog.API.Tests 项目中创建一个新的名为 record-data.json 的 JSON 文件。该文件将包含我们测试使用的测试数据:

{
  "item": {
    "Id": "86bff4f7-05a7-46b6-ba73-d43e2c45840f",
    "Name": "DAMN.",
    "Description": "DAMN. by Kendrick Lamar",
    "LabelName": "TDE, Top Dawg Entertainment",
    "Price": {
      "Amount": 34.5,
      "Currency": "EUR"
    },
    "PictureUri": "https://mycdn.com/pictures/45345345",
    "ReleaseDate": "2017-01-01T00:00:00+00:00",
    "Format": "Vinyl 33g",
    "AvailableStock": 5,
    "GenreId": "c04f05c0-f6ad-44d1-a400-3375bfb5dfd6",
    "Genre": null,
    "ArtistId": "3eb00b42-a9f0-4012-841d-70ebf3ab7474",
    "Artist": null
  },
  "genre": {
    "GenreId": "c04f05c0-f6ad-44d1-a400-3375bfb5dfd6",
    "GenreDescription": "Hip-Hop"
  },
  "artist": {
    "ArtistId": "f08a333d-30db-4dd1-b8ba-3b0473c7cdab",
    "ArtistName": "Anderson Paak."
  }
}

JSON 片段包含以下字段:itemartistgenre。这些字段包含与测试实体相关的数据。因此,我们将使用它们将数据反序列化到请求模型和实体类型中。因此,我们可以将 LoadData 属性应用于以下方式的 ItemControllerTests 类:

using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Shouldly;
using Catalog.Domain.Infrastructure.Entities;
using Catalog.Domain.Requests.Item;
using Catalog.Fixtures;
using Xunit;

namespace Catalog.API.Tests.Controllers
{
    public class ItemControllerTests : IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        ...

        [Theory]
        [LoadData("item")]
        public async Task get_by_id_should_return_right_data(Item request){...}

        [Theory]
        [LoadData("item")]
        public async Task add_should_create_new_item(AddItemRequest request){...}

        [Theory]
        [LoadTestData("item")]
        public async Task update_should_modify_existing_item(EditItemRequest request){...}

    }
}

现在,测试方法接受一个 ItemEditItemRequestAddItemRequest 类型的 request 参数,该参数将包含由 record-data.json 文件提供的数据。然后,该对象被序列化为 request 参数,并通过 InMemoryApplicationFactory 提供的 HttpClient 实例发送:

[Theory]
[LoadData( "item")]
public async Task add_should_create_new_record(AddItemRequest request)
{
    var client = _factory.CreateClient();

    var httpContent = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json");
    var response = await client.PostAsync($"/api/items", httpContent);

 response.EnsureSuccessStatusCode();
 response.Headers.Location.ShouldNotBeNull();
}

LoadDatarecord-data.json 文件中定义的内容序列化为 AddItemRequest 类型。然后,请求被序列化为 StringContent 并通过工厂创建的 HTTP 客户端发送。最后,该方法断言结果代码是成功的,并且 Location 标头不是 null

我们现在可以通过在解决方案根目录中执行 dotnet test 命令,或者通过运行我们首选 IDE 提供的测试运行器来验证 ItemController 类的行为。

总结来说,现在我们能够在一个独特的中央 JSON 文件中定义测试数据。除此之外,我们还可以通过向 JSON 文件添加新的部分来添加尽可能多的数据。本节接下来的部分将专注于通过添加一些存在性检查和处理异常使用过滤器来提高 API 的弹性。

提高 API 的弹性

前面的章节展示了ItemController类的可能实现以及如何使用 ASP.NET Core 提供的工具对其进行测试。在本节中,我们将学习如何通过在ItemController公开的信息上执行一些限制检查来提高我们服务的弹性。此外,我们还将探讨如何展示验证错误以及如何分页返回的数据。本节将应用前几章中解释的概念到 Web 服务项目中。

存在性检查

让我们先实现一个执行请求数据存在性检查的动作过滤器。该过滤器将被用于获取或编辑单个项的动作方法。如第七章中所述的过滤器管道,我们将实现以下过滤器:

using System;
using System.Threading.Tasks;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace Catalog.API.Filters
{
    public class ItemExistsAttribute : TypeFilterAttribute
    {
        public ItemExistsAttribute() : base(typeof
            (ItemExistsFilterImpl))
        {
        }

        public class ItemExistsFilterImpl : IAsyncActionFilter
        {
            private readonly IItemService _itemService;

            public ItemExistsFilterImpl(IItemService itemService)
            {
                _itemService = itemService;
            }

            public async Task OnActionExecutionAsync(ActionExecutingContext context,
                ActionExecutionDelegate next)
            {
                if (!(context.ActionArguments["id"] is Guid id))
                {
                    context.Result = new BadRequestResult();
                    return;
                }

                var result = await _itemService.GetItemAsync(new GetItemRequest { Id = id });

                if (result == null)
                {
                    context.Result = new NotFoundObjectResult($"Item with id {id} not exist.");
                    return;
                }

                await next();
            }
        }
    }
}

动作过滤器在构造函数中解析IItemService接口,并使用注入的实例通过请求中存在的id验证实体的存在。如果请求包含有效的Guid id,并且id存在于我们的数据源中,OnActionExecutionAsync方法将通过调用await next()方法继续管道。否则,它将停止管道并返回一个NotFoundObjectResult实例。我们可以通过添加[ItemExists]属性来将过滤器应用于ItemController的动作方法:

using Catalog.API.Filters;

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    public class ItemController : ControllerBase
    {
        ...

        [HttpGet("{id:guid}")]
        [ItemExists]
        public async Task<IActionResult> GetById(Guid id)
        {
           ...
        }

        [HttpPut("{id:guid}")]
        [ItemExists]
        public async Task<IActionResult> Put(Guid id, EditItemCommand request)
        {
          ...
        }
    }
}

在应用了ItemExists属性后,如果请求发送的 ID 不存在,API 将返回 404。我们还可以通过注入IItemService接口的模拟实例并对结果响应进行一些断言来验证在动作过滤器中实现的逻辑。在下面的测试类中,我们将使用Moq来验证对next()方法的调用。作为第一步,我们需要在项目文件夹内使用以下命令将Moq添加到Catalog.API.Tests项目中:

dotnet add package Moq

此外,我们可以继续定义ItemExistsAttributeTests类:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Catalog.API.Filters;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Responses;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Moq;
using Xunit;

namespace Catalog.API.Tests.Filters
{
    public class ItemExistsAttributeTests
    {
        [Fact]
        public async Task should_continue_pipeline_when_id_is_present()
        {
            var id = Guid.NewGuid();
            var itemService = new Mock<IItemService>();

            itemService
                .Setup(x => x.GetItemAsync(It.IsAny<GetItemRequest>()))
                .ReturnsAsync(new ItemResponse { Id = id });

            var filter = new ItemExistsAttribute.ItemExistsFilterImpl(itemService.Object);

            var actionExecutedContext = new ActionExecutingContext(
                new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()),
                new List<IFilterMetadata>(),
                new Dictionary<string, object>
                {
                    {"id", id}
                }, new object());

            var nextCallback = new Mock<ActionExecutionDelegate>();

            await filter.OnActionExecutionAsync(actionExecutedContext, nextCallback.Object);

            nextCallback.Verify(executionDelegate => executionDelegate(), Times.Once);
        }
    }
}

在前面的ItemExistsAttributeTests类中,我们模拟了整个IItemService接口以模拟GetItemAsync方法的响应。然后,它通过注入模拟的IItemService接口初始化ItemExistsAttribute。最后,它调用由filter类公开的OnActionExecutionAsync方法,并将其与Moq框架提供的Verify方法结合使用,以检查ItemExistsFilter类是否正确调用了next()回调方法。

JSON 自定义错误

异常的自定义和序列化是简化错误处理和改进 Web 服务监控的有用方法. 这些技术有时对于将异常传达给客户端以处理和管理错误是必要的。一般来说,虽然HTTP 状态码提供了关于请求状态的摘要信息,但响应内容提供了关于错误的更详细信息。

使用过滤器扩展错误处理行为是可能的。首先,让我们创建一个新的标准模型来表示错误结果:

namespace Catalog.API.Exceptions
{
    public class JsonErrorPayload
    {
        public int EventId { get; set; }
        public object DetailedMessage { get; set; }
    }
}

上述类位于Filters文件夹结构下。它包含一个EventId属性和一个DetailedMessage,类型为object。其次,我们应该继续实现一个新的过滤器,该过滤器扩展了IExceptionFilter接口。当引发异常时,该过滤器将被触发,并将修改返回给客户端的响应内容:

using System.Net;
using Catalog.API.Exceptions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Catalog.API.Filters
{
    public class JsonExceptionAttribute : TypeFilterAttribute
    {
        public JsonExceptionAttribute() : base(typeof(HttpCustomExceptionFilterImpl))
        {
        }

        private class HttpCustomExceptionFilterImpl : IExceptionFilter
        {
            private readonly IWebHostEnvironment _env;
            private readonly ILogger<HttpCustomExceptionFilterImpl> _logger;

            public HttpCustomExceptionFilterImpl(IWebHostEnvironment env,
                ILogger<HttpCustomExceptionFilterImpl> logger)
            {
                _env = env;
                _logger = logger;
            }

            public void OnException(ExceptionContext context)
            {
                var eventId = new EventId(context.Exception.HResult);

                _logger.LogError(eventId,
                    context.Exception,
                    context.Exception.Message);

                var json = new JsonErrorPayload { EventId = eventId.Id };

                if (_env.IsDevelopment())
                {
                    json.DetailedMessage = context.Exception;
                }

                var exceptionObject = new ObjectResult(json) { StatusCode = 500 };

                context.Result = exceptionObject;
                context.HttpContext.Response.StatusCode = (int) HttpStatusCode.InternalServerError;
            }
        }
    }
}

上述代码实现了IExceptionFilter接口。该类包含用于注入过滤器依赖项的一些构造函数的定义。它还包含OnException方法,该方法初始化一个新的JsonErrorPayload对象,该对象包含eventId字段和异常中包含的消息内容。根据环境,查看IsDevelopment()检查;它还向结果异常对象填充详细的错误消息。最后,OnException方法使用定义为参数的HttpContext来设置HttpStatusCode.InternalServerError,并添加之前创建的exceptionObject作为执行结果。这种方法保证了以独特的方式处理异常,通过集中序列化和所有由 Web 服务返回的错误的结果消息格式。

实现分页

分页是 API 的另一个基本功能。Get操作通常返回大量记录和信息。有时,实现分页以避免大响应大小是必要的。

如果您的 API 暴露给外部客户端,在可能的情况下减少响应大小是至关重要的。此外,客户端可能将信息存储在具有有限内存的设备内存中,例如智能手机或物联网设备。

让我们看看如何在ItemController类中实现可维护和可重用的分页。首先,我们需要创建一个新的分页响应模型来表示请求的页面。我们可以在Catalog.API项目中的ResponseModels文件夹内创建一个新的PaginatedItemResponseModel.cs文件,内容如下:

using System.Collections.Generic;

namespace Catalog.API.ResponseModels
{
    public class PaginatedItemsResponseModel<TEntity> where TEntity : class
    {
        public PaginatedItemsResponseModel(int pageIndex, int pageSize, long total, IEnumerable<TEntity> data)
        {
            PageIndex = pageIndex;
            PageSize = pageSize;
            Total = total;
            Data = data;
        }

        public int PageIndex { get; }
        public int PageSize { get; }
        public long Total { get; }
        public IEnumerable<TEntity> Data { get; }
    }
}

PaginatedItemsResponseModel函数接受一个泛型模型,它表示分页响应类型。它还实现了与页面相关的某些属性,例如PageIndexPageSizeTotal。此外,它包括一个IEnumerable接口,表示响应返回的记录。下一步是更改ItemController类中已经存在的Get操作方法,如下所示:

using Catalog.API.ResponseModels;
...

    public class ItemController : ControllerBase
    {
        ...

        [HttpGet]
        public async Task<IActionResult> Get(int pageSize = 10, int pageIndex = 0)
        {
            var result = await _itemService.GetItemsAsync();

            var totalItems = result.Count();

            var itemsOnPage = result
                .OrderBy(c => c.Name)
                .Skip(pageSize * pageIndex)
                .Take(pageSize);

            var model = new PaginatedItemsResponseModel<ItemResponse>(
 pageIndex, pageSize, totalItems, itemsOnPage);

            return Ok(model);
        }
        ...
    }
}

我们将Get操作方法更改为实现分页。首先,请注意,该方法接收一些与分页相关的参数:pageSizepageIndex参数。其次,它执行IItemService以获取相关记录并执行 LINQ 查询以仅获取所选页面的元素。最后,它使用与页面和数据相关的元数据实例化一个新的PaginatedItemsResponseModel<ItemResponse>,并返回该实例。

我们可以通过更改现有的ItemsControllerTests文件来使用单元测试来覆盖实现:

using Catalog.API.ResponseModels;
...

    public class ItemControllerTests : IClassFixture<InMemoryApplicationFactory<Startup>>
    {

        ...

        [Theory]
        [InlineData("/api/items/?pageSize=1&pageIndex=0", 1,0)]
 [InlineData("/api/items/?pageSize=2&pageIndex=0", 2,0)]
 [InlineData("/api/items/?pageSize=1&pageIndex=1", 1,1)]
        public async Task get_should_return_paginated_data(string url, int pageSize, int pageIndex)

        {
            var client = _factory.CreateClient();
            var response = await client.GetAsync(url);

                response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
 var responseEntity = JsonConvert.DeserializeObject<PaginatedItemsResponseModel<ItemResponse>>(responseContent);

 responseEntity.PageIndex.ShouldBe(pageIndex);
 responseEntity.PageSize.ShouldBe(pageSize);
 responseEntity.Data.Count().ShouldBe(pageSize);
        }
       ...
    }
} 

should_get_item_using_pagination测试用例使用InlineData属性来测试一些分页路由。它调用Get操作方法,将结果序列化为PaginatedItemsResponseModel<ItemResponse>,最后检查结果。

尽管本章中检查的分页实现技术提供了一些性能优势,但它并没有限制我们的服务与数据库之间的交互。为了将性能优势扩展到服务的数据源,我们应该考虑实现一个专门查询,直接从我们的数据源分页数据。

在下一节中,我们将继续我们的旅程,通过扩展 API 来处理相关实体。目前,请注意,我们正在暴露ItemArtistGenre实体的信息,而不管理相关实体,并且我们没有暴露任何编辑ArtistGenre实体的路由。

暴露相关实体

目前,Catalog.API项目中的当前实现允许我们读取和修改Item实体及其与GenreArtist实体的关系。在本节中,我们将使客户端能够列出和添加GenreArtist实体。因此,我们将扩展允许客户端与这些实体交互的 API。此实现要求我们对整个网络服务栈采取行动;此外,它还涉及Catalog.InfrastructureCatalog.DomainCatalog.API项目。在我们开始之前,让我们看看我们将要实现的路由:

动词 路径 描述
GET /api/artists 获取数据库中所有存在的艺术家
GET /api/artist/{id} 获取具有相应 ID 的艺术家
GET /api/artist/{id}/items/ 获取与相应艺术家 ID 对应的物品
POST /api/artist/ 创建一个新的艺术家并检索它

同样,我们将为Genre实体获取相应的路由:

动词 路径 描述
GET /api/genre 获取数据库中存在的所有流派
GET /api/genre/{id} 获取具有相应 ID 的流派
GET /api/genre/{id}/items/ 获取具有相应流派 ID 的项目
POST /api/genre/ 创建一个新的流派并获取它

在前表中提到的路由提供了一种与GenreArtist实体交互的方式。这些功能的实现将遵循我们在前几节中看到的项实体所使用的相同方法。在继续之前,我们需要通过添加代表ArtistGenre实体的属性来扩展CatalogContext类:

...
public class CatalogContext : DbContext, IUnitOfWork
{

    public DbSet<Item> Items { get; set; }
    public DbSet<Artist> Artists { get; set; }
 public DbSet<Genre> Genres { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new ItemEntitySchemaDefinition());
 modelBuilder.ApplyConfiguration(new ArtistEntitySchemaConfiguration());
 modelBuilder.ApplyConfiguration(new GenreEntitySchemaConfiguration());

        base.OnModelCreating(modelBuilder);   
    }
..

现在CatalogContext也通过使用modelBuilder.ApplyConfiguration方法处理ArtistsGenres实体。在下一个小节中,我们将通过使用Repositories类扩展数据访问层的实现。

扩展数据访问层

为了扩展我们的 API 以包含相关实体,我们应该从我们的堆栈底部开始。首先,让我们通过在Catalog.Domain项目的Repositories文件夹中添加以下接口来扩展数据访问层的功能:

// Repositories/IArtistsRepository.cs

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Catalog.Domain.Entities;

namespace Catalog.Domain.Repositories
{
    public interface IArtistRepository : IRepository
    {
        Task<IEnumerable<Artist>> GetAsync();
        Task<Artist> GetAsync(Guid id);
        Artist Add(Artist item);
    }
}

// Repositories/IGenreRepository.cs using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Catalog.Domain.Entities;

namespace Catalog.Domain.Infrastructure.Repositories
{
    public interface IGenreRepository : IRepository
    {
        Task<IEnumerable<Genre>> GetAsync();
        Task<Genre> GetAsync(Guid id);
        Genre Add(Genre item);
    }
}

IArtistRepositoryIGenreRepository接口反映了本节最初定义的路由:GetAsync方法返回次要实体的列表,GetAsync(Guid id)返回单个对象,而Add方法允许我们创建新实体。我们现在可以定义指定接口的实际实现。同样,ItemRepository类的实现将存储在Catalog.Infrastructure项目中:

//Repositories/ArtistRepository.cs using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;

namespace Catalog.Infrastructure.Repositories
{
    public class ArtistRepository : IArtistRepository
    {
        private readonly CatalogContext _catalogContext;
        public IUnitOfWork UnitOfWork => _catalogContext;

        public ArtistRepository(CatalogContext catalogContext)
        {
            _catalogContext = catalogContext;
        }

        public async Task<IEnumerable<Artist>> GetAsync()
        {
            return await _catalogContext.Artist
                .AsNoTracking()
                .ToListAsync();
        }

        public async Task<Artist> GetAsync(Guid id)
        {
            var artist = await _catalogContext.Artist
                .FindAsync(id);

            if (artist == null) return null;

            _catalogContext.Entry(artist).State = EntityState.Detached;
            return artist;
        }

        public Artist Add(Artist artist)
        {
            return _catalogContext.Artist.Add(artist).Entity;
        }
    }
}

上述代码定义了ArtistRepository类型,并为IArtistRepository接口提供了实现。该类使用CatalogContext作为我们的应用程序和 SQL 数据库之间的通信中心。GetAsyncGetAsync(Guid id)方法使用已在ItemRepository类中实现的相同模式来检索与所需实体相关的信息。此外,Add方法引用了CatalogContext公开的Artists字段以添加新艺术家。需要注意的是,在这种情况下,Add操作并不直接更新数据源。

让我们通过定义GenreRepository类来继续:

//Repositories/GenreRepository.cs using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;

namespace Catalog.Infrastructure.Repositories
{
    public class GenreRepository : IGenreRepository
    {
        private readonly CatalogContext _catalogContext;
        public IUnitOfWork UnitOfWork => _catalogContext;

        public GenreRepository(CatalogContext catalogContext)
        {
            _catalogContext = catalogContext;
        }

        public async Task<IEnumerable<Genre>> GetAsync()
        {
            return await _catalogContext.Genre
                .AsNoTracking()
                .ToListAsync();
        }

        public async Task<Genre> GetAsync(Guid id)
        {
            var item = await _catalogContext.Genre
                .FindAsync(id);

            if (item == null) return null;

            _catalogContext.Entry(item).State = EntityState.Detached;
            return item;
        }

        public Genre Add(Genre item)
        {
            return _catalogContext.Genre.Add(item).Entity;
        }
    }
}

ArtistRepository 类似,我们正在实现查询和操作 Genre 实体的操作。尽管方法和实现方式非常相似,但我选择保持存储库接口分离,并分别重新定义每个实现。一个更快的方法是创建一个代表 ItemRepositoryArtistRepositoryGenreRepository 典型行为的通用类,但通用存储库并不总是最佳选择。此外,构建错误抽象的成本远高于代码重复,为所有内容构建唯一的通用存储库意味着实体之间紧密耦合。

扩展测试覆盖率

正如我们在 ItemRepositoryTests 类中所做的那样,我们可以通过使用相同的方法来测试 ArtistRepositoryGenreRepository。在 第八章 “构建数据访问层” 中,我们定义了 TestDataContextFactory,它是 Catalog.Fixtures 项目的一部分。我们可以使用它来为测试目的实例化一个内存数据库:

using System;
using System.Linq;
using System.Threading.Tasks;
using Catalog.Domain.Entities;
using Catalog.Fixtures;
using Catalog.Infrastructure.Repositories;
using Shouldly;
using Xunit;

namespace Catalog.Infrastructure.Tests
{
    public class ArtistRepositoryTests :
        IClassFixture<CatalogContextFactory>
    {
        private readonly CatalogContextFactory _factory;

        public ArtistRepositoryTests(CatalogContextFactory factory)
        {
            _factory = factory;
        }

        [Theory]
        [LoadData("artist")]
        public async Task should_return_record_by_id(Artist artist)
        {
            var sut = new ArtistRepository(_factory.ContextInstance);
            var result = await sut.GetAsync(artist.ArtistId);

            result.ArtistId.ShouldBe(artist.ArtistId);
            result.ArtistName.ShouldBe(artist.ArtistName);
        }

        [Theory]
        [LoadData("artist")]
        public async Task should_add_new_item(Artist artist)
        {
            artist.ArtistId = Guid.NewGuid();
            var sut = new ArtistRepository(_factory.ContextInstance);

            sut.Add(artist);
            await sut.UnitOfWork.SaveEntitiesAsync();

            _factory.ContextInstance.Artist
                .FirstOrDefault(x => x.ArtistId == artist.ArtistId)
                .ShouldNotBeNull();
        }
    }
}

之前的代码探索了实现 ArtistRepository 类测试的方法。ArtistRepositoryTests 类扩展了 IClassFixture<CatalogContextFactory> 以检索 CatalogContextFactory 类型的实例。测试方法使用 ContextInstance 属性来检索一个新的目录上下文并初始化一个新的存储库。

他们通过执行方法作为测试并检查结果来继续。重要的是要注意,每个测试方法都使用 LoadData 属性来加载 record-data.json 文件的 artist 部分。为了简洁,我省略了一些测试用例;然而,它们背后的概念与我们之前看到的相同,并且可以扩展到 GenreRepository 测试。

扩展 IItemRepository 接口

我们可以采取的另一步是扩展我们的 Web 服务项目,以包含相关的实体,即在 IItemRepository 接口中实现两个新的方法来检索与艺术家或流派相关的项目:GetItemsByArtistIdAsyncGetItemsByGenreIdAsync 方法。这两个方法都可以由 GET /api/artists/{id}/itemsGET /api/genre/{id}/items 路由使用来检索项目。

让我们通过向 IItemsRepository 接口添加以下方法并在相应的实现中实现它们来继续:

 //Repositories/IItemRepository.cs
    public interface IItemRepository : IRepository
    {
        ...
        Task<IEnumerable<Item>> GetItemByArtistIdAsync(Guid id);
 Task<IEnumerable<Item>> GetItemByGenreIdAsync(Guid id);
        ...
    }

 //Repositories/ItemRepository.cs
    public class ItemRepository
        : IItemRepository
    {
        ...
        public async Task<IEnumerable<Item>> GetItemByArtistIdAsync(Guid id)
 {
 var items = await _context
 .Items
 .Where(item => item.ArtistId == id)
 .Include(x => x.Genre)
 .Include(x => x.Artist)
 .ToListAsync();

 return items;
 }

 public async Task<IEnumerable<Item>> GetItemByGenreIdAsync(Guid id)
 {
 var items = await _context.Items
 .Where(item => item.GenreId == id)
 .Include(x => x.Genre)
 .Include(x => x.Artist)
 .ToListAsync();

 return items;
 }
       ...
    }
}

此代码扩展了 IItemRepository 接口及其实现,以便包括使用 ArtistIdGenreId 查询项目的功能。这两个方法都使用 Where 子句和调用 Include 语句来包含查询结果中的相关实体。一旦我们完全扩展了存储库层,我们还可以继续扩展服务类。

扩展服务层的功能

新的Catalog.Infrastructure功能扩展了Catalog.Domain项目,并将IArtistRepositoryIGenreRepository暴露给 API 项目的控制器。首先,我们应该在Catalog.Domain项目中创建几个新的服务类,以便查询底层的Catalog.Infrastructure层。让我们从在Catalog.Domain项目的Services文件夹中定义IArtistServiceIGenreService接口开始:

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Requests.Artists;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Services
{
    public interface IArtistService
    {
        Task<IEnumerable<ArtistResponse>> GetArtistsAsync();
        Task<ArtistResponse> GetArtistAsync(GetArtistRequest request);
        Task<IEnumerable<ItemResponse>> GetItemByArtistIdAsync(GetArtistRequest request);
        Task<ArtistResponse> AddArtistAsync(AddArtistRequest request);
    }

    public interface IGenreService
    {
        Task<IEnumerable<GenreResponse>> GetGenreAsync();
        Task<GenreResponse> GetGenreAsync(GetGenreRequest request);
        Task<IEnumerable<ItemResponse>> GetItemByGenreIdAsync(GetGenreRequest request);
        Task<GenreResponse> AddGenreAsync(AddGenreRequest request);
    }
}

上述代码片段包含了IArtistServiceIGenreService接口的声明。为了简洁,我将它们保留在同一代码片段中。两个接口都定义了列表中的方法,获取详细信息,然后添加相关实体。GetArtistsAsync()GetGenreAsync()方法可以根据是否指定request参数返回完整的实体列表或单个实体。此外,还可以使用GetItemByArtistIdAsyncGetItemByGenreIdAsync方法通过艺术家 ID 或流派 ID 检索ItemResponse列表。最后,我们可以使用AddArtistAsyncAddGenreAsync方法添加新的艺术家和流派。

上述实现还依赖于以下请求模型的定义:

namespace Catalog.Domain.Requests.Artists
{
    public class AddArtistRequest
    {
        public string ArtistName { get; set; }
    }

    public class GetArtistRequest
    {
        public Guid Id { get; set; }
    }
}

namespace Catalog.Domain.Requests.Genre
{
    public class AddGenreRequest
    {
        public string GenreDescription { get; set; }
    }

    public class GetGenreRequest
    {
        public Guid Id { get; set; }
    }
}

在这里,请求类定义了ArtistGenre实体的允许操作。它们分别存储在Requests/ArtistRequests/Genre文件夹中。我们可以继续实现ArtistService类的具体部分,如下所示:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Mappers;
using Catalog.Domain.Repositories;
using Catalog.Domain.Requests.Artists;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Services
{
    public class ArtistService : IArtistService
    {
        private readonly IArtistRepository _artistRepository;
        private readonly IItemRepository _itemRepository;
        private readonly IArtistMapper _artistMapper;
        private readonly IItemMapper _itemMapper;

        public ArtistService(IArtistRepository artistRepository, IItemRepository itemRepository,
            IArtistMapper artistMapper, IItemMapper itemMapper)
        {
            _artistRepository = artistRepository;
            _itemRepository = itemRepository;
            _artistMapper = artistMapper;
            _itemMapper = itemMapper;
        }
        ...
    }
}

上述代码定义了ArtistService类的属性和构造函数。实现注入了IArtistRepositoryIItemRepositoryIArtistMapperIItemMapper依赖项。Repositories类将被用于与应用程序底层数据源通信。另一方面,调用映射器来初始化和映射作为响应发送的值。

一旦我们定义了ArtistService类的依赖关系,我们就可以继续实现核心方法:

    public class ArtistService : IArtistService
    {
        ...

        public async Task<IEnumerable<ArtistResponse>> GetArtistsAsync()
        {
            var result = await _artistRepository.GetAsync();

            return result.Select(_artistMapper.Map);
        }

        public async Task<ArtistResponse> GetArtistAsync(GetArtistRequest
            request)
        {
            if (request?.Id == null) throw new ArgumentNullException();

            var result = await _artistRepository.GetAsync(request.Id);

            return result == null ? null : _artistMapper.Map(result);
        }

        public async Task<IEnumerable<ItemResponse>> GetItemByArtistIdAsync(GetArtistRequest request)
        {
            if (request?.Id == null) throw new ArgumentNullException();

            var result = await _itemRepository.GetItemByArtistIdAsync(request.Id);

            return result.Select(_itemMapper.Map);
        }

        public async Task<ArtistResponse> AddArtistAsync(AddArtistRequest request)
        {
            var item = new Entities.Artist {ArtistName = request.ArtistName};

            var result = _artistRepository.Add(item);

            await _artistRepository.UnitOfWork.SaveChangesAsync();

            return _artistMapper.Map(result);
        }
    }

实现表示在IArtistService接口中已定义的方法。通过查看

它们的签名。

GetAsync方法调用IArtistRepository依赖项来映射和检索结果,在ArtistResponse对象中。GetItemByArtistIdAsync执行在IItemRepository接口中定义的同名方法。最后,AddArtistAsync执行IArtistRepository接口中定义的Add方法,并执行UnitOfWork.SaveChangesAsync方法将更改应用到数据源,然后映射结果数据。

GenreService实现类也可以采用相同的方法,它将依赖于IGenreRepositoryIGenreMapper接口。

你可以在本书的官方 GitHub 仓库中找到GenreService类的实现:github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3

最后,我们还应该记得将之前章节中定义的AddServices扩展方法中的这些接口定义包括在内,通过以下更改:

using Catalog.Domain.Mappers;
using Catalog.Domain.Services;
using Microsoft.Extensions.DependencyInjection;

namespace Catalog.Domain.Extensions
{
    public static class DependenciesRegistration
    {
        ...

        public static IServiceCollection AddServices(this IServiceCollection services)
        {
            services
                .AddScoped<IItemService, ItemService>()
                .AddScoped<IArtistService, ArtistService>()
 .AddScoped<IGenreService, GenreService>();
            return services;
        }
    }
}

这些更改注册了IArtistServiceIGenreService接口,以便它们可以被控制器和其他应用程序依赖项使用。在下一节中,我们将继续实现,通过向请求模型添加一些验证逻辑来完成。

改进验证机制

正如我们在上一章中解释的,我们正在使用FluentValidation包来实现 Web 服务的验证机制。由于我们已经构建了服务接口来处理ArtistGenre实体,现在我们可以改进AddItemRequestValidatorEditItemRequestValidator类中现有的验证检查。现在,我们将实现ArtistGenre相关实体的存在检查。

让我们从扩展AddItemRequestValidator类的实现开始:

using System;
using FluentValidation;
using System.Threading.Tasks;
using Catalog.Domain.Requests.Artists;
using Catalog.Domain.Requests.Genre;
using Catalog.Domain.Services;

namespace Catalog.Domain.Requests.Item.Validators
{
    public class AddItemRequestValidator : AbstractValidator<AddItemRequest>
    {
        private readonly IArtistService _artistService;
 private readonly IGenreService _genreService;

 public AddItemRequestValidator(IArtistService artistService, IGenreService genreService)
 {
 _artistService = artistService;
 _genreService = genreService;
 }

       private async Task<bool> ArtistExists(Guid artistId, CancellationToken cancellationToken)
        {
            if (string.IsNullOrEmpty(artistId.ToString()))
                return false;

            var artist = await _artistService.GetArtistAsync(new GetArtistRequest {Id = artistId});

            return artist != null;
        }

        private async Task<bool> GenreExists(Guid genreId, CancellationToken cancellationToken)
        {
            if (string.IsNullOrEmpty(genreId.ToString()))
                return false;

            var genre = await _genreService.GetGenreAsync(new GetGenreRequest {Id = genreId});

            return genre != null;
        }
    }
}

现在,AddItemRequestValidator类使用构造函数注入模式注入了IArtistServiceIGenreService接口。除此之外,验证类还定义了两个方法,ArtistExistsGenreExists,这些方法将通过调用IArtistServiceIGenreService接口方法来验证ArtistIdGenreId字段是否存在于数据库中。此外,我们可以通过以下方式改进验证规则:

using System;
...

namespace Catalog.Domain.Item.Validators
{
    public class AddItemRequestValidator : AbstractValidator<AddItemRequest>
    {
         ..
        public AddItemRequestValidator(IArtistService artistService, IGenreService genreService)
        {
            _artistService = artistService;
            _genreService = genreService;
            RuleFor(x => x.ArtistId)
 .NotEmpty()
 .MustAsync(ArtistExists).WithMessage("Artist must exists");
 RuleFor(x => x.GenreId)
 .NotEmpty()
 .MustAsync(GenreExists).WithMessage("Genre must exists");
           ...
        }
...

新的规则将ArtistIdGenreId字段分别绑定到ArtistExistsGenreExists方法。同样的方法也可以用于EditItemRequestValidator的实现,它将使用相同的模式来验证相关实体。因此,我们现在需要扩展测试类以验证新的验证规则:

...
using FluentValidation.TestHelper;
using Moq;

namespace Catalog.Domain.Tests.Requests.Item.Validators
{
    public class AddItemRequestValidatorTests
    {
        private readonly Mock<IArtistService> _artistServiceMock;
        private readonly Mock<IGenreService> _genreServiceMock;
        private readonly AddItemRequestValidator _validator;

        public AddItemRequestValidatorTests()
        {
            _artistServiceMock = new Mock<IArtistService>();
            _artistServiceMock
                .Setup(x => x.GetArtistAsync(It.IsAny<GetArtistRequest>()))
                .ReturnsAsync(() => new ArtistResponse());

            _genreServiceMock = new Mock<IGenreService>();
            _genreServiceMock
                .Setup(x => x.GetGenreAsync(It.IsAny<GetGenreRequest>()))
                .ReturnsAsync(() => new GenreResponse());

            _validator = new AddItemRequestValidator(_artistServiceMock.Object, _genreServiceMock.Object);
        }

        [Fact]
        public void should_have_error_when_ArtistId_doesnt_exist()
        {
            _artistServiceMock
                .Setup(x => x.GetArtistAsync(It.IsAny<GetArtistRequest>()))
                .ReturnsAsync(() => null);

            var addItemRequest = new AddItemRequest { Price = new Price(), ArtistId = Guid.NewGuid() };

            _validator.ShouldHaveValidationErrorFor(x => x.ArtistId, addItemRequest);
        }

        [Fact]
        public void should_have_error_when_GenreId_doesnt_exist()
        {
            _genreServiceMock
                .Setup(x => x.GetGenreAsync(It.IsAny<GetGenreRequest>()))
                .ReturnsAsync(() => null);

            var addItemRequest = new AddItemRequest { Price = new Price(), GenreId = Guid.NewGuid() };

            _validator.ShouldHaveValidationErrorFor(x => x.GenreId, addItemRequest);
        }
    }
}

上述代码将Mock<IArtistService>Mock<IGenreService>类型注入到验证类构造函数中,以模拟服务层的操作并验证验证类的逻辑。它还使用ShouldHaveValidationErrorFor方法来检查预期的响应。如果相关实体的 ID 缺失于数据源,ArtistExistsGenreExists方法应该抛出验证错误。

更新Startup类中的依赖项

在前面的几个部分中,我们创建了 IArtistServiceIGenreService 接口及其相应的实现。因此,我们想要更新应用程序的依赖图。尽管我们已经在 ConfigureService 方法中调用了 DependenciesRegistration 静态类的 AddMappersAddServices 扩展方法,但我们需要以下方式更新依赖项:

    public static class DependenciesRegistration
    {
        ...

        public static IServiceCollection AddServices(this IServiceCollection services)
        {
            services
                .AddScoped<IItemService, ItemService>()
                .AddScoped<IArtistService, ArtistService>()
 .AddScoped<IGenreService, GenreService>();

            return services;
        }
    }

我们还应该在 ConfigureService 方法中添加 IArtistRepositoryIGenreRepository

...
public void ConfigureServices(IServiceCollection services)
{
    services
         ...
        .AddScoped<IItemRepository, ItemRepository>()
 .AddScoped<IArtistRepository, ArtistRepository>()
 .AddScoped<IGenreRepository, GenreRepository>()
        ....
}

现在,Startup 类的 ConfigureServices 方法定义了我们堆栈中所需的所有依赖项。我们目前能够解析与 ArtistControllerGenreController 类相关的依赖链,这些类我们将在下一节中定义。

添加相关控制器

现在,Catalog.Domain 项目能够通过我们在 IArtistServiceIGenreService 类中实现的逻辑来处理与 ArtistGenre 实体相关的请求。因此,我们可以通过创建控制器层来处理传入的 HTTP 请求。由于我们有不同的独立实体,我们将实现 ArtistControllerGenreController 控制器类。让我们首先关注 ArtistController

using Catalog.API.Filters;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Route("api/artist")]
    [ApiController]
    [JsonException]
    public class ArtistController : ControllerBase
    {
        private readonly IArtistService _artistService;

        public ArtistController(IArtistService artistService)
        {
            _artistService = artistService;
        }
    }
}

上述代码定义了 ArtistController 类的初始签名:实现注入了来自服务层的 IArtistService 接口,以便与存储在数据库中的信息进行交互。我们可以进一步定义 GetGetById 动作方法的实现:

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Catalog.API.ResponseModels;
using Catalog.Domain.Requests.Artists;
using Catalog.Domain.Responses;

namespace Catalog.API.Controllers
{
    ..
    public class ArtistController : ControllerBase
    {
        ...
        [HttpGet]
        public async Task<IActionResult> Get([FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
        {
            var result = await _artistService.GetArtistsAsync();

            var totalItems = result.ToList().Count;

            var itemsOnPage = result
                .OrderBy(c => c.ArtistName)
                .Skip(pageSize * pageIndex)
                .Take(pageSize);

            var model = new PaginatedItemsResponseModel<ArtistResponse>(
                pageIndex, pageSize, totalItems, itemsOnPage);

            return Ok(model);
        }

        [HttpGet("{id:guid}")]
        public async Task<IActionResult> GetById(Guid id)
        {
            var result = await _artistService.GetArtistAsync(new GetArtistRequest {Id = id});

            return Ok(result);
        }
    }
}

前面的代码展示了 ArtistController 的实现,它对应于 /api/artist 路由。ArtistController 使用 PaginatedItemsResponseModel 来检索所有艺术家的相关信息。同样地,我们可以使用 IAritstService 接口来执行其他操作,并将它们映射到动作方法:

...
        [HttpGet("{id:guid}/items")]
        public async Task<IActionResult> GetItemsById(Guid id)
        {
            var result = await _artistService.GetItemByArtistIdAsync(new GetArtistRequest { Id = id });

            return Ok(result);
        }

        [HttpPost]
        public async Task<IActionResult> Post(AddArtistRequest request)
        {
            var result = await _artistService.AddArtistAsync(request);

            return CreatedAtAction(nameof(GetById), new { id = result.ArtistId }, null);
        }
}

前面的 ArtistController 类定义了以下路由:

动词 路径 描述
GET /api/artist 检索数据库中存在的所有艺术家
GET /api/artist/{id} 获取具有相应 ID 的艺术家
GET /api/artist/{id}/items/ 获取具有相应艺术家 ID 的项目
POST /api/artist/ 创建一个新的艺术家并检索它

对于每个路由,目录服务 将通过执行 IArtistService 接口方法来检索相应的数据。ArtistService 的实现将请求调度到包含在 Catalog.Infrastructure 项目中的存储库实现对应的相应方法。类似于 ItemController,我们可以通过使用 FluentValidation 来实现任何额外的验证行为。例如,在 AddArtistRequest 的情况下,我们可以进行以下实现:

using FluentValidation;

namespace Catalog.Domain.Requests.Artists.Validators
{
    public class AddArtistRequestValidator : AbstractValidator<AddArtistRequest>
    {
        public AddArtistRequestValidator()
        {
            RuleFor(artist => artist.ArtistName).NotEmpty();
        }
    }
}

AddArtistRequest的情况下,我们希望防止用户添加空的ArtistName字段。我们可以在Requests/Artist/Validator路径下创建一个额外的AddArtistRequestValidator类。验证类只包含一个与非空ArtistName字段相关的规则。

同样的实现模式也可以应用于GenreController类。以下代码定义了类实现的签名:

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Catalog.API.Filters;
using Catalog.API.ResponseModels;
using Catalog.Domain.Requests.Genre;
using Catalog.Domain.Responses;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Route("api/genre")]
    [ApiController]
    [JsonException]
    public class GenreController : ControllerBase
    {
        private readonly IGenreService _genreService;

        public GenreController(IGenreService genreService)
        {
            _genreService = genreService;
        }
    }
}

上述代码使用构造函数注入实践注入了IGenreService接口。此外,我们可以继续创建为ArtistController类定义的相应路由:

...

[HttpGet]
public async Task<IActionResult> Get([FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
{
    var result = await _genreService.GetGenreAsync();

    var totalItems = result.ToList().Count;

    var itemsOnPage = result
        .OrderBy(c => c.GenreDescription)
        .Skip(pageSize * pageIndex)
        .Take(pageSize);

    var model = new PaginatedItemsResponseModel<GenreResponse>(
        pageIndex, pageSize, totalItems, itemsOnPage);

    return Ok(model);
}

[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
    var result = await _genreService.GetGenreAsync(new GetGenreRequest {Id = id});

    return Ok(result);
}

[HttpGet("{id:guid}/items")]
public async Task<IActionResult> GetItemById(Guid id)
{
    var result = await _genreService.GetItemByGenreIdAsync(new GetGenreRequest {Id = id});

    return Ok(result);
}

[HttpPost]
public async Task<IActionResult> Post(AddGenreRequest request)
{
    var result = await _genreService.AddGenreAsync(request);

    return CreatedAtAction(nameof(GetById), new {id = result.GenreId}, null);
}
...

上述代码在GenreController类中定义了GetGetByIdGetItemByIdPost操作方法。每个操作都调用底层服务层以与数据库交互。尽管操作方法的实现与ArtistController类相似,但我仍然保持类分开;因此,GenreArtist实体可以独立演变,而不相互依赖。

扩展ArtistController类的测试

一旦我们通过ArtistControllerGenreController类公开了 API 路由,我们就可以使用与ItemController相同的方法来测试这些公开的路径。我们应该继续在Catalog.API.Tests项目中创建一个新的ArtistControllerTests类:

using System.Collections.Generic;
...

namespace Catalog.API.Tests.Controllers
{
    public class ArtistControllerTests : IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        public ArtistControllerTests(InMemoryApplicationFactory<Startup> factory)
        {
            _factory = factory;
        }

        private readonly InMemoryApplicationFactory<Startup> _factory;

        [Theory]
        [InlineData("/api/artist/")]
        public async Task smoke_test_on_items(string url)

        {
            var client = _factory.CreateClient();
            var response = await client.GetAsync(url);

            response.EnsureSuccessStatusCode();
        }

        [Theory]
        [InlineData("/api/artist/?pageSize=1&pageIndex=0", 1, 0)]
        public async Task get_should_returns_paginated_data(string url, int pageSize, int pageIndex)

        {
            var client = _factory.CreateClient();
            var response = await client.GetAsync(url);

            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var responseEntity =
                JsonConvert.DeserializeObject<PaginatedItemResponseModel<GenreResponse>>(responseContent);

            responseEntity.PageIndex.ShouldBe(pageIndex);
            responseEntity.PageSize.ShouldBe(pageSize);
            responseEntity.Data.Count().ShouldBe(pageSize);
        }

        [Theory]
        [LoadData("artist")]
        public async Task get_by_id_should_return_right_artist(Artist request)
        {
            var client = _factory.CreateClient();
            var response = await client.GetAsync($"/api/artist/{request.ArtistId}");

            response.EnsureSuccessStatusCode();

            var responseContent = await response.Content.ReadAsStringAsync();
            var responseEntity = JsonConvert.DeserializeObject<Artist>(responseContent);

            responseEntity.ArtistId.ShouldBe(request.ArtistId);
        }
  ...

上述代码描述了获取分页艺术家操作方法和按 ID 获取操作方法的测试用例。两者都使用相同的InMemoryApplicationFactory工厂来设置模拟的内存数据源。也可以结合额外的测试来验证添加艺术家过程:*

...
        [Fact]
        public async Task add_should_create_new_artist()
        {
            var addArtistRequest = new AddArtistRequest{ ArtistName = "The Braze" };

            var client = _factory.CreateClient();
            var httpContent = new StringContent(JsonConvert.SerializeObject(addArtistRequest), Encoding.UTF8,
                "application/json");
            var response = await client.PostAsync("/api/artist", httpContent);

            response.EnsureSuccessStatusCode();

            var responseHeader = response.Headers.Location;

            response.StatusCode.ShouldBe(HttpStatusCode.Created);
            responseHeader.ToString().ShouldContain("/api/artist/");
        }
    }
}

固定类为每个测试方法初始化一个新的TestServer实例,并通过CreateClient方法提供一个新的HttpClient实例来测试ArtistController类公开的路由。测试还使用LoadData属性从record-data.json文件中获取有关测试记录的匹配信息。可以采用类似的实现方法来测试GenreController类的操作方法。

最终概述

最后,让我们快速概述一下我们在第八章,构建数据访问层,第九章,实现领域逻辑和本章中实现的代码结构。以下架构图展示了Catalog.API解决方案的实际构建:

图片

一旦 Web 服务实例接收到客户端请求,它将请求调度到控制器的相应操作方法。随后,操作方法执行由 Catalog.Domain 项目中定义的服务类公开的相应功能,将请求发送到 Catalog.Infrastructure 项目中定义的相关存储库,然后映射实体与请求和响应模型。

摘要

本章涵盖了关于 Catalog.API 项目设计和开发的各个方面。此外,它还展示了如何构建我们的 API 路由。本章涵盖的主题是我们 Web 服务核心实现的一部分;因此,它们提供了在 .NET Core 中公开 HTTP 路由和处理请求和响应所需的所有知识。除此之外,本章还涵盖了使用框架提供的 Web 工厂工具进行的集成测试和实现。

在下一章中,我们将介绍关于 Web 服务的一些其他附加主题,例如如何实现数据软删除方法以及如何使用 Hypermedia As The Engine Of Application StateHATEOAS) 方法。本章还将涵盖一些与 .NET Core 相关的主题,例如对异步编程的简要介绍以及在 .NET Core 中的最佳实践。

第十一章:构建 API 的高级概念

上一章介绍了 Web 服务 HTTP 层的实现。尽管服务的核心功能已经到位,但仍有一些细节需要完善。本章将介绍一些额外的实现,这些实现将成为目录 Web 服务的一部分,例如软删除资源、HATEOAS 方法、添加响应时间中间件以及 ASP.NET Core 中异步代码的一些最佳实践。更具体地说,它将涵盖以下主题:

  • 实现软删除技术

  • 实现 HATEOAS

  • ASP.NET Core 中异步代码概述

  • 使用中间件测量 API 的响应时间

本章中提供的代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3

实现软删除过程

如同在第五章中提到的Web 服务堆栈在 ASP.NET Core 中删除资源部分,软删除技术在现实世界的应用中是一种常见的删除实践。此外,从我们的数据源物理删除实体相当不常见。

被删除的数据可能对历史目的以及执行分析和报告相关。软删除实现涉及我们在前三个章节中看到的所有项目。为了继续,让我们在Catalog.Domain项目中的Item实体中添加一个新的IsInactive字段:

namespace Catalog.Domain.Entities
{
    public class Item
    {
        ...
        public bool IsInactive { get; set; }
    }
}

由于我们更改了Item实体的模式,我们需要在Catalog.API项目中执行以下命令以执行另一个 EF Core 迁移:

dotnet ef migrations add Added_IsInactive_field
dotnet ef database update

执行上述命令的结果将在Migrations文件夹中生成一个新的迁移文件,并将新创建的迁移应用于Startup类中指定的数据库。

此外,正如我们在第八章中看到的,构建数据访问层,如果我们想连接到本地的 Docker SQL Server 实例,我们可以在appsettings.json文件中指定以下连接字符串:

{
    "Logging": {
        "LogLevel": {
            "Default": "Warning"
        }
    },
 "DataSource": {
 "ConnectionString": "Server=localhost,1433;Initial Catalog=Store;User Id=catalog_srv;Password=P@ssw0rd"
    }
}

之前提供的连接字符串提供了连接到本地 SQL Server 实例的连接。为了运行实例,必须遵循我们在第八章中看到的命令。以下使用名称 sql1 运行 docker 实例:

docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=<YOUR_SA_PASSWORD>" -p 1433:1433 --name sql1 -d mcr.microsoft.com/mssql/server:2017-latest

此外,我们可以使用以下命令登录到容器:

docker exec -it sql1 "bash"

最后,我们可以执行sqlcmd以登录到 SQL 服务器实例:

/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P '<YOUR_SA_PASSWORD>'
1> CREATE LOGIN catalog_srv WITH PASSWORD = 'P@ssw0rd';
2> CREATE DATABASE Store;
3> GO
1> USE Store;
2> CREATE USER catalog_srv;
3> GO
1> EXEC sp_addrolemember N'db_owner', N'catalog_srv';
2> GO

上面的代码创建了一个名为 catalog_srv 的登录用户的 Store 数据库。在本书的后面部分,我们将看到如何使用 Docker 提供的工具来自动化此过程。现在,数据库模式已更新,我们能够通过在应用程序的存储库和服务层中包含 IsInactive 字段来继续进行。

更新 IItemRepository 实现

自从我们在数据库中添加了新的 IsInactive 字段后,我们可以通过调整 IItemRepository 接口来根据 IsInactive 字段过滤我们的数据来继续进行。因此,我们将通过实施以下更改来保持数据源的一致性:

  • IItemRepository.GetAsync() 方法将通过 IsInactive = false 过滤所有字段。因此,生成的响应将仅包含活动实体。这种方法的优点是,当我们尝试从数据库中获取多个实体时,我们可以得到一个轻量级的响应。

  • 同样,GetItemByArtistIdAsyncGetItemByGenreIdAsync 将使用 IsInactive = false 标志来过滤结果。此外,在这种情况下,我们希望尽可能保持响应尽可能轻量。

  • IItemRepository.GetAsync(Guid id) 方法将根据 IsInactive 标志检索所需实体的详细信息。此外,此方法被应用程序的验证检查所使用,因此,当我们插入新对象时,无论它们是活动状态还是非活动状态,我们都需要避免重复的 ID。

让我们通过实现 IItemRepository 接口中提到的这些规范来继续:

namespace Catalog.Infrastructure.Repositories
{
    public class ItemRepository
        : IItemRepository
    {
        ...

        public async Task<IEnumerable<Item>> GetAsync()
        {
            return await _context.Items
                .Where(x => !x.IsInactive)
                .AsNoTracking()
                .ToListAsync();
        }

        public async Task<IEnumerable<Item>> GetItemByArtistIdAsync(Guid id)
        {
            var items = await _context
                .Items
                .Where(x => !x.IsInactive)
                .Where(item => item.ArtistId == id)
                .Include(x => x.Genre)
                .Include(x => x.Artist)
                .ToListAsync();

            return items;
        }

        public async Task<IEnumerable<Item>> GetItemByGenreIdAsync(Guid id)
        {
            var items = await _context.Items
                .Where(x => !x.IsInactive)
                .Where(item => item.GenreId == id)
                .Include(x => x.Genre)
                .Include(x => x.Artist)
                .ToListAsync();

            return items;
        }
...

此实现通过使用 Where LINQ 子句过滤 IsInactive == false 来更改 GetAsyncGetItemByArtistIdAsyncGetItemByGenreIdAsync 方法的行为。另一方面,GetAsync(Guid id) 保持不变,因为,如规范中所述,我们希望获取详细信息操作始终检索信息,包括记录非活动的情况。因此,我们可以通过在项目根目录中执行以下命令来测试生成的实现:

dotnet test

所有测试都应该通过,因为默认情况下,IsInactive 布尔字段始终为 false。为了测试 Where(x=>!x.IsInactive) 的更改,我们可以在 Catalog.API/tests/Catalog.Fixtures/Data/item.json 文件中添加一条新记录,添加一个非活动项:

{
    "Id": "f5da5ce4-091e-492e-a70a-22b073d75a52",
    "Name": "Untitled",
    "Description": "Untitled by Kendrick Lamar",
    "PictureUri": "https://mycdn.com/pictures/32423423",
    "ReleaseDate": "2016-01-01T00:00:00+00:00",
    "Price": {
      "Amount": 23.5,
      "Currency": "EUR"
    },
    "Format": "Vinyl 33g",
    "AvailableStock": 6,
    "GenreId": "c04f05c0-f6ad-44d1-a400-3375bfb5dfd6",
    "Genre": null,
    "ArtistId": "3eb00b42-a9f0-4012-841d-70ebf3ab7474",
    "Artist": null,
    "IsInactive": true
  },

因此,我们可以通过创建一个新的测试并计算结果来验证通过获取操作检索的记录数是否发生变化。例如,如果 item.json 文件包含三个记录,其中一个是非活动的,则获取操作应检索三个记录。作为替代方案,我们可以通过验证结果中的 Item.Id 字段不包含非活动记录来双重检查:

namespace Catalog.Infrastructure.Tests
{
    ...
    public class ItemRepositoryTests : IClassFixture<CatalogContextFactory>
    {

        [Theory]
        [InlineData("f5da5ce4-091e-492e-a70a-22b073d75a52")]
        public async Task getitems_should_not_return_inactive_records(string id)
        {
            var result =
                await _sut.GetAsync();

            result.Any(x => x.Id == new Guid(id)).ShouldBeFalse();
        }

      ...
   }
}

我选择通过在ItemRepositoryTests类中添加一个新的getitems_should_not_return_inactive_records方法来测试删除行为。该测试验证当我调用仓库的GetAsync方法时,结果排除了作为测试参数指定的IdItem实体。对于GetItemByArtistIdAsyncGetItemByGenreIdAsync方法,也可以采取类似的方法。

实现删除操作

现在我们已经在仓库端实现了正确的过滤逻辑,接下来让我们在Catalog.Domain项目中通过在IItemService接口声明中添加一个新的DeleteItemAsync方法来实施删除操作:

namespace Catalog.Domain.Services
{
    public interface IItemService
    {
        ...
        Task<ItemResponse> DeleteItemAsync(DeleteItemRequest request);
    }
}

DeleteItemAsync方法引用一个DeleteItemRequest类型,它可以声明如下:

using System;

namespace Catalog.Domain.Requests.Item
{
    public class DeleteItemRequest
    {
        public Guid Id { get; set; }
    }
}

一旦我们更新了IItemService接口声明,我们就可以继续向ItemService具体类添加实现:

public async Task<ItemResponse> DeleteItemAsync(DeleteItemRequest request)
{
    if (request?.Id == null) throw new ArgumentNullException();

    var result = await _itemRepository.GetAsync(request.Id);
    result.IsInactive = true;

    _itemRepository.Update(result);
    await _itemRepository.UnitOfWork.SaveChangesAsync();

    return _itemMapper.Map(result);
}

DeleteItemAsync方法接收一个Id字段,它是DeleteItemRequest类的一部分。此外,它使用GetAsync方法获取实体的详细信息,然后通过传递带有IsInactive标志设置为true的实体来调用Update方法。下一步是实现ItemController中的一个新操作方法,该方法涵盖了以下 HTTP 调用:

DELETE /items/{id}

此外,我们可以继续按照以下方式更改ItemController

using System;
using System.Threading.Tasks;
using Catalog.API.Filters;
using Catalog.Domain.Requests.Item;
using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    public class ItemController : ControllerBase
    {
        ...

 [HttpDelete("{id:guid}")]
 [ItemExists]
 public async Task<IActionResult> Delete(Guid id)
 {
 var request = new DeleteItemRequest { Id = id }; 
 await _itemService.DeleteItemAsync(request); 
 return NoContent();
 }
    }
}

Delete操作方法被HttpDelete属性动词装饰,这意味着它与 HTTP Delete请求映射。实现通过DeleteItemAsync方法设置IsInactive标志,并返回一个NoContent结果,这代表 HTTP 204 No Content状态码。

我们将继续通过实现以下测试来测试对ItemController类所做的更改:

namespace Catalog.API.Tests.Controllers
{
    public class ItemsController : IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        private readonly InMemoryApplicationFactory<Startup> _factory;

        public ItemsController(InMemoryApplicationFactory<Startup> factory)
        {
            _factory = factory;
        }

        ...

        [Theory]
        [LoadData("item")]
        public async Task delete_should_returns_no_content_when_called_with_right_id(DeleteItemRequest 
        request)
        {
            var client = _factory.CreateClient();

            var response = await client.DeleteAsync($"/api/items/{request.Id}");

            response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
        }

        [Fact]
        public async Task delete_should_returns_not_found_when_called_with_not_existing_id()
        {
            var client = _factory.CreateClient();

            var response = await client.DeleteAsync($"/api/items/{Guid.NewGuid()}");

            response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
        }
    }
}

此测试代码涵盖了以下两个行为:

  • delete_should_returns_no_content_when_called_with_right_id测试方法检查操作方法是否正确返回 HTTP NoContent响应。

  • delete_should_returns_not_found_when_called_with_not_existing_id方法检查当传递一个不存在的 GUID(Guid.NewGuid)时,操作方法是否返回HttpStatusCode.NotFound

在这两个测试用例中,我们使用已经实现的测试基础设施来验证 Web 服务的新行为。在本节中,我们看到了如何扩展我们的 Web 服务以支持软删除操作。

下一个部分将专注于实现 HATEOAS 响应方法。

实现 HATEOAS

我们已经在第一章中讨论了 HATEOAS 原则的理论,REST 101 和 ASP.NET Core 入门指南。本节解释了如何为Catalog.API项目中现有的ItemController实现 HATEOAS 方法。以下代码片段展示了通用 HATEOAS 响应的示例:

{
    "_links": {
        "get": {
            "rel": "ItemsHateoas/Get",
            "href": "https://localhost:5001/api/hateoas/items",
            "method": "GET"
        },
        "get_by_id": {
            "rel": "ItemsHateoas/GetById",
            "href": "https://localhost:5001/api/hateoas/items/8ff0fe8f-9dbc-451f-7a57-08d652340f56",
            "method": "GET"
        },
        "create": {
            "rel": "ItemsHateoas/Post",
            "href": "https://localhost:5001/api/hateoas/items",
            "method": "POST"
        },
        "update": {
            "rel": "ItemsHateoas/Put",
            "href": "https://localhost:5001/api/hateoas/items/8ff0fe8f-9dbc-451f-7a57-08d652340f56",
            "method": "PUT"
        },
        "delete": {
            "rel": "ItemsHateoas/Delete",
            "href": "https://localhost:5001/api/hateoas/items/8ff0fe8f-9dbc-451f-7a57-08d652340f56",
            "method": "DELETE"
        }
    },
    "id": "8ff0fe8f-9dbc-451f-7a57-08d652340f56",
    "name": "Malibu",
    "description": "Malibu. by Anderson Paak",
    "labelName": "Steel Wool/OBE/Art Club",
    "price": {
        "amount": 23.5,
        "currency": "EUR"
    },
    "pictureUri": "https://mycdn.com/pictures/32423423",
    "releaseDate": "2016-01-01T00:00:00+00:00",
    "format": "Vinyl 43",
    "availableStock": 3,
    "genreId": "7fcdde39-342b-4f80-0db1-08d65233f5a6",
    "genre": null,
    "artistId": "ff1921a8-f49a-4db2-0c2e-08d65233875e",
    "artist": null
}

尽管这种响应给有效的 JSON 响应增加了大量负载,但它通过提供我们数据中每个操作和资源的 URL 来帮助客户端。HATEOAS 原则将与 ItemController 并行实施,并且它将向客户端提供所有必要的信息以与目录服务拥有的数据进行交互.

用 HATEOAS 数据丰富我们的模型

要使 HATEOAS 正常工作,我们将依赖一个名为 RiskFirst.Hateoas 的第三方 NuGet 包. 此包允许我们有效地在我们的服务中集成 HATEOAS 原则。首先,让我们通过在两个项目文件夹中执行以下命令将包添加到 Catalog.API

 dotnet add package RiskFirst.Hateoas

接下来,创建一个新的实体,称为 ItemHateoasResponse 类,它表示 HATEOAS 响应。此类引用已实现的 ItemResponse 类,并实现了 RiskFirst.Hateoas 包公开的 ILinkContainer 接口:

using System.Collections.Generic;
using Newtonsoft.Json;
using RiskFirst.Hateoas.Models;
using Catalog.Domain.Responses.Item;

namespace Catalog.API.ResponseModels
{
        public class ItemHateoasResponse :  ILinkContainer
        {
            public ItemResponse Data;          
            private Dictionary<string, Link> _links;

            [JsonProperty(PropertyName = "_links")]
            public Dictionary<string, Link> Links
            {
                get => _links ?? (_links = new Dictionary<string, Link>());
                set => _links = value;
            }

            public void AddLink(string id, Link link)
            {
                Links.Add(id, link);
            }
        }
}

Links 字段是 Dictionary<string, Link> 类型,它包含与响应相关的资源的 URL。例如,如果我们获取特定项目的响应,Link 属性将提供添加、更新和删除项目的 URL。AddLink 方法用于向 Links 字典中添加新字段。因此,我们将继续在控制器中使用这种类型的响应,以便向客户端提供符合 HATEOAS 的反应。

在控制器中实现 HATEOAS

最后一步是实现一个可以处理之前实现的 ItemHateoasResponse 响应模型的控制器。更具体地说,我们可以在我们的 Catalog.API 项目中创建一个新的 ItemHateoasController 类。请注意,我们正在为了演示目的构建一个新的控制器。另一种选择是编辑已定义的 ItemController 以返回符合 HATEOAS 的响应。

ItemHateoasController 类将使用 RiskFirst.Hateoas 命名空间提供的 ILinksService 接口来丰富 ItemHateoasResponse 模型的 Links 属性并将其返回给我们的客户端。让我们通过实现控制器来继续:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Catalog.API.Filters;
using Catalog.API.ResponseModels;
using Catalog.Domain.Requests.Item;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Mvc;
using RiskFirst.Hateoas;

namespace Catalog.API.Controllers
{
    [Route("api/hateoas/items")]
    [ApiController]
    [JsonException]
    public class ItemsHateoasController : ControllerBase
    {
        private readonly IItemService _itemService;
        private readonly ILinksService _linksService;

        public ItemsHateoasController(ILinksService linkService, IItemService itemService)
        {
            _linksService = linkService;
            _itemService = itemService;
        }

        [HttpGet(Name = nameof(Get))]
        public async Task<IActionResult> Get([FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
        {
            var result = await _itemService.GetItemsAsync();

            var totalItems = result.Count();

            var itemsOnPage = result.OrderBy(c => c.Name)
                .Skip(pageSize * pageIndex)
                .Take(pageSize);

            var hateoasResults = new List<ItemHateoasResponse>();

            foreach (var itemResponse in itemsOnPage)
            {
                var hateoasResult = new ItemHateoasResponse { Data = itemResponse };
                await _linksService.AddLinksAsync(hateoasResult);

                hateoasResults.Add(hateoasResult);
            }

            var model = new PaginatedItemResponseModel<ItemHateoasResponse>(
                pageIndex, pageSize, totalItems, hateoasResults);

            return Ok(model);
        }

        [HttpGet("{id:guid}", Name = nameof(GetById))]
        [ItemExists]
        public async Task<IActionResult> GetById(Guid id)
        {
            var result = await _itemService.GetItemAsync(new GetItemRequest { Id = id });
            var hateoasResult = new ItemHateoasResponse { Data = result };
            await _linksService.AddLinksAsync(hateoasResult);

            return Ok(hateoasResult);
        }

GetGetById 动作方法与 ItemController 中现有的方法非常相似。唯一的区别是它们返回不同的响应类型,该类型由 ItemHateoasResponse 类表示。此外,动作方法将 response 对象分配给 Data 字段。每个动作方法还调用由 ILinksService 接口提供的 AddLinksAsync 方法来填充链接属性。同样,我们还可以扩展控制器类中其他动作方法的行为:

...

        [HttpPost(Name = nameof(Post))]
        public async Task<IActionResult> Post(AddItemRequest request)
        {
            var result = await _itemService.AddItemAsync(request);
            return CreatedAtAction(nameof(GetById), new { id = result.Id }, null);
        }

        [HttpPut("{id:guid}", Name = nameof(Put))]
        [ItemExists]
        public async Task<IActionResult> Put(Guid id, EditItemRequest request)
        {
            request.Id = id;
            var result = await _itemService.EditItemAsync(request);

            var hateoasResult = new ItemHateoasResponse { Data = result };
            await _linksService.AddLinksAsync(hateoasResult);

            return Ok(hateoasResult);
        }

        [HttpDelete("{id:guid}", Name = nameof(Delete))]
        [ItemExists]
        public async Task<IActionResult> Delete(Guid id)
        {
            var request = new DeleteItemRequest { Id = id };
            await _itemService.DeleteItemAsync(request);
            return NoContent();
        }
..

这是创建、更新和删除操作方法的实现声明。在本例中,我们使用 ItemHateoasResponse 模型类来检索操作方法的响应。我们应该注意,操作方法在 [HttpVerb] 属性装饰器中声明了 Name,例如 [HttpDelete("{id:guid}", Name = nameof(Delete))]。实际上,我们将使用在 Startup 类中声明的 Name 来引用每个路由并将其包含在 Link 属性中:

namespace Catalog.API
{
    public class Startup
    {
        ...

        public void ConfigureServices(IServiceCollection services)
        {
          ...

            services.AddLinks(config => 
            {
                config.AddPolicy<ItemHateoasResponse>(policy =>
                {
                    policy
                        .RequireRoutedLink(nameof(ItemsHateoasController.Get), 
                         nameof(ItemsHateoasController.Get))
                        .RequireRoutedLink(nameof(ItemsHateoasController.GetById), 
                         nameof(ItemsHateoasController.GetById), _ => new {id = _.Data.Id})
                        .RequireRoutedLink(nameof(ItemsHateoasController.Post), 
                         nameof(ItemsHateoasController.Post))
                        .RequireRoutedLink(nameof(ItemsHateoasController.Put), 
                         nameof(ItemsHateoasController.Put), x => new {id = x.Data.Id})
                        .RequireRoutedLink(nameof(ItemsHateoasController.Delete), 
                         nameof(ItemsHateoasController.Delete), x => new {id = x.Data.Id});
                });
            });
        }
        ...
    }
}

RiskFirst.Hateoas 包提供的 AddLinks 扩展方法允许我们定义与响应模型相关的策略。这是 config.AddPolicy<ItemHateoasResponse> 方法的例子,它为 ItemsHateoasController 中声明的每个操作方法名称调用 RequireRoutedLink。请注意,与前面的情况类似,我们可以将此代码片段提取到外部扩展方法中,以使 Startup 类尽可能干净。最后,这种方法允许我们为不同的响应模型定义不同的链接组。此外,可以针对特定的响应模型建立策略。

因此,我们现在可以通过在 Catalog.API 文件夹中执行 dotnet run 命令来运行并验证结果。请注意,要运行 Docker SQL Server,请在 appsetting.json 文件中指定连接字符串。之后,我们可以运行以下 curl 请求来验证 ItemsHateoasController。生成的 JSON 响应将如下所示:

{
    "_links": {
        "get": {
            "rel": "ItemsHateoas/Get",
            "href": "https://localhost:5001/api/hateoas/items",
            "method": "GET"
        },
        "get_by_id": {
            "rel": "ItemsHateoas/GetById",
            "href": "https://localhost:5001/api/hateoas/items/8ff0fe8f-9dbc-451f-7a57-08d652340f56",
            "method": "GET"
        },
        "create": {
            "rel": "ItemsHateoas/Post",
            "href": "https://localhost:5001/api/hateoas/items",
            "method": "POST"
        },
        "update": {
            "rel": "ItemsHateoas/Put",
            "href": "https://localhost:5001/api/hateoas/items/8ff0fe8f-9dbc-451f-7a57-08d652340f56",
            "method": "PUT"
        },
        "delete": {
            "rel": "ItemsHateoas/Delete",
            "href": "https://localhost:5001/api/hateoas/items/8ff0fe8f-9dbc-451f-7a57-08d652340f56",
            "method": "DELETE"
        }
    },
    "id": "8ff0fe8f-9dbc-451f-7a57-08d652340f56",
    "name": "Malibu",

...

如您所见,除了项目列表之外,ItemHateoasController 还检索了一个提供额外信息的信封,例如客户端需要的其他用于获取、添加、更新和删除操作的路由。此外,这种方法提供了客户端需要的所有 URI,以便通过 Web 服务公开的信息进行导航。

ASP.NET Core 中的异步代码

在本节中,我们将讨论 ASP.NET Core 的异步代码堆栈。我们已经处理了异步模式,并且已经看到前几章中的一些实现广泛使用了异步代码。

注意,以下部分主要关注基于 .NET Core 的 ASP.NET Core 中的异步代码。请记住,.NET Core 和 .NET Framework 中的异步编程之间存在一些差异。我们习惯于在 .NET Framework 中看到的某些死锁问题在 .NET Core 中不存在。

在我们深入探讨异步代码之前,让我们看看 ASP.NET Core 中同步和异步系统之间的区别。

首先,当一个同步 API 收到请求时,线程池中的一个线程将处理该请求。分配的线程将一直忙碌到请求的生命周期结束。在大多数情况下,API 对数据或第三方 API 执行 I/O 操作,这意味着线程将一直阻塞,直到这些操作结束。更具体地说,一个阻塞的线程不能被其他操作和请求使用。然而,对于异步代码,行为完全不同。

请求将被分配到一个线程,但该线程不会被锁定,它可以被其他操作分配和使用。如果任务被等待且不涉及 CPU 密集型工作,则可以将线程释放回池中以执行其他工作。

任务定义

Task 类型代表异步任务。Task 类代表一个工作单元。我们可以在其他语言中找到类似的概念。例如,在 JavaScript 中,Task 的概念由 Promise 表示。

Task 类型与 CPU 中的线程相关联是常见的,但这并不正确:将方法执行包装在 Task 中并不能保证该操作将在另一个线程上执行。

asyncawait 关键字是处理 C# 中异步操作的最简单方法:async 关键字将方法转换为状态机,并在方法实现中启用 await 关键字。await 关键字向编译器指示需要等待的操作,以便继续执行异步方法。因此,在 C# 代码库中经常可以找到类似的内容:

public async Task<String> GetStringAsync(String url)
{
    var request = await _httpClient.GetAsync(url);
    var responseContent = await request.Content.ReadAsStringAsync();
    return responseContent;
}

前面的代码片段非常直观:它通过 _httpClient 实例的 GetAsync 方法调用一个 url,并使用 ReadAsStringAsync 获取结果字符串并将其存储在 responseContent 对象中。重要的是要理解 asyncawait 是语法糖关键字,同样可以通过使用 ContinueWith 方法以不太易读的方式达到相同的结果:

public Task<String> GetStringAsync(String url) 
{ 
     var request =_httpClient.GetAsync(url); 
     var responseContentTask = request.ContinueWith(http => 
                         http.Result.Content.ReadAsStringAsync()); 
     return responseContentTask.Unwrap(); 
}

这个代码片段与前面的代码具有相同的效果。主要区别在于这个代码不太直观。此外,在执行大量嵌套事务的复杂操作中,我们会创建很多嵌套层级。

接受 async/await 语法作为处理异步代码的主要方式是至关重要的。其他语言也采取了类似的方法来使代码更加整洁和易于阅读。ECMAScript 就是这种情况,它在 ES 2016 中引入了 async/await 语法:github.com/tc39/ecma262/blob/master/README.md.

在 ASP.NET Core 中异步代码的需求

首先需要强调的是,异步代码并非关乎速度。正如之前提到的,异步编程仅仅是关于不阻塞传入的请求。因此,真正的益处在于更好的垂直可伸缩性*,而不是提高我们代码的速度。例如,假设我们的网络服务执行一些 I/O 操作,比如对数据库的查询:如果我们以同步方式运行我们的代码堆栈,那么用于传入请求的线程将被阻塞,直到读取或写入(I/O 操作)过程完成,期间不会被其他请求使用。通过采用异步方法,我们能够在读取/写入操作执行后立即释放线程。因此,一旦 I/O 操作完成,应用程序将选择另一个或相同的线程以继续执行堆栈。

异步代码也为我们系统增加了开销,实际上,有必要添加额外的逻辑来协调异步操作。例如,让我们看一下以下代码:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    // GET api/values
    [HttpGet]
    public async Task<ActionResult<string>> Get()
    {
        return await OperationAsync();
    }

    public async Task<string> OperationAsync()
    {
        await Task.Delay(100);
        return "Response";
    }
}

上述示例定义了一个调用异步方法的操作方法。借助一些外部工具,例如 ILSpy,可以反编译 C#代码并分析 IL 代码。IL 代码也被称为中间代码,这是由 C#编译器生成并在运行时执行的代码。

之前定义的Get方法的 IL 代码的结果转换如下:

[CompilerGenerated]
private sealed class <Get>d__0 : IAsyncStateMachine
{
    // Fields
    public int <>1__state;
    public AsyncTaskMethodBuilder<ActionResult<string>> <>t__builder;
    public ValuesController <>4__this;
    private string <>s__1;
    private TaskAwaiter<string> <>u__1;

    // Methods
    public <Get>d__0();
    private void MoveNext();
    [DebuggerHidden]
    private void SetStateMachine(IAsyncStateMachine stateMachine);
}

如您所见,该方法已被转换为一个实现了IAsyncStateMachine接口的密封类。MoveNext方法持续检查状态机的__state是否发生变化,并更新操作的结果。所有这些操作都是针对我们代码中包含的每个异步操作执行的。

ASP.NET Core 的新特性是什么?

自从在旧 ASP.NET 框架中引入以来,async/await代码之前受到了一些死锁问题的影响。旧版本的 ASP.NET 使用一个名为SynchronizationContext的类,它本质上提供了一种将Task排队到上下文中的方式,当方法调用其他嵌套的异步方法时,并强制它们使用.Result.Wait()关键字以同步方式执行,因此,这会导致请求上下文的死锁。

例如,假设以下代码在旧版本的 ASP.NET 上执行:

   public class ValuesController : ApiController
    {
        public string Get()
        {
            return Operation1Async().Result;
        }

        public async Task<string> Operation1Async()
        {
            await Task.Delay(1000);
            return "Test";
        }
    }

这导致应用程序出现死锁,因为Get()Operation1Async()方法使用了相同的上下文。当Operation1Async()方法捕获上下文,该上下文将被用于继续运行方法时,Get()操作方法会阻塞上下文,因为它正在等待结果。

因此,开发者开始在他们异步代码中填充 .ConfigureAwait(false) 指令,这主要提供了一种通过避免之前看到的死锁问题来在不同的上下文中执行 Task 的方法:

   public class ValuesController : ApiController
    {
        public string Get()
        {
            return Operation1Async().Result;
        }

        public async Task<string> Operation1Async()
        {
            await Task.Delay(1000)
                      .ConfigureAwait(continueOnCapturedContext:false);
            return "Test";
        }
    }

这种方法可行,但我们应该考虑,通过调用 Result.Wait(),我们正在失去异步编程模式提供的所有好处。

关于 ASP.NET Core 的好消息是它不使用 SynchronizationContext无上下文的概念提供了异步代码的轻量级管理:当 ASP.NET 在将异步单元分配给线程之前,在请求上下文中排队每个异步单元,而 ASP.NET Core 则从分配的线程池中选取一个线程并将其附加到异步任务。

此外,ASP.NET 团队在 ASP.NET Core 方面做得非常出色,正如我们在前面的章节中看到的,ASP.NET Core 管道中的几乎每个组件都有同步和异步版本。例如,我们看到的动作过滤器就有同步和异步版本。第六章过滤器管道*。

虽然在新的 .NET Core 版本中不再需要 .ConfigureAwait(false) 方法,但我们应记住,在某些代码库中它仍然很有用。如果您正在构建一个在 .NET Core 中编译但在旧版 .NET Framework 中也编译的 .NET 库,您仍然应该使用 .ConfigureAwait(false) 方法,以避免死锁。

异步编程的最佳实践

让我们来看看在 ASP.NET Core 中关于异步编程模型的良好和不良实践。首先,需要牢记的核心概念是避免在同步和异步代码之间混合。如前所述,ASP.NET Core 提供了大多数类和接口的同步和 async 版本。因此,在用同步代码阻塞异步堆栈之前,你应该探索并检查框架提供的所有替代方案。

以下建议来自 .NET 异步编程的高级视角。本书专注于框架的其他方面。如果您想了解更多关于如何使用异步编程的细节,我建议您访问以下链接:github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md。此外,如果您想了解 .NET 中并发的更一般概述,我建议您阅读以下书籍:Concurrency in C# Cookbook by Stephen Cleary (stephencleary.com/book/)。

不要使用 async void 方法

关于.NET Core 和更广泛的.NET 生态系统中异步编程的一个常见错误是声明一个方法为async void。实际上,当我们声明一个方法为async void时,我们将无法捕获该方法抛出的异常。此外,Task类型用于捕获方法的异常并将它们传播给调用者。总之,我们应该始终通过在方法返回void时返回Task类型来实现我们的async方法,否则,当方法返回一个类型时,我们应该返回Task<T>泛型类型。此外,Task类型在我们要通知调用者操作状态时也非常有用:Task类型通过以下属性公开操作状态:StatusIsCanceledIsCompletedIsFaulted

使用 Task.FromResult 代替 Task.Run

如果你之前使用过.NET Core 或.NET Framework,你可能已经处理过Task.FromResultTask.Run。两者都可以用来返回Task<T>。它们之间的主要区别在于它们的输入参数。看看下面的Task.Run代码片段:

 public Task<int> AddAsync(int a, int b)
   {
       return Task.Run(() => a + b);
   }

Task.Run方法会将执行作为线程池中的工作项排队。工作项将立即完成,并带有预计算的值。因此,我们浪费了线程池。此外,我们还应该注意到,Task.Run方法的初始目的是针对客户端.NET 应用程序的:ASP.NET Core 没有针对Task.Run操作进行优化,它不应该用来卸载代码的一部分执行。相反,让我们看看另一个案例:

 public Task<int> AddAsync(int a, int b)
   {
       return Task.FromResult(a + b);
   }

在这种情况下,Task.FromResult方法将预计算的值包装起来,而不会浪费线程池,这意味着我们不会承受Task.Run操作执行带来的开销。

启用取消

另一个重要的话题是启用异步操作的取消。如果我们查看我们在第八章和第九章中实现的服务层,我们可以看到在某些情况下,我们有传递CancellationToken作为参数的可能性。CancellationToken提供了一种轻量级的方式通知所有异步操作,消费者想要取消当前事务。此外,我们的代码可以检查CancellationToken.IsCancellationRequested属性以检测消费者是否请求取消任务。这种方法特别适合长时间运行的操作,因为我们的异步代码的消费者可以在过程中的任何时刻请求取消当前执行的任务。

I/O 绑定操作中的异步代码

当我们以异步方式实现代码时,我们应该问自己底层过程是否涉及任何类型的 I/O 操作。如果是这种情况,我们应该使用该堆栈的异步方法进行操作。例如,ItemRepository类的Get操作涉及到对数据库的查询:

namespace Catalog.Infrastructure.Repositories
{
    public class ItemRepository : IItemRepository
    {
        ..
        public async Task<Item> GetAsync(Guid id)
        {
            var item = await _context.Items
                .AsNoTracking()
                .Where(x => x.Id == id)
                .Include(x => x.Genre)
                .Include(x => x.Artist).FirstOrDefaultAsync();

            return item;
        }
        ...
    }
}

在这种情况下,我们使用FirstOrDefaultAsync方法以异步方式执行操作。相反,如果我们以另一个操作为例,例如ItemRepositoryAdd方法,我们可以看到即使 EF Core 框架公开了AddAsync方法,该操作也不是异步的:

public Item Add(Item order)
{
    return _context.Items
        .Add(order).Entity;
}

这是因为在添加操作的情况下,我们并没有执行任何类型的 I/O 操作:实体被添加到context属性中,并标记为已添加状态。当调用SaveChangesAsync方法时,与数据库的正确同步发生,这个方法是异步的,因为它涉及到与数据库的 I/O 操作。总之,我们应该始终牢记理解我们想要以异步方式执行的操作的完整上下文和堆栈。一般来说,每次我们必须处理文件系统、数据库和任何其他网络调用时,我们应该使用异步堆栈来实现我们的代码,否则我们可以保持代码的同步,以避免额外的线程开销。

使用中间件测量响应时间

测量 ASP.NET Core 操作响应时间的一种常见方法是使用中间件组件。如第三章中所述,与中间件管道一起工作,这些组件在 ASP.NET Core 请求生命周期的边缘操作,并且对于执行跨切面实现非常有用。测量动作方法的响应时间属于这种实现情况。此外,中间件是第一个被请求击中的组件,也是最后一个可以处理输出响应的组件。这意味着我们可以分析和包含请求的几乎整个生命周期。

让我们看看ResponseTimeMiddlewareAsync的一个实现示例:

using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace Catalog.API.Infrastructure.Middleware
{
    public class ResponseTimeMiddlewareAsync {  

        private const string X_RESPONSE_TIME_MS = "X-Response-Time-ms";  

        private readonly RequestDelegate _next;  

        public ResponseTimeMiddlewareAsync(RequestDelegate next) {  
            _next = next;  
        }  

        public Task InvokeAsync(HttpContext context) {  

            var watch = new Stopwatch(); 

            watch.Start();  

            context.Response.OnStarting(() => {  

                watch.Stop();  

                var responseTimeForCompleteRequest = watch.ElapsedMilliseconds;  
                context.Response.Headers[X_RESPONSE_TIME_MS] = responseTimeForCompleteRequest.ToString(); 

                return Task.CompletedTask;  
            });  

            return _next(context);  
        }  
    }  
}

之前的类定义了一个async中间件来检测请求的响应时间。它遵循以下步骤:

  1. 它在InvokeAsync方法中声明了一个Stopwatch实例。

  2. 它执行Stopwatch实例的Start()方法。

  3. 它使用OnStarting方法定义了一个新的响应委托。OnStarting方法允许我们在响应头之前调用一个委托动作,这个动作将被发送到客户端。

  4. 它调用Stop()方法,并使用X-Response-Time-ms自定义头设置ElapsedMilliseconds属性。

可以通过在Startup类中添加以下行将ResponseTimeMiddlewareAsync类包含在中间件管道中:

...
public void Configure(IApplicationBuilder app, IWebHostingEnvironment env)
{
    ...
    app.UseMiddleware<ResponseTimeMiddlewareAsync>();
    ...
}
...

此外,还可以通过实现以下方法,按照我们测试控制器所采取的相同方法来测试中间件:

using System.Threading.Tasks;
using Shouldly;
using Catalog.Fixtures;
using Xunit;

namespace Catalog.API.Tests.Middleware
{
    public class ResponseTimeMiddlewareTests : IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        public ResponseTimeMiddlewareTests(InMemoryApplicationFactory<Startup> factory)
        {
            _factory = factory;
        }

        private readonly InMemoryApplicationFactory<Startup> _factory;

        [Theory]
        [InlineData("/api/items/?pageSize=1&pageIndex=0")]
        [InlineData("/api/artist/?pageSize=1&pageIndex=0")]
        [InlineData("/api/genre/?pageSize=1&pageIndex=0")]
        public async Task middleware_should_set_the_correct_response_time_custom_header(string url)
        {
            var client = _factory.CreateClient();
            var response = await client.GetAsync(url);

            response.EnsureSuccessStatusCode();
 response.Headers.GetValues("X-Response-Time-ms").ShouldNotBeEmpty();
        }
    }
}

ResponseTimeMiddlewareTests 类通过扩展 InMemoryApplicationFactory 类,使我们能够通过 HTTP 调用。我们可以检查响应对象中是否存在 X-Response-Time-ms 头部。需要注意的是,其他测量不包括 Web 服务器或应用程序池的启动时间。此外,当 Web 服务器未初始化时,它还会花费一些额外的时间。

摘要

在本章中,我们介绍了构建 API 的不同主题:从我们资源的软删除到如何测量响应的性能。我们还概述了 ASP.NET Core 的异步编程堆栈以及如何使用它的建议。本章涵盖的主题将在高级开发阶段对您有所帮助,以便提高返回数据的可读性和 Web 服务的性能。在下一章中,我们将看到如何使用容器化技术运行我们的目录解决方案。本章提供了 Docker 的概述以及相关的 Docker Compose 工具。

第十二章:服务的容器化

上一章重点介绍了使用 ASP.NET Core 构建网络服务的一些高级主题。本章快速介绍了容器以及它们如何在本地沙盒环境中运行应用程序方面非常有用。本章的设计并不是要涵盖所有关于容器的内容;相反,它更多的是对它们的简要介绍。我们将学习如何在容器上运行目录服务。

本章将涵盖以下主题:

  • 容器简介

  • 如何在 Docker 上运行目录服务

  • .NET Core Docker 镜像概述

  • 优化 Docker 镜像

容器简介

现在,分布式系统构成了每个应用程序的基础。反过来,分布式系统的基础是容器。容器化的目标是资源在隔离环境中运行。容器定义了分布式系统组件之间的边界和关注点的分离。这种关注点的分离是我们通过提供参数化配置来重用容器的方式。网络服务和网络应用的另一个重要特性是 可伸缩性。应该很容易扩展容器并创建新的实例。

Docker 是一个旨在使用容器创建、运行和部署应用程序的工具。Docker 也被称为推广这项技术的平台。在过去的几年里,Docker 已经成为了一个热门词汇,并且被相当多的公司、初创企业和开源项目所采用。许多项目都与 Docker 有关。

首先,让我们谈谈 Moby (github.com/moby/moby),这是一个由 Docker 创建的开源项目。随着大量的人和社区开始为该项目做出贡献,Docker 决定开发 Moby。Moby 项目的所有贡献,可以被视为 Docker 的一个研发部门,都是开源的。

Moby 是一个用于构建特定容器系统的框架。它提供了一组组件,这些组件是容器系统的基本要素:

  • 操作系统和容器运行时

  • 编排

  • 基础设施管理

  • 网络

  • 存储

  • 安全

  • 构建

  • 镜像分发

它还提供了构建这些组件所需的工具,以创建适用于每个平台和架构的可运行工件。

如果我们寻找更多的下游解决方案,我们有 Docker 社区版CE)和 Docker 企业版EE)版本,这些是使用 Moby 项目的产品。Docker CE 被小型团队和开发者用来构建他们的系统,而商业客户使用 Docker EE。两者都是推荐解决方案,允许我们在开发和企业环境中使用容器化。

本章将使用 Docker CE 对目录服务进行容器化。Docker CE 和 Docker EE 可在 Docker 网站上找到:www.docker.com/。您可以通过遵循提供的步骤下载并安装社区版:www.docker.com/get-started

Docker 术语

在设置我们的服务使用 Docker 之前,让我们快速了解一下这项技术背后的术语。

让我们从定义容器镜像开始,这是创建容器实例所需的所有依赖项和工件列表。术语容器镜像不应与术语容器实例混淆,后者指的是容器镜像的单个实例*。

容器镜像的核心部分是 Dockerfile。Dockerfile 提供了构建容器的指令。例如,对于一个运行.NET Core 解决方案的容器,它提供了恢复包和构建解决方案的命令。Docker 还提供了一种对容器镜像进行标记并将它们分组到称为仓库的集合中的方法。仓库通常由注册表覆盖,允许访问特定的仓库。仓库可以是公共的或私有的,具体取决于它们的使用目的。例如,一家私人公司可以使用私有仓库为内部团队提供不同版本的容器。这种集中式思考方式非常强大,它为我们提供了重用容器的方法。公共仓库的主要例子是hub.docker.com/,这是世界上最大的库,提供容器镜像:

图片

上述图表描述了本节中描述的组件之间的典型交互。客户端通常会提供一些 Docker 命令,这些命令在Docker HOST上被解析和执行。Docker HOST包含我们在本地机器上运行的容器以及这些容器使用的镜像。这些镜像通常来自 Docker 公共或私有仓库,这通常是 Docker Hub 网站或私人公司仓库。

与 Docker 相关的另一个重要术语是 compose 工具。Compose 工具用于定义和运行一个多容器应用程序或服务。通常,composer 会与一个不同格式的定义文件结合使用,例如一个 YAML 文件,该文件定义了容器组的结构。

Compose 工具通常使用docker-compose CLI 命令运行。在本章中,我们将使用docker-compose命令使我们的服务在本地环境中运行。

容器系统的一个基本部分是编排器。编排器简化了多容器系统的使用。此外,编排器通常应用于复杂系统。容器编排器的例子包括 Kubernetes、Azure Service Fabric 和 Docker Swarm。

本书不会涵盖编排器的使用。本章提供了 Docker 和更广泛的容器化能力的高级概述。更复杂的 Docker 主题需要 DevOps 和系统工程技能。

让我们继续探讨容器化的力量以及如何快速使用它来构建我们的应用程序和服务。下一节将应用上一章中构建的目录服务的容器原则。下一节也将广泛使用容器技术来使我们的示例运行起来。

使用 Docker 运行目录服务

本节解释了如何将我们的目录服务与 Docker 结合起来,使其在本地运行。让我们首先检查目录服务背后的系统:

图片

如前图所示,Web 服务部分运行在 microsoft/dotnet Docker 镜像上,数据源部分运行在 Microsoft SQL Server 实例上,使用的是 microsoft/mssql-server-linux Docker 镜像(我们已经在第八章[84b281bd-11a2-4703-81ae-ca080e2a267a.xhtml],构建数据访问层中处理了 MSSQL 的容器化)。这两个镜像都已从 Docker Hub 中现有的公共 Microsoft 仓库下载;让我们看看如何使用 docker-compose 定义整个服务的基础设施。

首先,让我们在项目的根目录下创建一个包含以下内容的 docker-compose.yml 文件:

version: "3.7"
services:
  catalog_api:
    container_name: catalog_api
    build:
      context: .
      dockerfile: containers/api/Dockerfile
    env_file:
      - containers/api/api.env
    networks:
      - my_network
    ports:
      - 5000:5000
      - 5001:5001
    depends_on:
      - catalog_db

  catalog_db:
    image: microsoft/mssql-server-linux
    container_name: catalog_db
    ports:
      - 1433:1433
    env_file:
      - containers/db/db.env
    networks:
      - my_network

networks:
  my_network:
    driver: bridge

前面的文件使用 YAML 语法定义了两个容器。第一个是 catalog_api 容器:它将用于托管基于 ASP.NET Core 框架构建的服务核心部分。第二个容器是 catalog_db,它使用 microsoft/mssql-server-linux 镜像(与我们在第八章[84b281bd-11a2-4703-81ae-ca080e2a267a.xhtml],构建数据访问层中使用的一样)来设置 MSSQL 数据库。

此外,我们应在项目的根目录下创建以下文件夹结构:

mkdir containers
mkdir containers/api
mkdir containers/db

前面的文件夹将包含 docker-compose.yml 文件中指定的 API 和数据库容器的相关文件。让我们继续通过检查 catalog_api 容器的定义来深入了解:

  catalog_api:
    container_name: catalog_api
 build: 
      context: .
 dockerfile: containers/api/Dockerfile

上述代码片段指定当前文件夹为构建上下文,并引用 containers/api/Dockerfile 文件来构建 Docker 镜像。它还通过以下语法引用环境变量文件:

...
  env_file:
      - containers/api/api.env
...

最后,它声明了一个名为 my_network 的网络下的容器,并使用 ports: 指令将端口 5000 暴露给宿主系统。

同样地,catalog_db 容器声明了与 catalog_api 容器相同的网络,并使用之前看到的方法指定了一个不同的环境变量文件。

docker-compose.yml文件末尾,定义了my_network,它使用桥接驱动程序。桥接驱动程序是网络的默认选项。在同一桥接网络下的两个容器可以共享流量。

更多关于开箱即用的驱动程序类型的信息,请参阅以下链接:docs.docker.com/network/

定义环境变量

Docker 提供了许多指定容器环境变量的方法。在前面指定的docker-compose.yml文件中,我们使用了env_file方法。此外,我们还可以通过在catalog_api容器的定义中指定的路径创建api/api.env文件来继续:

ASPNETCORE_URLS=http://*:5000;https://*:5001
ASPNETCORE_ENVIRONMENT=Integration

文件语法期望文件中的每一行都遵循以下格式:VAR=VAL。在前面的例子中,我们正在定义 ASP.NET Core 运行服务所使用的环境变量:ASPNETCORE_URLS变量指定了由网络服务使用的 URL,ASPNETCORE_ENVIRONMENT指定了应用程序使用的环境名称。同样,我们也应该在相应的文件夹中定义db/db.env文件:

SA_PASSWORD=P@ssw0rd
ACCEPT_EULA="Y"

在这种情况下,文件定义了 SQL Server 容器的相应变量:SA_PASSWORD指定系统管理员账户密码,ACCEPT_EULA是 SQL Server 启动过程所需的。

此外,docker-compose支持在.env文件中声明默认环境变量。该文件必须放置在docker-compose.yml文件相同的目录中。该文件包含一些定义环境变量的简单规则。让我们在目录服务目录的根文件夹中创建一个新的.env文件:

COMPOSE_PROJECT_NAME=store

COMPOSE_PROJECT_NAME变量是 Docker 提供的docker-compose命令的保留变量。它指定了运行容器时要使用的项目名称。因此,catalog_apicatalog_db容器都将运行在同一个名为store的项目下。

定义 Dockerfile

让我们通过描述我们的 compose 项目的 Dockerfile 来继续。如前所述,Dockerfile 是一个简单的文本文件,其中包含用户可以调用的命令来构建镜像。让我们检查containers/api/文件夹中可能包含的 Dockerfile 的定义:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1.100
COPY . /app
WORKDIR /app
RUN dotnet restore
RUN dotnet build
RUN dotnet tool install --global dotnet-ef
ENV PATH="${PATH}:/root/.dotnet/tools"
RUN chmod +x containers/api/entrypoint.sh
CMD /bin/bash containers/api/entrypoint.sh

此代码定义了构建 Docker 镜像的特定步骤。FROM指令指明了构建过程中要使用的基镜像。此指令是强制性的,并且必须是文件的第一条指令。COPY指令将项目复制到/app文件夹,WORKDIR命令将/app文件夹设置为默认工作目录。之后,构建脚本通过执行dotnet restoredotnet build命令继续进行。最后,Dockerfile 在PATH变量中添加了/root/.dotnet/tools路径,并执行了containers/api/entrypoint.shBash 文件,其内容如下:

#!/bin/bash
set -e
run_cmd="dotnet run --verbose --project ./src/Catalog.API/Catalog.API.csproj"
until dotnet-ef database update --verbose --project ./src/Catalog.API/Catalog.API.csproj ; do
>&2 echo "SQL Server is starting up"
sleep 1
done
>&2 echo "SQL Server is up - executing command"
exec $run_cmd

entrypoint.sh入口点文件存储在与 Dockerfile 相同的级别。它通过执行dotnet run命令运行目录服务的主要项目,一旦数据库容器准备就绪,它将继续执行dotnet ef database update命令以创建数据库模式。

执行 docker-compose 命令

要在本地运行目录服务,我们必须使用docker-compose命令从 CLI 完成组合过程。可以通过运行docker-compose --help来获取命令的概述。

与多容器应用组合相关的命令如下:

  • docker-compose build: 这构建服务。它使用与镜像关联的 Dockerfile 执行构建。

  • docker-compose images: 这列出了当前容器镜像。

  • docker-compose up: 这创建并运行容器。

  • docker-compose config: 这验证并查看docker-compose.yml文件。

我们可以通过以下命令运行目录服务:

docker-compose up --build

通过指定--build标志,可以在运行容器之前触发构建。一旦构建完成,我们只需运行docker-compose up命令,直到我们的代码更改并需要重新构建项目。

尽管我们现在能够使用容器运行我们的服务,但构建和运行过程尚未优化:我们将解决方案中的所有文件都复制到容器中;除此之外,我们还在整个.NET Core SDK 上运行容器,如果我们只想运行项目,这是不必要的。在下一节中,我们将看到如何优化容器化过程以使其更轻量。

优化 Docker 镜像

微软提供了不同的 Docker 镜像来运行 ASP.NET Core 以及使用 Docker 的.NET Core 应用程序。重要的是要理解,执行 ASP.NET Core 服务的容器不需要提供 SDK。

本节概述了 Docker Hub 上可用的不同 Docker 镜像以及如何使用合适的 Docker 镜像优化我们的部署。

不同 Docker 镜像概述

微软根据您尝试通过应用程序实现的目标提供各种镜像。让我们看看 Docker Hub 中 microsoft/dotnet 仓库提供的不同 Docker 镜像(hub.docker.com/u/microsoft/)):

图像 描述 大小
mcr.microsoft.com/dotnet/core/sdk:3.1 此镜像包含整个 .NET Core SDK。它提供了所有开发、运行和构建应用程序的工具。可以使用开发命令,如 dotnet rundotnet ef 以及 SDK 提供的整套命令。 690 MB
mcr.microsoft.com/dotnet/core/runtime:3.1 此镜像包含 .NET Core 运行时。它提供了一种运行 .NET Core 应用程序(如控制台应用程序)的方式。由于镜像仅包含运行时,因此无法构建应用程序。该镜像仅暴露运行时 CLI 命令 dotnet 190 MB
mcr.microsoft.com/dotnet/core/aspnet:3.1 此镜像包含 .NET Core 运行时和 ASP.NET Core 运行时。可以执行 .NET Core 应用程序和 ASP.NET Core 应用程序。作为一个运行时镜像,无法构建应用程序。 205 MB
mcr.microsoft.com/dotnet/core/runtime-deps:3.1 此镜像非常轻量。它仅包含 .NET Core 运行所需的底层依赖项(github.com/dotnet/core/blob/master/Documentation/prereqs.md)。它不包含 .NET Core 运行时或 ASP.NET Core 运行时。它旨在用于自托管应用程序。 110 MB

Docker 镜像通常以三种模式可用:debian:stretch-slimubuntu:bionicalpine,具体取决于镜像运行的操作系统。默认情况下,镜像运行在 DebianOS 上。然而,也可以使用其他操作系统,例如 Alpine 来节省一些存储空间。例如,基于 Alpine 的镜像将 aspnetcore-runtime 镜像的大小从 ~260 MB 减少到 ~160 MB。

目录服务上的多阶段构建

让我们将 多阶段 构建的概念应用到之前定义的 Docker 镜像上。多阶段构建是 Docker 17.05 或更高版本的新功能。多阶段构建对于在保持 Dockerfile 易读和易于维护的同时优化 Dockerfile 的人来说非常有用。

让我们通过查看目录服务 Dockerfile 来探索如何应用多阶段构建过程:

FROM microsoft/dotnet
COPY . /app
WORKDIR /app
RUN dotnet restore
RUN dotnet build
RUN chmod +x ./entrypoint.sh
CMD /bin/bash ./entrypoint.sh

可以以下方式更改之前定义的文件:

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /project
COPY ["src/Catalog.API/Catalog.API.csproj", "src/Catalog.API/"]
COPY . .
WORKDIR "/project/src/Catalog.API"
RUN dotnet build "Catalog.API.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "Catalog.API.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Catalog.API.dll"]

如您所见,Dockerfile 现在执行了三个不同的步骤(前两个步骤一起描述,因为它们使用相同的 Docker 镜像):

  • builder 步骤使用 mcr.microsoft.com/dotnet/core/sdk 镜像来复制文件,并使用 dotnet build 命令触发项目的构建。

  • publish 步骤使用相同的镜像来触发 dotnet publish 命令,用于 Catalog.API 项目。

  • 如其名所示,final 步骤使用 mcr.microsoft.com/dotnet/core/aspnet:3.1 镜像在运行时环境中执行已发布的包。最后,它使用 ENTRYPOINT 命令运行服务。

需要注意的是,这个更改优化了由 Dockerfile 生成的最终镜像。在此更改之后,我们不再需要 entrypoint.sh 文件,因为 Dockerfile 直接使用 dotnet Catalog.API.dll 命令触发服务的执行。

使用多阶段构建方法时,我们还应该注意到,我们不能触发数据库迁移的执行,因为运行时 Docker 镜像不使用 Entity Framework CoreEF Core) 工具。因此,我们需要找到另一种触发迁移的方法。一个可能的选择是从我们服务的 Startup 文件中释放迁移。此外,EF Core 提供了一种在 Configure 方法中使用以下语法在运行时应用迁移的方法:

using Polly;
using Microsoft.Data.SqlClient;
...
    public class Startup
    {
        ...
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment()) app.UseDeveloperExceptionPage();

            ExecuteMigrations(app, env);
            ...
        }

        private void ExecuteMigrations(IApplicationBuilder app, 
         IWebHostEnvironment env)
        {
            if (env.EnvironmentName == "Testing") return;

            var retry = Policy.Handle<SqlException>()
                .WaitAndRetry(new TimeSpan[]
                {
                    TimeSpan.FromSeconds(2),
                    TimeSpan.FromSeconds(6),
                    TimeSpan.FromSeconds(12)
                });

            retry.Execute(() => 
                app.ApplicationServices.GetService<CatalogContext>().Database.Migrate());
        }
    }
}

之前的代码确保通过执行 app.ApplicationServices.GetService<CatalogContext>().Database.Migrate() 指令来执行数据库迁移。由于 msssql 容器的启动时间,我们需要通过处理 SqlException 并采用指数时间方法重试来实现重试策略。前面的实现使用 Polly 来定义和执行重试策略。此外,我们还需要通过在 Catalog.API 项目中执行以下命令来添加 Polly 依赖项:

dotnet add package Polly

重试策略在分布式系统世界中非常有用,可以成功处理失败。Polly 和在 Web 服务中实现弹性策略将在下一章中作为多个服务之间通过 HTTP 通信的一部分进行讨论。

在实际应用中,在服务的部署阶段执行迁移是非常不寻常的。数据库模式很少改变,将与服务相关的实现与数据库模式中的更改分开是至关重要的。出于演示目的,我们通过覆盖数据库更改以采取最直接的方法,在每次部署中都执行数据库的迁移。

摘要

在本章中,我们快速概述了 Docker 的功能以及如何使用它来提高隔离性、可维护性和服务可靠性。我们探讨了 Microsoft 提供的不同 Docker 镜像以及如何结合多步骤构建方法使用它们。

本章涵盖的主题提供了一个简单的方法来在本地、隔离的环境中运行我们的服务,并将相同的环境配置传播到预发布和生产环境中。

在下一章中,我们将提升如何在多个服务之间共享信息的知识。我们还将探讨一些与多服务系统相关的模式。本章中我们探讨的与 Docker 相关的概念也将在下一章中应用,以提供顺畅的部署体验。

第十三章:服务生态系统模式

在上一章中,我们提供了容器化过程的概述以及如何使用容器来运行服务。我们还学习了如何使用 Docker 在容器上托管目录服务,以及如何使用多阶段构建方法创建和运行我们的容器镜像。

本章重点介绍在多个服务属于同一生态系统时使用的某些模式。然后,我们将查看这些服务之间的实现。我们还将学习如何构建属于同一系统的各种 Web 服务之间的弹性连接,以避免与数据交换相关的某些常见陷阱。

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

  • 购物车服务简介

  • 使用 HTTP 客户端实现弹性通信

  • 事件总线简介

  • 如何使用 RabbitMQ 执行事件总线通信

到本章结束时,您将了解如何使用 Polly 提高弹性,以及如何使用事件总线在两个系统之间交换信息。

购物车服务简介

虽然 Catalog.API 项目处理我们商店的商品目录,但我们没有处理购物车功能的东西。在本节中,我们将发现一个新的 .NET Core 解决方案,该方案实现了一个购物车服务来完成这项工作。此外,我们还将介绍一种新的实现方法:中介者模式。在我们深入了解这个新服务的实现之前,让我们先看看项目结构概述,它提供了目录服务和购物车服务解决方案:

上述架构描述了目录服务和购物车服务的项目结构. 如我们所知,目录服务通过实现 APIDomainInfrastructure 项目来分离实现的各个层,并且它使用 SQL Server,结合 Entity Framework Core 作为主要的数据源。然而,尽管购物车服务遵循类似的项目结构,但它使用了一种略微不同的实现模式,并将购物车数据存储在 Redis 中。因此,主要用于缓存目的的 Redis 提供了一个非常轻量级/高性能的键值数据存储,也可以用作数据库。

购物车服务解决方案具有以下结构:

  • Cart.API 项目包含控制器,该控制器处理来自客户端的传入 HTTP 请求。它还包括错误处理聚合点和 Startup 类,该类初始化依赖注入引擎。

  • Cart.Domain 项目包含中介逻辑和处理程序,它们将操作调度到底层层。此外,它还包括应用程序使用的实体。

  • Cart.Infrastructure 项目是 Web 服务及其依赖项(如数据存储和其他第三方服务)之间的桥梁。

本章不会详细探讨购物车服务的发展过程:一些技术方面已经在 第八章,构建数据访问层,第九章,实现领域逻辑,和 第十章,实现 RESTful HTTP 层 中进行了讨论。购物车服务的介绍将有助于你确定多个 Web 服务之间的不同通信技术。尽管本章将涵盖实现的一些关键部分,例如处理器,但为了继续前进,你需要从 github.com/PacktPublishing/Hands-On-RESTful-Web-Services-with-ASP.NET-Core-3 下载购物车服务的源代码。

中介模式背后的理论

中介模式是一种封装逻辑在唯一入口点的方法。它使用请求、响应、命令或事件的概念来抽象单个入口点背后的实现。这种实现应用程序逻辑的方式有助于你的团队中的开发者将逻辑与应用程序的 Web 部分分离。为了了解中介模式是如何工作的,让我们看看它的组件。

上述架构描述了中介模式的一个简单实现。中介模式的消费者通过引用 IMediator 接口调用 Send 方法。中介实现传递一个特定的 IRequest 接口类型。因此,Mediator 实例使用 IRequest 接口的具体实现将消息派发到目标处理器,该处理器由 IMessageHandler 实现表示。在下一章中,我们将学习如何使用 IMediator 接口将消息派发到特定的处理器。

此外,我们将使用命令方法来应用中介模式。实现中介模式的方法略有不同。在本章中,我们将介绍一个非常流行的中介 NuGet 包,称为 MediatR。MediatR 是一个全功能的中介模式实现,涵盖了进程内消息传递。你可以在 GitHub 上找到有关 MediatR 项目的更多信息:github.com/jbogard/MediatR

为了实现 购物车服务,我们将使用以下组件,所有这些组件都由 MediatR 库公开:

  • IMediator 接口是中介模式的入口点。它公开了一个 Send 方法,用于将命令或请求派发到特定的处理器以获取结果。

  • IRequestHandler 接口是一个通用接口,用于定义处理器的实现。每个 IRequestHandler 类型都需要一个 IRequest 类型,该类型代表通过 IMediator 接口发送的请求。

  • IRequest 接口定义了用于执行特定处理器的请求或命令类型。

现在我们对中介者模式的工作方式有了更多的了解,我们可以继续进行购物车服务解决方案的具体实现。在下一节中,我们将查看如何定义服务的领域模型并实现数据访问层抽象,该抽象覆盖 Redis 数据存储。

领域模型和数据访问层

购物车服务的领域模型代表了我们需要描述用户购物车会话的实体。具体来说,购物车服务的领域模型实现了三个不同的实体类:CartCartItemCartUser。与目录服务一样,所有实体都存储在 Cart.Domain 项目中,该项目将由 Cart.InfrastructureCart.API 项目引用。

让我们从代表单个购物车会话的 CartSession 类开始:

using System;
using System.Collections.Generic;

namespace Cart.Domain.Entities
{
    public class CartSession
    {
        public string Id { get; set; }
        public IList<CartItem> Items { get; set; }
        public CartUser User { get; set; }
        public DateTimeOffset ValidityDate { get; set; }
    }
}

CartSession 实体代表一个由用户创建的单个购物车实例。因此,它引用了包含用户信息的 CartUser 类。此外,CartSession 实体还提供了 IList<CartItems> 字段,该字段表示购物车中的项目及其各自的数量。让我们继续定义 CartItem 类:

using System;

namespace Cart.Domain.Entities
{
    public class CartItem
    {
        public Guid CartItemId { get; set; }

        public int Quantity { get; set; }

        public void IncreaseQuantity()
        {
            Quantity = Quantity + 1;
        }

        public void DecreaseQuantity()
        {
            Quantity = Quantity - 1;
        }
    }
}

CartItem 类实现了 CartItemId 字段和 Quantity 字段。此外,它还提供了 IncreaseQuantityDecreaseQuantity 字段,分别用于增加和减少特定项目的数量。最后,我们可以确定 CartUser 类,该类代表与购物车相关的用户:

namespace Cart.Domain.Entities
{
    public class CartUser
    {
        public string Email { get; set; }
    }
}

为了演示目的,我们用只有一个属性来表示 CartUser;这将包含用户的电子邮件。前面的实体存储在 Entities 文件夹中,位于 Cart.Domain 项目内。

一旦我们定义了领域模型,我们就可以继续实现数据访问抽象。具体来说,购物车服务将使用我们之前为目录服务定义的相同访问模式来获取有关购物车会话的信息:

// src/Cart.Domain/Repositories/ICartRepository.cs

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Cart.Domain.Entities;

namespace Cart.Domain.Repositories
{
    public interface ICartRepository
    {
        IEnumerable<string> GetCarts();
        Task<CartSession> GetAsync(Guid id);
        Task<CartSession> AddOrUpdateAsync(CartSession item);
    }
}

ICartRepository 方法实现了检索和更新我们数据的方法,GetCarts 方法检索当前购物车的 ID,而 GetAsync 方法收集有关特定购物车的信息。最后,AddOrUpdateAsync 方法允许我们在数据存储中不存在时更新或添加一个新的购物车,而 ICartRepository 定义了我们的数据存储执行的操作。在下一小节中,我们将查看 CartRepository 类的具体实现。

这个领域模型已被简化,以提供一个购物车可能的实现示例。在实际应用中,我们应该考虑购物车状态的其他重要信息。

ICartRepository Redis 实现

购物车服务使用 Redis (redis.io/) 来存储购物车数据。购物车服务中 CartRepository 类的实际实现使用由 Stack Exchange 提供的 NuGet 包 StackExchange.Redis。此外,我们将使用 Newtonsoft.Json 包将对象序列化为 Redis,使用 JSON 格式。

CartRepository 类的具体实现将位于 Cart.Infrastructure 项目中,而 ICartRepository 接口类型将位于 Cart.Domain 项目中。此外,Cart.Infrastructure 项目还将依赖于 StackExchange.RedisNewtonsoft.Json 包。StackExchange.Redis 库提供了对 Redis 的低级抽象,以便我们的 .NET 应用程序可以在 Redis 实例上读取或写入数据。让我们看一下 CartRepository 类的实现:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Cart.Domain.Entities;
using StackExchange.Redis;
using Cart.Domain.Repositories;
using Cart.Infrastructure.Configurations;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;

namespace Cart.Infrastructure.Repositories
{
    public class CartRepository : ICartRepository
    {
        private readonly IDatabase _database;
        private readonly CartDataSourceSettings _settings;

        public CartRepository(IOptions<CartDataSourceSettings> options)
        {
            _settings = options.Value;

            var configuration = ConfigurationOptions
                .Parse(_settings.RedisConnectionString);

            try
            {
                _database = ConnectionMultiplexer
                    .Connect(configuration).GetDatabase();
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }
        ...
    }
}

CartRepository 类使用 StackExchange.Redis 库与 Redis 实例交互。它声明了一个 IDatabase 属性,它代表与 Redis 实例的连接,并使用一个自定义设置类来定义与 Redis 实例关联的连接字符串。在类的初始化过程中,构造函数调用 ConnectionMultiplexer 静态实例来创建一个新的数据库连接。让我们继续查看 ICartRepository 接口方法实现的细节:

...

        public IEnumerable<string> GetCarts()
        {
            var keys = _database.Multiplexer.GetServer
                (_settings.RedisConnectionString).Keys();

            return keys?.Select(k => k.ToString());
        }

        public async Task<CartSession> GetAsync(Guid id)
        {
            var data = await _database.StringGetAsync(id.ToString());

            return data.IsNullOrEmpty
                ? null
                : JsonConvert.DeserializeObject
                   <Domain.Entities.CartSession>(data);
        }

        public async Task<CartSession> AddOrUpdateAsync(CartSession 
            item)
        {
            var created = await _database.StringSetAsync(item.Id, 
                JsonConvert.SerializeObject(item));

            if (!created) return null;

            return await GetAsync(new Guid(item.Id));
        }
..

上述代码定义了 ICartRepository 接口的核心方法。GetCarts 方法收集表示存储在 Redis 实例中的所有购物车 ID 的键。GetAsync 方法通过传递特定购物车的 ID 并在 CartSession 实体中反序列化结果内容来检索卡片详情。最后,AddOrUpdateAsync 方法通过序列化其内容并使用库提供的 StringSetAsync 方法更新数据源来添加或更新与购物车 ID 相关的信息。我们使用 Redis 是因为它作为一个内存数据结构存储,可以非常快速地检索信息。通常,Redis 的主要目的是作为缓存系统,但它也可以用来临时存储信息。然而,Redis 并不是防止数据丢失的最佳系统。所有数据都在内存中处理,并且只能通过创建内存当前状态的快照来保存。有关更多信息,请访问以下网站:redis.io/topics/persistence

之前 CartRepository 的实现产生了两个主要问题。首先,Redis 不是一个设计用于扫描和检索多个键的数据库。此外,这种类型的数据存储设计用于执行 O(1) 操作,就像哈希表或字典一样。因此,GetCarts 方法非常低效。一个替代且更有效的方法是将列表 ID 存储在特定且唯一的字段中,并在我们添加/删除新的购物车记录时保持它们更新。其次,尽管 CartRepository 类的构造函数在每次类初始化时都调用 ConnectionMultiplexer 静态类,但强烈建议您将 IConnectionMultiplexer 接口初始化为单例实例,以避免性能问题。

下一个子节描述了通过中介逻辑公开购物车操作的处理器实现。此外,处理器将调用底层的 ICartRepository 接口以在 Redis 上执行 I/O 过程。

处理器和路由实现

购物车服务 实现了处理器,这些处理器反映了在服务域部分发生的不同购物车数据操作。正如我们将在本章后面看到的那样,处理器与特定请求相关联,并由 MediatR 库提供的 IMediator 接口执行。此外,在这种情况下,这些类位于 Cart.Domain 项目中。

让我们从查看 CreateCartHandler 类的实现开始:

//Handlers/Cart/CreateCartHandler.cs 
using System;
...

namespace Cart.Domain.Handlers.Cart
{
    public class CreateCartHandler : IRequestHandler<CreateCartCommand, CartExtendedResponse>
    {
        private readonly ICatalogService _catalogService;
        private readonly IMapper _mapper;
        private readonly ICartRepository _repository;

        public CreateCartHandler(ICartRepository repository, IMapper 
            mapper, ICatalogService catalogService)
        {
            _repository = repository;
            _mapper = mapper;
            _catalogService = catalogService;
        }

        public async Task<CartExtendedResponse> Handle
            (CreateCartCommand command, CancellationToken 
            cancellationToken)
        {
            var entity = new CartSession
            {
                Items = command.ItemsIds.Select(x => new CartItem { 
                  CartItemId = new Guid(x), Quantity = 1 }).ToList(),
                  User = new CartUser { Email = command.UserEmail },
                  ValidityDate = DateTimeOffset.Now.AddMonths(2),
                  Id = Guid.NewGuid().ToString()
            };

            var result = await _repository.AddOrUpdateAsync(entity);

            var response = _mapper.Map<CartExtendedResponse>(result);

            var tasks = response.Items
                .Select(async x => await _catalogService
                .EnrichCartItem(x, cancellationToken));

            response.Items = await Task.WhenAll(tasks);

            return response;
        }
    }
}

上述代码是 CreateCartHandler 类的定义,该类执行创建购物车的过程。该类使用构造函数注入技术通过 ASP.NET Core 的依赖注入引擎解决依赖关系。此外,处理器类依赖于 IMapperICartRepository 接口:IMapper 接口用于将 CartSession 实例映射到 CartExtendedResponse 响应类,而 ICartRepository 接口用于通过使用 AddOrUpdateAsync 方法在 Redis 上存储购物车数据。

处理器为实体分配一个新的 Guid,并为每个项目分配 2 个月的 ValidityDate。此外,它还通过为每个项目设置默认数量 1 来将新的购物车项目列表添加到购物车会话中。以类似的方式,GetCartHandler 类根据购物车的 Id 实现读取操作:

using System.Threading;
...

namespace Cart.Domain.Handlers.Cart
{
    public class GetCartHandler : IRequestHandler<GetCartCommand, 
        CartExtendedResponse>
    {
        private readonly ICatalogService _catalogService;
        private readonly IMapper _mapper;
        private readonly ICartRepository _repository;

        public GetCartHandler(ICartRepository repository, IMapper 
            mapper, ICatalogService catalogService)
        {
            _repository = repository;
            _mapper = mapper;
            _catalogService = catalogService;
        }

        public async Task<CartExtendedResponse> Handle(GetCartCommand 
            command, CancellationToken cancellationToken)
        {
            var result = await _repository.GetAsync(command.Id);
            var extendedResponse = _mapper.Map<CartExtendedResponse>
                (result);

            var tasks = extendedResponse.Items
                .Select(async x => await _catalogService
                .EnrichCartItem(x, cancellationToken));

            extendedResponse.Items = await Task.WhenAll(tasks);
            return extendedResponse;
        }
    }
}

在这种情况下,Handle 方法执行由底层存储库接口提供的 GetAsync(Guid id) 方法,并将响应映射到 CartExtendedResponse 类型。由 Cart.Domain 项目实现的最后一个处理器增加了特定购物车中项目的数量或减少了数量:

using System.Linq;
...

namespace Cart.Domain.Handlers.Cart
{
    public class UpdateCartItemQuantity : IRequestHandler
        <UpdateCartItemQuantityCommand, CartExtendedResponse>
    {
        private readonly ICatalogService _catalogService;
        private readonly IMapper _mapper;
        private readonly ICartRepository _repository;

        public UpdateCartItemQuantity(ICartRepository repository, 
            IMapper mapper, ICatalogService catalogService)
        {
            _repository = repository;
            _mapper = mapper;
            _catalogService = catalogService;
        }

        public async Task<CartExtendedResponse> Handle(UpdateCartItemQuantityCommand command, CancellationToken cancellationToken)
        {
            var cartDetail = await 
                _repository.GetAsync(command.CartId);

            if (command.IsAddOperation)
                cartDetail.Items.FirstOrDefault(x => x.CartItemId == 
                command.CartItemId)?.IncreaseQuantity();
            else
                cartDetail.Items.FirstOrDefault(x => x.CartItemId == 
                command.CartItemId)?.DecreaseQuantity();

            var cartItemsList = cartDetail.Items.ToList();

            cartItemsList.RemoveAll(x => x.Quantity <= 0);

            cartDetail.Items = cartItemsList;

            await _repository.AddOrUpdateAsync(cartDetail);

            var response = _mapper.Map<CartExtendedResponse>
                (cartDetail);
            var tasks = response.Items
                .Select(async x => await 
                _catalogService.EnrichCartItem(x, cancellationToken));

            response.Items = await Task.WhenAll(tasks);

            return response;
        }
    }
}

处理器接受UpdateCartItemQuantityRequest,它定义了CartIdCartItemId以及一个表示是否请求增加或减少指定项目数量的布尔值。

处理器使用与其他处理器相同的依赖项,并在数量等于零的情况下执行一些关于移除项目的额外检查。如果与CartItemId关联的数量达到0,则该项目将从购物车会话中移除;否则,数量将被更新,并通过更新 Redis 存储和检索cartDetail来继续购物车流程。

现在我们已经设置了处理器,我们将定义将公开 Web 服务 HTTP 路由的控制器类。

使用CartController公开功能

正如我们已经提到的,购物车服务负责电子商务店铺购物页面上执行的操作。此外,该服务公开以下路由表:

HttpVerb URL 描述
GET api/cart/{cartId} 此操作检索有关特定购物车及其内部产品的信息。
POST api/cart 此操作通过请求体中的产品列表创建一个新的购物车。
PUT api/cart/{cartId}/items/{id} 此操作通过添加一个单位来增加指定cartId中指定项目的数量。
DELETE api/cart/{cartId}/items/{id} 此操作通过移除一个单位来减少指定cartId中指定项目的数量。

上述表格提供了我们需要在控制器中定义的路由的一些详细信息。因此,以下代码片段显示了在Cart.API项目中使用CartController类实现此路由表的实现:

using System;
using System.Threading.Tasks;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Cart.API.Infrastructure.Filters;
using Cart.Domain.Commands.Cart;

namespace Cart.API.Controllers
{

    [Route("api/cart")]
    [ApiController]
    [JsonException]
    public class CartController : ControllerBase
    {
        private readonly IMediator _mediator;

        public CartController(IMediator mediator)
        {

            _mediator = mediator;
        }

          [HttpGet("{id:guid}")]
        public async Task<IActionResult> GetById(Guid id)
        {
            var result = await _mediator.Send(new GetCartCommand { Id = 
                id });
            return Ok(result);
        }

        [HttpPost]
        public async Task<IActionResult> Post(CreateCartCommand 
            request)
        {
            var result = await _mediator.Send(request);
            return CreatedAtAction(nameof(GetById), new { id = 
                result.Id }, null);
        }
..

正如你所见,与之前实现的控制器类似,CartController使用依赖注入来通过初始化IMediator接口来解析其依赖项。让我们继续查看PutDelete操作方法的实现:

...

    [HttpPut("{cartId:guid}/items/{id:guid}")]
        public async Task<IActionResult> Put(Guid cartId, Guid id)
        {
            var result = await _mediator.Send(new 
                UpdateCartItemQuantityCommand
            {
                CartId = cartId,
                CartItemId = id,
                IsAddOperation = true
            });
            return Ok(result);
        }
        [HttpDelete("{cartId:guid}/items/{id:guid}")]
        public async Task<IActionResult> Delete(Guid cartId, Guid id)
        {
            var result = await _mediator.Send(new 
                UpdateCartItemQuantityCommand
            {
                CartId = cartId,
                CartItemId = id,
                IsAddOperation = false
            });

            return Ok(result);
        }
}

DELETEPUT方法使用IsAddOperation标志来通知处理器请求的操作是用于增加还是减少数量。因此,每次我们使用DELETE HTTP 动词和UPDATE HTTP 动词调用路由时,服务将增加和减少 URL 中指定的项目 ID 的数量。

本章跳过了我们在第八章“构建数据访问层”、第九章“实现领域逻辑”和第十章“实现 RESTful HTTP 层”中涵盖的大量验证和 REST 兼容特性,然后在Catalog.API项目中实现。从下一节开始,本章旨在向您展示如何在独立服务之间共享信息和事件。因此,购物车服务将通过调用目录服务来收集与项目相关的信息。

现在我们已经了解了实现栈,我们可以通过实现目录服务与购物车服务之间的通信来继续前进.

使用 HTTP 客户端实现弹性通信

在上一节中,我们查看了一个购物车服务项目的概述。我们学习了购物车服务如何在 Redis 实例中存储信息以及如何为客户检索与购物车相关的数据。

必须注意的是,Redis 数据源中存储的信息与服务公开的数据之间存在差距。此外,通过检查CartItem实体,我们可以看到它仅实现了检索项目的CartItemIdQuantity信息:

namespace Cart.Domain.Entities
{
    public class CartItem
    {
        public string CartItemId { get; set; }

        public int Quantity { get; set; }

        ...
    }
}

另一方面,我们可以看到CartItemResponse提供了许多与项目数据相关的字段:

namespace Cart.Domain.Responses.Cart
{
    public class CartItemResponse
    {
        public string CartItemId { get; set; }

        public string Name { get; set; }

        public string Description { get; set; }

        public string LabelName { get; set; }

        public string Price { get; set; }

        public string PictureUri { get; set; }

        public string GenreDescription { get; set; }

        public string ArtistName { get; set; }

        public int Quantity { get; set; }
    }
}

CartItemResponse类所提供的附加信息是通过调用目录服务获取的。因此,购物车服务拥有关于项目 ID 的数据,并且它可以执行GET /api/items/{itemId}请求以检索项目信息。在本节中,我们将专注于实现一个 HTTP 客户端,以公开目录服务拥有的信息。

重要的是不要在 Web 服务之间复制信息。我们需要尽可能地将 Web 服务的数据源分开。每个服务拥有一个单一的数据源及其适当的信息。使用 HTTP 调用进行服务间通信是一种常见的做法。在下面的示例中,我们将看到购物车服务如何直接调用目录服务以检索项目信息。在实际应用中,所有服务之间的 HTTP 调用都通过代理进行,以确保服务的可靠性。

实现目录 HTTP 客户端

实现客户端库与 Web 服务一起是常见的做法。此外,提供与服务通信的方式是 Web 服务的责任。因此,我们可以使用以下方案来表示客户端的实现:

图片

这使我们能够将目录服务客户端库发布到内部 NuGet 仓库,以便我们可以将客户端传播到其他服务。此外,拥有特定服务的团队应该知道如何实现它以及如何正确地公开信息。让我们从在 Catalog.API 解决方案中创建两个新的 classlib 项目开始,该解决方案位于 src 文件夹中:

dotnet new classlib -n Catalog.API.Client -f netstandard2.1
dotnet sln ../Catalog.API.sln add Catalog.API.Client 
dotnet new classlib -n Catalog.API.Contract -f netstandard2.1
dotnet sln ../Catalog.API.sln add Catalog.API.Contract

dotnet add Catalog.API.Client reference Catalog.API.Contract

Catalog.API.Client 项目将包含我们查询目录服务所需的所有方法。Catalog.API.Contract 包含客户端用于传输数据的请求和响应,因此我们可以通过将 Catalog.Domain 项目的 Responses 文件夹中的类复制到之前创建的 Catalog.API.Contract 项目中来进行操作。结果文件夹结构将如下所示:

.
├── Item
│   ├── ArtistResponse.cs
│   ├── GenreResponse.cs
│   ├── ItemResponse.cs
│   └── PriceResponse.cs
├── Catalog.API.Contract.csproj
├── bin
└── obj

为了在项目中使用响应模型,必须引用 Catalog.API.Contract。这种做法通常应用于请求和响应类。通过这样做,可以将 API 的契约保存在一个单独的、持续集成管道中。作为第二步,我们需要在 Catalog.API.Client 项目中创建一个新的基础客户端。以下 IBaseClient 接口定义了客户端暴露的方法:

// /Base/IBaseClient.cs

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Catalog.API.Client.Base
{
        public interface IBaseClient
        {
            Task<T> GetAsync<T>(Uri uri, CancellationToken 
                cancellationToken);
            Uri BuildUri(string format);
        }
}

IBaseClient 接口建立了客户端的接口。它暴露了两个主要方法:GetAsyncBuildUri。这两个方法都在 BaseClient 具体类中实现。BaseClient 类依赖于框架提供的 HttpClient 和我们 API 的 string UrlGetAsync 方法调用 HttpClient 并使用 Newtonsoft.Json 包将客户端的响应反序列化为一个通用的模型 T

让我们继续定义 ICatalogItemResource 接口和 CatalogItemResource 类。这些类代表 Item 资源:

// Resources/ICatalogItemResource.cs

using System;
using System.Threading;
using System.Threading.Tasks;
using Catalog.Contract.Item;

namespace Catalog.API.Client.Resources
{
    public interface ICatalogItemResource
    {
        Task<ItemResponse> Get(Guid id, CancellationToken 
            cancellationToken = default);
    }
}

ICatalogItemResource 通过接受 idcancellationToken 来暴露 Get 方法。它返回一个 Task<ItemResponse> 类型的值。因此,CatalogItemResource 模型在 Catalog.API.Client.Resources 项目中定义如下:

// Resources/CatalogItemResource.cs

using System;
using System.Threading;
using System.Threading.Tasks;
using Catalog.API.Client.Base;
using Catalog.Domain.Responses;

namespace Catalog.API.Client.Resources
{
    public class CatalogItemResource : ICatalogItemResource
    {
        private readonly IBaseClient _client;

        public CatalogItemResource(IBaseClient client)
        {
            _client = client;
        }

        private Uri BuildUri(Guid id, string path = "")
        {
            return _client.BuildUri(string.Format("api/items/{0}", id, 
                path));
        }

        public async Task<ItemResponse> Get(Guid id, CancellationToken 
            cancellationToken)
        {
            var uri = BuildUri(id);
            return await _client.GetAsync<ItemResponse>(uri, 
                cancellationToken);
        }
    }
}

CatalogItemResource 指向 IBaseClient 接口,并通过使用 IBaseClient 接口实现 Get 方法。同样,CatalogItemResource 也负责通过构建 Web 服务的 Uri 来提供项目的路径。除此之外,CatalogItemResource 使用 IBaseClient 包装器来执行 HTTP 操作。让我们深入了解 IBaseClass 接口的实现:

// /Base/BaseClient.cs

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;

namespace Catalog.API.Client.Base
{
    public class BaseClient : IBaseClient
    {
        private readonly HttpClient _client;
        private readonly string _baseUri;

        public BaseClient(HttpClient client, string baseUri)
        {
            _client = client;
            _baseUri = baseUri;
        }

        public async Task<T> GetAsync<T>(Uri uri, CancellationToken 
            cancellationToken)
        {
            var result = await _client.GetAsync(uri, 
            cancellationToken);
            result.EnsureSuccessStatusCode();

            return JsonConvert.DeserializeObject<T>(await 
            result.Content.ReadAsStringAsync());
        }

        public Uri BuildUri(string format)
        {
            return new UriBuilder(_baseUri)
            {
                Path = format
            }.Uri;
        }
    }
}

上述代码使用框架提供的 HttpClient 类来实现 GetAsync<T> 泛型方法。因此,使用这种泛型模式允许我们使用自定义模型反序列化响应。

最后,我们可以通过添加以下组件来实现服务的实际客户端:

// ICatalogClient.cs

using Catalog.API.Client.Resources;

namespace Catalog.API.Client
{
    public interface ICatalogClient
    {
        ICatalogItemResource Item { get; }
    }
}

// CatalogClient.cs

using System.Net.Http;
using Catalog.API.Client.Base;
using Catalog.API.Client.Resources;

namespace Catalog.API.Client
{
    public class CatalogClient : ICatalogClient
    {
        public ICatalogItemResource Item { get; }

        public CatalogClient(HttpClient client)
        {
            Item = new CatalogItemResource(new BaseClient(client, 
            client.BaseAddress.ToString()));
        }
    }
}

最后,可以使用 Catalog.API.Client 实例化一个新的 HTTP 客户端实例,并使用独特且通用的合约调用目录服务:

var catalogClient = new CatalogClient(new HttpClient());
var result = await catalogClient.Item.Get(new Guid(item.CartItemId), cancellationToken);

现在,我们有一些独立的 DLL,它们提供了我们所需要的一切,因此我们可以查询目录 Web 服务。在下一节中,我们将学习如何使用本节中实现的客户端对目录服务执行 HTTP 调用。

将 HTTP 客户端集成到购物车服务中

下一步是将目录服务提供的 HTTP 客户端集成到购物车服务中。因此,我们将添加一个新的类,其职责是调用目录服务并检索特定购物车所需的信息。让我们从在 Cart.Domain 项目中创建一个名为 ICatalogService 的接口开始:

using System.Threading;
using System.Threading.Tasks;
using Cart.Domain.Responses.Cart;

namespace Cart.Domain.Services
{
    public interface ICatalogService
    {
        Task<CartItemResponse> EnrichCartItem(CartItemResponse item, 
            CancellationToken cancellationToken);
    }
}

ICatalogService 接口位于 Cart.Domain 项目的 Services 文件夹中。它公开了一个名为 EnrichCartItem 的异步方法,该方法接受 CartItemResponse 并返回相同类型。就像我们对 ICartRepository 接口所做的那样,我们可以在 Cart.Infrastructure 项目中创建 ICatalogService 接口的具体实现。因此,我们可以使用之前在 目录服务 中实现的 ICatalogClient 接口来检索目录信息。在实际应用中,这些 DLL 通常作为公司内部存储库中的 NuGet 包进行管理。在我们的情况下,我们将复制它们并将它们包含在 Cart.Infrastructure 项目中,如下所示:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netcoreapp3.1</TargetFramework>
    </PropertyGroup>

 ...

    <ItemGroup>
 <Reference Include="Catalog.API.Client, Version=1.0.0.0, 
        Culture=neutral, PublicKeyToken=null">
        <HintPath>ExternalDll\Catalog.API.Client.dll</HintPath>
      </Reference>
      <Reference Include="Catalog.API.Contract, Version=1.0.0.0, 
       Culture=neutral, PublicKeyToken=null">
        <HintPath>ExternalDll\Catalog.API.Contract.dll</HintPath> </Reference>
    </ItemGroup>
</Project>

让我们通过在 Cart.Infrastructure 项目中创建 CatalogService 类来继续操作:

using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Cart.Domain.Responses.Cart;
using Cart.Domain.Services;
using Catalog.API.Client;
using Catalog.API.Contract.Item;

namespace Cart.Infrastructure.Services
{
    public class CatalogService : ICatalogService
    {
        private readonly ICatalogClient _catalogClient;

        public CatalogService(ICatalogClient catalogClient)
        {
            _catalogClient = catalogClient;
        }

        public async Task<CartItemResponse> EnrichCartItem
        (CartItemResponse item, CancellationToken cancellationToken)
        {
            try
            {
                var result = await _catalogClient.Item.Get(new 
                    Guid(item.CartItemId), cancellationToken);
                return Map(item, result);
            }
            catch (Exception)
            {
                return item;
            }
        }

        private static CartItemResponse Map(CartItemResponse item, 
            ItemResponse result)
        {
            item.Description = result.Description;
            item.LabelName = result.LabelName;
            item.Name = result.Name;
            item.Price = result.Price.Amount.ToString
                (CultureInfo.InvariantCulture);
            item.ArtistName = result.Artist.ArtistName;
            item.GenreDescription = result.Genre.GenreDescription;
            item.PictureUri = result.PictureUri;

            return item;
        }
    }
}

CatalogService 使用构造函数注入解决 ICatalogClient 依赖关系。该类通过以下方式调用目录服务客户端来实现 EnrichCartItem 函数:

var result = await _catalogClient.Item.Get(new Guid(item.CartItemId), cancellationToken);

现在,该方法检索与目录项相关的信息,并使用 Map 方法将这些数据映射到 CartItemResponse。因此,我们将拥有每个已填充新数据的项目的信息。可以通过在 Cart.Domain 中实现的处理器中引用 ICatalogService 接口来继续操作。以下以 GetCartHandler 作为此示例:

using System.Linq;
...

namespace Cart.Domain.Handlers.Cart
{
    public class GetCartHandler : IRequestHandler<GetCartCommand, 
        CartExtendedResponse>
    {
        private readonly ICatalogService _catalogService;
        private readonly IMapper _mapper;
        private readonly ICartRepository _repository;

        public GetCartHandler( ICartRepository repository, IMapper 
            mapper, ICatalogService catalogService)
        {
            _repository = repository;
            _mapper = mapper;
            _catalogService = catalogService;
        }

        public async Task<CartExtendedResponse> Handle(GetCartCommand 
        command, CancellationToken cancellationToken)
        {
            var result = await _repository.GetAsync(command.Id);
            var extendedResponse = _mapper.Map<CartExtendedResponse>
                (result);

            var tasks = extendedResponse.Items
                .Select(async x => await 
                _catalogService.EnrichCartItem(x, cancellationToken));

            extendedResponse.Items = await Task.WhenAll(tasks);
            return extendedResponse;
        }
    }
}

我们可以执行 _catalogService.EnrichCartItem 方法来检索 extendedResponse 对象中每个 Item 的填充数据。此外,GetCartHandler 使用 Task.WhenAll 方法等待任务完成并返回其数据。为了在运行时执行此过程,有必要声明以下扩展方法,该方法将在 Cart.API 项目的 Startup 类中初始化依赖关系并在其中执行,通过传递 API 的端点:

using System;
using Microsoft.Extensions.DependencyInjection;
using Cart.Domain.Services;
using Cart.Services;
using Catalog.API.Client;

namespace Cart.Infrastructure
{
    public static class CatalogServiceExtensions
    {
        public static IServiceCollection AddCatalogService(this 
        IServiceCollection services, Uri uri)
        {
           services.AddScoped<ICatalogClient>(x => new 
                CatalogClient(uri));
 services.AddScoped<ICatalogService, CatalogService>();

            return services;
        }
    }

AddCatalogService 将在 Startup 类的 CofigureService 方法中被调用。它通过使用作用域生命周期将 ICatalogClientICatlogService 添加到依赖注入服务中。

使用 Polly.NET 实现弹性

在前面的章节中,我们描述了如何实现目录服务和购物车服务之间的通信。现在,我们应该问自己关于服务运行时执行和通信的以下问题:如果目录服务宕机了会怎样?如果目录服务响应时间变慢了会怎样?Polly.NET 包对于这类问题非常有用 (github.com/App-vNext/Polly)。

Polly.NET 基于 策略,其中每个 策略 都可以单独使用或与其他策略结合使用,以向客户端提供弹性。开箱即用,该库提供了一些标准的弹性策略,例如重试、断路器和超时. 让我们快速看一下一个示例策略,以便我们了解如何使用它们:

...

    services.AddHttpClient<IMyService, MyService>()
        .AddPolicyHandler(RetryPolicy());

...

static IAsyncPolicy<HttpResponseMessage> RetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
 .OrResult(msg => msg.StatusCode == 
           System.Net.HttpStatusCode.NotFound)
 .WaitAndRetryAsync(6, retryAttempt =>             
            TimeSpan.FromSeconds(Math.Pow(2,retryAttempt)));
}

之前的代码将一个 HttpClient 实例注入到 IMyService 中。该 HttpClient 实例结合了 RetryPolicyPolly 包。此外,如果 HTTP 调用返回 404 NotFound 消息,它将触发 RetryPolicy,该策略会在指定的时间后指数级增加的间隔内重试请求。

将 Polly 集成到 ICatalogService 中

让我们看看如何将 Polly.NET 集成到购物车服务中. 如我们之前所看到的,我们的用例使用目录服务来收集用户购物车中项目的详细信息并将其返回给客户端。此外,我们将在 ICatalogClient 上实现 CircuitBreakerPolicyCircuitBreakerPolicy 采用快速失败的方法,这意味着即使目录服务的响应没有到达,运行时也会继续执行应用程序.

在开始之前,让我们通过在项目文件夹中使用 add package 命令将一些 Polly.NET 包添加到 Cart.Infrastructure 项目中:

dotnet add package Polly

让我们通过在 Cart.Infrastructure 中创建一个新的 CatalogServicePolicies 静态类来为 ICatalogClient 创建一些策略:

using System;
using System.Net;
using System.Net.Http;
using Polly;
using Polly.Extensions.Http;

namespace Cart.Infrastructure.Extensions.Policies
{
    public static class CatalogServicePolicies
    {
        public static IAsyncPolicy<HttpResponseMessage> RetryPolicy()
        {
            return HttpPolicyExtensions
                .HandleTransientHttpError()
                .OrResult(msg => msg.StatusCode == 
                    HttpStatusCode.NotFound)
                .WaitAndRetryAsync(3, retryAttempt => 
                  TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
        }

        public static IAsyncPolicy<HttpResponseMessage> 
        CircuitBreakerPolicy()
        {
            return HttpPolicyExtensions
                .HandleTransientHttpError()
                .CircuitBreakerAsync(3, TimeSpan.FromMinutes(1));
        }
    }
} 

上述代码定义了两个策略:

  • RetryPolicy 静态方法定义在执行其他策略之前要进行的重试次数。它使用 .HandleTransientHttpError.OrResult 方法来检测客户端返回的所有失败条件。此外,它调用 WaitAndRetryAsync 方法,该方法将 RetryPolicy 限制在最多三次重试。每次重试都会增加睡眠时间。

  • CircuitBreaker 静态方法通过使用 .HandleTransientHttpError 捕获所有错误条件。它调用 .CircuitBreakerAsync 方法来定义 CircuitBreakerPolicyCircuitBreakerPolicy 在三次尝试后会触发,并保持活跃状态 1 分钟。

现在,我们可以将这些策略的定义注入到我们的 HttpClient 实例中,如下所示:

public static class CatalogServiceExtensions
{
    public static IServiceCollection AddCatalogService(this 
    IServiceCollection services, Uri uri)
    {
        services.AddScoped<ICatalogService, CatalogService>();

        services.AddHttpClient<ICatalogClient, CatalogClient>(client =>
            {
                client.BaseAddress = uri;
            })
            .SetHandlerLifetime(TimeSpan.FromMinutes(2)) 
            .AddPolicyHandler(CatalogServicePolicies.RetryPolicy())
 .AddPolicyHandler(CatalogServicePolicies.
             CircuitBreakerPolicy());

        return services;
    }
}

如您所见,我们使用AddPolicyHandler方法注入这些策略,并调用CatalogServicePolicies静态类来获取它们。同样重要的是要注意,在定义策略之前,我们使用SetHandlerLifetime方法来确定HttpClient的生存期。这种方法保证了购物车服务与目录服务之间更健壮的通信。此外,请注意,Polly策略可以应用于任何第三方依赖调用,这意味着每次我们依赖第三方服务时,我们都需要预见这种类型的做法,以便优雅地处理错误。

使用事件总线共享事件

到目前为止,在本章中,我们看到了如何通过调用其他 Web 服务通过 HTTP 共享信息。正如我们已经提到的,避免在服务之间复制信息非常重要,更重要的是,每个服务必须是单个数据源的所有者,该数据源需要尽可能隔离。我们可以用来共享信息的另一种技术是使用事件推送数据。在本节中,我们将首先检查一个适合事件总线的用例。

假设目录中的一个商品达到零的可用库存量并进入售罄状态。我们需要传播这个信息并告诉购物车服务这个特定商品已售罄。这个用例可以使用事件总线来实现。

要理解这个架构,请看以下图示:

图片

目录服务在某个商品的可用库存达到零时触发一个事件。购物车服务监听此事件,然后触发更新存储在 Redis 数据源中的购物车。为此,我们将使用 RabbitMQ,这是最常见的消息总线之一。RabbitMQ 提供了一个.NET 包,可用于实现两个解决方案之间的通信:github.com/rabbitmq/rabbitmq-dotnet-client

设置 RabbitMQ 实例并发布事件

事件总线通信由两部分组成:发送者和接收者。在事件的情况下,参与者的名称是发布者和订阅者。上一节描述了订阅者部分的实现。在这种情况下,目录服务将是发布者,而购物车服务将是订阅者。在我们查看如何实现发布者部分之前,我们需要创建一个 RabbitMQ 实例,使用 Docker 容器,通过向目录服务添加docker-compose.yml文件来实现:

version: "3.7"
services:
    ...        
 catalog_esb:
    container_name: catalog_esb
    image: rabbitmq:3-management-alpine
    ports:
      - 5672:5672
      - 15672:15672
    networks:
      - my_network
    ...
networks:
    my_network:
        driver: bridge

docker-compose.yml文件使用rabbitmq:3-management-alpine镜像定义了一个名为catalog_esb的新容器。它还确定了在本地主机网络中映射的两个端口:5672:567215672:15672。第一个端口映射用于暴露 RabbitMQ 实例,而第二个则用于显示管理控制台。

此外,我们还需要在目录网络服务中定义一个配置 RabbitMQ 的扩展方法。我们可以使用以下命令将 RabbitMQ.Client 包添加到 Catalog.Infrastructure 项目中:

dotnet add package RabbitMQ.Client 

此外,我们还需要在 Catalog.Domain 项目的 Events 文件夹中实现 ItemSoldOutEvent 类型:

namespace Catalog.Domain.Events
{
    public class ItemSoldOutEvent
    {
        public string Id { get; set; }
    }
}

上述类反映了我们在购物车项目中已经实现的事件,它将通过事件总线发送消息。事件总线还需要一个配置类,该类表示与 RabbitMQ 实例的连接参数。该类将存储在 Catalog.Domain 项目的 Configuration 文件夹中:

namespace Catalog.Domain.Configurations
{
    public class EventBusSettings
    {
        public string HostName { get; set; }
        public string User { get; set; }
        public string Password { get; set; }
        public string EventQueue { get; set; }
    }
}

EventBusSettings 类型描述了 RabbitMQ 实例的 HostName、用户的 UserPassword 以及用于推送消息的 EventQueue 名称。因此,我们可以通过在 Catalog.Infrastructure 项目中实现扩展方法来设置并启动事件总线:

using Catalog.Domain.Configurations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using RabbitMQ.Client;

namespace Catalog.Infrastructure.Extensions
{
    public static class EventsExtensions
    {
        public static IServiceCollection AddEventBus(this 
            IServiceCollection services, IConfiguration configuration)
        {
            var config = new EventBusSettings();
            configuration.Bind("EventBus", config);
            services.AddSingleton(config);

            ConnectionFactory factory = new ConnectionFactory
            {
                HostName = config.HostName,
                UserName = config.User,
                Password = config.Password
            };

            services.AddSingleton(factory);
            return services;
        }
    }
}

上述代码定义了如何将事件发布到 RabbitMQ。扩展方法执行以下操作:

  • 它使用 EventBus 字符串部分作为参数初始化一个新的 EventBusSettings,并将配置作为一个单例实例添加到 ASP.NET Core 的依赖注入引擎中。

  • 它使用 RabbitMQ 类型初始化传输协议,通过初始化一个新的 ConnectionFactory 类型。ConnectionFactory 将提供所有与通过 RabbitMQ 发布消息相关的工具。

  • 它将新的 ConnectionFactory 类型添加到依赖注入服务中作为一个单例类型。

此外,可以使用 .NET Core 的开箱即用的依赖注入来解析 ConnectionFactoryEventBusSettings 类型,并使用 RabbitMQ.Client 包提供的方法发布事件:

using Catalog.Domain.Events;
using RabbitMQ.Client;

namespace Catalog.Domain.Services
{
    public class ItemService : IItemService
    {
        private readonly IItemMapper _itemMapper;
        private readonly IItemRepository _itemRepository;
 private readonly ConnectionFactory _eventBusConnectionFactory;
 private readonly ILogger<ItemService> _logger;
        private readonly EventBusSettings _settings;

        public ItemService(IItemRepository itemRepository, IItemMapper 
            itemMapper,
            ConnectionFactory eventBusConnectionFactory, 
            ILogger<ItemService> logger, EventBusSettings settings)
        {
            _itemRepository = itemRepository;
            _itemMapper = itemMapper;
 _eventBusConnectionFactory = eventBusConnectionFactory;
            _logger = logger;
            _settings = settings;
        }

        ...

        public async Task<ItemResponse> DeleteItemAsync(DeleteItemRequest request,
            CancellationToken cancellationToken = default)
        {
            if (request?.Id == null) throw new ArgumentNullException();

            var result = await _itemRepository.GetAsync(request.Id);
            result.IsInactive = false;

            _itemRepository.Update(result);
            await _itemRepository.UnitOfWork.
                SaveChangesAsync(cancellationToken);

            SendDeleteMessage(new ItemSoldOutEvent { Id = request.Id.ToString() });
            return _itemMapper.Map(result);
        }

 private void SendDeleteMessage(ItemSoldOutEvent message)
        {
            try
            {
                var connection = _eventBusConnectionFactory.
                  CreateConnection();

                using var channel = connection.CreateModel();
                channel.QueueDeclare(queue: _settings.EventQueue, true, 
                    false);

                var body = Encoding.UTF8.GetBytes
                    (JsonSerializer.Serialize(message));

                channel.ConfirmSelect();
                channel.BasicPublish(exchange: "", routingKey: 
                    _settings.EventQueue, body: body);
                channel.WaitForConfirmsOrDie();
            }
            catch (Exception e)
            {
                _logger.LogWarning("Unable to initialize the event bus: 
                    {message}", e.Message);
            }
        }
    }
}

在这里,ItemService 类使用依赖注入将新的 ConnectionFactory 实例和 EventBusSettings 注入到类中。正如你可能已经注意到的,DeleteItemAsync 方法也调用了我们定义的 SendDeleteMessage。此外,SendDeleteMessage 方法使用 CreateConnection 方法来与 RabbitMQ 创建新的连接。然后,它继续使用 CreateModel 方法创建一个新的通道,并定义一个与 EventBusSettings 配置中定义的队列同名的新队列。最后,它通过使用 EventQueue 字段序列化 ItemSoldOut 事件并发布消息。整个流程被封装在一个 try-catch 块中,以便在目录服务和队列之间出现通信错误时忽略。现在我们已经设置了发送者,我们可以在 Catalog.APIStartup 类中以下方式调用之前定义的 AddEventBus 扩展方法:

public void ConfigureServices(IServiceCollection services)
{
        ... 
        services.AddEventBus(Configuration);
}

AddEventBus 方法现在使用 EventBus 部分为新实例的 ConnectionFactory 类型提供必要的配置。现在,我们可以将配置添加到 appsettings.json 文件中:

...   
 "EventBus": {
        "HostName": "catalog_esb",
        "User": "guest",
        "Password": "guest",
        "EventQueue": "ItemSoldOut"
    }
...

ConnectionString 指定了 catalog_esb 实例作为主机名,以及 RabbitMQ 提供的默认 usernamepassword。此外,它还指定了 ItemSoldOut 端点名称。现在,每次我们在目录服务中删除一个项目时,它都会将一个新的 ItemSoldOut 事件排队到 RabbitMQ。在下一章中,我们将看到如何在购物车服务中消费这些消息。在下一节中,我们将继续学习如何设置和配置购物车服务 Docker 镜像。

使用 Docker 运行购物车服务

让我们学习如何使用 Docker 运行之前实现的购物车服务。正如我们在上一章中描述的,我们将在购物车服务项目的根目录中定义 docker-compose.yml 文件和 Dockerfile。docker-compose 文件将定义两个容器:第一个托管购物车服务 ASP.NET Core 实例,而另一个代表 Redis 实例:

version: "3.7"
services:
    cart_api:
        container_name: cart_api
        build:
            context: .
        env_file:
            - .env
        networks:
            - my_network
        ports:
            - 5002:5002
        depends_on:
            - cart_db

    cart_db:
        container_name: cart_db
        networks:
            - my_network
        env_file:
            - .env
        ports:
            - 6378:6378
        image: redis:alpine

networks:
    my_network:
        driver: bridge

首先,前面的代码定义了 cart_api 容器。它是 my_network 的一部分,该网络在同一个文件中定义,并暴露端口 5002(HTTP)和 5003(HTTPS)。它还引用了位于项目根目录的 Dockerfile。其次,docker-compose.yml 文件定义了 cart_db 容器,它暴露了 Redis 的默认端口(6378)。cart_db 容器使用 Alpine 版本的 Redis,以便可以节省容器大小。该容器共享 my_network

在这种情况下,我们使用运行在容器上的存储系统。请注意,这个信息不是持久的,有两个原因。第一个原因是 Redis 使用 TTL 存储信息。默认的 TTL 是 24 小时;在此之后,购物车信息将被清除。在向 Redis 实例添加新键时,可以指定另一个 TTL。第二个原因是,一旦 cart_db 容器被终止,我们将丢失其中的信息。我们使用 Redis 实例仅作为演示目的。

现在,让我们看一下 Dockerfile 定义的概述:

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS base
WORKDIR /app
EXPOSE 5002

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /project

COPY ["/src/Cart.API/Cart.API.csproj", "/src/Cart.API/"]
RUN dotnet restore "/src/Cart.API/Cart.API.csproj"

COPY . .
WORKDIR "/project/src/Cart.API"
RUN dotnet build "Cart.API.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "Cart.API.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app

COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Cart.API.dll"]

Dockerfile 完成了与为 Catalog.API 项目定义的相同指令:它使用 microsoft/dotnet:sdk 镜像来构建项目并发布它,然后使用 microsoft/dotnet:3.0-aspnetcore 镜像来运行它。为了使容器正常工作,我们需要在 Catalog.API 项目和 Cart.API 项目容器之间共享信息。此外,还需要在同一个项目中初始化容器,通过将以下 .env 文件添加到与 docker-compose.yml 文件相同级别的位置来实现:

COMPOSE_PROJECT_NAME=store
ASPNETCORE_URLS=http://*:5002
ASPNETCORE_ENVIRONMENT=Integration

COMPOSE_PROJECT_NAME 变量设置项目名称。此值在启动时附加到容器名称之前,以及服务名称一起。最后,可以通过在 Catalog.APICart.API 项目文件夹中执行 docker-compose up --build 命令来运行两个项目:Catalog.API 文件夹中的 docker-compose 命令将初始化 catalog_apicatalog_dbcatalog_esb 容器。另一方面,Cart.API 文件夹中的 docker-compose 命令将初始化 cart_apicart_db 容器。

此外,通过以下一系列 HTTP 调用来创建一个新的目录项目也是可能的:

POST /api/genre HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{
 "genreDescription": "R&B"
}

POST /api/artist HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{
    "artistName": "Anderson .Paak"
}

这两个请求直接发送到 localhost:5000 URL,代表目录网络服务。它们返回一个包含每个创建的艺术家和流派实体 ID 的 201 Created HTTP 状态码。此外,我们可以通过以下 HTTP 请求在目录中创建一个新的项目:

POST /api/items HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{
 "name": "Test",
 "description": "Description",
 "labelName": "Label",
 "price": {
 "currency": "EUR",
 "amount": 34.3
 },
 "pictureUri": "",
 "releaseDate": "2019-11-21T16:18:42+00:00",
 "format": "",
 "availableStock": 4,
 "genreId":"<genre_id>",
 "artistId":"<artist_id>"
}

现在,我们可以通过调用购物车 API 服务来创建一个新的购物车会话,并使用我们刚刚创建的项目:

POST /api/cart/ HTTP/1.1
Host: localhost:5002
Content-Type: application/json
{
 "ItemsIds": ["<item_id>"],
 "UserEmail":"youremail@gmail.com"
}

最后,我们可以通过调用购物车会话的详细信息来验证目录服务与购物车服务之间的通信:

GET /api/cart/<cart_id> HTTP/1.1
Host: localhost:5002
cache-control: no-cache

购物车服务应该通过检索目录网络服务暴露的信息来响应与购物车中项目相关的详细信息。如果目录网络服务出现故障,购物车服务将仅返回相应的 ID,省略所有项目的详细信息。

摘要

在本章中,你学习了如何在服务生态系统中实现不同类型的通信。我们详细探讨了如何使用 HTTP 客户端在服务之间共享信息。我们还探讨了如何使用 Polly 结合弹性技术。

然后,我们描述了如何使用事件总线向 RabbitMQ 队列发送事件;我们使用了 RabbitMQ.Client 和 RabbitMQ 来完成这个任务。本章中涵盖的主题在你需要在不同或多个网络服务或系统之间传输数据或执行操作时将非常有用。

在下一章中,我们将学习如何通过使用 ASP.NET Core 的工作服务功能来消费 ItemSoldOut 事件。

第十四章:使用.NET Core 实现工作服务

.NET Core 的最新版本包括一种简单方便的方式来实现后台进程。此外,从版本 3.0 开始,可以使用工作服务内置模板创建新项目。.NET 工作服务适用于多种用例。此外,随着云计算技术和分布式系统的日益普及,也涉及到服务之间的事件驱动通信,这需要实现后台进程。本章将介绍 ASP.NET Core 提供的工作服务工具的一些概念和用例。我们还将探讨如何将 ASP.NET Core 的工作服务功能集成到消耗上一章中实现的ItemSoldOut事件队列中。

本章涵盖了以下主题:

  • 工作服务简介

  • 使用.NET Core 实现工作服务

  • 在 Docker 上部署和运行工作服务

  • 扩展背景服务类

到本章结束时,你将能够实现工作服务并使用 Docker 容器技术部署它。

工作服务介绍

.NET Core 工作服务在每次需要执行重复或后台运行的操作时都非常有用。更详细地说,它们可以在应用层中使用,以启用异步操作并处理基于事件的架构的事件。如果你每次需要发布或监听消息时都需要根据计划刷新数据,或者你的应用程序需要排队一个后台工作项,那么你可能需要使用工作服务。此外,使用工作服务,可以在同一服务器上运行多个后台任务,而不会消耗大量资源。

.NET Core 中工作服务的基础是IHostedService接口。内置的工作服务模板可以作为开始实现我们的工作服务项目的指南。更重要的是,IHostedService接口由一个BackgroundService类实现,这是我们实现工作服务时应扩展的基类。

理解工作服务生命周期

.NET Core 使用BackgroundService类的定义来识别工作服务。BackgroundService类公开了三个方法,这些方法代表了工作服务的生活周期阶段:

namespace Microsoft.Extensions.Hosting
{
    public abstract class BackgroundService : IHostedService, IDisposable
    {
        public virtual Task StartAsync(CancellationToken 
            cancellationToken);

        protected abstract Task ExecuteAsync(CancellationToken 
            stoppingToken);

        public virtual async Task StopAsync(CancellationToken 
            cancellationToken);
    }
}

以下代码是BackgroundService类的抽象实现。该类实现了IHostedServiceIDisposable接口,并公开了以下方法:

  • StartAsync方法代表了工作生活周期的第一阶段。当宿主准备好运行工作服务时,会调用此方法。它接受一个CancellationToken类型参数,该参数可用于取消正在运行的任务。

  • ExecuteAsync 方法包含 BackgroundService 类的核心实现。该方法在 IHostedService 启动后调用,并返回一个表示 Task 状态的 Task 类型。

  • 当托管应用程序优雅地停止时,会调用 StopAsync 方法。

本节提供了工作服务生命周期方法的概述。在下一节中,我们将看到 .NET Core 中可用于工作服务的托管模型。

托管模型

.NET Core 工作模板不过是一个普通的 .NET Core 应用程序。此外,我们还可以将工作模板作为普通的控制台应用程序运行。此外,工作模板还提供了托管 API,以便将工作作为始终运行的过程运行。在 Windows 系统中,可以使用 Windows 服务技术运行工作。在 Linux 系统中,工作通过 systemd 运行。

此外,.NET Core 提供了两个不同的 NuGet 包来指定工作的工作行为:Microsoft.Extensions.Hosting.WindowsServices 包和 Microsoft.Extensions.Hosting.Systemd 包,这两个包都在 NuGet 上可用。

Microsoft.Extensions.Hosting.WindowsServices 包提供了一个名为 UseWindowsService() 的扩展方法,该方法可以在 Program 类的 Main 方法中初始化托管之后应用。UseWindowsService() 方法设置 WindowsServiceLifetime 并使用 Windows 事件日志 作为默认的日志输出。

如果我们选择将我们的工作作为 systemd 服务托管,我们需要使用 Microsoft.Extensions.Hosting.Systemd NuGet 包。此包提供了 UseSystemd() 方法,它可以像 UseWindowsService() 一样应用;在这种情况下,我们的工作服务将使用 SystemdLifetime 类,并配置日志以符合 systemd 格式。

重要的是要注意,UseWindowsService()UseSystemd() 方法仅在作为 Windows 服务和 systemd 服务运行的工作服务上执行。

一旦我们发现了托管工作服务的方法,我们就可以通过将这些概念应用到具体的健康检查过程中来继续操作。此外,我们还将看到如何在 Docker 容器上运行工作。

实现健康检查工作

以下部分重点介绍工作服务的实现。我们将涵盖的用例是一个通用 Web 服务的健康检查工作。此外,假设我们想要定期检查我们 Web 服务的健康状态。这种检查通常在部署管道的末尾执行,或者一旦服务部署,以验证 Web 服务是否正在运行。

项目结构概述

本节为您概述了.NET Core 模板系统提供的开箱即用的工作模板项目。我们将使用此类项目来实现一个健康检查工作。首先,让我们使用以下 CLI 命令创建一个新的项目:

dotnet new worker -n HealthCheckWorker

此命令创建了一个名为HealthCheckWorker的新文件夹,并创建了基本工作服务项目所需的所有文件。让我们看看之前执行的dotnet new worker模板命令创建的文件。

其次,我们可以运行tree CLI 命令(在 macOS X 和 Windows 上均可用),该命令显示了之前创建的项目文件夹结构:

.
├── Program.cs
├── Properties
│   └── launchSettings.json
├── Worker.cs
├── HealthCheckWorker.csproj
├── appsettings.Development.json
├── appsettings.json
├── bin
│   └── Debug
│       └── netcoreapp3.0
└── obj
  ├── Debug
      └── netcoreapp3.0

Program.cs文件是我们工作服务的入口点。与webapi模板一样,工作模板使用Program.cs文件通过调用Host.CreateDefaultBuilderConfigureServices方法来初始化和检索一个新的IHostBuilder实例。Program.cs文件中的Main静态方法使用AddHostedService扩展方法初始化一系列工作:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace HealthCheckWorker
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureServices((hostContext, services) =>
                {
                    services.AddHostedService<Worker>();
                });
    }
}

如前所述,前面的代码片段使用AddHostedService初始化作为默认工作模板的一部分创建的Worker类。需要注意的是,在底层,AddHostedService使用Singleton生命周期初始化类。因此,在整个工作服务执行期间,我们将有一个工作实例。在下一节中,我们将深入探讨工作生命周期的执行。

另一个将工作项目与其他任何.NET Core 项目区分开来的主要特征是使用了Microsoft.NET.Sdk.Worker SDK。此外,我们还应该注意到,*.csproj文件仅引用了一个额外的 NuGet 包,该包提供了Program类和Main方法使用的宿主扩展方法:Microsoft.Extensions.Hosting

下一步是创建一个新的类,该类代表HealthCheckWorker项目的配置:

namespace HealthCheckWorker
{
    public class HealthCheckSettings
    {
        public string Url { get; set; }
        public int IntervalMs { get; set; }
    }
}

HealthCheckSettings将包含两个属性:UrlIntervalMs属性。第一个属性包含健康检查地址的 HTTP URL,该 URL 在appsettings.json中指定。IntervalMs属性表示工作生命周期的频率(以毫秒为单位)。

此外,还可以使用.NET Core 的配置系统在Program.cs文件的Main方法执行时绑定我们的配置对象,方法如下:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace HealthCheckWorker
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureServices((hostContext, services) =>
 {
 var healthCheckSettings = hostContext
 .Configuration
                        .GetSection("HealthCheckSettings") services.Configure<HealthCheckSettings>
                        (healthCheckSettings);
 services.AddHostedService<Worker>();
 });
    }
}

前面的代码使用hostContext检索.NET Core 提供的Configuration实例。默认情况下,hostContext将填充appsettings.json文件中编写的设置结构。此外,可以使用GetSection方法从我们的appsettings.json文件中检索特定的配置部分,并将其绑定到HealthCheckSettings类型。

之后,我们可以继续进行Worker类的具体实现。此外,我们现在能够使用.NET Core 的内置依赖注入来注入HealthCheckSettings类型实例。设置是通过IOption接口注入的:

public class Worker : BackgroundService
    {
        private readonly ILogger<Worker> _logger;
        private readonly HealthCheckSettings _settings;
        private HttpClient _client;

        public Worker(ILogger<Worker> logger, 
 IOptions<HealthCheckSettings> options)
        {
            _logger = logger;
            _settings = options.Value;
        }
...

前面的代码定义了Worker类的属性。正如所述,该类实现了一个使用构造函数注入技术初始化的_settings属性,我们还可以看到一个HttpClient属性,它将由BackgroundService类公开的StartAsync方法初始化,并在ExecuteAsync方法中使用:

public class Worker : BackgroundService
{
    ...

 public override Task StartAsync(CancellationToken cancellationToken)
 {
 _client = new HttpClient();
 return base.StartAsync(cancellationToken);
 }

 protected override async Task ExecuteAsync(CancellationToken 
    stoppingToken)
 {
 while (!stoppingToken.IsCancellationRequested)
 {
 var result = await _client.GetAsync(_settings.Url);

 if (result.IsSuccessStatusCode)
 _logger.LogInformation($"The web service is up. 
                HTTP {result.StatusCode}");
 await Task.Delay(_settings.IntervalMs, stoppingToken);
 }
 }

    ...
}

在客户端初始化之后,ExecuteAsync方法实现了一个while循环,该循环将持续到stoppingToken请求取消进程。循环的核心部分使用HttpClientGetAsync方法检查健康检查路由是否返回 HTTP 状态消息。最后,代码调用Task.Delay并使用填充了_settings实例的IntervalMs属性。

作为最后一步,Worker类覆盖了由BackgroundService类公开的StopAsync方法:

public class Worker : BackgroundService
{
    ...

 public override Task StopAsync(CancellationToken cancellationToken)
 {
 _client.Dispose();
 return base.StopAsync(cancellationToken);
 }

}

StopAsync方法通过调用Dispose()方法执行HttpClient实例的处置,并调用基类BackgroundServiceStopAsync(cancellationToken)

我们应该注意,StartAsyncStopAsync方法总是使用base关键字调用父方法。此外,在我们的情况下,我们需要保持基类BackgroundService类的行为。

本章的下一部分将专注于在 Docker 容器上执行工作。我们将看到如何配置Dockerfile,以及部署和运行应用程序。

在 Docker 上运行工作服务

本节专注于.NET 工作模板的部署步骤。我们将通过在 Docker Linux 镜像上运行我们的服务来继续操作。正如我们在第十二章,“服务的容器化”中已经看到的,我们将使用 Docker 在容器中运行应用程序。

让我们从配置项目文件夹中的Dockerfile开始:

FROM mcr.microsoft.com/dotnet/core/runtime:3.0 AS base
WORKDIR /app

# Step 1 - Building the project
FROM mcr.microsoft.com/dotnet/core/sdk:3.0 AS build
WORKDIR /src
COPY ["HealthCheckWorker.csproj", "./"]
RUN dotnet restore "./HealthCheckWorker.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "HealthCheckWorker.csproj" -c Release -o /app/build

# Step 2 - Publish the project
FROM build AS publish
RUN dotnet publish "HealthCheckWorker.csproj" -c Release -o /app/publish

# Step 3 - Run the project
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "HealthCheckWorker.dll"]

前面的代码描述了我们的.NET Core 工作应用程序的容器构建和部署过程。Dockerfile指令可以分为五个步骤:

  1. 第一步使用dotnet/core/sdk Docker 镜像执行项目的构建:
    • 首先,它将工作目录设置为/src并将项目文件夹中的文件复制过来。

    • 其次,它执行了dotnet restoredotnet build命令。

  1. 第二步在/app/publish文件夹中使用Release配置运行dotnet publish命令。

  2. 第三步使用dotnet/core/runtime Docker 镜像,通过dotnet CLI 命令运行之前执行过的dotnet publish的结果。

  3. 可以使用以下命令构建 Docker 镜像:

docker build --rm -f "Dockerfile" -t healthcheckworker:latest 
  1. 此外,我们可以使用以下命令运行之前构建的镜像:
docker run --rm -d healthcheckworker:latest

上述命令将运行 Docker 容器,从而运行工作服务进程。工作服务将通过应用节流(也在 appsettings.json 文件中指定),向项目配置的 URL 发送 HTTP GET 请求。

之前提到的 Dockerfile 使用了多阶段构建方法和其他一些技术来构建用于运行项目的 Docker 镜像。这些概念以及更多内容在 第十二章,服务的容器化中详细描述。

消费售罄事件

我们在 第十三章,服务生态系统模式中实现的售罄事件,提供了关于目录中不可用项目的信息。此外,我们可以通过使用本章中描述的 BackgroundService 类型功能来消费此事件。购物车服务将实现一个售罄处理程序,用于处理和从 Redis 实例中删除不可用的项目 ID。

创建售罄处理程序

首先,让我们在购物车服务中创建一个处理程序来管理产品的售罄状态。首先,我们应该通过执行以下命令将 RabbitMQ.Client 包添加到 Cart.Domain 项目中:

dotnet add ./src/Cart.Domain package RabbitMQ.Client

我们可以继续定义一个类来表示购物车服务在新的项目中使用的售罄事件。因此,我们将创建一个新的 ItemSoldOutEvent 类型,它代表一个售罄事件:

using MediatR;

namespace Cart.Domain.Events
{
    public class ItemSoldOutEvent : IRequest<Unit>
    {
        public string Id { get; set; }
    }
}

ItemSoldOutEvent 类型包含对售罄项目的 Id 的引用。该类型将用于将队列内容反序列化为强类型实例并通过 MediatR 实例发送消息。正如我们对目录服务的配置所做的那样,我们还需要在 Cart.Infrastructure 项目中有一个表示 RabbitMQ 配置的类似类:

namespace Cart.Infrastructure.Configurations
{
    public class EventBusSettings
    {
        public string HostName { get; set; }
        public string User { get; set; }
        public string Password { get; set; }
        public string EventQueue { get; set; }
    }
}

之前定义的 EventBusSettings 类型描述了 RabbitMQ 实例的 HostName、用户的 UserPassword 以及用于推送消息的 EventQueue 名称。此外,我们可以通过在 Cart.Domain 项目中定义一个新的类来定义消费 ItemSoldOutEvent 事件的调解处理程序:

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Cart.Domain.Entities;
using Cart.Domain.Events;
using Cart.Domain.Repositories;
using MediatR;

namespace Cart.Domain.Handlers.Cart
{
    public class ItemSoldOutEventHandler : 
        IRequestHandler<ItemSoldOutEvent>
    {
        private readonly ICartRepository _cartRepository;

        public ItemSoldOutEventHandler(ICartRepository cartRepository)
        {
            _cartRepository = cartRepository;
        }

        public async Task<Unit> Handle(ItemSoldOutEvent @event, 
            CancellationToken cancellationToken)
        {
            var cartIds = _cartRepository.GetCarts().ToList();

            var tasks = cartIds.Select(async x =>
            {
                var cart = await _cartRepository.GetAsync(new Guid(x));
                await RemoveItemsInCart(@event.Id, cart);
            });

            await Task.WhenAll(tasks);

            return Unit.Value;
        }

        private async Task RemoveItemsInCart(string itemToRemove, 
            CartSession cartSessionSession)
        {
            if (string.IsNullOrEmpty(itemToRemove)) return;

            var toDelete = cartSessionSession?.Items?.Where(x => 
                x.CartItemId.ToString() ==           
                     itemToRemove).ToList();

            if (toDelete == null || toDelete.Count == 0) return;

            foreach (var item in toDelete) 
                cartSessionSession.Items?.Remove(item);

            await _cartRepository.AddOrUpdateAsync(cartSessionSession);
        }
    }
}

之前的 ItemSoldOutEventHandler 类实现了由 MediatR 包提供的 IRequestHandler<T> 接口。该接口包含 Handle 方法,这是处理程序的主要入口点。当购物服务接收到 ItemSoldOutEvent 类型的事件时,将执行一个新的 ItemSoldOutEventHandler 实例。ItemSoldOutEventHandler 依赖于 ICartRepository。此外,RemoveItemsInCart 方法检索我们存储库中所有存储的购物车,并在每次接收到 ItemSoldOutEvent 类型的消息时移除售罄的商品。

测试售罄过程

可以通过测试 ItemSoldOutHandler 来检查售罄过程。处理程序将使用与其他处理程序相同的测试方法进行测试:

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Cart.Domain.Events;
using Cart.Domain.Handlers.Cart;
using Cart.Fixtures;
using Shouldly;
using Xunit;

namespace Cart.Domain.Tests.Handlers.Events
{
    public class ItemSoldOutEventHandlerTests : IClassFixture<CartContextFactory>
    {
        private readonly CartContextFactory _contextFactory;

        public ItemSoldOutEventHandlerTests(CartContextFactory 
            cartContextFactory)
        {
            _contextFactory = cartContextFactory;
        }

        [Fact]
        public async Task should_not_remove_records_when*soldout* message_contains_not_existing_id()
        {
            var repository = _contextFactory.GetCartRepository();
            var itemSoldOutEventHandler = new 
                ItemSoldOutEventHandler(repository);
            var found = false;

            await itemSoldOutEventHandler.Handle(new ItemSoldOutEvent { 
                Id = Guid.NewGuid().ToString() }, 
                   CancellationToken.None);

            var cartsIds = repository.GetCarts();

            foreach (var cartId in cartsIds)
            {
                var cart = await repository.GetAsync(new Guid(cartId));
                found = cart.Items.Any(i => i.CartItemId.ToString() == 
                    "be05537d-5e80-45c1-bd8c-
                     aa21c0f1251e");
            }

            found.ShouldBeTrue();
        }

        ...
    }
}

ItemSoldOutEventHandlerTests 类使用 CartContextFactory 类在测试方法的每个测试中初始化一个新的存储库,使用 _contextFactory.GetCartRepository(); 方法。此外,should_not_remove_records_when_soldout_message_contains_not_existing_id 测试方法检查如果 ItemSoldOutEvent 实例具有不存在的 ID,则不会出现任何问题。另一方面,should_remove_records_when_soldout_messages_contains_existing_ids 测试方法检查当 ItemSoldOutEvent 实例包含现有 ID 时,ItemSoldOutEventHandler 是否会删除篮子中的商品:

...
        [Fact]
        public async Task should_remove_records_when*soldout* messages_contains_existing_ids()
        {
            var itemSoldOutId = "be05537d-5e80-45c1-bd8c-aa21c0f1251e";
            var repository = _contextFactory.GetCartRepository();
            var itemSoldOutEventHandler = new 
                ItemSoldOutEventHandler(repository);
            var found = false;

            await itemSoldOutEventHandler.Handle(new ItemSoldOutEvent { 
              Id = itemSoldOutId }, 
                CancellationToken.None);

            foreach (var cartId in repository.GetCarts())
            {
                var cart = await repository.GetAsync(new Guid(cartId));
                found = cart.Items.Any(i => i.CartItemId.ToString() == 
                    itemSoldOutId);
            }

            found.ShouldBeFalse();
        }
    }
}

第二个测试方法提供了一个现有的商品 ID,并通过检查过程是否有效地移除了商品来验证处理方法。现在我们已经验证了 ItemSoldOutHandler 的行为,我们可以继续配置和注册事件总线实例。

配置后台服务

现在我们已经定义了事件总线抽象,我们可以创建一个新的 BackgroundService 类型,该类型将使用 IMediator 接口来分发售罄消息。本书使用 RabbitMQ,因为它开源且易于配置,但请记住,与此主题相关的产品和技术有成千上万种。在继续实现后台服务之前,有必要向 Cart.Infrastructure 项目添加一些包:

dotnet add package RabbitMQ.Client

此外,我们可以通过创建一个新的类来扩展 BackgroundService 类型:

using System;
using System.Threading;
using System.Threading.Tasks;
using Cart.Domain.Events;
using Cart.Infrastructure.Configurations;
using MediatR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

namespace Cart.Infrastructure.BackgroundServices
{
    public class ItemSoldOutBackgroundService : BackgroundService
    {
        private readonly IMediator _mediator;
        private readonly ILogger<ItemSoldOutBackgroundService> _logger;
        private readonly EventBusSettings _options;
        private readonly IModel _channel;

        public ItemSoldOutBackgroundService( IMediator mediator,
            EventBusSettings options, ConnectionFactory factory, 
                ILogger<ItemSoldOutBackgroundService> logger)
        {
            _options = options;
            _logger = logger;
            _mediator = mediator;

            try
            {
                var connection = factory.CreateConnection();
 _channel = connection.CreateModel();
            }
            catch (Exception e)
            {
                _logger.LogWarning("Unable to initialize the event bus: 
                    {message}", e.Message);
            }
        }

        ...
    }
}

前面的代码片段定义了 ItemSoldOutBackgroundService 类型。该类扩展了由 Microsoft.Extensions.Hosting 命名空间公开的 BackgroundService 基类。构造函数注入 IMediator 接口以将收集的事件分发给 ItemSoldOutEventHandler 类型。此外,它还定义了将由构造函数中注入的 ConnectionFactory 类型填充的 IModel 类型的属性。_channel 属性将由 BackgroundService 类提供的 ExecuteAsync 方法使用,以分发事件。让我们通过重写 ExecuteAsync 方法来继续:

using MediatR;
using Microsoft.Extensions.Hosting;
using Newtonsoft.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

namespace Cart.Infrastructure.BackgroundServices
{
    public class ItemSoldOutBackgroundService : BackgroundService
    {
         ...

        protected override Task ExecuteAsync(CancellationToken 
            stoppingToken)
        {
            stoppingToken.ThrowIfCancellationRequested();

            var consumer = new EventingBasicConsumer(_channel);

            consumer.Received += async (ch, ea) =>
            {
                var content = System.Text.Encoding.UTF8.
                    GetString(ea.Body);
                var @event = JsonConvert.DeserializeObject
                    <ItemSoldOutEvent>(content);

                await _mediator.Send(@event, stoppingToken);
                _channel.BasicAck(ea.DeliveryTag, false);
            };

            try
            {
                consumer.Model.QueueDeclare(_settings.EventQueue, true, 
                    false);                                         
                _channel.BasicConsume(_options.EventQueue, false, 
                    consumer);
            }
            catch (Exception e)
            {
                _logger.LogWarning("Unable to consume the event bus: 
                    {message}", e.Message);
            }

            return Task.CompletedTask;
        }
    }
}

前面的代码片段使用 _channel 属性初始化一个新的 EventingBasicConsumer 实例。对于每个接收到的消息,它将 Body 属性反序列化为 ItemSoldOutEvent 类型,并使用 Send 方法将事件发送到 IMediator 实例。最后,它通过使用 EventBusSettings 类型提供的 EventQueue 名称激活消费过程。此外,在这种情况下,消费过程被 try-catch 包装,以便在发生失败时隔离过程。

在我们能够使用 RabbitMQ 之前,有必要配置客户端以便我们连接到正确的消息总线实例。让我们首先在 Cart.Infrastructure 项目中创建一个新的扩展方法,这个方法可以在 Extensions 文件夹下找到:

using Cart.Infrastructure.Configurations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using RabbitMQ.Client;

namespace Cart.Infrastructure.Extensions
{
    public static class EventsExtensions
    {
        public static IServiceCollection AddEventBus(this 
            IServiceCollection services, IConfiguration    
         configuration)
        {
            var config = new EventBusSettings();
            configuration.Bind("EventBus", config);
            services.AddSingleton(config);

            ConnectionFactory factory = new ConnectionFactory
            {
                HostName = config.HostName,
                UserName = config.User,
                Password = config.Password
            };

            services.AddSingleton(factory);

            return services;
        }
    }
}

之前的定义实现了一个与 IServiceCollection 接口绑定的扩展方法,该接口由 ASP.NET Core 的依赖注入系统提供。它在 Startup 类中使用,以连接到 RabbitMQ。AddEventBus 方法通过传递 RabbitMQ 参数初始化 ConnectionFactory 类。

最后,我们可以通过向 Startup 类的 ConfigureServices 方法中添加以下扩展方法来激活后台服务:

using System;
...

namespace Cart.API
{
    public class Startup
    {

        public void ConfigureServices(IServiceCollection services)
        {
 services
                   ...
                .AddEventBus(Configuration)
                .AddHostedService<ItemSoldOutBackgroundService>();
        }

        ...
    }
}

AddEventBus 方法将销售一空事件消费过程所需的全部依赖项添加到依赖注入中。此外,AddHostedService 方法将 ItemSoldOutBackgroundService 注册为 IHostedService 类型。最后,为了使用消息总线,我们可以在 Cart.API 项目的 appsettings.json 文件中定义连接信息,如下所示:

{
...
  "EventBus": {
    "HostName": "catalog_esb",
    "User": "guest",
    "Password": "guest",
    "EventQueue": "ItemSoldOut"
  }
}

连接参数指向在目录服务中定义的 docker-compose.yml 文件中定义的 catalog_esb 实例。此外,ItemSoldOutBackgroundService 类将处理 ItemSoldOut 队列中的消息并触发移除商品。

摘要

本章描述了如何使用 .NET Core 工作模板实现持续运行的任务。.NET Core 工作者在分布式系统中非常有用,可以在不增加服务器负载的情况下执行所有异步和基于事件的计算。

与 Windows 服务和 systemd 的集成也提供了一种高效的方式来部署和运行工作。本章为您概述了 .NET Core 工作服务托管模型,并展示了如何使用 .NET Core 设置和实现工作服务以及如何在 Docker 上运行它。最后,我们看到了如何将 BackgroundService 类的功能与之前实现的销售一空消息集成相结合。

下一章将介绍 .NET Core 提供并实现的常见安全实践。

第十五章:保护您的服务

在前面的章节中,我们看到了如何使用事件总线和弹性客户端在多个服务之间交换数据,以及如何使用 ASP.NET Core 消费消息和运行后台服务。本章是关于保护服务数据。它涵盖了 SSL、跨源资源共享CORS)和 HTTP/2 等概念,并介绍了基于令牌的认证的实现。

更详细地说,本章涵盖了以下主题:

  • SSL 的一般概述

  • 如何在 ASP.NET Core 服务中启用 CORS

  • 如何启用 HTTPS 和 HTTP/2

  • 基于令牌的认证是如何工作的

  • 如何在 ASP.NET Core 中构建基于令牌的认证

到本章结束时,你将广泛了解 ASP.NET Core 提供的安全功能,并且能够实现基于令牌的认证在 ASP.NET Core 中。

安全通信概述

安全性是构建应用程序的一个关键方面。Web 服务通常向第三方客户端和公司公开信息;因此,避免数据泄露至关重要。Web 服务的安全层通常是开发过程中的一个繁琐部分,因为它难以测试和验证。

即使是发布在公司内部网络中的 Web 服务,安全性也很重要,默认情况下,外部无法访问。作为软件工程师,我们应该尽可能保证在发布 Web 服务时具有强大的安全性。了解保护 Web 服务是识别数据消费者和防止过度使用 Web 服务的必要条件。下一节将首先描述 HTTPS 以及如何在 ASP.NET Core Web 服务中使用 HTTPS 保护数据。

使用 HTTPS 保护数据

攻击者常用的一个做法是拦截客户端和服务器之间交换的数据。因此,加密他们之间的通信以保持数据安全至关重要。SSL 使用 SSL 证书在服务器公司和客户端之间建立信任连接。SSL 使用 对称非对称加密 加密在此通信过程中使用的密钥。让我们看看客户端和服务器之间典型 SSL 握手的方案:

图片

此方案显示了 SSL 握手的传统步骤:

  1. 连接从发起请求的客户端开始。在开始之前,服务器向客户端发送 SSL 证书,以确保证书有效且可信。

  2. 客户端接着提取并加密 SSL 证书中包含的 公钥

  3. 客户端将加密的密钥(私钥)发送到服务器,服务器对数据进行编码并将数据传输回客户端。

  4. 数据通信开始,加密密钥用于加密和解密客户端和服务器共享的数据。

SSL 是 HTTPS 协议的基础,是传输加密数据的标准方式。以下部分描述了如何在 ASP.NET Core 中设置和强制执行 HTTPS。

在 ASP.NET Core 中强制执行 HTTPS

ASP.NET Core 默认启用了 HTTPS。与 HTTPS 协议相关的中间件主要是HttpsRedirection中间件类,它强制从 HTTP 重定向到 HTTPS。因此,可以在Startup类的Startup类中调用UseHttpsRedirection扩展方法来启用中间件。

让我们看看如何在运行在 docker 容器中的 ASP.NET Core 应用程序中启用和强制执行 HTTPS。第一步是生成 ASP.NET Core 应用程序在容器中使用的自签名证书。.NET Core 提供了一个全局工具,称为dotnet-dev-certs,可以在本地环境中创建自签名证书。我们可以通过以下 CLI 命令在我们的本地环境中安装此工具:

dotnet tool install --global dotnet-dev-certs

之后,可以在以下命令中创建一个新的.pfx格式的证书:

dotnet dev-certs https -ep <path_to_certificate>/certificate.pfx -p <certificate_password>

上述指令使用-ep选项指定导出路径,并使用-p密码。此外,还可以使用--trust选项信任证书。

需要注意的是,dotnet-dev-certs工具仅在 Windows 和 macOS 上工作。在 Linux 的情况下,我们应该通过使用 OpenSSL 生成证书来继续操作。以下教程(www.humankode.com/asp-net-core/develop-locally-with-https-self-signed-certificates-and-asp-net-core)提供了有关使用 OpenSSL 创建 HTTPS 证书的更多信息。

一旦我们创建了新的证书文件,我们就可以通过调整Catalog.APICart.API解决方案的docker-compose.yml文件来继续操作:

version: "3.7"
services:
  catalog_api:
    container_name: catalog_api
    build:
      context: .
      dockerfile: containers/api/Dockerfile
 volumes:
 - ./<path_to_certificate>/:/root/.dotnet/https
    env_file:
      - containers/api/api.env
    networks:
      - my_network
    ports:
      - 5000:5000
 - 5001:5001
    depends_on:
      - catalog_db
      - catalog_esb

 ...

上述docker-compose.yml定义声明了一个volumes节点,用于在容器实例中将本地./certificate/文件夹与/root/.dotnet/https文件夹绑定。此外,我们还可以通过在containers/api.env文件中添加以下变量来继续操作:

ASPNETCORE_ENVIRONMENT=Integration
ASPNETCORE_URLS=https://*:5001
ASPNETCORE_Kestrel__Certificates__Default__Password=<certificate_password>
ASPNETCORE_Kestrel__Certificates__Default__Path=/root/.dotnet/https/certificate.pfx

该文件添加了两个与证书相关的环境变量:ASPNETCORE_Kestrel__Certificates__Default__Password提供证书密码,而ASPNETCORE_Kestrel__Certificates__Default__Path定义其路径。新的docker-compose.yml文件定义还公开了5001端口,并且它还向 Kestrel 运行的 URL 池中添加了https://*:5001 URL URL。此外,现在我们可以在Startup类的Configure方法中添加以下行来强制执行 HTTPS:

app.UseHttpsRedirection();

在应用 HTTPS 限制后,客户端在每次请求时都会被重定向到 Web 服务的 HTTPS 端点。

Kestrel 上的 HTTP/2

ASP.NET Core 自 2.2.0 版本起支持 Kestrel 上的 HTTP/2,如果你使用 HTTPS,则默认启用。此外,另一个 HTTP/2 的要求是支持应用层协议协商ALPN)协议。ALPN 协议增强了客户端和服务器之间的握手过程:客户端列出所有支持的协议,服务器将确认用于 HTTP 传输的协议。此外,这种方法还允许在客户端或服务器不支持 HTTP/2 的情况下回退到 HTTP 1.1。

作为默认配置,HTTP 1.1 和 HTTP/2 在相同的绑定上运行,但可以通过在static void Main方法中扩展 Kestrel 配置来自定义并创建一个专门的 HTTP/2 绑定:

using System.Net;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;

namespace Catalog.API
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureKestrel(options =>
 {
 options.Listen(IPAddress.Any, 5002,listenOptions =>
 {
 listenOptions.Protocols = HttpProtocols.Http2;
 });
 })
                .UseStartup<Startup>();
        }
    }
}

这个片段展示了如何在端口5002上设置 HTTP/2 绑定。这种方法强制使用 HTTP/2 绑定,而不提供任何回退到 HTTP 1.1 的选项。

在 ASP.NET Core 中启用 CORS

安全性的另一个关键方面是保护我们的 API 免受 CORS 调用的攻击。默认情况下,无法使用客户端代码调用托管在其他域上的服务,因为诈骗网站可能使用跨源调用获取有关用户的敏感信息。这种安全限制被称为同源策略

同源策略的限制作用于使用以下标准的 HTTP 调用:

  • 请求来自不同的域名(例如,位于example.com的网站调用api.com)。

  • 请求来自不同的子域名(例如,位于example.com的网站调用api.example.com)。

  • 请求来自不同的端口(例如,位于example.com的网站调用example.com:3001)。

  • 请求来自不同的协议(例如,https://example.com网站调用http://example.com)。

CORS 提供了一种允许特定域名向不同域名托管的服务进行客户端调用的方法。当我们想要允许客户或第三方客户端无限制地调用我们的服务时,这种方法非常有用。还必须注意,CORS 可以启用以允许所有域名。这种方法必须避免,因为它将使攻击者能够不当使用我们的 API。

ASP.NET Core 提供了一个开箱即用的方式来启用 CORS。该框架允许使用两种方法创建 CORS 策略:中间件方法属性方法。正如我们在第三章中看到的,与中间件管道一起工作,中间件通常被实现来开发覆盖整个 Web 服务的跨切面逻辑。另一方面,属性用于对单个操作应用限制。同样,这种方法也用于 CORS 策略。

使用中间件方法实现 CORS

可以使用 CORS 中间件 方法来启用特定的 HTTP 域、方法或端口调用我们的服务。与任何中间件一样,它可以在服务的 Startup 类的 Configure 方法中定义:

namespace Catalog.API
{
    public class Startup
    {
       ...

        public void Configure(IApplicationBuilder app, 
        IWebHostingEnvironment env)
        {
            ...
            app.UseCors(cfg =>
 {
 cfg.AllowAnyOrigin();
 });

            ..
        }
    }
}

UseCors 中间件扩展方法接受一个操作方法来配置不同的规则。例如,之前的代码执行了 AllowAnyOrigin 方法以允许来自任何网站的调用。同样,可以在特定域上定义更限制性的规则,如下所示:

namespace Catalog.API
{
    public class Startup
    {
       ...
        public void Configure(IApplicationBuilder app, 
        IWebHostingEnvironment env)
        {
            ...
            app.UseCors(cfg =>
 {
 cfg.AllowAnyOrigin("https://samuele.dev");
 });
            ...
        }
    }
}

在此情况下,我们阻止了所有来自samuele.dev/网站之外的所有跨源请求。定义 CORS 规则的更高级和简洁的方法是使用命名策略进行分组。也可以使用以下方法:

namespace Catalog.API
{
    public class Startup
    {

        public void ConfigureServices(IServiceCollection services)
        {
            ...
            services.AddCors(opt =>
            {
                opt.AddPolicy("BlogDomainPolicy", cfg => 
                { cfg.WithOrigins("https://samuele.dev"); });
            });
            ..
        }

        public void Configure(IApplicationBuilder app, 
        IWebHostingEnvironment env)
        {
           ...
            app.UseCors("BlogDomainPolicy");
           ...
        }
    }
}

之前的代码使用 ConfigureServices 方法中的 app.AddCors 构造定义了一个名为 BlogDomainPolicy 的 CORS 策略。一旦我们描述了策略的规则,我们就可以在 Startup 类的 Configure 方法中使用 app.UseCors 方法来使用定义的策略。这将使我们能够在 Startup 类中建立不同的策略,并条件性地应用它们。

使用属性方法实现 CORS

在某些情况下,可能需要为某些路由或操作定义特定的策略。因此,可以使用属性应用 CORS 策略,如下所示:

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    [JsonException]
    [EnableCors("BlogDomainPolicy")]
    public class ItemController : ControllerBase
    {

        ...
    }
}

在此情况下,我们仅将 BlogDomainPolicy 的使用限制在 ItemController 上。因此,控制器下定义的所有路由都将使用相同的策略。同样,我们可以在控制器中的特定操作方法上添加策略:

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    [JsonException]
    [EnableCors("BlogDomainPolicy")]
    public class ItemController : ControllerBase
    {
        ...

        [HttpGet("{id:guid}")]
        [EnableCors("GetByIdActionPolicy")]
        public async Task<IActionResult> GetById(string id)
        {
            ...
        }

        ...
    }
}

在那种情况下,GetByIdActionPolicy 将仅对 GetById 操作方法起作用,而 BlogDomainPolicy 将作用于整个控制器。这种方法提供了很好的粒度;此外,它提供了一种为服务中的单个路由指定策略的方法。下一节将描述基于令牌的认证方法的特点。

使用基于令牌的认证保护 API

应用程序传统上通过会话 cookie 持久化身份,依赖于存储在服务器端的会话 ID。这种方法带来了一些显著的问题和陷阱:它 不是 可扩展的,因为您需要一个共同点来存储会话,并且每次用户进行认证时,服务器都需要在数据源中创建一个新的记录。因此,这种方法可能会成为您的网络服务的瓶颈。

现在,基于令牌的认证对于验证和授权用户非常有帮助,尤其是在分布式系统环境中。基于令牌的认证的主要优势在于消费者向身份服务请求令牌。接下来,客户端可以本地存储令牌,并用于认证和授权目的。

因此,令牌认证是无状态的,并且设计用于可扩展性。让我们看看令牌认证过程以及它是如何工作的,以便更好地理解这种方法的优点:

图片

此架构描述了在实现基于令牌的认证时的典型工作流程。该架构描述了三个实体:

  • 客户端是尝试访问我们资源的应用程序。

  • 身份提供者是提供服务的服务,给定用户名和密码,提供加密的认证令牌。

  • 资源提供者是客户端调用的另一个服务。此外,资源提供者将接受加密的认证令牌,并在授权的情况下提供客户端请求的信息。

由于基于令牌的认证采用无状态的方法,应用程序不会存储认证令牌。因此,必须注意客户端必须在每次请求中传递认证令牌。

基于令牌的认证可以以不同的方式实现。JSON Web TokenJWT)是一个标准,定义在 RFC 7519(tools.ietf.org/html/rfc7519)开放指令中,它描述了在双方之间表示声明的途径。JWT 被定义为包含有效载荷和签名的 JSON 对象,其中签名加密了令牌中的数据。换句话说,它通过使用密钥提供了一种加密以 JSON 格式安全数据的方法。近年来,JWT 令牌标准变得非常流行,因为网络服务可以用它实现两个目的:

  • 授权:网络服务返回 JWT 令牌以传输有关声明和个人细节的信息给已登录的用户。此外,单点登录功能和令牌认证功能使用这种技术将数据传输到客户端。

  • 信息交换:您可以使用 JWT 令牌标准通过使用提供的密钥对数据进行签名来防止数据被利用,并验证您收到的数据的真实性。

JWT 令牌的解剖结构非常类似于 Web 请求的结构。它由三部分组成:头部载荷签名。头部部分包含有关令牌类型和令牌使用的签名算法的信息:

{   "alg": "HS256",   "typ": "JWT" }

在那种情况下,我们可以推断出该令牌使用的是HMAC SHA256算法,并且它是一个 JWT 令牌类型。载荷部分是我们令牌的核心部分,它包含要发送给用户的信息。默认情况下,有一组预定义的信息用于填充,例如,exp过期时间)字段。以下 JSON 是一个载荷的示例:

{
  "email": "example@handsonaspnetcore.com",
  "nbf": 1546196276,
  "exp": 1546801076,
  "iat": 1546196276
}

邮箱字段是对令牌的声明。nbf代表在...之前无效,而iat代表签发于。这三个字段代表自 UNIX 纪元以来计算的时间。

最后,令牌的签名部分使用在头部指定的密钥和算法对编码的头部和编码的有效负载进行签名。

生成的编码令牌类似于以下内容:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InNhbXVlbGUucmVzY2FAZ21haWwuY29tIiwibmJmIjoxNTQ2MTk2Mjc2LCJleHAiOjE1NDY4MDEwNzYsImlhdCI6MTU0NjE5NjI3Nn0.yQGT1TJYL4U_IqBpoQ6MjUchET06BRE-YJ0sf-MRA

需要强调的是,编码令牌中的每个点分隔符(.)代表一个加密令牌,如前所述。

实现基于令牌的认证

在本节中,我们将了解如何使用 ASP.NET Core 执行基于令牌的认证。更详细地说,我们将深入了解基于令牌的认证的开发和测试,并学习如何使用 ASP.NET Core 的默认身份提供者将用户数据存储在数据库中。

此外,我们将实现身份验证作为目录服务解决方案的一部分。需要注意的是,在实际应用中,身份验证和整个身份过程都有一个专门的服务和独立的数据存储。

为了演示目的,我们将在目录服务中实现认证部分。请考虑将您应用程序的身份验证部分保留在单独的服务中。

让我们先添加我们需要的包来开发基于令牌的认证,这些包在Catalog.DomainCatalog.Infrastructure项目中。请注意,这些包仅与netcoreapp3.1框架兼容,因此您需要将 csproj 中的TargetFramework变量从netstandard2.1更改为netcoreapp3.1

dotnet add Catalog.Domain package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add Catalog.Infrastructure package Microsoft.AspNetCore.Identity.EntityFrameworkCore

下一步是在Catalog.Domain项目中定义User实体:

using Microsoft.AspNetCore.Identity;

namespace Catalog.Domain.Entities
{
    public class User : IdentityUser
    {
        public string Name { get; set; }
    }
}

User实体代表服务领域模型中的通用用户。需要注意的是,它扩展了IdentityUser类,为实体提供了额外的字段。IdentityUser类标识一个可存储的用户实体。此外,该实体可以通过Microsoft.AspNetCore.Identity包存储数据。

让我们继续在Catalog.Domain项目中声明IUserRepository接口:

using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Entities;

namespace Catalog.Domain.Repositories
{
    public interface IUserRepository
    {
        Task<bool> AuthenticateAsync(string email, string password, 
            CancellationToken cancellationToken = default);
        Task<bool> SignUpAsync(User user, string password, 
            CancellationToken cancellationToken = default);
        Task<User> GetByEmailAsync(string requestEmail, 
            CancellationToken cancellationToken = default);
    }
}

此接口代表了目录服务和数据层之间的中介。此外,它可以用于验证、注册和检索User实体。IUserRepository充当用户数据的数据存储,并执行与用户相关的操作,例如注册过程和身份验证。需要注意的是,AuthenticateAsyncSignUpAsync方法返回一个布尔值,表示相应的操作是否成功。

定义服务层

在定义了User实体和IUserRepository接口之后,我们可以在Catalog.Domain项目中继续定义服务层。让我们先描述IUserService接口:

using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Repositories;
using Catalog.Domain.Requests.User;
using Catalog.Domain.Responses;

namespace Catalog.Domain.Services
{
    public interface IUserService
    {
        Task<UserResponse> GetUserAsync(GetUserRequest request, 
            CancellationToken cancellationToken = default);
        Task<UserResponse> SignUpAsync(SignUpRequest request, 
            CancellationToken cancellationToken = default);
        Task<TokenResponse> SignInAsync(SignInRequest request, 
            CancellationToken cancellationToken = default);
    }
}

接口定义了认证阶段所需的必要方法。GetUserAsync 方法使用 GetUserRequest 类型检索与特定用户相关的信息。SignUpAsyncSignInAsync 方法定义了注册和登录过程:注册操作返回一个新的 UserResponse 实例,它确定了注册用户的有关信息,而登录操作返回包含结果的 TokenResponse 实例,该实例将包含客户端将存储的令牌。因此,让我们通过定义服务接口使用的请求 DTO 来继续:

namespace Catalog.Domain.Requests.User
{
    public class GetUserRequest
    {
        public string Email { get; set; }
    }

    public class SignInRequest
    {
        public string Email { get; set; }
        public string Password { get; set; }
    }

    public class SignUpRequest
    {
        public string Email { get; set; }
        public string Password { get; set; }
        public string Name { get; set; }
    }
}

为了简洁起见,请求类以唯一的代码片段表示。GetUserRequest 类型包含一个 Email 字段,指定要检索的电子邮件地址。正如我们将在本章后面看到的那样,控制器上的 GetUser 动作方法需要认证才能检索用户数据。

SignInRequest 类型定义了用于认证用户的 EmailPassword 字段。最后,SignUpRequest 类型也包含用字符串表示的用户 Name。请注意,出于演示目的,代码仅存储用户的 Name。在实际应用中,SignUpRequest 类型的复杂性可能会随着更多个人信息的增加而增加。

让我们继续定义 IUserService 接口使用的响应类型:

namespace Catalog.Domain.Responses
{
    public class TokenResponse
    {
        public string Token { get; set; }
    }

    public class UserResponse
    {
        public string Name { get; set; }
        public string Email { get; set; }
    }
}

UserResponse 类型旨在检索用户的全部个人信息。需要注意的是,出于安全原因,它显然省略了实体的 Password 字段。另一方面,TokenResponse 类型检索包含认证过程产生的 JWT 令牌的 Token 字段。

因此,我们可以继续描述 IUserService 接口的实现:它将包含与 ASP.NET Core 用于认证的令牌生成以及用户实体上的获取和注册操作相关的逻辑。以下代码展示了实现中的依赖关系:

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Repositories;
using Catalog.Domain.Requests.User;
using Catalog.Domain.Responses;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace Catalog.Domain.Services
{
    public class UserService : IUserService
    {
 private readonly AuthenticationSettings 
            _authenticationSettings;
 private readonly IUserRepository _userRepository;

        public UserService(IUserRepository userRepository, 
 IOptions<AuthenticationSettings> authenticationSettings)
        {
            _userRepository = userRepository;
            _authenticationSettings = authenticationSettings.Value;
        }

如前所述,IUserRepository 接口用作查询和操作我们数据源的主要入口点。IOption<AuthenticationSettings> 类型定义了认证过程所需的设置:

 namespace Catalog.Domain.Configurations
{
    public class AuthenticationSettings
    {
        public string Secret { get; set; }
        public int ExpirationDays { get; set; }
    }
}

该类存储在 Catalog.Domain 项目的 Configurations 文件夹中。AuthenticationSettings 类包含 Secret 字段,它描述了一个用于加密令牌信息的短语,以及 ExpirationDays 字段,它提供了省略的令牌在多少天后过期。此外,我们可以继续定义 GetUserAsyncSignUpAsync 方法:

...
public async Task<UserResponse> GetUserAsync(GetUserRequest request, 
    CancellationToken cancellationToken)
{
    var response = await _userRepository.GetByEmailAsync(request.Email, 
        cancellationToken);

    return new UserResponse { Name = response.Name, Email = 
        response.Email };
}

public async Task<UserResponse> SignUpAsync(SignUpRequest request, 
    CancellationToken cancellationToken)
{
    var user = new Entities.User { Email = request.Email, UserName = 
        request.Email, Name = request.Name };

    bool result = await _userRepository.SignUpAsync(user, 
        request.Password, cancellationToken);

    return !result ? null : new UserResponse { Name = request.Name, 
        Email = request.Email };
}
...

GetUserAsync 方法使用高级层提供的请求来执行 IUserRepository 接口的 GetByEmailAsync 方法。它还映射响应并检索一个新的 UserReponse 类型实例。

另一方面,SignUpAsync 方法使用相应的值初始化一个新的 User 实例,并执行 IUserRepository 接口提供的 SignUpAsync 方法。最后,如果用户已创建,SignUpAsync 方法检索一个新的 UserResponse 实例。让我们继续通过定义 SignInAsync 方法来完成 IUserService 的实现:

...
public async Task<TokenResponse> SignInAsync(SignInRequest request,     CancellationToken cancellationToken)
{
    bool response = await _userRepository.
       AuthenticateAsync(request.Email, request.Password,
       cancellationToken);

    return response == false ? null : new TokenResponse { Token = 
        GenerateSecurityToken(request)          k . };

}

private string GenerateSecurityToken(SignInRequest request)
{
    var tokenHandler = new JwtSecurityTokenHandler();
    var key = Encoding.ASCII.GetBytes(_authenticationSettings.Secret);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new[]
        {
            new Claim(ClaimTypes.Email, request.Email)
        }),
        Expires = 
        DateTime.UtcNow.AddDays
        (_authenticationSettings.ExpirationDays),
        SigningCredentials = new SigningCredentials(new 
        SymmetricSecurityKey(key), 
            SecurityAlgorithms.HmacSha256Signature)
    };

    var token = tokenHandler.CreateToken(tokenDescriptor);
    return tokenHandler.WriteToken(token);
}
...

作为第一步,SignInAsync 方法通过提供客户端发送的 EmailPassword,调用由 IUserRepository 提供的底层 AuthenticateAsync 方法。该语句返回一个布尔变量,指示用户是否已认证。如果用户已认证,该方法通过调用 GenerateSecurityToken 方法检索 TokenResponse 类的新实例。

GenerateSecurityToken 方法定义了一个新的 JwtSecurityTokenHandler 类型的实例,该实例通过使用 CreateTokenWriteToken 方法提供了一些生成和创建令牌的实用工具。

此外,它定义了一个新的 SecurityTokenDescriptor 类型的实例,通过在 AuthorizationSettings 实例的 Secret 字段上签名,声明了 Expire 时间和 SigningCredentials 字段。

在控制器上应用认证

接下来的步骤包括在依赖注入引擎中注册依赖项,并在控制器层中使用生成的依赖项,例如 IUserService 实例。因此,本节重点介绍了 Catalog.APICatalog.Infrastructure 项目。

让我们从在 Catalog.Infrastructure 项目中定义一个新的扩展方法开始,该方法添加了认证部分:

using System.Text;
using Catalog.Domain.Configurations;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace Catalog.Infrastructure.Extensions
{
    public static class AuthenticationExtensions
    {
        public static IServiceCollection AddTokenAuthentication(this 
            IServiceCollection services, IConfiguration configuration)
        {
            var settings = configuration.GetSection
                ("AuthenticationSettings");
            var settingsTyped = settings.Get<AuthenticationSettings>();

            services.Configure<AuthenticationSettings>(settings);
            var key = Encoding.ASCII.GetBytes(settingsTyped.Secret);
            services.AddAuthentication(x =>
                {
                    x.DefaultAuthenticateScheme = 
                        JwtBearerDefaults.AuthenticationScheme;
                    x.DefaultChallengeScheme = 
                        JwtBearerDefaults.AuthenticationScheme;
                })
                .AddJwtBearer(x =>
                {
                    x.TokenValidationParameters = new 
                        TokenValidationParameters
                        {
                            IssuerSigningKey = new 
                                SymmetricSecurityKey(key),
                            ValidateIssuer = false,
                            ValidateAudience = false
                        };
                });
            return services;
        }
    }
}

上述代码的核心部分是执行两个方法:AddAuthenticationAddJwtBearer。这两个扩展方法都添加了认证过程中使用的中间件和服务。更详细地说,AddAuthentication 通过应用 JWT 携带者认证方案来指定 DefaultAuthenticationSchemeDefaultChallengeScheme

同时,AddJwtBearer 方法定义了与令牌认证相关的选项,例如 TokenValidationParameters 字段,它包括用于验证令牌参数的 SigningKey

此外,IssuerSigningKey 必须与生成令牌时使用的密钥相同。否则,验证将失败。重要的是要注意,ValidateIssuerValidateAudience 字段设置为 false。因此,ASP.NET Core 不会验证发行者或受众 URL。尽管这种方法在测试环境中运行良好,但我强烈建议在生产环境中使用以下设置:

.AddJwtBearer(x =>
{
    x.TokenValidationParameters = new TokenValidationParameters
    {
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidateIssuer = true,
 ValidateAudience = true,
 ValidIssuer = "yourhostname",
 ValidAudience = "yourhostname"
    };
});

在这种情况下,将验证发行者和受众;因此,它将检查令牌的发行者和受众是否与配置中指定的相匹配。AddTokenAuthentication 扩展方法还拥有 UserService 类使用的 AuthenticationSettings 的注册。因此,让我们看看在 appsettings.json 文件中定义的 AuthenticationSettings 值:

...
"AuthenticationSettings": {
    "Secret": "My Super long secret",
    "ExpirationDays": "7"
}
...

然后,我们可以通过向 Catalog.API 项目的 Startup 类中添加身份验证实现来继续操作:

namespace Catalog.API
{
    public class Startup
    {
        public Startup(IConfiguration configuration, 
        IWebHostingEnvironment environment)
        {
            Configuration = configuration;
            CurrentEnvironment = environment;
        }

        ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...

            services
                 .AddTokenAuthentication(Configuration)

            ...
        }

        public void Configure(IApplicationBuilder app, 
        IHostingEnvironment env)
        {
            ...
          app.UseAuthentication();
            app.UseAuthorization(); app.UseEndpoints(endpoints =>     {     endpoints.MapControllers();});
        }
    }
}

Startup 类是初始化身份验证过程的核心理念。在 ConfigureServices 方法中,它通过从 appsettings.json 文件中读取来配置和初始化 AuthorizationSettings 类。接下来,它通过传递 AuthorizationSettings 类型实例调用 AddAuthentication 扩展方法。还必须注意的是,Configure 方法通过调用 UseAuthentication 方法添加身份验证中间件。

最后,我们可以通过添加 UserController 并公开身份验证路由来继续操作:

using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Catalog.API.Filters;
using Catalog.Domain.Requests.User;
using Catalog.Domain.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Catalog.API.Controllers
{
    [Authorize]
    [ApiController]
    [Route("api/user")]
    [JsonException]
    public class UserController : ControllerBase
    {
        private readonly IUserService _userService;

        public UserController(IUserService userService)
        {
            _userService = userService;
        }

        [HttpGet]
        public async Task<IActionResult> Get()
        {
            var claim = HttpContext.User.Claims.FirstOrDefault(x => 
                x.Type == ClaimTypes.Email);

            if (claim == null) return Unauthorized();

            var token = await _userService.GetUserAsync(new 
                GetUserRequest { Email = claim.Value });
            return Ok(token);
        }

        [AllowAnonymous]
        [HttpPost("auth")]
        public async Task<IActionResult> SignIn(SignInRequest request)
        {
            var token = await _userService.SignInAsync(request);

            if (token == null) return BadRequest();

            return Ok(token);
        }

        [AllowAnonymous]
        [HttpPost]
        public async Task<IActionResult> SignUp(SignUpRequest request)
        {
            var user = await _userService.SignUpAsync(request);
            if (user == null) return BadRequest();
            return CreatedAtAction(nameof(Get), new { }, null);
        }
    }
}

上述代码定义了 UserController 类,该类公开身份验证路由。重要的是要注意,整个控制器都使用 [Authorize] 属性进行装饰,这意味着每个路由都受到身份验证的保护。因此,要访问控制器中声明的路由,必须在请求中使用有效的令牌。该类为服务层中定义的每个操作定义了一个动作方法:

  • Get 动作方法暴露了有关当前用户的一些细节,例如 Email 字段和 Name 字段。动作方法从传入的令牌中获取用户详情。令牌信息通过访问 HttpContext.User 属性并获取 ClaimType.Email 的值来表示。

  • SignIn 动作方法也使用 [AllowAnonymous] 属性进行装饰。此外,还可以在不进行身份验证的情况下调用动作方法。动作方法将 request.Emailrequest.Password 字段绑定,并使用 IUserService 接口发送请求对象。动作方法返回带有生成令牌的 TokenResponse

  • SignUp 动作方法也使用 [AllowAnonymous] 属性进行装饰。在这种情况下,动作方法注册一个新用户,如果操作成功,则返回 201 Created HTTP 状态码。

我们现在的设置几乎完成了。我们需要做的是定义 IUserRepository 接口和底层数据存储之间的最后一个共同点。为此,我们将再次使用 EF Core 框架和由 Microsoft 维护的 Microsoft.AspNetCore.Identity.EntityFrameworkCore 包。

使用 EF Core 存储数据

让我们继续实现数据访问层,并创建 IUserRepository 接口的具体实现。UserRepository 类将有两个主要依赖项,即 SignInManagerUserManager 类,这两个类都由 Microsoft.AspNetCore.Identity 包提供:

using System.Threading;
using System.Threading.Tasks;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

namespace Catalog.Infrastructure.Repositories
{
    public class UserRepository : IUserRepository
    {
        private readonly SignInManager<User> _signInManager;
        private readonly UserManager<User> _userManager;

        public UserRepository(UserManager<User> userManager, 
            SignInManager<User> signInManager)
        {
            _userManager = userManager;
            _signInManager = signInManager;
        }

        public async Task<bool> AuthenticateAsync(string email, 
            string password, CancellationToken cancellationToken)
        {
            var result = await _signInManager.PasswordSignInAsync(
                email, password, false, false);
            return result.Succeeded;
        }

        public async Task<bool> SignUpAsync(User user, string password, 
            CancellationToken cancellationToken)
        {
            var result = await _userManager.CreateAsync(user, 
                password);
            return result.Succeeded;
        }

        public async Task<User> GetByEmailAsync(string requestEmail, 
            CancellationToken cancellationToken)
        {
            return await _userManager
                .Users
                .FirstOrDefaultAsync(u => u.Email == requestEmail, 
                    cancellationToken);
        }
    }
}

如您所见,生成的代码实现了 IUserRepository 接口。该类依赖于 SignInManager<User>UserManager<User> 类型。这些类型接受一个泛型实体类,该类是认证对象的表示。SignInManager<T> 泛型类提供了与用户登录过程交互的功能。

它公开了 PasswordSignInAsync 方法,该方法由 UserRepository.Authenticate 方法使用。另一方面,UserManager<T> 类提供了与持久存储中用户交互的方法。此外,UserRepository 使用 SignUpUserRepository.GetByEmail 方法与数据库交互。

声明身份数据库上下文

一旦我们声明了 IUserRepository 的实现,我们就可以继续声明 身份数据上下文身份数据上下文 通过扩展 IdentityDbContext 类来识别。这种类型的 DbContext 由 EF Core 用于定位和访问用作持久用户存储的数据源。为了声明 身份数据上下文,有必要以下列方式扩展 CatalogContext

using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;
using Catalog.SchemaDefinitions;

namespace Catalog.Infrastructure
{
    public class CatalogContext : IdentityDbContext<User>, IUnitOfWork
    {
          ...

        protected override void OnModelCreating(ModelBuilder 
            modelBuilder)
        {
            modelBuilder.ApplyConfiguration(new 
                ItemEntitySchemaDefinition());
            modelBuilder.ApplyConfiguration(new 
                GenreEntitySchemaConfiguration());
            modelBuilder.ApplyConfiguration(new 
                ArtistEntitySchemaConfiguration());

            base.OnModelCreating(modelBuilder);
        }
    }
}

重要的是要注意,IdentityDbContext 类扩展了 DbContext 类。此外,DbContext 类中存在的每个属性和行为也被 IdentityDbContext 类继承。因此,必须注意,重写方法 OnModelCreating 也必须调用基方法。

为了提供使用 EF Core 存储用户信息的方法,还必须通过调用 AddIdentity 扩展方法添加和配置指定 User 类型的身份系统。此外,还必须调用 AddEntityFrameworkStores 并引用 CatalogContext 类以添加实体框架实现。以下代码是之前创建的 AddAuthentication 扩展方法:

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Catalog.Domain;
using Catalog.Domain.Entities;

namespace Catalog.Infrastructure.Extensions
{
    public static class AuthenticationExtensions
    {
        public static IServiceCollection AddTokenAuthentication(this 
        IServiceCollection services, AuthenticationSettings settings)
        {
            var key = Encoding.ASCII.GetBytes(settings.Secret);

            services.AddIdentity<User, IdentityRole>()
 .AddEntityFrameworkStores<CatalogContext>();

            ...
            return services;
        }
    }
}

最后,我们可以通过初始化 IUserRepository 的具体实现来继续操作。在这种情况下,我们将在 Startup 类中声明依赖注入解析:

        public void ConfigureServices(IServiceCollection services)
        {
            ...
            services.AddScoped<IUserRepository, UserRepository>();            

同样,我们可以通过向 AddServices 扩展方法添加以下行来注册 IUserService

public static IServiceCollection AddServices(this IServiceCollection services)
{
    services
          ...
        .AddScoped<IUserService, UserService>();

    return services;
}

总结来说,现在我们已经部署了整个认证栈。Catalog.API 项目通过 UserController 类公开了 HTTP 路由。该控制器依赖于 IUserService 接口,该接口公开了认证过程所需的操作。

因此,UserService 类依赖于 IUserRepository 接口,这是调用 EF Core 框架公开的 API 的主要入口点。因此,我们现在可以通过一些测试来验证认证逻辑。

测试认证

在这个阶段测试我们的代码是至关重要的:我们应该在将应用程序运行在服务器实例之前检查、记录并验证我们系统的行为。此外,测试认证行为也非常关键,因为它是我们服务的一个敏感部分。

由于 UserRepository 实现是认证堆栈中最底层的部分,并且是第一个依赖于 EF Core 来检索、更新和验证用户的组件,我们可以将其保持隔离,并通过模拟 IUserRepository 接口来排除它从测试过程中。

SignInManager<T>UserManager<T> 类代表了我们认证过程的核心部分,它们是微软维护的第三方包的一部分。此外,没有必要用测试来覆盖它们的实现。

让我们从在 tests 文件夹中的 Catalog.Fixture 项目中定义一个新的 UserContextFactory 开始:

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Catalog.Domain.Entities;
using Catalog.Domain.Repositories;
using Microsoft.AspNetCore.Identity;
using Moq;

namespace Catalog.Fixtures
{
    public class UsersContextFactory
    {
        private readonly PasswordHasher<User> _passwordHasher;
        private readonly IList<User> _users;

        public UsersContextFactory()
        {
            _passwordHasher = new PasswordHasher<User>();

            _users = new List<User>();

            var user = new User
            {
                Id = "test_id",
                Email = "samuele.resca@example.com",
                Name = "Samuele Resca"
            };
            user.PasswordHash = _passwordHasher.HashPassword(user, 
            "P@$$w0rd");

            _users.Add(user);
        }

        public IUserRepository InMemoryUserManager => 
        GetInMemoryUserManager();

        private IUserRepository GetInMemoryUserManager()
 {
 ...
 }
    }
}

工厂类公开了一个带有一些预填充数据的 IUserRepository 实例,并且它依赖于 PasswordHasher<T> 泛型类型,该类型在 SignUpAuthenticate 模拟方法声明中用于通过 HashPasswordVerifyHashedPassword 方法来 编码-解码 密码。

重要的是要注意,IUserRepository 接口是通过使用 GetInMemoryUserManager 方法模拟的。此外,它使用 List<User> 来模拟数据源,并使用 Moq 库实现了 IUserRepository 接口暴露的 AuthenticateAsyncGetByEmailAsyncSignUpAsync 方法:

namespace Catalog.Fixtures
{
    public class UsersContextFactory
    {
    ...
        private IUserRepository GetInMemoryUserManager()
        {
            var fakeUserService = new Mock<IUserRepository>();

            fakeUserService.Setup(x => 
            x.AuthenticateAsync(It.IsAny<string>(), 
            It.IsAny<string>(), CancellationToken.None))
                .ReturnsAsync((string email, string password, 
                CancellationToken token) =>
                {
                    var user = _users.FirstOrDefault(x => 
                    x.Email == email);

                    if (user == null) return false;

                    var result = _passwordHasher.
                        VerifyHashedPassword(user,  
                    user.PasswordHash, password);
                    return result == PasswordVerificationResult.
                        Success;
                });
            fakeUserService.Setup(x => 
            x.GetByEmailAsync(It.IsAny<string>(), 
                CancellationToken.None))
                .ReturnsAsync((string email, CancellationToken token) 
            => 
                 _users.First(x => x.Email == email));
            fakeUserService.Setup(x => x.SignUpAsync(It.IsAny<User>(), 
            It.IsAny<string>(), CancellationToken.None))
                .ReturnsAsync((User user, string password,  
                CancellationToken token) =>
                {
                    user.PasswordHash = 
                        _passwordHasher.HashPassword(user, 
                    password);
                    _users.Add(user);
                    return true;
                });
            return fakeUserService.Object;
        }
    }
}

之前的代码通过为接口的方法提供模拟行为来返回一个模拟的 IUserRepository 实例。因此,我们可以通过实现以下测试类来验证 IUserService 类:

using System.Threading.Tasks;
...
namespace Catalog.Domain.Tests.Services
{
    public class UserServiceTests : IClassFixture<UsersContextFactory>
    {
        private readonly IUserService _userService;

        public UserServiceTests(UsersContextFactory 
            usersContextFactory)
        {
            _userService = new UserService(usersContextFactory.
             InMemoryUserManager, Options.Create( 
             new AuthenticationSettings { Secret = 
             "Very Secret key-word to match", ExpirationDays = 7 }));
        }
        [Fact]
        public async Task 
        signin_with_invalid_user_should_return_a_valid_token_response()
        {
            var result =
                await _userService.SignInAsync(new SignInRequest { 
                Email = "invalid.user", Password = "invalid_password" });
            result.ShouldBeNull();
        }
        [Fact]
        public async Task 
        signin_with_valid_user_should_return_a_valid_token_response()
        {
            var result =
                await _userService.SignInAsync(new SignInRequest { 
                Email = "samuele.resca@example.com",
                Password = "P@$$w0rd" });
            result.Token.ShouldNotBeEmpty();
        }
        ...
    }
}

测试类实现了两个不同的测试:signin_with_invalid_user_should_return_a_valid_token_responsesignin_with_valid_user_should_return_a_valid_token_response。在两种情况下,测试都将使用 UserContextFactory 来解决类的依赖。我们还将使用 ASP.NET Core 提供的 Option.Create 方法来生成 AuthenticationSettings 选项。在这种情况下,我们正在测试在处理层中实现的整体堆栈。

重要的是要注意,我们正在排除与用户信息管理和存储相关的整个底层部分。我们可以通过包括控制器部分来扩展测试范围。更详细地说,我们可以实现测试来检查UserController类中实现的功能。为此,我们将在TStartup时间注入一个假的IUserRepository实现,使用services.Replace指令:

using System;
...

namespace Catalog.Fixtures
{
    public class InMemoryApplicationFactory<TStartup>
        : WebApplicationFactory<TStartup> where TStartup : class
    {
        protected override void ConfigureWebHost(IWebHostBuilder 
            builder)
        {
            builder
                .UseEnvironment("Testing")
                .ConfigureTestServices(services =>
                {
                    ...
                    services.Replace(ServiceDescriptor.Scoped(_ => new       
                    UsersContextFactory().InMemoryUserManager));

                    var sp = services.BuildServiceProvider();

                    using var scope = sp.CreateScope();
                    var scopedServices = scope.ServiceProvider;
                    var db = scopedServices.GetRequiredService
                    <CatalogContext>();
                    db.Database.EnsureCreated();
                });
        }
    }
}

我们可以通过初始化UsersContextFactory类来对InMemoryApplicationFactory<TStartup>类进行操作,用模拟类的新的实例替换IUserService。之后,将能够通过解析InMemoryApplicationFactory<TStartup>工厂类来测试UserController类的操作:

using System.Net;
...
namespace Catalog.API.Tests.Controllers
{
    public class UserControllerTests : 
 IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        private readonly InMemoryApplicationFactory<Startup> _factory;

        public UserControllerTests(InMemoryApplicationFactory<Startup> 
        factory)

        {
            _factory = factory;
        }

        [Theory]
        [InlineData("/api/user/auth")]
        public async Task sign_in_should_retrieve_a_token(string url)
        {
            var client = _factory.CreateClient();
            var request = new SignInRequest { Email = 
            "samuele.resca@example.com", Password = "P@$$w0rd" };
            var httpContent =
                new StringContent(JsonConvert.SerializeObject(request), 
                Encoding.UTF8, "application/json");

            var response = await client.PostAsync(url, httpContent);
            string responseContent = await 
            response.Content.ReadAsStringAsync();

            response.EnsureSuccessStatusCode();
            response.StatusCode.ShouldBe(HttpStatusCode.OK);
            responseContent.ShouldNotBeEmpty();
        }

        [Theory]
        [InlineData("/api/user/auth")]
        public async 
        Task sign_in_should_retrieve_bad_request_with_invalid_password
        (string url)
        {
            var client = _factory.CreateClient();
            var request = new SignInRequest { Email = 
            "samuele.resca@example.com", Password = "NotValidPWD" };
            var httpContent =
                new StringContent(JsonConvert.SerializeObject(request), 
                Encoding.UTF8, "application/json");

            var response = await client.PostAsync(url, httpContent);
            string responseContent = await 
            response.Content.ReadAsStringAsync();
            response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
            responseContent.ShouldNotBeEmpty();
        }
            ...
        }
    }
}

之前的代码验证了UserController类中定义的路由,并且还通过检查认证过程执行了集成测试。sign_in_should_retrieve_a_token测试方法使用 HTTP POST动词调用/api/user/auth地址来验证登录过程的实现。

此外,它还验证了在用户密码错误时的操作。此外,我们还可以提供更多测试来验证从登录阶段到检索已认证用户数据的整个认证过程:

...

[Theory]
[InlineData("/api/user")]
public async Task get_with_authorized_user_should_retrieve_the_right_user(string url)
{
    var client = _factory.CreateClient();

    var signInRequest = new SignInRequest { Email = 
    "samuele.resca@example.com", Password = "P@$$w0rd" };
    var httpContent = new StringContent(JsonConvert.SerializeObject
    (signInRequest), Encoding.UTF8, "application/json");

    var response = await client.PostAsync(url + "/auth", httpContent);
    string responseContent = await 
        response.Content.ReadAsStringAsync();

    response.EnsureSuccessStatusCode();

    var tokenResponse = JsonConvert.DeserializeObject<TokenResponse>
    (responseContent);

    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", tokenResponse.Token);

    var restrictedResponse = await client.GetAsync(url);

    restrictedResponse.EnsureSuccessStatusCode();
    restrictedResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
}
...

更详细地说,get_with_authorized_user_should_retrieve_the_right_user测试执行以下操作:

  1. 它向以下路由/auth执行POST请求以认证用户。

  2. 它反序列化POST请求的结果,并获取令牌字段。

  3. 它通过传递令牌添加一个认证头,并对/api/user路由执行请求。

  4. 它检查结果状态码是否为 HTTP 200 OK

以这种方式,我们正在测试UsersController类以及每个操作方法中使用的底层处理程序。通过运行docker-compose up --build命令来运行目录网络服务也可以测试认证过程。首先,我们需要通过添加一些必要的信息,如用户的电子邮件和姓名来创建一个新用户:

curl -X POST \
  https://localhost:5001/api/users \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "newuser@example.com",
    "password": "<my_secret_password>",
    "name": "Your name"
}'

之前的 HTTP 调用(以curl调用形式编写)使用指定的凭据创建了一个新用户。我们可以通过使用我们的凭据生成令牌来继续操作:

curl -X POST \
  https://localhost:5001/api/users/auth \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "newuser@example.com",
    "password": "<my_secret_password>"
}'

最后,我们可以通过以下调用继续调用秘密端点:

curl -X GET https://localhost:5001/api/users -H 'Authorization: Bearer <my_token>'

上述curl命令通过在请求的Authorization头中传递令牌来调用https://localhost:5001/api/users/地址。

摘要

在本章中,我们学习了如何使用一些标准实践来保护一个网络服务。HTTPS 现在是一个标准和必须具备的功能,如果你想要保护数据。此外,我们还看到了基于令牌的身份验证如何提供一种保护暴露的数据和信息的有用方式。

本章涵盖的主题提供了一种确保由网络服务暴露的信息的安全的方法,并探讨了在分布式系统中基于令牌的认证实现。

在下一章中,我们将看到如何在 ASP.NET Core 中缓存响应,并了解缓存机制的一般工作原理。

第四部分:构建服务的高级概念

在本节中,我们将解释一些关于网络服务的高级主题。您将了解缓存、监控和日志记录;在 Azure 上部署;使用 Swagger 文档 API;以及使用 Postman 等外部工具。

本节包括以下章节:

  • 第十六章,缓存网络服务响应

  • 第十七章,日志记录和健康检查

  • 第十八章,在 Azure 上部署服务

  • 第十九章,使用 Swagger 文档您的 API

  • 第二十章,使用 Postman 测试服务

第十六章:缓存网络服务响应

在本章中,我们将探讨 ASP.NET Core 的缓存模式,以及框架提供的缓存策略和工具,以帮助开发者实现它们。缓存可能有助于避免在服务器上进行额外计算,并因此为网络服务的客户端检索最快的响应。此外,我们还将查看目录网络服务的具体缓存实现。

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

  • HTTP 缓存系统的介绍

  • 在 ASP.NET Core 中实现响应缓存

  • 实现分布式缓存

到本章结束时,你将了解缓存机制,以及使用Redis在 ASP.NET Core 中实现分布式缓存所需的知识。

HTTP 缓存系统的介绍

缓存是网络服务开发的一个关键部分。网络服务缓存的主要目的是提高我们系统的性能并减少服务器的负载。此外,通过网络获取数据速度慢且成本高,因此有必要实现一个缓存系统来提高我们网络服务的响应速度,并避免不必要的额外计算。在本节中,我们将关注 HTTP 1.1 缓存规范中定义的一些特性。

这些缓存规范由网络服务器发送到客户端。任何拥有客户端的人都需要阅读缓存规范并做出相应的响应。通常,缓存响应有两个常见的用例:第一个是当网络服务公开非常动态内容时。在这种情况下,数据变化很大,缓存时间可以减少或完全避免。第二种情况是我们可能提供一些静态内容。在这种情况下,我们可以为不经常变化的内容设置较长的缓存时间。

HTTP 缓存规范

HTTP 1.1 缓存规范(tools.ietf.org/html/rfc7234)描述了缓存如何在 HTTP 中表现。与 HTTP 缓存相关的主要头是Cache-Control头。此头用于指定请求和响应中的指令。还必须注意,请求和响应中定义的Cache-Control指令是独立的。以下图显示了典型的请求-响应工作流程:

图片

该模式描述了通用客户端与它们之间带有缓存层的服务器之间的交互。

首先,客户端从服务器请求一个资源,缓存层将其转发到服务器。服务器为客户端生成数据并发送响应;在这个时候,缓存服务器在缓存层中缓存了响应。因此,如果客户端再次调用相同的内容,请求将不会击中服务器,但缓存系统将提供缓存的响应。

必须注意,缓存层会在响应中添加一些头:

Cache-Control: max-age=100
Age:0

这两个都是与缓存指令相关的 HTTP 头。Cache-Control添加了max-age指令来指示内容的 freshness lifetime 等于 100。Age头指定了缓存内容的年龄。当Age达到Cache-Control: max-age值时,缓存层将请求转发到服务器以提供新鲜数据。这两个头的值都是以为单位的。

Cache-Control头可以用来指定缓存机制。默认情况下,可以通过指定no-cache指令来禁用缓存,即Cache-Control: no-cacheCache-Control头的另一个关键方面是公共和私有指令,例如Cache-Control: public,max-age=100public指令意味着缓存的响应也可以存储在共享缓存中,并且任何其他客户端都可以访问该信息。另一方面,当一个响应是私有的,这意味着它可能包含敏感信息,并且不能与其他客户端共享。

缓存规范还定义了Vary头。这种头用于指示哪些字段影响缓存变化。更具体地说,它用于决定是否可以使用缓存的响应而不是从原始服务器请求新鲜的一个:

Vary: *
Vary: User-Agent

在前述代码的第一行中,每个请求的变化都被视为一个单独且不可缓存的请求。在第二行中,请求被处理为不可缓存,但添加了User-Agent头。

Expires头与max-age指令具有相同的目的:提供缓存过期时间。它们之间唯一的不同之处在于max-age指令关注的是固定的日期时间。例如,我们可以设置以下值:

Expires: Wed, 21 Oct 2015 07:28:00 GMT

必须注意,max-age指令覆盖了Expires头。因此,如果它们在同一个响应中都存在,则忽略Expires头。在下一节中,我们将学习如何使用 ASP.NET Core 工具实现响应缓存。

实现 ASP.NET Core 中的响应缓存

ASP.NET Core 通过声明性方式管理我们 Web 服务的响应中的缓存指令。此外,它提供了一个可用于缓存目的的属性。属性实现也与 HTTP 1.1 缓存规范兼容,因此,使用 ASP.NET Core 的即用型实现来实现这些缓存标准变得很容易,我们不必担心每个请求的细节。我们可以使用 ASP.NET Core 公开的[ResponseCache]属性来指定缓存行为:

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    [Authorize]
    public class ItemController : ControllerBase
    {
        private readonly IItemService _itemService;
        private readonly IEndpointInstance _messageEndpoint;

        public ItemController(IItemService itemService, 
        IEndpointInstance messageEndpoint)
        {
            _itemService = itemService;
            _messageEndpoint = messageEndpoint;
        }

        ...

        [HttpGet("{id:guid}")]
        [ItemExists]
 [ResponseCache(Duration = 100, VaryByQueryKeys = new []{"*"})]
        public async Task<IActionResult> GetById(Guid id)
        {
            var result = await _itemService.GetItemAsync(new 
                GetItemRequest { Id = id });
            return Ok(result);
        }
        ...
    }
}

例如,在这种情况下,代码在ItemController类的GetById操作方法上定义了一个缓存指令。ResponseCache属性设置了一个Duration字段和一个VaryByQueryKeys字段:第一个对应于max-age指令,而第二个反映了Vary HTTP 头。

到目前为止,我们只向服务器响应中添加了Cache-Control指令。因此,我们并没有实现任何缓存。缓存指令可以被第三方系统或应用程序使用,例如我们服务的客户端,以缓存信息。

除了ResponseCache属性之外,还需要在 Web 服务前放置缓存中间件。ResponseCachingMiddleware是 ASP.NET Core 提供的默认中间件。它符合 HTTP 1.1 缓存规范(tools.ietf.org/html/rfc7234)。因此,如果我们考虑ResponseCachingMiddleware类型,可以按以下方式更改之前的架构:

可以使用Microsoft.Extensions.DependencyInjection包提供的AddResponseCaching扩展方法初始化ResponseCachingMiddleware类。此外,我们可以在Startup类的Configure方法中执行UseResponseCaching扩展方法,将ResponseCachingMiddleware中间件添加到中间件管道中。ResponseCachingMiddleware类型检查响应是否可缓存,并存储响应并从缓存中提供答案。我们可以通过编辑Catalog.API项目中的Startup类将ResponseCachingMiddleware添加到服务管道中:

public class Startup
    {
         ...
        public void ConfigureServices(IServiceCollection services)
        {
            services
                  ...
               .AddResponseCaching();
        }

        public void Configure(IApplicationBuilder app, 
        IHostingEnvironment env)
        {     
             ...
             app.UseHsts();
             app.UseMiddleware<ResponseTimeMiddlewareAsync>();
             app.UseHttpsRedirection();
             app.UseAuthentication();
             app.UseResponseCaching();
             app.UseEndpoints(endpoints =>
                    {
                        endpoints.MapControllers();
                    });
        }
  }

上述代码添加了缓存中间件,但仅此不足以初始化缓存机制。因此,如果我们尝试使用curl命令调用带有ResponseCache属性的路线,我们将收到以下响应:

curl --verbose -X GET http://localhost:5000/api/items/08164f57-1e80-4d2a-739a-08d6731ac140
Note: Unnecessary use of -X or --request, GET is already inferred.
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 5000 (#0)
> GET /api/items/08164f57-1e80-4d2a-739a-08d6731ac140 HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Sat, 05 Jan 2019 16:55:21 GMT
< Content-Type: application/json; charset=utf-8
< Server: Kestrel
< Cache-Control: public,max-age=100
< Transfer-Encoding: chunked
< X-Response-Time-ms: 21
< 
* Connection #0 to host localhost left intact

如您所见,Cache-Control告诉我们这些信息可以在缓存中共享,并且max-age为 100 秒。因此,如果我们调用相同的路线在N(N<100)秒后,我们也可以看到包含对象在缓存中存在时间的Age头。

此外,如果我们调用相同的路由后 N 秒(其中 N >= 100),我们将访问服务器并在内存中缓存一个新的响应。还必须注意的是,我们可以通过在调用 URL 中附加查询字符串参数来取消缓存中间件。这是因为我们指定了以下字段使用 Vary 头:

 VaryByQueryKeys = new []{"*"}

可能的情况是,定义 ResponseCache 属性时使用了 VaryByQueryKeys 字段。在这种情况下,该属性将无法检测查询字符串的变化。此外,受 ResponseCache 属性覆盖的路线将检索以下异常:

"ClassName":"System.InvalidOperationException","Message":"'VaryByQueryKeys' requires the response cache middleware."

关于 ResponseCachingMiddleware 类的一个重要注意事项是它使用 IMemoryCache 接口来存储响应内容。因此,如果您在 GitHub 上检查该类的定义(github.com/aspnet/AspNetCore/.../ResponseCachingMiddleware.cs),您将看到以下构造函数:

...
       public ResponseCachingMiddleware(
            RequestDelegate next,
            IOptions<ResponseCachingOptions> options,
            ILoggerFactory loggerFactory,
            ObjectPoolProvider poolProvider)
            : this(
                next,
                options,
                loggerFactory,
                new ResponseCachingPolicyProvider(),
                new MemoryResponseCache(new MemoryCache(new 
                    MemoryCacheOptions
                {
                    SizeLimit = options.Value.SizeLimit
                })),
                new ResponseCachingKeyProvider(poolProvider, options))
        { }

    internal ResponseCachingMiddleware(
            RequestDelegate next,
            IOptions<ResponseCachingOptions> options,
            ILoggerFactory loggerFactory,
            IResponseCachingPolicyProvider policyProvider,
            IResponseCache cache,
            IResponseCachingKeyProvider keyProvider)
        {
....

前面的方法签名定义了 ResposeCachingMiddleware 类的构造函数。构造函数使用 this 关键字引用另一个内部构造函数重载,该重载用于初始化类的属性。如您所见,IResponseCache 接口默认使用 MemoryCache 类型初始化,该类型扩展了 IMemoryCache 接口。

IMemoryCache 接口表示存储在 web 服务器中的缓存。此外,您还可以通过向 Startup 类中服务的初始化添加 AddMemoryCache() 扩展方法,将 IMemoryCache 接口用作独立组件:

        public void ConfigureServices(IServiceCollection services)
        {
            services
                  ...
               .AddMemoryCache();
        }

这种方法允许您通过依赖注入引擎使用 IMemoryCache 接口,这意味着您可以通过调用 GetOrCreateGetOrCreateAsync 方法在 web 服务器的内存中设置缓存值。尽管内存缓存为我们提供了很好的抽象来缓存数据,但它不适用于分布式方法。因此,如果您想在不同的 web 服务器之间存储和共享缓存,ASP.NET Core 提供了您实现分布式缓存所需的工具。在下一节中,我们将了解如何在 ASP.NET Core 中实现分布式缓存。

实现分布式缓存

正如我们之前提到的,ASP.NET Core 允许我们实现分布式缓存。在本节中,我们将学习如何将 Redis 作为缓存存储的形式使用。您可以通过在 Catalog.API 项目的初始化中添加并执行以下 CLI 指令来扩展 ASP.NET Core 的缓存机制:

 dotnet add package Microsoft.Extensions.Caching.Redis

通过这样做,我们可以连接 Redis 服务器,并通过向 Startup 类添加以下扩展方法来使用它:

public class Startup
    {
        ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...
            services
               .AddDistributedRedisCache(
                       options => { options.Configuration = 
                       "catalog_cache:6380";
                });
               ...
        }
    }
}

微软提供了AddDistributedRedisCache扩展方法,该方法接受用于定义Redis实例的选项,以便它可以作为缓存输入使用。AddDistributedRedisCache扩展方法连接到catalog_cacheRedis实例。此外,我们还需要在目录服务的docker-compose.yml文件中声明新的实例。

    ...
    catalog_cache:
        container_name: catalog_cache
 image: redis:alpine
 networks:
            - my_network

networks:
    my_network:
        driver: bridge

前面的代码定义了目录缓存的Redis实例。因此,应用程序将能够将此实例作为my_network的一部分使用。此外,我们可以通过查看AddDistributedRedisCache扩展方法的源代码来了解该包的主要用途:

public static class RedisCacheServiceCollectionExtensions
{
  public static IServiceCollection AddDistributedRedisCache(
    this IServiceCollection services,
    Action<RedisCacheOptions> setupAction)
  {
    if (services == null)
      throw new ArgumentNullException(nameof (services));
    if (setupAction == null)
      throw new ArgumentNullException(nameof (setupAction));
    services.AddOptions();
    services.Configure<RedisCacheOptions>(setupAction);
    services.Add(ServiceDescriptor.Singleton<IDistributedCache, 
        RedisCache>());
    return services;
  }
}

为了简洁起见,我省略了一些文档注释,但您可以在以下 GitHub 链接中找到开源代码:github.com/aspnet/Extensions/../StackExchangeRedisCacheServiceCollectionExtensions.cs

从前面的代码中可以看出,扩展方法配置并绑定RedisCacheOptions类。其次,前面的代码片段向 ASP.NET Core 的内置依赖注入中添加了一个新的IDistributedCache接口。这个接口为我们提供了一些与Redis实例交互和存储缓存信息的有用方式。此外,实例被定义为单例,这意味着应用程序中的所有组件都将使用相同的实例。

让我们看看IDistributedCache接口:

  public interface IDistributedCache
  {

    byte[] Get(string key);

    Task<byte[]> GetAsync(string key, CancellationToken token = 
    default (CancellationToken));

    void Set(string key, byte[] value, DistributedCacheEntryOptions 
    options);

    Task SetAsync(
      string key,
      byte[] value,
      DistributedCacheEntryOptions options,
      CancellationToken token = default (CancellationToken));

    void Refresh(string key);
    Task RefreshAsync(string key, CancellationToken token = 
    default (CancellationToken));
    Task RemoveAsync(string key, CancellationToken token = 
    default (CancellationToken));
  }

在这种情况下,为了简洁起见,我省略了文档注释。您可以在github.com/aspnet/Extensions/../IDistributedCache.cs找到完整的版本。该接口提供了一个实用方法,我们可以使用它将信息读入和写入Redis实例。

此外,该接口公开了同步和异步方法来完成这项工作。由于该接口是依赖注入引擎的一部分,我们可以在任何组件中使用它,例如ItemController类:

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    [JsonException]
    [Authorize]
    public class ItemController : ControllerBase
    {
        private readonly IItemService _itemService;
        private readonly IEndpointInstance _messageEndpoint;
        private readonly IDistributedCache _distributedCache;

        public ItemController(IItemService itemService, 
        IEndpointInstance messageEndpoint, 
 IDistributedCache distributedCache)
        {
            _itemService = itemService;
            _messageEndpoint = messageEndpoint;
            _distributedCache = distributedCache;
        }

        ...
...

现在,ItemController类和应用程序中的任何其他组件都可以通过将其包含在构造函数和操作注入中,通过包含IDistributedCache单例实例来解析和使用。

ASP.NET Core 还提供了AddDistributedMemoryCache()方法,它是Microsoft.Extensions.DependencyInjection命名空间的一部分。尽管其名称如此,但它并不会初始化任何分布式缓存。让我们深入了解一下它的实现:

public static IServiceCollection AddDistributedMemoryCache(
  this IServiceCollection services)
{
  if (services == null)
    throw new ArgumentNullException(nameof (services));
  services.AddOptions();
  services.TryAdd(ServiceDescriptor.Singleton<IDistributedCache, MemoryDistributedCache>());
  return services;
}

扩展方法使用网络服务器的内存来存储信息。更具体地说,AddDistributedMemoryCache扩展方法是为开发/测试环境设计的,它不是一个真正的分布式缓存。

最后,我们可以通过创建一个专门用于缓存的新的配置类来优化和重构 Startup 类。首先,让我们在目录的域项目的 Configurations 文件夹中创建以下类:

namespace Catalog.Domain.Configurations
{
    public class CacheSettings
    {
        public string ConnectionString { get; set; }
    }
}

类定义包含 Redis 实例的 ConnectionString 字段。我们可以通过将 DistributedCacheExtensions 类添加到 Catalog.Infrastructure 项目中继续操作,该项目位于 Extensions 文件夹内:

using Catalog.Domain.Configurations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
namespace Catalog.Infrastructure.Extensions
{
    public static class DistributedCacheExtensions
    {
        public static IServiceCollection AddDistributedCache(this 
            IServiceCollection services,
            IConfiguration configuration)
        {
            var settings = configuration.GetSection("CacheSettings");
            var settingsTyped = settings.Get<CacheSettings>();
            services.Configure<CacheSettings>(settings);
            services.AddDistributedRedisCache(options => { 
            options.Configuration = settingsTyped.ConnectionString; });
            return services;
        }
    }
}

上述代码声明了一个新的 AddDistributedCache 扩展方法。该方法通过读取和注册节点到依赖注入引擎来配置 appsettings.json 文件的 CacheSettings 节点。接下来,它调用 AddDistributedCache 方法来配置分布式缓存,使其可以使用提供的连接字符串。

让我们从向 appsettings.json 文件添加新的 CacheSettings 节点开始:

...
"CacheSettings": {
  "ConnectionString": "catalog_cache"
}

接下来,我们需要在 Startup 类中调用 AddDistributedCache 扩展方法:

public class Startup
{
    ...
    public void ConfigureServices(IServiceCollection services)
    {
        services
           ...
            .AddDistributedCache(Configuration)
    }

现在,我们的应用程序使用 appsettings.json 文件提供的连接字符串在依赖注入引擎中初始化一个新的 IDistributedCache 实例。在下一节中,我们将检查分布式缓存的额外定制级别。

实现 IDistributedCache 接口

可以通过重载扩展方法来扩展 IDistributedCache 接口的行为。此外,我们需要存储复杂信息而不是字节。例如,在 ItemController 类的情况下,我们希望将 ItemResponse 类型传递给 IDistributedCache 接口的 Set 方法。

让我们从修改 Catalog.API 项目中的 DistributedCacheExtensions 类开始:

using System.Threading.Tasks;
using Catalog.Domain.Configurations;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;

namespace Catalog.Infrastructure.Extensions
{
    public static class DistributedCacheExtensions
    {
        private static readonly JsonSerializerSettings 
            _serializerSettings = CreateSettings();

        ...

        public static async Task<T> GetObjectAsync<T>(this 
            IDistributedCache cache, string key)
        {
            var json = await cache.GetStringAsync(key);

            return json == null ? default(T) :

            JsonConvert.DeserializeObject<T>(json, 
                _serializerSettings);
        }
        public static async Task SetObjectAsync(this IDistributedCache 
            cache, string key, object value)
        {
            var json = JsonConvert.SerializeObject(value, 
                _serializerSettings);
            await cache.SetStringAsync(key, json);
        }

        public static async Task SetObjectAsync(this IDistributedCache 
            cache, string key,
        object value, DistributedCacheEntryOptions options)
        {
            var json = JsonConvert.SerializeObject(value, 
                _serializerSettings);
            await cache.SetStringAsync(key, json, options);
        }

        private static JsonSerializerSettings CreateSettings()
        {
            return new JsonSerializerSettings();
        }
    }
}

上述类定义了获取和设置 Redis 缓存中复杂对象的泛型扩展方法。它使用 Newtonsoft.Json 方法以 JSON 格式存储对象。该类还定义了两个具有不同签名的 SetObject<T> 方法。一个提供了 DistributedCacheEntryOptions 的默认配置,而另一个允许我们覆盖此配置中的选项。此外,我们可以在 ItemController 类中使用这些扩展方法,如下所示:

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    [JsonException]
    [Authorize]
    public class ItemController : ControllerBase
    {
        private readonly IItemService _itemService;
        private readonly IEndpointInstance _messageEndpoint;
        private readonly IDistributedCache _distributedCache;

        public ItemController(IItemService itemService, 
        IEndpointInstance messageEndpoint,
            IDistributedCache distributedCache)
        {
            _itemService = itemService;
            _messageEndpoint = messageEndpoint;
            _distributedCache = distributedCache;
        }
        [HttpGet("{id:guid}")]
        [ItemExists]
        [ResponseCache(Duration = 100, VaryByQueryKeys = new[] { "*" })]
        public async Task<IActionResult> GetById(Guid id)
        {
            var key = $"{typeof(ItemController).FullName}.
            {nameof(GetById)}.{id}"; 
            var cachedResult = await _distributedCache. 
                GetObjectAsync<ItemResponse>(key);

 if (cachedResult != null)
 {
 return Ok(cachedResult);
 }

            var result = await _itemService.GetItemAsync(new 
                GetItemRequest{ Id = id });
            await _distributedCache.SetObjectAsync(key, result);

            return Ok(result);
        }
...

GetById 动作方法使用 IDistributedCache 接口来保存 ItemResponse 的信息:它定义了一个以下方式组合的键:

<controller_full_name>.<action_method_name>.<input_id>

动作方法尝试从缓存中检索此信息;如果此信息不存在,它将使用 IItemService 接口执行请求,并使用 IDistributedCache 接口的 SetObjectAsync 方法存储结果。

ResponseCache 属性相比,这种方法的实现开销更大,但 IDistributedCache 接口依赖于外部缓存系统。此外,可以使用过滤器管道堆栈实现这种类型的缓存逻辑。因此,可以将 ItemController 类中实现的缓存逻辑移动到自定义动作过滤器中:


namespace Catalog.API.Filters
{
    public class RedisCacheFilter : IAsyncActionFilter
    {
        private readonly IDistributedCache _distributedCache;
        private readonly DistributedCacheEntryOptions _options;

        public RedisCacheFilter(IDistributedCache distributedCache, 
        int cacheTimeSeconds)
        {
            _distributedCache = distributedCache;
            _options = new DistributedCacheEntryOptions
            {
                SlidingExpiration = 
                    TimeSpan.FromSeconds(cacheTimeSeconds)
            };
        }

        public async Task OnActionExecutionAsync(ActionExecutingContext 
        context, ActionExecutionDelegate next)
        {
            if (!context.ActionArguments.ContainsKey("id"))
            {
                await next();
            }

            var actionName = (string) 
                context.RouteData.Values["action"];
            var controllerName = (string) 
                context.RouteData.Values["controller"];

            var id = context.ActionArguments["id"];

            var key = $"{controllerName}.{actionName}.{id}";

            var result = await _distributedCache.
                GetObjectAsync<ItemResponse>(key);

            if (result != null)
            {
                context.Result = new OkObjectResult(result);
                return;
            }

            var resultContext = await next();

            if (resultContext.Result is OkObjectResult resultResponse 
            && resultResponse.StatusCode == 200)
 {
 await _distributedCache.SetObjectAsync(key, 
                    resultResponse.Value, _options);
 }
        }
    }
}

RedisCacheFilter 封装了缓存逻辑,以避免在每个动作方法中重复实现。它实现了以下逻辑:在动作执行之前,它尝试通过组合控制器名称、动作名称和请求的 id 值来从缓存中获取 ItemResponse。如果缓存键未填充,它将继续执行动作方法,如果结果 StatusCode200,它将继续将结果存储在 Redis 缓存中。下一个具有相同 id 值的请求将填充缓存,动作过滤器将返回缓存的对象。

可以以下方式使用动作过滤器:


    [Route("api/items")]
    [ApiController]
    [JsonException]
    public class ItemController : ControllerBase
    {
        ...

        [HttpGet("{id:guid}")]
        [ItemExists]
        [TypeFilter(typeof(RedisCacheFilter), Arguments = new object[] 
            {20})]
        public async Task<IActionResult> GetById(Guid id)
        {
            ...
        }

       ...
    }

动作方法使用 TypeFilter 属性解析 RedisCacheFilter,并将表示缓存过期时间的秒数作为参数传递。这种实现策略更易于阅读,并允许我们避免在不同动作方法之间复制代码。

将内存缓存注入到测试中

如果我们尝试使用 RedisCacheFilter 执行单元测试,我们会注意到 get_by_id_should_return_right_data 将会失败。这是因为 Redis 实例在开发环境中不可用。此外,我们可以使用 MemoryDistributedCache 实现来执行我们的测试。MemoryDistributedCache 实现是在 AddDistributedMemoryCache 扩展方法所使用的同一类中完成的。正如我们在上一章中提到的,该类不提供真正的分布式缓存,它用于测试目的。

因此,我们可以向包含在 Catalog.Fixtures 项目中的 InMemoryApplicationFactory<TStartup> 类添加以下行:

namespace Catalog.Fixtures
{
    public class InMemoryApplicationFactory<TStartup>
        : WebApplicationFactory<TStartup> where TStartup : class
    {
        protected override void ConfigureWebHost(IWebHostBuilder 
            builder)
        {
            builder.UseEnvironment("Testing")
                .ConfigureTestServices(services =>
                {
                    ...
                   services.AddSingleton<IDistributedCache, 
                      MemoryDistributedCache>();              
                    ...
                });
        }
    }
}

之前的代码将 IDistributedCache 的实现替换为 MemoryDistributedCache 的一个实例。因此,每次我们的实现调用 IDistributedCache 接口时,数据都会保存在测试服务器的内存中。

摘要

在本章中,我们介绍了 ASP.NET Core 中的一些主要缓存场景,以便您了解 HTTP 缓存的工作原理以及如何实现。此外,我们学习了如何使用 IDistributedCache 接口实现分布式缓存以及如何将 Web 服务连接到 Redis 实例。在下一章中,我们将探讨如何处理日志管理以及如何对目录 Web 服务的依赖项进行健康检查。

第十七章:日志和健康检查

构建网络服务的一个重要方面是日志和健康检查。如今,人们更倾向于拥有范围较小的服务。这种方法提供了巨大的好处,但它使得我们难以验证服务是否以正确的方式运行。日志帮助我们跟踪网络服务另一侧的操作和处理的信息,而健康检查机制提供了一种验证服务是否健康以及所有必需的依赖是否满足的方法。本章将介绍 ASP.NET Core 的一些日志部分,并展示如何使用框架提供的工具实现健康检查。

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

  • 在 ASP.NET Core 中登录

  • 实现日志

  • 实现日志提供者

  • 在测试中实现自定义日志

  • Web 服务健康检查

在 ASP.NET Core 中登录

我们将从这个章节开始,提供 ASP.NET Core 不同日志组件的概述。该框架提供了不同的接口,支持日志记录:

  • ILoggerProvider 用于定义与输出通道绑定的一种特定类型的日志

  • ILoggerFactory 接收一个 ILoggerProvider 接口并对其进行初始化

  • ILogger 接口是日志组件的一个特定实例

ASP.NET Core 的日志接口结构可以用以下架构来描述:

图片

简而言之,ILoggerProvider 接口代表日志的输出,ILoggerFactory 创建正确的实例类型,而 ILogger 是实际的日志实例。

这种方法保证了 ILogger 接口消费者和日志提供者部分之间的安全隔离。此外,我们可以在开发阶段选择添加对 ILogger 接口的调用;然后在发布阶段,我们可以决定使用哪个提供者来输出我们的日志数据。此外,我们的日志代码变得非常灵活,我们可以根据服务运行的环境类型来更改输出。让我们通过了解 ASP.NET Core 日志系统的不同特性来继续前进。

日志的关键特性

ASP.NET Core 日志系统以一些始终存在于每个日志记录中的关键属性为特征。此外,保持日志尽可能一致是非常重要的。ASP.NET Core 日志系统的关键组件是 日志类别日志级别日志事件 ID日志消息

当初始化 ILogger<T> 接口时,会指定 日志类别。它是日志过程的一个重要部分,因为它标识了发出日志记录的组件。日志类别通常对应于触发日志记录的类型或类。让我们以目录服务的 ItemController 类为例。日志类别是在 ILogger 接口注入过程中定义的:

namespace Catalog.API.Controllers
{
    [Route("api/items")]
    [ApiController]
    [JsonException]
    [Authorize]
    public class ItemController : ControllerBase
    {
        private readonly IItemService _itemService;
        private readonly IEndpointInstance _messageEndpoint;
        private readonly ILogger<ItemController> _logger;

        public ItemController(IItemService itemService, 
        IEndpointInstance messageEndpoint,
            IDistributedCache distributedCache, 
 ILogger<ItemController> logger)
        {
            _itemService = itemService;
            _messageEndpoint = messageEndpoint;
            _logger = logger;
        }
...

ItemController 类使用构造函数注入的广泛技术来初始化 ILogger<ItemController> 类型。因此,日志类别在 _logger 属性的签名中被隐式定义。尽管 日志类别 帮助我们识别哪个组件正在触发特定的日志消息,但我们还需要定义该消息的重要性。

日志级别 提供了指示日志记录严重性或重要性的信息。ASP.NET Core 提供了一个有用的扩展方法,它对日志级别提供了一些抽象:

_logger.LogInformation, _logger.LogWarning, _logger.LogError, _logger.LogCritical

这些方法都是 Microsoft.Extensions.Logging 命名空间提供的抽象。例如,如果我们检查 _logger.LogInformation 方法的实现,底层实际上只是调用了通用的 logger.Log 方法:

public static void LogInformation(
  this ILogger logger,
  EventId eventId,
  Exception exception,
  string message,
  params object[] args)
{
  logger.Log(LogLevel.Information, eventId, exception, message, args);
}

LogInformation 扩展方法通过隐式定义框架提供的信息级别来包装 logger.Log 方法调用。LogLevel 属性是一个由 Microsoft.Extension.Logging 命名空间暴露的 enum 结构,它提供了以下开箱即用的日志级别:

namespace Microsoft.Extensions.Logging
{
  public enum LogLevel
  {
    Debug,
    Warning,
    Error,
    Critical,
    None,
  }
}

上述代码描述了由 LogLevel 枚举提供的不同日志级别。日志级别从描述系统详细信息的 Trace 级别,到表示服务无法正确工作并正在关闭的 Critical 级别。LogLevel 属性是必不可少的,因为它通常用于过滤日志消息,告诉我们日志消息的优先级。

一旦我们确定了特定日志消息的严重性级别,我们需要提供 日志事件 ID,这有助于我们在日志输出中识别特定事件。虽然 日志类别 通常代表类的完整路径,但 日志事件 ID 在我们希望表达当前生成日志输出的方法时非常有用。让我们以 ItemController 类中包含的操作方法(GetGetByIdCreateUpdateDelete)为例。我们可以创建一个将每个操作方法映射到特定事件 ID 的日志事件类:

namespace Catalog.API
{
    public class LoggingEvents
    {
            public const int Get = 1000;
            public const int GetById = 1001;
            public const int Create = 1002;
            public const int Update = 1003;
            public const int Delete = 1004;
    }
}

因此,当我们调用 ItemController 类的操作方法中的 ILogger 接口时,可以传递相应的日志事件 ID。通过这样做,我们可以识别和分组事件:

 _logger.LogInformation(LoggingEvents.GetById, "Getting item");

最后,日志记录的另一个重要部分是与日志记录相关的消息。ASP.NET Core 的日志系统还提供了一个类似于 C# 字符串插值的模板系统。因此,可以使用以下方式使用模板系统:

 _logger.LogInformation(LoggingEvents.GetById, "GetById {id} ", id);

上述代码使用LoggingEvents.GetById事件 ID 记录了一条关于信息严重性的消息,并添加了消息"GetById {id} "。现在我们已经了解了 ASP.NET Core 提供的不同日志特性,我们将查看一个具体的应用实例,该实例已被应用于目录服务项目。

实现日志部分

在本节中,我们将学习如何在目录网络服务中执行日志记录。让我们首先选择一个我们将执行日志语句的层。由于逻辑封装在Catalog.Domain层项目中,我们将继续在项目中定义的服务类上实现日志部分。首先,让我们定义一个新的日志类,其中包含每个操作的相应事件 ID

namespace Catalog.Domain.Logging
{
    public class Events
    {
        public const int Get = 1000;
        public const int GetById = 1001;
        public const int Add = 1002;
        public const int Edit = 1003;
        public const int Delete = 1004;
    }
}

一旦为每个活动建立了相应的日志事件 ID,我们需要定义ILogger接口将使用的日志消息。目前,我们可以确定以下消息:

namespace Catalog.Domain.Logging
{
    public class Messages
    {
        public const string NumberOfRecordAffected_modifiedRecords = 
            "Number of record affected {records}";
        public const string ChangesApplied_id = "Changes applied to the 
            following entity id ({id})";
        public const string TargetEntityChanged_id = "Target entity id 
            ({id})";
    }
}

第一个指的是受变更影响记录的数量,而第二个是指被更改的实体。最后,第三个为处理器的目标实体提供一条消息。重要的是要注意,这两个常量遵循一个命名约定:它们名称的第一部分指的是消息的内容;在第一个下划线之后,我们有在日志模板系统中将被值替换的参数。

此外,我们可以通过更改IItemService方法并通过实现日志记录来继续进行。让我们从AddItemAsync方法开始:

using Microsoft.Extensions.Logging; using Catalog.Domain.Logging;
...

namespace Catalog.Domain.Services
{
    public class ItemService : IItemService
    {
        private readonly IItemMapper _itemMapper;
        private readonly IItemRepository _itemRepository;
        private readonly ILogger<IItemService> _logger;

        public ItemService(IItemRepository itemRepository, 
        IItemMapper itemMapper, ILogger<IItemService> logger)
        {
            _itemRepository = itemRepository;
            _itemMapper = itemMapper;
            _logger = logger;
        }

        ...

        public async Task<ItemResponse> AddItemAsync(AddItemRequest 
            request)
        {
            var item = _itemMapper.Map(request);
            var result = _itemRepository.Add(item);

            var modifiedRecords = await _itemRepository. 
                UnitOfWork.SaveChangesAsync();

            _logger.LogInformation(Events.Add, Messages.
            NumberOfRecordAffected_modifiedRecords, modifiedRecords);
 _logger.LogInformation(Events.Add, Messages.
            ChangesApplied_id, result?.Id);

            return _itemMapper.Map(result);
        }

        ...
    } 
}

上述代码跟踪受影响记录的数量和添加记录的 ID 信息。我们也可以用ItemService类的其他方法做同样的事情。在只读操作的情况下,我们可以添加目标记录的 ID;例如,在GetItemAsync方法的情况下:

public class ItemService : IItemService
{
    ...

    public async Task<ItemResponse> GetItemAsync(GetItemRequest 
        request)
    {
        if (request?.Id == null) throw new ArgumentNullException();
        var entity = await _itemRepository.GetAsync(request.Id);

        _logger.LogInformation(Events.GetById, 
            Messages.TargetEntityChanged_id, entity?.Id);

        return _itemMapper.Map(entity);
    }

上述代码使用Event.GetById字段记录了服务检索到的 ID 相关的信息。它使用Messages类型来指定事件消息。在下一节中,我们将学习如何通过增强日志记录的异常处理实现来记录异常。

异常日志记录

如果服务的一部分抛出异常怎么办?处理异常是服务开发过程中的关键部分。正如我们在第七章“过滤器管道”中看到的,可以使用它们通过过滤器来捕获异常。过滤器是 MVC 堆栈的关键部分:它们在操作方法之前和之后执行,并且可以用于在单个实现中记录异常。让我们再次看看Catalog.API中的JsonExceptionAttribute

using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using Catalog.API.Exceptions;

namespace Catalog.API.Filters
{
    public class JsonExceptionAttribute : TypeFilterAttribute
    {
        public JsonExceptionAttribute() : base(typeof(HttpCustomExceptionFilterImpl))
        {
        }

        public class HttpCustomExceptionFilterImpl : IExceptionFilter
        {
            private readonly IHostingEnvironment _env;
            private readonly ILogger _logger;

            public HttpCustomExceptionFilterImpl(IHostingEnvironment 
                env,
                ILogger<HttpCustomExceptionFilterImpl> logger)
            {
                _env = env;
                _logger = logger;
            }

            public void OnException(ExceptionContext context)
            {
                var eventId = new EventId(context.Exception.HResult);

                _logger.LogError(eventId, context.Exception, context.Exception.Message);

                var json = new JsonErrorPayload
                {
                    EventId = eventId.Id
                };

                json.DetailedMessage = context.Exception;

                var exceptionObject = new ObjectResult(json) { 
                    StatusCode = 500 };

                context.Result = exceptionObject;
                context.HttpContext.Response.StatusCode = (int) 
                HttpStatusCode.InternalServerError;
            }
        }
    }
}

该类通过类似模式跟踪和返回异常,这可以在处理器的实现中看到:ILogger<T> 接口通过依赖注入注入到构造函数中,并使用 _logger.LogError 方法。

在下一节中,我们将学习如何使用 Moq 在我们的测试中验证日志记录。

使用 Moq 验证日志记录

让我们学习如何验证我们实现的日志记录。ILogger 接口的依赖注入系统帮助我们模拟日志机制并验证结果实现。重要的是要注意,我们的处理器正在使用 ILogger 接口的扩展方法。以下以 Microsoft.Extensions.Logging 命名空间中 LogInformation 扩展方法的 ASP.NET Core 实现为例:

using Microsoft.Extensions.Logging.Internal;
using System;

namespace Microsoft.Extensions.Logging
{
  /// <summary>ILogger extension methods for common scenarios.</summary>
  public static class LoggerExtensions
  {
        public static void LogInformation(this ILogger logger, 
           Exception exception, string message, params object[] args)
        {
              logger.Log(LogLevel.Information, exception, message, 
                   args);
        }
}

扩展方法不是面向模拟的结构。它们是静态方法,根据定义,在 C# 的运行时中不可能模拟静态结构。因此,我们需要提供一个对 ILogger 工厂进行抽象的机制,允许我们注入和模拟测试中使用的接口。

通常情况下,无法测试静态方法。此外,模拟库通常通过在运行时动态创建类来创建模拟。通常,它们通过扩展类型来覆盖类型的行为。由于静态方法不能被覆盖,因此无法模拟它们。因此,当需要模拟一个静态元素时,建议我们抽象它们并将它们封装到类中。

让我们看看 Catalog.Fixture 项目中 LoggerAbstraction 类的声明:

using System;
using Microsoft.Extensions.Logging;

namespace Catalog.Fixtures
{
    public abstract class LoggerAbstraction<T> : ILogger<T>
    {
        public IDisposable BeginScope<TState>(TState state) => throw 
            new NotImplementedException();

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(LogLevel logLevel, EventId eventId, 
            TState state, Exception exception, Func<TState, Exception,
             string> formatter)
            => Log(logLevel, exception, formatter(state, exception));

       public abstract void Log(LogLevel logLevel, Exception ex, 
            string information);
    }
}

LoggerAbstraction 是一个实现 ILogger<T> 接口的泛型类。更具体地说,抽象类通过执行 void Log 方法的重载版本来定义 Log<TState> 方法。Log 方法和 LoggerAbstraction 类都是抽象的,这意味着我们可以模拟它们的行为。因此,可以模拟日志记录类的行为,如下面修改后的 ItemServiceTests 类所示:

namespace Catalog.Domain.Tests.Services
{
    public class ItemServiceTests : IClassFixture<CatalogContextFactory>
    {
        private readonly ItemRepository _itemRepository;
        private readonly IItemMapper _mapper;
        private readonly Mock<LoggerAbstraction<IItemService>> _logger;

              public ItemServiceTests(CatalogContextFactory 
                catalogContextFactory, ITestOutputHelper outputHelper)
        {
            _itemRepository = new ItemRepository

            (catalogContextFactory.ContextInstance);
            _mapper = catalogContextFactory.ItemMapper;
            _logger = new Mock<LoggerAbstraction<IItemService>>();

            _logger.Setup(x => x.Log(It.IsAny<LogLevel>(),It.IsAny      
                <Exception>(), It.IsAny<string>()))
 .Callback((LogLevel logLevel, Exception exception, 
                        string information) => 
 outputHelper.WriteLine($"{logLevel}:
                            {information}"));
        }

        ...

        [Theory]
        [LoadData("item")]
        public async Task additem_should_log_information(AddItemRequest 
            request)
        {
            var sut = new ItemService(_itemRepository, _mapper, 
                _logger.Object);

            await sut.AddItemAsync(request);

            _logger
 .Verify(x => x.Log(It.IsAny<LogLevel>(), It.IsAny
                <Exception>(), It.IsAny<string>()), Times.AtMost(2));
        }
        ...
    }
}

ItemServiceTests 类将 LoggerAbstraction<IItemService> 类型初始化为一个类属性。类的构造函数使用 ITestOutputHelper 模拟服务层使用的日志系统:

 _logger.Setup(x => x.Log(It.IsAny<LogLevel>(),It.IsAny<Exception>(), 
    It.IsAny<string>()))
        .Callback((LogLevel logLevel, Exception exception, string 
            information) => 
                       outputHelper.WriteLine($"{logLevel}:
                          {information}"));

ITestOutputHelper 是由 Xunit.Abstractions 命名空间公开并由 Xunit 运行时解析的一个接口。它允许我们在测试运行器的测试控制台中编写代码。最后,测试类实现了 additem_should_log_information 测试方法。测试方法调用我们在 ItemService 类中实现的 AddItemAsync 方法。最后,可以使用以下代码片段验证 ILogger 接口:

    _logger
     .Verify(x => x.Log(It.IsAny<LogLevel>(),It.IsAny<Exception>(), 
        It.IsAny<string>()), Times.AtMost(2));

前面的代码片段验证了 Log 方法被调用了两次。它还将结果日志作为由 ITestOutputHelper 接口定义的日志系统的一部分输出。现在我们已经实现了 Catalog.Domain 项目服务层的日志机制,我们将检查和实现必要的日志提供者。

实现日志提供者

到目前为止,我们已经定义了在应用程序中要记录的内容;在本节中,我们将说明如何实现这一点。在 ASP.NET Core 中,日志提供者是通过依赖注入初始化的。此外,根据环境或其他启动选项,我们可以有条件地初始化它。

ASP.NET Core 提供了一些内置的日志提供者,如下表所示:

提供者 命名空间 描述
Console Microsoft.Extensions.Logging.Console 将运行应用程序的控制台设置为日志的输出。
Debug Microsoft.Extensions.Logging.Debug 当附加调试器时,在调试输出窗口中写入消息。
EventSource Microsoft.Extensions.Logging.EventSource 将 Windows ETW 设置为主要日志的输出。
EventLog Microsoft.Extensions.Logging.EventLog 将 Windows 事件日志设置为主要日志的输出。
TraceSource Microsoft.Extensions.Logging.TraceSource 要使用此提供者,ASP.NET Core 需要在 .NET Framework 上运行。应用程序将使用由跟踪源提供的监听器。

重要的是要注意,所有日志提供者都是互补的。此外,我们可以添加许多提供者,以便在更多源中进行跟踪日志记录。我们可以在 Startup 类和 Program 类中配置提供者。让我们看看 Catalog.APIProgram 类:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;

namespace Catalog.API
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] 
            args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>();
        }
    }
}

在这里,Program 类使用 WebHost.CreateDefaultBuilder 来创建服务的 WebHostBuilder 实例。如果我们进一步查看该方法,我们会看到它使用以下语法来定义日志的提供者:

...
.ConfigureLogging((Action<WebHostBuilderContext, ILoggingBuilder>) ((hostingContext, logging) =>
{
  logging.AddConfiguration((IConfiguration) 
  hostingContext.Configuration.GetSection("Logging"));
  logging.AddConsole();
  logging.AddDebug();
  logging.AddEventSourceLogger();
}))

正如您所看到的,Program 类默认定义了三个内置提供者。此外,它使用 Configuration 实例来传递在 appsettings.json 文件中描述的配置。

此外,我们还可以通过在 Program 类中显式调用 ConfigureLogging 扩展方法来覆盖默认提供者:

...
    public class Program
    {
          ...
        public static IWebHostBuilder CreateWebHostBuilder(string[] 
            args)
        {
            return WebHost.CreateDefaultBuilder(args)
                .ConfigureLogging(builder =>
 {
 builder.[...]
 })
                .UseStartup<Startup>();
        }
    }

如果我们想在应用程序中添加自定义日志提供者,前面的代码片段非常有用。ASP.NET Core 还为我们提供了一个方便的方法来在 Startup 类中配置日志提供者:在 ConfigureServices 中可以使用 AddLogging 扩展方法:


    public class Startup
    {
       ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...
            services.AddLogging(builder => builder.AddConsole());
        }

       ...
    }
}

前面的代码片段在Startup类的ConfigureServices执行时初始化日志服务。在Startup级别初始化日志提供程序对于我们需要根据环境或特定的配置标志初始化日志提供程序时非常有用。让我们通过学习如何实现自定义日志提供程序来继续前进。

在测试中实现自定义日志提供程序

如我们所见,ASP.NET Core 的日志系统旨在提供最大的可扩展性。在本节中,我们将学习如何实现一个自定义日志提供程序,我们可以在测试中使用它。Catalog.API.Tests项目中的所有测试类都使用InMemoryApplicationFactory<T>来运行 Web 服务器并提供HttpClient来调用 API。如您所注意到的,当其中一个测试失败时,测试不会返回显式的错误。例如,让我们检查ItemControllerTests类中的以下测试方法:

public class ItemController : IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        ...

        [Fact]
        public async Task update_should_returns_not_found
            _when_item_is_not_present()
        {
            var client = _factory.CreateClient();

            var httpContent = new StringContent(jsonPayload.ToString(), 
            Encoding.UTF8, "application/json");
            var response = await client.PutAsync($"/api/items/
              {Guid.NewGuid()}", httpContent);

            response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
        }
        ..
    }

如果由于任何原因调用返回错误,测试端将收到以下消息:

...
Shouldly.ShouldAssertException : response.StatusCode
 should be
HttpStatusCode.NotFound
 but was
HttpStatusCode.InternalServerError ...

在这里,我们不知道为什么API 返回了InternalServerError。在这里,我们可以使用Xunit提供的ITestOutputHelper接口来创建一个新的日志提供程序,并在我们的测试中使用它。要在 ASP.NET Core 中声明一个日志记录器,我们需要以下结构和组件:

图片

前面的架构描述了两个主要组件:TestOutputLoggerProvider类型和TestOutputLogger类型。TestOutputLoggerProvider类型的目的是管理日志实例的列表。TestOutputLogger类描述了实际的日志机制实现。让我们首先定义自定义的ILogger组件:

using System;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;

namespace Catalog.Fixtures
{
    public class TestOutputLogger : ILogger
    {
        private readonly ITestOutputHelper _output;

        public TestOutputLogger(ITestOutputHelper output) =>
            _output = output;

        public IDisposable BeginScope<TState>(TState state) => null;

        public bool IsEnabled(LogLevel logLevel) =>
             logLevel == LogLevel.Error;

        public void Log<TState>(LogLevel logLevel, EventId eventId, 
             TState state, Exception exception, Func<TState,
             Exception, string> formatter)
        {
            if (!IsEnabled(logLevel))
                return;

            _output.WriteLine($"{logLevel.ToString()} - 
                {exception.Message} - {exception.StackTrace}");
        }
    }
}

ITestOutputClass实现了ILogger接口提供的方法。首先,它在构造函数中声明了一个ITestOutputHelper字段。然后,它通过调用_output.WriteLine方法在Log方法的具体实现中使用_output属性。该类还实现了IsEnabled方法来检查日志级别是否对应于LogLevel.Error字段。如果日志记录不这样做,LogLevel.Error将被写入控制台输出。为了完成此实现,我们需要一个日志提供程序来初始化自定义日志记录器。让我们继续创建另一个名为TestOutputLoggerProvider的类,该类扩展了ILoggerProvider接口:

using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Catalog.Fixtures
{
    public class TestOutputLoggerProvider : ILoggerProvider
    {
        private readonly ConcurrentDictionary<string, TestOutputLogger> 
            _loggers = new ConcurrentDictionary
            <string, TestOutputLogger>();

        private readonly ITestOutputHelper _testOutputHelper;

        public TestOutputLoggerProvider(ITestOutputHelper 
            testOutputHelper) => _testOutputHelper = testOutputHelper;

        public ILogger CreateLogger(string categoryName) => 
            _loggers.GetOrAdd(categoryName, name =>  new 
            TestOutputLogger(_testOutputHelper));

        public void Dispose() => _loggers.Clear();
    }
}

TestOutputLoggerProvider定义了一个ConcurrentDictionary,其中包含一个stringTestOutputLogger的键值对;它还接受ITestOutputHelper接口作为接口,该接口在CreateLogger方法中使用,以将日志记录器添加到日志管道中。通过这样做,我们可以将自定义日志记录器集成到我们的测试中。我们将使用在Catalog.Fixtures项目中实现的InMemoryApplicationFactory<T>类来添加自定义日志记录器,如下所示:

using Xunit.Abstractions; ...

namespace Catalog.Fixtures
{
    public class InMemoryApplicationFactory<TStartup>
        : WebApplicationFactory<TStartup> where TStartup : class
    {
        private ITestOutputHelper _testOutputHelper;
 public void SetTestOutputHelper(ITestOutputHelper 
            testOutputHelper)
 {
 _testOutputHelper = testOutputHelper;
 }
        protected override void ConfigureWebHost(IWebHostBuilder 
            builder)
        {
            builder
                .UseEnvironment("Testing")
                .ConfigureTestServices(services =>
                {
                    ...
                    if (_testOutputHelper != null)
 {
 services.AddLogging(cfg => cfg.AddProvider(new 
                        TestOutputLoggerProvider(_testOutputHelper)));
 }
                   ...
                });
        }
    }
}

前面的类声明了一个新的 ITestOutputHelper 属性类型,并定义了一个设置器。我们可以在 ConfigureTestService 类内部通过调用 AddProvider 扩展方法来添加我们的自定义日志记录器,创建 TestOutputLoggerProvider 的新实例。在做出这些更改后,我们可以通过将自定义日志记录器集成到 ItemControllerTests 类中继续操作:

...
namespace Catalog.API.Tests.Controllers
{
    public class ItemControllerTests : 
        IClassFixture<InMemoryApplicationFactory<Startup>>
    {
        private readonly InMemoryApplicationFactory<Startup> _factory;   
        public ItemControllerTests(InMemoryApplicationFactory<Startup> 
        factory, ITestOutputHelper outputHelper)
 {
 _factory = factory;
 _factory.SetTestOutputHelper(outputHelper);
 } ...

正如你所见,ItemControllerTests 在构造函数中通过设置注入的 ITestOutputHelper 接口来调用 _factory.SetTestOutputHelper。现在,每次测试抛出错误时,我们都会得到详细的错误信息。重要的是要注意,ITestOutputHelper 接口是在测试类中分配的,这意味着这是唯一可能通过依赖注入获取该接口的点。在下一节中,我们将学习如何实现与网络服务依赖相关的健康检查。

网络服务健康检查

另一个始终存在于网络服务中的关键特性是对依赖项的 健康检查过程。通常,健康检查过程由 CI/CD 管道用于检查服务在部署后是否健康,或者在监控仪表板中执行功能检查。健康检查通常通过调用 HTTP 路由来检测网络服务中是否存在任何正在进行的问题。

注意,这些服务暴露了健康检查路由。这些健康检查的监控是在一个独立且分开的应用中实现的。此外,这种做法使我们能够保持服务与监控逻辑的独立性。

ASP.NET Core 提供了一些开箱即用的实现,以帮助开发者将健康检查过程添加到他们的服务中。这些功能是通过一种 面向中间件的方法 实现的。此外,健康检查作为 HTTP 端点暴露,可以用来检查第三方服务、数据库和数据存储系统的状态。因此,我们可以测试服务的依赖项,以确认它们的可用性和功能。

健康检查功能是在 2018 年 8 月的 ASP.NET Core 2.2.0-preview1 版本中引入的。这个功能引入了 IHealthCheck 接口. 在未来,它将与 Polly 库集成,以提供更好的弹性,当用户执行日志检查时。

在数据库上实现健康检查

数据库通常是网络服务的主要依赖之一。因此,始终需要检查服务与数据库之间的连接。让我们先学习如何使用AspNetCore.HealthChecks.SqlServer (www.nuget.org/packages/AspNetCore.HealthChecks.SqlServer/) 和 Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore (www.nuget.org/packages/Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore/) 包来实现健康检查。我们将将这些更改应用到 目录服务 项目中。让我们首先将此包添加到 Catalog.API 项目中:

dotnet add package AspNetCore.HealthChecks.SqlServer

AspNetCore.HealthChecks.SqlServer 包允许我们对 SQL Server 实例执行健康检查。让我们继续在 Startup 类中注册以下服务:

namespace Catalog.API
{
    public class Startup
    {
        ...

        public void ConfigureServices(IServiceCollection services)
        {
            ...
            services
 .AddHealthChecks()
 .AddSqlServer(Configuration.GetSection
                ("DataSource:ConnectionString").Value);
            ...
        }

        public void Configure(IApplicationBuilder app, 
            IWebHostEnvironment env)
        {
            if (!env.IsTesting())
                app.ApplicationServices.GetService<CatalogContext>()
                .Database.Migrate();

            app.UseAuthentication();
            app.UseAuthorization();
            app.UseResponseCaching();
            app.UseHealthChecks("/health");
            ...
        }
    }
}

如您所见,代码使用 AddHealthChecks 扩展方法配置了 健康检查中间件,该方法返回一个 IHealthChecksBuilder 接口;它调用由构建器提供的 AddSqlServer 扩展方法来绑定健康检查与 SQL Server 数据库。最后,通过调用 UseHealthChecks 方法并传入健康检查路由来添加中间件。如果我们可以在 https://<hostname>:<port>/health 路由上调用我们的服务,我们将根据与数据源的连接收到 Healthy/Unhealthy 响应。

实现自定义健康检查

ASP.NET Core 的健康检查功能不仅适用于我们服务的数据源;它还可以用于执行复杂和自定义的健康检查。框架提供了 IHealthCheck 接口,以便我们可以实现我们的健康检查。让我们在 Catalog.API 项目的 HealthCheck 文件夹中创建一个名为 RedisCacheHealthCheck 的新类:

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using Catalog.Domain.Settings;

namespace Catalog.API.HealthChecks
{
    public class RedisCacheHealthCheck : IHealthCheck
    {
        private readonly CacheSettings _settings;

        public RedisCacheHealthCheck(IOptions<CacheSettings> settings)
 {
 _settings = settings.Value;
 }

        public async Task<HealthCheckResult> 
        CheckHealthAsync(HealthCheckContext context, 
            CancellationToken cancellationToken = default)
        {
            try
            {
                var redis = ConnectionMultiplexer.Connect
                (_settings.ConnectionString);
                var db = redis.GetDatabase();

                var result =  await db.PingAsync();
                if (result < TimeSpan.FromSeconds(5))
                {
                    return await Task.FromResult(
                        HealthCheckResult.Healthy());
                }

                return await Task.FromResult(
                    HealthCheckResult.Unhealthy());
            }
            catch (Exception e)
            {
                return await Task.FromResult(
                    HealthCheckResult.Unhealthy(e.Message));
            }
        }
    }
}

RedisCacheHealthCheck 类使用 StackExchange.Redis 包通过设置连接字符串中指定的 Redis 实例创建连接。这个类的核心部分是 CheckHealthAsync 方法;它根据 Redis 实例的响应时间返回 HealthCheckResult.HealthyHealthCheckResult.Unhealthy。如果 ping 响应时间少于五秒,则表示实例是健康的;否则,它不是。该类是 ASP.NET Core 堆栈的一部分,并且可以使用框架的依赖注入引擎来解决依赖关系。

因此,可以通过向Startup类的ConfigureServices方法中添加以下代码片段来将类添加到健康检查堆栈中:

...
            services
                .AddHealthChecks()
                .AddCheck<RedisCacheHealthCheck>("cache_health_check")
                .AddSqlServer(Configuration.GetSection
                ("DataSource:ConnectionString").Value);
..

这种方法将 SQL Server 连接的检查以及我们在 RedisCacheHealthCheck 类中实现的自定义检查添加到中间件管道中。如果两者都成功,则该服务将被归类为健康状态。此外,还可以使用 docker-compose up --build 命令运行目录容器,并通过调用 http://<hostname:port>/health 路由来验证目录网络服务的依赖项状态。

摘要

在本章中,我们学习了如何使用日志来跟踪我们服务的状态。我们还学习了如何自定义日志提供程序以及如何将其与 ASP.NET Core 应用程序集成。此外,我们还处理了 ASP.NET Core 的新健康检查功能,并学习了如何构建自定义健康检查。

现在,你已经知道如何在 ASP.NET Core 应用程序中实现日志记录并创建自定义日志提供程序。你将能够使用这些技能来跟踪你的服务暴露的数据并监控你的网络服务实例的状态。

在下一章中,我们将学习如何使用 Azure 将我们的网络服务迁移到云端。

第十八章:在 Azure 上部署服务

在本章中,你将学习如何在 Microsoft Azure 上部署目录 Web 服务。尽管我们将重点关注 Microsoft Azure 云提供商,并且大部分说明将紧密关联该平台,但一些概念可以应用于多个云提供商:容器正在成为在云上构建和运行应用程序和 Web 服务的常见方式,因此,每个云提供商都提供略微不同的服务和产品来托管容器。本章不会深入探讨 Microsoft Azure;它将概述 Azure 云的Azure 容器实例ACI)和 Azure 应用服务功能。

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

  • Azure 入门

  • 将容器推送到 Azure 容器注册库

  • 配置 ACI

  • 配置应用服务

Azure 入门

如我们之前提到的,Microsoft Azure 是由微软构建的云平台。Azure 提供了广泛的 IT 产品、技术和集成工具。虚拟机、无服务器技术、数据库和机器学习管道只是它提供的产品中的一部分。在本章中,我们将重点关注 Azure 提供的一些服务,例如容器实例应用服务Azure SQL 数据库

让我们从创建订阅开始。当新用户首次创建 Azure 账户时,微软允许我们尝试 Azure 服务。您可以在 azure.microsoft.com/free/ 注册新的 Microsoft Azure 账户。

注册过程将要求您提供一些个人详细信息,以及有效的电话号码和有效的信用卡。默认情况下,微软提供 12 个月的流行免费服务,加上 30 天的 170 欧元 Azure 服务和 25+ 永久免费的服务。这使得新开发者或工程师能够轻松测试/学习如何使用 Azure 的一些新服务。

完成注册过程后,您将能够使用您刚刚创建的账户登录到 Azure 门户 (portal.azure.com)。

Azure CLI 是微软官方用于管理 Azure 资源的 CLI;它几乎适用于所有操作系统,并且是 Azure SDK 的一部分。Azure SDK 是跨平台的;因此,您可以通过三种不同的方式在 Windows、macOS 或 Linux 上安装它:

平台 命令 要求
Linux curl -L https://aka.ms/InstallAzureCli &#124; bash 您需要一些预先安装的软件,即 Python 2.7 或 Python 3.x(www.python.org/downloads/)、libffi(sourceware.org/libffi/)和 OpenSSL 1.0.2(www.openssl.org/source/)。有关更多信息,请访问docs.microsoft.com/en-us/cli/azure/install-azure-cli-linux?view=azure-cli-latest
macOS brew update && brew install azure-cli 您需要brew,它应该已经安装到您的机器上。有关brew的更多信息,请访问brew.sh/
Windows aka.ms/installazurecliwindows Microsoft 为 Windows 平台提供了一个 MSI。

Azure SDK 及其 CLI 提供了您管理 Azure 服务所需的所有命令行工具。让我们首先使用 CLI 进行登录。我们可以通过执行以下命令来完成此操作:

az login

上述命令将打开浏览器窗口,并将您带到 Microsoft Azure 门户的登录页面,并将会话保存到您的本地环境中。在下一节中,我们将看到如何使用 CLI 将我们在前几章中构建的容器镜像推送到 Azure 的容器注册表服务。

将容器推送到 Azure 容器注册表

在本节中,我们将专注于将我们的容器部署到 Microsoft Azure。这个过程涉及到一些云提供商提供的开箱即用的资源和服务。以下是我们将要构建的架构方案的概述图:

让我们看看参与此架构的不同组件:

  • Azure 容器注册表是一个基于开源 Docker Registry 2.0 的托管 Docker 注册表服务(docs.docker.com/registry/)。您可以使用 Azure 容器注册表来存储、管理和使用您的私有 Docker 容器镜像。我们将使用它来保存与我们的自定义镜像相关的图像,例如catalog_api镜像,并使它们可供其他云服务使用。

  • 容器 Web 应用允许我们使用我们的容器,并将它们作为 Web 应用部署到App Service。此外,它消除了耗时的基础设施管理任务,如更新、扩展以及在一般情况下管理基础设施。

  • 应用程序的其他依赖项,如catalog_esbcatalog_cache,将从公共 Docker Hub(hub.docker.com)获取镜像。

让我们继续创建一个 Azure 容器注册表。在本章的后续内容中,我们将使用该注册表来推送和拉取catalog_api的镜像。

创建 Azure 容器注册表

要创建一个新的 Azure 容器注册表,我们应该首先创建一个新的资源组。资源组是 Azure 资源管理中的一个基本概念:它们允许我们根据管理、部署和计费原因将一组资源分组在一起。一般来说,所有具有相同生命周期的资源都应该被分组到同一个资源组中。让我们开始吧:

  1. 首先,在 Azure CLI 中使用以下命令创建一个资源组:
az group create --name handsOn --location westeurope

之前的命令在我们的账户中创建了一个名为 handsOn 的新资源组,该资源组存储在西欧地区。

  1. 接下来,我们将通过执行以下命令来创建 Azure 容器注册表:
az acr create --resource-group handsOn --name <container_registry_name> --sku Basic

之前的命令在名为 handsOn 的资源组下创建了一个新的 Azure 容器注册表,并使用了我们选择的名称。它还定义了该资源的 库存单位 (SKU)——在我们的例子中,是基本版本。

SKU 通常指代产品的特定变体以及识别该类型产品的所有属性。同样,Microsoft Azure 使用这个术语来识别特定的可购买商品或服务。

现在我们已经创建了一个 Azure 容器注册表,让我们将 catalog_api 镜像推送到注册表中。为了解决我们容器的其他依赖项,我们将创建另一个 appsettings.json 文件,专门用于 Stage 环境。因此,我们将设置 ASPNETCORE_ENVIRONMENT 变量为 Stage 以应用容器所需的连接字符串。我们可以通过以下方式创建 appsettings.Stage.json 文件:

{
    "Logging": {
        "LogLevel": {
            "Default": "Warning"
        }
    },
    "AllowedHosts": "*",
    "DataSource": {
        "ConnectionString": "Server=catalog db.westeurope
        .azurecontainer.io;Database=master;User=sa;
        Password=P@ssw0rd"
    },
    "ESB": {
        "ConnectionString": "host=catalog-esb.westeurope.
         azurecontainer.io;username=guest;password=guest;",
        "EndPointName": "ItemSoldOut"
    },
    "CacheSettings": {
        "ConnectionString": "catalog-cache.westeurope.
         azurecontainer.io:6379,abortConnect=false"
    },
    "AuthenticationSettings": {
        "Secret": "<secret>",
        "ExpirationDays" : "7"
    }
}

之前的 appsettings.json 文件定义了 catalog_dbcatalog-esbcatalog-cache 容器的端点。每个端点都由我们将要创建的容器的名称,后跟语法——<region_name>.azurecontainer.io 组成。第一部分代表地区,接着是使用服务的子域,在我们的例子中是 azurecontainer.io。让我们继续定义将我们的本地镜像推送到之前创建的容器注册表的步骤:

  1. 让我们先使用以下命令在容器注册表中验证 Azure CLI:
az acr login --name <container_registry_name>

这应该在 CLI 中返回一个登录成功的消息。

  1. 之后,我们可以通过准备我们的服务 Docker 镜像并触发以下命令在 Catalog.API 文件夹中构建镜像来继续:
docker-compose build

这个指令基于我们在项目文件夹中已有的 Dockerfile 创建一个新的 Docker 镜像。镜像的名称将取决于 docker-compose.yml 文件中指定的名称以及 .env 文件中指定的 COMPOSE_PROJECT_NAME:如果 COMPOSE_PROJECT_NAMEstore,那么命令将创建一个名为 store_catalog_api 的镜像。

  1. 可以通过执行 docker images 命令来验证生成的镜像:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
catalog_api latest 714e538b7da5 About a minute ago 618MB
  1. 必须获取 Azure 容器注册表服务器的地址,以便我们可以将本地镜像推送到注册表。我们可以通过将我们刚刚创建的容器标记为之前创建的 Azure 容器注册表的地址来继续操作:
docker tag catalog_api <container_registry_name>.azurecr.io/catalog_api:v1
docker push <container_registry_name>.azurecr.io/catalog_api:v1

在标记镜像并使用docker push命令后,Docker 将开始将容器上传到我们的 Azure 容器存储库。因此,我们可以在 Azure 提供的所有服务中使用我们的容器镜像。此上传通常需要一些时间,具体取决于镜像的大小和您的互联网连接质量。上传完成后,可以通过浏览 Azure 门户的容器注册表部分来检查结果(portal.azure.com/):

图片

在这种情况下,我们可以看到,在handsOn资源组下创建了一个名为handsonaspnetcoreacr的容器注册表。最终,我们可以选择直接从门户创建或管理容器注册表。现在我们已经推送了容器,我们可以继续配置 ACI。

配置 Azure 容器实例

微软 Azure 的 ACI 服务为我们提供了一种快速简便的方式在云中运行容器,无需担心虚拟机的管理部分,也不必学习新的工具。此服务旨在尽可能快速,简化在云中启动和运行容器的过程。此外,可以通过执行简单的 Azure CLI 命令来启动容器,例如以下命令:

az container create --resource-group myResourceGroup \
 --name cache-container \
 --image redis:alpine \
 --cpu 1 \
 --memory 1 \
 --dns-name-label cache-container \
 --ports 6379 

ACI 服务是测试和运行 Azure 中容器的理想服务。因此,ACI 服务允许我们通过利用每秒计费来降低基础设施成本。因此,ACI 服务也是持续集成和持续管道目的的首选服务。以下步骤显示了如何在 ACI 上部署目录服务*:

  1. 让我们先创建一个新的资源组,以便我们可以分组我们的容器。使用以下命令来完成此操作:
az group create --name handsOnContainerServices --location "West Europe"
  1. 我们可以通过以下命令获取容器注册表的注册表用户名和密码,以继续操作:
az acr credential show -n <container_registry_name>
  1. 创建组后,我们需要使用 GitHub 仓库中名为aci-deploy.sh的 Bash 脚本来执行 Azure CLI 命令:
#!/bin/bash
# Set the service group name
export resource_group=handsOnContainerServices
# Set the registry address
export registry_address=<registry_address>
# Set the registry username
export registry_username=<registry_username>
# Set the registry password
export registry_password=<registry_password> # Set the api ASPNETCORE_ENVIRONMENT variables
export environment=Stage
# Set the sql container name
export sql_name=catalog-db
# Set the sql admin password
export sql_admin_pass=P@ssw0rd
# Set the event service bus name
export esb_name=catalog-esb
# Set the event service bus username
export rabbitmq_user=guest
# Set the event service bus password
export rabbitmq_pass=guest
# Set the cache container name
export cache_name=catalog-cache
# Set the service name
export api_name=catalog-api

az container create --resource-group ${resource_group} \
                    --location westeurope \
                    --name ${sql_name} \
                    --image microsoft/mssql-server-linux \
                    --cpu 1 \
                    --memory 1 \
                    --dns-name-label ${sql_name} \
                    --ports 1433 \
                    --environment-variables ACCEPT_EULA=Y SA_PASSWORD=${sql_admin_pass}

az container create --resource-group ${resource_group} \
                    --location westeurope \
                    --name ${esb_name} \
                    --image rabbitmq:3-management \
                    --cpu 1 \
                    --memory 1 \
                    --dns-name-label ${esb_name} \
                    --ports 5672 \
                    --environment-variables RABBITMQ_DEFAULT_USER=${rabbitmq_user} RABBITMQ_DEFAULT_PASS=${rabbitmq_pass} 

az container create --resource-group ${resource_group} \
                    --name ${cache_name} \
                    --image redis:alpine \
                    --cpu 1 \
                    --memory 1 \
                    --dns-name-label ${cache_name} \
                    --ports 6379 

az container create --resource-group ${resource_group} \
                    --location westeurope \
                    --name ${api_name} \
                    --image ${registry_address}/catalog_api:v1 \
                    --cpu 1 \
                    --memory 1 \
                    --dns-name-label ${api_name} \
                    --ports 80 \
                    --ip-address public \
                    --environment-variables ASPNETCORE_ENVIRONMENT=${environment} \
                    --registry-password=${registry_password} \
                    --registry-username=${registry_username}

脚本主要运行五个不同的指令来创建这些容器的新的实例:

    • 它声明有关容器的信息,例如资源组、分配给容器的名称以及一些额外的环境变量。

    • 它执行az container create命令来创建和运行microsoft/mssql-server-linux

    • 它执行 az container create 指令来创建和运行 rabbitmq:3-management-alpine 镜像,并使用 rabbitmq_userrabbitmq_pass 环境变量来设置 RabbitMQ 实例的默认用户。

    • 它使用 redis:alpine 部署 Redis 缓存实例。

    • 最后,它通过指定注册表 URL 来创建和部署已存在于 Azure 容器注册表存储库中的 catalog_api 镜像。

请注意,执行顺序遵循这些容器依赖项的逻辑;因此,API 容器是最后一个运行的。

注意,为了使演示尽可能简单,aci-deploy.sh 脚本使用 --ip-address public 创建目录服务容器,这意味着任何人都可以访问我们的容器。在生产环境中,强烈不建议直接暴露 API 而没有任何反向代理和 API 网关,以避免将容器暴露给外部世界。

现在我们已经执行了脚本,我们可以通过登录到 Azure 门户 (portal.azure.com/#) 并在容器实例部分检查我们的容器来查看结果:

图片

如您所见,有四个容器实例正在运行。它们都在使用 --dns-name-label 参数在 DNS 上运行,并且可以通过它们的地址相互访问。因此,可以使用由我们的 shell 脚本生成的地址调用容器 API。我们还可以通过单击容器的名称来检查与容器相关的统计信息和属性:

图片

最后,我们可以从浏览器中调用健康检查的 HTTP 路由来验证所有依赖项是否正确:

http://catalog-api.westeurope.azurecontainer.io/health

前面的过程描述了如何将目录服务部署到 ACI 产品中。尽管 ACI 功能强大且易于部署,但它们缺少一些最小化的开箱即用功能,例如 SSH、监控和配置管理。因此,在生产环境中管理容器实例变得困难。在下一节中,我们将关注不同的托管过程,该过程使用应用服务技术来托管名为应用服务的应用程序。这种方式更专注于 Web 应用程序和 Web 服务的托管;因此,它提供了一套用于监控和配置应用程序的工具和功能。

配置应用服务

ACI 的替代方案是应用服务。微软 Azure 最近发布了一个新功能,使我们能够使用应用服务部署 Docker 镜像。当你想要保持开发机器和生产环境相同的运行环境时,这种方法非常有用。与 ACI*相比,应用服务为我们提供了一个管理容器运行的方式。它自带一些开箱即用的功能,例如 SSL 加密、监控、配置管理、远程调试以及应用扩展设置。除此之外,应用服务与 Azure 的其他产品紧密集成。因此,将其他服务轻松地连接到catalog-srv成为可能。例如,我们可能会选择运行我们的 Azure SQL 数据库解决方案,为目录服务设置一个完全管理的 SQL 数据库。Azure SQL 提供了最广泛的 SQL Server 引擎兼容性;它使用你偏好的 SQL 工具简化了维护过程。

正如我们之前提到的,在不使用持久卷的情况下使用 Docker 集成持久数据存储并不容易。因此,在本节中,我们将探讨一种存储数据的替代方法。

让我们通过使用项目根目录中的azuresql-deploy.sh脚本创建一个新的 Azure SQL 数据库来继续操作:

#!/bin/bash

# Set an admin login and password for your database
export user_admin=catalog_srv
export user_pass=P@ssw0rd
# The logical server name has to be unique in the system
export server_name=storecatalogapi
export database_name=catalog
# The resource group name
export resourceGroup=handsOnAppService
# The ip address range that you want to allow to access your DB
export startip=0.0.0.0
export endip=0.0.0.0

# Create a resource group
az group create \
   --name ${resourceGroup} \
   --location westeurope

# Create a logical server in the resource group
az sql server create \
   --name ${server_name} \
   --resource-group ${resourceGroup} \
   --location westeurope  \
   --admin-user ${user_admin} \
   --admin-password ${user_pass}

# Configure a firewall rule and open to local Azure services
az sql server firewall-rule create \
   --resource-group ${resourceGroup} \
   --server ${server_name} \
   -n AllowYourIp \
   --start-ip-address ${startip} \
   --end-ip-address ${endip}

# Create a database in the server 
az sql db create \
   --resource-group ${resourceGroup} \
   --server ${server_name} \
   --name ${database_name} \
   --service-objective S0 \
    --zone-redundant false

在前面的脚本中,azuresql-deploy.sh文件创建了托管store数据库和包含目录信息的有效数据库的逻辑服务器。首先,脚本通过创建资源组开始;然后继续创建 Azure SQL 元素。由于防火墙规则中指定的start-ipend-ip都是0.0.0.0,因此该账户的所有 Azure 服务都可以连接到数据库。

通过这种方式,可以将之前创建的catalog-srv应用服务连接到数据库。

使用容器镜像创建应用服务

让我们逐步了解如何使用已发布并存在于 Azure 容器注册表中的镜像创建应用服务,即<registry_name>.azurecr.io/catalog_api:v1。作为第一步,我们需要使用以下命令创建一个应用服务计划:

az appservice plan create --name catalogServicePlan --resource-group <service_group_name> --sku FREE --is-linux

创建应用服务需要应用服务计划:它定义了一组用于运行同一计划中所有应用服务的计算资源。在这个例子中,我们将使用最基本的服务计划,可以使用以下标志指定:--sku FREE。此计划支持最多 10 个实例,并且不提供任何额外的自动扩展功能。

现在我们已经创建了所有必需的要求,我们可以通过执行位于项目根目录的appservice-deploy.sh文件来继续操作:

#!/bin/bash
# Set the service group name
export resource_group=handsOnAppService
# Set the plan
export plan=catalogServicePlan
# Set the service name
export app_service_name=catalog-srv
# Set the api ASPNETCORE_ENVIRONMENT variables
export environment=StageAppServices
# Defines the ACR registry URL
export registry_address=<registry_address>.azurecr.io

# Create the app service
az webapp create --resource-group ${resource_group} \
                             --plan ${plan} \
                             --name ${app_service_name} \
                             --deployment-container-image-name ${registry_address}/catalog_api:v1

# Set the ASPNETCORE_ENVIRONMENT variable
az webapp config appsettings set -g ${resource_group} \
                                 -n ${app_service_name} \
                                 --settings ASPNETCORE_ENVIRONMENT=${environment}

前面的脚本使用 az webapp create 指令创建了 Web 应用,并在应用服务创建完成后,通过执行 az webapp config appsettings set 命令来设置正确的 ASP.NET Core 环境值。一旦脚本执行完毕,我们可以在门户中检查应用服务的状态:

图片

此外,我们还可以通过调用健康检查 URL 来验证服务状态:http://catalog-api.westeurope.azurecontainer.io/health

摘要

在本章中,我们看到了如何在 Microsoft Azure 中托管和运行目录服务项目。我们还学习了如何创建私有 Azure 容器注册库以及如何存储目录服务的 Docker 镜像。然后,我们向您展示了您可以使用的一些模式将自定义容器部署到云端,以及如何使用 Microsoft Azure 云提供商提供的服务来运行它们。最后,我们探讨了两种托管目录服务的方法:使用 ACI 产品和 Azure App Service,以及使用 Azure SQL 服务来存储数据。

在下一章中,我们将学习如何通过实现 Swagger 框架来使用 OpenAPI 规范来记录 API。

第十九章:使用 Swagger 记录您的 API

在本章中,我们将学习如何使用 OpenAPI 规范记录我们的 API,以及如何使用 Swagger 工具解析和生成文档。当我们的 Web 服务被外部公司或外国组织团队消费时,记录 API 特别重要。此外,一些服务可能相当复杂,并公开大量端点。因此,一些与 .NET 生态系统相关的工具保证了 API 文档的更新。在此过程中可以使用两个主要工具链:NSwag 和 Swashbuckle。在本书中,我们将介绍并使用 NSwag 来记录我们的 API。

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

  • 理解 OpenAPI

  • 在 ASP.NET Core 服务中实现 OpenAPI

到本章结束时,您将能够使用 Swagger 和 NSwag 包自动生成符合 OpenAPI 规范的最新文档。

理解 OpenAPI

OpenAPI 创新是 Linux 基金会的一部分,并定义了 OpenAPI 规范OAS)标准。OpenAPI 规范旨在为 REST API 提供一个语言无关的接口。这种方法保证了人类和客户端应用程序可以通过参考一个唯一的入口点来理解和发现 Web 服务的功能。此外,它还提供了一个高级抽象,也可以用于商业或设计目的。

此外,其查询服务的标准方式简化了各种自动化——从客户端的自动生成到文档的自动生成。

实现 Swagger 项目

就像 OpenAPI 规范一样,Swagger 作为一个描述 REST API 的语言无关规范而诞生。它最近已被 OpenAPI 项目采用,这意味着这两个项目之间没有差异。

Swagger 的主要目标是自动生成并公开一个名为 swagger.json 的文档,也称为 Swagger 规范。Swagger 规范是 API 的自动生成文档,提供了关于 Web 服务公开的每个单独路由的信息。以下代码展示了示例 swagger.json 文件的结构:

{
  "x-generator": "NSwag v12.0.12.0 (NJsonSchema v9.13.15.0 (Newtonsoft.Json v12.0.0.0))",
  "swagger": "2.0",
  "host": "localhost:5000",
  "schemes": [
    "http"
  ],
  "consumes": [
    "application/json"
  ],
  "paths": {
    "/api/artist": {
      "get": {
        "tags": [
          "Artist"
        ],
        "operationId": "Artist_Get",
        "parameters": [
          {
            "type": "string",
            "name": "artistId",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "schema": {
              "type": "file"
            }
          }
        }
...

上述代码片段描述了在目录服务API 中定义的一些路由。正如你所见,在 JSON 的第一级中,有一些关于服务的一般信息,例如服务的apiVersiontitlebasePath。此外,我们还可以看到一个名为paths的节点,它包含我们服务的所有路径。对于每个路由,它描述了不同的响应类型、不同的 HTTP 动词以及服务接受的全部有效负载信息。由于我们有一个独特的标准来描述我们的 API,因此也可以定义一个独特的用户界面,以便我们可以查询并向服务发送信息;这就是Swagger UI的作用。Swagger UI 是一个使用swagger.json文件提供用户友好 UI 的工具:

图片

上一张截图显示了我们可以用来浏览 API 公开的不同路由的有用 UI 示例。此外,它还允许消费者立即全面了解 API 提供的数据。现在,让我们学习如何在 ASP.NET Core 中实现 OpenAPI。

在 ASP.NET Core 服务中实现 OpenAPI

我们可以使用两个不同的包在 ASP.NET Core 中实现 OpenAPI:

这两个都使用中间件来生成和提供swagger.json文件,并允许用户界面浏览服务定义。在本节中,我们将讨论如何将 NSwag 集成到我们的 vinyl 目录服务中。以下架构显示了 NSwag 是如何集成到我们的 ASP.NET Core 服务中的:

图片

让我们从通过以下命令将NSwag.AspNetCore添加到Catalog*.*API项目开始:

dotnet add package NSwag.AspNetCore

之后,我们可以通过结合生成和提供 OpenAPI 规范的中介件以及初始化 UI 的中介件来继续操作。正如我们在第三章“与中介件管道一起工作”中看到的,我们需要使用在Startup类中实现的ConfigureConfigureServices方法:

...
    public class Startup
    {

        public void ConfigureServices(IServiceCollection services)
        {
            services
                .AddCatalogContext(Configuration.
                GetSection("DataSource:ConnectionString").Value);

            services
               ..
                .AddOpenApiDocument(settings =>{
                                       settings.Title = "Catalog API";
                                       settings.DocumentName = "v3";
                                       settings.Version = "v3";                                   
                                    });
        }

        public void Configure(IApplicationBuilder app, 
        IHostingEnvironment env)
        {
            ...
             app
                .UseOpenApi()
 .UseSwaggerUi3();
        }
    }
}

AddOpenApiDocument 添加了生成 OpenAPI 3.0 所需的服务。UseOpenApi 添加了 OpenAPI/Swagger 生成器,它使用 API 描述来执行 Swagger 生成,而 UseSwaggerUi3 创建并实例化提供 Swagger UI 的中间件。由于我们已经将 OpenAPI 中间件集成到我们的服务中,我们可以通过运行我们的服务并使用我们首选的浏览器浏览 https://localhost/swagger URL 来继续操作。

NSwag 和 Swashbuckle 使用反射来浏览我们控制器内的操作方法。幸运的是,这个过程只在服务第一次运行时执行。有时,复杂的响应类型可能会阻止生成 swagger.json 文件。因此,强烈建议您检查我们控制器操作方法提供的所有响应类型。

NSwag 还提供了一些有用的工具,以便我们可以在我们的 Web 服务上执行代码生成,例如以下这些:

这些允许我们分别为 C# 和 Typescript 自动生成客户端类。

在本节中,我们学习了如何安装和配置 NSwag,以便我们可以公开与 OpenAPI 规范兼容的 Swagger 文档。在下一节中,我们将学习如何显式定义我们 API 的约定,以及如何在 swagger.json 合同中包含额外的信息。

理解 ASP.NET Core 的约定

Swagger UI 的默认响应类型会产生一些错误信息。如果我们查看响应部分,我们会看到响应代码是不正确的,并且它与由 Web 服务返回的实际 HTTP 代码不对应。当使用 ASP.NET Core 2.2 或更高版本时,可以使用约定来指定响应类型:

..
    [ApiController]
    public class ItemController : ControllerBase
    {
        [HttpGet]
        [ApiConventionMethod(typeof(DefaultApiConventions), 
        nameof(DefaultApiConventions.Get))]
        public async Task<IActionResult> Get([FromQuery] int pageSize = 
        10, [FromQuery] int pageIndex = 0)

        [HttpGet("{id:guid}")]
        [ApiConventionMethod(typeof(DefaultApiConventions), 
        nameof(DefaultApiConventions.Get))]
        public async Task<IActionResult> GetById(Guid id)
      ...

例如,前面的代码使用 ApiConventionMethod 属性传递一个自定义类型和方法名称。ApiConventionMethod 属性是 Microsoft.AspNetCore.Mvc 命名空间的一部分,并使用 DefaultApiConventions 静态类,它为通用 API 中的每个操作提供一组默认约定。同样,我们还可以将此属性添加到 ItemController 的写入方法中,例如 CreateUpdateDelete 方法:

        ...

        [HttpPost]
        [ApiConventionMethod(typeof(DefaultApiConventions), 
        nameof(DefaultApiConventions.Create))]
        public async Task<IActionResult> Create(AddItemRequest request)

        [HttpPut("{id:guid}")]
        [ApiConventionMethod(typeof(DefaultApiConventions), 
        nameof(DefaultApiConventions.Update))]
        public async Task<IActionResult> Update(Guid id, 
        EditItemRequest request)

        [HttpDelete("{id:guid}")]
        [ApiConventionMethod(typeof(DefaultApiConventions), 
        nameof(DefaultApiConventions.Delete))]
        public async Task<IActionResult> Delete(Guid id)
    }
}

这种方法是一种快捷方式,我们可以用它来声明操作方法响应,而无需显式使用 ProducesResponseType 属性。让我们看看 DefaultApiConventions 静态类,如果我们声明一些静态 void 方法,它将提供一组默认响应类型:

using Microsoft.AspNetCore.Mvc.ApiExplorer;

namespace Microsoft.AspNetCore.Mvc
{
  public static class DefaultApiConventions
  {
    [ProducesResponseType(200)]
 [ProducesResponseType(404)]
 [ProducesDefaultResponseType]
 [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
 public static void Get([ApiConventionNameMatch
    (ApiConventionNameMatchBehavior.Suffix), ApiConventionTypeMatch(
    ApiConventionTypeMatchBehavior.Any)] object id)
 {
 }

    ...
  }
}

例如,对于 Get 方法,它声明了 HTTP 200 OK 响应和 HTTP 404 Not found。通过这样做,我们可以轻松地为每个操作声明适当的响应类型。DefaultApiConventions 类是 Microsoft.AspNetCore.Mvc 命名空间的一部分。

自定义约定

DefaultApiConvention 类并不总是适合我们的控制器。此外,它过于通用,操作方法通常过于具体,不适合 DefaultApiConvention 类。因此,ASP.NET Core 允许我们根据我们的需求创建自定义的 API 约定。要声明一个新的约定,我们需要创建一个新的静态类,其中包含相应的静态方法,如下所示:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;

namespace Catalog.API.Conventions
{
    public static class ItemApiConvention
    {

        [ProducesResponseType(200)]
        [ProducesResponseType(404)]
        [ProducesResponseType(400)]
        [ProducesDefaultResponseType]
        [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
        public static void Get([ApiConventionNameMatch
        (ApiConventionNameMatchBehavior.Suffix),
                                ApiConventionTypeMatch
                                (ApiConventionTypeMatchBehavior.Any)]
                                 object id)
        {
        }

        ...
    }
}

我们在这里实现的约定描述了 ItemControllerGet 操作方法。如您所见,此方法产生以下 HTTP 响应:200404400。这种方法还允许我们生成和扩展由路由返回的响应类型。此外,我们可以通过以下方式应用属性来分配和使用这些约定:

[HttpGet]
[ApiConventionMethod(typeof(ItemApiConvention), nameof(ItemApiConvention.Get))]
public async Task<IActionResult> Get([FromQuery] int pageSize = 10, [FromQuery] int pageIndex = 0)
{

    ...
}

这种方法使我们能够将 API 约定自定义并分组到一个独特的类中,并完全自定义 API 的合同。同样的方法也可以用于您服务中控制器类中存在的其他操作方法。

摘要

在本章中,我们学习了如何通过使用 OpenAPI 规范来记录 Web 服务,从而提高其可发现性。OpenAPI 技术还为我们提供了一种标准方式,以生成每种语言的客户端并生成自动维护的文档。当服务被第三方团队和消费者使用时,记录 API 是有用的,并且它还为我们提供了服务公开的信息和动作的高级概述。

在下一章中,我们将学习关于 Postman 以及如何使用它来查询、测试和检查 Web 服务的响应。

第二十章:使用 Postman 测试服务

Postman 是我们可用于查询 API 的最强大的工具之一,主要面向开发者和企业团队。在本章中,我们将介绍 Postman 并展示如何使用此工具测试和监控 Web 服务。Postman 被描述为以 API 为首的解决方案,拥有行业唯一的完整 API 开发环境。接下来,我们将学习如何使用 Postman 调用通用 HTTP API。然后,我们将学习如何将上一章中生成的 OpenAPI 规范导入 Postman,以自动生成在目录服务中实现的 API 调用。

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

  • Postman 概述

  • 使用 OpenAPI 导入集合

在本章结束时,你将学会如何使用 Postman 通过 Postman UI 和自动化测试工具来测试和验证 API 响应。

Postman 概述

Postman 可以在公司内部使用,用于共享内部 API,并应用集合的概念,以便它可以对相关服务或服务集合进行分组、索引和查询。集合是一组与同一服务或服务集合相关的 HTTP 调用。

以下截图显示了 Postman 的传统 UI。在左侧,你可以看到 Postman 账户中可用的 集合 列表,而在右侧,你可以看到 UI 的核心部分:

图片

UI 的前半部分代表我们打算发起的 HTTP 请求。界面上方的每个标签页代表一个 API 调用;可以在左侧输入 URL 并指定 HTTP 动词。对于每个请求,还可以指定查询参数、授权规范、头部、正文以及在我们每次请求前后要执行的脚本。

在这里,请求使用 {{baseUrl}}/api/artists/:id URL 并传递 id:<guid_value>。需要注意的是,在 Postman 中,可以指定环境变量,这些变量可以用于参数化我们的 API 查询。在这种情况下,{{baseUrl}} 占位符被替换为集合中指定的值。可以通过单击集合名称旁边的三个点、选择编辑并导航到变量选项卡来查看集合变量:

图片

Postman 界面的第二部分专注于请求的响应。主要部分包含 API 的 JSON 响应:

图片

在右侧,我们有响应的状态、响应时间和响应大小。此外,还可以切换响应的格式,使其为 JSON、HTML 或纯文本。响应部分还提供了有关头部、Cookies 和测试结果的详细信息。

在下一节中,我们将学习如何使用 Postman 的工具自动测试我们的 API。

使用 Postman 测试 API

Postman 还为我们提供了一个有用的工具/框架,我们可以用它来自动测试我们的 API。测试运行时基于 Node.js;因此,我们应该使用 JavaScript 编写测试断言。Postman 的测试框架使用以下工作流程来测试我们的代码:

对于集合中的每个请求,脚本将按以下方式执行:

  • 在集合中的每个请求之前都会运行一个预请求脚本。这被认为是测试用例的设置部分。

  • 在集合中的每个请求之后都会运行一个测试脚本;这是我们的测试的核心部分。此外,它还提供了我们测试的核心断言。

让我们使用这个程序来实现我们 API 的简单测试:

pm.test("response is ok", function () {
    pm.response.to.have.status(200);
});

如您所见,测试使用 pm 全局对象来描述一个测试,并添加一个回调函数来进行一些断言。在这种情况下,它检查 API 是否具有 200 OK 状态。以下实现可以通过在请求的测试标签页中输入前面的代码来添加到请求中,如下面的屏幕截图所示:

之后,我们将在 Postman 的响应部分检查结果:

前一个屏幕截图中所显示的测试结果部分包含四个不同的子标签页,所有这些标签页都将测试按结果分组。如果发生错误,UI 将提供错误详情。

使用运行器测试 API

Postman 团队构建了一个运行器工具,可以自动测试和运行您的 API,这样您就可以——字面意义上——坐下来观看您的 API 进行测试。此外,您可以使用脚本构建集成测试套件,在 API 请求之间传递数据,并构建反映您实际 API 用例的工作流程。

运行器功能要求至少有一个测试与请求相关联,以便它可以断言结果。可以通过点击屏幕左上角的运行器按钮(如下一个屏幕截图中的黄色矩形所示),并选择要运行的集合来访问 Postman 的运行器界面:

Postman 还允许我们使用 CLI 集成这些功能。开发团队发布了一个 npm 包,用于在本地主机环境中执行运行器。您可以使用以下命令在您的本地计算机上安装此包:

npm install -g newman

之后,可以通过传递表示集合的 JSON 文件并执行前面的命令来在特定的集合上执行运行器。可以通过点击集合旁边的三个点并点击导出选项来导出集合:

然后,我们可以通过在文件上执行以下命令来继续:

newman run examples/sample-collection.json

结果执行将类似于以下内容:

此实现提供了每个测试请求的单一详细响应以及显示所有与测试相关的信息的最终报告。当我们将 Postman 的测试功能集成到持续集成管道中时,这种方法非常有用。我们只需要在我们的服务器上安装 newman 工具并添加收集的路径。

在下一节中,我们将学习如何使用 OpenAPI 规范(在上一章中描述)来自动生成 Postman 收集。

使用 OpenAPI 导入收集

在本节中,您将学习如何使用 OpenAPI 规范导入收集。Postman 使用一些常见的 API 描述标准,例如 OpenAPI v3、Swagger v2 和 v1(如前一章所述),以导入 Web 服务的路由。让我们开始吧:

  1. 首先,点击屏幕左上角的导入(显示在黄色矩形中)按钮,然后点击“从链接导入”标签:

  1. 现在,我们可以复制并粘贴正在运行的 localhost 服务的文档 API 的 URL,如下所示:
http://localhost:5000/swagger/v3/swagger.json

通过这样做,收集将以我们给 Swagger 文档相同的名称导入。这将包含 Swagger 描述的所有路由的选择。

摘要

在本章中,我们学习了如何使用 Postman 查询、测试和监控 Web 服务。Postman 的功能远不止于此;然而,我们还可以与其他团队和我们的消费者共享 API 收集。如果 API 消费者希望检查所消费服务提供的成果和信息,它是一个必不可少的工具。

您现在拥有了使用 Postman 套件测试和验证 Web 服务的必要知识。

posted @ 2025-10-21 10:42  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报