C-7-和--NET-Core-2-0-高性能-全-

C#7 和 .NET Core 2.0 高性能(全)

原文:zh.annas-archive.org/md5/7B34F69B3C37FC27C73A3C065B05D042

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书首先介绍了 C# 7 和.NET Core 2.0 的新功能,以及它们如何帮助提高应用程序的性能。然后,本书将帮助您了解.NET Core 的核心内部,包括编译过程、垃圾回收、利用 CPU 的多个核心来开发高性能应用程序,以及使用强大的基准测试应用程序库 BenchmarkDotNet 来测量性能。我们将学习使用多线程和异步编程来开发应用程序和程序,以及如何利用这些概念构建高效的应用程序以实现更快的执行。接下来,您将了解数据结构优化的重要性以及如何有效地使用它。我们将继续讨论在.NET Core 中设计应用程序时使用的模式和最佳实践,以及如何有效地利用内存并避免内存泄漏。之后,我们将讨论在.NET Core 应用程序中实现安全性和弹性,并使用 Polly 框架来实现断路器、重试和回退模式,以及某些中间件来加固 HTTP 管道。我们还将使用 Identity 框架实现授权和身份验证等安全性。接下来,我们将学习微服务架构,以及如何使用它创建模块化、高度可扩展和独立部署的应用程序。最后,我们将学习如何使用 App Metrics 来监控应用程序的性能。

这本书适合谁

这本书适合.NET 开发人员,他们希望提高应用程序代码的速度,或者只是想将自己的技能提升到下一个水平,从而开发和生产不仅性能优越,而且符合行业最佳实践的高质量应用程序。假定具有基本的 C#知识。

本书涵盖的内容

第一章,在.NET Core 2 和 C# 7 中的新功能,讨论了.NET Core 框架,并涵盖了.NET Core 2.0 引入的一些改进。我们还将了解 C# 7 的新功能,以及如何编写更干净的代码和简化语法表达。最后,我们将涵盖编写高质量代码的主题。我们将看到如何利用 Visual Studio 2017 的代码分析功能向我们的项目添加分析器并改进代码质量。

第二章,了解.NET Core 内部和测量性能,讨论了.NET Core 的核心概念,包括编译过程、垃圾回收、利用 CPU 的多个核心来构建高性能的.NET Core 应用程序,以及使用发布版本构建发布应用程序。我们还将探讨用于代码优化的基准测试工具,并提供特定于内存对象的结果。

第三章,在.NET Core 中进行多线程和异步编程,探讨了多线程和异步编程的核心基础知识。本章从多线程和异步编程之间的基本区别开始,并引导您了解核心概念。它探讨了 API 以及在编写多线程应用程序时如何使用它们。我们将学习如何使用任务编程库来执行异步操作,以及如何实现任务异步模式。最后,我们将探讨并行编程技术以及一些最佳的设计模式。

第四章,“C#中的数据结构和编写优化代码”,概述了数据结构的核心概念、数据结构的类型以及它们的优缺点,然后介绍了每种数据结构适用的最佳场景。我们还将学习大 O 符号,这是编写代码时需要考虑的核心主题之一,有助于开发人员检查代码质量和性能。最后,我们将探讨一些最佳实践,并涵盖诸如装箱和拆箱、字符串连接、异常处理、forforeach以及委托等主题。

第五章,“.NET Core 应用程序性能设计指南”,展示了一些使应用程序代码看起来整洁且易于理解的编码原则。如果代码整洁,它可以让其他开发人员完全理解,并在许多其他方面有所帮助。我们将学习一些基本的设计原则,这些原则被认为是设计应用程序时的核心原则的一部分。像 KISS、YAGNI、DRY、关注点分离和 SOLID 这样的原则在软件设计中非常重要,缓存和选择正确的数据结构对性能有重大影响,如果使用得当,可以提高性能。最后,我们将学习在处理通信、资源管理和并发时应考虑的一些最佳实践。

第六章,“.NET Core 中的内存管理技术”,概述了.NET 中内存管理的基本过程。我们将探索调试工具,开发人员可以使用它来调查堆上对象的内存分配。我们还将了解内存碎片化、终结器以及如何通过实现IDisposable接口来实现处理模式以清理资源。

第七章,“在.NET Core 应用程序中实现安全和弹性”,带您了解弹性,这是在.NET Core 中开发高性能应用程序时非常重要的因素。我们将学习不同的策略,并使用 Polly 框架在.NET Core 中使用这些策略。我们还将了解安全存储机制以及如何在开发环境中使用它们,以便将敏感信息与项目存储库分开。在本章末尾,我们将学习一些安全基础知识,包括 SSL、CSRF、CORS、安全标头和 ASP.NET Core 身份框架,以保护 ASP.NET Core 应用程序。

第八章,“微服务架构”,着眼于基于微服务的快速发展的软件架构,用于开发云端高性能和可扩展的应用程序。我们将学习微服务架构的一些核心基础知识、其优势以及在设计架构时使用的模式和实践。我们将讨论将企业应用程序分解为微服务架构风格时面临的挑战,并学习诸如 API 组合和 CQRS 之类的模式以解决这些挑战。在本章后期,我们将在.NET Core 中开发一个基本应用程序,并讨论解决方案的结构和微服务的组件。然后我们将开发身份和供应商服务。

第九章,使用工具监视应用程序性能,深入探讨了监视应用程序性能所必需的关键性能指标。我们将探索并设置 App Metrics,这是一个跨平台的免费工具,提供各种扩展,可用于实现广泛的报告。我们将逐步指南地介绍如何配置和设置 App Metrics 及相关组件,如 InfluxDb 和 Grafana,用于在 Grafana 基于 Web 的工具中存储和查看遥测,并将其与 ASP.NET Core 应用程序集成。

为了充分利用本书

读者应具备以下环境配置:

  1. 开发环境:Visual Studio 2015/2017 社区版

  2. 执行环境:.NET Core

  3. 操作系统环境:Windows 或 Linux

下载示例代码文件

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

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

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

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

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/C-Sharp-7-and-NET-Core-2-High-Performance/。如果代码有更新,将在现有的 GitHub 存储库中进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/CSharp7andNETCore2HighPerformance_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统上的另一个磁盘。”

代码块设置如下:

public static IWebHost BuildWebHost(string[] args) => 
  WebHost.CreateDefaultBuilder(args) 
    .UseMetrics() 
    .UseStartup<Startup>() 
    .Build(); 

任何命令行输入或输出都以以下形式书写:

Install-Package App.Metrics 
Install-Pacakge App.Metrics.AspnetCore.Mvc 

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。例如:“从管理面板中选择系统信息。”

警告或重要说明会以这种形式出现。

提示和技巧会以这种形式出现。

第一章:.NET Core 2 和 C# 7 中的新功能是什么?

.NET Core 是微软的一个跨平台开发平台,由微软和 GitHub 社区维护。由于其性能和平台可移植性,它是开发社区中最新兴和最受欢迎的框架。它面向每个开发人员,可以为包括 Web、云、移动、嵌入式和物联网在内的任何平台开发任何应用程序。

使用.NET Core,我们可以使用 C#、F#,现在也可以使用 VB.NET。然而,C#是开发人员中最广泛使用的语言。

在本章中,您将学习以下主题:

  • .NET Core 2.0 中的性能改进

  • 从.NET Core 1.x 升级到 2.0 的路径

  • .NET 标准 2.0

  • ASP.NET Core 2.0 带来了什么

  • C# 7.0 中的新功能

.NET 的演变

在 2002 年初,当微软首次推出.NET Framework 时,它面向的是那些在经典 ASP 或 VB 6 平台上工作的开发人员,因为他们没有任何引人注目的框架来开发企业级应用程序。随着.NET Framework 的发布,开发人员有了一个可以选择 VB.NET、C#和 F#中的任何一种语言来开发应用程序的平台。无论选择哪种语言,代码都是可互操作的,开发人员可以创建一个 VB.NET 项目并在其 C#或 F#项目中引用它,反之亦然。

.NET Framework 的核心组件包括公共语言运行时CLR)、框架类库FCL)、基类库BCL)和一组应用程序模型。随着新版本的.NET Framework 的推出,新功能和补丁也随之引入,这些新功能和补丁通常随着 Windows 的新版本一起发布,开发人员必须等待一年左右才能获得这些改进。微软的每个团队都在不同的应用程序模型上工作,每个团队都必须等待新框架发布的日期来移植他们的修复和改进。当时主要使用的应用程序模型是 Windows Forms 和 Web Forms。

当 Web Forms 首次推出时,它是一个突破,吸引了既在经典 ASP 上工作的 Web 开发人员,又在 Visual Basic 6.0 上工作的桌面应用程序开发人员。开发人员体验非常吸引人,并提供了一套不错的控件,可以轻松地拖放到屏幕上,然后跟随它们的事件和属性,这些属性可以通过视图文件(.aspx)或代码后台文件进行设置。后来,微软推出了模型视图控制器MVC)应用程序模型,实现了关注点分离设计原则,因此视图、模型和控制器是独立的实体。视图是呈现模型的用户界面,模型代表业务实体并保存数据,控制器处理请求并更新模型,并将其注入视图。MVC 是一个突破,让开发人员编写更干净的代码,并使用模型绑定将其模型与 HTML 控件绑定。随着时间的推移,添加了更多功能,核心.NET web 程序集System.Web变得非常庞大,包含了许多包和 API,这些 API 并不总是在每种类型的应用程序中都有用。然而,随着.NET 的推出,引入了一些重大变化,System.Web被拆分为 NuGet 包,可以根据需求引用和单独添加。

.NET Core(代号.NET vNext)首次在 2014 年推出,以下是使用.NET Core 的核心优势:

好处 描述
跨平台 .NET Core 可以在 Windows、Linux 和 macOS 上运行
主机无关 .NET Core 在服务器端不依赖于 IIS,并且可以作为控制台应用程序进行自托管,并且可以通过反向代理选项与成熟的服务器(如 IIS、Apache 等)结合使用,还有两个轻量级服务器KestrelWebListener
模块化 以 NuGet 包的形式发布
开源 整个源代码通过.NET 基金会作为开源发布
CLI 工具 用于从命令行创建、构建和运行项目的命令行工具

.NET Core 是一个跨平台的开源框架,实现了.NET 标准。它提供了一个称为.NET Core CLR 的运行时,框架类库,即称为CoreFX的基本库,以及类似于.NET Framework 的 API,但依赖较少(对其他程序集的依赖较少):

.NET Core 提供了以下灵活的部署选项:

  • 基于框架的部署(FDD):需要在机器上安装.NET Core SDK

  • 自包含部署(SCD):在机器上不需要安装.NET Core SDK,.NET Core CLR 和框架类库是应用程序包的一部分

要安装.NET Core 2.0,您可以转到以下链接www.microsoft.com/net/core并查看在 Windows、Linux、MAC 和 Docker 上安装的选项。

.NET Core 2.0 的新改进

最新版本的.NET Core,2.0,带来了许多改进。.NET Core 2.0 是有史以来最快的版本,可以在包括各种 Linux 发行版、macOS(操作系统)和 Windows 在内的多个平台上运行。

Distros 代表 Linux 发行版(通常缩写为 distro),它是基于 Linux 内核和通常是一个软件集合的操作系统。

性能改进

.NET Core 更加健壮和高性能,并且由于其开源,微软团队与其他社区成员正在带来更多的改进。

以下是.NET Core 2.0 的改进部分。

.NET Core 中的 RyuJIT 编译器

RyuJIT 是一种全新的 JIT 编译器,是对即时JIT)编译器的完全重写,并生成更高效的本机机器代码。它比之前的 64 位编译器快两倍,并提供 30%更快的编译速度。最初,它只在 X64 架构上运行,但现在也支持 X86,开发人员可以同时为 X64 和 X86 使用 RyuJIT 编译器。.NET Core 2.0 在 X86 和 X64 平台上都使用 RyuJIT。

基于配置文件的优化

基于配置文件的优化PGO)是 C++编译器使用的一种编译技术,用于生成优化的代码。它适用于运行时和 JIT 的内部本机编译组件。它分两步进行编译,如下所示:

  1. 它记录了有关代码执行的信息。

  2. 根据这些信息,它生成了更好的代码。

以下图表描述了代码的编译生命周期:

在.NET Core 1.1 中,微软已经为 Windows X64 架构发布了 PGO,但在.NET Core 2.0 中,这已经添加到了 Windows X64 和 X86 架构。此外,根据观察结果,实际的启动时间主要由 Windows 的coreclr.dllclrjit.dll占用。而在 Linux 上,分别是libcoreclr.solibclrjit.so

将 RyuJIT 与旧的 JIT 编译器 JIT32 进行比较,RyuJIT 在代码生成方面更加高效。JIT32 的启动时间比 RyuJIT 快,但代码效率不高。为了克服 RyuJIT 编译器的初始启动时间,微软使用了 PGO,这使得性能接近 JIT32 的性能,并在启动时实现了高效的代码和性能。

对于 Linux,每个发行版的编译器工具链都不同,微软正在开发一个单独的 Linux 版本的.NET,该版本使用适用于所有发行版的 PGO 优化。

简化的打包

使用.NET Core,我们可以从 NuGet 向我们的项目添加库。所有框架和第三方库都可以作为 NuGet 包添加。对于引用了许多库的大型应用程序,逐个添加每个库是一个繁琐的过程。.NET Core 2.0 简化了打包机制,并引入了可以作为一个单一包添加的元包,其中包含了所有与之链接的程序集。

例如,如果你想在.NET Core 2.0 中使用 ASP.NET Core,你只需要添加一个单一的包Microsoft.AspNetCore.All,使用 NuGet。

以下是将此包安装到你的项目中的命令:

Install-Package Microsoft.AspNetCore.All -Version 2.0.0

从.NET Core 1.x 升级到 2.0 的路径

.NET Core 2.0 带来了许多改进,这是人们想要将他们现有的.NET Core 应用程序从 1.x 迁移到 2.0 的主要原因。然而,在这个主题中,我们将通过一个清单来确保平稳迁移。

1. 安装.NET Core 2.0

首先,在你的机器上安装.NET Core 2.0 SDK。它将在你的机器上安装最新的程序集,这将帮助你执行后续步骤。

2. 升级 TargetFramework

这是最重要的一步,也是需要在.NET Core 项目文件中升级不同版本的地方。由于我们知道,对于.csproj类型,我们没有project.json,要修改框架和其他依赖项,我们可以使用任何 Visual Studio 编辑器编辑现有项目并修改 XML。

需要更改的 XML 节点是TargetFramework。对于.NET Core 2.0,我们需要将TargetFramework修改为netcoreapp2.0,如下所示:

<TargetFramework>netcoreapp2.0</TargetFramework>

接下来,你可以开始构建项目,这将升级.NET Core 依赖项到 2.0。然而,仍然有一些可能仍然引用旧版本的依赖项,需要使用 NuGet 包管理器显式地进行升级。

3. 更新.NET Core SDK 版本

如果你的项目中已经添加了global.json,你需要将 SDK 版本更新为2.0.0,如下所示:

{ 
  "sdk": { 
    "version": "2.0.0" 
  } 
} 

4. 更新.NET Core CLI

.NET Core CLI 也是你的.NET Core 项目文件中的一个重要部分。在迁移时,你需要将DotNetCliToolReference的版本升级到2.0.0,如下所示:

<ItemGroup> 
  <DotNetCliToolReference Include=
  "Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="2.0.0" /> 
</ItemGroup> 

根据你是否使用 Entity Framework Core、User Secrets 等,可能会添加更多的工具。你需要更新它们的版本。

ASP.NET Core Identity 的更改

ASP.NET Core Identity 模型已经进行了一些改进和更改。一些类已经更名,你可以在以下链接找到它们:docs.microsoft.com/en-us/aspnet/core/migration

探索.NET Core CLI 和新项目模板

命令行界面CLI)是一个非常流行的工具,几乎在所有流行的框架中都有,比如 Yeoman Generator,Angular 等。它使开发人员能够执行命令来创建、构建和运行项目,恢复包等。

.NET CLI 提供了一组命令,可以从命令行界面执行,用于创建.NET Core 项目,恢复依赖项,构建和运行项目。在幕后,Visual Studio 2015/2017 和 Visual Studio Code 甚至使用这个工具来执行开发人员从他们的 IDE 中采取的不同选项;例如,要使用.NET CLI 创建一个新项目,我们可以运行以下命令:

dotnet new 

它将列出可用的模板和在创建项目时可以使用的简称。

以下是包含可以使用.NET Core CLI 创建/脚手架项目的项目模板列表的屏幕截图:

通过运行以下命令,将创建一个新的 ASP.NET Core MVC 应用程序:

dotnet new mvc 

以下屏幕截图显示了在运行上述命令后新的 MVC 项目的配置。它在运行命令的同一目录中创建项目并恢复所有依赖项:

要安装 .NET Core CLI 工具集,有一些适用于 Windows、Linux 和 macOS 的本机安装程序可用。这些安装程序可以在您的计算机上安装和设置 .NET CLI 工具,并且开发人员可以从 CLI 运行命令。

以下是提供在 .NET Core CLI 中的命令及其描述的列表:

命令 描述 示例
new 根据所选模板创建新项目 dotnet new razor
restore 恢复项目中定义的所有依赖项 dotnet restore
build 构建项目 dotnet build
run 在不进行任何额外编译的情况下运行源代码 dotnet run
publish 将应用程序文件打包到一个文件夹中以进行部署 dotnet publish
test 用于执行单元测试 dotnet test
vstest 执行指定文件中的单元测试 dotnet vstest [<TEST_FILE_NAMES>]
pack 将代码打包成 NuGet 包 dotnet pack
migrate 将 .NET Core 预览 2 迁移到 .NET Core 1.0 dotnet migrate
clean 清理项目的输出 dotnet clean
sln 修改 .NET Core 解决方案 dotnet sln
help 显示可通过 .NET CLI 执行的命令列表 dotnet help
store 将指定的程序集存储在运行时包存储中 dotnet store

以下是一些项目级别的命令,可用于添加新的 NuGet 包、删除现有的 NuGet 包、列出引用等:

命令 描述 示例
add package 向项目添加包引用 dotnet add package Newtonsoft.Json
remove package 从项目中删除包引用 dotnet remove package Newtonsoft.Json
add reference 向项目添加项目引用 dotnet add reference chapter1/proj1.csproj
remove reference 从项目中删除项目引用 dotnet remove reference chapter1/proj1.csproj
list reference 列出项目中的所有项目引用 dotnet list reference

以下是一些常见的 Entity Framework Core 命令,可用于添加迁移、删除迁移、更新数据库等。

命令 描述 示例
dotnet ef migrations add 添加新的迁移 dotnet ef migrations add Initial- Initial 是迁移的名称
dotnet ef migrations list 列出可用的迁移 dotnet ef migrations list
dotnet ef migrations remove 删除特定的迁移 dotnet ef migrations remove Initial- Initial 是迁移的名称
dotnet ef database update 将数据库更新到指定的迁移 dotnet ef database update Initial- Initial 是迁移的名称
dotnet ef database drop 删除数据库 dotnet ef database drop

以下是一些服务器级别的命令,可用于从机器中删除 NuGet 包的实际源存储库,将 NuGet 包添加到机器上的实际源存储库等:

命令 描述 示例
nuget delete 从服务器中删除包 dotnet nuget delete Microsoft.AspNetCore.App 2.0
nuget push 将包推送到服务器并发布 dotnet nuget push foo.nupkg
nuget locals 列出本地 NuGet 资源 dotnet nuget locals -l all
msbuild 构建项目及其所有依赖项 dotnet msbuild
dotnet install script 用于安装 .NET CLI 工具和共享运行时的脚本 ./dotnet-install.ps1 -Channel LTS

要运行上述命令,我们可以使用命令行中的名为 dotnet 的工具,并指定实际命令,然后跟随其后。当安装了.NET Core CLI 时,它会设置到 Windows OS 的 PATH 变量中,并且可以从任何文件夹访问。因此,例如,如果您在项目根文件夹中并且想要恢复依赖关系,您只需调用以下命令,它将恢复在项目文件中定义的所有依赖项:

dotnet restore 

上述命令将开始恢复项目文件中定义的依赖项或特定于项目的工具。工具和依赖项的恢复是并行进行的:

我们还可以使用--packages参数设置包的恢复路径。但是,如果未指定此参数,则使用系统用户文件夹下的.nuget/packages文件夹。例如,Windows OS 的默认 NuGet 文件夹是{systemdrive}:\Users\{user}\.nuget\packages,Linux OS 分别是/home/{user}

理解.NET 标准

在.NET 生态系统中,有许多运行时。我们有.NET Framework,这是安装在 Windows 操作系统上的全面机器范围框架,并为Windows Presentation FoundationWPF)、Windows Forms 和 ASP.NET 提供应用程序模型。然后,我们有.NET Core,它针对跨平台操作系统和设备,并提供 ASP.NET Core、Universal Windows PlatformUWP)和针对 Xamarin 应用程序的 Mono 运行时,开发人员可以使用 Mono 运行时在 Xamarin 上开发应用程序,并在 iOS、Android 和 Windows OS 上运行。

以下图表描述了.NET 标准库如何提供.NET Framework、.NET Core 和 Xamarin 的公共构建块的抽象:

所有这些运行时都实现了一个名为.NET 标准的接口,其中.NET 标准是每个运行时的.NET API 规范的实现。这使得您的代码可以在不同的平台上移植。这意味着为一个运行时创建的代码也可以由另一个运行时执行。.NET 标准是我们之前使用的可移植类库PCL)的下一代。简而言之,PCL 是一个针对.NET 的一个或多个框架的类库。创建 PCL 时,我们可以选择需要使用该库的目标框架,并最小化程序集并仅使用所有框架通用的程序集。

.NET 标准不是可以下载或安装的 API 或可执行文件。它是一个规范,定义了每个平台实现的 API。每个运行时版本实现特定的.NET 标准版本。以下表格显示了每个平台实现的.NET 标准版本:

我们可以看到.NET Core 2.0 实现了.NET 标准 2.0,而.NET Framework 4.5 实现了.NET 标准 1.1。因此,例如,如果我们有一个在.NET Framework 4.5 上开发的类库,这可以很容易地添加到.NET Core 项目中,因为它实现了一个更高版本的.NET 标准。另一方面,如果我们想要将.NET Core 程序集引用到.NET Framework 4.5 中,我们可以通过将.NET 标准版本更改为 1.1 来实现,而无需重新编译和构建我们的项目。

正如我们所了解的,.NET 标准的基本理念是在不同的运行时之间共享代码,但它与 PCL 的不同之处如下所示:

可移植类库(PCL) .NET 标准
代表着微软平台并针对一组有限的平台 不受平台限制
API 由您所针对的平台定义 精选的 API 集
它们不是线性版本 线性版本

.NET 标准也映射到 PCL,因此如果您有一个现有的 PCL 库,希望将其转换为.NET 标准,可以参考以下表格:

PCL 配置文件 .NET 标准 PCL 平台
7 1.1 .NET Framework 4.5, Windows 8
31 1.0 Windows 8.1, Windows Phone Silverlight 8.1
32 1.2 Windows 8.1, Windows Phone 8.1
44 1.2 .NET Framework 4.5.1, Windows 8.1
49 1.0 .NET Framework 4.5, Windows Phone Silverlight 8
78 1.0 .NET Framework 4.5, Windows 8, Windows Phone Silverlight 8
84 1.0 Windows Phone 8.1, Windows Phone Silverlight 8.1
111 1.1 .NET Framework 4.5, Windows 8, Windows Phone 8.1
151 1.2 .NET Framework 4.5.1, Windows 8.1, Windows Phone 8.1
157 1.0 Windows 8.1, Windows Phone 8.1, Windows Phone Silverlight 8.1
259 1.0 .NET Framework 4.5, Windows 8, Windows Phone 8.1, Windows Phone Silverlight 8

考虑到前面的表格,如果我们有一个 PCL,它的目标是.NET Framework 4.5.1、Windows 8.1 和 Windows Phone 8.1,PCL 配置文件设置为 151,它可以转换为版本 1.2 的.NET 标准库。

.NET 标准的版本控制

与 PCL 不同,每个.NET 标准版本都是线性版本化的,并包含了以前版本的 API 等。一旦版本发布,它就被冻结,不能更改,并且应用程序可以轻松地针对该版本。

以下图表是.NET 标准版本化的表示。版本越高,可用的 API 就越多,而版本越低,可用的平台就越多:

.NET 标准 2.0 的新改进

.NET Core 2.0 针对.NET 标准 2.0,并提供了两个主要好处。这包括从上一个版本提供的 API 数量的增加以及其兼容模式,我们将在本章进一步讨论。

.NET 标准 2.0 中的更多 API

.NET 标准 2.0 中添加了更多的 API,数量几乎是上一个.NET 标准 1.0 的两倍。此外,像 DataSet、集合、二进制序列化、XML 模式等 API 现在都是.NET 标准 2.0 规范的一部分。这增加了从.NET Framework 到.NET Core 的代码可移植性。

以下图表描述了每个领域中添加的 API 的分类视图:

兼容模式

尽管已经将超过 33K 个 API 添加到.NET 标准 2.0 中,但许多 NuGet 包仍然针对.NET Framework,并且将它们移动到.NET 标准是不可能的,因为它们的依赖项仍然没有针对.NET 标准。但是,使用.NET 标准 2.0,我们仍然可以添加显示警告但不会阻止将这些包添加到我们的.NET 标准库中的包。

在底层,.NET 标准 2.0 使用兼容性 shim,解决了第三方库的兼容性问题,并且在引用这些库时变得更加容易。在 CLR 世界中,程序集的标识是类型标识的一部分。这意味着当我们在.NET Framework 中说System.Object时,我们引用的是[mscorlib]System.Object,而在.NET 标准中,我们引用的是[netstandard]System.Object,因此,如果我们引用任何.NET Framework 的程序集,它不能轻松地在.NET 标准上运行,因此会出现兼容性问题。为了解决这个问题,他们使用了类型转发,提供了一个虚假的mscorlib程序集,该程序集将所有类型转发到.NET 标准实现。

以下是.NET Framework 库如何在任何.NET 标准实现中使用类型转发方法运行的表示:

另一方面,如果我们有一个.NET Framework 库,并且想要引用一个.NET 标准库,它将添加netstandard虚假程序集,并通过使用.NET Framework 实现对所有类型进行类型转发:

为了抑制警告,我们可以为特定的 NuGet 包添加 NU1701,这些包的依赖项没有针对.NET 标准。

创建.NET 标准库

要创建.NET Standard 库,可以使用 Visual Studio 或.NET Core CLI 工具集。从 Visual Studio,我们只需点击如下屏幕截图中显示的.NET Standard 选项,然后选择 Class Library (.NET Standard)。

创建.NET Standard 库后,我们可以将其引用到任何项目,并根据需要更改版本,具体取决于我们要引用的平台。版本可以从属性面板更改,如下面的屏幕截图所示:

ASP.NET Core 2.0 的新功能

ASP.NET Core 是开发云就绪和企业 Web 应用程序的最强大平台之一,可跨平台运行。Microsoft 在 ASP.NET Core 2.0 中添加了许多功能,包括新的项目模板、Razor 页面、简化的 Application Insights 配置、连接池等。

以下是 ASP.NET Core 2.0 的一些新改进。

ASP.NET Core Razor 页面

ASP.NET Core 中引入了基于 Razor 语法的页面。现在,开发人员可以在 HTML 上开发应用程序并写语法,而无需放置控制器。相反,有一个代码后台文件,可以在其中处理其他事件和逻辑。后端页面类继承自PageModel类,可以使用 Razor 语法中的Model对象访问其成员变量和方法。以下是一个简单的示例,其中包含在code-behind类中定义的GetTitle方法,并在视图页面中使用:

public class IndexModel : PageModel 
{ 
  public string GetTitle() => "Home Page"; 
}

这是Index.cshtml文件,通过调用GetCurrentDate方法显示日期:

@page 
@model IndexModel 
@{ 
  ViewData["Title"] = Model.GetTitle(); 
} 

发布时自动页面和视图编译

在发布 ASP.NET Core Razor 页面项目时,所有视图都会编译成一个单一的程序集,发布文件夹的大小相对较小。如果我们希望在发布过程中生成视图和所有.cshtml文件,我们必须添加一个条目,如下所示:

Razor 对 C# 7.1 的支持

现在,我们可以使用 C# 7.1 功能,如推断的元组名称、泛型模式匹配和表达式。为了添加此支持,我们必须在项目文件中添加一个 XML 标记,如下所示:

<LangVersion>latest</LangVersion>

Application Insights 的简化配置

使用 ASP.NET Core 2.0,您可以通过单击一次启用 Application Insights。用户只需右键单击项目,然后单击添加 | Application Insights Telemetry,然后通过简单的向导即可启用 Application Insights。这允许您监视应用程序,并提供来自 Azure Application Insights 的完整诊断信息。

我们还可以从 Visual Studio 2017 IDE 的 Application Insights 搜索窗口查看完整的遥测,并从 Application Insights 趋势监视趋势。这两个窗口都可以从 View | Other Windows 菜单中打开。

在 Entity Framework Core 2.0 中池化连接

最近发布的 Entity Framework Core 2.0 中,我们可以使用Startup类中的AddDbContextPool方法来池化连接。正如我们已经知道的,在 ASP.NET Core 中,我们必须使用依赖注入DI)在Startup类的ConfigureServices方法中添加DbContext对象,并在控制器中使用时,会注入DbContext对象的新实例。为了优化性能,Microsoft 提供了这个AddDbContextPool方法,它首先检查可用的数据库上下文实例,并在需要时注入它。另一方面,如果数据库上下文实例不可用,则会创建并注入一个新实例。

以下代码显示了如何在Startup类的ConfigureServices方法中添加AddDbContext

services.AddDbContextPool<SampleDbContext>( 
  options => options.UseSqlServer(connectionString)); 

Owned Types、表拆分、数据库标量函数映射和字符串插值等功能已添加了一些新特性,您可以从以下链接中查看:docs.microsoft.com/en-us/ef/core/what-is-new/

C# 7.0 中的新功能

C#是.NET 生态系统中最流行的语言,最早是在 2002 年与.NET Framework 一起推出的。C#的当前稳定版本是 7。以下图表显示了 C# 7.0 的进展情况以及不同年份引入的版本:

以下是 C# 7.0 引入的一些新功能:

  • 元组

  • 模式匹配

  • 引用返回

  • 异常作为表达式

  • 本地函数

  • 输出变量文字

  • 异步主函数

元组

元组解决了从方法返回多个值的问题。传统上,我们可以使用引用变量的输出变量,如果它们从调用方法中修改,则值会更改。但是,没有参数,存在一些限制,例如不能与async方法一起使用,不建议与外部服务一起使用。

元组具有以下特点:

  • 它们是值类型。

  • 它们可以转换为其他元组。

  • 元组元素是公共且可变的。

元组表示为System.Tuple<T>,其中T可以是任何类型。以下示例显示了如何使用元组与方法以及如何调用值:

static void Main(string[] args) 
{ 
  var person = GetPerson(); 
  Console.WriteLine($"ID : {person.Item1}, 
  Name : {person.Item2}, DOB : {person.Item3}");       
} 
static (int, string, DateTime) GetPerson() 
{ 
  return (1, "Mark Thompson", new DateTime(1970, 8, 11)); 
}

正如你可能已经注意到的,项目是动态命名的,第一个项目被命名为Item1,第二个为Item2,依此类推。另一方面,我们也可以为项目命名,以便调用方了解值,这可以通过为元组中的每个参数添加参数名来实现,如下所示:

static void Main(string[] args) 
{ 
  var person = GetPerson(); 
  Console.WriteLine($"ID : {person.id}, Name : {person.name}, 
  DOB : {person.dob}");  
} 
static (int id, string name, DateTime dob) GetPerson() 
{ 
  return (1, "Mark Thompson", new DateTime(1970, 8, 11)); 
} 

要了解更多关于元组的信息,请查看以下链接:

docs.microsoft.com/en-us/dotnet/csharp/tuples

模式

模式匹配是执行语法测试的过程,以验证值是否与某个模型匹配。有三种类型的模式:

  • 常量模式。

  • 类型模式。

  • Var 模式。

常量模式

常量模式是检查常量值的简单模式。考虑以下示例:如果Person对象为空,则返回并退出body方法。

Person类如下:

class Person 
{ 
  public int ID { set; get; } 
  public string Name { get; set; } 

  public DateTime DOB { get; set; } 
} 
Person class that contains three properties, namely ID, Name, and DOB (Date of Birth).

以下语句检查person对象是否具有空常量值,并在对象为空时返回它:

if (person is null) return; 

类型模式

类型模式可用于对象,以验证它是否与类型匹配或是否满足基于指定条件的表达式。假设我们需要检查PersonID是否为int;将该ID分配给另一个变量i,并在程序中使用它,否则return

if (!(person.ID is int i)) return; 

Console.WriteLine($"Person ID is {i}"); 

我们还可以使用多个逻辑运算符来评估更多条件,如下所示:

if (!(person.ID is int i) && !(person.DOB>DateTime.Now.AddYears(-20))) return;   

前面的语句检查Person.ID是否为空,以及人是否年龄大于 20。

Var 模式

var 模式检查var是否等于某种类型。以下示例显示了如何使用var模式来检查类型并打印Type名称:

if (person is var Person) Console.WriteLine($"It is a person object and type is {person.GetType()}"); 

要了解更多关于模式的信息,可以参考以下链接:docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-7#pattern-matching

引用返回

引用返回允许方法返回一个对象的引用,而不是它的值。我们可以通过在方法签名中的类型前添加ref关键字来定义引用返回值,并在方法本身返回对象时返回它。

以下是允许引用返回的方法的签名:

public ref Person GetPersonInformation(int ID); 

Following is the implementation of the GetPersonInformation method that uses the ref keyword while returning the person's object.  

Person _person; 
public ref Person GetPersonInformation(int ID) 
{ 
  _person = CallPersonHttpService(); 
  return ref _person; 
} 

表达式体成员扩展

表达式体成员是在 C# 6.0 中引入的,其中方法的语法表达可以以更简单的方式编写。在 C# 7.0 中,我们可以在构造函数、析构函数、异常等中使用此功能。

以下示例显示了如何使用表达式体成员简化构造函数和析构函数的语法表达:

public class PersonManager 
{ 
  //Member Variable 
  Person _person; 

  //Constructor 
  PersonManager(Person person) => _person = person; 

  //Destructor 
  ~PersonManager() => _person = null; 
} 

有了属性,我们还可以简化语法表达,以下是如何编写的基本示例:

private String _name; 
public String Name 
{ 
  get => _name; 
  set => _name = value; 
} 

我们还可以使用表达式体语法表达异常并简化表达式,如下所示:

private String _name; 
public String Name 
{ 
  get => _name; 
  set => _name = value ?? throw new ArgumentNullException(); 
} 

在前面的例子中,如果值为 null,将抛出一个新的ArgumentNullException

创建局部函数

在函数内部创建的函数称为局部函数。这些主要用于定义必须在函数本身范围内的辅助函数。以下示例显示了如何通过编写局部函数并递归调用它来获得数字的阶乘:

static void Main(string[] args) 
{ 
  Console.WriteLine(ExecuteFactorial(4));          
} 

static long ExecuteFactorial(int n) 
{ 
  if (n < 0) throw new ArgumentException("Must be non negative", 
  nameof(n)); 

  else return CheckFactorial(n); 

  long CheckFactorial(int x) 
  { 
    if (x == 0) return 1; 
    return x * CheckFactorial(x - 1); 
  } 
}

输出变量

在 C# 7.0 中,当使用out变量时,我们可以编写更清晰的代码。正如我们所知,要使用out变量,我们必须首先声明它们。通过新的语言增强,我们现在可以只需将out作为前缀写入,并指定我们需要将该值分配给的变量的名称。

为了澄清这个概念,我们首先看一下传统的方法,如下所示:

public void GetPerson() 
{ 
  int year; 
  int month; 
  int day; 
  GetPersonDOB(out year, out month, out day); 
} 

public void GetPersonDOB(out int year, out int month, out int day ) 
{ 
  year = 1980; 
  month = 11; 
  day = 3; 
} 

在 C# 7.0 中,我们可以简化前面的GetPerson方法,如下所示:

public void GetPerson() 
{ 
  GetPersonDOB(out int year, out int month, out int day); 
} 

Async Main

正如我们已经知道的,在.NET Framework 中,Main方法是应用程序/程序由操作系统执行的主要入口点。例如,在 ASP.NET Core 中,Program.cs是定义Main方法的主要类,它创建一个WebHost对象,运行 Kestrel 服务器,并根据Startup类中配置的方式加载 HTTP 管道。

在以前的 C#版本中,Main方法具有以下签名:

public static void Main();
public static void Main(string[] args);
public static int Main();
public static int Main(string[] args);

在 C# 7.0 中,我们可以使用 Async Main 执行异步操作。Async/Await 功能最初是在.NET Framework 4.5 中发布的,以便异步执行方法。如今,许多 API 提供了 Async/Await 方法来执行异步操作。

以下是使用 C# 7.1 添加的Main方法的一些附加签名:

public static Task Main();
public static Task Main(string[] args);
public static Task<int> Main();
public static Task<int> Main(string[] args);

由于前面的异步签名,现在我们可以从Main入口点本身调用async方法,并使用 await 执行异步操作。以下是调用RunAsync方法而不是Run的 ASP.NET Core 的简单示例:

public class Program
{
  public static async Task Main(string[] args)
  {
    await BuildWebHost(args).RunAsync();
  }
  public static IWebHost BuildWebHost(string[] args) =>
    WebHost.CreateDefaultBuilder(args)
    .UseStartup<Startup>()
    .Build();
}

Async Main 是 C# 7.1 的一个特性,要在 Visual Studio 2017 中启用此功能,可以转到项目属性,单击 Advance 按钮,并将语言版本设置为 C#最新的次要版本(latest),如下所示:

编写优质代码

对于每个性能高效的应用程序,代码质量都起着重要作用。正如我们已经知道的,Visual Studio 是开发.NET 应用程序最流行的集成开发环境IDE),由于 Roslyn(.NET 编译器 SDK)公开了编译器平台作为 API,许多功能已经被引入,不仅扩展了 Visual Studio 的功能,而且增强了开发体验。

实时静态代码分析是 Visual Studio 中可以用于开发.NET 应用程序的核心功能之一,它在编写代码时提供代码分析。由于此功能使用 Roslyn API,许多其他第三方公司也引入了一套可以使用的分析器。我们还可以为特定需求开发自己的分析器,这并不是一个非常复杂的过程。让我们快速介绍一下如何在我们的.NET Core 项目中使用实时静态代码分析以及它如何通过分析代码并提供警告、错误和潜在修复来增强开发体验。

我们可以将分析器作为 NuGet 包添加。在 NuGet.org 上有许多可用的分析器,一旦我们将任何分析器添加到我们的项目中,它就会在项目的Dependencies部分添加一个新的Analyzer节点。然后我们可以自定义规则,抑制警告或错误等。

让我们在我们的.NET Core 项目中从 Visual Studio 添加一个新的分析器。如果你不知道要添加哪个分析器,你可以在 NuGet 包管理器窗口中只需输入analyzers,它就会为你列出所有的分析器。我们将只添加Microsoft.CodeQuality.Analyzers分析器,其中包含一些不错的规则:

一旦选择的分析器被添加,一个新的Analyzers节点将被添加到我们的项目中:

在上图中,我们可以看到Analyzers节点已经添加了三个节点,要查看/管理规则,我们可以展开子节点Microsoft.CodeQuality.AnalyzersMicrosoft.CodeQuality.CSharp.Analyzers,如下所示:

此外,我们还可以通过右键单击规则并选择严重性来更改规则的严重性,如下所示:

在上图中,规则 CA1008 指出枚举应该有一个值为零。让我们测试一下,看看它是如何工作的。

创建一个简单的Enum并指定值,如下所示:

public enum Status 
{ 
  Create =1, 
  Update =2, 
  Delete =3, 
} 

当你编写这段代码时,你会立刻看到以下错误,并提供潜在的修复方法:

最后,这是我们可以应用的修复方法,错误将消失:

你还可以使用一个名为 Roslynator 的流行的 Visual Studio 扩展程序,可以从以下链接下载。它包含了超过 190 个适用于基于 C#的项目的分析器和重构工具:marketplace.visualstudio.com/items?itemName=josefpihrt.Roslynator

实时静态代码分析是一个很棒的功能,它帮助开发人员编写符合最佳准则和实践的高质量代码。

总结

在本章中,我们了解了.NET Core 框架以及.NET Core 2.0 引入的一些新改进。我们还研究了 C# 7 的新功能,以及如何编写更干净的代码和简化语法表达。最后,我们讨论了编写高质量代码的主题,以及如何利用 Visual Studio 2017 提供的代码分析功能来添加满足我们需求的分析器到我们的项目中。下一章将是一个关于.NET Core 的深入章节,将涵盖.NET Core 内部和性能改进的主题。

第二章:理解.NET Core 内部和性能测量

在开发应用程序架构时,了解.NET 框架的内部工作原理对确保应用程序性能的质量起着至关重要的作用。在本章中,我们将重点关注.NET Core 的内部机制,这可以帮助我们为任何应用程序编写高质量的代码和架构。本章将涵盖.NET Core 内部的一些核心概念,包括编译过程、垃圾回收和 Framework Class Library(FCL)。我们将通过使用 BenchmarkDotNet 工具来完成本章,该工具主要用于测量代码性能,并且强烈推荐用于在应用程序中对代码片段进行基准测试。

在本章中,您将学习以下主题:

  • .NET Core 内部

  • 利用 CPU 的多个核心实现高性能

  • 发布构建如何提高性能

  • 对.NET Core 2.0 应用程序进行基准测试

.NET Core 内部

.NET Core 包含两个核心组件——运行时 CoreCLR 和基类库 CoreFX。在本节中,我们将涵盖以下主题:

  • CoreFX

  • CoreCLR

  • 理解 MSIL、CLI、CTS 和 CLS

  • CLR 的工作原理

  • 从编译到执行——在幕后

  • 垃圾回收

  • .NET 本机和 JIT 编译

CoreFX

CoreFX 是.NET Core 一组库的代号。它包含所有以 Microsoft.或 System.开头的库,并包含集合、I/O、字符串操作、反射、安全性等许多功能。

CoreFX 是与运行时无关的,可以在任何平台上运行,而不管它支持哪些 API。

要了解每个程序集的更多信息,您可以参考.NET Core 源浏览器source.dot.net

CoreCLR

CoreCLR 为.NET Core 应用程序提供了公共语言运行时环境,并管理完整应用程序生命周期的执行。在程序运行时,它执行各种操作。CoreCLR 的操作包括内存分配、垃圾回收、异常处理、类型安全、线程管理和安全性。

.NET Core 的运行时提供与.NET Framework 相同的垃圾回收(GC)和一个新的更优化的即时编译器(JIT),代号为 RyuJIT。当.NET Core 首次发布时,它仅支持 64 位平台,但随着.NET Core 2.0 的发布,现在也可用于 32 位平台。但是,32 位版本仅受 Windows 操作系统支持。

理解 MSIL、CLI、CTS 和 CLS

当我们构建项目时,代码被编译为中间语言(IL),也称为 Microsoft 中间语言(MSIL)。MSIL 符合公共语言基础设施(CLI),其中 CLI 是提供公共类型系统和语言规范的标准,分别称为公共类型系统(CTS)和公共语言规范(CLS)。

CTS 提供了一个公共类型系统,并将语言特定类型编译为符合规范的数据类型。它将所有.NET 语言的数据类型标准化为语言互操作的公共数据类型。例如,如果代码是用 C#编写的,它将被转换为特定的 CTS。

假设我们有两个变量,在以下使用 C#定义的代码片段中:

class Program 
{ 
  static void Main(string[] args) 
  { 
    int minNo = 1; 
    long maxThroughput = 99999; 
  } 
} 

在编译时,编译器将 MSIL 生成为一个程序集,通过 CoreCLR 可执行 JIT 并将其转换为本机机器代码。请注意,intlong类型分别转换为int32int64

并不是每种语言都必须完全符合 CTS,并且它也可以支持 CTS 的较小印记。例如,当 VB.NET 首次发布在.NET Framework 中时,它只支持有符号整数数据类型,并且没有使用无符号整数的规定。通过.NET Framework 的后续版本,现在通过.NET Core 2.0,我们可以使用所有托管语言,如 C#、F#和 VB.NET,来开发应用程序,并轻松引用任何项目的程序集。

CLR 的工作原理

CLR 实现为一组在进程中加载的内部库,并在应用程序进程的上下文中运行。在下图中,我们有两个运行的.NET Core 应用程序,名为 App1.exe 和 App2.exe.每个黑色方框代表应用程序进程地址空间,其中应用程序 App1.exe 和 App2.exe 并行运行其自己的 CLR 版本:

在打包.NET Core 应用程序时,我们可以将其发布为依赖框架部署FDDs)或自包含部署SCDs)。在 FDDs 中,发布的包不包含.NET Core 运行时,并期望目标/托管系统上存在.NET Core。对于 SCDs,所有组件,如.NET Core 运行时和.NET Core 库,都包含在发布的包中,并且目标系统上不需要.NET Core 安装。

要了解有关 FDDs 或 SCDs 的更多信息,请参阅docs.microsoft.com/en-us/dotnet/core/deploying/

从编译到执行-底层

.NET Core 编译过程类似于.NET Framework 使用的过程。项目构建时,MSBuild 系统调用内部.NET CLI 命令,构建项目并生成程序集(.dll)或可执行文件(.exe)。该程序集包含包含程序集元数据的清单,包括版本号、文化、类型引用信息、有关引用程序集的信息以及程序集中其他文件及其关联的列表。该程序集清单存储在 MSIL 代码中或独立的可移植可执行文件PE)中:

现在,当可执行文件运行时,会启动一个新进程并引导.NET Core 运行时,然后初始化执行环境,设置堆和线程池,并将程序集加载到进程地址空间中。根据程序,然后执行主入口方法(Main)并进行 JIT 编译。从这里开始,代码开始执行,对象开始在堆上分配内存,原始类型存储在堆栈上。对于每个方法,都会进行 JIT 编译,并生成本机机器代码。

当 JIT 编译完成,并在生成本机机器代码之前,它还执行一些验证。这些验证包括以下内容:

  • 验证,在构建过程中生成了 MSIL

  • 验证,在 JIT 编译过程中是否修改了任何代码或添加了新类型

  • 验证,已生成了针对目标机器的优化代码

垃圾收集

CLR 最重要的功能之一是垃圾收集器。由于.NET Core 应用程序是托管应用程序,大部分垃圾收集都是由 CLR 自动完成的。CLR 有效地在内存中分配对象。CLR 不仅会定期调整虚拟内存资源,还会减少底层虚拟内存的碎片,使其在空间方面更加高效。

当程序运行时,对象开始在堆上分配内存,并且每个对象的地址都存储在堆栈上。这个过程会一直持续,直到内存达到最大限制。然后 GC 开始起作用,通过移除未使用的托管对象并分配新对象来回收内存。这一切都是由 GC 自动完成的,但也可以通过调用GC.Collect方法来调用 GC 执行垃圾收集。

让我们举一个例子,我们在Main方法中有一个名为cCar对象。当函数被执行时,CLR 将Car对象分配到堆内存中,并且将指向堆上Car对象的引用存储在堆栈地址中。当垃圾收集器运行时,它会从堆中回收内存,并从堆栈中移除引用:

需要注意的一些重要点是,垃圾收集是由 GC 自动处理托管对象的,如果有任何非托管对象,比如数据库连接、I/O 操作等,它们需要显式地进行垃圾收集。否则,GC 会高效地处理托管对象,并确保应用程序在进行 GC 时不会出现性能下降。

GC 中的世代

垃圾收集中有三种世代,分别为第零代、第一代和第二代。在本节中,我们将看一下世代的概念以及它对垃圾收集器性能的影响。

假设我们运行一个创建了三个名为 Object1、Object2 和 Object3 的对象的应用程序。这些对象将在第零代中分配内存:

现在,当垃圾收集器运行时(这是一个自动过程,除非你从代码中显式调用垃圾收集器),它会检查应用程序不需要的对象,并且在程序中没有引用。它将简单地移除这些对象。例如,如果 Object1 的范围在任何地方都没有被引用,那么这个对象的内存将被回收。然而,另外两个对象 Object1 和 Object2 仍然在程序中被引用,并且将被移动到第一代。

现在,假设我们创建了两个名为 Object4 和 Object5 的对象。我们将它们存储在第零代槽中,如下图所示:

当垃圾收集再次运行时,它将在第零代找到两个名为 Object4 和 Object5 的对象,并且在第一代找到两个名为 Object2 和 Object3 的对象。垃圾收集器将首先检查第零代中这些对象的引用,如果它们没有被应用程序使用,它们将被移除。对于第一代的对象也是一样。例如,如果 Object3 仍然被引用,它将被移动到第二代,而 Object2 将从第一代中被移除,如下图所示:

这种世代的概念实际上优化了 GC 的性能,存储在第二代的对象更有可能被存储更长时间。GC 执行更少的访问,而不是一遍又一遍地检查每个对象。第一代也是如此,它也不太可能回收空间,而不像第零代。

.NET 本机和 JIT 编译

JIT 编译主要在运行时进行,它将 MSIL 代码转换为本机机器代码。这是代码第一次运行时进行的,比其后的运行需要更多的时间。如今,在.NET Core 中,我们正在为 CPU 资源和内存有限的移动设备和手持设备开发应用程序。目前,Universal Windows PlatformUWP)和 Xamarin 平台运行在.NET Core 上。使用这些平台,.NET Core 会在编译时或生成特定平台包时自动生成本机程序集。虽然这不需要在运行时进行 JIT 编译过程,但最终会增加应用程序的启动时间。这种本机编译是通过一个名为.NET Native 的组件完成的。

.NET Native 在语言特定编译器完成编译过程后开始编译过程。.NET Native 工具链读取语言编译器生成的 MSIL,并执行以下操作:

  • 它从 MSIL 中消除了元数据。

  • 在比较字段值时,它用静态本机代码替换依赖反射和元数据的代码。

  • 它检查应用程序调用的代码,并只在最终程序集中包含那些代码。

  • 它用不带 JIT 编译器的重构运行时替换了完整的 CLR。重构后的运行时与应用程序一起,并包含在名为mrt100_app.dll的程序集中。

利用 CPU 的多个核心实现高性能

如今,应用程序的性质更加注重连接性,有时它们的操作需要更长的执行时间。我们也知道,现在所有的计算机都配备了多核处理器,有效地利用这些核心可以提高应用程序的性能。诸如网络/IO 之类的操作存在延迟问题,应用程序的同步执行往往会导致长时间的等待。如果长时间运行的任务在单独的线程中或以异步方式执行,结果操作将花费更少的时间并提高响应性。另一个好处是性能,它实际上利用了处理器的多个核心并同时执行任务。在.NET 世界中,我们可以通过将任务分割成多个线程并使用经典的多线程编程 API,或者更简化和先进的模型,即任务编程库TPL)来实现响应性和性能。TPL 现在在.NET Core 2.0 中得到支持,我们很快将探讨如何使用它在多个核心上执行任务。

TPL 编程模型是基于任务的。任务是工作单元,是正在进行的操作的对象表示。

可以通过编写以下代码来创建一个简单的任务:

static void Main(string[] args) 
{ 
  Task t = new Task(execute); 
  t.Start(); 
  t.Wait(); 
} 

private static void Execute() { 
  for (int i = 0; i < 100; i++) 
  { 
    Console.WriteLine(i); 
  } 
}

在上述代码中,任务可以使用Task对象进行初始化,其中Execute是在调用Start方法时执行的计算方法。Start方法告诉.NET Core 任务可以开始并立即返回。它将程序执行分成两个同时运行的线程。第一个线程是实际的应用程序线程,第二个线程是执行execute方法的线程。我们使用了t.Wait方法来等待工作任务在控制台上显示结果。否则,一旦程序退出Main方法下的代码块,应用程序就会结束。

并行编程的目标是有效地利用多个核心。例如,我们在单核处理器上运行上述代码。这两个线程将运行并共享同一个处理器。然而,如果相同的程序可以在多核处理器上运行,它可以通过分别利用每个核心在多个核心上运行,从而提高性能并实现真正的并行性:

与 TPL 不同,经典的Thread对象不能保证您的线程将在 CPU 的不同核心上运行。然而,使用 TPL,它保证每个线程将在不同的线程上运行,除非它达到了与 CPU 一样多的任务数量并共享核心。

要了解 TPL 提供的更多信息,请参阅

docs.microsoft.com/en-us/dotnet/standard/parallel-programming/task-parallel-library-tpl

发布构建如何提高性能

.NET 应用程序提供了发布和调试两种构建模式。调试模式在编写代码或解决错误时通常使用,而发布构建模式通常在打包应用程序以部署到生产服务器时使用。在开发部署包时,开发人员经常忘记将构建模式更新为发布构建,然后在部署应用程序时遇到性能问题:

以下表格显示了调试模式和发布模式之间的一些区别:

调试 发布
编译器不对代码进行优化 使用发布模式构建时,代码会被优化和缩小
在异常发生时捕获并抛出堆栈跟踪 不捕获堆栈跟踪
调试符号被存储 所有在#debug 指令下的代码和调试符号都被移除
源代码在运行时使用更多内存 源代码在运行时使用更少内存

对.NET Core 2.0 应用程序进行基准测试

基准测试应用程序是评估和比较与约定标准的工件的过程。要对.NET Core 2.0 应用程序代码进行基准测试,我们可以使用BenchmarkDotNet工具,该工具提供了一个非常简单的 API 来评估应用程序中代码的性能。通常,在微观级别进行基准测试,例如使用类和方法,不是一件容易的事,需要相当大的努力来衡量性能,而BenchmarkDotNet则完成了所有与基准测试解决方案相关的低级管道和复杂工作。

探索BenchmarkDotNet

在本节中,我们将探索BenchmarkDotNet并学习如何有效地使用它来衡量应用程序性能。

可以简单地通过 NuGet 包管理器控制台窗口或通过项目引用部分来安装BenchmarkDotNet。要安装BenchmarkDotNet,执行以下命令:

Install-Package BenchmarkDotNet 

上述命令从NuGet.org添加了一个BenchmarkDotNet包。

为了测试BenchmarkDotNet工具,我们将创建一个简单的类,其中包含两种方法来生成一个包含10个数字的斐波那契数列。斐波那契数列可以用多种方式实现,这就是为什么我们使用它来衡量哪个代码片段更快,更高效。

这是第一个以迭代方式生成斐波那契数列的方法:

public class TestBenchmark 
{ 
  int len= 10; 
  [Benchmark] 
  public  void Fibonacci() 
  { 
    int a = 0, b = 1, c = 0; 
    Console.Write("{0} {1}", a, b); 

    for (int i = 2; i < len; i++) 
    { 
      c = a + b; 
      Console.Write(" {0}", c); 
      a = b; 
      b = c; 
    } 
  } 
} 

这是另一种使用递归方法生成斐波那契数列的方法:


[Benchmark] 
public  void FibonacciRecursive() 
{ 
  int len= 10; 
  Fibonacci_Recursive(0, 1, 1, len); 
} 

private void Fibonacci_Recursive(int a, int b, int counter, int len) 
{ 
  if (counter <= len) 
  { 
    Console.Write("{0} ", a); 
    Fibonacci_Recursive(b, a + b, counter + 1, len); 
  } 
}  

请注意,斐波那契数列的两个主要方法都包含Benchmark属性。这实际上告诉BenchmarkRunner要测量包含此属性的方法。最后,我们可以从应用程序的主入口点调用BenchmarkRunner,该入口点测量性能并生成报告,如下面的代码所示:

static void Main(string[] args)
{
  BenchmarkRunner.Run<TestBenchmark>();
  Console.Read();
}

一旦运行基准测试,我们将得到以下报告:

此外,它还在运行BenchmarkRunner的应用程序的根文件夹中生成文件。这是包含有关BenchmarkDotNet版本和操作系统、处理器、频率、分辨率和计时器详细信息、.NET 版本(在我们的情况下是.NET Core SDK 2.0.0)、主机等信息的.html 文件:

表格包含四列。但是,我们可以添加更多列,默认情况下是可选的。我们也可以添加自定义列。Method 是包含基准属性的方法的名称,Mean 是所有测量所需的平均时间(us 为微秒),Error 是处理错误所需的时间,StdDev 是测量的标准偏差。

比较两种方法后,FibonacciRecursive方法更有效,因为平均值、错误和 StdDev 值都小于Fibonacci方法。

除了 HTML 之外,还创建了两个文件,一个逗号分隔值(CSV)文件和一个Markdown 文档(MD)文件,其中包含相同的信息。

它是如何工作的

基准为每个基准方法在运行时生成一个项目,并以发布模式构建它。它尝试多种组合来测量方法的性能,通过多次启动该方法。运行多个周期后,将生成报告,其中包含有关基准的文件和信息。

设置参数

在上一个示例中,我们只测试了一个值的方法。实际上,在测试企业应用程序时,我们希望使用不同的值来估计方法的性能。

TestBenchmark class:
public class TestBenchmark 
{ 

  [Params(10,20,30)] 
  public int Len { get; set; } 

  [Benchmark] 
  public  void Fibonacci() 
  { 
    int a = 0, b = 1, c = 0; 
    Console.Write("{0} {1}", a, b); 

    for (int i = 2; i < Len; i++) 
    { 
      c = a + b; 
      Console.Write(" {0}", c); 
      a = b; 
      b = c; 
    } 
  } 

  [Benchmark] 
  public  void FibonacciRecursive() 
  { 
    Fibonacci_Recursive(0, 1, 1, Len); 
  } 

  private void Fibonacci_Recursive(int a, int b, int counter, int len) 
  { 
    if (counter <= len) 
    { 
      Console.Write("{0} ", a); 
      Fibonacci_Recursive(b, a + b, counter + 1, len); 
    } 
  } 
}

运行 Benchmark 后,将生成以下报告:

使用 BenchmarkDotnet 进行内存诊断

使用BenchmarkDotnet,我们还可以诊断内存问题,并测量分配的字节数和垃圾回收。

可以使用MemoryDiagnoser属性在类级别实现。首先,让我们在上一个示例中创建的TestBenchmark类中添加MemoryDiagnoser属性:

[MemoryDiagnoser] 
public class TestBenchmark {} 

重新运行应用程序。现在它将收集其他内存分配和垃圾回收信息,并相应地生成日志:

在上表中,Gen 0 和 Gen 1 列分别包含每 1,000 次操作的特定代数的数量。如果值为 1,则表示在 1,000 次操作后进行了垃圾回收。但是,请注意,在第一行中,值为0.1984,这意味着在198.4秒后进行了垃圾回收,而该行的 Gen 1 中没有进行垃圾回收。Allocated 表示在调用该方法时分配的内存大小。它不包括 Stackalloc/堆本机分配。

添加配置

可以通过创建自定义类并从ManualConfig类继承来定义基准配置。以下是我们之前创建的TestBenchmark类的示例,其中包含一些基准方法:

[Config(typeof(Config))] 
public class TestBenchmark 
{ 
  private class Config : ManualConfig 
  { 
    // We will benchmark ONLY method with names with names (which 
    // contains "A" OR "1") AND (have length < 3) 
    public Config() 
    { 
      Add(new DisjunctionFilter( 
        new NameFilter(name => name.Contains("Recursive")) 
      ));  

    } 
  } 

  [Params(10,20,30)] 
  public int Len { get; set; } 

  [Benchmark] 
  public  void Fibonacci() 
  { 
    int a = 0, b = 1, c = 0; 
    Console.Write("{0} {1}", a, b); 

    for (int i = 2; i < Len; i++) 
    { 
      c = a + b; 
      Console.Write(" {0}", c); 
      a = b; 
      b = c; 
    } 
  } 

  [Benchmark] 
  public  void FibonacciRecursive() 
  { 
    Fibonacci_Recursive(0, 1, 1, Len); 
  } 

  private void Fibonacci_Recursive(int a, int b, int counter, int len) 
  { 
    if (counter <= len) 
    { 
      Console.Write("{0} ", a); 
      Fibonacci_Recursive(b, a + b, counter + 1, len); 
    } 
  } 
} 

在上述代码中,我们定义了Config类,该类继承了基准框架中提供的ManualConfig类。规则可以在Config构造函数内定义。在上面的示例中,有一个规则规定只有包含Recursive的基准方法才会被执行。在我们的情况下,只有一个方法FibonacciRecursive会被执行,并且我们将测量其性能。

另一种方法是通过流畅的 API,我们可以跳过创建Config类,并实现以下内容:

static void Main(string[] args) 
{ 
  var config = ManualConfig.Create(DefaultConfig.Instance); 
  config.Add(new DisjunctionFilter(new NameFilter(
    name => name.Contains("Recursive")))); 
  BenchmarkRunner.Run<TestBenchmark>(config); 
}

要了解有关BenchmarkDotNet的更多信息,请参阅benchmarkdotnet.org/Configs.htm

摘要

在本章中,我们已经了解了.NET Core 的核心概念,包括编译过程、垃圾回收、如何利用 CPU 的多个核心开发高性能的.NET Core 应用程序,以及使用发布构建发布应用程序。我们还探讨了用于代码优化的基准工具,并提供了特定于类对象的结果。

在下一章中,我们将学习.NET Core 中的多线程和并发编程。

第三章:.NET Core 中的多线程和异步编程

多线程和异步编程是两种重要的技术,可以促进高度可扩展和高性能应用程序的开发。如果应用程序不响应,会影响用户体验并增加不满的程度。另一方面,它还会增加服务器端或应用程序运行位置的资源使用,并增加内存大小和/或 CPU 使用率。如今,硬件非常便宜,每台机器都配备了多个 CPU 核心。实现多线程和使用异步编程技术不仅可以提高应用程序的性能,还可以使应用程序具有更高的响应性。

本章将探讨多线程和异步编程模型的核心概念,以帮助您在项目中使用它们,并提高应用程序的整体性能。

以下是本章将学习的主题列表:

  • 多线程与异步编程

  • .NET Core 中的多线程

  • .NET Core 中的线程

  • 线程同步

  • 任务并行库(TPL)

  • 使用 TPL 创建任务

  • 基于任务的异步模式

  • 并行编程的设计模式

I/O 绑定操作是依赖于外部资源的代码。例如访问文件系统,访问网络等。

多线程与异步编程

如果正确实现,多线程和异步编程可以提高应用程序的性能。多线程是指同时执行多个线程以并行执行多个操作或任务的实践。通常有一个主线程和几个后台线程,通常称为工作线程,同时并行运行,同时执行多个任务,而同步和异步操作都可以在单线程或多线程环境中运行。

在单线程同步操作中,只有一个线程按照定义的顺序执行所有任务,并依次执行它们。在单线程异步操作中,只有一个线程执行任务,但它会分配一个时间片来运行每个任务。时间片结束后,它会保存该任务的状态并开始执行下一个任务。在内部,处理器在每个任务之间执行上下文切换,并分配一个时间片来运行它们。

在多线程同步操作中,有多个线程并行运行任务。与异步操作中的上下文切换不同,任务之间没有上下文切换。一个线程负责执行分配给它的任务,然后开始另一个任务,而在多线程异步操作中,多个线程运行多个任务,任务可以由单个或多个线程提供和执行。

以下图表描述了单线程和多线程同步和异步操作之间的区别:

上图显示了四种操作类型。在单线程同步操作中,有一个线程按顺序运行五个任务。一旦任务 1完成,就执行任务 2,依此类推。在单线程异步操作中,有一个线程,但每个任务都会在执行下一个任务之前获得一个时间片来执行,依此类推。每个任务将被执行多次,并从暂停的地方恢复。在多线程同步操作中,有三个线程并行运行三个任务任务 1任务 2任务 3。最后,在多线程异步操作中,有三个任务任务 1任务 2任务 3由三个线程运行,但每个线程根据分配给每个任务的时间片进行一些上下文切换。

在异步编程中,并不总是每个异步操作都会在新线程上运行。Async/Await是一个没有创建额外线程的好例子。*async*操作在主线程的当前同步上下文中执行,并将异步操作排队在分配的时间片中执行。

.NET Core 中的多线程

在 CPU 和/或 I/O 密集型应用程序中使用多线程有许多好处。它通常用于长时间运行的进程,这些进程具有更长或无限的生命周期,作为后台任务工作,保持主线程可用以管理或处理用户请求。然而,不必要的使用可能会完全降低应用程序的性能。有些情况下,创建太多线程并不是一个好的架构实践。

以下是一些多线程适用的示例:

  • I/O 操作

  • 运行长时间的后台任务

  • 数据库操作

  • 通过网络进行通信

多线程注意事项

尽管多线程有许多好处,但在编写多线程应用程序时需要彻底解决一些注意事项。如果计算机是单核或双核计算机,并且应用程序创建了大量线程,则这些线程之间的上下文切换将减慢性能:

上图描述了在单处理器机器上运行的程序。第一个任务是同步执行的,比在单处理器上运行的三个线程快得多。系统执行第一个线程,然后等待一段时间再执行第二个线程,依此类推。这增加了在线程之间切换的不必要开销,从而延迟了整体操作。在线程领域,这被称为上下文切换。每个线程之间的框表示在每个上下文切换之间发生的延迟。

就开发人员的经验而言,调试和测试是创建多线程应用程序时对开发人员具有挑战性的另外两个问题。

.NET Core 中的线程

.NET 中的每个应用程序都从一个单线程开始,这是主线程。线程是操作系统用来分配处理器时间的基本单位。每个线程都有一个优先级、异常处理程序和保存在自己的线程上下文中的数据结构。如果发生异常,它是在线程的上下文中抛出的,其他线程不受其影响。线程上下文包含一些关于 CPU 寄存器、线程的主机进程的地址空间等低级信息。

如果应用程序在单处理器上运行多个线程,则每个线程将被分配一段处理器时间,并依次执行。时间片通常很小,这使得看起来好像线程在同时执行。一旦分配的时间结束,处理器就会移动到另一个线程,之前的线程等待处理器再次可用并根据分配的时间片执行。另一方面,如果线程在多个 CPU 上运行,则它们可能同时执行,但如果有其他进程和线程在运行,则时间片将被分配并相应地执行。

在.NET Core 中创建线程

在.NET Core 中,线程 API 与完整的.NET Framework 版本相同。可以通过创建Thread类对象并将ThreadStartParameterizedThreadStart委托作为参数来创建新线程。ThreadStartParameterizedThreadStart包装了在启动新线程时调用的方法。ParameterizedThreadStart用于包含参数的方法。

以下是一个基本示例,该示例在单独的线程上运行ExecuteLongRunningOperation方法:

static void Main(string[] args) 
{ 
  new Thread(new ThreadStart(ExecuteLongRunningOperation)).Start(); 
} 
static void ExecuteLongRunningOperation() 
{ 
  Thread.Sleep(100000); 
  Console.WriteLine("Operation completed successfully"); 
} 

在启动线程时,我们还可以传递参数并使用ParameterizedThreadStart委托:

static void Main(string[] args) 
{ 
  new Thread(new ParameterizedThreadStart
  (ExecuteLongRunningOperation)).Start(100000); 
} 

static void ExecuteLongRunningOperation(object milliseconds) 
{ 
  Thread.Sleep((int)milliseconds); 
  Console.WriteLine("Operation completed successfully"); 
} 

ParameterizedThreadStart委托接受一个对象作为参数。因此,如果要传递多个参数,可以通过创建自定义类并添加以下属性来实现:

public interface IService 
{ 
  string Name { get; set; } 
  void Execute(); 
} 

public class EmailService : IService 
{ 
  public string Name { get; set; } 
  public void Execute() => throw new NotImplementedException(); 

  public EmailService(string name) 
  { 
    this.Name = name; 
  } 
} 

static void Main(string[] args) 
{ 
  IService service = new EmailService("Email"); 
  new Thread(new ParameterizedThreadStart
  (RunBackgroundService)).Start(service); 
} 

static void RunBackgroundService(Object service) 
{ 
  ((IService)service).Execute(); //Long running task 
} 

每个线程都有一个线程优先级。当线程被创建时,其优先级被设置为正常。优先级影响线程的执行。优先级越高,线程将被赋予的优先级就越高。线程优先级可以在线程对象上定义,如下所示:

static void RunBackgroundService(Object service) 
{ 
  Thread.CurrentThread.Priority = ThreadPriority.Highest;      
  ((IService)service).Execute(); //Long running task
}

RunBackgroundService是在单独的线程中执行的方法,可以使用ThreadPriority枚举设置优先级,并通过调用Thread.CurrentThread引用当前线程对象,如上面的代码片段所示。

线程生命周期

线程的生命周期取决于在该线程中执行的方法。一旦方法执行完毕,CLR 将释放线程占用的内存并进行处理。另一方面,也可以通过调用InterruptAbort方法显式地处理线程。

另一个非常重要的因素是异常。如果异常在线程内部没有得到适当处理,它们将传播到调用方法,依此类推,直到它们到达调用堆栈中的方法。当它达到这一点时,如果没有得到处理,CLR 将关闭线程。

对于持续或长时间运行的线程,关闭过程应该被正确定义。平滑关闭线程的最佳方法之一是使用volatile bool变量:

class Program 
{ 

  static volatile bool isActive = true;  
  static void Main(string[] args) 
  { 
    new Thread(new ParameterizedThreadStart
    (ExecuteLongRunningOperation)).Start(1000); 
  } 

  static void ExecuteLongRunningOperation(object milliseconds) 
  { 
    while (isActive) 
    { 
      //Do some other operation 
      Console.WriteLine("Operation completed successfully"); 
    } 
  } 
} 

在上面的代码中,我们使用了volatile bool变量isActive,它决定了while循环是否执行。

volatile关键字表示一个字段可能会被多个同时执行的线程修改。声明为 volatile 的字段不受编译器优化的影响,假设只有一个线程访问。这确保了字段中始终存在最新的值。要了解更多关于 volatile 的信息,请参考以下 URL:

docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/volatile

.NET 中的线程池

CLR 提供了一个单独的线程池,其中包含要用于异步执行任务的线程列表。每个进程都有自己特定的线程池。CLR 向线程池中添加和移除线程。

使用ThreadPool来运行线程,我们可以使用ThreadPool.QueueUserWorkItem,如下面的代码所示:

class Program 
{ 
  static void Main(string[] args) 
  { 
    ThreadPool.QueueUserWorkItem(ExecuteLongRunningOperation, 1000); 
    Console.Read(); 
  } 
  static void ExecuteLongRunningOperation(object milliseconds) 
  { 

    Thread.Sleep((int)milliseconds); 
    Console.WriteLine("Thread is executed"); 
  } 
} 

QueueUserWorkItem将任务排队,由 CLR 在线程池中可用的线程中执行。任务队列按照先进先出FIFO)的顺序进行维护。但是,根据线程的可用性和任务本身,任务完成可能会延迟。

线程同步

在多线程应用程序中,我们有共享资源,可以被多个线程同时访问。资源在多个线程之间共享的区域称为临界区。为了保护这些资源并提供线程安全的访问,有一些技术将在本节中讨论。

让我们举一个例子,我们有一个用于将消息记录到文件系统的单例类。单例,根据定义,表示应该只有一个实例在多次调用之间共享。以下是一个基本的单例模式实现,它不是线程安全的:

public class Logger 
{ 
  static Logger _instance; 

  private Logger() { } 

  public Logger GetInstance() 
  { 
    _instance = (_instance == null ? new Logger() : _instance); 
    return _instance; 
  } 

  public void LogMessage(string str) 
  { 
    //Log message into file system 
  } 

} 

上面的代码是一个懒惰初始化的单例模式,它在第一次调用GetInstance方法时创建一个实例。GetInstance是临界区,不是线程安全的。如果多个线程进入临界区,将创建多个实例,并发条件将发生。

竞争条件是多线程编程中出现的问题,当结果取决于事件的时间时。当两个或多个并行任务访问共享对象时,就会出现竞争条件。

要实现线程安全的单例,我们可以使用锁定模式。锁定确保只有一个线程可以进入临界区,如果另一个线程尝试进入,它将等待直到线程被释放。以下是一个修改后的版本,使单例线程安全:

public class Logger 
{ 

  private static object syncRoot = new object(); 
  static Logger _instance; 

  private Logger() { } 

  public Logger GetInstance() 
  { 
    if (_instance == null) 
    { 
      lock (syncRoot) 
      { 
        if (_instance == null) 
        _instance = new Logger(); 
      } 
    } 
    return _instance; 
  } 

  public void LogMessage(string str) 
  { 
    //Log message into file system 
  } 
} 

监视器

监视器用于提供对资源的线程安全访问。它适用于多线程编程,在那里有多个线程需要同时访问资源。当多个线程尝试进入monitor以访问任何资源时,CLR 只允许一个线程一次进入,其他线程被阻塞。当线程退出监视器时,下一个等待的线程进入,依此类推。

如果我们查看Monitor类,所有方法如Monitor.EnterMonitor.Exit都是在对象引用上操作的。与lock类似,Monitor也提供对资源的门控访问;但是,开发人员在 API 方面会有更大的控制。

以下是在.NET Core 中使用Monitor的基本示例:

public class Job 
{ 

  int _jobDone; 
  object _lock = new object(); 

  public void IncrementJobCounter(int number) 
  { 
    Monitor.Enter(_lock); 
    // access to this field is synchronous
    _jobDone += number; 
    Monitor.Exit(_lock); 
  } 

} 
IncrementJobCounter method to increment the _jobDone counter.

在某些情况下,关键部分必须等待资源可用。一旦它们可用,我们希望激活等待块以执行。

为了帮助我们理解,让我们举一个运行Job的例子,其任务是运行多个线程添加的作业。如果没有作业存在,它应该等待线程推送并立即开始执行它们。

JobExecutor: 
public class JobExecutor 
{ 
  const int _waitTimeInMillis = 10 * 60 * 1000; 
  private ArrayList _jobs = null; 
  private static JobExecutor _instance = null; 
  private static object _syncRoot = new object(); 

  //Singleton implementation of JobExecutor
  public static JobExecutor Instance 
  { 
    get{ 
    lock (_syncRoot) 
    { 
      if (_instance == null) 
      _instance = new JobExecutor(); 
    } 
    return _instance; 
  } 
} 

private JobExecutor() 
{ 
  IsIdle = true; 
  IsAlive = true; 
  _jobs = new ArrayList(); 
} 

private Boolean IsIdle { get; set; } 
public Boolean IsAlive { get; set; } 

//Callers can use this method to add list of jobs
public void AddJobItems(List<Job> jobList) 
{ 
  //Added lock to provide synchronous access. 
  //Alternatively we can also use Monitor.Enter and Monitor.Exit
  lock (_jobs) 
  { 
    foreach (Job job in jobList) 
    { 
      _jobs.Add(job); 
    } 
    //Release the waiting thread to start executing the //jobs
    Monitor.PulseAll(_jobs); 
  } 
} 

/*Check for jobs count and if the count is 0, then wait for 10 minutes by calling Monitor.Wait. Meanwhile, if new jobs are added to the list, Monitor.PulseAll will be called that releases the waiting thread. Once the waiting is over it checks the count of jobs and if the jobs are there in the list, start executing. Otherwise, wait for the new jobs */
public void CheckandExecuteJobBatch() 
{ 
  lock (_jobs) 
  { 
    while (IsAlive) 
    { 
      if (_jobs == null || _jobs.Count <= 0) 
      { 
        IsIdle = true; 
        Console.WriteLine("Now waiting for new jobs"); 
        //Waiting for 10 minutes 
        Monitor.Wait(_jobs, _waitTimeInMillis); 
      } 
      else 
      { 
        IsIdle = false; 
        ExecuteJob(); 
      } 
    } 
  } 
} 

//Execute the job
private void ExecuteJob() 
{ 
  for(int i=0;i< _jobs.Count;i++) 
  { 
    Job job = (Job)_jobs[i]; 
    //Execute the job; 
    job.DoSomething(); 
    //Remove the Job from the Jobs list 
    _jobs.Remove(job); 
    i--; 
  } 
} 
} 

这是一个单例类,其他线程可以使用静态的Instance属性访问JobExecutor实例,并调用AddJobsItems方法将要执行的作业列表添加到其中。CheckandExecuteJobBatch方法持续运行并每 10 分钟检查列表中的新作业。或者,如果通过调用Monitor.PulseAll方法中断了AddJobsItems方法,它将立即转移到while语句并检查项目计数。如果项目存在,CheckandExecuteJobBatch方法调用ExecuteJob方法来运行该作业。

Job class containing two properties, namely JobID and JobName, and the DoSomething method that will print the JobID on the console:
public class Job 
{ 
  // Properties to set and get Job ID and Name
  public int JobID { get; set; } 
  public string JobName { get; set; } 

  //Do some task based on Job ID as set through the JobID        
  //property
  public void DoSomething() 
  { 
    //Do some task based on Job ID  
    Console.WriteLine("Executed job " + JobID);  
  } 
} 

最后,在主Program类上,我们可以调用三个工作线程和一个JobExecutor线程,如下所示:

class Program 
{ 
  static void Main(string[] args) 
  { 
    Thread jobThread = new Thread(new ThreadStart(ExecuteJobExecutor)); 
    jobThread.Start(); 

    //Starting three Threads add jobs time to time; 
    Thread thread1 = new Thread(new ThreadStart(ExecuteThread1)); 
    Thread thread2 = new Thread(new ThreadStart(ExecuteThread2)); 
    Thread thread3 = new Thread(new ThreadStart(ExecuteThread3)); 
    Thread1.Start(); 
    Thread2.Start(); 
    thread3.Start(); 

    Console.Read(); 
  } 

  //Implementation of ExecuteThread 1 that is adding three 
  //jobs in the list and calling AddJobItems of a singleton 
  //JobExecutor instance
  private static void ExecuteThread1() 
  { 
    Thread.Sleep(5000); 
    List<Job> jobs = new List<Job>(); 
    jobs.Add(new Job() { JobID = 11, JobName = "Thread 1 Job 1" }); 
    jobs.Add(new Job() { JobID = 12, JobName = "Thread 1 Job 2" }); 
    jobs.Add(new Job() { JobID = 13, JobName = "Thread 1 Job 3" }); 
    JobExecutor.Instance.AddJobItems(jobs); 
  } 

  //Implementation of ExecuteThread2 method that is also adding 
  //three jobs and calling AddJobItems method of singleton 
  //JobExecutor instance 
  private static void ExecuteThread2() 
  { 
    Thread.Sleep(5000); 
    List<Job> jobs = new List<Job>(); 
    jobs.Add(new Job() { JobID = 21, JobName = "Thread 2 Job 1" }); 
    jobs.Add(new Job() { JobID = 22, JobName = "Thread 2 Job 2" }); 
    jobs.Add(new Job() { JobID = 23, JobName = "Thread 2 Job 3" }); 
    JobExecutor.Instance.AddJobItems(jobs); 
  } 

  //Implementation of ExecuteThread3 method that is again 
  // adding 3 jobs instances into the list and 
  //calling AddJobItems to add those items into the list to execute
  private static void ExecuteThread3() 
  { 
    Thread.Sleep(5000); 
    List<Job> jobs = new List<Job>(); 
    jobs.Add(new Job() { JobID = 31, JobName = "Thread 3 Job 1" }); 
    jobs.Add(new Job() { JobID = 32, JobName = "Thread 3 Job 2" }); 
    jobs.Add(new Job() { JobID = 33, JobName = "Thread 3 Job 3" }); 
    JobExecutor.Instance.AddJobItems(jobs); 
  } 

  //Implementation of ExecuteJobExecutor that calls the 
  //CheckAndExecuteJobBatch to run the jobs
  public static void ExecuteJobExecutor() 
  { 
    JobExecutor.Instance.IsAlive = true; 
    JobExecutor.Instance.CheckandExecuteJobBatch(); 
  } 
} 

以下是运行此代码的输出:

任务并行库(TPL)

到目前为止,我们已经学习了一些关于多线程的核心概念,并使用线程执行多个任务。与.NET 中的经典线程模型相比,TPL 最小化了使用线程的复杂性,并通过一组 API 提供了抽象,帮助开发人员更多地专注于应用程序程序,而不是专注于如何提供线程以及其他事项。

使用 TPL 而不是线程有几个好处:

  • 它将并发自动扩展到多核级别

  • 它将 LINQ 查询自动扩展到多核级别

  • 它处理工作的分区并在需要时使用ThreadPool

  • 它易于使用,并减少了直接使用线程的复杂性

使用 TPL 创建任务

TPL API 可在System.ThreadingSystem.Threading.Tasks命名空间中使用。它们围绕任务工作,任务是异步运行的程序或代码块。可以通过调用Task.RunTaskFactory.StartNew方法来运行异步任务。当我们创建一个任务时,我们提供一个命名委托、匿名方法或 lambda 表达式,任务执行它。

ExecuteLongRunningTasksmethod using Task.Run:
class Program 
{ 
  static void Main(string[] args) 
  { 
    Task t = Task.Run(()=>ExecuteLongRunningTask(5000)); 
    t.Wait(); 
  } 

  public static void ExecuteLongRunningTask(int millis) 
  { 
    Thread.Sleep(millis); 
    Console.WriteLine("Hello World"); 

  } 
} 
ExecuteLongRunningTask method asynchronously using the Task.Run method. The Task.Run method returns the Task object that can be used to further wait for the asynchronous piece of code to be executed completely before the program ends. To wait for the task, we have used the Wait method.

或者,我们也可以使用Task.Factory.StartNew方法,这是更高级的并提供更多选项。在调用Task.Factory.StartNew方法时,我们可以指定CancellationTokenTaskCreationOptionsTaskScheduler来设置状态、指定其他选项和安排任务。

TPL 默认使用 CPU 的多个核心。当使用 TPL API 执行任务时,它会自动将任务分割成一个或多个线程,并利用多个处理器(如果可用)。创建多少个线程的决定是由 CLR 在运行时计算的。而线程只有一个处理器的亲和性,要在多个处理器上运行任何任务需要适当的手动实现。

基于任务的异步模式(TAP)

在开发任何软件时,总是要在设计其架构时实现最佳实践。基于任务的异步模式是在使用 TPL 时可以使用的推荐模式之一。然而,在实现 TAP 时有一些需要牢记的事情。

命名约定

异步执行的方法应该以Async作为命名后缀。例如,如果方法名以ExecuteLongRunningOperation开头,它应该有后缀Async,结果名称为ExecuteLongRunningOperationAsync

返回类型

方法签名应该返回System.Threading.Tasks.TaskSystem.Threading.Tasks.Task<TResult>。任务的返回类型等同于返回void的方法,而TResult是数据类型。

参数

outref参数不允许作为方法签名中的参数。如果需要返回多个值,可以使用元组或自定义数据结构。方法应该始终返回TaskTask<TResult>,如前面所讨论的。

以下是同步和异步方法的一些签名:

同步方法 异步方法
Void Execute(); Task ExecuteAsync();
List<string> GetCountries(); Task<List<string>> GetCountriesAsync();
Tuple<int, string> GetState(int stateID); Task<Tuple<int, string>> GetStateAsync(int stateID);
Person GetPerson(int personID); Task<Person> GetPersonAsync(int personID);

异常

异步方法应该总是抛出分配给返回任务的异常。然而,使用错误,比如将空参数传递给异步方法,应该得到适当处理。

假设我们想根据预定义的模板列表动态生成多个文档,其中每个模板都使用动态值填充占位符并将其写入文件系统。我们假设这个操作将花费足够长的时间来为每个模板生成一个文档。下面是一个代码片段,显示了如何处理异常:

static void Main(string[] args) 
{ 
  List<Template> templates = GetTemplates(); 
  IEnumerable<Task> asyncDocs = from template in templates select 
  GenerateDocumentAsync(template); 
  try 
  { 
    Task.WaitAll(asyncDocs.ToArray()); 

  }catch(Exception ex) 
  { 
    Console.WriteLine(ex); 
  } 
  Console.Read(); 
} 

private static async Task<int> GenerateDocumentAsync(Template template) 
{ 
  //To automate long running operation 
  Thread.Sleep(3000); 
  //Throwing exception intentionally 
  throw new Exception(); 
}

在上面的代码中,我们有一个GenerateDocumentAsync方法,执行长时间运行的操作,比如从数据库中读取模板,填充占位符,并将文档写入文件系统。为了自动化这个过程,我们使用Thread.Sleep来让线程休眠三秒,然后抛出一个异常,这个异常将传播到调用方法。Main方法循环遍历模板列表,并为每个模板调用GenerateDocumentAsync方法。每个GenerateDocumentAsync方法都返回一个任务。在调用异步方法时,异常实际上是隐藏的,直到调用WaitWaitAllWhenAll和其他方法。在上面的例子中,一旦调用Task.WaitAll方法,异常将被抛出,并在控制台上记录异常。

任务状态

任务对象提供了TaskStatus,用于了解任务是否正在执行方法运行,已完成方法,遇到故障,或者是否发生了其他情况。使用Task.Run初始化的任务最初具有Created状态,但当调用Start方法时,其状态会更改为Running。在应用 TAP 模式时,所有方法都返回Task对象,无论它们是否在方法体内使用Task.Run,方法体都应该被激活。这意味着状态应该是除了Created之外的任何状态。TAP 模式确保消费者任务已激活,并且不需要启动任务。

任务取消

取消对于基于 TAP 的异步方法是可选的。如果方法接受CancellationToken作为参数,调用方可以使用它来取消任务。但是,对于 TAP,取消应该得到适当处理。这是一个基本示例,显示了如何实现取消:

static void Main(string[] args) 
{ 
  CancellationTokenSource tokenSource = new CancellationTokenSource(); 
  CancellationToken token = tokenSource.Token; 
  Task.Factory.StartNew(() => SaveFileAsync(path, bytes, token)); 
} 

static Task<int> SaveFileAsync(string path, byte[] fileBytes, CancellationToken cancellationToken) 
{ 
  if (cancellationToken.IsCancellationRequested) 
  { 
    Console.WriteLine("Cancellation is requested..."); 
    cancellationToken.ThrowIfCancellationRequested      
  } 
  //Do some file save operation 
  File.WriteAllBytes(path, fileBytes); 
  return Task.FromResult<int>(0); 
} 

在前面的代码中,我们有一个SaveFileAsync方法,它接受byte数组和CancellationToken作为参数。在Main方法中,我们初始化了CancellationTokenSource,可以在程序后面用于取消异步操作。为了测试取消场景,我们将在Task.Factory.StartNew方法之后调用tokenSourceCancel方法,操作将被取消。此外,当任务被取消时,其状态设置为CancelledIsCompleted属性设置为true

任务进度报告

使用 TPL,我们可以使用IProgress<T>接口从异步操作中获取实时进度通知。这可以用于需要更新用户界面或控制台应用程序的异步操作的场景。在定义基于 TAP 的异步方法时,在参数中定义IProgress<T>是可选的。我们可以有重载的方法,可以帮助消费者在特定需要的情况下使用。但是,它们只能在异步方法支持它们的情况下使用。这是修改后的SaveFileAsync,用于向用户更新实际进度:

static void Main(string[] args) 
{ 
  var progressHandler = new Progress<string>(value => 
  { 
    Console.WriteLine(value); 
  }); 

  var progress = progressHandler as IProgress<string>; 

  CancellationTokenSource tokenSource = new CancellationTokenSource(); 
  CancellationToken token = tokenSource.Token; 

  Task.Factory.StartNew(() => SaveFileAsync(path, bytes, 
  token, progress)); 
  Console.Read(); 

} 
static Task<int> SaveFileAsync(string path, byte[] fileBytes, CancellationToken cancellationToken, IProgress<string> progress) 
{ 
  if (cancellationToken.IsCancellationRequested) 
  { 
    progress.Report("Cancellation is called"); 
    Console.WriteLine("Cancellation is requested..."); 
  } 

  progress.Report("Saving File"); 
  File.WriteAllBytes(path, fileBytes);   
  progress.Report("File Saved"); 
  return Task.FromResult<int>(0); 

} 

使用编译器实现 TAP

任何使用async关键字(对于 C#)或Async(对于 Visual Basic)标记的方法都称为异步方法。async关键字可以应用于方法、匿名方法或 Lambda 表达式,语言编译器可以异步执行该任务。

这是使用编译器方法的 TAP 方法的简单实现:

static void Main(string[] args) 
{ 
  var t = ExecuteLongRunningOperationAsync(100000); 
  Console.WriteLine("Called ExecuteLongRunningOperationAsync method, 
  now waiting for it to complete"); 
  t.Wait(); 
  Console.Read(); 
}   

public static async Task<int> ExecuteLongRunningOperationAsync(int millis) 
{ 
  Task t = Task.Factory.StartNew(() => RunLoopAsync(millis)); 
  await t; 
  Console.WriteLine("Executed RunLoopAsync method"); 
  return 0; 
} 

public static void RunLoopAsync(int millis) 
{ 
  Console.WriteLine("Inside RunLoopAsync method"); 
  for(int i=0;i< millis; i++) 
  { 
    Debug.WriteLine($"Counter = {i}"); 
  } 
  Console.WriteLine("Exiting RunLoopAsync method"); 
} 

在前面的代码中,我们有ExecuteLongRunningOperationAsync方法,它是根据编译器方法实现的。它调用RunLoopAsync,该方法执行一个传递的毫秒数的循环。ExecuteLongRunningOperationAsync方法上的async关键字实际上告诉编译器该方法必须异步执行,一旦达到await语句,该方法返回到Main方法,在控制台上写一行并等待任务完成。一旦RunLoopAsync执行,控制权回到await,并开始执行ExecuteLongRunningOperationAsync方法中的下一个语句。

实现对任务的更大控制的 TAP

我们知道,TPL 以TaskTask<TResult>对象为中心。我们可以通过调用Task.Run方法执行异步任务,并异步执行delegate方法或一段代码,并在该任务上使用Wait或其他方法。然而,这种方法并不总是适当,有些情况下我们可能有不同的方法来执行异步操作,我们可能会使用基于事件的异步模式(EAP)或异步编程模型(APM)。为了在这里实现 TAP 原则,并以不同的模型执行异步操作,我们可以使用TaskCompletionSource<TResult>对象。

TaskCompletionSource<TResult>对象用于创建执行异步操作的任务。异步操作完成后,我们可以使用TaskCompletionSource<TResult>对象设置任务的结果、异常或状态。

这是一个基本示例,执行ExecuteTask方法返回Task,其中ExecuteTask方法使用TaskCompletionSource<TResult>对象将响应包装为Task,并通过Task.StartNew方法执行ExecuteLongRunningTask

static void Main(string[] args) 
{ 
  var t = ExecuteTask(); 
  t.Wait(); 
  Console.Read(); 
} 

public static Task<int> ExecuteTask() 
{ 
  var tcs = new TaskCompletionSource<int>(); 
  Task<int> t1 = tcs.Task; 
  Task.Factory.StartNew(() => 
  { 
    try 
    { 
      ExecuteLongRunningTask(10000); 
      tcs.SetResult(1); 
    }catch(Exception ex) 
    { 
      tcs.SetException(ex); 
    } 
  }); 
  return tcs.Task; 

} 

public static void ExecuteLongRunningTask(int millis) 
{ 
  Thread.Sleep(millis); 
  Console.WriteLine("Executed"); 
} 

并行编程的设计模式

任务可以以各种方式设计并行运行。在本节中,我们将学习 TPL 中使用的一些顶级设计模式:

  • 管道模式

  • 数据流模式

  • 生产者-消费者模式

  • Parallel.ForEach

  • 并行 LINQ(PLINQ)

管道模式

管道模式通常用于需要按顺序执行异步任务的场景:

考虑一个任务,我们需要首先创建一个用户记录,然后启动工作流并发送电子邮件。要实现这种情况,我们可以使用 TPL 的ContinueWith方法。以下是一个完整的示例:

static void Main(string[] args) 
{ 

  Task<int> t1 = Task.Factory.StartNew(() =>  
  { return CreateUser(); }); 

  var t2=t1.ContinueWith((antecedent) => 
  { return InitiateWorkflow(antecedent.Result); }); 
  var t3 = t2.ContinueWith((antecedant) => 
  { return SendEmail(antecedant.Result); }); 

  Console.Read(); 

} 

public static int CreateUser() 
{ 
  //Create user, passing hardcoded user ID as 1 
  Thread.Sleep(1000); 
  Console.WriteLine("User created"); 
  return 1; 
} 

public static int InitiateWorkflow(int userId) 
{ 
  //Initiate Workflow 
  Thread.Sleep(1000); 
  Console.WriteLine("Workflow initiates"); 

  return userId; 
} 

public static int SendEmail(int userId) 
{ 
  //Send email 
  Thread.Sleep(1000); 
  Console.WriteLine("Email sent"); 

  return userId; 
}  

数据流模式

数据流模式是一种具有一对多和多对一关系的通用模式。例如,以下图表表示两个任务任务 1任务 2并行执行,第三个任务任务 3只有在前两个任务都完成后才会开始。一旦任务 3完成,任务 4任务 5将并行执行:

我们可以使用以下代码实现上述示例:

static void Main(string[] args) 
{ 
  //Creating two tasks t1 and t2 and starting them at the same //time
  Task<int> t1 = Task.Factory.StartNew(() => { return Task1(); }); 
  Task<int> t2 = Task.Factory.StartNew(() => { return Task2(); }); 

  //Creating task 3 and used ContinueWhenAll that runs when both the 
  //tasks T1 and T2 will be completed
  Task<int> t3 = Task.Factory.ContinueWhenAll(
  new[] { t1, t2 }, (tasks) => { return Task3(); }); 

  //Task 4 and Task 5 will be started when Task 3 will be completed. 
  //ContinueWith actually creates a continuation of executing tasks 
  //T4 and T5 asynchronously when the task T3 is completed
  Task<int> t4 = t3.ContinueWith((antecendent) => { return Task4(); }); 
  Task<int> t5 = t3.ContinueWith((antecendent) => { return Task5(); }); 
  Console.Read(); 
} 
//Implementation of Task1
public static int Task1() 
{ 
  Thread.Sleep(1000); 
  Console.WriteLine("Task 1 is executed"); 
  return 1; 
} 

//Implementation of Task2 
public static int Task2() 
{ 
  Thread.Sleep(1000); 
  Console.WriteLine("Task 2 is executed"); 
  return 1; 
} 
//Implementation of Task3 
public static int Task3() 
{ 
  Thread.Sleep(1000); 
  Console.WriteLine("Task 3 is executed"); 
  return 1; 
} 
Implementation of Task4
public static int Task4() 
{ 
  Thread.Sleep(1000); 
  Console.WriteLine("Task 4 is executed"); 
  return 1; 
} 

//Implementation of Task5
public static int Task5() 
{ 
  Thread.Sleep(1000); 
  Console.WriteLine("Task 5 is executed"); 
  return 1; 
} 

生产者/消费者模式

执行长时间运行操作的最佳模式之一是生产者/消费者模式。在这种模式中,有生产者和消费者,一个或多个生产者通过共享的数据结构BlockingCollection连接到一个或多个消费者。BlockingCollection是并行编程中使用的固定大小的集合。如果集合已满,生产者将被阻塞,如果集合为空,则不应再添加更多的消费者:

在现实世界的例子中,生产者可以是从数据库中读取图像的组件,消费者可以是处理该图像并将其保存到文件系统的组件:

static void Main(string[] args) 
{ 
  int maxColl = 10; 
  var blockingCollection = new BlockingCollection<int>(maxColl); 
  var taskFactory = new TaskFactory(TaskCreationOptions.LongRunning, 
  TaskContinuationOptions.None); 

  Task producer = taskFactory.StartNew(() => 
  { 
    if (blockingCollection.Count <= maxColl) 
    { 
      int imageID = ReadImageFromDB(); 
      blockingCollection.Add(imageID); 
      blockingCollection.CompleteAdding(); 
    } 
  }); 

  Task consumer = taskFactory.StartNew(() => 
  { 
    while (!blockingCollection.IsCompleted) 
    { 
      try 
      { 
        int imageID = blockingCollection.Take(); 
        ProcessImage(imageID); 
      } 
      catch (Exception ex) 
      { 
        //Log exception 
      } 
    } 
  }); 

  Console.Read(); 

} 

public static int ReadImageFromDB() 
{ 
  Thread.Sleep(1000); 
  Console.WriteLine("Image is read"); 
  return 1; 
} 

public static void ProcessImage(int imageID) 
{ 
  Thread.Sleep(1000); 
  Console.WriteLine("Image is processed"); 

} 

在上面的示例中,我们初始化了通用的BlockingCollection<int>来存储由生产者添加并通过消费者处理的imageID。我们将集合的最大大小设置为 10。然后,我们添加了一个Producer项,它从数据库中读取图像并调用Add方法将imageID添加到阻塞集合中,消费者可以进一步提取并处理。消费者任务只需检查集合中是否有可用项目并对其进行处理。

要了解有关并行编程可用的数据结构,请参阅docs.microsoft.com/en-us/dotnet/standard/parallel-programming/data-structures-for-parallel-programming

Parallel.ForEach

Parallel.ForEach是经典foreach循环的多线程版本。foreach循环在单个线程上运行,而Parallel.ForEach在多个线程上运行,并利用 CPU 的多个核心(如果可用)。

以下是一个基本示例,使用Parallel.ForEach处理需要处理的文档列表,并包含 I/O 绑定操作:

static void Main(string[] args) 
{ 
  List<Document> docs = GetUserDocuments(); 
  Parallel.ForEach(docs, (doc) => 
  { 
    ManageDocument(doc); 
  }); 
} 
private static void ManageDocument(Document doc) => Thread.Sleep(1000); 

为了复制 I/O 绑定的操作,我们只是在ManageDocument方法中添加了 1 秒的延迟。如果您使用foreach循环执行相同的方法,差异将是明显的。

并行 LINQ(PLINQ)

并行 LINQ 是 LINQ 的一个版本,它在多核 CPU 上并行执行查询。它包含完整的标准 LINQ 查询操作符以及一些用于并行操作的附加操作符。强烈建议您在长时间运行的任务中使用此功能,尽管不正确的使用可能会降低应用程序的性能。并行 LINQ 操作集合,如ListList<T>IEnumerableIEnumerable<T>等。在底层,它将列表分割成段,并在 CPU 的不同处理器上运行每个段。

以下是上一个示例的修改版本,使用Parallel.ForEach而不是 PLINQ 操作:

static void Main(string[] args) 
{ 
  List<Document> docs = GetUserDocuments(); 

  var query = from doc in docs.AsParallel() 
  select ManageDocument(doc); 
} 

private static Document ManageDocument(Document doc) 
{ 
  Thread.Sleep(1000); 
  return doc; 
} 

摘要

在本章中,我们学习了多线程和异步编程的核心基础知识。本章从两者之间的基本区别开始,并介绍了一些关于多线程的核心概念,可用的 API 以及如何编写多线程应用程序。我们还看了任务编程库如何用于提供异步操作以及如何实现任务异步模式。最后,我们探讨了并行编程技术以及用于这些技术的一些最佳设计模式。

在下一章中,我们将探讨数据结构的类型及其对性能的影响,如何编写优化的代码以及一些最佳实践。

第四章:数据结构和在 C#中编写优化代码

数据结构是软件工程中存储数据的一种特定方式。它们在计算机中存储数据方面发挥着重要作用,以便可以有效地访问和修改数据,并为存储不同类型的数据提供不同的存储机制。有许多类型的数据结构,每种都设计用于存储一定类型的数据。在本章中,我们将详细介绍数据结构,并了解应该在特定场景中使用哪些数据结构以改善系统在数据存储和检索方面的性能。我们还将了解如何在 C#中编写优化代码以及什么主要因素可能影响性能,这有时在编写程序时被开发人员忽视。我们将学习一些可以用于优化性能有效的最佳实践。

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

  • 数据结构是什么以及它们的特点

  • 选择正确的数据结构进行性能优化

  • 了解使用大 O 符号来衡量程序的性能和复杂性

  • 在.NET Core 中编写代码时的最佳实践

什么是数据结构?

数据结构是一种以有效的方式存储和统一数据的方式。数据可以以多种方式存储。例如,我们可以有一个包含一些属性的Person对象,例如PersonIDPersonName,其中PersonID是整数类型,PersonName字符串类型。这个Person对象将数据存储在内存中,并可以进一步用于将该记录保存在数据库中。另一个例子是名为Countries字符串类型的数组,其中包含国家列表。我们可以使用Countries数组来检索国家名称并在程序中使用它。因此,存储数据的任何类型的对象都称为数据结构。所有原始类型,例如整数、字符串、字符和布尔值,都是不同类型的数据结构,而其他集合类型,例如LinkedListArrayListSortedList等,也是可以以独特方式存储信息的数据结构类型。

以下图表说明了数据结构的类型及其相互关系:

数据结构有两种类型:原始非原始类型。原始类型是包括有符号整数无符号整数Unicode 字符IEEE 浮点高精度小数布尔枚举结构可空值类型的值类型。

以下是 C#中可用的原始数据类型列表:

原始类型
有符号整数
无符号整数
Unicode 字符
IEEE 浮点
高精度小数
布尔
字符串
对象

非原始类型是用户定义的类型,并进一步分类为线性或非线性类型。在线性数据结构中,元素按顺序组织,例如数组链表和其他相关类型,而在非线性数据结构中,元素存储在没有任何顺序的情况下,例如

以下表格显示了.NET Core 中可用的线性和非线性类的类型:

非原始类型 - 线性数据结构
数组
队列
链表

.NET Core 不提供任何非原始、非线性类型来表示树形或图形格式的数据。但是,我们可以开发自定义类来支持这些类型。

例如,以下是编写存储数据的自定义树的代码格式:

class TreeNode 
{ 
  public TreeNode(string text, object tag) 
  { 
    this.NodeText = text; 
    this.Tag = tag; 
    Nodes = new List<TreeNode>(); 
  } 
  public string NodeText { get; set; } 
  public Object Tag { get; set; } 
  public List<TreeNode> Nodes { get; set; } 
} 

最后,我们可以编写一个程序,在控制台窗口上填充树视图如下:

static void Main(string[] args) 
{ 
  TreeNode node = new TreeNode("Root", null); 
  node.Nodes.Add(new TreeNode("Child 1", null)); 
  node.Nodes[0].Nodes.Add(new TreeNode("Grand Child 1", null)); 
  node.Nodes.Add(new TreeNode("Child 1 (Sibling)", null)); 
  PopulateTreeView(node, ""); 
  Console.Read(); 
} 

//Populates a Tree View on Console 
static void PopulateTreeView(TreeNode node, string space) 
{ 
  Console.WriteLine(space + node.NodeText); 
  space = space + " "; 
  foreach(var treenode in node.Nodes) 
  { 
    //Recurive call 
    PopulateTreeView(treenode, space); 
  } 
}

当您运行上述程序时,它会生成以下输出:

理解使用大 O 符号来衡量算法的性能和复杂性

大 O 符号用于定义算法的复杂性和性能,以及在执行期间所消耗的时间或空间。这是一种表达算法性能并确定程序最坏情况复杂性的重要技术。

为了详细了解它,让我们通过一些代码示例并使用大 O 符号来计算它们的性能。

如果我们计算以下程序的复杂度,大 O 符号将等于O(1)

static int SumNumbers(int a, int b) 
{ 
  return a + b; 
} 

这是因为无论参数如何指定,它只是添加并返回它。

让我们考虑另一个循环遍历列表的程序。大 O 符号将被确定为O(N)

static bool FindItem(List<string> items, string value) 
{ 
  foreach(var item in items) 
  { 
    if (item == value) 
    { 
      return true; 
    } 
  } 
  return false; 
} 

在上面的示例中,程序正在循环遍历项目列表,并将传递的值与列表中的每个项目进行比较。如果找到项目,则程序返回true

复杂度被确定为O(N),因为最坏情况可能是一个循环向N个项目,其中N可以是第一个索引或任何索引,直到达到最后一个索引,即N

现在,让我们看一个选择排序的例子,它被定义为O(N2)

static void SelectionSort(int[] nums) 
{ 
  int i, j, min; 

  // One by one move boundary of unsorted subarray 
  for (i = 0; i <nums.Length-1; i++) 
  { 
    min = i; 
    for (j = i + 1; j < nums.Length; j++) 
    if (nums[j] < nums[min]) 
    min = j; 

    // Swap the found minimum element with the first element 
    int temp = nums[min]; 
    nums[min] = nums[i]; 
    nums[i] = temp; 
  } 
} 

在上面的示例中,我们有两个嵌套的循环。第一个循环从0遍历到最后一个索引,而第二个循环从下一个项目遍历到倒数第二个项目,并交换值以按升序排序数组。嵌套循环的数量与N的幂成正比,因此大 O 符号被定义为O(N2)

接下来,让我们考虑一个递归函数,其中大 O 符号被定义为O(2N),其中2N确定所需的时间,随着输入数据集中每个额外元素的加入而加倍。以下是一个递归调用方法的示例,该方法递归调用方法,直到计数器变为最大数字为止:

static void Main(string[] args){ 
  Fibonacci_Recursive(0, 1, 1, 10); 
} 

static void Fibonacci_Recursive(int a, int b, int counter, int maxNo) 
{ 
  if (counter <= maxNo) 
  { 
    Console.Write("{0} ", a); 
    Fibonacci_Recursive(b, a + b, counter + 1, len); 
  } 
} 

对数

对数运算是指数运算的完全相反。对数是表示必须将基数提高到产生给定数字的幂的数量。

例如,2x = 32,其中x=5,可以表示为log2 32 =5

在这种情况下,上述表达式的对数是 5,表示固定数字 2 的幂,它被提高以产生给定数字 32。

考虑一个二分搜索算法,通过将项目列表分成两个数据集并根据数字使用特定数据集来更有效地工作。例如,假设我有一个按升序排列的不同数字列表:

{1, 5, 6, 10, 15, 17, 20, 42, 55, 60, 67, 80, 100}

假设我们要找到数字55。这样做的一种方法是循环遍历每个索引并逐个检查每个项目。更有效的方法是将列表分成两组,并检查我要查找的数字是否大于第一个数据集的最后一个项目,或者使用第二个数据集。

以下是一个二分搜索的示例,其大 O 符号将被确定为O(LogN)

static int binarySearch(int[] nums, int startingIndex, int length, int itemToSearch) 
{ 
  if (length >= startingIndex) 
  { 
    int mid = startingIndex + (length - startingIndex) / 2; 

    // If the element found at the middle itself 
    if (nums[mid] == itemToSearch) 
    return mid; 

    // If the element is smaller than mid then it is 
    // present in left set of array 
    if (nums[mid] > itemToSearch) 
    return binarySearch(nums, startingIndex, mid - 1, itemToSearch); 

    // Else the element is present in right set of array 
    return binarySearch(nums, mid + 1, length, itemToSearch); 
  } 

  // If item not found return 1 
  return -1; 
} 

选择正确的数据结构进行性能优化

数据结构是计算机程序中组织数据的一种精确方式。如果数据没有有效地存储在正确的数据结构中,可能会导致一些影响应用程序整体体验的性能问题。

在本节中,我们将学习.NET Core 中可用的不同集合类型的优缺点,以及哪些类型适用于特定场景:

  • 数组和列表

  • 栈和队列

  • 链表(单链表,双链表和循环链表)

  • 字典,哈希表和哈希集

  • 通用列表

数组

数组是保存相似类型元素的集合。可以创建值类型和引用类型的数组。

以下是数组有用的一些情况:

  • 如果数据是固定的,长度固定,使用数组比其他集合更快,例如arraylists和通用列表

  • 数组很适合以多维方式表示数据

  • 它们占用的内存比其他集合少

  • 使用数组,我们可以顺序遍历元素

以下表格显示了可以在数组中执行的每个操作的大 O 符号:

操作 大 O 符号
按索引访问 O(1)
搜索 O(n)
在末尾插入 O(n)
在末尾删除 O(n)
在最后一个元素之前的位置插入 O(n)
删除索引处的元素 O(1)

如前表所示,在特定位置搜索和插入项目会降低性能,而访问索引中的任何项目或从任何位置删除它对性能的影响较小。

列表

.NET 开发人员广泛使用列表。虽然在许多情况下最好使用它,但也存在一些性能限制。

当您想使用索引访问项目时,大多数情况下建议使用列表。与链表不同,链表需要使用枚举器迭代每个节点来搜索项目,而使用列表,我们可以轻松使用索引访问它。

以下是列表有用的一些建议:

  • 建议在集合大小未知时使用列表。调整数组大小是一项昂贵的操作,而使用列表,我们可以根据需要轻松增加集合的大小。

  • 与数组不同,列表在创建时不会为项目数量保留总内存地址空间。这是因为使用列表时不需要指定集合的大小。另一方面,数组依赖于初始化时的类型和大小,并在初始化期间保留地址空间。

  • 使用列表,我们可以使用 lambda 表达式来过滤记录,按降序对项目进行排序,并执行其他操作。数组不提供排序、过滤或其他此类操作。

  • 列表表示单维集合。

以下表格显示了可以在列表上执行的每个操作的大 O 符号:

操作 大 O 符号
按索引访问 O(1)
搜索 O(n)
在末尾插入 O(1)
从末尾删除 O(1)
在最后一个元素之前的位置插入 O(n)
删除索引处的元素 O(n)

堆栈

堆栈以后进先出LIFO)顺序维护项目的集合。最后插入的项目首先被检索。堆栈只允许两种操作,即pushpop。堆栈的真正应用是undo操作,它将更改插入堆栈中,并在撤消时删除执行的最后一个操作:

上图说明了如何将项目添加到堆栈中。最后插入的项目首先弹出,要访问首先插入的项目,我们必须弹出每个元素,直到达到第一个元素。

以下是一些堆栈有用的情况:

  • 当访问其值时应删除项目的情况

  • 需要在程序中实现undo操作

  • 在 Web 应用程序上维护导航历史记录

  • 递归操作

以下表格显示了可以在堆栈上执行的每个操作的大 O 符号:

操作 大 O 符号
访问第一个对象 O(1)
搜索 O(n)
推送项目 O(1)
弹出项目 O(1)

队列

队列以先进先出FIFO)顺序维护项目的集合。首先插入队列的项目首先从队列中检索。队列中只允许三种操作,即EnqueueDequeuePeek

Enqueue将元素添加到队列的末尾,而Dequeue从队列的开头移除元素。Peek返回队列中最旧的元素,但不会将它们移除:

上图说明了如何将项目添加到队列。首先插入的项目将首先从队列中移除,并且指针移动到队列中的下一个项目。Peek始终返回第一个插入的项目或指针所指向的项目,取决于是否移除了第一个项目。

以下是队列有用的一些情况:

  • 按顺序处理项目

  • 按先来先服务的顺序提供服务

以下表格显示了可以在队列上执行的每个操作的大 O 表示法:

操作 大 O 表示法
访问第一个插入的对象 O(1)
搜索 O(n)
队列项目 O(1)
入队项目 O(1)
Peek 项目 O(1)

链表

链表是一种线性数据结构,其中列表中的每个节点都包含对下一个节点的引用指针,最后一个节点引用为 null。第一个节点称为头节点。有三种类型的链表,称为单向双向循环链表。

单链表

单链表只包含对下一个节点的引用。以下图表示单链表:

双向链表

在双向链表中,节点包含对下一个节点和上一个节点的引用。用户可以使用引用指针向前和向后迭代。以下图像是双向链表的表示:

循环链表

在循环链表中,最后一个节点指向第一个节点。以下是循环链表的表示:

以下是链表有用的一些情况:

  • 以顺序方式提供对项目的访问

  • 在列表的任何位置插入项目

  • 在任何位置或节点删除任何项目

  • 当需要消耗更少的内存时,因为链表中没有数组复制

以下表格显示了可以在链表上执行的每个操作的大 O 表示法值:

操作 大 O 表示法
访问项目 O(1)
搜索项目 O(n)
插入项目 O(1)
删除项目 O(1)

字典,哈希表和哈希集

字典,哈希表和哈希集对象以键-值格式存储项目。但是,哈希集和字典适用于性能至关重要的场景。以下是这些类型有用的一些情况:

  • 以键-值格式存储可以根据特定键检索的项目

  • 存储唯一值

以下表格显示了可以在这些对象上执行的每个操作的大 O 表示法值:

操作 大 O 表示法
访问 O(n)
如果不知道键,则搜索值 O(n)
插入项目 O(n)
删除项目 O(n)

通用列表

通用列表是一种强类型的元素列表,可以使用索引访问。与数组不同,通用列表是可扩展的,列表可以动态增长;因此,它们被称为动态数组或向量。与数组不同,通用列表是一维的,是操作内存中元素集合的最佳选择之一。

我们可以定义一个通用列表,如下面的代码示例所示。代码短语lstNumbers只允许存储整数值,短语lstNames存储only字符串,personLst存储Person对象,等等:

List<int> lstNumbers = new List<int>();     
List<string> lstNames = new List<string>();     
List<Person> personLst = new List<Person>();              
HashSet<int> hashInt = new HashSet<int>();

以下表格显示了可以在这些对象上执行的每个操作的大 O 符号值:

操作 大 O 符号
通过索引访问 O(1)
搜索 O(n)
在末尾插入 O(1)
从末尾删除 O(1)
在最后一个元素之前的位置插入 O(n)
删除索引处的元素 O(n)

在 C#中编写优化代码的最佳实践

有许多因素会对.NET Core 应用程序的性能产生负面影响。有时这些是在编写代码时未考虑的小事情,并且不符合已接受的最佳实践。因此,为了解决这些问题,程序员经常求助于临时解决方案。然而,当不良实践结合在一起时,它们会产生性能问题。了解有助于开发人员编写更清洁的代码并使应用程序性能良好的最佳实践总是更好的。

在本节中,我们将学习以下主题:

  • 装箱和拆箱开销

  • 字符串连接

  • 异常处理

  • forforeach

  • 委托

装箱和拆箱开销

装箱和拆箱方法并不总是好用的,它们会对关键任务应用程序的性能产生负面影响。装箱是将值类型转换为对象类型的方法,它是隐式完成的,而拆箱是将对象类型转换回值类型的方法,需要显式转换。

让我们通过一个例子来看,我们有两种方法执行 1000 万条记录的循环,每次迭代时都会将计数器加 1。AvoidBoxingUnboxing方法使用原始整数来初始化并在每次迭代时递增,而BoxingUnboxing方法是通过首先将数值赋给对象类型进行装箱,然后在每次迭代时进行拆箱以将其转换回整数类型,如下所示:

private static void AvoidBoxingUnboxing() 
{ 

  Stopwatch watch = new Stopwatch(); 
  watch.Start(); 
  //Boxing  
  int counter = 0; 
  for (int i = 0; i < 1000000; i++) 
  { 
    //Unboxing 
    counter = i + 1; 
  } 
  watch.Stop(); 
  Console.WriteLine($"Time taken {watch.ElapsedMilliseconds}"); 
} 

private static void BoxingUnboxing() 
{ 

  Stopwatch watch = new Stopwatch(); 
  watch.Start(); 
  //Boxing  
  object counter = 0; 
  for (int i = 0; i < 1000000; i++) 
  { 
    //Unboxing 
    counter = (int)i + 1; 
  } 
  watch.Stop(); 
  Console.WriteLine($"Time taken {watch.ElapsedMilliseconds}"); 
}

当我们运行这两种方法时,我们将清楚地看到性能上的差异。如下截图所示,BoxingUnboxing方法的执行速度比AvoidBoxingUnboxing方法慢了七倍:

对于关键任务应用程序,最好避免装箱和拆箱。然而,在.NET Core 中,我们有许多其他类型,内部使用对象并执行装箱和拆箱。System.CollectionsSystem.Collections.Specialized下的大多数类型在内部存储时使用对象和对象数组,当我们在这些集合中存储原始类型时,它们执行装箱并将每个原始值转换为对象类型,增加额外开销并对应用程序的性能产生负面影响。System.Data的其他类型,即DateSetDataTableDataRow,也在内部使用对象数组。

在性能是主要关注点时,System.Collections.Generic命名空间下的类型或类型化数组是最佳的方法。例如,HashSet<T>LinkedList<T>List<T>都是通用集合类型。

例如,这是一个将整数值存储在ArrayList中的程序:

private static void AddValuesInArrayList() 
{ 

  Stopwatch watch = new Stopwatch(); 
  watch.Start(); 
  ArrayList arr = new ArrayList(); 
  for (int i = 0; i < 1000000; i++) 
  { 
    arr.Add(i); 
  } 
  watch.Stop(); 
  Console.WriteLine($"Total time taken is 
  {watch.ElapsedMilliseconds}"); 
}

让我们编写另一个使用整数类型的通用列表的程序:

private static void AddValuesInGenericList() 
{ 

  Stopwatch watch = new Stopwatch(); 
  watch.Start(); 
  List<int> lst = new List<int>(); 
  for (int i = 0; i < 1000000; i++) 
  { 
    lst.Add(i); 
  } 
  watch.Stop(); 
  Console.WriteLine($"Total time taken is 
  {watch.ElapsedMilliseconds}"); 
} 

运行这两个程序时,差异是非常明显的。使用通用列表List<int>的代码比使用ArrayList的代码快了 10 倍以上。结果如下:

字符串连接

在.NET 中,字符串是不可变对象。直到字符串值改变之前,两个字符串引用堆上的相同内存。如果任何一个字符串被改变,将在堆上创建一个新的字符串,并分配一个新的内存空间。不可变对象通常是线程安全的,并消除了多个线程之间的竞争条件。字符串值的任何更改都会在内存中创建并分配一个新对象,并避免与多个线程产生冲突的情况。

例如,让我们初始化字符串并将Hello World的值分配给a字符串变量:

String a = "Hello World"; 

现在,让我们将a字符串变量分配给另一个变量b

String b = a;

ab都指向堆上的相同值,如下图所示:

现在,假设我们将b的值更改为Hope this helps

b= "Hope this helps"; 

这将在堆上创建另一个对象,其中a指向相同的对象,而b指向包含新文本的新内存空间:

随着字符串的每次更改,对象都会分配一个新的内存空间。在某些情况下,这可能是一个过度的情况,其中字符串修改的频率较高,并且每次修改都会分配一个单独的内存空间,这会导致垃圾收集器在收集未使用的对象并释放空间时产生额外的工作。在这种情况下,强烈建议您使用StringBuilder类。

异常处理

不正确处理异常也会降低应用程序的性能。以下列表包含了在.NET Core 中处理异常的一些最佳实践:

  • 始终使用特定的异常类型或可以捕获方法中的异常的类型。对所有情况使用Exception类型不是一个好的做法。

  • 在可能引发异常的代码中,始终使用trycatchfinally块。通常使用最终块来清理资源,并返回调用代码期望的适当响应。

  • 在嵌套深的代码中,不要使用try catch块,而是将其处理给调用方法或主方法。在多个堆栈上捕获异常会减慢性能,不建议这样做。

  • 始终使用异常处理程序来处理终止程序的致命条件。

  • 不建议对非关键条件使用异常,例如将值转换为整数或从空数组中读取值,并且应通过自定义逻辑进行处理。例如,将字符串值转换为整数类型可以使用Int32.Parse方法,而不是使用Convert.ToInt32方法,然后在字符串表示为数字时失败。

  • 在抛出异常时,添加一个有意义的消息,以便用户知道异常实际发生的位置,而不是查看堆栈跟踪。例如,以下代码显示了抛出异常并根据所调用的方法和类添加自定义消息的方法:

static string GetCountryDetails(Dictionary<string, string> countryDictionary, string key)
{
  try
  {
    return countryDictionary[key];
  }
  catch (KeyNotFoundException ex)
  {
    KeyNotFoundException argEx = new KeyNotFoundException("
    Error occured while executing GetCountryDetails method. 
    Cause: Key not found", ex);
    throw argEx;
  }
}
  • 抛出异常而不是返回自定义消息或错误代码,并在主调用方法中处理它。

  • 在记录异常时,始终检查内部异常并阅读异常消息或堆栈跟踪。这是有帮助的,并且可以给出代码中实际引发错误的位置。

forforeach

forforeach是在列表中进行迭代的两种替代方式。它们每个都以不同的方式运行。for 循环实际上首先将列表的所有项加载到内存中,然后使用索引器迭代每个元素,而 foreach 使用枚举器并迭代直到达到列表的末尾。

以下表格显示了适合在forforeach中使用的集合类型:

类型 For/Foreach
类型化数组 适合使用 for 和 foreach
数组列表 更适合使用 for
通用集合 更适合使用 for

委托

委托是.NET 中保存方法引用的一种类型。该类型相当于 C 或 C++中的函数指针。在定义委托时,我们可以指定方法可以接受的参数和返回类型。这样,引用方法将具有相同的签名。

这是一个简单的委托,它接受一个字符串并返回一个整数:

delegate int Log(string n);

现在,假设我们有一个LogToConsole方法,它具有与以下代码中所示的相同签名。该方法接受字符串并将其写入控制台窗口:

static int LogToConsole(string a) { Console.WriteLine(a); 
  return 1; 
}   

我们可以像这样初始化和使用这个委托:

Log logDelegate = LogToConsole; 
logDelegate ("This is a simple delegate call"); 

假设我们有另一个名为LogToDatabase的方法,它将信息写入数据库:

static int LogToDatabase(string a) 
{ 
  Console.WriteLine(a); 
  //Log to database 
  return 1; 
} 

这是新的logDelegate实例的初始化,它引用了LogToDatabase方法:

Log logDelegateDatabase = LogToDatabase; 
logDelegateDatabase ("This is a simple delegate call"); 

前面的委托是单播委托的表示,因为每个实例都引用一个方法。另一方面,我们也可以通过将LogToDatabase分配给相同的LogDelegate实例来创建多播委托,如下所示:

Log logDelegate = LogToConsole; 
logDelegate += LogToDatabase; 
logDelegate("This is a simple delegate call");     

前面的代码看起来非常直接和优化,但在底层,它有巨大的性能开销。在.NET 中,委托是由一个MutlicastDelegate类实现的,它经过优化以运行单播委托。它将方法的引用存储到目标属性,并直接调用该方法。对于多播委托,它使用调用列表,这是一个通用列表,并保存添加的每个方法的引用。对于多播委托,每个目标属性都保存对包含方法的通用列表的引用,并按顺序执行。然而,这会为多播委托增加开销,并且需要更多时间来执行。

总结

在这一章中,我们学习了关于数据结构的核心概念,数据结构的类型,以及它们的优缺点,接着是它们可以使用的最佳场景。我们还学习了大 O 符号,这是编写代码时需要考虑的核心主题之一,它帮助开发人员识别代码性能。最后,我们研究了一些最佳实践,并涵盖了诸如装箱和拆箱、字符串连接、异常处理、forforeach循环以及委托等主题。

在下一章中,我们将学习一些在设计.NET Core 应用程序时可能有帮助的准则和最佳实践。

第五章:.NET Core 应用程序性能设计指南

架构和设计是任何应用程序的核心基础。遵循最佳实践和指南使应用程序具有高可维护性、高性能和可扩展性。应用程序可以是基于 Web 的应用程序、Web API、服务器/客户端基于 TCP 的消息传递应用程序、关键任务应用程序等等。然而,所有这些应用程序都应该遵循一定的实践,从而在各种方面获益。在本章中,我们将学习几种几乎所有应用程序中常见的实践。

以下是本章将学习的一些原则:

  • 编码原则:

  • 命名约定

  • 代码注释

  • 每个文件一个类

  • 每个方法一个逻辑

  • 设计原则:

  • KISS(保持简单,愚蠢)

  • YAGNI(你不会需要它)

  • DRY(不要重复自己)

  • 关注点分离

  • SOLID 原则

  • 缓存

  • 数据结构

  • 通信

  • 资源管理

  • 并发

编码原则

在本节中,我们将介绍一些基本的编码原则,这些原则有助于编写提高应用程序整体性能和可扩展性的优质代码。

命名约定

在每个应用程序中始终使用适当的命名约定,从解决方案名称开始,解决方案名称应提供有关您正在工作的项目的有意义的信息。项目名称指定应用程序的层或组件部分。最后,类应该是名词或名词短语,方法应该代表动作。

当我们在 Visual Studio 中创建一个新项目时,默认的解决方案名称设置为您为项目名称指定的内容。解决方案名称应始终与项目名称不同,因为一个解决方案可能包含多个项目。项目名称应始终代表系统的特定部分。例如,假设我们正在开发一个消息网关,该网关向不同的方发送不同类型的消息,并包含三个组件,即监听器、处理器和调度器;监听器监听传入的请求,处理器处理传入的消息,调度器将消息发送到目的地。命名约定可以如下:

  • 解决方案名称:MessagingGateway(或任何代码词)

  • 监听器项目名称:ListenerApp

  • 处理器项目名称:ProcessorAPI(如果是 API)

  • 调度项目名称:DispatcherApp

在.NET 中,我们通常遵循的命名约定是类和方法名称使用帕斯卡命名法。在帕斯卡命名法中,每个单词的第一个字符都是大写字母,而参数和其他变量则使用骆驼命名法。以下是一些示例代码,显示了在.NET 中应如何使用命名法。

public class MessageDispatcher 
{ 
  public const string SmtpAddress = "smpt.office365.com"; 

  public void SendEmail(string fromAddress, string toAddress, 
  string subject, string body) 
  { 

  } 
}

在上述代码中,我们有一个常量字段SmtpAddress和一个使用帕斯卡命名法的SendEmail方法,而参数则使用骆驼命名法。

以下表格总结了.NET 中不同工件的命名约定:

属性 命名约定 示例
帕斯卡命名法 class PersonManager {}
方法 帕斯卡命名法 void SaveRecord(Person person) {}
参数/成员变量 骆驼命名法 bool isActive;
接口 帕斯卡命名法;以字母 I 开头 IPerson
枚举 帕斯卡命名法 enum Status {InProgress, New, Completed}

代码注释

任何包含适当注释的代码都可以在许多方面帮助开发人员。它不仅减少了理解代码的时间,还可以利用诸如SandcastleDocFx之类的工具,在生成完整的代码文档时即时共享给团队中的其他开发人员。此外,在谈论 API 时,Swagger 在开发人员社区中被广泛使用和受欢迎。Swagger 通过提供有关 API 的完整信息,可用方法,每个方法所需的参数等,来赋予 API 使用者权力。Swagger 还读取这些注释,以提供完整的文档和接口,以测试任何 API。

每个文件一个类

与许多其他语言不同,在.NET 中,我们不受限于为每个类创建单独的文件。我们可以创建一个单独的.cs文件,并在其中创建多个类。相反,这是一种不好的做法,当处理大型应用程序时会很痛苦。

每个方法一个逻辑

始终编写一次只执行一件事的方法。假设我们有一个方法,它从数据库中读取用户 ID,然后调用 API 来检索用户上传的文档列表。在这种情况下,最好的方法是有两个单独的方法,GetUserIDGetUserDocuments,分别首先检索用户 ID,然后检索文档:

public int GetUserId(string userName) 
{ 
  //Get user ID from database by passing the username 
} 

public List<Document> GetUserDocuments(int userID) 
{ 
  //Get list of documents by calling some API 
} 

这种方法的好处在于减少了代码重复。将来,如果我们想要更改任一方法的逻辑,我们只需在一个地方进行更改,而不是在所有地方复制它并增加错误的机会。

设计原则

遵循最佳实践开发清晰的架构会带来多种好处,应用程序性能就是其中之一。我们经常看到,应用程序背后使用的技术是强大而有效的,但应用程序的性能仍然不尽人意或不佳,这通常是因为糟糕的架构设计和在应用程序设计上投入较少的时间。

在这一部分,我们将讨论一些在.NET Core 中设计和开发应用程序时应该解决的常见设计原则:

  • KISS(保持简单,愚蠢)

  • YAGNI(你不会需要它)

  • DRY(不要重复自己)

  • 关注点分离

  • SOLID 原则

  • 缓存

  • 数据结构

  • 通信

  • 资源管理

  • 并发

KISS(保持简单,愚蠢)

编写更清洁的代码并始终保持简单有助于开发人员在长期内理解和维护它。在代码中添加不必要的复杂性不仅使其难以理解,而且在需要时也难以维护和更改。这就是 KISS 所说的。在软件上下文中,KISS 可以在设计软件架构时考虑,使用面向对象原则OOP),设计数据库,用户界面,集成等。添加不必要的复杂性会使软件的设计复杂化,并可能影响应用程序的可维护性和性能。

YAGNI(你不会需要它)

YAGNI 是 XP(极限编程)的核心原则之一。XP 是一种软件方法,包含短期迭代,以满足客户需求,并在需要或由客户发起时欢迎变更。主要目标是满足客户的期望,并保持客户所需的质量和响应能力。它涉及成对编程和代码审查,以保持质量完整,并满足客户的期望。

YAGNI 最适合极限编程方法,该方法帮助开发人员专注于应用程序功能或客户需求的特性。做一些额外的事情,如果没有告知客户或不是迭代或需求的一部分,最终可能需要重新工作,并且会浪费时间。

DRY(不要重复自己)

DRY(不要重复自己)也是编写更清晰代码的核心原则之一。它解决了开发人员在大型应用程序中不断更改或扩展功能或基础逻辑时所面临的挑战。根据该原则,它规定“系统中的每个知识片段必须有一个可靠的表示”。

在编写应用程序时,我们可以使用抽象来避免代码的重复,以避免冗余。这有助于适应变化,并让开发人员专注于需要更改的一个领域。如果相同的代码在多个地方重复,那么在一个地方进行更改需要在其他地方进行更改,这会消除良好的架构实践,从而引发更高的错误风险,并使应用程序代码更加错误。

关注点分离(SoC)

开发清晰架构的核心原则之一是关注点分离SoC)。这种模式规定,每种不同类型的应用程序应该作为一个独立的组件单独构建,与其他组件几乎没有或没有紧密耦合。例如,如果一个程序将用户消息保存到数据库,然后一个服务随机选择消息并选择获胜者,你可以看到这是两个独立的操作,这就是所谓的关注点分离。通过关注点分离,代码被视为一个独立的组件,如果需要,任何定制都可以在一个地方完成。可重用性是另一个因素,它帮助开发人员在一个地方更改代码,以便在多个地方使用。然而,测试要容易得多,而且在出现问题的情况下,错误可以被隔离和延后修复。

SOLID 原则

SOLID 是 5 个原则的集合,列举如下。这些是在开发软件设计时经常使用的常见设计原则:

  • 单一责任原则SRP

  • 开闭原则OCP

  • 里氏替换原则LSP

  • 接口隔离原则ISP

  • 依赖倒置原则DIP

单一责任原则

单一责任原则规定类应该只有一个特定的目标,并且该责任应该完全封装在类中。如果有任何更改或需要适应新目标,应创建一个新的类或接口。

在软件设计中应用这一原则使我们的代码易于维护和理解。架构师通常在设计软件架构时遵循这一原则,但随着时间的推移,当许多开发人员在该代码/类中工作并进行更改时,它变得臃肿,并且违反了单一责任原则,最终使我们的代码难以维护。

这也涉及到内聚性和耦合的概念。内聚性指的是类中责任之间的关联程度,而耦合指的是每个类相互依赖的程度。我们应该始终专注于保持类之间的低耦合和类内的高内聚。

这是一个基本的PersonManager类,包含四个方法,即GetPersonSavePersonLogErrorLogInformation

所有这些方法都使用数据库持久性管理器来读取/写入数据库中的记录。正如你可能已经注意到的那样,LogErrorLogInformationPersonManager类的内聚性不高,并且与PersonManager类紧密耦合。如果我们想在其他类中重用这些方法,我们必须使用PersonManager类,并且更改内部日志记录的逻辑也需要更改PersonManager类。因此,PersonManager违反了单一责任原则。

为了修复这个设计,我们可以创建一个单独的LogManager类,可以被PersonManager使用来记录执行操作时的信息或错误。下面是更新后的类图,表示关联关系:

开闭原则

根据定义,开闭原则规定,类、方法、接口等软件实体应该对修改封闭,对扩展开放。这意味着我们不能修改现有代码,并通过添加额外的类、接口、方法等来扩展功能,以应对任何变化。

在任何应用程序中使用这个原则可以解决各种问题,列举如下:

  • 在不改变现有代码的情况下添加新功能会产生更少的错误,并且不需要彻底测试

  • 更少的涟漪效应通常在更改现有代码以添加或更新功能时经历

  • 扩展通常使用新接口或抽象类来实现,其中现有代码是不必要的,而且破坏现有功能的可能性较小

为了实现开闭原则,我们应该使用抽象化,这是通过参数、继承和组合方法实现的。

参数

方法中可以设置特殊参数,用于控制该方法中编写的代码的行为。假设有一个LogException方法,它将异常保存到数据库,并发送电子邮件。现在,每当调用这个方法时,两个任务都会执行。没有办法从代码中停止发送电子邮件来处理特定的异常。然而,如果以某种方式表达,并使用一些参数来决定是否发送电子邮件,就可以控制。然而,如果现有代码不支持这个参数,那么就需要定制,但是在设计时,我们可以采用这种方法来暴露某些参数,以便处理方法的内部行为:

public void LogException(Exception ex) 
{ 
  SendEmail(ex); 
  LogToDatabase(ex); 
} 

推荐的实现如下:

public void LogException(Exception ex, bool sendEmail, bool logToDb) 
{ 
  if (sendEmail) 
  { 
    SendEmail(ex); 
  } 

  if (logToDb) 
  { 
    LogToDatabase(ex); 
  } 
}

继承

使用继承方法,我们可以使用模板方法模式。使用模板方法模式,我们可以在根类中创建默认行为,然后创建子类来覆盖默认行为并实现新功能。

例如,这里有一个Logger类,它将信息记录到文件系统中:

public class Logger 
{ 
  public virtual void LogMessage(string message) 
  { 
    //This method logs information into file system 
    LogToFileSystem(message); 
  } 

  private void LogtoFileSystem(string message) { 
    //Log to file system 
  } 
} 

我们有一个LogMessage方法,通过调用LogToFileSystem方法将消息记录到文件系统中。这个方法一直工作得很好,直到我们想要扩展功能。假设,以后我们提出了将这些信息也记录到数据库的要求。我们必须更改现有的LogMessage方法,并将代码编写到同一个类中。以后,如果出现其他要求,我们必须一遍又一遍地添加功能并修改这个类。根据开闭原则,这是一种违反。

使用模板方法模式,我们可以重新设计这段代码,遵循开闭原则,使其对扩展开放,对定制封闭。

遵循 OCP,这里是新设计,我们有一个包含LogMessage抽象方法的抽象类,以及两个具有自己实现的子类:

有了这个设计,我们可以在不改变现有Logger类的情况下添加第 n 个扩展:

public abstract class Logger 
{ 
  public abstract void LogMessage(string message); 

} 

public class FileLogger : Logger 
{ 
  public override void LogMessage(string message) 
  { 
    //Log to file system 
  } 
} 

public class DatabaseLogger : Logger 
{ 
  public override void LogMessage(string message) 
  { 
    //Log to database 
  } 
} 

组合

第三种方法是组合,这可以通过策略模式实现。通过这种方法,客户端代码依赖于抽象,实际实现封装在一个单独的类中,该类被注入到暴露给客户端的类中。

让我们看一个实现策略模式的例子。基本要求是发送可能是电子邮件或短信的消息,并且我们需要以一种方式构造它,以便将来可以添加新的消息类型而不对主类进行任何修改:

根据策略模式,我们有一个MessageStrategy抽象类,它公开一个抽象方法。每种工作类型都封装到继承MessageStrategy基本抽象类的单独类中。

这是MessageStrategy抽象类的代码:

public abstract class MessageStrategy 
{ 
  public abstract void SendMessage(Message message); 
}

我们有两个MessageStrategy的具体实现;一个用于发送电子邮件,另一个用于发送短信,如下所示:

public class EmailMessage : MessageStrategy 
{ 
  public override void SendMessage(Message message) 
  { 
    //Send Email 
  } 
} 

public class SMSMessage : MessageStrategy 
{ 
  public override void SendMessage(Message message) 
  { 
    //Send SMS  
  } 
} 

最后,我们有MessageSender类,客户端将使用它。在这个类中,客户端可以设置消息策略并调用SendMessage方法,该方法调用特定的具体实现类型来发送消息:

public class MessageSender 
{ 
  private MessageStrategy _messageStrategy; 
  public void SetMessageStrategy(MessageStrategy messageStrategy) 
  { 
    _messageStrategy = messageStrategy; 
  } 

  public void SendMessage(Message message) 
  { 
    _messageStrategy.SendMessage(message); 
  } 

} 

从主程序中,我们可以使用MessageSender,如下所示:

static void Main(string[] args) 
{ 
  MessageSender sender = new MessageSender(); 
  sender.SetMessageStrategy(new EmailMessage()); 
  sender.SendMessage(new Message { MessageID = 1, MessageTo = "jason@tfx.com", 
  MessageFrom = "donotreply@tfx.com", MessageBody = "Hello readers", 
  MessageSubject = "Chapter 5" }); 
}

Liskov 原则

根据 Liskov 原则,通过基类对象使用派生类引用的函数必须符合基类的行为。

这意味着子类不应该删除基类的行为,因为这违反了它的不变性。通常,调用代码应该完全依赖于基类中公开的方法,而不知道其派生实现。

让我们举一个例子,首先违反 Liskov 原则的定义,然后修复它以了解它特别设计用于什么:

IMultiFunctionPrinter接口公开了两种方法,如下所示:

public interface IMultiFunctionPrinter 
{ 
  void Print(); 
  void Scan(); 
}

这是一个可以由不同类型的打印机实现的接口。以下是实现IMultiFunctionPrinter接口的两种打印机,它们分别是:

public class OfficePrinter: IMultiFunctionPrinter 
{ 
  //Office printer can print the page 
  public void Print() { } 
  //Office printer can scan the page 
  public void Scan() { } 
} 

public class DeskjetPrinter : IMultiFunctionPrinter 
{ 
  //Deskjet printer print the page 
  public void Print() { } 
  //Deskjet printer does not contain this feature 
  public void Scan() => throw new NotImplementedException(); 
}

在前面的实现中,我们有一个提供打印和扫描功能的OfficePrinter,而另一个家用DeskjetPrinter只提供打印功能。当调用Scan方法时,DeskjetPrinter实际上违反了 Liskov 原则,因为它会抛出NotImplementedException

作为对前面问题的补救,我们可以将IMultiFunctionPrinter拆分为两个接口,即IPrinterIScanner,而IMultiFunctionPrinter也可以实现这两个接口以支持两种功能。DeskjetPrinter只实现了IPrinter接口,因为它不支持扫描:

这是三个接口IPrinterIScannerIMultiFunctionPrinter的代码:

public interface IPrinter 
{ 
  void Print(); 
} 

public interface IScanner 
{ 
  void Scanner(); 
} 

public interface MultiFunctionPrinter : IPrinter, IScanner 
{  

} 

最后,具体实现将如下所示:

public class DeskjetPrinter : IPrinter 
{ 
  //Deskjet printer print the page 
  public void Print() { } 
} 

public class OfficePrinter: IMultiFunctionPrinter 
{ 
  //Office printer can print the page 
  public void Print() { } 
  //Office printer can scan the page 
  public void Scan() { } 
}

接口隔离原则

接口隔离原则规定,客户端代码只应依赖于客户端使用的东西,不应依赖于他们不使用的任何东西。这意味着你不能强迫客户端代码依赖于不需要的某些方法。

让我们举一个首先违反接口隔离原则的例子:

在前面的图表中,我们有一个包含两种方法WriteLogGetLogs的 ILogger 接口。ConsoleLogger类将消息写入应用程序控制台窗口,而DatabaseLogger类将消息存储到数据库中。ConsoleLogger在控制台窗口上打印消息并不持久化它;对于GetLogs方法,它抛出NotImplementedException,因此违反了接口隔离原则。

这是前面问题的代码:

public interface ILogger 
{ 
  void WriteLog(string message); 
  List<string> GetLogs(); 
} 

/// <summary> 
/// Logger that prints the information on application console window 
/// </summary> 
public class ConsoleLogger : ILogger 
{ 
  public List<string> GetLogs() => throw new NotImplementedException(); 
  public void WriteLog(string message) 
  { 
    Console.WriteLine(message); 
  } 
} 

/// <summary> 
/// Logger that writes the log into database and persist them 
/// </summary> 
public class DatabaseLogger : ILogger 
{ 
  public List<string> GetLogs() 
  { 
    //do some work to get logs stored in database, as the actual code 
    //in not written so returning null 
    return null;  
  } 
  public void WriteLog(string message) 
  { 
    //do some work to write log into database 
  } 
}

为了遵守接口隔离原则ISP),我们分割了 ILogger 接口,并使其更精确和相关于其他实现者。ILogger 接口将仅包含WriteLog方法,并引入了一个新的IPersistenceLogger接口,它继承了 ILogger 接口并提供了GetLogs方法:

以下是修改后的示例,如下所示:

public interface ILogger 
{ 
  void WriteLog(string message); 

} 

public interface PersistenceLogger: ILogger 
{ 
  List<string> GetLogs(); 
} 

/// <summary> 
/// Logger that prints the information on application console window 
/// </summary> 
public class ConsoleLogger : ILogger 
{ 
  public void WriteLog(string message) 
  { 
    Console.WriteLine(message); 
  } 
} 

/// <summary> 
/// Logger that writes the log into database and persist them 
/// </summary> 
public class DatabaseLogger : PersistenceLogger 
{ 
  public List<string> GetLogs() 
  { 
    //do some work to get logs stored in database, as the actual code 
    //in not written so returning null 
    return null; 
  } 
  public void WriteLog(string message) 
  { 
    //do some work to write log into database 
  } 
}

依赖倒置原则

依赖倒置原则规定,高级模块不应依赖于低级模块,它们两者都应该依赖于抽象。

软件应用程序包含许多类型的依赖关系。依赖关系可以是框架依赖关系、第三方库依赖关系、Web 服务依赖关系、数据库依赖关系、类依赖关系等。根据依赖倒置原则,这些依赖关系不应该紧密耦合在一起。

例如,在分层架构方法中,我们有一个表示层,其中定义了所有视图;服务层公开了表示层使用的某些方法;业务层包含系统的核心业务逻辑;数据库层定义了后端数据库连接器和存储库类。将其视为 ASP.NET MVC 应用程序,其中控制器调用服务,服务引用业务层,业务层包含系统的核心业务逻辑,并使用数据库层对数据库执行 CRUD(创建、读取、更新和删除)操作。依赖树将如下所示:

根据依赖倒置原则,不建议直接从每个层实例化对象。这会在层之间创建紧密耦合。为了打破这种耦合,我们可以通过接口或抽象类实现抽象化。我们可以使用一些实例化模式,如工厂或依赖注入来实例化对象。此外,我们应该始终使用接口而不是类。假设在我们的服务层中,我们引用了我们的业务层,并且我们的服务契约正在使用EmployeeManager来执行一些 CRUD 操作。EmployeeManager包含以下方法:

public class EmployeeManager 
{ 

  public List<Employee> GetEmployees(int id) 
  { 
    //logic to Get employees 
    return null; 
  } 
  public void SaveEmployee(Employee emp) 
  { 
    //logic to Save employee 
  } 
  public void DeleteEmployee(int id) 
  { 
    //Logic to delete employee 
  } 

} 

在服务层中,我们可以使用 new 关键字实例化业务层EmployeeManager对象。在EmployeeManager类中添加更多方法将直接基于访问修饰符在服务层中使用。此外,对现有方法的任何更改都将破坏服务层代码。如果我们将接口暴露给服务层并使用一些工厂或依赖注入DI)模式,它将封装底层实现并仅暴露所需的方法。

以下代码显示了从EmployeeManager类中提取出IEmployeeManager接口:

public interface IEmployeeManager 
{ 
  void DeleteEmployee(int id); 
  System.Collections.Generic.List<Employee> GetEmployees(int id); 
  void SaveEmployee(Employee emp); 
}

考虑到上述示例,我们可以使用依赖注入来注入类型,因此每当服务管理器被调用时,业务管理器实例将被初始化。

缓存

缓存是可以用来提高应用程序性能的最佳实践之一。它通常与数据一起使用,其中更改不太频繁。有许多可用的缓存提供程序,我们可以考虑使用它们来保存数据并在需要时检索数据。它比数据库操作更快。在 ASP.NET Core 中,我们可以使用内存缓存,它将数据存储在服务器的内存中,但对于部署到多个地方的 Web 农场或负载平衡场景,建议使用分布式缓存。Microsoft Azure 还提供了 Redis 缓存,它是一个分布式缓存,提供了一个端点,可以用来在云上存储值,并在需要时检索。

要在 ASP.NET Core 项目中使用内存缓存,我们可以简单地在ConfigureServices方法中添加内存缓存,如下所示:

public void ConfigureServices(IServiceCollection services) 
{ 
  services.AddMvc(); 
  services.AddMemoryCache(); 
}

然后,我们可以通过依赖注入在我们的控制器或页面模型中注入IMemoryCache,并使用SetGet方法设置或获取值。

数据结构

选择正确的数据结构在应用程序性能中起着至关重要的作用。在选择任何数据结构之前,强烈建议考虑它是否是一种负担,或者它是否真正解决了特定的用例。在选择适当的数据结构时需要考虑的一些关键因素如下:

  • 了解您需要存储的数据类型

  • 了解数据增长的方式以及在增长时是否存在任何缺点

  • 了解是否需要通过索引或键/值对访问数据,并选择适当的数据结构

  • 了解是否需要同步访问,并选择线程安全的集合

选择正确的数据结构时还有许多其他因素,这些因素已经在第四章中涵盖,C#中的数据结构和编写优化代码。

通信

如今,通信已经成为任何应用程序中的重要缩影,主要因素是技术的快速发展。诸如基于 Web 的应用程序、移动应用程序、物联网应用程序和其他分布式应用程序在网络上执行不同类型的通信。我们可以以一个应用程序为例,该应用程序在某个云实例上部署了 Web 前端,调用了云中另一个实例上部署的某个服务,并对本地托管的数据库执行一些后端连接。此外,我们可以有一个物联网应用程序,通过互联网调用某个服务发送室温,等等。设计分布式应用程序时需要考虑的某些因素如下:

使用轻量级接口

避免多次往返服务器造成更多的网络延迟,降低应用程序性能。使用工作单元模式避免向服务器发送冗余操作,并执行一次单一操作以与后端服务通信。工作单元将所有消息分组为一个单元并将它们作为一个单元进行处理。

最小化消息大小

尽量减少与服务通信的数据量。例如,有一个 Person API 提供一些GETPOSTPUTDELETE方法来对后端数据库执行 CRUD 操作。要删除一个人的记录,我们可以只传递该人的ID(主键)作为参数传递给服务,而不是将整个对象作为参数传递。此外,使用少量属性或方法的对象,提供最小的工件集。最好的情况是使用POCOPlain Old CLR object)实体,它们对其他对象的依赖性很小,只包含必须发送到网络的属性。

排队通信

对于较大的对象或复杂操作,将单一的请求/响应通道与分布式消息通道解耦会提高应用程序的性能。对于大型、笨重的操作,我们可以将通信设计和分发到多个组件中。例如,有一个网站调用一个服务来上传图像,一旦上传完成,它会进行一些处理以提取缩略图并将其保存在数据库中。一种方法是在单个调用中同时进行上传和处理,但有时当用户上传较大的图像或图像处理需要更长时间时,用户可能会遇到请求超时异常,请求将终止。

通过排队架构,我们可以将这两个操作分开进行。用户上传图像,该图像将保存在文件系统中,并且图像路径将保存到存储中。后台运行的服务将获取该文件并异步进行处理。与此同时,当后端服务在处理时,控制权将返回给用户,用户可以看到一些正在进行的通知。最后,当缩略图生成时,用户将收到通知:

资源管理

每台服务器都有一组有限的资源。无论服务器规格多么好,如果应用程序没有设计成以高效的方式利用资源,就会导致性能问题。在设计.NET Core 应用程序时,有一些需要注意的最佳实践来最大程度地利用服务器资源。

避免线程的不当使用

为每个任务创建一个新线程,而不监视或中止线程的生命周期是一种不好的做法。线程适合执行多任务和利用服务器的多个资源并行运行。然而,如果设计是为每个请求创建线程,这会减慢应用程序的性能,因为 CPU 在线程之间切换的上下文中花费的时间比执行实际工作更多。

每当使用线程时,我们应该尽量保持一个共享的线程池,任何需要执行的新项目都会在队列中等待,如果线程忙碌,则在可用时获取。这样,线程管理就变得简单,服务器资源也会被有效利用。

及时释放对象

CLR公共语言运行时)提供自动内存管理,使用 new 关键字实例化的对象不需要显式进行垃圾回收;GC垃圾回收)会处理。然而,非托管资源不会被 GC 自动释放,应该通过实现IDisposable接口来显式进行回收。这些资源可能是数据库连接、文件处理程序、套接字等。要了解更多关于在.NET Core 中处理非托管资源的信息,请参考第六章,在.NET Core 中的内存管理技术

在需要时获取资源

只有在需要时才获取资源。提前实例化对象不是一个好的做法。这会占用不必要的内存并利用系统资源。此外,使用trycatchfinally来阻塞和释放finally块中的对象。这样,如果发生任何异常,方法内部实例化的对象将被释放。

并发

在并发编程中,许多对象可能同时访问同一资源,保持它们线程安全是主要目标。在.NET Core 中,我们可以使用锁来提供同步访问。然而,有些情况下,线程必须等待较长时间才能访问资源,这会导致应用程序无响应。

最佳实践是仅对那些需要线程安全的特定代码行应用同步访问,例如可以使用锁的地方,这些是数据库操作、文件处理、银行账户访问以及应用程序中许多其他关键部分。这些需要同步访问,以便一次处理一个线程。

总结

编写更清洁的代码,遵循架构和设计原则,并遵循最佳实践在应用程序性能中起着重要作用。如果代码臃肿和重复,会增加错误的机会,增加复杂性,并影响性能。

在本章中,我们学习了一些编码原则,使应用程序代码看起来更清晰,更容易理解。如果代码干净,它可以让其他开发人员完全理解,并在许多其他方面提供帮助。随后,我们学习了一些被认为是设计应用程序时的核心原则的基本设计原则。诸如 KISS、YAGNI、DRY、关注分离和 SOLID 等原则在软件设计中非常重要,缓存和选择正确的数据结构对性能有重大影响,如果使用得当可以提高性能。最后,我们学习了一些在处理通信、资源管理和并发时应考虑的最佳实践。

下一章是对内存管理的详细介绍,在这里我们将探讨.NET Core 中的一些内存管理技术。

第六章:.NET Core 中的内存管理技术

内存管理显著影响任何应用程序的性能。当应用程序运行时,.NET CLR(公共语言运行时)在内存中分配许多对象,并且它们会一直保留在那里,直到它们不再需要,直到创建新对象并分配空间,或者直到 GC 运行(偶尔会运行)以释放未使用的对象,并为其他对象提供更多空间。大部分工作由 GC 自己完成,它会智能地运行并通过删除不需要的对象来释放空间。然而,有一些实践可以帮助任何应用程序避免性能问题并平稳运行。

在第二章,了解.NET Core 内部和性能测量中,我们已经了解了垃圾回收的工作原理以及在.NET 中如何维护代。在本章中,我们将专注于一些推荐的最佳实践和模式,以避免内存泄漏并使应用程序性能良好。

我们将学习以下主题:

  • 内存分配过程概述

  • 通过 SOS 调试分析内存

  • 内存碎片化

  • 避免终结器

  • 在.NET Core 中最佳的对象处理实践

内存分配过程概述

内存分配是应用程序运行时在内存中分配对象的过程。这是由公共语言运行时CLR)完成的。当对象被初始化(使用new关键字)时,GC 会检查代是否达到阈值并执行垃圾回收。这意味着当系统内存达到其限制时,将调用 GC。当应用程序运行时,GC 寄存器本身会接收有关系统内存的事件通知,当系统达到特定限制时,它会调用垃圾回收。

另一方面,我们也可以使用GC.Collect方法以编程方式调用 GC。然而,由于 GC 是一个高度调优的算法,并且根据内存分配模式自动行为,显式调用可能会影响性能,因此强烈建议在生产中不要使用它。

通过.NET Core 中的 SOS 调试器分析 CLR 内部

SOS 是一个随 Windows 一起提供并且也适用于 Linux 的调试扩展。它通过提供有关 CLR 内部的信息,特别是内存分配、创建的对象数量以及有关 CLR 的其他详细信息,来帮助调试.NET Core 应用程序。我们可以在.NET Core 中使用 SOS 扩展来调试特定于每个平台的本机机器代码。

要在 Windows 上安装 SOS 扩展,需要从developer.microsoft.com/en-us/windows/hardware/download-kits-windows-hardware-development安装Windows Driver KitWDK)。

安装了 Windows Driver Kit 后,我们可以使用各种命令来分析应用程序的 CLR 内部,并确定在堆中占用最多内存的对象,并相应地对其进行优化。

我们知道,在.NET Core 中,不会生成可执行文件,我们可以使用dotnet cli命令来执行.NET Core 应用程序。运行.NET Core 应用程序的命令如下:

  • dotnet run

  • dotnet applicationpath/applicationname.dll

我们可以运行上述任一命令来运行.NET Core 应用程序。对于 ASP.NET Core 应用程序,我们可以转到应用程序文件夹的根目录,其中包括ViewswwwrootModelsControllers和其他文件,并运行以下命令:

另一方面,调试工具通常需要.exe文件或进程 ID 来转储与 CLR 内部相关的信息。要运行 SOS 调试器,我们可以转到 Windows Driver Kit 安装的路径(目录路径将是{driveletter}:Program Files (x86)Windows Kits10Debuggersx64),并运行以下命令:

windbg dotnet {application path}

以下是一个截图,显示了如何使用windbg命令运行 ASP.NET Core 应用程序:

一旦你运行了上述命令,它会打开 Windbg 窗口和调试器,如下所示:

你可以通过点击 Debug | Break 来停止调试器,并运行SOS命令来加载.NET Core CLR 的信息。

从 Windbg 窗口执行以下命令,然后按Enter

.loadby sos coreclr

以下截图是一个界面,你可以在其中输入并运行上述命令:

最后,我们可以运行!DumpHeap命令来查看对象堆的完整统计细节:

在上述截图中,如下截图所示的前三列,代表每个方法的地址方法表和大小

利用上述信息,它提供了按类型对堆上存储的对象进行分类的统计信息。MT是该类型的方法表,Count是该类型实例的总数,TotalSize是所有该类型实例占用的总内存大小,Classname代表在堆上占用该空间的实际类型。

还有一些其他命令,我们可以使用来获取特定的细节,列举如下:

开关 命令 描述
统计信息 !DumpHeap -stat 仅显示统计细节
类型 !DumpHeap -type TypeName 显示堆上存储的特定类型的统计信息
Finalization queue !FinalizationQueue 显示有关终结器的详细信息

这个工具帮助开发人员调查对象在堆上的分配情况。在实际场景中,我们可以在后台运行这个工具,运行我们的应用程序在测试或暂存服务器上,并检查关于堆上存储的对象的详细统计信息。

内存碎片化

内存碎片化是.NET 应用程序性能问题的主要原因之一。当对象被实例化时,它占用内存空间,当它不再需要时,它被垃圾回收,分配的内存块变得可用。当对象被分配了一个相对于该内存段/块中可用大小更大的空间,并等待空间变得可用时,就会发生这种情况。内存碎片化是一个问题,当大部分内存分配在较多的非连续块中时发生。当较大大小的对象存储或占用较大的内存块,而内存只包含较小的可用空闲块时,这会导致碎片化,系统无法在内存中分配该对象。

.NET 维护两种堆,即小对象堆(SOH)和大对象堆(LOH)。大于 85,000 字节的对象存储在 LOH 中。SOH 和 LOH 之间的关键区别在于 LOH 中没有 GC 进行的压缩。压缩是在垃圾回收时进行的过程,其中存储在 SOH 中的对象被移动以消除可用的较小空间块,并增加总可用空间,作为其他对象可以使用的一种大内存块的形式,从而减少碎片化。然而,在 LOH 中,GC 没有隐式地进行压缩。大小较大的对象存储在 LOH 中并创建碎片化问题。此外,如果我们将 LOH 与 SOH 进行比较,LOH 的压缩成本适度高,并涉及显着的开销,GC 需要两倍的内存空间来移动对象进行碎片整理。这也是为什么 LOH 不会被 GC 隐式地进行碎片整理的另一个原因。

以下是内存碎片的表示,其中白色块代表未分配的内存空间,后面跟着一个已分配的块:

假设一个大小为 1.5 MB 的对象想要分配一些内存。即使总可用内存量为 1.8 MB,它也找不到任何可用的空间。这是由于内存碎片:

另一方面,如果内存被碎片化,对象可以轻松使用可用的空间并被分配:

在.NET Core 中,我们可以使用GCSettings显式地在 LOH 中执行压缩,如下所示:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; 
GC.Collect(); 

避免使用终结器

在.NET Core 应用程序中使用终结器不是一个好的实践。使用终结器的对象会在内存中停留更长时间,最终影响应用程序的性能。

在特定时间点,应用程序不需要的对象会留在内存中,以便调用它们的Finalizer方法。例如,如果 GC 认为对象在第 0 代中已经死亡,它将始终存活在第 1 代中。

在.NET Core 中,CLR 维护一个单独的线程来运行Finalizer方法。包含Finalizer方法的所有对象都被放置到终结队列中。应用程序不再需要的任何对象都被放置在 F-Reachable 队列中,然后由专用的终结线程执行。

以下图表显示了一个包含Finalizer方法的object1对象。Finalizer方法被放置在终结队列中,对象占据了 Gen0(第 0 代)堆中的内存空间:

当对象不再需要时,它将从 Gen0(第 0 代)移动到 Gen1(第 1 代),并从终结队列移动到 F-Reachable 队列

一旦终结线程在 F-Reachable 队列中运行方法,它将被 GC 从内存中移除。

在.NET Core 中,终结器可以定义如下:

public class FileLogger 
{ 
  //Finalizer implementation 
   ~FileLogger() 
  { 
    //dispose objects 
  } 
} 

通常,此方法用于处理非托管对象并包含一些代码。然而,代码可能包含影响性能的错误。例如,我们有三个对象排队在终结队列中,然后等待第一个对象被终结线程释放,以便它们可以被处理。现在,假设第一个Finalizer方法中存在问题并延迟了终结线程的返回和处理其余的方法。过一段时间,更多的对象将进入终结队列并等待终结线程处理,影响应用程序的性能。

处理对象的最佳实践是使用IDisposable接口而不是实现Finalizer方法。如果出于某种原因使用Finalizer方法,最好也实现IDisposable接口,并通过调用GC.SuppressFinalize方法来抑制终结。

.NET Core 中释放对象的最佳实践

我们已经在前一节中学习了在.NET Core 中对象的处理是由 GC 自动完成的。然而,在您的代码中处理对象始终是一个良好的实践,并且在处理非托管对象时强烈推荐。在本节中,我们将探讨一些在.NET Core 中编写代码时可以用来释放对象的最佳实践。

IDisposable 接口简介

IDisposable是一个简单的接口,包含一个Dispose方法,不带参数,并返回void

public interface IDisposable 
{ 
  void Dispose(); 
} 

它用于释放非托管资源。因此,如果任何类实现了IDisposable接口,这意味着该类包含非托管资源,这些资源必须通过调用类的Dispose方法来释放。

什么是非托管资源?

任何超出应用程序边界的资源都被视为非托管资源。它可能是数据库、文件系统、Web 服务或类似的资源。为了访问数据库,我们使用托管的.NET API 来打开或关闭连接并执行各种命令。但是,实际的数据库连接是不受管理的。文件系统和 Web 服务也是如此,我们使用托管的.NET API 与它们交互,但它们在后台使用非托管资源。IDisposable接口是所有这些情况的最佳选择。

使用 IDisposable

这里有一个简单的DataManager类,它使用System.Data.SQL API 在 SQL 服务器数据库上执行数据库操作:

public class DataManager : IDisposable 
{ 
  private SqlConnection _connection; 

  //Returns the list of users from database 
  public DataTable GetUsers() 
  { 
    //Invoke OpenConnection to instantiate the _connection object 

    OpenConnection(); 

    //Executing command in a using block to dispose command object 
    using(var command =new SqlCommand()) 
    { 
      command.Connection = _connection; 
      command.CommandText = "Select * from Users"; 

      //Executing reader in a using block to dispose reader object 
      using (var reader = command.ExecuteReader()) 
      { 
        var dt = new DataTable(); 
        dt.Load(reader); 
        return dt; 
      } 

    } 
  } 
  private void OpenConnection() 
  { 
    if (_connection == null) 
    { 
      _connection = new SqlConnection(@"Integrated Security=SSPI;
      Persist Security Info=False;Initial Catalog=SampleDB;
      Data Source=.sqlexpress"); 
      _connection.Open(); 
    } 
  } 

  //Disposing _connection object 
  public void Dispose() { 
    Console.WriteLine("Disposing object"); 
    _connection.Close(); 
    _connection.Dispose(); 
  } 
} 

在前面的代码中,我们已经实现了IDisposable接口,该接口又实现了Dispose方法来清理 SQL 连接对象。我们还调用了连接的Dispose方法,这将在管道中链接该过程并关闭底层对象。

从调用程序中,我们可以使用using块来实例化DatabaseManager对象,该对象在调用GetUsers方法后调用Dispose方法:

static void Main(string[] args) 
{ 
  using(DataManager manager=new DataManager()) 
  { 
    manager.GetUsers(); 
  } 
} 

using块是 C#的一个构造,由编译器渲染为try finally块,并在finally块中调用Dispose方法。这意味着当您使用using块时,我们不必显式调用Dispose方法。另外,前面的代码也可以以以下方式编写,这种特定的代码格式由using块在内部管理:

static void Main(string[] args) 
{ 
  DataManager _manager; 
  try 
  { 
    _manager = new DataManager(); 
  } 
  finally 
  { 
    _manager.Dispose(); 
  } 
} 

何时实现 IDisposable 接口

我们已经知道,每当需要释放非托管资源时,应该使用IDisposable接口。但是,在处理对象的释放时,有一个标准规则应该被考虑。规则规定,如果类中的实例实现了IDisposable接口,我们也应该在使用该类时实现IDisposable。例如,前面的DatabaseManager类使用了SqlConnection,其中SqlConnection在内部实现了IDisposable接口。为了遵守这个规则,我们将实现IDisposable接口并调用实例的Dispose方法。

这里有一个更好的例子,它从DatabaseManager Dispose方法中调用protected Dispose方法,并传递一个表示对象正在被处理的Boolean值。最终,我们将调用GC.SuppressFinalize方法,告诉 GC 对象已经被清理,防止调用冗余的垃圾回收:

public void Dispose() { 
  Console.WriteLine("Disposing object"); 
  Dispose(true); 
  GC.SuppressFinalize(this); 
} 
protected virtual void Dispose(Boolean disposing) 
{ 
  if (disposing) 
  { 
    if (_connection != null) 
    { 
      _connection.Close(); 
      _connection.Dispose(); 
      //set _connection to null, so next time it won't hit this block 
      _connection = null; 
    } 
  } 
} 
}

我们将参数化的Dispose方法保持为protectedvirtual,这样,如果从DatabaseManager类派生的子类可以重写Dispose方法并清理自己的资源。这确保了对象树中的每个类都将清理其资源。子类处理其资源并调用基类上的Dispose,依此类推。

Finalizer 和 Dispose

Finalizer方法由 GC 调用,而Dispose方法必须由开发人员在程序中显式调用。GC 不知道类是否包含Dispose方法,并且需要在对象处置时调用以清理非托管资源。在这种情况下,我们需要严格清理资源而不是依赖调用者调用对象的Dispose方法时,应该实现Finalizer方法。

以下是实现Finalizer方法的DatabaseManager类的修改示例:

public class DataManager : IDisposable 
{ 
  private SqlConnection _connection; 
  //Returns the list of users from database 
  public DataTable GetUsers() 
  { 
    //Invoke OpenConnection to instantiate the _connection object 

    OpenConnection(); 

    //Executing command in a using block to dispose command object 
    using(var command =new SqlCommand()) 
    { 
      command.Connection = _connection; 
      command.CommandText = "Select * from Users"; 

      //Executing reader in a using block to dispose reader object 
      using (var reader = command.ExecuteReader()) 
      { 
        var dt = new DataTable(); 
        dt.Load(reader); 
        return dt; 
      } 
    } 
  } 
  private void OpenConnection() 
  { 
    if (_conn == null) 
    { 
      _connection = new SqlConnection(@"Integrated Security=SSPI;
      Persist Security Info=False;Initial Catalog=SampleDB;
      Data Source=.sqlexpress"); 
      _connection.Open(); 
    } 
  } 

  //Disposing _connection object 
  public void Dispose() { 
    Console.WriteLine("Disposing object"); 
    Dispose(true); 
    GC.SuppressFinalize(this); 
  } 

  private void Dispose(Boolean disposing) 
  { 
    if(disposing) { 
      //clean up any managed resources, if called from the 
      //finalizer, all the managed resources will already 
      //be collected by the GC 
    } 
    if (_connection != null) 
    { 
      _connection.Close(); 
      _connection.Dispose(); 
      //set _connection to null, so next time it won't hit this block 
      _connection = null; 
    } 

  } 

  //Implementing Finalizer 
  ~DataManager(){ 
    Dispose(false); 
  } 
}
Dispose method and added the finalizer using a destructor syntax, ~DataManager. When the GC runs, the finalizer is invoked and calls the Dispose method by passing a false flag as a Boolean parameter. In the Dispose method, we will clean up the connection object. During the finalization stage, the managed resources will already be cleaned up by the GC, so the Dispose method will now only clean up the unmanaged resources from the finalizer. However, a developer can explicitly dispose of objects by calling the Dispose method and passing a true flag as a Boolean parameter to clean up managed resources.

总结

本章重点是内存管理。我们学习了一些最佳实践,以及.NET 中内存管理的实际底层过程。我们探索了调试工具,开发人员可以使用它来调查堆上对象的内存分配。我们还学习了内存碎片化、终结器,以及如何通过实现IDisposable接口来实现清理资源的处理模式。

在下一章中,我们将创建一个遵循微服务架构的应用程序。微服务架构是一种高性能和可扩展的架构,可以帮助应用程序轻松扩展。接下来的章节将为您提供一个完整的理解,说明如何遵循最佳实践和原则开发应用程序。

第七章:在.NET Core 应用程序中保护和实施弹性

安全性和弹性是开发任何规模应用程序时应考虑的两个重要方面。安全性保护应用程序的机密信息,执行身份验证,并提供对安全内容的授权访问,而弹性在应用程序失败时保护应用程序,使其能够优雅地降级。弹性使应用程序高度可用,并允许应用程序在发生错误或处于故障状态时正常运行。它在微服务架构中被广泛使用,其中应用程序被分解为多个服务,并且每个服务与其他服务通信以执行操作。

在.NET Core 中有各种技术和库可用于实现安全性和弹性。在 ASP.NET Core 应用程序中,我们可以使用 Identity 来实现用户身份验证/授权,使用流行的 Polly 框架来实现诸如断路器、重试模式等模式。

在本章中,我们将讨论以下主题:

  • 弹性应用程序简介

  • 实施健康检查以监视应用程序性能

  • 在 ASP.NET Core 应用程序中实施重试模式以重试瞬时故障上的操作

  • 实施断路器模式以防止可能失败的调用

  • 保护 ASP.NET Core 应用程序并使用 Identity 框架启用身份验证和授权

  • 使用安全存储来存储应用程序机密

弹性应用程序简介

开发具有弹性作为重要因素的应用程序总是会让您的客户感到满意。今天,应用程序本质上是分布式的,并涉及大量的通信。当服务因网络故障而宕机或未能及时响应时,问题就会出现,这最终会导致客户操作终止之前的延迟。弹性的目的是使您的应用程序从故障中恢复,并使其再次响应。

当您调用一个服务,该服务调用另一个服务,依此类推时,复杂性会增加。在一长串操作中,考虑弹性是很重要的。这就是为什么它是微服务架构中最广泛采用的原则之一。

弹性政策

弹性政策分为两类:

  • 反应性政策

  • 积极的政策

在本章中,我们将使用 Polly 框架实施反应性和积极性政策,该框架可用于.NET Core 应用程序。

反应性政策

根据反应性政策,如果服务请求在第一次尝试时失败,我们应立即重试服务请求。要实施反应性政策,我们可以使用以下模式:

  • 重试:在请求失败时立即重试

  • 断路器:在故障状态下停止对服务的所有请求

  • 回退:如果服务处于故障状态,则返回默认响应

实施重试模式

重试模式用于重试故障服务多次以获得响应。它在涉及服务之间的相互通信的场景中被广泛使用,其中一个服务依赖于另一个服务执行特定操作。当服务分别托管并通过网络进行通信时,最有可能是通过 HTTP 协议时,会发生瞬时故障。

以下图表示两个服务:一个用户注册服务,用于在数据库中注册和保存用户记录,以及一个电子邮件服务,用于向用户发送确认电子邮件,以便他们激活他们的帐户。假设电子邮件服务没有响应。这将返回某种错误,如果实施了重试模式,它将重试请求已实施的次数,并在失败时调用电子邮件服务:

用户注册服务电子邮件服务是 ASP.NET Core Web API 项目,其中用户注册实现了重试模式。我们将通过将其添加为 NuGet 包在用户注册服务中使用 Polly 框架。要添加 Polly,我们可以在 Visual Studio 的 NuGet 包管理器控制台窗口中执行以下命令:

Install-Package Polly

Polly 框架基于策略。您可以定义包含与您正在实现的模式相关的特定配置的策略,然后通过调用其ExecuteAsync方法来调用该策略。

这是包含实现重试模式以调用电子邮件服务的 POST 方法的UserController

[Route("api/[controller]")] 
public class UserController : Controller 
{ 

  HttpClient _client; 
  public UserController(HttpClient client) 
  { 
    _client = client; 
  } 

  // POST api/values 
  [HttpPost] 
  public void Post([FromBody]User user) 
  { 

    //Email service URL 
    string emailService = "http://localhost:80/api/Email"; 

    //Serialize user object into JSON string 
    HttpContent content = new StringContent(JsonConvert.SerializeObject(user)); 

    //Setting Content-Type to application/json 
    _client.DefaultRequestHeaders 
    .Accept 
    .Add(new MediaTypeWithQualityHeaderValue("application/json")); 

    int maxRetries = 3; 

    //Define Retry policy and set max retries limit and duration between each retry to 3 seconds 
    var retryPolicy = Policy.Handle<HttpRequestException>().WaitAndRetryAsync(
    maxRetries, sleepDuration=> TimeSpan.FromSeconds(3)); 

    //Call service and wrap HttpClient PostAsync into retry policy 
    retryPolicy.ExecuteAsync(async () => { 
      var response =  _client.PostAsync(emailService, content).Result; 
      response.EnsureSuccessStatusCode(); 
    }); 

  }    
}

在前面的代码中,我们使用HttpClient类向电子邮件服务 API 发出 RESTful 请求。HTTP POST方法接收一个包含以下五个属性的用户对象:

public class User 
{ 
  public string FirstName { get; set; } 
  public string LastName { get; set; } 
  public string EmailAddress { get; set; }  
  public string UserName { get; set; } 
  public string Password { get; set; } 
}  

由于请求将以 JSON 格式发送,我们必须将Content-Type标头值设置为application/json。然后,我们必须定义重试策略以等待并重试每三秒一次的操作,最大重试次数为三次。最后,我们调用ExecuteAsync方法来调用client.PostAsync方法,以便调用电子邮件服务。

在运行上述示例后,如果电子邮件服务宕机或抛出异常,将重试三次以尝试获取所需的响应。

实施断路器

在调用通过网络通信的服务时,实现重试模式是一个很好的实践。然而,调用机制本身需要资源和带宽来执行操作并延迟响应。如果服务已经处于故障状态,不总是一个好的实践为每个请求重试多次。这就是断路器发挥作用的地方。

断路器有三种状态,如下图所示:

最初,断路器处于关闭状态,这意味着服务之间的通信正常工作,目标远程服务正在响应。如果目标远程服务失败,断路器将变为打开状态。当状态变为打开时,随后的所有请求都无法在特定的指定时间内调用目标远程服务,并直接将响应返回给调用者。一旦时间过去,断路器转为半开状态并尝试调用目标远程服务以获取响应。如果成功接收到响应,断路器将变回关闭状态,或者如果失败,状态将变回关闭并保持关闭,直到配置中指定的时间。

实现断路器模式,我们将使用相同的 Polly 框架,您可以从 NuGet 包中添加。我们可以按照以下方式添加断路器策略:

var circuitBreakerPolicy = Policy.HandleResult<HttpResponseMessage>(result => !result.IsSuccessStatusCode) 
  .CircuitBreakerAsync(3, TimeSpan.FromSeconds(10), OnBreak, OnReset, OnHalfOpen); 

Startup类的ConfigureServices方法中添加上述断路器策略。将其定义在Startup类中的原因是通过依赖注入DI)将断路器对象注入为单例对象。因此,所有请求将共享相同的实例,并且状态将得到适当维护。

在定义断路器策略时,我们将允许断开电路之前的事件数设置为三次,这将检查请求失败的次数,并在达到三次的阈值时断开电路。它将保持断路器在打开状态下 10 秒钟,然后在时间过去后的第一个请求到来时将状态更改为半开

最后,如果远程服务仍然失败,断路器状态再次变为Open状态;否则,它将被设置为Close。我们还定义了OnBreakOnResetOnHalfOpen委托,当断路器状态改变时会被调用。如果需要,我们可以在数据库或文件系统中记录这些信息。在Startup类中添加这些委托方法:

private void OnBreak(DelegateResult<HttpResponseMessage> responseMessage, TimeSpan timeSpan) 
{ 
  //Log to file system 
} 
private void OnReset() 
{ 
  //log to file system 
} 
private void OnHalfOpen() 
{ 
  // log to file system 
}

现在,我们将在Startup类的ConfigureServices方法中使用 DI 添加circuitBreakerPolicyHttpClient对象:

services.AddSingleton<HttpClient>(); 
  services.AddSingleton<CircuitBreakerPolicy<HttpResponseMessage>>(circuitBreakerPolgicy);

这是我们的UserController,它在参数化构造函数中接受HttpClientCircuitBreakerPolicy对象:

public class UserController : Controller 
{ 
  HttpClient _client; 
  CircuitBreakerPolicy<HttpResponseMessage> _circuitBreakerPolicy; 
  public UserController(HttpClient client, 
  CircuitBreakerPolicy<HttpResponseMessage> circuitBreakerPolicy) 
  { 
    _client = client; 
    _circuitBreakerPolicy = circuitBreakerPolicy; 
  } 
} 

这是使用断路器策略并调用电子邮件服务的HTTP POST方法:

// POST api/values 
[HttpPost] 
public async Task<IActionResult> Post([FromBody]User user) 
{ 

  //Email service URL 
  string emailService = "http://localhost:80/api/Email"; 

  //Serialize user object into JSON string 
  HttpContent content = new StringContent(JsonConvert.SerializeObject(user)); 

  //Setting Content-Type to application/json 
  _client.DefaultRequestHeaders 
  .Accept 
  .Add(new MediaTypeWithQualityHeaderValue("application/json")); 

  //Execute operation using circuit breaker 
  HttpResponseMessage response = await _circuitBreakerPolicy.ExecuteAsync(() => 
  _client.PostAsync(emailService, content)); 

  //Check if response status code is success 
  if (response.IsSuccessStatusCode) 
  { 
    var result = response.Content.ReadAsStringAsync(); 
    return Ok(result); 
  } 

  //If the response status is not success, it returns the actual state 
  //followed with the response content 
  return StatusCode((int)response.StatusCode, response.Content.ReadAsStringAsync()); 
} 

这是经典的断路器示例。Polly 还提供了高级断路器,它在特定时间内基于失败请求的百分比来断开电路,这在需要在一定时间内处理大量事务的大型应用程序或涉及大量事务的应用程序中更有用。在一分钟内,有 2%到 5%的事务由于其他非瞬态故障问题而失败的可能性,因此我们不希望断路器中断。在这种情况下,我们可以实现高级断路器模式,并在我们的ConfigureServices方法中定义策略,如下所示:

public void ConfigureServices(IServiceCollection services) 
{ 

  var circuitBreakerPolicy = Policy.HandleResult<HttpResponseMessage>(
  result => !result.IsSuccessStatusCode) 
  .AdvancedCircuitBreaker(0.1, TimeSpan.FromSeconds(60),5, TimeSpan.FromSeconds(10), 
  OnBreak, OnReset, OnHalfOpen); 
  services.AddSingleton<HttpClient>(); 
  services.AddSingleton<CircuitBreakerPolicy<HttpResponseMessage>>(circuitBreakerPolicy); 
}

AdvancedCircuitBreakerAsync方法中的第一个参数包含了 0.1 的值,这是在指定的时间段内(60 秒)失败的请求的百分比,如第二个参数所指定的。第三个参数定义了值为 5,是在特定时间内(第二个参数为 60 秒)正在服务的请求的最小吞吐量。最后一个参数定义了如果任何请求失败并尝试再次服务请求的时间量,断路器保持打开状态的时间。其他参数只是在每个状态改变时调用的委托方法,与之前的经典断路器示例中的情况相同。

将断路器与重试包装起来

到目前为止,我们已经学习了如何使用 Polly 框架来使用和实现断路器和重试模式。重试模式用于在指定的时间内重试请求,如果请求失败,而断路器保持电路的状态,并根据失败请求的阈值打开电路,并停止调用远程服务一段时间,如配置中所指定的,以节省网络带宽。

使用 Polly 框架,我们可以将重试和断路器模式结合起来,并将断路器与重试模式包装在一起,以便在重试模式达到失败请求阈值限制的计数时打开断路器。

在本节中,我们将开发一个自定义的HttpClient类,该类提供GETPOSTPUTDELETE等方法,并使用重试和断路器策略使其具有弹性。

创建一个新的IResilientHttpClient接口,并添加四个 HTTP GETPOSTPUTDELETE方法:

public interface IResilientHttpClient 
{ 
  HttpResponseMessage Get(string uri); 

  HttpResponseMessage Post<T>(string uri, T item); 

  HttpResponseMessage Delete(string uri); 

  HttpResponseMessage Put<T>(string uri, T item); 
} 

现在,创建一个名为ResilientHttpClient的新类,该类实现了IResilientHttpClient接口。我们将添加一个参数化构造函数,以注入断路器策略和HttpClient对象,该对象将用于进行 HTTP GETPOSTPUTDELETE请求。以下是ResilientHttpClient类的构造函数实现:

public class ResilientHttpClient : IResilientHttpClient 
{ 

  static CircuitBreakerPolicy<HttpResponseMessage> _circuitBreakerPolicy; 
  static Policy<HttpResponseMessage> _retryPolicy; 
  HttpClient _client; 

  public ResilientHttpClient(HttpClient client, 
  CircuitBreakerPolicy<HttpResponseMessage> circuitBreakerPolicy) 
  { 
    _client = client; 
    _client.DefaultRequestHeaders.Accept.Clear(); 
    _client.DefaultRequestHeaders.Accept.Add(
    new MediaTypeWithQualityHeaderValue("application/json")); 

    //circuit breaker policy injected as defined in the Startup class 
    _circuitBreakerPolicy = circuitBreakerPolicy; 

    //Defining retry policy 
    _retryPolicy = Policy.HandleResult<HttpResponseMessage>(x => 
    { 
      var result = !x.IsSuccessStatusCode; 
      return result; 
    })
    //Retry 3 times and for each retry wait for 3 seconds 
    .WaitAndRetry(3, sleepDuration => TimeSpan.FromSeconds(3)); 

  } 
} 

在前面的代码中,我们已经定义了CircuitBreakerPolicy<HttpResponseMessage>HttpClient对象,它们是通过 DI 注入的。我们定义了重试策略,并将重试阈值设置为三次,每次重试都会在调用服务之前等待三秒钟。

ExecuteWithRetryandCircuitBreaker method:
//Wrap function body in Retry and Circuit breaker policies 
public HttpResponseMessage ExecuteWithRetryandCircuitBreaker(string uri, Func<HttpResponseMessage> func) 
{ 

  var res = _retryPolicy.Wrap(_circuitBreakerPolicy).Execute(() => func()); 
  return res; 
} 

我们将从我们的 GET、POST、PUT 和 DELETE 实现中调用此方法,并定义将在重试和断路器策略中执行的代码。

以下分别是 GET、POST、PUT 和 DELETE 方法的实现:

public HttpResponseMessage Get(string uri) 
{ 
  //Invoke ExecuteWithRetryandCircuitBreaker method that wraps the code 
  //with retry and circuit breaker policies 
  return ExecuteWithRetryandCircuitBreaker(uri, () => 
  { 
    try 
    { 
      var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri); 
      var response = _client.SendAsync(requestMessage).Result; 
      return response; 
    }
    catch(Exception ex) 
    { 
      //Handle exception and return InternalServerError as response code 
      HttpResponseMessage res = new HttpResponseMessage(); 
      res.StatusCode = HttpStatusCode.InternalServerError;   
      return res; 
    } 
  }); 
} 

//To do HTTP POST request 
public HttpResponseMessage Post<T>(string uri, T item) 
{ 
  //Invoke ExecuteWithRetryandCircuitBreaker method that wraps the code 
  //with retry and circuit breaker policies 
  return ExecuteWithRetryandCircuitBreaker(uri, () => 
  { 
    try 
    { 
      var requestMessage = new HttpRequestMessage(HttpMethod.Post, uri); 

      requestMessage.Content = new StringContent(JsonConvert.SerializeObject(item), 
      System.Text.Encoding.UTF8, "application/json"); 

      var response = _client.SendAsync(requestMessage).Result; 

      return response; 

    }catch (Exception ex) 
    { 
      //Handle exception and return InternalServerError as response code 
      HttpResponseMessage res = new HttpResponseMessage(); 
      res.StatusCode = HttpStatusCode.InternalServerError; 
      return res; 
    } 
  }); 
} 

//To do HTTP PUT request 
public HttpResponseMessage Put<T>(string uri, T item) 
{ 
  //Invoke ExecuteWithRetryandCircuitBreaker method that wraps 
  //the code with retry and circuit breaker policies 
  return ExecuteWithRetryandCircuitBreaker(uri, () => 
  { 
    try 
    { 
      var requestMessage = new HttpRequestMessage(HttpMethod.Put, uri); 

      requestMessage.Content = new StringContent(JsonConvert.SerializeObject(item), 
      System.Text.Encoding.UTF8, "application/json"); 

      var response = _client.SendAsync(requestMessage).Result; 

      return response; 
    } 
    catch (Exception ex) 
    { 
    //Handle exception and return InternalServerError as response code 
    HttpResponseMessage res = new HttpResponseMessage(); 
    res.StatusCode = HttpStatusCode.InternalServerError; 
    return res; 
    } 

  }); 
} 

//To do HTTP DELETE request 
public HttpResponseMessage Delete(string uri) 
{ 
  //Invoke ExecuteWithRetryandCircuitBreaker method that wraps the code 
  //with retry and circuit breaker policies 
  return ExecuteWithRetryandCircuitBreaker(uri, () => 
  { 
    try 
    { 
      var requestMessage = new HttpRequestMessage(HttpMethod.Delete, uri); 

      var response = _client.SendAsync(requestMessage).Result; 

      return response; 

    } 
    catch (Exception ex) 
    { 
      //Handle exception and return InternalServerError as response code 
      HttpResponseMessage res = new HttpResponseMessage(); 
      res.StatusCode = HttpStatusCode.InternalServerError; 
      return res; 
    } 
  }); 

} 

最后,在我们的启动类中,我们将添加以下依赖项:

public void ConfigureServices(IServiceCollection services) 
{ 

  var circuitBreakerPolicy = Policy.HandleResult<HttpResponseMessage>(x=> { 
    var result = !x.IsSuccessStatusCode; 
    return result; 
  }) 
  .CircuitBreaker(3, TimeSpan.FromSeconds(60), OnBreak, OnReset, OnHalfOpen); 

   services.AddSingleton<HttpClient>(); 
   services.AddSingleton<CircuitBreakerPolicy<HttpResponseMessage>>(circuitBreakerPolicy); 

   services.AddSingleton<IResilientHttpClient, ResilientHttpClient>(); 
   services.AddMvc(); 
   services.AddSwaggerGen(c => 
   { 
     c.SwaggerDoc("v1", new Info { Title = "User Service", Version = "v1" }); 
   }); 
 } 

在我们的UserController类中,我们可以通过 DI 注入我们的自定义ResilientHttpClient对象,并修改 POST 方法,如下所示:

[Route("api/[controller]")] 
public class UserController : Controller 
{ 

  IResilientHttpClient _resilientClient; 

  HttpClient _client; 
  CircuitBreakerPolicy<HttpResponseMessage> _circuitBreakerPolicy; 
  public UserController(HttpClient client, IResilientHttpClient resilientClient) 
  { 
    _client = client; 
    _resilientClient = resilientClient; 

  } 

  // POST api/values 
  [HttpPost] 
  public async Task<IActionResult> Post([FromBody]User user) 
  { 

    //Email service URL 
    string emailService = "http://localhost:80/api/Email"; 

    var response = _resilientClient.Post(emailService, user); 
    if (response.IsSuccessStatusCode) 
    { 
      var result = response.Content.ReadAsStringAsync(); 
      return Ok(result); 
    } 

    return StatusCode((int)response.StatusCode, response.Content.ReadAsStringAsync()); 

  } 
} 

通过这种实现,当应用程序启动时,电路将最初关闭。当对EmailService进行请求时,如果服务没有响应,它将尝试三次调用服务,每个请求等待三秒。如果服务没有响应,电路将变为打开状态,并且对于所有后续请求,将停止调用电子邮件服务,并在 60 秒内将异常返回给用户,如断路器策略中指定的。60 秒后,下一个请求将发送到EmailService,并且断路器状态将变为半开放状态。如果它有响应,电路状态将再次变为关闭;否则,它将在接下来的 60 秒内保持打开状态。

带有断路器和重试的回退策略

Polly 还提供了一个回退策略,如果服务失败,它将返回一些默认响应。它可以与重试和断路器策略一起使用。回退的基本思想是向消费者发送默认响应,而不是在响应中返回实际错误。响应应该向用户提供一些与应用程序性质相关的有意义的信息。当您的服务被应用程序的外部消费者使用时,这是非常有益的。

我们可以修改上面的示例,并为重试和断路器异常添加回退策略。在ResilientHttpClient类中,我们将添加这两个变量:

static FallbackPolicy<HttpResponseMessage> _fallbackPolicy; 
static FallbackPolicy<HttpResponseMessage> _fallbackCircuitBreakerPolicy; 

接下来,我们将添加断路器策略来处理断路器异常,并返回带有我们自定义内容消息的HttpResponseMessage。在ResilientHttpClient类的参数化构造函数中添加以下代码:

_fallbackCircuitBreakerPolicy = Policy<HttpResponseMessage> 
.Handle<BrokenCircuitException>() 
.Fallback(new HttpResponseMessage(HttpStatusCode.OK) 
  { 
    Content = new StringContent("Please try again later[Circuit breaker is Open]") 
  } 
);

然后,我们将添加另一个回退策略,它将包装断路器以处理任何不是断路器异常的其他异常:

_fallbackPolicy = Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.InternalServerError) 
.Fallback(new HttpResponseMessage(HttpStatusCode.OK) { 
  Content = new StringContent("Some error occured") 
}); 

最后,我们将修改ExecuteWithRetryandCircuitBreaker方法,并将重试和断路器策略包装在回退策略中,该策略将以 200 状态代码向用户返回通用消息:

public HttpResponseMessage ExecuteWithRetryandCircuitBreaker(string uri, Func<HttpResponseMessage> func) 
{ 

  PolicyWrap<HttpResponseMessage> resiliencePolicyWrap = 
  Policy.Wrap(_retryPolicy, _circuitBreakerPolicy); 

  PolicyWrap<HttpResponseMessage> fallbackPolicyWrap = 
  _fallbackPolicy.Wrap(_fallbackCircuitBreakerPolicy.Wrap(resiliencePolicyWrap)); 

  var res = fallbackPolicyWrap.Execute(() => func()); 
  return res; 
}

通过这种实现,用户将不会收到任何响应中的错误。内容包含实际错误,如下面从 Fiddler 中获取的快照所示:

主动策略

根据主动策略,如果请求导致失败,我们应该主动响应。我们可以使用超时、缓存和健康检查等技术来主动监控应用程序的性能,并在发生故障时主动响应。

  • 超时:如果请求花费的时间超过通常时间,它会结束请求

  • 缓存:缓存先前的响应并在将来的请求中使用它们

  • 健康检查:监控应用程序的性能,并在发生故障时调用警报

实施超时

超时是一种主动策略,在目标服务需要很长时间来响应的情况下适用,而不是让客户端等待响应,我们返回一个通用消息或响应。我们可以使用相同的 Polly 框架来定义超时策略,并且它也可以与我们之前学习的重试和断路器模式结合使用:

在上图中,用户注册服务正在调用电子邮件服务发送电子邮件。现在,如果电子邮件服务在特定时间内没有响应,如超时策略中指定的,将引发超时异常。

要添加超时策略,请在ResilientHttpClient类中声明一个_timeoutPolicy变量:

static TimeoutPolicy<HttpResponseMessage> _timeoutPolicy; 

然后,添加以下代码来初始化超时策略:

_timeoutPolicy = Policy.Timeout<HttpResponseMessage>(1); 

最后,我们将包装超时策略并将其添加到resiliencyPolicyWrap中。以下是ExecuteWithRetryandCircuitBreaker方法的修改代码:

public HttpResponseMessage ExecuteWithRetryandCircuitBreaker(string uri, Func<HttpResponseMessage> func) 
{ 

  PolicyWrap<HttpResponseMessage> resiliencePolicyWrap = 
  Policy.Wrap(_timeoutPolicy, _retryPolicy, _circuitBreakerPolicy); 

  PolicyWrap<HttpResponseMessage> fallbackPolicyWrap = 
  _fallbackPolicy.Wrap(_fallbackCircuitBreakerPolicy.Wrap(resiliencePolicyWrap)); 

  var res = fallbackPolicyWrap.Execute(() => func()); 
  return res; 
} 

实施缓存

在进行网络请求或调用远程服务时,Polly 可用于缓存来自远程服务的响应,并提高应用程序响应时间的性能。Polly 缓存分为两种,即内存缓存和分布式缓存。我们将在本节中配置内存缓存。

首先,我们需要从 NuGet 添加另一个Polly.Caching.MemoryCache包。添加完成后,我们将修改我们的Startup类,并将IPolicyRegistry添加为成员变量:

private IPolicyRegistry<string> _registry; 

ConfigurationServices方法中,我们将初始化注册表并通过 DI 将其添加为单例对象:

_registry = new PolicyRegistry();
services.AddSingleton(_registry);

在配置方法中,我们将定义缓存策略,该策略需要缓存提供程序和缓存响应的时间。由于我们使用的是内存缓存,我们将初始化内存缓存提供程序,并在策略中指定如下:

Polly.Caching.MemoryCache.MemoryCacheProvider memoryCacheProvider = new MemoryCacheProvider(memoryCache); 

CachePolicy<HttpResponseMessage> cachePolicy = Policy.Cache<HttpResponseMessage>(memoryCacheProvider, TimeSpan.FromMinutes(10)); 

最后,我们将在ConfigurationServices方法中初始化cachepolicy并将其添加到我们的注册表中。我们将我们的注册表命名为cache

_registry.Add("cache", cachePolicy); 

修改我们的UserController类,并声明通用的CachePolicy如下:

CachePolicy<HttpResponseMessage> _cachePolicy;

现在,我们将修改我们的UserController构造函数,并添加通过 DI 注入的注册表。此注册表对象用于获取在Configure方法中定义的缓存。

以下是UserController类的修改后构造函数:

public UserController(HttpClient client, IResilientHttpClient resilientClient, IPolicyRegistry<string> registry) 
{ 
  _client = client; 
  // _circuitBreakerPolicy = circuitBreakerPolicy; 
  _resilientClient = resilientClient; 

  _cachePolicy = registry.Get<CachePolicy<HttpResponseMessage>>("cache"); 
} 

最后,我们将定义一个GET方法,调用另一个服务以获取用户列表并将其缓存在内存中。为了缓存响应,我们将使用缓存策略的Execute方法包装我们的自定义弹性客户端 GET 方法,如下所示:

[HttpGet] 
public async Task<IActionResult> Get() 
{ 
  //Specify the name of the Response. If the method is taking    
  //parameter, we can append the actual parameter to cache unique 
  //responses separately 
  Context policyExecutionContext = new Context($"GetUsers"); 

  var response = _cachePolicy.Execute(()=>   
  _resilientClient.Get("http://localhost:7637/api/users"), policyExecutionContext); 
  if (response.IsSuccessStatusCode) 
  { 
    var result = response.Content.ReadAsStringAsync(); 
    return Ok(result); 
  } 

  return StatusCode((int)response.StatusCode, response.Content.ReadAsStringAsync()); 
}

当请求返回时,它将检查缓存上下文是否为空或已过期,并且请求将被缓存 10 分钟。在此期间的所有后续请求将从内存缓存存储中读取响应。一旦缓存过期,根据设置的时间限制,它将再次调用远程服务并缓存响应。

实施健康检查

健康检查是积极策略的一部分,可以及时监控服务的健康状况。它们还允许您在任何服务未响应或处于故障状态时采取积极的行动。

在 ASP.NET Core 中,我们可以通过使用HealthChecks库轻松实现健康检查,该库可作为 NuGet 包使用。要使用HealthChecks,我们只需将以下 NuGet 包添加到我们的 ASP.NET Core MVC 或 Web API 项目中:

Microsoft.AspNetCore.HealthChecks

我们必须将此包添加到监视服务和需要监视健康状况的服务的应用程序中。

在用于检查服务健康状况的应用程序的Startup类的ConfigureServices方法中添加以下代码:

services.AddHealthChecks(checks => 
{ 
  checks.AddUrlCheck(Configuration["UserServiceURL"]); 
  checks.AddUrlCheck(Configuration["EmailServiceURL"]); 
}); 

在上述代码中,我们已添加了两个服务端点来检查健康状态。这些端点在appsettings.json文件中定义。

健康检查库通过AddUrlCheck方法检查指定服务的健康状况。但是,需要通过Startup类对需要由外部应用程序或服务监视健康状况的服务进行一些修改。我们必须将以下代码片段添加到所有服务中,以返回其健康状态:

services.AddHealthChecks(checks => 
{ 
  checks.AddValueTaskCheck("HTTP Endpoint", () => new 
  ValueTask<IHealthCheckResult>(HealthCheckResult.Healthy("Ok"))); 
});

如果它们的健康状况良好且服务正在响应,它将返回Ok

最后,我们可以在监视应用程序中添加 URI,这将触发健康检查中间件来检查服务的健康状况并显示健康状态。我们必须添加UseHealthChecks并指定用于触发服务健康状态的端点:

public static IWebHost BuildWebHost(string[] args) => 
WebHost.CreateDefaultBuilder(args) 
.UseHealthChecks("/hc") 
.UseStartup<Startup>() 
.Build(); 

当我们运行我们的监视应用程序并访问 URI 时,例如http://{base_address}/hc以获取健康状态,如果所有服务都正常工作,我们应该看到以下响应:

使用应用程序机密存储敏感信息

每个应用程序都有一些包含敏感信息的配置,例如数据库连接字符串、一些第三方提供商的密钥以及其他敏感信息,通常存储在配置文件或数据库中。将所有敏感信息进行安全保护,以保护这些资源免受入侵者的侵害,这总是一个更好的选择。Web 应用程序通常托管在服务器上,这些信息可以通过导航到服务器路径并访问文件来读取,尽管服务器始终具有受保护的访问权限,只有授权用户有资格访问数据。然而,将信息以明文形式存储并不是一个好的做法。

在.NET Core 中,我们可以使用 Secret Manager 工具来保护应用程序的敏感信息。Secret Manager 工具允许您将信息存储在secrets.json文件中,该文件不存储在应用程序文件夹本身中。相反,该文件保存在不同平台的以下路径:

Windows: %APPDATA%microsoftUserSecrets{userSecretsId}secrets.json
Linux: ~/.microsoft/usersecrets/{userSecretsId}/secrets.json
Mac: ~/.microsoft/usersecrets/{userSecretsId}/secrets.json

{userSecretId}是与您的应用程序关联的唯一 ID(GUID)。由于这保存在单独的路径中,每个开发人员都必须在自己的目录下的UserSecrets目录下定义或创建此文件。这限制了开发人员检入相同的文件到源代码控制中,并将信息保持分离到每个用户。有些情况下,开发人员使用自己的帐户凭据进行数据库认证,因此这有助于将某些信息与其他信息隔离开来。

从 Visual Studio 中,我们可以通过右键单击项目并选择管理用户机密选项来简单地添加secrets.json文件,如下所示:

当您选择管理用户机密时,Visual Studio 会创建一个secrets.json文件并在 Visual Studio 中打开它,以 JSON 格式添加配置设置。如果您打开项目文件,您会看到UserSecretsId存储在项目文件中的条目:

因此,如果您意外关闭了secrets.json文件,您可以从UserSecretsId是用户机密路径内的子文件夹中打开它,如上图所示。

以下是secrets.json文件的示例内容,其中包含日志信息、远程服务 URL 和连接字符串:

{ 
  "Logging": { 
    "IncludeScopes": false, 
    "Debug": { 
      "LogLevel": { 
        "Default": "Warning" 
      } 
    }, 
    "Console": { 
      "LogLevel": { 
        "Default": "Warning" 
      } 
    } 
  }, 
  "EmailServiceURL": "http://localhost:6670/api/values", 
  "UserServiceURL": "http://localhost:6546/api/user", 
  "ConnectionString": "Server=OVAISPC\sqlexpress;Database=FraymsVendorDB;
  User Id=sa;Password=P@ssw0rd;" 
} 

要在 ASP.NET Core 应用程序中访问此内容,我们可以在我们的Startup类中添加以下命名空间:

using Microsoft.Extensions.Configuration;

然后,注入IConfiguration对象并将其分配给Configuration属性:

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

最后,我们可以使用Configuration对象访问变量,如下所示:

var UserServicesURL = Configuration["UserServiceURL"] 
services.AddEntityFrameworkSqlServer() 
.AddDbContext<VendorDBContext>(options => 
{ 
  options.UseSqlServer(Configuration["ConnectionString"], 
  sqlServerOptionsAction: sqlOptions => 
  { 
    sqlOptions.MigrationsAssembly(typeof(Startup)
    .GetTypeInfo().Assembly.GetName().Name); 
    sqlOptions.EnableRetryOnFailure(maxRetryCount: 10, 
    maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null); 
  }); 
}, ServiceLifetime.Scoped 
); 
} 

保护 ASP.NET Core API

保护 Web 应用程序是任何企业级应用程序的重要里程碑,不仅可以保护数据,还可以保护免受恶意网站的不同攻击。

在任何 Web 应用程序中,安全性都是一个重要因素的各种场景:

  • 通过网络发送的信息包含敏感信息。

  • API 是公开暴露的,并且被用户用于执行批量操作。

  • API 托管在服务器上,用户可以使用一些工具进行数据包嗅探并读取敏感数据。

为了解决上述挑战并保护我们的应用程序,我们应该考虑以下选项:

SSL(安全套接字层)

在传输或网络层添加安全性,当数据从客户端发送到服务器时,应该加密。SSL(安全套接字层)是在网络上传输信息的推荐方式。在 Web 应用程序中使用 SSL 加密从客户端浏览器发送到服务器的所有数据,在服务器级别解密。显然,这似乎会增加性能开销,但由于我们在今天的世界中拥有的服务器资源的规格,这似乎是相当可忽略的。

在 ASP.NET Core 应用程序中启用 SSL

在我们的 ASP.NET Core 项目中启用 SSL,我们可以在Startup类的ConfigureServices方法中定义的AddMvc方法中添加过滤器。过滤器用于过滤 HTTP 调用并采取某些操作:

services.AddMvc(options => 
{ 
  options.Filters.Add(new RequireHttpsAttribute()) 
}); 
launchSettings.json file to use the HTTPS port and enable SSL for our project. One way to do this is to enable SSL from the Debug tab in the Visual Studio project properties window, which is shown as follows:

这还修改了launchSettings.json文件并添加了 SSL。另一种方法是直接从launchSetttings.json文件本身修改端口号。以下是使用端口44326进行 SSL 的launchsettings.json文件,已添加到iisSettings下:

{ 
  "iisSettings": { 
    "windowsAuthentication": false, 
    "anonymousAuthentication": true, 
    "iisExpress": { 
      "applicationUrl": "http://localhost:3743/", 
      "sslPort": 44326 
    } 
  }, 

在上述代码中显示的默认 HTTP 端口设置为*3743*。由于在AddMvc中间件中,我们已经指定了一个过滤器来对所有传入请求使用 SSL。它将自动重定向到 HTTPS 并使用端口44326

要在 IIS 上托管 ASP.NET Core,请参阅以下链接。网站运行后,可以通过 IIS 中的站点绑定选项添加 HTTPS 绑定:docs.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/index?tabs=aspnetcore2x

防止 CSRF(跨站点请求伪造)攻击

CSRF 是一种代表经过身份验证的用户执行未经请求的操作的攻击。由于攻击者无法伪造请求的响应,因此它主要涉及HTTP POSTPUTDELETE方法,这些方法用于修改服务器上的数据。

ASP.NET Core 提供了内置令牌以防止 CSRF 攻击,您可以在向Startup类的ConfigureServices方法中添加 MVC 时自行添加ValidateAntiForgeryTokenAttribute过滤器。以下是向 ASP.NET Core 应用程序全局添加防伪标记的代码:

public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options => { options.Filters.Add(new ValidateAntiForgeryTokenAttribute()); });
 }

或者,我们还可以在特定的控制器操作方法上添加ValidateAntyForgeryToken。在这种情况下,我们不必在Startup类的ConfigureServices方法中添加ValidateAntiForgeryTokenAttribute过滤器。以下是保护HTTP POST操作方法免受 CSRF 攻击的代码:

[HttpPost]

[ValidateAntiForgeryToken]
public async Task<IActionResult> Submit()
{
  return View();
}
CORS (Cross Origin Security)

第二个选项是为经过身份验证的来源、标头和方法启用CORS(跨源安全)。设置 CORS 允许您的 API 仅从配置的来源访问。在 ASP.NET Core 中,可以通过添加中间件并定义其策略来轻松设置 CORS。

ValidateAntiForgery属性告诉 ASP.NET Core 将令牌放在表单中,当提交时,它会验证并确保令牌是有效的。这通过验证每个HTTP POSTPUT和其他 HTTP 请求的令牌来防止您的应用程序受到 CSRF 攻击,并保护表单免受恶意发布。

加强安全标头

许多现代浏览器提供了额外的安全功能。如果响应包含这些标头,浏览器运行您的站点时将自动启用这些安全功能。在本节中,我们将讨论如何在我们的 ASP.NET Core 应用程序中添加这些标头,并在浏览器中启用额外的安全性。

要调查我们的应用程序中缺少哪些标头,我们可以使用www.SecurityHeaders.io网站。但是,要使用此功能,我们需要使我们的站点在互联网上公开访问。

或者,我们可以使用ngrok将 HTTP 隧道到我们的本地应用程序,从而使我们的站点可以从互联网访问。可以从以下链接下载ngrok工具:ngrok.com/download

您可以选择您拥有的操作系统版本并相应地下载特定的安装程序。

安装ngrok后,您可以打开它并运行以下命令。请注意,在执行以下命令之前,您的站点应在本地运行:

ngrok http -host-header localhost 7204

您可以将localhost替换为您的服务器 IP,将7204替换为应用程序侦听的端口。

运行上述命令将生成公共网址,如Forwarding属性中所指定的那样:

我们现在可以在www.securityheaders.io中使用这个公共网址,扫描我们的网站并得到结果。它对网站进行分类,并提供从 A 到 F 的字母表,其中 A 是一个优秀的分数,表示网站包含所有安全标头,而 F 表示网站不安全且不包含安全标头。从默认模板生成的默认 ASP.NET Core 网站扫描得到 F 的分数,如下所示。它还显示了缺失的标头,用红色框起来:

首先,我们应该在我们的网站上启用 HTTPS。要启用 HTTPS,请参阅与 SSL 相关的部分。接下来,我们将从 NuGet 添加NWebsec.AspNetCore.Middleware包,如下所示:

NWebsec 提供了各种中间件,可以从Startup类的Configure方法中添加到我们的应用程序中。

添加 HTTP 严格传输安全标头

严格传输安全标头是一个出色的功能,通过获取用户代理并强制其使用 HTTPS 来加强TLS(传输层安全)的实现。我们可以通过在Startup类的Configure方法中添加以下中间件来添加严格传输安全标头:

app.UseHsts(options => options.MaxAge(days:365).IncludeSubdomains());

此中间件强制执行您的网站,以便在一年内只能通过 HTTPS 访问。这也适用于子域。

添加 X-Content-Type-Options 标头

此标头阻止浏览器尝试MIME-sniff内容类型,并强制其遵循声明的内容类型。我们可以在Startup类的Configure方法中添加此中间件,如下所示:

app.UseXContentTypeOptions();

添加 X-Frame-Options 标头

此标头允许浏览器保护您的网站免受在框架内呈现的攻击。通过使用以下中间件,我们可以防止我们的网站被框架化,从而可以防御不同的攻击,其中最著名的是点击劫持:

app.UseXfo(options => options.SameOrigin());

添加 X-Xss-Protection 标头

此标头允许浏览器在检测到跨站脚本攻击时停止页面加载。我们可以在Startup类的Configure方法中添加此中间件,如下所示:

app.UseXXssProtection(options => options.EnabledWithBlockMode());

添加内容安全策略标头

内容安全策略标头通过列入批准内容的来源并阻止浏览器加载恶意资源来保护您的应用程序。这可以通过从 NuGet 添加NWebsec.Owin包并在Startup类的Configure方法中定义来实现,如下所示:

app.UseCsp(options => options
.DefaultSources(s => s.Self())
.ScriptSources(s => s.Self()));

在上述代码中,我们已经提到了DefaultSourcesScriptSources,以从同一来源加载所有资源。如果有任何需要从外部来源加载的脚本或图像,我们可以定义自定义来源,如下所示:

app.UseCsp(options => options
  .DefaultSources(s => s.Self()).ScriptSources(s => s.Self().CustomSources("https://ajax.googleapis.com")));

有关此主题的完整文档,请参阅以下网址:docs.nwebsec.com/en/4.1/nwebsec/Configuring-csp.html

添加引荐策略标头

当用户浏览网站并点击链接到其他网站时,目标网站通常会收到有关用户来源网站的信息。引荐标头让您控制标头中应该存在的信息,目标网站可以读取该信息。我们可以在Startup类的Configure方法中添加引荐策略中间件,如下所示:

app.UseReferrerPolicy(opts => opts.NoReferrer());

NoReferrer选项意味着不会向目标网站发送引荐信息。

在我们的 ASP.NET Core 应用程序中启用所有前面的中间件后,当我们通过securityheaders.io网站进行扫描时,我们将看到我们有一个安全报告摘要,得到 A+的分数,这意味着网站完全安全:

在 ASP.NET Core 应用程序中启用 CORS

CORS 代表跨域资源共享,它受到浏览器的限制,以防止跨域 API 请求。例如,我们在浏览器上运行一个 SPA(单页应用程序),使用类似 Angular 或 React 的客户端框架调用托管在另一个域上的 Web API,比如我的 SPA 站点具有一个域(mychapter8webapp.com)并访问另一个域(appservices.com)的 API,这是受限制的。浏览器限制了对托管在其他服务器和域上的服务的调用,用户将无法调用这些 API。在服务器端启用 CORS 可以解决这个问题。

要在 ASP.NET Core 项目中启用 CORS,我们可以在ConfigureServices方法中添加 CORS 支持:

services.AddCors(); 

Configure方法中,我们可以通过调用UseCors方法并定义策略来使用 CORS 以允许跨域请求。以下代码允许从任何标头、来源或方法发出请求,并且还允许我们在请求标头中传递凭据:

app.UseCors(config => { 
  config.AllowAnyHeader(); 
  config.AllowAnyMethod(); 
  config.AllowAnyOrigin(); 
  config.AllowCredentials(); 
});

上述代码将允许应用程序全局使用 CORS。或者,我们也可以根据不同的情况定义 CORS 策略,并在特定控制器上启用它们。

以下表格定义了定义 CORS 时使用的基本术语:

术语 描述 示例
标头 允许在请求中传递的请求标头 内容类型、接受等
方法 请求的 HTTP 动词 GET、POST、DELETE、PUT 等
来源 域或请求 URL techframeworx.com

要定义策略,我们可以在ConfigureServices方法中添加 CORS 支持时添加一个策略。以下代码显示了在添加 CORS 支持时定义的两个策略:

services.AddCors(config => 
{ 
  //Allow only HTTP GET Requests 
  config.AddPolicy("AllowOnlyGet", builder => 
  { 
    builder.AllowAnyHeader(); 
    builder.WithMethods("GET"); 
    builder.AllowAnyOrigin(); 
  }); 

  //Allow only those requests coming from techframeworx.com 
  config.AddPolicy("Techframeworx", builder => { 
    builder.AllowAnyHeader(); 
    builder.AllowAnyMethod(); 
    builder.WithOrigins("http://techframeworx.com"); 
  }); 
});

AllowOnlyGet策略将只允许进行GET请求的请求;Techframeworx策略将只允许来自techframeworx.com的请求。

我们可以通过使用EnableCors属性并指定属性的名称在控制器和操作上使用这些策略:

[EnableCors("AllowOnlyGet")] 
public class SampleController : Controller 
{ 

 } 

身份验证和授权

安全的 API 只允许经过身份验证的用户访问。在 ASP.NET Core 中,我们可以使用 ASP.NET Core Identity 框架对用户进行身份验证,并为受保护的资源提供授权访问。

使用 ASP.NET Core Identity 进行身份验证和授权

一般来说,安全性分为两种机制,如下:

  • 身份验证

  • 授权

身份验证

身份验证是通过获取用户的用户名、密码或身份验证令牌进行用户访问的认证过程,然后从后端数据库或服务进行验证。一旦用户通过了身份验证,将进行一些操作,其中包括在浏览器中设置一个 cookie 或向用户返回一个令牌,以便在请求消息中传递以访问受保护的资源。

授权

授权是用户认证后进行的过程。授权用于了解访问资源的用户的权限。即使用户已经通过了身份验证,也并不意味着所有受保护或安全的资源都是可访问的。这就是授权发挥作用的地方,它只允许用户访问他们被允许访问的资源。

使用 ASP.NET Core Identity 框架实现身份验证和授权

ASP.NET Core Identity 是由 Microsoft 开发的安全框架,现在由开源社区贡献。这允许开发人员在 ASP.NET Core 应用程序中启用用户身份验证和授权。它提供了在数据库中存储用户身份、角色和声明的完整系统。它包含用于用户身份、角色等的某些类,可以根据要求进一步扩展以支持更多属性。它使用 Entity Framework Core 代码为第一个模型创建后端数据库,并可以轻松集成到现有数据模型或应用程序的特定表中。

在本节中,我们将创建一个简单的应用程序,从头开始添加 ASP.NET Core Identity,并修改IdentityUser类以定义附加属性,并使用基于 cookie 的身份验证来验证请求并保护 ASP.NET MVC 控制器。

在创建 ASP.NET Core 项目时,我们可以将身份验证选项更改为个人用户帐户身份验证,该选项为您的应用程序生成所有与安全相关的类并配置安全性:

这将创建一个AccountControllerPageModels来注册、登录、忘记密码和其他与用户管理相关的页面。

Startup类还包含一些与安全相关的条目。这是ConfigureServices方法,其中添加了一些特定于安全性的代码。

public void ConfigureServices(IServiceCollection services) 
{ 
  services.AddDbContext<ApplicationDbContext>(options => 
  options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); 

  services.AddIdentity<ApplicationUser, IdentityRole>() 
  .AddEntityFrameworkStores<ApplicationDbContext>() 
  .AddDefaultTokenProviders(); 

  services.AddMvc() 
  .AddRazorPagesOptions(options => 
  { 
    options.Conventions.AuthorizeFolder("/Account/Manage"); 
    options.Conventions.AuthorizePage("/Account/Logout"); 
  }); 

  services.AddSingleton<IEmailSender, EmailSender>(); 
} 

AddDbContext使用 SQL 服务器在数据库中创建 Identity 表,如下所示:DefaultConnection键。

  • services.AddIdentity用于在我们的应用程序中启用 Identity。它接受ApplicationUserIdentityRole,并定义ApplicationDbContext用作 Entity Framework,用于存储创建的实体。

  • AddDefaultTokenProviders 被定义为生成重置密码、更改电子邮件、更改电话号码和双因素身份验证的令牌。

Configure方法中,它添加了UseAuthentication中间件,该中间件启用了身份验证并保护了已配置为授权请求的页面或控制器。这是在管道中启用身份验证的Configure方法。定义的中间件按顺序执行。因此,UseAuthentication中间件在UseMvc中间件之前定义,以便所有调用控制器的请求首先经过身份验证:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
  if (env.IsDevelopment()) 
  { 
    app.UseBrowserLink(); 
    app.UseDeveloperExceptionPage(); 
    app.UseDatabaseErrorPage(); 
  } 
  else 
  { 
    app.UseExceptionHandler("/Error"); 
  } 

  app.UseStaticFiles(); 

  app.UseAuthentication(); 

  app.UseMvc(); 
} 

在用户表中添加更多属性

IdentityUser是基类,包含与用户相关的属性,如电子邮件、密码和电话号码。当我们创建 ASP.NET Core 应用程序时,它会创建一个空的ApplicationUser类,该类继承自IdentityUser类。在ApplicationUser类中,我们可以添加更多属性,这些属性将在运行实体框架迁移时创建。我们将在我们的ApplicationUser类中添加FirstNameLastNameMobileNumber属性,这些属性在创建表时将被考虑:

public class ApplicationUser : IdentityUser 
{ 
  public string FirstName { get; set; } 
  public string LastName { get; set; } 
  public string MobileNumber { get; set; } 
} 

在运行迁移之前,请确保Startup类的ConfigureServices方法中指定的DefaultConnection字符串是有效的。

我们可以从 Visual Studio 的包管理器控制台或通过dotnet CLI工具集运行迁移。从 Visual Studio 中,选择特定项目并运行Add-Migration命令,指定迁移名称,在我们的情况下是 Initial:

上述命令创建了{timestamp}_Initial类文件,其中包含UpDown方法。Up方法用于发布后端数据库中的更改,而Down方法用于撤消数据库中的更改。要将更改应用于后端数据库,我们将运行Update-Database命令,该命令将创建一个包含AspNet相关表的数据库,这些表是身份框架的一部分。如果您以设计模式打开AspNetUsers表,您将看到自定义列FirstNameLastNameMobileNumber

我们可以运行应用程序并使用注册选项创建用户。为了保护我们的 API,我们必须在ControllerAction级别添加Authorize属性。当请求到来并且用户经过身份验证时,方法将被执行;否则,它将重定向请求到登录页面。

摘要

在本章中,我们学习了弹性,这是在.NET Core 中开发高性能应用程序时非常重要的因素。我们了解了不同的策略,并使用 Polly 框架在.NET Core 中使用这些策略。我们还学习了安全存储机制以及如何在开发环境中使用它们,以便将敏感信息与项目存储库分开。在本章的结尾,我们学习了一些核心基础知识,包括 SSL、CSRF、CORS、启用安全标头以及 ASP.NET Core 身份框架,以保护 ASP.NET Core 应用程序。

在下一章中,我们将学习一些关键的指标和必要的工具,以监控.NET Core 应用程序的性能。

第八章:微服务架构

微服务应用程序开发在软件行业以快速的速度增长。它被广泛用于开发具有弹性、可扩展、分布式和云就绪的高性能应用程序。许多组织和软件公司正在将他们的应用程序转变为微服务架构风格。亚马逊、eBay 和 Uber 是将他们的应用程序转变为微服务的好例子。

微服务将应用程序水平和垂直地分解成较小的组件,其中这些组件彼此独立,并通过端点进行通信。随着容器行业的最新发展,我们可以使用容器来部署/运行可以独立扩展的微服务,而不依赖于应用程序的其他组件,并且可以利用按需付费模式。

今天,我们可以使用 Azure 容器服务(ACS)或 Service Fabric 在云中部署.NET Core 应用程序,并提供一个与 Docker、Kubernetes 和其他第三方组件联合使用的容器化模型。

在本章中,我们将学习微服务架构的基础知识和挑战,并根据微服务的原则和实践创建一个基本的应用程序。

以下是本章我们将学习的主题:

  • 微服务架构

  • 好处和标准实践

  • 无状态与有状态的微服务

  • 分解数据库及其挑战

  • 在.NET Core 中开发微服务

  • 在 Docker 上运行.NET Core 微服务

微服务架构

微服务架构是一种架构风格,其中应用程序松散耦合;它根据业务能力或领域划分为组件,并且可以独立扩展而不影响应用程序的其他服务或组件。这与单体架构相反,单体架构将整个应用程序部署在服务器或虚拟机上,并且扩展不是一种经济有效或简单的解决方案。对于每个扩展操作,都必须克隆一个新的虚拟机实例,并且必须部署应用程序。

以下图表显示了单体应用程序的架构,其中大部分功能被隔离在单个进程中,并且在多个服务器上进行扩展需要在其他服务器上部署整个应用程序:

以下是微服务架构的表示,它将应用程序分成较小的服务,并根据工作负载独立扩展:

在微服务架构中,应用程序被划分为松散耦合的服务,每个服务都公开一个端点,并部署在单独的服务器上,或者更可能是容器上。每个服务通过某些端点与其他服务通信。

微服务架构的好处

微服务架构有各种好处,如下所示:

  • 微服务是自治的,并且通过与其他服务松散耦合的依赖关系公开了一个独立的功能单元

  • 它通过明确定义的 API 合同向调用者公开功能

  • 如果任何服务失败,它会优雅地降级

  • 它可以独立扩展。

  • 与 VM 相比,它最适合容器化部署,这是一种经济有效的解决方案

  • 每个组件可以通过端点重用,并且修改任何服务不会影响其他服务

  • 与单体架构相比,开发速度更快

  • 由于每个微服务提供特定的业务能力,因此它很容易被重用和组合

  • 由于每个服务都是独立的,因此使用旧的架构或技术并不是一个问题。

  • 它是弹性的,消除了单体故障转移场景

开发微服务的标准实践

作为标准做法,微服务是根据业务能力或业务领域设计和分解的。业务领域分解遵循领域驱动设计DDD)模式,其中每个服务都被开发为提供业务领域的特定功能。这与分层架构方法相反,分层架构方法将应用程序分成多个层,其中每个层依赖于另一层,并且对其有严格的依赖性,移除任何一层都会破坏整个应用程序。

以下图表说明了分层架构和微服务架构之间的区别:

微服务的类型

微服务分为两类,如下所示:

  • 无状态微服务

  • 有状态的微服务

无状态微服务

无状态服务要么没有状态,要么可以从外部数据存储中检索状态。由于状态是单独存储的,多个实例可以同时运行。

有状态的微服务

有状态服务在其自己的上下文中维护状态。一次只有一个实例是活动的。但是,状态也会复制到其他非活动实例中。

DDD

DDD 是一种强调应用程序业务域的模式。在按照 DDD 模式构建应用程序时,我们根据业务域划分应用程序,每个域都有一个或多个有界上下文,有界上下文代表业务需求。在技术术语中,每个有界上下文都有自己的代码和持久性机制,并且独立于其他上下文。考虑一个供应商管理系统,供应商在网站上注册,登录网站,更新其个人资料并附加报价。每种操作都被称为有界上下文,并且独立于其他操作。一组供应商操作可以称为供应商领域。

DDD 将需求分解为特定领域的块,称为有界上下文,每个有界上下文都有自己的模型,逻辑和数据。有可能一个单一服务被许多服务使用,因为它提供了核心功能。例如,供应商注册服务使用身份验证服务来创建新用户,同样的身份验证服务可能被其他服务用来登录系统。

使用微服务进行数据操作

作为一般做法,每个服务为用户提供特定的业务功能,并涉及创建读取更新删除CRUD)操作。在企业应用程序中,我们有一个或多个具有许多表的数据库。遵循 DDD 模式,我们可以设计每个专注于特定领域的服务。但是,有时我们需要从一些其他超出服务领域范围的数据库或表中提取数据。然而,有两种方法可以解决这个挑战:

  • 将微服务包装在 API 网关后面

  • 将数据分解为扁平模式以供读取/查询目的

将微服务包装在 API 网关后面

基于微服务架构的企业应用程序包含许多服务。企业资源规划ERP)系统包含许多模块,如人力资源HR),财务,采购申请等。每个模块可能有许多提供特定业务功能的服务。例如,HR 模块可能包含以下三个服务:

  • 个人记录管理

  • 评估管理

  • 招聘管理

个人记录管理服务公开了一些方法,用于创建、更新或删除员工的基本信息。绩效管理服务公开了一些方法,用于为员工创建绩效评估请求,招聘管理服务执行新的招聘决策。假设我们需要开发一个包含基本员工信息和过去五年内完成的评估总数的网页。在这种情况下,我们将调用两个服务,即个人记录管理和绩效管理,调用者将对这些服务进行两次单独的调用。或者,我们可以将这两个调用封装成一个单一的调用,使用 API 网关。解决这种情况的技术称为API 组合,在本章后面的什么是 API 组合?部分中进行了讨论。

将数据非规范化为扁平模式以供读取/查询

这是另一种技术,我们希望消费一个服务来从异构源读取数据。它可以来自多个表或数据库。为了将多个服务调用转换为单个调用,我们可以设计每个服务,并使用发布者/订阅者或中介等模式,监听要在任何服务上执行的任何 CRUD 操作,将数据保存到扁平模式中,并开发一个仅从该表中读取数据的服务。解决这种情况的技术称为命令查询责任分离CQRS),在本章后面的 CQRS 部分中进行了讨论。

业务场景的一致性

由于我们了解到每个服务都设计为提供特定的业务功能,让我们以订单管理系统为例,客户访问网站并下订单。下订单后,库存会反映出来。在这种情况下,我们可以有两个微服务:一个用于下订单并在订单数据库中创建数据库记录,另一个是执行库存相关表上的 CRUD 的库存服务:

在实施端到端业务场景并在多个微服务之间保持一致性时,要遵循的重要实践是保持数据和模型特定于其领域。考虑前面的例子,订单放置服务不应访问或执行订单表以外的 CRUD 操作,如果需要访问任何超出该服务领域的数据,应直接调用该服务。

原子性、一致性、完整性和持久性ACID)事务是另一个挑战。我们可能有多个服务为一个完整的事务提供服务,其中每个事务都在一个单独的服务后面运行。为了适应微服务架构风格的 ACID 事务,我们可以实现异步事件驱动通信,这将在本章后面进行讨论。

与微服务通信

在微服务架构中,每个微服务都托管在某个服务器上,很可能是一个容器,并公开一个端点。这些端点可以用于与该服务进行通信。有许多协议可以使用,但由于在许多平台上具有可访问性支持,基于 REST 的 HTTP 端点是最广泛使用的。在 ASP.NET Core 中,我们可以使用 ASP.NET Core MVC 框架创建微服务,并通过 RESTful 端点使用它们。还有一些微服务也使用其他微服务来完成特定操作,这可以很容易地通过.NET Core 中的HttpClient类来实现。然而,我们应该设计成这样,使我们的服务具有弹性,并处理瞬态故障。

微服务架构中的数据库架构

使用微服务架构,每个服务提供特定功能,并且对其他服务的依赖性很小。然而,将关系数据库转换为较小的集合是一个挑战,其中每个集合代表一个特定领域,并包含与该领域相关的表。根据领域对表进行分离并使它们成为独立的数据库需要适当的考虑。

让我们考虑提供企业对消费者(B2C)和企业对企业(B2B)流程的供应商管理系统,并涉及以下操作:

  • 供应商在网站上注册

  • 供应商添加其他供应商或客户可以购买的产品。

  • 供应商下订单购买产品

为了实现上述场景,我们可以根据以下两种模式对数据库进行分解:

  • 每个服务的表

  • 每个服务的数据库

每个服务的表

采用这种设计,每个服务都设计为使用数据库中的特定表。在这种情况下,数据库是集中的,托管在一个地方。其他微服务也连接到同一个数据库,但处理自己的领域特定表:

这有助于我们使用中央数据库,但模式的任何修改可能会破坏或需要更新一个或多个微服务。

每个服务的数据库

采用这种设计,每个服务都有自己的数据库,应用程序松散耦合。对数据库的修改不会损害或破坏任何其他服务,并提供完全隔离。这种设计对部署场景很有好处,因为每个服务都包含自己的数据库,部署在自己的容器中:

按服务分离表或数据库的挑战

根据业务能力或业务领域对表或数据库进行分离,建议限制依赖关系并使其与领域模型保持完整。但这也带来了一些挑战。例如,我们有两个服务:供应商服务和订单服务。供应商服务用于在自己的供应商数据库中创建供应商记录,订单服务用于为特定供应商下订单。当我们需要将供应商及其订单的聚合记录返回给用户时,就会面临挑战。为了解决这个问题,我们可以使用以下两种方法之一:

  • API 组合

  • CQRS

什么是 API 组合?

API 组合是一种技术,其中多个微服务被组合以向用户公开一个端点,并提供聚合视图。在单个数据库中,通过进行 SQL 查询连接并从不同表中获取数据,这是很容易实现的。

让我们考虑供应商管理系统,我们有两个服务。一个用于注册新供应商,并有相应的数据库来保存供应商的人口统计学、地址和其他信息。另一个服务是订单服务,用于存储供应商的交易数据,并包含订单信息,如订单号、数量等。假设我们有一个要求显示已完成订单的供应商列表。在这种情况下,我们可以在供应商注册服务中提供一个方法,首先从自己的数据存储中加载供应商详细信息,然后通过调用订单服务加载他们的订单,最后返回聚合数据。

CQRS

CQRS 是一个原则,其中应用程序命令(如创建,更新和删除)被读操作分隔。它基于事件模型工作,当 API 上采取任何创建,更新或删除操作时,事件处理程序被调用并将该信息存储到其相应的数据存储中。我们可以在先前的供应商注册示例中实现 CQRS,这将方便从单个服务查询供应商及其订单。当在供应商或订单服务上执行任何命令(创建,更新,删除)操作时,它将调用处理程序来调用查询服务,将更新的数据保存到其存储中。

我们可以将数据保留在扁平模式中,或者使用 NoSQL 数据库来保存有关供应商及其订单的所有信息,并在需要时读取它们:

上述图表代表了三个服务:供应商服务,订单服务和查询服务。当在供应商服务上执行任何创建,更新或删除操作时,事件被触发,并调用相应的处理程序,使 HTTP POST,PUT 或 DELETE 请求在查询服务上保存或更新其数据存储。订单服务也是如此,它调用查询服务并存储与订单相关的信息。最后,查询服务用于在单个调用中读取独立服务的累积数据。

这种方法的好处如下:

  • 我们可以通过定义集群和非集群索引来优化查询数据库

  • 我们可以使用其他数据库模型,如 NoSQL,MongoDB 或 Elasticsearch,为用户提供更快的检索和搜索体验

  • 每个服务都有自己的数据存储,但是通过这种方法,我们可以将数据聚合在一个地方

  • 我们可以使用查询数据进行报告

CQRS 可以使用中介者模式来实现,我们将在本章后面讨论。

使用.NET Core 开发微服务架构

到目前为止,我们已经学习了微服务的基础知识和 DDD 的重要性。在本节中,我们将为一个包含以下功能的示例应用程序开发微服务架构:

  • 身份服务

  • 供应商服务

使用微服务架构在.NET Core 中创建一个示例应用程序

在本节中,我们将在.NET Core 中创建一个示例应用程序,并定义包括授权服务器、供应商服务和订单服务在内的服务。首先,我们可以使用 Visual Studio 2017 或 Visual Studio Code,并使用 dotnet 命令行界面CLI)工具创建项目。选择 Visual Studio 2017 的优势在于,在创建项目时提供了一个选项,可以启用 Docker 支持,添加与 Docker 相关的文件,并将 Docker 设置为启动项目:

解决方案结构

解决方案的结构将如下所示:

在上述结构中,我们有根文件夹,即核心微服务WebFront。共同和核心组件驻留在核心中,所有微服务驻留在微服务文件夹中,WebFront包含前端项目,很可能是 ASP.NET MVC Core 项目,移动应用程序等。

在指定文件夹内创建项目可以为解决方案赋予适当的含义,并且使得理解解决方案的整体图像变得容易。

以下表格显示了每个文件夹中创建的项目:

文件夹 项目名称 项目类型 描述
核心 基础设施 .NET 标准 2.0 包含存储库类,UnitOfWorkBaseEntity
核心 API 组件 .NET 标准 2.0 包含BaseControllerLoggingActionFilterResilientHttpClient
微服务 > 认证服务器 Identity.AuthServer ASP.NET Core 2.0 web API 使用 OpenIddict 和 ASP.NET Core Identity 的授权服务器
微服务 >``供应商 供应商.API ASP.NET Core 2.0 web API 包含供应商 API 控制器
微服务 >``供应商 供应商.Domain .NET Standard 2.0 包含特定于供应商领域的领域模型
微服务 >``供应商 供应商.Infrastructure .NET Standard 2.0 包含特定于供应商的存储库和数据库上下文
WebFront FraymsWebApp ASP.NET Core 2.0 web app 包含前端视图、页面和客户端框架

逻辑架构

示例应用的逻辑架构代表了两个微服务,即身份服务和供应商服务。身份服务用于执行用户身份验证和授权,而供应商服务用于执行供应商注册:

我们将使用 DDD 方法来表达数据模型,其中每个服务将有其自己对应的表。

供应商服务基于业务领域,分为三层,即暴露 HTTP 端点并由客户端使用的 API 层,包含领域实体、聚合和 DDD 模式的领域层,以及包含所有通用类的基础设施层,包括存储库、Entity Framework (EF)、Core 上下文和其他辅助类。

领域层是实际定义业务逻辑和实体的层,通常是特定业务场景的Plain Old CLR Object (POCO)。它不应直接依赖于任何数据库框架或对象关系映射 (ORM),如 EF、Hibernate 等。然而,使用 EF Core,我们可以将实体与其他程序集分开,并将它们定义为 POCO 实体,从而消除对 EF Core 库的依赖。

当 API 收到请求时,它使用领域层执行特定的业务场景并传递接收到的数据。领域层执行业务逻辑并使用基础设施层对数据库执行 CRUD 操作。最后,API 将响应发送回调用者:

开发核心基础设施项目

该项目包含应用程序使用的核心类和组件。它将包含一些通用或基础类、门面和其他在整个应用程序中通用的辅助类。

我们将创建以下类,并讨论它们对于特定于微服务的其他项目的用处。

创建 BaseEntity 类

BaseEntity class:
public abstract class BaseEntity 
{ 

  public BaseEntity() 
  { 
    this.CreatedOn = DateTime.Now; 
    this.UpdatedOn = DateTime.Now; 
    this.State = (int)EntityState.New; 

  } 
  public string CreatedBy { get; set; } 
  public DateTime CreatedOn { get; set; } 
  public string UpdatedBy { get; set; } 
  public DateTime UpdatedOn { get; set; } 

} 

使用NotMapped属性注释的任何属性都不会在后端数据库中创建相应的字段。

UnitOfWork 模式

我们将实现UnitOfWork模式,以便在单个调用中保存上下文更改到后端数据库。在每个对象状态更改时更新数据库不是一个好的做法,会降低应用程序性能。考虑一个包含可编辑表格的表单的例子。在每次行更新时提交更改到数据库会降低应用程序性能。更好的方法是将每行状态保存在内存中,并在提交表单后一次性更新数据库。使用 Unit of Work 模式,我们可以定义一个包含以下四个方法的接口:

public interface IUnitOfWork: IDisposable 
{ 
  void BeginTransaction(); 

  void RollbackTransaction(); 

  void CommitTransaction(); 

  Task<bool> SaveChangesAsync(); 

} 

接口包含与事务相关的方法,即BeginTransactionRollbackTransactionCommitTransaction,其中SaveChangesAsync用于保存对数据库的更改。每个服务都有自己的数据库上下文实现,并实现了IUnitOfWork接口,以提供事务处理并将更改保存到后端数据库。

创建存储库接口

我们将创建一个通用的存储库接口,每个服务的存储库类都将实现该接口,因为每个服务都将遵循 DDD 方法,并且都有自己的存储库,以便根据业务域向开发人员提供有意义的信息。在这个接口中,我们可以保留通用方法,如AllContains,以及返回UnitOfWork的属性:

public interface IRepository<T> where T : BaseEntity 
{ 
  IUnitOfWork UnitOfWork { get; } 

  IQueryable<T> All<T>() where T : BaseEntity; 
  T Find<T>(Expression<Func<T, bool>> predicate) where T : BaseEntity; 

  bool Contains<T>(Expression<Func<T, bool>> predicate) where T : BaseEntity; 
} 

日志记录

日志记录是任何企业应用程序的重要部分。通过日志记录,我们可以在应用程序运行时跟踪或排除实际错误。在任何优秀的产品中,我们通常看到每个错误都有一个错误代码。定义错误代码,然后在记录异常时使用它们直观地告诉开发人员或支持团队进行故障排除,并达到实际错误发生的地点并提供解决方案。对于所有应用程序级错误,我们可以创建一个LoggingEvents类,并指定在开发过程中可以进一步使用的常量值。这是包含一些GETCREATEUPDATE和其他事件代码的LoggingEvents类。我们可以在Infrastructure项目的Façade文件夹下创建这个类:

public static class LoggingEvents 
{ 
  public const int GET_ITEM = 1001; 
  public const int GET_ITEMS = 1002; 
  public const int CREATE_ITEM = 1003; 
  public const int UPDATE_ITEM = 1004; 
  public const int DELETE_ITEM = 1005; 
  public const int DATABASE_ERROR = 2000; 
  public const int SERVICE_ERROR = 2001; 
  public const int ERROR = 2002; 
  public const int ACCESS_METHOD = 3000; 
} 
LoggerHelper class:
public static string GetExceptionDetails(Exception ex) 
{ 

  StringBuilder errorString = new StringBuilder(); 
  errorString.AppendLine("An error occured. "); 
  Exception inner = ex; 
  while (inner != null) 
  { 
    errorString.Append("Error Message:"); 
    errorString.AppendLine(ex.Message); 
    errorString.Append("Stack Trace:"); 
    errorString.AppendLine(ex.StackTrace); 
    inner = inner.InnerException; 
  } 
  return errorString.ToString(); 
} 

创建 APIComponents 基础设施项目

BaseController class:
public class BaseController : Controller 
{ 
  private ILogger _logger; 
  public BaseController(ILogger logger) 
  { 
    _logger = logger; 
  } 

  public ILogger Logger { get { return _logger; } } 
  public HttpResponseMessage LogException(Exception ex) 
  { 
    HttpResponseMessage message = new HttpResponseMessage(); 
    message.Content = new StringContent(ex.Message); 
    message.StatusCode = System.Net.HttpStatusCode.ExpectationFailed; 
    return message; 
  } 
} 

BaseController在参数化构造函数中使用ILogger,它将通过 ASP.NET Core 的内置依赖注入DI)组件进行注入。

LogException方法用于记录异常并返回HttpResponseMessage,在发生任何错误时,派生控制器将返回给用户。

LoggingActionFilter class:
public class LoggingActionFilter: ActionFilterAttribute 
{ 
  public override void OnActionExecuting(ActionExecutingContext context) 
  { 

    Log("OnActionExecuting", context.RouteData, context.Controller); 

  } 

  public override void OnActionExecuted(ActionExecutedContext context) 
  { 
    Log("OnActionExecuted", context.RouteData, context.Controller); 

  } 

  public override void OnResultExecuted(ResultExecutedContext context) 
  { 
    Log("OnResultExecuted", context.RouteData, context.Controller); 
  } 

  public override void OnResultExecuting(ResultExecutingContext context) 
  { 
    Log("OnResultExecuting", context.RouteData, context.Controller); 
  } 

  private void Log(string methodName, RouteData routeData, Object controller) 
  { 
    var controllerName = routeData.Values["controller"]; 
    var actionName = routeData.Values["action"]; 
    var message = String.Format("{0} controller:{1} action:{2}", 
    methodName, controllerName, actionName); 
    BaseController baseController = ((BaseController)controller); 
    baseController.Logger.LogInformation(LoggingEvents.ACCESS_METHOD, message); 
  } 
} 

在这个项目中,我们还有ResilientHttpClient,我们在第七章中学习了在.NET Core 应用程序中实现弹性和安全

为用户授权开发身份服务

在 ASP.NET Core 中,我们可以选择从各种身份验证提供程序对应用程序进行身份验证。在微服务架构中,服务是分别部署和托管在不同的容器中的。我们可以使用 ASP.NET Core Identity 并将其添加为服务本身的中间件,或者我们可以使用 IdentityServer 并开发一个中央身份验证服务器来执行身份验证和授权中心化,访问所有注册到中央身份验证服务器CAS)的服务,并通过传递令牌访问受保护的资源。

身份服务基本上充当注册企业中所有服务的 CAS。当请求到达服务时,它会请求可以从授权服务器获取的令牌。一旦获得令牌,就可以用它来访问资源服务。

有各种库来构建身份验证服务器,如下所示:

  • IdentityServer4:IdentityServer4 是 ASP.NET Core 的 OpenID Connect 和 OAuth 2.0 框架

  • OpenIddict:在 ASP.NET Core 项目中实现 OpenID Connect 服务器的易于插入的解决方案

  • ASOS (AspNet.Security.OpenIdConnect.Server):ASOS 是一个高级的 OpenID Connect 服务器,旨在提供低级协议优先的方法

我们将在我们的身份服务中使用 OpenIddict。

OpenIddict 连接流

OpenIddict 提供各种类型的流,包括授权代码流、密码流、客户端凭据流等。然而,在本章中,我们使用了隐式流。

在隐式流中,令牌是通过授权端点通过传递用户名和密码来检索的。所有通信都在单次往返中与授权服务器完成。认证完成后,令牌被添加到重定向 URI 中,并且可以在后续请求中通过传递请求标头来使用。以下图表描述了隐式流的工作原理:

隐式流广泛用于单页应用程序SPAs)。该过程始于单页应用程序 Web 应用程序希望访问受保护的资源服务器上的受保护的 Web API。由于 Web API 受保护,它需要一个令牌来验证请求并验证调用者。为了获取令牌(通常称为持有者令牌),单页应用程序首先前往授权服务器并输入用户名和密码。成功验证后,授权服务器返回令牌并将其附加到重定向 URI 本身。Web 应用程序解析统一资源定位符URL)并检索令牌,进一步用于访问受保护的资源。

创建身份服务项目

身份服务是一个 ASP.NET Core Web API 项目。要使用 OpenIddict 库,我们必须在 Visual Studio 的包源对话框中添加一个aspnet-contrib引用。要从 Visual Studio 中添加此源,请右键单击项目,然后点击设置按钮,如下截图所示:

然后添加aspnet-contrib的条目,源为www.myget.org/F/aspnet-contrib/api/v3/index.json

添加了这些后,我们现在可以在 NuGet 包管理器窗口中轻松添加 OpenIddict 包。

请记住检查是否选中了包括预发布复选框。

以下是我们可以直接添加到项目文件或从 Visual Studio 的 NuGet 包管理器窗口中添加的包:

<PackageReference Include="AspNet.Security.OAuth.Validation" Version="2.0.0-rc1-final" /> 
<PackageReference Include="AspNet.Security.OpenIdConnect.Server" Version="2.0.0-rc1-final" /> 
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.0.1" /> 
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="2.0.1" /> 
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.0.2" /> 
<PackageReference Include="OpenIddict" Version="2.0.0-rc2-0797" /> 
<PackageReference Include="OpenIddict.Core" Version="2.0.0-rc2-0797" /> 
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="2.0.0-rc2-0797" /> 
<PackageReference Include="OpenIddict.Models" Version="2.0.0-rc2-0797" /> 
<PackageReference Include="OpenIddict.Mvc" Version="2.0.0-rc2-0797" /> 

添加自定义 UserEntity 和 UserRole 类

ASP.NET Core Identity 包含IdentityUserIdentityRole类,并使用 EF Core 来创建后端数据库。但是,如果我们想要自定义默认表,可以通过继承这些基类来实现。

我们将创建一个Models文件夹,并通过创建自定义的UserEntity类来自定义IdentityUser,并添加以下四个字段:

public class UserEntity : IdentityUser<Guid> 
{ 

  public int VendorId { get; set; } 

  public string FirstName { get; set; } 
  public string LastName { get; set; } 

  public DateTimeOffset CreatedAt { get; set; } 

} 

我们添加了这些字段,所以当供应商注册时,我们将在这个表中保留他们的名字、姓氏和 ID。接下来,我们添加另一个类UserRole,它派生自IdentityRole,并添加以下参数化构造函数:

public class UserRoleEntity : IdentityRole<Guid> 
{ 
  public UserRoleEntity() : base() { } 

  public UserRoleEntity(string roleName) : base(roleName) { } 
} 

我们将添加一个自定义数据库上下文类,它派生自IdentityDbContext,并指定UserEntityUserRoleEntity类型如下:

public class BFIdentityContext : IdentityDbContext<UserEntity, UserRoleEntity, Guid> 
{ 
  public BFIdentityContext(Microsoft.EntityFrameworkCore.DbContextOptions options) : 
  base(options) { } 
} 

我们可以运行 EF Core 迁移来创建 ASP.NET Identity 表,也可以使用 EF CLI 工具运行迁移。在运行迁移之前,我们在Startup类的ConfigureServices方法中添加以下条目:

public void ConfigureServices(IServiceCollection services) 
{ 
  var connection= Configuration["ConnectionString"]; 

  services.AddDbContext<BFIdentityContext>(options => 
  { 
    // Configure the context to use Microsoft SQL Server. 
    options.UseSqlServer(connection); 
  }); 

  services.AddIdentity<UserEntity, UserRole>().AddEntityFrameworkStores<BFIdentityContext>(); 

  services.AddMvc(); 
}   

您可以从 Visual Studio 的包管理器控制台窗口运行 EF 迁移。要添加迁移,首先运行以下命令:

Add-Migration Initial

Add-Migration是 EF CLI 工具集的命令,其中Initial是迁移的名称。一旦我们运行这个命令,它将在我们的项目中添加Migrations文件夹和包含UpDown方法的Initial类,以应用或移除对数据库的更改。接下来,我们可以运行Update-Database命令,加载Initial类并将更改应用到后端数据库。

现在我们在Startup类中添加与 OpenIddict 隐式流相关的配置。以下是修改后的ConfigureServices方法,添加了 OpenIddict 隐式流:

public void ConfigureServices(IServiceCollection services) 
{ 

  var connection = @"Server=.sqlexpress;Database=FraymsIdentityDB;
  User Id=sa;Password=P@ssw0rd;"; 

  services.AddDbContext<BFIdentityContext>(options => 
  { 
    // Configure the context to use Microsoft SQL Server. 
    options.UseSqlServer(connection); 

    // Register the entity sets needed by OpenIddict. 
    // Note: use the generic overload if you need 
    // to replace the default OpenIddict entities. 
    options.UseOpenIddict(); 
  }); 

  services.AddIdentity<UserEntity, UserRoleEntity>() 
  .AddEntityFrameworkStores<BFIdentityContext>(); 

  // Configure Identity to use the same JWT claims as OpenIddict instead 
  // of the legacy WS-Federation claims it uses by default (ClaimTypes), 
  // which saves you from doing the mapping in your authorization controller. 
  services.Configure<IdentityOptions>(options => 
  { 
    options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name; 
    options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject; 
    options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role; 
  }); 

  // Register the OpenIddict services. 
  services.AddOpenIddict(options => 
  { 
    // Register the Entity Framework stores. 
    options.AddEntityFrameworkCoreStores<BFIdentityContext>(); 

    // Register the ASP.NET Core MVC binder used by OpenIddict. 
    // Note: if you don't call this method, you won't be able to 
    // bind OpenIdConnectRequest or OpenIdConnectResponse parameters. 
    options.AddMvcBinders(); 

    // Enable the authorization, logout, userinfo, and introspection endpoints. 
    options.EnableAuthorizationEndpoint("/connect/authorize") 
    .EnableLogoutEndpoint("/connect/logout") 
    .EnableIntrospectionEndpoint("/connect/introspect") 
    .EnableUserinfoEndpoint("/api/userinfo"); 

    // Note: the sample only uses the implicit code flow but you can enable 
    // the other flows if you need to support implicit, password or client credentials. 
    options.AllowImplicitFlow(); 

    // During development, you can disable the HTTPS requirement. 
    options.DisableHttpsRequirement(); 

    // Register a new ephemeral key, that is discarded when the application 
    // shuts down. Tokens signed using this key are automatically invalidated. 
    // This method should only be used during development. 
    options.AddEphemeralSigningKey(); 

    options.UseJsonWebTokens(); 
  }); 

  services.AddAuthentication() 
  .AddOAuthValidation(); 

  services.AddCors(); 
  services.AddMvc(); 
}  

在前面的方法中,我们首先在AddDbContext选项中添加UseOpenIddict方法,它将在数据库中创建与 OpenIddict 相关的表。然后,我们通过设置IdentityOptions来配置 Identity,以使用与 OpenIddict 相同的JSON Web Tokens (JWT)声明,如下所示:

services.Configure<IdentityOptions>(options => 
{ 
  options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name; 
  options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject; 
  options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role; 
}); 

最后,我们通过调用services.AddOpenIddict方法注册 OpenIddict 功能并指定值。

以下是Configure方法,首先启用跨源资源共享CORS),允许来自任何标头、来源和方法的请求。然后,添加身份验证并调用InitializeAsync方法,以填充 OpenIddict 表中的应用程序和资源(服务)信息:

public void Configure(IApplicationBuilder app) 
{ 
  app.UseCors(builder => 
  { 
    builder.AllowAnyOrigin(); 
    builder.AllowAnyHeader(); 
    builder.AllowAnyMethod(); 
  }); 

  app.UseAuthentication(); 

  app.UseMvcWithDefaultRoute(); 

  // Seed the database with the sample applications. 
  // Note: in a real world application, this step should be part of a setup script. 
  InitializeAsync(app.ApplicationServices, CancellationToken.None).GetAwaiter().GetResult(); 
} 

以下是所示的InitializeAsync方法:

private async Task InitializeAsync(IServiceProvider services, CancellationToken cancellationToken) 
{ 
  // Create a new service scope to ensure the database context 
  // is correctly disposed when this methods returns. 
  using (var scope = services.GetRequiredService<IServiceScopeFactory>().CreateScope()) 
  { 
    var context = scope.ServiceProvider.GetRequiredService<BFIdentityContext>(); 
    await context.Database.EnsureCreatedAsync(); 

    var manager = scope.ServiceProvider.GetRequiredService
    <OpenIddictApplicationManager<OpenIddictApplication>>(); 

    if (await manager.FindByClientIdAsync("bfrwebapp", cancellationToken) == null) 
    { 
      var descriptor = new OpenIddictApplicationDescriptor 
      { 
        ClientId = "bfrwebapp", 
        DisplayName = "Business Frayms web application", 
        PostLogoutRedirectUris = { new Uri("http://localhost:8080/signout-oidc") }, 
        RedirectUris = { new Uri("http://localhost:8080/signin-oidc") } 
      }; 

      await manager.CreateAsync(descriptor, cancellationToken); 
    } 

    if (await manager.FindByClientIdAsync("vendor-api", cancellationToken) == null) 
    { 
      var descriptor = new OpenIddictApplicationDescriptor 
      { 
        ClientId = "vendor-api", 
        ClientSecret = "846B62D0-DEF9-4215-A99D-86E6B8DAB342", 
        //RedirectUris = { new Uri("http://localhost:12345/api") } 
      }; 

      await manager.CreateAsync(descriptor, cancellationToken); 
    } 

  } 
} 

在上述方法中,我们添加了以下三个应用程序:

  • bfrwebapp:一个 ASP.NET Core Web 应用程序。当用户访问 Web 应用程序时,它会检查用户是否经过身份验证,根据是否提供了令牌。如果用户未经身份验证,它将重定向到授权服务器。用户输入凭据,并在成功验证后,将重定向回bfrwebapp。在此范围内指定的重定向 URI 是bfrwebapp的 URI。

  • vendor-api:具有唯一客户端密钥的供应商微服务。

前面的配置是服务器端配置,我们将看到客户端需要添加哪些配置。

AuthorizationController:
public class AuthorizationController : Controller 
{ 
  private readonly IOptions<IdentityOptions> _identityOptions; 
  private readonly SignInManager<UserEntity> _signInManager; 
  private readonly UserManager<UserEntity> _userManager; 

  public AuthorizationController( 
    IOptions<IdentityOptions> identityOptions, 
    SignInManager<UserEntity> signInManager, 
    UserManager<UserEntity> userManager) 
  { 
    _identityOptions = identityOptions; 
    _signInManager = signInManager; 
    _userManager = userManager; 
  } 

  [HttpGet("~/connect/authorize")] 
  public async Task<IActionResult> Authorize(OpenIdConnectRequest request) 
  { 
    Debug.Assert(request.IsAuthorizationRequest(), 
    "The OpenIddict binder for ASP.NET Core MVC is not registered. " + 
    "Make sure services.AddOpenIddict().AddMvcBinders() is correctly called."); 

    if (!User.Identity.IsAuthenticated) 
    { 
      // If the client application request promptless authentication, 
      // return an error indicating that the user is not logged in. 
      if (request.HasPrompt(OpenIdConnectConstants.Prompts.None)) 
      { 
        var properties = new AuthenticationProperties(new Dictionary<string, string> 
        { 
          [OpenIdConnectConstants.Properties.Error] = 
          OpenIdConnectConstants.Errors.LoginRequired, 
          [OpenIdConnectConstants.Properties.ErrorDescription] = 
          "The user is not logged in." 
        }); 

        // Ask OpenIddict to return a login_required error to the client application. 
        return Forbid(properties, OpenIdConnectServerDefaults.AuthenticationScheme); 
      } 

      return Challenge(); 
    } 

    // Retrieve the profile of the logged in user. 
    var user = await _userManager.GetUserAsync(User); 
    if (user == null) 
    { 
      return BadRequest(new OpenIdConnectResponse 
      { 
        Error = OpenIdConnectConstants.Errors.InvalidGrant, 
        ErrorDescription = "The username/password couple is invalid." 
      }); 
    } 

    // Create a new authentication ticket. 
    var ticket = await CreateTicketAsync(request, user); 

    // Returning a SignInResult will ask OpenIddict to issue 
    the appropriate access/identity tokens. 
    return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme); 
  } 

  [HttpGet("~/connect/logout")] 
  public async Task<IActionResult> Logout() 
  { 
    // Ask ASP.NET Core Identity to delete the local and external cookies created 
    // when the user agent is redirected from the external identity provider 
    // after a successful authentication flow (e.g Google or Facebook). 
    await _signInManager.SignOutAsync(); 

    // Returning a SignOutResult will ask OpenIddict to redirect the user agent 
    // to the post_logout_redirect_uri specified by the client application. 
    return SignOut(OpenIdConnectServerDefaults.AuthenticationScheme); 
  } 

  private async Task<AuthenticationTicket> CreateTicketAsync(
  OpenIdConnectRequest request, UserEntity user) 
  { 
    // Create a new ClaimsPrincipal containing the claims that 
    // will be used to create an id_token, a token or a code. 
    var principal = await _signInManager.CreateUserPrincipalAsync(user); 

    // Create a new authentication ticket holding the user identity. 
    var ticket = new AuthenticationTicket(principal, 
    new AuthenticationProperties(), 
    OpenIdConnectServerDefaults.AuthenticationScheme); 

    // Set the list of scopes granted to the client application. 
    ticket.SetScopes(new[] 
    { 
      OpenIdConnectConstants.Scopes.OpenId, 
      OpenIdConnectConstants.Scopes.Email, 
      OpenIdConnectConstants.Scopes.Profile, 
      OpenIddictConstants.Scopes.Roles 
    }.Intersect(request.GetScopes())); 

    ticket.SetResources("vendor-api"); 

    // Note: by default, claims are NOT automatically included in 
    // the access and identity tokens. 
    // To allow OpenIddict to serialize them, you must attach them a destination, that specifies 
    // whether they should be included in access tokens, in identity tokens or in both. 

    foreach (var claim in ticket.Principal.Claims) 
    { 
      // Never include the security stamp in the access and 
      // identity tokens, as it's a secret value. 
      if (claim.Type == _identityOptions.Value.ClaimsIdentity.SecurityStampClaimType) 
      { 
        continue; 
      } 

      var destinations = new List<string> 
      { 
        OpenIdConnectConstants.Destinations.AccessToken 
      }; 

      // Only add the iterated claim to the id_token if 
      // the corresponding scope was granted to the client application. 
      // The other claims will only be added to the access_token, 
      // which is encrypted when using the default format. 
      if ((claim.Type == OpenIdConnectConstants.Claims.Name && 
      ticket.HasScope(OpenIdConnectConstants.Scopes.Profile)) || 
      (claim.Type == OpenIdConnectConstants.Claims.Email && 
      ticket.HasScope(OpenIdConnectConstants.Scopes.Email)) || 
      (claim.Type == OpenIdConnectConstants.Claims.Role && 
      ticket.HasScope(OpenIddictConstants.Claims.Roles))) 
      { 
        destinations.Add(OpenIdConnectConstants.Destinations.IdentityToken); 
      } 

      claim.SetDestinations(destinations); 
    } 

    return ticket; 
  } 
} 

AuthorizationController公开两种方法,即authorizelogoutauthorize方法检查用户是否经过身份验证,并返回一个挑战,显示登录页面,用户可以输入其用户名和密码。一旦输入了正确的凭据并且用户从身份表中验证通过,授权服务器将创建一个新的身份验证令牌,并根据为bfrwebapp指定的重定向 URI 将其返回给客户端应用程序。要查看工作示例,请参考代码存储库。

实现供应商服务

供应商服务是一个 Web API,公开了一个执行供应商注册的方法。该服务实现了供应商系统的实际业务领域,供应商可以注册。正如我们在前一节中所学到的,我们可以根据业务能力或业务领域来分解应用程序。该服务实现了 DDD 原则,并根据业务领域进行了分解。它包含以下三个项目:

  • Vendor.API:一个 ASP.NET Core Web API 项目,公开注册供应商的方法

  • Vendor.Domain:.NET Standard 2.0 类库,包含诸如VendorMasterVendorDocument之类的 POCO 模型,以及一个IVendorRepository接口,用于定义供应商领域的基本方法。

  • Vendor.Infrastructure:.NET Standard 2.0 类库,包含实现IVendorRepository接口的VendorRepository和执行数据库操作的VendorDBContext

创建供应商领域

创建一个新的.NET Standard 库项目,并将其命名为Vendor.Domain。我们将引用先前创建的Infrastructure项目,以从BaseEntity类派生我们的 POCO 实体。

VendorMaster class:
public class VendorMaster : BaseEntity 
{ 
  [Key] 
  public int ID { get; set; } 
  public string VendorName { get; set; } 
  public string ContractNumber { get; set; } 
  public string Email { get; set; } 
  public string Title { get; set; } 
  public string PrimaryContactPersonName{ get; set; } 
  public string PrimaryContactEmail { get; set; } 
  public string PrimaryContactNumber { get; set; } 
  public string SecondaryContactPersonName { get; set; } 
  public string SecondaryContactEmail { get; set; } 
  public string SecondaryContactNumber { get; set; } 
  public string Website { get; set; } 
  public string FaxNumber { get; set; } 
  public string AddressLine1 { get; set; } 
  public string AddressLine2 { get; set; } 
  public string City { get; set; } 
  public string State { get; set; } 
  public string Country { get; set; } 

  public List<VendorDocument> VendorDocuments { get; set; } 

} 
VendorDocument class:
public class VendorDocument : BaseEntity 
{ 

  [Key] 
  public int ID { get; set; } 
  public string DocumentName { get; set; } 
  public string DocumentType { get; set; } 
  public Byte[] DocumentContent { get; set; } 
  public DateTime DocumentExpiry { get; set; } 

  public int VendorMasterID { get; set; } 

  [ForeignKey("VendorMasterID")] 
  public VendorMaster VendorMaster { get; set; } 

} 
IVendorRepository interface:
public interface IVendorRepository : IRepository<VendorMaster> 
{ 
  VendorMaster Add(VendorMaster vendorMaster); 

  void Update(VendorMaster vendorMaster); 

  Task<VendorMaster> GetAsync(int vendorID); 

  void Add(VendorDocument vendorDocument); 

  void Delete(int vendorDocumentID); 
} 

创建供应商基础设施

该项目是一个.NET Standard 2.0 类库项目,引用了核心InfrastructureVendor.Domain项目。其中包含了VendorRepository的实际实现以及与后端 SQL Server 数据库连接的数据库上下文。

这是VendorDBContext类,它从 EF Core 的DbContext类派生,并为VendorMasterVendorDocument实体定义了DbSet

public class VendorDBContext : DbContext, IUnitOfWork 
{ 

  public VendorDBContext(DbContextOptions options) : base(options) 
  { 

  } 

  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 
  { 
    base.OnConfiguring(optionsBuilder); 
    //  optionsBuilder.UseSqlServer(@"Data Source=.sqlexpress;
    Initial Catalog=FraymsVendorDB;Integrated Security=False; User Id=sa; 
    Password=P@ssw0rd; Timeout=500000;"); 
  } 

  protected override void OnModelCreating(ModelBuilder builder) 
  { 
    base.OnModelCreating(builder); 
  } 

  public void BeginTransaction() 
  { 
    this.Database.BeginTransaction(); 
  } 
  public void RollbackTransaction() 
  { 
    this.Database.RollbackTransaction(); 
  } 
  public void CommitTransaction() 
  { 
    this.Database.CommitTransaction(); 
  } 
  public Task<bool> SaveChangesAsync() 
  { 
    return this.SaveChangesAsync(); 
  } 

  public DbSet<VendorMaster> VendorMaster { get; set; } 
  public DbSet<VendorDocument> VendorDocuments { get; protected set; } 

我们还将实现IUnitOfWork接口,因此当VendorRepository被注入到控制器中时,我们可以执行事务处理并在单个调用中保存与关联数据库的更改。

以下是实现IVendorRepository接口的VendorRepository

public class VendorRepository : IVendorRepository 
{ 

  VendorDBContext _dbContext; 

  public VendorRepository(VendorDBContext dbContext) 
  { 
    this._dbContext = dbContext; 
  } 

  public IUnitOfWork UnitOfWork 
  { 
    get 
    { 
      return _dbContext; 
    } 
  } 

  public VendorMaster Add(VendorMaster vendorMaster) 
  { 
    var res= _dbContext.Add(vendorMaster); 
    return res.Entity; 
  } 

  public void AddDocument(VendorDocument vendorDocument) 
  { 
    var res = _dbContext.Add(vendorDocument); 
  } 

  public void Update(VendorMaster vendorMaster) 
  { 
    _dbContext.Entry(vendorMaster).State = Microsoft.EntityFrameworkCore.EntityState.Modified; 
  } 

  public async Task<VendorMaster> GetAsync(int vendorID) 
  { 
    var vendorMaster = await _dbContext.VendorMaster.FindAsync(vendorID); 
    if (vendorMaster != null) 
    { 
      await _dbContext.Entry(vendorMaster) 
      .Collection(i => i.VendorDocuments).LoadAsync(); 
    } 
    return vendorMaster; 
  } 

  public IQueryable<T> All<T>() where T : BaseEntity 
  { 
    return _dbContext.Set<T>().AsQueryable(); 
  } 

  public bool Contains<T>(Expression<Func<T, bool>> predicate) where T : BaseEntity 
  { 
    return _dbContext.Set<T>().Count<T>(predicate) > 0; 
  } 

  public T Find<T>(Expression<Func<T, bool>> predicate) where T : BaseEntity 
  { 
    return _dbContext.Set<T>().FirstOrDefault<T>(predicate); 
  }       
}

创建供应商服务

我们现在将创建一个供应商服务项目,该项目将公开供客户端应用程序使用的方法来注册供应商。首先,让我们创建一个新的 ASP.NET Core Web API 项目,并将其命名为Vendor.API

在供应商服务中实现中介者模式

在微服务架构中,一个应用被分割成多个服务,每个服务通过端点连接到其他服务。当事件被调用时,有可能一个服务会调用或与多个服务交互。隔离服务之间的交互始终是一种推荐的方法,并解决了对其他服务的紧密依赖。例如,一个应用程序调用此服务来注册供应商,然后调用身份服务来创建其用户帐户,并通过调用消息服务发送电子邮件。我们可以实现中介者模式来解决这种情况。

中介者模式基于事件驱动的拓扑结构,作为发布者/订阅者模型。当任何事件被调用时,注册的处理程序被调用并执行底层逻辑。这封装了服务之间如何相互交互的逻辑,使得每个交互的实际逻辑保持分离。此外,代码清晰且易于更改。

Vendor.API中,我们将使用.NET 的MediatR库实现中介者模式。MediatR是中介者模式的实现,支持命令处理和领域事件发布。在接下来的部分中,我们将在用户注册时实现中介者,并调用身份服务来创建新用户并发送电子邮件。

要使用MediatR,我们必须添加以下两个包:

  • MediatR

  • MediatR.Extensions.Microsoft.DependencyInjection

添加这些包后,我们可以在ConfigureServices方法中调用services.AddMediatR方法添加它。MediatR提供以下两种类型的消息:

  • 请求/响应:请求是可能返回值的命令

  • 通知:通知是可能不返回值的事件

在我们的示例中,我们将实现请求/响应来将供应商记录保存到数据库中,并且一旦返回布尔值 true 作为响应,我们将调用通知事件来创建供应商用户并发送电子邮件。

要实现请求/响应,我们应该定义一个实现IRequestHandlerIRequestHandlet<TRequest, TResponse>接口的类,其中TRequest是请求对象类型,TResponse是响应对象类型。

CreateVendorCommand:
public class CreateVendorCommand : IRequest<bool> 
{ 

  [DataMember] 
  public VendorViewModel VendorViewModel { get; set; } 

  public CreateVendorCommand(VendorViewModel vendorViewModel) 
  { 
    VendorViewModel = vendorViewModel; 
  } 

} 

它实现了返回布尔值作为响应的IRequest类。我们还指定了我们的VendorViewModel,在调用VendorController类中的send方法时,将由MediatR库注入。

CreateVendorCommandHandler:
public class CreateVendorCommandHandler : IRequestHandler<CreateVendorCommand, bool> 
{ 
  private readonly IVendorRepository _vendorRepository; 

  public CreateVendorCommandHandler(IVendorRepository vendorRepository) 
  { 
    _vendorRepository = vendorRepository; 
  } 

  public async Task<bool> Handle(CreateVendorCommand command, 
  CancellationToken cancellationToken) 
  { 

    _vendorRepository.UnitOfWork.BeginTransaction(); 
    try 
    { 
      _vendorRepository.Add(command.VendorMaster); 
      _vendorRepository.UnitOfWork.CommitTransaction(); 
    }catch(Exception ex) 
    { 
      _vendorRepository.UnitOfWork.RollbackTransaction(); 
    } 
    return await _vendorRepository.UnitOfWork.SaveChangesAsync();            } 
} 

当调用此处理程序时,它将调用Handle方法并传递命令和取消令牌。从命令对象中,我们可以获取在调用VendorController类中的IMediator对象的Send方法时传递的对象。该方法调用VendorRepositoryAdd方法并将信息保存到数据库中。使用请求/响应方法,即使为命令定义了多个处理程序,也只执行一个命令处理程序。要调用所有处理程序,我们可以使用通知。我们将扩展上述示例,并添加通知事件和相应的处理程序,一旦成功执行命令,将调用这些处理程序。

CreateVendorNotification event that will be used by the notification handlers:
public class CreateVendorNotification : INotification 
{  
  public VendorMaster _vendorVM; 
  public CreateVendorNotification(VendorMaster vendorVM) 
  { 
    _vendorVM = vendorVM; 
  }  
} 
CreateUserHandler:
public class CreateUserHandler : INotificationHandler<CreateVendorNotification> 
{ 
  IResilientHttpClient _client;  
  public CreateUserHandler(IResilientHttpClient client) 
  { 
    _client = client; 
  } 
  public Task Handle(CreateVendorNotification notification, CancellationToken cancellationToken) 
  { 
    string uri = "http://businessfrayms.com/api/Identity"; 
    string token = "";//read token from user session 
    var response = _client.Post<VendorMaster>(uri, notification._vendorVM,""); 
    return Task.FromResult(0);  
  } 
} 
SendEmailHandler:
public class SendEmailHandler : INotificationHandler<CreateVendorNotification> 
{ 

  MessagingService _service; 

  public SendEmailHandler(MessagingService service) : base() 
  { 
    _service = service; 
  } 

  public Task Handle(CreateVendorNotification notification, CancellationToken cancellationToken) 
  { 
    _service.SendEmail(notification._vendorVM.Email, "Registration", 
    "Thankyou for registration"); 
    return Task.FromResult(0); 
  } 
} 

根据要求,我们可以添加更多的通知处理程序。例如,如果我们想要在将供应商记录保存到数据库后启动工作流通知,我们可以创建供应商工作流通知处理程序,依此类推。

VendorController:
[Produces("application/json")] 
[Route("api/Vendor")] 
public class VendorController : BaseController 
{ 
  private readonly IMediator _mediator; 
  private ILogger _logger; 

  public VendorController(IMediator mediator, ILogger logger) : base(logger) 
  { 
    _mediator = mediator; 
    _logger = logger; 
  } 

  [Authorize(AuthenticationSchemes = OAuthIntrospectionDefaults.AuthenticationScheme)] 
  // POST: api/VendorMaster 
  [HttpPost] 
  public void Post([FromBody]VendorMaster value) 
  { 
    try 
    { 

      bool result = _mediator.Send(new CreateVendorCommand(value)).Result; 
      if (result) 
      { 
        //Record saved succesfully, publishing event now 
        _mediator.Publish(new CreateVendorNotification(value)); 
      } 
    } 
    catch (Exception ex) 
    { 
      _logger.LogError(ex.Message); 
    } 
  } 

}

在上述代码中,我们有一个Post方法,客户端应用程序将调用该方法来创建一个新的供应商。它首先调用Send方法,该方法调用CreateVendorCommandHandler并将记录保存在数据库中,一旦记录创建并且响应为 true,它将调用SendEmailHandler发送电子邮件。

您可以从提供的 GitHub 链接中访问完整的示例应用程序。

在 Docker 容器上部署微服务

微服务最适合容器化部署。容器是一个进程,为应用程序提供了一个隔离和受控的环境,使其能够在不影响系统或反之的情况下运行。我们大多数人都有在 VM 中托管应用程序的经验,VM 提供了一个隔离的空间来安装、配置和运行应用程序,并使用专用资源而不影响底层系统或应用程序。与 VM 相比,容器提供了相同级别的隔离,但在启动时间和开销方面更轻量。与 VM 不同,容器不会预分配内存、磁盘和 CPU 使用率等资源。我们可以在同一台机器上运行多个容器,其中容器彼此隔离,但共享内存、磁盘和 CPU 使用率。这使得在容器中运行的任何应用程序能够利用最大的可用资源,而不需要任何预分配或分配。

以下图表显示了虚拟机在主机操作系统上的运行方式:

我们在主机操作系统上运行应用程序,而在客用操作系统上运行虚拟机。虚拟化是在硬件级别进行的,其中虚拟机可以使用主机操作系统提供的 hypervisor 虚拟化系统中的驱动程序与主机硬件进行通信。

以下是容器在主机操作系统上的运行方式:

使用容器时,内核在多个容器之间共享。内核是操作系统的核心组件,负责与不同的进程和硬件进行交互,并管理 CPU 周期和虚拟管理等资源。内核是在不同容器之间创建隔离的组件。

Docker 是什么?

Docker 是一家提供容器的软件公司。Docker 容器在软件行业中非常流行,用于运行微服务。它们最适合于微服务应用程序开发,并提供一组命令行工具,提供了一种统一的方式来构建和维护不同的容器映像。我们可以创建自定义映像,或者使用来自 Docker Hub(hub.docker.com)等注册表中的现有映像。

以下是 Docker 的一些好处:

好处 描述
简单性 为应用程序创建和编排提供了强大的工具
开放性 使用开源技术构建,并易于集成到现有环境中
独立性 在应用程序和基础设施之间创建关注点分离

使用 Docker 与.NET Core

.NET Core 是模块化的,与.NET 框架相比更快,并有助于并行运行应用程序,其中每个应用程序都在运行其自己的 CLR 库和运行时。这使其非常适合在 Docker 容器上运行。与安装.NET 框架的映像相比,.NET Core 的映像要小得多。.NET Core 使用 Windows Nano 服务器或 Linux 映像,比 Windows 服务核心映像要小得多。由于.NET Core 是跨平台运行的,我们还可以创建其他平台的 Docker 映像,并在其上运行应用程序。

使用 Visual Studio 2017,我们可以在创建.NET Core 或 ASP.NET Core 项目时选择 Docker,并自动创建 Docker 文件并设置基本配置以在 Docker 上运行应用程序。以下截图显示了 Visual Studio 2017 中可用的 Docker 选项,用于配置 Docker 容器:

或者,如果项目已经创建,我们可以通过右键单击.NET Core 项目并单击“添加| Docker 支持”选项来添加 Docker 支持。

一旦我们在应用程序中创建或启用 Docker 支持,它会在我们的项目中创建 Docker 文件,并添加另一个名为docker-compose的项目,如下所示:

docker-compose 项目包含一组 YAML(.yml)文件,其中包含与容器中托管的应用程序相关的配置,以及在添加 Docker 支持时为项目创建的 Dockerfile 的路径引用。以下是包含两个服务详细信息的示例 docker-compose.yml 文件,例如镜像名称、dockerfile 路径等。此文件来自我们之前讨论的示例应用程序。

version: '1' 

services: 
  vendor.api: 
    image: vendor.api 
    build: 
      context: . 
      dockerfile: srcmicroservicesVendorVendor.APIDockerfile 

  identity.api: 
    image: identity.api 
    build: 
      context: . 
      dockerfile: srcmicroservicesAuthServerIdentity.AuthServerDockerfile 

以下是我们在上面示例应用程序中创建的 Vendor.API 项目内的 Dockerfile 的内容:

FROM microsoft/aspnetcore:2.0-nanoserver-1709 AS base 
WORKDIR /app 
EXPOSE 80 

FROM microsoft/aspnetcore-build:2.0-nanoserver-1709 AS build 
WORKDIR /src    
COPY *.sln ./ 
COPY src/microservices/Vendor/Vendor.API/Vendor.API.csproj src/microservices/Vendor/Vendor.API/ 
RUN dotnet restore 
COPY . . 
WORKDIR /src/src/microservices/Vendor/Vendor.API 
RUN dotnet build -c Release -o /app 

FROM build AS publish 
RUN dotnet publish -c Release -o /app 

FROM base AS final 
WORKDIR /app 
COPY --from=publish /app . 
ENTRYPOINT ["dotnet", "Vendor.API.dll"] 

前面的 Dockerfile 开始引用一个基础镜像 microsoft/aspnetcore:2.0-nanoserver-1709,该镜像将用于创建一个 Docker 容器。COPY 命令是项目文件所在的实际路径。然后将使用 dotnet CLI 命令,如 dotnet restore 在容器内还原所有 NuGet 包,dotnet build 构建应用程序,以及 dotnet publish 构建和发布编译输出到容器内的发布文件夹。

运行 Docker 镜像

我们可以从命令行或直接从 Visual Studio 运行 Docker 镜像。正如我们在前一节中看到的,添加 Docker 支持到我们的项目后会创建一个新的 docker-compose 项目。运行 docker-compose 项目会读取 docker-compose YAML 文件,并为定义的服务连接容器。Docker 在 Visual Studio 中是一等公民。它不仅支持运行 Docker 容器,还提供了完整的调试功能。

或者,从命令行,我们可以通过转到 docker-compose.yml 文件所在的根路径并运行以下命令来运行 Docker 容器:

docker-compose up 

一旦容器启动,每个应用程序在运行时都有自己的 IP 地址。要检查运行在单独容器上的每个服务的实际 IP,我们可以运行 docker inspect 命令来检索它。但是,docker inspect 命令需要容器 ID 作为参数。要获取正在运行的容器列表,我们可以首先调用 docker ps 命令如下:

docker ps 

前面的命令显示了容器列表,如下截图所示:

最后,我们可以使用容器 ID 并执行 docker inspect 命令来获取其 IP 地址,如下所示:

docker inspect -f "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" containerid  

前面的命令显示 IP 地址如下:

摘要

在本章中,我们学习了用于基于微服务开发高性能和可扩展云应用程序的微服务架构。我们学习了一些微服务的基础知识、它们的优势,以及在设计架构时使用的模式和实践。我们讨论了将企业应用程序分解为微服务架构风格的某些挑战,并学习了诸如 API 组合和 CQRS 等模式来解决这些挑战。在本章后期,我们在 .NET Core 中开发了一个基本应用程序,并讨论了微服务的解决方案结构和组件,并开发了身份和供应商服务。

在下一章中,我们将讨论在 .NET Core 应用程序中实现安全性和弹性。

第九章:使用工具监控应用程序性能

监控应用程序性能是大型组织中的一般流程,以持续监控和改进其客户的应用程序体验。这是一个围绕不同工具和技术来测量应用程序性能并快速做出决策的重要因素。

在本章中,我们将学习一些建议用于监控.NET Core 应用程序的关键指标,以及探索 App Metrics 以获取有关关键指标的实时分析和遥测信息。

在本章中,我们将研究以下主题:

  • 监控应用程序性能的关键指标

  • 用于测量应用程序性能的工具和技术,其中包括:

    • 探索 App Metrics
    • 设置用于 ASP.NET Core 应用程序的 App Metrics
    • 设置 Grafana 并使用 App Metrics 仪表板
    • 设置 InfluxDB 数据库并将其与 ASP.NET Core 应用程序集成
    • 通过 Grafana 网站监控性能

要了解有关 App Metrics 的更多信息或为开源项目做出贡献,您可以从以下链接访问 GitHub 存储库,并查看完整的文档和一些示例:github.com/AppMetrics/AppMetrics

应用程序性能关键指标

以下是一些用于考虑基于 Web 的应用程序的关键指标。

平均响应时间

在每个 Web 应用程序中,响应时间是在监控应用程序性能时要考虑的关键指标。响应时间是服务器处理请求所需的总时间。这是一个在服务器接收请求时计算的时间,服务器在处理请求并返回响应时所花费的时间。它可能受到网络延迟、活跃用户、活跃请求的数量以及服务器上的 CPU 和内存使用率的影响。平均响应时间是服务器在特定时间内处理的所有请求的总平均时间。

Apdex 分数

Apdex 是一个可以根据应用程序的性能进行分类的用户满意度分数。Apdex 分数可以被分类为令人满意的、可容忍的或令人沮丧的。

错误百分比

这是在特定时间内报告的错误总百分比。用户可以概览用户遇到的错误总百分比,并立即纠正它们。

请求速率

请求速率是用于扩展应用程序的有价值的指标。如果请求速率很高,而应用程序的性能不佳,则可以扩展应用程序以支持该数量的请求。另一方面,如果请求速率非常低,这意味着存在问题,或者活跃用户的数量正在减少,他们不再使用该应用程序。在这两种情况下,可以迅速做出决定,以提供一致的用户体验。

吞吐量/端点

吞吐量是应用程序在一定时间内可以处理的请求数。通常,在商业应用程序中,请求的数量非常高,吞吐量允许您基准应用程序可以处理的响应数量,而不会影响性能。

CPU 和内存使用率

CPU 和内存使用率是另一个重要的指标,用于分析 CPU 或内存使用率高的高峰时段,以便您可以调查根本原因。

测量性能的工具和技术

市场上有各种工具可用于测量和监视应用程序性能。在本节中,我们将专注于 App Metrics 并分析 HTTP 流量、错误和网络性能。

介绍 App Metrics

App Metrics 是一个开源工具,可以与 ASP.NET Core 应用程序插件。它提供有关应用程序性能的实时见解,并提供应用程序健康状态的完整概述。它以 JSON 格式提供指标,并与 Grafana 仪表板集成以进行可视化报告。App Metrics 基于.NET Standard 并可跨平台运行。它提供各种扩展和报告仪表板,可在 Windows 和 Linux 操作系统上运行。

使用 ASP.NET Core 设置应用程序指标

我们可以通过以下三个简单步骤在 ASP.NET Core 应用程序中设置 App Metrics,具体如下:

  1. 安装 App Metrics。

App Metrics 可以作为 NuGet 包安装。以下是可以通过 NuGet 添加到.NET Core 项目中的两个包:

 Install-Package App.Metrics 
      Install-Pacakge App.Metrics.AspnetCore.Mvc 
  1. Program.cs中添加 App Metrics。

BuildWebHost方法中的Program.cs中添加UseMetrics,如下所示:

      public static IWebHost BuildWebHost(string[] args) => 
        WebHost.CreateDefaultBuilder(args) 
          .UseMetrics() 
          .UseStartup<Startup>() 
          .Build(); 
  1. Startup.cs中添加 App Metrics。

最后,在Startup类的ConfigureServices方法中添加一个指标资源过滤器,如下所示:

      public void ConfigureServices(IServiceCollection services) 
      { 
        services.AddMvc(options => options.AddMetricsResourceFilter()); 
      } 
  1. 运行您的应用程序。

构建并运行应用程序。我们可以通过使用以下表中显示的 URL 来测试 App Metrics 是否正常运行。只需将 URL 附加到应用程序的根 URL:

URL 描述
/metrics 使用配置的指标格式显示指标
/metrics-text 使用配置的文本格式显示指标
/env 显示环境信息,包括操作系统、计算机名称、程序集名称和版本

/metrics/metrics-text附加到应用程序的根 URL,可以提供有关应用程序指标的完整信息。/metrics返回可以解析并在视图中表示的 JSON 响应,需要进行一些自定义解析。

跟踪中间件

使用 App Metrics,我们可以手动定义记录遥测信息所必需的典型 Web 指标。但是,对于 ASP.NET Core,有一个跟踪中间件可以在项目中使用和配置,其中包含一些特定于 Web 应用程序的内置关键指标。

跟踪中间件记录的指标如下:

  • Apdex:这用于根据应用程序的整体性能监控用户的满意度。Apdex 是一种开放的行业标准,根据应用程序的响应时间来衡量用户的满意度。

我们可以为每个请求周期设置时间阈值T,并根据以下条件计算指标:

用户满意度 描述
令人满意 如果响应时间小于或等于阈值时间(T)
容忍 如果响应时间在阈值时间(T)和阈值时间(T)的4倍之间
令人沮丧 如果响应时间大于阈值时间(T)的4
  • 响应时间:这提供了应用程序处理的请求的总体吞吐量以及应用程序内每个路由所需的持续时间。

  • 活动请求:这提供了在特定时间内在服务器上收到的活动请求列表。

  • 错误:这提供了错误的聚合结果百分比,包括总体错误请求率、每种未捕获异常类型的总体计数、每个 HTTP 状态代码的错误请求总数等。

  • POST 和 PUT 大小:这提供了 HTTP POST 和 PUT 请求的请求大小。

添加跟踪中间件

我们可以按照以下方式将跟踪中间件作为 NuGet 包添加:

Install-Package App.Metrics.AspNetCore.Tracking

跟踪中间件提供了一组中间件,用于记录特定指标的遥测。我们可以在Configure方法中添加以下中间件来测量性能指标:

app.UseMetricsApdexTrackingMiddleware(); 
app.UseMetricsRequestTrackingMiddleware(); 
app.UseMetricsErrorTrackingMiddleware(); 
app.UseMetricsActiveRequestMiddleware(); 
app.UseMetricsPostAndPutSizeTrackingMiddleware(); 
app.UseMetricsOAuth2TrackingMiddleware();

或者,我们也可以使用元包中间件,它会添加所有可用的跟踪中间件,以便我们可以获取有关前面代码中所有不同指标的信息:

app.UseMetricsAllMiddleware(); 

接下来,我们将在我们的ConfigureServices方法中添加跟踪中间件如下:

services.AddMetricsTrackingMiddleware(); 

在主Program.cs类中,我们将修改BuildWebHost方法并添加UseMetricsWebTracking方法如下:

public static IWebHost BuildWebHost(string[] args) => 
  WebHost.CreateDefaultBuilder(args) 
    .UseMetrics() 
    .UseMetricsWebTracking() 
    .UseStartup<Startup>() 
    .Build();

设置配置

一旦中间件添加完成,我们需要设置默认阈值和其他配置值,以便相应地生成报告。Web 跟踪属性可以在appsettings.json文件中进行配置。以下是包含MetricWebTrackingOptions JSON 键的appsettings.json文件的内容:

"MetricsWebTrackingOptions": { 
  "ApdexTrackingEnabled": true, 
  "ApdexTSeconds": 0.1, 
  "IgnoredHttpStatusCodes": [ 404 ], 
  "IgnoredRoutesRegexPatterns": [], 
  "OAuth2TrackingEnabled": true 
    }, 

ApdexTrackingEnabled设置为 true,以便生成客户满意度报告,ApdexTSeconds是决定请求响应时间是否令人满意、容忍或令人沮丧的阈值。IgnoredHttpStatusCodes包含了如果响应返回404状态则将被忽略的状态码列表。IgnoredRoutesRegexPatterns用于忽略与正则表达式匹配的特定 URI,OAuth2TrackingEnabled可以设置为监视和记录每个客户端的指标,并提供特定于请求速率、错误率以及每个客户端的 POST 和 PUT 大小的信息。

运行应用程序并进行一些导航。在应用程序 URL 中添加/metrics-text将以文本格式显示完整报告。以下是文本指标的示例快照:

添加可视化报告

有各种扩展和报告插件可用,提供可视化报告仪表板。其中一些是GrafanaCloud Hosted MetricsInfluxDBPrometheusElasticSearchGraphiteHTTPConsoleText File。在本章中,我们将配置InfluxDB扩展,并看看如何实现可视化报告。

设置 InfluxDB

InfluxDB 是由 Influx Data 开发的开源时序数据库。它是用Go语言编写的,被广泛用于存储实时分析的时间序列数据。Grafana 是提供报告仪表板的服务器,可以通过浏览器查看。InfluxDB 可以轻松地作为 Grafana 中的扩展导入,以从 InfluxDB 数据库显示可视化报告。

设置 Windows 子系统以运行 Linux

在本节中,我们将在 Linux 操作系统的 Windows 子系统上设置 InfluxDB。

  1. 首先,我们需要通过在 PowerShell 中以管理员身份执行以下命令来启用 Linux 的 Windows 子系统:
 Enable-WindowsOptionalFeature -Online -FeatureName 
      Microsoft-Windows-Subsystem-Linux

在运行上述命令之后,重新启动您的计算机。

  1. 接下来,我们将从 Microsoft 商店安装 Linux 发行版。在我们的情况下,我们将从 Microsoft 商店安装 Ubuntu。前往 Microsoft 商店,搜索 Ubuntu,并安装它。

  2. 安装完成后,点击启动:

  1. 这将打开控制台窗口,要求您为 Linux 操作系统(操作系统)创建用户帐户。

  2. 指定将要使用的用户名和密码。

  3. 运行以下命令以从 bash shell 更新 Ubuntu 到最新的稳定版本。要运行 bash,打开命令提示符,输入bash,然后按Enter

  1. 最后,它将要求您创建一个 Ubuntu 用户名和密码。指定用户名和密码,然后按 Enter。

安装 InfluxDB

在这里,我们将通过一些步骤在 Ubuntu 中安装 InfluxDB 数据库:

  1. 要设置 InfluxDB,请以管理员模式打开命令提示符并运行 bash shell。

  2. 在本地 PC 上执行以下命令以设置 InfluxDB 数据存储:

 $ curl -sL https://repos.influxdata.com/influxdb.key | sudo apt-key add - 
      $ source /etc/lsb-release 
      $ echo "deb https://repos.influxdata.com/${DISTRIB_ID,,} 
      $ {DISTRIB_CODENAME} stable" | sudo tee /etc/apt/sources.list.d/influxdb.list 
  1. 通过执行以下命令来安装 InfluxDB:
 $ sudo apt-get update && sudo apt-get install influxdb 
  1. 执行以下命令来运行 InfluxDB:
 $ sudo influxd
  1. 通过运行以下命令启动 InfluxDB shell:
 $ sudo influx 

它将打开一个可以执行特定于数据库的命令的 shell。

  1. 通过执行以下命令创建数据库。为数据库指定一个有意义的名称。在我们的情况下,它是appmetricsdb
      > create database appmetricsdb  

安装 Grafana

Grafana 是一个用于在 Web 界面中显示仪表板的开源工具。可以从 Grafana 网站导入各种可用的仪表板,以显示实时分析。Grafana 可以从docs.grafana.org/installation/windows/下载为 zip 文件。下载后,我们可以通过单击bin目录中的grafana-server.exe可执行文件来启动 Grafana 服务器。

Grafana 提供了一个网站,监听端口为3000。如果 Grafana 服务器正在运行,我们可以通过导航到http://localhost:3000来访问该网站。

添加 InfluxDB 仪表板

Grafana 提供了一个现成的 InfluxDB 仪表板,可以从以下链接导入:grafana.com/dashboards/2125

复制仪表板 ID 并使用它将其导入 Grafana 网站。

我们可以通过转到 Grafana 网站上的管理选项来导入 InfluxDB 仪表板,如下所示:

从管理选项中,单击+ 仪表板按钮,然后单击新仪表板选项。单击导入仪表板将导致 Grafana 要求您输入仪表板 ID:

将之前复制的仪表板 ID(例如2125)粘贴到框中,然后按Tab。系统将显示仪表板的详细信息,单击导入按钮将其导入系统:

配置 InfluxDB

我们现在将配置 InfluxDB 仪表板,并添加一个连接到我们刚刚创建的数据库的数据源。

为了继续,我们将转到 Grafana 网站上的数据源部分,并单击添加新数据源选项。以下是为 InfluxDB 数据库添加数据源的配置:

修改 Startup 中的 Configure 和 ConfigureServices 方法

到目前为止,我们已经在我们的机器上设置了 Ubuntu 和 InfluxDB 数据库。我们还设置了 InfluxDB 数据源,并通过 Grafana 网站添加了一个仪表板。接下来,我们将配置我们的 ASP.NET Core Web 应用程序,以将实时信息推送到 InfluxDB 数据库。

这是修改后的ConfigureServices方法,它初始化MetricsBuilder以定义与应用程序名称、环境和连接详细信息相关的属性:

public void ConfigureServices(IServiceCollection services)
{
  var metrics = new MetricsBuilder()
  .Configuration.Configure(
  options =>
  {
    options.WithGlobalTags((globalTags, info) =>
    {
      globalTags.Add("app", info.EntryAssemblyName);
      globalTags.Add("env", "stage");
    });
  })
  .Report.ToInfluxDb(
  options =>
  {
    options.InfluxDb.BaseUri = new Uri("http://127.0.0.1:8086");
    options.InfluxDb.Database = "appmetricsdb";
    options.HttpPolicy.Timeout = TimeSpan.FromSeconds(10);
  })
  .Build();
  services.AddMetrics(metrics);
  services.AddMetricsReportScheduler();
  services.AddMetricsTrackingMiddleware();         
  services.AddMvc(options => options.AddMetricsResourceFilter());
}

在上述代码中,我们将应用程序名称app设置为程序集名称,将环境env设置为stagehttp://127.0.0.1:8086是 InfluxDB 服务器的 URL,用于监听应用程序推送的遥测。appmetricsdb是我们在前一节中创建的数据库。然后,我们添加了AddMetrics中间件,并指定了包含配置的指标。AddMetricsTrackingMiddleware用于跟踪显示在仪表板上的 Web 遥测信息,AddMetricsReportScheduled用于将遥测信息推送到数据库。

这是包含UseMetricsAllMiddleware以使用 App Metrics 的Configure方法。UseMetricsAllMiddleware添加了 App Metrics 中可用的所有中间件:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
  if (env.IsDevelopment())
  {
    app.UseBrowserLink();
    app.UseDeveloperExceptionPage();
  }
  else
  {
    app.UseExceptionHandler("/Error");
  }
  app.UseStaticFiles();
  app.UseMetricsAllMiddleware();
  app.UseMvc();
}

我们可以根据需求显式地添加单个中间件,而不是调用UseAllMetricsMiddleware。以下是可以添加的中间件列表:

app.UseMetricsApdexTrackingMiddleware();
app.UseMetricsRequestTrackingMiddleware();
app.UseMetricsErrorTrackingMiddleware();
app.UseMetricsActiveRequestMiddleware();
app.UseMetricsPostAndPutSizeTrackingMiddleware();
app.UseMetricsOAuth2TrackingMiddleware();

测试 ASP.NET Core 应用程序并在 Grafana 仪表板上报告

为了测试 ASP.NET Core 应用程序并在 Grafana 仪表板上看到可视化报告,我们将按照以下步骤进行:

  1. 通过转到{installation_directory}\bin\grafana-server.exe来启动 Grafana 服务器。

  2. 从命令提示符启动 bash 并运行sudo influx命令。

  3. 从命令提示符启动另一个 bash 并运行sudo influx命令。

  4. 运行 ASP.NET Core 应用程序。

  5. 访问http://localhost:3000并单击 App Metrics 仪表板。

  6. 这将开始收集遥测信息,并显示性能指标,如下面的屏幕截图所示:

以下图表显示了每分钟请求RPM)的总吞吐量,错误百分比和活动请求:

这里是 Apdex 分数,将用户满意度分为三种不同的颜色,红色代表令人沮丧,橙色代表容忍,绿色代表满意。以下图表显示蓝线绘制在绿色条上,这意味着应用程序性能是令人满意的:

以下快照显示了所有请求的吞吐量图,每个请求都用不同的颜色标记:红色,橙色和绿色。在这种情况下,有两个 HTTP GET 请求,分别是关于和联系我们页面:

这是响应时间图,显示了两个请求的响应时间:

总结

在本章中,我们学习了一些对于监控应用程序性能至关重要的关键指标。我们探索并设置了 App Metrics,这是一个免费的跨平台工具,提供了许多可以添加以实现更多报告的扩展。我们逐步介绍了如何配置和设置 App Metrics 以及相关组件,如 InfluxDb 和 Grafana,以存储和查看 Grafana Web 工具中的遥测,并将其与 ASP.NET Core 应用程序集成。

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报