C--研讨会-全-

C# 研讨会(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于本书

C#是一种强大且多功能的面向对象编程(OOP)语言,可以开启多种职业道路。但是,与任何编程语言一样,学习 C#可能会具有挑战性。由于有各种各样的资源可用,很难知道从哪里开始。

正是《C#工作坊》在这里发挥作用。由行业专家编写和审阅,它提供了一种快速、支持性的学习体验,能迅速帮助你编写 C#代码并构建应用程序。与其他专注于枯燥、技术性解释底层理论的软件开发书籍不同,这个工作坊通过引人入胜的示例来帮助你学习每个概念如何在现实世界中应用。

在你阅读本书的过程中,你将解决模拟软件开发者每天都会遇到的真实练习。这些小型项目包括构建一个随机数猜测游戏、使用发布者-订阅者模型设计一个网络文件下载器、使用 Razor Pages 创建待办事项列表、使用 async/await 任务从斐波那契数列生成图像,以及开发一个温度单位转换应用程序,然后将其部署到生产服务器。

到这本书的结尾,你将拥有在 C#中提升职业生涯和应对雄心勃勃的项目所需的知识、技能和信心。

读者对象

这本书是为有志于成为 C#开发者的读者所写。建议你在开始之前对核心编程概念有基本的了解。虽然拥有其他编程语言的经验会有所帮助,但这并不是绝对必要的。

关于作者

贾森·黑尔斯自 2001 年 C#首次发布以来,一直在使用各种微软技术开发低延迟、实时应用程序。他是设计模式、OO 原则和测试驱动实践的积极倡导者。当他不在摆弄代码时,喜欢与他的妻子安以及他们的三个女儿在英国剑桥郡共度时光。

阿尔曼塔斯·卡帕维丘斯是信息和技术公司 TransUnion 的首席软件工程师。他作为一名专业程序员已经超过五年。除了全职编程工作外,阿尔曼塔斯在空闲时间免费教授编程,他在Twitch.tv上教授编程。他是名为 C# Inn 的 C#编程社区的创始人,该社区拥有超过 7000 名成员,并创建了两个免费的 C#训练营,帮助数百人开始他们的职业生涯。他采访过编程名人,如乔恩·斯基特、罗伯特·C·马丁(Uncle Bob)、马克·塞曼,并在一段时间内担任过兼职 Java 教师。阿尔曼塔斯喜欢谈论软件设计、清洁代码和架构。他还对敏捷(特别是 Scrum)感兴趣,是自动化测试的大粉丝,尤其是那些使用 BDD 进行的测试。他还获得了两年的微软 MVP(packt.link/2qUJp)。

马特乌斯·维加斯在软件工程和架构领域工作了十多年,在过去的几年里致力于领导和管理工作。他对技术的兴趣主要集中在 C#、分布式系统和产品开发。作为一名户外爱好者,当他不工作时,喜欢花时间与家人一起探索自然、拍照或跑步。

关于章节

第一章你好,C#,介绍了语言的基本概念,如变量、常量、循环和算术逻辑运算符。

第二章构建高质量面向对象代码,涵盖了面向对象编程的基础及其四个支柱,然后介绍了清洁编码的五个主要原则——SOLID。本章还涵盖了 C#语言的最新特性。

第三章委托、事件和 Lambda 表达式,介绍了委托和事件,它们是对象之间通信的核心机制,以及 Lambda 语法,它提供了一种清晰表达代码意图的方法。

第四章数据结构和 LINQ,涵盖了用于存储多个值的常见集合类,以及专为在内存中查询集合而设计的集成语言 LINQ。

第五章并发:多线程并行和异步代码,介绍了编写在不同场景下性能高效的代码的方法,以及如何避免常见的陷阱和错误。

第六章使用 SQL Server 的 Entity Framework,介绍了使用 SQL 和 C#进行数据库设计和存储,并深入探讨了使用 Entity Framework 进行对象关系映射。本章还教授了与数据库一起工作的常见设计模式。

注意

对于那些对学习数据库基础知识以及如何使用 PostgreSQL 工作感兴趣的人,本书的 GitHub 仓库中包含了一章参考内容。您可以通过packt.link/oLQsL访问它。

第七章使用 ASP.NET 创建现代 Web 应用程序,探讨了如何编写简单的 ASP.NET 应用程序,以及如何使用诸如服务器端渲染和单页应用程序等技术来创建 Web 应用程序。

第八章创建和使用 Web API 客户端,介绍了 API,并教您如何从 ASP.NET 代码中访问和消费 Web API。

第九章创建 API 服务,继续讨论 API 主题,教您如何创建 API 服务以供使用,以及如何对其进行安全保护。本章还介绍了微服务概念。

注意

此外,还有两个附加章节(第十章自动化测试,和第十一章生产就绪的 C#:从开发到部署),您可以在packt.link/44j2Xpackt.link/39qQA分别找到。

您还可以在packt.link/qclbF在线找到本研讨会中所有活动的解决方案。

本书有一些约定来有效地安排内容。在下一节中了解它们。

习惯用法

代码块

在书中,代码块设置如下:

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

在输入和执行某些代码给出即时输出的情况下,这如下所示:

dotnet run
Hello World!
Good morning Mars!

着重

定义、新术语和重要词汇如下所示:

多线程是一种并发形式,其中使用多个线程来执行操作。

技术术语

章节正文中的语言命令以以下方式指示:

在这里,最简单的Task构造函数传递了一个Action lambda 表达式,这是您想要实际执行的代码目标。目标代码将消息Inside taskA写入控制台。

添加信息

以下方式指示了以下方式指示的重要信息:

注意

在软件开发中,术语Factory通常用来表示帮助创建对象的那些方法。

截断

长代码片段被截断,GitHub 上相应代码文件的名称放置在截断代码的顶部。整个代码的永久链接放置在代码片段下方,如下所示:

HashSetExamples.cs
using System;
using System.Collections.Generic;
namespace Chapter04.Examples
{
}
You can find the complete code here: http://packt.link/ZdNbS.

在深入探索 C#语言的强大功能之前,您需要安装.NET 运行时和 C#的开发与调试工具。

在开始之前

您可以选择安装完整的 Visual Studio 集成开发环境(IDE),它提供了一个功能齐全的代码编辑器(这是一个昂贵的许可证)或者您可以选择安装 Visual Studio Code(VS Code),这是微软的轻量级跨平台编辑器。C#研讨会针对 VS Code 编辑器,因为这不需要许可证费用并且可以在多个平台上无缝工作。

安装 VS Code

访问 VS Code 网站code.visualstudio.com,根据您首选平台的安装说明下载它。

注意

为了方便使用,最好勾选“创建桌面图标”复选框。

VS Code 是免费和开源的。它支持多种语言,并需要配置 C# 语言。一旦安装了 VS Code,您需要添加 C# for Visual Studio Code(由 OmniSharp 驱动)扩展以支持 C#。这可以在 https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp 找到。要安装 C# 扩展,请按照每个平台的说明进行操作:

  1. 打开 扩展 标签,并输入 C#

    注意

    如果您不想直接从网站安装 C# 扩展,可以从 VS Code 本身安装。

  2. 选择第一个选项,即 C# for Visual Studio Code (由 OmniSharp 驱动)

  3. 点击 安装 按钮。

  4. 重启 VS Code

图 0.1:安装 VS Code 的 C# 扩展

图 0.1:安装 VS Code 的 C# 扩展

您将看到 C# 扩展在 VS Code 中成功安装。现在您已将 VS Code 安装到您的系统上。

下一节将介绍如何在书的不同章节之间使用 VS Code。

在 VS Code 中切换章节

要更改默认项目以构建(无论是活动、练习还是演示),您需要指向这些练习文件:

  • tasks.json / tasks.args

  • launch.json / configurations.program

您应该了解两种不同的练习模式。一些练习有自己的项目。其他的有不同的主方法。每个练习的单个项目配置可以像这样(在这个例子中,对于 第三章委托、事件和 Lambda 表达式,您正在配置 Exercise02 作为构建和启动点):

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/Exercises/ /Exercise02/bin/Debug/net6.0/Exercise02.exe",
            "args": [],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            "console": "internalConsole"
        }

    ]
}

tasks.json

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/Chapter05.csproj",
                "/property:GenerateFullPaths=true",
                "/consoleloggerparameters:NoSummary"
            ],
            "problemMatcher": "$msCompile"
        },

    ]
}

每个练习(例如,Chapter05 Exercise02)可以配置如下:

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/bin/Debug/net6.0/Chapter05.exe",
            "args": [],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            "console": "internalConsole"
        }

    ]
}

tasks.json

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
              "build",
              "${workspaceFolder}/Chapter05.csproj",
              "/property:GenerateFullPaths=true",
              "/consoleloggerparameters:NoSummary",
              "-p:StartupObject=Chapter05.Exercises.Exercise02.Program",
            ],
            "problemMatcher": "$msCompile"
        },

    ]
}

现在,您已经了解了 launch.jsontasks.json,可以继续下一节,该节详细介绍了 .NET 开发者平台的安装。

安装 .NET 开发者平台

.NET 开发者平台可以从 dotnet.microsoft.com/download 下载。有适用于 Windows、macOS 和 Linux Docker 的变体。《C# 实验室》 书籍使用 .NET 6.0。

按照步骤在 Windows 上安装 .NET 6.0 平台:

  1. 选择 Windows 平台标签:

图 0.2:.NET 6.0 下载窗口

图 0.2:.NET 6.0 下载窗口

  1. 点击 下载 .NET SDK x64 选项。

    注意

    图 0.2 中显示的屏幕可能会根据微软的最新发布版本而变化。

  2. 根据您系统上安装的相应操作系统,打开并完成安装。

  3. 安装完成后,重启计算机。

按照步骤在 macOS 上安装 .NET 6.0 平台:

  1. 选择 macOS 平台标签 (图 0.2)。

  2. 点击 下载 .NET SDK x64 选项。

下载完成后,打开安装文件。您应该有一个类似于 图 0.3 的屏幕:

图 0.3:macOS 安装开始屏幕

图 0.3:macOS 安装开始屏幕

  1. 点击 继续 按钮。

以下屏幕将确认安装所需的磁盘空间量:

  1. 点击 安装 按钮继续:

图 0.4:显示安装所需磁盘空间的窗口

图 0.4:显示安装所需磁盘空间的窗口

您将在下一屏幕上看到一个进度条在移动:

图 0.5:显示安装进度的窗口

图 0.5:显示安装进度的窗口

安装完成后不久,您将看到一个成功屏幕(图 0.6):

图 0.6:显示安装完成的窗口

图 0.6:显示安装完成的窗口

  1. 为了检查安装是否成功,打开您的终端应用程序并输入:

     dotnet –list-sdks 
    

这将检查您机器上安装的 .NET 版本。图 0.7 显示了输出,其中将列出您的已安装 SDK:

图 0.7:在终端中检查已安装的 .NET SDK

图 0.7:在终端中检查已安装的 .NET SDK

通过这些步骤,您可以在您的机器上安装 .NET 6.0 SDK 并检查已安装的版本。

注意

Linux 的 .NET 6.0 安装步骤未包括,因为它们与 Windows 和 macOS 类似。

在继续之前,了解 .NET 6.0 功能是很重要的。

Windows、macOS 和 Linux 中发现的 .NET 6.0 功能

Windows

  • .NET 6.0:这是推荐用于 Windows 的最新长期支持 (LTS) 版本。它可以用于构建许多不同类型的应用程序。

  • .NET Framework 4.8:这是仅适用于 Windows 的版本,用于构建仅在 Windows 上运行的应用程序。

macOS

  • .NET 6.0:这是推荐用于 macOS 的 LTS 版本。它可以用于构建许多不同类型的应用程序。请选择与您的苹果电脑处理器兼容的版本——x64 用于英特尔芯片和 ARM64 用于苹果芯片。

Linux

  • .NET 6.0:这是推荐用于 Linux 的 LTS 版本。它可以用于构建许多不同类型的应用程序。

Docker

  • .NET 映像:这个开发者平台可以用于构建不同类型的应用程序。

  • .NET Core 映像:这为构建许多类型的应用程序提供终身支持。

  • .NET 框架映像:这些是仅适用于 Windows 的 .NET 版本,用于构建在 Windows 上运行的任何类型的应用程序。

在您的系统上安装 .NET 6.0 后,下一步是使用 CLI 配置项目。

.NET 命令行界面 (CLI)

安装 .NET 后,可以使用 CLI 创建和配置与 VS Code 一起使用的项目。要在命令提示符下启动 .NET CLI,请运行以下命令:

dotnet

如果 .NET 安装正确,您将在屏幕上看到以下消息:

Usage: dotnet [options]
Usage: dotnet [path-to-application]

一旦您安装了 CLI 以使用 VS Code 配置项目,您就需要了解一个强大的开源对象关系型数据库系统,它使用并扩展了 SQL 语言,即 PostgreSQL。

注意

您将首先通过安装 PostgreSQL 的说明进行 Windows 安装,然后是 macOS,最后是 Linux。

Windows 版 PostgreSQL 安装

PostgreSQL 已在第六章SQL Server 中的 Entity Framework中使用。在继续该章节之前,您必须按照以下步骤在您的系统上安装 PostgreSQL:

  1. 前往 www.enterprisedb.com/downloads/postgres-postgresql-downloads 并下载适用于 Windows 的最新版本安装程序:

图 0.8:每个平台的最新 PostgreSQL 版本

图 0.8:每个平台的最新 PostgreSQL 版本

注意

图 0.8所示的屏幕可能会根据供应商的最新发布而更改。

  1. 打开下载的交互式安装程序并点击“下一步”按钮。将显示“设置 PostgreSQL”屏幕:

图 0.9:PostgreSQL 上传的欢迎屏幕

图 0.9:PostgreSQL 上传的欢迎屏幕

  1. 点击“下一步”按钮进入下一屏幕,该屏幕要求输入安装目录的详细信息:

图 0.10:PostgreSQL 默认安装目录

图 0.10:PostgreSQL 默认安装目录

  1. 保持默认的“安装目录”不变,并点击“下一步”按钮。

  2. 图 0.11中的列表中选择以下内容:

    • PostgreSQL 服务器指的是数据库。

    • pgAdmin 4 是数据库管理工具。

    • Stack Builder 是 PostgreSQL 环境构建器(可选)。

    • 命令行工具通过命令行与数据库交互。

图 0.11:选择继续的 PostgreSQL 组件

图 0.11:选择继续的 PostgreSQL 组件

  1. 然后点击“下一步”按钮。

  2. 在下一屏幕,数据目录屏幕要求您输入存储数据的目录。因此,输入数据目录名称:

图 0.12:存储数据的目录

图 0.12:存储数据的目录

  1. 一旦您输入了数据目录,点击“下一步”按钮继续。下一屏幕要求您输入密码。

  2. 输入新的“密码。”

  3. 在“重新输入密码”旁边重新输入数据库超级用户的密码:

图 0.13:为数据库超级用户提供密码

图 0.13:为数据库超级用户提供密码

  1. 然后点击“下一步”按钮继续。

  2. 下一屏幕显示端口号为5432。使用默认端口,即5432

图 0.14:选择端口

图 0.14:选择端口

  1. 点击“下一步”按钮。

  2. 高级选项屏幕要求您输入数据库集群的区域。将其保留为 [默认区域]

图 0.15:选择数据库集群的区域设置

图 0.15:选择数据库集群的区域设置

  1. 然后点击 下一步 按钮。

  2. 当显示预安装摘要屏幕时,点击 下一步 按钮继续:

图 0.16:设置窗口显示准备安装的消息

图 0.16:设置窗口显示准备安装的消息

  1. 继续选择 下一步 按钮(保持默认设置不变),直到安装过程开始。

  2. 等待其完成。完成之后,将显示完成 PostgreSQL 设置向导的屏幕。

  3. 取消勾选 退出时启动 Stack Builder 选项:

图 0.17:安装完成,Stack Builder 未勾选

图 0.17:安装完成,Stack Builder 未勾选

Stack Builder 用于下载和安装额外的工具。默认安装包含所有用于练习和活动的工具。

  1. 最后,点击 完成 按钮。

  2. 现在从 Windows 打开 pgAdmin4

  3. 在设置主密码窗口中,为连接 PostgreSQL 中的任何数据库输入一个主 密码

图 0.18:设置连接到 PostgreSQL 服务器的主密码

图 0.18:设置连接到 PostgreSQL 服务器的主密码

注意

输入一个您容易记住的密码会更好,因为它将用于管理您所有的其他凭证。

  1. 接下来点击 OK 按钮。

  2. 在 pgadmin 窗口的左侧,通过点击其旁边的箭头展开 服务器

  3. 您将被要求输入您的 PostgreSQL 服务器密码。输入与您在 步骤 22 中输入的相同密码。

  4. 由于安全原因,请不要点击 保存密码

图 0.19:设置 PostgreSQL 服务器 postgres 用户密码

图 0.19:设置 PostgreSQL 服务器 postgres 用户密码

PostgreSQL 服务器密码是您连接到 PostgreSQL 服务器并使用 postgres 用户时使用的密码。

  1. 最后点击 OK 按钮。您将看到 pgAdmin 仪表板:

图 0.20:pgAdmin 4 仪表板窗口

图 0.20:pgAdmin 4 仪表板窗口

为了探索 pgAdmin 仪表板,请转到 探索 pgAdmin 仪表板 部分。

macOS 的 PostgreSQL 安装

使用以下步骤在您的 macOS 上安装 PostgreSQL:

  1. 访问 Postgres 应用程序的官方网站以在 mac 平台上下载和安装 PostgreSQL:www.enterprisedb.com/downloads/postgres-postgresql-downloads

  2. 下载适用于 macOS 的最新 PostgreSQL:

    注意

    以下截图是在 macOS Monterey(版本 12.2)的 14.4 版本上拍摄的。

图 0.21:PostgreSQL 安装页面

图 0.21:PostgreSQL 安装页面

  1. 下载 macOS 的安装文件后,双击安装文件以启动 PostgreSQL 设置向导:

图 0.22:启动 PostgreSQL 设置向导

图 0.22:启动 PostgreSQL 设置向导

  1. 选择您想要安装 PostgreSQL 的位置:

图 0.23:选择安装目录

图 0.23:选择安装目录

  1. 点击下一步按钮。

  2. 在下一屏幕中,选择以下组件进行安装:

    • PostgreSQL 服务器

    • pgAdmin 4

    • 命令行工具

  3. 取消选择Stack Builder组件:

图 0.24:选择要安装的组件

图 0.24:选择要安装的组件

  1. 选择好选项后,点击下一步按钮。

  2. 指定 PostgreSQL 将存储数据的数据目录:

图 0.25:指定数据目录

图 0.25:指定数据目录

  1. 点击下一步按钮。

  2. 现在为 Postgres 数据库超级用户设置一个密码

图 0.26:设置密码

图 0.26:设置密码

确保安全地记下密码,以便登录到 PostgreSQL 数据库。

  1. 点击下一步按钮。

设置您想要运行 PostgreSQL 服务器时的端口号。这里默认端口号设置为5432

图 0.27:指定端口号

图 0.27:指定端口号

  1. 点击下一步按钮。

  2. 选择 PostgreSQL 要使用的区域。在这里,[默认区域]是 macOS 选择的区域:

图 0.28:选择区域规范

图 0.28:选择区域规范

  1. 点击下一步按钮。

  2. 在下一屏幕中,检查安装详情:

图 0.29:预安装摘要页面

图 0.29:预安装摘要页面

最后,点击下一步按钮以开始您系统上 PostgreSQL 数据库服务器的安装过程:

图 0.30:开始安装过程前的准备安装页面

图 0.30:开始安装过程前的准备安装页面

  1. 等待片刻,直到安装过程完成:

图 0.31:设置安装进行中

图 0.31:设置安装进行中

  1. 当提示时,点击下一步按钮。下一屏幕显示消息,表明 PostgreSQL 安装已在您的系统上完成:

图 0.32:显示设置完成的成功消息

图 0.32:显示设置完成的成功消息

  1. 安装完成后,点击完成按钮。

  2. 现在,在 PostgreSQL 服务器中加载数据库。

  3. 双击 pgAdmin 4 图标从您的 Launchpad 启动它。

  4. 输入在安装过程中设置的 PostgreSQL 用户的密码。

  5. 然后点击 确定 按钮。您现在将看到 pgAdmin 仪表板。

这完成了 macOS 上 PostgreSQL 的安装。下一节将使您熟悉 PostgreSQL 界面。

探索 pgAdmin 仪表板

在 Windows 和 macOS 上安装 PostgreSQL 后,按照以下步骤更好地掌握界面:

  1. 从 Windows/ macOS(如果 pgAdmin 在您的系统上未打开)打开 pgAdmin4

  2. 点击左侧的 服务器 选项:

图 0.33:点击  创建数据库

图 0.33:点击 服务器 创建数据库

  1. 右键单击 PostgreSQL 14

  2. 然后点击 创建 选项。

  3. 选择 数据库… 选项以创建新数据库:

图 0.34:创建新数据库

图 0.34:创建新数据库

这将打开一个创建 – 数据库窗口。

  1. 输入数据库名称,即 TestDatabase

  2. 选择数据库的所有者或保留默认设置。目前,只需使用 所有者 作为 postgres

图 0.35:选择数据库的所有者

图 0.35:选择数据库的所有者

  1. 然后点击 保存 按钮。这将创建一个数据库。

  2. 右键单击 数据库 并选择 刷新 按钮:

图 0.36:在数据库上右键单击后点击  按钮

图 0.36:在数据库上右键单击后点击 刷新… 按钮

在仪表板中现在显示了一个名为 TestDatabase 的数据库:

图 0.37:TestDatabase 准备就绪

图 0.37:TestDatabase 准备就绪

现在您的数据库已准备好在 Windows 和 Mac 环境中使用。

Ubuntu 上的 PostgreSQL 安装

在此示例中,您正在使用 Ubuntu 20.04 进行安装。执行以下步骤:

  1. 为了安装 PostgreSQL,首先打开您的 Ubuntu 终端。

  2. 确保使用以下命令更新您的仓库:

    $ sudo apt update
    
  3. 使用以下命令安装 PostgreSQL 软件及其附加包(推荐):

    $ sudo apt install postgresql postgresql-contrib
    

    注意

    要仅安装 PostgreSQL(不建议不使用附加包),请使用命令 $ sudo apt install postgresql 然后按 Enter

此安装过程创建了一个名为 postgres 的用户账户,它具有默认的 Postgres 角色。

使用 postgres 角色访问 postgres 用户账户

有两种方式可以使用 postgres 用户账户启动 PostgreSQL CLI:

选项 1 如下所示:

  1. 要以 postgres 用户身份登录,请使用以下命令:

    $ sudo -i -u postgres
    
  2. 使用以下命令访问 CLI:

    $ psql
    

    注意

    有时,在执行前面的命令时,可能会显示psql错误,例如could not connect to server: No such file or directory。这是由于系统上的端口问题。由于此端口阻塞,PostgreSQL 应用程序可能无法工作。您可以稍后再尝试此命令。

  3. 要退出 CLI,使用以下命令:

    $ \q
    

选项 2 如下:

  1. 要以 postgres 用户登录,使用以下命令:

    $ sudo -u postgres psql
    
  2. 要退出 CLI,使用以下命令:

    $ \q
    

验证 postgres 用户账户作为 postgres 用户角色

  1. 要验证用户账户,登录并使用conninfo命令:

    $ sudo -u postgres psql
    $ \conninfo
    $ \q
    

使用此命令,您可以确保您已通过端口5432连接到postgres数据库作为postgres用户。如果您不想使用默认用户postgres,您可以为您创建一个新用户。

访问新用户和数据库

  1. 使用以下命令创建新用户,并按Enter键:

    $ sudo -u postgres createuser –interactive
    

前面的命令将提示用户添加角色的名称和类型。

  1. 输入角色的名称,例如,testUser

  2. 接下来,当提示设置新角色为超级用户时,输入y

    Prompt:
    Enter the name of the role to add: testUser
    Shall the new role be a superuser? (y/n) y
    

这将创建一个名为testUser的新用户。

  1. 使用以下命令创建一个名为testdb的新数据库:

    $ sudo -u postgres createdb testdb
    
  2. 使用以下命令登录到新创建的用户账户:

    $ sudo -u testUser psql -d testdb
    
  3. 使用以下命令来检查连接详情:

    $ \conninfo
    
  4. 要退出 CLI,使用以下命令:

    $ \q
    

使用此命令,您可以确保您已通过端口5432连接到testdb数据库作为testUser用户。

通过这些步骤,您已经完成了 Ubuntu 上的 PostgreSQL 安装。

下载代码

从 GitHub 下载代码,链接为packt.link/sezEm。请参考这些文件以获取完整的代码。

本书使用的优质彩色图像可以在packt.link/5XYmX找到。

如果您在安装过程中遇到任何问题或疑问,请通过电子邮件联系我们的workshops@packt.com

第一章:1. 欢迎来到 C#

概述

本章将向您介绍 C# 的基础知识。您将从学习 .NET 命令行界面 (CLI) 的基础知识以及如何使用 Visual Studio Code (VS Code) 作为基本集成开发环境 (IDE) 开始。然后,您将了解各种 C# 数据类型以及如何为这些类型声明变量,之后将进入关于算术和逻辑运算符的部分。到本章结束时,您将了解如何处理异常和错误,并能够用 C# 编写简单的程序。

简介

C# 是一种在 2000 年代初期由微软团队创建的编程语言,该团队由安德斯·海尔斯伯格领导,他也是一些其他流行语言的创造者之一,例如 Delphi 和 Turbo Pascal,这两种语言在 1990 年代被广泛使用。在过去 20 年中,C# 不断发展和演变,根据 Stack Overflow 的 2020 年洞察,如今它已成为全球最广泛使用的编程语言之一。

它在技术社区中占据如此崇高的地位有其原因。C# 允许你为广泛的市场和设备编写应用程序。从具有高安全标准的银行业务,到拥有巨大交易量的电子商务公司,它是一种被需要性能和可靠性的公司所信赖的语言。除此之外,C# 还使得编写 Web、桌面、移动甚至物联网应用程序成为可能,让你可以为几乎任何类型的设备进行开发。

C# 最初仅限于在 Windows 上运行;然而,在过去几年中,C# 团队已经做出了共同努力,使其实现跨平台兼容。如今,它可以与所有主要操作系统发行版一起使用,即 Windows、Linux 和 macOS。目标很简单:在任何地方开发、构建和运行 C#,让每个开发者和团队选择他们最 productive 或最喜欢的环境。

C# 的另一个显著特点是它是一种强类型编程语言。您将在接下来的部分中深入了解这一点,并看到强类型在编程时能够提供更好的数据安全性。

此外,C# 在过去几年中已成为开源语言,微软是其主要维护者。这非常有优势,因为它允许语言从全球范围内持续改进,并得到一个既推广又投资的稳固公司的支持。C# 也是一种多范式语言,这意味着你可以用它以多种编程风格编写软件,以美观、简洁和正确的方式。

使用 .NET CLI 运行和开发 C#

在 C# 世界中,你将经常听到一个术语 .NET。它是 C# 的基础,是一个语言构建在其之上的框架。它既有一个软件开发工具包 (SDK),允许开发语言,也有一个运行时,允许语言运行。

话虽如此,要开始使用 C# 进行开发,你只需要安装 .NET SDK。此安装将在开发环境中提供编译器和运行时。在本节中,你将了解为在本地开发和运行 C# 准备环境的基本步骤。

注意

请参阅本书的前言部分,了解如何逐步下载 .NET 6.0 SDK 并将其安装到你的机器上的详细说明。

一旦完成 .NET 6.0 SDK 的安装,你将拥有一个称为 .NET CLI 的东西。这个命令行界面(CLI)允许你使用非常简单的命令直接从你的终端创建新项目、编译它们并运行它们。

安装后,在你的首选终端中运行以下命令:

dotnet --list-sdks

你应该看到如下输出:

6.0.100 [/usr/local/share/dotnet/sdk]

此输出显示你在计算机上安装了 6.0.100 版本的 SDK。这意味着你已准备好开始开发你的应用程序。如果你输入 dotnet -–help,你会注意到 CLI 中将出现几个命令供你选择运行。在本节中,你将了解你需要创建和运行应用程序的最基本命令:newbuildrun

dotnet new 命令允许你创建一个引导项目以开始开发。CLI 有几个内置模板,它们只是为各种类型的应用程序(如 Web 应用程序、桌面应用程序等)提供的基本引导。在 dotnet new 命令中,你必须指定两件事:

  • 模板名称

  • 项目名称

名称作为参数传递,这意味着你应该使用 -n–name 标志来指定它。命令如下:

dotnet new TYPE -n NAME

例如,要创建一个名为 MyConsoleApp 的新控制台应用程序,你可以简单地输入:

dotnet new console -n MyConsoleApp

这将生成一个新文件夹,其中包含一个名为 MyConsoleApp.csproj 的文件,这是编译器构建你的项目所需的全部元数据文件,以及构建和运行应用程序所需的某些文件。

接下来,dotnet build 命令允许你构建一个应用程序并使其准备好运行。此命令应仅放置在两个位置:

  • 一个包含 .csproj 文件的项目文件夹。

  • 包含 .sln 文件的文件夹。

解决方案(.sln)文件是包含一个或多个项目文件元数据的文件。它们用于将多个项目文件组织成单个构建。

最后,第三个重要的命令是 dotnet run。此命令允许你正确地运行应用程序。你可以在包含你的 .NET 应用 .csproj 文件的文件夹中不带任何参数调用此命令,或者不带 -–project 标志通过 CLI 传递项目文件夹。run 命令还会在运行之前自动构建应用程序。

使用 CLI 和 VS Code 创建程序

在阅读本书的过程中,您将使用 Visual Studio Code (VS Code) 作为您的代码编辑器。它适用于所有平台,您可以在 https://code.visualstudio.com/ 下载适用于您操作系统的版本。尽管 VS Code 不是一个完整的集成开发环境 (IDE),但它拥有许多扩展,使其成为开发 C# 的强大工具,无论使用的是哪种操作系统。

为了正确开发 C# 代码,你主要需要安装 Microsoft C# 扩展。它为 VS Code 提供了代码补全和识别错误的能力,可在 marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp 获取。

注意

在继续之前,建议您安装 VS Code 和 Microsoft C# 扩展。您可以在本书的 前言 中找到安装过程的逐步说明。

C# 程序的基本结构

为了运行,每个 C# 程序都需要一个称为入口点的东西。在 C# 中,程序的默认入口点是 Main 方法。无论您的程序类型如何,是 Web 应用程序、桌面应用程序,甚至是简单的控制台应用程序,Main 方法都将作为您的 C# 程序的 入口点。这意味着每次应用程序运行时,运行时都会在您的代码中搜索此方法并执行其中的代码块。

此结构由 CLI 的 new 命令为您创建。一个 Program.cs 文件包含一个名为 Program 的类,其中有一个名为 Main 的方法,该方法包含一个在程序构建并运行后将被执行的单一指令。您将在以后学习更多关于方法和类的内容,但就现在而言,只需知道类通常是包含一组数据的东西,并且可以通过这些 方法 对这些数据进行操作。

关于基本 C# 概念的另一个重要注意事项是 //

练习 1.01:创建一个输出 "Hello World" 的控制台应用程序

在这个练习中,您将看到上一节中学习的 CLI 命令,在构建您的第一个 C# 程序时。它将是一个简单的控制台应用程序,将在控制台上打印 Hello World

执行以下步骤来完成此操作:

  1. 打开 VS Code 集成终端并输入以下内容:

    dotnet new console -n Exercise1_01 
    

此命令将在 Exercise1_01 文件夹中创建一个新的控制台应用程序。

  1. 在命令行中,输入以下内容:

    dotnet run --project Exercise1_01
    

您应该看到以下输出:

图 1.1: "Hello World" 在控制台上的输出

图 1.1: "Hello World" 在控制台上的输出

注意

您可以在 packt.link/HErU6 找到此练习使用的代码。

在这个练习中,你使用 C# 创建了可能的最基本程序,这是一个将一些文本打印到提示符的控制台应用程序。你还学习了如何使用 .NET CLI,这是 .NET SDK 内部构建的机制,用于创建和管理 .NET 项目。

现在继续下一节,了解如何编写顶层语句。

顶层语句

你一定注意到了在练习 1.01中,默认情况下,当你创建一个控制台应用程序时,你会有一个Program.cs文件,其中包含以下内容:

  • 一个名为Program的类。

  • 静态 void Main关键字。

你将在后面详细学习类和方法,但就目前而言,为了简单起见,你不需要这些资源来使用 C#创建和执行程序。最新版本(.NET 6)引入了一个功能,使得编写简单的程序更加容易和简洁。例如,考虑以下:

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

你可以简单地用以下两行代码替换这个片段,如下所示:

using System;
Console.WriteLine("Hello World!");

通过使用这样的顶层语句,你可以编写简洁的程序。你只需将需要执行的语句放在程序顶部即可。这也有助于加快学习 C#的曲线,因为你不需要一开始就担心高级概念。这里唯一需要注意的是,项目只能有一个包含顶层语句的文件。

正因如此,在本章中,你会发现所有练习都将使用这种格式,以便尽可能清晰地说明。

声明变量

你现在将迈出创建自己程序的第一步。本节将深入探讨变量的概念——它们是什么以及如何使用它们。

变量是给计算机内存位置起的名字,该位置存储了一些可能变化的数据。为了使变量存在,它首先必须用类型和名字声明。它也可以给它赋值。变量的声明可以通过几种不同的方式实现。

关于 C#中变量命名约定的基本考虑如下:

  • 名称必须是唯一的,以字母开头,并且只能包含字母、数字和下划线字符(_)。名称也可以以下划线字符开头。

  • 名称是区分大小写的;因此,myVariableMyVariable是不同的名称。

  • 保留关键字,如intstring,不能用作名称(这是一个编译器限制),除非你在名称前加上@符号,例如@int@string

变量可以通过两种方式声明:显式和隐式。这两种声明方式都有其优缺点,你将在下一节中探讨。

显式声明变量

可以通过写出其类型和值来显式声明一个变量。假设你想要创建两个变量,ab,它们都包含整数。这样做显式地看起来像这样:

int a = 0;
int b = 0;

在使用变量之前,必须给它赋值。否则,C#编译器在构建你的程序时会报错。以下示例说明了这一点:

int a;
int b = a; // The compiler will prompt an error on this line: Use of unassigned local variable

你也可以在同一行声明多个变量,如下面的代码片段所示,其中你声明了三个变量;两个变量持有值100,一个变量持有值10

int a, b = 100, c = 10;

隐式声明变量

记住,C# 是一种强类型编程语言;这意味着变量将始终与一个类型相关联。无论类型是隐式声明还是显式声明,使用 var 关键字,C# 编译器都会根据已分配给它的值推断变量类型。

假设你想使用这种方法创建一个包含一些文本的变量。这可以通过以下语句完成:

var name = "Elon Musk";

要在变量中存储文本,你应该使用双引号(")开始和结束文本。在上面的例子中,通过查看分配给 name 的值,C# 知道这个变量持有的类型是字符串,即使声明中没有提到类型。

显式声明与隐式声明

显式声明通过声明类型提高了可读性,这是这种技术的主要优点之一。另一方面,它们往往会使得代码变得更加冗长,尤其是在处理一些数据类型(你将在后面看到)时,例如 Collections

实际上,选择声明风格取决于程序员的个人喜好,在某些情况下可能还会受到公司指南的影响。在学习之旅中,建议你选择一种可以使你的学习路径更加顺畅的风格,因为从纯粹的技术角度来看,它们之间几乎没有实质性的区别。

在下一个练习中,你将亲自通过将变量分配给来自用户与控制台应用程序交互的输入来完成这个任务,用户将被要求输入他们的名字。为了完成这个练习,你将使用 C# 提供的以下内置方法,你将在你的 C# 之旅中经常使用这些方法:

  • Console.ReadLine(): 这允许你检索用户在控制台上提示的值。

  • Console.WriteLine(): 这会将作为参数传递的值作为输出写入控制台。

练习 1.02:将变量分配给用户输入

在这个练习中,你将创建一个交互式控制台应用程序。该应用程序应该要求你输入你的名字,一旦提供,它应该显示包含你的名字的问候语。

要完成这个练习,请执行以下步骤:

  1. 打开命令提示符并输入以下内容:

    dotnet new console -n Exercise1_02
    

此命令在 Exercise1_02 文件夹中创建一个新的控制台应用程序。

  1. 打开 Program.cs 文件。在 Main 方法中粘贴以下内容:

    Console.WriteLine("Hi! I'm your first Program. What is your name?");
    var name = Console.ReadLine();
    Console.WriteLine($"Hi {name}, it is very nice to meet you. We have a really fun journey ahead.");
    
  2. 保存文件。在命令行中,输入以下内容:

    dotnet run --project Exercise1_02
    

这将输出以下内容:

Hi! I'm your first Program. What is your name?
  1. 现在,请在控制台中输入你的名字,然后按键盘上的 Enter 键。例如,如果你输入 Mateus,以下将是输出结果:

    Hi! I'm your first Program. What is your name?
    Mateus
    Hi Mateus, it is very nice to meet you. We have a really fun journey ahead.
    

    注意

    你可以在 packt.link/1fbVH 找到用于此练习的代码。

你对变量是什么、如何声明它们以及如何给它们赋值已经很熟悉了。现在,是时候开始讨论这些变量可以存储什么数据了,更具体地说,是讨论有哪些数据类型。

数据类型

在本节中,你将讨论 C#中的主要数据类型及其功能。

字符串

C#使用string关键字来标识存储文本作为字符序列的数据。你可以以多种方式声明字符串,如下面的代码片段所示。然而,当将某个值赋给字符串变量时,你必须将内容放在一对双引号之间,如最后两个示例所示:

// Declare without initializing.
string message1;
// Initialize to null.
string message2 = null;
// Initialize as an empty string
string message3 = System.String.Empty;
// Will have the same content as the above one
string message4 = "";
// With implicit declaration
var message4 = "A random message"     ;

一种简单但有效的技术(你在前面的练习 1.02中使用过)称为字符串插值。使用这种技术,将普通文本值与变量值混合变得非常简单,这样文本就可以在这两者之间结合。你可以通过以下步骤组合两个或多个字符串:

  1. 在初始引号之前插入一个$符号。

  2. 现在,在字符串中放置大括号和你要放入字符串中的变量的名称。在这种情况下,这是通过在初始字符串中放置{name}来完成的:

    $"Hi {name}, it is very nice to meet you. We have a really fun journey ahead.");
    

关于字符串,需要记住的另一个重要事实是它们是不可变的。这意味着字符串对象在创建后不能被更改。这是因为 C#中的字符串是一个字符数组。数组是收集相同类型对象并具有固定长度的数据结构。你将在接下来的章节中详细学习数组。

在下一个练习中,你将探索字符串不可变性。

练习 1.03:检查字符串不可变性

在这个练习中,你将使用两个字符串来演示字符串引用始终是不可变的。执行以下步骤来完成此操作:

  1. 打开 VS Code 集成终端并输入以下内容:

    dotnet new console -n Exercise1_03
    
  2. 打开Program.cs文件,创建一个具有void返回类型的方法,该方法替换字符串的一部分,如下所示:

    static void FormatString(string stringToFormat)
    {
    stringToFormat.Replace("World", "Mars");
    }
    

在前面的代码片段中,使用了Replace函数将第一个字符串(在这个例子中是World)替换为第二个字符串(Mars)。

  1. 现在,创建一个执行相同操作但返回结果的方法:

    static string FormatReturningString(string stringToFormat)
    {
    return stringToFormat.Replace("Earth", "Mars");
    }
    
  2. 现在在前面方法之后插入以下内容。在这里,你创建了两个字符串变量,并在尝试使用前面创建的方法修改它们后观察它们的行为:

    var greetings = "Hello World!";
    FormatString(greetings);
    Console.WriteLine(greetings);
    var anotherGreetings = "Good morning Earth!";
    Console.WriteLine(FormatReturningString(anotherGreetings));
    
  3. 最后,从命令行调用dotnet run --project Exercise1_03。你应该在控制台上看到以下输出:

    dotnet run
    Hello World!
    Good morning Mars!
    

    注意

    你可以在packt.link/ZoNiw找到用于此练习的代码。

通过这个练习,你看到了字符串不可变性的实际应用。当你传递一个作为引用类型的字符串(Hello World!)作为方法参数时,它没有被修改。这就是当你使用返回voidFormatString方法时发生的情况。由于字符串不可变性,会创建一个新的字符串,但不会分配给任何变量,原始字符串保持不变。第二个方法返回一个新的字符串,然后这个字符串被打印到控制台。

字符串比较

即使字符串是引用类型,当你使用 .Equals() 方法、等号运算符 (==) 和其他运算符(例如 !=)时,你实际上是在比较字符串的值,如下例所示:

string first = "Hello.";
string second = first;
first = null;

现在,你可以比较这些值并调用 Console.WriteLine() 来输出结果,如下所示:

Console.WriteLine(first == second);
Console.WriteLine(string.Equals(first, second));

运行前面的代码会产生以下输出:

False
False

你得到这个输出是因为,尽管字符串是引用类型,但 ==.Equals 比较都是针对字符串值进行的。还要记住,字符串是不可变的。这意味着当你将 second 赋值给 first 并将 first 设置为 null 时,会为 first 创建一个新的值,因此 second 的引用不会改变。

数值类型

C# 将其数值类型分为两大类——整型数和浮点型数。整型数类型如下:

  • sbyte: 存储从 -128 到 127 的值

  • short: 存储从 -32,768 到 32,767 的值

  • int: 存储从 -2,147,483,648 到 2,147,483,647 的值

  • long: 存储从 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 的值

选择使用哪种整型取决于你想要存储的值的范围。

所有这些类型都称为有符号值。这意味着它们可以存储负数和正数。还有另一系列类型称为无符号类型。无符号类型是 byteushortuintulong。它们之间的主要区别是有符号类型可以存储负数,而无符号类型只能存储大于或等于零的数字。你将大多数时间使用有符号类型,所以不必担心一次性记住所有这些。

另一类,即浮点类型,指的是用于存储带有一个或多个小数点的数字的类型。在 C# 中有三种浮点类型:

  • float: 占用四个字节,可以存储从 ± 1.5 x 10−45 到 ± 3.4 x 1038 的数字,精度范围为六到九位数字。要使用 var 声明一个浮点数,你只需在数字末尾附加 f,如下所示:

    var myFloat = 10f;
    
  • double: 占用八个字节,可以存储从 ± 5.0 × 10−324 到 ± 1.7 × 1030 的数字,精度范围为 15 到 17 位数字。要使用 var 声明一个双精度数,你可以在数字末尾附加 d,如下所示:

    var myDouble = 10d;
    
  • decimal: 占用 16 字节,可以存储从 ± 1.0 x 10-28 到 ± 7.9228 x 1028 的数字,精度范围为 28 到 29 位数字。要使用 var 声明一个十进制数,你只需在数字末尾附加 m,如下所示:

    var myDecimal = 10m;
    

选择浮点数类型主要取决于所需的精度程度。例如,decimal 主要用于需要非常高的精度且不能依赖四舍五入进行准确计算的金融应用程序。对于 GPS 坐标,如果你想要处理通常有 10 位数字的亚米精度,double 变量可能很合适。

在选择数值类型时,还需要考虑的一个相关点是性能。分配给变量的内存空间越大,使用这些变量的操作性能就越低。因此,如果不需要高精度,float类型的变量将比double类型的变量性能更好,而double类型的变量又将比decimal类型的变量性能更好。

现在你已经掌握了变量及其主要类型的概念。接下来,你将使用它们进行一些基本的计算,例如加法、减法和乘法。这可以通过 C#中可用的算术运算符来完成,如+-/*。因此,继续进行下一个练习,你将使用这些运算符创建一个基本的计算器。

练习 1.04:使用基本算术运算符

在这个练习中,你将创建一个简单的计算器,它接收两个输入并显示它们之间的结果,这取决于选定的算术运算。

以下步骤将帮助你完成这个练习:

  1. 打开 VS Code 集成终端,并输入以下命令:

    dotnet new console -n Exercise1_04
    
  2. 导航到项目文件夹,打开Program.cs文件,然后在Main方法中声明两个变量来读取用户输入,如下所示:

    Console.WriteLine("Type a value for a: ");
    var a = int.Parse(Console.ReadLine());
    Console.WriteLine("Now type a value for b: ");
    var b = int.Parse(Console.ReadLine());
    

前面的代码片段使用了.ReadLine方法来读取输入。然而,这个方法返回的是一个string类型,而你需要评估一个数字。因此,这里使用了Parse方法。所有数值类型都有一个名为Parse的方法,它接收一个字符串并将其转换为数字。

  1. 接下来,你需要将这些基本运算符的输出写入控制台。将以下代码添加到Main方法中:

    Console.WriteLine($"The value for a is { a } and for b is { b }");
    Console.WriteLine($"Sum: { a + b}");
    Console.WriteLine($"Multiplication: { a * b}");
    Console.WriteLine($"Subtraction: { a - b}");
    Console.WriteLine($"Division: { a / b}"); 
    
  2. 使用dotnet run命令运行程序,如果你输入1020,你应该会看到以下输出:

    Type a value for a:
    10
    Now type a value for b:
    20
    The value for a is 10 and b is 20
    Sum: 30
    Multiplication: 200
    Subtraction: -10
    Division: 0
    

    注意

    你可以在packt.link/ldWVv找到这个练习使用的代码。

因此,你已经使用算术运算符在 C#中构建了一个简单的计算器应用程序。你还学习了解析的概念,它用于将字符串转换为数字。在下一节中,将简要介绍类,这是 C#编程的核心概念之一。

类是 C#编程的一个基本组成部分,将在第二章构建高质量面向对象代码中全面介绍。本节将介绍类的基础知识,以便你可以在程序中使用它们。

在 C#中,当你想要定义一个对象类型时,会使用保留的class关键字。一个对象,也可以称为实例,不过是一个分配了内存块来存储信息的实体。根据这个定义,类的作用就是通过一些属性来描述这个对象,并指定这个对象可以通过方法执行的操作,从而作为对象的蓝图。

例如,假设你有一个名为Person的类,具有两个属性NameAge,以及一个检查Person是否为孩子的方法。方法是可以放置逻辑以执行某些操作的地方。它们可以返回特定类型的数据,或者有特殊的void关键字,表示它们不返回任何内容,只是执行一些操作。你还可以有调用其他方法的方法:

public class Person
{
	public Person() { }
	public Person(string name, int age)
{
	Name = name;
	Age = age;
}
	public string Name { get; set; }
	public int Age { get; set; }
	public void GetInfo()
{
	Console.WriteLine($"Name: {Name} – IsChild? {IsChild()}");
}
	public bool IsChild()
{
	return Age < 12;
}
}

尽管如此,还有一个问题。由于类充当蓝图(或者如果你更喜欢,定义),你实际上是如何为类定义的信息分配内存的?这是通过一个称为实例化的过程来完成的。当你实例化一个对象时,你会在一个称为堆的预留区域为其分配一些内存空间。当你将一个变量分配给一个对象时,你是在设置这个变量,使其拥有这个内存空间的地址,因此每次你操作这个变量时,它都会指向并操作在这个内存空间分配的数据。以下是一个简单的实例化示例:

var person = new Person();

注意,Person具有具有两个魔法关键字的属性——getset。获取器定义了可以检索属性值,而设置器定义了可以设置属性值。

另一个重要的概念是构造函数的概念。构造函数是一个没有返回类型的方法,通常位于类的顶层,以提高可读性。它指定了创建对象所需的内容。默认情况下,类将始终有一个无参数的构造函数。如果定义了具有参数的另一个构造函数,则类将仅限于这个一个。在这种情况下,如果你仍然想要一个无参数的构造函数,你必须指定一个。这非常有用,因为类可以有多个构造函数。

话虽如此,你可以以下方式为具有设置器的对象属性赋值:

  • 在创建时,通过其构造函数:

    var person = new Person("John", 10);
    
  • 在创建时,通过直接变量赋值:

    var person = new Person() { Name = "John", Age = 10 };
    
  • 在对象创建后,如下所示:

    var person = new Person();
    person.Name = "John";
    person.Age = 10;
    

在你接下来会看到的类中,还有很多内容。目前,主要思想如下:

  • 类是对象的蓝图,可以具有描述这些对象的属性和方法。

  • 对象需要被实例化,这样你才能对它们执行操作。

  • 类默认有一个无参数的构造函数,但可以根据需要有许多自定义的构造函数。

  • 对象变量是引用,包含分配给对象在名为堆的专用内存区域内的特殊内存地址。

日期

在 C#中,可以使用DateTime值类型来表示日期。它是一个具有两个静态属性的结构体,分别称为MinValueMaxValue,其中MinValue是公元 0001 年 1 月 1 日 00:00:00,而MaxValue是公元 9999 年 12 月 31 日 23:59:59。正如其名称所暗示的,这两个值都代表了根据格里高利日历日期格式的最小和最大日期。DateTime对象的默认值是MinValue

可以以多种方式构造 DateTime 变量。以下是一些最常见的方法:

  • 按如下方式分配当前时间:

    var now = DateTime.Now;
    

这将变量设置为调用计算机上的当前日期和时间,以本地时间表示。

var now = DateTime.UtcNow;

这将变量设置为这台计算机上的当前日期和时间,以协调世界时(UTC)表示。

  • 您还可以使用构造函数传递天数、月份、年份、小时、分钟,甚至秒和毫秒。

  • 对于 DateTime 对象,还有一个特殊属性称为 Ticks。它是自 DateTime.MinValue 以来经过的 100 纳秒数的度量。每次您有这种类型的对象时,都可以调用 Ticks 属性来获取这样的值。

  • 另一种特殊类型用于日期的是 TimeSpan 结构。一个 TimeSpan 对象表示一个时间间隔,以天、小时、分钟和秒为单位。当需要获取日期之间的间隔时,它非常有用。现在您将看到它在实际中的应用。

练习 1.05:使用日期算术

在这个练习中,您将使用 TimeSpan 方法/结构来计算您的本地时间和 UTC 时间之间的差异。为了完成这个练习,请执行以下步骤:

  1. 打开 VS Code 集成终端并输入以下内容:

    dotnet new console -n Exercise1_05
    
  2. 打开 Program.cs 文件。

  3. 将以下代码粘贴到 Main 方法中并保存文件:

    Console.WriteLine("Are the local and utc dates equal? {0}", DateTime.Now.Date == DateTime.UtcNow.Date);
    Console.WriteLine("\nIf the dates are equal, does it mean that there's no TimeSpan interval between them? {0}",
    (DateTime.Now.Date - DateTime.UtcNow.Date) == TimeSpan.Zero);
    DateTime localTime = DateTime.Now;
    DateTime utcTime = DateTime.UtcNow;
    TimeSpan interval = (localTime - utcTime);
    Console.WriteLine("\nDifference between the {0} Time and {1} Time: {2}:{3} hours",
        localTime.Kind.ToString(),
        utcTime.Kind.ToString(),
        interval.Hours,
        interval.Minutes);
    Console.Write("\nIf we jump two days to the future on {0} we'll be on {1}",
        new DateTime(2020, 12, 31).ToShortDateString(),
        new DateTime(2020, 12, 31).AddDays(2).ToShortDateString());
    

在前面的代码片段中,您首先检查当前本地日期和 UTC 日期是否相等。然后,使用 TimeSpan 方法检查它们之间的间隔(如果有的话)。接下来,打印本地时间和 UTC 时间之间的差异,并打印当前日期两天后的日期(在这种情况下为 31/12/ 2020)。

  1. 保存文件。在命令行中,输入以下内容:

    dotnet run --project Exercise1_05
    

您应该看到以下输出:

Are the local and utc dates equal? True
If the dates are equal, does it mean there's no TimeSpan interval between them? True
Difference between the Local Time and Utc Time: 0:0 hours
If we jump two days to the future on 31/12/2020 we'll be on 02/01/2021

注意

您可以在 packt.link/WIScZ 找到用于此练习的代码。

注意,根据您的时区,您可能会看到不同的输出。

日期格式化

您还可以将 DateTime 值格式化为本地化字符串。这意味着根据 C# 语言中称为“文化”的特殊概念来格式化 DateTime 实例,它是您本地时间的表示。例如,不同国家的日期表示方式不同。现在请看以下示例,其中日期以法国和美国使用的格式输出:

var frenchDate = new DateTime(2008, 3, 1, 7, 0, 0);
Console.WriteLine(frenchDate.ToString(System.Globalization.CultureInfo.
  CreateSpecificCulture("fr-FR")));
// Displays 01/03/2008 07:00:00
var usDate = new DateTime(2008, 3, 1, 7, 0, 0);
Console.WriteLine(frenchDate.ToString(System.Globalization.CultureInfo.CreateSpecificCulture("en-US")));
// For en-US culture, displays 3/1/2008 7:00:00 AM

您也可以显式定义您希望日期输出的格式,如下例所示,其中传递了 yyyyMMddTHH:mm:ss 值,表示您希望日期以年、月、日、时、分(带冒号)和秒(也带冒号)的顺序输出:

var date1 = new DateTime(2008, 3, 1, 7, 0, 0);
Console.WriteLine(date1.ToString("yyyyMMddTHH:mm:ss"));

将显示以下输出:

     20080301T07:00:00

逻辑运算符和布尔表达式

您已经熟悉这些了。回想一下,在前面的练习中,您做了以下比较:

var now = DateTime.Now.Date == DateTime.UtcNow.Date;

这个输出将 true 值赋给 now 如果日期相等。但正如你所知,它们可能并不一定相同。因此,如果日期不同,将分配一个 false 值。这两个值是这种布尔表达式的结果,被称为布尔值。这就是为什么 now 变量的类型是 bool

布尔表达式是每个程序中每个逻辑比较的基础。基于这些比较,计算机可以在程序中执行特定的行为。以下是一些其他布尔表达式和变量赋值的示例:

  • 将检查 a 是否大于 b 的比较结果赋值:

    var basicComparison = a > b;
    
  • 将检查 b 是否大于或等于 a 的比较结果赋值:

    bool anotherBasicComparison = b >= a; 
    
  • 检查两个字符串是否相等,并将此比较的结果赋值给变量:

    var animal1 = "Leopard";
    var animal2 = "Lion";
    bool areTheseAnimalsSame = animal1 == animal2;
    

显然,前一个比较的结果将是 false,并将这个值赋给 areTheseAnimalsSame 变量。

现在你已经学习了什么是布尔值以及它们是如何工作的,现在是时候看看你可以用来比较布尔变量和表达式的逻辑运算符了:

  • &&(与)运算符:这个运算符将执行相等比较。如果两者相等则返回 true,如果不相等则返回 false。考虑以下示例,其中你检查两个字符串的长度是否为 0

    bool areTheseStringsWithZeroLength = "".Length == 0 && " ".Length == 0; 
    Console.WriteLine(areTheseStringsWithZeroLength);// will return false
    
  • ||(或)运算符:这个运算符将检查被比较的值中是否至少有一个是 true。例如,在这里你检查至少有一个字符串的长度为零:

    bool isOneOfTheseStringsWithZeroLength = "".Length == 0 || " ".Length == 0;
    Console.WriteLine(isOneOfTheseStringsWithZeroLength); // will return true
    
  • !(非)运算符:这个运算符取一个布尔表达式或值并取反;也就是说,它返回相反的值。例如,考虑以下示例,其中你取反了一个检查字符串长度是否为零的比较结果:

    bool isOneOfTheseStringsWithZeroLength = "".Length == 0 || " ".Length == 0; 
    bool areYouReallySure = !isOneOfTheseStringsWithZeroLength;
    Console.WriteLine(areYouReallySure); // will return false
    

使用 if-else 语句

到目前为止,你已经学习了关于类型、变量和运算符的内容。现在,是时候深入了解帮助你将这些概念应用于现实世界问题的机制了——即决策语句。

在 C# 中,if-else 语句是实现代码分支的最受欢迎的选择之一,这意味着如果条件满足,则告诉代码遵循一条路径,否则遵循另一条路径。它们是评估布尔表达式并基于此评估结果继续程序执行的逻辑语句。

例如,你可以使用 if-else 语句来检查输入的密码是否满足某些标准(例如至少有六个字符和一个数字)。在下一个练习中,你将做 exactly that,在一个简单的控制台应用程序中。

练习 1.06:使用 if-else 进行分支

在这个练习中,你将使用if-else语句编写一个简单的凭证检查程序。应用程序应该要求用户输入他们的用户名;除非这个值至少有六个字符长,否则用户不能继续。一旦这个条件得到满足,用户应该被要求输入密码。密码也应该至少有六个字符,并且至少包含一个数字。只有当这两个标准都满足后,程序才应显示成功信息,例如User successfully registered

以下步骤将帮助你完成这个练习:

  1. 在 VS Code 集成终端中,创建一个名为Exercise1_06的新控制台项目:

    dotnet new console -n Exercise1_06
    
  2. Main方法中,添加以下代码以请求用户输入用户名,并将值分配给一个变量:

    Console.WriteLine("Please type a username. It must have at least 6 characters: ");
    var username = Console.ReadLine();
    
  3. 接下来,程序需要检查用户名是否超过六个字符,如果不是,则将错误信息写入控制台:

    if (username.Length < 6)
    {
    Console.WriteLine($"The username {username} is not valid.");
    }
    
  4. 现在,在else子句中,你将继续验证并要求用户输入密码。一旦用户输入了密码,需要检查三个点。第一个条件是检查密码是否至少有六个字符,然后是否有至少一个数字。然后,如果这些条件中的任何一个失败,控制台应该显示错误信息;否则,应该显示成功信息。为此,添加以下代码:

    else
    {
    Console.WriteLine("Now type a 
    password. It must have a length of at least 6 characters and also contain a number.");
    var password = Console.ReadLine();
    
    if (password.Length < 6)
         {
         		Console.WriteLine("The password must have at least 6 characters.");
    }
         else if (!password.Any(c => char.IsDigit©))
         {
         		Console.WriteLine("The password must contain at least one number.");
    }
    else
         {
                 Console.WriteLine("User successfully registered.");
    }
    }
    

从前面的代码片段中,你可以看到如果用户输入的字符少于六个,会显示错误信息The password must have at least 6 characters.。如果密码不包含任何数字但满足前面的条件,则会显示另一个错误信息The password must contain at least one number.

注意这里使用的逻辑条件是!password.Any(c => char.IsDigit(c))。你将在第二章构建高质量面向对象代码中了解更多关于=>符号的内容,但就目前而言,你只需要知道这一行会检查密码中的每个字符,并使用IsDigit函数来检查该字符是否为数字。这是对每个字符进行的,如果没有找到数字,则会显示错误信息。如果所有条件都满足,则会显示成功信息User successfully registered.

  1. 使用dotnet run运行程序。你应该会看到以下输出:

    Please type a username. It must have at least 6 characters:
    thekingjames
    Now type a password. It must have at least 6 characters and a number.
    James123!"#
    User successfully registered
    

    注意

    你可以在packt.link/3Q7oK找到这个练习所使用的代码。

在这个练习中,你使用了 if-else 分支语句来实现一个简单的用户注册程序。

三元操作符

另一个简单易用且有效的决策操作符是三元操作符。它允许你根据布尔比较设置变量的值。例如,考虑以下示例:

var gift = person.IsChild() ? "Toy" : "Clothes";

在这里,你使用 ? 符号来检查它之前放置的布尔条件是否有效。编译器为 person 对象运行 IsChild 函数。如果该方法返回 true,则第一个值(在 : 符号之前)将被分配给 gift 变量。如果该方法返回 false,则第二个值(在 : 符号之后)将被分配给 gift 变量。

三元运算符简单且使基于简单布尔验证的赋值更加简洁。你将在你的 C# 之旅中经常使用它。

引用类型和值类型

C# 中有两种类型的变量,即引用类型和值类型。值类型变量,如结构体,包含自身值,正如其名称所示。这些值存储在称为堆栈的内存空间中。当声明此类类型的变量时,将分配特定的内存空间来存储此值,如下图所示:

图 1.2:值类型变量的内存分配

图 1.2:值类型变量的内存分配

在这里,变量的值,即 5,存储在 RAM 中的 0x100 位置。C# 的内置值类型包括 boolbytechardecimaldoubleenumfloatintlongsbyteshortstructuintulongushort

引用类型变量的场景不同。在本章中,你需要了解的三个主要引用类型是 string、数组和 class。当分配新的引用类型变量时,存储在内存中的不是值本身,而是一个值分配的内存地址。例如,考虑以下图示:

图 1.3:引用类型变量的内存分配

图 1.3:引用类型变量的内存分配

在这里,存储在内存中的不是字符串变量(Hello)的值,而是它分配的地址(0x100)。为了简洁起见,你不会深入探讨这个主题,但了解以下要点很重要:

  • 当值类型变量作为参数传递或作为另一个变量的值分配时,.NET 运行时会将变量的值复制到另一个对象。这意味着原始变量不会受到在较新和后续变量中做出的任何更改的影响,因为值是从一个地方直接复制到另一个地方的。

  • 当引用类型变量作为参数传递或作为另一个变量的值分配时,.NET 会传递堆内存地址而不是值。这意味着在方法内部对此变量所做的任何后续更改都将反映在外部。

例如,考虑以下处理整数的代码。这里,你声明一个名为 aint 变量并将其值设置为 100。稍后,你创建另一个名为 bint 变量并将 a 的值赋给它。最后,你修改 b,将其增加 100

using System;
int a = 100;
Console.WriteLine($"Original value of a: {a}");
int b = a;
Console.WriteLine($"Original value of b: {b}");
b = b + 100;
Console.WriteLine($"Value of a after modifying b: {a}");
Console.WriteLine($"Value of b after modifying b: {b}");

ab 的值将在以下输出中显示:

Original value of a: 100
Original value of b: 100
Value of a after modifying b: 100
Value of b after modifying b: 200

在这个例子中,a 的值被复制到 b 中。从这一点开始,你对 b 所做的任何其他修改都只会反映在 b 上,而 a 将继续保持其原始值。

现在,如果你将引用类型作为方法参数传递,会发生什么?考虑以下程序。这里,你有一个名为 Car 的类,具有两个属性——NameGearType。在程序中有一个名为 UpgradeGearType 的方法,它接收 Car 类型的对象并将其 GearType 更改为 Automatic

using System;
var car = new Car();
car.Name = "Super Brand New Car";
car.GearType = "Manual";
Console.WriteLine($"This is your current configuration for the car {car.Name}: Gea–Type - {car.GearType}");
UpgradeGearType(car);
Console.WriteLine($"You have upgraded your car {car.Name} for the GearType {car.GearType}");
void UpgradeGearType(Car car)
{
    car.GearType = "Automatic";
}
class Car
{
    public string Name { get; set; }
    public string GearType { get; set; }
}

在创建 CarUpgradeGearType() 方法后,输出将如下所示:

This is your current configuration for the car Super Brand New Car: GearType – Manual
You have upgraded your car Super Brand New Car for the GearType Automatic

因此,你可以看到,如果你将一个 car(在这种情况下)作为参数传递给一个方法(例如示例中的 UpgradeGearType),在这个 对象 内部所做的任何更改都会在方法调用之后和外部反映出来。这是因为引用类型引用内存中的特定位置。

练习 1.07:掌握值和引用相等

在这个练习中,你将看到值类型和引用类型的相等比较有何不同。执行以下步骤来完成此操作:

  1. 在 VS Code 中,打开集成终端并输入以下内容:

    dotnet new console -n Exercise1_07
    
  2. 打开 Program.cs 文件。在同一文件中,创建一个名为 GoldenRetriever 的结构体,具有 Name 属性,如下所示:

    struct GoldenRetriever
    {
        public string Name { get; set; }
    }
    
  3. 仍然在同一文件中,创建一个名为 BorderCollie 的另一个类,具有类似的 Name 属性:

    class BorderCollie
    {
        public string Name { get; set; }
    }
    
  4. 还必须创建一个最终的类,一个名为 Bernese 的类,也具有 Name 属性,但额外重写了原生的 Equals 方法:

    class Bernese
    {
        public string Name { get; set; }
        public override bool Equals(object obj)
        {
            if (obj is Bernese borderCollie && obj != null)
            {
                return this.Name == borderCollie.Name;
            }
            return false;
        }
    }
    

在这里,this 关键字用于引用当前的 borderCollie 类。

  1. 最后,在 Program.cs 文件中,你将为这些类型创建一些对象。请注意,由于你正在使用 顶层语句,这些声明应该在类和结构体声明之上:

            var aGolden = new GoldenRetriever() { Name = "Aspen" };
            var anotherGolden = new GoldenRetriever() { Name = "Aspen" };
            var aBorder = new BorderCollie() { Name = "Aspen" };
            var anotherBorder = new BorderCollie() { Name = "Aspen" };
            var aBernese = new Bernese() { Name = "Aspen" };
            var anotherBernese = new Bernese() { Name = "Aspen" };
    
  2. 现在,在之前的声明之后,使用 Equals 方法比较这些值并将结果赋给一些变量:

    var goldenComparison = aGolden.Equals(anotherGolden) ? "These Golden Retrievers have the same name." : "These Goldens have different names.";
    var borderComparison = aBorder.Equals(anotherBorder) ? "These Border Collies have the same name." : "These Border Collies have different names.";
    var berneseComparison = aBernese.Equals(anotherBernese) ? "These Bernese dogs have the same name." : "These Bernese dogs have different names.";
    
  3. 最后,使用以下代码将比较结果打印到控制台:

              Console.WriteLine(goldenComparison);
              Console.WriteLine(borderComparison);
              Console.WriteLine(berneseComparison);
    
  4. 使用 dotnet run 从命令行运行程序,你将看到以下输出:

    These Golden Retrievers have the same name.
    These Border Collies have different names.
    These Bernese dogs have the same name.
    

    注意

    你可以在 packt.link/xcWN9 找到用于此练习的代码。

如前所述,结构体是值类型。因此,当两个相同结构体的对象使用 Equals 进行比较时,.NET 内部会检查所有结构体属性。如果这些属性的值相等,则返回 true。以 Golden Retrievers 为例,如果你有一个 FamilyName 属性,并且这个属性在两个对象之间不同,则相等比较的结果将是 false

对于类和所有其他引用类型,相等比较相当不同。默认情况下,在相等比较时检查对象引用。如果引用不同(除非两个变量被分配到同一个对象),相等比较将返回false。这解释了示例中Border Collies的结果,即两个实例的引用是不同的。

然而,可以在引用类型中实现一个名为Equals的方法。给定两个对象,Equals方法可以用于在方法内部放置的逻辑之后的比较。这正是 Bernese dogs 示例中发生的情况。

默认值类型

现在你已经处理了值和引用类型,你将简要探索默认值类型。在 C#中,每个类型都有一个默认值,如下表所示:

图 1.4:默认值类型表

图 1.4:默认值类型表

这些默认值可以使用default关键字分配给变量。要在变量声明中使用此词,必须在变量名称之前显式声明变量类型。例如,考虑以下代码片段,其中你将default值分配给两个int变量:

int a = default;
int b = default;

在这种情况下,ab都将被分配值为0。请注意,在这种情况下不能使用var。这是因为,对于隐式声明的变量,编译器需要将值分配给变量以推断其类型。因此,以下代码片段将导致错误,因为没有设置类型,无论是通过显式声明还是通过变量赋值:

var a = default;
var b = default;

使用switch语句增强决策

当需要测试单个表达式与三个或更多条件时,switch语句通常用作 if-else 结构的替代方案,即当你想要执行多个代码部分中的一个时,如下所示:

switch (matchingExpression) 
{
  case firstCondition:
    // code section
    break;
  case secondCondition:
    // code section
    break;
  case thirdCondition:
    // code section
    break;
  default:
    // code section
    break;
}

匹配表达式应该返回以下类型之一:charstringboolnumbersenumobject。然后,这个值将在匹配的某个 case 子句或默认子句中进行评估,如果它不匹配任何先前的子句。

重要的是要说明,在switch语句中,只有一个switch部分会被执行。C#不允许从一个switch部分继续执行到下一个。然而,switch语句本身并不知道如何停止。你可以使用break关键字,如果你只想执行某些操作而不返回,或者如果需要返回某些值。

此外,switch语句上的default关键字是如果没有匹配其他选项,执行将去的地方。在下一个练习中,你将使用switch语句创建一个餐厅菜单应用程序。

练习 1.08:使用switch来点餐

在这个练习中,您将创建一个控制台应用程序,允许用户从餐厅提供的菜单中选择食物项目。应用程序应显示订单的确认收据。您将使用switch语句来实现逻辑。

按照以下步骤完成此练习:

  1. 创建一个名为Exercise1_08的新控制台项目。

  2. 现在,创建一个System.Text.StringBuilder。这是一个帮助以多种方式构建字符串的类。在这里,您是逐行构建字符串,以便它们可以在控制台上正确显示:

    var menuBuilder = new System.Text.StringBuilder();
    menuBuilder.AppendLine("Welcome to the Burger Joint. ");
    menuBuilder.AppendLine(string.Empty);
    menuBuilder.AppendLine("1) Burgers and Fries - 5 USD");
    menuBuilder.AppendLine("2) Cheeseburger - 7 USD");
    menuBuilder.AppendLine("3) Double-cheeseburger - 9 USD");
    menuBuilder.AppendLine("4) Coke - 2 USD");
    menuBuilder.AppendLine(string.Empty);
    menuBuilder.AppendLine("Note that every burger option comes with fries and ketchup!");
    
  3. 在控制台上显示菜单,并要求用户选择一个选项:

    Console.WriteLine(menuBuilder.ToString());
    Console.WriteLine("Please type one of the following options to order:");
    
  4. 读取用户按下的键并将其分配给一个变量,使用Console.ReadKey()方法。此方法与您之前使用的ReadLine()方法类似,区别在于它读取方法调用后立即按下的键。为此添加以下代码:

    var option = Console.ReadKey();
    
  5. 现在是时候使用switch语句了。在此处将option.KeyChar.ToString()用作switch子句的匹配表达式。键1234应分别导致接受汉堡芝士汉堡双层芝士汉堡可乐的订单:

    switch (option.KeyChar.ToString())
    {
        case "1":
            {
                Console.WriteLine("\nAlright, some burgers on the go. Please pay the cashier.");
                break;
            }
        case "2":
            {
                Console.WriteLine("\nThank you for ordering cheeseburgers. Please pay the cashier.");
                break;
            }
        case "3":
            {
                Console.WriteLine("\nThank you for ordering double cheeseburgers, hope you enjoy them. Please pay the cashier!");
    

然而,任何其他输入都应被视为无效,并显示一条消息,告知您已选择无效选项:

            break;
        }
    case "4":
        {
            Console.WriteLine("\nThank you for ordering Coke. Please pay the cashier.");
            break;
        }
    default:
        {
            Console.WriteLine("\nSorry, you chose an invalid option.");
            break;
        }
}
  1. 最后,使用dotnet run --project Exercise1_08运行程序,并与控制台交互以查看可能的输出。例如,如果您输入1,您应该看到以下输出:

    Welcome to the Burger Joint. 
    1) Burgers and Fries – 5 USD
    2) Cheeseburger – 7 USD
    3) Double-cheeseburger – 9 USD
    4) Coke – 2 USD
    Note that every burger option comes with fries and ketchup!
    Please type one of the follow options to order:
    1
    Alright, some burgers on the go! Please pay on the following cashier!
    

    注意

    您可以在packt.link/x1Mvn找到用于此练习的代码。

类似地,您还应该获取其他选项的输出。您已经学习了 C#中的分支语句。在用 C#编程时,您还会经常使用另一种类型的语句,称为迭代语句。下一节将详细介绍这一主题。

迭代语句

迭代语句,也称为循环,是实际应用中有用的语句类型,因为您经常需要在满足某些条件(例如,操作必须递增到某个值的数字)之前,在您的应用程序中连续重复某些逻辑执行。C#提供了多种实现此类迭代的方法,在本节中,您将详细检查这些方法。

while

您将考虑的第一个迭代语句是while语句。此语句允许 C#程序在某个布尔表达式评估为true时执行一系列指令。它具有最基本的结构之一。考虑以下代码片段:

int i = 0;
while (i < 10)
{
Console.WriteLine(i);
i = i +1;
}

前面的代码片段展示了如何使用while语句。请注意,while关键字后面跟着一对括号,括号内包含一个逻辑条件;在这种情况下,条件是i的值必须小于10。大括号内编写的代码将在该条件为true时执行。

因此,前面的代码将打印从0开始的i的值,直到10。这是一段相当简单的代码;在下一个练习中,您将使用while语句进行一些更复杂的操作,例如检查您输入的数字是否为质数。

练习 1.09:使用while循环检查数字是否为质数

在这个练习中,您将使用while循环来检查您输入的数字是否为质数。为此,while循环将检查计数器是否小于或等于数字除以2的整数结果。当这个条件满足时,您检查数字除以计数器的余数是否为0。如果不是,您增加计数器并继续,直到循环条件不满足。如果满足,这意味着数字不是false,循环可以停止。

执行以下步骤以完成此练习:

  1. 在 VS Code 集成终端中,创建一个名为Exercise1_09的新控制台项目。

  2. Program.cs文件中,创建以下方法,该方法将执行您在练习开始时引入的逻辑:

    static bool IsPrime(int number)
    {
    if (number ==0 || number ==1) return false;
    bool isPrime = true;
    int counter = 2;
    while (counter <= Math.Sqrt(number))
         {
         		if (number % counter == 0)
               {
               	isPrime = false;
                    break;
    }
    counter++;
    }
         return isPrime;
    }
    
  3. 现在,输入一个数字,这样您就可以检查它是否为质数:

    Console.Write("Enter a number to check whether it is Prime: ");
    var input = int.Parse(Console.ReadLine());
    
  4. 现在,检查数字是否为质数并打印结果:

    Console.WriteLine($"{input} is prime? {IsPrime(input)}.");
    
  5. 最后,在 VS Code 集成终端中,调用dotnet run --project Exercise1_09并与程序交互。例如,尝试输入29作为输入:

    Enter a number to check whether it is Prime:
    29
    29 is prime? True
    

如预期,29的结果为true,因为它是一个质数。

注意

您可以在packt.link/5oNg5找到用于此练习的代码。

前面的练习旨在向您展示一个带有一些更复杂逻辑的while循环的简单结构。它检查一个数字(命名为input)并打印它是否为质数。在这里,您再次看到了break关键字被用来停止程序执行。现在继续学习跳转语句。

跳转语句

在循环中还有一些其他重要的关键字也值得提及。这些关键字被称为跳转语句,用于将程序执行转移到另一个部分。例如,您可以按如下方式重写IsPrime方法:

static bool IsPrimeWithContinue(int number)
        {
        if (number == 0 || number ==1) return false;
            bool isPrime = true;
            int counter = 2;
            while (counter <= Math.Sqrt(number))
            {
                if (number % counter != 0)
                {
                    counter++;
                    continue;
                }
                isPrime = false;
                break;
            }
            return isPrime;
        }

在这里,您反转了逻辑检查。您不是检查余数是否为零然后中断程序执行,而是检查余数是否不为零,如果是这样,就使用continue语句将执行传递到下一个迭代。

现在,看看您如何使用另一个特殊关键字goto来重写它:

static bool IsPrimeWithGoTo(int number)
        {
        if (number == 0 || number ==1) return false;
bool isPrime = true;
            int counter = 2;
            while (counter <= Math.Sqrt(number))
            {
                if (number % counter == 0)
                {
                    isPrime = false;
                    goto isNotAPrime; 
                }
                counter++;
            }
            isNotAPrime:
            return isPrime;
        }

可以使用goto关键字从一个代码部分跳转到另一个由所谓的标签定义的部分。在这种情况下,标签被命名为isNotAPrime。最后,看看另一种编写这种逻辑的方法:

static bool IsPrimeWithReturn(int number)
        {
        if (number == 0 || number ==1) return false;
            int counter = 2;
            while (counter <= Math.Sqrt(number))
            {
                if (number % counter == 0)
                {
                    return false;
                }
                counter ++;
            }
            return true;
        }

现在,您不是使用breakcontinue来停止程序执行,而是简单地使用return来中断循环执行,因为您已经找到了您要找的结果。

do-while

do-while 循环与之前类似,但有一个细微的区别:它至少执行一次逻辑,而简单的 while 语句如果首次执行时条件不满足,可能永远不会执行。它具有以下结构:

int t = 0;
do
{
    Console.WriteLine(t);
    t++;
} while (t < 5);

在这个例子中,你从 0 开始写入 t 的值,并在它小于 5 时持续递增。在深入研究下一类循环之前,了解一个新概念,即数组。

数组

数组是一种用于存储许多相同类型对象的数据结构。例如,以下示例是一个声明为整数数组变量的变量:

int[] numbers = { 1, 2, 3, 4, 5 };

关于数组的一个重要注意事项是它们具有固定容量。这意味着数组在其创建时将定义长度,并且这个长度不能改变。长度可以通过各种方式确定。在前面的例子中,长度是通过计算数组中的对象数量来推断的。然而,创建数组的另一种方式如下:

var numbers = new int[5];

在这里,你正在创建一个容量为 5 个整数的数组,但你没有为数组元素指定任何值。当创建任何数据类型的数组而不向其中添加元素时,该值类型的默认值将设置为数组的每个位置。例如,考虑以下图示:

图 1.5:未分配索引的值类型数组

图 1.5:未分配索引的值类型数组

前面的图示显示,当你创建一个包含五个元素的整数数组时,如果不为任何元素分配值,数组将自动在每个位置填充默认值。在这种情况下,默认值是 0。现在考虑以下图示:

图 1.6:具有固定大小和单个索引的引用类型数组

图 1.6:具有固定大小和单个索引的引用类型数组

在前面的例子中,你创建了一个包含五个对象的数组,并将 "Hello" 字符串值分配给索引 1 的元素。数组的其他位置自动分配对象的默认值,该默认值为 null

最后,值得注意的是,所有数组都有索引,这指的是单个数组元素的位置。第一个位置始终具有索引 0。因此,大小为 n 的数组中的位置可以从索引 0n-1 指定。因此,如果你调用 numbers[2],这意味着你正在尝试访问 numbers 数组中的位置 2 的元素。

for 循环

for 循环在布尔表达式匹配指定条件时执行一系列指令。就像 while 循环一样,可以使用跳转语句来停止循环执行。它具有以下结构:

for (initializer; condition; iterator)
{
	[statements]
}

初始化语句在循环开始之前执行。它用于声明并分配一个仅用于循环作用域内的局部变量。

但在更复杂的场景中,它也可以用来组合其他语句表达式。条件指定了一个布尔条件,指示循环何时继续或退出。迭代器通常用于在初始化部分创建的变量中增加或减少。以下是一个例子,其中使用for循环打印整数数组的元素:

int[] array = { 1, 2, 3, 4, 5 };
for (int j = 0; j < array.Length - 1; j++)
{
Console.WriteLine(array[j]);
}

在这个例子中,已经创建了一个初始化变量j,其初始值为0for循环将在j小于数组长度减1时持续执行(记住索引总是从0开始)。在每次迭代后,j的值增加1。这样,for循环就会遍历整个数组并执行给定的操作,即打印当前数组元素的值。

C#还允许使用嵌套循环,即循环中的循环,你将在下一个练习中看到。

练习 1.10:使用冒泡排序对数组进行排序

在这个练习中,你将执行一种最简单的排序算法。冒泡排序包括遍历数组中的每一对元素,并在它们未排序时交换它们。最终,期望得到一个按升序排列的数组。你将使用嵌套的for循环来实现这个算法。

首先,应该将待排序的数组作为参数传递给此方法。对于数组的每个元素,如果当前元素大于下一个元素,则应交换它们的位置。这种交换通过将下一个元素的值存储在一个临时变量中,将当前元素的值赋给下一个元素,最后使用存储的临时值设置当前元素的值来实现。一旦第一个元素与其他所有元素进行比较,就会开始对第二个元素进行比较,依此类推,直到最终数组被排序。

以下步骤将帮助你完成这个练习:

  1. 使用以下命令创建一个新的控制台项目:

    dotnet new console -n Exercise1_10
    
  2. Program.cs文件中,创建一个方法来实现排序算法。添加以下代码:

    static int[] BubbleSort(int[] array)
    {
        int temp;
        // Iterate over the array
        for (int j = 0; j < array.Length - 1; j++)
        {
            // If the last j elements are already ordered, skip them
            for (int i = 0; i < array.Length - j - 1; i++)
            {
                if (array[i] > array[i + 1])
                {
                    temp = array[i + 1];
                    array[i + 1] = array[i];
                    array[i] = temp;
                }
            }
        }
        return array;
    }
    
  3. 现在创建一个包含一些数字的数组,如下所示:

    int[] randomNumbers = { 123, 22, 53, 91, 787, 0, -23, 5 };
    
  4. 调用BubbleSort方法,将数组作为参数传递,并将结果赋给一个变量,如下所示:

    int[] sortedArray = BubbleSort(randomNumbers);
    
  5. 最后,你需要打印出数组已排序的消息。为此,遍历数组,打印数组元素:

    Console.WriteLine("Sorted:");
    for (int i = 0; i < sortedArray.Length; i++)
    {
        Console.Write(sortedArray[i] + " ");
    }
    
  6. 使用 dotnet run --project Exercise1_10 命令运行程序。你应该会在屏幕上看到以下输出:

    Sorted:
    -23 0 5 22 53 91 123 787
    

    注意

    你可以在packt.link/cJs8y找到这个练习使用的代码。

在这个练习中,你使用了在上一节学到的两个概念:数组和 for 循环。你操作数组,通过索引访问它们的值,并使用 for 循环遍历这些索引。

有另一种方法可以遍历数组或foreach语句的每个元素。你将在下一节中探索这一点。

foreach 语句

foreach语句为集合中的每个元素执行一组指令。就像for循环一样,breakcontinuegotoreturn关键字也可以与foreach语句一起使用。考虑以下示例,其中你遍历数组的每个元素并将其写入控制台作为输出:

var items = new int[] { 1, 2, 3, 4, 5 };
foreach (int element in items)
{
Console.WriteLine(element);
}

前面的代码片段将数字从15打印到控制台。你可以使用foreach语句与比数组更多的东西一起使用;它们还可以与列表、集合和 span 一起使用,这些是将在后续章节中介绍的其他数据结构。

文件处理

到目前为止,你一直在创建主要与 CPU 和内存交互的程序。本节将重点介绍 I/O 操作,即物理磁盘上的输入和输出操作。文件处理是这类操作的一个很好的例子。

C#有几个类可以帮助你执行 I/O 操作。其中一些如下:

  • File:这个类提供了对文件进行操作的方法,即读取、写入、创建、删除、复制和移动磁盘上的文件。

  • Directory:与File类类似,这个类包括创建、移动和枚举磁盘上的目录和子目录的方法。

  • Path:提供处理磁盘上文件和目录的绝对路径和相对路径的实用工具。相对路径始终与当前应用程序执行的当前目录内的某个路径相关联,而绝对路径则指向硬盘内的一个绝对位置。

  • DriveInfo:提供有关磁盘驱动器的信息,例如NameDriveTypeVolumeLabelDriveFormat

你已经知道文件主要是一些数据集合,它们位于硬盘的某个位置,可以通过某些程序打开进行读取或写入。当你在一个 C#应用程序中打开一个文件时,你的程序通过一个通信通道将文件作为一系列字节读取。这个通信通道被称为流。流有两种类型:

  • 输入流用于读取操作。

  • 输出流用于写入操作。

Stream类是 C#中的一个抽象类,它使你可以执行与这种字节流相关的常见操作。对于硬盘上的文件处理,你将使用专门为此目的设计的FileStream类。以下是这个类两个重要的属性:FileAccessFileMode

FileAccess

这是一个enum,它为你提供了在打开指定文件时选择访问级别选项:

  • Read:以只读模式打开文件。

  • ReadWrite:以读写模式打开文件。

  • Write:以只写模式打开文件。这很少使用,因为你通常会在写入的同时进行一些读取。

FileMode

这是一个 enum,指定可以在文件上执行的操作。它应与访问模式一起使用,因为某些模式仅与某些访问级别一起工作。查看以下选项:

  • 追加:当你想在文件末尾添加内容时使用此选项。如果文件不存在,则会创建一个新文件。对于此操作,文件必须具有写入权限;否则,任何尝试读取都会失败并抛出 NotSupportedException 异常。异常是一个重要的概念,将在本章后面进行介绍。

  • 创建:使用此选项来创建新文件或覆盖现有文件。对于此选项,也需要写入权限。在 Windows 中,如果文件存在但被隐藏,则会抛出 UnauthorizedAccessException 异常。

  • CreateNew:这与 Create 类似,但用于创建新文件,并且也需要写入权限。然而,如果文件已存在,则会抛出 IOException 异常。

  • 打开:正如其名所示,此模式用于打开文件。文件必须具有读取或读取和写入权限。如果文件不存在,则会抛出 FileNotFoundException 异常。

  • OpenOrCreate:这与 Open 类似,但如果文件不存在,则会创建一个新文件。

练习 1.11:从文本文件中读取内容

在此练习中,你将从一个逗号分隔值 (CSV) 文件中读取文本。CSV 文件仅包含由字符串表示的数据,并且由冒号或分号分隔。

执行以下步骤以完成此练习:

  1. 打开命令提示符并输入以下内容:

    dotnet new console -n Exercise1_11
    
  2. 在你的计算机中 Exercise1_11 项目文件夹位置,创建一个名为 products.csv 的文件,并将以下内容粘贴到其中:

    Model;Memory;Storage;USB Ports;Screen;Condition;Price USD
    Macbook Pro Mid 2012;8GB;500GB HDD;USB 2.0x2;13" screen;Refurbished;400
    Macbook Pro Mid 2014;8GB;512GB SSD;USB 3.0x3;15" screen;Refurbished;750
    Macbook Pro Late 2019;16GB;512GB SSD;USB 3.0x3;15" screen;Refurbished;1250
    
  3. 打开 Program.cs 文件,并用以下内容替换其内容:

    using System;
    using System.IO;
    using System.Threading.Tasks;
    namespace Exercise1_11
    {
        public class Program
        {
            public static async Task Main()
            {
            using (var fileStream = new FileStream("products.csv", FileMode.Open, FileAccess.Read))
            {
                using (var reader = new StreamReader(fileStream))
                {
                    var content = await reader.ReadToEndAsync();
                    var lines = content.Split(Environment.NewLine);
                    foreach (var line in lines)
                    {
                        Console.WriteLine(line);
                    }
                }
            }
            }
        }
    }
    
  4. 在命令提示符中调用 dotnet run,你将得到一个输出,其内容与您创建的 CSV 文件内容相同。

    注意

    你可以在 packt.link/5flid 找到用于此练习的代码。

此练习有一些相当有趣的结果,你将逐步了解。首先,你使用 FileStream 类打开了一个文件。这允许你从文件开始流式传输字节,具有两个特殊属性,即 FileModeFileAccess。它将返回一个 StreamReader 类。此类使你能够将这些字节作为文本字符读取。

注意,你的 Main 方法已从 void 更改为 async Task。此外,已使用 await 关键字,该关键字用于异步操作。你将在接下来的章节中了解更多关于这些主题的内容。现在,你只需要知道异步操作是那些不会阻塞程序执行的操作。这意味着你可以在读取时输出行;也就是说,你不需要等待它们全部被读取。

在下一节中,你将了解处理文件、数据库和网络连接的特殊关键字。

可丢弃对象

前一个练习中还有一个特殊之处,那就是 using 关键字。这是一个用于从内存中清理未管理资源的关键字。这些资源是特殊对象,它们处理一些操作系统资源,例如文件、数据库和网络连接。它们被称为 特殊,因为它们执行所谓的 I/O 操作;也就是说,它们与机器的真实资源(如网络和硬盘)交互,而不仅仅是与内存空间。

C# 中对象使用的内存由称为垃圾回收器的东西来处理。默认情况下,C# 处理堆栈和堆内存空间。唯一不执行此清理的对象类型称为未管理对象。

从内存中清理这些对象意味着资源将可供计算机中的另一个进程使用。这意味着文件可以被另一个进程处理,数据库连接可以再次由连接池使用,等等。这些类型的资源被称为可处置资源。每次你处理可处置资源时,在创建对象时都可以使用 using 关键字。然后,编译器知道当 using 语句关闭时,它可以自动释放这些资源。

练习 1.12:向文本文件写入

在这个练习中,你将使用 FileStream 类将一些文本写入 CSV 文件。

按照以下步骤完成此练习:

  1. 打开 VS Code 集成终端并输入以下内容:

    dotnet new console -n Exercise1_12
    
  2. 在你的计算机上的一个合适位置,将上一个练习中的 products.csv 文件复制并粘贴到这个练习的文件夹中。

  3. Program.cs 中创建一个名为 ReadFile 的方法,该方法将接收一个 FileStream 文件并遍历文件行,将结果输出到控制台:

    static async Task ReadFile(FileStream fileStream)
        {
            using (var reader = new StreamReader(fileStream))
            {
                var content = await reader.ReadToEndAsync();
                var lines = content.Split(Environment.NewLine);
                foreach (var line in lines)
                {
                    Console.WriteLine(line);
                }
            }
        }
    
  4. 现在,在你的程序中,使用 StreamWriter 打开 products.csv 文件,并向其中添加更多信息,如下所示:

            using (var file = new StreamWriter("products.csv", append: true))
            {
                file.Write("\nOne more macbook without details.");
            }
    
  5. 最后,修改文件后读取文件内容:

    using (var fileStream = new FileStream("products.csv", FileMode.Open,
                FileAccess.Read))
            {
                await ReadFile(fileStream);
            }
    
  6. 在 VS Code 集成终端中调用 dotnet run --project Exercise1_12,你将能够看到你刚刚创建的 CSV 文件的内容,以及你刚刚追加的行:

    Model;Memory;Storage;USB Ports;Screen;Condition;Price USD
    Macbook Pro Mid 2012;8GB;500GB HDD;USB 2.0x2;13" screen;Refurbished;400
    Macbook Pro Mid 2014;8GB;512GB SSD;USB 3.0x3;15" screen;Refurbished;750
    Macbook Pro Late 2019;16GB;512GB SSD;USB 3.0x3;15" screen;Refurbished;1250
    One more macbook without details.
    

注意,对于每次运行,程序将追加一行,所以你会看到更多行被添加。

注意

你可以在 packt.link/dUk2z 找到用于此练习的代码。

有时你的程序可能在某个点失败并无法提供输出。这种情况被称为异常错误。下一节将详细介绍此类错误。

异常

异常表示程序在某些原因下未能执行,可能由代码本身或.NET 运行时引发。通常,异常是一个严重的失败,甚至可能终止程序的执行。幸运的是,C# 提供了一种特殊的方式来处理异常,即 try/catch 块:

try
{
// some logic that might throw an exception
}
catch
{
// error handling
}

try块中,你调用可能会抛出异常的代码,而在catch块中,你可以处理所抛出的异常。例如,考虑以下示例:

double Divide(int a, int b) => a/b;

此方法接受两个整数并返回它们之间的除法结果。然而,如果b0会发生什么?在这种情况下,运行时会抛出System.DivideByZeroException,表示无法执行除法。你如何在现实世界的程序中处理这个异常?你将在下一个练习中探索这个问题。

练习 1.13:使用 try/catch 处理无效用户输入

在这个练习中,你将创建一个控制台应用程序,该程序从你那里接收两个输入,将第一个数字除以第二个数字,并输出结果。如果你输入了无效字符,应用程序应该抛出异常,并且所有这些都应该在程序逻辑中处理。

执行以下步骤以完成此练习:

  1. 在 VS Code 集成终端中,创建一个名为Exercise1_13的新控制台应用程序。

  2. Program.cs文件中创建以下方法:

    static double Divide(int a, int b)
    {
        return a / b;
    }
    
  3. 现在,创建一个布尔变量来指示除法是否正确执行。将其初始值设置为false

    bool divisionExecuted = false;
    
  4. 编写一个while循环,检查除法是否成功执行。如果成功了,程序应该终止。如果没有,程序应该提示你输入有效数据并再次执行除法。添加以下代码来完成此操作:

    while (!divisionExecuted)
    {
        try
        {
            Console.WriteLine("Please input a number");
            var a = int.Parse(Console.ReadLine());
            Console.WriteLine("Please input another number");
            var b = int.Parse(Console.ReadLine());
            var result = Divide(a, b);
            Console.WriteLine($"Result: {result}");
            divisionExecuted = true;
        }
        catch (System.FormatException)
        {
            Console.WriteLine("You did not input a number. Let's start again ... \n");
            continue;
        }
        catch (System.DivideByZeroException)
        {
            Console.WriteLine("Tried to divide by zero. Let's start again ... \n");
            continue;
        }
    }
    
  5. 最后,使用dotnet run命令执行程序并与控制台交互。尝试插入字符串而不是数字,看看你会得到什么输出。以下是一个示例输出:

    Please input a number
    5
    Please input another number
    0
    Tried to divide by zero. Let's start again …
    Please input a number
    5
    Please input another number
    s
    You did not input a number. Let's start again …
    Please input a number
    5
    Please input another number
    1
    Result: 5
    

    注意

    你可以在packt.link/EVsrJ找到用于此练习的代码。

在这个练习中,你处理了两种类型的异常,如下所示:

  • 当无法将string变量转换为整数时,int.Parse(string str)方法会抛出System.FormatException异常。

  • double Divide(int a, int b)方法中的b为 0 时,会抛出System.DivideByZeroException异常。

现在你已经看到了如何处理异常,重要的是要注意一个经验法则,这将有助于你在 C#的学习之旅中,那就是你应该只捕获你可以处理或需要处理的异常。只有在少数情况下,异常处理才是真正必要的,如下所示:

  • 当你想抑制异常,即捕获它并假装什么都没发生时。这被称为异常抑制。这应该在抛出的异常不会影响程序流程时发生。

  • 当你想控制程序的执行流程以执行一些替代操作时,就像在前面的练习中所做的那样

  • 当你想捕获一种异常并将其抛出为另一种类型时。例如,在与你的 Web API 通信时,你可能会看到类型为 HttpException 的异常,这表明目标不可达。你可以在这种情况下使用自定义异常,例如 IntegrationException,以更清楚地表明它发生在你的应用程序的某个部分,该部分执行了一些与外部 API 的集成操作。

throw 关键字也可以用于在特定情况下故意停止程序执行流程。例如,考虑你正在创建一个 Person 对象,并且 Name 属性在创建时不应为 null。你可以在这个类上强制执行 System.ArgumentExceptionSystem.ArgumentNullException,如下面的代码片段所示,它使用 ArgumentNullException 来执行此操作:

class Person
{
Person(string name)
     {
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name));
Name = name;
     }
    String Name { get ; set; }
}

在这里,如果 name 参数的值为 null 或如果你只输入空格字符,则会抛出 ArgumentNullException 异常,程序无法成功执行。null/空格条件是通过 IsNullOrWhiteSpace 函数检查的,该函数可用于字符串变量。

现在是时候通过一个活动来练习你在前面的部分中学到的所有内容了。

活动 1.01:创建一个猜数字游戏

要完成这个活动,你需要使用在本章中学习并实践过的概念来创建一个猜数字游戏。在这个游戏中,首先,必须生成一个 1 到 10 之间的随机数,但不能输出到控制台。然后,控制台应提示用户输入一个数字并猜测哪个随机数已被生成,用户最多有五次机会。

每次输入错误时,应显示一个警告消息,让用户知道他们还剩下多少次机会,如果所有五次机会都因猜测错误而耗尽,则程序终止。然而,一旦用户猜对了,在程序终止之前应显示一个成功消息。

以下步骤将帮助你完成这个活动:

  1. 创建一个名为 numberToBeGuessed 的变量,将其分配给 C# 中的随机数。你可以使用以下代码片段来完成此操作:

    new Random().Next(0, 10)
    

这会为你生成一个介于 010 之间的随机数。如果你想使游戏稍微困难一些,可以将 10 替换为一个更大的数字,或者为了使游戏更容易,可以替换为一个更小的数字,但在这个活动中,你将使用 10 作为最大值。

  1. 创建一个名为 remainingChances 的变量,用于存储用户剩余的机会数。

  2. 创建一个名为 numberFound 的变量,并将其分配一个 false 值。

  3. 现在,创建一个while循环,该循环将在仍有机会的情况下执行。在这个循环中,添加代码以输出剩余的机会数量,直到猜出正确的数字。然后,创建一个名为number的变量,它将接收number变量,如果猜对了,将true值分配给numberFound变量。如果没有猜对,剩余的机会数量应该减少1

  4. 最后,添加代码来通知用户他们是否正确猜出了数字。如果他们猜对了,你可以输出类似于恭喜!你用{remainingChanges}次机会猜对了数字!的信息。如果他们用完了机会,输出你用完了机会。数字是{numberToBeGuessed}。

    注意

    本活动的解决方案可以在packt.link/qclbF找到。

摘要

本章为你概述了 C#的基础知识以及如何用它来编写程序。你探索了从变量声明、数据类型、基本算术和逻辑运算符到文件和异常处理的各个方面。你还了解了 C#在处理值和引用类型时如何分配内存。

在本章的练习和活动中,你能够解决一些现实世界的问题,并思考出可以用这种语言及其资源实现解决方案。你学习了如何在控制台应用程序中提示用户输入,如何处理系统中的文件,以及最后如何通过异常处理来处理意外的输入。

下一章将涵盖面向对象编程的精华,深入探讨类和对象的概念。你还将了解编写易于维护的简洁代码的重要性,以及你可以遵循的原则来编写这样的代码。

第二章:2. 构建高质量面向对象代码

概述

在本章中,你将学习如何使用面向对象编程(OOP)简化复杂逻辑。你将首先创建类和对象,然后探索 OOP 的四个支柱。接着,你将了解一些编码的最佳实践,即所谓的 SOLID 原则,并了解如何使用 C# 10 特性来根据这些原则编写有效的代码。到本章结束时,你将能够使用 C#面向对象设计编写干净的代码。

简介

人们如何编写即使经过几十年仍然可维护的软件?在现实世界概念周围建模软件的最佳方式是什么?这两个问题的答案都是面向对象编程(OOP)。OOP 是专业编程中广泛使用的一种范式,在企业环境中尤其有用。

面向对象编程(OOP)可以被视为连接现实世界概念和源代码的桥梁。例如,一只猫具有某些定义属性,如年龄、毛色、眼色和名字。天气可以用温度和湿度等因素来描述。这两个都是人类在长时间内识别和定义的现实世界概念。在 OOP 中,类是帮助定义程序逻辑的东西。当将这些类的属性赋予具体值时,结果就是一个对象。例如,使用 OOP,你可以定义一个表示房屋中房间的类,然后为其属性(颜色和面积)赋值以创建该类的对象。

第一章Hello C#中,你学习了如何使用 C#编写基本程序。在本章中,你将看到如何通过实现 OOP 概念和使用 C#的最佳方式来设计你的代码。

类和对象

一个类就像一个描述概念的蓝图。另一方面,对象是在应用这个蓝图后得到的结果。例如,weather可以是一个类,而25 degrees and cloudless可以指这个类的对象。同样,你可以有一个名为Dog的类,而一只四岁的Spaniel可以代表Dog类的对象。

在 C#中声明一个类很简单。它以class关键字开始,后面跟着类名和一对花括号。要定义一个名为Dog的类,你可以编写以下代码:

class Dog
{
}

目前,这个类只是一个空的骨架。然而,它仍然可以通过使用new关键字来创建对象,如下所示:

Dog dog = new Dog();

这创建了一个名为dog的对象。目前,这个对象是一个空壳,因为它缺少属性。你将在接下来的部分中看到如何为类定义属性,但首先,你将探索构造函数。

构造函数

在 C#中,构造函数是用于创建新对象的函数。您还可以使用它们来设置对象的初始值。像任何函数一样,构造函数有一个名称,接受参数,并且可以重载。一个类必须至少有一个构造函数,但如果需要,它可以有多个具有不同参数的构造函数。即使您没有显式定义单个构造函数,类仍然有一个默认构造函数——它不接受任何参数或执行任何操作,只是为新建的对象及其字段分配内存。

考虑以下代码片段,其中正在声明Dog类的构造函数:

// Within a class named Dog
public class Dog
{
  // Constructor
  public Dog()
  {
    Console.WriteLine("A Dog object has been created");
  }
}

注意

您可以在packt.link/H2lUF找到用于此示例的代码。您可以在packt.link/4WoSX找到代码的用法。

如果一个方法与类的名称相同且不提供return类型,则它是一个构造函数。在这里,代码片段位于名为Dog的类中。因此,构造函数位于指定的代码行内。请注意,通过显式定义此构造函数,您隐藏了默认构造函数。如果有一个或多个这样的自定义构造函数,您将无法再使用默认构造函数。一旦调用新的构造函数,您应该看到控制台打印出此消息:“已创建一个 Dog 对象”。

字段和类成员

您已经知道什么是变量:它有一个类型、一个名称和一个值,正如您在第一章Hello C#中看到的。变量也可以存在于类作用域中,这样的变量被称为字段。声明一个字段就像声明一个局部变量一样简单。唯一的区别是在开始时添加一个关键字,即访问修饰符。例如,您可以使用公共访问修饰符在Dog类中声明一个字段,如下所示:

public string Name = "unnamed";

这行代码表示,Name字段,其值是一个字符串"unnamed",可以公开访问。除了public之外,C#中的其他两个主要访问修饰符是privateprotected,你将在稍后详细了解它们。

注意

您可以在docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/access-modifiers.找到有关访问修饰符的更多信息。

类所持有的所有内容都称为类成员。类成员可以从类外部访问;然而,这种访问需要使用public访问修饰符显式授权。默认情况下,所有成员都具有private访问修饰符。

您可以通过编写对象名称后跟一个点(.)和成员名称来访问类成员。例如,考虑以下代码片段,其中创建了两个Dog类的对象:

Dog sparky = new Dog();
Dog ricky = new Dog();

这里,你可以声明两个独立的变量,sparkyricky。然而,你没有明确地将这些名称分配给对象;注意,这些只是变量名。要使用点表示法将这些名称分配给对象,你可以编写以下代码:

sparky.Name = "Sparky";
ricky.Name = "Ricky";

你现在可以通过一个练习来亲身体验创建类和对象。

练习 2.01:创建类和对象

考虑有两个书籍,都是由名为New Writer的作者所著。第一本书名为First Book,由Publisher 1出版。这本书没有可用的描述。同样,第二本书名为Second Book,由Publisher 2出版。它的描述简单地说:“有趣阅读”。

在这个练习中,你将使用代码来模拟这些书籍。以下步骤将帮助你完成这个练习。

  1. 创建一个名为Book的类。为TitleAuthorPublisherDescription和页数添加字段。你必须从类外部打印这些信息,所以确保每个字段都是public

        public class Book
        {
            public string Title;
            public string Author;
            public string Publisher;
            public int Pages;
            public string Description;
        }
    
  2. 创建一个名为Solution的类,包含Main方法。正如你在第一章Hello C#中看到的,这个包含Main方法的类是应用程序的起点:

        public static class Solution
        {
            public static void Main()
            {
            }
        }
    
  3. Main方法内部,为第一本书创建一个对象并设置字段的值,如下所示:

    Book book1 = new Book();
    book1.Author = "New Writer";
    book1.Title = "First Book";
    book1.Publisher = "Publisher 1";
    

这里,创建了一个名为book1的新对象。通过写点(.)后跟字段名来为不同的字段赋值。第一本书没有描述,所以可以省略book1.Description字段。

  1. 为第二本书重复此步骤。对于这本书,你需要为Description字段设置一个值:

    Book book2 = new Book();
    book2.Author = "New Writer";
    book2.Title = "Second Book";
    book2.Publisher = "Publisher 2";
    book2.Description = "Interesting read";
    

在实践中,你很少会看到具有公共访问修饰符的字段。数据容易更改,你可能在初始化后不希望程序对外部更改开放。

  1. Solution类内部,创建一个名为Print的方法,该方法接受一个Book对象作为参数并打印所有字段及其值。使用字符串插值将书籍信息连接起来,并使用Console.WriteLine()将其打印到控制台,如下所示:

    private static void Print(Book book)
    {
        Console.WriteLine($"Author: {book.Author}, " +
                          $"Title: {book.Title}, " +
                          $"Publisher: {book.Publisher}, " +
                          $"Description: {book.Description}.");
    }
    
  2. Main方法内部,调用book1book2Print方法:

    Print(book1);
    Print(book2);
    

运行此代码后,你将在控制台看到以下输出:

Author: New Writer, Title: First Book, Publisher: Publisher 1, Description: .
Author: New Writer, Title: Second Book, Publisher: Publisher 2, Description: Interesting read.

注意

你可以在packt.link/MGT9b找到这个练习所使用的代码。

在这个练习中,你看到了如何在简单程序中使用字段和类成员。现在继续了解引用类型。

引用类型

假设你有一个对象,但这个对象不是创建的,只是声明的,如下所示:

Dog speedy;

如果你尝试访问其Name值会发生什么?调用speedy.Name会抛出NullReferenceException异常,因为speedy尚未初始化。对象是引用类型,它们的默认值是 null,直到初始化。你已经处理过值类型,例如intfloatdecimal。现在你需要理解值类型和引用类型之间有两个主要区别。

首先,值类型在栈上分配内存,而引用类型在堆上分配内存。栈是内存中的一个临时位置。正如其名所示,在栈中,内存块是堆叠在一起的。当你调用一个函数时,所有局部函数变量最终都会位于栈的一个单独块中。如果你调用一个嵌套函数,该函数的局部变量将分配在另一个栈块中。

在以下图中,你可以看到在执行过程中哪些代码部分会在栈上分配内存,哪些会在堆上分配。方法调用(1、8、10)和局部变量(2、4)将存储在栈上。对象(3、5)及其成员(6)将存储在堆上。栈使用 Push 方法分配数据,使用 Pop 释放数据。当分配内存时,它位于栈顶。当它被释放时,也从栈顶移除。你一旦离开方法的范围,就会从栈上释放内存(8、10、11)。堆更加随机,垃圾回收器(GC)自动释放内存(与一些其他语言不同,你需要自己这样做)。

注意

GC 本身是一个巨大的主题。如果你想了解更多信息,请参阅官方 Microsoft 文档:docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals

图 2.1:栈和堆比较

图 2.1:栈和堆比较

注意

如果你进行了太多的嵌套调用,你会遇到StackoverflowException异常,因为栈内存已耗尽。在栈上释放内存只是退出函数的事情。

第二个区别是,当值类型传递给方法时,它们的值被复制,而对于引用类型,只有引用被复制。这意味着在方法内部可以修改引用类型对象的状态,这与值类型不同,因为引用仅仅是对象的地址。

考虑以下代码片段。这里,一个名为SetTo5的函数将数字的值设置为5

private static void SetTo5(int number)
{
        number = 5;
}

现在,考虑以下代码:

int a = 2;
// a is 2
Console.WriteLine(a);
SetTo5(a);
// a is still 2
Console.WriteLine(a);

这应该产生以下输出:

2
2 

如果你运行此代码,你会发现打印的a的值仍然是2而不是5。这是因为a是一个值类型,传递了值2,因此它的值被复制。在函数内部,你永远不与原始值打交道;总是创建一个副本。

那么引用类型呢?假设你在Dog类内部添加一个名为Owner的字段:

public class Dog
{    public string Owner;
}

创建一个函数ResetOwner,将对象的Owner字段的值设置为None

private static void ResetOwner(Dog dog)
{
    dog.Owner = "None";
}

现在,假设以下代码被执行:

Dog dog = new Dog("speedy");
Console.WriteLine(dog.Owner);
ResetOwner(dog);
// Owner is "None"- changes remain
Console.WriteLine(dog.Owner);

这应该产生以下输出:

speedy
None 

注意

你可以在packt.link/gj164找到用于此示例的代码。

如果你亲自尝试运行这段代码,你将首先在一行上看到名称speedy,然后在另一行上打印出None。这将改变狗的名称,并且这些更改将保留在函数外部。这是因为Dog是一个类,而类是一个引用类型。当传递给函数时,会创建一个引用的副本。然而,引用的副本指向整个对象,因此所做的更改也会保留在外部。

听到你传递一个引用的副本可能会让人困惑。你怎么能确定你正在处理一个副本呢?为了了解这一点,考虑以下函数:

private static void Recreate(Dog dog)
{
    dog = new Dog("Recreated");
}

在这里,创建一个新的对象创建了一个新的引用。如果你改变一个引用类型的值,你正在处理一个完全不同的对象。它可能看起来相同,但存储在内存中的完全不同的地方。为传递的参数创建一个对象将不会影响对象外部的东西。尽管这可能听起来很有用,但你通常应该避免这样做,因为它可以使代码难以理解。

属性

Dog类有一个缺陷。从逻辑上讲,你不会希望一旦分配了狗的名称就改变它。然而,到目前为止,没有任何东西可以阻止改变它。从你可以用它做什么的角度来考虑这个对象。你可以通过设置狗的名称(sparky.Name = "Sparky")或通过调用sparky.Name来获取它。然而,你想要的只是一个只读的名称,可以设置一次。

大多数语言通过设置器和获取器方法来处理这个问题。如果你将public修饰符添加到一个字段上,这意味着它可以被检索(读取)和修改(写入)。不可能只允许这些操作中的任何一个。然而,通过设置器和获取器,你可以限制读取和写入访问。在面向对象编程(OOP)中,限制对对象可以执行的操作是确保数据完整性的关键。在 C#中,你可以使用属性而不是设置器和获取器方法。

在面向对象编程语言(例如 Java)中,要设置或获取名称的值,你会写如下内容:

public string GetName()
{
    return Name;
}
public string SetName (string name)
{
    Name = name;
}

在 C#中,它就像以下这样:

public string Name {get; set;}

这是一个属性,它实际上是一种像字段一样读取的方法。属性有两种类型:获取器和设置器。你可以使用它们执行读取和写入操作。从前面的代码中,如果你移除get,它将变成只写,如果你移除set,它将变成只读。

在内部,属性包括一个带有后置字段的设置器和获取器方法。后置字段只是一个存储值的私有字段,获取器和设置器方法与该值一起工作。你还可以像以下这样编写自定义的获取器和设置器:

private string _owner;
public string Owner
{
    get
    {
        return _owner;
    }
    set
    {
        _owner = value;
    }
}

在前面的片段中,Owner 属性显示了 Dog 类默认的获取器和设置器方法的外观。

就像其他成员一样,属性的各个部分(无论是获取器还是设置器)都可以有自己的访问修饰符,如下所示:

public string Name {get; private set;}

在这种情况下,获取器是 public,而设置器是 private。属性的各个部分(获取器、设置器或两者,根据定义)都继承自属性(在这种情况下是 Name)的访问修饰符,除非明确指定了其他情况(如 private 设置)。如果你不需要设置名称,你可以去掉设置器。如果你需要一个默认值,你可以按照以下方式编写代码:

public string Name {get;} = "unnamed";

这段代码意味着 Name 字段是只读的。你只能通过构造函数设置名称。请注意,这与 private 设置不同,因为后者意味着你仍然可以在 Dog 类内部更改名称。如果没有提供设置器(如这里的情况),你只能在构造函数中设置值。

当你创建一个只读属性时,内部会发生什么?以下代码是由编译器生成的:

private readonly string _name;
public string get_Name()
{
    return _name;
}

这表明获取器和设置器属性仅仅是带有后置字段的简单方法。需要注意的是,如果你有一个名为 Name 的属性,set_Name()get_Name() 方法将被保留,因为这是编译器内部生成的。

你可能在前面的片段中注意到了一个新的关键字 readonly。它表示字段的值只能初始化一次——要么在声明时,要么在构造函数中。

有时,使用属性返回后置字段可能会显得多余。例如,考虑以下片段:

private string _name;

public string Name
{
    get
    {
        return "Dog's name is " + _name;
    }
}

这个代码片段是一个自定义属性。当一个获取器或设置器不仅仅是基本的返回时,你可以以这种方式编写属性,以向其中添加自定义逻辑。这个属性,不会影响狗的原有名称,会在返回名称之前添加 Dog's name is。你可以使用表达式主体属性语法使其更简洁,如下所示:

public string Name => "Dog's name is " + _name;

这段代码与之前的代码做的是同样的事情;=> 操作符表示这是一个只读属性,你返回 => 操作符右侧指定的值。

如果没有设置器,你如何设置初始值?答案是构造函数。在面向对象编程中,构造函数只有一个目的——那就是设置字段的初始值。使用构造函数可以很好地防止创建处于无效状态的对象。

为了给 Dog 类添加一些验证,你可以编写以下代码:

public Dog(string name)
{
  if(string.IsNullOrWhitespace(name))
  {
    throw new ArgumentNullException("name")
  }
  Name = name;
}

你刚才编写的代码将防止在创建 Dog 实例时传递空名称。

值得注意的是,在类内部,你可以访问将要创建的对象本身。这可能听起来有些令人困惑,但通过以下示例应该会变得清晰:

private readonly string name;
public Dog(string name)
{
  this.name = name;
}

this关键字最常用于消除类成员和参数之间的区别。this指的是刚刚创建的对象,因此this.name指的是该对象的名称,而name指的是传递的参数。

创建Dog类的对象并设置名称的初始值现在可以简化为以下形式:

Dog ricky = new Dog("Ricky");
Dog sparky = new Dog("Sparky");

你仍然有一个私有设置器,这意味着你拥有的属性并非完全只读。你仍然可以在类内部更改名称的值。但是,修复这个问题相当简单;你只需简单地移除设置器,它就会变成真正的只读。

注意

你可以在packt.link/hjHRV找到这个示例使用的代码。

对象初始化

通常,一个类有读和写属性。通常,而不是通过构造函数设置属性值,它们是在对象创建后分配的。然而,在 C#中有一个更好的方法——对象初始化。这就是你创建一个新对象并立即设置可变(可读和可写)字段值的地方。如果你需要创建一个Dog类的新对象并将该对象的Owner值设置为Tobias,你可以添加以下代码:

Dog dog = new Dog("Ricky");
dog.Owner = "Tobias";

这可以通过以下对象初始化来完成:

Dog dog = new Dog("Ricky")
{
  Owner = "Tobias"
};

当这些初始属性不是构造函数的一部分时,通常会更简洁。同样的规则也适用于数组和其它集合类型。假设你有两个Dog类的对象,如下所示:

Dog ricky = new Dog("Ricky");
Dog sparky = new Dog("Sparky");

在这种情况下,创建数组的一种方法如下:

Dog[] dogs = new Dog[2];
dogs[0] = ricky;
dogs[1] = sparky;

然而,而不是这样做,你只需添加以下代码,这会更简洁:

Dog[] dogs = {ricky, sparky};

在 C# 10 中,如果你可以从声明中推断出类型,你可以简化对象初始化而不提供类型,如下面的代码所示:

Dog dog = new("Dog");

比较函数和方法

到目前为止,你可能经常看到“函数”和“方法”这两个术语被频繁使用,几乎可以互换。现在继续深入了解函数和方法。函数是一段你可以通过其名称和一些输入调用的代码块。方法是存在于类中的函数。

然而,在 C#中,你不能在类外有函数。因此,在 C#中,每个函数都是一个方法。许多语言,尤其是非面向对象的语言,只有一些函数可以被称作方法(例如,JavaScript)。

类的行为是通过方法定义的。你已经为Dog类定义了一些行为,即获取其名称。为了完成这个类的行为实现,你可以实现一些现实世界的类比,比如坐下和吠叫。这两个方法都将从外部调用:

public void Sit()
{
    // Implementation of how a dog sits
}
public void Bark()
{
    // Implementation of how a dog barks 
}

你可以这样调用这两个方法:

Ricky.Sit();
Sparky.Bark();

在大多数情况下,最好避免公开数据,因此你应该只公开函数。在这里,你可能想知道,属性怎么办?属性只是获取器和设置器函数;它们与数据一起工作,但本身不是数据。你应该避免直接公开数据,原因和你锁门或把手机放在手机套里一样。如果数据是公开的,任何人都可以无限制地访问它。

此外,当程序需要数据保持不变时,数据不应发生变化。方法是一种确保对象不被以无效方式使用的机制,如果它被这样使用,它会被妥善处理。

如果你需要在整个应用程序中持续验证字段,那么,属性,即获取器和设置器方法,可以帮到你。你可以限制对数据的操作,并添加验证逻辑。属性帮助你完全控制如何获取和设置数据。属性很方便,但使用它们时需要谨慎。如果你想做一些复杂的事情,需要额外的计算,那么使用方法会更合适。

例如,假设你有一个由物品组成的库存类,每个物品都有一些重量。在这里,可能有一个属性来返回最重的物品是有意义的。如果你选择通过属性(称为MaxWeight)来做这件事,你可能会得到意想不到的结果;获取最重的物品需要遍历所有物品的集合,并按重量找到最大值。这个过程并不像你预期的那么快。实际上,在某些情况下,它甚至可能会抛出错误。属性应该有简单的逻辑,否则与它们一起工作可能会产生意想不到的结果。因此,当需要计算密集型的属性时,考虑将它们重构为方法。在这种情况下,你会将MaxWeight属性重构为GetMaxWeight方法。

应该避免使用属性来返回复杂计算的结果,因为调用属性可能会很昂贵。获取或设置字段的值应该是直接的。如果变得昂贵,它就不再被视为属性。

有效的类

Dog类模拟了一个dog对象;因此,它可以被称为模型。一些开发者更喜欢在数据和逻辑之间保持严格的分离。其他人则试图在模型中放入尽可能多的逻辑,只要它是自包含的。这里没有对错之分。这完全取决于你正在工作的上下文。

注意

这个讨论超出了本章的范围,但如果你想了解更多,可以参考关于领域驱动设计(DDD)的讨论,请参阅martinfowler.com/bliki/DomainDrivenDesign.xhtml

很难确定一个有效的类是什么样的。然而,在决定一个方法更适合类 A 还是类 B 时,试着问自己这些问题:

  • 一个非程序员是否知道你在谈论类?它是否是现实世界概念的逻辑表示?

  • 这个类有多少理由需要改变?是只有一个还是更多?

  • 私有数据是否与公共行为紧密相关?

  • 这个类有多频繁地改变?

  • 破坏代码有多容易?

  • 这个类是否可以自行执行某些操作?

高聚合性是一个用来描述一个类,其所有成员不仅语义上,而且在逻辑上都有强烈关联的术语。相比之下,低聚合性的类具有松散相关的方法和字段,这些方法可能有一个更好的位置。这样的类效率低下,因为它可能因为多个原因而改变,并且你不能期望在其中找到任何东西,因为它根本没有任何强烈的逻辑意义。

例如,Computer 类的一部分可能看起来像这样:

class Computer
{
    private readonly Key[] keys;
}

然而,Computerkeys 并不在同一级别上相关。可能还有一个更适合 Key 类的类,那就是 Keyboard

class Computer
{
    private readonly Keyboard keyboard;
}
class Keyboard
{
    private readonly Key[] keys;
}

注意

你可以在 packt.link/FFcDa 找到这个示例所使用的代码。

键盘与键直接相关,就像它与电脑直接相关一样。在这里,KeyboardComputer 类都具有高度的聚合性,因为它们的依赖关系有一个稳定的逻辑位置。你现在可以通过一个练习来了解更多关于它的信息。

练习 2.02:比较不同形状所占的面积

你有两个后院的部分,一个有圆形瓷砖,另一个有矩形瓷砖。你想要拆解后院的一个部分,但你不确定应该拆哪个。显然,你希望尽可能少地造成混乱,并决定选择占据面积最少的部分。

给定两个数组,一个用于不同尺寸的矩形瓷砖,另一个用于不同尺寸的圆形瓷砖,你需要找到要拆解的部分。这个练习的目标是输出占据面积较小的部分的名字,即 rectangularcircular

执行以下步骤来完成:

  1. 创建一个 Rectangle 类,如下所示。它应该有 widthheightarea 字段:

    public class Rectangle
    {
        private readonly double _width;
        private readonly double _height;
        public double Area
        {
            get
            {
                return _width * _height;
            }
        } 
    
        public Rectangle(double width, double height)
        {
            _width = width;
            _height = height;
        }
    }
    

在这里,_width_height 已经被设置为不可变的,使用了 readonly 关键字。选择的数据类型是 double,因为你将执行 math 操作。唯一公开暴露的属性是 Area。它将返回一个简单的计算:宽度和高度的乘积。Rectangle 是不可变的,所以它只需要通过构造函数传递一次,之后就会保持不变。

  1. 类似地,创建一个 Circle 类,如下所示:

    public class Circle
    {
        private readonly double _radius;
    
        public Circle(double radius)
        {
            _radius = radius;
        }
    
        public double Area
        {
            get { return Math.PI * _radius * _radius; }
        }
    }
    

Circle 类与 Rectangle 类相似,除了它没有宽度和高度,而是有 radius,并且 Area 计算使用不同的公式。这里使用了常数 PI,它可以从 Math 命名空间中访问。

  1. 创建一个名为 Solution 的类,并有一个名为 Solve 的骨架方法:

    public static class Solution
    {
        public const string Equal = "equal";
        public const string Rectangular = "rectangular";
        public const string Circular = "circular";
        public static string Solve(Rectangle[] rectangularSection, Circle[] circularSection)
        {
            var totalAreaOfRectangles = CalculateTotalAreaOfRectangles(rectangularSection);
            var totalAreaOfCircles = CalculateTotalAreaOfCircles(circularSection);
            return GetBigger(totalAreaOfRectangles, totalAreaOfCircles);
        }
    }
    

在这里,Solution类展示了代码的工作方式。目前,有三个基于要求的常量(哪个部分更大?矩形还是圆形,或者它们是否相等?)。流程将是先计算矩形的总面积,然后是圆形的总面积,最后返回更大的面积。

在实现解决方案之前,你必须首先创建用于计算矩形部分总面积、圆形部分总面积以及比较两者的辅助方法。你将在接下来的几个步骤中完成这项工作。

  1. Solution类内部,添加一个方法来计算矩形部分的总体面积:

    private static double CalculateTotalAreaOfRectangles(Rectangle[] rectangularSection)
    {
        double totalAreaOfRectangles = 0;
        foreach (var rectangle in rectangularSection)
        {
            totalAreaOfRectangles += rectangle.Area;
        }
    
        return totalAreaOfRectangles;
    }
    

此方法遍历所有矩形,获取每个矩形的面积,并将其添加到总和中。

  1. 同样,添加一个方法来计算圆形部分的总体面积:

    private static double CalculateTotalAreaOfCircles(Circle[] circularSection)
    {
        double totalAreaOfCircles = 0;
        foreach (var circle in circularSection)
        {
            totalAreaOfCircles += circle.Area;
        }
    
        return totalAreaOfCircles;
    }
    
  2. 接下来,添加一个方法来获取更大的面积,如下所示:

    private static string GetBigger(double totalAreaOfRectangles, double totalAreaOfCircles)
    {
        const double margin = 0.01;
        bool areAlmostEqual = Math.Abs(totalAreaOfRectangles - totalAreaOfCircles) <= margin;
        if (areAlmostEqual)
        {
            return Equal;
        }
        else if (totalAreaOfRectangles > totalAreaOfCircles)
        {
            return Rectangular;
        }
        else
        {
            return Circular;
        }
    }
    

这个片段包含了最有趣的部分。在大多数语言中,带有小数点的数字并不精确。事实上,在大多数情况下,如果 a 和 b 是浮点数或双精度浮点数,它们可能永远不会相等。因此,在比较这样的数字时,你必须考虑精度。

在此代码中,你定义了边距,以便在比较时,当数字被认为是相等的时候(例如,0.001 和 0.0011 在这种情况下将是相等的,因为边距是 0.01)。之后,你可以进行常规比较,并返回面积最大的部分。

  1. 现在,创建Main方法,如下所示:

    public static void Main()
    { 
        string compare1 = Solve(new Rectangle[0], new Circle[0]);
        string compare2 = Solve(new[] { new Rectangle(1, 5)}, new Circle[0]);
        string compare3 = Solve(new Rectangle[0], new[] { new Circle(1) });
        string compare4 = Solve(new []
        {
            new Rectangle(5.0, 2.1), 
            new Rectangle(3, 3), 
        }, new[]
        {
            new Circle(1),
            new Circle(10), 
        });
    
        Console.WriteLine($"compare1 is {compare1}, " +
                          $"compare2 is {compare2}, " +
                          $"compare3 is {compare3}, " +
                          $"compare4 is {compare4}.");
    }
    

在这里,创建了四组形状以进行比较。compare1有两个空的部分,这意味着它们应该是相等的。compare2有一个矩形但没有圆形,所以矩形更大。compare3有一个圆形但没有矩形,所以圆形更大。最后,compare4既有矩形也有圆形,但圆形的总面积更大。你使用了字符串插值在Console.WriteLine内部打印结果。

  1. 运行代码。你应该会在控制台看到以下输出:

    compare1 is equal, compare2 is rectangular, compare3 is circular, compare4 is circular.
    

    注意

    你可以在packt.link/tfDCw找到用于此练习的代码。

如果你没有对象会怎样?在这种情况下,部分由什么组成?对于一个圆,只传递半径可能是可行的,但对于矩形,你需要传递另一个包含宽度和高度的共线数组。

面向对象的代码非常适合将相似的数据和逻辑组合在一个外壳下,即一个类,并传递这些类对象。通过这种方式,你可以通过简单的类交互来简化复杂的逻辑。

现在,你将了解面向对象编程的四个支柱。

面向对象编程的四个支柱

高效的代码应该易于理解和维护,面向对象编程努力实现这种简单性。面向对象设计的整个概念基于四个主要原则,也称为面向对象编程的四个支柱。

封装

面向对象编程的第一个支柱是封装。它定义了数据和行为的关联,放置在同一壳中,即类。它指的是只公开必要的内容,隐藏其他所有内容的需求。当你考虑封装时,要考虑代码安全性的重要性:如果你泄露了密码,返回了机密数据,或者公开了 API 密钥,会发生什么?粗心大意往往会导致难以修复的损害。

安全性不仅限于防止恶意意图,还扩展到防止人为错误。人类容易犯错。事实上,可供选择的项目越多,他们犯错的概率就越高。封装在这方面有所帮助,因为你可以简单地限制将代码提供给使用代码的人的选项数量。

应该默认阻止所有访问,并在必要时才授予明确的访问权限。例如,考虑一个简化的 LoginService 类:

public class LoginService
{
    // Could be a dictionary, but we will use a simplified example.
    private string[] _usernames;
    private string[] _passwords;

    public bool Login(string username, string password)
    {
        // Do a password lookup based on username
        bool isLoggedIn = true;
        return isLoggedIn;
    }
}

这个类有两个 private 字段:_usernames_passwords。这里需要注意的是,密码和用户名对公众不可访问,但你仍然可以通过在 Login 方法中公开足够的逻辑来实现所需的功能。

注意

你可以在 packt.link/6SO7a 找到用于此示例的代码。

继承

警察可以逮捕某人,邮递员投递邮件,教师教授一个或多个科目。他们每个人都执行着广泛不同的职责,但他们有什么共同点?在现实世界的背景下,他们都是人类。他们都有名字、年龄、身高和体重。如果你要为每个角色建模,你需要创建三个类。这些类除了每个类都有一个独特的方法之外,看起来都一样。你如何在代码中表达他们都是人类的事实?

解决这个问题的关键是继承。它允许你从父类中获取所有属性并将其转移到子类中。继承还定义了一个“是”关系。警察、邮递员和教师都是人类,因此你可以使用继承。你现在将用代码写下这一点。

  1. 创建一个具有 nameageweightheight 字段的 Human 类:

    public class Human
    {
        public string Name { get; }
        public int Age { get; }
        public float Weight { get; }
        public float Height { get; }
    
        public Human(string name, int age, float weight, float height)
        {
            Name = name;
            Age = age;
            Weight = weight;
            Height = height;
        }
    }
    
  2. 邮递员是人类。因此,Mailman 类应该拥有 Human 类的所有功能,但在此基础上,它还应该具有能够投递邮件的附加功能。按照以下方式编写代码:

    public class Mailman : Human
    {
        public Mailman(string name, int age, float weight, float height) : base(name, age, weight, height)
        {
        }
    
        public void DeliverMail(Mail mail)
        {
           // Delivering Mail...
        }
    }
    

现在,仔细看看 Mailman 类。编写 class Mailman : Human 表示 Mailman 继承自 Human。这意味着 Mailman 会继承 Human 中的所有属性和方法。你还可以看到一个新关键字,base。这个关键字用于告诉在创建 Mailman 时将使用哪个父构造函数;在这种情况下,是 Human

  1. 接下来,创建一个名为 Mail 的类来表示邮件,该类包含一个用于将消息发送到地址的字段:

    public class Mail
    {
       public string Message { get; }
       public string Address { get; }
    
       public Mail(string message, string address)
       {
           Message = message;
           Address = address;
       }
    }
    

创建一个 Mailman 对象与创建一个不使用继承的类的对象没有区别。

  1. 创建 mailmanmail 变量,并告诉 mailman 按以下方式投递邮件:

    var mailman = new Mailman("Thomas", 29, 78.5f, 190.11f);
    var mail = new Mail("Hello", "Somewhere far far way");
    mailman.DeliverMail(mail);
    

    注意

    你可以在 packt.link/w1bbf 找到用于此示例的代码。

在前面的代码片段中,你创建了 mailmanmail 变量。然后,你告诉 mailman 投递 mail

通常,在定义子构造函数时必须提供一个基构造函数。这个规则的唯一例外是当父类有一个无参构造函数。如果基构造函数不带参数,那么使用基构造函数的子构造函数将是多余的,因此可以忽略。例如,考虑以下代码片段:

Public class A
{
}
Public class B : A
{
}

A 没有自定义构造函数,因此实现 B 也不需要自定义构造函数。

在 C# 中,只能继承一个类;然而,你可以有多层深度的继承。例如,你可以有一个名为 RegionalMailmanMailman 子类,它将负责一个单一的区域。这样,你可以进一步深入,并为 RegionalMailman 创建另一个子类,称为 RegionalBillingMailman,然后是 EuropeanRegionalBillingMailman,依此类推。

在使用继承时,重要的是要知道即使所有内容都被继承,并不是所有内容都是可见的。就像之前一样,只有 public 成员才能从父类访问。然而,在 C# 中,有一个特殊的修饰符,称为 protected,它就像 private 修饰符一样工作。它允许子类访问 protected 成员(就像 public 成员一样),但阻止它们从类外部访问(就像 private 一样)。

数十年前,继承曾是许多问题的答案和代码重用的关键。然而,随着时间的推移,人们逐渐意识到使用继承是有代价的,这个代价是耦合。当你应用继承时,你会将子类与父类耦合起来。深度继承会将类作用域从父类一直扩展到子类。继承越深,作用域就越深。为了避免与全局变量相同的原因,应该避免深度继承(两个或更多级别),因为它很难知道来源是什么,也很难控制状态变化。这反过来使得代码难以维护。

没有人想编写重复的代码,但替代方案是什么?答案是组合。就像计算机由不同的部分组成一样,代码也应该由不同的部分组成。例如,想象你正在开发一个 2D 游戏,并且它有一个 Tile 对象。一些地砖包含陷阱,而一些地砖可以移动。使用继承,你可以这样编写代码:

class Tile
{
}
class MovingTile : Tile
{
    public void Move() {}
}
class TrapTile : Tile
{
    public void Damage() {}
}
//class MovingTrapTile : ?

这种方法在面临更复杂的需求时仍然有效。如果有些瓦片既是陷阱又能移动,该怎么办?你应该从移动瓦片继承并重写那里的 TrapTile 功能吗?你能同时继承两个吗?正如你所见,你一次不能继承多个类,因此,如果你要使用继承来实现这一点,你将被迫使情况复杂化,并重写一些代码。相反,你可以考虑不同瓦片包含的内容。TrapTile 有一个陷阱。MovingTile 有一个电机。

它们两者都代表瓦片,但它们各自额外的功能应该来自不同的组件,而不是子类。如果你想要将其作为一个基于组合的方法,你需要进行大量的重构。

为了解决这个问题,保持 Tile 类不变:

class Tile
{
}

现在,添加两个组件——电机和陷阱类。这样的组件作为逻辑提供者。目前,它们什么也不做:

class Motor
{
    public void Move() { }
}
class Trap
{
    public void Damage() { }
}

注意

你可以在 packt.link/espfn 找到用于此示例的代码。

接下来,你定义一个名为 MovingTile 的类,它包含一个单一组件 _motor。在组合中,组件很少会动态变化。你不应该暴露类的内部结构,因此应用 private readonly 修饰符。组件本身可以有一个子类或发生变化,因此不应该从构造函数中创建。相反,它应该作为参数传递(参见高亮代码):

class MovingTile : Tile
{
    private readonly Motor _motor;

    public MovingTile(Motor motor)
    {
        _motor = motor;
    } 

    public void Move()
    {
        _motor.Move();
    }
}

注意,Move 方法现在调用 _motor.Move()。这就是组合的本质;持有组合的类通常本身并不做什么。它只是将逻辑调用的调用委托给其组件。实际上,尽管这只是一个示例类,但一个真正的游戏类看起来会非常相似。

你将为 TrapTile 执行相同的操作,除了它将包含一个 Trap 组件而不是 Motor

class TrapTile : Tile
{
    private readonly Trap _trap;

    public TrapTile(Trap trap)
    {
        _trap = trap;
    }

    public void Damage()
    {
        _trap.Damage();
    }
}

最后,是时候创建 MovingTrapTile 类了。它有两个组件,为 MoveDamage 方法提供逻辑。同样,这两个方法作为参数传递给构造函数:

class MovingTrapTile : Tile
{
    private readonly Motor _motor;
    private readonly Trap _trap;

    public MovingTrapTile(Motor motor, Trap trap)
    {
        _motor = motor;
        _trap = trap;
    }
    public void Move()
    {
        _motor.Move();
    }
    public void Damage()
    {
        _trap.Damage();
    }
}

注意

你可以在 packt.link/SX4qG 找到用于此示例的代码。

可能看起来这个类重复了其他类中的一些代码,但这种重复是可以忽略不计的,而且带来的好处是值得的。毕竟,最大的逻辑块来自组件本身,重复的字段或调用并不重要。

你可能已经注意到,尽管没有将 Tile 提取为其他类的组件,但你继承了 Tile。这是因为 Tile 是所有继承它的类的本质。无论瓦片是什么类型,它仍然是一个瓦片。继承是面向对象编程的第二个支柱。它强大且有用。然而,要正确使用继承可能很困难,因为为了可维护性,它确实需要非常清晰和逻辑。在考虑是否应该使用继承时,考虑以下因素:

  • 不深入(理想情况下为单级)。

  • 逻辑性(is-a 关系,正如你在你的拼图示例中看到的那样)。

  • 类之间的关系在未来几乎不会改变,且不太可能经常被修改。

  • 纯加性(子类不应使用父类成员,除了构造函数)。

如果违反了这些规则中的任何一个,建议使用组合而不是继承。

多态性

面向对象编程的第三个支柱是多态性。为了掌握这个支柱,查看这个单词的含义是有用的。ThomasThomas既是人类也是邮递员。MailmanThomas的专门形式,而HumanThomas的通用形式。然而,你可以通过这两种形式中的任何一种与Thomas进行交互。

如果你不知道每个人类的职责,你可以使用抽象类。

抽象类是未完成类的同义词。这意味着它不能被初始化。这也意味着如果你用abstract关键字标记它们,其中一些方法可能没有实现。你可以为Human类实现如下:

public abstract class Human
{
    public string Name { get; }

    protected Human(string name)
    {
        Name = name;
    }

    public abstract void Work();
}

你在这里创建了一个抽象(不完整)的Human类。与之前的不同之处在于,你将abstract关键字应用于类,并添加了一个新的abstract方法,public abstract void Work()。你还改变了构造函数为受保护的,这样它就只能从子类中访问。这是因为如果你不能创建抽象类,它就不再有public的意义;你不能调用public构造函数。从逻辑上讲,这意味着Human类本身没有意义,它只有在你在其他地方(即在子类中)实现了Work方法之后才有意义。

现在,你将更新Mailman类。它没有太大变化;它只是增加了一个额外的方法,即Work()。为了为抽象方法提供实现,你必须使用override关键字。通常,这个关键字用于在子类内部更改现有方法的实现。你将在稍后详细探讨这一点:

public override void Work()
{
    Console.WriteLine("A mailman is delivering mails.");
}

如果你为这个类创建一个新的对象并调用Work方法,它会在控制台打印出"A mailman is delivering mails."。为了全面了解多态性,你现在将创建另一个类,Teacher

public class Teacher : Human
{
    public Teacher(string name, int age, float weight, float height) : base(name, age, weight, height)
    {
    }

    public override void Work()
    {
        Console.WriteLine("A teacher is teaching.");
    }
}

这个类几乎与Mailman相同;然而,提供了Work方法的不同实现。因此,你有了两个以两种不同方式做同样事情的类。调用同名方法但得到不同行为的行为称为多态性。

你已经了解了方法重载(不要与覆盖混淆),这是当你有相同名称但不同输入的方法时。这被称为静态多态性,它发生在编译时。以下是一个例子:

public class Person
{
    public void Say()
    {
        Console.WriteLine("Hello");
    }

    public void Say(string words)
    {
        Console.WriteLine(words);
    }
}

Person类有两个同名的方法,Say。一个不接受任何参数,另一个接受一个字符串参数。根据传递的参数,将调用不同实现的方法。如果没有传递任何内容,将打印"Hello"。否则,将打印你传递的单词。

在面向对象编程的上下文中,多态被称为动态多态,它发生在运行时。在本章的其余部分,多态应理解为动态多态。

多态的好处是什么?

老师是人,老师的工作方式是通过教学。这和邮递员不同,但老师也有名字、年龄、体重和身高,就像邮递员一样。多态允许你以相同的方式与两者交互,无论它们的特殊形式如何。最好的说明方式是将两者存储在humans值的数组中,并让它们工作:

Mailman mailman = new Mailman("Thomas", 29, 78.5f, 190.11f);
Teacher teacher = new Teacher("Gareth", 35, 100.5f, 186.49f);
// Specialized types can be stored as their generalized forms.
Human[] humans = {mailman, teacher};
// Interacting with different human types
// as if they were the same type- polymorphism.
foreach (var human in humans)
{
    human.Work();
}

这段代码会导致以下内容在控制台打印出来:

A mailman is delivering mails.
A teacher is teaching.

注意

你可以在packt.link/ovqru找到用于此示例的代码。

这段代码展示了多态的作用。你将MailmanTeacher都视为Human,并为两者实现了Work方法。结果是每种情况都有不同的行为。这里要注意的重要点是,你不必关心Human的确切实现来实施Work

没有多态,你将如何实现这一点?你需要根据对象的精确类型编写if语句来找到它应该使用的行为:

foreach (var human in humans)
{
    Type humanType = human.GetType();
    if (humanType == typeof(Mailman))
    {
        Console.WriteLine("Mailman is working...");
    }
    else
    {
        Console.WriteLine("Teaching");
    }
}

如你所见,这要复杂得多,也更难理解。当你遇到许多if语句的情况时,请记住这个例子。多态可以通过将每个分支的代码移动到子类中并简化交互来消除所有这些分支代码的负担。

如果你想打印有关某人的信息呢?考虑以下代码:

Human[] humans = {mailman, teacher};
foreach (var human in humans)
{
    Console.WriteLine(human);
}

运行此代码会导致对象类型名称被打印到控制台:

Chapter02.Examples.Professions.Mailman
Chapter02.Examples.Professions.Teacher

在 C#中,所有内容都从System.Object类派生,所以 C#中的每个类型都有一个名为ToString()的方法。每个类型都有自己的方法实现,这是多态的另一个例子,在 C#中广泛使用。

注意

ToString()Work()不同,因为它提供了一个默认实现。你可以使用virtual关键字来实现这一点,这将在本章后面详细讨论。从子类的角度来看,使用virtualabstract关键字是相同的。如果你想改变或提供行为,你将重写该方法。

在下面的代码片段中,一个Human对象被赋予了一个自定义的ToString()方法实现:

public override string ToString()
{
    return $"{nameof(Name)}: {Name}," +
           $"{nameof(Age)}: {Age}," +
           $"{nameof(Weight)}: {Weight}," +
           $"{nameof(Height)}: {Height}";
}

尝试在同一个 foreach 循环中打印关于人类的信息会导致以下输出:

Name: Thomas,Age: 29,Weight: 78.5,Height: 190.11
Name: Gareth,Age: 35,Weight: 100.5,Height: 186.49

注意

你可以在packt.link/EGDkC找到用于此示例的代码。

多态是在处理缺失类型信息时使用不同底层行为的最有效方法之一。

抽象

面向对象编程(OOP)的最后一根支柱是抽象。有人说 OOP 只有三个支柱,因为抽象并没有真正引入很多新的东西。抽象鼓励你隐藏实现细节并简化对象之间的交互。每当你需要仅一个通用形式的功能时,你不应该依赖于它的实现。

抽象可以通过一个例子来说明人们如何与他们的电脑互动。当你打开电脑时,内部电路会发生什么?大多数人可能没有头绪,这是正常的。如果你只需要使用某些功能,你不需要了解内部工作原理。你所需要知道的是你可以做什么,而不是它是如何工作的。你知道你可以通过按按钮来打开和关闭电脑,而所有复杂的细节都被隐藏起来。抽象对其他三个支柱的贡献很小,因为它反映了它们每一个。抽象与封装相似,因为它隐藏了不必要的细节以简化交互。它也类似于多态,因为它可以与不知道其确切类型的对象交互。最后,继承只是创建抽象的一种方式。

在创建函数时,你不需要提供通过实现类型传递的不必要细节。以下示例说明了这个问题。你需要创建一个进度条。它应该跟踪当前进度,并应将进度增加到一定点。你可以创建一个带有设置器和获取器的基本类,如下所示:

public class ProgressBar
{
    public float Current { get; set; }
    public float Max { get; }

    public ProgressBar(float current, float max)
    {
        Max = max;
        Current = current;
    }
}

以下代码演示了如何初始化一个进度条,该进度条从0进度开始,增加到100。其余的代码说明了当你想要将新的进度设置为120时会发生什么。进度不能超过Max,因此,如果它超过bar.Max,它应该保持在bar.Max。否则,你可以使用你设置的值更新新的进度。最后,你需要检查进度是否完成(达到Max值)。为此,你需要比较增量与允许的误差容忍度范围(0.0001)。如果进度条接近容忍度,则表示进度条已完成。因此,更新进度的代码可能如下所示:

var bar = new ProgressBar(0, 100);
var newProgress = 120;
if (newProgress > bar.Max)
{
    bar.Current = bar.Max;
}
else
{
    bar.Current = newProgress;
}

const double tolerance = 0.0001;
var isComplete = Math.Abs(bar.Max - bar.Current) < tolerance;

这段代码完成了所需的功能,但需要一个函数的很多细节。想象一下,如果你在其他代码中使用这段代码,你需要再次执行相同的检查。换句话说,它容易实现但复杂难用。在类内部,你拥有的内容很少。一个强烈的迹象是,你总是调用对象,而不是在类内部做些事情。公开地,可能会通过忘记检查进度的Max值并将它设置为某个高值或负值来破坏对象状态。你写的代码耦合度低,因为要改变ProgressBar,你不会在类内部做,而是在类外部做。你需要创建一个更好的抽象。

考虑以下代码片段:

public class ProgressBar
{
    private const float Tolerance = 0.001f;

    private float _current;
    public float Current
    {
        get => _current;
        set
        {
            if (value >= Max)
            {
                _current = Max;
            }
            else if (value < 0)
            {
                _current = 0;
            }
            else
            {
                _current = value;
            }
        }
    }

使用这段代码,你隐藏了细节。当涉及到更新进度和定义容差时,这由ProgressBar类来决定。在重构的代码中,你有一个属性Current,它有一个后端字段_current来存储进度。属性设置器检查进度是否超过最大值,如果是,则不允许将_current的值设置为更高的值,即=。它也不能是负数,因为在这种情况下,值将被调整为0。最后,如果它既不是负数也不超过最大值,那么你可以将_current设置为传递的任何值。

显然,这段代码使得与ProgressBar类的交互变得更加简单:

var bar = new ProgressBar(0, 100);
bar.Current = 120;
bool isComplete = bar.IsComplete;

你不能破坏任何东西;你没有任何额外的选择,你所能做的只是通过最小化方法定义。当你被要求实现一个功能时,不建议做超过所需的事情。尽量保持最小化和简单化,因为这对于有效的代码至关重要。

记住,优秀的抽象代码充满了对读者的同理心。仅仅因为今天实现一个类或函数很容易,你也不应该忘记明天。需求会变化,实现也会变化,但结构应该保持稳定,否则你的代码很容易崩溃。

注意

你可以在packt.link/U126i找到用于此示例的代码。GitHub 中给出的代码分为两个对比鲜明的示例——ProgressBarGoodProgressBarBad。这两个代码都是简单的ProgressBar,但被明确地命名以避免歧义。

接口

之前提到过,继承并不是设计代码的正确方式。然而,你希望拥有高效的抽象以及多态性的支持,并且尽量减少耦合。如果你想要拥有机器人或蚂蚁工人呢?它们没有名字。诸如身高和体重等信息都是无关紧要的。而且从Human类继承几乎没有什么意义。使用接口可以解决这个问题。

在 C# 中,按照惯例,接口的命名以字母 I 开头,后跟其实际名称。接口是一个合同,它声明了一个类可以做什么。它没有任何实现。它只为实现它的每个类定义行为。现在,你将使用接口重构人类示例。

Human 类的对象能做什么?它可以工作。谁或什么能做工作?一个工人。现在,考虑以下片段:

public interface IWorker
{
    void Work();
}

注意

接口 Work 方法将与接口访问修饰符相同,在这种情况下,public

蚂蚁不是人类,但它可以工作。通过接口,将蚂蚁抽象为工人是直接的:

public class Ant : IWorker
{
    public void Work()
    {
        Console.WriteLine("Ant is working hard.");
    }
}

同样,机器人不是人类,但它也可以工作:

public class Robot : IWorker
{
    public void Work()
    {
        Console.WriteLine("Beep boop- I am working.");
    }
}

如果你引用 Human 类,你可以将其定义更改为 public abstract class Human : IWorker。这可以读作:Human 类实现了 IWorker 接口。

在下一个片段中,Mailman 继承了 Human 类,该类实现了 IWorker 接口:

public class Mailman : Human
{
    public Mailman(string name, int age, float weight, float height) : base(name, age, weight, height)
    {
    }

    public void DeliverMail(Mail mail)
    {
        // Delivering Mail...
    }

    public override void Work()
    {
        Console.WriteLine("Mailman is working...");
    }
}

如果子类继承了一个实现了某些接口的父类,则子类默认也能实现相同的接口。然而,Human 是一个抽象类,你必须提供 abstract void Work 方法的实现。

如果有人问人类、蚂蚁和机器人有什么共同点,你可以说它们都可以工作。你可以模拟这种情况如下:

IWorker human = new Mailman("Thomas", 29, 78.5f, 190.11f);
IWorker ant = new Ant();
IWorker robot = new Robot();

IWorker[] workers = {human, ant, robot};
foreach (var worker in workers)
{
    worker.Work();
}

这将在控制台打印以下内容:

Mailman is working...
Ant is working hard.
Beep boop- I am working.

注意

你可以在 packt.link/FE2ag 找到用于示例的代码。

C# 不支持多重继承。然而,可以实现多个接口。实现多个接口不算作多重继承。例如,为了实现 Drone 类,你可以添加一个 IFlyer 接口:

public interface IFlyer
{
    void Fly();
}

Drone 是一个既能飞行又能做些工作的飞行物体;因此它可以表达如下:

public class Drone : IFlyer, IWorker
{
    public void Fly()
    {
        Console.WriteLine("Flying");
    }

    public void Work()
    {
        Console.WriteLine("Working");
    }
}

列出多个接口并用逗号分隔意味着该类实现了它们中的每一个。你可以组合任意数量的接口,但尽量不要过度。有时,两个接口的组合构成一个逻辑抽象。如果每个无人机都能飞行并做一些工作,你可以在代码中这样写:

public interface IDrone : IWorker, IFlyer
{
}

Drone 类简化为 public class Drone : IDrone

还可以将接口与基类(但不超过一个基类)混合。如果你想表示一个会飞的蚂蚁,你可以编写以下代码:

public class FlyingAnt : Ant, IFlyer
{
    public void Fly()
    {
        Console.WriteLine("Flying");
    }
}

显然,接口是最好的抽象方式,因为它不强迫你依赖任何实现细节。所需的一切只是已经定义的逻辑概念。实现可能会变化,但类之间关系背后的逻辑不会。

如果一个接口定义了一个类可以做什么,那么是否也可以定义一个公共数据的契约?绝对可以。接口持有行为,因此它也可以持有属性,因为它们定义了设置器和获取器行为。例如,你应该能够跟踪无人机,为此,它应该是可识别的,也就是说,它需要一个 ID。这可以编码如下:

public interface IIdentifiable
{
    long Id { get; }
}
public interface IDrone : IWorker, IFlyer 
{
}

在现代软件开发中,程序员每天都会使用一些复杂的底层细节。然而,他们通常并不知道这一点。如果你想创建一个具有大量逻辑和易于理解的代码的可维护代码库,你应该遵循以下抽象原则:

  • 保持简单和小巧。

  • 不要依赖于细节。

  • 隐藏复杂性。

  • 只暴露必要的部分。

通过这个练习,你将掌握面向对象编程的工作原理。

练习 2.03:后院铺地板

一个建造者正在用马赛克覆盖 x 平方米的面积。你有一些剩余的瓷砖,它们要么是矩形的,要么是圆形的。在这个练习中,你需要找出,如果你将瓷砖打碎以完美填充它们占据的面积,这些瓷砖是否可以完全填满马赛克。

你将编写一个程序,如果马赛克可以用瓷砖覆盖,则打印 true,如果不能,则打印 false。执行以下步骤:

  1. 创建一个名为 IShape 的接口,具有 Area 属性:

    public interface IShape
    {
        double Area { get; }
    }
    

这是一个只读属性。请注意,属性是一个方法,所以它可以在接口中存在。

  1. 创建一个名为 Rectangle 的类,具有宽度和高度,以及一个名为 Area 的计算面积的方法。为此实现一个 IShape 接口,如下所示代码:

    Rectangle.cs
    public class Rectangle : IShape
    {
        private readonly double _width;
        private readonly double _height;
    
        public double Area
        {
            get
            {
                return _width * _height;
            }
        } 
    
        public Rectangle(double width, double height)
        {
    
You can find the complete code here: https://packt.link/zSquP.

唯一需要的是计算面积。因此,只有 Area 属性是 public 的。你的接口需要实现一个获取 Area 属性的 getter,通过乘以 widthheight 来实现。

  1. 创建一个具有 radiusArea 计算功能的 Circle 类,它还实现了 IShape 接口:

    public class Circle : IShape
    {
        Private readonly double _radius;
    
        public Circle(double radius)
        {
            _radius = radius;
        }
    
        public double Area
        {
            get { return Math.PI * _radius * _radius; }
        }
    }
    
  2. 创建一个名为 Solution 的骨架类,其中包含一个名为 IsEnough 的方法,如下所示:

    public static class Solution
    {
            public static bool IsEnough(double mosaicArea, IShape[] tiles)
            {
       }
    }
    

类和方法只是将来实现的占位符。这个类是 static 的,因为它将用作示例,并且不需要有状态。IsEnough 方法接受所需的 mosaicArea,一个瓷砖对象的数组,并返回瓷砖占据的总面积是否足够覆盖马赛克。

  1. IsEnough 方法内部,使用 for 循环来计算 totalArea。然后,返回总面积是否覆盖了马赛克面积:

                double totalArea = 0;
                foreach (var tile in tiles)
                {
                    totalArea += tile.Area;
                }
                const double tolerance = 0.0001;
                return totalArea - mosaicArea >= -tolerance;
           }
    
  2. Solution 类内部创建一个示例。添加几组不同形状的集合,如下所示:

    public static void Main()
    {
        var isEnough1 = IsEnough(0, new IShape[0]);
        var isEnough2 = IsEnough(1, new[] { new Rectangle(1, 1) });
        var isEnough3 = IsEnough(100, new IShape[] { new Circle(5) });
        var isEnough4 = IsEnough(5, new IShape[]
        {
            new Rectangle(1, 1), new Circle(1), new Rectangle(1.4,1)
        });
    
        Console.WriteLine($"IsEnough1 = {isEnough1}, " +
                          $"IsEnough2 = {isEnough2}, " +
                          $"IsEnough3 = {isEnough3}, " +
                          $"IsEnough4 = {isEnough4}.");
    }
    

在这里,你使用了四个示例。当要覆盖的区域为0时,无论你传递什么形状,都足够了。当要覆盖的区域为1时,一个面积为1x1的矩形就足够了。当它是100时,半径为5的圆是不够的。最后,对于第四个示例,三个形状占据的总面积被加起来,即一个面积为1x1的矩形,一个半径为1的圆,以及第二个面积为1.4x1的矩形。总面积是5,这小于这三个形状的总面积。

  1. 运行演示。你应该在屏幕上看到以下输出:

    IsEnough1 = True, IsEnough2 = True, IsEnough3 = False, IsEnough4 = False.
    

    注意

    你可以在packt.link/EODE6找到用于此练习的代码。

这个练习与练习 2.02非常相似。然而,尽管任务更复杂,但代码比之前的任务要少。通过使用面向对象编程的支柱,你能够为复杂问题创建一个简单的解决方案。你能够创建依赖于抽象的函数,而不是为不同类型创建重载。因此,面向对象编程是一个强大的工具,而这只是触及了表面。

每个人都能写出能工作的代码,但写出能持续数十年且易于理解的代码是困难的。因此,了解面向对象编程(OOP)的最佳实践集合是至关重要的。

OOP 中的 SOLID 原则

SOLID 原则是一组面向对象编程的最佳实践。SOLID 是五个原则的缩写,分别是单一职责、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。你将不会详细探讨这些原则中的每一个。

单一职责原则

函数、类、项目和整个系统都会随时间而变化。每一次变化都有可能引起破坏,因此你应该限制同时改变太多事物的风险。换句话说,代码块的一部分应该只有一个改变的理由。

对于一个函数来说,这意味着它应该只做一件事,并且没有副作用。在实践中,这意味着一个函数要么改变,要么获取某些东西,但不能两者兼而有之。这也意味着负责高级事物的函数不应与执行低级事物的函数混合。低级主要涉及与硬件的交互和与原语一起工作。高级则专注于软件构建块或服务的组合。当谈论高级和低级函数时,通常指的是依赖链。如果函数 A 调用函数 B,那么 A 被认为是比 B 更高级的。一个函数不应实现多个事物;相反,它应该调用其他实现单一事物的函数。这个一般性指南是,如果你认为可以将你的代码拆分成不同的函数,那么在大多数情况下,你应该这么做。

对于类,这意味着你应该保持它们小巧且相互独立。一个高效的类示例是 File 类,它可以读取和写入。如果它实现了读取和写入,它会有两个原因(读取和写入)发生变化:

public class File
{
    public string Read(string filePath)
    {
        // implementation how to read file contents
        // complex logic
        return "";
    }

    public void Write(string filePath, string content)
    {
        // implementation how to append content to an existing file
        // complex logic
    }
}

因此,为了符合这个原则,你可以将读取代码拆分到一个名为 Reader 的类中,将写入代码拆分到一个名为 Writer 的类中,如下所示:

public class Reader
{
    public string Read(string filePath)
    {
        // implementation how to read file contents
        // complex logic
        return "";
    }
}
public class Writer
{
    public void Write(string filePath, string content)
    {
        // implementation how to append content to an existing file
        // complex logic
    }
}

现在,File 类将不再单独实现读取和写入功能,而是简单地由一个读取器和写入器组成:

public class File
{
    private readonly Reader _reader;
    private readonly Writer _writer;

    public File()
    {
        _reader = new Reader();
        _writer = new Writer();
    }  

    public string Read(string filePath) => _reader.Read(filePath);
    public void Write(string filePath, string content) => _writer.Write(filePath, content);
}

注意

你可以在packt.link/PBppV找到用于此示例的代码。

这可能有些令人困惑,因为类所执行的基本操作本质上保持不变。然而,现在,它只是消费一个组件,而不负责实现它。一个高级类(File)只是为如何使用低级类(ReaderWriter)添加上下文。

对于一个模块(库),这意味着你应该努力不引入消费者不需要的依赖。例如,如果你正在使用日志记录库,它不应该附带一些第三方日志提供者特定的实现。

对于一个子系统,这意味着不同的系统应该尽可能独立。如果两个(低级)系统需要通信,它们可以直接调用对方。一个考虑因素(不是强制性的)是有一个第三系统(高级)进行协调。系统还应该通过边界(例如,指定通信参数的合同)进行分离,隐藏所有细节。如果一个子系统是一个大的库集合,它应该有一个接口来公开它所能做的事情。如果一个子系统是一个网络服务,它应该是一组端点。在任何情况下,子系统的合同应该只提供客户端可能需要的那些方法。

有时,原则被过度执行,类被分割得太多,以至于需要更改多个地方。这确实符合原则,即一个类将有一个单一的理由进行更改,但在这种情况下,多个类将因为相同的原因而更改。例如,假设你有两个类:MerchandiseTaxCalculatorMerchandise 类有 NamePriceVat 字段:

public class Merchandise
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    // VAT on top in %
    public decimal Vat { get; set; }
}

接下来,你将创建 TaxCalculator 类。vat 以百分比的形式衡量,因此实际支付的金额将是 vat 加上原始价格:

public static class TaxCalculator
{
    public static decimal CalculateNextPrice(decimal price, decimal vat)
    {
        return price * (1 + vat / 100);
    }
}

如果将计算价格的功能移动到 Merchandise 类,会发生什么变化?你仍然可以执行所需的操作。这里有两个关键点:

  • 单独的操作很简单。

  • 此外,税务计算器所需的一切都来自 Merchandise 类。

如果一个类可以自己实现逻辑,只要它是自包含的(不涉及额外组件),通常应该这样做。因此,一个合适的代码版本如下:

public class Merchandise
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    // VAT on top in %
    public decimal Vat { get; set; }
    public decimal NetPrice => Price * (1 + Vat / 100);
}

这段代码将NetPrice计算移动到Merchandise类,并且已经删除了TaxCalculator类。

注意

单一职责原则(SRP)可以用几个词来概括:拆分它。你可以在这个例子中找到使用的代码packt.link/lWxNO

开放封闭原则

如前所述,代码的每一次更改都可能是破坏性的。为了解决这个问题,通常更倾向于编写新的代码,而不是更改现有的代码。每个软件实体都应该有一个扩展点,通过这个扩展点引入更改。然而,在完成这个更改后,不应该干扰软件实体。开放封闭原则(OCP)难以实现且需要大量的实践,但其好处(最小化破坏性更改的数量)是非常值得的。

如果一个多步骤算法本身没有变化,但其各个步骤可以变化,你应该将其拆分为几个函数。单个步骤的更改将不再影响整个算法,而只会影响那个步骤。这种最小化单个类或函数更改原因的做法正是 OCP 的核心。

注意

你可以在social.technet.microsoft.com/wiki/contents/articles/18062.open-closed-principle-ocp.aspx找到有关 OCP 的更多信息。

你可能想要实施这个原则的另一个例子是,一个与代码中特定值组合工作的函数。这被称为硬编码,通常被认为是一种低效的做法。为了使其能够与新的值一起工作,你可能想创建一个新的函数,但通过简单地移除硬编码的部分并通过函数参数公开它,你可以使其可扩展。然而,当你有已知是固定且不会改变变量的情况,可以硬编码它们,但应该将它们标记为常量。

之前,你创建了一个具有两个依赖项(ReaderWriter)的文件类。这些依赖项是硬编码的,没有提供扩展点。解决这个问题将涉及两个方面。首先,为ReaderWriter类方法添加虚拟修饰符:

public virtual string Read(string filePath)
public virtual void Write(string filePath, string content)

然后,更改File类的构造函数,使其接受ReaderWriter的实例,而不是硬编码依赖项:

public File(Reader reader, Writer writer)
{
    _reader = reader;
    _writer = writer;
}

这段代码使你能够覆盖现有的读取器和写入器行为,并用你想要的任何行为替换它,即File类的扩展点。

OCP 可以用几个词来概括为不要改变它,扩展它

李斯克代换

李斯克代换原则(LSP)是最直接的原则之一。它简单意味着子类应该支持父类的所有公共行为。如果你有两个类,CarCarWreck,其中一个继承自另一个,那么你就违反了这个原则:

class Car
{
    public object Body { get; set; }
    public virtual void Move()
    {
        // Moving
    }
}
class CarWreck : Car
{
    public override void Move()
    {
        throw new NotSupportedException("A broken car cannot start.");
    }
}

注意

你可以在 packt.link/6nD76 找到用于此示例的代码。

CarCarWreck 都有一个 Body 对象。Car 可以移动,但 CarWreck 呢?它只能待在一个地方。Move 方法是虚拟的,因为 CarWreck 想要覆盖它以标记为不支持。如果一个子类不再支持父类可以做的事情,那么它就不应该再继承那个父类。在这种情况下,车祸不是一辆车,它只是一个废墟。

你如何遵循这个原则?你只需要移除继承关系并复制必要的功能和结构。在这种情况下,CarWreck 仍然有一个 Body 对象,但 Move 方法是不必要的:

class CarWreck
{
    public object Body { get; set; }
}

代码更改很常见,有时你可能会无意中使用错误的方法来实现你的目标。有时,你以某种方式耦合代码,结果你原本认为灵活的代码变成了一个复杂的混乱。不要用继承作为代码重用的方式。保持事物小巧,并重新组合它们(再次)而不是尝试覆盖现有行为。在事物可以重用之前,它们应该是可用的。设计简单,你将免费获得灵活性。

LSP 可以用几个词来概括:不要假装

注意

你可以在 www.microsoftpressstore.com/articles/article.aspx?p=2255313 找到更多关于 LSP 的信息。

接口隔离

接口隔离原则是 OCP 的一个特殊情况,但仅适用于将公开暴露的合同。记住,你做的每一个改变都可能是一个破坏性改变,这在修改合同时尤其重要。破坏性改变效率低下,因为它们通常需要多个人努力适应这些变化。

例如,假设你有一个接口,IMovableDamageable

interface IMovableDamageable
{
    void Move(Location location);
    float Hp{get;set;}
}

一个接口应该代表一个单一的概念。然而,在这种情况下,它做了两件事:移动和管理 Hp(生命值)。一个接口有两个方法本身并不是问题。然而,在实现只需要接口的一部分的场景中,你被迫创建一个解决方案。

例如,得分文本是不可摧毁的,但你希望它能够动画化,并在场景中移动:

class ScoreText : IMovableDamageable
{
    public float Hp 
    { 
        get => throw new NotSupportedException(); 
        set => throw new NotSupportedException(); 
    }

    public void Move(Location location)
    {
        Console.WriteLine($"Moving to {location}");
    }
}

public class Location
{
}

注意

这里的重点不是打印位置;只是提供一个使用示例。是否打印取决于位置实现的意愿。

再举一个例子,你可能有一个不移动但可以被摧毁的房子:

class House : IMovableDamageable
{
    public float Hp { get; set; }

    public void Move(Location location)
    {
        throw new NotSupportedException();
    }
}

在这两种情况下,你通过抛出 NotSupportedException 来绕过这个问题。然而,另一个程序员不应该有调用从开始就永远不会工作的代码的选择。为了解决表示太多概念的问题,你应该将 IMoveableDamageable 接口拆分为 IMoveableIDamageable

interface IMoveable
{
    void Move(Location location);
}
interface IDamageable
{
    float Hp{get;set;}
}

现在实现可以去掉不必要的部分:

class House : IDamageable
{
    public float Hp { get; set; }
}

class ScoreText : IMovable
{
    public void Move(Location location)
    {
        Console.WriteLine($"Moving to {location}");
    }
}

在前面的代码中,Console.WriteLine 会显示命名空间名称和类名。

注意

接口隔离可以总结为不要强制执行。你可以在这个例子中找到用于此示例的代码:packt.link/32mwP

依赖反转

大型软件系统可能包含数百万个类。每个类都是一个小的依赖项,如果未管理好,复杂性可能会累积成难以维护的状态。如果一个低级组件损坏,它会导致连锁反应,破坏整个依赖链。依赖反转原则指出,你应该避免对底层类有硬依赖。

依赖注入是实现依赖反转的行业标准方式。不要混淆这两个概念;一个是原则,另一个是这个原则的实现。

注意,你也可以在不使用依赖注入的情况下实现依赖反转。例如,在声明字段时,而不是写 private readonly List<int> _numbers = new List<int>();,最好是写 private readonly IList<int> _numbers,这样就将依赖转移到了抽象(IList)而不是实现(List)。

什么是依赖注入?它是传递一个实现并将其设置到抽象槽位的行为。有三种方法来实现这一点:

  • 构造函数注入是通过在构造函数参数中暴露抽象并通过创建对象时传递实现来完成的,然后将它分配给一个字段。当你想在同一个对象中(但不一定是同一个类中)一致地使用相同的依赖项时使用它。

  • 方法注入是通过通过方法参数暴露抽象,然后在调用该方法时传递实现来实现的。当对于单个方法,依赖项可能会变化,并且你不想在整个对象的生命周期中存储依赖项时使用它。

  • 属性注入是通过通过公共属性暴露抽象,然后分配(或不分配)该属性到某个具体实现来实现的。属性注入是注入依赖项的一种罕见方式,因为它暗示依赖项甚至可能是 null 或临时的,并且有多种方式可能导致其失败。

给定两个类型,interface IBartender { }class Bar : Bartender { },你可以展示名为 Bar 的类的三种依赖注入方式。

首先,为构造函数注入准备 Bar 类:

class Bar
{
    private readonly IBartender _bartender;

    public Bar(IBartender bartender)
    {
        _bartender = bartender;
    }
}

构造函数注入如下所示:

var bar = new Bar(new Bartender());

这种依赖注入是一种占主导地位的继承方式,因为它通过不可变性来强制稳定性。例如,一些酒吧只有一位调酒师。

方法注入看起来是这样的:

class Bar
{
    public void ServeDrinks(IBartender bartender)
    {
        // serve drinks using bartender
    }
}

注入本身如下:

var bar = new Bar();
bar.ServeDrinks(new Bartender());

通常,这种依赖注入被称为接口注入,因为方法通常在接口下进行。接口本身是一个很好的想法,但这并没有改变这种依赖注入背后的理念。当你立即消耗你设置的依赖项,或者当你有复杂的方式动态设置新的依赖项时,使用方法注入。例如,使用不同的调酒师来服务饮品是有意义的。

最后,属性注入可以这样做:

class Bar
{
    public IBartender Bartender { get; set; }
}

调酒师现在是这样注入的:

var bar = new Bar();
bar.Bartender = new Bartender();

例如,酒吧可能会有调酒师换班,但一次只有一个调酒师。

注意

你可以在 packt.link/JcmAT 找到用于此示例的代码。

在其他语言中,属性注入可能有不同的名称:setter 注入。在实践中,组件并不经常改变,因此这种依赖注入是最罕见的。

对于 File 类,这意味着你应该暴露抽象(接口)而不是类(实现),这意味着你的 ReaderWriter 类应该实现某些合同:

public class Reader : IReader
public class Writer: IWriter

你的文件类应该暴露读取器和写入器抽象,而不是实现,如下所示:

private readonly IReader _reader;
private readonly IWriter _writer;

public File(IReader reader, IWriter writer)
{
    _reader = reader;
    _writer = writer;
}

这允许你选择想要注入的 IReaderIWriter 类型。不同的读取器可能读取不同的文件格式,或者不同的写入器可能以不同的方式输出。你有选择权。

依赖注入是一个常用的强大工具,特别是在企业环境中。它通过在实现和抽象之间插入一个接口,实现实现-抽象-实现的 1:1 依赖关系,从而简化了复杂系统。

编写不会出错的代码可能是矛盾的。这就像从商店买工具一样;你无法确定它将持续多长时间,或者它将如何工作。代码,就像那些工具一样,现在可能工作,但不久后可能会出故障,你只有在它出故障时才会知道它不工作。

观察和等待,看代码如何演变,是确定你是否编写了有效代码的唯一方法。在小型的个人项目中,你可能甚至不会注意到任何变化,除非你将项目公之于众或涉及其他人。对大多数人来说,SOLID 原则听起来像过时的原则,就像过度设计。但实际上,它们是一套经过时间考验的最佳实践,由在企业管理环境中经验丰富的顶级专业人士制定。一开始就编写完美的 SOLID 代码是不可能的。事实上,在某些情况下,这甚至不是必要的(例如,如果项目很小且预期寿命短)。作为一个想要生产高质量软件并作为专业人士工作的人,你应该尽早开始练习它。

C# 如何帮助面向对象设计

到目前为止,您所学的原则并非特定于任何一种语言。现在是时候学习如何使用 C# 进行面向对象编程了。C# 是一种非常好的语言,因为它包含了许多非常有用的特性。它不仅是最富有生产力的编程语言之一,而且它还允许您编写美观、难以破坏的代码。凭借丰富的关键字和语言特性,您可以完全按照自己的意愿来建模类,使意图清晰可见。本节将深入探讨有助于面向对象设计的 C# 特性。

静态

到目前为止,在这本书中,您主要与 static 代码进行交互。这指的是不需要新类和对象的代码,并且可以立即调用。在 C# 中,静态修饰符可以应用于五种不同的场景——方法、字段、类、构造函数和 using 语句。

静态方法和字段是 static 关键字的最简单应用:

public class DogsGenerator
{
    public static int Counter { get; private set; }
    static DogsGenerator()
    {
        // Counter will be 0 anyways if not explicitly provided,
        // this just illustrates the use of a static constructor.
        Counter = 0;
    }
    public static Dog GenerateDog()
    {
        Counter++;
        return new Dog("Dog" + Counter);
    }
}

注意

您可以在 packt.link/748m3 找到用于此示例的代码。

在这里,您创建了一个名为 DogsGenerator 的类。static class 不能手动初始化(使用 new 关键字)。内部初始化,但仅初始化一次。调用 GenerateDog 方法将返回一个带有其名称旁边计数器的新的 Dog 对象,例如 Dog1Dog2Dog3。编写这样的计数器允许您从任何地方增加它,因为它 public static 并具有设置器。这可以通过直接从类访问成员来完成:DogsGenerator.Counter++ 将计数器增加 1

再次提醒,这不需要通过对象进行调用,因为 static class 实例在整个应用程序中是相同的。然而,DogsGenerator 并不是 static class 的最佳示例。这是因为您刚刚创建了一个全局状态。许多人会说 static 是低效的,应该避免使用,因为它可能会由于不可控的修改和访问而产生不可预测的结果。

公开可变状态意味着更改可以在应用程序的任何地方发生。除了难以理解之外,此类代码在具有多个线程的应用程序上下文中也容易出错(即它不是线程安全的)。

注意

您将在第五章 并发:多线程、并行和异步代码 中详细了解线程。

您可以通过使全局状态公开不可变来减少其影响。这样做的好处是现在您处于控制之中。您将不再允许计数器从程序内部的任何地方增加,而是仅在 DogsGenerator 中进行更改。对于 counter 属性,实现这一点就像将设置器属性 private 一样简单。

尽管如此,static 关键字有一个有价值的用途,那就是与辅助函数一起使用。这些函数接收输入并返回输出,而不在内部修改任何状态。此外,包含此类函数的类是 static 的,并且没有状态。static 关键字的另一个良好应用是创建不可变常量。它们使用不同的关键字(const)定义。例如 PIE,静态辅助方法如 SqrtAbs 等。

DogsGenerator 类没有适用于对象的成员。如果所有类成员都是 static,则该类也应为 static。因此,你应该将类更改为 public static class DateGenerator。然而,请注意,依赖 static 与依赖具体实现相同。虽然它们易于使用且直观,但静态依赖难以摆脱,并且仅应用于简单代码,或者你确信不会更改且实现细节至关重要的代码。因此,Math 类也是一个 static 类;它拥有所有算术计算的基础。

static 的最后一个应用是 using static。在 using 语句前使用 static 关键字会导致所有方法和字段直接可访问,无需调用 class。例如,考虑以下代码:

using static Math;
public static class Demo
{
    public static void Run()
    {
   //No need Math.PI
        Console.WriteLine(PI);
    } 
}

这是 C# 中的静态导入功能。通过使用 static Math,可以直接访问所有静态成员。

密封

此前,你提到应该非常小心地处理继承,因为复杂性可能会迅速失控。你在阅读和编写代码时可以仔细考虑复杂性,但你能否通过设计来防止复杂性?C# 有一个用于停止继承的关键字,称为 sealed。如果从逻辑上讲继承一个类没有意义,那么你应该使用 sealed 关键字标记它。与安全相关的类也应密封,因为保持它们简单且不可覆盖至关重要。此外,如果性能至关重要,则与直接在密封类中相比,继承类中的方法会更慢。这是由于方法查找的工作方式。

部分

在 .NET 中,使用 WinForms 制作桌面应用程序相当流行。WinForms 的工作方式是,你可以借助设计器来设计应用程序的外观。内部,它会生成 UI 代码,而你只需双击一个组件,它就会生成事件处理程序代码。这就是部分类的作用所在。所有无聊的自动生成代码将在一个类中,而你编写的代码将在另一个类中。需要注意的是,这两个类将具有相同的名称,但位于不同的文件中。

你可以拥有任意数量的部分类。然而,建议的部分类数量不超过两个。编译器会将它们视为一个大类,但对于用户来说,它们看起来像是两个独立的类。生成代码会创建新的类文件,这将覆盖你编写的代码。当你处理自动生成的代码时,请使用partial。初学者犯的最大错误之一就是使用partial来管理大型复杂类。如果你的类很复杂,最好是将其拆分成更小的类,而不仅仅是不同的文件。

partial还有一个用例。想象一下,你有一个类中的代码片段,它只在一个其他程序集(assembly)中需要,但在它最初定义的程序集中是不必要的。你可以在不同的程序集中拥有相同的类,并将其标记为partial。这样,类中不需要的部分将只在使用它的地方使用,而在不应该看到的地方隐藏。

虚拟

抽象方法可以被重写;然而,它们不能被实现。如果你想要一个具有默认行为的方法,这个行为将来可以被重写,怎么办?你可以使用virtual关键字来实现,如下面的示例所示:

public class Human
{
    public virtual void SayHi()
    {
        Console.WriteLine("Hello!");
    }
}

在这里,Human类有SayHi方法。这个方法以virtual关键字为前缀,这意味着它可以在子类中更改行为,例如:

public class Frenchman : Human
{
    public override void SayHi()
    {
        Console.WriteLine("Bonjour!");
    }
}

注意

你可以在packt.link/ZpHhI找到这个示例使用的代码。

Frenchman类继承自Human类,并重写了SayHi方法。从Frenchman对象调用SayHi将打印Bonjour

C#的一个特点是它的行为很难被重写。在声明方法时,你需要明确地告诉编译器该方法可以被重写。只有virtual方法可以被重写。接口方法是虚拟的(因为它们稍后获得行为),然而,你不能从子类中重写接口方法。你只能在父类中实现接口。

抽象方法是最后一种虚拟方法,它与virtual最相似,因为它可以被重写任意多次(在子类和孙类中)。

为了避免有脆弱的、变化的、可重写的表现行为,最好的虚拟方法是来自接口的方法。abstractvirtual关键字允许在子类中更改类行为并重写它,如果不受控制,这可能会成为一个大问题。重写行为通常会导致不一致和意外的结果,所以在使用virtual关键字之前你应该小心。

内部

publicprivateprotected是已经提到过的三种访问修饰符。许多初学者认为默认的类修饰符是private。然而,private意味着它不能从类外部调用,在命名空间的环境中,这并没有什么意义。类的默认访问修饰符是internal。这意味着该类将只在其定义的命名空间内部可见。internal修饰符非常适合在同一个程序集内重用类,同时又不让外部看到。

条件运算符

空引用异常可能是编程中最常见的错误。例如,参考以下代码:

int[] numbers = null;
numbers.length;

这段代码将抛出NullReferenceException,因为你正在与一个具有空值的变量交互。空数组的长度是多少?这个问题没有合适的答案,所以这里将抛出异常。

防止此类错误发生的最佳方式是根本避免处理空值。然而,有时这是不可避免的。在这种情况下,还有一种称为防御性编程的技术。在使用可能为null的值之前,请确保它不是null

现在回想一下Dog类的例子。如果你创建一个新的对象,Owner的值可能是null。如果你要确定所有者的名字是否以字母A开头,你需要首先检查Owner的值是否为null,如下所示:

if (dog.Owner != null)
{
    bool ownerNameStartsWithA = dog.Owner.StartsWith('A');
}

然而,在 C#中,使用空条件运算符,这段代码变得像下面这样简单:

dog.Owner?.StartsWith('A');

空条件运算符(?)是 C#中的条件运算符的一个例子。它是一个隐式运行if语句(基于特定运算符的特定if语句)并返回某些值或继续工作的运算符。Owner?.StartsWith('A')部分如果条件满足则返回true,如果不满足或对象为null则返回false

C#中还有更多你将学习的条件运算符。

三元运算符

几乎没有哪种语言没有if语句。最常见的一种if语句是if-else。例如,如果一个Dog类的实例的Owner值为null,你可以简单地描述这个实例为{Name}。否则,你可以更好地描述它为{Name}, dog of {Owner},如下面的代码片段所示:

if (dog1.Owner == null)
{
    description = dog1.Name;
}
else
{
    description = $"{dog1.Name}, dog of {dog1.Owner}";
}

C#像许多其他语言一样,通过使用三元运算符来简化这一点:

description = dog1.Owner == null
    ? dog1.Name
    : $"{dog1.Name}, dog of {dog1.Owner}";

在左侧,你有一个条件(真或假),后面跟着一个问号(?),如果条件为真,则返回右侧的值,后面跟着一个冒号(:),如果条件为假,则返回左侧的值。$是一个字符串插值字面量,它允许你写出$"{dog1.Name}, dog of {dog1.Owner}"而不是dog1.Name + "dog of" + dog1.Owner。你应该在连接文本时使用它。

假设现在有两只狗。你希望第一只狗加入第二只(即,成为第二只狗的主人),但这只能发生在第二只狗已经有主人的情况下。通常,你会使用以下代码:

if (dog1.Owner != null)
{
    dog2.Owner = dog1.Owner;
}

但在 C#中,你可以使用以下代码:

dog1.Owner = dog1.Owner ?? dog2.Owner;

在这里,你使用了空值合并运算符(??),如果右侧的值是null,则返回右侧的值;如果不是null,则返回左侧的值。然而,你可以进一步简化这个操作:

dog1.Owner ??= dog2.Owner;

这意味着如果你尝试分配的值(在左侧)是null,那么输出将是右侧的值。

空值合并运算符的最后一个用途是输入验证。假设有两个类,ComponentAComponentB,并且ComponentB必须包含一个初始化的ComponentA实例。你可以编写以下代码:

public ComponentB(ComponentA componentA)
{
    if (componentA == null)
    {
        throw new ArgumentException(nameof(componentA));
    }
    else
    {
        _componentA = componentA;
    }
}

然而,而不是前面的代码,你可以简单地写以下内容:

_componentA = componentA ?? throw new ArgumentNullException(nameof(componentA));

这可以理解为如果没有componentA,则必须抛出异常。

注意

你可以在packt.link/yHYbh找到用于此示例的代码。

在大多数情况下,应该使用空值运算符来替换标准的if null-else语句。然而,在使用三元运算符时要小心,并将其限制在简单的if-else语句中,因为代码可能会变得难以阅读。

重载运算符

在 C#中,可以抽象出很多内容是非常迷人的。比较原始数字、乘法或除法很容易,但涉及到对象时,这并不那么简单。一个人加上另一个人是什么?一袋苹果乘以另一袋苹果是什么?这很难说,但在某些领域背景下,这可以完全有道理。

考虑一个稍微好一点的例子。假设你正在比较银行账户。找出谁在银行账户中有更多的钱是一个常见的用例。通常,为了比较两个账户,你必须访问它们的成员,但 C#允许你重载比较运算符,以便你可以比较对象。例如,想象你有一个BankAccount类如下所示:

public class BankAccount
{
    private decimal _balance;

    public BankAccount(decimal balance)
    {
        _balance = balance;
    }
}

在这里,余额是private的。你并不关心确切的余额值;你只想比较一个与另一个。你可以实现一个CompareTo方法,但相反,你将实现一个比较运算符。在BankAccount类中,你将添加以下代码:

public static bool operator >(BankAccount account1, BankAccount account2)
    => account1?._balance > account2?._balance;

上述代码被称为运算符重载。使用这种自定义运算符重载,当余额更大时可以返回true,否则返回false。在 C#中,运算符是public static,后面跟着返回类型。然后是operator关键字,后面跟着被重载的实际运算符。输入取决于被重载的运算符。在这种情况下,你传递了两个银行账户。

如果你尝试按原样编译代码,你会得到一个错误,表明缺少某些内容。比较运算符有一个相反的操作方法是有意义的。现在,添加小于运算符重载如下:

public static bool operator <(BankAccount account1, BankAccount account2)
    => account1?._balance < account2?._balance;

代码现在可以编译了。最后,进行等式比较是有意义的。记住,你需要添加一对,等于和不等于:

public static bool operator ==(BankAccount account1, BankAccount account2)
    => account1?._balance == account2?._balance; 
public static bool operator !=(BankAccount account1, BankAccount account2)
    => !(account1 == account2);

接下来,你将创建用于比较的银行账户。注意,所有数字后面都附加了m,因为这个后缀使这些数字成为decimal类型。默认情况下,有分数的数字是double类型,所以你需要添加m在末尾来使它们成为decimal

var account1 = new BankAccount(-1.01m);
var account2 = new BankAccount(1.01m);
var account3 = new BankAccount(1001.99m);
var account4 = new BankAccount(1001.99m);

比较两个银行账户现在变得如此简单:

Console.WriteLine(account1 == account2);
Console.WriteLine(account1 != account2);
Console.WriteLine(account2 > account1);
Console.WriteLine(account1 < account2);
Console.WriteLine(account3 == account4);
Console.WriteLine(account3 != account4);

运行代码会在控制台打印出以下内容:

False
True
True
True
True
False

注意

你可以在packt.link/5DioJ找到用于此示例的代码。

许多(但不是所有)运算符可以重载,但仅仅因为你可以这样做并不意味着你应该这样做。在某些情况下,重载运算符是有意义的,但在其他情况下,它可能是不直观的。再次提醒,不要滥用 C#功能,只有在它们逻辑上有意义,并且使代码更容易阅读、学习和维护时才使用它们。

可空原始类型

你是否曾经想过当原始值未知时应该做什么?例如,假设宣布了一组产品。它们的名称、描述和一些其他参数是已知的,但价格只在发布前揭晓。你应该使用什么类型来存储价格值?

可空原始类型是一些可能具有某个值或没有值的原始类型。在 C#中,要声明此类类型,你必须在原始类型后添加?,如下面的代码所示:

int? a = null;

这里,你声明了一个可能具有值或没有值的字段。具体来说,这意味着 a 可能是未知的。不要将其与默认值混淆,因为默认情况下,int类型的值是0

你可以非常简单地给一个可空字段赋值,如下所示:

a = 1;

要在之后检索其值,你可以编写如下代码:

int b = a.Value;

泛型

有时,你会遇到使用不同类型做完全相同的事情的情况,唯一的区别就是类型本身。例如,如果你需要创建一个打印int值的函数,你可以编写以下代码:

public static void Print(int element)
{
    Console.WriteLine(element);
}
If you need to print a float, you could add another overload:
public static void Print(float element)
{
    Console.WriteLine(element);
}

同样,如果你需要打印一个字符串,你可以添加另一个重载:

public static void Print(string element)
{
    Console.WriteLine(element);
}

你做了三遍同样的事情。当然,肯定有减少代码重复的方法。记住,在 C#中,所有类型都从object类型派生,该类型具有ToString()方法,因此你可以执行以下命令:

public static void Print(object element)
{
    Console.WriteLine(element);
}

即使最后的实现包含的代码最少,但实际上它是最不高效的。对象是一种引用类型,而原始类型是一种值类型。当你将一个原始类型赋值给一个对象时,你也会为它创建一个新的引用。这被称为装箱。这并不是免费的,因为你将对象从移动到。程序员应该意识到这个事实,并在可能的情况下避免它。

在本章的早期,你遇到了多态——使用相同类型做不同事情的一种方式。你也可以使用不同类型做相同的事情,泛型就是让你能够做到这一点的东西。在Print示例的情况下,你需要的是一个泛型方法:

public static void Print<T>(T element)
{
    Console.WriteLine(element);
}

使用菱形括号(<>),你可以指定一个类型T,这个函数将与之一起工作。<T>意味着它可以与任何类型一起工作。

现在,假设你想打印数组中的所有元素。简单地将一个集合传递给WriteLine语句会导致打印一个引用,而不是所有元素。通常,你会创建一个打印所有传递的元素的方法。利用泛型的力量,你可以有一个打印任何类型数组的单一方法:

public static void Print<T>(T[] elements)
{
    foreach (var element in elements)
    {
        Console.WriteLine(element);
    }
}

请注意,泛型版本并不像使用object类型那样高效,仅仅是因为你仍然会使用一个接受对象作为参数的WriteLine重载。当你传递一个泛型时,你无法确定它是否需要调用一个接受intfloatString的重载,或者是否一开始就有一个精确的重载。如果没有接受对象的重载WriteLine,你就无法调用Print方法。因此,最高效的代码实际上是具有三个重载的代码。尽管这并不非常重要,因为这只是一个装箱无论如何都会发生的非常具体的场景。然而,还有许多其他情况,你可以使代码不仅简洁,而且高效。

有时候,选择泛型或多态函数的答案隐藏在微小的细节中。如果你必须实现一个比较两个元素并返回true如果第一个更大的方法,你可以在 C#中使用一个IComparable接口:

public static bool IsFirstBigger1(IComparable first, IComparable second)
{
    return first.CompareTo(second) > 0;
}

这个泛型版本看起来是这样的:

public static bool IsFirstBigger2<T>(T first, T second)
    where T : IComparable
{
    return first.CompareTo(second) > 0;
}

这里的新特性是 where T : IComparable。这是一个泛型约束。默认情况下,你可以将任何类型传递给泛型类或方法。约束仍然允许传递不同类型,但它们显著减少了可能的选项。泛型约束只允许符合约束的类型作为泛型类型传递。在这种情况下,你将只允许实现 IComparable 接口类型的传递。约束可能看起来是对类型的限制;然而,它们暴露了受约束类型的内部行为,你可以在泛型方法中使用这些行为。有约束使你能够使用这些类型的特性,因此非常有用。在这种情况下,你确实限制了可以使用的类型,但与此同时,传递给泛型方法的任何内容都将是可以比较的。

如果不是返回第一个元素是否更大,而是需要返回第一个元素本身,你会怎么做?你可以编写一个非泛型方法,如下所示:

public static IComparable Max1(IComparable first, IComparable second)
{
    return first.CompareTo(second) > 0
        ? first
        : second;
}

泛型版本将如下所示:

public static T Max2<T>(T first, T second)
    where T : IComparable
{
    return first.CompareTo(second) > 0
        ? first
        : second;
}

此外,值得比较使用每个版本如何获取有意义的输出。使用非泛型方法,代码将如下所示:

int max1 = (int)Comparator.Max1(3, -4);

使用泛型版本,代码将如下所示:

int max2 = Comparator.Max2(3, -4);

注意

你可以在 packt.link/sIdOp 找到用于此示例的代码。

在这种情况下,获胜者是显而易见的。在非泛型版本中,你必须进行类型转换。在代码中进行类型转换是不受欢迎的,因为如果你确实遇到错误,你将在运行时遇到它们,事情可能会改变,类型转换将失败。类型转换也是一个额外的动作,而泛型版本则更加流畅,因为它没有类型转换。当你想要直接使用类型而不是通过它们的抽象来工作时,请使用泛型。从函数中返回精确(非多态)类型是泛型的一个最佳用例之一。

C# 泛型将在第四章 数据结构和 LINQ 中详细介绍。

枚举

enum 类型代表一组已知值。由于它是一种类型,你可以将它传递给方法,而不是传递原始值。enum 包含所有可能的值,因此不可能有一个它不包含的值。以下代码片段展示了这个简单示例:

public enum Gender
{
    Male,
    Female,
    Other
}

注意

你可以在 packt.link/gP9Li 找到用于此示例的代码。

现在,你可以通过编写 Gender.Other 来获取一个可能的性别值,就像它在 static class 中一样。枚举可以很容易地通过类型转换转换为整数——(int)Gender.Male 将返回 0(int)Gender.Female 将返回 1,依此类推。这是因为 enum 默认从 0 开始编号。

枚举没有任何行为,它们被称为常量容器。当你想要使用常量并设计上防止传递无效值时,你应该使用它们。

扩展方法

几乎总是,你将处理不属于你的代码的一部分。有时,这可能会造成不便,因为你没有权限更改它。是否有可能以某种方式扩展现有类型以添加你想要的功能?是否可以在不继承或创建新的组件类的情况下做到这一点?

你可以通过扩展方法轻松实现这一点。它们允许你在完整类型上添加方法,并像它们是本地存在的方法一样调用它们。

如果你想使用 Print 方法将一个 string 打印到控制台,但要从 string 本身调用它?String 没有这样的方法,但你可以使用扩展方法(extension method)来添加它:

public static class StringExtensions
{
    public static void Print(this string text)
    {
        Console.WriteLine(text);
    }
}

这允许你编写以下代码:

"Hey".Print();

这将按照以下方式将 Hey 打印到控制台:

Hey

注意

你可以在 packt.link/JC5cj 找到用于此示例的代码。

扩展方法是 static 的,并且必须放置在 static class 内部。如果你查看方法的语义,你会注意到 this 关键字的使用。在扩展方法中,this 关键字应该是第一个参数。之后,函数会像平常一样继续执行,你可以使用带有 this 关键字的参数,就像它是另一个参数一样。

使用扩展方法(extension methods)向现有类型添加(扩展,但不是与继承相同的方式)新行为,即使该类型在其他情况下不支持有方法。使用扩展方法,你甚至可以向 enum 类型添加方法,这在其他情况下是不可能的。

结构体

类(class)是一个引用类型,但并非所有对象都是引用类型(保存在堆上)。一些对象可以在栈上创建,这样的对象是通过结构体(struct)创建的。

结构体(struct)的定义类似于类(class),但它用于稍微不同的目的。现在,创建一个名为 Pointstruct

public struct Point
{
    public readonly int X;
    public readonly int Y;

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

这里的唯一真正区别是 struct 关键字,它表示这个对象将被保存在栈上。你可能也注意到没有使用属性。很多人会直接使用 xy 而不是 Point。这不是什么大问题,但这样你将处理两个变量而不是一个。这种处理原始数据的方式被称为原始数据依赖(primitive obsession)。你应该遵循面向对象编程(OOP)的原则,与抽象、良好封装的数据以及行为一起工作,以保持事物紧密相连,从而具有高内聚性。在选择放置变量的位置时,问问自己这个问题:x 是否可以独立于 y 改变?你是否曾经修改过一个点?一个点是否是一个完整的值?对这个问题的所有回答都是 ,因此将其放入数据结构中是有意义的。但为什么选择结构体而不是类?

结构体之所以快速,是因为它们在堆上没有进行任何分配。它们也很快,因为它们是通过值传递的(因此,访问是直接的,而不是通过引用)。通过值传递会复制值,所以即使你可以修改结构体,更改也不会保留在方法之外。当某物只是一个简单的小复合值时,你应该使用结构体。最后,使用结构体,你可以得到值相等性。

struct的另一个有效例子是DateTimeDateTime只是一个时间单位,包含一些信息。它也不会单独改变,支持AddDaysTryParseNow等方法。尽管它包含几个不同的数据项,但它们可以被视为一个单元,因为它们与日期和时间相关。

大多数struct应该是不可变的,因为它们是通过值的副本传递的,所以在方法内部更改任何内容都不会保留这些更改。你可以在struct上添加readonly关键字,使其所有字段都变为readonly

public readonly struct Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

一个readonlystruct可以有一个readonly字段或 getter 属性。这对于你代码库的未来维护者来说很有用,因为它防止他们执行你没有设计过的操作(无可变性)。结构体只是微小的分组数据,但它们也可以有行为。有一个方法来计算两点之间的距离是有意义的:

public static double DistanceBetween(Point p1, Point p2)
{
    return Math.Sqrt((p1.X - p2.X) * (p1.X - p2.X) + (p1.Y - p2.Y) * (p1.Y - p2.Y));
}

上述代码中包含一点数学——即两点之间的距离是 x 和 y 的平方差的平方根之和。

计算这个点与其他点之间的距离也是有意义的。你不需要做任何改变,因为你可以直接重用现有的代码,传递正确的参数:

public double DistanceTo(Point p)
{
    return DistanceBetween(this, p);
}

如果你想测量两点之间的距离,你可以这样创建它们:

var p1 = new Point(3,1);
var p2 = new Point(3,4);

并且使用成员函数来计算距离:

var distance1 = p1.DistanceTo(p2);

或者使用静态函数:

var distance2 = Point.DistanceBetween(p1, p2);

每个版本的输出结果如下:

– 3.

注意

你可以在packt.link/PtQzz找到用于此示例的代码。

当你考虑结构体时,把它想象成只是一个原始类型的组合。需要记住的关键点是,结构体中的所有数据成员(属性或字段)都必须在对象初始化时分配。这需要完成,原因与局部变量在没有初始值设置的情况下不能使用是一样的。结构体不支持继承;然而,它们支持实现接口。

结构体实际上是一种拥有简单业务逻辑的极好方式。结构体应该保持简单,并且不应该在其中包含其他对象引用;它们应该是仅包含原始类型的。然而,一个类可以拥有它需要的任意数量的结构体对象。使用结构体是避免过度使用原始类型并自然地在属于小数据组中应用简单逻辑的极好方法——也就是说,一个struct

记录

记录是一种引用类型(与struct不同,更像是类)。然而,默认情况下,它具有按值比较的方法(既使用equals方法也使用运算符)。此外,记录有一个不同的ToString()默认实现,它不再打印类型,而是打印所有属性。这正是许多情况下所需要的,因此它非常有帮助。最后,记录周围有很多语法糖,你很快就会看到。

你已经知道如何在 C#中创建自定义类型。不同自定义类型之间的唯一区别是使用的关键字。对于记录类型,这样的关键字是record。例如,你现在将创建一个电影记录。它有TitleDirectorProducerDescriptionReleaseDate

public record MovieRecordV1
{
    public string Title { get; }
    public string Director { get; }
    public string Producer { get; } 
    public string Description { get; set; }
    public DateTime ReleaseDate { get; }

    public MovieRecordV1(string title, string director, string producer, DateTime releaseDate)
    {
        Title = title;
        Director = director;
        Producer = producer;
        ReleaseDate = releaseDate;
    }
}

到目前为止,你应该觉得这非常熟悉,因为唯一的区别是关键字。无论这样一个细节如何,你都已经获得了巨大的好处。

注意

在章节中保留MovieRecordV1类,而不是 GitHub 代码中的MovieClass,是为了有一个类似于类的类型,然后通过重构突出记录的帮助。

创建两个相同的电影:

private static void DemoRecord()
{
    var movie1 = new MovieRecordV1(
        "Star Wars: Episode I – The Phantom Menace",
        "George Lucas",
        "Rick McCallum",
        new DateTime(1999, 5, 15));

    var movie2 = new MovieRecordV1(
        "Star Wars: Episode I – The Phantom Menace",
        "George Lucas",
        "Rick McCallum",
        new DateTime(1999, 5, 15));
}

到目前为止,一切都没有变化。尝试将电影打印到控制台:

    Console.WriteLine(movie1);

输出结果如下:

MovieRecordV1 { Title = Star Wars: Episode I - The Phantom Menace, Director = George Lucas, Producer
= Rick McCallum, Description = , ReleaseDate = 5/15/1999 12:00:00 AM }

注意

你可以在packt.link/xylkW找到这个示例使用的代码。

如果你尝试对类或struct对象做同样的事情,你只会得到一个类型打印出来。然而,对于记录,默认行为是打印出所有属性及其值。

这不是记录的唯一好处。同样,记录具有值相等语义。比较两个电影记录将通过它们的属性值进行比较:

    Console.WriteLine(movie1.Equals(movie2));
    Console.WriteLine(movie1 == movie2);

这将打印true true

仅通过将数据结构更改为记录,你就用相同数量的代码实现了最多的功能。默认情况下,记录提供了Equals()GetHashCode()重写、== 和 !=重写,甚至还有一个ToString重写,它会打印记录本身(所有成员及其值)。记录的好处不仅限于此,因为使用它们,你可以减少大量的样板代码。充分利用记录并重写你的电影记录:

public record MovieRecord(string Title, string Director, string Producer, string Description, DateTime ReleaseDate);

这是一个位置记录,意味着你传递的所有参数都将最终作为只读数据成员出现在正确的位置,就像是一个专门的构造函数。如果你再次运行演示,你会注意到它不再编译。这个声明的重大区别在于,现在,改变描述不再可能。创建可变属性并不困难,你只需要明确指出:

public record MovieRecord(string Title, string Director, string Producer, DateTime ReleaseDate)
{
    public string Description { get; set; }
}

你在这个段落开始时讨论了不可变性,但为什么主要关注的是记录?记录的好处实际上集中在不可变性上。使用with表达式,你可以创建一个具有零个或多个已修改属性的记录对象的副本。所以,假设你将以下内容添加到你的演示中:

var movie3 = movie2 with { Description = "Records can do that?" };
movie2.Description = "Changing original";
Console.WriteLine(movie3);

代码将产生以下结果:

MovieRecord { Title = Star Wars: Episode I - The Phantom Menace, Director = George Lucas, Producer
= Rick McCallum, ReleaseDate = 5/15/1999 12:00:00 AM, Description = Records can do that? }

如你所见,这段代码只拷贝了一个属性已更改的对象。在记录之前,你需要编写大量的代码来确保所有成员都被复制,然后才能设置值。请记住,这创建了一个浅拷贝。浅拷贝是一个包含所有引用的拷贝。深拷贝是一个包含所有引用类型对象重新创建的对象。不幸的是,没有方法可以覆盖这种行为。记录不能继承类,但可以继承其他记录。它们还可以实现接口。

除了是引用类型外,记录更像结构体,因为它们具有值相等性和关于不可变性的语法糖。它们不应该用作结构体的替代品,因为对于小型和简单的对象,结构体仍然是首选,这些对象具有简单的逻辑。当你想要不可变的数据对象,这些对象可能包含其他复杂对象(如果嵌套对象的状态可能会改变,浅拷贝可能会导致意外的行为)时,请使用记录。

init设置器

随着记录的引入,上一个版本 C# 9 也引入了仅init的设置器属性。使用init而不是set可以启用属性的初始化:

public class House
{
    public string Address { get; init; }
    public string Owner { get; init; }
    public DateTime? Built { get; init; }
}

这使你能够创建一个具有未知属性的房子:

var house2 = new House();

或者分配它们:

var house1 = new House
{
    Address = "Kings street 4",
    Owner = "King",
    Built = DateTime.Now
};

使用仅init的设置器特别有用,当你想要只读数据时,这些数据可以是已知的或未知的,但不一定是一致的。

注意

你可以在packt.link/89J99找到这个示例的代码。

ValueTuple和解构

你已经知道一个函数只能返回一个值。在某些情况下,你可以使用out关键字来返回第二个值。例如,将字符串转换为数字通常是这样做的:

var text = "123";
var isNumber = int.TryParse(text, out var number);

TryParse返回解析的数字以及文本是否为数字。

然而,C#有一个更好的方法来返回多个值。你可以使用名为ValueTuple的数据结构来实现这一点。它是一个泛型struct,包含从一到六个公共可变字段,可以是任何(指定的)类型。它只是一个用于持有无关值的容器。例如,如果你有一个dog、一个human和一个Bool,你可以在一个ValueTuple结构体中存储所有三个:

var values1 = new ValueTuple<Dog, Human, bool>(dog, human, isDogKnown);

然后,你可以访问每个——也就是说,通过values1.Item1访问dog,通过values1.Item2访问human,以及通过values.Item3访问isDogKnown。创建ValueTuple`结构体的另一种方法是使用括号。这和之前做的是完全一样,但使用了括号语法:

var values2 = (dog, human, isDogKnown);

以下语法非常有用,因为它允许你声明一个实际上返回多个值的功能:

public (Dog, Human, bool) GetDogHumanAndBool()
{
    var dog = new Dog("Sparky");
    var human = new Human("Thomas");
    bool isDogKnown = false;

    return (dog, human, isDogKnown);
}

注意

你可以在packt.link/OTFpm找到这个示例的代码。

你也可以做相反的操作,使用另一个名为解构的 C#特性。它接受对象数据成员,并允许你将它们分开,到不同的变量中。元组类型的问题是没有强名称。如前所述,每个字段都将被命名为ItemX,其中X是返回项的顺序。处理所有这些,GetDogHumanAndBool将需要将结果分配给三个不同的变量:

var dogHumanAndBool = GetDogHumanAndBool();
var dog = dogHumanAndBool.Item1;
var human = dogHumanAndBool.Item2;
var boo = dogHumanAndBool.Item3;

你可以简化这一点,并利用解构——立即将对象属性分配给不同的变量:

var (dog, human, boo) = GetDogHumanAndBool(); 

通过解构,你可以使代码变得更加易读和简洁。当你有多个无关变量并希望从函数中返回它们时,请使用ValueTuple。你不必总是使用out关键字来绕过,也不必通过创建一个新类来增加开销。你可以通过简单地返回并解构一个ValueTuple结构体来解决这个问题。

你现在可以通过以下练习亲身体验使用 SOLID 原则逐步编写代码。

练习 2.04:创建可组合的温度单位转换器

温度可以用不同的单位来衡量:摄氏度、开尔文和华氏度。将来可能会添加更多单位。然而,用户不必动态地添加单位;应用程序要么支持它,要么不支持。你需要创建一个应用程序,将温度从任何单位转换为另一个单位。

需要注意的是,从该单位转换到其他单位以及从其他单位转换到该单位将完全是两回事。因此,你将为每个转换器需要两个方法。作为一个标准单位,你将使用摄氏度。因此,每个转换器都应该有一个从和到摄氏度的转换方法,这使得它成为程序中最简单的单位。当你需要将非摄氏度转换为摄氏度时,你需要涉及两个转换器——一个将输入适配到标准单位(C),然后另一个将 C 转换为所需的任何单位。这个练习将帮助你开发一个应用程序,使用你在本章中学到的 SOLID 原则和 C#特性,例如recordenum

执行以下步骤来完成此操作:

  1. 创建一个使用enum类型定义常量(即一组已知值)的TemperatureUnit。你不需要动态添加它:

    public enum TemperatureUnit
    {
        C,
        F,
        K
    }
    

在这个例子中,你将使用三个温度单位,即CKF

  1. 应该将温度视为由两个属性组成的简单对象:UnitDegrees。你可以使用recordstruct,因为这是一个非常简单的具有数据的对象。在这里的最佳选择是选择struct(由于对象的大小),但为了练习的目的,你将使用record

    public record Temperature(double Degrees, TemperatureUnit Unit);
    
  2. 接下来,添加一个定义你希望从单个特定温度转换器中获得的合同:

    public interface ITemperatureConverter
    {
        public TemperatureUnit Unit { get; }
        public Temperature ToC(Temperature temperature);
        public Temperature FromC(Temperature temperature);
    }
    

你定义了一个包含三个方法的外部接口——Unit属性用于标识转换器针对的是哪种温度,以及ToCFromC用于从和到标准单位进行转换。

  1. 现在你已经有一个转换器了,添加一个组合转换器,它包含一个转换器数组:

    public class ComposableTemperatureConverter
    {
        private readonly ITemperatureConverter[] _converters;
    
  2. 存在重复的温度单位转换器是没有意义的。因此,当检测到重复的转换器时,应该抛出一个错误。同样,没有任何转换器也是没有意义的。因此,应该有一些代码来验证null或空转换器:

    public class InvalidTemperatureConverterException : Exception
    {
        public InvalidTemperatureConverterException(TemperatureUnit unit) : base($"Duplicate converter for {unit}.")
        {
        }
    
        public InvalidTemperatureConverterException(string message) : base(message)
        {
        }
    }
    

在创建自定义异常时,你应该尽可能提供有关错误上下文的信息。在这种情况下,传递转换器未找到的unit

  1. 添加一个需要非空转换器的method

    private static void RequireNotEmpty(ITemperatureConverter[] converters)
    {
        if (converters?.Length > 0 == false)
        {
            throw new InvalidTemperatureConverterException("At least one temperature conversion must be supported");
        }
    }
    

传递一个空转换器数组将抛出InvalidTemperatureConverterException异常。

  1. 添加一个需要非重复转换器的method

    private static void RequireNoDuplicate(ITemperatureConverter[] converters)
    {
        for (var index1 = 0; index1 < converters.Length - 1; index1++)
        {
            var first = converters[index1];
            for (int index2 = index1 + 1; index2 < converters.Length; index2++)
            {
                var second = converters[index2];
                if (first.Unit == second.Unit)
                {
                    throw new InvalidTemperatureConverterException(first.Unit);
                }
            }
        }
    }
    

这种方法会遍历每一个转换器,并检查在其他索引处是否重复了相同的转换器(通过复制TemperatureUnit)。如果发现重复的单位,它将抛出异常。如果没有,它将成功终止。

  1. 现在将所有这些组合在一个构造函数中:

    public ComposableTemperatureConverter(ITemperatureConverter[] converters)
    {
        RequireNotEmpty(converters);
        RequireNoDuplicate(converters);
        _converters = converters;
    }
    

在创建转换器时,验证它们不为空且不重复,然后才设置它们。

  1. 接下来,创建一个private辅助方法来帮助你在组合转换器中找到所需的转换器,即FindConverter

    private ITemperatureConverter FindConverter(TemperatureUnit unit)
    {
        foreach (var converter in _converters)
        {
            if (converter.Unit == unit)
            {
                return converter;
            }
        }
    
        throw new InvalidTemperatureConversionException(unit);
    }
    

此方法返回所需单位的转换器,如果找不到转换器,则抛出异常。

  1. 为了简化从任何单位到摄氏度的搜索和转换,添加一个ToCelsius方法:

    private Temperature ToCelsius(Temperature temperatureFrom)
    {
        var converterFrom = FindConverter(temperatureFrom.Unit);
        return converterFrom.ToC(temperatureFrom);
    }
    

在这里,你找到所需的转换器并将Temperature转换为摄氏度。

  1. 将同样的方法用于将摄氏度转换为任何其他单位:

    private Temperature CelsiusToOther(Temperature celsius, TemperatureUnit unitTo)
    {
        var converterTo = FindConverter(unitTo);
        return converterTo.FromC(celsius);
    }
    
  2. 通过实现这个算法来总结整个过程,标准化温度(转换为摄氏度),然后转换为任何其他温度:

    public Temperature Convert(Temperature temperatureFrom, TemperatureUnit unitTo)
    {
        var celsius = ToCelsius(temperatureFrom);
        return CelsiusToOther(celsius, unitTo);
    }
    
  3. 添加一些转换器。从开尔文转换器KelvinConverter开始:

    public class KelvinConverter : ITemperatureConverter
    {
        public const double AbsoluteZero = -273.15;
    
        public TemperatureUnit Unit => TemperatureUnit.K;
    
        public Temperature ToC(Temperature temperature)
        {
            return new(temperature.Degrees + AbsoluteZero, TemperatureUnit.C);
        }
    
        public Temperature FromC(Temperature temperature)
        {
            return new(temperature.Degrees - AbsoluteZero, Unit);
        }
    }
    

所有其他转换器的实现都是直接的。你所要做的就是实现从或到摄氏度的正确单位的转换公式。开尔文有一个有用的常数,绝对零,所以你使用了一个命名的常数而不是一个魔法数字-273.15。此外,值得记住的是,温度不是一个原始数据类型。它既是一个度值,也是一个单位。因此,在转换时,你需要传递这两个值。ToC将始终以TemperatureUnit.C为单位,而FromC将接受转换器被识别为的任何单位,在这种情况下,TemperatureUnit.K

  1. 现在添加一个华氏度转换器,FahrenheitConverter

    public class FahrenheitConverter : ITemperatureConverter
    {
        public TemperatureUnit Unit => TemperatureUnit.F;
    
        public Temperature ToC(Temperature temperature)
        {
            return new(5.0/9 * (temperature.Degrees - 32), TemperatureUnit.C);
        }
    
        public Temperature FromC(Temperature temperature)
        {
            return new(9.0 / 5 * temperature.Degrees + 32, Unit);
        }
    }
    

华氏度的结构相同;唯一的区别是公式和单位值。

  1. 添加一个CelsiusConverter,它将接受一个温度值并返回相同的值,如下所示:

        public class CelsiusConverter : ITemperatureConverter
        {
            public TemperatureUnit Unit => TemperatureUnit.C;
    
            public Temperature ToC(Temperature temperature)
            {
                return temperature;
            }
    
            public Temperature FromC(Temperature temperature)
            {
                return temperature;
            }
        }
    

CelsiusConverter 是最简单的一个。它什么也不做;它只是返回相同的温度。转换器将转换为标准温度——摄氏度到摄氏度总是摄氏度。为什么你需要这样的类呢?如果没有它,你将需要稍微改变一下流程,添加 if 语句来忽略如果是摄氏度的温度。但是,使用这种实现,你可以将其纳入相同的流程,并使用相同的抽象 ITemperatureConverter 以相同的方式使用。

  1. 最后,创建一个演示:

    Solution.cs
    public static class Solution
    {
        public static void Main()
        {
            ITemperatureConverter[] converters = {new FahrenheitConverter(), new KelvinConverter(), new CelsiusConverter()};
            var composableConverter = new ComposableTemperatureConverter(converters);
    
            var celsius = new Temperature(20.00001, TemperatureUnit.C);
    
            var celsius1 = composableConverter.Convert(celsius, TemperatureUnit.C);
            var fahrenheit = composableConverter.Convert(celsius1, TemperatureUnit.F);
            var kelvin = composableConverter.Convert(fahrenheit, TemperatureUnit.K);
            var celsiusBack = composableConverter.Convert(kelvin, TemperatureUnit.C);
            Console.WriteLine($"{celsius} = {fahrenheit}");
    
You can find the complete code here: https://packt.link/ruBph.

在这个例子中,你已经创建了所有转换器并将它们传递给了名为 composableConverter 的转换器容器。然后你创建了一个摄氏度温度并使用它来执行到所有其他温度的转换。

  1. 运行代码,你将得到以下结果:

    Temperature { Degrees = 20.00001, Unit = C } = Temperature { Degrees = 68.000018, Unit = F }
    Temperature { Degrees = 68.000018, Unit = F } = Temperature { Degrees = -253.14998999999997, Unit = K }
    Temperature { Degrees = -253.14998999999997, Unit = K } = Temperature { Degrees = 20.000010000000003, Unit = C }
    

    注意

    你可以在 packt.link/dDRU6 找到用于此练习的代码。

软件开发者应该以这样的方式设计代码,即现在或将来进行更改所需的时间相同。通过使用 SOLID 原则,你可以增量地编写代码并最小化破坏性更改的风险,因为你永远不会更改现有代码;你只是添加新代码。随着系统的增长,复杂性增加,可能很难了解事物是如何工作的。通过定义良好的契约,SOLID 使你能够拥有易于阅读和维护的代码,因为每个部分本身都很简单,并且它们彼此隔离。

你现在将通过一个活动来测试你创建类和重载操作符的知识。

活动二.01:合并两个圆

在这个活动中,你将创建类并重载操作符来解决以下数学问题:一块披萨面团的一部分可以用来制作两个半径为三厘米的圆形披萨块。如果使用相同量的面团制作单个披萨块,其半径会是多少?你可以假设所有披萨块厚度相同。以下步骤将帮助你完成这个活动:

  1. 创建一个具有半径的 Circle 结构。它应该是一个 struct,因为它是一个简单的数据对象,它有一点点逻辑,计算面积。

  2. 添加一个属性来获取圆的面积(尝试使用表达式成员)。记住,圆的面积公式是 pi*r*r。要使用 PI 常量,你需要导入 Math 包。

  3. 将两个圆的面积相加。最自然的方式是使用加号(+)的重载。实现一个 + 操作符重载,它接受两个圆并返回一个新的圆。新圆的面积是两个旧圆面积的总和。然而,不要通过传递面积来创建一个新的圆。你需要一个半径。你可以通过将新面积除以 PI 然后取结果的平方根来计算这个半径。

  4. 现在创建一个 Solution 类,它接受两个圆并返回一个结果——新圆的半径。

  5. main方法中,创建两个半径为3厘米的圆,并定义一个新的圆,其面积等于另外两个圆面积之和。打印结果。

  6. 运行main方法,结果应该如下:

    Adding circles of radius of 3 and 3 results in a new circle with a radius 4.242640687119285
    

如你所见,这个最终输出显示,新的圆将有一个半径为4.24(四舍五入到小数点后第二位)。

注意

该活动的解决方案可在packt.link/qclbF找到。

这个活动旨在测试你创建类和重载运算符的知识。通常不会使用运算符来解决这类问题,但在这个情况下,它效果很好。

摘要

在本章中,你学习了面向对象编程(OOP)以及它是如何帮助将复杂问题抽象成简单概念的。C#有几个有用的特性,并且大约每一年或两年就会发布一个新的语言版本。本章中提到的特性只是 C#帮助提高生产力的几种方式之一。你已经看到,通过设计,它允许编写更好、更清晰的代码,且更不容易出错。C#是生产力方面最好的语言之一。使用 C#,你可以快速编写有效的代码,因为很多样板代码都是为你预先准备好的。

最后,你学习了 SOLID 原则并在一个应用中使用了它们。SOLID 不是你可以一读就学会的东西;它需要实践、与同伴的讨论以及大量的试错,才能正确地掌握并开始持续应用。然而,这些好处是值得的。在现代软件开发中,快速、高效地编写代码已不再是首要任务。如今,重点是平衡生产力(你开发的速度)和性能(你的程序运行的速度)。C#是性能和生产力方面最有效的语言之一。

在下一章中,你将学习什么是函数式编程以及如何使用 lambda 和函数式结构,如委托。

第三章:3. 委托、事件和 Lambda

概述

在本章中,你将学习如何定义和调用委托,并探索它们在.NET 生态系统中的广泛使用。有了这些知识,你将转向内置的ActionFunc委托,以了解它们的用法如何减少不必要的样板代码。然后,你将看到如何利用多播委托向多个参与者发送消息,以及如何将事件集成到事件驱动代码中。在这个过程中,你将发现一些常见的陷阱和最佳实践,以防止一个优秀应用程序变成不可靠的应用程序。

本章将揭开 lambda 语法风格的神秘面纱,并展示如何有效地使用它。到本章结束时,你将能够舒适地使用 lambda 语法来创建简洁、易于理解和维护的代码。

简介

在上一章中,你学习了面向对象编程(OOP)的一些关键方面。在本章中,你将通过查看 C#中用于使类相互交互的常见模式来在此基础上进行构建。

你是否发现自己正在处理必须监听某些信号并在其上采取行动的代码,但你无法确定这些行动应该在运行时是什么?也许你有一段需要重用或传递给其他方法的代码块,以便它们在准备好时调用。或者,你可能想过滤一组对象,但需要根据用户偏好的组合来决定如何进行。许多这样的功能可以通过使用接口来实现,但通常更有效的方法是创建可以以类型安全的方式传递给其他类的代码块。这些块被称为委托,是许多.NET 库的骨架,允许将方法或代码片段作为参数传递。

委托的自然扩展是事件,这使得在软件中提供一种可选行为成为可能。例如,你可能有一个广播实时新闻和股票价格的组件,但除非你提供一种方式来选择加入这些服务,否则你可能限制了该组件的可使用性。

用户界面(UI)应用程序通常提供各种用户动作的通知,例如按键、滑动屏幕或点击鼠标按钮;这些通知在 C#中遵循一个标准模式,将在本章中详细讨论。在这种情况下,检测此类动作的 UI 元素被称为发布者,而执行这些消息的代码被称为订阅者。当它们结合在一起时,形成了一种称为发布者-订阅者或 pub-sub 的事件驱动设计。你将看到如何在所有类型的 C#中使用它。记住,它的使用并不局限于 UI 应用程序。

最后,你将学习关于 lambda 语句和 lambda 表达式,统称为 lambda。这些具有不寻常的语法,一开始可能需要一段时间才能适应。与在类中分散许多方法和函数不同,lambda 允许更小的代码块,通常是自包含的,并且位于代码中使用它们的附近,从而提供了一种更容易跟踪和维护代码的方法。你将在本章的后半部分详细了解 lambda。首先,你将学习关于委托(delegates)的内容。

委托

.NET 委托与其他语言中找到的函数指针类似,例如 C++;换句话说,它是一个指向将在运行时调用的方法的指针。本质上,它是一个代码块的占位符,这可能是一个简单的语句,也可能是一个完整的、多行的代码块,包括复杂的执行分支,你要求其他代码在某个时间点执行。术语“委托”暗示了一种形式的“代表”,这正是这个占位符概念所关联的。

委托允许对象之间的最小耦合和更少的代码。不需要创建从特定类或接口派生的类。通过使用委托,你定义了一个兼容方法的外观,无论它是在类或结构体中,静态的还是基于实例的。参数和返回类型定义了这个调用兼容性。

此外,委托可以以回调的方式使用,这允许将多个方法连接到单个发布源。它们通常需要的代码更少,并且提供的功能比基于接口的设计更多。

以下示例显示了委托的有效性。假设你有一个通过姓氏搜索用户的类。它可能看起来像这样:

public User FindBySurname(string name)
{
    foreach(var user in _users)
       if (user.Surname == name)
          return user;
    return null;
}

然后你需要扩展这个功能,包括搜索用户的登录名:

public User FindByLoginName(string name)
{
    foreach(var user in _users)
       if (user.LoginName == name)
          return user;
    return null;
}

再次,你决定添加另一个搜索,这次是通过位置:

public User FindByLocation(string name)
{
    foreach(var user in _users)
       if (user.Location == name)
          return user;
    return null;
}

你开始搜索的代码如下:

public void DoSearch()
{
  var user1 = FindBySurname("Wright");
  var user2 = FindByLoginName("JamesR");
  var user3 = FindByLocation("Scotland"); 
}

你能看出每次发生的是什么模式吗?你正在重复相同的代码,该代码遍历用户列表,应用布尔条件(也称为谓词)以找到第一个匹配的用户。

唯一不同的是,谓词(predicate)决定是否找到了匹配项。这是委托在基本级别使用的一个常见案例。predicate可以用一个委托替换,作为占位符,在需要时进行评估。

将此代码转换为委托风格,你定义一个名为FindUser的委托(这一步可以跳过,因为.NET 包含一个可以重用的委托定义;你将在稍后了解这一点)。

你只需要一个辅助方法,Find,它接受一个FindUser委托实例。Find知道如何遍历用户,调用委托并传入用户,以返回匹配的布尔值:

private delegate bool FindUser(User user);
private User Find(FindUser predicate)
{
  foreach (var user in _users)
    if (predicate(user))
      return user;
  return null;
}
public void DoSearch()
{
  var user4 = Find(user => user.Surname == "Wright");
  var user5 = Find(user => user.LoginName == "JamesR");
  var user6 = Find(user => user.Location == "Scotland");
}

如你所见,代码现在更加紧凑。不需要剪切和粘贴遍历用户的代码,因为所有这些都在一个地方完成。对于每种类型的搜索,你只需定义一个 delegate 并将其传递给 Find。要添加新的搜索类型,你只需在单行语句中定义它,而不是复制至少八行重复的循环函数。

Lambda 语法是定义方法体的一种基本风格,但它的奇怪语法可能会在最初成为障碍。乍一看,Lambda 表达式可能看起来很奇怪,因为它们有 => 风格,但它们确实提供了一种更简洁的方式来指定目标方法。定义 Lambda 的行为与定义方法类似;你基本上省略了方法名,并使用 => 作为代码块的占位符。

现在,你将查看另一个示例,这次使用接口。假设你正在开发一个图形引擎,并且需要在用户每次旋转或缩放时计算屏幕上图像的位置。注意,此示例跳过了任何复杂的数学计算。

考虑到你需要使用 ITransform 接口和名为 Move 的单个方法来转换 Point 类,如下面的代码片段所示:

public class Point
{
  public double X { get; set; } 
  public double Y { get; set; }
}
public interface ITransform
{
  Point Move(double height, double width);
}

当用户旋转一个对象时,你需要使用 RotateTransform,而对于缩放操作,你将使用 ZoomTransform,如下所示。两者都基于 ITransform 接口:

public class RotateTransform : ITransform
{
    public Point Move(double height, double width)
    {
        // do stuff
        return new Point();
    }
}
public class ZoomTransform : ITransform
{
    public Point Move(double height, double width)
    {
        // do stuff
        return new Point();
    }
}

因此,给定这两个类,可以通过创建一个新的 Transform 实例并将其传递给名为 Calculate 的方法来转换一个点,如下面的代码所示。Calculate 调用相应的 Move 方法,并在点上进行一些额外的未指定工作,然后返回点给调用者:

public class Transformer
{
    public void Transform()
    {
        var rotatePoint = Calculate(new RotateTransform(), 100, 20);
        var zoomPoint = Calculate(new ZoomTransform(), 5, 5);
    }
    private Point Calculate(ITransform transformer, double height, double width)
    {
        var point = transformer.Move(height, width);
        //do stuff to point
        return point;
    }
}

这是一个基于类和接口的标准设计,但你也可以看到,你为了从 Move 方法中仅使用一个数值就创建了大量的新类。将计算分解成易于遵循的实现是一个值得考虑的想法。毕竟,如果在一个方法中实现多个 if-then 分支,可能会引起未来的维护问题。

通过重新实现基于 delegate 的设计,你仍然拥有可维护的代码,但需要照看的部分要少得多。你可以有一个 TransformPoint delegate 和一个可以传递 TransformPoint delegate 的新 Calculate 函数。

你可以通过在其名称周围添加括号并传递任何参数来调用 delegate。这与调用标准类级别的函数或方法的方式相似。你将在稍后详细介绍这种调用;现在,考虑以下代码片段:

    private delegate Point TransformPoint(double height, double width);
    private Point Calculate(TransformPoint transformer, double height, double width)
    {
        var point = transformer(height, width);
        //do stuff to point
        return point;
    }

你仍然需要实际的 RotateZoom 方法,但不需要创建不必要的类来执行此操作。你可以添加以下代码:

    private Point Rotate(double height, double width)
    {
        return new Point();
    }
    private Point Zoom(double height, double width)
    {
        return new Point();
    }

现在,调用名为 delegate 的方法就像以下这样简单:

    public void Transform()
    {
         var rotatePoint1 = Calculate(Rotate, 100, 20);
         var zoomPoint1 = Calculate(Zoom, 5, 5);
    }

注意使用 delegate 的这种方式如何帮助消除大量不必要的代码。

注意

你可以在packt.link/AcwZA找到用于此示例的代码。

除了调用单个占位符方法外,委托还包含额外的管道,允许它以多播方式使用,即一种将多个目标方法链接在一起的方式,每个方法依次被调用。这通常被称为调用列表或委托链,由充当发布源代码启动。

如何将这种多播概念应用于 UI 的一个简单例子可以在 UI 中看到。想象一下,你有一个显示一个国家地图的应用程序。当用户将鼠标移到地图上时,你可能想执行以下各种操作:

  • 当鼠标悬停在建筑物上时,将鼠标指针更改为不同的形状。

  • 显示一个计算真实世界经纬度坐标的工具提示。

  • 在状态栏中显示计算鼠标悬停区域人口的消息。

要实现这一点,你需要一种方法来检测用户何时将鼠标移到屏幕上。这通常被称为发布者。在这个例子中,它的唯一目的是检测鼠标移动并将它们发布给任何正在监听的人。

要执行所需的三个 UI 操作,你需要创建一个类,该类具有一个对象列表,当鼠标位置改变时通知这些对象,允许每个对象独立于其他对象执行所需的任何活动。这些对象中的每一个都被称为订阅者。

当你的发布者检测到鼠标已移动时,你将遵循以下伪代码:

MouseEventArgs args = new MouseEventArgs(100,200)
foreach(subscription in subscriptionList)
{
   subscription.OnMouseMoved(args)
} 

这假设subscriptionList是一个对象列表,可能基于具有OnMouseMoved方法的接口。添加代码以使感兴趣方能够订阅和取消订阅OnMouseMoved通知取决于你。如果之前已订阅的代码没有取消订阅的方式,并且在不再需要调用时反复被调用,这将是一个不幸的设计。

在前面的代码中,发布者和订阅者之间存在相当多的耦合,你将回到使用接口进行类型安全实现。如果你还需要监听按键,包括按键按下和按键释放,很快就会感到非常沮丧,需要反复复制如此相似的代码。

幸运的是,委托类型包含所有这些内置行为。你可以单目标或多目标方法互换使用;你所需要做的就是调用一个委托,委托将为你处理其余部分。

你将很快深入了解多播委托,但首先,你将探索单目标方法场景。

定义自定义委托

委托的定义方式与标准方法类似。编译器不关心目标方法体中的代码,只关心它可以在某个时间点安全地被调用。

使用以下格式定义 delegate 关键字:

public delegate void MessageReceivedHandler(string message, int size);

以下列表描述了此语法的每个组成部分:

  • 范围:一个访问修饰符,例如 publicprivateprotected,用于定义委托的作用域。如果你不包括修饰符,编译器将默认将其标记为 private,但总是最好明确地显示你代码的意图。

  • delegate 关键字。

  • 返回类型:如果没有返回类型,则使用 void

  • 委托名称:这可以是任何你喜欢的名称,但名称必须在命名空间内是唯一的。许多命名约定(包括微软的)建议将 HandlerEventHandler 添加到你的委托名称中。

  • 如果需要,则提供参数。

    注意

    委托可以嵌套在类或命名空间内;它们也可以在全局命名空间中定义,尽管这种做法是不被推荐的。在 C# 中定义类时,常见的做法是在父命名空间中定义它们,通常基于以公司名称开始,然后是产品名称,最后是功能的分层约定。这有助于为类型提供更独特的标识。

    通过在没有任何命名空间的情况下定义委托,如果它也在没有命名空间保护的库中定义了具有相同名称的另一个委托,那么它很可能与另一个委托发生冲突。这可能导致编译器混淆,不知道你指的是哪个委托。

在 .NET 的早期版本中,定义自定义委托是一种常见的做法。此类代码后来被各种内置的 .NET 委托所取代,你将在稍后查看。现在,你将简要介绍定义自定义委托的基础知识。如果你维护任何遗留的 C# 代码,了解这一点是很有价值的。

在下一个练习中,你将创建一个自定义委托,它接受一个 DateTime 参数并返回一个布尔值以指示有效性。

练习 3.01:定义和调用自定义委托

假设你有一个允许用户订购产品的应用程序。在填写订单详情时,客户可以指定订单日期和交货日期,这两个日期在接受订单之前都必须经过验证。你需要一种灵活的方式来验证这些日期。对于某些客户,你可能允许周末的交货日期,而对于其他客户,交货日期至少必须提前七天。你还可以允许某些客户的订单日期倒退。

你知道委托提供了一种在运行时改变实现的方式,因此这是最佳的做法。你不想使用多个接口,或者更糟糕的是,一个复杂的 if-then 语句的混乱,来实现这一点。

根据客户的配置文件,你可以创建一个名为 Order 的类,该类可以传递不同的日期验证规则。这些规则可以通过一个 Validate 方法进行验证:

执行以下步骤来完成此操作:

  1. 创建一个名为 Chapter03 的新文件夹。

  2. 切换到Chapter03文件夹,并使用 CLI dotnet命令创建一个新的控制台应用程序,名为Exercise01,如下所示:

    source\Chapter03>dotnet new console -o Exercise01
    

你将看到以下输出:

The template "Console Application" was created successfully.
Processing post-creation actions...
Running 'dotnet restore' on Exercise01\Exercise01.csproj...
  Determining projects to restore...
  Restored source\Chapter03\Exercise01\Exercise01.csproj (in 191 ms).
Restore succeeded.
  1. 打开Chapter03\Exercise01.csproj并将内容替换为以下设置:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    </Project>
    
  2. 打开Exercise01\Program.cs并清除内容。

  3. 使用命名空间来防止与其他库中的对象冲突的偏好已经在前面提到过,因此为了保持独立,使用Chapter03.Exercise01作为命名空间。

要实现你的日期验证规则,你将定义一个接受单个DateTime参数并返回布尔值的代理。你将把它命名为DateValidationHandler

using System;
namespace Chapter03.Exercise01 
{
    public delegate bool DateValidationHandler(DateTime dateTime);
}
  1. 接下来,你将创建一个名为Order的类,它包含订单的详细信息,并且可以被传递给两个日期验证代理:

       public class Order
        {
            private readonly DateValidationHandler _orderDateValidator;
            private readonly DateValidationHandler _deliveryDateValidator;
    

注意你如何声明了两个只读的类级别实例DateValidationHandler,一个用于验证订单日期,另一个用于验证交付日期。这种设计假设对于这个Order实例,日期验证规则不会改变。

  1. 现在是构造函数,你传递这两个代理:

           public Order(DateValidationHandler orderDateValidator,
                DateValidationHandler deliveryDateValidator)
            {
                _orderDateValidator = orderDateValidator;
                _deliveryDateValidator = deliveryDateValidator;
            }  
    

在这个设计中,通常有一个不同的类负责根据所选客户的配置文件决定使用哪个代理。

  1. 你需要添加两个待验证的日期属性。这些日期可以使用一个监听按键并直接将用户编辑应用到这个类的 UI 来设置:

            public DateTime OrderDate { get; set; }
            public DateTime DeliveryDate { get; set; }
    
  2. 现在添加一个IsValid方法,将OrderDate传递给orderDateValidator代理,将DeliveryDate传递给deliveryDateValidator代理:

            public bool IsValid() => 
                _orderDateValidator(OrderDate) &&
                _deliveryDateValidator(DeliveryDate);
        }
    

如果两者都有效,那么这个调用将返回true。关键在于Order不需要知道单个客户的日期验证规则的确切实现,因此你可以在程序的其他地方轻松重用Order。要调用代理,你只需将任何参数用括号括起来,在这种情况下,将正确的日期属性传递给每个代理实例:

  1. 要创建一个控制台应用程序来测试这个,添加一个名为Programstatic类:

        public static class Program
        {
    
  2. 你想要创建两个函数来验证传递给它们的日期是否有效。这些函数将构成你的代理目标方法的基础:

            private static bool IsWeekendDate(DateTime date)
            {
                Console.WriteLine("Called IsWeekendDate");
                return date.DayOfWeek == DayOfWeek.Saturday ||
                       date.DayOfWeek == DayOfWeek.Sunday;
            }
            private static bool IsPastDate(DateTime date)
            {
                Console.WriteLine("Called IsPastDate");
                return date < DateTime.Today;
            }
    

注意它们都有DateValidationHandler代理期望的确切签名。它们两个都不知道它们正在验证的日期的性质,因为这不是它们的关注点。由于它们在这个类的任何地方都不与任何变量或属性交互,所以它们都被标记为static

  1. 现在是Main入口点。在这里,你创建了两个DateValidationHandler代理实例,将IsPastDate传递给一个,将IsWeekendDate传递给另一个。这些是当每个代理被调用时将被调用的目标方法:

            public static void Main()
            {
               var orderValidator = new DateValidationHandler(IsPastDate);
               var deliverValidator = new DateValidationHandler(IsWeekendDate);
    
  2. 现在你可以创建一个Order实例,传递代理并设置订单和交付日期:

              var order = new Order(orderValidator, deliverValidator)
                {
                    OrderDate = DateTime.Today.AddDays(-10), 
                    DeliveryDate = new DateTime(2020, 12, 31)
                };
    

创建委托有多种方式。在这里,您首先将它们分配给变量,以使代码更清晰(您将在后面了解不同的风格)。

  1. 现在只需在控制台中显示日期并调用IsValid即可,这将依次调用您的每个委托方法。请注意,使用了自定义日期格式,以便使日期更易于阅读:

              Console.WriteLine($"Ordered: {order.OrderDate:dd-MMM-yy}");
              Console.WriteLine($"Delivered: {order.DeliveryDate:dd-MMM-yy }");
              Console.WriteLine($"IsValid: {order.IsValid()}");
            }
        }
    }
    
  2. 运行控制台应用程序的输出如下:

    Ordered: 07-May-22
    Delivered: 31-Dec-20
    Called IsPastDate
    Called IsWeekendDate
    IsValid: False
    

此顺序有效,因为交付日期是星期四,而不是您所需的周末:

您已经学会了如何定义自定义委托,并创建了两个实例,这些实例使用小的辅助函数来验证日期。这使您了解委托是多么灵活。

注意

您可以在packt.link/cmL0s找到用于此练习的代码。

内置的 Action 和 Func 委托

当您定义一个委托时,您正在描述其签名,即返回类型和输入参数列表。话虽如此,考虑这两个委托:

public delegate string DoStuff(string name, int age);
public delegate string DoMoreStuff(string name, int age);

它们具有相同的签名,但仅名称不同,这就是为什么您可以在调用时声明每个实例,并使它们指向相同的目标方法:

public static void Main()
{
    DoStuff stuff = new DoStuff(MyMethod);
    DoMoreStuff moreStuff = new DoMoreStuff(MyMethod);
    Console.WriteLine($"Stuff: {stuff("Louis", 2)}");
    Console.WriteLine($"MoreStuff: {moreStuff("Louis", 2)}");
}
private static string MyMethod(string name, int age)
{
    return $"{name}@{age}";
}

运行控制台应用程序在两次调用中都产生相同的结果:

Stuff: Louis@2
MoreStuff: Louis@2

注意

您可以在packt.link/r6B8n找到用于此示例的代码。

如果您能省去定义DoStuffDoMoreStuff委托,并使用具有完全相同签名的更通用委托,那就太好了。毕竟,在先前的代码片段中,您创建DoStuffDoMoreStuff委托都没有关系,因为它们都调用相同的目标方法。

.NET 实际上提供了各种内置委托,您可以直接使用它们,从而节省了您自己定义这些委托的努力。这些是ActionFunc委托。

ActionFunc委托的组合有很多种可能,每种组合都允许使用更多参数。您可以从 0 到 16 种不同的参数类型中进行选择。由于组合众多,您几乎不需要定义自己的委托类型。

值得注意的是,ActionFunc委托是在.NET 的后续版本中添加的,因此自定义委托的使用通常在较老的遗留代码中找到。您不需要自己创建新的委托。

在以下代码片段中,MyMethod使用三个参数的Func变体调用;您很快就会了解这个看起来很奇怪的<string, int, string>语法:

Func<string, int, string> funcStuff = MyMethod;
Console.WriteLine($"FuncStuff: {funcStuff("Louis", 2)}");

这产生的返回值与前面的两次调用相同:

FuncStuff: Louis@2

在你继续探索 ActionFunc 委托之前,探索一下 Action<string, int, string> 语法可能会有所帮助。这种语法允许使用类型参数来定义类和方法。这些被称为泛型,并作为特定类型的占位符。在 第四章数据结构和 LINQ 中,你会更详细地了解泛型,但在这里用 ActionFunc 委托总结它们的用法是值得的。

Action 委托的非泛型版本在 .NET 中如下预定义:

public delegate void Action()

如你所知,从你对委托的早期了解来看,这是一个不接受任何参数且没有返回类型的委托;这是可用的最简单类型的委托。

与.NET 中预定义的泛型 Action 委托之一进行对比:

public delegate void Action<T>(T obj)

你可以看到这包括一个 <T>T 参数部分,这意味着它接受一个受限于字符串的 Action,它接受单个字符串参数并返回无值,如下所示:

Action<string> actionA;

那么一个受限于 int 的版本呢?这也没有返回类型,并且接受单个 int 参数:

Action<int> actionB;

你能在这里看到模式吗?本质上,你指定的类型可以在编译时用来声明一个类型。如果你想用两个、三个、四个……或者 16 个参数呢?很简单。有 ActionFunc 泛型类型可以接受多达 16 种不同的参数类型。你不太可能编写需要超过 16 个参数的代码。

这个接受两个参数的 Actionintstring 作为参数:

Action<int, string> actionC;

你可以把它转过来。这里有一个接受两个参数的 Action,但这个 Action 接受一个 string 参数和一个 int 参数:

Action<string, int> actionD;

这些涵盖了大多数参数组合,所以你可以看到,创建自己的委托类型是非常罕见的。

同样的规则适用于返回值的委托;这就是 Func 类型被使用的地方。泛型 Func 类型以单个值类型参数开始:

public delegate T Func<T>()

在下面的示例中,funcE 是一个返回布尔值且不接受任何参数的委托:

Func<bool> funcE;

你能猜出这个相对较长的 Func 声明返回的类型是什么吗?

Func<bool, int, int, DateTime, string> funcF;

这给出了一个返回 string 的委托。换句话说,Func 中的最后一个参数类型定义了返回类型。注意 funcF 接受四个参数:boolintintDateTime

总结来说,泛型是定义类型的好方法。通过允许类型参数作为占位符,它们可以节省大量的重复代码。

分配委托

你在 练习 3.01 中介绍了创建自定义委托和简要介绍了如何分配和调用委托。然后你查看使用首选的 ActionFunc 等效项,但你有其他方法来分配构成委托的方法(或方法)吗?还有其他调用委托的方法吗?

委托可以被分配给一个变量,其方式与分配一个类实例类似。你还可以传递新的实例或静态实例,而无需使用变量。一旦分配,你可以调用委托或将引用传递给其他类,以便它们可以调用它,这通常在框架 API 中完成。

现在将查看一个Func委托,它接受一个DateTime参数并返回一个bool值以指示有效性。你将使用包含两个辅助方法的static类,这些方法形成实际的目标:

public static class DateValidators
{
    public static bool IsWeekend(DateTime dateTime)
        => dateTime.DayOfWeek == DayOfWeek.Saturday ||
           dateTime.DayOfWeek == DayOfWeek.Sunday;
    public static bool IsFuture(DateTime dateTime) 
      => dateTime.Date > DateTime.Today;
}

注意

你可以在packt.link/mwmxh找到此示例使用的代码。

注意,DateValidators类被标记为static。你可能听说过“静态是不高效的”这句话。换句话说,创建一个包含许多静态类的应用程序是一种弱实践。静态类在第一次通过运行代码访问时被实例化,并保持内存中直到应用程序关闭。这使得控制它们的生存期变得困难。如果确实是无状态的,将小型实用类定义为静态就不再是问题。无状态意味着它们不会设置任何局部变量。设置局部状态的静态类非常难以进行单元测试;你永远无法确定设置的变量来自一个测试还是另一个测试。

在前面的代码片段中,IsFuture如果DateTime参数的Date属性晚于当前日期,则返回true。你正在使用静态的DateTime.Today属性来检索当前系统日期。"IsWeekend"使用表达式主体语法定义,如果DateTime参数的星期几是星期六或星期日,则返回true

你可以像分配常规变量一样分配委托(记住你分配了futureValidatorweekendValidator。每个构造函数都传递实际的目标方法,分别是IsFutureIsWeekend实例):

var futureValidator = new Func<DateTime, bool>(DateValidators.IsFuture);
var weekendValidator = new Func<DateTime, bool>(DateValidators.IsWeekend);

注意,使用var关键字在不包含Func前缀的情况下分配委托是不合法的:

var futureValidator = DateValidation.IsFuture;

这会导致以下编译器错误:

Cannot assign method group to an implicitly - typed variable

在了解了委托的知识之后,继续了解如何调用委托。

调用委托

调用一个委托有多种方式。例如,考虑以下定义:

var futureValidator = new Func<DateTime, bool>(DateValidators.IsFuture);

要调用futureValidator,你必须传递一个DateTime值,它将使用以下任何一种样式返回一个bool值:

  • 使用空合并运算符调用:

    var isFuture1 = futureValidator?.Invoke(new DateTime(2000, 12, 31));
    

这是首选且最安全的方法;在调用Invoke之前,你应该始终检查是否为 null。如果有可能委托不指向内存中的对象,那么在访问方法和属性之前必须执行空引用检查。不这样做会导致抛出NullReferenceException。这是运行时警告你对象没有指向任何东西的方式。

通过使用空合并运算符,编译器会为你添加空值检查。在代码中,你明确声明了futureValidator,所以在这里它不能为空。但如果你是从另一个方法传递futureValidator,你如何确保调用者正确地分配了引用?

委托有额外的规则,使得它们在调用时可以抛出NullReferenceException。在前面的例子中,futureValidator有一个单一的目标,但正如你将看到的,NullReferenceException

  • 直接调用

这与前面的方法相同,但没有空值检查的安全性。同样,这也不推荐,因为委托可能会抛出NullReferenceException

var isFuture1 = futureValidator.Invoke(new DateTime(2000, 12, 31));
  • 没有使用Invoke前缀

这样看起来更简洁,因为你只需调用委托而不需要Invoke前缀。然而,这并不推荐,因为可能存在空引用:

var isFuture2 = futureValidator(new DateTime(2050, 1, 20));

通过将它们放在一起进行练习,尝试安全地分配和调用一个委托。

练习 3.02:分配和调用委托

在这个练习中,你将编写一个控制台应用程序,展示如何使用Func委托提取数值。你将创建一个具有DistanceJourneyTime属性的Car类。你将提示用户输入昨天和今天的行程距离,并将这些信息传递给一个Comparison类,该类被告知如何提取值并计算它们的差异。

执行以下步骤来完成此操作:

  1. 切换到Chapter03文件夹,并使用 CLI dotnet命令创建一个新的控制台应用程序,命名为Exercise02

    source\Chapter03>dotnet new console -o Exercise02
    
  2. 打开Chapter03\Exercise02.csproj并将整个文件替换为以下设置:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    </Project>
    
  3. 打开Exercise02\Program.cs并清除其内容。

  4. 首先添加一个名为Car的记录。包含System.Globalization命名空间以进行字符串解析。使用Chapter03.Exercise02命名空间以保持代码与其他练习的分离。

  5. 添加两个属性,DistanceJourneyTime。它们将只有init属性,所以你将使用init关键字:

    using System;
    using System.Globalization;
    namespace Chapter03.Exercise02
    {
        public record Car
        {
            public double Distance { get; init; }
            public double JourneyTime { get; init; }
        }
    
  6. 接下来,创建一个名为Comparison的类,该类传递一个Func委托以进行操作。Comparison类将使用委托提取DistanceJourneyTime属性,并计算两个Car实例之间的差异。通过使用委托的灵活性,Comparison将不知道它是在提取Distance还是JourneyTime,只知道它正在使用一个双精度值来计算差异。这表明,如果你需要将来计算其他Car属性,你可以重用这个类:

        public class Comparison
        {
            private readonly Func<Car, double> _valueSelector;
            public Comparison(Func<Car, double> valueSelector)
            {
                _valueSelector = valueSelector;
            } 
    
  7. 添加三个属性,如下所示:

            public double Yesterday { get; private set; }
            public double Today { get; private set; }
            public double Difference { get; private set; }
    
  8. 现在进行计算,传递两个Car实例,一个用于昨天的汽车行程yesterdayCar,另一个用于今天的行程todayCar

            public void Compare(Car yesterdayCar, Car todayCar)
            {
    
  9. 要计算 Yesterday 的值,调用 valueSelector Func 委托,传入 yesterdayCar 实例。再次记住,Comparison 类不知道它是在提取 Distance 还是 JourneyTime;它只需要知道当 delegate 被一个 Car 参数调用时,它会返回一个双精度浮点数:

                Yesterday = _valueSelector(yesterdayCar);
    
  10. 使用相同的 Func 委托,通过传递 todayCar 实例来提取 Today 的值,但使用相同的方式:

                Today = _valueSelector(todayCar);
    
  11. 现在只是计算两个提取的数字之间的差异;你不需要使用 Func 委托来做这件事:

                Difference = Yesterday - Today;
            }
         }
    
  12. 因此,你有一个知道如何调用 Func 委托来提取特定 Car 属性的类。现在,你需要一个类来封装 Comparison 实例。为此,添加一个名为 JourneyComparer 的类:

        public class JourneyComparer
        {
            public JourneyComparer()
            {
    
  13. 对于汽车行程,你需要计算 YesterdayTodayDistance 属性之间的差异。为此,创建一个 Comparison 类,告诉它如何从一个 Car 实例中提取值。你可以使用与提取汽车 Distance 相同的名称来命名这个 Comparison 类。记住,Comparison 构造函数需要一个 Func 委托,它接收一个 Car 实例并返回一个双精度值。你很快就会添加 GetCarDistance();这最终将通过传递昨天和今天的行程 Car 实例来调用:

              Distance = new Comparison(GetCarDistance);
    
  14. 按照前面步骤描述的过程,为 JourneyTime Comparison 重复此过程;这个应该被告知使用 GetCarJourneyTime(),如下所示:

              JourneyTime = new Comparison(GetCarJourneyTime);
    
  15. 最后,添加另一个名为 AverageSpeedComparison 属性,如下所示。你很快就会看到 GetCarAverageSpeed() 是另一个函数:

               AverageSpeed = new Comparison(GetCarAverageSpeed);
    
  16. 对于 GetCarDistanceGetCarJourneyTime 这两个局部函数,它们接收一个 Car 实例,并相应地返回 DistanceJourneyTime

               static double GetCarDistance(Car car) => car.Distance; 
               static double GetCarJourneyTime(Car car) => car.JourneyTime;
    
  17. 如其名所示,GetCarAverageSpeed 返回平均速度。在这里,你展示了 Func 委托只需要一个兼容的函数;只要返回 double 类型,它返回什么并不重要。Comparison 类不需要知道它在调用 Func 委托时返回的是这样的计算值:

              static double GetCarAverageSpeed(Car car)             => car.Distance / car.JourneyTime;
           }
    
  18. 三个 Comparison 属性应该这样定义:

            public Comparison Distance { get; }
            public Comparison JourneyTime { get; }
            public Comparison AverageSpeed { get; }
    
  19. 现在是主 Compare 方法。这个方法将传入两个 Car 实例,一个用于 yesterday,一个用于 today,并且它只是简单地调用三个 Comparison 项目上的 Compare,传入两个 Car 实例:

            public void Compare(Car yesterday, Car today)
            {
                Distance.Compare(yesterday, today);
                JourneyTime.Compare(yesterday, today);
                AverageSpeed.Compare(yesterday, today);
            }
        }
    
  20. 你需要一个控制台应用程序来输入每天行驶的英里数,因此添加一个名为 Program 的类,并包含一个静态的 Main 入口点:

        public class Program
        {
            public static void Main()
            {
    
  21. 你可以随机分配行程时间以节省一些输入,因此添加一个新的 Random 实例和 do-while 循环的开始,如下所示:

                var random = new Random();
                string input;
                do
                {
    
  22. 按照以下方式读取昨天的距离:

                    Console.Write("Yesterday's distance: ");
                    input = Console.ReadLine();
                    double.TryParse(input, NumberStyles.Any,                    CultureInfo.CurrentCulture, out var distanceYesterday);
    
  23. 你可以使用距离来创建昨天的 Car,并随机分配 JourneyTime,如下所示:

                    var carYesterday = new Car
                    {
                        Distance = distanceYesterday,
                        JourneyTime = random.NextDouble() * 10D
                    };
    
  24. 对于今天的距离也做同样的处理:

                    Console.Write("    Today's distance: ");
                    input = Console.ReadLine();
                    double.TryParse(input, NumberStyles.Any,                    CultureInfo.CurrentCulture, out var distanceToday);
                    var carToday = new Car
                    {
                        Distance = distanceToday,
                        JourneyTime = random.NextDouble() * 10D
                    };
    
  25. 现在您有两个填充了昨天和今天值的Car实例,您可以创建JourneyComparer实例并调用Compare。这将调用您的三个Comparison实例的Compare方法:

                    var comparer = new JourneyComparer();
                    comparer.Compare(carYesterday, carToday);
    
  26. 现在,将结果写入控制台:

                    Console.WriteLine();
                    Console.WriteLine("Journey Details   Distance\tTime\tAvg Speed");
                    Console.WriteLine("-------------------------------------------------");
    
  27. 输出昨天的成果:

                    Console.Write($"Yesterday         {comparer.Distance.Yesterday:N0}   \t");
                    Console.WriteLine($"{comparer.JourneyTime.Yesterday:N0}\t {comparer.AverageSpeed.Yesterday:N0}");
    
  28. 输出今天的成果:

                    Console.Write($"Today             {comparer.Distance.Today:N0}     \t");                 Console.WriteLine($"{comparer.JourneyTime.Today:N0}\t {comparer.AverageSpeed.Today:N0}");
    
  29. 最后,使用Difference属性写入摘要值:

                    Console.WriteLine("=================================================");
                    Console.Write($"Difference             {comparer.Distance.Difference:N0}     \t");                Console.WriteLine($"{comparer.JourneyTime.Difference:N0} \t{comparer.AverageSpeed.Difference:N0}");
                   Console.WriteLine("=================================================");
    
  30. 完成以下do-while循环,如果用户输入空字符串则退出:

                } 
                while (!string.IsNullOrEmpty(input));
            }
        }
    }
    

运行控制台并输入距离1000900产生以下结果:

Yesterday's distance: 1000
    Today's distance: 900
Journey Details   Distance      Time    Avg Speed
-------------------------------------------------
Yesterday         1,000         8       132
Today             900           4       242
=================================================
Difference        100           4       -109

程序将在循环中运行,直到您输入空白值。您会注意到输出不同,因为JourneyTime是使用Random类实例返回的随机值设置的。

注意

您可以在packt.link/EJTtS找到用于此练习的代码。

在这个练习中,您已经看到了如何使用Func<Car, double>委托来创建通用代码,这些代码可以轻松重用,而无需创建额外的接口或类。

现在是时候看看删除的第二个重要方面以及它们将多个目标方法链接在一起的能力了。

多播委托

到目前为止,您已经调用了分配了单个方法的委托,通常是函数调用的形式。委托提供了使用+=运算符将一系列方法组合在一起执行的能力,可以添加任意数量的附加目标方法到目标列表中。每次调用委托时,每个目标方法都会被调用。但如果你决定要删除一个目标方法呢?这就是-=运算符的用武之地。

在以下代码片段中,您有一个名为loggerAction<string>委托。它以单个目标方法LogToConsole开始。如果您调用此委托并传入一个字符串,则将调用LogToConsole方法一次:

Action<string> logger = LogToConsole;
logger("1\. Calculating bill");  

如果您观察调用栈,您会看到这些调用:

logger("1\. Calculating bill")
--> LogToConsole("1\. Calculating bill")

要添加新的目标方法,您使用+=运算符。以下语句将LogToFile添加到logger委托的调用列表中:

logger += LogToFile;

现在,每次调用logger时,都会调用LogToConsoleLogToFile。现在再次调用logger

logger("2\. Saving order"); 

调用栈看起来如下:

logger("2\. Saving order")
--> LogToConsole("2\. Saving order")
--> LogToFile("2\. Saving order")

再次假设您使用+=添加一个名为LogToDataBase的第三个目标方法,如下所示:

logger += LogToDataBase

现在再次调用它:

logger("3\. Closing order"); 

调用栈看起来如下:

logger("3\. Closing order")
--> LogToConsole("3\. Closing order")
--> LogToFile("3\. Closing order")
--> LogToDataBase("3\. Closing order")

然而,考虑您可能不再希望将LogToFile包含在目标方法列表中。在这种情况下,只需使用-=运算符将其删除,如下所示:

logger -= LogToFile

您可以再次按如下方式调用委托:

logger("4\. Closing customer"); 

现在,调用栈如下所示:

logger("4\. Closing customer")
--> LogToConsole("4\. Closing customer")
--> LogToDataBase("4\. Closing customer")

如所示,此代码只导致了LogToConsoleLogToDataBase的调用。

通过这种方式使用委托,您可以在运行时根据某些标准来决定调用哪些目标方法。这允许您将配置好的委托传递给其他方法,以便在需要时调用。

您已经看到 Console.WriteLine 可以用来将消息写入控制台窗口。要创建一个将日志记录到文件的方法(如前一个示例中的 LogToFile 所做的那样),您需要使用 System.IO 命名空间中的 File 类。File 有许多静态方法可以用来读取和写入文件。这里不会详细介绍 File,但值得提到的是 File.AppendAllText 方法,它可以用来创建或替换包含字符串值的文本文件,File.Exists 用于检查文件是否存在,以及 File.Delete 用于删除文件。

现在是时候通过练习来实践你所学到的知识了。

练习 3.03:调用多播委托

在这个练习中,您将使用多播委托创建一个当用户输入他们的 PIN 并要求查看他们的余额时记录详细信息的自动柜员机。为此,您将创建一个 CashMachine 类,该类调用配置好的 记录 委托,您可以使用它作为控制器类来决定消息是否发送到文件或控制台。

您将使用 Action<string> 委托,因为您不需要返回任何值。使用 +=,您可以在 CashMachine 调用您的委托时控制哪些目标方法被调用。

执行以下步骤来完成此操作:

  1. 切换到 Chapter03 文件夹,并使用 CLI dotnet 命令创建一个新的控制台应用程序,名为 Exercise03

    source\Chapter03>dotnet new console -o Exercise03
    
  2. 打开 Chapter03\Exercise03.csproj 并用以下设置替换整个文件:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    </Project>
    
  3. 打开 Exercise03\Program.cs 并清除其内容。

  4. 添加一个名为 CashMachine 的新类。

  5. 使用 Chapter03.Exercise03 命名空间:

    using System;
    using System.IO;
    namespace Chapter03.Exercise03
    {
        public class CashMachine
        {
            private readonly Action<string> _logger;
            public CashMachine(Action<string> logger)
            {
                _logger = logger;
            } 
    

CashMachine 构造函数传递了一个 Action<string> 委托,您可以将它分配给一个名为 _loggerreadonly 类变量。

  1. 添加一个 Log 辅助函数,在调用 _logger 委托之前检查它是否为 null:

            private void Log(string message)
                => _logger?.Invoke(message);
    
  2. 当调用 VerifyPinShowBalance 方法时,应该记录一些详细信息。创建这些方法如下:

            public void VerifyPin(string pin) 
                => Log($"VerifyPin called: PIN={pin}");
            public void ShowBalance() 
                => Log("ShowBalance called: Balance=999");
        }
    
  3. 现在,添加一个控制台应用程序,配置一个 logger 委托,您可以将其传递给 CashMachine 对象。请注意,这是一种常见的使用形式:一个负责决定其他类如何记录消息的类。使用一个常量 OutputFile 来指定用于文件记录的文件名,如下所示:

        public static class Program
        {
            private const string OutputFile = "activity.txt";
            public static void Main()
            {
    
  4. 每次程序运行时,它应该以 File.Delete 开始,以删除输出文件:

                if (File.Exists(OutputFile))
                {
                    File.Delete(OutputFile);
                }
    
  5. 创建一个委托实例,logger,它以单个目标方法 LogToConsole 开始:

                Action<string> logger = LogToConsole;
    
  6. 使用 += 操作符,将 LogToFile 作为第二个目标方法添加,以便在 CashMachine 调用委托时也会被调用:

                logger += LogToFile;
    
  7. 你将很快实现两个目标日志方法;现在,创建一个cashMachine实例并准备调用其方法,如下所示:

                var cashMachine = new CashMachine(logger);
    
  8. 提示输入pin并将其传递给VerifyPin方法:

                Console.Write("Enter your PIN:");
                var pin = Console.ReadLine();
                if (string.IsNullOrEmpty(pin))
                {
                    Console.WriteLine("No PIN entered");
                    return;
                }
                cashMachine.VerifyPin(pin);
                Console.WriteLine();
    

如果你输入一个空值,则会进行检查并显示警告。然后,程序将使用return语句关闭。

  1. 在调用ShowBalance方法之前,等待Enter键被按下:

                Console.Write("Press Enter to show balance");
                Console.ReadLine();
                cashMachine.ShowBalance();
                Console.Write("Press Enter to quit");
                Console.ReadLine();
    
  2. 现在来看一下日志方法。它们必须与你的Action<string>委托兼容。一个将消息写入控制台,另一个将其追加到文本文件。按照以下方式添加这两个静态方法:

                static void LogToConsole(string message)
                    => Console.WriteLine(message);
                static void LogToFile(string message)
                    => File.AppendAllText(OutputFile, message);
            }
         }
    }
    
  3. 运行控制台应用程序,你会看到VerifyPinShowBalance调用被写入控制台:

    Enter your PIN:12345
    VerifyPin called: PIN=12345
    Press Enter to show balance
    ShowBalance called: Balance=999
    
  4. 对于每个logger委托调用,LogToFile方法也会被调用,所以当你打开activity.txt时,你应该看到以下行:

    VerifyPin called: PIN=12345ShowBalance called: Balance=999
    

    注意

    你可以在packt.link/h9vic找到这个练习使用的代码。

重要的是要记住,委托是不可变的,所以每次你使用+=-=运算符时,你都会创建一个新的委托实例。这意味着如果你在将委托传递给目标类之后修改了它,你将不会看到目标类内部调用该方法有任何变化。

你可以在下面的示例中看到这个操作:

MulticastDelegatesAddRemoveExample.cs
using System;
namespace Chapter03Examples
{
    class MulticastDelegatesAddRemoveExample
    {
        public static void Main()
        {
            Action<string> logger = LogToConsole;
            Console.WriteLine($"Logger1 #={logger.GetHashCode()}");
            logger += LogToConsole;
            Console.WriteLine($"Logger2 #={logger.GetHashCode()}");
            logger += LogToConsole;
            Console.WriteLine($"Logger3 #={logger.GetHashCode()}");
You can find the complete code here: https://packt.link/vqZMF.

C#中的所有对象都有一个GetHashCode()函数,它返回一个唯一的 ID。运行代码会产生以下输出:

Logger1 #=46104728
Logger2 #=1567560752
Logger3 #=236001992

你可以看到+=的调用。这表明对象引用每次都在改变。

现在看看另一个使用Action<string>委托的示例。在这里,你将使用+=运算符添加目标方法,然后使用-=来移除目标方法:

MulticastDelegatesExample.cs
using System;
namespace Chapter03Examples
{
    class MulticastDelegatesExample
    {
        public static void Main()
        {
            Action<string> logger = LogToConsole;
            logger += LogToConsole;
            logger("Console x 2");

            logger -= LogToConsole;
            logger("Console x 1");
            logger -= LogToConsole;
You can find the complete code here: https://packt.link/Xe0Ct.

你从一个目标方法LogToConsole开始,然后再次添加相同的目标方法。使用logger("Console x 2")调用日志委托会导致LogToConsole被调用两次。

然后使用-=移除LogToConsole两次,这样原本有两个目标,现在一个都没有了。运行代码会产生以下输出:

Console x 2
Console x 2
Console x 1

然而,由于logger("logger is now null")没有正确运行,你最终会得到一个未处理的异常,如下所示:

System.NullReferenceException
  HResult=0x80004003
  Message=Object reference not set to an instance of an object.
  Source=Examples
  StackTrace:
   at Chapter03Examples.MulticastDelegatesExample.Main() in Chapter03\MulticastDelegatesExample.cs:line 16

通过移除最后一个目标方法,-=运算符返回了一个空引用,然后你将其分配给了日志。正如你所看到的,在尝试调用委托之前始终检查委托是否为空是非常重要的。

使用 Func 委托进行多播

到目前为止,你已经在Action委托内部使用了Action<string>委托。

你已经看到当需要从调用的委托中获取返回值时使用Func委托。C#编译器在多播委托中使用Func委托也是完全合法的。

考虑以下示例,其中有一个Func<string, string>代理。此代理支持传递字符串并返回格式化字符串的函数。这可以在你需要通过删除@符号和点符号来格式化电子邮件地址时使用:

using System;
namespace Chapter03Examples
{
    class FuncExample
    {
        public static void Main()
        {

你首先将RemoveDots字符串函数分配给emailFormatter,并使用Address常量调用它:

            Func<string, string> emailFormatter = RemoveDots;
            const string Address = "admin@google.com";
            var first = emailFormatter(Address);
            Console.WriteLine($"First={first}");

然后你添加第二个目标,RemoveAtSign,并第二次调用emailFormatter

            emailFormatter += RemoveAtSign;
            var second = emailFormatter(Address);
            Console.WriteLine($"Second={second}");
            Console.ReadLine();
            static string RemoveAtSign(string address)
                => address.Replace("@", "");
            static string RemoveDots(string address)
                => address.Replace(".", "");
        }
    }
} 

运行代码会产生以下输出:

First=admin@googlecom
Second=admingoogle.com

第一次调用返回admin@googlecom字符串。添加到目标列表中的RemoveAtSign返回一个只删除了@符号的值。

注意

你可以在packt.link/fshse找到用于此示例的代码。

Func1Func2都被调用,但只有Func2的值返回到ResultAResultB变量中,即使传递了正确的参数。当以这种方式使用多播的Func<>代理时,链中的所有目标Func实例都会被调用,但返回值将是链中最后一个Func<>的值。Func<>更适合单方法场景,尽管编译器仍然允许你将其用作多播代理而不会出现编译错误或警告。

当事情出错时会发生什么?

当调用代理时,调用列表中的所有方法都会被调用。对于单名称代理,这将是一个目标方法。如果其中的一个目标抛出异常,多播代理会发生什么?

考虑以下代码。当调用logger代理时,通过传入try log this,你可能期望方法按它们添加的顺序被调用:LogToConsoleLogToError,最后是LogToDebug

MulticastWithErrorsExample.cs
using System;
using System.Diagnostics;
namespace Chapter03Examples
{
    class MulticastWithErrorsExample
    {
            public static void Main()
            {
                Action<string> logger = LogToConsole;
                logger += LogToError;
                logger += LogToDebug;
                try
                {
                    logger("try log this");
You can find the complete code here: https://packt.link/Ti3Nh.

如果任何目标方法抛出异常,例如你在LogToError中看到的,那么剩余的目标将不会被调用。

运行代码会产生以下输出:

Console: try log this
Caught oops!
All done

你会看到这个输出,因为LogToDebug方法根本没有被调用。考虑一个有多个目标监听鼠标按钮点击的 UI。第一个方法在按钮被按下时触发,并禁用按钮以防止双击,第二个方法更改按钮的图像以指示成功,第三个方法启用按钮。

如果第二个方法失败,则第三个方法将不会调用,按钮可能保持禁用状态,并分配了不正确的图像,从而使用户感到困惑。

为了确保所有目标方法都运行,你可以遍历调用列表并手动调用每个方法。看看.NET 的MulticastDelegate类型。你会发现有一个函数GetInvocationList,它返回一个包含代理对象的数组。这个数组包含已添加的目标方法:

public abstract class MulticastDelegate : Delegate {
  public sealed override Delegate[] GetInvocationList();
}

你现在可以遍历这些目标方法,并在try/catch块中执行每个方法。现在通过这个练习来练习你所学到的内容。

练习 3.04:确保在多播代理中调用所有目标方法

在本章中,你一直在使用Action<string>代理来执行各种日志操作。在这个练习中,你有一个日志代理的目标方法列表,并且你想要确保“所有”目标方法都被调用,即使早期的一些方法失败了。你可能有一个场景,日志记录到数据库或文件系统偶尔会失败,可能是因为网络问题。在这种情况下,你希望其他日志操作至少有机会执行它们的日志活动。

执行以下步骤来完成此操作:

  1. 切换到Chapter03文件夹,并使用 CLI dotnet命令创建一个新的控制台应用程序,命名为Exercise04

    source\Chapter03>dotnet new console -o Exercise04
    
  2. 打开Chapter03\Exercise04.csproj并替换整个文件为以下设置:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    </Project>
    
  3. 打开Exercise04\Program.cs并清除其内容。

  4. 现在为你的控制台应用程序添加一个静态的Program类,包括System,以及额外的System.IO,因为你想要创建一个文件:

    using System;
    using System.IO;
    namespace Chapter03.Exercise04
    {
        public static class Program
        {
    
  5. 使用一个const来命名日志文件。当程序执行时,此文件将被创建:

            private const string OutputFile = "Exercise04.txt";
    
  6. 现在必须定义应用程序的Main入口点。在这里,如果你已经存在输出文件,则删除它。最好在这里从一个空文件开始,否则每次运行应用程序时,日志文件都会不断增长:

            public static void Main()
            {
                if (File.Exists(OutputFile))
                {
                    File.Delete(OutputFile);
                }
    
  7. 你将开始于只有一个目标方法logger,即LogToConsole,你很快就会添加它:

                Action<string> logger = LogToConsole;
    
  8. 你使用InvokeAll方法来调用代理,传入"First call"作为参数。这不会失败,因为logger有一个有效的方法,你很快也会添加InvokeAll

                InvokeAll(logger, "First call"); 
    
  9. 本练习的目的是拥有一个多播代理,因此添加一些额外的目标方法:

                logger += LogToConsole;
                logger += LogToDatabase;
                logger += LogToFile; 
    
  10. 使用以下方式尝试第二次调用InvokeAll

                InvokeAll(logger, "Second call"); 
                Console.ReadLine();
    
  11. 现在对于添加到代理中的目标方法。为此添加以下代码:

                static void LogToConsole(string message)
                    => Console.WriteLine($"LogToConsole: {message}");
                static void LogToDatabase(string message)
                    => throw new ApplicationException("bad thing happened!");
                static void LogToFile(string message)
                    => File.AppendAllText(OutputFile, message);
    
    
  12. 你现在可以实现InvokeAll方法:

                static void InvokeAll(Action<string> logger, string arg)
                {
                    if (logger == null)
                         return;
    

它传递了一个与logger代理类型匹配的Action<string>代理,以及一个用于调用每个目标方法的arg字符串。在此之前,检查logger是否已经为 null,以及你无法对 null 代理做任何事情是很重要的。

  1. 使用代理的GetInvocationList()方法来获取所有目标方法的列表:

                    var delegateList = logger.GetInvocationList();
                    Console.WriteLine($"Found {delegateList.Length} items in {logger}"); 
    
  2. 现在,按照以下方式遍历列表中的每个项目:

                    foreach (var del in delegateList)
                    {
    
  3. 在将每个循环元素包裹在try/catch块中之后,将del强制转换为Action<string>

                       try
                       {
                         var action = del as Action<string>; 
    

GetInvocationList返回每个项目作为基本代理类型,而不考虑它们的实际类型。

  1. 如果它是正确的类型并且不是null,那么尝试调用它是安全的:

                          if (del is Action<string> action)
                          {
                              Console.WriteLine($"Invoking '{action.Method.Name}' with '{arg}'");
                              action(arg);
                          }
                          else
                          {
                              Console.WriteLine("Skipped null");
                          } 
    

你添加了一些额外的细节来显示将要使用代理的Method.Name属性调用的内容。

  1. 最后,添加一个catch块来记录捕获到的错误消息:

                      }
                      catch (Exception e)
                      {
                          Console.WriteLine($"Error: {e.Message}");
                      }
                    }
                }
            }
        }
    }
    
  2. 运行代码,会创建一个名为Exercise04.txt的文件,其中包含以下结果:

    Found 1 items in System.Action`1[System.String]
    Invoking '<Main>g__LogToConsole|1_0' with 'First call'
    LogToConsole: First call
    Found 4 items in System.Action`1[System.String]
    Invoking '<Main>g__LogToConsole|1_0' with 'Second call'
    LogToConsole: Second call
    Invoking '<Main>g__LogToConsole|1_0' with 'Second call'
    LogToConsole: Second call
    Invoking '<Main>g__LogToDatabase|1_1' with 'Second call'
    Error: bad thing happened!
    Invoking '<Main>g__LogToFile|1_2' with 'Second call'
    

你会看到它捕获了LogToDatabase抛出的错误,同时仍然允许调用LogToFile

注意

你可以在packt.link/Dp5H4找到用于此练习的代码。

现在重要的是要使用事件来扩展多播概念。

事件

在前面的章节中,你已经创建了委托,并在同一方法中直接调用它们,或者将它们传递给其他方法,以便在需要时调用。通过这种方式使用委托,你有一个简单的方法来让代码在发生感兴趣的事情时得到通知。到目前为止,这还没有成为一个大问题,但你可能已经注意到似乎没有方法可以防止一个可以访问委托的对象直接调用它。

考虑以下场景:你创建了一个应用程序,允许其他程序通过将它们的目标方法添加到你提供的委托中,来注册当收到新电子邮件时的通知。如果一个程序,无论是由于错误还是恶意原因,决定自己调用你的委托,会发生什么?这可能会轻易地压倒你的调用列表中的所有目标方法。这样的监听程序绝对不应该以这种方式调用委托——毕竟,它们应该是被动的监听者。

你可以添加额外的允许监听者将它们的目标方法添加或从调用列表中删除的方法,并保护委托免受直接访问,但如果你在一个应用程序中有数百个这样的委托,这将需要编写大量的代码。

event关键字指示 C#编译器添加额外的代码以确保委托只能由声明它的类或结构体调用。外部代码可以添加或删除目标方法,但被阻止调用委托。尝试这样做会导致编译器错误。

这种模式通常被称为发布-订阅模式。引发事件的对象被称为事件发送者或发布者;接收事件的对象被称为事件处理程序或订阅者

定义一个事件

event关键字用于定义事件及其关联的委托。其定义看起来与委托的定义方式相似,但与委托不同,你不能使用全局命名空间来定义事件:

public event EventHandler MouseDoubleClicked

事件有四个元素:

  • 范围:一个访问修饰符,如publicprivateprotected,用于定义作用域。

  • event关键字。

  • 委托类型:关联的委托,例如本例中的EventHandler

  • 事件名称:可以是任何你喜欢的名称,例如MouseDoubleClicked。然而,名称必须在命名空间内是唯一的。

事件通常与内置的.NET 委托EventHandler或其泛型版本EventHandler<>相关联。为事件创建自定义委托很少见,但你可能会在Action和泛型Action<T>委托之前创建的旧版代码中找到它。

EventHandler委托在.NET 的早期版本中可用。它具有以下签名,接受一个发送者object和一个EventArgs参数:

public delegate void EventHandler(object sender, EventArgs e); 

更近期的基于泛型的EventHandler<T>委托看起来相似;它也接受一个发送者object和一个由类型T定义的参数:

public delegate void EventHandler<T>(object sender, T e); 

sender参数被定义为object,允许发送任何类型的对象到订阅者,以便他们可以识别事件的发送者。这在需要集中处理各种类型对象而不是特定实例的情况下非常有用。

例如,在一个 UI 应用程序中,你可能有一个订阅者监听 OK 按钮被点击,另一个订阅者监听取消按钮被点击——每个这些都可以由两个不同的方法处理。在多个复选框用于切换选项开或关的情况下,你可以使用一个单一的目标方法,只需要告诉它复选框是发送者,并相应地切换设置。这允许你重用相同的复选框处理程序,而不是为屏幕上的每个复选框创建一个方法。

在调用EventHandler委托时,包含发送者的详细信息不是强制的。通常,你可能不希望向外界透露你代码的内部工作原理;在这种情况下,将 null 引用传递给委托是一种常见的做法。

两个委托中的第二个参数可以用来提供关于事件的额外上下文信息(例如,是左键还是右键被按下?)。传统上,这些额外信息是通过从EventArgs派生的类包装的,但在较新的.NET 版本中,这种约定已经放宽。

你应该为你的事件定义使用两个标准的.NET 委托吗?

  • EventHandler: 当没有额外信息描述事件时可以使用。例如,复选框点击事件可能不需要任何额外信息,它只是被点击了。在这种情况下,传递 null 或EventArgs.Empty作为第二个参数是完全有效的。这个委托通常可以在使用从EventArgs派生的类进一步描述事件的旧版应用程序中找到。是鼠标的双击触发了这个事件吗?在这种情况下,可能已经向从EventArgs派生的类中添加了一个Clicks属性来提供这样的额外细节。

  • EventHandler<T>: 由于 C#中泛型的引入,这已成为更频繁使用的委托事件,仅仅因为使用泛型需要创建更少的类。

有趣的是,无论你给事件赋予什么范围(例如 public),C# 编译器都会在内部创建一个具有该名称的私有成员。这是事件的关键概念:只有定义事件的类才能调用它。消费者可以自由添加或删除他们的兴趣,但不能自己调用它。

当定义一个事件时,定义该事件的发布者类可以简单地按需调用它,就像调用委托一样。在早期示例中,强调了在调用之前始终检查委托是否为空。对于事件,也应采取相同的方法,因为你对订阅者如何或何时添加或删除目标方法几乎没有控制权。

当发布者类最初创建时,所有事件都有一个初始值为空。当任何订阅者添加目标方法时,这会变为非空。相反,一旦订阅者删除目标方法,如果调用列表中没有其他方法,事件将恢复为空。这是你之前在委托中看到的标准行为。

你可以通过在事件定义的末尾添加一个空委托来防止事件永远为空:

public event EventHandler<MouseEventArgs> MouseDoubleClicked = delegate {};

而不是使用默认的空值,你添加了自己的默认委托实例——一个什么也不做的实例。因此,在 {} 符号之间有一个空格。

在使用发布者类中的事件时,通常会遵循一个常见的模式,尤其是在可能进一步派生的类中。现在,通过一个简单的示例,你将看到这一点:

  1. 定义一个名为 MouseClickedEventArgs 的类,该类包含关于事件的附加信息,在本例中,是检测到的鼠标点击次数:

    using System;
    namespace Chapter03Examples
    {
        public class MouseClickedEventArgs 
        {
            public MouseClickedEventArgs(int clicks)
            {
                Clicks = clicks;
            }
            public int Clicks { get; }
        }
    

观察一下 MouseClickPublisher 类,它使用泛型 EventHandler<> 委托定义了一个 MouseClicked 事件。

  1. 现在添加 delegate { }; 块以防止 MouseClicked 初始为空:

        public class MouseClickPublisher
        {
         public event EventHandler<MouseClickedEventArgs> MouseClicked = delegate { };
    
  2. 添加一个 OnMouseClicked 虚拟方法,以便任何进一步派生的 MouseClickPublisher 类有机会抑制或更改事件通知,如下所示:

            protected virtual void OnMouseClicked( MouseClickedEventArgs e)
            {
                var evt = MouseClicked;
                evt?.Invoke(this, e);
            }
    
  3. 现在你需要一个跟踪鼠标点击的方法。在这个例子中,你实际上不会展示如何检测鼠标点击,但你将调用 OnMouseClicked,传递 2 来指示双击。

  4. 注意,你没有直接调用 MouseClicked 事件;你总是通过 OnMouseClicked 中间方法来调用。这为其他 MouseClickPublisher 的实现提供了覆盖事件通知的方式:

            private void TrackMouseClicks()
            {
                OnMouseClicked(new MouseClickedEventArgs(2));
            }
        } 
    
  5. 现在添加一个基于 MouseClickPublisher 的新类型的发布者:

        public class MouseSingleClickPublisher : MouseClickPublisher
        {
            protected override void OnMouseClicked(MouseClickedEventArgs e)
            {
                if (e.Clicks == 1)
                {
                    OnMouseClicked(e);
                }
            }
        }
    } 
    

MouseSingleClickPublisher 覆盖了 OnMouseClicked 方法,并且只有在检测到单次点击时才调用基类的 OnMouseClicked。通过实现这种模式,你允许不同类型的发布者以定制的方式控制是否向订阅者触发事件。

注意

你可以在 packt.link/J1EiB 找到用于此示例的代码。

你现在可以通过以下练习来练习你所学的知识。

练习 3.05:发布和订阅事件

在这个练习中,你将创建一个闹钟作为发布者的示例。闹钟将模拟 Ticked 事件。你还将添加一个 WakeUp 事件,当当前时间与闹钟时间匹配时发布。在 .NET 中,DateTime 用于表示时间点,因此你将使用它来表示当前时间和闹钟时间属性。你将使用 DateTime.Subtract 来获取当前时间和闹钟时间之间的差异,并在到期时发布 WakeUp 事件。

执行以下步骤来完成此操作:

  1. 切换到 Chapter03 文件夹,并使用 CLI dotnet 命令创建一个新的控制台应用程序,命名为 Exercise05

    dotnet new console -o Exercise05
    
  2. 打开 Chapter03\Exercise05.csproj 并将整个文件替换为以下设置:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    </Project>
    
  3. 打开 Exercise05\Program.cs 并清空其内容。

  4. 添加一个名为 AlarmClock 的新类。在这里你需要使用 DateTime 类,因此包含 System 命名空间:

    using System;
    namespace Chapter03.Exercise05
    {
        public class AlarmClock
        {
    

你将为订阅者提供两个事件来监听——WakeUp,基于非泛型的 EventHandler 委托(因为你不会在此事件中传递任何额外信息),以及 Ticked,它使用泛型的 EventHandler 委托,并带有 DateTime 参数类型。

  1. 你将使用这个方法来传递当前时间以在控制台显示。注意,两者都包含初始的 delegate {}; 安全机制:

            public event EventHandler WakeUp = delegate {};
            public event EventHandler<DateTime> Ticked = delegate {};
    
  2. 包含一个 OnWakeUp 重写作为示例,但不要对 Ticked 做同样的事情;这是为了展示不同的调用方法:

            protected void OnWakeUp()
            {
                WakeUp.Invoke(this, EventArgs.Empty);
            }
    
  3. 现在添加两个 DateTime 属性,闹钟时间和时钟时间,如下所示:

            public DateTime AlarmTime { get; set; }
            public DateTime ClockTime { get; set; }
    
  4. 使用 Start 方法来启动时钟。你通过一个简单的循环模拟时钟每分钟响一次,持续 24 小时,如下所示:

            public void Start()
            {
                // Run for 24 hours
                const int MinutesInADay = 60 * 24;
    
  5. 对于每个模拟的分钟,使用 DateTime.AddMinute 增加时钟,并发布 Ticked 事件,传入 thisAlarmClock 发送实例)和时钟时间:

                for (var i = 0; i < MinutesInADay; i++)
                {
                    ClockTime = ClockTime.AddMinutes(1);
                    Ticked.Invoke(this, ClockTime);
    

使用 ClockTime.Subtract 来计算点击时间和闹钟时间之间的差异。

  1. 你将 timeRemaining 值传递给本地函数 IsTimeToWakeUp,调用 OnWakeUp 方法,并在需要醒来时跳出循环:

                  var timeRemaining = ClockTime                 .Subtract(AlarmTime)                .TotalMinutes;
                   if (IsTimeToWakeUp(timeRemaining))
                    {
                        OnWakeUp();
                        break;
                    }
                }
    
  2. 使用 IsTimeToWakeUp,一个关系模式,来查看是否剩余时间不足一分钟。为此添加以下代码:

                static bool IsTimeToWakeUp(double timeRemaining) 
                    => timeRemaining is (>= -1.0 and <= 1.0);
            }
        }   
    
  3. 现在添加一个控制台应用程序,它从静态 void Main 入口点开始订阅闹钟及其两个事件:

             public static class Program
        {
            public static void Main()
            {
    
  4. 创建 AlarmClock 实例,并使用 += 运算符订阅 Ticked 事件和 WakeUp 事件。你将很快定义 ClockTickedClockWakeUp。现在,只需添加以下代码:

                var clock = new AlarmClock();
                clock.Ticked += ClockTicked;
                clock.WakeUp += ClockWakeUp; 
    
  5. 设置时钟的当前时间,使用 DateTime.AddMinutes 向闹钟时间添加 120 分钟,然后启动时钟,如下所示:

                clock.ClockTime = DateTime.Now;
                clock.AlarmTime = DateTime.Now.AddMinutes(120);
                Console.WriteLine($"ClockTime={clock.ClockTime:t}");
                Console.WriteLine($"AlarmTime={clock.AlarmTime:t}");
                clock.Start(); 
    
  6. 通过提示按 Enter 键来完成 Main 方法:

                Console.WriteLine("Press ENTER");
                Console.ReadLine();
    
    
  7. 现在你可以添加事件订阅者的局部方法:

                static void ClockWakeUp(object sender, EventArgs e)
                {
                   Console.WriteLine();
                   Console.WriteLine("Wake up");
                }
    

ClockWakeUp传递发送者和EventArgs参数。你不需要这些参数,但它们对于EventHandler委托是必需的。当这个订阅者的方法被调用时,你将"Wake up"写入控制台。

  1. ClockTicked传递了DateTime参数,这是EventHandler<DateTime>委托所要求的。在这里,你传递当前时间,因此使用:t将时间以短格式写入控制台:

                 static void ClockTicked(object sender, DateTime e)
                    => Console.Write($"{e:t}...");
            }
        }
    } 
    
  2. 运行应用程序会产生以下输出:

    ClockTime=14:59
    AlarmTime=16:59
    15:00...15:01...15:02...15:03...15:04...15:05...15:06...15:07...15:08...15:09...15:10...15:11...15:12...15:13...15:14...15:15...15:16...15:17...15:18...15:19...15:20...15:21...15:22...15:23...15:24...15:25...15:26...15:27...15:28...15:29...15:30...15:31...15:32...15:33...15:34...15:35...15:36...15:37...15:38...15:39...15:40...15:41...15:42...15:43...15:44...15:45...15:46...15:47...15:48...15:49...15:50...15:51...15:52...15:53...15:54...15:55...15:56...15:57...15:58...15:59...16:00...16:01...16:02...16:03...16:04...16:05...16:06...16:07...16:08...16:09...16:10...16:11...16:12...16:13...16:14...16:15...16:16...16:17...16:18...16:19...16:20...16:21...16:22...16:23...16:24...16:25...16:26...16:27...16:28...16:29...16:30...16:31...16:32...16:33...16:34...16:35...16:36...16:37...16:38...16:39...16:40...16:41...16:42...16:43...16:44...16:45...16:46...16:47...16:48...16:49...16:50...16:51...16:52...16:53...16:54...16:55...16:56...16:57...16:58...16:59...
    Wake up
    Press ENTER
    

在这个例子中,你可以看到闹钟每分钟模拟一次滴答声并发布一个Ticked事件。

注意

你可以在packt.link/GPkYQ找到用于此练习的代码。

现在是时候掌握事件和委托之间的区别了。

事件或委托?

表面上看,事件和委托看起来非常相似:

  • 事件是委托的扩展形式。

  • 两者都提供后期绑定的语义,因此,而不是在编译时精确知道的方法调用,你可以在运行时延迟一个目标方法列表。

  • 两者都是Invoke()或更简单的()后缀快捷方式,理想情况下在使用之前进行空值检查。

关键考虑因素如下:

  • 可选性:事件提供了一种可选的方法;调用者可以决定是否加入事件。如果你的组件可以在不需要任何订阅者方法的情况下完成任务,那么使用基于事件的方法更可取。

  • 返回类型:你需要处理返回类型吗?与事件相关联的委托总是无返回值的。

  • 生命周期:事件订阅者通常比发布者有更短的生存期,即使没有活跃的订阅者,发布者也会继续检测新消息。

静态事件可能导致内存泄漏

在你结束对事件的审视之前,使用事件时要格外小心,尤其是那些静态定义的事件。

每当你将订阅者的目标方法添加到发布者的事件中时,发布者类将存储对你的目标方法的引用。当你完成对订阅者实例的使用,并且它仍然附加到一个static发布者上时,你的订阅者使用的内存可能不会被清理。

这些通常被称为孤儿、幽灵或鬼魂事件。为了防止这种情况,始终尝试将每个+=调用与相应的-=操作符配对。

注意

反应式扩展(Rx)(github.com/dotnet/reactive) 是一个用于利用和驯服基于事件和异步编程的 LINQ 样式操作符的出色库。Rx 提供了一种时间转换的方法,例如,只需几行代码就可以将非常嘈杂的事件缓冲到可管理的流中。更重要的是,Rx 流非常容易进行单元测试,让你能够有效地控制时间。

现在来了解一下有趣的 lambda 表达式主题。

Lambda 表达式

在前面的章节中,您主要使用类级方法作为委托和事件的目标,例如在练习 3.05中也使用的ClockTickedClockWakeUp方法:

var clock = new AlarmClock();
clock.Ticked += ClockTicked;
clock.WakeUp += ClockWakeUp;
static void ClockTicked(object sender, DateTime e)
  => Console.Write($"{e:t}...");

static void ClockWakeUp(object sender, EventArgs e)
{
    Console.WriteLine();
    Console.WriteLine("Wake up");
}

ClockWakeUpClockTicked方法易于理解和逐步执行。然而,通过将它们转换为 lambda 表达式语法,您可以拥有更简洁的语法,并且与它们在代码中的位置更接近。

现在将TickedWakeUp事件转换为使用两个不同的 lambda 表达式:

clock.Ticked += (sender, e) =>
{
    Console.Write($"{e:t}..."); 
};  
clock.WakeUp += (sender, e) =>
{
    Console.WriteLine();
    Console.WriteLine("Wake up");
}; 

您使用了相同的+=运算符,但您看到的是(sender, e) =>而不是方法名,以及与ClockTickedClockWakeUp中相同的代码块。

定义 lambda 表达式时,您可以在括号()内传递任何参数,然后是=>(这通常读作goes to),然后是您的表达式/语句块:

(parameters) => expression-or-block

代码块可以像您需要的那么复杂,如果它是一个基于Func的委托,则可以返回一个值。

编译器通常可以推断每个参数类型,因此您甚至不需要指定它们的类型。此外,如果只有一个参数并且编译器可以推断其类型,则可以省略括号。

无论何时需要将委托(记住ActionAction<T>Func<T>是委托的内置示例)用作参数,而不是创建一个类或局部方法或函数,您都应该考虑使用 lambda 表达式。主要原因是这样通常会产生更少的代码,并且代码放置在它被使用的位置附近。

现在考虑另一个关于 Lambda 的例子。给定一个电影列表,您可以使用List<string>类来存储这些基于字符串的名称,如下面的代码片段所示:

using System;
using System.Collections.Generic;
namespace Chapter03Examples
{
    class LambdaExample
    {
        public static void Main()
        {
            var names = new List<string>
            {
                "The A-Team",
                "Blade Runner",
                "There's Something About Mary",
                "Batman Begins",
                "The Crow"
            };

您可以使用List.Sort方法按字母顺序对名称进行排序(最终输出将在本例的末尾显示):

            names.Sort();
            Console.WriteLine("Sorted names:");
            foreach (var name in names)
            {
                Console.WriteLine(name);
            }
            Console.WriteLine();

如果您需要更多控制排序的方式,List类还有一个接受此形式委托的Sort方法:delegate int Comparison<T>(T x, T y)。此委托传递两个相同类型的参数(xy)并返回一个int值。您可以使用int值来定义列表中项的排序顺序,而无需担心Sort方法的内部工作原理。

作为替代方案,您可以对名称进行排序,以排除电影标题开头的"The"。这通常用作列出名称的替代方法。您可以通过传递一个 lambda 表达式并使用( )语法来包装两个字符串x, y,当Sort()调用您的 lambda 时,这两个字符串将被传递,来实现这一点。

如果xy以您的噪声词"The"开头,那么您将使用string.Substring函数跳过前四个字符。然后使用String.Compare返回一个数值,该数值比较结果字符串值,如下所示:

            const string Noise = "The ";
            names.Sort( (x, y) =>
            {
                if (x.StartsWith(Noise))
                {
                    x = x.Substring(Noise.Length);
                }
                if (y.StartsWith(Noise))
                {
                    y = x.Substring(Noise.Length);
                }
                return string.Compare(x , y);
            });

然后,您可以将排序后的结果输出到控制台:

            Console.WriteLine($"Sorted excluding leading '{Noise}':");
            foreach (var name in names)
            {
                Console.WriteLine(name);
            }
            Console.ReadLine();
         }
     }
} 

运行示例代码会产生以下输出:

Sorted names:
Batman Begins
Blade Runner
The A-Team
The Crow
There's Something About Mary
Sorted excluding leading 'The ':
The A-Team
Batman Begins
Blade Runner
The Crow
There's Something About Mary 

你可以看到,第二组名称按"The"被忽略的顺序排序。

注意

你可以在packt.link/B3NmQ找到用于此示例的代码。

为了看到这些 lambda 表达式在实际中的应用,尝试以下练习。

练习 3.06:使用语句 Lambda 反转句子中的单词

在这个练习中,你将创建一个实用类,该类可以将句子中的单词拆分并返回单词顺序颠倒的句子。

执行以下步骤来完成此操作:

  1. 切换到Chapter03文件夹,并使用 CLI dotnet命令创建一个新的控制台应用程序,命名为Exercise06

    source\Chapter03>dotnet new console -o Exercise06
    
  2. 打开Chapter03\Exercise06.csproj,并将整个文件替换为以下设置:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    </Project>
    
  3. 打开Exercise02\Program.cs并清除其内容。

  4. 添加一个名为WordUtilities的新类,其中包含一个名为ReverseWords的字符串函数。你需要包含System.Linq命名空间以帮助进行字符串操作:

    using System;
    using System.Linq;
    namespace Chapter03.Exercise06
    {
        public static class WordUtilities
        {
            public static string ReverseWords(string sentence)
            {
    
  5. 定义一个名为swapWordsFunc<string, string>委托,它接受一个字符串输入并返回一个字符串值:

              Func<string, string> swapWords = 
    
  6. 你将接受一个名为phrase的字符串输入参数:

                phrase =>
    
  7. 现在来看 lambda 表达式体。使用string.Split函数将phrase字符串拆分为一个字符串数组,以空格作为分隔符:

                      {
                        const char Delimit = ' ';
                        var words = phrase
                            .Split(Delimit)
                            .Reverse();
                        return string.Join(Delimit, words);
                    };
    

String.Reverse在将反转的单词字符串数组使用string.Join连接成一个字符串之前,先反转数组中字符串的顺序。

  1. 你已经定义了所需的Func,因此通过传递句子参数并返回结果来调用它:

                return swapWords(sentence);
             }
        }
    
  2. 现在为一个控制台应用程序,它提示输入一个句子,该句子被传递给WordUtilities.ReverseWords,并将结果写入控制台:

        public static class Program
        {
            public static void Main()
            {
                do
                {
                    Console.Write("Enter a sentence:");
                    var input = Console.ReadLine();
                    if (string.IsNullOrEmpty(input))
                    {
                        break;
                    }
                    var result = WordUtilities.ReverseWords(input);
                    Console.WriteLine($"Reversed: {result}")
    

运行控制台应用程序会产生类似以下的结果输出:

Enter a sentence:welcome to c#
Reversed: c# to welcome
Enter a sentence:visual studio by microsoft
Reversed: microsoft by studio visual

注意

你可以在packt.link/z12sR找到用于此练习的代码。

你将通过查看一些不太明显的问题来结束对 lambda 表达式的探讨,这些问题你可能不会在运行和调试时预期看到。

捕获和闭包

Lambda 表达式可以捕获定义方法中任何变量或参数。捕获这个词用来描述 lambda 表达式如何捕获或向上进入父方法以访问任何变量或参数。

为了更好地理解这一点,考虑以下示例。在这里,你将创建一个名为joinerFunc<int, string>,它使用Enumerable.Repeat方法将单词连接起来。word变量(称为外部变量)被捕获在joiner表达式的主体中:

var word = "hello";
Func<int, string> joiner = 
    input =>
    {
        return string.Join(",", Enumerable.Repeat(word, input));
    };  
Console.WriteLine($"Outer Variables: {joiner(2)}"); 

运行前面的示例会产生以下输出:

Outer Variables: hello,hello

你通过传递2作为参数调用了joiner委托。在那个时刻,外部word变量的值为"hello",它被重复两次。

这证实了从父方法捕获的变量在 Func 被调用时被评估。现在将 word 的值从 hello 更改为 goodbye,并再次调用 joiner,传递 3 作为参数:

word = "goodbye";
Console.WriteLine($"Outer Variables Part2: {joiner(3)}");

运行此示例会产生以下输出:

Outer Variables Part2: goodbye,goodbye,goodbye

值得记住的是,你定义 joiner 的位置在代码中并不重要。你可以在声明 joiner 之前或之后将 word 的值更改为任意数量的字符串。

将捕获进一步扩展,如果你在 lambda 中定义了一个与相同名称的变量,它将具有 word 的作用域,这将对该名称的外部变量没有任何影响:

Func<int, string> joinerLocal =
    input =>
    {
        var word = "local";
        return string.Join(",", Enumerable.Repeat(word, input));
    };
Console.WriteLine($"JoinerLocal: {joinerLocal(2)}");
Console.WriteLine($"JoinerLocal: word={word}");   

上述示例的结果如下。注意外部变量 wordgoodbye 保持不变:

JoinerLocal: local,local
JoinerLocal: word=goodbye

最后,你将了解 C# 语言的一个微妙概念——闭包,它经常导致意外的结果。

在以下示例中,你有一个变量 actions,它包含一个 Action 委托的 List。你使用基本的 for 循环向列表中添加五个单独的 Action 实例。每个 Action 的 lambda 表达式只是将 for 循环中的 i 的值写入控制台。最后,代码只是遍历 actions 列表中的每个 Action 并调用它们:

var actions = new List<Action>();
for (var i = 0; i < 5; i++)
{
    actions.Add( () => Console.WriteLine($"MyAction: i={i}")) ;
}
foreach (var action in actions)
{
    action();
}

运行示例会产生以下输出:

MyAction: i=5
MyAction: i=5
MyAction: i=5
MyAction: i=5
MyAction: i=5

MyAction: i 没有从 0 开始的原因是,当从 Action 委托内部访问 i 的值时,它只会在 Action 被调用后评估。到每个委托被调用时,外部循环已经重复了五次。

注意

你可以在 packt.link/vfOPx 找到用于此示例的代码。

这与你在捕获概念中观察到的类似,其中外部变量(在这种情况下为 i),只有在被调用时才会被评估。你在 for 循环中使用 i 将每个 Action 添加到列表中,但在调用每个动作时,i 已经有了它的最终值 5

这通常会导致意外的行为,特别是如果你假设每个动作的循环变量中使用了 i。为了确保在 lambda 表达式内部使用 i 的递增值,你需要引入一个 for 循环,它包含迭代变量的副本。

在以下代码片段中,你添加了 closurei 变量。它看起来非常微妙,但现在你有一个更局部的作用域变量,你从 lambda 表达式内部而不是迭代器 i 访问它:

var actionsSafe = new List<Action>();
for (var i = 0; i < 5; i++)
{
    var closurei = i;
    actionsSafe.Add(() => Console.WriteLine($"MyAction: closurei={closurei}"));
}
foreach (var action in actionsSafe)
{
    action();
}

运行示例会产生以下输出。你可以看到,当每个 Action 被调用时,使用的是递增的值,而不是你之前看到的 5 的值:

MyAction: closurei=0
MyAction: closurei=1
MyAction: closurei=2
MyAction: closurei=3
MyAction: closurei=4

你已经涵盖了事件驱动应用程序中委托和事件的关键方面。你通过使用 lambda 提供的简洁编码风格扩展了这一点,以便在感兴趣的事件发生时得到通知。

现在,您将把这些想法整合到一个活动中,在这个活动中,您将使用一些内置的.NET 类及其自己的事件。您需要将这些事件适配到您自己的格式,并发布它们,以便控制台应用程序可以订阅。

现在,是时候通过以下活动来练习您所学到的一切。

活动 3.01:创建网页文件下载器

您计划调查美国风暴事件的模式。为此,您需要从在线来源下载风暴事件数据集以供后续分析。国家海洋和大气管理局是此类数据的一个来源,可以通过www1.ncdc.noaa.gov/pub/data/swdi/stormevents/csvfiles访问。

您的任务是创建一个.NET Core 控制台应用程序,允许输入一个网址,并将该网址的内容下载到本地磁盘。为了尽可能友好,应用程序需要使用表示输入无效地址、下载进度和完成时的事件。

理想情况下,您应该尝试隐藏您用于下载文件的内部实现,更愿意将您使用的任何事件适配为调用者可以订阅的事件。这种适配形式通常用于通过隐藏内部细节来使代码更易于维护。

为了这个目的,C#中的WebClient类可以用于下载请求。与.NET 的许多部分一样,这个类返回实现IDisposable接口的对象。这是一个标准接口,它表示您正在使用的对象应该被using语句包裹,以确保在您完成使用对象后,任何资源或内存都会为您清理。using的格式如下:

using (IDisposable) { statement_block }

最后,WebClient.DownloadFileAsync方法在后台下载文件。理想情况下,您应该使用一种机制,允许代码的一部分使用System.Threading.ManualResetEventSlim类,该类具有SetWait方法,可以帮助进行此类信号。

对于这个活动,您需要执行以下步骤:

  1. 添加一个进度更改的EventArgs类(一个示例名称可以是DownloadProgressChangedEventArgs),当发布进度事件时可以使用。这个类应该有ProgressPercentageBytesReceived属性。

  2. 应该使用System.Net中的WebClient类来下载请求的网页文件。您应该创建一个适配器类(建议的名称为WebClientAdapter),以隐藏您对WebClient的内部使用情况。

  3. 您的适配器类应该提供三个事件——DownloadCompletedDownloadProgressChangedInvalidUrlRequested——调用者可以订阅这些事件。

  4. 适配器类需要一个DownloadFile方法,该方法调用WebClient类的DownloadFileAsync方法来启动下载请求。这需要将基于字符串的网页地址转换为统一资源标识符(URI)类。Uri.TryCreate()方法可以从控制台输入的字符串创建一个绝对地址。如果Uri.TryCreate的调用失败,您应该发布InvalidUrlRequested事件来指示这个失败。

  5. WebClient有两个事件——DownloadFileCompletedDownloadProgressChanged。您应该订阅这两个事件,并使用您自己的类似事件重新发布它们。

  6. 创建一个控制台应用程序,使用WebClientAdapter的实例(如步骤 2中创建的)并订阅这三个事件。

  7. 通过订阅DownloadCompleted事件,您应该在控制台指示成功。

  8. 通过订阅DownloadProgressChanged,您应该在控制台报告进度消息,显示ProgressPercentageBytesReceived值。

  9. 通过订阅InvalidUrlRequested事件,您应该在控制台使用不同的控制台背景颜色显示警告。

  10. 使用一个do循环允许用户重复输入一个网页地址。这个地址和一个临时的目标文件路径可以传递给WebClientAdapter.DownloadFile(),直到用户输入一个空地址来退出。

  11. 一旦您运行了带有各种下载请求的控制台应用程序,您应该看到以下类似的输出:

    Enter a URL:
    https://www1.ncdc.noaa.gov/pub/data/swdi/stormevents/csvfiles/StormEvents_details-ftp_v1.0_d1950_c20170120.csv.gz
    Downloading https://www1.ncdc.noaa.gov/pub/data/swdi/stormevents/csvfiles/StormEvents_details-ftp_v1.0_d1950_c20170120.csv.gz...
    Downloading...73% complete (7,758 bytes)
    Downloading...77% complete (8,192 bytes)
    Downloading...100% complete (10,597 bytes)
    Downloaded to C:\Temp\StormEvents_details-ftp_v1.0_d1950_c20170120.csv.gz
    Enter a URL:
    https://www1.ncdc.noaa.gov/pub/data/swdi/stormevents/csvfiles/StormEvents_details-ftp_v1.0_d1954_c20160223.csv.gz
    Downloading https://www1.ncdc.noaa.gov/pub/data/swdi/stormevents/csvfiles/StormEvents_details-ftp_v1.0_d1954_c20160223.csv.gz...
    Downloading...29% complete (7,758 bytes)
    Downloading...31% complete (8,192 bytes)
    Downloading...54% complete (14,238 bytes)
    Downloading...62% complete (16,384 bytes)
    Downloading...84% complete (22,238 bytes)
    Downloading...93% complete (24,576 bytes)
    Downloading...100% complete (26,220 bytes)
    Downloaded to C:\Temp\StormEvents_details-ftp_v1.0_d1954_c20160223.csv.gz
    

通过完成这个活动,您已经看到了如何从现有的.NET 基于事件的发布者类(WebClient)订阅事件,在将它们重新发布到您的适配器类(WebClientAdapter)之前,根据您自己的规格进行适配,最终这些事件由控制台应用程序订阅。

注意

这个活动的解决方案可以在packt.link/qclbF找到。

摘要

在本章中,您深入了解了委托。您创建了自定义委托,并看到了它们如何被现代的内置ActionFunc委托所替代。通过使用空引用检查,您发现了调用委托的安全方式,以及如何将多个方法链接在一起形成多播委托。您进一步扩展了委托,使用event关键字来限制调用,并遵循定义和调用事件时的首选模式。最后,您涵盖了简洁的 lambda 表达式风格,并看到了如何通过识别捕获和闭包的使用来避免错误。

在下一章中,您将了解 LINQ 和数据结构,这是 C#语言的基本组成部分。

第四章:4. 数据结构和 LINQ

概述

在本章中,你将学习 C#中主要集合及其主要用法。然后,你将了解如何使用语言集成查询(LINQ)通过高效且简洁的代码查询内存中的集合。到本章结束时,你将熟练掌握使用 LINQ 进行排序、过滤和聚合数据等操作。

简介

在前面的章节中,你已经使用了引用单个值的变量,例如stringdouble系统类型、系统class实例以及你自己的类实例。.NET 有多种数据结构可以用来存储多个值。这些结构通常被称为集合。本章通过介绍来自System.Collections.Generic命名空间中的集合类型来扩展这一概念。

你可以使用集合类型创建可以存储多个对象引用的变量。这些集合包括可以调整大小以容纳元素数量的列表和提供使用唯一键作为标识符访问元素的字典。例如,你可能需要使用代码作为唯一标识符来存储国际电话区号列表。在这种情况下,你需要确保相同的电话区号不会被添加到集合中两次。

这些集合的实例化方式与其他类类似,并且在大多数应用程序中被广泛使用。选择正确的集合类型主要取决于你打算如何添加项目以及你希望如何访问这些项目。常用的集合类型包括ListSetHashSet,你将在稍后详细学习。

LINQ 是一种提供表达性和简洁语法的查询对象技术。使用类似 SQL 的语言或如果你更喜欢,一组可以串联在一起的扩展方法,可以消除围绕过滤、排序和分组对象的许多复杂性,从而可以轻松枚举生成的集合。

数据结构

.NET 提供了各种内置数据结构类型,如ArrayListDictionary类型。所有数据结构的核心是IEnumerableICollection接口。实现这些接口的类提供了一种遍历单个元素和操作其项的方法。很少需要创建直接从这些接口派生的自己的类,因为所有必需的功能都由内置的集合类型提供,但了解关键属性是值得的,因为它们在.NET 中被广泛使用。

每种集合类型的泛型版本需要一个单一的类型参数,该参数定义了可以添加到集合中的元素类型,使用泛型类型的标准<T>语法。

IEnumerable 接口有一个属性,即 GetEnumerator<T>()。此属性返回一个类型,它提供了允许调用者遍历集合中元素的方法。您无需直接调用 GetEnumerator() 方法,因为编译器会在您使用 foreach 语句(例如 foreach(var book in books))时自动调用它。您将在接下来的章节中了解更多关于如何使用它的信息。

ICollection 接口具有以下属性:

  • int Count { get; }: 返回集合中的项目数量。

  • bool IsReadOnly { get; }: 表示集合是否为只读。某些集合可以被标记为只读,以防止调用者向集合中添加、删除或移动元素。C# 不会阻止您修改只读集合中单个项目的属性。

  • void Add(T item): 将类型为 <T> 的项目添加到集合中。

  • void Clear(): 从集合中删除所有项目。

  • bool Contains(T item): 如果集合包含特定值,则返回 true。根据集合中项目类型的不同,这可以是值相等,其中对象基于其成员相似,或者引用相等,其中对象指向相同的内存位置。

  • void CopyTo(T[] array, int arrayIndex): 将集合中的每个元素复制到目标数组中,从指定索引位置的第一个元素开始。如果您需要从集合的开始跳过特定数量的元素,这可能很有用。

  • bool Remove(T item): 从集合中删除指定的项目。如果有多个实例,则只删除第一个实例。如果成功删除项目,则返回 true

IEnumerableICollection 是所有集合都实现的接口:

图 4.1: ICollection 和 IEnumerable 类图

图 4.1: ICollection 和 IEnumerable 类图

根据在集合内如何访问元素,某些集合实现了进一步的接口。

IList 接口用于可以按索引位置访问的集合,从零开始。因此,对于包含两个项目“红色”和“蓝色”的列表,索引零的元素是“红色”,索引一的元素是“蓝色”。

图 4.2: IList 类图

图 4.2: IList 类图

IList 接口具有以下属性:

  • T this[int index] { get; set; }: 获取或设置指定索引位置上的元素。

  • int Add(T item): 添加指定的项目并返回该项目在列表中的索引位置。

  • void Clear(): 从列表中删除所有项目。

  • bool Contains(T item): 如果列表包含指定的项目,则返回 true

  • int IndexOf(T item): 返回项目的索引位置,如果未找到则返回 -1

  • void Insert(int index, T item): 在指定的索引位置插入项目。

  • void Remove(T item): 如果列表中存在该项目,则将其删除。

  • void RemoveAt(int index): 删除指定索引位置的项目。

您现在已经看到了集合的常见主要接口。因此,现在您将了解可用的主要集合类型以及它们的使用方法。

列表

List<T> 类型是 C# 中最广泛使用的集合之一。当您有一组项目并希望使用它们的索引位置来控制项目顺序时,它会使用。它实现了 IList 接口,允许使用索引位置插入、访问或删除项目:

图 4.3:List 类图

图 4.3:List 类图

列表具有以下行为:

  • 可以在集合的任何位置插入项目。任何尾随项目都将增加它们的索引位置。

  • 可以通过索引或值移除项目。这将更新尾随项目的索引位置。

  • 可以使用它们的索引值设置项目。

  • 可以将项目添加到集合的末尾。

  • 可以在集合内重复项目。

  • 可以使用各种 Sort 方法对项目的位置进行排序。

列表的一个例子可能是网页浏览器应用程序中的标签页。通常,用户可能希望在其他标签页之间拖动浏览器标签页,在末尾打开新的标签页,或在标签页列表的任何位置关闭标签页。可以使用 List 实现控制这些操作的代码。

内部,List 维护一个数组来存储其对象。当在末尾添加项目时,这可能很高效,但在插入项目时可能效率不高,尤其是在列表的起始位置附近,因为需要重新计算项目的索引位置。

以下示例展示了如何使用泛型 List 类。代码使用了 List<string> 类型参数,这允许将 string 类型添加到列表中。尝试添加任何其他类型将导致编译器错误。这将展示 List 类的多种常用方法。

  1. 在您的源代码文件夹中创建一个名为 Chapter04 的新文件夹。

  2. 切换到 Chapter04 文件夹并创建一个新的控制台应用程序,名为 Chapter04,使用以下 .NET 命令:

    source\Chapter04>dotnet new console -o Chapter04
    The template "Console Application" was created successfully.
    
  3. 删除 Class1.cs 文件。

  4. 添加一个名为 Examples 的新文件夹。

  5. 添加一个名为 ListExamples.cs 的新类文件。

  6. System.Collections.Generic 命名空间添加到访问 List<T> 类并声明一个名为 colors 的新变量:

    using System;
    using System.Collections.Generic;
    namespace Chapter04.Examples
    {
        class ListExamples
        {     
            public static void Main()
            {
                var colors = new List<string> {"red", "green"};
                colors.Add("orange");
    

代码声明了新的 colors 变量,该变量可以存储多个颜色名称作为 strings。在这里,使用了集合初始化语法,以便将 redgreen 添加为变量初始化的一部分。调用 Add 方法,将 orange 添加到列表中。

  1. 类似地,AddRangeyellowpink 添加到列表的末尾:

                colors.AddRange(new [] {"yellow", "pink"});
    
  2. 到目前为止,列表中有五个颜色,red 在索引位置 0green 在位置 1。您可以使用以下代码进行验证:

                Console.WriteLine($"Colors has {colors.Count} items");
                Console.WriteLine($"Item at index 1 is {colors[1]}");
    

运行代码会产生以下输出:

Colors has 5 items
Item at index 1 is green
  1. 使用Insert,可以将blue插入列表的开头,即索引0,如下面的代码所示。请注意,这将red从索引0移动到1,其他所有颜色的索引都将增加一个:

                Console.WriteLine("Inserting blue at 0");
                colors.Insert(0, "blue");
                Console.WriteLine($"Item at index 1 is now {colors[1]}");
    

运行此代码后,你应该看到以下输出:

Inserting blue at 0
Item at index 1 is now red
  1. 使用foreach可以遍历列表中的字符串,如下将每个字符串写入控制台:

                Console.WriteLine("foreach");
                foreach (var color in colors)
                    Console.Write($"{color}|");
                Console.WriteLine();
    

你应该得到以下输出:

foreach
blue|red|green|orange|yellow|pink|
  1. 现在,添加以下代码以反转数组。在这里,每个color字符串都使用ToCharArray转换为char类型的数组:

                Console.WriteLine("ForEach Action:");
                colors.ForEach(color =>
                {
                    var characters = color.ToCharArray();
                    Array.Reverse(characters);
                    var reversed = new string(characters);
                    Console.Write($"{reversed}|");
                });
                Console.WriteLine();
    

这不会影响colors列表中的任何值,因为characters引用的是不同的对象。请注意,foreach遍历每个字符串,而ForEach定义一个 Action 委托,用于调用每个字符串(回想一下,在第三章委托、事件和 Lambda中,你看到了如何使用 Lambda 语句创建Action委托)。

  1. 运行代码会导致以下输出:

    ForEach Action:
    eulb|der|neerg|egnaro|wolley|knip|
    
  2. 在下一个代码片段中,List构造函数接受一个源集合。这创建了一个包含colors字符串副本的新列表,在这种情况下,它使用默认的Sort实现进行排序:

                var backupColors = new List<string>(colors);
                backupColors.Sort();
    

字符串类型使用值类型语义,这意味着backupColors列表填充了每个源字符串值的副本。更新一个列表中的字符串将不会影响另一个列表。相反,类被定义为引用类型,因此将类实例的列表传递给构造函数仍将创建一个新的列表,具有独立的元素索引,但每个元素将指向内存中相同的共享引用而不是独立的副本。

  1. 在以下代码片段中,在移除所有颜色(使用colors.Clear)之前,每个值都会写入控制台(列表将很快被重新填充):

                Console.WriteLine("Foreach before clearing:");
                foreach (var color in colors)
                    Console.Write($"{color}|");
                Console.WriteLine();
                colors.Clear();
                Console.WriteLine($"Colors has {colors.Count} items");
    

运行代码会产生以下输出:

Foreach before clearing:
blue|red|green|orange|yellow|pink|
Colors has 0 items
  1. 然后,再次使用AddRange,将完整的颜色列表添加回colors列表,使用排序后的backupColors项作为源:

                colors.AddRange(backupColors);
                Console.WriteLine("foreach after addrange (sorted items):");
                foreach (var color in colors)
                    Console.Write($"{color}|");
                Console.WriteLine();
    

你应该看到以下输出:

foreach after addrange (sorted items):
blue|green|orange|pink|red|yellow|
  1. ConvertAll方法传递一个委托,可以用来返回任何类型的新列表:

                var indexes = colors.ConvertAll(color =>                      $"{color} is at index {colors.IndexOf(color)}");
                Console.WriteLine("ConvertAll:");
                Console.WriteLine(string.Join(Environment.NewLine, indexes));
    

在这里,返回一个新的List<string>,其中每个项目都使用其值和列表中的索引进行格式化。正如预期的那样,运行代码会产生以下输出:

ConvertAll:
blue is at index 0
green is at index 1
orange is at index 2
pink is at index 3
red is at index 4
yellow is at index 5
  1. 在下一个代码片段中,使用了两个Contains()方法来展示字符串值相等性的实际应用:

                Console.WriteLine($"Contains RED: {colors.Contains("RED")}");
                Console.WriteLine($"Contains red: {colors.Contains("red")}");
    

注意,大写的REDred。运行代码会产生以下输出:

Contains RED: False
Contains red: True
  1. 现在,添加以下代码片段:

                var existsInk = colors.Exists(color => color.EndsWith("ink"));
                Console.WriteLine($"Exists *ink: {existsInk}");
    

在这里,Exists方法传递一个 Predicate 委托,如果满足测试条件,则返回TrueFalse。Predicate 是一个内置委托,它返回一个布尔值。在这种情况下,如果任何项的字符串值以字母ink结尾(例如pink),则返回True

你应该看到以下输出:

Exists *ink: True
  1. 你知道已经有 red 颜色了,但如果你在列表的非常开始处插入 red,两次,将会很有趣:

                Console.WriteLine("Inserting reds");
                colors.InsertRange(0, new [] {"red", "red"});
                foreach (var color in colors)
                    Console.Write($"{color}|");
                Console.WriteLine();
    

你将得到以下输出:

Inserting reds
red|red|blue|green|orange|pink|red|yellow|

这表明可以在列表中插入相同的项多次。

  1. 下一个片段显示了如何使用 FindAll 方法。FindAll 方法与 Exists 方法类似,因为它传递了一个 Predicate 条件。所有符合该规则的项目都将被返回。添加以下代码:

                var allReds = colors.FindAll(color => color == "red");
                Console.WriteLine($"Found {allReds.Count} red");
    

你应该得到以下输出。正如预期的那样,返回了三个 red 项:

Found 3 red
  1. 完成示例后,使用 Remove 方法从列表中删除第一个 red。还有两个 FindLastIndex 来获取最后一个 red 项的索引:

                colors.Remove("red");
                var lastRedIndex = colors.FindLastIndex(color => color == "red");
                Console.WriteLine($"Last red found at index {lastRedIndex}");
                Console.ReadLine();
            }
        }
    }
    

运行代码产生以下输出:

Last red found at index 5

注意

你可以在 packt.link/dLbK6 找到用于此示例的代码。

在了解了如何使用泛型 List 类之后,现在是时候做练习了。

练习 4.01:在列表中维护秩序

在本章开头,网络浏览器标签被描述为一个理想的列表示例。在这个练习中,你将把这个想法付诸实践,并创建一个控制模拟网络浏览器的应用程序中标签导航的类。

为了做到这一点,你将创建一个 Tab 类和一个 TabController 应用程序,允许打开新的标签和关闭或移动现有的标签。以下步骤将帮助你完成这个练习:

  1. 在 VSCode 中,选择你的 Chapter04 项目。

  2. 添加一个名为 Exercises 的新文件夹。

  3. Exercises 文件夹内,添加一个名为 Exercise01 的文件夹,并添加一个名为 Exercise01.cs 的文件。

  4. 打开 Exercise01.cs 文件并定义一个带有字符串 URL 构造参数的 Tab 类,如下所示:

    using System;
    using System.Collections;
    using System.Collections.Generic;
    namespace Chapter04.Exercises.Exercise01
    {
        public class Tab 
        {
            public Tab()
            {}
            public Tab(string url) => (Url) = (url);
            public string Url { get; set; }
            public override string ToString() => Url;
        }   
    

在这里,ToString 方法已被重写以返回当前 URL,以帮助在控制台记录详细信息。

  1. 按照以下方式创建 TabController 类:

        public class TabController : IEnumerable<Tab>
        {
            private readonly List<Tab> _tabs = new();
    

TabController 类包含一个标签列表。注意这个类是如何从 IEnumerable 接口继承的。这个接口被用来使类提供一种遍历其项的方法,使用 foreach 语句。你将在下一步提供打开、移动和关闭标签的方法,这些方法将直接控制 _tabs 列表中项的顺序。请注意,你可以直接将 _tabs 列表暴露给调用者,但最好是限制通过你自己的方法访问标签。因此,它被定义为 readonly 列表。

  1. 接下来,定义 OpenNew 方法,它将新标签添加到列表的末尾:

            public Tab OpenNew(string url)
            {
                var tab = new Tab(url);
                _tabs.Add(tab);
                Console.WriteLine($"OpenNew {tab}");
                return tab;
            }
    
  2. 定义另一个方法 Close,如果标签存在,则从列表中删除它。为此添加以下代码:

            public void Close(Tab tab)
            {
                if (_tabs.Remove(tab))
                {
                    Console.WriteLine($"Removed {tab}");
                }
            }
    
  3. 要将标签移动到列表的起始位置,添加以下代码:

            public void MoveToStart(Tab tab)
            {
                if (_tabs.Remove(tab))
                {
                    _tabs.Insert(0, tab);
                    Console.WriteLine($"Moved {tab} to start");
                }
    

在这里,MoveToStart 将尝试删除标签并将其插入索引 0

  1. 类似地,添加以下代码以将标签移动到末尾:

            public void MoveToEnd(Tab tab)
            {
                if (_tabs.Remove(tab))
                {
                    _tabs.Add(tab);
                    Console.WriteLine($"Moved {tab} to end. Index={_tabs.IndexOf(tab)}");
                }
            }
    

在这里,调用MoveToEnd首先删除标签页,然后将其添加到末尾,并将新的索引位置记录到控制台。

最后,IEnumerable接口要求您实现两个方法,IEnumerator<Tab> GetEnumerator()IEnumerable.GetEnumerator()。这些允许调用者使用类型为Tab的泛型或使用第二个方法通过基于对象的类型迭代集合。第二个方法是对 C#早期版本的回顾,但需要为了兼容性。

  1. 对于两种方法的实际结果,您可以使用_tab列表的GetEnumerator方法,因为该列表包含标签页的列表形式。添加以下代码以实现此目的:

            public IEnumerator<Tab> GetEnumerator() => _tabs.GetEnumerator();
            IEnumerator IEnumerable.GetEnumerator() => _tabs.GetEnumerator();
        }
    
  2. 您现在可以创建一个控制台应用程序来测试控制器的行为。首先打开三个新的标签页,并通过LogTabs记录标签页详情(这将在稍后定义):

        static class Program
        {
            public static void Main()
            {
                var controller = new TabController();
                Console.WriteLine("Opening tabs...");
                var packt = controller.OpenNew("packtpub.com");
                var msoft = controller.OpenNew("microsoft.com");
                var amazon = controller.OpenNew("amazon.com");
                controller.LogTabs();
    
  3. 现在,将amazon移动到开头,将packt移动到末尾,并记录标签页详情:

                Console.WriteLine("Moving...");
                controller.MoveToStart(amazon);
                controller.MoveToEnd(packt);
                controller.LogTabs();
    
  4. 关闭msoft标签页并再次记录详情:

                Console.WriteLine("Closing tab...");
                controller.Close(msoft);
                controller.LogTabs();
                Console.ReadLine();
            }
    
  5. 最后,添加一个扩展方法,帮助在TabController中记录每个标签页的 URL。将其定义为IEnumerable<Tab>的扩展方法,而不是TabController,因为您只需要一个迭代器来使用foreach循环遍历标签页。

  6. 使用PadRight将每个 URL 左对齐,如下所示:

            private static void LogTabs(this IEnumerable<Tab> tabs)
            {
                Console.Write("TABS: |");
                foreach(var tab in tabs)
                    Console.Write($"{tab.Url.PadRight(15)}|");
                Console.WriteLine();
            }    
       } 
    }
    
  7. 运行代码将产生以下输出:

    Opening tabs...
    OpenNew packtpub.com
    OpenNew microsoft.com
    OpenNew amazon.com
    TABS: |packtpub.com   |microsoft.com  |amazon.com     |
    Moving...
    Moved amazon.com to start
    Moved packtpub.com to end. Index=2
    TABS: |amazon.com     |microsoft.com  |packtpub.com   |
    Closing tab...
    Removed microsoft.com
    TABS: |amazon.com     |packtpub.com   |
    

    注意

    有时,Visual Studio 可能会在您第一次执行程序时报告不可为空的属性错误。这是一个有用的提醒,表明您正在尝试使用可能在运行时具有 null 值的字符串值。

打开了三个标签页。然后amazon.compacktpub.com被移动到microsoft.com之前,最后关闭并从标签页列表中删除。

注意

您可以在packt.link/iUcIs找到用于此练习的代码。

在这个练习中,您已经看到了如何使用列表来存储相同类型的多个项目,同时保持项目的顺序。下一节将介绍QueueStack类,这些类允许以预定义的顺序添加和删除项目。

队列

Queue类提供了一种先进先出机制。项目使用Enqueue方法添加到队列的末尾,并使用Dequeue方法从队列的前端移除。队列中的项目不能通过索引元素访问。

队列通常用于需要确保项目按它们添加到队列的顺序处理的流程。一个典型的例子可能是一个繁忙的在线票务系统,向客户销售有限数量的音乐会门票。为了确保公平性,客户在登录时立即被添加到排队系统中。然后系统将逐个出队每个客户并处理每个订单,直到所有门票售罄或客户队列变空。

以下示例创建了一个包含五个 CustomerOrder 记录的队列。当是时候处理订单时,每个订单都会使用 TryDequeue 方法出队,该方法将返回 true,直到所有订单都已被处理。客户订单的处理顺序与它们被添加的顺序相同。如果请求的票数多于或等于剩余的票数,则显示成功消息。如果剩余的票数少于请求的数量,则显示道歉信息。

图 4.4:队列的 Enqueue() 和 Dequeue() 工作流程

图 4.4:队列的 Enqueue() 和 Dequeue() 工作流程

完成此示例的步骤如下:

  1. 在您的 Chapter04 源文件夹的 Examples 文件夹中,添加一个名为 QueueExamples.cs 的新类,并按以下方式编辑它:

    using System;
    using System.Collections.Generic;
    namespace Chapter04.Examples
    {
        class QueueExamples
        {      
            record CustomerOrder (string Name, int TicketsRequested)
            {}
            public static void Main()
            {
                var ticketsAvailable = 10;
                var customers = new Queue<CustomerOrder>();
    
  2. 使用 Enqueue 方法添加五个订单,如下所示:

                customers.Enqueue(new CustomerOrder("Dave", 2));
                customers.Enqueue(new CustomerOrder("Siva", 4));
                customers.Enqueue(new CustomerOrder("Julien", 3));
                customers.Enqueue(new CustomerOrder("Kane", 2));
                customers.Enqueue(new CustomerOrder("Ann", 1));
    
  3. 现在,使用一个 while 循环,直到 TryDequeue 返回 false,这意味着所有当前订单都已处理:

                // Start processing orders...
                while(customers.TryDequeue(out CustomerOrder nextOrder))
                {
                    if (nextOrder.TicketsRequested <= ticketsAvailable)
                    {
                        ticketsAvailable -= nextOrder.TicketsRequested;   
                        Console.WriteLine($"Congratulations {nextOrder.Name}, you've purchased {nextOrder.TicketsRequested} ticket(s)");
                    }
                    else
                    {
                        Console.WriteLine($"Sorry {nextOrder.Name}, cannot fulfil {nextOrder.TicketsRequested} ticket(s)");
                    }
                }
                Console.WriteLine($"Finished. Available={ticketsAvailable}");
                Console.ReadLine();
            }
        }
    }
    
  4. 运行示例代码会产生以下输出:

    Congratulations Dave, you've purchased 2 ticket(s)
    Congratulations Siva, you've purchased 4 ticket(s)
    Congratulations Julien, you've purchased 3 ticket(s)
    Sorry Kane, cannot fulfil 2 ticket(s)
    Congratulations Ann, you've purchased 1 ticket(s)
    Finished. Available=0
    

    注意

    第一次运行此程序时,Visual Studio 可能会显示一个不可为 null 的类型错误。这个错误是一个提醒,说明您正在使用可能为 null 值的变量。

输出显示Dave请求了两张票。由于有两张或更多票可用,他成功了。SivaJulien 也成功了,但到 Kane 下单两张票时,只剩下一张票了,所以他看到了道歉信息。最后,Ann请求了一张票,并成功下单。

注意

您可以在 packt.link/Zb524 找到用于此示例的代码。

Stack 类提供了与 Queue 类相反的机制;项目以后进先出的顺序处理。与 Queue 类一样,您不能通过索引位置访问元素。项目通过 Push 方法添加到栈中,通过 Pop 方法移除。

一个应用程序的“撤销”菜单可以使用栈来实现。例如,在一个文字处理程序中,当用户编辑文档时,会创建一个“操作”代理,用户每次按下 Ctrl + Z 时,都可以撤销最近的更改。最近的操作会被从栈中弹出,并撤销更改。这允许撤销多个步骤。

图 4.5:栈的 Push() 和 Pop() 工作流程

图 4.5:栈的 Push() 和 Pop() 工作流程

以下示例展示了这一点。

您将首先创建一个支持多个撤销操作的 UndoStack 类。调用者决定每次调用 Undo 请求时应运行什么操作。

一个典型的可撤销操作是在用户添加单词之前存储文本的副本。另一个可撤销操作是在应用新字体之前存储当前字体的副本。您可以开始添加以下代码,其中您创建UndoStack类并定义一个名为_undoStack的只读Action委托Stack

  1. 在您的Chapter04\Examples文件夹中,添加一个名为StackExamples.cs的新类,并按以下方式编辑它:

    using System;
    using System.Collections.Generic;
    namespace Chapter04.Examples
    {
        class UndoStack
        {
            private readonly Stack<Action> _undoStack = new Stack<Action>();
    
  2. 当用户完成某些操作后,可以撤销相同的操作。因此,将一个Action推送到_undoStack的前面:

            public void Do(Action action)
            {
                _undoStack.Push(action);
            }
    
  3. Undo方法检查是否有任何要撤销的项目,然后调用Pop来移除最近的Action并调用它,从而撤销刚刚应用的变化。以下是如何添加此代码的示例:

            public void Undo()
            {
                if (_undoStack.Count > 0)
                {
                    var undo = _undoStack.Pop();
                    undo?.Invoke();
                }
            }
        }
    
  4. 现在,您可以创建一个TextEditor类,允许将编辑添加到UndoStack。此构造函数传递UndoStack,因为可能有多个编辑器需要将各种Action委托添加到堆栈中:

        class TextEditor
        {
            private readonly UndoStack _undoStack;
            public TextEditor(UndoStack undoStack)
            {
                _undoStack = undoStack;
            }
            public string Text {get; private set; }
    
  5. 接下来,添加EditText命令,它复制previousText的值并创建一个Action委托,如果被调用,可以将文本恢复到其先前值:

            public void EditText(string newText)
            {
                var previousText = Text;
                _undoStack.Do( () =>
                {
                    Text = previousText;
                    Console.Write($"Undo:'{newText}'".PadRight(40));
                    Console.WriteLine($"Text='{Text}'");
                });
    
  6. 现在,应使用+=运算符将newText值附加到Text属性上。有关此操作的详细信息记录到控制台,使用PadRight来改进格式:

                Text += newText;
                Console.Write($"Edit:'{newText}'".PadRight(40));
                Console.WriteLine($"Text='{Text}'");
            }
        }
    
  7. 最后,是时候创建一个测试TextEditorUndoStack的控制台应用程序了。最初进行四次编辑,然后进行两次撤销操作,最后再进行两次文本编辑:

        class StackExamples
        {
    
            public static void Main()
            {
                var undoStack = new UndoStack();
                var editor = new TextEditor(undoStack);
                editor.EditText("One day, ");
                editor.EditText("in a ");
                editor.EditText("city ");
                editor.EditText("near by ");
                undoStack.Undo(); // remove 'near by'
                undoStack.Undo(); // remove 'city'
                editor.EditText("land ");
                editor.EditText("far far away ");
                Console.ReadLine();
            }
        }    
    }
    
  8. 运行控制台应用程序会产生以下输出:

    Edit:'One day, '                        Text='One day, '
    Edit:'in a '                            Text='One day, in a '
    Edit:'city '                            Text='One day, in a city '
    Edit:'near by '                         Text='One day, in a city near by '
    Undo:'near by '                         Text='One day, in a city '
    Undo:'city '                            Text='One day, in a '
    Edit:'land '                            Text='One day, in a land '
    Edit:'far far away '                    Text='One day, in a land far far away '
    

    注意

    Visual Studio 在代码首次执行时可能会显示不可为空的属性错误。这是因为 Visual Studio 注意到Text属性在运行时可能为 null 值,因此提供了改进代码的建议。

左侧输出显示了应用时的文本编辑和撤销操作,以及右侧的最终Text值。两次Undo调用导致near bycityText值中移除,然后landfar far away最终添加到Text值中。

注意

您可以在packt.link/tLVyf找到用于此示例的代码。

HashSets

HashSet类以高效且高性能的方式提供了数学集合操作,用于对象集合。HashSet不允许重复元素,并且项目不按任何特定顺序存储。使用HashSet类非常适合高性能操作,例如需要快速找到两个对象集合重叠的位置。

通常,HashSet用于以下操作:

  • public void UnionWith(IEnumerable<T> other): 生成集合并集。这修改HashSet以包括当前HashSet实例中存在的项目、其他集合或两者。

  • public void IntersectWith(IEnumerable<T> other): 产生一个集合交集。这修改HashSet以包含当前HashSet实例和其他集合中存在的项目。

  • public void ExceptWith(IEnumerable<T> other): 产生一个集合减法。这从当前HashSet实例和其他集合中删除存在的项目。

HashSet在需要从集合中包含或排除某些元素时很有用。例如,考虑一个代理人管理各种名人,并被要求找到三组明星:

  • 那些既能行动又能唱歌的人。

  • 那些既能行动又能唱歌的人。

  • 那些只能行动(不允许唱歌)的人。

在以下代码片段中,创建了一个演员和歌手名字的列表:

  1. 在你的Chapter04\Examples文件夹中,添加一个名为HashSetExamples.cs的新类,并按照以下方式编辑它:

    using System;
    using System.Collections.Generic;
    namespace Chapter04.Examples
    {
        class HashSetExamples
        {
            public static void Main()
            {
                var actors = new List<string> {"Harrison Ford", "Will Smith", 
                                               "Sigourney Weaver"};
                var singers = new List<string> {"Will Smith", "Adele"};
    
  2. 现在,创建一个新的只包含歌手的HashSet实例,然后使用UnionWith修改集合以包含既能行动又能唱歌的人的独特集合:

                var actingOrSinging = new HashSet<string>(singers);
                actingOrSinging.UnionWith(actors);
                Console.WriteLine($"Acting or Singing: {string.Join(", ", 
                                  actingOrSinging)}");
    
  3. 对于那些可以行动的歌手HashSet实例,并使用IntersectWith修改HashSet实例以包含两个集合中都有的人的独特列表:

                var actingAndSinging = new HashSet<string>(singers);
                actingAndSinging.IntersectWith(actors);
                Console.WriteLine($"Acting and Singing: {string.Join(", ", 
                                  actingAndSinging)}");
    
  4. 最后,对于那些可以ExceptWithHashSet实例中删除也能唱歌的人:

                var actingOnly = new HashSet<string>(actors);
                actingOnly.ExceptWith(singers);
                Console.WriteLine($"Acting Only: {string.Join(", ", actingOnly)}");
                Console.ReadLine();
            }
        }
    }
    
  5. 运行控制台应用程序会产生以下输出:

    Acting or Singing: Will Smith, Adele, Harrison Ford, Sigourney Weaver
    Acting and Singing: Will Smith
    Acting Only: Harrison Ford, Sigourney Weaver
    

从输出中,你可以看到在给定的演员和歌手列表中,只有Will Smith既能行动又能唱歌

注意

你可以在packt.link/ZdNbS找到这个示例使用的代码。

字典

另一个常用的集合类型是泛型Dictionary<TK, TV>。这允许添加多个项目,但需要一个独特的来识别项目实例。

字典通常用于使用已知键查找值。键和值类型参数可以是任何类型。一个值可以在Dictionary中存在多次,前提是其键是唯一的。尝试添加已存在的键将导致抛出运行时异常。

Dictionary的一个常见例子可能是按 ISO 国家代码键控的已知国家注册表。客户服务应用程序可以从数据库中加载客户详细信息,然后使用 ISO 代码从国家列表中查找客户的国籍,而不是为每个客户创建一个新的国家实例。

注意

你可以在www.iso.org/iso-3166-country-codes.xhtml找到有关标准 ISO 国家代码的更多信息。

Dictionary类中使用的主要方法如下:

  • public TValue this[TKey key] {get; set;}:获取或设置与键关联的值。如果键不存在,则抛出异常。

  • Dictionary<TKey, TValue>.KeyCollection Keys { get; }:返回一个包含所有键的KeyCollection字典实例。

  • Dictionary<TKey, TValue>.ValueCollection Values { get; }: 返回一个包含所有值的 ValueCollection 字典实例。

  • public int Count { get; }: 返回 Dictionary 中的元素数量。

  • void Add(TKey key, TValue value): 添加键和关联的值。如果键已存在,则抛出异常。

  • void Clear(): 从 Dictionary 中清除所有键和值。

  • bool ContainsKey(TKey key): 如果指定的键存在,则返回 true

  • bool ContainsValue(TValue value): 如果指定的值存在,则返回 true

  • bool Remove(TKey key): 删除与相关键关联的值。

  • bool TryAdd(TKey key, TValue value): 尝试添加键和值。如果键已存在,不会抛出异常。如果值已添加,则返回 true

  • bool TryGetValue(TKey key, out TValue value): 如果存在,获取与键关联的值。如果找到,则返回 true

以下代码显示了如何使用 Dictionary 添加和导航 Country 记录:

  1. 在你的 Chapter04\Examples 文件夹中,添加一个名为 DictionaryExamples.cs 的新类。

  2. 首先定义一个 Country 记录,它传递一个 Name 参数:

    using System;
    using System.Collections.Generic;
    namespace Chapter04.Examples
    {
        public record Country(string Name)
        {}
        class DictionaryExamples
        {
            public static void Main()
            {
    
  3. 使用 Dictionary 初始化语法创建一个包含五个国家的 Dictionary,如下所示:

                var countries = new Dictionary<string, Country>
                {
                    {"AFG", new Country("Afghanistan")},
                    {"ALB", new Country("Albania")},
                    {"DZA", new Country("Algeria")},
                    {"ASM", new Country("American Samoa")},
                    {"AND", new Country("Andorra")}
                };
    
  4. 在下一个代码片段中,Dictionary 实现了 IEnumerable 接口,这允许你检索表示 Dictionary 中键和值项的键值对:

                Console.WriteLine("Enumerate foreach KeyValuePair");
                foreach (var kvp in countries)
                {
                    Console.WriteLine($"\t{kvp.Key} = {kvp.Value.Name}");
                }
    
  5. 运行示例代码会产生以下输出。通过遍历 countries 中的每个项目,你可以看到五个国家代码及其名称:

    Enumerate foreach KeyValuePair
            AFG = Afghanistan
            ALB = Albania
            DZA = Algeria
            ASM = American Samoa
            AND = Andorra
    
  6. 存在一个具有 AFG 键的条目,因此使用传递 AFG 作为键的 set indexer 允许设置一个新的 Country 记录,该记录替换了具有 AGF 键的先前项。你可以添加以下代码来完成此操作:

                Console.WriteLine("set indexor AFG to new value");
                countries["AFG"] = new Country("AFGHANISTAN");
                Console.WriteLine($"get indexor AFG: {countries["AFG"].Name}");
    
  7. 当你运行代码时,添加 AFG 的键允许你使用该键获取值:

    set indexor AFG to new value
    get indexor AFG: AFGHANISTAN
    ContainsKey AGO: False
    ContainsKey and: False
    
  8. 对于字符串键,键比较是区分大小写的,因此 AGO 存在,但 and 不存在,因为相应的国家(安道尔)使用大写 AND 键定义。你可以添加以下代码来检查这一点:

                Console.WriteLine($"ContainsKey {"AGO"}:                          {countries.ContainsKey("AGO")}");
                Console.WriteLine($"ContainsKey {"and"}:                          {countries.ContainsKey("and")}"); // Case sensitive
    
  9. 使用 Add 添加新条目时,如果键已存在,将抛出异常。这可以通过添加以下代码来查看:

                var anguilla = new Country("Anguilla");
                Console.WriteLine($"Add {anguilla}...");
                countries.Add("AIA", anguilla);
                try
                {
                    var anguillaCopy = new Country("Anguilla");
                    Console.WriteLine($"Adding {anguillaCopy}...");
                    countries.Add("AIA", anguillaCopy);
                }
                catch (Exception e)
                {
                    Console.WriteLine($"Caught {e.Message}");
                }
    
  10. 相反,TryAdd 不接受 AIA 键,因此使用 TryAdd 仅返回一个 false 值而不是抛出异常:

                var addedAIA = countries.TryAdd("AIA", new Country("Anguilla"));
                Console.WriteLine($"TryAdd AIA: {addedAIA}");
    
  11. 如以下输出所示,使用 AIA 键添加 Anguilla 一次是有效的,但再次使用 AIA 键添加它会导致捕获到异常:

    Add Country { Name = Anguilla }...
    Adding Country { Name = Anguilla }...
    Caught An item with the same key has already been added. Key: AIA
    TryAdd AIA: False
    
  12. TryGetValue,正如其名所示,允许你通过键尝试获取值。你传入一个可能不存在于 Dictionary 中的键。请求一个键缺失的对象将确保不会抛出异常。如果你不确定是否已为指定的键添加了值,这很有用:

                var tryGet = countries.TryGetValue("ALB", out Country albania1);
                Console.WriteLine($"TryGetValue for ALB: {albania1}                              Result={tryGet}");
                countries.TryGetValue("alb", out Country albania2);
                Console.WriteLine($"TryGetValue for ALB: {albania2}");
            }
        }
    }
    
  13. 运行此代码后,你应该看到以下输出:

    TryGetValue for ALB: Country { Name = Albania } Result=True
    TryGetValue for ALB:
    

    注意

    Visual Studio 可能会报告以下警告:Warning CS8600: 将空字面量或可能的空值转换为不可为 null 的类型。这是 Visual Studio 提醒您,变量可能在运行时具有空值。

您已经看到如何使用 Dictionary 类来确保仅将唯一标识符与值相关联。即使您不知道 Dictionary 中有哪些键直到运行时,您也可以使用 TryGetValueTryAdd 方法来防止运行时异常。

注意

您可以在packt.link/vzHUb找到用于此示例的代码。

在此示例中,使用了字符串键作为 Dictionary。然而,任何类型都可以用作键。您会发现,当从关系型数据库检索源数据时,通常使用整数值作为键,因为整数在内存中通常比字符串更高效。现在,您将通过练习使用此功能。

练习 4.02:使用 Dictionary 计算句子中的单词数量

您被要求创建一个控制台应用程序,要求用户输入一个句子。然后控制台应将输入分割成单个单词(使用空格字符作为单词分隔符)并计算每个单词出现的次数。如果可能,应从输出中移除简单的标点符号,并且您需要忽略大写字母的单词,例如,Appleapple 都应视为单个单词。

这是对 Dictionary 的理想使用。Dictionary 将使用字符串作为键(每个单词的唯一条目)以及一个 int 值来计数单词。您将使用 string.Split() 将句子分割成单词,并使用 char.IsPunctuation 移除任何尾随的标点符号。

执行以下步骤:

  1. 在您的 Chapter04\Exercises 文件夹中,创建一个新的文件夹,称为 Exercise02

  2. Exercise02 文件夹中,添加一个名为 Program.cs 的新类。

  3. 首先定义一个新的类,称为 WordCounter。这个类可以被标记为 static,这样就可以在不创建实例的情况下使用:

    using System;
    using System.Collections.Generic;
    namespace Chapter04.Exercises.Exercise02
    {
        static class WordCounter 
        {
    
  4. 定义一个名为 Processstatic 方法:

            public static IEnumerable<KeyValuePair<string, int>> Process(            string phrase)
            {
                var wordCounts = new Dictionary<string, int>();
    

这将传递一个短语并返回 IEnumerable<KeyValuePair>,允许调用者遍历结果的 Dictionary。在此定义之后,wordCountsDictionary 使用 string(每个找到的单词)和一个 int(单词出现的次数)作为键。

  1. 您需要忽略首字母大写的单词的案例,因此在使用 string.Split 方法分割短语之前,将字符串转换为小写等效形式。

  2. 然后,您可以使用 RemoveEmptyEntries 选项移除任何空字符串值。为此,添加以下代码:

                 var words = phrase.ToLower().Split(' ',                        StringSplitOptions.RemoveEmptyEntries);
    
  3. 使用简单的 foreach 循环遍历短语中找到的各个单词:

                foreach(var word in words)
                {
                    var key = word;
                    if (char.IsPunctuation(key[key.Length-1]))
                    {
                        key = key.Remove(key.Length-1);
                    }
    

使用 char.IsPunctuation 方法从单词末尾移除标点符号。

  1. 使用 TryGetValue 方法检查是否存在具有当前单词的 Dictionary 条目。如果有,则通过一个更新 count 为一:

                    if (wordCounts.TryGetValue(key, out var count))
                    {
                        wordCounts[key] = count + 1;
                    }
                    else
                    {
                        wordCounts.Add(key, 1);
                    }
                }
    

如果单词不存在,则添加一个新的单词键,其起始值为1

  1. 一旦处理完短语中的所有单词,返回wordCounts Dictionary

                return wordCounts;
            }
        }
    
  2. 现在,编写一个控制台应用程序,允许用户输入一个短语:

        class Program
        {
            public static void Main()
            {
                string input;
                do
                {
                    Console.Write("Enter a phrase:");
                    input = Console.ReadLine();
    

当用户输入一个空字符串时,do循环将结束;你将在接下来的步骤中添加这段代码。

  1. 调用WordCounter.Process方法以返回一个可以枚举的键值对。

  2. 对于每个keyvalue,写出单词及其计数,并将每个单词右对齐:

                    if (!string.IsNullOrEmpty(input))
                    {
                        var countsByWord = WordCounter.Process(input);
                        var i = 0;
                        foreach (var (key, value) in countsByWord)
                        {
                            Console.Write($"{key.PadLeft(20)}={value}\t");
                            i++;
                            if (i % 3 == 0)
                            {
                                Console.WriteLine();
                            }
                        }
                        Console.WriteLine();
    

每隔三个单词后开始新的一行(使用i % 3 = 0),以改善输出格式。

  1. 完成循环:

                        }
                } while (input != string.Empty);
            }
        }
    }
    
  2. 使用 1863 年《葛底斯堡演说》的开头文本运行控制台产生以下输出:

    Enter a phrase: Four score and seven years ago our fathers brought forth, upon this continent, a new nation, conceived in liberty, and dedicated to the proposition that all men are created equal. Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived, and so dedicated, can long endure.
                    four=1                 score=1                 and=3
                   seven=1                 years=1                 ago=1
                     our=1               fathers=1             brought=1
                   forth=1                  upon=1                this=1
               continent=1                     a=2                 new=1
                  nation=3             conceived=2                  in=2
                 liberty=1             dedicated=2                  to=1
                     the=1           proposition=1                that=2
                     all=1                   men=1                 are=2
                 created=1                 equal=1                 now=1
                      we=1               engaged=1               great=1
                   civil=1                   war=1             testing=1
                 whether=1                    or=1                 any=1
                      so=2                   can=1                 long=1
                  endure=1
    

    注意

    您可以在网上搜索《葛底斯堡演说》或访问rmc.library.cornell.edu/gettysburg/good_cause/transcript.htm

从结果中,您可以看到每个单词只显示一次,并且某些单词,如andthat,在演讲中出现了多次。单词按其在文本中出现的顺序列出,但这种情况并不总是适用于Dictionary类。应假定顺序不会以这种方式保持固定;应使用键访问字典的值。

注意

您可以在packt.link/Dnw4a找到此练习使用的代码。

到目前为止,您已经了解了.NET 中常用的主要集合。现在是时候看看 LINQ 了,它广泛使用了基于IEnumerable接口的集合。

LINQ

LINQ(发音为link)代表语言集成查询。LINQ 是一种通用语言,可以使用类似于结构化查询语言(SQL)的语法来查询内存中的对象,即它用于查询数据库。它是 C#语言的一个增强,使得使用类似 SQL 的查询表达式或查询运算符(通过一系列扩展方法实现)与内存中的对象交互变得更加容易。

微软最初为 LINQ 的想法是使用 LINQ 提供者来弥合.NET 代码和数据源(如关系数据库和 XML)之间的差距。LINQ 提供者形成了一套构建块,可以用来查询各种数据源,使用类似的一组查询运算符,而不需要调用者了解每个数据源如何工作的复杂性。以下是一个提供者列表及其使用方法:

  • LINQ to Objects:应用于内存中对象(如列表中定义的对象)的查询。

  • LINQ to SQL:应用于 SQL Server、Sybase 或 Oracle 等关系数据库的查询。

  • LINQ to XML:应用于 XML 文档的查询。

本章将介绍 LINQ to Objects。这无疑是 LINQ 提供程序最常见的使用方式,它提供了一种灵活的方式来查询内存中的集合。实际上,当谈论 LINQ 时,大多数人指的是 LINQ to Objects,主要归因于它在 C# 应用程序中的普遍使用。

LINQ 的核心在于,可以使用简洁且易于使用的语法将集合转换为、过滤和聚合成新的形式。LINQ 可以使用两种可互换的风格:

  • 查询运算符

  • 查询表达式

每种风格都提供不同的语法来实现相同的结果,你选择使用哪一种通常取决于个人喜好。每种风格都可以在代码中轻松交织。

查询运算符

这些基于一系列核心扩展方法。一个方法的结果可以被链式组合成编程风格,这通常比基于表达式的对应风格更容易理解。

扩展方法通常接受一个 IEnumerable<T>IQueryable<T> 输入源,例如列表,并允许应用 Func<T> 断言到该源。源基于泛型,因此查询运算符可以与所有类型一起工作。例如,处理 List<string> 与处理 List<Customer> 一样简单。

在以下代码片段中,.Where.OrderBy.Select 是被调用的扩展方法:

books.Where(book => book.Price > 10)
     .OrderBy(book => book.Price)
     .Select(book => book.Name)

在这里,你正在使用 .Where 扩展方法从结果中找到所有单价大于 10 的书籍,然后使用 .OrderBy 扩展方法进行排序。最后,使用 .Select 方法提取每本书的名称。这些方法本来可以声明为单行代码,但以这种方式链式调用提供了更直观的语法。这将在接下来的章节中详细介绍。

查询表达式

查询表达式是 C# 语言的增强功能,类似于 SQL 语法。C# 编译器将查询表达式编译成一系列查询运算符扩展方法调用。请注意,并非所有查询运算符都有等效的查询表达式实现。

查询表达式有以下规则:

  • 它们以 from 子句开始。

  • 它们可以包含至少一个或多个可选的 whereorderbyjoinlet 以及额外的 from 子句。

  • 它们以 selectgroup 子句结束。

以下代码片段在功能上与上一节中定义的查询运算符风格等效:

from book in books where book.Price > 10 orderby book.Price select book.Name

当你学习标准查询运算符时,将更深入地了解这两种风格。

延迟执行

无论你选择使用查询运算符、查询表达式,还是两者的混合,重要的是要记住,对于许多运算符,你定义的查询在定义时并不会执行,只有在枚举时才会执行。这意味着只有在调用 foreach 语句或 ToListToArrayToDictionaryToLookupToHashSet 方法时,实际的查询才会执行。

这允许在代码的其他地方构建查询,包括额外的标准,然后使用或甚至重新使用不同的数据集。回想一下在 第三章委托、Lambda 表达式和事件 中,您看到了类似的行为。委托不是在定义的地方执行,而是在被调用时执行。

在以下简短的查询操作符示例中,输出将是 abz,即使 z 是在查询定义 之后 但在 枚举之前 添加的。这表明 LINQ 查询是在需要时评估的,而不是在声明的地方。

var letters = new List<string> { "a", "b"}
var query = letters.Select(w => w.ToUpper());
letters.Add("z");
foreach(var l in query) 
  Console.Write(l);

标准查询操作符

LINQ 由一组核心扩展方法驱动,称为标准查询操作符。这些方法根据其功能分组。有许多标准查询操作符可用,因此在本介绍中,您将探索您可能经常使用的所有主要操作符。

投影操作

投影操作允许您仅使用所需的属性将对象转换为新的结构。您可以创建一个新类型,应用数学运算,或返回原始对象:

  • Select:将源中的每个项目投影到新的形式。

  • SelectMany:将源中的所有项目投影,将结果扁平化,并可选择将它们投影到新的形式。没有 SelectMany 的查询表达式等效项。

Select

考虑以下代码片段,它遍历一个包含值 MonTuesWednesList<string>,并将每个值后面附加单词 day 输出。

在您的 Chapter04\Examples 文件夹中,添加一个名为 LinqSelectExamples.cs 的新文件,并按照以下方式编辑它:

using System;
using System.Collections.Generic;
using System.Linq;
namespace Chapter04.Examples
{
    class LinqSelectExamples
    {
        public static void Main()
        {
            var days = new List<string> { "Mon", "Tues", "Wednes" };
            var query1 = days.Select(d => d + "day");
            foreach(var day in query1)
                Console.WriteLine($"Query1: {day}");         

首先查看查询操作符语法,您可以看到 query1 使用了 Select 扩展方法,并定义了一个 Func<T>,如下所示:

d => d + "day"

当执行时,变量 d 被传递到 lambda 表达式,该表达式将单词 day 添加到 days 列表中的每个字符串:"Mon""Tues""Wednes"。这返回一个新的 IEnumerable<string> 实例,其中源变量 days 中的原始值保持不变。

您现在可以使用 foreach 遍历新的 IEnumerable 实例,如下所示:

            var query2 = days.Select((d, i) => $"{i} : {d}day");
            foreach (var day in query2)
                Console.WriteLine($"Query2: {day}");

注意,Select 方法还有一个重载,允许访问源中的索引位置和值,而不仅仅是值本身。在这里,使用 ( d , i ) => 语法传递 d(字符串值)和 i(其索引),并将它们连接成一个新的字符串。输出将显示为 0 : Monday1 : Tuesday,依此类推。

匿名类型

在您继续查看 Select 投影之前,值得注意的是,C# 并不限制您仅从现有字符串创建新字符串。您可以投影到任何类型。

你还可以创建匿名类型,这些类型是由编译器根据你命名的和指定的属性创建的。例如,考虑以下示例,它将创建一个新类型,该类型表示Select方法的结果:

            var query3 = days.Select((d, i) => new
            {
                Index = i, 
                UpperCaseName = $"{d.ToUpper()}DAY"
            });
            foreach (var day in query3)
                Console.WriteLine($"Query3: Index={day.Index},                                             UpperCaseDay={day.UpperCaseName}");

在这里,query3产生了一个具有索引和UpperCaseName属性的新类型;值使用Index = iUpperCaseName = $"{d.ToUpper()}DAY"分配。

这些类型的作用域是可在你的局部方法中使用,然后可以在任何局部语句中使用,例如在之前的foreach块中。这可以节省你创建类以临时存储Select方法中值的需要。

运行代码会产生以下格式的输出:

Index=0, UpperCaseDay=MONDAY

作为替代,考虑等效的查询表达式看起来如何。在以下示例中,你从from day in days表达式开始。这将为days列表中的字符串值分配名称day。然后使用select将其投影到一个新的字符串,并为每个字符串添加"day"

这在功能上与query1中的示例等效。唯一的区别是代码的可读性:

            var query4 = from day in days
                         select day + "day";
            foreach (var day in query4)
                Console.WriteLine($"Query4: {day}");

以下示例片段混合了查询操作符和查询表达式。select查询表达式不能用于选择值和索引,因此使用Select扩展方法来创建一个具有NameIndex属性的匿名类型:

                       var query5 = from dayIndex in 
                         days.Select( (d, i) => new {Name = d, Index = i})
                         select dayIndex;
            foreach (var day in query5)
                Console.WriteLine($"Query5: Index={day.Index} : {day.Name}");
            Console.ReadLine();
        }
    }
}

运行完整示例会产生以下输出:

Query1: Monday
Query1: Tuesday
Query1: Wednesday
Query2: 0 : Monday
Query2: 1 : Tuesday
Query2: 2 : Wednesday
Query3: Index=0, UpperCaseDay=MONDAY
Query3: Index=1, UpperCaseDay=TUESDAY
Query3: Index=2, UpperCaseDay=WEDNESDAY
Query4: Monday
Query4: Tuesday
Query4: Wednesday
Query5: Index=0 : Mon
Query5: Index=1 : Tues
Query5: Index=2 : Wednes

再次强调,这主要取决于个人喜好,选择使用哪种形式。随着查询变长,一种形式可能比另一种形式需要更少的代码。

注意

你可以在packt.link/wKye0找到此示例使用的代码。

SelectMany

你已经看到Select如何用于从源集合中的每个项目投影值。在具有可枚举属性的源的情况下,SelectMany扩展方法可以将多个项目提取到单个列表中,然后可以可选地将其投影到新形式。

以下示例创建了两个City记录,每个记录包含多个Station名称,并使用SelectMany从两个城市中提取所有车站:

  1. 在你的Chapter04\Examples文件夹中,添加一个名为LinqSelectManyExamples.cs的新文件,并按照以下方式编辑它:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    namespace Chapter04.Examples
    {
        record City (string Name, IEnumerable<string> Stations);
        class LinqSelectManyExamples
        {
            public static void Main()
            {
                var cities = new List<City>
                {
                    new City("London", new[] {"Kings Cross KGX",                                           "Liverpool Street LVS",                                           "Euston EUS"}),
                    new City("Birmingham", new[] {"New Street NST"})
                };
                Console.WriteLine("All Stations: ");
                foreach (var station in cities.SelectMany(city => city.Stations))
                {
                    Console.WriteLine(station);
                }
    

传递给SelectManyFunc参数要求你指定一个可枚举属性,在这种情况下,是City类的Stations属性,它包含一个字符串名称列表(参见突出显示的代码)。

注意这里是如何使用快捷方式,直接将查询集成到foreach语句中。你没有修改或重用查询变量,所以单独定义它(如之前所做)没有好处。

SelectManyList<City> 变量中的所有项中提取所有车站名称。从元素 0 处的 City 类开始,其名称为 London,它将提取三个车站名称 ("Kings Cross KGX", "Liverpool Street LVS", 和 "Euston EUS")。然后它将移动到第二个 City 元素,名为 Birmingham,并提取单个车站,名为 "New Street NST"

  1. 运行示例会产生以下输出:

    All Stations:
    Kings Cross KGX
    Liverpool Street LVS
    Euston EUS
    New Street NST
    
  2. 作为替代,考虑以下代码片段。在这里,您恢复使用查询变量 stations,以使代码更容易理解:

                Console.Write("All Station Codes: ");
                var stations = cities
                    .SelectMany(city => city.Stations.Select(s => s[³..]));
                foreach (var station in stations)
                {
                    Console.Write($"{station} ");
                }
                Console.WriteLine();
                Console.ReadLine();
            }
        }
    }
    

而不是仅仅返回每个 Station 字符串,此示例使用嵌套的 Select 方法和 Range 操作符,通过 s[³..] 从车站名称中提取最后三个字符,其中 s 是每个车站名称的字符串,³ 表示 Range 操作符应该提取从字符串最后三个字符开始的字符串。

  1. 运行示例会产生以下输出:

    All Station Codes: KGX LVS EUS NST
    

您可以在输出中看到每个车站名称的最后三个字符。

注意

您可以在 packt.link/g8dXZ 找到用于此示例的代码。

在下一节中,您将了解根据条件过滤结果的过滤操作。

过滤操作

过滤操作允许您过滤结果,只返回符合特定条件的那些项。例如,考虑以下代码片段,其中包含一个订单列表:

  1. 在您的 Chapter04\Examples 文件夹中,添加一个名为 LinqWhereExample.cs 的新文件,并按以下方式编辑它:

    LinqWhereExamples.cs
    using System;
    using System.Collections.Generic;
    using System.Linq;
    namespace Chapter04.Examples
    {
        record Order (string Product, int Quantity, double Price);
        class LinqWhereExamples
        {
            public static void Main()
            {
                var orders = new List<Order>
                {
                    new Order("Pen", 2, 1.99),
                    new Order("Pencil", 5, 1.50),
                    new Order("Note Pad", 1, 2.99),
    
You can find the complete code here: https://packt.link/ZJpb5.

在这里,定义了一些用于各种文具产品的订单项。假设您想输出所有数量大于五的订单(这应该输出源中的 RulerUSB Memory Stick 订单)。

  1. 为了做到这一点,您可以添加以下代码:

                Console.WriteLine("Orders with quantity over 5:");
                foreach (var order in orders.Where(o => o.Quantity > 5))
                {
                    Console.WriteLine(order);
                }
    
  2. 现在,假设您将标准扩展到查找所有产品名称为 PenPencil 的产品。您可以将该结果链入 Select 方法,该方法将返回每个订单的总价值;请记住,Select 可以从源返回任何内容,甚至是一个简单的额外计算,如下所示:

                Console.WriteLine("Pens or Pencils:");
                foreach (var orderValue in orders
                    .Where(o => o.Product == "Pen"  || o.Product == "Pencil")
                    .Select( o => o.Quantity * o.Price))
                {
                    Console.WriteLine(orderValue);
                }
    
  3. 接下来,以下代码片段中的查询表达式使用 where 子句查找价格小于或等于 3.99 的订单。它将这些订单投影到一个具有 NameValue 属性的匿名类型中,您可以使用 foreach 语句遍历这些属性:

                var query = from order in orders
                   where order.Price <= 3.99
                   select new {Name=order.Product, Value=order.Quantity*order.Price};
                Console.WriteLine("Cheapest Orders:");
                foreach(var order in query)
                {
                    Console.WriteLine($"{order.Name}: {order.Value}");
                }
            }
        }
    }
    
  4. 运行完整示例会产生以下结果:

    Orders with quantity over 5:
    Order { Product = Ruler, Quantity = 10, Price = 0.5 }
    Order { Product = USB Memory Stick, Quantity = 6, Price = 20 }
    Pens or Pencils:
    3.98
    7.5
    Cheapest Orders:
    Pen: 3.98
    Pencil: 7.5
    Note Pad: 2.99
    Stapler: 3.99
    Ruler: 5
    

现在您已经看到了查询操作的实际应用,值得回顾延迟执行,看看它如何影响多次枚举的查询。

在下一个示例中,您有一个由车辆完成的旅程集合,这些旅程通过 TravelLog 记录填充。TravelLog 类包含一个 AverageSpeed 方法,该方法在每次执行时记录一个控制台消息,并且,正如其名称所暗示的,返回该旅程期间车辆的平均速度:

  1. 在您的 Chapter04\Examples 文件夹中,添加一个名为LinqMultipleEnumerationExample.cs的新文件,并按以下方式编辑它:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    namespace Chapter04.Examples
    {
        record TravelLog (string Name, int Distance, int Duration)
        {
            public double AverageSpeed()
            {
                Console.WriteLine($"AverageSpeed() called for '{Name}'");
                return Distance / Duration;
            }
        }
        class LinqMultipleEnumerationExample
        {
    
  2. 接下来,定义控制台应用程序的Main方法,该方法使用四个TravelLog记录填充travelLogs列表。您将为这个添加以下代码:

            public static void Main()
            {
                var travelLogs = new List<TravelLog>
                {
                    new TravelLog("London to Brighton", 50, 4),
                    new TravelLog("Newcastle to London", 300, 24),
                    new TravelLog("New York to Florida", 1146, 19),
                    new TravelLog("Paris to Berlin", 546, 10)
                };
    
  3. 现在,您将创建一个包含Where子句的fastestJourneys查询变量。当枚举时,此Where子句将调用每个行程的AverageSpeed方法。

  4. 然后,使用foreach循环遍历fastestJourneys中的项目,并将名称和距离写入控制台(注意在foreach循环中调用AverageSpeed方法):

                var fastestJourneys = travelLogs.Where(tl => tl.AverageSpeed() > 50);
                Console.WriteLine("Fastest Distances:");
                foreach (var item in fastestJourneys)
                {
                    Console.WriteLine($"{item.Name}: {item.Distance} miles");
                }
                Console.WriteLine();
    
  5. 运行代码块将产生以下输出,每个行程的NameDistance

    Fastest Distances:
    AverageSpeed() called for 'London to Brighton'
    AverageSpeed() called for 'Newcastle to London'
    AverageSpeed() called for 'New York to Florida'
    New York to Florida: 1146 miles
    AverageSpeed() called for 'Paris to Berlin'
    Paris to Berlin: 546 miles
    
  6. 您可以看到AverageSpeed被调用为Where条件。到目前为止,这是预期的,但现在,您可以使用相同的查询输出Name,或者,作为替代,输出Duration

                Console.WriteLine("Fastest Duration:");
                foreach (var item in fastestJourneys)
                {
                    Console.WriteLine($"{item.Name}: {item.Duration} hours");
                }
                Console.WriteLine();
    
  7. 运行此代码块将产生相同的AverageSpeed方法:

    Fastest Duration:
    AverageSpeed() called for 'London to Brighton'
    AverageSpeed() called for 'Newcastle to London'
    AverageSpeed() called for 'New York to Florida'
    New York to Florida: 19 hours
    AverageSpeed() called for 'Paris to Berlin'
    Paris to Berlin: 10 hours
    

这表明每当查询被枚举时,完整的查询是AverageSpeed,但如果一个方法需要访问数据库以提取一些数据怎么办?这将导致多次数据库调用,并且可能是一个非常慢的应用程序。

  1. 您可以使用ToListToArrayToDictionaryToLookupToHashSet等方法确保查询可以多次枚举,但Where子句中包含一个额外的ToList调用以立即执行查询并确保它不会被重新评估:

                Console.WriteLine("Fastest Duration Multiple loops:");
                var fastestJourneysList = travelLogs
                      .Where(tl => tl.AverageSpeed() > 50)
                      .ToList();
                for (var i = 0; i < 2; i++)
                {
                    Console.WriteLine($"Fastest Duration Multiple loop iteration {i+1}:");
                    foreach (var item in fastestJourneysList)
                    {
                        Console.WriteLine($"{item.Name}: {item.Distance} in {item.Duration} hours");
                    }
                }
            }
        }
    }
    
  2. 运行代码块将产生以下输出。注意AverageSpeed是如何在Fastest Duration Multiple loop iteration消息中调用的:

    Fastest Duration Multiple loops:
    AverageSpeed() called for 'London to Brighton'
    AverageSpeed() called for 'Newcastle to London'
    AverageSpeed() called for 'New York to Florida'
    AverageSpeed() called for 'Paris to Berlin'
    Fastest Duration Multiple loop iteration 1:
    New York to Florida: 1146 in 19 hours
    Paris to Berlin: 546 in 10 hours
    Fastest Duration Multiple loop iteration 2:
    New York to Florida: 1146 in 19 hours
    Paris to Berlin: 546 in 10 hours
    

注意,从车辆行程集合中,代码返回了车辆在行程中的平均速度。

注意

您可以在packt.link/CIZJE找到此示例使用的代码。

排序操作

在源中对项目进行排序有五种操作。项目主要按顺序排序,之后可以跟一个可选的次要排序,该排序对主要组内的项目进行排序。例如,您可以使用主要排序按City属性对人员列表进行排序,然后使用次要排序进一步按Surname属性对它们进行排序:

  • OrderBy: 按升序排列值。

  • OrderByDescending: 按降序排列值。

  • ThenBy: 将主要排序的值按次要升序排序。

  • ThenByDescending: 将主要排序的值按次要降序排序。

  • Reverse: 简单地返回一个集合,其中源中元素的顺序被反转。没有等效的表达式。

OrderBy 和 OrderByDescending

在此示例中,您将使用System.IO命名空间查询宿主机的temp文件夹中的文件,而不是从列表中创建小对象。

静态Directory类提供了可以查询文件系统的功能。FileInfo检索有关特定文件的信息,例如其大小或创建日期。Path.GetTempPath方法返回系统的temp文件夹。为了说明这一点,在 Windows 操作系统中,这通常可以在C:\Users\username\AppData\Local\Temp找到,其中username是特定的 Windows 登录名。对于其他用户和其他系统,这将是不同的:

  1. 在您的Chapter04\Examples文件夹中,添加一个名为LinqOrderByExamples.cs的新文件,并按照以下方式编辑它:

    using System;
    using System.IO;
    using System.Linq;
    namespace Chapter04.Examples
    {
        class LinqOrderByExamples
        {
            public static void Main()
            {
    
  2. 使用Directory.EnumerateFiles方法在temp文件夹中查找所有具有.tmp扩展名的文件名:

                var fileInfos = Directory.EnumerateFiles(Path.GetTempPath(), "*.tmp")
                    .Select(filename => new FileInfo(filename))
                    .ToList();
    

在这里,每个文件名被投影到一个FileInfo实例中,并通过ToList链接到一个已填充的集合中,这允许您进一步查询结果fileInfos的详细信息。

  1. 接下来,使用OrderBy方法通过比较文件的CreationTime属性来按最早的时间排序文件:

                Console.WriteLine("Earliest Files");
                foreach (var fileInfo in fileInfos.OrderBy(fi => fi.CreationTime))
                {
                    Console.WriteLine($"{fileInfo.CreationTime:dd MMM yy}: {fileInfo.Name}");
                }
    
  2. 要找到最大的文件,重新查询fileInfos,并使用OrderByDescending按每个文件的Length属性排序:

                Console.WriteLine("Largest Files");
                foreach (var fileInfo in fileInfos                                        .OrderByDescending(fi => fi.Length))
                {
                    Console.WriteLine($"{fileInfo.Length:N0} bytes: \t{fileInfo.Name}");
                }
    
  3. 最后,使用whereorderby降序表达式找到小于1,000字节的长度的大文件:

                Console.WriteLine("Largest smaller files");
                foreach (var fileInfo in
                    from fi in fileInfos
                    where fi.Length < 1000
                    orderby fi.Length descending
                    select fi)
                {
                    Console.WriteLine($"{fileInfo.Length:N0} bytes: \t{fileInfo.Name}");
                }
                Console.ReadLine();
            }
        }
    }
    
  4. 根据您的temp文件夹中的文件,您应该看到如下输出:

    Earliest Files
    05 Jan 21: wct63C3.tmp
    05 Jan 21: wctD308.tmp
    05 Jan 21: wctFE7.tmp
    04 Feb 21: wctE092.tmp
    Largest Files
    38,997,896 bytes:       wctE092.tmp
    4,824,572 bytes:        cb6dfb76-4dc9-494d-9683-ce31eab43612.tmp
    4,014,036 bytes:        492f224c-c811-41d6-8c5d-371359d520db.tmp
    Largest smaller files
    726 bytes:      wct38BC.tmp
    726 bytes:      wctE239.tmp
    512 bytes:      ~DF8CE3ED20D298A9EC.TMP
    416 bytes:      TFR14D8.tmp
    

在这个示例中,您查询了宿主机的temp文件夹中的文件,而不是从列表中创建小对象。

注意

您可以在packt.link/mWeVC找到用于此示例的代码。

然后是 ThenBy 和 ThenByDescending

以下示例根据每个引用中找到的单词数量对流行引用进行排序。

在您的Chapter04\Examples文件夹中,添加一个名为LinqThenByExamples.cs的新文件,并按照以下方式编辑它:

using System;
using System.IO;
using System.Linq;
namespace Chapter04.Examples
{
    class LinqThenByExamples
    {
        public static void Main()
        {

您首先声明一个引用字符串数组,如下所示:

            var quotes = new[]
            {
                "Love for all hatred for none",
                "Change the world by being yourself",
                "Every moment is a fresh beginning",
                "Never regret anything that made you smile",
                "Die with memories not dreams",
                "Aspire to inspire before we expire"
            };

在下一个片段中,每个字符串引用都被投影到一个基于引用中单词数量(使用String.Split()找到)的新匿名类型中。项目首先按降序排序以显示单词最多的项目,然后按字母顺序排序:

            foreach (var item in quotes
                .Select(q => new {Quote = q, Words = q.Split(" ").Length})
                .OrderByDescending(q => q.Words)
                .ThenBy(q => q.Quote))
            {
                Console.WriteLine($"{item.Words}: {item.Quote}");
            }
            Console.ReadLine();
        }
    }
}

运行代码按单词计数顺序列出引用,如下所示:

7: Never regret anything that made you smile
6: Aspire to inspire before we expire
6: Change the world by being yourself
6: Every moment is a fresh beginning
6: Love for all hatred for none
5: Die with memories not dreams

注意具有六个单词的引用是如何按字母顺序显示的。

以下(突出显示的代码)是带有orderby quote.Words descending的等效查询表达式,后面跟着quote.Words升序子句:

var query = from quote in 
            (quotes.Select(q => new {Quote = q, Words = q.Split(" ").Length}))
orderby quote.Words descending, quote.Words ascending 
            select quote;
foreach(var item in query)        
            {
                Console.WriteLine($"{item.Words}: {item.Quote}");
            }
            Console.ReadLine();
        }
    }
}

注意

您可以在packt.link/YWJRz找到用于此示例的代码。

现在您已经根据每个引用中找到的单词数量对流行引用进行了排序。现在是时候应用在下一个练习中学到的技能了。

练习 4.03:按大陆和面积过滤国家列表

在前面的示例中,您已经看到了可以选择、过滤和排序集合源的代码。现在,您将把这些结合到一个练习中,该练习通过两个大陆(南美洲和非洲)过滤一个小国家列表,并按地理面积排序结果。

执行以下步骤来完成此操作:

  1. 在您的Chapter04\Exercises文件夹中,创建一个新的Exercise03文件夹。

  2. Exercise03文件夹中添加一个名为Program.cs的新类。

  3. 首先,添加一个Country记录,它将传递国家的Name、它所属的Continent以及其平方英里的Area

    using System;
    using System.Linq;
    namespace Chapter04.Exercises.Exercise03
    {
        class Program
        {
            record Country (string Name, string Continent, int Area);
            public static void Main()
            {
    
  4. 现在创建一个小的国家数据子集,定义在数组中,如下所示:

                var countries = new[]
                {
                    new Country("Seychelles", "Africa", 176),
                    new Country("India", "Asia", 1_269_219),
                    new Country("Brazil", "South America",3_287_956),
                    new Country("Argentina", "South America", 1_073_500),
                    new Country("Mexico", "South America",750_561),
                    new Country("Peru", "South America",494_209),
                    new Country("Algeria", "Africa", 919_595),
                    new Country("Sudan", "Africa", 668_602)
                };
    

该数组包含一个国家的名称、它所属的大陆以及其平方英里的地理大小。

  1. 您的搜索条件必须包括南美洲非洲。因此,请将它们定义在数组中,而不是使用两个特定的字符串硬编码where子句:

                var requiredContinents = new[] {"South America", "Africa"};
    

如果您需要修改它,这提供了额外的代码灵活性。

  1. 通过按大陆过滤和排序、按面积排序以及使用.Select扩展方法(该方法返回Indexitem值)来构建查询:

                var filteredCountries = countries
                    .Where(c => requiredContinents.Contains(c.Continent))
                    .OrderBy(c => c.Continent)
                    .ThenByDescending(c => c.Area)
                    .Select( (cty, i) => new {Index = i, Country = cty});
    
                foreach(var item in filteredCountries)
                    Console.WriteLine($"{item.Index+1}: {item.Country.Continent}, {item.Country.Name} = {item.Country.Area:N0} sq mi");
            }
        }
    }
    

最后,将每个项目投影到一个新的匿名类型中,以便写入控制台。

  1. 运行代码块产生以下结果:

    1: Africa, Algeria = 919,595 sq mi
    2: Africa, Sudan = 668,602 sq mi
    3: Africa, Seychelles = 176 sq mi
    4: South America, Brazil = 3,287,956 sq mi
    5: South America, Argentina = 1,073,500 sq mi
    6: South America, Mexico = 750,561 sq mi
    7: South America, Peru = 494,209 sq mi
    

注意,阿尔及利亚非洲拥有最大的面积,而巴西南美洲拥有最大的面积(基于这个小的数据子集)。注意您是如何为每个Index添加1以提高可读性的(因为从零开始对用户来说不太友好)。

注意

您可以在packt.link/Djddw找到用于此练习的代码。

您已经看到了如何使用 LINQ 扩展方法访问数据源中的项。现在,您将学习关于分区数据的内容,这可以用于提取项目子集。

分区操作

到目前为止,您已经看到了如何过滤数据源中匹配定义条件的项。分区用于您需要将数据源分成两个不同的部分并返回这两个部分中的任意一个以进行后续处理。

例如,假设您有一个按价值排序的车辆列表,并想使用某种方法处理五个最便宜的车辆。如果列表是升序排序的,那么您可以使用Take(5)方法(如下文定义)来分区数据,这将提取前五个项并丢弃剩余的项。

有六个分区操作用于分割源数据,返回两个部分中的任意一个。没有分区查询表达式:

  • Skip: 返回一个跳过源序列中指定数字位置的项的集合。当您需要跳过源集合中的前 N 个项时使用。

  • SkipLast: 返回一个跳过源序列中最后 N 个项的集合。

  • SkipWhile: 返回一个跳过源序列中满足指定条件的项的集合。

  • Take: 返回一个包含序列中前 N 个项的集合。

  • TakeLast: 返回一个包含序列中最后 N 个项的集合。

  • TakeWhile: 返回一个只包含满足指定条件的项的集合。

以下示例演示了在未排序的考试等级列表上进行的各种 SkipTake 操作。在这里,你使用 Skip(1) 来忽略排序列表中的最高等级。

  1. 在你的 Chapter04\Examples 文件夹中,添加一个名为 LinqSkipTakeExamples.cs 的新文件,并按照以下方式编辑它:

    using System;
    using System.Linq;
    namespace Chapter04.Examples
    {
        class LinqSkipTakeExamples
        {
            public static void Main()
            {
                var grades = new[] {25, 95, 75, 40, 54, 9, 99};
                Console.Write("Skip: Highest Grades (skipping first):");
                foreach (var grade in grades
                    .OrderByDescending(g => g)
                    .Skip(1))
                {
                    Console.Write($"{grade} ");
                }
                Console.WriteLine();
    
  2. 接下来,使用关系运算符 is 来排除小于 25 或大于 75 的那些:

                Console.Write("SkipWhile@ Middle Grades (excluding 25 or 75):");
                foreach (var grade in grades
                    .OrderByDescending(g => g)
                    .SkipWhile(g => g is <= 25 or >=75))
                {
                    Console.Write($"{grade} ");
                }
                Console.WriteLine();
    
  3. 通过使用 SkipLast,你可以显示结果的后半部分。添加以下代码:

                Console.Write("SkipLast: Bottom Half Grades:");
                foreach (var grade in grades
                    .OrderBy(g => g)
                    .SkipLast(grades.Length / 2))
                {
                    Console.Write($"{grade} ");
                }
                Console.WriteLine();
    
  4. 最后,这里使用 Take(2) 来显示两个最高等级:

                Console.Write("Take: Two Highest Grades:");
                foreach (var grade in grades
                    .OrderByDescending(g => g)
                    .Take(2))
                {
                    Console.Write($"{grade} ");
                }
            }
        }
    }
    
  5. 运行示例产生以下输出,这是预期的:

    Skip: Highest Grades (skipping first):95 75 54 40 25 9
    SkipWhile Middle Grades (excluding 25 or 75):54 40 25 9
    SkipLast: Bottom Half Grades:9 25 40 54
    Take: Two Highest Grades:99 95
    

此示例演示了在未排序的考试等级列表上进行的各种 SkipTake 操作。

注意

你可以在 packt.link/TsDFk 找到此示例使用的代码。

分组操作

GroupBy 对具有相同属性的元素进行分组。它通常用于对数据进行分组或按公共属性对项目计数。结果是类型为 IGrouping<K, V> 的可枚举集合,其中 K 是键类型,V 是指定的值类型。IGrouping 本身也是可枚举的,因为它包含所有匹配指定键的项。

例如,考虑以下代码片段,它按名称对客户订单的 List 进行分组。在你的 Chapter04\Examples 文件夹中,添加一个名为 LinqGroupByExamples.cs 的新文件,并按照以下方式编辑它:

LinqGroupByExamples.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Chapter04.Examples
{
    record CustomerOrder(string Name, string Product, int Quantity);
    class LinqGroupByExamples
    {
        public static void Main()
        {
            var orders = new List<CustomerOrder>
            {
                new CustomerOrder("Mr Green", "LED TV", 4),
                new CustomerOrder("Mr Smith", "iPhone", 2),
                new CustomerOrder("Mrs Jones", "Printer", 1),
You can find the complete code here: https://packt.link/GbwF2.

在此示例中,你有一个 CustomerOrder 对象的列表,并希望按 Name 属性对其进行分组。为此,GroupBy 方法传递一个 Func 委托,该委托从每个 CustomerOrder 实例中选择 Name 属性。

GroupBy 结果中的每一项都包含一个 Key(在这种情况下,是客户的 Name)。然后你可以对分组项进行排序,以显示按 Quantity 排序的 CustomerOrders 项,如下所示:

                foreach (var item in grouping.OrderByDescending(i => i.Quantity))
                {
                    Console.WriteLine($"\t{item.Product} * {item.Quantity}");
                }
            }
            Console.ReadLine();
        }
    }
}

运行代码产生以下输出:

Customer Mr Green:
        LED TV * 4
        MP3 Player * 1
        Microwave Oven * 1
Customer Mr Smith:
        PC * 5
        iPhone * 2
        Printer * 2
Customer Mrs Jones:
        Printer * 1

你可以看到数据首先按客户 Name 进行分组,然后在每个客户分组内按订单 Quantity 排序。等效的查询表达式如下所示:

            var query = from order in orders
                        group order by order.Name;
            foreach (var grouping in query)
            {
                Console.WriteLine($"Customer {grouping.Key}:");
                foreach (var item in from item in grouping 
                                     orderby item.Quantity descending 
                                     select item)
                {
                    Console.WriteLine($"\t{item.Product} * {item.Quantity}");
                }
            }

你现在已经看到了一些常用的 LINQ 操作符。现在你将在练习中将它们组合起来。

练习 4.04:在书中查找最常用的单词

第三章委托、事件和 Lambda 表达式 中,你使用了 WebClient 类从网站下载数据。在这个练习中,你将使用从 Project Gutenberg 下载的数据。

注意

Project Gutenberg 是一个包含 60,000 本免费电子书的图书馆。你可以在网上搜索 Project Gutenberg 或访问 www.gutenberg.org/

你将创建一个控制台应用程序,允许用户输入一个 URL。然后,你将从 Project Gutenberg URL 下载书籍文本,并使用各种 LINQ 语句来查找书籍文本中最频繁出现的单词。

此外,你还想排除一些常见的停用词;这些词如andorthe在英语中经常出现,但对句子的意义贡献很小。你将使用Regex.Split方法来帮助比简单的空格分隔符更准确地分割单词。执行以下步骤来完成此操作:

注意

你可以在packt.link/v4hGN上找到有关正则表达式的更多信息。

  1. 在你的Chapter04\Exercises文件夹中,创建一个新的Exercise04文件夹。

  2. Exercise04文件夹中添加一个名为Program.cs的新类。

  3. 首先,定义TextCounter类。这个类将传入文件的路径,你很快就会添加。这个类应该包含常见的英语停用词:

    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Net;
    using System.Text;
    using System.Text.RegularExpressions;
    namespace Chapter04.Exercises.Exercise04
    {
        class TextCounter
        {
            private readonly HashSet<string> _stopWords;
            public TextCounter(string stopWordPath)
            {
                Console.WriteLine($"Reading stop word file: {stopWordPath}");
    
  4. 使用File.ReadAllLines将每个单词添加到_stopWords HashSet中。

              _stopWords = new HashSet<string>(File.ReadAllLines(stopWordPath));
            }
    

你已经使用了HashSet,因为每个停用词都是唯一的。

  1. 接下来,将包含书籍文本的字符串和要显示的最大单词数传递给Process方法。

  2. 将结果作为Tuple<string, int>集合返回,这样你就不需要创建一个类或记录来保存结果:

            public IEnumerable<Tuple<string, int>> Process(string text,                                                        int maximumWords)
            {
    
  3. 现在执行查询部分。使用Regex.Split和模式@"\s+"来分割所有单词。

在最简单的形式中,这个模式将字符串分割成单词列表,通常使用空格或标点符号来识别单词边界。例如,字符串Hello Goodbye将被分割成一个包含两个元素的数组,HelloGoodbye。返回的字符串项通过where进行过滤,以确保使用Contains方法忽略所有停用词。然后,单词按值分组,GroupBy(t=>t),使用单词作为Key,并使用grp.Count来获取出现的次数:

  1. 最后,按Item2排序,对于这个Tuple来说,它是单词计数,然后只取所需数量的单词:

                var words = Regex.Split(text.ToLower(), @"\s+")
                    .Where(t => !_stopWords.Contains(t))
                    .GroupBy(t => t)
                    .Select(grp => Tuple.Create(grp.Key, grp.Count()))
                    .OrderByDescending(tup => tup.Item2) //int
                    .Take(maximumWords);
                return words;
            }
        }
    
  2. 现在开始创建主控制台应用程序:

        class Program
        {
            public static void Main()
            {
    
  3. Chapter04源文件夹中包含一个名为StopWords.txt的文本文件:

                const string StopWordFile = "StopWords.txt";
                var counter = new TextCounter(StopWordFile);
    

    注意

    你可以在 GitHub 上找到StopWords.txt,网址为packt.link/Vi8JH,或者你可以下载任何标准的停用词文件,例如 NLTK 的packt.link/ZF1Tf。这个文件应该保存在Chapter04\Exercises文件夹中。

  4. 一旦创建了TextCounter,提示用户输入一个 URL:

                string address;
                do
                {
                    //https://www.gutenberg.org/files/64333/64333-0.txt
                    Console.Write("Enter a Gutenberg book URL: ");
                    address = Console.ReadLine();
                    if (string.IsNullOrEmpty(address)) 
                        continue;
    
  5. 输入一个有效的地址并创建一个新的WebClient实例,将数据文件下载到临时文件中。

  6. 在将文本文件的内容传递给TextCounter之前,对文本文件进行额外处理:

                    using var client = new WebClient();
                    var tempFile = Path.GetTempFileName();
                    Console.WriteLine("Downloading...");
                    client.DownloadFile(address, tempFile);
    

古腾堡文本文件包含额外的细节,如作者和标题。这些可以通过读取文件中的每一行来读取。实际的书本文本直到找到以*** START OF THE PROJECT GUTENBERG EBOOK开头的行才开始,因此你需要读取每一行以寻找这个起始信息:

                Console.WriteLine($"Processing file {tempFile}");
                const string StartIndicator = "*** START OF THE PROJECT GUTENBERG EBOOK";
                //Title: The Little Review, October 1914(Vol. 1, No. 7)
                //Author: Various
                var title = string.Empty;
                var author = string.Empty;
  1. 接下来,将读取到的每一行追加到StringBuilder实例中,这对于此类字符串操作来说效率很高:

                    var bookText = new StringBuilder();
                    var isReadingBookText = false;
                    var bookTextLineCount = 0;
    
  2. 现在解析tempFile中的每一行,寻找AuthorTitleStartIndicator

                    foreach (var line in File.ReadAllLines(tempFile))
                    {
                        if (line.StartsWith("Title"))
                        {
                            title = line;
                        }
                        else if (line.StartsWith("Author"))
                        {
                            author = line;
                        }
                        else if (line.StartsWith(StartIndicator))
                        {
                            isReadingBookText = true;
                        }
                        else if (isReadingBookText)
                        {
                            bookText.Append(line);
                            bookTextLineCount++;
                        }
                    }
    
  3. 如果找到书籍文本,在调用counter.Process方法之前提供读取的行和字符的摘要。这里,你想要前50个单词:

                    if (bookTextLineCount > 0)
                    {
                        Console.WriteLine($"Processing {bookTextLineCount:N0} lines ({bookText.Length:N0} characters)..");
                      var wordCounts = counter.Process(bookText.ToString(), 50);
                      Console.WriteLine(title);
                      Console.WriteLine(author);
    
  4. 一旦你得到结果,使用一个foreach循环来输出单词计数详情,在每三个单词后添加一个空白行:

                        var i = 0;
                        //deconstruction
                        foreach (var (word, count) in wordCounts)
                        {
                            Console.Write($"'{word}'={count}\t\t");
                            i++;
                            if (i % 3 == 0)
                            {
                                Console.WriteLine();
                            }
                        }
                        Console.WriteLine();
                    }
                    else
                    {
    
  5. 使用https://www.gutenberg.org/files/64333/64333-0.txt作为示例 URL 运行控制台应用程序,会产生以下输出:

    Reading stop word file: StopWords.txt
    Enter a Gutenberg book URL: https://www.gutenberg.org/files/64333/64333-0.txt
    Downloading...
    Processing file C:\Temp\tmpB0A3.tmp
    Processing 4,063 lines (201,216 characters)..
    Title: The Little Review, October 1914 (Vol. 1, No. 7)
    Author: Various
    'one'=108               'new'=95                'project'=62
    'man'=56                'little'=54             'life'=52
    'would'=51              'work'=50               'book'=42
    'must'=42               'people'=39             'great'=37
    'love'=37               'like'=36               'gutenberg-tm'=36
    'may'=35                'men'=35                'us'=32
    'could'=30              'every'=30              'first'=29
    'full'=29               'world'=28              'mr.'=28
    'old'=27                'never'=26              'without'=26
    'make'=26               'young'=24              'among'=24
    'modern'=23             'good'=23               'it.'=23
    'even'=22               'war'=22                'might'=22
    'long'=22               'cannot'=22             '_the'=22
    'many'=21               'works'=21              'electronic'=21
    'always'=20             'way'=20                'thing'=20
    'day'=20                'upon'=20               'art'=20
    'terms'=20              'made'=19
    

    注意

    当代码第一次运行时,Visual Studio 可能会显示以下警告:warning SYSLIB0014: 'WebClient.WebClient()' is obsolete: 'WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.'

    这是一个建议使用较新的HttpClient类而不是WebClient类的推荐。然而,两者在功能上是等效的。

输出显示在下载的4,063行文本中找到的单词列表。计数器显示onenewproject是最受欢迎的单词。注意mr.gutenberg-tmit._the如何作为单词出现。这表明在分割单词时使用的正则表达式并不完全准确。

注意

你可以在packt.link/Q7Pf8找到用于此练习的代码。

对这个练习的一个有趣的增强是按计数对单词进行排序,包括找到的停用词的数量,或者找到平均单词长度。

聚合操作

聚合操作用于从数据源中的值集合计算单个值。一个例子可以是收集一个月的降雨量中的最大值、最小值和平均值。

  • Average:计算集合中的平均值。

  • Count:计算匹配谓词的项的数量。

  • Max:计算最大值。

  • Min:计算最小值。

  • Sum:计算值的总和。

以下示例使用System.Diagnostics命名空间中的Process.GetProcess方法来检索系统上当前正在运行的进程列表:

在你的Chapter04\Examples文件夹中,添加一个名为LinqAggregationExamples.cs的新文件,并按以下方式编辑它:

using System;
using System.Diagnostics;
using System.Linq;
namespace Chapter04.Examples
{
    class LinqAggregationExamples
    {
        public static void Main()
        {

首先,调用Process.GetProcesses().ToList()来检索系统上正在运行的活动的进程列表:

            var processes = Process.GetProcesses().ToList();

然后,Count扩展方法获取返回项的数量。Count有一个额外的重载,它接受一个Func委托,用于过滤要计数的每个项。Process类有一个PrivateMemorySize64属性,它返回进程当前消耗的字节数,因此你可以用它来计数1,000,000字节的内存:

            var allProcesses = processes.Count;
            var smallProcesses = processes.Count(proc =>                                        proc.PrivateMemorySize64 < 1_000_000);

接着,Average扩展方法返回processes列表中所有项的特定值的总体平均值。在这种情况下,你使用它来计算平均内存消耗,再次使用PrivateMemorySize64属性:

            var average = processes.Average(p => p.PrivateMemorySize64);

PrivateMemorySize64属性还用于计算所有进程的最大和最小内存使用量,以及总内存,如下所示:

            var max = processes.Max(p => p.PrivateMemorySize64);
            var min = processes.Min(p => p.PrivateMemorySize64);
            var sum = processes.Sum(p => p.PrivateMemorySize64);

计算完统计数据后,每个值都会写入控制台:

            Console.WriteLine("Process Memory Details");
            Console.WriteLine($"  All Count: {allProcesses}");
            Console.WriteLine($"Small Count: {smallProcesses}");
            Console.WriteLine($"    Average: {FormatBytes(average)}");
            Console.WriteLine($"    Maximum: {FormatBytes(max)}");
            Console.WriteLine($"    Minimum: {FormatBytes(min)}");
            Console.WriteLine($"      Total: {FormatBytes(sum)}");
        }

在前面的代码片段中,Count方法返回所有进程的数量,并使用Predicate重载,计算内存小于 1,000,000 字节的进程数量(通过检查process.PrivateMemorySize64属性)。您还可以看到,AverageMaxMinSum用于计算系统上进程内存使用的统计数据。

注意

如果尝试使用不包含任何元素的源集合进行计算,聚合运算符将抛出InvalidOperationException错误,错误信息为Sequence contains no elements。在调用任何聚合运算符之前,您应该检查CountAny方法。

最后,FormatBytes将内存量格式化为它们的兆字节等效值:

        private static string FormatBytes(double bytes)
        {
            return $"{bytes / Math.Pow(1024, 2):N2} MB";
        }
    }
}

运行示例会产生类似以下的结果:

Process Memory Details
  All Count: 305
Small Count: 5
    Average: 38.10 MB
    Maximum: 1,320.16 MB
    Minimum: 0.06 MB
      Total: 11,620.03 MB

从输出中,您将观察到程序如何检索系统上当前正在运行的进程列表。

注意

您可以在Chapter04\Examples文件夹中找到此示例使用的代码,链接为packt.link/HI2eV

量词操作

量词操作返回一个bool值,指示是否满足Predicate条件。这通常用于验证集合中的任何元素是否满足某些标准,而不是依赖于Count,后者会枚举集合中的所有项,即使您只需要一个结果。

使用以下扩展方法访问量词操作:

  • All:如果源序列中的所有元素都匹配条件,则返回true

  • Any:如果源序列中的任何元素匹配条件,则返回true

  • Contains:如果源序列包含指定的项,则返回true

以下发牌示例随机选择三张牌,并返回所选牌的摘要。摘要使用AllAny扩展方法来确定是否有任何牌是梅花或红色,以及所有牌是否是钻石或偶数:

  1. 在您的Chapter04\Examples文件夹中,添加一个名为LinqAllAnyExamples.cs的新文件。

  2. 首先,声明一个enum来表示一副扑克牌中的四种花色,以及一个record类来定义一张扑克牌:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    namespace Chapter04.Examples
    {
        enum PlayingCardSuit
        {
            Hearts,
            Clubs,
            Spades,
            Diamonds
        }
        record PlayingCard (int Number, PlayingCardSuit Suit)
        {
    
  3. 在运行时描述对象状态的常用做法是重写ToString方法,以提供一种用户友好的方式。在这里,牌的数字和花色以字符串的形式返回:

            public override string ToString()
            {
                return $"{Number} of {Suit}";
            }
        }
    
  4. 现在创建一个类来表示一副牌(为了方便,只创建编号为 1 到 10 的牌)。牌组的构造函数将使用每种花色的 10 张牌填充_cards集合:

        class Deck
        {
            private readonly List<PlayingCard> _cards = new();
            private readonly Random _random = new();
            public Deck()
            {
                for (var i = 1; i <= 10; i++)
                {
                    _cards.Add(new PlayingCard(i, PlayingCardSuit.Hearts));
                    _cards.Add(new PlayingCard(i, PlayingCardSuit.Clubs));
                    _cards.Add(new PlayingCard(i, PlayingCardSuit.Spades));
                    _cards.Add(new PlayingCard(i, PlayingCardSuit.Diamonds));
                }
            }
    
  5. 接下来,Draw方法从_cards列表中随机选择一张牌,并在返回给调用者之前将其移除:

            public PlayingCard Draw()
            {
                var index = _random.Next(_cards.Count);
                var drawnCard = _cards.ElementAt(index);
                _cards.Remove(drawnCard);
                return drawnCard;
            }
        }
    
  6. 控制台应用程序使用牌组的Draw方法选择三张牌。按照以下方式添加此代码:

        class LinqAllAnyExamples
        {
            public static void Main()
            {
                var deck = new Deck();
                var hand = new List<PlayingCard>();
    
                for (var i = 0; i < 3; i++)
                {
                    hand.Add(deck.Draw());
                }
    
  7. 要显示摘要,使用OrderByDescendingSelect操作提取每个PlayingCard的用户友好ToString描述。然后将其连接成一个分隔的字符串,如下所示:

                var summary = string.Join(" | ", 
                    hand.OrderByDescending(c => c.Number)
                        .Select(c => c.ToString()));
                Console.WriteLine($"Hand: {summary}");
    
  8. 使用AllAny,您可以通过卡片的数字总和来概述卡片及其分数。通过使用Any,您确定是否PlayingCardSuit.Clubs

                Console.WriteLine($"Any Clubs: {hand.Any(card => card.Suit == PlayingCardSuit.Clubs)}");
    
  9. 类似地,Any用于查看是否是HeartsDiamonds花色,因此是Red

                Console.WriteLine($"Any Red: {hand.Any(card => card.Suit == PlayingCardSuit.Hearts || card.Suit == PlayingCardSuit.Diamonds)}");
    
  10. 在下一个代码片段中,All扩展方法检查集合中的每个项目,并返回true,在这种情况下,如果Diamonds

                Console.WriteLine($"All Diamonds: {hand.All(card => card.Suit == PlayingCardSuit.Diamonds)}");
    
  11. 再次使用All来查看是否所有卡片号码都能被 2 整除,即它们是偶数:

                Console.WriteLine($"All Even: {hand.All(card => card.Number % 2 == 0)}");
    
  12. 最后,使用Sum聚合方法计算手中的卡片价值:

                Console.WriteLine($"Score :{hand.Sum(card => card.Number)}");
            }
        }
    }
    
  13. 运行控制台应用程序会产生如下输出:

    Hand: 8 of Spades | 7 of Diamonds | 6 of Diamonds
    Any Clubs: False
    Any Red: True
    All Diamonds: False
    All Even: False
    Score :21
    

卡片是随机选择的,所以每次运行程序时您都会有不同的手牌。在这个例子中,得分是21,这在纸牌游戏中通常是一个获胜的手牌。

注意

您可以在packt.link/xPuTc找到此示例使用的代码。

连接操作

连接操作用于根据一个数据源中对象与第二个数据源中具有共同属性的关联来连接两个源。如果您熟悉数据库设计,这可以被视为表之间的主键和外键关系。

一个常见的连接示例是单向关系,例如Orders,它有一个类型为Products的属性,但Products类没有表示到Orders集合的逆向关系的集合属性。通过使用Join运算符,您可以创建逆向关系以显示ProductsOrders

以下两个连接扩展方法如下:

  • Join: 使用键选择器将两个序列连接起来,以提取值对。

  • GroupJoin: 使用键选择器将两个序列连接起来,并将结果项分组。

以下示例包含三个Manufacturer记录,每个记录都有一个唯一的ManufacturerId。这些数字 ID 用于定义各种Car记录,但为了节省内存,您将不会从Manufacturer直接到Car的内存引用。您将使用Join方法在ManufacturerCar实例之间创建关联:

  1. 在您的Chapter04\Examples文件夹中,添加一个名为LinqJoinExamples.cs的新文件。

  2. 首先,声明ManufacturerCar记录如下:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    namespace Chapter04.Examples
    {
        record Manufacturer(int ManufacturerId, string Name);
        record Car (string Name, int ManufacturerId);
    
  3. Main入口点内部,创建两个列表,一个用于制造商,另一个用于表示cars

    LinqJoinExamples.cs
        class LinqJoinExamples
        {
            public static void Main()
            {
                var manufacturers = new List<Manufacturer>
                {
                    new(1, "Ford"),
                    new(2, "BMW"),
                    new(3, "VW")
                };
                var cars = new List<Car>
                {
                    new("Focus", 1),
                    new("Galaxy", 1),
                    new("GT40", 1),
    
You can find the complete code here: https://packt.link/Ue7Fj.
  1. 到目前为止,没有直接的引用,但如您所知,您可以使用ManufacturerId通过int ID 将它们链接起来。您可以为此添加以下代码:

                var joinedQuery = manufacturers.Join(
                    cars,
                    manufacturer => manufacturer.ManufacturerId,
                    car => car.ManufacturerId,
                    (manufacturer, car) => new                        {ManufacturerName = manufacturer.Name,                         CarName = car.Name});
                foreach (var item in joinedQuery)
                {
                    Console.WriteLine($"{item}");
                }
            }
        }
    }
    

在前面的代码片段中,Join操作有多种参数。你传入cars列表并定义在manufacturercar类中应该使用哪些属性来创建连接。在这种情况下,manufacturer.ManufacturerId = car.ManufacturerId确定了正确的连接。

最后,manufacturercar参数返回一个包含manufacturer.Namecar.Name属性的新匿名类型。

  1. 运行控制台应用程序会产生以下输出:

    { ManufacturerName = Ford, CarName = Focus }
    { ManufacturerName = Ford, CarName = Galaxy }
    { ManufacturerName = Ford, CarName = GT40 }
    { ManufacturerName = BMW, CarName = 1 Series }
    { ManufacturerName = BMW, CarName = 2 Series }
    { ManufacturerName = VW, CarName = Golf }
    { ManufacturerName = VW, CarName = Polo }
    

如你所见,每个CarManufacturer实例都使用ManufacturerId正确连接。

  1. 相等的查询表达式如下(注意,在这种情况下,它比查询运算符语法更简洁):

    var query = from manufacturer in manufacturers
                join car in cars
                  on manufacturer.ManufacturerId equals car.ManufacturerId
                  select new
                  {
                    ManufacturerName = manufacturer.Name, CarName = car.Name
                  };
    foreach (var item in query)
    {
      Console.WriteLine($"{item}");
    }
    

    注意

    你可以在packt.link/Wh8jK找到用于此示例的代码。

在你完成对 LINQ 的探索之前,还有一个与 LINQ 查询表达式相关的区域——let子句。

在查询表达式中使用 let 子句

在早期的查询表达式中,你通常需要在各种子句中重复类似的外观代码。使用let子句,你可以在表达式查询内部引入新变量,并在查询的其余部分重用变量的值。例如,考虑以下查询:

var stations = new List<string>
{
    "Kings Cross KGX", 
    "Liverpool Street LVS", 
    "Euston EUS", 
    "New Street NST"
};
var query1 = from station in stations
             where station[³..] == "LVS" || station[³..] == "EUS" || 
                   station[0..³].Trim().ToUpper().EndsWith("CROSS")
             select new { code= station[³..],                           name= station[0..³].Trim().ToUpper()};

这里,你正在搜索具有LVSEUS代码或以CROSS结尾的名称的站点。为此,你必须使用范围提取最后三个字符,station[³..],但你已经在两个where子句和最终的投影中重复了它。

可以使用let子句将站点代码和站点名称都转换为局部变量:

var query2 = from station in stations
             let code = station[³..]
             let name = station[0..³].Trim().ToUpper()
             where code == "LVS" || code == "EUS" || 
                   name.EndsWith("CROSS") 
             select new {code, name};

这里,你使用let子句定义了codename,并在整个查询中重用了它们。这段代码看起来更整洁,也更易于理解和维护。

运行代码会产生以下输出:

Station Codes: 
KGX : KINGS CROSS
LVS : LIVERPOOL STREET
EUS : EUSTON
Station Codes (2):
KGX : KINGS CROSS
LVS : LIVERPOOL STREET
EUS : EUSTON

注意

你可以在packt.link/b2KiG找到用于此示例的代码。

到现在为止,你已经看到了 LINQ 的主要部分。现在你将把这些部分组合到一个活动中,该活动根据用户的准则过滤一组飞行记录,并对找到的飞行子集提供各种统计数据。

活动 4.01:国库飞行数据分析

你被要求创建一个控制台应用程序,允许用户下载公开可用的飞行数据文件,并对这些文件进行统计分析。这种分析应用于计算找到的总记录数,以及在该子集中支付的平均、最小和最大票价。

用户应该能够输入多个命令,并且每个命令都应该基于飞行类别、起点或目的地属性添加特定的过滤器。一旦用户输入了所需的准则,就必须输入go命令,控制台应该运行查询并输出结果。

你将用于此活动的数据文件包含英国财政部(HM Treasury)在 2011 年 1 月 1 日至 12 月 31 日期间执行的航班详细信息(共有 714 条记录)。你需要使用 WebClient.DownloadFile 从以下网址下载数据:www.gov.uk/government/uploads/system/uploads/attachment_data/file/245855/HMT_-_2011_Air_Data.csv

注意

网站可能在 Internet Explorer 或 Google Chrome 中打开方式不同。这取决于 IE 或 Chrome 在您的机器上的配置。使用 WebClient.DownloadFile,您可以按建议下载数据。

理想情况下,程序应一次性下载数据,然后在每次启动时从本地文件系统中重新读取。

图 4.6:Excel 中 HM 财政部交通数据的预览

图 4.6:Excel 中 HM 财政部交通数据的预览

下载后,数据应被读取到合适的记录结构中,然后添加到集合中,以便应用各种查询。输出应显示所有符合用户标准的行的以下汇总值:

  • 记录计数

  • 平均票价

  • 最低票价

  • 最高票价

用户应能够输入以下控制台命令:

  • Class c: 添加一个类别过滤器,其中 c 是要搜索的航班类别,例如 经济舱商务舱

  • Origin o: 添加一个 origin 过滤器,其中 o 是航班起点,例如 都柏林伦敦巴塞尔

  • Destination d: 添加一个目的地过滤器,其中 d 是航班目的地,例如 德里

  • Clear: 清除所有过滤器。

  • go: 应用当前过滤器。

如果用户输入了多个同类型的过滤器,则应将这些过滤器视为一个 OR 过滤器。

可以使用 enum 来标识输入的过滤标准类型,如下面的代码行所示:

enum FilterCriteriaType {Class, Origin, Destination}

类似地,可以使用记录来存储每个过滤类型和比较运算符,如下所示:

record FilterCriteria(FilterCriteriaType Filter, string Operand)

每个指定的过滤器都应添加到 List<FilterCriteria> 实例中。例如,如果用户输入了两个起点过滤器,一个用于 dublin,另一个用于 london,那么列表应包含两个对象,每个对象代表一个起点类型过滤器。

当用户输入 go 命令时,应构建一个查询,执行以下步骤:

  • 将所有 class 过滤器的值提取到一个字符串列表(List<string>)中。

  • 将所有 origin 过滤器的值提取到 List<string> 中。

  • 将所有 destination 过滤器的值提取到 List<string> 中。

  • 使用 where 扩展方法根据指定的 List<string> 过滤每个标准类型的航班记录。它包含一个执行不区分大小写搜索的方法。

以下步骤将帮助您完成此活动:

  1. Chapter04 文件夹中创建一个名为 Activities 的新文件夹。

  2. 在新文件夹中添加一个名为 Activity01 的新文件夹。

  3. 添加一个名为 Flight.cs 的新类文件。这将是一个 Record 类,其字段与航班数据中的字段匹配。应该使用 Record 类,因为它仅提供一种简单的类型来存储数据,而不是任何形式的行为。

  4. 添加一个名为 FlightLoader.cs 的新类文件。这个类将用于下载或导入数据。FlightLoader 应该包含数据文件中字段索引位置列表,用于读取每一行数据并将内容分割成字符串数组,例如:

    public const int Agency = 0;
    public const int PaidFare = 1; 
    
  5. 现在来实现 FlightLoader,使用一个 static 类来定义数据文件中已知字段位置索引。这将使得处理数据布局的任何未来变化更加容易。

  6. 接下来,应该给 Download 方法传递一个 URL 和目标文件。使用 WebClient.DownloadFile 下载数据文件,然后委托给 Import 来处理下载的文件。

  7. 需要添加一个 Import 方法。这个方法接收要导入的本地文件名(使用 Import 方法下载)并返回一个 Flight 记录列表。

  8. 添加一个名为 FilterCriteria.cs 的类文件。这个文件应该包含一个 FilterCriteriaType enum 定义。您将提供基于航班类、出发地和目的地属性的过滤器,因此 FilterCriteriaType 应该代表这些属性中的每一个。

  9. 现在,对于主要的过滤类,添加一个名为 FlightQuery.cs 的新类文件。构造函数将传递一个 FlightLoader 实例。在其内部,创建一个名为 _flights 的列表来包含通过 FlightLoader 导入的数据。创建一个名为 _filtersList<FilterCriteria> 实例,代表每次用户指定新的过滤条件时添加的每个标准项。

  10. FlightLoaderImportDownload 方法应该在启动时由控制台调用,以便通过 _loader 实例处理之前下载的数据。

  11. 创建一个 Count 变量,它返回已导入的航班记录数。

  12. 当用户指定要添加的过滤器时,控制台将调用 AddFilter,传递一个 enum 来定义标准类型和要过滤的字符串值。

  13. RunQuery 是主要方法,它返回符合用户标准的航班。您需要使用内置的 StringComparer.InvariantCultureIgnoreCase 比较器来确保字符串比较忽略任何大小写差异。您定义一个查询变量,它对航班调用 Select;目前,这将产生一个过滤后的结果集。

  14. 可用的每种过滤类型都是基于字符串的,因此需要提取所有字符串项。如果有任何要过滤的项,为每种类型(ClassDestinationOrigin)在查询中添加一个额外的 Where 调用。每个 Where 子句使用一个 Contains 断言,它检查相关的属性。

  15. 接下来,添加RunQuery使用的两个辅助方法。GetFiltersByType传递代表已知类型标准类型的每个FilterCriteriaType枚举,并使用.Where方法在过滤器列表中查找这些类型。例如,如果用户添加了两个目的地标准,如印度和德国,这将导致返回两个字符串IndiaGermany

  16. FormatFilters简单地将filterValues字符串列表连接成一个用户友好的字符串,每个项目之间用单词OR分隔,例如伦敦 OR 都柏林

  17. 现在创建主控制台应用程序。添加一个名为Program.cs的新类,它将允许用户输入请求并处理他们的命令。

  18. 将下载的 URL 和目标文件名硬编码。

  19. 创建主FlightQuery类,传入一个FlightLoader实例。如果应用程序之前已经运行过,你可以导入本地航班数据,如果没有,则使用下载

  20. 显示导入的记录摘要和可用的命令。

  21. 当用户输入命令时,也可能有一个参数,例如destination united kingdom,其中destination是命令,united kingdom是参数。为了确定这一点,使用IndexOf方法查找输入中第一个空格字符的位置(如果有的话)。

  22. 对于go命令,调用RunQuery并在返回的结果上使用各种聚合运算符。

  23. 对于剩余的命令,根据要求清除或添加过滤器。如果指定了清除命令,调用查询的ClearFilters方法,这将清除标准项列表。

  24. 如果指定了class过滤器命令,调用AddFilter指定FilterCriteriaType.Class枚举和字符串Argument

  25. 应该使用相同的模式为起始地目的地命令。调用AddFilter,传入所需的枚举值和参数。

控制台输出应类似于以下内容,这里列出用户可用的命令:

Commands: go | clear | class value | origin value | destination value
  1. 用户应该能够添加两个类过滤器,对于经济舱商务舱(所有字符串比较应不区分大小写),如下面的代码片段所示:

    Enter a command:class economy
    Added filter: Class=economy
    Enter a command:class Business Class
    Added filter: Class=business class
    
  2. 同样,用户应该能够添加一个起始地过滤器,如下所示(此示例为伦敦):

    Enter a command:origin london
    Added filter: Origin=london
    
  3. 添加目的地过滤器应如下所示(此示例为苏黎世):

    Enter a command:destination zurich
    Added filter: Destination=zurich
    
  4. 输入go应显示所有指定的过滤器摘要,然后是匹配过滤器的航班结果:

    Enter a command:go
    Classes: economy OR business class
    Destinations: zurich
    Origins: london
    Results: Count=16, Avg=266.92, Min=-74.71, Max=443.49
    

    注意

    本活动的解决方案可在packt.link/qclbF找到。

摘要

在本章中,您了解了IEnumerableICollection接口构成了.NET 数据结构的基础,以及它们如何用于存储多个项目。您根据每个集合的用途创建了不同类型的集合。您了解到List集合最广泛地用于存储项目集合,尤其是当元素数量在编译时未知时。您还看到StackQueue类型允许以受控的方式处理项目顺序,而HashSet提供基于集合的处理,而Dictionary则使用键标识符存储唯一值。

您随后通过使用 LINQ 查询表达式和查询运算符进一步探索了数据结构,展示了如何将查询应用于数据,并说明查询可以根据过滤需求在运行时进行更改。您对数据进行排序和分区,并看到如何使用查询运算符和查询表达式实现类似操作,每个都根据上下文提供偏好和灵活性。

在下一章中,您将看到如何使用并行和异步代码一起运行复杂或长时间运行的操作。

第五章:5. 并发:多线程、并行和异步代码

概述

C# 和 .NET 提供了一种高效的方式来运行并发代码,使得执行复杂且通常耗时的操作变得容易。在本章中,你将探索可用的各种模式,从使用 Task 工厂方法创建任务到使用延续将任务链接起来,然后转向 async/await 关键字,这些关键字极大地简化了此类代码。在本章结束时,你将看到如何使用 C# 执行并发运行的代码,并且通常比单线程应用程序产生结果更快。

简介

并发是一个通用术语,描述了软件同时执行多项任务的能力。通过利用并发的力量,你可以通过将 CPU 密集型活动从主 UI 线程卸载来提供更响应的用户界面。在服务器端,通过利用多处理器和多核架构的现代处理能力,可以通过并行处理操作来实现可伸缩性。

多线程是一种并发形式,其中使用多个线程来执行操作。这通常是通过创建许多 Thread 实例并在它们之间协调操作来实现的。它被认为是一种遗留实现,已被并行和异步编程所取代;你可能会在旧项目中找到它的使用。

并行编程是一种多线程类别,其中相似的操作彼此独立运行。通常,相同的操作会通过多个循环重复执行,其中操作的参数或目标本身会随着迭代而变化。.NET 提供了库,可以保护开发者免受线程创建的低级复杂性的影响。短语令人尴尬的并行常用来描述一种只需要额外努力就能分解成可以并行运行的任务集的活动,通常在这些子任务之间几乎没有交互。并行编程的一个例子可能是计算文件夹中每个文本文件中找到的单词数量。打开文件和扫描单词的工作可以分解成并行任务。每个任务执行相同的代码行,但被分配处理不同的文本文件。

异步编程是一种更近期的并发形式,其中一旦启动操作,将在未来的某个时刻完成,并且调用代码能够继续执行其他操作。这种完成通常被称为 Task<> 等价物。在 C# 和 .NET 中,异步编程已成为实现并发操作的首选方法。

异步编程的一个常见应用场景是,在调用最终步骤之前,需要初始化和整理多个慢速运行或昂贵的依赖项。例如,一个移动徒步旅行应用程序可能需要在用户安全开始徒步之前等待可靠的 GPS 卫星信号、计划好的导航路线和心率监测服务准备就绪。每个这些独立的步骤都会使用一个专用的任务来初始化。

异步编程的另一个非常常见的用例出现在 UI 应用程序中,例如,将客户的订单保存到数据库可能需要 5-10 秒才能完成。这可能涉及验证订单、打开与远程服务器或数据库的连接、打包并发送订单以供通过网络传输的格式,然后最终等待确认客户的订单已成功存储在数据库中。在一个单线程应用程序中,这会花费更长的时间,这种延迟很快就会被用户注意到。应用程序会在操作完成之前无响应。在这种情况下,用户可能会错误地认为应用程序已崩溃,并尝试关闭它。这不是一个理想的用户体验。

通过使用执行任何慢速操作的专用任务的异步代码,可以减轻此类问题。这些任务可以选择在进度中提供反馈,UI 的主线程可以使用这些反馈来通知用户。总体而言,操作应该会更快完成,从而使用户能够继续与应用程序交互。在现代应用程序中,用户已经习惯了这种操作方式。事实上,许多 UI 指南建议,如果某个操作可能需要几秒钟才能完成,那么它应该使用异步代码来执行。

注意,当代码正在执行时,无论是同步代码还是异步代码,它都是在 Thread 实例的上下文中运行的。在异步代码的情况下,这个 Thread 实例是由 .NET 调度程序从可用的线程池中选择。

Thread 类具有多种属性,但其中最有用的一项是 ManagedThreadId,它将在本章中广泛使用。这个整数值用于在您的进程中唯一标识一个线程。通过检查 Thread.ManagedThreadId,您可以确定正在使用多个线程实例。这可以通过在代码中使用静态的 Thread.CurrentThread 方法来访问 Thread 实例来完成。

例如,如果您启动了五个长时间运行的任务,并检查每个任务的 Thread.ManagedThreadId,您会观察到五个唯一的 ID,可能编号为二、三、四、五和六。在大多数情况下,ID 为一的线程是进程的主线程,它在进程首次启动时创建。

跟踪线程 ID 可以非常有用,尤其是在你需要执行耗时操作时。正如你所见,使用并发编程,可以同时执行多个操作,而不是使用传统的单线程方法,在后续操作开始之前,必须完成一个操作。

在现实世界中,考虑在山脉中建造火车隧道的案例。如果两个团队从山脉的两侧开始,同时向对方挖掘隧道,那么这个过程可以大大加快。两个团队可以独立工作;一个团队在一边遇到的问题不应对另一边的团队产生不利影响。一旦两边都完成了隧道挖掘,就应有一个单一的隧道,然后可以继续进行下一个任务,例如铺设火车线路。

下一节将探讨使用 C# 的 Task 类,它允许你同时独立地执行代码块。再次考虑 UI 应用程序的例子,其中客户的订单需要保存到数据库。为此,你有两种选择:

第一种选择是创建一个 C# Task,依次执行每个步骤:

  • 验证订单。

  • 连接到服务器。

  • 发送请求。

  • 等待响应。

第二种选择是为每个步骤创建一个 C# Task,尽可能并行执行每个步骤。

这两种选择都达到了相同的结果,即释放 UI 的主线程以响应用户交互。第一种选择可能完成得较慢,但优点是代码会更简单。然而,第二种选择将是首选,因为你正在卸载多个步骤,因此它应该完成得更快。尽管如此,这可能会涉及额外的复杂性,因为你可能需要协调每个单独的任务,一旦它们完成。

在接下来的章节中,你将首先了解如何采用第一种选择,即使用单个 Task 来运行代码块,然后再继续探讨第二种选择,即使用多个任务并协调它们的复杂性。

使用任务运行异步代码

Task 类用于异步执行代码块。它的使用已被较新的 asyncawait 关键字所取代,但本节将介绍创建任务的基础知识,因为它们在较大的或成熟的 C# 应用程序中普遍存在,并构成了 async/await 关键字的基石。

在 C# 中,有三种方法可以使用 Task 类及其泛型等效 Task<T> 来安排异步代码的运行。

创建新任务

你将从最简单的形式开始,即执行操作但不向调用者返回结果的形式。你可以通过调用任何Task构造函数并传递基于Action的委托来声明一个Task实例。此委托包含在未来的某个时刻执行的实际代码。许多构造函数重载允许使用取消令牌和Task运行。

一些常用的构造函数如下:

  • public Task(Action action): Action委托代表要运行的代码主体。

  • public Task(Action action, CancellationToken cancellationToken): 可以使用CancellationToken参数作为中断正在运行代码的方式。通常,这用于调用者已提供一种请求停止操作的手段,例如添加一个用户可以按下的Cancel按钮。

  • public Task(Action action, TaskCreationOptions creationOptions): TaskCreationOptions提供了一种控制Task如何运行的方式,允许你向调度器提供提示,表明某个Task可能需要额外的时间来完成。这有助于在运行相关任务时。

以下是最常用的Task属性:

  • public bool IsCompleted { get; }: 如果Task已完成(完成并不表示成功),则返回true

  • public bool IsCompletedSuccessfully { get; }: 如果Task成功完成,则返回true

  • public bool IsCanceled { get; }: 如果在完成之前Task被取消,则返回true

  • public bool IsFaulted { get; }: 如果在完成之前Task抛出了未处理的异常,则返回true

  • public TaskStatus Status { get; }: 返回任务当前状态的指示器,例如CanceledRunningWaitingToRun

  • public AggregateException Exception { get; }: 如果有,返回导致Task提前结束的异常。

注意,Action委托中的代码只有在调用Start()方法之后才会执行。这可能是几毫秒之后,由.NET 调度器决定。

从创建一个新的 VS Code 控制台应用程序开始,添加一个名为Logger的实用工具类,你将在接下来的练习和示例中使用它。它将用于将消息记录到控制台,并附带当前时间和当前线程的ManagedThreadId

这些步骤如下:

  1. 切换到你的源文件夹。

  2. 通过运行以下命令创建一个名为Chapter05的新控制台应用程序项目:

    source>dotnet new console -o Chapter05
    
  3. Class1.cs文件重命名为Logger.cs并移除所有模板代码。

  4. 一定要包含SystemSystem.Threading命名空间。System.Threading包含基于Threading的类:

    using System;
    using System.Threading;
    namespace Chapter05
    {
    
  5. Logger类标记为静态,这样就可以在不创建实例的情况下使用它:

        public static class Logger
        {
    

    注意

    如果你使用 Chapter05 命名空间,那么 Logger 类将可供示例和活动中的代码访问,前提是它们也使用 Chapter05 命名空间。如果你更喜欢为每个示例和练习创建一个文件夹,那么你应该将文件 Logger.cs 复制到你创建的每个文件夹中。

  6. 现在声明一个名为 Logstatic 方法,它接受一个 string message 参数:

            public static void Log(string message)
            {
                Console.WriteLine($"{DateTime.Now:T} [{Thread.CurrentThread.ManagedThreadId:00}] {message}");
            }
        }
    }
    

当被调用时,它将使用 WriteLine 方法将消息记录到控制台窗口。在上面的代码片段中,C# 中的字符串插值功能使用 $ 符号定义一个字符串;这里,:T 将当前时间 (DateTime.Now) 格式化为一个时间格式的字符串,:00 用于包含带前导 0 的 Thread.ManagedThreadId

因此,你已经创建了一个静态的 Logger 类,它将在本章的其余部分中使用。

注意

你可以在 packt.link/cg6c5 找到用于此示例的代码。

在下一个示例中,你将使用 Logger 类来记录线程即将开始和结束时的一些细节。

  1. 首先添加一个名为 TaskExamples.cs 的新类文件:

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    namespace Chapter05.Examples
    {
        class TaskExamples
        {
    
  2. Main 入口点将记录 taskA 正在创建:

            public static void Main()
            {
                Logger.Log("Creating taskA");
    
  3. 接下来,添加以下代码:

                var taskA = new Task(() =>
                {
                    Logger.Log("Inside taskA");
                    Thread.Sleep(TimeSpan.FromSeconds(5D));
                    Logger.Log("Leaving taskA");
                });
    

在这里,最简单的 Task 构造函数传递了一个 Action lambda 表达式,这是你想要实际执行的代码的目标。目标代码将消息 Inside taskA 写入控制台。它使用 Thread.Sleep 暂停五秒钟以阻塞当前线程,从而模拟一个长时间运行的活动,最后将 Leaving taskA 写入控制台。

  1. 现在你已经创建了 taskA,确认它只有在调用 Start() 方法时才会调用其目标代码。你将通过在方法调用前后立即记录一条消息来完成此操作:

                Logger.Log($"Starting taskA. Status={taskA.Status}");
                taskA.Start();
                Logger.Log($"Started taskA. Status={taskA.Status}");
                Console.ReadLine();
            }
        }
    } 
    
  2. Logger.cs 文件的全部内容复制到与 TaskExamples.cs 示例相同的文件夹中。

  3. 接下来运行控制台应用程序,产生以下输出:

    10:47:34 [01] Creating taskA
    10:47:34 [01] Starting taskA. Status=Created
    10:47:34 [01] Started taskA. Status=WaitingToRun
    10:47:34 [03] Inside taskA
    10:47:39 [03] Leaving taskA
    

注意,即使你调用了 Start,任务的状态仍然是 WaitingToRun。这是因为你要求 .NET 调度器安排代码运行——也就是说,将其添加到其挂起操作队列中。根据你的应用程序中其他任务的繁忙程度,它可能在你调用 Start 后不会立即运行。

注意

你可以在 packt.link/DHxt3 找到用于此示例的代码。

在 C# 的早期版本中,这是直接创建和启动 Task 对象的主要方式。现在不再推荐使用,这里仅包括它,因为你在旧代码中可能会遇到它的使用。它的使用已被 Task.RunTask.Factory.StartNew 静态工厂方法所取代,这些方法为最常见的使用场景提供了一个更简单的接口。

使用 Task.Factory.StartNew

静态方法 Task.Factory.StartNew 包含各种重载,使得创建和配置 Task 更加容易。注意方法被命名为 StartNew。它创建一个 Task 并自动为您启动方法。.NET 团队认识到,创建一个在首次创建后不会立即启动的 Task 几乎没有价值。通常,您希望 Task 立即开始执行其操作。

第一个参数是要执行的熟悉 Action 委托,后面跟着可选的取消令牌、创建选项和一个 TaskScheduler 实例。

以下是一些常见的重载:

  • Task.Factory.StartNew(Action action): Action 委托包含要执行的代码,正如您之前所看到的。

  • Task.Factory.StartNew(Action action, CancellationToken cancellationToken): 在这里,CancellationToken 协调任务的取消。

  • Task.Factory.StartNew(Action<object> action, object state, CancellationToken cancellationToken, TaskCreationOptions creationOptions, TaskScheduler scheduler): TaskScheduler 参数允许您指定一种低级调度程序类型,该调度程序负责排队任务。此选项很少使用。

考虑以下代码,它使用了第一个也是最简单的重载:

var taskB = Task.Factory.StartNew((() =>
{
  Logger.Log("Inside taskB");
  Thread.Sleep(TimeSpan.FromSeconds(3D));
  Logger.Log("Leaving taskB");
}));
Logger.Log($"Started taskB. Status={taskB.Status}");
Console.ReadLine();

运行此代码将产生以下输出:

21:37:42 [01] Started taskB. Status=WaitingToRun
21:37:42 [03] Inside taskB
21:37:45 [03] Leaving taskB

从输出中,您可以看到此代码实现了与创建 Task 相同的结果,但更加简洁。要考虑的主要点是,Task.Factory.StartNew 被添加到 C# 中是为了使创建由您启动的任务更加容易。与直接创建任务相比,使用 StartNew 更为可取。

注意

在软件开发中,术语 Factory 通常用于表示帮助创建对象的函数。

Task.Factory.StartNew 提供了一种高度可配置的方式来启动任务,但实际上,许多重载很少使用,并且需要传递很多额外的参数。因此,Task.Factory.StartNew 本身也变得有些过时,转而使用更新的 Task.Run 静态方法。尽管如此,Task.Factory.StartNew 仍将简要介绍,因为您可能会在遗留的 C# 应用程序中看到它的使用。

使用 Task.Run

作为替代和首选的 static 工厂方法,Task.Run 有各种重载,后来被添加到 .NET 中以简化并缩短最常见的任务场景。对于新代码来说,使用 Task.Run 创建已启动的任务更为可取,因为需要的参数更少,可以完成常见的线程操作。

一些常见的重载如下:

  • public static Task Run(Action action): 包含要执行的 Action 委托代码。

  • public static Task Run(Action action, CancellationToken cancellationToken): 此外还包含一个用于协调任务取消的取消令牌。

例如,考虑以下代码:

var taskC = Task.Run(() =>
{
  Logger.Log("Inside taskC");
  Thread.Sleep(TimeSpan.FromSeconds(1D));
  Logger.Log("Leaving taskC");
  });
Logger.Log($"Started taskC. Status={taskC.Status}");
Console.ReadLine();

运行此代码将产生以下输出:

21:40:27 [03] Inside taskC
21:40:27 [01] Started taskC. Status=WaitingToRun
21:40:28 [03] Leaving taskC

如你所见,输出与前面两个代码片段的输出非常相似。在相关的 Action 委托完成之前,每个等待的时间都比前一个短。

主要的区别是直接创建 Task 实例是一种过时的做法,但它将允许你在显式调用 Start 方法之前添加一个额外的日志调用。这是直接创建 Task 的唯一好处,但这并不是一个特别有说服力的理由去做这件事。

同时运行这三个示例会产生以下结果:

21:45:52 [01] Creating taskA
21:45:52 [01] Starting taskA. Status=Created
21:45:52 [01] Started taskA. Status=WaitingToRun
21:45:52 [01] Started taskB. Status=WaitingToRun
21:45:52 [01] Started taskC. Status=WaitingToRun
21:45:52 [04] Inside taskB
21:45:52 [03] Inside taskA
21:45:52 [05] Inside taskC
21:45:53 [05] Leaving taskC
21:45:55 [04] Leaving taskB
21:45:57 [03] Leaving taskA

你可以看到各种 ManagedThreadIds 被记录,并且由于在每个情况下 Thread.Sleep 调用中指定的秒数逐渐减少,taskCtaskB 之前完成,而 taskBtaskA 之前完成。

更倾向于使用两种静态方法中的任何一种,但当你安排一个新的任务时应该使用哪一个?应该使用 Task.Run,因为 Task.Run 将会递归到 Task.Factory.StartNew

当你有更高级的要求时,例如使用接受 TaskScheduler 实例的重载之一来定义任务队列的位置时,应该使用 Task.Factory.StartNew,但在实践中,这很少是必需的。

注意

你可以在 devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/blog.stephencleary.com/2013/08/startnew-is-dangerous.xhtml 找到关于 Task.RunTask.Factory.StartNew 的更多信息。

到目前为止,你已经看到了如何启动小任务,每个任务在完成前都有一个小延迟。这样的延迟可以模拟代码访问慢速网络连接或运行复杂计算时产生的效果。在接下来的练习中,你将通过启动运行时间越来越长的数值计算来扩展你的 Task.Run 知识。

这作为一个例子,展示了如何启动可能复杂的任务,并允许它们相互独立地运行到完成。请注意,在传统的同步实现中,这种计算的吞吐量会受到严重限制,因为需要在下一个操作开始之前等待一个操作完成。现在是时候通过练习来实践你所学的知识了。

练习 5.01:使用任务执行多个慢速运行的计算

在这个练习中,你将创建一个递归函数 Fibonacci,该函数调用自身两次来计算累积值。这是一个使用 Thread.Sleep 来模拟慢速调用的潜在慢速运行代码的例子。你将创建一个控制台应用程序,该程序会反复提示输入一个数字。这个数字越大,每个任务计算并输出其结果所需的时间就越长。以下步骤将帮助你完成这个练习:

  1. Chapter05 文件夹中,添加一个名为 Exercises 的新文件夹。在该文件夹内,添加一个名为 Exercise01 的新文件夹。你应该有如下文件夹结构:Chapter05\Exercises\Exercise01

  2. 创建一个名为 Program.cs 的新文件。

  3. 按如下方式添加递归的 Fibonacci 函数。如果你请求的迭代小于或等于 2,可以返回 1 以节省一些处理时间:

    using System;
    using System.Globalization;
    using System.Threading;
    using System.Threading.Tasks;
    namespace Chapter05.Exercises.Exercise01
    {
      class Program
      {
            private static long Fibonacci(int n)
            {
                if (n <= 2L)
                    return 1L;
                return Fibonacci(n - 1) + Fibonacci(n - 2);
            }
    
  4. static Main 入口点添加到控制台应用程序中,并使用 do-循环提示输入一个数字。

  5. 如果用户输入一个字符串,使用 int.TryParse 将其转换为整数:

            public static void Main()
            {
                string input;
                do
                {
                    Console.WriteLine("Enter number:");
                    input = Console.ReadLine();
                    if (!string.IsNullOrEmpty(input) &&                     int.TryParse(input, NumberStyles.Any, CultureInfo.CurrentCulture, out var number))
    
  6. 定义一个 lambda 表达式,使用 DateTime.Now 获取当前时间,调用慢速运行的 Fibonacci 函数,并记录运行时间:

                     {
                        Task.Run(() =>
                        {
                            var now = DateTime.Now;
                            var fib = Fibonacci(number);
                            var duration = DateTime.Now.Subtract(now);
                            Logger.Log($"Fibonacci {number:N0} = {fib:N0} (elapsed time: {duration.TotalSeconds:N0} secs)");
                        });
                    } 
    

lambda 表达式被传递给 Task.Run,并将很快由 Task.Run 启动,从而释放 do-while 循环以提示输入另一个数字。

  1. 当输入空值时,程序应退出循环:

                 } while (input != string.Empty);
            }
        }
    }
    
  2. 对于运行控制台应用程序,首先输入数字 12。由于这些计算非常快,它们都在一秒内返回。

    Enter number:1
    Enter number:2
    11:25:11 [04] Fibonacci 1 = 1 (elapsed time: 0 secs)
    Enter number:45
    11:25:12 [04] Fibonacci 2 = 1 (elapsed time: 0 secs)
    Enter number:44
    Enter number:43
    Enter number:42
    Enter number:41
    Enter number:40
    Enter number:10
    11:25:35 [08] Fibonacci 41 = 165,580,141 (elapsed time: 4 secs)
    11:25:35 [09] Fibonacci 40 = 102,334,155 (elapsed time: 2 secs)
    11:25:36 [07] Fibonacci 42 = 267,914,296 (elapsed time: 6 secs)
    Enter number: 39
    11:25:36 [09] Fibonacci 10 = 55 (elapsed time: 0 secs)
    11:25:37 [05] Fibonacci 43 = 433,494,437 (elapsed time: 9 secs)
    11:25:38 [06] Fibonacci 44 = 701,408,733 (elapsed time: 16 secs)
    Enter number:38
    11:25:44 [06] Fibonacci 38 = 39,088,169 (elapsed time: 1 secs)
    11:25:44 [05] Fibonacci 39 = 63,245,986 (elapsed time: 2 secs)
    11:25:48 [04] Fibonacci 45 = 1,134,903,170 (elapsed time: 27 secs)
    

注意,对于 12ThreadId 都是 [04]。这表明 Task.Run 在这两个迭代中都使用了相同的线程。当输入 2 时,之前的计算已经完成。所以 .NET 决定再次重用线程 04。对于值 45,尽管它是第三次请求,但它仍然花费了 27 秒来完成。

你可以看到,输入超过 40 的值会导致经过的时间显著增加(每次增加一个,所需时间几乎翻倍)。从更高的数字开始,向下递减,你可以看到 414042 的计算都在 4443 之前完成,尽管它们是在类似的时间启动的。在少数情况下,同一个线程出现了两次。同样,这是 .NET 重新使用空闲线程来运行任务的操作。

注意

你可以在 packt.link/YLYd4 找到用于此练习的代码。

协调任务

在之前的 练习 5.01 中,你看到了如何启动多个任务并在它们完成时无需任何交互地运行。一个这样的场景是需要一个过程来搜索文件夹以查找图像文件,并为每个找到的图像文件添加版权水印。这个过程可以使用多个任务,每个任务处理一个不同的文件。不需要协调每个任务及其生成的图像。

相反,启动各种长时间运行的任务并在某些或所有任务完成后再继续的情况相当常见;也许你有一系列复杂的计算需要启动,并且只能在其他任务完成后执行最终计算。

简介 部分中,提到一个徒步旅行应用程序在使用前需要 GPS 卫星信号、导航路线和心率监测器。这些依赖项都可以使用 Task 创建,并且只有当所有这些都表示它们已准备好使用时,应用程序才应允许用户开始他们的路线。

在接下来的几节中,你将了解 C# 提供的各种任务协调方式。例如,你可能需要启动许多独立任务运行,每个任务运行一个复杂的计算,并在所有先前任务完成后计算一个最终值。你可能喜欢从多个网站下载数据,但希望取消耗时过长的下载。下一节将介绍这种情况。

等待任务完成

可以使用 Task.Wait 来等待单个任务完成。如果你正在处理多个任务,那么静态的 Task.WaitAll 方法将等待所有任务完成。WaitAll 重载允许传递取消和超时选项,其中大多数返回一个布尔值以指示成功或失败,如以下列表所示:

  • public static bool WaitAll(Task[] tasks, TimeSpan timeout): 这接受一个等待的 Task 项目数组。如果 TimeSpan 允许表达特定单位,如小时、分钟和秒,则返回 true

  • public static void WaitAll(Task[] tasks, CancellationToken cancellationToken): 这接受一个等待的 Task 项目数组和一个可以用来协调任务取消的取消令牌。

  • public static bool WaitAll(Task[] tasks, int millisecondsTimeout, CancellationToken cancellationToken): 这接受一个等待的 Task 项目数组和一个可以用来协调任务取消的取消令牌。millisecondsTimeout 指定了等待所有任务完成所需的毫秒数。

  • public static void WaitAll(params Task[] tasks): 这允许等待一个 Task 项数组。

如果你需要等待任务列表中的任何任务完成,则可以使用 Task.WaitAny。所有的 WaitAny 重载都返回第一个完成的任务的索引号,或者在超时发生时返回 -1(等待的最大时间)。

例如,如果你传递一个包含五个 Task 项的数组,并且该数组中的最后一个 Task 完成,那么你将返回值四(数组索引始终从零开始计数)。

  • public static int WaitAny(Task[] tasks, int millisecondsTimeout, CancellationToken cancellationToken): 这接受一个等待的 Task 项目数组,等待任何 Task 完成的毫秒数,以及一个可以用来协调任务取消的取消令牌。

  • public static int WaitAny(params Task[] tasks): 这个方法接收一个 Task 项的数组,等待任何 Task 完成。

  • public static int WaitAny(Task[] tasks, int millisecondsTimeout): 在这里,您传递等待任何任务完成的毫秒数。

  • public static int WaitAny(Task[] tasks, CancellationToken cancellationToken) CancellationToken: 这个方法接收一个可以用来协调任务取消的取消令牌。

  • public static int WaitAny(Task[] tasks, TimeSpan timeout): 这个方法接收等待的最大时间周期。

调用 WaitWaitAllWaitAny 将阻塞当前线程,这可能会抵消最初使用任务的好处。因此,最好在可等待的任务(如 Task.Run)内部调用这些方法,如下例所示。

代码使用 lambda 表达式创建 outerTask,然后它本身创建了两个内部任务 inner1inner2。使用 WaitAny 获取 inner2 将首先完成的索引,因为它暂停的时间较短,所以结果索引值将是 1

TaskWaitAnyExample.cs
1    var outerTask = Task.Run( () =>
2    {
3        Logger.Log("Inside outerTask");
4        var inner1 = Task.Run(() =>
5        {
6            Logger.Log("Inside inner1");
7            Thread.Sleep(TimeSpan.FromSeconds(3D));
8        });
9        var inner2 = Task.Run(() =>
10        {
11            Logger.Log("Inside inner2");
12            Thread.Sleep(TimeSpan.FromSeconds(2D));
13        });
14
15        Logger.Log("Calling WaitAny on outerTask");
You can find the complete code here: http://packt.link/CicWk.

当代码运行时,它会产生以下输出:

15:47:43 [04] Inside outerTask
15:47:43 [01] Press ENTER
15:47:44 [04] Calling WaitAny on outerTask
15:47:44 [05] Inside inner1
15:47:44 [06] Inside inner2
15:47:46 [04] Waitany index=1

应用程序保持响应,因为您在 Task 内部调用了 WaitAny。您没有阻塞应用程序的主线程。如您所见,线程 ID 01 记录了以下消息:“15:47:43 [01] 按下 ENTER”。

这种模式可以在需要一次性忘记任务的情况下使用。例如,您可能希望将信息性消息记录到数据库或日志文件中,但如果任一任务未能完成,程序流程不必要改变。

从“一次性”任务到常见进阶的例子是那些需要在一定时间限制内等待多个任务完成的场景。下一个练习将涵盖这个场景。

练习 5.02:在时间周期内等待多个任务完成

在这个练习中,您将启动三个长时间运行的任务,并在它们在随机选择的时间跨度内全部完成时决定您的下一步行动。

在这里,您将看到通用 Task<T> 类的使用。Task<T> 类包含一个 Value 属性,可以用来访问 Task 的结果(在这个练习中,它是一个基于字符串的泛型,因此 Value 将是字符串类型)。在这里您不会使用 Value 属性,因为这个练习的目的是展示 void 和泛型任务可以一起等待。完成以下步骤以完成此练习:

  1. 将主入口点添加到控制台应用程序中:

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    namespace Chapter05.Exercises.Exercise02
    {
        class Program
        {
            public static void Main()
            {
                Logger.Log("Starting");
    
  2. 声明一个名为 taskA 的变量,将 Task.Run 传递一个 lambda 表达式,该表达式暂停当前线程 5 秒:

                var taskA = Task.Run( () =>
                {
                    Logger.Log("Inside TaskA");
                    Thread.Sleep(TimeSpan.FromSeconds(5));
                    Logger.Log("Leaving TaskA");
                    return "All done A";
                });
    
  3. 使用方法组语法创建两个更多任务:

                var taskB = Task.Run(TaskBActivity);
                var taskC = Task.Run(TaskCActivity);
    

如您所知,如果编译器可以确定零参数或单参数方法所需的参数类型,则可以使用这种简短的语法。

  1. 现在随机选择一个以秒为单位的最大超时时间。这意味着两个任务中的任何一个在超时期间都可能不会完成:

                var timeout = TimeSpan.FromSeconds(new Random().Next(1, 10));
                Logger.Log($"Waiting max {timeout.TotalSeconds} seconds...");
    

注意,每个任务仍然会运行到完成,因为您没有在Task.Run Action lambda 的主体中添加停止执行代码的机制。

  1. 调用WaitAll,传入三个任务和timeout超时时间:

                var allDone = Task.WaitAll(new[] {taskA, taskB, taskC}, timeout);
                Logger.Log($"AllDone={allDone}: TaskA={taskA.Status}, TaskB={taskB.Status}, TaskC={taskC.Status}");
                Console.WriteLine("Press ENTER to quit");
                Console.ReadLine();
            }
    

如果所有任务都能及时完成,这将返回true。然后您将记录所有任务的状态,并等待按下Enter键以退出应用程序。

  1. 最后添加两个运行缓慢的Action方法:

            private static string TaskBActivity()
            {
                Logger.Log($"Inside {nameof(TaskBActivity)}");
                Thread.Sleep(TimeSpan.FromSeconds(2));
                Logger.Log($"Leaving {nameof(TaskBActivity)}");
                return "";
            }
            private static void TaskCActivity()
            {
                Logger.Log($"Inside {nameof(TaskCActivity)}");
                Thread.Sleep(TimeSpan.FromSeconds(1));
                Logger.Log($"Leaving {nameof(TaskCActivity)}");
            }
        }
    }
    

每个任务在开始和离开任务时都会记录一条消息,几秒钟后。有用的nameof语句用于包含方法的名称以提供额外的日志信息。通常,检查日志文件以查看已访问的方法的名称比将名称硬编码为字面字符串更有用。

  1. 运行代码后,您将看到以下输出:

    14:46:28 [01] Starting
    14:46:28 [04] Inside TaskBActivity
    14:46:28 [05] Inside TaskCActivity
    14:46:28 [06] Inside TaskA
    14:46:28 [01] Waiting max 7 seconds...
    14:46:29 [05] Leaving TaskCActivity
    14:46:30 [04] Leaving TaskBActivity
    14:46:33 [06] Leaving TaskA
    14:46:33 [01] AllDone=True: TaskA=RanToCompletion, TaskB=RanToCompletion, TaskC=RanToCompletion
    Press ENTER to quit
    

在运行代码时,运行时随机选择了七秒的超时时间。这使得所有任务都能及时完成,因此WaitAll返回true,此时所有任务的状态都是RanToCompletion。请注意,三个任务中的线程 ID,用方括号括起来,是不同的。

  1. 再次运行代码:

    14:48:20 [01] Starting
    14:48:20 [01] Waiting max 2 seconds...
    14:48:20 [05] Inside TaskCActivity
    14:48:20 [06] Inside TaskA
    14:48:20 [04] Inside TaskBActivity
    14:48:21 [05] Leaving TaskCActivity
    14:48:22 [04] Leaving TaskBActivity
    14:48:22 [01] AllDone=False: TaskA=Running, TaskB=Running, TaskC=RanToCompletion
    Press ENTER to quit
    14:48:25 [06] Leaving TaskA
    

这次运行时选择了两秒的最大等待时间,因此WaitAll调用超时,返回false

您可能已经注意到输出中Inside TaskBActivity有时会出现在Inside TaskCActivity之前。这展示了.NET 调度器的排队机制。当您调用Task.Run时,您是在要求调度器将其添加到队列中。您调用Task.Run和它调用您的 lambda 之间可能只有几毫秒的差距,但这可能取决于您最近添加到队列中的其他任务数量;待处理任务的数量越多,这个时间间隔可能会更长。

有趣的是,输出显示了Leaving TaskBActivity,但taskB的状态在WaitAll等待结束后仍然是Running。这表明有时在超时任务的状态改变时可能会有一个非常小的延迟。

在按下Enter键大约三秒后,记录了Leaving TaskA。这表明任何超时任务中的Action将继续运行,.NET 不会为您停止它。

注意

您可以在packt.link/5lH0o找到用于此练习的代码。

延续任务

到目前为止,您已经创建了相互独立的任务,但假设您需要使用前一个任务的结果来继续一个任务怎么办?而不是通过调用Wait或访问Result属性来阻塞当前线程,您可以使用TaskContinueWith方法来实现这一点。

这些方法返回一个新的任务,称为延续任务,或者更简单地说,延续,它可以消费前一个任务或前驱的结果。

与标准任务一样,它们不会阻塞调用线程。有几个ContinueWith重载可用,许多允许广泛的定制。以下是一些更常用的重载:

  • public Task ContinueWith(Action<Task<TResult>> continuationAction): 这定义了一个基于泛型Action<T>Task,在先前的任务完成时运行。

  • public Task ContinueWith(Action<Task<TResult>> continuationAction, CancellationToken cancellationToken): 这有一个要运行的任务和一个可以用来协调任务取消的取消令牌。

  • public Task ContinueWith(Action<Task<TResult>> continuationAction, TaskScheduler scheduler): 这也有一个要运行的任务和一个低级别的TaskScheduler,可以用来排队任务。

  • public Task ContinueWith(Action<Task<TResult>> continuationAction, TaskContinuationOptions continuationOptions): 一个要运行的任务,其行为由TaskContinuationOptions指定。例如,指定NotOnCanceled表示如果先前的任务被取消,则不调用后续操作。

后续操作有一个初始的WaitingForActivation状态。.NET Framework 将在先前的任务或任务完成时执行此任务。需要注意的是,您不需要启动后续操作,尝试这样做将导致异常。

以下示例模拟调用一个长时间运行的功能,GetStockPrice(这可能是一种需要几秒钟才能返回的网页服务或数据库调用):

ContinuationExamples.cs
1    class ContinuationExamples
2    {
3        public static void Main()
4        {
5            Logger.Log("Start...");
6            Task.Run(GetStockPrice)
7                .ContinueWith(prev =>
8                {
9                    Logger.Log($"GetPrice returned {prev.Result:N2}, status={prev.Status}");
10                });
11
12           Console.ReadLine();
13        }
14
You can find the complete code here: http://packt.link/rpNcx.

调用GetStockPrice返回一个double,这导致将泛型Task<double>作为后续操作传递(请参阅突出显示的部分)。prev参数是一个类型为Task<double>的泛型Action,允许您访问先前的任务及其Result以检索从GetStockPrice返回的值。

如果您将鼠标悬停在ContinueWith方法上,您将看到以下 IntelliSense 描述:

图 5.1:ContinueWith 方法签名

图 5.1:ContinueWith 方法签名

注意

ContinueWith方法有多种选项可以用来微调行为,您可以从docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcontinuationoptions获取更多详细信息。

运行示例会产生类似于以下输出的结果:

09:30:45 [01] Start...
09:30:45 [03] Inside GetStockPrice
09:30:50 [04] GetPrice returned 76.44, status=RanToCompletion

在输出中,线程 [01] 代表控制台的主线程。调用GetStockPrice的任务是由线程 ID [03] 执行的,但后续操作是使用不同的线程(线程 [04])执行的。

注意

您可以在packt.link/rpNcx找到此示例使用的代码。

在不同线程上运行的延续操作可能不会成问题,但如果你正在开发 UWP、WPF 或 WinForms UI 应用程序,并且必须使用主 UI 线程来更新 UI 元素(除非你使用绑定语义),那么这肯定是一个问题。

值得注意的是,可以使用 TaskContinuationOptions.OnlyOnRanToCompletion 选项来确保延续操作仅在先决任务首先完成时运行。例如,你可能创建一个 Task 来从数据库中检索客户的订单,然后使用延续任务来计算平均订单价值。如果先前的任务失败或被用户取消,那么如果用户不再关心结果,就没有必要浪费处理能力来计算平均值。

注意

ContinueWith 方法有多种选项可以用来微调行为,你可以查看 docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcontinuationoptions 获取更多详细信息。

如果你访问正在抛出的 AggregateException 上的 Task<T> Result 属性。这将在稍后详细说明。

使用 Task.WhenAll 和 Task.WhenAny 与多个任务

你已经看到了如何使用单个任务来创建延续任务,但如果你有多个任务,需要在之前的任务完成时执行最终操作,该怎么办呢?

之前,使用 Task.WaitAnyTask.WaitAll 方法来等待任务完成,但这些会阻塞当前线程。这就是 Task.WhenAnyTask.WhenAll 可以使用的地方。它们返回一个新的 Task,其 Action 委托在 任何所有 先前的任务完成时被调用。

有四个 WhenAll 重载,两个返回 Task,两个返回泛型 Task<T>,允许访问任务的结果:

  1. public static Task WhenAll(IEnumerable<Task> tasks): 当任务集合完成时,这个操作会继续执行。

  2. public static Task WhenAll(params Task[] tasks): 当任务数组完成时,这个操作会继续执行。

  3. public static Task<TResult[]> WhenAll<TResult>(params Task<TResult>[] tasks): 当泛型 Task<T> 项的数组完成时,这个操作会继续执行。

  4. public static Task<TResult[]> WhenAll<TResult>(IEnumerable<Task<TResult>> tasks): 当泛型 Task<T> 项的集合完成时,这个操作会继续执行。

WhenAny 具有一组类似的重载,但返回的是 TaskTask<T>,这在实践中相当于 WhenAllWhenAny

练习 5.03:等待所有任务完成

假设你被一位汽车经销商要求创建一个控制台应用程序,用于计算不同地区销售的汽车的平均销售价值。经销商是一个繁忙的地方,但他们知道获取和计算平均值可能需要一段时间。因此,他们希望输入他们准备等待平均计算的最大秒数。如果时间更长,他们将离开应用程序并忽略结果。

经销商有 10 个区域销售中心。为了计算平均值,首先需要调用一个名为FetchSales的方法,该方法为这些区域中的每一个返回一个CarSale项目的列表。

每次调用FetchSales可能是一个可能运行缓慢的服务(你将实现随机暂停来模拟这种延迟),因此你需要为每个调用使用一个Task,因为你不能确定每个调用需要多长时间才能完成。你也不希望运行缓慢的任务影响其他任务,但为了计算有效的平均值,在继续之前,重要的是要所有结果都返回。

创建一个SalesLoader类,实现IEnumerable<CarSale> FetchSales()以返回汽车销售详情。然后,一个SalesAggregator类应该传递一个SalesLoader列表(在这个练习中,将有 10 个加载器实例,每个地区一个)。聚合器将等待所有加载器完成使用Task.WhenAll,然后再继续执行一个计算所有地区平均值的任务。

执行以下步骤:

  1. 首先,创建一个CarSale记录。构造函数接受两个值,汽车的名字和其销售价格(namesalePrice):

    using System;
    using System.Collections.Generic;
    using System.Globalization;
    using System.Linq;
    using System.Threading;
    using System.Threading.Tasks;
    namespace Chapter05.Exercises.Exercise03
    {
        public record CarSale
        {
            public CarSale(string name, double salePrice)
                => (Name, SalePrice) = (name, salePrice);
            public string Name { get; }
            public double SalePrice { get; }
        }
    
  2. 现在创建一个接口,ISalesLoader,它表示销售数据加载服务:

        public interface ISalesLoader
        {
            public IEnumerable<CarSale> FetchSales();
        }
    

它只有一个调用,FetchSales,返回一个类型为CarSale的可枚举。现在,了解加载器的工作原理并不重要;只需知道调用时会返回汽车销售列表。在这里使用接口允许根据需要使用各种类型的加载器。

  1. 使用聚合类调用ISalesLoader实现:

        public static class SalesAggregator
        {
           public static Task<double> Average(IEnumerable<ISalesLoader> loaders)
           {
    

它被声明为static,因为在调用之间没有状态。定义一个Average函数,该函数接受一个ISalesLoader项目的可枚举,并返回一个通用的Task<Double>用于最终的平均值计算。

  1. 对于每个加载器参数,使用 LINQ 投影将loader.FetchSales方法传递给Task.Run

             var loaderTasks = loaders.Select(ldr => Task.Run(ldr.FetchSales));
             return Task
                    .WhenAll(loaderTasks)
                    .ContinueWith(tasks =>
    

这些都会返回一个Task<IEnumerable<CarSale>>实例。WhenAll用于创建一个在ContinueWith调用时继续的单个任务。

  1. 使用 LINQ 的SelectMany从每个加载器调用结果中获取所有的CarSale项目,在调用 Linq 的Average之前对每个CarSale项目的SalePrice字段进行操作:

                    {
                        var average = tasks.Result
                            .SelectMany(t => t)
                            .Average(car => car.SalePrice);
                        return average;
                    });
            }
        }
    }
    
  2. 从一个名为SalesLoader的类中实现ISalesLoader接口:

        public class SalesLoader : ISalesLoader
        {
            private readonly Random _random;
            private readonly string _name;
            public SalesLoader(int id, Random rand)
            {
                _name = $"Loader#{id}";
                _random = rand;
            }
    

构造函数将传递一个用于日志记录的int变量和一个Random实例,以帮助创建随机数量的CarSale项目。

  1. 你的ISalesLoader实现需要一个FetchSales函数。包括13秒之间的随机延迟来模拟不太可靠的服务:

            public IEnumerable<CarSale> FetchSales()
            {
                var delay = _random.Next(1, 3);
                Logger.Log($"FetchSales {_name} sleeping for {delay} seconds ...");
                Thread.Sleep(TimeSpan.FromSeconds(delay));
    

你正在尝试测试你的应用程序在各种时间延迟下的行为。因此,使用了随机类。

  1. 使用Enumerable.Rangerandom.Next来选择一个介于一和五之间的随机数:

                var sales = Enumerable
                    .Range(1, _random.Next(1, 5))
                    .Select(n => GetRandomCar())
                    .ToList();
                foreach (var car in sales)
                    Logger.Log($"FetchSales {_name} found: {car.Name} @ {car.SalePrice:N0}");
                return sales;
            }
    

这是使用你的GetRandomCar函数返回的CarSale项目总数。

  1. 使用GetRandomCar生成一个具有随机制造商名称的CarSale项目,该名称来自硬编码的列表。

  2. 使用carNames.length属性来选择一个介于零和四之间的随机索引号用于汽车名称:

            private readonly string[] _carNames = { "Ford", "BMW", "Fiat", "Mercedes", "Porsche" };
            private CarSale GetRandomCar()
            {
                var nameIndex = _random.Next(_carNames.Length);
                return new CarSale(
                    _carNames[nameIndex], _random.NextDouble() * 1000);
            }
        }
    
  3. 现在,创建你的控制台应用程序来测试这个功能:

        public class Program
        {
            public static void Main()
            {
                var random = new Random();
                const int MaxSalesHubs = 10;
                string input;
                do
                {
                    Console.WriteLine("Max wait time (in seconds):");
                    input = Console.ReadLine();
                    if (string.IsNullOrEmpty(input))
                        continue;
    

你的应用程序将反复询问用户愿意等待的最大时间,以便在下载数据时使用。一旦所有数据都下载完毕,应用程序将使用此信息来计算平均价格。单独按下Enter键将导致程序循环结束。MaxSalesHubs是请求数据的最大销售中心数。

  1. 将输入的值转换为int类型,然后再次使用Enumerable.Range来创建一个随机数量的新SalesLoader实例(你有最多 10 个不同的销售中心):

                    if (int.TryParse(input, NumberStyles.Any, CultureInfo.CurrentCulture, out var maxDelay))
                    {
                           var loaders = Enumerable.Range(1,                                           random.Next(1, MaxSalesHubs))
                            .Select(n => new SalesLoader(n, random))
                            .ToList();
    
  2. 将加载器传递给静态SalesAggregator.Average方法以接收一个Task<Double>

  3. 调用Wait,传入最大等待时间:

                        var averageTask = SalesAggregator.Average(loaders);
                        var hasCompleted = averageTask.Wait(                              TimeSpan.FromSeconds(maxDelay));
                        var average = averageTask.Result;
    

如果Wait调用在规定时间内返回,那么你将看到has completed的值为true

  1. 最后,检查hasCompleted并相应地记录消息:

                        if (hasCompleted)
                        {
                            Logger.Log($"Average={average:N0}");
                        }
                        else
                        {
                            Logger.Log("Timeout!");
                        }
                    }
                } while (input != string.Empty);
            }
        }
    }
    
  2. 当运行控制台应用程序并输入短的最大等待时间1秒时,你会看到随机创建的三个加载器实例:

    Max wait time (in seconds):1
    10:52:49 [04] FetchSales Loader#1 sleeping for 1 seconds ...
    10:52:49 [06] FetchSales Loader#3 sleeping for 1 seconds ...
    10:52:49 [05] FetchSales Loader#2 sleeping for 1 seconds ...
    10:52:50 [04] FetchSales Loader#1 found: Mercedes @ 362
    10:52:50 [04] FetchSales Loader#1 found: Ford @ 993
    10:52:50 [06] FetchSales Loader#3 found: Fiat @ 645
    10:52:50 [05] FetchSales Loader#2 found: Mercedes @ 922
    10:52:50 [06] FetchSales Loader#3 found: Ford @ 9
    10:52:50 [05] FetchSales Loader#2 found: Porsche @ 859
    10:52:50 [05] FetchSales Loader#2 found: Mercedes @ 612
    10:52:50 [01] Timeout!
    

每个加载器在返回一个随机的CarSale记录列表之前会睡眠1秒(你可以看到各种线程 ID 被记录),然后达到最大超时值,因此显示没有平均值的Timeout!消息。

  1. 输入一个更大的超时时间10秒:

    Max wait time (in seconds):10
    20:08:41 [05] FetchSales Loader#1 sleeping for 2 seconds ...
    20:08:41 [12] FetchSales Loader#4 sleeping for 1 seconds ...
    20:08:41 [08] FetchSales Loader#2 sleeping for 1 seconds ...
    20:08:41 [11] FetchSales Loader#3 sleeping for 1 seconds ...
    20:08:41 [15] FetchSales Loader#5 sleeping for 2 seconds ...
    20:08:41 [13] FetchSales Loader#6 sleeping for 2 seconds ...
    20:08:41 [14] FetchSales Loader#7 sleeping for 1 seconds ...
    20:08:42 [08] FetchSales Loader#2 found: Porsche @ 735
    20:08:42 [08] FetchSales Loader#2 found: Fiat @ 930
    20:08:42 [11] FetchSales Loader#3 found: Porsche @ 735
    20:08:42 [12] FetchSales Loader#4 found: Porsche @ 735
    20:08:42 [08] FetchSales Loader#2 found: Porsche @ 777
    20:08:42 [11] FetchSales Loader#3 found: Ford @ 500
    20:08:42 [12] FetchSales Loader#4 found: Ford @ 500
    20:08:42 [12] FetchSales Loader#4 found: Porsche @ 710
    20:08:42 [14] FetchSales Loader#7 found: Ford @ 144
    20:08:43 [05] FetchSales Loader#1 found: Fiat @ 649
    20:08:43 [15] FetchSales Loader#5 found: Ford @ 779
    20:08:43 [13] FetchSales Loader#6 found: Porsche @ 763
    20:08:43 [15] FetchSales Loader#5 found: Fiat @ 137
    20:08:43 [13] FetchSales Loader#6 found: BMW @ 415
    20:08:43 [15] FetchSales Loader#5 found: Fiat @ 853
    20:08:43 [15] FetchSales Loader#5 found: Porsche @ 857
    20:08:43 [01] Average=639
    

输入10秒的值允许7个随机加载器及时完成并最终创建平均值为639的值。

注意

你可以在packt.link/kbToQ找到用于此练习的代码。

到目前为止,本章已经考虑了创建单个任务的各种方法以及如何使用静态Task方法来创建为我们启动的任务。你看到了如何使用Task.Factory.StartNew来创建配置任务,尽管它有一组更长的配置参数。最近添加到 C#中的Task.Run方法,在大多数常规场景下,由于其更简洁的签名而更受欢迎。

使用延续,单个和多个任务可以独立运行,只有当所有或任何前面的任务都完成时,才会继续执行最终任务。

现在是时候看看asyncawait关键字来运行异步代码了。这些关键字是 C#语言中相对较新的添加。Task.Factory.StartNewTask.Run方法可以在旧的 C#应用程序中找到,但希望你会看到async/await提供了更清晰的语法。

异步编程

到目前为止,你已经创建了任务,并使用静态Task工厂方法来运行和协调这些任务。在 C#的早期版本中,这些是创建任务的唯一方式。

C#语言现在提供了asyncawait关键字,这使得async/await风格的代码更简洁,所创建的代码通常更容易理解,因此也更容易维护。

注意

你可能会经常发现,遗留的并发启用应用程序最初是使用Task.Factory.StartNew方法创建的,随后更新为使用等效的Task.Run方法,或者直接更新为async/await风格。

async关键字表示方法在完成其操作之前将返回给调用者,因此调用者应该在某个时间点等待其完成。

async关键字添加到方法中指示编译器它可能需要生成额外的代码来创建状态机。本质上,状态机将你的原始方法中的逻辑提取到一系列委托和局部变量中,允许代码在await表达式之后继续执行到下一个语句。编译器生成可以跳回方法中相同位置的委托。

注意

你通常不会看到额外的编译代码,但如果你对 C#中的状态机感兴趣,可以访问devblogs.microsoft.com/premier-developer/dissecting-the-async-methods-in-c了解更多信息。

添加async关键字并不意味着async方法立即执行,它一开始是同步运行的,直到遇到带有await关键字的代码段。在此点,会检查可等待的代码块(在以下示例中,由于前面的async关键字,BuildGreetings调用是可等待的)是否已经完成。如果是,它将继续同步执行。如果不是,异步方法将被暂停,并返回一个不完整的Task给调用者。这将在async代码完成时完成。

在以下控制台应用程序中,入口点static Main已被标记为async,并添加了Task返回类型。你不能将返回intvoidMain入口点标记为async,因为当控制台应用程序关闭时,运行时必须能够返回Task结果给调用环境:

AsyncExamples.cs
1    using System;
2    using System.Threading;
3    using System.Threading.Tasks;
4    
5    namespace Chapter05.Examples
6    {
7        public class AsyncExamples
8        {
9            public static async Task Main()
10            {
11                Logger.Log("Starting");
12                await BuildGreetings();
13
14                Logger.Log("Press Enter");
15                Console.ReadLine();
You can find the complete code here: http://packt.link/CsCek.

运行示例会产生如下输出:

18:20:31 [01] Starting
18:20:31 [01] Morning
18:20:41 [04] Morning...Afternoon
18:20:42 [04] Morning...Afternoon...Evening
18:20:42 [04] Press Enter

Main 运行时,它记录 Starting。注意 ThreadId[01]。正如你之前看到的,控制台应用程序的主线程编号为 1(因为 Logger.Log 方法使用 00 格式字符串,它为范围零到九的数字添加前导 0)。

然后调用异步方法 BuildGreetings。它将字符串变量 message 设置为 "Morning" 并记录消息。ThreadId 仍然是 [01];这目前是同步运行的。

到目前为止,你一直使用 Thread.Sleep 来阻塞调用线程以模拟或模拟长时间运行的操作,但 async/await 使得使用静态 Task.Delay 方法模拟慢动作并等待该调用变得更容易。Task.Delay 返回一个任务,因此它也可以用于后续任务。

使用 Task.Delay,你将进行两个不同的可等待调用(一个等待 10 秒,另一个等待两秒),然后继续并将它们附加到你的本地 message 字符串。这两个 Task.Delay 调用可以是你的代码中返回 Task 的任何方法。

这里很棒的一点是,每个等待的部分都会按照它在代码中声明的顺序获得其正确的状态,无论之前等待了 10 秒(或两秒)。线程 ID 都已从 [01] 变为 [04]。这告诉你不同的线程正在运行这些语句。甚至最后的 Press Enter 消息也有一个与原始线程不同的线程。

Async/await 使得使用熟悉的 WhenAllWhenAnyContinueWith 方法交替运行一系列基于任务的操作变得更容易。

以下示例展示了如何在程序的不同阶段使用各种可等待调用混合应用多个 async/await 调用。这模拟了一个调用数据库(FetchPendingAccounts)以获取用户账户列表的应用程序。待处理账户列表中的每个用户都分配了一个唯一的 ID(每个用户使用一个任务)。

根据用户的区域,Task.WhenAll 调用中的创建账户操作表示一切已完成。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Chapter05.Examples
{

使用 enum 定义一个 RegionName

    public enum RegionName { North, East, South, West };

User 记录构造函数接收一个 userName 和用户的 region

    public record User
    {
        public User(string userName, RegionName region)
            => (UserName, Region) = (userName, region);
        public string UserName { get; }
        public RegionName Region { get; }
        public string ID { get; set; }
    }

AccountGenerator 是主要的控制类。它包含一个可以被控制台应用程序等待的 async CreateAccounts 方法(这在示例的末尾实现):

    public class AccountGenerator
    {
        public async Task CreateAccounts()
        {

使用 await 关键字,你定义了对 FetchPendingAccounts 的可等待调用:

            var users = await FetchPendingAccounts();

对于 FetchPendingAccounts 返回的每个用户,你都会对 GenerateId 进行一个可等待调用。这表明循环可以包含多个可等待调用。运行时将为正确的用户实例设置用户 ID:

            foreach (var user in users)
            {
                var id = await GenerateId();
                user.ID = id;
            }

使用 Linq 的 Select 函数,你创建了一个任务列表。根据用户的区域,为每个用户创建一个北方或其他账户(每个调用都是一个针对用户的 Task):

            var accountCreationTasks = users.Select(
                user => user.Region == RegionName.North
                    ? Task.Run(() => CreateNorthernAccount(user))
                    : Task.Run(() => CreateOtherAccount(user)))
                .ToList();

使用staticWhenAll调用等待账户创建任务列表。一旦完成,UpdatePendindAccounts将被调用,并传递更新的用户列表。这表明你可以在async语句之间传递任务列表:

            Logger.Log($"Creating {accountCreationTasks.Count} accounts");
            await Task.WhenAll(accountCreationTasks);
            var updatedAccountTask = UpdatePendingAccounts(users);
            await updatedAccountTask;
            Logger.Log($"Updated {updatedAccountTask.Result} pending accounts");
        }

FetchPendingAccounts方法返回一个包含用户列表的Task(在这里,你使用Task.Delay模拟了3秒的延迟):

        private async Task<List<User>> FetchPendingAccounts()
        {
            Logger.Log("Fetching pending accounts...");
            await Task.Delay(TimeSpan.FromSeconds(3D));
            var users = new List<User>
            {
                new User("AnnH", RegionName.North),
                new User("EmmaJ", RegionName.North),
                new User("SophieA", RegionName.South),
                new User("LucyG", RegionName.West),
            };
            Logger.Log($"Found {users.Count} pending accounts");
            return users;
        }

GenerateId使用Task.FromResult通过Guid类生成一个全局唯一 ID。Task.FromResult用于当你想要返回一个结果但不需要创建一个运行的任务时,就像使用Task.Run一样:

        private static Task<string> GenerateId()
        {
            return Task.FromResult(Guid.NewGuid().ToString());
        }

两个bool任务方法创建一个北方账户或其他账户。在这里,你返回true以指示每个账户创建调用都成功,无论:

        private static async Task<bool> CreateNorthernAccount(User user)
        {
            await Task.Delay(TimeSpan.FromSeconds(2D));
            Logger.Log($"Created northern account for {user.UserName}");
            return true;
        }
        private static async Task<bool> CreateOtherAccount(User user)
        {
            await Task.Delay(TimeSpan.FromSeconds(1D));
            Logger.Log($"Created other account for {user.UserName}");
            return true;
        }

接下来,UpdatePendingAccounts传递一个用户列表。对于每个用户,你创建一个任务来模拟一个慢速运行的调用以更新每个用户,并返回随后更新的用户数量:

        private static async Task<int> UpdatePendingAccounts(IEnumerable<User> users)
        {
            var updateAccountTasks = users.Select(usr => Task.Run(
                async () =>
                {
                    await Task.Delay(TimeSpan.FromSeconds(2D));
                    return true;
                }))
                .ToList();
            await Task.WhenAll(updateAccountTasks);
            return updateAccountTasks.Count(t => t.Result);
        }
    }

最后,控制台应用程序创建一个AccountGenerator实例,在写入All done消息之前等待CreateAccounts完成:

    public static class AsyncUsersExampleProgram
    {
        public static async Task Main()
        {
            Logger.Log("Starting");
            await new AccountGenerator().CreateAccounts();
            Logger.Log("All done");
            Console.ReadLine();
        }
    }

}

运行控制台应用程序会产生以下输出:

20:12:38 [01] Starting
20:12:38 [01] Fetching pending accounts...
20:12:41 [04] Found 4 pending accounts
20:12:41 [04] Creating 4 accounts
20:12:42 [04] Created other account for SophieA
20:12:42 [07] Created other account for LucyG
20:12:43 [04] Created northern account for EmmaJ
20:12:43 [05] Created northern account for AnnH
20:12:45 [05] Updated 4 pending accounts
20:12:45 [05] All done

在这里,你可以看到线程[01]写入Starting消息。这是应用程序的主线程。注意,主线程还从FetchPendingAccounts方法中写入Fetching pending accounts...。这仍然是以同步方式运行的,因为可等待的块(Task.Delay)尚未到达。

线程[4][5][7]创建四个用户账户中的每一个。你使用Task.Run调用CreateNorthernAccountCreateOtherAccount方法。线程[5]运行CreateAccounts: Updated 4 pending accounts中的最后一个语句。线程号可能因系统而异,因为.NET 使用一个基于每个线程繁忙程度的内部线程池。

注意

你可以在packt.link/ZIK8k找到此示例使用的代码。

异步 Lambda 表达式

第三章委托、事件和 Lambda 表达式探讨了 Lambda 表达式及其如何用于创建简洁的代码。你还可以在 Lambda 表达式中使用async关键字来创建包含各种async代码的事件处理程序代码。

以下示例使用WebClient类展示从网站下载数据的两种不同方式(这将在第八章创建和使用 Web API 客户端和第九章创建 API 服务中详细讨论)。

using System;
using System.Net;
using System.Net.Http
using System.Threading.Tasks;
namespace Chapter05.Examples
{
    public class AsyncLambdaExamples
    {
        public static async Task Main()
        {
            const string Url = "https://www.packtpub.com/";
            using var client = new WebClient();

在这里,你使用带有async关键字的 Lambda 语句将你自己的事件处理程序添加到WebClient类的DownloadDataCompleted事件中。编译器将允许你在 Lambda 表达式的主体内部添加可等待的调用。

在调用DownloadData并为我们下载所需数据之后,此事件将被触发。代码使用可等待的块Task.Delay在另一个线程上模拟一些额外的处理:

            client.DownloadDataCompleted += async (sender, args) =>
            {
                Logger.Log("Inside DownloadDataCompleted...looking busy");
                await Task.Delay(500);
                Logger.Log("Inside DownloadDataCompleted..all done now");
            };

你调用DownloadData方法,传入你的 URL,然后记录接收到的网络数据的长度。这个特定的调用本身会阻塞主线程,直到数据下载完成。WebClient提供了DownloadData方法的基于任务的异步版本,称为DownloadDataTaskAsync。因此,建议使用更现代的DownloadDataTaskAsync方法,如下所示:

            Logger.Log($"DownloadData: {Url}");
            var data = client.DownloadData(Url);
            Logger.Log($"DownloadData: Length={data.Length:N0}");

再次强调,你请求相同的 URL,但可以简单地使用await语句,该语句将在数据下载完成后执行。正如你所见,这需要更少的代码,并且语法更简洁:

            Logger.Log($"DownloadDataTaskAsync: {Url}");
            var downloadTask = client.DownloadDataTaskAsync(Url);
            var downloadBytes =  await downloadTask;
            Logger.Log($"DownloadDataTaskAsync: Length={downloadBytes.Length:N0}");
            Console.ReadLine();
        }
    }
}

运行代码会产生以下输出:

19:22:44 [01] DownloadData: https://www.packtpub.com/
19:22:45 [01] DownloadData: Length=278,047
19:22:45 [01] DownloadDataTaskAsync: https://www.packtpub.com/
19:22:45 [06] Inside DownloadDataCompleted...looking busy
19:22:45 [06] DownloadDataTaskAsync: Length=278,046
19:22:46 [04] Inside DownloadDataCompleted..all done now

注意

在运行程序时,你可能会看到以下警告:“Warning SYSLIB0014: 'WebClient.WebClient()' is obsolete: 'WebRequest, HttpWebRequest, ServicePoint, and WebClient are obsolete. Use HttpClient instead.'"。在这里,Visual Studio 建议使用HttpClient类,因为WebClient已被标记为过时。

DownloadData由线程[01](主线程)记录,该线程在下载完成前大约被阻塞一秒钟。然后使用downloadBytes.Length属性记录下载文件的长度。

DownloadDataTaskAsync请求由线程06处理。最后,DownloadDataCompleted事件处理器内部的延迟代码通过线程04完成。

注意

你可以在packt.link/IJEaU找到这个示例使用的代码。

取消任务

任务取消是一个两步过程:

  • 你需要添加一种请求取消的方式。

  • 任何可取消的代码都需要支持这一点。

如果没有这两种机制,你无法提供取消功能。

通常,你会启动一个支持取消的长运行任务,并允许用户通过在 UI 上按按钮来取消操作。在许多实际场景中需要这样的取消,例如图像处理,需要修改多个图像,如果用户时间不够,允许他们取消剩余的任务。另一个常见场景是向不同的 Web 服务器发送多个数据请求,并在收到第一个响应后立即取消慢速运行或挂起的请求。

在 C#中,CancellationTokenSource作为一个顶级对象,通过其Token属性CancellationToken来发起取消请求,并将其传递给可以定期检查并对此取消状态做出响应的并发/慢速运行代码。理想情况下,你不会希望低级方法随意取消高级操作,因此源和令牌之间有明确的分离。

CancellationTokenSource有各种构造函数,包括一个在指定时间过后会发起取消请求的构造函数。以下是CancellationTokenSource的一些方法,提供了多种发起取消请求的方式:

  • public bool IsCancellationRequested { get; }: 如果已请求取消此令牌源(调用者已调用Cancel方法),则该属性返回true。这可以在目标代码的间隔中检查。

  • public CancellationToken Token { get; }: 与此源对象关联的CancellationToken通常传递给Task.Run的重载版本,允许.NET 检查挂起任务的状态,或者允许您的代码在运行时进行检查。

  • public void Cancel(): 启动取消请求。

  • public void Cancel(bool throwOnFirstException): 启动取消请求并确定是否在发生异常时进一步处理操作。

  • public void CancelAfter(int millisecondsDelay): 在指定数量的毫秒后安排取消请求。

CancellationTokenSource具有Token属性。CancellationToken包含各种方法和属性,可用于代码检测取消请求:

  • public bool IsCancellationRequested { get; }: 该属性返回true,如果已请求取消此令牌。

  • public CancellationTokenRegistration Register(Action callback): 允许代码注册一个在令牌被取消时由系统执行的委托。

  • public void ThrowIfCancellationRequested(): 调用此方法将在请求取消时抛出OperationCanceledException。这通常用于跳出循环。

在前面的示例中,您可能已经注意到CancellationToken可以传递给许多静态Task方法。例如,Task.RunTask.Factory.StartNewTask.ContinueWith都包含接受CancellationToken的重载版本。

.NET 不会尝试中断或停止任何正在运行的代码,无论您在CancellationToken上调用Cancel多少次。本质上,您将这些令牌传递到目标代码中,但该代码必须在其能够时定期检查取消状态,例如在循环中,然后决定如何响应。这在逻辑上是合理的;.NET 如何知道何时可以安全地中断一个方法,比如一个可能有数百行代码的方法?

CancellationToken传递给Task.Run仅向队列调度器提供提示,表明可能不需要启动任务的操作,但一旦启动,.NET 不会为您停止正在运行的代码。运行中的代码本身必须随后观察取消状态。

这与一个行人等待在交通灯处过马路的情况类似。机动车辆可以被视为在其他地方已启动的任务。当行人到达交叉路口并按下按钮(在CancellationTokenSource上调用Cancel)时,交通灯最终应该变为红色,以便请求移动的车辆停止。是否停止车辆取决于每个驾驶员是否注意到红灯已变亮(检查IsCancellationRequested),然后决定停止他们的车辆。交通灯不会强制停止每辆车(.NET 运行时)。如果驾驶员注意到后面的车辆太近,并且很快停止可能会导致碰撞,他们可能会决定不立即停车。一个完全不观察交通灯状态的驾驶员可能会未能停车。

下面的章节将继续通过练习展示async/await的实际应用,一些常用的取消任务选项,在这些选项中,你需要控制是否允许挂起的任务完成或中断,以及何时应该尝试捕获异常。

练习 5.04:取消长时间运行的任务

你将分两部分创建这个练习:

  • 使用返回基于双精度浮点数结果的Task

  • 第二个提供了通过检查Token.IsCancellationRequested属性提供精细级别控制的选项。

执行以下步骤来完成这个练习:

  1. 创建一个名为SlowRunningService的类。正如其名所示,服务内部的方法已被设计为执行缓慢:

    using System;
    using System.Globalization;
    using System.Threading;
    using System.Threading.Tasks;
    namespace Chapter05.Exercises.Exercise04
    {
        public class SlowRunningService
        {
    
  2. 添加第一个慢速运行的操作Fetch,它接受一个延迟时间(通过简单的Thread.Sleep调用实现),以及取消令牌,你将其传递给Task.Run

            public Task<double> Fetch(TimeSpan delay, CancellationToken token)
            {
                return Task.Run(() =>
                    {
                        var now = DateTime.Now;
                        Logger.Log("Fetch: Sleeping");
                        Thread.Sleep(delay);
                        Logger.Log("Fetch: Awake");
                        return DateTime.Now.Subtract(now).TotalSeconds;
                    },
                    token);
            }
    

当调用Fetch时,在休眠线程醒来之前,令牌可能会被取消。

  1. 为了测试Fetch是否会停止运行或返回一个数字,添加一个控制台应用程序来测试这个。在这里,使用默认延迟(DelayTime)为3秒:

        public class Program
        {
            private static readonly TimeSpan DelayTime=TimeSpan.FromSeconds(3);
    
  2. 添加一个辅助函数来提示你准备等待的最大秒数。如果输入了有效的数字,将输入的值转换为TimeSpan

            private static TimeSpan? ReadConsoleMaxTime(string message)
            {
                Console.Write($"{message} Max Waiting Time (seconds):");
                var input = Console.ReadLine();
                if (int.TryParse(input, NumberStyles.Any, CultureInfo.CurrentCulture, out var intResult))
                {
                    return TimeSpan.FromSeconds(intResult);
                }
                return null;
            }
    
  3. 为控制台应用程序添加一个标准的Main入口点。这个入口点标记为异步并返回一个Task

    public static async Task Main()
            {
    
  4. 创建服务的一个实例。你将在循环中使用相同的实例,很快:

                var service = new SlowRunningService();
    
  5. 现在添加一个do循环,反复请求最大延迟时间:

              Console.WriteLine($"ETA: {DelayTime.TotalSeconds:N} seconds");  
    
              TimeSpan? maxWaitingTime;
                while (true)
                {
                    maxWaitingTime = ReadConsoleMaxTime("Fetch");
                    if (maxWaitingTime == null)
                        break;
    

这允许你尝试不同的值,以查看它如何影响取消令牌和返回的结果。在null值的情况下,你将退出do循环。

  1. 创建CancellationTokenSource,传入最大等待时间:

                    using var tokenSource = new CancellationTokenSource( maxWaitingTime.Value);
                    var token = tokenSource.Token;
    

这将触发取消,而无需你自己调用Cancel方法。

  1. 使用CancellationToken.Register方法,传递一个在令牌被信号取消时要调用的Action委托。在这里,简单地在发生这种情况时记录一条消息:

                    token.Register(() => Logger.Log($"Fetch: Cancelled token={token.GetHashCode()}"));
    
  2. 现在对于主要活动,调用服务的Fetch方法,传入默认的DelayTime和令牌:

                    var resultTask = service.Fetch(DelayTime, token);
    
  3. 在你等待resultTask之前,添加一个try-catch块来捕获任何TaskCanceledException

                    try
                    {
                        await resultTask;
                        if (resultTask.IsCompletedSuccessfully)
                            Logger.Log($"Fetch: Result={resultTask.Result:N0}");
                        else
                            Logger.Log($"Fetch: Status={resultTask.Status}");
                    }
                    catch (TaskCanceledException ex)
                    {
                        Logger.Log($"Fetch: TaskCanceledException {ex.Message}");
                    }
                }
            }
        }
    }
    

当使用可取消的任务时,它们可能会抛出TaskCanceledException。在这种情况下,这是可以接受的,因为你确实期望这种情况发生。请注意,你只有在任务被标记为IsCompletedSuccessfully时才访问resultTask.Result。如果你尝试访问一个已故障任务的Result属性,则会抛出AggregateException实例。在一些较旧的项目中,你可能看到捕获AggregateException的非异步/await 代码。

  1. 运行应用并输入一个大于三秒预计到达时间的等待时间,在这个例子中是5

    ETA: 3.00 seconds
    Fetch Max Waiting Time (seconds):5
    16:48:11 [04] Fetch: Sleeping
    16:48:14 [04] Fetch: Awake
    16:48:14 [04] Fetch: Result=3
    

如预期的那样,在完成之前令牌没有被取消,所以你看到Result=3(已过时间,单位为秒)。

  1. 再试一次。为了触发并检测取消,将秒数输入为2

    Fetch Max Waiting Time (seconds):2
    16:49:51 [04] Fetch: Sleeping
    16:49:53 [08] Fetch: Cancelled token=28589617
    16:49:54 [04] Fetch: Awake
    16:49:54 [04] Fetch: Result=3 
    

注意,当Fetch唤醒时,记录了Cancelled token消息,但你最终仍然收到了3秒的结果,没有TaskCanceledException消息。这强调了将取消令牌传递给Start.Run不会停止任务动作的启动,更重要的是,它也没有中断它。

  1. 最后,使用0作为最大等待时间,这将有效地立即触发取消:

    Fetch Max Waiting Time (seconds):
    0
    16:53:32 [04] Fetch: Cancelled token=48717705
    16:53:32 [04] Fetch: TaskCanceledException A task was canceled. 
    

你将看到取消令牌消息和捕获到的TaskCanceledException,但没有任何SleepingAwake消息被记录。这表明传递给Task.RunAction实际上并没有被运行时启动。当你将CancelationToken传递给Start.Run时,任务的动作会被排队,但如果TaskScheduler在启动之前注意到令牌已被取消,它将不会运行该动作;它只会抛出TaskCanceledException

现在对于一种替代的慢速运行方法,它允许你通过轮询取消状态的变化来支持可取消的动作。

  1. SlowRunningService类中,添加一个FetchLoop函数:

            public Task<double?> FetchLoop(TimeSpan delay, CancellationToken token)
            {
                return Task.Run(() =>
                {
                    const int TimeSlice = 500;
                    var iterations = (int)(delay.TotalMilliseconds / TimeSlice);
                    Logger.Log($"FetchLoop: Iterations={iterations} token={token.GetHashCode()}");
                    var now = DateTime.Now;
    

这会产生与之前Fetch函数类似的结果,但其目的是展示一个函数如何被分解成一个重复的循环,该循环在每次循环迭代运行时能够检查CancellationToken

  1. 定义for...next循环的主体,该循环在每次迭代中检查IsCancellationRequested属性是否为true,如果检测到请求取消,则简单地返回一个可空的double值:

                    for (var i = 0; i < iterations; i++)
                    {
                        if (token.IsCancellationRequested)
                        {
                            Logger.Log($"FetchLoop: Iteration {i + 1} detected cancellation token={token.GetHashCode()}");
                            return (double?)null;
                        }
                        Logger.Log($"FetchLoop: Iteration {i + 1} Sleeping");
                        Thread.Sleep(TimeSlice);
                        Logger.Log($"FetchLoop: Iteration {i + 1} Awake");
                    }
                    Logger.Log("FetchLoop: done");
                    return DateTime.Now.Subtract(now).TotalSeconds;
                }, token);
            }
    

这是一种相当坚决的退出循环的方式,但就这段代码而言,不需要做其他任何事情。

注意

你也可以使用continue语句并在返回之前进行清理。另一种选择是调用token.ThrowIfCancellationRequested()而不是检查token.IsCancellationRequested,这将迫使你退出for循环。

  1. Main 控制台应用程序中,添加一个类似的 while 循环,这次调用 FetchLoop 方法。代码与之前的循环代码类似:

            while (true)
                {
                    maxWaitingTime = ReadConsoleMaxTime("FetchLoop");
                    if (maxWaitingTime == null)
                        break;
                    using var tokenSource = new CancellationTokenSource(maxWaitingTime.Value);
                    var token = tokenSource.Token;
                    token.Register(() => Logger.Log($"FetchLoop: Cancelled token={token.GetHashCode()}"));
    
  2. 现在调用 FetchLoop 并等待结果:

                    var resultTask = service.FetchLoop(DelayTime, token);
                    try
                    {
                        await resultTask;
                        if (resultTask.IsCompletedSuccessfully)
                            Logger.Log($"FetchLoop: Result={resultTask.Result:N0}");
                        else
                            Logger.Log($"FetchLoop: Status={resultTask.Status}");
                    }
                    catch (TaskCanceledException ex)
                    {
                        Logger.Log($"FetchLoop: TaskCanceledException {ex.Message}");
                    }
                } 
    
  3. 运行控制台应用程序并使用 5 秒最大值允许所有迭代运行,没有任何检测到取消请求。结果是预期的 3

    FetchLoop Max Waiting Time (seconds):5
    17:33:38 [04] FetchLoop: Iterations=6 token=6044116
    17:33:38 [04] FetchLoop: Iteration 1 Sleeping
    17:33:38 [04] FetchLoop: Iteration 1 Awake
    17:33:38 [04] FetchLoop: Iteration 2 Sleeping
    17:33:39 [04] FetchLoop: Iteration 2 Awake
    17:33:39 [04] FetchLoop: Iteration 3 Sleeping
    17:33:39 [04] FetchLoop: Iteration 3 Awake
    17:33:39 [04] FetchLoop: Iteration 4 Sleeping
    17:33:40 [04] FetchLoop: Iteration 4 Awake
    17:33:40 [04] FetchLoop: Iteration 5 Sleeping
    17:33:40 [04] FetchLoop: Iteration 5 Awake
    17:33:40 [04] FetchLoop: Iteration 6 Sleeping
    17:33:41 [04] FetchLoop: Iteration 6 Awake
    17:33:41 [04] FetchLoop: done
    17:33:41 [04] FetchLoop: Result=3
    
  4. 使用 2 作为最大值。这次在迭代 4 时自动触发令牌,并在迭代 5 时被发现,因此返回了一个空结果:

    FetchLoop Max Waiting Time (seconds):
    2
    17:48:47 [04] FetchLoop: Iterations=6 token=59817589
    17:48:47 [04] FetchLoop: Iteration 1 Sleeping
    17:48:48 [04] FetchLoop: Iteration 1 Awake
    17:48:48 [04] FetchLoop: Iteration 2 Sleeping
    17:48:48 [04] FetchLoop: Iteration 2 Awake
    17:48:48 [04] FetchLoop: Iteration 3 Sleeping
    17:48:49 [04] FetchLoop: Iteration 3 Awake
    17:48:49 [04] FetchLoop: Iteration 4 Sleeping
    17:48:49 [06] FetchLoop: Cancelled token=59817589
    17:48:49 [04] FetchLoop: Iteration 4 Awake
    17:48:49 [04] FetchLoop: Iteration 5 detected cancellation token=59817589
    17:48:49 [04] FetchLoop: Result=
    
  5. 使用 0,您将看到与之前的 Fetch 示例相同的输出:

    FetchLoop Max Waiting Time (seconds):
    0
    17:53:29 [04] FetchLoop: Cancelled token=48209832
    17:53:29 [08] FetchLoop: TaskCanceledException A task was canceled.
    

动作没有机会运行。您可以看到一个 Cancelled token 消息和 TaskCanceledException 被记录。

通过运行此练习,您已经看到长时间运行的任务如果未在指定时间内完成,.NET 运行时将自动将其标记为取消。通过使用 for 循环,任务被分解成小的迭代步骤,这提供了频繁检测是否请求取消的机会。

注意

您可以在 packt.link/xa1Yf 找到用于此练习的代码。

异步/等待代码中的异常处理

您已经看到取消任务可能导致抛出 TaskCanceledException。异步代码的异常处理可以像标准同步代码一样实现,但您需要注意一些事项。

async 方法中的代码抛出异常时,任务的状态被设置为 Faulted。然而,异常不会重新抛出,直到等待的表达式被重新安排。这意味着如果您不等待调用,则可能抛出异常,并且这些异常在代码中可能完全未被观察。

除非您绝对无法避免,否则不应创建 async void 方法。这样做会使调用者难以等待您的代码。这意味着他们无法捕获抛出的任何异常,默认情况下,这会导致程序终止。如果调用者没有 Task 引用以等待,那么他们就没有办法知道被调用的方法是否运行完成。

此指南的一般例外是在本章开头提到的“一次性”方法。异步记录应用程序使用情况的方法可能不是那么关键,因此您可能不关心这些调用是否成功。

可以检测和处理未观察到的任务异常。如果您将事件委托附加到静态 TaskScheduler.UnobservedTaskException 事件,您将收到通知,表示任务异常未被观察。您可以通过以下方式将委托附加到该事件:

TaskScheduler.UnobservedTaskException += (sender, args) =>
{
  Logger.Log($"Caught UnobservedTaskException\n{args.Exception}");
};

当任务对象被最终化后,运行时会将任务异常视为 未观察到的

注意

您可以在 packt.link/OkH7r 找到用于此示例的代码。

继续使用一些异常处理示例,看看你如何可以像同步代码一样捕获特定类型的异常。

在以下示例中,CustomerOperations类提供了AverageDiscount函数,它返回Task<int>。然而,它可能会抛出DivideByZeroException,所以你需要捕获它;否则,程序将崩溃。

using System;
using System.Threading.Tasks;
namespace Chapter05.Examples
{
    class ErrorExamplesProgram
    {
        public static async Task Main()
        {
            try
            {

创建一个CustomerOperations实例并等待AverageDiscount方法返回一个值:

                var operations = new CustomerOperations();
                var discount = await operations.AverageDiscount();
                Logger.Log($"Discount: {discount}");
            }
            catch (DivideByZeroException)
            {
                Console.WriteLine("Caught a divide by zero");
            }
            Console.ReadLine();
        }
        class CustomerOperations
        {
            public async Task<int> AverageDiscount()
            {
                Logger.Log("Loading orders...");
                await Task.Delay(TimeSpan.FromSeconds(1));

02之间选择一个随机的ordercount值。尝试除以零将导致.NET 运行时抛出异常:

                var orderCount = new Random().Next(0, 2);
                var orderValue = 1200;
                return orderValue / orderCount;
            }
        }
    }
}

结果显示,当orderCount为零时,你确实如预期那样捕获了DivideByZeroException

15:47:21 [01] Loading orders...
Caught a divide by zero

第二次运行时,没有捕获到错误:

17:55:54 [01] Loading orders...
17:55:55 [04] Discount: 1200

在你的系统上,你可能需要多次运行程序,DivideByZeroException才会被引发。这是由于使用了随机实例来分配orderCount的值。

注意

你可以在packt.link/18kOK找到这个示例使用的代码。

所以到目前为止,你已经创建了可能会抛出异常的单个任务。接下来的练习将查看一个更复杂的变体。

练习 5.05:处理异步异常

假设你有一个CustomerOperations类,它可以用来通过Task获取客户列表。对于每个客户,你需要运行一个额外的async任务,该任务将去到一个服务中计算该客户订单的总价值。

一旦你有了客户列表,需要按销售额降序对客户进行排序,但由于一些安全限制,如果你读取的客户区域名称是West,则不允许读取客户的TotalOrders属性。在这个练习中,你将创建一个在早期示例中使用的RegionName枚举的副本。

执行以下步骤来完成这个练习:

  1. 首先添加Customer类:

    1    using System;
    2    using System.Collections.Generic;
    3    using System.Linq;
    4    using System.Threading.Tasks;
    5
    6    namespace Chapter05.Exercises.Exercise05
    7    {
    8        public enum RegionName { North, East, South, West };
    9
    10        public class Customer
    11        {
    12            private readonly RegionName _protectedRegion;
    13
    14            public Customer(string name, RegionName region, RegionName protectedRegion)
    15            {
    

构造函数传递客户的name和他们的region,以及一个标识protectedRegion名称的第二个区域。如果客户的region与这个protectedRegion相同,则在尝试读取TotalOrders属性时抛出访问违规异常。

  1. 然后添加一个CustomerOperations类:

    public class CustomerOperations
    {
       public const RegionName ProtectedRegion = RegionName.West;
    

这个类知道如何加载一个客户的名字并填充他们的总订单价值。这里的要求是来自West区域的客户需要有一个硬编码的限制,所以添加一个名为ProtectedRegion的常量,其值为RegionName.West

  1. 添加一个FetchTopCustomers函数:

            public async Task<IEnumerable<Customer>> FetchTopCustomers()
            {
                await Task.Delay(TimeSpan.FromSeconds(2));
                Logger.Log("Loading customers...");
                var customers = new List<Customer>
                {
                new Customer("Rick Deckard", RegionName.North, ProtectedRegion),
                new Customer("Taffey Lewis", RegionName.North, ProtectedRegion),
                new Customer("Rachael", RegionName.North, ProtectedRegion),
                new Customer("Roy Batty", RegionName.West, ProtectedRegion),
                new Customer("Eldon Tyrell", RegionName.East, ProtectedRegion)
                };
    

这将返回一个CustomerTask枚举,并且被标记为async,因为你在函数内部将进行进一步的async调用以填充每个客户的订单详情。使用Task.Delay来模拟一个慢速运行的操作。在这里,有一个硬编码的客户样本列表。创建每个Customer实例,传递他们的名字、实际区域和受保护的区域常量ProtectedRegion

  1. FetchOrders 中添加一个 await 调用(稍后将声明):

                await FetchOrders(customers);
    
  2. 现在,遍历客户列表,但请确保将每个对 TotalOrders 的调用用 try-catch 块包装起来,该块明确检查如果尝试查看受保护的客户将抛出的访问违规异常:

                var filteredCustomers = new List<Customer>();
                foreach (var customer in customers)
                {
                    try
                    {
                        if (customer.TotalOrders > 0)
                            filteredCustomers.Add(customer);
                    }
                    catch (AccessViolationException e)
                    {
                        Logger.Log($"Error {e.Message}");
                    }
                }
    
  3. 现在,filteredCustomers 列表已经填充了一个过滤后的客户列表,使用 Linq 的 OrderByDescending 扩展方法按每个客户的 TotalOrders 值返回项目:

                return filteredCustomers.OrderByDescending(c => c.TotalOrders);
            } 
    
  4. 完成带有 FetchOrders 实现的 CustomerOperations

  5. 对于列表中的每个客户,使用一个暂停 500 毫秒的 async lambda,然后为 TotalOrders 分配一个随机值:

            private async Task FetchOrders(IEnumerable<Customer> customers)
            {
                var rand = new Random();
                Logger.Log("Loading orders...");
                var orderUpdateTasks = customers.Select(
                  cust => Task.Run(async () =>
                  {
                        await Task.Delay(500);
                        cust.TotalOrders = rand.Next(1, 100);
                   }))
                  .ToList();
    

延迟可能代表另一个运行缓慢的服务。

  1. 使用 Task.WhenAll 等待 orderUpdateTasks 完成:

                await Task.WhenAll(orderUpdateTasks);
            }
        }
    
  2. 现在创建一个控制台应用程序来运行操作:

        public class Program
        {
            public static async Task Main()
            {
                var ops = new CustomerOperations();
                var resultTask = ops.FetchTopCustomers();
                var customers = await resultTask;
                foreach (var customer in customers)
                {
                    Logger.Log($"{customer.Name} ({customer.Region}): {customer.TotalOrders:N0}");
                }
                Console.ReadLine();
            }
        }
    }
    
  3. 在运行控制台时,没有错误,因为来自 West 区域的 Roy Batty 被安全地跳过了:

    20:00:15 [05] Loading customers...
    20:00:16 [05] Loading orders...
    20:00:16 [04] Error Cannot access orders for Roy Batty
    20:00:16 [04] Rachael (North): 56
    20:00:16 [04] Taffey Lewis (North): 19
    20:00:16 [04] Rick Deckard (North): 10
    20:00:16 [04] Eldon Tyrell (East): 6
    

在这个练习中,您看到了如何使用异步代码优雅地处理异常。您在所需位置放置了一个 try-catch 块,而不是过度复杂化并添加过多的不必要的嵌套 try-catch 块。当代码运行时,捕获了一个异常而没有使应用程序崩溃。

注意

您可以在 packt.link/4ozac 找到用于此练习的代码。

AggregateException 类

在本章开头,您看到 Task 类有一个 Exception 属性,其类型为 AggregateException。此类包含有关在异步调用期间发生的一个或多个错误的详细信息。

AggregateException 有各种属性,但主要如下:

  • public ReadOnlyCollection<Exception> InnerExceptions { get; }: 由当前异常引起的异常集合。单个异步调用可能导致多个异常被引发并收集在此处。

  • public AggregateException Flatten(): 将 InnerExeceptions 属性中的所有 AggregateException 实例合并为一个单一的新实例。这可以避免您需要遍历嵌套在异常列表中的 AggregateException

  • public void Handle(Func<Exception, bool> predicate): 对此聚合异常中的每个异常调用指定的 Func 处理程序。这允许处理程序返回 truefalse 以指示每个异常是否被处理。任何剩余未处理的异常将抛出,由调用者按需捕获。

当出现问题时并且此异常被调用者捕获时,InnerExceptions 包含导致当前异常的异常列表。这些可能来自多个任务,因此每个单独的异常都被添加到结果任务的 InnerExceptions 集合中。

你可能会经常遇到带有try-catch块来捕获AggregateException并记录每个InnerExceptions详细信息的async代码。在这个例子中,BadTask返回一个基于int的任务,但它运行时可能会引发异常。执行以下步骤来完成此示例:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Chapter05.Examples
{
    class WhenAllErrorExamples
    {+

如果传入的数字是偶数(使用%运算符来查看数字是否可以被2整除且没有余数),则它会在抛出InvalidOperationException之前睡眠1,000毫秒:

        private static async Task<int> BadTask(string info, int n)
        {
            await Task.Delay(1000);
            Logger.Log($"{info} number {n} awake");
            if (n % 2 == 0)
            {
                Logger.Log($"About to throw one {info} number {n}"…");
                throw new InvalidOperationException"($"Oh dear from {info} number "n}");
            }
            return n;
        }

添加一个辅助函数CreateBadTasks,该函数创建一个包含五个坏任务的集合。当启动时,每个任务最终都会抛出InvalidOperationException类型的异常:

        private static IEnumerable<Task<int>> CreateBadTasks(string info)
        {
            return Enumerable.Range(0, 5)
                .Select(i => BadTask(info, i))
                .ToList();
        }

现在,创建控制台应用程序的Main入口点。你将CreateBadTasks的结果传递给WhenAll,传递字符串[WhenAll]以便更容易地在输出中看到正在发生的事情:

        public static async Task Main()
        {
            var whenAllCompletedTask = Task.WhenAll(CreateBadTasks("[WhenAll]"));

在尝试等待whenAllCompletedTask任务之前,你需要将其包裹在try-catch中,以捕获基础Exception类型(如果你期望更具体的一个,则可以捕获更具体的一个)。

你不能在这里捕获AggregateException,因为它是你接收到的Task中的第一个异常,但你仍然可以使用whenAllCompletedTaskException属性来获取AggregateException本身:

            try
            {
                await whenAllCompletedTask;
            }
            catch (Exception ex)
            {

你已经捕获了一个异常,因此记录其类型(这将是你抛出的InvalidOperationException实例)和消息:

                Console.WriteLine($"WhenAll Caught {ex.GetType().Name}, Message={ex.Message}");

现在,你可以检查whenAllCompletedTask,通过迭代此任务的AggregateException来查看其InnerExceptions列表:

                Console.WriteLine($"WhenAll Task.Status={whenAllCompletedTask.Status}");
               foreach (var ie in whenAllCompletedTask.Exception.InnerExceptions)
               {
                   Console.WriteLine($"WhenAll Caught Inner Exception: {ie.Message}");
               }
            }
            Console.ReadLine();
        }      
    }
}

运行代码,你会看到五个任务在睡眠,最终数字024各自抛出InvalidOperationException,这将由你捕获:

17:30:36 [05] [WhenAll] number 3 awake
17:30:36 [09] [WhenAll] number 1 awake
17:30:36 [07] [WhenAll] number 0 awake
17:30:36 [06] [WhenAll] number 2 awake
17:30:36 [04] [WhenAll] number 4 awake
17:30:36 [06] About to throw one [WhenAll] number 2...
17:30:36 [04] About to throw one [WhenAll] number 4...
17:30:36 [07] About to throw one [WhenAll] number 0...
WhenAll Caught InvalidOperationException, Message=Oh dear from [WhenAll] number 0
WhenAll Task.Status=Faulted
WhenAll Caught Inner Exception: Oh dear from [WhenAll] number 0
WhenAll Caught Inner Exception: Oh dear from [WhenAll] number 2
WhenAll Caught Inner Exception: Oh dear from [WhenAll] number 4

注意数字 0似乎是被捕获的唯一错误((Message=Oh dear from [WhenAll] number 0))。然而,通过记录InnerExceptions列表中的每个条目,你会看到数字 0`再次出现。

你可以尝试相同的代码,但这次使用WhenAny。记住,WhenAny将在列表中的第一个任务完成时完成,所以请注意在这种情况下错误处理的完全缺失:

            var whenAnyCompletedTask = Task.WhenAny(CreateBadTasks("[WhenAny]"));
            var result = await whenAnyCompletedTask;
            Logger.Log($"WhenAny result: {result.Result}");

除非你等待所有任务完成,否则在使用WhenAny时可能会错过任务引发的异常。运行此代码会导致没有捕获到任何错误,并且应用执行3,因为这是第一个完成的:

18:08:46 [08] [WhenAny] number 2 awake
18:08:46 [10] [WhenAny] number 0 awake
18:08:46 [10] About to throw one [WhenAny] number 0...
18:08:46 [07] [WhenAny] number 3 awake
18:08:46 [09] [WhenAny] number 1 awake
18:08:46 [07] WhenAny result: 3
18:08:46 [08] About to throw one [WhenAny] number 2...
18:08:46 [06] [WhenAny] number 4 awake
18:08:46 [06] About to throw one [WhenAny] number 4...

你将通过查看 C#中处理async结果流的一些较新选项来完成对async/await代码的审视。这提供了一种方法,可以在调用代码等待整个集合被填充并返回之前,高效地遍历集合中的项目。

注意

你可以在packt.link/SuCXK找到此示例使用的代码。

IAsyncEnumerable 流

如果你的应用程序针对.NET 5、.NET6、.NET Core 3.0、.NET Standard 2.1 或任何后续版本,那么你可以使用IAsyncEnumerable流来创建可等待的代码,将yield关键字结合到枚举器中,以异步方式遍历对象集合。

注意

微软的文档提供了yield关键字的以下定义:当在迭代方法中达到yield返回语句时,表达式返回,并保留代码中的当前位置。下一次迭代函数被调用时,从该位置重新启动执行。

使用yield语句,你可以创建返回项目枚举的方法。此外,调用者不需要等待返回整个列表的所有项目,就可以开始遍历列表中的每个项目。相反,调用者可以在项目可用时立即访问每个项目。

在此示例中,你将创建一个控制台应用程序,该程序复制了一个保险报价系统。你将发出五个保险报价请求,再次使用Task.Delay来模拟接收每个报价的 1 秒延迟。

对于基于列表的方法,你只能在所有五个结果都返回到Main方法后才能记录每个引用一次。使用IAsyncEnumerableyield关键字,引用接收之间存在相同的一秒间隔,但一旦接收到每个引用,yield语句就允许调用Main方法接收并处理引用的值。如果你希望立即开始处理项目或者可能不想在处理单个项目所需的时间之外在列表中保留数千个项目,这是理想的:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Chapter05.Examples
{
    class AsyncEnumerableExamplesProgram
    {
        public static async Task Main()
        {

首先,通过GetInsuranceQuotesAsTask返回一个字符串列表并遍历每个,记录每个报价的详细信息。此代码将在接收到所有报价之前等待所有报价:

            Logger.Log("Fetching Task quotes...");
            var taskQuotes = await GetInsuranceQuotesAsTask();
            foreach(var quote in taskQuotes)
            {
                Logger.Log($"Received Task: {quote}");
            }

现在是async流版本。如果你将以下代码与前面的代码块进行比较,你会看到需要迭代的代码行更少。此代码不会等待接收到所有引用项,而是从GetInsuranceQuotesAsync接收到每个引用后立即将其写出:

            Logger.Log("Fetching Stream quotes...");
            await foreach (var quote in GetInsuranceQuotesAsync())
            {
                Logger.Log($"Received Stream: {quote}");
            }
            Logger.Log("All done...");
            Console.ReadLine();
        }

GetInsuranceQuotesAsTask方法返回一个字符串的Task。在五个引用之间的每个引用之间,你等待一秒钟来模拟延迟,然后将结果添加到列表中,并最终将整个列表返回给调用者:

        private static async Task<IEnumerable<string>> GetInsuranceQuotesAsTask()
        {
            var rand = new Random();
            var quotes = new List<string>();
            for (var i = 0; i < 5; i++)
            {
                await Task.Delay(1000);
                quotes.Add($"Provider{i}'s quote is {rand.Next(5, 10)}");
            }
            return quotes;
        } 

GetInsuranceQuotesAsync方法在每个引用之间有相同的延迟,但不是填充列表以返回给调用者,而是使用yield语句允许Main方法立即处理每个引用项:

        private static async IAsyncEnumerable<string> GetInsuranceQuotesAsync()
        {
            var rand = new Random();
            for (var i = 0; i < 5; i++)
            {
                await Task.Delay(1000);
                yield return $"Provider{i}'s quote is {rand.Next(5, 10)}";
            }
        }
    }
}

运行控制台应用程序会产生以下输出:

09:17:57 [01] Fetching Task quotes...
09:18:02 [04] Received Task: Provider0's quote is 7
09:18:02 [04] Received Task: Provider1's quote is 9
09:18:02 [04] Received Task: Provider2's quote is 9
09:18:02 [04] Received Task: Provider3's quote is 8
09:18:02 [04] Received Task: Provider4's quote is 8
09:18:02 [04] Fetching Stream quotes...
09:18:03 [04] Received Stream: Provider0's quote is 7
09:18:04 [04] Received Stream: Provider1's quote is 8
09:18:05 [05] Received Stream: Provider2's quote is 9
09:18:06 [05] Received Stream: Provider3's quote is 8
09:18:07 [04] Received Stream: Provider4's quote is 7
09:18:07 [04] All done...

线程 [04] 在应用程序启动后 5 秒内记录了所有五个基于任务的引用详情。在这里,它等待所有引用返回后才记录每个引用。然而,请注意,基于流的每个引用都在线程45之间产生了 1 秒的间隔后立即被记录。

两次调用所需的总时间相同(总共 5 秒),但当你想要一有结果就立即开始处理时,yield更可取。这在 UI 应用程序中非常有用,你可以向用户提供早期结果。

注意

你可以在packt.link/KarKW找到此示例使用的代码。

并行编程

到目前为止,本章已经介绍了使用Task类和async/await关键字进行异步编程。你已经看到了如何定义任务和async代码块,以及随着这些结构的完成,程序流程可以精细控制。

并行框架(PFX)提供了进一步利用多核处理器以高效运行并发操作的方法。术语 TPL(任务并行库)通常用来指代 C#中的Parallel类。

使用并行框架,你不需要担心创建和重用线程或协调多个任务的复杂性。框架为你管理这些,甚至调整使用的线程数量,以最大化吞吐量。

为了使并行编程有效,每个任务执行的顺序必须是无关紧要的,并且所有任务都应该相互独立,因为你不能确定何时一个任务完成,下一个任务开始。协调会抵消任何好处。并行编程可以分解为两个不同的概念:

  • 数据并行

  • 任务并行

数据并行

当你有多个数据值,并且需要将这些值中的每个值都并发应用相同的操作时,就会使用数据并行。在这种情况下,对每个值的处理被分配到不同的线程中。

一个典型的例子可能是计算从 1 到 1,000,000 之间的所有质数。对于范围内的每个数字,都需要应用相同的函数来确定该值是否为质数。与其逐个迭代每个数字,不如采用异步方法,将数字分配到多个线程中。

任务并行

相反,当一组线程同时执行不同的操作时,例如调用不同的函数或代码段,就会使用任务并行。一个这样的例子是分析一本书中找到的单词的程序,通过下载书的文本并定义以下单独的任务:

  • 计算单词数量。

  • 找到最长的单词。

  • 计算平均单词长度。

  • 计算噪声词(例如,the、and、of)的数量。

每个这些任务都可以并发运行,并且它们之间互不依赖。

对于Parallel类,Parallel Framework 提供了各种层,提供了并行性,包括 Parallel Language Integrated Query (PLINQ)。PLINQ 是一组扩展方法,它将并行编程的力量添加到 LINQ 语法中。这里不会详细介绍 PLINQ,但会对Parallel类进行更详细的介绍。

Note

如果你想了解更多关于 PLINQ 的信息,可以参考在线文档docs.microsoft.com/en-us/dotnet/standard/parallel-programming/introduction-to-plinq

The Parallel Class

Parallel类仅包含三个static方法,但提供了许多重载,以提供控制并影响操作执行方式的选择。Parallel类中的每个方法通常在awaitable块(如Task.Run)内部调用。

值得记住的是,运行时只有在认为有理由的情况下才会并行运行所需的操作。在单个步骤比其他步骤更快完成的情况下,运行时可能会决定并行运行剩余操作的开销是不合理的。

一些常用的Parallel方法重载如下:

  • public static ParallelLoopResult For(int from, int to, Action<int> body): 这是一个数据并行调用,通过调用Action委托体来执行循环,将一个int值传递给从到到数字范围的每个值。它返回ParallelLoopResult,其中包含循环完成后的详细信息。

  • public static ParallelLoopResult For(int from, int to, ParallelOptions options, Action<int, ParallelLoopState> body): 这是一个数据并行调用,在数字范围内执行循环。ParallelOptions允许配置循环选项,ParallelLoopState用于在运行时监控或操作循环的状态。它返回ParallelLoopResult

  • public static ParallelLoopResult ForEach<TSource>(IEnumerable<TSource> source, Action<TSource, ParallelLoopState> body): 一个数据并行调用,在IEnumerable源中的每个项目上调用Action委托体。它返回ParallelLoopResult

  • public static ParallelLoopResult ForEach<TSource>(Partitioner<TSource> source, Action<TSource> body): 一个高级数据并行调用,调用Action委托体,并允许你指定Partitioner来提供针对特定数据结构优化的分区策略,以提高性能。它返回ParallelLoopResult

  • public static void Invoke(params Action[] actions): 一个任务并行调用,执行传递的每个操作。

  • public static void Invoke(ParallelOptions parallelOptions, params Action[] actions): 一个任务并行调用,执行每个操作,并允许指定ParallelOptions来配置方法调用。

The ParallelOptions class can be used to configure how the Parallel methods operate:

  • public CancellationToken CancellationToken { get; set; }:熟悉的取消令牌,可用于在循环中检测调用者是否请求取消。

  • public int MaxDegreeOfParallelism { get; set; }:一个高级设置,确定一次可以启用多少个并发任务的最大数量。

  • public TaskScheduler? TaskScheduler { get; set; }:一个高级设置,允许设置某种类型的任务队列调度程序。

ParallelLoopState可以被传递到Action的主体中,以便该操作可以确定或监控通过循环的流程。最常用的属性如下:

  • public bool IsExceptional { get; }: 如果迭代抛出了未处理的异常,则返回true

  • public bool IsStopped { get; }: 如果迭代通过调用Stop方法停止了循环,则返回true

  • public void Break(): Action循环可以调用此方法来指示执行应在当前迭代之后停止。

  • public void Stop(): 请求循环应在当前迭代处停止执行。

  • ParallelLoopResult,由ForForEach方法返回,包含Parallel循环的完成状态。

  • public bool IsCompleted { get; }: 表示循环运行完成,并且在完成之前没有收到结束请求。

  • public long? LowestBreakIteration { get; }:如果Break在循环运行时被调用。这返回循环到达的最低迭代索引。

使用Parallel类并不意味着特定的批量操作会自动完成得更快。在任务调度中存在开销,因此在运行过短或过长的任务时应谨慎。遗憾的是,没有简单的指标可以确定这里的最佳数值。通常情况下,需要通过分析来查看是否使用Parallel类确实可以更快地完成操作。

注意

你可以在docs.microsoft.com/en-us/dotnet/standard/parallel-programming/potential-pitfalls-in-data-and-task-parallelism找到有关数据和任务并行性的更多信息。

Parallel.For 和 Parallel.ForEach

这两个方法提供数据并行性。相同的操作应用于数据对象或数字的集合。为了从中受益,每个操作应该是 CPU 密集型的,也就是说,它应该需要 CPU 周期来执行,而不是 I/O 密集型(例如访问文件)。

使用这两种方法,你定义一个要应用的操作,该操作传递一个对象实例或数字来处理。在Parallel.ForEach的情况下,Action传递一个对象引用参数。Parallel.For传递一个数字参数。

如你在第三章委托、事件和 Lambda 表达式中看到的,Action委托代码可以像你需要的那样简单或复杂:

using System;
using System.Threading.Tasks;
using System.Globalization;
using System.Threading;
namespace Chapter05.Examples
{
    class ParallelForExamples
    {
        public static async Task Main()
        {

在这个示例中,调用 Parallel.For 时,你传递一个包含的 int 值作为起始点(99)和一个排他的结束值(105)。第三个参数是一个 lambda 表达式,Action,你希望对每个迭代进行调用。这个重载使用 Action<int>,通过 i 参数传递一个整数:

            var loopResult = Parallel.For(99, 105, i =>
            {
                Logger.Log($"Sleep iteration {i}");
                Thread.Sleep(i * 10);
                Logger.Log($"Awake iteration {i}");
            });

检查 ParallelLoopResultIsCompleted 属性:

            Console.WriteLine($"Completed: {loopResult.IsCompleted}");
            Console.ReadLine();
        }
    }
}

运行代码,你会看到它在 104 处停止。每个迭代由一组不同的线程执行,顺序似乎有些随机,某些迭代在另一些迭代之前唤醒。你使用了相对较短的时间延迟(使用 Thread.Sleep),因此并行任务调度器可能需要额外几毫秒来激活每个迭代。这就是为什么迭代执行的顺序应该相互独立:

18:39:37 [10] Sleep iteration 104
18:39:37 [03] Sleep iteration 100
18:39:37 [06] Sleep iteration 102
18:39:37 [04] Sleep iteration 101
18:39:37 [01] Sleep iteration 99
18:39:37 [07] Sleep iteration 103
18:39:38 [03] Awake iteration 100
18:39:38 [01] Awake iteration 99
18:39:38 [06] Awake iteration 102
18:39:38 [04] Awake iteration 101
18:39:38 [07] Awake iteration 103
18:39:38 [10] Awake iteration 104
Completed: True

使用 ParallelLoopState 重载,你可以通过 Action 代码控制迭代。在下面的示例中,代码检查它是否在迭代编号 15

            var loopResult1 = Parallel.For(10, 20,               (i, loopState) =>
              {
                Logger.Log($"Inside iteration {i}");
                if (i == 15)
                {
                    Logger.Log($"At {i}…break when you're ready");

loopState 上调用 Break 传达了 Parallel 循环应尽快停止进一步迭代的意图:

                    loopState.Break();
                }
              });
            Console.WriteLine($"Completed: {loopResult1.IsCompleted}, LowestBreakIteration={loopResult1.LowestBreakIteration}");
            Console.ReadLine();

从结果中,你可以看到在实际上停止之前,你到达了项目 17,尽管在迭代 15 时请求中断,如下面的片段所示:

19:04:48 [03] Inside iteration 11
19:04:48 [03] Inside iteration 13
19:04:48 [03] Inside iteration 15
19:04:48 [03] At 15...break when you're ready
19:04:48 [01] Inside iteration 10
19:04:48 [05] Inside iteration 14
19:04:48 [07] Inside iteration 17
19:04:48 [06] Inside iteration 16
19:04:48 [04] Inside iteration 12
Completed: False, LowestBreakIteration=15

代码使用了 ParallelLoopState.Break;这表明循环 17 尽管在迭代 15 时请求停止。这通常发生在运行时已经开始后续迭代,然后刚刚检测到一个 Break 请求。这些是停止请求;运行时可能在停止之前运行额外的迭代。

或者,可以使用 ParallelLoopState.Stop 方法实现更突然的停止。一个替代的 Parallel.For 重载允许将状态传递到每个循环,并返回一个单个聚合值。

为了更好地了解这些重载,你将在下一个示例中计算 pi 的值。这是一个非常适合 Parallel.For 的任务,因为它意味着重复计算一个值,该值在传递给下一个迭代之前被聚合。迭代次数越高,最终数值越精确。

注意

你可以在 www.mathscareers.org.uk/article/calculating-pi/ 上找到有关公式的更多信息。

你使用循环提示用户输入系列数(要显示的小数位数)作为百万的倍数(以节省输入许多零):

            double series;
            do
            {
                Console.Write("Pi Series (in millions):");
                var input = Console.ReadLine();

尝试解析输入:

                if (!double.TryParse(input, NumberStyles.Any, CultureInfo.CurrentCulture, out series))
                {
                    break;
                }

将输入的值乘以一百万,并将其传递给可等待的 CalcPi 函数(稍后将定义):

                var actualSeries = series * 1000000;
                Console.WriteLine($"Calculating PI {actualSeries:N0}");
                var pi = await CalcPi((int)(actualSeries));

你最终会收到 pi 的值,因此使用字符串插值功能将 pi 写入 18 位小数,使用 :N18 数值格式样式:

                Console.WriteLine($"PI={pi:N18}");
            }

重复循环,直到输入 0

            while (series != 0D);
            Console.ReadLine();

现在是 CalcPi 函数。你知道 Parallel 方法都会阻塞调用线程,所以你需要使用 Task.Run,它最终将返回最终计算出的值。

将简要介绍线程同步的概念。当使用多个线程和共享变量时,存在一种危险,即一个线程可能从内存中读取一个值,并在另一个线程尝试执行相同操作的同时尝试写入新值,该线程使用自己的值和它认为的正确当前值,而此时它可能已经读取了一个已经过时的共享值。

为了防止此类问题,可以使用互斥锁,以便在给定线程持有锁时执行其语句,并在完成后释放该锁。所有其他线程都被阻止获取锁,并被迫等待直到锁被释放。

这可以通过使用 lock 语句来实现。当使用 lock 语句实现线程同步时,所有复杂性都由运行时处理。lock 语句具有以下形式:

lock (obj){ //your thread safe code here }.

概念上,你可以将 lock 语句想象成一个狭窄的通道,足够容纳一个人一次通过。无论一个人通过通道需要多长时间以及他们在那里做什么,其他人必须等待,直到持有钥匙的人离开(释放锁)才能通过通道。

返回到 CalcPi 函数:

        private static Task<double> CalcPi(int steps)
        {
            return Task.Run(() =>
            {
                const int StartIndex = 0;
                var sum = 0.0D;
                var step = 1.0D / (double)steps;

gate 变量是 object 类型,并在 lambda 表达式中与 lock 语句一起使用,以保护 sum 变量免受不安全访问:

                var gate = new object();

这里事情变得稍微复杂一些,因为你使用了 Parallel.For 重载,它还允许你传递额外的参数和委托:

  • fromInclusive:起始索引(在本例中为 0)。

  • toExclusive:结束索引(步数)。

  • localInit:一个 Func 委托,返回每个迭代的局部数据的 初始状态

  • body:实际计算 Pi 值的 Func 委托。

  • localFinal:一个 Func 委托,用于对每个迭代的局部状态执行最终操作。

                Parallel.For(
                    StartIndex, 
                    steps,
                    () => 0.0D,                 // localInit 
                    (i, state, localFinal) =>   // body
                    {
                        var x = (i + 0.5D) * step;
                        return localFinal + 4.0D / (1.0D + x * x);
                    },
                    localFinal =>               //localFinally
                    { 

在这里,你现在使用 lock 语句来确保一次只有一个线程可以递增 sum 的值,并使用其正确的值:

                        lock (gate)
                            sum += localFinal; 
                    });
                return step * sum;
            });
        }

通过使用 lock(obj) 语句,你已经提供了一种最低级别的线程安全性,运行程序会产生以下输出:

Pi Series (in millions):1
Calculating PI 1,000,000
PI=3.141592653589890000
Pi Series (in millions):20
Calculating PI 20,000,000
PI=3.141592653589810000
Pi Series (in millions):30
Calculating PI 30,000,000
PI=3.141592653589750000

Parallel.ForEach 遵循类似的语义;而不是将数字范围传递给 Action 委托,你传递一个要处理的对象集合。

注意

你可以在 packt.link/1yZu2 找到用于此示例的代码。

以下示例显示了使用 ParallelOptions 和取消令牌的 Parallel.ForEach。在这个例子中,你有一个控制台应用程序,它创建了 10 个客户。每个客户都有一个包含所有已下订单值的列表。你想要模拟一个按需获取客户订单的慢速运行服务。每当任何代码访问 Customer.Orders 属性时,列表只填充一次。在这里,你将为每个客户实例使用另一个 lock 语句来确保列表安全地填充。

Aggregator 类将遍历客户列表,并使用 Parallel.ForEach 调用来计算总订单成本和平均订单成本。允许用户输入他们愿意等待所有聚合操作完成的最大时间周期,然后显示前五名客户。

首先,创建一个 Customer 类,其构造函数接收一个 name 参数:

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Chapter05.Examples
{
    public class Customer
    {
        public Customer(string name)
        {
            Name = name;
            Logger.Log($"Created {Name}");
        }
        public string Name { get; }

你希望按需填充 Orders 列表,并且每个客户只填充一次,因此使用另一个 lock 示例来确保订单列表安全地只填充一次。你只需使用 Ordersget 访问器来检查 _orders 变量的空引用,然后使用 Enumerable.Range LINQ 方法创建一个随机数量的订单值来生成一个数字范围。

注意,你还可以通过添加 Thread.Sleep 来模拟慢速请求,这将阻塞第一次访问此客户订单的线程(由于你使用了 Parallel 类,这将是一个后台线程而不是主线程):

ParallelForEachExample.cs
1            private readonly object _orderGate = new object();
2            private IList<double> _orders;
3            public IList<double> Orders
4            {
5                get
6                {
7                    lock (_orderGate)
8                    {
9                        if (_orders != null)
10                            return _orders;
11
12                        var random = new Random();
13                        var orderCount = random.Next(1000, 10000);
14
You can find the complete code here: https://packt.link/Nmx3X.

你的 Aggregator 类将计算以下 TotalAverage 属性:

        public double? Total { get; set; }
        public double? Average { get; set; }
    }

观察一下 Aggregator 类,注意它的 Aggregate 方法接收一个要处理的客户列表和 CancellationToken,该令牌将根据控制台用户的偏好时间周期自动发出取消请求。该方法返回一个基于 boolTask。结果将指示操作是否在处理客户过程中被取消:

    public static class Aggregator
    {
        public static Task<bool> Aggregate(IEnumerable<Customer> customers, CancellationToken token)
        {
            var wasCancelled = false;

Parallel.ForEach 方法通过创建一个 ParallelOptions 类并传入取消令牌来配置。当由 Parallel 类调用时,Action 委托传递一个 Customer 实例(customer =>),该实例仅简单地对订单值求和并计算平均值,然后将平均值分配给客户的属性。

注意 Parallel.ForEach 调用被包裹在一个 try-catch 块中,该块捕获任何类型的 OperationCanceledException 异常。如果超过最大时间周期,则运行时会抛出异常以停止处理。你必须捕获此异常;否则,应用程序将因未处理的异常错误而崩溃:

ParallelForEachExample.cs
1                return Task.Run(() =>
2                {
3                    var options = new ParallelOptions { CancellationToken = token };
4    
5                    try
6                    {
7                        Parallel.ForEach(customers, options,
8                            customer =>
9                            {
10                                customer.Total = customer.Orders.Sum();
11                                customer.Average = customer.Total / 12                                                   customer.Orders.Count;
13                                Logger.Log($"Processed {customer.Name}");
14                            });
15                    }
You can find the complete code here: https://packt.link/FfVNA.

主控制台应用程序提示输入最大等待时间,maxWait

    class ParallelForEachExampleProgram
    {
        public static async Task Main()
        {
            Console.Write("Max waiting time (seconds):");
            var input = Console.ReadLine();
            var maxWait = TimeSpan.FromSeconds(int.TryParse(input, NumberStyles.Any, CultureInfo.CurrentCulture, out var inputSeconds)
                ? inputSeconds
                : 5);

创建 100 个客户,这些客户可以传递给聚合器:

            var customers = Enumerable.Range(1, 10)
                .Select(n => new Customer($"Customer#{n}"))
                .ToList();

创建CancellationTokenSource实例,传入最大等待时间。如您之前所见,如果超过时间限制,使用此令牌的任何代码都将被取消异常中断:

            var tokenSource = new CancellationTokenSource(maxWait);
            var aggregated = await Task.Run(() => Aggregator.Aggregate(customers,                                   tokenSource.Token));            

任务完成后,您只需取出按总订单排序的前五个客户。使用PadRight方法对输出中的客户姓名进行对齐:

            var topCustomers = customers
                .OrderByDescending(c => c.Total)
                .Take(5);
            Console.WriteLine($"Cancelled: {aggregated }");
            Console.WriteLine("Customer      \tTotal         \tAverage  \tOrders");

            foreach (var c in topCustomers)
            {
                Console.WriteLine($"{c.Name.PadRight(10)}\t{c.Total:N0}\t{c.Average:N0}\t\t{c.Orders.Count:N0}");
            }
            Console.ReadLine();
        }
    }
}

使用1秒的短时间运行控制台应用程序会产生以下输出:

Max waiting time (seconds):1
21:35:56 [01] Created Customer#1
21:35:56 [01] Created Customer#2
21:35:56 [01] Created Customer#3
21:35:56 [01] Created Customer#4
21:35:56 [01] Created Customer#5
21:35:56 [01] Created Customer#6
21:35:56 [01] Created Customer#7
21:35:56 [01] Created Customer#8
21:35:56 [01] Created Customer#9
21:35:56 [01] Created Customer#10
21:35:59 [07] Processed Customer#5
21:35:59 [04] Processed Customer#3
21:35:59 [10] Processed Customer#7
21:35:59 [06] Processed Customer#2
21:35:59 [05] Processed Customer#1
21:35:59 [11] Processed Customer#8
21:35:59 [08] Processed Customer#6
21:35:59 [09] Processed Customer#4
21:35:59 [05] Caught The operation was canceled.
Cancelled: True
Customer        Total           Average         Orders
Customer#1      23,097,348      2,395           9,645
Customer#4      19,029,182      2,179           8,733
Customer#8      15,322,674      1,958           7,827
Customer#6      9,763,247       1,568           6,226
Customer#2      6,189,978       1,250           4,952

使用线程01创建10个客户的操作是同步进行的。

注意

Visual Studio 在您第一次运行程序时可能会显示以下警告:“非空字段_orders在退出构造函数时必须包含一个非空值。考虑将字段声明为可空。”这是检查代码以检查空引用可能性的建议。

Aggregator随后开始处理每个客户。注意如何使用不同的线程,并且处理并不从第一个客户开始。这是任务调度器决定队列中下一个任务的过程。在令牌引发取消异常之前,您只成功处理了八个客户。

注意

您可以在packt.link/1LDxI找到此示例使用的代码。

您已经查看了一些Parallel类中可用的功能。您可以看到它提供了一个简单而有效的方法来跨多个任务或数据片段运行代码。

术语Parallel类是此类的一个例子,并且可以是一个非常有用的工具。

下一节将把这些并发概念引入到一个使用多个任务生成一系列图像的活动。由于每个图像的创建可能需要几秒钟,因此您需要提供一个让用户选择取消任何剩余任务的方法。

活动 5.01:从斐波那契数列创建图像

练习 5.01中,您查看了一个创建称为斐波那契数的值的递归函数。这些数字可以组合成所谓的斐波那契数列,并用于创建有趣的螺旋形状的图像。

对于这个活动,您需要创建一个控制台应用程序,允许将各种输入传递给序列计算器。一旦用户输入了他们的参数,应用程序将开始创建 1,000 个图像的耗时任务。

序列中的每个图像可能需要几秒钟来计算和创建,因此您需要提供一个方法,使用TaskCancellationSource在操作中途取消操作。如果用户取消任务,他们仍然可以访问取消请求之前的图像。本质上,您允许用户尝试不同的参数,看看这对输出图像有什么影响。

图 5.2:斐波那契数列图像文件

图 5.2:斐波那契数列图像文件

如果您更喜欢async/await任务,这是一个理想的Parallel类示例。以下是需要从用户那里获取的输入:

  • 输入phi的值(1.06.0之间的值提供理想的图像)。

  • 输入要创建的图像数量(建议每个周期1,000个)。

  • 输入可选的每图像点数(建议默认为3,000)。

  • 输入可选的图像大小(默认为800像素)。

  • 输入可选的点大小(默认为5)。

  • 接下来,输入可选的文件格式(默认为.png格式)。

  • 控制台应用程序应使用循环来提示用户输入前一个参数,并在为前一个参数创建图像的同时允许用户输入新的标准。

  • 如果用户在创建前一组图像时按下Enter键,则应取消该任务。

  • 按下x键应关闭应用程序。

由于此活动旨在测试您的异步技能,而不是数学或图像处理,您有以下类来帮助进行计算和图像创建:

  • 这里定义的Fibonacci类计算连续序列项的XY坐标。对于每个图像循环,返回一个Fibonacci类的列表。

  • 通过调用CreateSeed创建第一个元素。列表的其余部分应使用CreateNext,传入前一个项:

    FibonacciSequence.cs
    1    public class Fibonacci
    2    {
    3        public static Fibonacci CreateSeed()
    4        {
    5            return new Fibonacci(1, 0D, 1D);
    6        }
    7    
    8        public static Fibonacci CreateNext(Fibonacci previous, double angle)
    9        {
    10            return new Fibonacci(previous, angle);
    11        }
    12    
    13        private Fibonacci(int index, double theta, double x)
    14        {
    15            Index = index;
    
You can find the complete code here: http://packt.link/I7C6A.
  • 使用以下FibonacciSequence.Calculate方法创建一个 Fibonacci 项的列表。这将传递要绘制的点的数量和phi的值(两者均由用户指定):

    FibonacciSequence.cs
    1    public static class FibonacciSequence
    2    {
    3        public static IList<Fibonacci> Calculate(int indices, double phi)
    4        {
    5            var angle = phi.GoldenAngle();
    6    
    7            var items = new List<Fibonacci>(indices)
    8            {
    9                Fibonacci.CreateSeed()
    10            };
    11            
    12            for (var i = 1; i < indices; i++)
    13            {
    14                var previous = items.ElementAt(i - 1);
    15                var next = Fibonacci.CreateNext(previous, angle);
    
You can find the complete code here: https://packt.link/gYK4N.
  • 使用dotnet add package命令导出生成的数据到.png格式图像文件,以添加对System.Drawing.Common命名空间的引用。在您的项目源文件夹中运行以下命令:

    source\Chapter05>dotnet add package System.Drawing.Common
    
  • 此图像创建类ImageGenerator可用于创建每个最终图像文件:

    ImageGenerator.cs
    1    using System.Collections.Generic;
    2    using System.Drawing;
    3    using System.Drawing.Drawing2D;
    4    using System.Drawing.Imaging;
    5    using System.IO;
    6    
    7    namespace Chapter05.Activities.Activity01
    8    {
    9        public static class ImageGenerator
    10        {
    11            public static void ExportSequence(IList<Fibonacci> sequence, 
    12                string path, ImageFormat format, 13                int width, int height, double pointSize)
    14            {
    15                double minX = 0; 
    
You can find the complete code here: http://packt.link/a8Bu7.

要完成此活动,请执行以下步骤:

  1. 创建一个新的控制台应用程序项目。

  2. 生成的图像应保存在系统Temp文件夹内的一个文件夹中,因此请使用Path.GetTempPath()获取Temp路径,并使用Directory.CreateDirectory创建一个名为Fibonacci的子文件夹。

  3. 声明一个do循环,重复以下步骤 4步骤 7

  4. 提示用户输入phi的值(这通常在1.06.00之间)。您需要将用户输入读取为字符串,并使用double.TryParse尝试将输入转换为有效的双精度浮点变量。

  5. 接下来,提示用户输入要创建的图像文件数量(1,000是一个可接受的示例值)。将解析后的输入存储在名为imageCountint变量中。

  6. 如果输入的任一值是空的,这表明用户仅按下了Enter键,因此退出do循环。理想情况下,也可以定义并使用CancellationTokenSource来取消任何挂起的计算。

  7. phiimageCount的值应传递给名为GenerateImageSequences的新方法,该方法返回一个Task

  8. GenerateImageSequences方法需要使用一个循环,该循环为请求的每个图像计数迭代。每次迭代应增加phi,然后是一个常数值(建议为0.015),在等待调用Task.Run方法之前,该方法调用FibonacciSequence.Calculate,传入phi和用于点的常数值(例如3,000是一个可接受的示例值)。这将返回一个斐波那契项列表。

  9. 然后GenerateImageSequences应将生成的斐波那契列表传递给图像创建者ImageGenerator.ExportSequence,使用Task.Run调用等待。对于ExportSequence的调用,建议的常数值为图像大小800和点大小5

  10. 运行控制台应用程序应该产生以下控制台输出:

    Using temp folder: C:Temp\Fibonacci\
    Phi (eg 1.0 to 6.0) (x=quit, enter=cancel):1
    Image Count (eg 1000):1000
    Creating 1000 images...
    20:36:19 [04] Saved Fibonacci_3000_1.015.png
    20:36:19 [06] Saved Fibonacci_3000_1.030.png
    20:36:20 [06] Saved Fibonacci_3000_1.090.png
    

你会发现系统Temp文件夹中的斐波那契文件夹中生成了各种图像文件:

图 5.3:Windows 10 资源管理器图像文件夹内容(生成的图像子集)

图 5.3:Windows 10 资源管理器图像文件夹内容(生成的图像子集)

通过完成这个活动,你看到了如何启动多个长时间运行的操作,然后协调它们以产生单个结果,每个步骤都在隔离中运行,允许其他操作按需继续。

注意

该活动的解决方案可以在packt.link/qclbF找到。

摘要

在本章中,你考虑了并发提供的部分强大和灵活的功能。你首先通过将目标操作传递到你创建的任务来开始,然后查看静态的Task工厂辅助方法。通过使用延续任务,你看到单个任务和任务集合可以被协调以执行聚合操作。

接下来,你研究了async/await关键字,这些关键字可以帮助你编写更简单、更简洁的代码,希望这样更容易维护。

本章探讨了 C#如何以相对简单的方式提供并发模式,这使得可以利用多核处理器的强大功能。这对于卸载耗时计算非常有用,但这也带来了一定的代价。你看到了如何使用lock语句来安全地防止多个线程同时读取或写入一个值。

在下一章中,你将了解如何使用 Entity Framework 和 SQL Server 在 C#应用程序中与关系型数据交互。本章是关于数据库操作的内容。如果你对数据库结构不熟悉或想复习 PostgreSQL 的基本知识,请参阅本书 GitHub 仓库中提供的附加章节。

第六章:6. 使用 SQL Server 的 Entity Framework

概述

本章将向您介绍使用 SQL 和 C#进行数据库设计、存储和处理的基础知识。您将了解实体框架(EF)和对象关系映射器(ORM),并使用它们将数据库结果转换为 C#对象。然后,您将了解 SQL 和 EF 的主要性能陷阱以及如何查找和修复它们。

最后,您将通过查看存储库和命令查询责任分离(CQRS)模式以及设置用于开发和测试的本地数据库来深入了解与数据库的企业级实践。到本章结束时,您将能够使用 PostgreSQL 服务器创建和设计自己的数据库,并使用 EF 将其与 C#后端连接。

简介

数据库有多种类型,但最常见的一种是关系型数据库,管理关系型数据库的语言是 SQL。SQL 针对数据持久性进行了优化。然而,在其中执行业务规则效率低下。因此,在消费之前,数据通常在应用程序内存中检索并转换为对象。这种转换称为对象关系映射。

将数据库记录映射到对象中存在很多复杂性。然而,这种复杂性通过对象关系映射器(ORM)得到了缓解。一些 ORM 仅执行映射(称为微 ORM),但许多流行的 ORM 还抽象了数据库语言,允许您使用相同的语言来执行业务规则和处理数据:

图 6.1:ORM 在将 C#转换为 SQL 以及反向转换中的工作方式

图 6.1:ORM 在将 C#转换为 SQL 以及反向转换中的工作方式

本章的重点将是实体框架(EF)——.NET 中最受欢迎的 ORM。在本章的实践部分,您将使用它来快速原型化关系型数据库,并对它们进行查询。值得一提的是,在涉及数据库的情况下,您实际上是在与.NET 的 ADO.NET 部分进行交互。

在继续之前,建议您安装最新版本的 PostgreSQL,以及在此处找到的 PostgreSQL 服务器:www.enterprisedb.com/downloads/postgres-postgresql-downloads。您可以在前言中找到此安装说明。

本章将使用AdventureWorks数据库,这是一个微软经常使用的流行示例数据库的改编版本;它将在下一节中详细定义。

注意

对于那些对学习数据库基础知识以及如何使用 PostgreSQL 感兴趣的人来说,本书的 GitHub 仓库中包含了一个参考章节。您可以通过packt.link/sezEm访问它。

在开始之前创建一个演示数据库

您将使用Adventureworks作为示例,因为它是由微软常用的一个常见数据库,并且具有足够的复杂性来学习数据库主题。

执行以下步骤:

  1. 打开命令行,创建一个名为AdventureWorks数据库的目录,并移动到该目录:

    C:\<change-with-your-download-path-to-The-C-Sharp-Workshop>\Chapter06\AdventureWorks\>
    

    注意

    <change-with-your-download-path-to-The-C-Sharp-Workshop>替换为您下载 The-C-Sharp-Workshop 存储库的目录。

  2. 在控制台中运行以下命令以创建一个空的Adventureworks数据库:

    psql -U postgres -c "CREATE DATABASE \"Adventureworks\";"
    
  3. 使用安装脚本创建表格并填充数据。

    注意

    安装脚本位于packt.link/0SHd5

  4. 运行以下命令指向安装脚本:

    psql -d Adventureworks -f install.sql -U postgres
    

使用 EF 建模数据库

使用其他语言与数据库交互会带来一个有趣的问题,那就是如何将表行转换为 C#对象?在 C#中,与数据库通信需要数据库连接和 SQL 语句。执行这些语句将弹出一个结果读取器,它与表格非常相似。使用结果读取器字典,您可以遍历结果并将它们映射到一个新的对象中。

此代码可能看起来如下所示:

using var connection = new NpgsqlConnection(Program.GlobalFactoryConnectionString);
connection.Open(); 
NpgsqlCommand command = new NpgsqlCommand("SELECT * FROM factory.product", connection);
var reader = command.ExecuteReader();
var products = new List<Product>();
while (reader.Read())
{
    products.Add(new Product
    {
        Id = (int)reader["id"],
        //ManufacturerId = (int)reader["ManufacturerId"],
        Name = (string)reader["name"],
        Price = (decimal)reader["price"]
    });
}
return products;

不要担心此代码的细节;它很快就会被分解。现在,只需知道前面的代码片段返回了factory.product表的所有行,并将结果映射到名为products的列表中。当处理单个表时,这种方法可能是可以的,但当涉及到连接时,它就会变得复杂。从一种类型到另一种类型的映射,如这里所做的那样,非常细粒度,可能会变得繁琐。为了运行此示例,请访问packt.link/2oxXn,并在static void Main(string[] args)体中注释掉所有行,除了Examples.TalkingWithDb.Raw.Demo.Run();

注意

您可以在packt.link/7uIJq找到用于此示例的代码。

另一个需要考虑的因素是,当您从客户端处理 SQL 时,您应该小心。您不应该假设用户会像预期的那样使用您的程序。因此,您应该在客户端和服务器端都添加验证。例如,如果文本框需要输入用户 ID,客户端可以输入105并获取该 ID 的用户详细信息。此查询如下所示:

SELECT * FROM Users WHERE UserId = 105

用户还可以输入105 or 1 = 1,这始终为真,因此此查询返回所有用户:

SELECT * FROM Users WHERE UserId = 105 or 1 = 1

最坏的情况下,这会破坏您的应用程序。最坏的情况下,它会泄露所有数据。这种攻击被称为 SQL 注入。

解决接受任何类型用户输入的问题的一个简单而有效的方法是使用 ORM,因为它允许您将数据库表转换为 C#对象,反之亦然。在.NET 生态系统中,最常用的三个 ORM 是 EF、Dapper 和 NHibernate。当需要高性能时,Dapper 非常有效,因为它涉及执行原始 SQL 语句。这种 ORM 被称为 micro-ORM,因为它们只做映射,不做其他任何事情。

NHibernate 起源于 Java 生态系统,是.NET 中第一个 ORM 之一。NHibernate,就像 EF 一样,通过尝试抽象 SQL 和数据库相关的低级细节,解决了一个比微 ORM 更大的问题。使用完整的 ORM,如 EF 或 Nhibernate,通常意味着你不需要编写 SQL 来与数据库通信。实际上,这两个 ORM 允许你从你拥有的对象中生成复杂的数据库。反之亦然(即,你可以从你已有的数据库中生成对象)。

在接下来的几节中,我们将重点关注 EF。为什么不选择 Dapper?因为 Dapper 需要了解 SQL,而你希望使用简化的语法。为什么不选择 NHibernate?因为 NHibernate 已经过时,它有太多的配置选项,其中没有一个对 ORM 的入门有用。

在深入研究 EF 之前,你首先需要连接到数据库。因此,继续学习关于连接字符串和安全的知识。

连接字符串和安全

无论你使用什么语言,连接到数据库都将涉及使用连接字符串。它包含三个重要部分:

  • IP 或服务器名称。

  • 你想要连接到的数据库的名称。

  • 一些安全凭证(或者如果没有使用仅用于同一网络上的数据库的受信任连接,则不需要)。

要连接到你在“使用 EF 建模数据库”部分之前正在工作的本地数据库(new NpgsqlConnection(ConnectionString)),你可以使用以下连接字符串(出于安全原因,密码已被模糊处理):

"Host=localhost;Username=postgres;Password=*****;Database=globalfactory2021"

当你在操作系统中添加环境变量时,将使用连接字符串。这将在后面详细说明。不同的数据库使用不同的连接。例如,以下数据库使用这些连接:

  • SQL Server: SqlConnection

  • PostgreSQL: NpgsqlConnection

  • MySql: MySqlConnection

  • SQLite: SqliteConnection

连接对象是.NET 和 SQL 数据库之间的接触点,因为只有通过它你才能与数据库进行通信。

连接字符串硬编码会带来一些问题:

  • 要更改连接字符串,程序必须重新编译。

  • 这不安全。连接字符串可以被任何知道如何反编译代码的人查看(或者更糟糕的是,如果是一个开源项目,它可能是公开可见的)。

因此,连接字符串通常存储在配置文件中。但这并没有解决连接字符串敏感部分存储的问题。为了解决这个问题,通常在应用程序部署期间会替换整个字符串或其一部分。有三种主要方式来安全地存储和检索应用程序的秘密:

  • 环境变量:这些是系统特有的变量,可以被同一台机器上的任何应用程序访问。这是最简单的安全方法,但在生产环境中可能不安全。

  • 秘密管理器工具(可在.NET 和.NET Core 应用程序中使用):类似于环境变量,但更适用于.NET,它将在本地机器上存储所有秘密,但存储在一个名为secrets.json的文件中。这个选项在生产环境中也可能不安全。

  • 密钥保管库:这是最安全的方法,因为它与其他两种方法不同,它不与特定环境耦合。密钥保管库将秘密存储在一个集中的位置;通常是在远程位置。这种方法最常用于企业应用程序。在 Azure 的上下文中,Azure 密钥保管库是最好的选择,并且非常适合生产环境。

在下面的示例中,您将尝试安全地存储之前创建的连接字符串。您将使用最适合开发环境的简单安全方法——即环境变量。这种方法最适合本地开发,因为其他两种方法需要第三方工具进行设置,并且需要更长的时间。

注意

在继续之前,请确保阅读参考章节中的练习 1简单数据库和 SQL 入门。它包含了创建所需表的新数据库所需的步骤。

在您的操作系统中添加环境变量只是执行一些简单步骤的问题。在 Windows 中执行以下步骤来设置环境变量:

  1. 前往控制面板

  2. 点击系统与安全并选择系统

  3. 在搜索框中输入环境变量

  4. 然后从显示的列表中选择编辑您的账户的环境变量

  5. 环境变量窗口中,点击系统变量窗口下的新建

  6. 在新建系统变量窗口中,在变量名旁边输入GlobalFactory

  7. 变量值旁边粘贴以下内容:

    Host=localhost;Username=postgres;Password=*****;Database=globalfactory2021
    
  8. 接下来,在所有窗口上点击确定以设置您的环境变量。

    注意

    这里密码将携带您在创建globalfactory2021数据库时在 PostgreSQL 中输入的数据库超级用户密码。

    • Mac:从命令行中,找到bash-profile: ~/.bash-profile f。使用任何文本编辑器打开它,然后在文件末尾添加export GlobalFactory='Host=localhost;Username=postgres;Password=*****;Database=globalfactory2021'。最后,运行source ~/.bash-profile,这将更新环境变量。

    • Linux:从命令行运行以下命令:export GlobalFactory='Host=localhost;Username=postgres;Password=*****;Database=globalfactory2021'

现在可以通过在Program.cs文件中,类的顶部放置一个属性来获取环境变量而不是内存中的变量,如下所示:

public static string ConnectionString { get; } = Environment.GetEnvironmentVariable("GlobalFactory", EnvironmentVariableTarget.User);

这行代码返回本地用户配置的GlobalFactory环境变量的值。在上面的代码片段中,您已将此行添加到Program.cs文件中,并将其设置为静态,因为这样可以使其在整个应用程序中易于访问。虽然在大型应用程序中,这不是您希望采取的做法;然而,对于您在这里的目的来说,这是可以的。

在你掌握模型——程序的核心之前,你需要了解 EF 的主要版本。

选择哪一个——EF 还是 EF Core?

EF 有两个主要版本——EF 和 EF Core。两者都广泛使用,但在做出最适合你项目需求的选择之前,你应该了解一些因素。EF 首次于 2008 年发布。当时,没有 .NET Core,C# 仅适用于 Windows,并且严格需要 .NET Framework。目前,EF 的最新主要版本是 6,并且很可能不会再有其他主要版本,因为 2016 年,随着 .NET Core 1.0 一起发布了 EF Core 1(对 EF 6 的重写)。

EF Core 最初被命名为 EF 7。然而,它是对 EF 6 的完全重写,因此很快被重命名为 EF Core 1.0。EF 只能在 .NET 上运行,并且仅适用于 Windows,而 .NET Core 只能在 .NET Core 上运行,并且是多平台的。

在功能上,这两个框架都很相似,并且仍在不断发展。然而,现在的重点是 EF Core,因为 C# 的未来与 .NET 6 相关联,这是一个多平台框架。在撰写本书时,EF 6 拥有一套更丰富的功能。然而,EF Core 正在迅速迎头赶上,并可能很快就会领先。如果你的项目规格不需要与 .NET Framework 一起工作,坚持使用 EF Core 是更好的选择。

注意

关于两者之间最新差异的详细列表,请参阅微软的以下比较:docs.microsoft.com/en-us/ef/efcore-and-ef6/

在你继续之前,安装 EF Core NuGet 包,以便你可以访问 EF Core API。在 Visual Studio Code (VS Code) 中打开项目后,在终端中运行以下行:

dotnet add package Microsoft.EntityFrameworkCore

单独来看,EntityFrameworkCore 只是一个用于抽象数据库结构的工具。为了将其与特定的数据库提供程序连接,你需要另一个包。这里你使用的是 PostgreSQL。因此,你将安装的包是 Npgsql.EntityFrameworkCore.PostgreSQL。为了安装它,从 VS Code 控制台运行以下命令:

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

你现在已经了解了 EF 的两种版本以及它们如何与 .NET Framework 和 .NET 一起工作。下一节将深入探讨模型,这是程序的核心。

模型

一个设计用来表示业务对象的类被称为模型。它总是通过属性或方法管理数据。模型是程序的核心。它们不依赖于任何东西;程序的其它部分指向它们。

一个将数据表映射到对象的 ORM 被称为实体。在简单应用中,实体和模型是同一个类。在复杂应用中,对数据库的更改是常见的事情。这意味着实体经常发生变化,如果你没有为模型单独创建一个类,你的模型也会受到影响。业务逻辑应该与数据库更改隔离,因此建议有两个类——一个用于实体,一个用于模型。

在继续下一节之前,快速查看一下 factory.productfactory.manufacturer 表。一个制造商生产许多产品。以下实体关系(ER)图在图 6.2 中展示了这种关系。

图 6.2:产品和制造商的 ER 图

图 6.2:产品和制造商的 ER 图

实体,理想情况下,应该与表列相对应。您可以通过属性来映射列。例如,factory.product 表有 idnamepricemanufacturerId。映射到该表的对象可能看起来像这样:

public class Product
{
    public int id { get; set; }
    public string name { get; set; }
    public decimal price { get; set; }
    public int manufacturerId { get; set; }
}

您知道只有产品的价格可以改变;其余属性不会变。然而,在前面的代码片段中,每个属性都仍然编写了一个设置器。这是因为通过 ORM 创建的实体始终需要所有属性都有设置器,否则可能无法设置值。

实体应该设计成与表结构相匹配,但并不总是必须这样。例如,如果 Id 属性被重命名为 PrimaryKey,您仍然可以使用 EF 以相同的方式使用,通过使用特殊的数据注释 [Key]

public class Product
{
    [Key]
    public int PrimaryKey { get; set; }
    public string name { get; set; }
    public decimal price { get; set; }
    public int manufacturerId { get; set; }
}

数据注释是一个属性,它向属性添加元数据。您可以使用它来提供不同的名称,将约束列作为键,为字段添加最小和最大长度,添加精度,声明字段为必填项,等等。单独使用数据注释不会做任何事情。它们不会向模型添加逻辑。其他一些组件将消费注释对象,这将涉及读取它们的属性并根据这些属性执行操作。

您的模型(展示图 6.2 中的 ER 图)几乎完成了,但还有一些问题需要解决:

  • 首先,表格模型映射缺少一个模式(factory,在这种情况下),因此您需要使用 Table 属性显式指定它。

  • 第二,默认情况下,如果您还想检索一个 manufacturer,您将需要一个额外的查询。您可以通过添加一个指向制造商的导航属性来修复这个问题。但为什么您应该使用导航属性呢?如果只有一个 ID,您将需要一个单独的查询来获取相关实体。然而,使用导航属性,您可以使用预加载一次获取两个或更多实体。

以下代码片段将向您展示如何创建 Manufacturer 类并修复这两个模型的问题:

[Table("manufacturer", Schema = "factory")]
public class Manufacturer
{
    public int id { get; set; }
    public string name { get; set; }
    public string country { get; set; }
    public virtual ICollection<Product> Products { get; set; } = new List<Product>();
}

注意新的 List<Product>(); 部分。这是必需的,以便在尝试添加新产品而表格中尚未有产品时,代码仍然可以正常工作,而不会抛出 NullReferenceException

在以下代码片段中,为产品表创建了一个模型:

[Table("product", Schema = "factory")]
public class Product
{
    public int id { get; set; }
    public string name { get; set; }
    public decimal price { get; set; }
    public int manufacturerId { get; set; }
    public virtual Manufacturer Manufacturer { get; set; }
}

这两个模型已经完整,可以映射到你的数据库表。你没有用导航属性替换 ID 属性;两者都存在。如果你没有这样做,那么在你可以对产品进行任何操作之前,需要先获取父实体(Manufacturer)。使用这种方法,你可以独立于制造商处理产品。你所需要的就是一个 ID 链接。

在上述修复的基础上,你还使你的导航属性(ManufacturerProducts)虚拟。这对于启用 EF 的懒加载是必要的。懒加载意味着直到引用该属性时,该属性中才没有加载数据。

最后,值得提到的是,对于制造商产品,你使用了 ICollection 而不是 IEnumerable 或其他集合。这很有意义,因为 EF 在检索和映射项目时需要填充集合。List 或甚至 Set 都可以工作,但在设计面向对象的代码时,你应该专注于你可以依赖的最高抽象,在这种情况下是 ICollection

注意

你可以在 packt.link/gfgB1 找到用于此示例的代码。

为了运行此示例,请访问 packt.link/2oxXn 并注释掉 static void Main(string[] args) 体内的所有行,除了 Examples.TalkingWithDb.Orm.Demo.Run();

你现在对实体、模型、实体关系、数据注释、预加载和懒加载有了清晰的认识。下一节将展示如何结合所有这些内容,并通过 EF Core 与数据库进行通信。

DbContext 和 DbSet

DbContext 是 EF 用于数据库抽象的类。一个新的数据库抽象必须从 DbContext 类派生,并提供连接到数据库的方式。就像数据库包含一个或多个表一样,DbContext 包含一个或多个 DbSet 实体。例如,考虑以下代码:

public class FactoryDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }
    public DbSet<Manufacturer> Manufacturers { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            optionsBuilder.UseNpgsql(Program.GlobalFactoryConnectionString);
        }
    }
}

在这里,FactoryDbContext 是你之前创建的数据库抽象,包含两个表:ProductsManufacturersOnConfiguring 方法接受 DbContextOptionsBuilder,这允许你指定要连接的数据库以及如何建立连接。在这种情况下,你正在使用 PostgreSQL Server 并指定要连接的数据库。请注意,如果已经配置了数据库提供程序,则你将不会在 if 语句中使用 Npgsql,即 if (!optionsBuilder.IsConfigured) 语句。

需要注意的是,你应该不依赖于特定的数据库提供程序,原因有两个:

  • 首先,更改数据库提供程序很容易;这只是一个在构建器上使用不同扩展方法的问题。

  • 其次,EF 有一个内存数据库提供程序,这对于测试非常有效。或者,你也可以使用 SQLite,它是一个轻量级的数据库,仅用于测试。

目前,你的数据库抽象需要改进,因为它只允许你与 SQL Server 数据库通信。而不是硬编码选项,你将注入它们。注入允许你配置现有的类,而无需修改它。你不需要更改模型就能选择你想要连接到的数据库。你可以通过将 options 对象传递给 FactoryDbContext 构造函数来指定你想要连接到的数据库:

 public FactoryDbContext(DbContextOptions<FactoryDbContext> options)
    : base(options)
{
}

默认构造函数用于默认提供程序,当没有提供选项时将使用它。在这种情况下,上下文被设计为使用 PostgreSQL;因此,你需要添加以下代码:

public FactoryDbContext()
    : base(UsePostgreSqlServerOptions())
{
}

可以使用 DbContextOptions 来配置 DbContext。在这个例子中,你需要配置一个数据库提供程序(PostgreSQL)和一个连接字符串。使用 DbContextOptionsBuilder 来选择提供程序。UseNpgsql 是将 PostgreSQL 提供程序与数据库上下文连接起来的方式,如下所示:

protected static DbContextOptions UsePostgreSqlServerOptions()
{
    return new DbContextOptionsBuilder()
        .UseNpgsql(Program.ConnectionString)
        .Options;
}

完整的 DbContext 现在看起来是这样的:

FactoryDbContext.cs
public class FactoryDbContext : DbContext
{
    public DbSet<Product> Products { get; set; }
    public DbSet<Manufacturer> Manufacturers { get; set; }

    public FactoryDbContext(DbContextOptions<FactoryDbContext> options)
        : base(options)
    {
    }

    public FactoryDbContext()
        : base(UsePostgreSqlServerOptions())
    {
    }
The complete code can be found here: https://packt.link/0uVPP.

为了运行此示例,请访问 packt.link/2oxXn 并在 static void Main(string[] args) 主体中注释掉所有行,除了 Examples.TalkingWithDb.Orm.Demo.Run();

要从数据库中获取产品,你首先通过初始化你的 DbContext 实例来连接到数据库。然后,从该上下文中调用一个想要的 DbSet,并通过调用 ToList() 发送对数据库的调用:

using var context = new FactoryDbContext();
var products = context.Products.ToList();

在这种情况下,你创建了一个 FactoryDbContext(它连接到 GlobalFactory 数据库),而 context.Products.ToList() 等同于一个 SELECT * FROM Products SQL 语句。

注意

提到的两行不包括在 GitHub 中。它们很简单,这里只是为了说明目的。

当你初始化一个 DbContext 时,你几乎总是创建一个数据库连接,如果没有管理好,你最终可能会在连接池(一组可用连接)中耗尽连接。DbContext 是一个未管理资源;它实现了 IDisposable 接口,因此需要显式清理。在这里,你应用了一个 C# 功能——内联使用,它在对象离开其作用域后销毁对象:

using var context = new FactoryDbContext()

当你拥有一个 DbContext 时,从中获取数据是微不足道的:

  • 访问 DbSet

  • 将其转换为列表。

然而,为什么你需要进行任何转换呢?那是因为 DbSetIEnumerable 类似,是懒加载的。它封装了执行所需的 SQL。所以,除非你明确要求(例如,通过调用 ToList),否则不会查询任何数据。调用 ToList 会实际调用数据库并检索所有产品。

现在,你已经了解了所有关于数据库的知识。下一节将涉及 AdventureWorks 数据库,这是一个常用于教授初学者 SQL 的数据库。

AdventureWorks 数据库

AdventureWorks 是一个用于学习目的的数据库。它包含数十个表,每个表中都有数百条记录。这些表专注于批发,这是企业应用中的常见场景。换句话说,AdventureWorks 数据库提供了与真实世界问题接近的学习示例。

注意

你必须首先在 PostgreSQL 中创建 AdventureWorks 数据库。你可以在 GitHub 上找到创建此数据库的步骤,位于参考章节中。

前几节介绍了实体、模型以及如何组合一切并与数据库通信。你还学习了 DbContextDbSet。这完成了本节的理论部分。在下一节中,你将通过练习将其付诸实践。

练习 6.01:从 AdventureWorks 数据库读取库存位置

EF 的最简单用法是将数据表读入 C# 对象。本练习将教会你如何创建数据实体类并将其添加正确的属性。为此,你将在示例 AdventureWorks 数据库中创建一个库存 location 表。执行以下步骤来完成此操作:

  1. 创建一个 Location 实体。它应该有 LocationIdNameCostrateAvailabilityModifiedDate 属性,如下所示:

    [Table("location", Schema = "production")]
    public class Location
    {
        [Column("locationid")]
        public int LocationId { get; set; }
        [Column("name")]
        public string Name { get; set; }
        [Column("costrate")]
        public double Costrate { get; set; }
        [Column("availability")]
        public double Availability { get; set; }
        [Column("modifieddate")]
        public DateTime ModifiedDate { get; set; }
    }
    

由于需要指定模式和正确大写的表名,已应用 [Table] 属性。此外,每个列名都需要使用 [Column] 属性显式指定,因为大小写不匹配。

  1. 创建一个名为 AdventureWorksContext 的类,它继承自 DbContext,如下所示:

    public class AdventureWorksContext : DbContext
    {
        public DbSet<Location> Locations { get; set; }
    
        public AdventureWorksContext()
            : base(UsePostgreSqlServerOptions())
        {
        }
    
        protected static DbContextOptions UsePostgreSqlServerOptions()
        {
            return new DbContextOptionsBuilder()
                .UseNpgsql(Program.AdventureWorksConnectionString)
                .Options;
        }
    

如果你想重用数据库抽象的基础功能,如连接到数据库,则必须继承 DbContext。基础功能的用法在两个基础构造函数中可见。在参数化构造函数中,你使用 PostgreSQL;在非参数化构造函数中,你可以提供你选择的任何数据库提供程序。

  1. 现在按照以下方式使用 Program.AdventureWorksConnectionString 连接字符串:

    Host=localhost;Username=postgres;Password=****;Database=Adventureworks. DbSet<Location>Locations
    

这代表所需的 location 表。

注意

请确保您的 PostgreSQL 密码安全。不要在代码中以明文形式写入它们,而是使用环境变量或密钥。

  1. 连接到数据库:

    var db = new AdventureWorksContext();
    

这就像创建一个新的 DbContext 一样简单。

  1. 通过添加以下代码来获取所有产品:

    var locations = db.Locations.ToList();
    
  2. 现在你已经查询了位置并且不再需要保持连接打开,最好是断开与数据库的连接。为了断开与数据库的连接,请按照以下方式调用 Dispose 方法:

    db.Dispose();
    
  3. 通过添加以下代码来打印结果:

    foreach (var location in locations)
    {
        Console.WriteLine($"{location.LocationId} {location.Name} {location.Costrate} {location.Availability} {location.ModifiedDate}");
    }
    

代码本身在 packt.link/2oxXn 运行。请确保在 static void Main(string[] args) 方法体内注释掉所有 static void 的行,除了 Exercises.Exercise03.Demo.Run()。当你运行代码时,以下输出将显示:

1 Tool Crib 0 0 2008-04-30 00:00:00
2 Sheet Metal Racks 0 0 2008-04-30 00:00:00
3 Paint Shop 0 0 2008-04-30 00:00:00
4 Paint Storage 0 0 2008-04-30 00:00:00
5 Metal Storage 0 0 2008-04-30 00:00:00
6 Miscellaneous Storage 0 0 2008-04-30 00:00:00
7 Finished Goods Storage 0 0 2008-04-30 00:00:00
10 Frame Forming 22,5 96 2008-04-30 00:00:00
20 Frame Welding 25 108 2008-04-30 00:00:00
30 Debur and Polish 14,5 120 2008-04-30 00:00:00
40 Paint 15,75 120 2008-04-30 00:00:00
45 Specialized Paint 18 80 2008-04-30 00:00:00
50 Subassembly 12,25 120 2008-04-30 00:00:00
60 Final Assembly 12,25 120 2008-04-30 00:00:00

使用 EF 很简单。正如你可以从这个练习中看到的那样,它直观,感觉像是 C#的自然扩展。

注意

你可以在packt.link/9Weup找到用于此练习的代码。

查询数据库——LINQ to SQL

EF(Entity Framework)的一个更有趣的特性是执行 SQL 语句非常类似于操作一个集合。例如,假设你想通过名称检索一个产品。你可以通过名称获取产品,就像使用 LINQ 一样:

public Product GetByName(string name)
{
    var product = db.Products.FirstOrDefault(p => p.Name == name);
    return product;
}

在这里,FirstOrDefault通过名称返回第一个匹配的产品。如果不存在具有该名称的产品,则返回null

那么如何通过 ID 查找唯一元素呢?在这种情况下,你会使用一个特殊的方法(Find),它要么从数据库中获取一个实体,要么如果最近已经检索了具有相同 ID 的实体,则从内存中返回它:

public Product GetById(int id)
{
    var product = db.Products.Find(id);
    return product;
}

当使用主键时,最好使用Find而不是Where,因为在 EF 的上下文中,它们具有略微不同的含义。Find不会尝试创建 SQL 查询并执行它,而是会检查此项目是否已经被访问,并从缓存中检索它,而不是通过数据库。这使得操作更加高效。

关于通过相关制造商 ID 查找所有产品,你可以创建一个返回IEnumerable<Product>的方法来实现此目的,命名为GetByManufacturer,如下所示:

public IEnumerable<Product> GetByManufacturer(int manufacturerId)
{    var products = db
        .Products
        .Where(p => p.Manufacturer.Id == manufacturerId)
        .ToList();

    return products;
}

你可能想知道为什么你应该选择在这里使用Where而不是Find。那是因为你正在通过外键manufacturerId获取许多产品。请注意不要混淆外键和主键;Find仅用于主键。

为了运行此示例,请访问packt.link/2oxXn,并在static void Main(string[] args)体中注释掉所有行,除了Examples.Crud.Demo.Run();

注意

你可以在packt.link/pwcwx找到用于此示例的代码。

现在,关于检索相关实体,如果你简单地调用db.Manufacturers.ToList(),你将得到 null 产品。这是因为除非明确指定,否则产品不会自动检索。如果你没有调用ToList(),你可以利用延迟加载(即按需加载所需的实体),但这将导致一个非常低效的解决方案,因为你将始终查询每个父实体的子实体。

一个合适的解决方案是调用Include(parent => parent.ChildToInclude)

db.Manufacturers
.Include(m => m.Products)
.ToList();

这种方法被称为预加载。使用这种方法,你指定哪些子实体应该立即检索。在子实体还有其子实体的情况下,你可以调用ThenInclude。为了运行此示例,请在Program.cs中注释掉static void Main(string[] args)体中的所有行,除了Examples.Crud.Demo.Run();

注意

你可以在packt.link/c82nA找到用于此示例的代码。

记得当提到尝试从表中获取所有内容在大多数情况下不是正确做法时吗?贪婪加载也有同样的问题。那么,如果你只想获取一些属性,你应该学习 LINQ 的另一面。

查询语法

查询语法是 LINQ lambdas 的另一种语法。它与 SQL 非常相似。查询语法相对于 lambda 的主要优势是,当你有复杂的连接并且只想获取一些数据时,写查询会感觉更自然。想象一下,如果你想获取所有产品-制造商名称对。你不能简单地获取制造商并包含产品;你只想获取两个产品。如果你尝试使用 LINQ,代码将类似于以下:

db.Products
.Join(db.Manufacturers,
    p => p.ManufacturerId, m => m.Id,
    (p, m) => new {Product = p.Name, Manufacturer = m.Name})
.ToList();

使用查询语法执行相同操作的样子如下:

(from p in db.Products
join m in db.Manufacturers
    on p.ManufacturerId equals m.Id
select new {Product = p.Name, Manufacturer = m.Name}
).ToList();

分解代码:

from p in db.Products

现在选中所有产品和它们的列:

join m in db.Manufacturers

对于每个产品,添加制造商列如下:

on p.ManufacturerId equals m.Id

只有当产品的ManufacturerId等于制造商的Id时,才会添加制造商列(INNER JOIN)。

注意

为什么不能写==而不是equals?这是因为,在 LINQ 查询语法中,equals完成了一个连接;它不仅仅是两个值的比较。

select部分在 lambda 和查询语法中都是相同的;然而,提一下你选择了什么是有意义的。select new {...}意味着你创建了一个新的匿名对象,包含你想要选择的所有内容。这个想法是稍后使用它来返回一个你需要的有强类型的对象。因此,在调用ToList方法之后,你很可能会执行另一个select操作来映射最终返回的结果。你不能立即进行映射,因为在调用ToList之前,你仍在处理一个尚未转换为 SQL 的表达式。只有调用ToList之后,你才能确定你正在处理 C#对象。

最后,你可能想知道为什么在调用ToList之前,连接操作被括号包围。这是因为你仍然处于查询语法模式,而唯一摆脱它并回到正常 LINQ 的方式就是用括号包围它。

如果你难以记住 LINQ 查询语法,记住一个foreach循环:

foreach(var product in db.Products)

from的查询语法如下:

from product in db.Products

在前面的代码片段中突出显示的部分是两个语法重叠的部分。这也适用于连接。这两个部分充分利用了查询语法。

lambda 和查询语法都有相同的性能指标,因为最终,查询语法将被编译成 lambda 等价物。当进行复杂的连接时,使用查询语法可能更有意义,因为它看起来更接近 SQL,因此可能更容易理解。

现在运行代码。为了运行此示例,在Program.cs中的static void Main(string[] args)体内部除Examples.Crud.Demo.Run();之外的所有行进行注释:

注意

你可以在packt.link/c82nA找到用于此示例的代码。

你现在知道查询语法是 LINQ lambda 的另一种语法。但你是如何使用查询语法来执行剩余的操作,比如创建、更新和删除行呢?下一节将详细说明如何做到这一点。

CRUD 的其余部分

使用查询语法添加、更新和删除数据与基本的 LINQ 也类似。然而,与通过调用ToList执行查询类似,它涉及一个额外的步骤,即提交更改。考虑以下代码,其中你正在创建一个新的产品:

var product = new Product
{
    Name = "Teddy Bear",
    Price = 10,
    ManufacturerId = 1
};
db.Products.Add(product);
db.SaveChanges();

这段代码看起来几乎完全熟悉,除了最后一行。SaveChanges方法用于运行实际的 SQL。如果你不调用它,什么都不会发生,更改在断开数据库连接后将会消失。此外,当添加子实体(product)时,你不需要获取父实体(manufacturer)。你只需要通过外键(ManufacturerId)在两者之间提供链接。

为什么你需要一个额外的方法来应用你的更改?直接调用Add并立即创建一个新的产品行不是更简单吗?在实践中,事情并不那么简单。如果有多个不同实体的Add方法需要执行,并且其中一个失败了怎么办?你应该允许其中一些成功,而其他失败吗?最糟糕的事情是你可以将数据库置于无效状态,换句话说,破坏数据完整性。你需要一个机制来完全完成或失败,而不影响任何东西。

在 SQL 上下文中,这些一起运行的命令被称为事务。你可以对事务做两件事——要么提交,要么回滚。在 EF 中,除了查询之外,每个操作都会导致一个事务。SaveChanges方法完成事务,而命令失败则会回滚事务。

如果你需要在 C#中调用纯 SQL 命令,你需要创建一个参数化的 SQL 命令,分别提供每个参数,并拼接 SQL 以进行多查询更新。对于小型实体来说可能很简单;然而,随着规模的增大,复杂性也会增加。使用 EF,你不需要关心低级细节,例如向命令传递参数。例如,使用 EF,添加一个包含几个产品的manufacturer就像向Manufacturers列表添加一个manufacturer一样简单:

var manufacturer = new Manufacturer
{
    Country = "Lithuania",
    Name = "Toy Lasers",
    Products = new List<Product>
    {
        new()
        {
            Name = "Laser S",
            Price = 4.01m
        },
        new()
        {
            Name = "Laser M",
            Price = 7.99m
        }
    }
};
db.Manufacturers.Add(manufacturer);
db.SaveChanges();

如你所见,创建制造商几乎与向列表添加元素相同。主要区别在于需要使用db.SaveChanges()方法来完成更改。

关于更新现有产品?将产品的价格设置为45.99

var productUpdate = new Product
{
    Id = existingProduct.Id,
    Price = 45.99m,
    ManufacturerId = existingProduct.ManufacturerId,
    Name = existingProduct.Name
};
db.Products.Update(productUpdate);
 db.SaveChanges();

如果你仔细查看此代码,你不仅提供了更新的Price和现有的Id项,还提供了所有其他字段。这是因为 EF 无法知道你是否想将现有值设置为 null,或者只设置新值。但别担心;从逻辑上讲,更新某物而没有任何依据的情况很少见。你应该在某处加载一组项目。因此,更新现有对象只是设置该对象属性的新值的问题。

当然,当你只想更新一项内容时,会有例外。在这种情况下,你可以有一个专门的方法,并完全控制。在下面的代码片段中,你将更新产品值,但仅当它们不为空时:

var productToUpdate = db.Products.Find(productUpdate.Id);
var anyProductToUpdate = productToUpdate != null;
if (anyProductToUpdate)
{
    productToUpdate.Name = productUpdate.Name ?? productToUpdate.Name;

    productToUpdate.ManufacturerId = (productUpdate.ManufacturerId != default)
        ? productUpdate.ManufacturerId
        : productToUpdate.ManufacturerId;

    productToUpdate.Price = (productUpdate.Price != default)
        ? productUpdate.Price
        : productToUpdate.Price;

    db.SaveChanges();
}

在这里,你只会更新那些不是默认值的值。理想情况下,在类似这种情况(你只想更新一些字段)的情况下工作,你应该有一个专门用于更新字段的模型,发送这些字段,并使用如 AutoMapper 之类的库进行映射。

注意

要了解更多关于 AutoMapper 的信息,请参阅他们的官方文档:docs.automapper.org/en/stable/Getting-started.xhtml

关于从数据库中删除现有行呢?这涉及到首先获取你想要删除的对象,然后才能删除它。例如,假设你想删除具有特定 ID 的产品:

var productToDelete = db.Products.Find(productId);
if (productToDelete != null)
{
    db.Products.Remove(productToDelete);
    db.SaveChanges();
} 

再次强调,从数据库中删除某项内容几乎与从列表中删除一个元素相同,唯一的区别是使用db.SaveChanges()来确认更改。为了运行此示例,请在Program.cs中注释掉static void Main(string[] args)体内的所有行,除了Examples.Crud.Demo.Run();

注意

你可以在此示例中找到使用的代码:packt.link/bH5c4

你已经掌握了 CRUD 的基本概念是四个函数的组合——创建、读取、更新和删除。现在,是时候在以下练习中将其付诸实践了。

练习 6.02:更新产品和制造商表

你已经创建了一个包含ProductsManufacturers表的GlobalFactory数据库,你现在有足够的组件来执行数据库的完整创建、读取、更新和删除(CRUD)操作。在这个练习中,你将使用FactoryDbContext在名为GlobalFactoryService的新类中创建方法,以完成以下任务:

  • 添加美国地区的制造商列表。

  • 将产品列表添加到美国的所有制造商中。

  • 在美国更新任何一种产品,并给出一个折扣价格。

  • 从美国地区删除任何一种产品。

  • 获取美国的所有制造商及其产品。

执行以下步骤以完成此练习:

  1. 首先,创建一个GlobalFactoryService类。

  2. 在构造函数中创建 FactoryDbContext 并注入上下文。注入上下文意味着您可以选择以任何您想要的方式设置它(例如,使用不同的提供者)。

  3. 创建一个接受 FactoryDbContext 作为参数的构造函数,如下所示:

    public class GlobalFactoryService : IDisposable
    {
        private readonly FactoryDbContext _context;
    
        public GlobalFactoryService(FactoryDbContext context)
        {
            _context = context;
        }
    
  4. 创建一个 public void CreateManufacturersInUsa(IEnumerable<string> names) 方法,如下所示:

    public void CreateManufacturersInUsa(IEnumerable<string> names)
    {
        var manufacturers = names
            .Select(name => new Manufacturer()
            {
                Name = name,
                Country = "USA"
            });
    
        _context.Manufacturers.AddRange(manufacturers);
        _context.SaveChanges();
    }
    

制造商只有两个字段——NameCountry。在这种情况下,Country 的值已知为 "USA"。您只需传递一个制造商 names 列表,并通过将 Country 的值与它们的名称组合来构建 Manufacturers

  1. 要创建产品,创建一个 public void CreateUsaProducts(IEnumerable<Product> products) 方法。

  2. 然后获取所有美国制造商。

  3. 最后,遍历每个制造商并将所有产品添加到每个制造商中:

    public void CreateUsaProducts(IEnumerable<Product> products)
    {
        var manufacturersInUsa = _context
            .Manufacturers
            .Where(m => m.Country == "USA")
            .ToList();
    
        foreach (var product in products)
        {
            manufacturersInUsa.ForEach(m => m.Products.Add(
                new Product {Name = product.Name, Price = product.Price}
                ));
        }
    
        _context.SaveChanges();
    }
    

注意,在这个例子中,每次您将相同的产品添加到制造商时,都会创建一个新的产品。这样做是因为尽管产品具有相同的属性,但它属于不同的制造商。为了设置这种区别,您需要传递不同的对象。如果您不这样做,产品将被分配给相同的(最后引用的)制造商。

  1. 创建一个 public void SetAnyUsaProductOnDiscount(decimal discountedPrice) 方法。

  2. 要设置任何美国产品的折扣,首先获取所有来自美国地区的商品,然后选择其中的第一个(顺序不重要)。

  3. 接下来为该产品设置一个新的 Price,并调用 SaveChanges() 以确认:

    public void SetAnyUsaProductOnDiscount(decimal discountedPrice)
    {
        var anyProductInUsa = _context
            .Products
            .FirstOrDefault(p => p.Manufacturer.Country == "USA");
    
        anyProductInUsa.Price = discountedPrice;
    
        _context.SaveChanges();
    }
    
  4. 创建一个 public void RemoveAnyProductInUsa() 方法。

  5. 要删除一个项目,只需选择 "USA" 组中的第一个产品并将其删除:

    public void RemoveAnyProductInUsa()
    {
        var anyProductInUsa = _context
            .Products
            .FirstOrDefault(p => p.Manufacturer.Country == "USA");
    
        _context.Remove(anyProductInUsa);
        _context.SaveChanges();
    }
    

    注意

    注意到在每一步之后都调用了 SaveChanges

  6. 为了获取来自美国的制造商,创建一个 public IEnumerable<Manufacturer> GetManufacturersInUsa() 方法。

  7. 在查询的末尾调用 ToList() 以执行 SQL:

      public IEnumerable<Manufacturer> GetManufacturersInUsa()
      {
          var manufacturersFromUsa = _context
              .Manufacturers
              .Include(m => m.Products)
              .Where(m => m.Country == "USA")
              .ToList();
    
          return manufacturersFromUsa;
      }
    }
    
  8. 创建一个 Demo 类,在其中调用所有函数:

    Demo.cs
    public static class Demo
    {
        public static void Run()
        {
            var service = new GlobalFactoryService(new FactoryDbContext());
            service.CreateManufacturersInUsa(new []{"Best Buy", "Iron Retail"});
            service.CreateUsaProducts(new []
            {
                new Product
                {
                    Name = "Toy computer",
                    Price = 20.99m
                },
                new Product
                {
    
The complete code can be found here: https://packt.link/qMYbi.

为了运行此练习,在 Program.cs 中的 static void Main(string[] args) 体内部除 Exercises.Exercise02.Demo.Run(); 之外的所有行进行注释。前面代码的输出将如下所示:

Best Buy:
Loli microphone 5
Iron Retail:
Toy computer 20,99
Loli microphone 7,51

此输出显示了您想要实现的确切内容。您创建了两个制造商:Best BuyIron Retail。每个制造商都有两个产品,但从第一个制造商 Best Buy 中移除了一个。因此,只有单个产品出现在其下,而 Iron Retail 下有产品。

注意

您可以在 packt.link/uq97N 找到用于此练习的代码。

到目前为止,您已经知道如何与现有数据库交互。然而,您到目前为止所做的是手动编写模型以适应您创建的 GlobalFactory 数据库。使用 EF,您只需要一边——要么是数据库,要么是 DbContext 架构。在下一节中,您将学习如何使用这两种方法之一。

数据库优先

在某些情况下,您可能不需要自己设计数据库。通常,一个架构师会为您做这件事,然后数据库管理员会处理进一步的更改。在其他情况下,您可能需要与一些非常旧的项目和遗留数据库一起工作。这两种情况都适合数据库-first 方法,因为您可以使用现有数据库生成包含所有所需模型的DbContext模式。

选择的必须是一个可执行项目。例如,WebApiConsoleApp是可以的;然而,一个类库不行(您不能运行类库;您只能从其他应用程序中引用它)。因此,在控制台中运行以下命令来安装 EF 工具:

dotnet add package Microsoft.EntityFrameworkCore.tools

最后,运行以下命令:

dotnet ef dbcontext scaffold "Host=localhost;Username=postgres;Password=****;Database=Adventureworks" Npgsql.EntityFrameworkCore.PostgreSQL -o your/models/path --schema "production"

此命令读取数据库模式(您指定从所有模式而不是仅一个生产模式生成数据库)并从中生成模型。您使用了AdventureWorks数据库。使用-o标志,您选择输出目录,使用–schema标志,您指定要从中生成数据库的模式。

注意

从现有数据库生成的模型可以在packt.link/8KIOK找到。

生成的模型非常有趣。它们揭示了两个尚未讨论过的事情。当您创建Manufacturer类(阅读使用 EF 建模数据库部分)时,您没有从构造函数中初始化产品集合。这不是一个大问题,但您不会返回数据,而是得到一个空引用异常,这可能不是您想要的。无论模型多么简单或复杂,都没有属性。

您几乎完成了 db-first 方法。下一节将回顾DbContext并检查 EF 是如何做的,这样您就可以将您在代码-first 方法中学到的知识应用到实践中。

回顾 DbContext

通过逻辑理解以下片段AdventureWorksContext,您会注意到默认配置与DbContext 和 DbSet部分中创建的配置略有不同。不是直接使用 SQL Server 的连接字符串,生成的上下文使用OnConfiguring方法来双重检查给定的上下文选项,如果它们未配置,则设置一个。这是一个更干净的方法,因为您不必手动初始化构建器自己,并防止未配置的选项:

public globalfactory2021Context()
        {
        }
        public globalfactory2021Context(DbContextOptions<globalfactory2021Context> options)
            : base(options)
        {
        } 
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.UseNpgsql(Program.GlobalFactoryConnectionString);
            }
        }

接下来,有一个名为OnModelCreating的方法。这是一个接受ModelBuilder的方法,用于动态构建数据库模型。ModelBuilder直接替换了基于属性的方案,因为它允许您保持模型无属性,并在上下文初始化时添加所需的约束或映射。它包括列名、约束、键和索引。

ModelBuilder允许您使用 Fluent API(即方法链),这反过来又允许您向模型添加额外配置。考虑以下单个、完全配置的模型:

globalfactory2021Context.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Manufacturer>(entity =>
{
              entity.ToTable("manufacturer", "factory");

              entity.Property(e => e.Id)
                      .HasColumnName("id")
                      .UseIdentityAlwaysColumn();

              entity.Property(e => e.Country)
                      .IsRequired()
                      .HasMaxLength(50)
                      .HasColumnName("country");
The complete code can be found here: https://packt.link/S5s6d.

查看此部分的ModelBuilder将向您展示模型如何映射到表及其列、键、索引和关系。生成的代码已为您分解。要开始配置实体,您需要调用以下代码:

modelBuilder.Entity< Manufacturer >(entity =>

映射到表和模式如下所示:

entity.ToTable("manufacturer", "factory");

您还可以添加约束(例如,确保字段不为空)并设置映射到列的字符限制和名称。在以下代码中,您正在对Name执行此操作:

entity.Property(e => e.Name)
        .IsRequired()
        .HasMaxLength(50)
   .HasColumnName("name");

最后,一些实体与多个导航属性相关联。当涉及多个导航属性时,EF 可能无法清楚地解释应该是什么关系。在这些情况下,您需要手动配置,如下面的代码所示:

                entity.HasOne(d => d.Manufacturer)
                    .WithMany(p => p.Products)
                    .HasForeignKey(d => d.Manufacturerid)
                    .HasConstraintName("product_manufacturerid_id");

上述代码将Manufacturer实体映射到Product,并设置外键列名为product_manufacturerid_id。发现这些情况可能很棘手;因此,只有在错误通知您存在这种歧义时,才应添加手动配置:

Unable to determine the relationship represented by navigation property Entity.NavProperty' of type 'AnotherEntity'. Either manually configure the relationship, or ignore this property from the model.

注意

这里没有可运行的代码;这只是一个数据库的框架。

现在您知道了生成的DbContext的样子以及如何自己自定义模型。在不接触模型类的情况下,而是使用ModelBuidler,现在是时候熟悉如何从上下文中生成数据库了。

从现有数据库生成 DbContext

对于后续示例,您将使用GlobalFactory2021数据库。为了确保您所做的是与数据库内容相同的,您将再次执行数据库框架操作。框架操作是一个操作,它从数据库模式(或DbContext)生成物理数据库。

打开控制台并运行以下命令:

dotnet ef dbcontext scaffold "Host=localhost;Username=postgres;Password=****;Database=globalfactory2021" Npgsql.EntityFrameworkCore.PostgreSQL -o Examples/GlobalFactory2021.

为了安全起见,不要忘记将DbContext中的硬编码连接字符串替换为环境变量中的连接字符串。生成的DbContext应如下所示:

图 6.3:应用框架命令后生成的 DbContext

图 6.3:应用框架命令后生成的 DbContext

EF 的主要优势之一是您可以快速定义实体,然后从它们创建数据库。但首先,您需要先学习代码优先的方法。

代码优先和迁移

通常,当您需要创建一个概念验证时,您将创建一个包含模型和数据库的DbContext模式,然后从该模式生成数据库。这种方法称为代码优先。

在这个例子中,你将使用从 GlobalFactory2021 数据库生成的上下文,然后基于它生成一个新的数据库。这种方法需要一个名为 Design 的额外包,所以请确保通过运行以下命令安装它:

dotnet add package Microsoft.EntityFrameworkCore.Design

EF 能够生成数据库,并且可以为它提供不同的版本控制。实际上,它可以从一个数据库版本迁移到另一个版本。在任何给定时间的一个单独的数据库版本被称为迁移。迁移是必要的,以确保你不仅仅总是重新创建数据库(毕竟,你不想丢失现有数据),而是以整洁、安全和可信的方式应用它们。要添加第一个迁移,从 VS Code 终端运行以下命令:

dotnet ef migrations add MyFirstMigration -c globalfactory2021Context

这将生成一个迁移文件:

图 6.4:新迁移默认放置在项目根目录下的 Migrations 文件夹中

图 6.4:新迁移默认放置在项目根目录下的 Migrations 文件夹中

迁移为 EF 提供有关下一个数据库架构版本的信息,因此可以用来从(或应用新更改到现有数据库)。请注意,由于你有多个 DbContext 架构,EF 无法告诉你使用哪个上下文,你必须明确提供。还值得一提的是,运行此命令需要选择默认项目,该项目包括所需的上下文,并将迁移放置在该项目的目录中。

为什么不能立即生成数据库呢?当处理数据时,捕捉任何给定时间的变更并能够回到之前的版本非常重要。尽管直接生成数据库听起来很简单,但这不是一个可行的方案,因为变更一直在发生。你希望保持控制,并能够随意在版本之间切换。迁移方法也与代码版本控制系统(如 Git)兼容,因为你可以通过迁移文件查看对数据库所做的更改。你将在 第十一章生产就绪的 C#:从开发到部署 中了解更多关于版本控制的内容。

在创建数据库之前,请确保更改连接字符串中的数据库名称,以便可以创建新的数据库而不会覆盖现有数据库。可以从迁移运行以下命令来创建新的数据库:

dotnet ef database update -c globalfactory2021context

如果你打开 pgAdmin,你会看到一个非常熟悉的视图,包括 manufacturerproduct。然而,有一个新的表格用于迁移历史:

图 6.5:pgAdmin 浏览器内生成的数据库(为了简洁,此处为简化视图)

图 6.5:pgAdmin 浏览器内生成的数据库(为了简洁,此处为简化视图)

__EFMigrationsHistory 表列出了所有已执行的迁移,执行时间以及执行迁移时使用的 EF 版本。在下面的屏幕截图中,你可以看到创建的第一个迁移名为 MyfirstMigration

图 6.6:EFMigrationsHistory 表的行

图 6.6:EFMigrationsHistory 表的行

您可能会觉得迁移表只有两列很奇怪。然而,这两列包含了所有需要的信息,例如何时、何事以及如何。在 MigrationId 下,下划线前的数字指的是迁移运行的时间和日期。这后面跟着迁移名称。ProductVersion 指的是执行命令时使用的 EF Core 版本。

如果您想在您的数据模型中进行更改怎么办?如果您希望 manufacturer 表也有一个成立日期怎么办?您将需要通过相同的流程——添加迁移并更新数据库。

因此,首先,您会在 Manufacturer 类内部添加一个新属性:

public DateTime FoundedAt { get; set; }

在这里,FoundedAt 是一个日期。它不需要与它相关联的时间,因此您应该指定一个适当的 SQL Server 类型,该类型映射到它。您将在 GlobalFactory2021Context 中的 OnModelCreating 方法中这样做:

entity.Property(e => e.FoundedAt)
    .HasColumnType("date")

现在您可以将它添加到一个新的迁移中:

dotnet ef migrations add AddManufacturerFoundedDate -c globalfactory2021Context

将新迁移应用到数据库中:

dotnet ef database update -c globalfactory2021context

这将在迁移历史中添加一个新条目:

图 6.7:作为在迁移表中创建的新迁移的迁移 2

图 6.7:作为在迁移表中创建的新迁移的迁移 2

您应该会看到 manufacturer 表中的新列如下:

图 6.8:名为 foundedat 的新列的制造商表

图 6.8:名为 foundedat 的新列的制造商表

现在您知道了如何应用您的模型,更改它们,并从模型生成数据库。到目前为止,您已经做出了以下更改:

  • 添加了 FoundedAt 属性和模型构建器更改。

  • 创建了迁移文件。

  • 使用该迁移文件更新了数据库。

取消这些更改将涉及以下顺序的相反操作:

  • 回滚数据库更改(将数据库更新到最后一次成功的迁移)。

  • 移除迁移文件。

  • 移除模型构建器更改。

EF 迁移允许您选择性地应用任何您想要的迁移。在这里,您将应用之前的迁移:

dotnet ef database update MyFirstMigration -c globalfactory2021context

您将使用以下命令删除迁移文件:

dotnet ef migrations remove -c globalfactory2021Context

当处理大型且复杂的数据库时,尤其是在它们已经处于生产状态时,使用 EF 工具进行迁移可能会变得过于复杂。毕竟,您无法完全控制 EF 为迁移生成的确切脚本。如果您需要自定义迁移脚本,EF 将不再适合您的需求。然而,您始终可以将 EF 要执行的操作转换为 SQL。您可以通过运行以下命令来完成此操作:

dotnet ef migrations script -c globalfactory2021context

此命令生成的是 SQL 脚本,而不是 C# 迁移类。在生产环境中,执行 SQL 脚本(通常是经过修改的)是执行迁移的首选方式。

这些只是你在与数据库工作时可能会遇到的一些基本但常见的场景。变化几乎总是发生的;因此,你应该预料到它并做好准备,就像你将在以下练习中看到的那样。

练习 6.03:管理产品价格变动

再次,你的经理对你的结果印象深刻。这次,他们要求你跟踪产品价格变动。他们希望有一个新的表,产品价格历史,记录产品价格的变化。

以下步骤将帮助你完成这个练习:

  1. 为了跟踪价格变动,添加一个新的模型产品价格历史,包含以下字段:

    • ID

    • 价格

    • 价格日期

    • 产品 ID

    • 产品

新模型代码如下:

public class ProductPriceHistory
{
    public int Id { get; set; }
    public decimal Price { get; set; }
    public DateTime DateOfPrice { get; set; }
    public int ProductId { get; set; }

    public Product Product { get; set; }
}
  1. 接下来,更新产品模型,使其包括历史价格变动。因此,添加一个新的集合属性产品价格历史

    public ICollection<ProductPriceHistory> PriceHistory { get; set; }
    
  2. 修改价格列。价格现在应该是一个获取产品最新价格的方法,完整的模型现在看起来如下:

    public partial class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int ManufacturerId { get; set; }
    
        public decimal GetPrice() => PriceHistory
            .Where(p => p.ProductId == Id)
            .OrderByDescending(p => p.DateOfPrice)
            .First().Price;
    
        public Manufacturer Manufacturer { get; set; }
        public ICollection<ProductPriceHistory> PriceHistory { get; set; }
    }
    
  3. 更新DbContext以包含一个新的DbSet,并将产品价格历史配置添加到OnModelCreating方法中,如下所示:

    modelBuilder.Entity<ProductPriceHistory>(entity =>
    {
        entity.ToTable("ProductPriceHistory", "Factory");
    
        entity.Property(e => e.Price)
            .HasColumnType("money");
    
        entity.Property(e => e.DateOfPrice)
            .HasColumnType("date");
    

上述代码提供了到表和列属性类型的映射。一个产品有许多历史价格变动,因此它与价格历史形成一个 1:n 的关系。

  1. 在上述代码之后,在产品价格历史之间创建一个 1:n 的关系:

     RelationalForeignKeyBuilderExtensions.HasConstraintName((ReferenceCollectionBuilder)
            entity.HasOne(d => d.Product)
                .WithMany(p => p.PriceHistory)
                .HasForeignKey(d => d.ProductId), "FK_ProductPriceHistory_Product");
    });
    
  2. 为了捕获数据库更改(以便你可以将更改从代码应用到数据库或回滚),添加以下迁移

    dotnet ef migrations add AddProductPriceHistory -c globalfactory2021Contextv3 -o Exercises/Exercise03/Migrations
    

将生成以下内容:

图 6.9:生成的数据库迁移和额外文件

图 6.9:生成的数据库迁移和额外文件

  1. 为了应用迁移,运行以下命令:

    dotnet ef database update -c globalfactory2021contextv3
    
  2. 通过添加一些虚拟数据创建一个演示

    Demo.cs
    public static class Demo
    {
        public static void Run()
        {
            var db = new globalfactory2021Contextv3();
            var manufacturer = new Manufacturer
            {
                Country = "Canada",
                FoundedAt = DateTime.UtcNow,
                Name = "Fake Toys"
            };
    
            var product = new Product
            {
                Name = "Rubber Sweater",
    
The complete code can be found here: https://packt.link/4FMz4.

在这里,你首先创建了一个制造商及其产品,然后添加了一些价格变动。一旦变动被保存,你就从数据库断开连接(这样你就不与缓存的实体一起工作)。为了测试它是否工作,你查询了所有假玩具制造商及其产品及其价格历史。

注意

当处理日期时,特别是在数据库或可能超出你本地环境的共享环境中,最好使用不带你本地化的日期,通过调用DateTime.UtcNow

  1. 为了运行这个练习,在Program.cs中的static void Main(string[] args)方法体内注释掉所有行,除了Exercises.Exercise03.Demo.Run();。你将看到以下输出:

    Fake Toys Rubber Sweater 15.5000
    

演示中,你创建了一个包含一个产品(玩具橡胶毛衣)的制造商。这个玩具有两个价格:15.1115.50(最新的)。然后你将这个玩具保存到数据库中,断开连接,并重新连接到该数据库(确保玩具没有被缓存,而是被检索),并执行了一个基于懒加载的连接。

注意

你可以在packt.link/viVZW找到他练习所用的代码。

EF(Entity Framework)对于快速数据库开发非常有效,但正因为如此,它也非常危险。缺乏经验的开发者往往依赖于幕后发生的魔法,因此忘记了 EF 不能神奇地优化数据模型以适应您的特定场景,或者猜测预期的查询应该表现得更好。以下部分将回顾人们在使用 EF 时犯的主要错误。

EF 的陷阱

EF 从你那里抽象了很多细节,显著简化了你的工作。然而,它也引入了不了解实际发生情况的风险。有时,你可能会达到你想要的结果,但可能存在你并没有最优地实现目标的机会。以下是在 EF 中犯的一些最常见的错误。

示例设置

对于以下所有示例,假设你将在开始时初始化以下行:

var db = new GlobalFactory2021Context();

假设,每个示例都将以以下内容结束:

db.Dispose();

此外,数据本身将使用以下代码进行(预先生成):

DataSeeding.cs
public static class DataSeeding
{
    public const string ManufacturerName = "Test Factory";
    public const string TestProduct1Name = "Product1     ";
    /// <summary>
    /// Padding should be 13 spaces to the right as per our test data, db and filtering requirements
    /// </summary>
    public const string TestProduct2NameNotPadded = "Product2";
    public const decimal MaxPrice = 1000;

    public static void SeedDataNotSeededBefore()
    {
        var db = new globalfactory2021Context();
        var isDataAlreadySeeded = db.Manufacturers.Any(m => m.Name == ManufacturerName);
        if (isDataAlreadySeeded) return;
The complete code can be found here: https://packt.link/58JTd.

之前的代码创建了一个manufacturer,它有10,000个产品,但前提是那个manufacturer之前不存在。ManufacturerName的长度将正好是 13 个字符,它们的价格将是随机的,但不会超过最大价格。所有这些信息在您断开连接之前都将保存到数据库中。

注意

这不是可运行的代码,它将被用于所有性能比较示例。

所有示例都将比较两个产生相同输出的函数。所有比较的摘要通过执行此演示代码来完成:

Demo.cs
public static class Demo
{
    public static void Run()
    {
        // For benchmarks to be more accurate, make sure you run the seeding before anything
        // And then restart the application
        // Lazy loading is a prime example of being impacted by this inverting the intended results.
        DataSeeding.SeedDataNotSeededBefore();
        // Slow-Faster example pairs
        // The title does not illustrate which you should pick
        // It rather illustrates when it becomes a problem.

    CompareExecTimes(EnumerableVsQueryable.Slow, EnumerableVsQueryable.Fast, "IEnumerable over IQueryable");
    CompareExecTimes(MethodChoice.Slow, MethodChoice.Fast, "equals over ==");
    CompareExecTimes(Loading.Lazy, Loading.Eager, "Lazy over Eager loading");
The complete code can be found here: https://packt.link/xE0Df.

在这里,你将比较内存和 SQL 过滤、懒加载和急加载、跟踪和非跟踪实体,以及逐个添加实体而不是批量添加。在接下来的段落中,你将找到被比较的函数,但每个比较都将显示以下内容:

  • 场景名称

  • 做同样事情的速度快和慢版本

你将使用秒表来测量执行时间,并在每次运行后打印格式化的比较。为了运行此示例,请在Program.cs中的static void Main(string[] args)主体内注释掉所有行,除了Examples.PerformanceTraps.Demo.Run();。你可以参考结果摘要部分以获取输出。

这些示例背后的想法是将 EF 的有效工作方式与直接等效的低效方式进行比较。慢速场景是低效的方式,而快速(即有效的方式)是应该这样做的方式。下一节将详细说明使用 EF 的有效方式。

多次添加

有时,你可能没有意识到,在编写程序时,你倾向于使用最直接的方法。例如,要添加 100 个项目,你可能使用 100 个单独的添加操作。然而,这并不总是最佳方法,尤其是在使用 EF 时。你可能会选择一次性插入 100 次,而不是对一个包含 100 个项目的批量进行单个查询。以下是一个示例代码:

for (int i = 0; i < 1000; i++)
{
    var product = new Product
    {
        Name = productName,
        Price = 11,
        ManufacturerId = 2
    };
    db.Products.Add(product);
}

此代码创建 1,000 个产品并将它们附加到 DbContext。发生的情况是,DbContext 模式中的 1,000 个实体被跟踪。你想要的不是将它们作为一个单独的批次跟踪,而是逐个跟踪。

你想要做的是进行范围操作:

  • AddRange

  • UpdateRange,或者

  • RemoveRange

上述代码的一个更好的版本,旨在以最佳方式与批量操作一起工作,如下所示:

var toAdd = new List<Product>();
for (int i = 0; i < 1000; i++)
{
    var product = new Product
    {
        Name = productName,
        Price = 11,
        Manufacturerid = 2
    };
    toAdd.Add(product);
}
db.Products.AddRange(toAdd);

当创建多个项目并打算将它们添加到数据库时,你应该首先将它们添加到一个列表中。当你的列表完成后,你可以将项目作为批量添加到 DbSet<Product> 中。你仍然会遇到多次添加的问题,但与直接调用 DbSet<Product> 添加相比,它的好处是,你不再需要在每次添加时都触发更改跟踪器。为了运行此示例,请在 Program.cs 中的 static void Main(string[] args) 方法体中注释掉所有 Examples.PerformanceTraps.Demo.Run(); 之外的行。

注意

你可以在 packt.link/wPLyB 找到用于此示例的代码。

下一个部分将探讨另一个陷阱——如何根据属性的相等性正确查询。

使用等于(Equals)而不是相等运算符(==)

魔鬼在于细节。C# 开发者通常不会犯这个错误,但如果你在语言之间转换(尤其是从 Java 转换过来),你可能在过滤时这样做:

var filtered = db.Products
    .Where(p => p.Name.Equals(DataSeeding.TestProduct1Name))
    .ToList();

对于 LINQ 来说,这是无害的。然而,在使用 EF 时,这种方法并不推荐。问题是 EF 只能将一些表达式转换为 SQL。通常,一个复杂的方法,如等于,无法转换,因为它来自基对象类,该类可以有多个实现。相反,使用简单的相等运算符:

var filtered = db.Products
    .Where(p => p.Name == DataSeeding.TestProduct1Name)
    .ToList();

第一次尝试的问题在于,它会首先获取所有产品(即执行 SQL 中的 get 语句),然后才会应用过滤条件(在内存中,在 C# 中)。再次强调,这是有问题的,因为使用数据库原生语言应用过滤条件获取数据是最佳的,但在 SQL 中获取产品然后在 C# 中过滤是不太理想的。在第二次尝试中,通过将 Equals 替换为相等运算符 == 来解决这个问题。为了运行此示例,请在 Program.cs 中的 static void Main(string[] args) 方法体中注释掉所有 Examples.PerformanceTraps.Demo.Run(); 之外的行。

注意

你可以在 packt.link/js2es 找到用于此示例的代码。

使用 IEnumerable 而不是 IQueryable

另一个例子涉及对 IEnumerable<Product> 概念的误解:

IEnumerable<Product> products = db.Products;
var filtered = products
    .Where(p => p.Name == DataSeeding.TestProduct1Name)
    .ToList();

在这里,你通过特定的产品名称获取产品。但是,当你将DbSet<Product>对象分配给Ienumerable<Product>时会发生什么,那就是执行SELECT *语句。因此,你不会只获取你需要的筛选后的产品,而是首先获取所有产品,然后手动筛选。

你可能会想知道为什么不能立即进行筛选。在某些情况下,构建查询并在方法间传递是有意义的。但是,在这样做的时候,你应该在它们完全构建之前不要执行它们。因此,你应该使用Iqueryable<Product>而不是Ienumerable<Product>,它是查询实体的抽象——一个在调用ToList<Product>或类似方法后将被转换为 SQL 的表达式。前面代码的高效版本看起来如下:

IQueryable<Product> products = db.Products;
var filtered = products
    .Where(p => p.Name == DataSeeding.TestProduct1Name)
    .ToList();

后者运行更快,因为你在 SQL 中而不是在内存中应用筛选。为了运行此示例,请在Program.cs中注释掉static void Main(string[] args)体内的所有行,除了Examples.PerformanceTraps.Demo.Run();

注意

你可以在packt.link/ehq6C找到用于此示例的代码。

急加载和懒加载已经提到,但还有一个重要的复杂性,应该被涵盖。下一节将详细说明。

懒加载与急加载

在 EF 中,你有一个有趣的 n+1 查询问题。例如,如果你获取一个项目列表,然后随后获取它们各自制造商的列表会导致执行一个 SQL 查询;这将导致延迟加载。幸运的是,从 EF 2.1 开始,这不再是默认行为,并且需要显式启用。假设在以下示例中,你已经启用了它。

这是一个获取任何第一项及其制造商的查询:

var product = db.Products.First();
// Lazy loaded
var manufacturer = product.Manufacturer;

初始时,查看此代码时,你可能认为没有问题,但这段小代码执行了两个 SQL 查询:

  • 首先,它选择最上面的产品。

  • 然后它选择关联的制造商,以及制造商 ID。

为了使代码更高效,你需要明确指定你确实想要将Manufacturer包含在产品中。代码的更好、更高效的版本如下:

var manufacturer = db.Products
    // Eager loaded
    .Include(p => p.Manufacturer)
    .First()
    .Manufacturer;

后者转换为一个查询,其中两个表之间进行连接,并返回其中一个表的第一项。为了运行此示例,请在Program.cs中注释掉static void Main(string[] args)体内的所有行,除了Examples.PerformanceTraps.Demo.Run();

注意

你可以在packt.link/osrEM找到用于此示例的代码。

只读查询

EF 在运行你的查询时假设了很多事情。在大多数情况下,它做得很好,但有许多情况下你应该明确指出,并指示它不要假设。例如,你可以这样获取所有产品:

var products = db.Products
    .ToList();

默认情况下,EF 将跟踪所有检索和更改的实体。在某些情况下这很有用,但并不总是如此。当你有只读查询,只是获取而不修改实体时,你会明确告诉 EF 不要跟踪任何实体。获取产品的最佳方式如下:

var products = db.Products
    .AsNoTracking()
    .ToList();

所有这些代码所做的只是对数据库运行查询并将结果映射。EF 保持上下文清洁。为了运行此示例,请在Program.cs中的static void Main(string[] args)方法体内注释掉除Examples.PerformanceTraps.Demo.Run();之外的所有行。

注意

你可以在packt.link/rSW1k找到用于此示例的代码。

结果摘要

以下代码片段显示了前几节的所有结果,以表格形式呈现:

IENUMERABLE OVER IQUERYABLE          Scenario1: 75ms,   Scenario2: 31ms
EQUALS OVER ==                       Scenario1: 33ms,   Scenario2: 24ms
LAZY OVER EAGER LOADING              Scenario1: 3ms,    Scenario2: 29ms
READ-ONLY QUERIES                    Scenario1: 40ms,   Scenario2: 10ms
MULTIPLE ADDS                        Scenario1: 8ms,    Scenario2: 8ms

注意,输出取决于你运行数据库的机器、数据等。这个比较的目的不是给你提供应该选择什么的具体规则,而是展示不同的方法可能如何节省大量的计算时间。

EF 是一个强大的工具,它允许你快速与数据库工作;然而,你应该小心地使用它。不用担心,即使你认为你对查询的内部工作方式不确定,仍然有方法可以看到下面发生了什么。

帮助你早期发现问题的工具

EF 本身就是一个工具箱;它允许你轻松地将其钩入并跟踪所发生的事情,而无需任何外部工具。你可以通过将以下内容添加到OnConfiguring方法来启用记录所有 EF 操作:

optionsBuilder.LogTo((s) => Debug.WriteLine(s));

如果你运行示例代码,这将记录在output窗口内的跟踪信息,如下所示:

图 6.10:运行性能陷阱演示后的调试输出

图 6.10:运行性能陷阱演示后的调试输出

该图像显示了 EF 执行代码时生成的 SQL——特别是选择所有产品。

当你想完全调试你的应用程序并了解 EF 的每一步时,这种方法很有用。它对于发现你预期以 SQL 执行但实际上在内存中执行的查询非常有效。

在下一节中,你将了解有助于组织数据库通信代码的模式。

在企业中与数据库一起工作

当谈到数据库时,你通常会想象使用 SQL 或其他语言与之通信。在此基础上,另一种语言(在这个例子中是 C#)通常被用来连接数据库并执行 SQL 查询。如果不加控制,C#会与 SQL 混合,这会导致你的代码变得混乱。多年来,已经有一些模式被精炼出来,以干净的方式实现与数据库的通信。其中两种模式,即仓储和 CQRS,至今仍被广泛使用。

仓储模式

仓储是一个针对模型的目标模式,并定义了所有(如果需要)可能的 CRUD 操作。例如,如果你有一个Product模型,你可以有一个具有以下接口的仓储:

public interface IProductRepository
{
    int Create(Product product);
    void Delete(int id);
    void Update(Product product);
    Product Get(int id);
    IEnumerable<Product> Get();
}

这是一个经典的存储库模式,其中每个数据库操作都被抽象化。这允许你在数据库中做几乎所有你想做的事情,而不用担心底层数据库或你用来与数据库通信的技术。

注意,在这种情况下,Create方法返回一个整数。通常,在编写代码时,你会将改变状态的方法与查询方法分开。换句话说,不要试图同时获取和更改某个东西。然而,在这种情况下,这是很难实现的,因为实体的 ID 将由数据库生成。因此,如果你想对实体进行操作,你需要获取那个 ID。你也可以返回整个实体,但这就像你需要的是一个地址,而你却得到了一栋房子。

假设你想执行相同的四个操作(创建、删除、更新和获取),该模式看起来像这样:

public interface IManufacturerRepository
{
    int Create(Manufacturer product);
    void Delete(int id);
    void Update(Manufacturer product);
    Manufacturer Get(int id);
    IEnumerable<Manufacturer> Get();
}

它看起来几乎一样;唯一的区别是目标实体。鉴于你有一个非常简单的应用程序,它只是以非常简单的方式处理数据,使这些存储库通用是有意义的:

public interface IRepository<TEntity>: IDisposable where TEntity : class
{
    int Create(TEntity productentity);
    void Delete(long id)(int id);
    void Update(TEntity entityproduct);
    TEntity Get(long id)(int id);
    IEnumerable<TEntity> Get();
    void Dispose();
}

在这里,而不是ProductManufacturer,接口接受一个通用的TEntity,它必须是一个类。你还有一个继承的IDisposable接口,用于清理存储库使用的所有资源。这个存储库仍然有缺陷。那么,你应该能够持久化任何类吗?如果是这样,标记你可以持久化的类会很好。

是的,你可以这样做。当谈论存储库时,你应该意识到,即使某物应该保存在数据库中,这并不意味着它会被单独保存。例如,联系信息总是与一个人一起保存。一个人可以没有联系信息存在,但联系信息不能没有一个人存在。人和联系信息都是实体。然而,一个人也是一个聚合(即,当你向数据库添加数据时,你将针对的实体),它可以独立存在。这意味着,如果存储联系信息会违反数据完整性,那么为联系信息创建存储库就没有意义。因此,你应该按聚合而不是按实体创建存储库。

数据库中的每一行应该有什么?它应该有一个 ID。实体是一个你可以持久化的模型(即,有一个 ID);因此,你可以为它定义一个接口:

public interface IEntity
{
    int Id { get; }
}

请注意,在这里你正在使用一个只读的get属性,因为在所有情况下设置 ID 都没有意义。然而,能够通过获取 ID 来识别一个对象是至关重要的。此外,请注意,在这种情况下,ID 是一个整数,因为这只是一个简单的示例,并且不会有太多数据;但在实际应用中,它通常是整数或 GUID。有时,ID 甚至可以是两者之一。在这些情况下,可以考虑使实体接口通用(即,使用通用的TId)。

那么,聚合体是什么?聚合体是一个实体;因此,你会写出以下内容:

public interface IAggregate : IEntity
{
}

在这种情况下,你将只写 Person: IAggregate, ContactInfo: IEntity。如果你将相同的原理应用到两个表上,你会得到 Product: IAggregate, Manufacturer: IAggregate,因为这两个可以单独保存。

注意

这里没有可运行的代码;然而,你将在接下来的练习中使用它。你可以在这个示例中找到用于此示例的代码:packt.link/JDLAo

为每个聚合体编写仓库可能会变得是一项繁琐的工作,特别是如果没有特殊的持久性逻辑。在接下来的练习中,你将学习如何泛化和重用仓库。

练习 6.04:创建一个泛型仓库

与 ORM 的耦合可能会使你的业务逻辑更难测试。此外,由于持久性在大多数应用程序的核心中根深蒂固,更改 ORM 可能会变得麻烦。出于这些原因,你可能在业务逻辑和数据库之间放置一个抽象层。如果你直接使用 DbContext,你将耦合到 EntityFramework

在这个练习中,你将学习如何创建一个数据库操作抽象——一个泛型仓库,它可以在任何实体上工作并支持创建、删除、更新和获取操作。逐个实现这些方法:

  1. 首先,创建一个通用的仓库类,该类在构造函数中接受 DbContext

    public class Repository<TAggregate>: IRepository<TAggregate> where TAggregate: class
    {
        private readonly DbSet<TAggregate> _dbSet;
        private readonly DbContext _context;
    
        public Repository(DbContext context)
        {
            _dbSet = context.Set<TAggregate>();
            _context = context;
        }
    

context.Set<TEntity>() 允许获取一个表模型绑定,然后在整个仓库中使用它。另一个有趣的点是,你不必提供具体的 DbContext,因为它使用泛型实体,并且泛型仓库适用于任何类型的上下文。

  1. 要实现 Create 操作,添加一个方法插入单个聚合:

    public int Create(TAggregate aggregate)
    {
        var added = _dbSet.Add(aggregate);
        _context.SaveChanges();
    
        return added.Entity.Id;
    }
    
  2. 要实现 Delete 操作,添加一个方法通过 ID 删除一个聚合:

        public void Delete(int id)
        {
            var toRemove = _dbSet.Find(id);
            if (toRemove != null)
            {
                _dbSet.Remove(toRemove);
            }
    
            _context.SaveChanges();
        }
    
  3. 要实现 Update 操作,添加一个方法通过用新实体的值覆盖旧值来更新一个实体:

        public void Update(TAggregate aggregate)
        {
            _dbSet.Update(aggregate);
            _context.SaveChanges();
        }
    
  4. 要实现 Read 操作,添加一个方法通过 ID 获取单个实体:

        public TAggregate Get(int id)
        {
            return _dbSet.Find(id);
        }
    
  5. Read 操作也应该支持获取所有实体。因此,添加一个获取所有实体的方法:

        public IEnumerable<TAggregate> Get()
        {
            return _dbSet.ToList();
        }
    
  6. DbContext 传递给构造函数将打开数据库连接。一旦你完成数据库的使用,你应该断开连接。为了支持传统的断开连接,实现 IDisposable 模式:

        public void Dispose()
        {
            _context?.Dispose();
        }
    }
    
  7. 为了测试通用仓库是否工作正常,创建一个新的 Run() 方法:

    public static void Run()
    {
    
  8. Run() 方法内部,为 Manufacturer 实体初始化一个新的仓库:

         var db = new FactoryDbContext();
         var manufacturersRepository = new Repository<Manufacturer>(db);
    
  9. 通过以下代码插入一个新的 manufacturer 来测试 Create 操作是否正常工作:

        var manufacturer = new Manufacturer { Country = "Lithuania", Name = "Tomo Baldai" };
         var id = manufacturersRepository.Create(manufacturer);
    
  10. 通过以下方式更新制造商的名称来测试 Update 操作是否正常工作:

         manufacturer.Name = "New Name";
         manufacturersRepository.Update(manufacturer);
    
  11. 通过从数据库检索新的制造商并打印它来测试 Read 操作在单个实体上的工作情况:

         var manufacturerAfterChanges = manufacturersRepository.Get(id);
         Console.WriteLine($"Id: {manufacturerAfterChanges.Id}, " +
                      $"Name: {manufacturerAfterChanges.Name}");
    

你应该看到以下输出:

Id: 25, Name: New Name
  1. 通过以下代码获取所有制造商的计数来测试Read操作是否对所有实体都有效:

        var countBeforeDelete = manufacturersRepository.Get().Count();
    
  2. 您可以通过以下方式删除新制造商来测试Delete操作是否正常工作:

        manufacturersRepository.Delete(id);
    
  3. 为了看到删除(预期减少一个制造商)的影响,按照以下方式比较计数:

        var countAfter = manufacturersRepository.Get().Count();
        Console.WriteLine($"Before: {countBeforeDelete}, after: {countAfter}");
    }
    
  4. 为了运行此练习,请在Program.cs中的static void Main(string[] args)方法体内注释掉除Exercises.Exercise04.Demo.Run();之外的所有行。运行dotnet run命令后,您应该看到以下输出:

    Before: 3, after: 2
    

以前,使用存储库来实现与数据库的交互是(可能是在 10-20 年前)一种流行的方法,因为这些方法是对数据库调用的一种很好的抽象方式。从数据库中抽象出来将使人们能够在需要时更改底层数据库提供程序。如果数据库发生变化,只有实现该接口的类会发生变化,而使用该接口的任何内容都不会受到影响。

回顾DbContextDbSet,您可能会问为什么不能直接使用它们。答案是您可以使用,并且它具有与存储库类似的作用。这就是为什么存储库模式只有在您的查询足够复杂(意味着它有几行长)时才应该使用。

注意

您可以在packt.link/jDR0C找到此练习使用的代码。

下一个部分将探讨 EF 的另一个好处,即本地数据库测试。

本地测试数据持久性逻辑

在开发软件时,您应该始终考虑质量和可测试性。数据库可测试性的问题在于它通常需要一个物理机器来托管数据库。然而,您并不总是能够访问这样的设置,尤其是在项目开始时。

幸运的是,EF 非常灵活,并提供了一些包来帮助您在这里。使用 EF 进行测试主要有三种方式——InMemory、使用 SQLite 和调用实际数据库。您已经看到了许多调用物理数据库的演示。接下来,您将探索另外两种:内存和 SQLite。

内存数据库提供程序

内存数据库提供程序只是内部可用的一组内存列表,它根本不对数据库进行任何查询。通常,甚至垃圾回收也会消除其状态。在您继续之前,就像所有其他数据库提供程序一样,您需要将其添加到您的项目中。

运行以下命令:

dotnet add package Microsoft.EntityFrameworkCore.InMemory

此命令允许您在向DbContextOptionsBuilder提供UseInMemoryDatabase选项时使用内存数据库,如下面的代码片段所示:

var builder = new DbContextOptionsBuilder<FactoryDbContext>();
builder.UseInMemoryDatabase(Guid.NewGuid().ToString());
var options = builder.Options;
_db = new FactoryDbContext(options);

在此代码片段中,你使用了选项构建器并创建了一个新的、独立的内存数据库。这里最重要的部分是 builder.UseInMemoryDatabase(); 方法,它指定了应该创建内存数据库。此外,请注意 Guid.NewGuid().ToString() 参数。这个参数是数据库名称。在这种情况下,这意味着每次调用该行时,你将生成一个唯一的数据库名称,从而确保新测试数据库之间的隔离。如果你不使用此参数,你可能会影响测试状态下的上下文。你想要避免这种情况,因为测试场景需要从全新状态开始。

为了运行此示例,在 Program.cs 中的 static void Main(string[] args) 方法体内注释掉所有除 Examples.TestingDb.Demo.Run(); 之外的所有行。

注意

你可以在 packt.link/mOodJ 找到用于此示例的代码。

要测试制造商的通用存储库是否工作(假设前面的代码将被重用),首先创建一个新的存储库:

var productsRepository = new Repository<Product>(db);

这种模式的强大之处在于,新的实体存储库只需指定为不同的泛型参数。如果你想测试制造商,你不需要为它设计存储库类。你所要做的就是用 Manufacturer 作为泛型参数初始化存储库,例如 new Repository<Manfacturer>(db)

现在,创建一个测试 product 并保存它:

var product = new Product {Name = "Test PP", ManufacturerId = 1, Price = 9.99m};
var id = productsRepository.Create(product);

要测试价格更新方法,更新 product.Price 并调用 Update 方法:

product.Price = 19m;
productsRepository.Update(product);

为了检查产品是否成功创建,调用一个 Get 方法并传递新的产品 id

var productAfterChanges = productsRepository.Get(id);

输入以下内容以将产品打印到控制台:

Console.WriteLine($"Id: {productAfterChanges.Id}, " +
                  $"Name: {productAfterChanges.Name}, " +
                  $"Price: {productAfterChanges.Price}");

输出将如下显示:

Id: 1, Name: Test PP, Price: 19

现在需要检查删除是否生效。因此,创建一个新的产品:

var productToDelete = new Product { Name = "Test PP 2", ManufacturerId = 1, Price = 9.99m };
var idToDelete = productsRepository.Create(productToDelete);

检查存储库中产品的当前数量:

var countBeforeDelete = productsRepository.Get().Count();

现在删除产品:

productsRepository.Delete(idToDelete);

再次检查数量,与之前的一个进行比较:

var countAfter = productsRepository.Get().Count();
Console.WriteLine($"Before: {countBeforeDelete}, after: {countAfter}");

为了运行此示例,在 Program.cs 中的 static void Main(string[] args) 方法体内注释掉所有除 Examples.TestingDb.Demo.Run(); 之外的所有行。以下输出将显示:

Before: 2, after: 1

注意

你可以在 packt.link/DGjf2 找到用于此示例的代码。

使用内存提供程序有其局限性。接下来,你将学习另一个替代方案,用于测试依赖于 DbContext 的代码,它具有更少的局限性。

SQLite 数据库提供程序

内存提供程序的问题在于你无法在它们上运行任何 SQL 语句。如果你这样做,代码会失败。此外,内存提供程序完全是关于内存数据结构,与 SQL 没有任何关系。SQLite 数据库提供程序免除了这些问题。它唯一的问题是 SQLite 是 SQL 的方言,因此其他提供程序的某些原始 SQL 查询可能不会工作。

要尝试 SQLite,请在 VS Code 终端中运行以下命令:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite

安装的 NuGet 允许您在创建DbContext架构时使用 SQLite 提供者,如下所示:

var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
var builder = new DbContextOptionsBuilder<FactoryDbContext>();
builder.UseSqlite(connection);
var options = builder.Options;
var db = new FactoryDbContext(options);
db.Database.EnsureCreated();

在前面的代码片段中,您创建了一个 SQL 连接,指定将使用内存中的 SQLite 数据库。Db.Database.EnsureCreated()是必需的,因为数据库不会总是使用该连接字符串创建。为了运行此示例,请在Program.cs中的static void Main(string[] args)主体中注释掉除Examples.TestingDb.Demo.Run();之外的所有行。

注意

您可以在packt.link/rW3JS找到用于此示例的代码。

如果您创建ProductsRepository并从InMemory数据库示例中运行相同的代码,您将得到一个错误:SQLite Error 19: 'FOREIGN KEY constraint failed'。这是由于缺少一个 ID 为 1 的制造商,您正在尝试将其与新测试产品关联。这是一个 EF 内存提供者不可靠的典型例子。

为了修复这个问题,在创建测试产品之前添加以下内容:

var manufacturer = new Manufacturer() { Id = 1 };
db.Manufacturers.Add(manufacturer);
db.SaveChanges();

唯一要记住的是清理。在您使用完使用 SQL 连接创建的数据库上下文之后,不要忘记以这种方式销毁该连接:

connection.Dispose();

到目前为止,您已经知道如何以许多不同的方式使用DbContext与数据库进行通信。然而,如果所有这些都依赖于特定的 ORM,那么对第三方库(EF Core)和单元测试的依赖可能会很棘手。在下一段中,您将学习如何避免这种依赖。

关于仓库的一些话

仓库模式适用于简单的 CRUD 应用程序,因为它可以进一步简化数据库交互。然而,鉴于您正在使用 EF,与数据库的交互已经足够简单,额外的抽象层并不总是有必要的。毕竟,仓库模式之所以受到如此多的关注,其中一个关键原因就是它允许您避免数据库交互。然而,EF 内存提供者也允许这样做,因此使用仓库的理由就更少了。

通用仓库模式是一个有用的抽象。它通过一个简单的接口抽象出数据库交互。然而,对于非平凡场景,您可能需要自定义 CRUD 操作,然后您将创建一个非通用仓库。实际上,非通用仓库是推荐的方法(如果您想实现该模式),因为您很少希望所有实体都具有所有 CRUD 方法。在仓库上只有一个方法的情况并不少见。如果您使用通用仓库,您仍然可以使得所有方法都是虚拟的并覆盖它们,但这样您将不断覆盖或拥有不使用的方法。这并不理想。

以下部分将探讨一种不同的模式,该模式力求使每个数据库操作都进行简单、最优的交互——CQRS。

查询和命令处理器模式

命令查询责任分离(CQRS)是一种旨在将读取和写入分离的模式。而不是一个类用于所有 CRUD 操作,你将有一个类用于每个 CRUD 方法。在此基础上,而不是一个适合所有情况的实体,你将有一个针对特定场景的请求和查询对象模型。在 CQRS 中,所有数据库操作可以分为两类:

  • 命令:一种改变状态的运算(创建、更新、删除)。

  • 查询:一种获取某些内容但不影响状态的运算。

图 6.11:马丁·福勒使用的 CQRS 模式

图 6.11:马丁·福勒使用的 CQRS 模式

注意

此图表的原始来源可在www.martinfowler.com/bliki/CQRS.xhtml找到。

为了实现创建产品的命令处理程序,你将首先定义命令。产品需要什么?它需要一个名称、一个价格以及一个制造商。创建命令的 ID 不需要(因为数据库会生成它),制造商属性也可以删除,因为你将不会使用导航属性。CQRS 操作的名字由三个部分组成——操作名称、实体名称以及commandquery后缀。你正在创建一个产品;因此,模型将被称为CreateProductCommand

public class CreateProductCommand
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int ManufacturerId { get; set; }
}

接下来,你将创建此命令的处理程序。在构造函数中,传递数据库上下文。在Handle方法中,传递CreateProductCommand

CreateProductQueryHandler.cs
public class CreateProductCommandHandler
{
    private readonly FactoryDbContext _context;

    public CreateProductCommandHandler(FactoryDbContext context)
    {
        _context = context;
    }

    public int Handle(CreateProductCommand command)
    {
        var product = new Product
        {
            ManufacturerId = command.ManufacturerId,
            Name = command.Name,
The complete code can be found here: https://packt.link/xhAVS.

处理程序是简单的、单方法对象,实现了处理命令或查询所需的所有内容。为了测试,你还将创建一个GetProductQueryHandler类:

public class GetProductQueryHandler
{
    private readonly FactoryDbContext _context;

    public GetProductQueryHandler(FactoryDbContext context)
    {
        _context = context;
    }

    public Product Handle(int id)
    {
        return _context.Products.Find(id);
    }
}

理念几乎相同,只是在这种情况下,查询变得如此简单,其最优模型是一个简单的整数。在某些场景中,如果你能预测复杂性增长和查询变得更加复杂,那么甚至这样的整数也可能转变为一个模型(为了避免查询格式完全改变——从原始整数到对象)。

为了查看命令和查询是否工作,你将再次使用内存数据库上下文。因此,创建一个创建新产品的命令,一个处理它的处理程序,执行它,并按如下方式打印结果:

var command = new CreateProductCommand { Name = "Test PP", Manufacturerid = 1, Price = 9.99m };
var commandHandler = new CreateProductCommandHandler(db);
var newProductId = commandHandler.Handle(command);

创建一个查询以获取创建的产品和一个处理查询的处理程序:

var query = newProductId;
var queryHandler = new GetProductQueryHandler(db);
var product = queryHandler.Handle(query);
Console.WriteLine($"Id: {product.Id}, " +
                  $"Name: {product.Name}, " +
                  $"Price: {product.Price}");

为了运行此示例,请在Program.cs中的static void Main(string[] args)体中注释掉除Examples.Cqrs.Demo.Test();之外的所有行。输出将如下所示:

Id: 1, Name: Test PP, Price: 9,99

注意

你可以在packt.link/Ij6J8找到用于此示例的代码。

你可能想知道,在演示了这么多之后,ProductId为什么仍然是1。那是因为它是一个内存数据库——每次测试时都会创建一个新的。由于你每次都是从空数据库开始的,所以向数据库添加新实体时,结果是一个 ID 为 1 的新条目。

你可能会想知道,如果你对数据库进行了某些更改或向其中添加了列,这将对代码库和业务逻辑产生什么影响。下一节将详细说明这些场景。

将数据库模型从业务逻辑(领域)模型中分离出来

数据库经常发生变化。然而,这会影响代码库的其他部分吗?一列数据类型改变或添加另一列是否会影响到业务逻辑?对此并没有直接的答案。这完全取决于项目范围、资源和团队的成熟度。然而,如果你正在从事一个中等或大型项目,你应该考虑将数据库和领域模型完全分离。这不仅意味着不同的逻辑应该放在不同的项目中,还意味着这些项目应该相互解耦。

数据库层消费领域层是可以的,但领域层消费数据库层是不可以的。如果你想在这两者之间实现完全分离,你将不得不引入一个反腐败层。这是一个概念,表示不要消费外部模型,而是在它们到达该层的公共组件时立即将它们映射。这个想法是,所有接口都应该特定于领域(即与领域模型一起工作)。然而,对于数据库通信实现,内部你将使用数据库实体而不是领域模型。这需要将它们映射到对方(在接收输入或返回输出时)。

在数据库实体完全改变的情况下,领域特定的接口将保持不变。只有映射会改变,这将防止数据库影响其他任何东西。对于初学者来说,这并不是一件容易理解或实现的事情。建议你现在忽略这一点;你的个人项目范围不值得付出这样的努力,你可能看不到任何好处。

这就结束了本节的理论部分。在接下来的部分,你将通过一个活动来将其付诸实践。

活动六点零一:卡车派发跟踪系统

一家物流公司雇佣你来跟踪派发的卡车。单次派发包括卡车的当前位置、卡车 ID 和驾驶员 ID。在这个活动中,你将创建一个派发卡车的数据库,用几个派发数据初始化它,并通过从中获取所有可能的数据来证明其工作正常。

你将创建两个类(TruckPerson),它们包含以下对象:

  • Truck: Id, Brand, Model, YearOfMaking

  • Person: Id, Name, DoB

所有表都存储在TruckLogistics数据库的TruckLogistics模式中。

执行以下步骤以完成此活动:

  1. 创建一个Person类。

  2. 创建一个Truck类。

  3. 创建一个TruckDispatch类。

  4. 创建一个包含三个表的TruckDispatchDbContext架构。

  5. 创建一个连接字符串(理想情况下从环境变量中获取)。

  6. 添加一个数据库迁移。

  7. 从迁移中生成数据库。

  8. 连接到数据库。

  9. 使用初始数据填充数据库。

  10. 从数据库中获取所有数据。

  11. 打印结果。

  12. 处理TruckDispatchesDbContext架构(即断开连接)。

完成这些步骤后,你应该看到以下输出:

Dispatch: 1 1,1,1 2021-11-02 21:45:42
Driver: Stephen King 2021-07-25 21:45:42
Truck: Scania R 500 LA6x2HHA 2009

注意

为了运行此活动,请在Program.cs中的static void Main(string[] args)体内部除Activities.Activity01.Demo.Run()外的所有行进行注释;。

数据库应该看起来像这样:

图 6.12:生成的 TruckLogistics 数据库(为了简洁而简化)

图 6.12:生成的 TruckLogistics 数据库(为了简洁而简化)

并且将创建以下迁移文件(类似但不完全相同):

图 6.13:为解决方案创建的迁移文件

图 6.13:为解决方案创建的迁移文件

注意

这个活动的解决方案可以在packt.link/qclbF找到。

通过成功执行此活动,你现在应该对 EF 如何用于快速开发与数据库集成的解决方案有了坚实的了解。

摘要

在本章中,你了解了 ORM 的好处以及如何使用 EF Core 6 从 C#与数据库进行交互。EF 允许你使用DbContext抽象数据库,并包括对表和DbSet的抽象。

你体验了使用 EF 消费数据库的简单性,这几乎感觉就像编写 LINQ 查询一样。唯一的区别是使用数据库上下文设置连接的初始设置。你了解到客户端输入不应被信任,但 ORM 允许你自信地消费查询,因为它们考虑了安全性并保护你免受 SQL 注入的侵害。然而,你连接到数据库的方式(即连接字符串)必须得到保护,因此你必须像存储任何其他秘密一样存储它。你还研究了使用 EF 时最常见的问题以及可以帮助避免这些问题的工具。本章已经为你提供了足够的技能来使用 EF 创建和消费数据库。

在下一章中,你将更多地关注 Web 应用程序——它们是什么,以及如何构建它们。

第七章:7. 使用 ASP.NET 创建现代 Web 应用程序

概览

现在有许多类型的应用程序在使用中,Web 应用程序是其中使用最广泛的一种。在本章中,你将介绍 ASP.NET,这是一个用 C# 和 .NET 运行时构建的 Web 框架,旨在轻松创建 Web 应用程序。你还将了解基本 ASP.NET 应用程序的解剖结构、Web 应用程序开发方法,如服务器端渲染和单页应用程序,以及 C# 如何帮助实现这些方法来构建安全、高效和可扩展的应用程序。

简介

第一章你好 C# 中,你了解到 .NET 是使 C# 生机勃勃的东西,因为它包含用于构建你的代码的软件开发工具包 (SDK) 和执行代码的运行时。在本章中,你将了解 ASP.NET,它是一个嵌入在 .NET 运行时中的开源和跨平台框架。它用于构建 Web、移动和物联网设备的客户端和后端应用程序。

它是这类开发的完整工具箱,因为它提供了几个内置功能,例如轻量级和可定制的 HTTP 管道、依赖注入以及对现代托管技术(如容器、Web UI 页面、路由和 API)的支持。一个著名的例子是 Stack Overflow;其架构完全建立在 ASP.NET 之上。

本章的重点是使你熟悉 ASP.NET 的基础知识,并为你提供对使用 Razor Pages 进行 Web 应用程序开发的介绍和端到端概述,Razor Pages 是 ASP.NET 中内置的工具箱,用于构建 Web 应用程序。

ASP.NET Web 应用的解剖结构

你将从这个章节开始,使用 ASP.NET 创建一个新的 Razor Pages 应用程序。它只是可以使用 ASP.NET 创建的多种应用程序类型之一,但将是一个有效的起点,因为它与其他可以使用该框架构建的 Web 应用程序类型共享并展示了许多共同点。

  1. 要创建一个新的 Razor Pages 应用程序,请在 CLI 中输入以下命令:

    dotnet new razor -n ToDoListApp dotnet new sln -n ToDoList dotnet sln add ./ToDoListApp
    

在这里,你正在使用 Razor Pages 创建一个待办事项列表应用程序。一旦执行了前面的命令,你将看到一个具有以下结构的文件夹:

/ToDoListApp |-- /bin |-- /obj |-- /Pages
|-- /Properties |-- /wwwroot |-- appsettings.json |-- appsettings.Development.json |-- Program.cs
|-- ToDoListApp.csproj
|ToDoList.sln
  1. 在 Visual Studio Code 中打开根文件夹。

这些文件夹中的一些文件将在接下来的章节中介绍。现在,请考虑以下结构:

  • bin 是在应用程序构建后放置最终二进制文件的文件夹。

  • obj 是在构建过程中编译器放置中间输出的文件夹。

  • Pages 是放置应用程序 Razor Pages 的文件夹。

  • Properties 是包含 launchSettings.json 文件的文件夹,这是一个放置运行配置的文件。在此文件中,你可以定义一些本地运行的配置,例如环境变量和应用程序端口。

  • wwwroot 是放置应用程序所有静态文件的文件夹。

  • appsettings.json 是一个配置文件。

  • appsettings.Development.json 是开发环境的配置文件。

  • Program.cs 是自第一章“Hello C#”以来您所看到的程序类。它是应用程序的入口点。

现在您已经知道在 .NET 6.0 中,位于文件夹根目录的 Program.cs 文件使 WebApplication 生命周期开始,您可以在下一节中更深入地探索 Program.cs

Program.cs 和 WebApplication

如前所述,Program.cs 是任何 C# 应用程序的入口点。在本节中,您将了解一个典型的 ASP.NET 应用程序的 Program 类是如何构建的。以下是一个 Program.cs 的示例,它描述了一个非常简单的 ASP.NET 应用程序:

Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
The complete code can be found here: https://packt.link/tX9iK.

这里首先执行的是创建一个 WebApplicationBuilder 对象。该对象包含启动 ASP.NET 基本 Web 应用程序所需的一切——配置、日志记录、依赖注入、服务注册、中间件和其他主机配置。这个主机负责管理 Web 应用程序的生命周期;它们设置一个 Web 服务器和一个基本的 HTTP 管道来处理 HTTP 请求。

如您所见,通过几行代码就能完成许多事情,这使您能够运行一个结构良好的 Web 应用程序,这非常令人印象深刻。ASP.NET 做所有这些是为了让您能够专注于通过您将要构建的功能提供价值。

注意

Bootstrap 是一个用于美化网页内容的 CSS 库。您可以在官方网站上了解更多信息。

中间件

将中间件想象成连接在一起的小型应用程序组件,以形成一个处理 HTTP 请求和响应的管道。每个组件都是一个可以在管道中执行其他组件之前或之后执行一些工作的组件。它们还通过 next() 调用相互连接,如图 7.1 所示:

图 7.1:HTTP 管道的中间件

图 7.1:HTTP 管道的中间件

中间件是一个庞大的宇宙。以下列表定义了构建 Web 应用程序的主要特征:

  • 中间件的放置顺序很重要。由于它们一个接一个地链接,每个组件的放置都会影响管道的处理方式。

  • 如图 7.1 所示,before 逻辑会一直执行,直到最终到达端点。一旦到达端点,管道将继续使用 after 逻辑处理响应。

  • next() 是一个方法调用,它将在执行当前中间件的 after 逻辑之前,执行管道中的下一个中间件。

在 ASP.NET 应用程序中,可以在 WebApplicationBuilder 使用 WebApplication? 对象作为结果调用 Build 方法之后,在 Program.cs 文件中定义中间件。

Program.cs 和 WebApplication 部分创建的应用程序中,已经包含了一组为新的样板 Razor Pages 应用程序预置的中间件,当 HTTP 请求 到达时,这些中间件将按顺序被调用。

这很容易配置,因为 WebApplication 对象包含一个通用的 UseMiddleware<T> 方法。此方法允许您创建中间件并将其嵌入到 HTTP 管道中,用于请求和响应。当在 Configure 方法中使用时,每次应用程序收到一个传入请求,该请求将按照在 Configure 方法中放置请求的顺序通过所有中间件。默认情况下,ASP.NET 提供基本的错误处理、自动重定向到 HTTPS、服务静态文件,以及一些基本的路由和授权。

然而,您可能会在 Program.cs 文件中注意到,在 Program.cs 和 WebApplication 部分没有 UseMiddleware<> 调用。这是因为您可以编写扩展方法来给代码提供一个更简洁的名称和可读性,并且 ASP.NET 框架默认情况下已经为一些内置中间件做了这件事。例如,考虑以下示例:

using Microsoft.AspNetCore.HttpsPolicy;
public static class HttpsPolicyBuilderExtensions
{
public static IApplicationBuilder UseHttpsRedirection(this WebApplication app)
      { 
           app.UseMiddleware<HttpsRedirectionMiddleware>();
           return app;
}
}

在这里,使用了内置的 UseHttpsRedirection 扩展方法的一个示例来启用重定向中间件。

日志记录

日志记录可能被理解为将应用程序所做的一切都写入输出的简单过程。这个输出可能是控制台应用程序、文件,甚至是第三方日志监控应用程序,例如 ELK Stack 或 Grafana。日志记录在理解应用程序行为方面占有重要位置,尤其是在错误跟踪方面。这使得它成为一个重要的概念需要学习。

使 ASP.NET 成为有效企业应用平台的一个因素是其模块化。由于它是建立在抽象之上的,因此任何新的实现都可以轻松完成,而无需将太多内容加载到框架中。日志记录抽象就是这些之一。

默认情况下,Program.cs 中创建的 WebApplication 对象在这些日志抽象之上添加了一些日志提供程序,它们是 ConsoleDebugEventSourceEventLog。后者——EventLog——是仅针对 Windows 操作系统的先进功能。这里的重点将是 Console 日志提供程序。正如其名所示,此提供程序将所有记录的信息输出到您的应用程序控制台。您将在本节后面的内容中了解更多关于它的信息。

由于日志基本上记录了应用程序所做的所有事情,你可能会想知道这些日志是否会变得非常大,尤其是对于大型应用程序。它们可能会,但在编写应用程序日志时,一个重要的事情是要掌握日志的严重性。可能有一些信息是至关重要的,例如意外的异常。也可能有一些信息你只想记录到开发环境中,以便更好地了解某些行为。话虽如此,.NET 中的日志有七个可能的日志级别,它们是:

  • 跟踪 = 0

  • 调试 = 1

  • 信息 = 2

  • 警告 = 3

  • 错误 = 4

  • 关键 = 5

  • = 6

输出到提供者的级别是通过设置环境变量或通过 Logging:LogLevel 部分中的 appSettings.json 文件来定义的,如下例所示:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "ToDoListApp": "Warning",
      "ToDoListApp.Pages": "Information"
    }
  }
}

在此文件中,有日志类别,它们是 默认 类别或想要设置日志的类型命名空间的一部分。这正是这些命名空间存在的原因。例如,你可以在命名空间内部为文件设置不同的日志级别。

在前面的示例配置中,整个 ToDoListApp 是一个只使用 LogLevel 等于或高于 WarningLogLevel 来记录日志的命名空间。你还指定了,对于 ToDoListApp.Pages 类别/命名空间,应用程序将记录所有日志,其级别等于或高于 Information。这意味着在更具体的命名空间上的更改会覆盖在更高级别设置的设置。

这一节向你展示了如何配置应用程序的日志级别。有了这些知识,你现在可以理解以下章节中讨论的 DI 概念。

依赖注入

依赖注入(DI)是 ASP.NET 框架原生支持的技巧。它是实现面向对象编程中一个著名概念的一种形式,称为控制反转(IoC)。

任何对象需要以功能正常运作的组件都可以称为依赖项。在类的例子中,这可能会指需要构造的参数。在方法的例子中,这可能是参数执行所需的那个方法。使用 IoC 和依赖项意味着将创建类的责任委托给框架,而不是手动完成所有事情。

第二章构建高质量的面向对象代码 中,你学习了接口。接口基本上是建立契约的常见形式。它们允许你关注调用结果的输出,而不是执行方式。当你使用 IoC 时,你的依赖项现在可以是接口而不是具体类。这允许你的类或方法专注于这些接口建立的契约,而不是实现细节。这带来了以下优势:

  • 你可以轻松地替换实现,而不会影响任何依赖于这些契约的类。

  • 它解耦了应用程序边界和模块,因为通常不需要任何硬编码的依赖项。

  • 它使测试变得更容易,允许你将这些显式依赖项作为模拟或伪造,并专注于行为而不是真实实现细节。

假设现在为了创建应用程序的中间件,你需要构建它们的每个依赖项,并且有很多中间件在构造函数中相互链接。显然,这将是一个繁琐的过程。此外,测试任何此类中间件都将是一个繁琐的过程,因为你需要依赖每个具体的实现来创建一个对象。

通过注入依赖项,你告诉编译器如何构建一个在其构造函数中声明了依赖项的类。DI 机制在运行时执行此操作。这相当于告诉编译器,每当它找到特定类型的依赖项时,它应该使用适当的类实例来解析它。

ASP.NET 提供了一个本地的依赖注入容器,该容器存储了有关如何解析类型的所有信息。接下来,你将学习如何将此信息存储在容器中。

Program.cs 文件中,你会看到调用 builder.Services.AddRazorPages()Services 属性是 IServiceCollection 类型,它包含注入到容器中的所有依赖项——也称为服务。许多 ASP.NET 应用程序运行所需的依赖项已经在 Program.cs 文件顶部的 WebApplication.CreateBuilder(args) 方法中注入。例如,一些本地的日志依赖项将在下一个练习中看到。

练习 7.01:创建自定义日志中间件

在这个练习中,你将创建一个自定义日志中间件,该中间件将输出 HTTP 请求的详细信息及其持续时间到控制台。创建后,你将将其放置在 HTTP 管道中,以便它被应用程序接收到的每个请求调用。其目的是为你提供一个关于中间件、日志和 DI 的第一个实际介绍。

以下步骤将帮助你完成此练习:

  1. 创建一个名为 Middlewares 的新文件夹。

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

  3. 创建一个名为 RequestDelegate 的新私有 readonly 字段,并在构造函数中初始化此字段:

    private readonly RequestDelegate _next;
    public RequestLoggingMiddleware(RequestDelegate next)
    {
        _next = next; 
    }
    

这是 ASP.NET 收集的下一个要在 HTTP 管道中执行的中间件引用。通过初始化此字段,你可以调用已注册的下一个中间件。

  1. System.Diagnostics 命名空间中添加一个 using 语句,以便添加一个名为 Stopwatch 的特殊类。它将被用来测量请求时间长度:

    using System.Diagnostics;
    
  2. 创建一个私有的 readonly ILogger 字段。ILogger 接口是 .NET 提供的默认接口,用于手动记录信息。

  3. 之后,在 ILoggerFactory 类型的构造函数中放置第二个参数。此接口是 .NET 提供的另一个接口,允许您创建 ILogger 对象。

  4. 使用此工厂的 CreateLogger<T> 方法创建一个日志记录器对象:

    private readonly ILogger _logger;
    private readonly RequestDelegate _next;
    public RequestLoggingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory)
    {
        _next = next; 
        _logger = loggerFactory.CreateLogger<RequestLoggingMiddleware>();
    }
    

在这里,T 是一个泛型参数,它指的是一个类型,即日志类别,如 日志 部分所示。在这种情况下,该类别将是执行日志记录的类的类型,即 RequestLoggingMiddleware 类。

  1. 一旦初始化了字段,创建一个新的方法,其签名如下:

    public async Task InvokeAsync(HttpContext context) { }
    
  2. 在此方法内部,声明一个名为 Stopwatch 的变量,并将其赋值为 Stopwatch.StartNew()

    var stopwatch = Stopwatch.StartNew();
    

Stopwatch 类是一个辅助类,用于从调用 .StartNew() 方法的那一刻起测量执行时间。

  1. 在此变量之后,编写一个 try-catch 块,其中包含调用下一个请求以及从 stopwatch.Stop() 方法调用以测量 _next() 调用所花费的时间:

    using System.Diagnostics;
    namespace ToDoListApp.Middlewares;
    public class RequestLoggingMiddleware
    {
        private readonly ILogger _logger;
        private readonly RequestDelegate _next;
        public RequestLoggingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory)
        {
            _next = next;
            _logger = loggerFactory.CreateLogger<RequestLoggingMiddleware>();
        }
    

您还可以在此处理可能的异常。因此,最好将这两个调用包裹在一个 try-catch 方法中。

  1. Program.cs 文件中,通过以下声明调用自定义中间件:

    var app = builder.Build();
    // Configure the HTTP request pipeline.app.UseMiddleware<RequestLoggingMiddleware>();
    

在将 app 变量赋值的下一行编写它。

  1. 最后,在 Program.cs 文件中,放置一个 using 语句到 ToDoListApp.Middlewares

    Program.cs
    using ToDoListApp.Middlewares;
    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    builder.Services.AddRazorPages();
    var app = builder.Build();
    // Configure the HTTP request pipeline.
    app.UseMiddleware<RequestLoggingMiddleware>();
    if (!app.Environment.IsDevelopment())
    {
        app.UseExceptionHandler("/Error");
    
The complete code can be found here: https://packt.link/tX9iK.
  1. 要在您的网络浏览器中查看应用程序的运行情况及其在 Visual Studio Code 中的输出,请在地址栏中键入以下命令:

    localhost:####
    

这里 #### 代表端口号。这会因不同的系统而异。

  1. 按下回车键后,将显示以下屏幕:

图 7.2:在浏览器上运行的应用程序

图 7.2:在浏览器上运行的应用程序

  1. 每次在 VS Code 中执行练习/活动后,都要执行 步骤 13

  2. 在 VS code 终端内部按 Control+C 以在执行另一个练习/活动之前中断任务。

  3. 在您的浏览器中执行应用程序后,您将在 Visual Studio Code 终端看到类似的输出:

    info: ToDoListApp.Middlewares.RequestLoggingMiddleware[0]
          HTTP GET request for path / with status 200 executed in 301 ms
    info: ToDoListApp.Middlewares.RequestLoggingMiddleware[0]
          HTTP GET request for path /lib/bootstrap/dist/css/bootstrap.min.css with status 200 executed in 18 ms
    info: ToDoListApp.Middlewares.RequestLoggingMiddleware[0]
          HTTP GET request for path /css/site.css with status 200 executed in 1 ms
    info: ToDoListApp.Middlewares.RequestLoggingMiddleware[0]
          HTTP GET request for path /favicon.ico with status 200 executed in 1 ms
    

您将观察到控制台日志中记录了带有 HTTP 请求中间件管道中经过的时间的消息。由于您已使用自己的方法声明了它,它应该考虑所有管道链的执行时间。

在这个练习中,您创建了您的第一个中间件——RequestLoggingMiddleware。此中间件测量 HTTP 请求的执行时间,在您的 HTTP 管道中。通过将其放置在所有其他中间件之前,您将能够测量整个中间件管道中请求的整个执行时间。

注意

您可以在 packt.link/i04Iq 找到此练习使用的代码。

现在想象一下,你有一个 10 到 20 个中间件的 HTTP 管道,每个中间件都有自己的依赖项,你必须手动实例化每个中间件。在这种情况下,IoC 通过将这些类的实例化和它们的依赖项注入委托给 ASP.NET 来派上用场。你已经看到了如何创建使用原生 ASP.NET 日志机制和依赖注入的自定义中间件。

在 ASP.NET 中,日志记录和依赖注入是强大的机制,允许你为应用程序创建非常详细的日志。正如你所看到的,这是通过构造函数中的 logger 注入实现的。对于这些日志记录器,你可以通过两种方式创建日志类别的对象:

  • 如练习所示,一种方式是注入 ILoggerFactory。你可以调用 CreateLogger(categoryName) 方法,它接收一个字符串作为参数。你也可以调用 CreateLogger<CategoryType>() 方法,它接收一个泛型类型。这种方法更可取,因为它将 logger 的类别设置为类型的全名(包括命名空间)。

  • 另一种方式是通过注入 ILogger<CategoryType>。在这种情况下,类别类型通常是你要注入日志记录器的类的类型,如前一个练习中所示。在前面的练习中,你可以用 ILogger<RequestLoggingMiddleware> 替换 ILoggerFactory 的注入,并将这个新的注入依赖项直接分配给 ILogger 的私有字段,如下所示:

    private readonly ILogger _logger;
    private readonly RequestDelegate _next;
    public RequestLoggingMiddleware(RequestDelegate next, ILogger< RequestLoggingMiddleware> logger)
    {
        _next = next; 
        _logger = logger;
    }
    

现在,你知道日志记录和依赖注入是强大的机制,允许你为应用程序创建非常详细的日志。在转向 Razor 页面之前,了解应用程序中对象的生命周期是很重要的。这被称为依赖生命周期。

依赖生命周期

在进入本章的下一主题之前,讨论依赖生命周期是很重要的。在前面的练习中使用的所有依赖项都是通过构造函数注入的。但这些依赖项的解析之所以成为可能,仅仅是因为 ASP.NET 在 Program.cs 部分之前注册了这些依赖项。在下面的代码中,你可以看到一个 ASP.NET 内置的代码示例,它处理日志依赖项的注册,通过向服务容器添加 ILoggerFactory 依赖项:

LoggingServiceCollectionExtensions.cs
public static IServiceCollection AddLogging(this IServiceCollection services, Action<ILoggingBuilder> configure)
{T
if (services == null)
     {
     throw new ArgumentNullException(nameof(services));
     }
     services.AddOptions();
     services.TryAdd(ServiceDescriptor.Singleton<ILoggerFactory, LoggerFactory>());

services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(Logger<>)));
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<LoggerFilterOptions>>(new DefaultLoggerLevelConfigureOptions(LogLevel.Information)));
configure(new LoggingBuilder(services));
return services;
}
The complete code can be found here: https://packt.link/g4JPp.

注意

上述代码是一个来自标准库并内置在 ASP.NET 中的示例,它处理日志依赖项的注册。

这里发生了很多事情,但需要考虑的两个重要事项如下:

  • 这里的方法是 TryAdd,它将依赖项注册到 DI 容器中。

  • ServiceDescriptor.Singleton 方法定义了依赖生命周期。这是本章 依赖注入 部分的最后一个重要概念。

依赖生命周期描述了应用程序中对象的生命周期。ASP.NET 有三个默认的生命周期,可以用来注册依赖项:

  • 临时(Transient):具有这种生命周期的对象每次请求时都会创建,并在使用后销毁。这对于无状态依赖项非常有效,即当它们被调用时不需要保持状态。例如,如果你需要连接到 HTTP API 来请求一些信息,你可以注册一个具有这种生命周期的依赖项,因为 HTTP 请求是无状态的。

  • 作用域(Scoped):具有作用域生命周期的对象为每个客户端连接创建一次。例如,在 HTTP 请求中,作用域依赖项在整个请求期间将具有相同的实例,无论它被调用多少次。这种依赖项在一段时间内携带一些状态。在连接结束时,依赖项将被销毁。

  • 单例(Singleton):具有单例生命周期的对象在整个应用程序的生命周期内只创建一次。一旦它们被请求,它们的实例将在应用程序运行期间保持。这种生命周期应该仔细考虑,因为它可能会消耗大量内存。

如前所述,这些依赖项的手动注册可以在 Startup 类中的 ConfigureServices 方法中完成。每个不是由 ASP.NET 自动提供和注册的新依赖项都应该在那里手动注册,了解这些生命周期很重要,因为它们允许应用程序以不同的方式管理依赖项。

你已经了解到,这些依赖项的解析之所以成为可能,仅仅是因为 ASP.NET 注册了三个默认的生命周期,可以用来注册依赖项。现在你将转向 Razor Pages,它允许使用 ASP.NET 提供和驱动的所有功能来构建基于页面的应用程序。

Razor Pages

现在你已经了解了与 ASP.NET 应用程序相关的主要方面,你将继续构建本章开头开始的应用程序。这里的目的是构建一个待办事项列表应用程序,你可以在 Kanban 风格的看板上轻松创建和管理任务列表。

早期章节中提到了 Razor Pages,但究竟它是什么呢?Razor Pages 是一个框架,它允许使用 ASP.NET 提供和驱动的所有功能来构建基于页面的应用程序。它是为了能够构建具有清晰关注点分离的动态数据驱动应用程序而创建的,也就是说,每个方法和类都有各自但互补的责任。

基本 Razor 语法

Razor Pages 使用由 Microsoft 驱动的 Razor 语法,它允许页面具有静态 HTML/CSS/JS、C# 代码和自定义标签助手,这些是可重用组件,它们能够使页面中渲染 HTML 片段。

如果你查看在第一次练习中运行的 dotnet new 命令生成的 .cshtml 文件,你会注意到大量的 HTML 代码,以及一些带有 @ 前缀的方法和变量。在 Razor 中,一旦你写下这个符号,编译器就会检测到将编写一些 C# 代码。你已经知道 HTML 是一种用于构建网页的标记语言。Razor 使用它与 C# 结合来创建强大的标记,并结合服务器端渲染的代码。

如果你想放置一个代码块,它可以在括号内完成,如下所示:

@{ … }

在这个块内部,你可以基本上做所有可以用 C# 语法做的事情,从局部变量声明到循环等。如果你想放置一个 static @,你必须通过放置两个 @ 符号来转义它,以便在 HTML 中渲染。例如,在电子邮件 ID(如 james@@bond.com)中就是这样做的。

文件结构

Razor 页面以 .cshtml 扩展名结尾,可能还包含另一个名为 .cshtml.cs 的文件。如果你前往应用程序的根目录并导航到 Pages 文件夹,你将看到在创建页面时生成的以下结构:

|-- /Pages
|---- /Shared |------ _Layout.cshtml |------ _ValidationScriptsPartial.cshtml |---- _ViewImports.cshtml
|---- _ViewStart.cshtml
|---- Error.cshtml
|---- Error.cshtml.cs
|---- Index.cshtml
|---- Index.cshtml.cs
|---- Privacy.cshtml
|---- Privacy.cshtml.cs

IndexPrivacyError 页面在项目创建后自动生成。简要查看这里的其他文件。

/Shared 文件夹包含一个默认用于应用程序的共享 Layout 页面。这个页面包含一些共享部分,如导航栏、页眉、页脚和元数据,这些在几乎每个应用程序页面中都会重复:

_Layout.cshtml
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - ToDoListApp</title>    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/ToDoListApp.styles.css" asp-append-version="true" />
</head>
<body>
    <header>
        <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
            <div class="container">
                <a class="navbar-brand" asp-area="" asp-page="/Index">ToDoListApp</a>
The complete code can be found here: https://packt.link/2Hb8r.

将这些共享部分保存在单个文件中,使得重用性和可维护性更容易。如果你查看在模板中生成的这个 Layout 页面,有一些值得强调的事情:

  • 默认情况下,Razor Pages 应用程序使用 Twitter Bootstrap 进行设计——一个用于编写美观、简单和响应式网站的库——以及 jQuery 进行基本脚本编写。这可以根据每个应用程序进行自定义,因为这些都是静态文件。

  • 有一个特殊的 RenderBody() 方法,它指示应用程序页面生成的 HTML 将放置在哪里。

  • 另一种名为 RenderSection() 的方法,用于按页面渲染预定义的各个部分。例如,当需要仅对某些页面使用某些静态文件(如图像、脚本或样式表)时,它非常有用。这样,你可以在需要这些文件的页面中特定部分内放置这些文件,并在你希望它们被渲染的 HTML 层级上调用 RenderSection 方法。这是在 _Layout.cshtml 页面上完成的。

_ViewImports.cshtml 文件是另一个重要的文件;它使应用程序页面能够共享公共指令,并通过在每一页上放置这些指令来减少工作量。它定义了所有全局使用命名空间、标签辅助器和全局 Pages 命名空间的地方。该文件支持的指令如下:

  • @namespace:用于设置 Pages 的基本命名空间。

  • @inject:用于在页面中放置依赖注入。

  • @model:包含 PageModel 类,该类将确定页面将处理哪些信息。

  • @using:类似于 .cs 文件,这个指令允许你在 Razor 页面的顶级完全限定命名空间,以避免在代码中重复这些命名空间。

_ViewStart.cshtml 文件用于放置在每个页面调用开始时执行的代码。在这个页面上,你定义 Layout 属性并设置 Layout 页面。

现在你已经熟悉了 Razor Pages 的基础知识,是时候开始着手你的应用程序并深入研究一些更有趣的话题了。你将从创建待办事项应用程序的基本结构开始。

练习 7.02:使用 Razor 创建看板

本练习的目标是使用其第一个组件——看板来开始创建待办事项应用程序。这个看板用于控制工作流程,人们可以将他们的工作分成卡片,并在不同的状态之间移动这些卡片,例如待办、进行中和完成。使用这种功能的流行应用程序是 Trello。在本章中,我们将使用与 练习 7.01 中创建的相同的 ToDoListApp 项目来学习新概念并逐步发展应用程序,包括本练习。执行以下步骤:

  1. 导航到你的应用程序的根目录,并创建一个名为 Models 的文件夹。

  2. Models 文件夹中,创建一个新的 enumETaskStatus,包含 ToDoDoingDone 选项:

    public enum ETaskStatus {
    ToDo,
    Doing,
    Done
    }
    
  3. 再次,在 Models 文件夹中,创建一个新的类 ToDoTask,它将被用来为待办事项列表创建一个新的任务,具有以下属性:

    namespace ToDoListApp.Models;
    public class ToDoTask
    {
        public Guid Id { get; set; }
        public DateTime CreatedAt { get; set; }
        public DateTime? DueTo { get; set; }
        public string Title { get; set; }
        public string? Description { get; set; }
        public ETaskStatus Status { get; set; }
    }
    
  4. ToDoTask 类创建两个构造函数,如下所示:

    ToDoTask.cs
    namespace ToDoListApp.Models;
    public class ToDoTask
    {
        public ToDoTask()
        {
            CreatedAt = DateTime.UtcNow;
            Id = Guid.NewGuid();
        }
        public ToDoTask(string title, ETaskStatus status) : this()
        {
            Title = title;
            Status = status;
        }
    
The complete code can be found here: https://packt.link/nFk00.

创建一个不带参数的,用于设置 IdCreatedAt 属性的默认值,另一个带有小写命名的参数,用于初始化前面类的 TitleStatus 属性。

Pages/ Index.cshtml 文件是在你的应用程序样板中自动生成的。这个页面将是你的应用程序的入口点。

  1. 现在,通过编辑文件 Pages/ Index.cshtml.cs 并替换以下代码中的样板代码来自定义它:

    Index.cshtml.cs
    using System.Collections.Generic;
    using Microsoft.AspNetCore.Mvc.RazorPages;
    using ToDoListApp.Models;
    namespace ToDoListApp.Pages;
    public class IndexModel : PageModel
    {
        public IList<ToDoTask> Tasks { get; set; } = new List<ToDoTask>();
        public IndexModel()
        {
        }
    
The complete code can be found here: https://packt.link/h8mni.

基本上,这段代码填充了你的模型。在这里,PageModel 类的 OnGet 方法被用来通知应用程序,当页面加载时,它应该使用分配给 Task 的属性填充模型。

  1. Pages/ Index.cshtml 中的代码替换为以下代码,以创建你的看板和任务卡片:

    Index.cshtml
    @page
    @using ToDoListApp.Models
    @model IndexModel
    @{
        ViewData["Title"] = "My To Do List";
    }
    <div class="text-center">
        <h1 class="display-4">@ViewData["Title"]</h1>
        <div class="row">
            <div class="col-4">
                <div class="card bg-light">
                    <div class="card-body">
                        <h6 class="card-title text-uppercase text-truncate py-2">To Do</h6>
                        <div class="border border-light">
    
The complete code can be found here: https://packt.link/IhELU.

这个页面是你的视图。它共享 Pages/ Index.cshtml.cs 类(也称为代码后类)的属性。当你为代码后类中的 Tasks 属性赋值时,它对视图可见。使用这个属性,你可以从页面填充 HTML。

  1. 现在,使用 dotnet run 命令运行你的应用程序。当应用程序在浏览器中加载时,你将在 Index 页面上看到以下内容:

图 7.3:显示你的第一个应用程序,看板

图 7.3:显示你的第一个应用程序,看板

注意,目前应用程序不包含任何逻辑。你在这里构建的只是一个由 PageModel 数据驱动的 UI。

注意

你可以在 packt.link/1PRdq 找到用于此练习的代码。

正如你在 练习 7.02 中所看到的,为每个创建的页面都有两种主要类型的文件,即 .cshtml.cshtml.cs 文件。这些文件构成了每个 Razor 页面的基础。下一节将详细介绍文件名后缀的差异以及这两个文件是如何相互补充的。

PageModel

练习 7.02 中创建的 Index.cshtml.cs 文件中,你可能已经注意到其中的类继承自 PageModel 类。拥有这个代码后置类提供了一些优势——例如,客户端和服务器之间关注点的清晰分离——这使得维护和开发更加容易。它还使你能够为放置在服务器上的逻辑创建单元和集成测试。你将在 第十章自动化测试 中了解更多关于测试的内容。

PageModel 可能包含一些绑定到视图的属性。在 练习 7.02 中,IndexModel 页面有一个属性是 List<ToDoTask>。这个属性在页面加载时的 OnGet() 方法中被填充。那么填充是如何发生的呢?下一节将讨论填充属性的生命周期以及在 PageModel 中使用它们。

带有页面处理器的生命周期

处理程序方法是 Razor Pages 的核心功能。当服务器从页面接收到请求时,这些方法会自动执行。例如,在 练习 7.02 中,每次页面接收到 GET 请求时,OnGet 方法都会被执行。

按照惯例,处理程序方法将根据请求的 HTTP 动词来回答。例如,如果你想在一个 POST 请求之后执行某些操作,你应该有一个 OnPost 方法。同样,在 PUT 请求之后,你应该有一个 OnPut 方法。这些方法中的每一个都有一个异步等效方法,它改变了方法的签名;方法名后添加了 Async 后缀,并且它返回一个 Task 属性而不是 void。这也使得 await 功能可用于该方法。

然而,有一个棘手的情况,你可能希望表单使用相同的 HTTP 动词执行多个操作。在这种情况下,你可以在后端执行一些令人困惑的逻辑来处理不同的输入。然而,Razor Pages 为你提供了一个开箱即用的功能,称为asp-page-handler,允许你指定在服务器上被调用的处理程序名称。标签辅助器将在下一节中讨论,但在此阶段,请考虑以下代码作为示例。该代码包含一个 HTML 表单,包含两个提交按钮,用于执行两个不同的操作——一个用于创建订单,另一个用于取消订单:

<form method="post">
    <button asp-page-handler="PlaceOrder">Place Order</button>
    <button asp-page-handler="CancelOrder">Cancel Order</button>
</form>

在服务器端,你只需要有两个处理程序,每个操作一个,如下面的代码所示:

public async Task<IActionResult> OnPostPlaceOrderAsync()
{
    // …
}
public async Task<IActionResult> OnPostCancelOrderAsync()
{
    // …
}

在这里,页面的后台代码与.cshtml文件上的form方法和asp-page-handler标签的值相匹配,与代码后台文件中的方法名称相匹配。这样,你可以在同一个表单中为相同的 HTTP 动词执行多个操作。

关于这个主题的最后一句话是,在这种情况下,服务器上的方法名称应该写成如下格式:

On + {VERB} + {HANDLER}

这可以带有或不带有Async后缀。在前面的例子中,OnPostPlaceOrderAsync方法是PlaceOrder按钮的PlaceOrder处理程序,而OnPostCancelOrderAsyncCancelOrder按钮的处理程序。

使用标签辅助器渲染可重用静态代码

你可能已经注意到,之前编写的 HTML 代码很长。你创建了看板卡片、列表和一个板来包裹所有内容。如果你仔细查看代码,会发现整个代码中都有相同的模式重复出现。这引发了一个主要问题,维护。很难想象需要处理、维护和演变所有这些纯文本。

幸运的是,标签辅助器在这方面可以非常有用。它们基本上是渲染静态 HTML 代码的组件。ASP.NET 有一组内置的标签辅助器,具有自定义服务器端属性,如锚点、表单和图像。标签辅助器是一个核心功能,有助于使高级概念易于处理,例如模型绑定,这将在稍后进一步讨论。

除了为内置 HTML 标签添加渲染功能外,它们还是实现静态和重复代码可重用性的令人印象深刻的方法。在下一个练习中,你将学习如何创建自定义标签辅助器。

练习 7.03:使用标签辅助器创建可重用组件

在这个练习中,你将改进上一个练习中的工作。这里的改进是将可以复用的部分代码移动到自定义标签辅助器中,以简化 HTML 代码。

要做到这一点,请执行以下步骤:

  1. 打开与你的应用程序一起创建的_ViewImports.cshtml文件。

  2. 将以下行添加到末尾,以定义自定义标签辅助器的内容@addTagHelper指令:

    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
    @addTagHelper *, ToDoListApp 
    

在前面的代码中,你使用星号 (*) 添加了此命名空间中存在的所有自定义标签助手。

  1. 现在,在项目的根目录(ToDoApp)下创建一个名为 TagHelpers 的新文件夹。

  2. 在此文件夹内创建一个名为 KanbanListTagHelper.cs 的新类。

  3. 让这个类继承自 TagHelper 类:

    namespace ToDoListApp.TagHelpers;
    
  4. 这种继承使得 ASP.NET 能够识别内置和自定义的标签助手。

  5. 现在,在 Microsoft.AspNetCore.Razor.TagHelpers 命名空间下放置一个 using 语句:

    using Microsoft.AspNetCore.Razor.TagHelpers;
    namespace ToDoListApp.TagHelpers;
    public class KanbanListTagHelper : TagHelper
    {
    }
    
  6. 对于 KanbanListTagHelper 类,创建两个名为 NameSize 的字符串属性,并具有获取器和设置器:

    using Microsoft.AspNetCore.Razor.TagHelpers;
    namespace ToDoListApp.TagHelpers;
    public class KanbanListTagHelper : TagHelper
    {
        public string? Name { get; set; }
        public string? Size { get; set; }
    }
    
  7. 使用以下代码重写基类的异步 ProcessAsync (TagHelperContext context, TagHelperOutput) 输出方法:

    KanbanListTagHelper.cs
    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
         output.TagName = "div";
         output.Attributes.SetAttribute("class", $"col-{Size}");
         output.PreContent.SetHtmlContent(
         $"<div class=\"card bg-light\">"
              + "<div class=\"card-body\">"
              + $"<h6 class=\"card-title text-uppercase text-truncate py-     2\">{Name}</h6>"
              + "<div class \"border border-light\">");
         var childContent = await output.GetChildContentAsync();
         output.Content.SetHtmlContent(childContent.GetContent());
    
The complete code can be found here: https://packt.link/bjFIk.

每个标签助手都有一个标准的 HTML 标签作为输出。这就是为什么在方法开始时,从 TagHelperOutput 对象中调用 TagName 属性来指定将用作输出的 HTML 标签。此外,你还可以通过从 TagHelperOutput 对象中调用 Attributes 属性及其 SetAttribute 方法来设置此 HTML 标签的属性。这正是你在指定 HTML 输出标签之后所做的事情。

  1. 现在,创建另一个名为 KanbanCardTagHelper.cs 的类,具有相同的继承和命名空间使用语句,如前所述:

    namespace ToDoListApp.TagHelpers;
    using Microsoft.AspNetCore.Razor.TagHelpers;
    public class KanbanCardTagHelper: TagHelper
    {
        public string? Task { get; set; }
    }
    

对于这个类,创建一个名为 Taskstring 属性,并具有公共的获取器和设置器:

  1. 在这个新类中,重写基类的同步 Process(TagHelperContext context, TagHelperOutput output) 方法。在这个方法中,编写以下代码:

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
         output.TagName = "div";
         output.Attributes.SetAttribute("class", "card");
         output.PreContent.SetHtmlContent(
         "<div class=\"card-body p-2\">"
              + "<div class=\"card-title\">");
         output.Content.SetContent(Task);
         output.PostContent.SetHtmlContent(
         "</div>"
              + "<button class=\"btn btn-primary btn-sm\">View</button>"
              + "</div>");
    output.TagMode = TagMode.StartTagAndEndTag;
    }
    

一个重要的概念是了解 HTML 内容如何在标签助手内放置。正如你所见,代码使用了 TagHelperOutput 对象的三个不同属性来放置内容:

  • PreContent

  • Content

  • PostContent

预设和后置属性对于在生成内容之前和之后设置内容非常有用。它们的一个用例是当你想要设置固定内容作为 div 容器、标题和页脚时。

你在这里做的另一件事是通过 Mode 属性设置标签助手如何被渲染。你使用了 TagMode.StartTagAndEndTag 作为值,因为你使用了 div 容器作为标签助手输出,而 div 元素在 HTML 中既有开始标签也有结束标签。如果输出标签是其他 HTML 元素,例如自闭合的电子邮件,你会使用 TagMode.SelfClosing 代替。

  1. 最后,转到 Pages 文件夹下的 Index.cshtml 文件,并用标签助手替换 练习 7.02 中创建的 HTML,以使你的代码更简洁:

    Index.cshtml 
    @page
    @using ToDoListApp.Models
    @model IndexModel
    @{
        ViewData["Title"] = "My To Do List";
    }
    <div class="text-center">
        <h1 class="display-4">@ViewData["Title"]</h1>
        <div class="row">
            <kanban-list name="To Do" size="4">
                @foreach (var task in Model.Tasks.Where(t => t.Status == ETaskStatus.ToDo))
                {
                    <kanban-card task="@task.Description">
                    </kanban-card>
    
The complete code can be found here: https://packt.link/YIgdp.
  1. 现在用以下命令运行应用程序:

    dotnet run
    
  2. 在你的浏览器中,导航到 Visual Studio 控制台输出提供的 localhost:#### 地址,就像你在上一个练习中所做的那样:

图 7.4:浏览器中显示的前端

图 7.4:浏览器中显示的前端

你将在前端看到与之前相同的结果,如图 7.3所示。改进之处在于,尽管输出相同,你现在拥有一个更加模块化和简洁的代码来维护和演进。

注意

你可以在packt.link/YEdiU找到用于此练习的代码。

在这个练习中,你使用了标签辅助器来创建可重用的组件,这些组件生成静态 HTML 代码。你现在可以看到 HTML 代码变得更加干净和简洁。下一节将详细介绍通过使用模型绑定将代码背后的内容与你的 HTML 视图链接起来创建交互式页面。

模型绑定

到目前为止,你已经涵盖了有助于为待办事项应用程序打下基础的概念。作为一个快速回顾,主要点如下:

  • PageModel 用于向页面添加数据。

  • 标签辅助器为服务器生成的 HTML 添加了自定义的静态渲染。

  • 处理程序方法定义了页面与 HTTP 请求交互的方式。

一个至关重要的最终概念,是构建 Razor Pages 应用程序的核心,即模型绑定。在处理程序方法中用作参数的数据以及通过页面模型传递的数据是通过此机制渲染的。它包括从 HTTP 请求中提取键/值对,并根据绑定的方向(即数据是从客户端移动到服务器还是从服务器移动到客户端),将它们放置在客户端 HTML 或服务器端代码中。

这些数据可能放置在路由、表单或查询字符串中,并与 .NET 类型绑定,无论是原始类型还是复杂类型。练习 7.04 将有助于阐明模型绑定在从客户端到服务器时的运作方式。

练习 7.04:创建一个提交任务的页面

本练习的目标是创建一个新页面。它将被用来创建将在看板中显示的新任务。按照以下步骤完成此练习:

  1. 在项目根目录文件夹中,运行以下命令:

    dotnet add package Microsoft.EntityFrameworkCore
    dotnet add package Microsoft.EntityFrameworkCore.Sqlite
    dotnet add package Microsoft.EntityFrameworkCore.Design
    
  2. 在项目根目录中创建一个名为 Data 的新文件夹,并在其中包含一个 ToDoDbContext 类。这个类将继承自 Entity Framework 的 DbContext 并用于访问数据库。

  3. 现在将其中的以下代码添加进去:

    using Microsoft.EntityFrameworkCore;
    using ToDoListApp.Models;
    namespace ToDoListApp.Data;
    public class ToDoDbContext : DbContext
    {
        public ToDoDbContext(DbContextOptions<ToDoDbContext> options) : base(options)
        {
        }
        public DbSet<ToDoTask> Tasks { get; set; } 
    }
    
  4. 更新你的 Program.cs 文件以匹配以下内容:

    Program.cs
    using Microsoft.EntityFrameworkCore;
    using ToDoListApp.Data;
    using ToDoListApp.Middlewares;
    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.builder.Services.AddRazorPages();
    builder.Services.AddDbContext<ToDoDbContext>(opt => opt.UseSqlite("Data Source=Data/ToDoList.db")); 
    var app = builder.Build();
    // Configure the HTTP request pipeline.app.UseMiddleware<RequestLoggingMiddleware>();
    
The complete code can be found here: https://packt.link/D4M8o.

此更改将在 DI 容器中注册 DbContext 依赖项,并设置数据库访问。

  1. 在终端上运行以下命令来安装 dotnet ef 工具。这是一个 CLI 工具,它将帮助你与数据库辅助器进行迭代,例如架构创建和更新:

    dotnet tool install --global dotnet-ef
    
  2. 现在,构建应用程序并在终端上运行以下命令:

    dotnet ef migrations add 'FirstMigration'
    dotnet ef database update
    

这些命令将创建一个新的迁移,该迁移将从你的数据库创建架构并将此迁移应用到数据库中。

  1. 迁移运行并数据库更新后,在 Pages 文件夹内创建一个名为 Tasks 的新文件夹。

  2. Index页面文件——index.cshtmlindex.cshtml.cs——移动到Tasks文件夹。

  3. 接下来,将Program.cs中的AddRazorPages调用替换为以下调用:

    builder.Services.AddRazorPages(opt =>{    opt.Conventions.AddPageRoute("/Tasks/Index", ""); });
    

这将为页面路由添加一个约定。

  1. 用以下代码替换_Layout.cshtml文件中的标题标签(位于Pages/Shared/)以创建应用的共享navbar

    <header>
            <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
                <div class="container">
                    <a class="navbar-brand" asp-area="" asp-page="/Index">MyToDos</a>
                    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
                            aria-expanded="false" aria-label="Toggle navigation">
                        <span class="navbar-toggler-icon"></span>
                    </button>
                    <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
                        <ul class="navbar-nav flex-grow-1">
                            <li class="nav-item">
                                <a class="nav-link text-dark" asp-area="" asp-page="/tasks/create">Create Task</a>
                            </li>
                        </ul>
                    </div>
                </div>
            </nav>
        </header>
    

这个navbar将允许你访问新创建的页面。

  1. 创建Create.cshtml页面(位于Pages/Tasks/),并添加以下代码:

    Create.cshtml
    @page "/tasks/create"
    @model CreateModel
    @{
        ViewData["Title"] = "Task";
    }
    <h2>Create</h2>
    <div>
        <h4>@ViewData["Title"]</h4>
        <hr />
        <dl class="row">
            <form method="post" class="col-6">
                <div class="form-group">
                    <label asp-for="Task.Title"></label>
                    <input asp-for="Task.Title" class="form-control" />
    
The complete code can be found here: https://packt.link/2NjdN.

这应该包含一个表单,该表单将使用PageModel类来创建新任务。对于每个表单输入字段,在input标签辅助器内部使用asp-for属性。此属性负责在name属性中用适当的值填充 HTML 输入。

由于你正在将页面模型中的复杂属性Task进行绑定,名称值使用以下语法生成:

{PREFIX}_{PROPERTYNAME} pattern

这里PREFIXPageModel上的复杂对象名称。因此,对于一个任务的 ID,客户端会生成一个带有name="Task_Id"的输入,并且输入通过具有来自服务器的Task.Id属性值的value属性进行填充。在页面的情况下,因为你正在创建一个新任务,字段之前没有被预先填充。这是因为你使用OnGet方法将一个新对象分配给了PageModel类的Task属性。

  1. 现在,创建名为CreateModel.cshtml.cs的后台代码页面(位于Pages/Tasks/):

    Create.cshtml.cs
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.RazorPages;
    using ToDoListApp.Data;
    using ToDoListApp.Models;
    namespace ToDoListApp.Pages.Tasks;
    public class CreateModel : PageModel {
        private readonly ToDoDbContext _context;
        public CreateModel(ToDoDbContext context)
        {
            _context = context;
        }
    
The complete code can be found here: https://packt.link/06ciR.

当提交表单时,表单内的所有值都放置在传入的HttpRequest中。TryUpdateModelAsync的调用尝试使用来自客户端的请求中的这些值填充一个对象。由于表单是用前面解释过的格式创建的,此方法知道如何提取这些值并将它们绑定到对象上。简单来说,这就是模型绑定的魔法。

  1. 现在,用以下代码替换Index.cshtml(位于Pages/Tasks/)的代码:

    Index.cshtml
    @page
    @using ToDoListApp.Models
    @model IndexModel
    @{
        ViewData["Title"] = "My To Do List";
    }
    <div class="text-center">
        @if (TempData["SuccessMessage"] != null)
        {
            <div class="alert alert-success" role="alert">
                @TempData["SuccessMessage"]
            </div>
        }
        <h1 class="display-4">@ViewData["Title"]</h1>
    
The complete code can be found here: https://packt.link/hNOTx.

此代码添加了一个部分,用于显示如果TempData字典中存在带有SuccessMessage键的条目时将显示的警告。

  1. 最后,通过数据注释向Models/ToDoTask.cs类的属性添加一些显示和验证规则:

    ToDoTask.cs
    using System.ComponentModel;
    using System.ComponentModel.DataAnnotations;
    namespace ToDoListApp.Models;
    public class ToDoTask
    {
        public ToDoTask()
        {
            CreatedAt = DateTime.UtcNow;
            Id = Guid.NewGuid();
        }
        public ToDoTask(string title, ETaskStatus status) : this()
        {
    
The complete code can be found here: https://packt.link/yau4p.

在这里,对属性的Required数据注释是为了确保这个属性被设置为一个有效的值。在这个练习中,你添加了使用 Entity Framework Core 和 SQLite 的持久性,并创建了一个新页面,用于为待办应用创建任务,最后将其保存到数据库中。

  1. 现在,在 VS Code 中运行代码。

  2. 要在网页浏览器上查看输出,请在地址栏中输入以下命令:

    Localhost:####
    

这里####代表端口号。这会因不同的系统而异。

按下回车后,将显示以下屏幕:

图 7.5:带有创建任务按钮的导航栏的主页

图 7.5:包含创建任务按钮的导航栏主页

  1. 点击创建任务按钮,你将看到你刚刚创建的页面,用于将新卡片插入到你的看板(Kanban Board)中:

图 7.6:创建任务页面

图 7.6:创建任务页面

注意

你可以在packt.link/3FPaG找到这个练习所使用的代码。

现在,你将深入了解模型绑定如何将所有这些整合在一起,使你能够在客户端和服务器之间传输数据。你还将了解下一节中关于验证的更多信息。

验证

在开发应用程序时,验证数据是经常需要做的事情。验证一个字段可能意味着它是必填字段,或者它应该遵循特定的格式。你可能已经注意到,在上一个练习的最后部分,你在最后一个练习的最后一步中,在模型属性上放置了一些[Required]属性。这些属性被称为数据注释,用于创建服务器端验证。此外,你还可以添加一些客户端验证,与这种技术相结合。

注意,在练习 7.04 的第 10 步中,前端有一些带有asp-validation-for属性的span标签辅助器,这些属性指向模型属性。将这一切联系在一起的是_ValidationScriptsPartial.cshtml部分页面的包含。部分页面是下一节讨论的主题,但就现在而言,只需知道它们是可以被其他页面重用的页面。刚刚提到的那个包括页面的默认验证。

将这三个放在一起(即,所需的注释、asp-validation-for标签辅助器和ValidationScriptsPartial页面),客户端将创建验证逻辑,防止表单提交无效值。如果你想将验证放在服务器上,可以使用内置的TryValidateModel方法,传递要验证的模型。

部分页面的动态行为

到目前为止,你已经创建了一个用于显示任务和创建及编辑任务的方式的看板。然而,对于一个待办事项应用来说,还有一个主要功能需要添加——一种在看板之间移动任务的方式。你可以从最简单的方式开始,即从待办事项到进行中,再从进行中到完成。

到目前为止,你的任务卡片都是使用标签辅助器构建的。然而,标签辅助器被渲染为静态组件,不允许在渲染过程中添加任何动态行为。你可以直接将标签辅助器添加到你的页面上,但你必须为每个看板列表重复它。这正是 Razor Pages 的一个主要功能发挥作用的地方,那就是部分页面。它们允许你以更小的片段创建可重用的页面代码片段。这样,你可以共享基础页面的动态工具,同时避免在应用程序中重复代码。

这就结束了本节的理沦部分。在接下来的部分中,你将通过练习将其付诸实践。

练习 7.05:将标签助手重构为具有自定义逻辑的部分页面

在这个练习中,你将创建一个部分页面来替换 KanbanCardTagHelper,并为你的任务卡片添加一些动态行为,例如根据自定义逻辑更改内容。你将看到部分页面如何帮助减少重复代码并使其更容易重用。执行以下步骤以完成此练习:

  1. Pages/Tasks 文件夹内,创建一个名为 _TaskItem.cshtml 的新文件,内容如下:

    _TaskItem.cshtml
    @model ToDoListApp.Models.ToDoTask
    <form method="post">
        <div class="card">
            <div class="card-body p-2">
                <div class="card-title">
                    @Model.Title
                </div>
                <a class="btn btn-primary btn-sm" href="/tasks/@Model.Id">View</a>
                @if (Model.Status == Models.ETaskStatus.ToDo)
                {
                    <button type="submit" class="btn btn-warning btn-sm" href="@Model.Id" asp-page-handler="StartTask" asp-route-id="@Model.Id">
                        Start 
                    </button>
    
The complete code can be found here: https://packt.link/aUOcj.

_TaskItem.cshtml 主要是包含看板板卡片 .cshtml 代码的部分页面。

  1. 现在,将 Index.cshtml.cs 文件内的代码替换为以下代码,该代码可以从数据库中读取保存的任务,并将你创建的操作放置在部分页面上:

    Index.cshtml.cs
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.RazorPages;
    using ToDoListApp.Data;
    using ToDoListApp.Models;
    namespace ToDoListApp.Pages
    {
        public class IndexModel : PageModel
        {
            private readonly ToDoDbContext _context;
            public IndexModel(ToDoDbContext context)
    
The complete code can be found here: https://packt.link/Tqgup.

此代码为三个 HTTP 请求创建了处理程序方法——一个 GET 请求和两个 POST 请求。它还放置了在这些处理程序上要执行的逻辑。你将使用 GET 从数据库中读取值,并使用 POST 将它们保存回去。

  1. 最后,使用以下代码更新 Index.cshtml 页面,用你的看板卡片的部分 Razor 页面替换标签助手的使用:

    Index.cshtml
    @page
    @using ToDoListApp.Models
    @model IndexModel
    @{
        ViewData["Title"] = "MyToDos";
    }
    <div class="text-center">
    
        @if(TempData["SuccessMessage"] != null)
        {
            <div class="alert alert-success" role="alert">
                @TempData["SuccessMessage"]
            </div>
    
The complete code can be found here: https://packt.link/9SRsY.

这样做,你会注意到有多少重复代码被消除了。

  1. 现在运行以下命令来启动应用程序:

    dotnet run
    
  2. 接下来点击创建任务按钮并填写表单。创建任务后,你将看到一个确认消息,如图 7.7 所示。

图 7.7:创建任务后的主屏幕

图 7.7:创建任务后的主屏幕

注意

如果你已经在之前的屏幕上创建了一些任务,你的系统上的屏幕显示可能会有所不同。

在这个练习中,你创建了一个几乎完全功能化的待办事项应用程序,你可以创建任务并将它们保存到数据库中,甚至记录你的请求以查看它们花费了多长时间。

注意

你可以在 packt.link/VVT4M 找到用于此练习的代码。

现在,是时候通过一个活动来工作在增强功能上了。

活动 7.01:创建一个页面来编辑现有任务

现在是时候通过一个新功能和基本功能来增强之前的练习,即移动看板板上的任务。你必须使用本章中涵盖的概念(如模型绑定、标签助手、部分页面和依赖注入)来构建此应用程序。

要完成此活动,你需要添加一个编辑任务的页面。以下步骤将帮助你完成此活动:

  1. 创建一个名为 Edit.cshtml 的新文件,其表单与 Create.cshtml 相同。

  2. 将页面指令中的路由更改为接收 "/tasks/{id}"

  3. 创建一个后端代码文件,通过 OnGet IDDbContext 架构中加载数据。如果 ID 没有返回任务,则将其重定向到 Create 页面。

  4. 在 Post 表单中,从数据库中恢复任务,更新其值,发送成功消息,然后重定向到 Index 视图。

页面的输出如下所示:

图 7.8:输出到活动的编辑任务页面

图 7.8:输出到活动的编辑任务页面

注意

该活动的解决方案可在packt.link/qclbF找到。

通过到目前为止的示例和活动,你现在知道如何使用 Razor 开发页面。在下一节中,你将学习如何使用一个具有更小范围隔离和可重用逻辑的工具,即视图组件。

视图组件

到目前为止,你已经看到了两种创建可重用组件的方法,以提供更好的维护并减少代码量,那就是标签辅助器和部分页面。虽然标签辅助器主要生成静态 HTML 代码(因为它将自定义标签转换为具有一些内容的现有 HTML 标签),但部分页面是另一个 Razor 页面内的一个小型 Razor 页面,它共享页面数据绑定机制并可以执行一些操作,如表单提交。部分页面的唯一缺点是它的动态行为依赖于包含它的页面。

本节是关于另一个允许你创建可重用组件的工具,即视图组件。视图组件在某种程度上类似于部分页面,因为它们也允许你提供动态功能并在后端有逻辑。然而,它们更强大,因为它们是自包含的。这种自包含性允许它们独立于页面开发,并且可以单独进行全面测试。

创建视图组件有几个要求,如下所述:

  • 自定义组件类必须从Microsoft.AspNetCore.Mvc.ViewComponent继承。

  • 它必须在类名中具有ViewComponent后缀或用[ViewComponent]属性装饰。

  • 此类必须实现IViewComponentResult Invoke()同步方法或Task<IViewComponentResult> InvokeAsync()异步方法(当你需要从内部调用异步方法时)。

  • 两种先前方法的典型结果是带有视图组件模型的View(model)方法。在前端,默认视图文件名应按惯例称为Default.cshtml

  • 为了渲染视图,它必须位于Pages/Components/{MY_COMPONENT_NAME}/Default.cshtml/Views/Shared/Components/{MY_COMPONENT_NAME}/Default.cshtml之一。

  • 如果不在上述任何路径中,视图的位置必须显式地作为参数传递给InvokeInvokeAsync方法返回的View方法。

这就结束了本节的理沦部分。在下一节中,你将通过练习将其付诸实践。

练习 7.06:创建用于显示任务统计信息的视图组件

在这个练习中,你将创建一个视图组件,允许你在应用程序的导航栏上显示有关延迟任务的统计信息。通过完成这个练习,你将学习视图组件的基本语法以及如何在 Razor 页面上放置它们。执行以下步骤来完成此操作:

  1. ToDoListApp 项目的根目录下,创建一个名为 ViewComponents 的新文件夹。

  2. 在此文件夹内,创建一个名为 StatsViewComponent 的新类:

    namespace ToDoListApp.ViewComponents;
    public class StatsViewComponent
    {
    }
    
  3. 再次,在 ViewComponents 文件夹内,创建一个名为 StatsViewModel 的新类,包含两个公共 int 属性,分别命名为 DelayedDueToday

    namespace ToDoListApp.ViewComponents;
    public class StatsViewModel
    {
        public int Delayed { get; set; }
        public int DueToday { get; set; }
    }
    
  4. 编辑 StatsViewComponent 类,使其继承自 Microsoft.AspNetCore.Mvc 命名空间中包含的 ViewComponent 类:

    using Microsoft.AspNetCore.Mvc;
    public class StatsViewComponent : ViewComponent
    {
    }
    
  5. 通过构造函数初始化一个 private readonly 字段来注入 ToDoDbContext

    public class StatsViewComponent : ViewComponent
    {
        private readonly ToDoDbContext _context;
        public StatsViewComponent(ToDoDbContext context) => _context = context;
    }
    

放置正确的 using 命名空间。

  1. 创建一个名为 InvokeAsync 的方法,具有以下签名和内容:

    StatsViewComponent.cs
    using ToDoListApp.Data;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.EntityFrameworkCore;
    using System.Linq;
    namespace ToDoListApp.ViewComponents;
    public class StatsViewComponent : ViewComponent
    {
        private readonly ToDoDbContext _context;
        public StatsViewComponent(ToDoDbContext context) => _context = context;
        public async Task<IViewComponentResult> InvokeAsync()
        {
            var delayedTasks = await _context.Tasks.Where(t =>
    
The complete code can be found here: https://packt.link/jl2Ue.

此方法将使用 ToDoDbContext 查询数据库并检索延迟任务,以及当天到期的任务。

  1. 现在,在 Pages 文件夹下,创建一个名为 Components 的新文件夹。

  2. 在其下创建一个名为 Stats 的文件夹。

  3. 然后,在 Stats 文件夹内,创建一个名为 default.cshtml 的新文件,内容如下:

    @model ToDoListApp.ViewComponents.StatsViewModel
    <form class="form-inline my-2 my-lg-0">
        @{
             var delayedEmoji = Model.Delayed > 0 ? "" : "";
             var delayedClass = Model.Delayed > 0 ? "btn-warning" : "btn-success";
             var dueClass = Model.DueToday > 0 ? "btn-warning" : "btn-success";
         }
        <button type="button" class="btn @delayedClass my-2 my-sm-0">
            <span class="badge badge-light">@Model.Delayed</span> Delayed Tasks @delayedEmoji
        </button>
        &nbsp;
        <button type="button" class="btn @dueClass my-2 my-sm-0">
            <span class="badge badge-light">@Model.DueToday</span> Tasks Due Today 
        </button>
    </form>
    

default.cshtml 将包含视图组件类的视图部分。在这里,你基本上是基于指定的模型创建一个 .cshtml 文件。

  1. 最后,在 _Layout.cshtml(位于 Pages/Shared/ 下),通过在导航栏内添加 <vc:stats></vc:stats> 标签来调用 ViewComponent。用以下代码替换页面代码:

    _Layout.cshtml
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>@ViewData["Title"] - ToDoListApp</title>
        <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
        <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
        <link rel="stylesheet" href="~/ToDoListApp.styles.css" asp-append-version="true" />
    </head>
    <body>
        <header>
            <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
    
The complete code can be found here: https://packt.link/DNUBC.
  1. 运行应用程序以查看如图 7.8 所示的导航栏:

图 7.9:任务统计视图组件

图 7.9:任务统计视图组件

在这个练习中,你创建了你的第一个视图组件,这是一个直接显示在导航栏上的任务统计信息。正如你可能已经注意到的,视图组件的一个高效之处在于它们与显示在页面上的页面无关。你将前端和后端都封装在组件内部,没有任何外部依赖。

注意

你可以在 packt.link/j9eLW 找到本练习使用的代码。

本练习涵盖了视图组件,这些组件允许你在应用程序的导航栏上显示有关延迟任务的统计信息。有了这些知识,你现在将完成一个活动,在这个活动中,你将在视图组件中工作以显示日志历史。

活动 7.02:编写一个用于显示任务日志的视图组件

作为本章的最后一步,这个活动将基于现实世界应用中的常见任务——记录用户活动。在这种情况下,你将把用户对字段的每个更改写入数据库并在视图中显示。为此,你需要使用视图组件。

以下步骤将帮助你完成此活动:

  1. Models 文件夹下创建一个新的类,命名为 ActivityLog。该类应具有以下属性:Guid IdString EntityIdDateTime TimestampString PropertyString OldValueString NewValue

  2. ToDoDbContext 下为该模型创建一个新的 DbSet<ActivityLog> 属性。

  3. 在你的 DbContext 下创建一个方法,使用 Entity Framework 的 ChangeTracker 中的 EntityState.ModifiedEntries 的修改属性生成活动日志。

  4. DbContext 中重写 SaveChangesAsync() 方法,通过在调用 base 方法之前将生成的日志添加到 DbSet

  5. 创建一个新的 Entity Framework Core 迁移并更新数据库以支持此迁移。

  6. 创建一个 ViewComponent 类,该类应加载在调用中传递的给定 taskId 的所有日志,并将它们返回给 ViewComponent

  7. 创建 ViewComponent 视图,该视图应接受一个 ActivityLog 集合作为模型,并在 Bootstrap 表中显示它们,如果有的话。如果没有记录日志,显示一个警告,说明没有可用的日志。

  8. 将视图组件添加到 Edit 页面,传递 taskId 属性。

  9. 运行应用程序,通过打开任务详情来检查最终输出。你将看到一个右侧的框,其中包含你的活动日志,或者如果没有记录活动日志,将显示一条没有日志的消息,对于该任务还没有记录活动日志。

图 7.10:没有日志的活动日志显示

图 7.10:没有日志的活动日志显示

在这个活动中,你能够创建一个具有完全新功能且与页面解耦的独立视图组件,使其能够一次处理一个功能。

注意

该活动的解决方案可在 packt.link/qclbF 找到。

摘要

在本章中,你学习了使用 C# 和 Razor Pages 构建现代 Web 应用程序的基础。你专注于章节开头的重要概念,例如中间件、日志记录、DI 和配置。接下来,你使用 Razor Pages 创建了 CRUD 模型,并使用了一些更高级的功能,如自定义标签助手、部分页面和视图组件,这些功能使你能够更轻松地创建可维护的应用程序功能。

最后,你看到了 ASP.NET 模型绑定的工作原理,以便在客户端和服务器之间实现双向数据绑定。到目前为止,你应该已经具备了使用 ASP.NET 和 Razor Pages 独立构建现代 Web 应用程序的有效基础。

在接下来的两章中,你将学习如何构建和与 APIs 通信。

第八章:8. 创建和使用 Web API 客户端

概述

在本章中,你将通过调用 Web API 来步入 HTTP 实践的世界。你将使用网络浏览器、你自己的 HTTP 客户端和 NuGet 包以各种方式与 Web API 交互。你将学习 Web API 中涉及的安全基础,使用 PayPal 进行沙盒支付,并探索 Azure 文本分析服务和 Azure Blob 存储等云服务。

到本章结束时,你将能够阅读 HTTP 请求和响应消息,调用任何 Web API,并创建自己的 HTTP 客户端以简化与复杂 API 的工作。你还将能够分析并学习任何形式的传入 HTTP 请求和传出 HTTP 响应,并使用 Chrome 浏览器中的开发工具检查浏览你喜欢的网站时来回移动的流量。

简介

万维网(WWW)(或简称网络)是一个包含各种文档(XML、JSON、HTML、MP3、JPG 等)的大仓库,这些文档可以通过统一资源定位符(URL)访问。在网络的上下文中,一个文档通常被称为资源。有些资源不会改变。它们存储在某处,并且每次请求时,都会返回相同的资源。这类资源被称为静态资源。其他资源是动态的,这意味着它们将在需要时生成。

网络上的通信通过协议进行。在检索文档的上下文中,你使用超文本传输协议(HTTP)。超文本是一种特殊的文本,它包含指向网络中资源的链接。点击它将打开它指向的资源。HTTP 基于客户端-服务器架构。简单来说,客户端发送请求,服务器响应。实践中一个例子是浏览器(客户端)和网站(托管在服务器上)之间的通信。通常,单个服务器为许多客户端提供服务:

图 8.1:客户端-服务器架构

图 8.1:客户端-服务器架构

当你导航到网站时,你发送一个 HTTP GET 请求,服务器通过在浏览器中显示相关网站内容来响应。GET 是一个 HTTP 动词——一个标识请求应该如何被处理的方法。常见的 HTTP 动词如下:

  • GET:获取一个资源。

  • POST:创建一个资源或发送一个复杂的查询。

  • PUT:更新所有资源字段。

  • PATCH:更新单个字段。

  • DELETE:删除一个资源。

浏览器

现代浏览器不仅仅是一个访问互联网内容的工具。它包括分解网站元素、检查流量甚至执行代码的工具。浏览器的这一部分被称为开发者工具。确切的快捷键可能有所不同,但按 F12Control + Shift + I 应该会调出开发者工具标签。执行以下步骤以更好地了解它:

  1. 打开 Google Chrome 或任何其他浏览器。

  2. 导航到 google.com。按 Control + Shift + I 键。

  3. 转到 网络 (1)。以下窗口应该会显示:

图 8.2:Chrome 浏览器中已打开开发者工具并加载了 google.com

图 8.2:Chrome 浏览器中已打开开发者工具并加载了 google.com

  1. 选择第一个条目,www.google.com (2)。

  2. 点击头部(3)。

  3. 通用(4)部分,你可以观察到导航到google.com时的效果。首先发生的事情是发送了HTTP GET请求到www.google.com/

  4. 请求头部部分(5),你可以看到随请求发送的元数据。

  5. 要查看 Google 的响应,请点击响应部分(6)。

这种流程被称为客户端-服务器架构,以下适用:

  • 客户端是发送请求到google.com的 Chrome 浏览器。

  • 服务器是一台(或多台)托管google.com的机器,它以google.com网站内容作为响应。

Web API

应用程序编程接口(API)是通过代码调用某些功能的一个接口。它可以是 C#中的类或接口,或者是一个浏览器(你可以通过其提供的接口与之交互),但在 HTTP 的上下文中,它是一个网络服务。网络服务是托管在远程机器上的 API,可以通过 HTTP 访问。用于在 Web API 上调用单个功能点的访问点称为端点。最常用的 Web API 类型是 RESTful。

RESTful API

表示性状态转移(REST)API 是基于以下六个原则构建的 API。其中四个原则是无论你使用什么框架实现 RESTful API,作为客户端都应该预期的:

  • 客户端-服务器:在客户端和服务器之间建立连接。客户端发送请求以从服务器获取响应。

  • 无状态:服务器将能够处理请求,而不管之前的请求。这意味着每个请求都应该包含所有信息,而不是依赖于服务器记住之前发生的事情。

  • 可缓存:使用 HTTP 方法或头部指定哪些请求可以被缓存的特性。

  • 按需代码(可选):REST 允许脚本在客户端下载并执行。在互联网主要由静态页面组成的时候,这很有用,但现在要么不再需要,要么被视为安全风险。

然而,其他两个原则(客户端-服务器和无状态)取决于你,因此你将需要更加关注它们。分层系统是由层组成的系统,每一层只与直接下方的层进行通信。一个典型的例子是三层架构,其中你将表示层、业务逻辑和数据存储分开。从实际的角度来看,这意味着 RESTful API(业务逻辑层)不应发送 HTML 作为响应,因为渲染输出的责任在于客户端(表示层)。

最后一个原则被称为统一接口。它定义了 API 的一组规则:

  • 资源识别:

这些示例包括获取资源的所有实例 (/resource)、创建资源 (/resource)、获取单个资源 (/resource/id) 以及获取资源中所有子资源的实例 (/resource/subresource/)。

  • 通过这些表示来操作资源:

使用表示创建、读取、更新和删除(CRUD)的 HTTP 动词来操作资源——GETUPDATEPUTPATCHDELETE

  • 自描述消息:

包含所有必要信息且没有额外文档的响应,并指示如何处理消息(头、MIME 类型等)。

  • 超媒体作为应用程序状态引擎(HATEOAS):

在响应中包含超链接,以便您可以导航到相关资源。此指南通常被忽略。

REST 与 HTTP 不同。REST 是一组指南,而 HTTP 是一种协议。两者可能会混淆,因为 HTTP 约束与 REST 约束(方法、头等)高度重叠。然而,RESTful API 不必使用 HTTP 才是 RESTful,同时 HTTP 可以通过使用会话或查询参数来执行操作而违反 REST 约束。RESTful API 可以与 XML 和 JSON 数据格式一起工作。然而,几乎所有场景都涉及 JSON。

Postman

Postman 是用于测试不同类型 Web API 的最受欢迎的工具之一。它易于设置和使用。Postman 就像浏览器一样,充当 HTTP 客户端。为了下载 Postman,请访问 www.postman.com/。你需要注册并下载安装程序。安装 Postman 后,执行以下步骤:

  1. 打开 Postman。

  2. 通过点击 Workspaces 创建你的工作空间,然后点击 Create Workspace

  3. 在新窗口中,转到 Collections 选项卡 (2),然后点击 Create new Collection (+) 按钮 (3)。

  4. 创建 New Collection (4)。

  5. 点击 Add a request (5):

图 8.3:没有请求的新 Postman 收藏夹

图 8.3:没有请求的新 Postman 收藏夹

将会打开一个新的请求窗口。

  1. 点击 New Request 旁边的编辑符号,并将新请求命名为 Users (6)。

  2. 选择 GET HTTP 动词,并将 URL api.github.com/users/github-user (7) 复制粘贴。

    注意

    在此处以及随后的所有地方,将 github-user 替换为您的 GitHub 用户名。

  3. 点击 Send 按钮 (8)。

  4. 现在向下滚动查看返回的响应结果 (9):

图 8.4:Postman 中的 GET GitHub 用户请求

图 8.4:Postman 中的 GET GitHub 用户请求

Postman 在充当 HTTP 客户端方面优于浏览器。它专注于构建 HTTP 请求,并以紧凑的方式显示响应信息,提供多种输出格式。在 Postman 中,您可以使用多个环境,为请求设置预条件和后条件,自动调用等,但这些高级功能超出了本章的范围。目前,只需知道 Postman 是手动测试 Web API 的首选工具即可。

客户端

REST 需要在客户端和服务器之间进行通信。在先前的示例中,客户端角色由浏览器或 Postman 扮演。然而,浏览器或 Postman 都不能替代您代码中的客户端。相反,您需要使用 C# 创建 HTTP 请求。

流行的 Web API 通常为您创建了客户端(在大多数常用语言中也是如此)。Web API 客户端的目的在于简化与底层 API 的交互。例如,您不需要在不支持 DELETE 请求的端点上发送请求并得到 Method Not Allowed 的响应,在自定义客户端上甚至不会有这样的选项。

Octokit

Octokit 是一个 GitHub API 客户端。它通过一个 C# 类暴露接口,您可以通过它传递对象来调用 GitHub。此类客户端的好处是您无需担心传递哪些头信息或如何命名以便它们被正确序列化。API 客户端为您处理所有这些。

您可以通过在 VS Code 终端或命令提示符中运行以下命令来在项目中安装 Octokit 客户端:

dotnet add package Octokit

一旦安装了 Octokit 客户端,您就可以使用它来创建 GitHub 客户端,如下所示:

var github = new GitHubClient(new ProductHeaderValue("Packt"));

在前面的代码片段中,您需要一个新的 ProductHeaderValue,因为 GitHub 期望一个 UserAgent 头信息。如前所述,自定义 HTTP 客户端可以防止在您甚至可以发出请求之前发生错误。在这种情况下,不提供 UserAgent 头信息(通过 ProductHeaderValue)不是一个选项。

要检查客户端是否工作,尝试获取用户名 github-user 的信息:

const string username = "github-user";
var user = await github.User.Get(username);

注意

在 GitHub 上,github-user 显示为 Almantask。最好将其更改为您的个人 GitHub 用户名,以便代码能够正常工作。

要打印用户创建的日期,请输入以下代码:

Console.WriteLine($"{username} created profile at {user.CreatedAt}");

您将看到以下输出:

github-user created profile at 2018-06-22 07:51:56 +00:00

GitHub API 上可用的每种方法在 GitHub 客户端 Octokit 上也同样可用。您无需担心端点、必填头信息、响应或请求格式;这一切都由强类型客户端定义。

注意

您可以在 packt.link/DK2n2 找到此示例使用的代码。

API 密钥

在许多公共免费 API 中,您可能会遇到以下担忧:

  • 如何控制大量的请求?

  • 应在何时对哪个客户端收费?

如果所有这些公共 API 只提供匿名访问,您将无法识别客户端或确定每个客户端各自调用了多少次。API 密钥是基本认证(识别客户端)和授权(授予他们使用 API 进行某些操作的权限)的最基本手段。简单来说,API 密钥允许您调用 API。没有它,您将几乎没有访问 API 的权限。

为了帮助您更好地掌握 API 密钥的使用,下一节将探讨需要 API 密钥的 Web API,即 Azure 文本分析。

Azure 文本分析

Azure 文本分析是 Azure API,用于以下方式分析文本:

  • 识别命名实体(人物、事件、组织)

  • 解释文本的情绪(积极、消极、中性)

  • 撰写文档的摘要或突出显示关键短语

  • 处理非结构化医疗数据,例如识别人员、分类诊断等

为了演示 Azure 文本分析 API,您将专注于情感分析。这是根据积极、消极或中立的置信度分数评估文本的过程:

  • 得分为 1,即 100%,表示预测(消极、积极、中性)的正确概率。

  • 得分为 0,即 0%,表示不可能的预测。

    注意

    使用 Azure 文本分析免费,直到您在 30 天内分析超过 5,000 个单词。

在开始编码之前,您需要在 Azure 云上设置 Azure 文本分析。毕竟,您需要一个端点和 API 密钥来调用此 API。

注意

确保您已设置 Azure 订阅。如果您没有,请访问azure.microsoft.com/en-gb/free/search,并按照那里的说明创建一个免费订阅。Azure 免费试用提供许多免费服务。其中一些服务在一年后仍将免费。学生订阅是获取 Azure 信用额和更长时间免费服务的选项。创建 Azure 订阅需要信用卡或借记卡;然而,除非您超出免费服务的限制,否则您不会收费。

Azure 文本分析可以用来对积极和消极反馈进行排序的一种方式是确定您所写的内容听起来是被动攻击性的还是友好的。要查看此功能如何运作,请按照以下步骤创建一个小型应用程序,该应用程序可以分析您输入控制台中的任何文本:

  1. 首先,访问portal.azure.com/#create/Microsoft.CognitiveServicesTextAnalytics

  2. 点击“继续创建资源”而不使用任何附加功能:

图 8.5:Azure 文本分析资源创建

图 8.5:Azure 文本分析资源创建

  1. 在创建文本分析窗口中,点击“基础”选项卡。这是创建新资源时首次打开的第一个选项卡。

  2. 订阅资源组字段中选择一个选项:

图 8.6:输入新资源创建的项目详情

图 8.6:输入新资源创建的项目详情

  1. 然后,选择区域,例如,北欧

  2. 输入名称,例如,Packt-Test

  3. 之后,选择免费 F0定价层并点击审查 + 创建按钮:

图 8.7:Azure 文本分析定价层

图 8.7:Azure 文本分析定价层

新窗口将显示以确认您的输入。

  1. 点击创建选项。文本分析 API 将开始部署。服务部署完成后,将打开一个新窗口,显示您的部署已完成

  2. 点击转到资源按钮:

图 8.8:显示部署完成的文本分析 API

图 8.8:显示部署完成的文本分析 API

显示文本分析资源窗口。

  1. 点击密钥和端点选项。您将看到端点选项以及KEY 1KEY 2以调用此 API。您可以从任一密钥中选择:

图 8.9:带有 API 密钥超链接的 Azure 文本分析快速入门窗口

图 8.9:带有 API 密钥超链接的 Azure 文本分析快速入门窗口

  1. 跟踪KEY 1(一个 API 密钥)。API 密钥是秘密的,不应以纯文本形式公开。您将再次使用环境变量来存储它。

创建一个键值对环境变量。值将是连接到 Azure 文本分析所需的端点 API 密钥。为了帮助识别缺失的环境变量,请使用辅助类。GetOrThrow方法将获取用户环境变量,如果不存在,将抛出异常:

    public static class EnvironmentVariable
    {
        public static string GetOrThrow(string environmentVariable)
        {
            var variable = Environment.GetEnvironmentVariable(environmentVariable, EnvironmentVariableTarget.User);
            if (string.IsNullOrWhiteSpace(variable))
            {
                throw new ArgumentException($"Environment variable {environmentVariable} not found.");
            }
            return variable;
        }
    }
  1. 跟踪端点选项。您将在接下来的练习中使用它来调用您刚刚部署的 API。

本节帮助您在 Azure 云上设置 Azure 文本分析,同时设置端点和 API 密钥以调用 API。在接下来的练习中,您将使用 Azure 文本分析客户端调用 API。

练习 8.01:对任何文本执行情感分析

Azure 文本分析只是另一个 REST API。再次,您向它发送 HTTP 请求并获取响应。这次,您将发送一段文本以获取其情感分析。再次练习使用强类型客户端从 C#调用 RESTful API。

使用最近部署的 Azure 文本分析服务(在本例中为Pack-Test),对任何您想要的文本执行情感分析。按照以下步骤完成此练习:

  1. 按照以下步骤安装Azure.AI.TextAnalytics NuGet 包以获取 Azure 文本分析 API 客户端:

    dotnet add package Azure.AI.TextAnalytics
    
  2. 添加TextAnalysisApiKey环境变量。

  3. 然后添加TextAnalysisEndpoint环境变量。

  4. 创建一个 Demo 类,并添加对最近添加的两个环境变量的引用:

    public class Demo
    {
        private static string TextAnalysisApiKey { get; } = EnvironmentVariable.GetOrThrow("TextAnalysisApiKey");
        private static string TextAnalysisEndpoint { get; } = EnvironmentVariable.GetOrThrow("TextAnalysisEndpoint");
    

这些属性用于隐藏 API 密钥和端点的敏感值。

  1. 创建一个新的 BuildClient 方法来构建 API 客户端:

    static TextAnalyticsClient BuildClient()
    {
        var credentials = new AzureKeyCredential(TextAnalysisApiKey);
        var endpoint = new Uri(TextAnalysisEndpoint);
        var client = new TextAnalyticsClient(endpoint, credentials);
        return client;
    }
    

API 客户端需要两个操作参数:一个基础 URL——一种统一资源标识符 (URI)——和一个 API 密钥,这两个参数在初始化时传递给它。

  1. 使用客户端,创建 PerformSentimentalAnalysis 方法来分析文本:

    private static async Task<DocumentSentiment> PerformSentimentalAnalysis(TextAnalyticsClient client, string text)
    {
        var options = new AnalyzeSentimentOptions { IncludeOpinionMining = true };
        DocumentSentiment documentSentiment = await client.AnalyzeSentimentAsync(text, options: options);
        return documentSentiment;
    }
    

在这里,你正在使用配置对象 AnalyzeSentimentOptions 提取目标和对其的意见。客户端既有 AnalyzeSentimentAsync 方法,也有 AnalyzeSentiment 方法。对于公共客户端库,公开相同方法的异步和非异步版本是一个非常常见的场景。毕竟,并不是每个人都会对异步 API 感到舒适。然而,当调用另一台机器(数据库、API 等)时,最好使用异步 API。这是因为异步调用在等待 API 响应时不会阻塞调用线程。

  1. 现在创建一个 DisplaySentenceSymmary 函数来显示句子的整体评估:

    private static void DisplaySentenceSummary(SentenceSentiment sentence)
    {
        Console.WriteLine($"Text: \"{sentence.Text}\"");
        Console.WriteLine($"Sentence sentiment: {sentence.Sentiment}");
        Console.WriteLine($"Positive score: {sentence.ConfidenceScores.Positive:0.00}");
        Console.WriteLine($"Negative score: {sentence.ConfidenceScores.Negative:0.00}");
        Console.WriteLine($"Neutral score: {sentence.ConfidenceScores.Neutral:0.00}{Environment.NewLine}");
    }
    
  2. 创建一个 DisplaySentenceOpinions 函数,用于在句子中的每个目标上显示消息 Opinions

    private static void DisplaySentenceOpinions(SentenceSentiment sentence)
    {
        if (sentence.Opinions.Any())
        {
            Console.WriteLine("Opinions: ");
            foreach (var sentenceOpinion in sentence.Opinions)
            {
                Console.Write($"{sentenceOpinion.Target.Text}");
                var assessments = sentenceOpinion
                    .Assessments
                    .Select(a => a.Text);
                Console.WriteLine($" is {string.Join(',', assessments)}");
                Console.WriteLine();
            }
        }
    }
    

句子的目标是应用了意见(语法修饰符)的主题。例如,在句子 a beautiful day 中,day 是目标,beautiful 是意见。

  1. 要在控制台中输入的文本上执行情感分析,创建 SentimentAnalysisExample 方法:

    static async Task SentimentAnalysisExample(TextAnalyticsClient client, string text)
    {
        DocumentSentiment documentSentiment = await PerformSentimentalAnalysis(client, text);
        Console.WriteLine($"Document sentiment: {documentSentiment.Sentiment}\n");
        foreach (var sentence in documentSentiment.Sentences)
        {
            DisplaySentenceSummary(sentence);
            DisplaySentenceOpinions(sentence);
        }
    }
    

在前面的代码片段中,分析文本评估整体文本的情感,然后将其分解成句子,并对每个句子进行评估。

  1. 为了演示你的代码如何工作,创建一个静态的 Demo.Run 方法:

    public static Task Run()
    {
        var client = BuildClient();
        string text = "Today is a great day. " +
                           "I had a wonderful dinner with my family!";
        return SentimentAnalysisExample(client, text);
    }
    

在设置正确的环境变量后,以下输出应该被显示:

Document sentiment: Positive
Text: "Today is a great day."
Sentence sentiment: Positive
Positive score: 1,00
Negative score: 0,00
Neutral score: 0,00
Text: "I had a wonderful dinner with my family!"
Sentence sentiment: Positive
Positive score: 1,00
Negative score: 0,00
Neutral score: 0,00
Opinions:
dinner is wonderful

你在这里没有硬编码 API 密钥的值,因为公开的 API 密钥可能会被不当使用,如果被盗用,可能会产生灾难性的后果(例如,过度使用、创建虚假资源、数据泄露、删除数据等)。这就是为什么在处理机密信息时,应使用尽可能少的防护措施,即环境变量。

环境变量的另一个好处是能够在不同的环境中具有不同的值(本地、集成、系统测试、生产等)。不同的环境通常使用不同的资源。因此,通过环境变量指向这些资源不需要对代码进行任何更改。

为了运行这个练习,请访问 packt.link/GR27A 并注释掉 static void Main(string[] args) 方法体内的所有行,除了 await Exercises.Exercise01.Demo.Run();。同样,在执行每个练习/示例/活动之前,取消注释 Program.cs 中的相应练习/示例/活动代码行。

注意

你可以在packt.link/y1Bqy找到用于此练习的代码。

这项练习只是您消费公共 Web API 的许多练习之一。Azure 充满了这样的服务。使用强类型客户端调用 API 很简单;然而,并非所有 API 都有。在下一节中,您将学习如何创建自己的 Web API 客户端。

您自己的客户端

到目前为止,您只使用预制的客户端来消费 Web API。然而,对于不太受欢迎的 API,可能没有可用的客户端。在这种情况下,您将不得不自己进行 HTTP 调用。在.NET 中,进行调用的方式已经发生了很大的变化。如果您不想使用任何第三方库,您可以使用HttpClient类。

HttpClient

在本节中,您将重复 GitHub Users示例(来自Postman部分),但这次使用HttpClient。这个流程相当简单,以下示例将为您详细描述:

  1. GitHttp静态类中,创建GetUser方法:

    public static async Task GetUser()
    
  2. GitExamples方法中,首先创建一个客户端:

    client = new HttpClient { BaseAddress = new Uri("https://api.github.com") };
    client.DefaultRequestHeaders.Add("User-Agent", "Packt");
    

创建客户端几乎总是涉及指定特定的基本 URL。通常,Web API 需要传递强制性的头信息,否则它们将使请求无效(400 Bad Request)。对于 GitHub,您需要发送标识调用 API 的客户端的User-Agent头信息。将Packt用户代理头信息添加到默认头信息中,将使该头信息随每个请求发送到客户端。

  1. 然后按照以下方式创建一个请求:

    const string username = "github-user"; //replace with your own
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"users/{username}", UriKind.Relative));
    

记得将github-user替换为您自己的 GitHub 用户名。在这里,您指定了您想要创建一个GET请求。您没有指定完整的路径,而是只指定了您想要触发的端点;因此,您必须将UriKind标记为Relative

  1. 接下来,使用客户端发送请求:

    var response = await client.SendAsync(request);
    

发送 HTTP 请求消息只有一个异步版本,因此您需要等待它。发送HttpRequestMessage的结果是HttpResponseMessage

  1. 然后,按照以下方式将内容反序列化为可用的对象:

    var content = await response.Content.ReadAsStringAsync();
    var user = JsonConvert.DeserializeObject<User>(content);
    

反序列化是将结构化文本(如 JSON)转换为内存中对象的过程。为此,您需要将内容转换为字符串,然后进行反序列化。您可以使用来自 Octokit NuGet 的用户模型。由于您已经在进行自定义调用,您也可以使用自定义模型。对于最基本的情况(仅使用到的字段),您的模型可能看起来像这样:

public class User
{
    public string Name { get; set; }
    [JsonProperty("created_at")]
    public DateTime CreatedAt { get; set; }
}

public DateTime CreatedAt { get; set; }之上,行[JsonProperty("created_at")]将 JSON 字段绑定到 C#属性。这种绑定是必要的,因为名称不匹配。

如果您想创建自己的客户端(用于进行 GitHub 调用),您有责任公开 API 返回的所有数据,而不仅仅是特定场景下可能需要的数据,让消费者进行选择。

  1. 使用 Postman 之前的调用消息来获取 GitHub 用户响应体以生成要反序列化的模型。在这种情况下,响应消息如下(消息为了清晰而截断):

    {
       "login":"github-user",
       "id":40486932,
       "node_id":"MDQ6VXNlcjQwNDg2OTMy",
       "name":"Kaisinel",
       "created_at":"2018-06-22T07:51:56Z",
       "updated_at":"2021-08-12T14:55:29Z"
    }
    

有许多工具可以将 JSON 转换为 C#模型。

  1. 在这种情况下,使用json2csharp.com/将 JSON 转换为 C#模型代码。

  2. 复制响应(GET github/user)并转到json2csharp.com/

  3. 将响应粘贴到左侧的文本框中,然后点击Convert按钮:

图 8.10:将 JSON 转换为 C#模型代码

图 8.10:将 JSON 转换为 C#模型代码

左侧显示 JSON 的模型,而右侧显示从 JSON 生成的代码(C#类)。

  1. 将右侧的内容复制并粘贴到你的代码中:

    public class Root
    {
        public string login { get; set; }
        public int id { get; set; }
        public string node_id { get; set; }
        public string name { get; set; }
        public DateTime created_at { get; set; }
        public DateTime updated_at { get; set; }
    }
    

这就是你的模型。在前面的代码中观察,Root是一个不可读的类名。这是因为转换器没有方法知道 JSON 代表什么类。Root类代表一个用户;因此,将其重命名为User

最后,转换器可能是在.NET 5 之前创建的,这就是为什么它没有记录功能。记录是一个用于序列化的优秀类,也是数据传输对象(DTO)的优秀候选者。DTO 是一个没有逻辑但仅包含数据,有时还包含用于绑定序列化的属性的类。你获得的好处如下:

  • 值相等

  • ToString将返回属性及其值

  • 能够用更简洁的语法定义它们

因此,尽可能在你的应用程序中使用记录来定义 DTO。

  1. 将(Root重命名为User)并将类型从class更改为record。代码行看起来如下,不需要对属性进行任何更改:

    public record User
    
  2. 最后,运行以下代码行:

    Console.WriteLine($"{user.Name} created profile at {user.CreatedAt}");
    

输出显示如下:

Kaisinel created profile at 2018-06-22 07:51:56

为了运行此练习,请访问packt.link/GR27A并注释掉static void Main(string[] args)体内的所有行,除了await Examples.GitHttp.Demo.Run();。同样,在执行之前,取消注释Program.cs中的相应练习/示例/活动的代码行。

注意

你可以在packt.link/UPxmW找到用于此示例的代码。

现在你已经看到了使用HttpClient类代替第三方库的好处,你可以在下一节探索IDisposable模式。

HttpClient 和 IDisposable

HttpClient 实现了 IDisposable 模式。一般来说,在你完成使用实现 IDisposable 的对象后,你应该清理并调用 Dispose 方法,或者将调用封装在一个 using 块中。然而,HttpClient 是特殊的,你不应该频繁地创建和销毁它。销毁和重新初始化 HttpClient 的问题在于 HttpClient 管理它与其他 API 建立的连接,销毁 HttpClient 并没有正确关闭这些连接(或套接字)。

最危险的部分是,由于可用的连接数量巨大,你在本地开发应用程序时不会注意到任何差异。然而,当将应用程序部署到生产环境时,你可能会耗尽免费的套接字连接。再一次,避免调用 Dispose 方法并重新初始化 HttpClient。如果你必须这样做,请使用 HttpClientFactory。不仅 HttpClientFactory 通过管理 HttpClientMessageHandler(负责发送 HTTP 请求和接收响应的组件)来管理套接字连接的生存期,它还提供了日志记录功能,允许集中管理客户端的配置,支持向客户端注入中间件等。如果你在企业环境中使用 HttpClient,这些提到的优势很重要。你可以在 第九章创建 API 服务 中了解更多关于 HttpClientFactory 的信息。

理想情况下,你应该有一个静态的 HttpClient,你可以在整个应用程序中重复使用它来调用 Web API。然而,你不应该为所有事情使用单个 HttpClient。关于不销毁 HttpClient 并拥有一个静态的并不是一个硬性规则。如果你调用许多不同的 API,它们将有自己的基本地址、强制头等信息。为所有这些拥有一个单一的对象不是一个可行的场景。

你迄今为止处理过的请求是公开可访问的,并且没有安全性。然而,Web API 中的昂贵或私有操作通常受到保护。通常,保护是通过设置一个授权头来实现的。在许多情况下,授权头涉及某种形式的 ID 和密钥。在 GitHub API 的情况下,它涉及客户端 ID 和客户端密钥。但是,要获取它们,你需要创建一个 OAuth 应用。

在你能够这样做之前,你需要熟悉 OAuth。

OAuth

OAuth 是一种开放标准的授权协议,允许代表用户委托访问。本节将探讨两个示例:

  • 生活中的类比

  • API 类比

生活中的类比

想象一个在学校的孩子。那位孩子的老师正在组织去另一个城市的旅行。需要家长提供一份同意书。家长写了一张便条:我的孩子可以去地方 X,没有问题。 孩子把便条交给老师,并获得前往目的地 X 的郊游许可。

API 类比

许多应用程序相互连接,彼此之间有集成。例如,著名的社交平台 Discord 允许您显示您在其他社交媒体上的任何账户。但要做到这一点,您需要连接到您想要显示的社交媒体平台。例如,当您在 Discord 上并尝试链接一个 Twitter 账户时,您将需要在 Twitter 上登录。登录需要一定的访问范围(在这种情况下是您的个人资料名称)。成功的登录是授予访问权限的证明,Discord 将能够代表您在 Twitter 上显示您的个人资料信息。

GitHub OAuth 应用程序

返回到 GitHub 的主题,OAuth 应用程序是什么?它是一个单一安全点的注册。它充当您的应用程序身份。GitHub 用户可能有零个或多个应用程序。如前所述,OAuth 应用程序包括客户端 ID 和密钥。通过它们,您可以使用 GitHub API。换句话说,您可以设置它来请求访问 GitHub 的安全功能,例如更改您在 GitHub 上的个人数据。

GitHub 有一个有趣的 API 限制。如果来自同一 IP 的未认证请求超过 60 个,它将阻止长达一小时的后续请求。然而,通过授权请求可以移除速率限制。这就是您将使用授权来访问其他情况下公开端点的主要原因。

OAuth 通常涉及两个客户端应用程序:

  • 代表某人请求权限的应用

  • 另一个授予该权限的应用

因此,在设置 OAuth 时,您很可能会被要求创建一个 URL,在客户端授予访问权限后返回。在 GitHub 上设置 OAuth 应用程序涉及以下步骤:

  1. 在右上角,点击您的个人资料图片并点击设置

图 8.11:GitHub 中的账户设置

图 8.11:GitHub 中的账户设置

  1. 在左侧,向下滚动菜单直到接近底部,然后点击开发者设置选项:

图 8.12:GitHub 中的开发者设置

图 8.12:GitHub 中的开发者设置

  1. 现在选择OAuth 应用程序选项:

图 8.13:在 GitHub 开发者设置中选择 OAuth 应用程序

图 8.13:在 GitHub 开发者设置中选择 OAuth 应用程序

  1. 然后点击注册新应用程序按钮:

图 8.14:在 GitHub 中创建新的 OAuth 应用程序

图 8.14:在 GitHub 中创建新的 OAuth 应用程序

注意

如果您之前已创建 OAuth 应用程序,则此窗口将显示所有列出的应用程序。为了创建一个新的,您将必须点击新建 OAuth 应用程序

  1. 在下一个窗口中,您将完成表格。首先填写应用程序名称5)。避免使用特殊字符。

  2. 接下来,填写主页 URL6)。

这个 URL 通常指向一个网站,该网站描述了特定情况下 OAuth 的使用以及为什么需要它。即使你没有描述此类情况的网站,你也可以输入一个占位符 URL(在这个例子中,是myapp.com)。只要是一个有效的 URL,这个字段就接受任何内容。

  1. 填写授权回调 URL7)字段。这可以是任何你想要的内容。这里使用的是myapp.com/home。使用一个有效的回调 URL。

  2. 点击注册应用8图 8.15:GitHub 中的新 OAuth 应用窗口

图 8.15:GitHub 中的新 OAuth 应用窗口

  1. 在新窗口中,你会看到Client IDClient secrets

图 8.16:GitHub 上新的 OAuth 应用详情,包括应用凭据—Client ID 和 Client secrets

图 8.16:GitHub 上新的 OAuth 应用详情,包括应用凭据—Client ID 和 Client secrets

最好将客户端密钥存储在一个安全的地方以供将来参考,因为你只会在 GitHub 上看到它一次。如果你忘记了它,你将不得不创建一个新的密钥并删除旧的密钥。

现在你已经在 GitHub 上成功创建了一个 OAuth 应用。客户端密钥在这张截图中被部分隐藏,这是出于一个原因。你永远不应该公开它。为了在演示中使用它,你将首先使用环境变量来隐藏它们。

  1. 因此,将这些值存储在环境变量GithubClientIdGithubSecret中。

  2. 然后在Demo.cs中的静态属性中公开这两个属性(前面已解释)如下:

    private static string GitHubClientId { get; } = Environment.GetEnvironmentVariable("GithubClientId", EnvironmentVariableTarget.User);
    private static string GitHubSecret { get; } = Environment.GetEnvironmentVariable("GithubSecret", EnvironmentVariableTarget.User);
    

本节介绍了在 GitHub 上设置 OAuth 应用的步骤,该应用可以用来请求访问 GitHub 的安全功能,例如更改你的个人数据。有了这些知识,你现在可以使用客户端 ID 和客户端密钥在 GitHub API 上创建授权调用,如下节所示。

授权头

授权头有三种形式——基本、API 密钥(或个人访问令牌)和第三方身份验证。GitHub API 不允许来自同一来源的无限制调用。就像 Azure Text Analytics 客户端一样,它也使用 API 密钥。然而,在这种情况下,API 密钥用于速率限制(你每小时可以调用多少次)。对于匿名调用,它每小时只允许 60 次调用。但是,通过使用有效的授权头,这个数量可以增加到 5,000。

在下面的示例中,你将比速率限制允许的次数多调用一次(60 + 1 = 61)。这样,你将获取 61 次用户信息。为了实现这一点,你还需要确保将CacheControl头设置为NoCache,因为你不希望在 60 次连续调用后忽略请求:

public static async Task GetUser61Times()
{
    const int rateLimit = 60;
    for (int i = 0; i < rateLimit + 1; i++)
    {
        const string username = "github-user";
        var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"users/{username}", UriKind.Relative));
        request.Headers.CacheControl = new CacheControlHeaderValue(){NoCache = true};

        var response = await client.SendAsync(request);
        if (!response.IsSuccessStatusCode)
        {
            throw new Exception(response.ReasonPhrase);
        }

这段代码是对HttpClient部分中的GetUser方法的改编。这里主要有三个调整:

  • 第一个调整是循环中的所有内容都运行 61 次。

  • 你还添加了一个错误处理器,这意味着如果响应不是成功的,你将打印出 API 返回的错误信息。

  • 最后,你添加一个 CacheControl 头来忽略缓存(因为你确实希望有 61 次调用服务器)。

运行此代码在第 61 次调用时会产生错误信息,这证明了 API 速率限制(错误信息已被截断以清晰显示):

60) Kaisinel created profile at 2018-06-22 07:51:56
Unhandled exception. System.Exception: rate limit exceeded

为了修复这个问题,你需要添加一个 Authorization 头(你将在 CacheControl 头下面添加它):

GitHttp.cs
public static async Task GetUser61Times(string authHeader)
{
    const int rateLimit = 60;
            for (int i = 0; i < rateLimit + 1; i++)
            {
                const string username = "github-user"; // replace with your own
                var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"users/{username}", UriKind.Relative));
               request.Headers.CacheControl = new CacheControlHeaderValue(){NoCache = true};
               request.Headers.Add("Authorization", authHeader);
               var response = await client.SendAsync(request);
                if (!response.IsSuccessStatusCode)
                {
                    throw new Exception(response.ReasonPhrase);
                }
The complete code can be found here: https://packt.link/1C5wb.

由于 GitHub 对匿名调用有限制(例如,每小时只能进行 60 次请求以获取用户配置文件信息),你会发现提供授权头以识别自己并因此摆脱这种严格限制会更有效率。在接下来的示例中,你将获取一个授权令牌,并将其提供给此方法,从而展示授权如何帮助你克服速率限制。

当运行放置在 packt.link/Uz2BL 的演示代码时,建议你一次运行一个示例(即,取消注释一行,并在 Run 方法中注释掉其余行)。这是因为 Demo.cs 文件是授权和匿名调用的混合,你可能会得到意外的结果。但是,保留获取令牌的行,因为它可能被个别示例所需要。

在本节结束时,你应该已经掌握了授权头背后的逻辑及其三种形式——基本认证、API 密钥(或个人访问令牌)和第三方认证——并且了解到,与 Azure 文本分析客户端一样,GitHub API 使用 API 密钥。现在你可以继续学习基本认证。

基本认证

基本认证涉及用户名和密码。这两个通常组合成一个字符串,并使用以下格式进行编码:

Basic username:password

这里是用于生成基本认证授权令牌的代码:

public static string GetBasicToken()
{
    var id = GitHubClientId;
    var secret = GitHubSecret;
    var tokenRaw = $"{id}:{secret}";
    var tokenBytes = Encoding.UTF8.GetBytes(tokenRaw);
    var token = Convert.ToBase64String(tokenBytes);
    return "Basic " + token;
}

使用用户名和密码获取基本令牌。然后将它传递给 GetUser61Times 方法:

var basicToken = GitExamples.GetBasicToken();
await GitExamples.GetUser61Times(basicToken);

调用 GetUser61Times 时不再显示错误,因为通过提供授权头避免了速率限制。

注意

你可以在 packt.link/Uz2BLpackt.link/UPxmW 找到用于此示例的代码。

下一节将介绍更专业的 API 密钥和个人访问令牌,它们很相似,因为它们都提供了访问受保护数据的权限。

API 密钥和个人访问令牌

个人访问令牌仅限于个人数据。然而,API 密钥可以用于整个 API。除了可以访问的范围外,两者在使用方式上没有区别。你可以像这样将 API 密钥或个人访问令牌添加到授权头中。

但是,当然,要使用某个 API 的访问令牌,你首先需要创建它。你可以通过以下步骤完成:

  1. 在“设置”窗口下,前往 GitHub 的“开发者设置”选项。

  2. 导航到“个人访问令牌”(1)。

  3. 选择“生成新令牌”按钮(2):

图 8.17:创建新的个人访问令牌

图 8.17:创建新的个人访问令牌

  1. 接下来,输入您的 GitHub 密码。

  2. 添加一个备注(这可以是任何内容)并向下滚动。此屏幕将帮助您修改用户数据,因此请勾选user复选框(4)以获取访问权限。

  3. 点击Generate token按钮(5):

图 8.18:为个人访问令牌配置的访问范围

图 8.18:为个人访问令牌配置的访问范围

在新窗口中,您将看到所有个人访问令牌,包括新添加的:

图 8.19:在 GitHub 上创建的新个人访问令牌

图 8.19:在 GitHub 上创建的新个人访问令牌

注意

请记住,您只能看到令牌值一次。因此,请确保您将其安全地复制并存储。此外,请注意,个人访问令牌在一个月后过期,此时您需要重新生成它。

  1. 创建一个名为GitHubPersonalAccess的环境变量。

  2. 将个人访问令牌添加到Demo.cs中:

    private static string GitHubPersonAccessToken { get; } = Environment.GetEnvironmentVariable("GitHubPersonalAccess", EnvironmentVariableTarget.User);
    
  3. 运行以下代码:

    await GetUser61Times(GitHubPersonAccessToken);
    

您将观察到调用GetUser61Times方法不会失败。

访问令牌、授权令牌、API 密钥和 JWT(将在以下章节中进一步介绍)是向 API 证明您已被授予访问权限并有权访问您想要资源的不同方式。但无论您使用哪种具体的授权方式,它们通常都会指向同一个地方——即授权头。

下一节将详细介绍一个名为 OAuth2 的授权协议。

第三方身份验证——OAuth2

GitHub 是一个授权服务器的示例。它允许以所有者的名义访问资源或功能。例如,更新用户的就业状态仅对已登录用户可用。但是,如果用户已被授予执行此操作的访问权限,则可以直接这样做。OAuth2 就是关于代表某人获取访问权限的程序。

执行以下步骤以修改用户的就业状态:

  1. 导航到此 URL 或发送一个 HTTP GET请求:

    https://github.com/login/oauth/authorize?client_id={{ClientId}}&redirect_uri={{RedirectUrl}}
    

在这里,{{ClientId}}{{RedirectUrl}}是您在 OAuth2 GitHub 应用中设置的值。

注意

将占位符{{ClientId}}{{RedirectUrl}}替换为您 GitHub OAuth 应用中的相应值。

以下屏幕提示您登录到您的 GitHub 应用:

图 8.20:登录 OAuth2 GitHub 应用

图 8.20:登录 OAuth2 GitHub 应用

  1. 完成用户名和密码。

  2. 接下来,点击Sign in按钮进行登录。

登录成功后,您将被重定向到 OAuth2 应用中指定的 URL。

  1. 通过向以下格式的 URI 发送 HTTP POST请求来创建令牌请求:

    {tokenUrl}?client_id={clientId}&redirect_uri={redirectUri}&client_secret={secret}&code={code}:
    

其代码如下:

private static HttpRequestMessage CreateGetAccessTokenRequest()
{
    const string tokenUrl = "https://github.com/login/oauth/access_token";
    const string code = "2ecab6ecf412f28f7d4d";
    const string redirectUri = "https://www.google.com/";
    var uri = new Uri($"{tokenUrl}?client_id={GitHubClientId}&redirect_uri={redirectUri}&client_secret={GitHubSecret}&code={code}");
    var request = new HttpRequestMessage(HttpMethod.Post, uri);
    return request;
}

在这种情况下,重定向 URL 是www.google.com。你最终得到的 URI 是www.google.com/?code=a681b5126b4d0ba160bacode=部分是获取OAuth访问令牌所需的代码。令牌以以下格式返回:

access_token=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&scope=user&token_type=bearer
  1. 在此令牌可以使用之前,你需要从响应中解析它。因此,创建一个解析令牌响应的函数:

    private static Dictionary<string, string> ConvertToDictionary(string content)
    {
        return content
            .Split('&')
            .Select(kvp => kvp.Split('='))
            .Where(kvp => kvp.Length > 1)
            .ToDictionary(kvp => kvp[0], kvp => kvp[1]);
    }
    

这会将每个=属性放入一个字典中。=之前的是键,=之后的是值。

  1. 使用GetToken函数创建并发送请求,然后解析响应,然后格式化令牌并返回它:

    private static async Task<string> GetToken()
    {
        HttpRequestMessage request = CreateGetAccessTokenRequest();
        var response = await client.SendAsync(request);
        var content = await response.Content.ReadAsStringAsync();
        Dictionary<string, string> tokenResponse = ConvertToDictionary(content);
        // ValidateNoError(tokenResponse);
        var token = $"{tokenResponse["token_type"]} {tokenResponse["access_token"]}";
        return token;
    }
    

在这里,你创建了一个请求,将其发送到客户端,将响应解析为令牌,然后返回。ValidateNoError目前被注释掉了。你稍后会回到它。返回的令牌应该看起来像这样:

bearer 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

这个令牌是一个持票令牌,它是由授权服务器(在这种情况下,是 GitHub)生成的,代表你(或任何用于登录 GitHub 的其它用户名)访问 GitHub。你可以用它来发送需要特殊访问权限的请求。例如,更新用户的就业状态。

  1. 要更新用户的就业状态,请使用UpdateEmploymentStatus函数:

    public static async Task UpdateEmploymentStatus(bool isHireable, string authToken)
    {
        var user = new UserFromWeb
        {
            hireable = isHireable
        };
        var request = new HttpRequestMessage(HttpMethod.Patch, new Uri("/user", UriKind.Relative));
        request.Headers.Add("Authorization", authToken);
        var requestContent = JsonConvert.SerializeObject(user, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
        request.Content = new StringContent(requestContent, Encoding.UTF8, "application/json");
        var response = await client.SendAsync(request);
        var responseContent = await response.Content.ReadAsStringAsync();
        Console.WriteLine(responseContent);
    }
    

这段代码将用户的属性isHireable设置为true并打印更新后的用户信息。这里重要的是内容;在发送PUTPATCHPOST请求时,你通常需要一个带有请求(或换句话说,内容)的主体。

将内存中的对象转换为结构化文本(例如,JSON)的行为称为序列化。在这种情况下,主体是一个用户更新。你发送一个PATCH请求,因为你只想更改更新的值。如果内容中没有提供值,则不应更改。这是PATCH请求与POST请求之间的关键区别——成功的请求会覆盖所有值(即使你没有提供它们)。

你使用了new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }来避免提供null值。这是因为你不想更新所有字段,只更新你提供的那些字段。

在创建 HTTP 内容时,你还需要提供一个 MIME 类型(请求中发送的一种媒体类型)。这是必要的,以便服务器可以了解它应该如何处理请求。MIME 类型遵循以下格式:

type/subtype

在这种情况下,application/json意味着客户端应该期望从服务器接收 JSON。application是最常见的 MIME 类型,意味着二进制数据。

此外,还有StringContent,它是一种序列化内容,通常是 JSON 或 XML。或者,你也可以使用StreamContentByteContent,但它们稍微少用一些,并且在使用性能或数据量是关注点时使用。

以下代码展示了完整的演示:

public static async Task Run()
{
    var oathAccessToken = await GitExamples.GetToken();
    await GitExamples.UpdateEmploymentStatus(true, oathAccessToken);
}

GetToken 方法(用于 第三部分认证(OAuth2) 部分的 步骤 6)中,有一行被注释掉的代码,ValidateNoError。取消注释它并实现 GetToken 方法,因为你不总是能得到成功的响应,在这种情况下解析令牌将失败(即它不存在)。因此,始终验证服务器响应并在意外发生时抛出异常是一个好主意。查看以下 GitHub 错误格式:

error=bad_verification_code&error_description=The+code+passed+is+incorrect+or+expired.&error_uri=https%3A%2F%2Fdocs.github.com%2Fapps%2Fmanaging-oauth-apps%2Ftroubleshooting-oauth-app-access-token-request-errors%2F%23bad-verification-code

这不是很易读。ValidateNoError 将格式化响应并将其作为异常抛出,而不是让它静默失败:

private static void ValidateNoError(Dictionary<string, string> tokenResponse)
{
    if (tokenResponse.ContainsKey("error"))
    {
        throw new Exception(
            $"{tokenResponse["error"].Replace("_", " ")}. " +
            $"{tokenResponse["error_description"].Replace("+", " ")}");
    }
}

如果你再次运行代码并且因为相同的原因失败,错误信息现在将如下所示:

bad verification code. The code passed is incorrect or expired.

本节介绍了如何在有安全措施的情况下发送 HTTP 请求的基础知识。在接下来的部分(RestsharpRefit)中,你将使用第三方库创建客户端,以减少 HttpClient 所需的样板代码。

注意

你可以在 packt.link/UPxmW 找到用于此示例的代码。

请求幂等性

幂等 HTTP 请求是指总是产生相同结果的请求。只有 GETPUTPATCH 请求是幂等的,因为它们要么不改变任何内容,要么重复相同的改变,但这种改变永远不会导致错误并产生相同的数据。DELETE 不是幂等的,因为删除已删除的项目将产生错误。POST 可能是或不是幂等的,这完全取决于实现。

PUT、PATCH 或 POST

PUTPATCHPOST 之间的区别可以总结如下:

  • PUT 用于覆盖模型中的字段。即使明确提供了单个值,整个模型也将包含未提供的值(或者至少这是预期)。例如,如果你想通过首先获取旧详情然后发送修改后的版本来更新用户详情,你会使用 PUT

  • PATCH 用于更新明确提供的单个值。例如,如果你想更新用户名,使用 PATCH 而不是 PUT 请求会更合理。

  • POST 用于创建项目或发送复杂查询。无论哪种方式,此动词的默认预期都有副作用。例如,如果你想创建用户,你会使用 POST 请求。

练习 8.02:HttpClient 调用星球大战 Web API

你可能熟悉星球大战。有电影、游戏和电视剧。然而,你知道它还有多个 API 来检索数据吗?即将到来的练习将介绍 API 的不同格式,并使你熟悉更复杂的响应的反序列化。

在这个练习中,你将创建一个强类型 API 客户端,它将底层使用HttpClient。该客户端将用于返回星球大战电影。你将使用星球大战 API(SWAPI)(swapi.dev/)。所需的端点是swapi.dev/api/films/。执行以下步骤以完成此练习:

  1. 创建一个新的类来持有HttpClient和基本 URL:

    public class StarWarsClient
        {
            private readonly HttpClient _client;
            public StarWarsClient()
            {
                _client = new HttpClient {BaseAddress = new Uri("https://swapi.dev/api/")};
            }
    

这将作为一个强类型 API 客户端。

注意

URI 末尾的/表示将在 URI(在api之后而不是在dev之后)之后附加更多文本。

  1. 创建一个表示电影的类型:

    Film.cs
    public record Film
    {
        public string Title { get; set; }
        public int EpisodeId { get; set; }
        public string OpeningCrawl { get; set; }
        public string Director { get; set; }
        public string Producer { get; set; }
        [JsonProperty("release_date")]
        public string ReleaseDate { get; set; }
        public string[] Characters { get; set; }
        public string[] Planets { get; set; }
        public string[] Starships { get; set; }
        public string[] Vehicles { get; set; }
        public string[] Species { get; set; }
        public DateTime Created { get; set; }
    
The complete code can be found here: https://packt.link/tjHLa.

这是一个你将用于在响应中反序列化电影的类。ReleaseDate属性上方有[JsonProperty("release_date")]来指定"release_date" JSON 字段将映射到ReleaseDate C#属性。

  1. 创建一个用于存储结果的类型:

    public record ApiResult<T>
    {
        public int Count { get; set; }
        public string Next { get; set; }
        public string Previous { get; set; }
        [JsonProperty("results")]
        public T Data { get; set; }
    }
    

这也是一个用于反序列化电影响应的类型;然而,星球大战 API 以分页格式返回结果。它包含PreviousNext属性,分别指向上一页和下一页。例如,如果你不提供想要的页码,它将返回null的值。然而,如果还有剩余元素,下一个属性将指向下一页(否则它也将是null)。使用下一个或上一个作为 URI 查询 API 将返回该页的资源。你使用JsonProperty属性在T Data上方提供 JSON 到属性的映射,因为属性和 JSON 名称不匹配(JSON 字段名是results,而Data是属性名)。

注意

你可以将ApiResult更改为具有Results属性而不是Data。然而,ApiResult.Results有点令人困惑。在编写代码时,与其选择自动化(在这种情况下,序列化)的便利性,不如选择可维护性和可读性。因此,步骤 3中选择的名称不同但更清晰。

  1. 现在,创建一个获取多个电影的方法:

    public async Task<ApiResult<IEnumerable<Film>>> GetFilms()
    {
    

你返回了一个任务,以便其他人可以等待此方法。几乎所有 HTTP 调用都将使用async Task

  1. 创建一个 HTTP 请求以获取所有电影:

    var request = new HttpRequestMessage(HttpMethod.Get, new Uri("films", UriKind.Relative));
    

URI 是相对的,因为你是从已经设置了基本 URI 的HttpClient中调用的。

  1. 要查询星球大战 API 中的电影,发送此请求:

    var response = await _client.SendAsync(request);
    
  2. 它返回HttpResponseMessage。这个有两个重要部分:状态码和响应体。C#有一个方法可以根据状态码确定是否有错误。要处理错误,请使用以下代码:

    if (!response.IsSuccessStatusCode)
    {
          throw new HttpRequestException(response.ReasonPhrase);
    }
    

错误处理很重要,因为失败的 HTTP 请求通常会返回错误状态码而不是异常。在尝试反序列化响应体之前,建议你做类似的事情,因为如果失败,你可能会得到意外的体。

  1. 现在,调用ReadAsStringAsync方法:

    var content = await response.Content.ReadAsStringAsync();
    var films = JsonConvert.DeserializeObject<ApiResult<Film>>(content);
        return films;
    }
    

响应的内容更有可能是某种流。要将 HttpContent 转换为字符串,调用 ReadAsStringAsync 方法。这返回一个字符串(JSON),允许你将 JSON 转换为 C# 对象并反序列化结果。最后,通过反序列化响应内容体并将所有内容转换为 ApiResult<Film> 来获取结果。

  1. 为了演示,创建客户端并使用它获取所有星球大战电影,然后打印它们:

    public static class Demo
    {
        public static async Task Run()
        {
            var client = new StarWarsClient();
            var filmsResponse = await client.GetFilms();
            var films = filmsResponse.Data;
            foreach (var film in films)
            {
                Console.WriteLine($"{film.ReleaseDate} {film.Title}");
            }
        }
    }
    

如果一切正常,你应该看到以下结果:

1977-05-25 A New Hope
1980-05-17 The Empire Strikes Back
1983-05-25 Return of the Jedi
1999-05-19 The Phantom Menace
2002-05-16 Attack of the Clones
2005-05-19 Revenge of the Sith

这个练习说明了如何创建强类型 HTTP 客户端以简化操作。

备注

你可以在 packt.link/2CHpb 找到这个练习使用的代码。

你可能已经注意到,发送 HTTP 请求和使用 HTTP 客户端的方式非常类似于将简单的文本文件发送到 GitHub API 的方式。即使它们不同,同一个 API 中的端点通常具有相同的要求。然而,如果你每次需要调用 API 时都手动构建 HTTP 请求,你并不高效。更好的方法是创建可重用的东西。一个常见的方法是创建 BaseHttpClient。你将在以下活动中将其付诸实践。

活动 8.01:通过重用 HttpClient 快速创建 API 客户端

HttpClient 的问题是你仍然需要自己管理很多事情:

  • 错误处理

  • 序列化和反序列化

  • 必须的头部信息

  • 授权

当你在团队中工作或在更大的项目中工作时,你很可能会进行不止一次的 HTTP 调用。不同调用之间的一致性和相同要求需要被管理。

这个活动的目的是展示你可以简化重复 HTTP 调用的多种方法之一。你将使用 BaseHttpClient 类,你首先需要创建这个类。这个类将通用错误处理和反序列化响应和请求,这将显著简化你做出的不同 HTTP 调用。在这里,你将学习如何通过重写 StarWarsClient 使用 BaseHttpClient 来实现基础客户端。

执行以下步骤以完成此活动:

  1. 创建一个基础的 HttpClient 类。基础客户端封装 HttpClient。因此,你将保留对其的私有引用,并允许它从一个 URL 创建。内部的 HttpClient 通常也包含基础头部,但在这个例子中不是必需的。

  2. 定义一种为每个方法创建请求的方式。为了简洁,我们坚持使用 GET 请求。在 GET 请求中,定义默认头部是一个常见的做法,但在这个例子中,它不是强制的。

  3. 创建一个发送请求的方法,并包括错误处理和反序列化。

  4. 在 SWAPI 中,如果你正在查询多个结果,你会得到 ApiResult<IEnumerable<T>> 用于分页。创建一个 SendGetManyRequest 方法。

  5. 使用你创建的基础客户端,简化 练习 8.02 中的客户端。

  6. 使用新的 StarWarsClient 版本通过相同的演示代码运行代码。

  7. 如果您再次使用新的 StarWarsClient 运行演示,您应该看到相同的电影返回:

    1977-05-25 A New Hope
    1980-05-17 The Empire Strikes Back
    1983-05-25 Return of the Jedi
    1999-05-19 The Phantom Menace
    2002-05-16 Attack of the Clones
    2005-05-19 Revenge of the Sith
    

为了运行此活动,请转到 packt.link/GR27A 并在 static void Main(string[] args) 体中注释掉所有行,除了 await Activities.Activity01.Demo.Run();

注意

此活动的解决方案可以在 packt.link/qclbF 找到。

以这种方式重用 HttpClient 非常有用,因为它消除了代码重复。然而,调用 Web API 并消除重复代码是一个常见问题,并且可能通过某些库以某种方式解决。下一节将探讨如何使用两个流行的 NuGet 包简化对 Web API 的调用:

  • RestSharp

  • Refit

RestSharp

RestSharp 的理念与基 HttpClient 非常相似——减少代码重复。它简化了请求的创建,并为进行 HTTP 调用提供了很多实用工具。使用 RestSharp 重新做 StarWarsClient,但首先,您需要安装 RestSharp NuGet:

dotnet add package RestSharp

现在创建一个与您在 活动 8.01 中创建的非常相似的客户端:

    public class StarWarsClient
    {
        private readonly RestClient _client;
        public StarWarsClient()
        {
            _client = new RestClient("https://swapi.dev/api/");
        }

创建 RestSharp 后,您将获得一个内置的响应序列化。它还能够猜测您将使用哪个 HTTP 方法:

        public async Task<ApiResult<IEnumerable<Film>>> GetFilms()
        {
            var request = new RestRequest("films");
            var films = await _client.GetAsync<ApiResult<IEnumerable<Film>>>(request);
            return films;
        }
    }

您已经通过了制作 HTTP 请求所需的最小信息(调用电影,返回 ApiResult<IEnumerable<Film>>),其余的工作已经完成。这非常类似于您之前编写的基客户端。

注意

ApiResult 是与 练习 8.02 中相同的类型。

然而,如果您在您的演示中运行此代码,您会注意到 JSON 的 Data 属性返回为 null。这是因为您在 responsefilm 类上有一个 JsonProperty 属性。RestSharp 使用不同的序列化器,它不知道这些属性。为了使其工作,您可以将所有属性更改为 RestSharp 可以理解的形式,或者使用之前的相同序列化器。您正在使用 Newtonsoft.Json,为了在 RestSharp 中使用它,您需要调用 UseSerializer 方法,选择 JsonNetSerializer

        public StarWarsClient()
        {
            _client = new RestClient("https://swapi.dev/api/");
            _client.UseSerializer(() => new JsonNetSerializer());
        }

在运行演示时,以下输出会显示:

1977-05-25 A New Hope
1980-05-17 The Empire Strikes Back
1983-05-25 Return of the Jedi
1999-05-19 The Phantom Menace
2002-05-16 Attack of the Clones
2005-05-19 Revenge of the Sith

结果与 练习 8.02 中的结果相同;然而,区别在于前一个示例中使用了 Newtonsoft 序列化器。RestSharp 可能是 HttpClient 的最佳抽象,因为它最小化了您需要编写的代码量来执行 HTTP 调用,同时保持了与 HttpClient 的相似性。

注意

您可以在 packt.link/f5vVG 找到用于此示例的代码。

此示例旨在通过 HTTP 请求与 Web API 进行通信。尽管演示文件看起来相同,但它们使用的是不同的库或设计模式。在接下来的活动中,您将练习使用 RestSharp 消费更多 API。

活动八.02:使用 RestSharp 列出所有国家的国家 API

地址restcountries.com/v3/是一个公开的 Web API,它提供了所有现有国家的列表。假设您需要使用该 API 获取所有国家的列表,通过首都(例如,维尔纽斯)查找国家,以及查找说给定语言(例如,立陶宛语)的所有国家。您需要只打印前两个国家的名称、它们所在的地区和首都,并实现一个强类型客户端来使用RestSharp访问此 API。

本活动的目的是让您在使用第三方库(RestSharp)进行 HTTP 调用时感到更加舒适。使用第三方库通常可以节省大量时间。它允许您重用已经存在的东西。

执行以下步骤以完成此活动:

  1. 使用restcountries.com/v3/的 URL 创建一个基本客户端类。

    注意

    导航到restcountries.com/v3/将返回 HTTP 状态码404,并显示“页面未找到”消息。这是因为基本 API URI 不包含任何关于资源的信息;它尚未完成,只是资源完整 URI 的开始。

  2. 创建用于序列化的模型。

  3. 使用示例restcountries.com/v3/name/peru来获取响应。

  4. 复制响应,然后使用类生成器,例如json2csharp.com/,将 JSON(响应)转换为模型。

  5. 在客户端中创建以下方法:GetGetByCapitalGetByLanguage

  6. 创建一个调用所有三个方法的演示。

  7. 打印每个响应中的国家。

结果应该是这样的:

All:
Aruba Americas Oranjestad
Afghanistan Asia Kabul
Lithuanian:
Lithuania Europe Vilnius
Vilnius:
Lithuania Europe Vilnius

注意

本活动的解决方案可以在packt.link/qclbF找到。

您现在知道 RestSharp 简化了请求的创建,并为进行 HTTP 调用提供了许多实用工具。下一节将帮助您练习使用 Refit,这是另一种消费 API 的方式。

Refit

Refit 是最智能的客户端抽象,因为它从一个接口生成客户端。您需要做的只是提供一个抽象:

  1. 要使用Refit库,首先安装Refit NuGet:

    dotnet add package Refit
    
  2. 要在 Refit 中创建客户端,首先创建一个具有 HTTP 方法的接口:

    public interface IStarWarsClient
    {
        [Get("/films")]
        public Task<ApiResult<IEnumerable<Film>>> GetFilms();
    }
    

请注意,这里的端点是/films而不是films。如果您用films运行代码,您将得到一个异常,建议您使用前面的/更改端点。

  1. 要解析客户端,只需运行以下代码:

    var client = RestService.For<IStarWarsClient>("https://swapi.dev/api/");
    

在运行演示时,以下输出将显示:

1977-05-25 A New Hope
1980-05-17 The Empire Strikes Back
1983-05-25 Return of the Jedi
1999-05-19 The Phantom Menace
2002-05-16 Attack of the Clones
2005-05-19 Revenge of the Sith

结果与您在练习 8.02中看到的结果相同;然而,区别在于实现方式。

注意

您可以在packt.link/cqkH5找到用于此示例的代码。

仅当你的场景非常简单时才使用 Refit。尽管 Refit 可能看起来是最简单的解决方案,但在需要为更复杂的场景进行自定义授权时,它也会带来自己的复杂性。你将在接下来的活动中进一步简化解决方案。

活动 8.03:使用 Refit 列出所有国家的国家 API

你知道执行同一任务的不同方法越多,你做出选择并挑选最适合的工具就越容易。不同的团队可能会使用不同的工具,Refit 是一种相当独特、简约的方法,你可能会遇到。其他人可能会说它使工作复杂化,因为客户端界面中隐藏了太多东西(代码越少并不意味着你可以轻松掌握代码)。无论你是否支持 Refit 或反对它,亲自实践并形成自己的观点都是好的。这个活动将帮助你做到这一点。在这里,你将访问国家 API 来显示所有国家、按语言划分的国家以及按首都划分的国家。

这个活动的目的是展示 Refit 在消费简单 API 时如何适用于快速原型设计。以下是这些步骤:

  1. 创建用于序列化的模型。为此,使用示例 restcountries.com/v3/name/peru 获取响应。

  2. 现在复制响应。

  3. 然后使用一个类生成器,例如 json2csharp.com/,从 JSON(响应)中创建模型。

  4. 定义一个具有以下方法的接口:GetGetByCapitalGetByLanguage

  5. 创建一个打印国家名称、地区和国家状态的演示。

结果将如下显示:

All:
Aruba Americas Oranjestad
Afghanistan Asia Kabul
Lithuanian:
Lithuania Europe Vilnius
Vilnius:
Lithuania Europe Vilnius

注意

这个活动的解决方案可以在 packt.link/qclbF 找到。

.NET 有几种创建 HTTP 请求的本地方法,为此,你可以使用 HttpWebRequestWebClient。这两个方法尚未被弃用,使用它们是可以的,但与较新的 HttpClient 相比,它们是较旧的替代方案。下一节将涵盖所有这些内容。

在以下部分,你将了解到其他一些库,它们可以解决在使用 HttpClient 时代码重复的问题。

其他创建 HTTP 请求的方法

Refit 和 RestSharp 只是众多解决在使用 HttpClient 时代码重复问题的库中的两个。Flurl 和 TinyRest 是另外两个流行的替代方案。每年都会创建新的库,并且它们在不断发展。没有一种适合所有场景的最佳方法。为了确保你做出正确的选择,你首先需要进行一些研究,因为对这些替代方案有一些潜在的问题需要考虑。

HttpClient 是为 .NET 中的最低级 HTTP 调用而设计的。它是最安全的选项,因为它有良好的文档记录,经过测试,并允许最大的自由度。尽管有许多库比 HttpClient 更容易使用,但它们通常针对基本场景(无授权,无动态设置的头)。当涉及到创建高级 HTTP 调用时,它们往往变得相当复杂。

当涉及到选择使用哪个客户端时,首先选择 API 本地提供的客户端。如果没有为 API 提供客户端,考虑你项目的复杂性和范围。对于简单、范围较小的项目,使用你找到的最方便的 NuGet HttpClient 替代方案。但如果项目的范围很大且调用复杂,请使用框架提供的本地 HttpClient

在下一个练习中,你将实现一个示例,使用 Refit 将其转变为一个复杂问题。为了解决这个问题,你将同时使用 HttpClient 和 RestSharp。

练习 8.03:用于在 PayPal 沙盒中测试支付的强类型 HTTP 客户端

编程中常见的场景是进行支付。然而,在开发阶段,你不想使用真实的银行账户,因此寻找在测试环境中处理支付的方法——即沙盒。在这个练习中,你将学习如何调用支付沙盒 API。你将使用 PayPal 的沙盒 API (developer.paypal.com/docs/api/orders/v2/) 创建订单并获取你创建的订单。

这个练习将使用 Refit 作为客户端接口和实现解析。它还将使用 HttpClient 为 Refit 提供获取 auth 标头的途径。最后,你将使用 RestSharp 从 HttpClient 内部获取访问令牌。按照以下步骤完成此练习:

  1. 前往 www.paypal.com/tt/webapps/mpp/account-selection

  2. 创建一个 PayPal 账户(个人或商业)。

  3. 选择你的位置并点击 Get Started 按钮。

  4. 提供你的手机号码。

  5. 点击 Next 按钮,并输入代码。

  6. 通过输入电子邮件地址和密码设置你的个人资料。

  7. 提供你的地址详情。

  8. 现在链接你的信用卡或借记卡。你也可以通过遵循 www.paypal.com/tt/webapps/mpp/account-selection 提供的说明免费完成此操作。

    注意

    在 PayPal 上创建账户是免费的。链接信用卡(或借记卡)的要求只是账户创建的一部分,并且不会收取费用。一旦身份验证确认,支付就会退款。

  9. 现在退出账户并前往 developer.paypal.com/developer/accounts/

  10. 点击 Log in to Dashboard 按钮,继续操作:

图 8.21:登录 PayPal 控制面板以管理沙盒和实时环境

图 8.21:登录 PayPal 控制面板以管理沙盒和实时环境

  1. 然后输入所需的凭据,并转到下一屏幕。

  2. Sandbox选项下点击Accounts选项。您将看到为您创建的两个测试账户:

图 8.22:用于测试的沙盒 PayPal 账户

图 8.22:用于测试的沙盒 PayPal 账户

您将使用这些账户在下一步中进行测试。

注意

PayPal 沙盒是免费的。

  1. 前往developer.paypal.com/developer/applications获取您的客户端 ID 和密钥。就像 GitHub 示例一样,PayPal 使用 OAuth 应用为您提供客户端 ID 和密钥。

  2. 对于默认账户之一,PayPal 还会生成一个默认的 OAuth 应用。因此,点击Sandbox选项卡并选择Default Application

图 8.23:为 PayPal 创建 OAuth 应用

图 8.23:为 PayPal 创建 OAuth 应用

  1. 在新窗口中,检查Client IDSecret

  2. 注意这两者并将它们存储在环境变量中:

图 8.24:显示 Client ID 和 Secret 的默认应用程序详情

图 8.24:显示 Client ID 和 Secret 的默认应用程序详情

  1. 在一个新空类Exercise03.AuthHeaderHandler.cs中创建用于访问 PayPal 客户端 ID 和密钥的属性:

    public static string PayPalClientId { get; } = EnvironmentVariable.GetOrThrow("PayPalClientId");
    public static string PayPalSecret { get; } = EnvironmentVariable.GetOrThrow("PayPalSecret");
    

这里使用了EnvironmentVariable.GetOrThrow辅助方法来获取用户的环镜变量,如果不存在则抛出异常。您将使用这些属性来连接到沙盒 PayPal API。

注意

您可以在packt.link/y2MCy找到用于环境变量的代码。

  1. Demo.cs类中,添加一个const变量用于 PayPal 沙盒的BaseAddress

    public const string BaseAddress = "https://api.sandbox.paypal.com/";
    

BaseAddress将用于初始化具有 PayPal URL 的不同客户端(RestSharp 和 Refit)。

  1. 使用Refit创建一个具有CreateOrderGetOrder方法的客户端:

    public interface IPayPalClient
    {
        [Post("/v2/checkout/orders")]
        public Task<CreatedOrderResponse> CreateOrder(Order order);
        [Get("/v2/checkout/orders/{id}")]
        public Task<Order> GetOrder(string id);
    }
    

要获取示例请求,请参考您想要调用的 API 的文档。通常,它们都有一个示例请求。在这种情况下,PayPal 的 CreateOrder 请求可以在developer.paypal.com/docs/api/orders/v2/找到:

{
   "intent":"CAPTURE",
   "purchase_units":[
      {
         "amount":{
            "currency_code":"USD",
            "value":"100.00"
         }
      }
   ]
}

图 8.25:带有突出显示的 PayPal CreateOrder 请求体的示例请求

图 8.25:带有突出显示的 PayPal CreateOrder 请求体的示例请求

图 8.25中,-d是一个参数,不属于请求体。

  1. 使用json2csharp.com/生成 C# 类,从 JSON 中生成相应的 C# 类。

  2. RootObject重命名为Order,并将所有类更改为record类型,因为这对于 DTO 是一个更合适的类型:

    IPayPalClient.cs
    public record Order
    {
        public string intent { get; set; }
        public Purchase_Units[] purchase_units { get; set; }
    }
    public record Name
    {
        public string name { get; set; }
    }
    public record Purchase_Units
    {
        public Amount amount { get; set; }
        public Payee payee { get; set; }
    
The complete code can be found here: https://packt.link/GvEZ8.
  1. 使用相同的 PayPal 文档(developer.paypal.com/docs/api/orders/v2/),复制示例响应:

    {
        "id": "7XS70547FW3652617",
        "intent": "CAPTURE",
        "status": "CREATED",
        "purchase_units": [
            {
                "reference_id": "default",
                "amount": {
                    "currency_code": "USD",
                    "value": "100.00"
                },
                "payee": {
                    "email_address": "sb-emttb7510335@business.example.com",
                    "merchant_id": "7LSF4RYZLRB96"
                }
            }
        ],
        "create_time": "2021-09-04T13:01:34Z",
        "links": [
            {
                "href": "https://api.sandbox.paypal.com/v2/checkout/orders/7XS70547FW3652617",
                "rel": "self",
                "method": "GET"
            }
         ]
    }
    
  2. 使用json2csharp.com/从 JSON 生成 C#类。在这里,你会得到与请求 JSON 非常相似的类。唯一的区别是响应(为了简洁而简化):

    public class CreateOrderResponse
    {
        public string id { get; set; }
    }
    
  3. 使用AuthHeaderHandler在请求时获取访问令牌,并确保它继承自DelegatingHandler

    public class AuthHeaderHandler : DelegatingHandler
    {
    

要调用 PayPal,你需要在每个请求中包含auth头。auth头的值是从另一个端点检索的。Refit 不能随意添加头。然而,你可以使用自定义HttpClient和自定义HttpMessageHandler设置 Refit,该处理器在每次请求时都会获取访问令牌。AuthHeaderHandler就是为此而使用的。

DelegatingHandler是一个允许在发送HttpRequest时拦截它,并在发送前后执行某些操作的类。在这种情况下,在发送 HTTP 请求之前,你将获取auth头并将其添加到发送的请求中。

  1. 现在,通过向AuthenticationHeader添加 bearer 令牌来覆盖SendRequest

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
                    var accessToken = await GetAccessToken(CreateBasicAuthToken());
                    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
                    return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
    }
    
  2. 要获取访问令牌,你首先需要使用基本的auth(客户端 ID 和密钥)获取 OAuth 令牌:

     private static string CreateBasicAuthToken()
          {
                    var credentials = Encoding.GetEncoding("ISO-8859-1").GetBytes(PayPalClientId + ":" + PayPalSecret);
                    var authHeader = Convert.ToBase64String(credentials);
                    return "Basic " + authHeader;
          }
    
  3. 获取访问令牌需要auth令牌。使用RestSharp客户端并在请求中添加Authorization头。

  4. 接下来,根据 PayPal API 规范将content-type设置为application/x-www-form-urlencoded

  5. 按如下方式添加正文内容grant_type=client_credentials

                private static async Task<string> GetAccessToken(string authToken)
                {
                    var request = new RestRequest("v1/oauth2/token");
                    request.AddHeader("Authorization", authToken);
                    request.AddHeader("content-type", "application/x-www-form-urlencoded");
                    request.AddParameter("application/x-www-form-urlencoded", "grant_type=client_credentials", ParameterType.RequestBody);
    
  6. 使用私有嵌套类Response执行前面的请求并返回响应,以简化你的工作:

                    var response = await RestClient.ExecuteAsync<Response>(request, Method.POST);
                    return response.Data.access_token;
                }
            private class Response
            {
                public string access_token { get; set; }
            }
          }
    

为什么需要嵌套类?在这里,访问令牌嵌套在响应中。它返回的不仅仅是一个字符串,而是一个对象。自己从 JSON 解析它可能会稍微复杂一些。然而,你已经知道如何反序列化对象。所以,即使只有一个属性,反序列化仍然有帮助。

  1. 现在,为GetAccessToken方法创建RestClient。在AuthHandler类中这样做:

    private static readonly RestClient RestClient = new RestClient(baseAddress);
    
  2. Demo类中创建Run方法:

    public static async Task Run()
            	{
    
  3. 使用自定义AuthHeaderHandler提供程序解析Refit客户端:

                var authHandler = new AuthHeaderHandler {InnerHandler = new HttpClientHandler() };
                var payPalClient = RestService.For<IPayPalClient>(new HttpClient(authHandler)
                    {
                        BaseAddress = new Uri(baseAddress)
                    });
    
  4. 假设通过创建Order对象进行了支付,运行以下代码:

    var order = new Order
                {
                    intent = "CAPTURE",
                    purchase_units = new[]
                    {
                        new Purchase_Units
                        {
                            amount = new Amount
                            {
                                currency_code = "EUR", value = "100.00"
                            }
                        }
                    }
                };
    
  5. 现在,调用 PayPal API 并创建一个带有你刚刚创建的订单的订单端点。

  6. 获取创建的订单以查看它是否工作,并打印检索到的订单支付信息:

    var createOrderResponse = await payPalClient.CreateOrder(order);
    var payment = await payPalClient.GetOrder(createOrderResponse.id);
    var pay = payment.purchase_units.First();
    Console.WriteLine($"{pay.payee.email_address} - " +
                                  $"{pay.amount.value}" +
                                  $"{pay.amount.currency_code}");
    

在设置正确的环境变量后,你应该看到以下输出:

sb-emttb7510335@business.example.com - 100.00EUR

如前所述,这是一个沙盒 API。然而,切换到使用真实货币的实时环境只需在该环境中设置新的 PayPal 账户并调用不同的端点:api-m.paypal.com

注意

您将无法访问 api-m.paypal.com,因为它用于生产 PayPal,并且是付费的。然而,当您准备好进行与 PayPal 的实际集成时,代码中应该只有一处变化(不同的基本 URI)。

请确保您已设置环境变量,并使用您自己的客户端和密钥。否则,可能会显示一些未处理的异常错误。

注意

你可以在 packt.link/cFRq6 找到用于此练习的代码。

你现在已经知道如何使用 Web API 进行简单的 CRUD 操作。然而,到目前为止你只处理过文本。那么,调用带有图像的 API 会有所不同吗?在下一个活动中找出答案。

活动 8.04:使用 Azure Blob 存储客户端上传和下载文件

Azure Blob Storage 是 Azure 上的一个云服务,用于存储不同的文件(日志、图像、音乐和整个驱动器)。在您可以使用任何 Azure 存储服务之前,您将需要一个存储帐户。Blob 只是文件,但它们不能直接存储在帐户中;相反,它们需要一个容器。

Azure 存储容器就像一个目录,其中存储着其他文件。然而,与目录不同的是,容器不能包含其他容器。使用 Azure 存储帐户创建两个容器,上传一个图像和一个文本文件,然后在本地上传的文件。所有这些都将在你自己的客户端中完成,该客户端围绕 Azure Blob 存储客户端。

本活动的目的是熟悉通过云存储处理文件,同时将您迄今为止所学的一切应用到实践中。执行以下步骤以完成此活动:

  1. 导航到 Azure 存储帐户

  2. 创建一个新的 Azure 存储帐户。

  3. 将 blob 存储访问密钥存储在名为 BlobStorageKey 的环境变量中。

  4. 安装 Azure Blob Storage 客户端。

  5. 创建 FilesClient 类以存储 blob 客户端和默认容器客户端的字段(blob 将默认存储在此容器中)。

  6. 创建一个构造函数来初始化两个客户端(以支持访问不同的容器)。

  7. 添加一个方法来创建容器或获取已存在的容器。

  8. 创建一个方法来将文件上传到特定的容器。

  9. 创建一个方法来从特定的容器中下载文件。

  10. 创建一个 Demo 类,包含下载和上传目录的路径。

  11. 添加测试数据,即两个文件——即一个图像和一个文本文件(图 8.26图 8.27图 8.28):

图 8.26:您的存储帐户中的两个 Azure 存储容器,exercise04 和 exercise04b

图 8.26:您的存储帐户中的两个 Azure 存储容器,exercise04 和 exercise04b

文本文件:

图 8.27:练习 04 容器中上传的 Test1.txt 文件

图 8.27:练习 04 容器中上传的 Test1.txt 文件

图像文件:

图 8.28:exercise04b 容器中上传的 Morning.jpg 文件

图 8.28:exercise04b 容器中上传的 Morning.jpg 文件

  1. 创建名为 Run 的方法以上传文本文件,然后将其本地下载。

  2. 运行代码。如果你一切都做对了,你应该会看到以下输出,两个文件都已本地下载:

图 8.29:演示代码执行后从两个容器中下载的 Morning.jpg 和 Test1.txt 文件

图 8.29:演示代码执行后从两个容器中下载的 Morning.jpg 和 Test1.txt 文件

注意

该活动的解决方案可在 packt.link/qclbF 找到。

几乎不可能创建一个适合所有人的完美客户端。因此,即使有人给你提供了一个问题的解决方案,你通常仍然需要进一步抽象它,以适应解决你确切的问题。你遇到的问题是上传和下载特定文件夹中的文件。为了解决这个问题,你抽象出多层客户端,只暴露两个函数——一个用于上传文件,另一个用于下载文件。

摘要

无论你是什么类型的程序员,都会有许多你必须消费网络服务的场景。在线上有不同种类的服务,但最常见的一种是 RESTful。REST 只是一组指南,因此不应与 HTTP 混淆。REST API 简单、自文档化、结构良好,目前是 Web API 的黄金标准。然而,在大多数情况下,在 RESTful API 的上下文中,请求是通过 HTTP 发送的,你的消息包含 JSON。

使用 C# 进行 HTTP 调用的主要工具是 HttpClient,然而,在你尝试自己实现 HTTP 调用之前,你应该寻找你试图消费的 Web API 的 NuGet 包。Azure Blob 存储空间、Azure 文本分析、PayPal 和 GitHub 只是 Web API 的几个例子。

在本章中,你了解了许多为你完成的网络功能。消费起来并不困难;你现在需要知道的是如何与第三方 RESTful 网络 API 进行通信。在下一章中,你将学习如何使用 ASP.NET Core Web API 模板创建自己的 RESTful 网络服务,同时还将介绍 Azure Functions 和特殊的工具 Swagger 以及 NuGet。

第九章:9. 创建 API 服务

概述

在现代软件开发中,大多数逻辑都是通过不同的 Web 服务来提供的。这对于作为开发者能够调用和创建新的 Web 服务至关重要。

在本章中,你将使用 ASP.NET Core Web API 模板创建自己的 RESTful Web 服务。你不仅将学习如何做到这一点,还将了解设计和管理 Web API 的一些最佳实践。你还将学习如何使用 Azure Active Directory (AAD)保护 API、集中处理错误、调试错误、生成文档等。

到本章结束时,你将能够创建专业的、使用 AAD 保护的、托管在云上、可扩展并能服务数千用户的 Web API。

简介

ASP.NET Core 是.NET Core 框架的一部分,旨在创建 Web 应用程序。使用它,你可以创建前端(如 Razor 或 Blazor)和后端(如 Web API 或 gRPC)应用程序。然而,在本章中,你将专注于创建 RESTful Web API。第一次创建新的 Web 服务可能听起来是一项艰巨的任务,但不必过于担心;对于大多数场景,都有一个模板可以帮助你开始。在本章中,你将使用 ASP.NET Core 6.0 创建几个 Web API。

ASP.NET Core Web API

第八章创建和使用 Web API 客户端中,你学习了如何调用 RESTful API。在本章中,你将创建一个。Web API 是创建.NET 中 RESTful Web API 的模板。它包含路由、依赖注入(DI)、示例控制器、日志记录和其他有用的组件,帮助你开始。

创建新项目

为了创建一个新的 Web API,请按照以下步骤操作:

  1. 创建一个新的目录。

  2. 根据你想要创建的项目来命名它。

  3. 使用cd命令导航到该目录。

  4. 在命令行中执行以下操作:

    dotnet new webapi
    

这就是开始所需的全部。

  1. 为了查看这是否按预期执行,运行以下命令并看到你的应用程序启动(图 9.1):

    dotnet run --urls=https://localhost:7021/
    

    图 9.1:显示应用程序托管端口的终端窗口

图 9.1:显示应用程序托管端口的终端窗口

图 9.1中,你将看到应用程序的https版本的 7021 端口。可能会有多个端口,特别是如果你同时托管了应用程序的HTTPHTTPs版本。然而,要记住的关键是你可以通过端口来识别应用程序的运行位置(例如,通过命令行)。

端口是通过它允许所有其他应用程序调用特定应用程序的通道。它是一个出现在基础 URL 之后的数字,它允许单个应用程序通过。这些应用程序不必是外部程序;同样的规则也适用于内部通信。

本地主机指的是本地托管的应用程序。在本章的后面部分,你将配置服务以绑定到你想要的任何端口。

注意

单台机器上有 65,535 个端口可用。从 0 到 1023 的端口被称为知名端口,因为通常系统的相同部分会监听这些端口。通常,如果单个应用程序托管在一台机器上,端口将是 80 用于 http 和 443 用于 https。如果您托管多个应用程序,端口将会有很大差异(通常从端口 1024 开始)。

Web API 项目结构

每个 Web API 至少由两个类组成——Program 和一个或多个控制器(在这个例子中是 WeatherForecastController):

  • 程序:这是应用程序的起点。它作为应用程序的低级运行者并管理依赖项。

  • 控制器:这是一个 [Model]Controller。在这个例子中,WeatherForecastController 将通过 /weatherforecast 端点被调用。

图 9.2:在 VS Code 中新创建的 MyProject 结构,突出显示关键部分

图 9.2:在 VS Code 中新创建的 MyProject 结构,突出显示关键部分

深入了解 WeatherForecastController

默认模板中的控制器前面有两个属性:

  • [ApiController]:此属性添加了常见的、方便的(但具有观点)Web API 功能。

  • [Route("[controller]")]:此属性用于提供给定控制器的路由模式。

例如,在这些属性不存在或请求复杂的情况下,您需要自己验证传入的 HTTP 请求,而不需要默认的路由:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{

此控制器以 /WeatherForecast 作为路由。路由通常由位于 Controller 之前的单词组成,除非有其他指定。在专业开发 API 或拥有客户端和服务器端应用程序时,建议在路由前预先添加 /api,使其成为 [Route("api/[controller]")]

接下来,您将学习控制器类的声明。常见的控制器功能来自派生的 ControllerBase 类和一些组件(通常是一个日志记录器)以及服务。这里唯一有趣的部分是,您使用 ILogger<WeatherForecastController> 而不是 Ilogger

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

使用泛型部分的原因纯粹是为了从日志被调用的地方获取上下文。使用日志记录器的泛型版本,您使用作为泛型参数提供的类的完全限定名称。调用 logger.Log 将在其前面加上上下文;在这种情况下,它将是 Chapter09.Service.Controllers.WeatherForecastController[0]

最后,看看以下控制器方法:

    [HttpGet]
    public IEnumerable<WeatherForecast> Get()
    {
        return new List<WeatherForecast>(){new WeatherForecast()};
    }
}

[HttpGet] 属性将 Get 方法与根控制器端点(/WeatherForecast)的 HTTP GET 方法绑定。对于每个 HTTP 方法都有一个该属性的版本,它们是 HttpGetHttpPostHttpPatchHttpPutHttpDelete。要检查服务是否正常工作,请使用以下命令运行应用程序:

dotnet run --urls=https://localhost:7021/

这里,-urls=https://localhost:7021/ 参数不是必需的。这个参数只是确保.NET 选择的端口与在执行此示例时指示的相同。

要查看输出,请在浏览器中导航到 https://localhost:7021/weatherforecast/。这将返回在调用 HTTP GET 时一个默认的 WeatherForecast

[{"date":"0001-01-01T00:00:00","temperatureC":0,"temperatureF":32,"summary":null}].

注意

https://localhost:7021/weatherforecast/ 显示错误消息(localhost refused to connect)时,这意味着应用程序可能正在运行,但端口不同。所以,始终记得按照 创建新项目 部分中描述的方式指定端口(步骤 5)。

以不同的状态码响应

查找 public IEnumerable<WeatherForecast> Get() 可以响应哪些状态码。使用以下步骤,你可以对其进行操作并在浏览器中检查发生了什么:

  1. 在浏览器中导航到 https://localhost:7021/weatherforecast/

  2. 点击 更多工具

  3. 选择 开发者工具 选项。或者,你可以使用 F12 键来启动开发者工具。

  4. 接下来,点击 网络 标签。

  5. 点击 头部 标签。你会看到 https://localhost:7021/weatherforecast/ 返回 200 状态码

图 9.3:开发者工具网络标签页——检查成功响应的响应头

图 9.3:开发者工具网络标签页——检查成功响应的响应头

  1. 创建一个名为 GetError 的新端点,如果在程序运行期间出现罕见情况,它会抛出异常:

            [HttpGet("error")]
            public IEnumerable<WeatherForecast> GetError()
            {
                throw new Exception("Something went wrong");
            }
    
  2. 现在,调用 https://localhost:7021/weatherforecast/error。它返回状态码为 500

图 9.4:开发者工具网络标签页——检查带有异常的响应

图 9.4:开发者工具网络标签页——检查带有异常的响应

如果你想返回不同的状态码,应该怎么做?为此,BaseController 类包含用于返回所需任何类型状态码的实用方法。例如,如果你想显式返回一个 OK 响应,而不是立即返回一个值,你可以返回 Ok(value)。然而,如果你尝试更改代码,你会得到以下错误:

Cannot implicitly convert type 'Microsoft.AspNetCore.Mvc.OkObjectResult' to 'Chapter09.Service.Models.WeatherForecast'

这不起作用,因为你没有从控制器返回 HTTP 状态码;你要么返回某个值,要么抛出某个错误。要返回你选择的任何状态码,你需要更改返回类型。因此,控制器永远不应该有某种值的返回类型。它应该始终返回 IActionResult 类型——一个支持所有状态码的类型。

创建一个获取任何一周中任何一天天气的方法。如果找不到该天(小于 1 或大于 7 的值),你将显式返回 404 – not found

[HttpGet("weekday/{day}")]
public IActionResult GetWeekday(int day)
{
    if (day < 1 || day > 7)
    {
        return NotFound($"'{day}' is not a valid day of a week.");
    }
    return Ok(new WeatherForecast());
}

在这里,你在端点末尾添加了一个新的 {day}。这是一个占位符值,来自匹配的函数参数(在这种情况下,day)。重新运行服务并导航到 https://localhost:7021/weatherforecast/weekday/8 将导致 404 – not found 状态码,因为它超过了最大允许的天数值,即 7

图 9.5:寻找不存在的一周中的天气预报的响应

图 9.5:寻找不存在的一周中的天气预报的响应

注意

你可以在 packt.link/SCudR 找到用于此示例的代码。

这结束了本主题的理论部分。在接下来的部分,你将通过练习将其付诸实践。

练习 9.01:.NET Core 当前时间服务

一旦你成功运行了一个 Web API,添加新的控制器应该很简单。通常,检查一个服务是否在运行,最基本的方法是检查它是否返回 OK 或获取当前的 DateTime 值。在这个练习中,你将创建一个简单的当前时间服务,返回 ISO 标准的当前时间。执行以下步骤来完成此操作:

  1. 创建一个新的控制器 TimeController 来获取本地时间,并进一步添加用于测试的功能:

        [ApiController]
        [Route("[controller]")]
        public class TimeController : ControllerBase
        {
    

这里显示的控制器不仅仅用于测试;它还充当业务逻辑。

  1. 添加一个名为 GetCurrentTime 的 HTTP GET 端点,指向 time/current 路由。你将使用它来获取当前时间:

            [HttpGet("current")]
            public IActionResult GetCurrentTime()
            {
    
  2. 将当前的 DateTime 转换为 ISO 格式的字符串:

                return Ok(DateTime.Now.ToString("o"));
            }
        }
    
  3. 导航到 https://localhost:7021/time/current,你应该看到以下响应:

    2022-07-30T15:06:28.4924356+03:00
    

Web API 项目结构 部分所述,你可以使用端点来确定服务是否正在运行。如果它在运行,那么你会得到 DateTime 值,这在前面的输出中已经看到。如果它没有运行,那么你会得到一个状态码为 404 – not found 的响应。如果它在运行但存在问题,那么你会得到 500 状态码。

注意

你可以在 packt.link/OzaTd 找到用于此练习的代码。

到目前为止,你所有的关注点都在控制器上。现在是时候将你的注意力转移到 Web API 的另一个关键部分——Program 类上。

Web API 的引导

Program 类将整个 API 连接在一起。用通俗易懂的话来说,你注册了所有控制器使用的抽象实现,并添加了所有必要的中间件。

依赖注入

第二章构建面向对象的高质量代码 中,你探讨了依赖注入(DI)的概念。在 第七章使用 ASP.NET 创建现代 Web 应用程序 中,你看到了一个用于日志服务的 DI 示例。在本章中,你将获得在依赖注入(DI)和反转控制(IoC)容器方面的实践经验——这是一个用于在中央位置连接和解决所有依赖关系的组件。在 .NET Core 及其后续版本中,默认容器是 Microsoft.Extensions.DependencyInjection。你将在稍后了解更多关于它的内容。

Program.cs 和最小 API

.NET 6 中最简单的 Web API 看起来是这样的:

// Inject dependencies (DI)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Add middleware
var app = builder.Build();
if (builder.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
app.MapControllers();
app.Run();

这是一个最小 API,因为它使用了顶层语句功能。在 .NET 6 之前,你会在 Startup 类(ConfigureConfigureService)和 Program 类中找到两个方法。现在你只有一个文件,Program.cs,没有类或方法。你仍然可以使用旧的方式启动应用程序。实际上,.NET 6 将在底层生成类似的类。然而,如果你正在使用 .NET 6 创建新应用程序,那么使用最小 API 应该是首选的。

分析前面的代码片段。要启动应用程序,你首先需要构建它。因此,你将使用以下代码行创建一个构建器:

var builder = WebApplication.CreateBuilder(args);

builder.Services 指定要注入哪些服务。在这种情况下,你注册了控制器的实现。因此,这里只有一个控制器被调用——即 WeatherForecastController

builder.Services.AddControllers();

当你使用 builder.Build() 时,你可以访问 app 对象,并通过添加中间件进一步配置应用程序。例如,要添加控制器路由,请调用以下代码:

app.MapControllers();

最后,builder.Environment.IsDevelopment() 检查环境是否为开发环境。如果是开发环境,它将调用 app.UseDeveloperExceptionPage();,在失败时添加详细错误。

日志在任何地方都没有被提及;但你仍然在使用它。一个常见的模式是将所有相关的注入分组在 IServiceCollection 的同一个扩展方法下。所有与控制器相关的功能,包括日志,的扩展方法示例是 AddControllers 方法。

你已经在运行 API 后立即看到了通过控制台日志发送的日志消息。在底层,调用了 builder.Services.AddLogging 方法。此方法清除所有日志提供程序:

builder.Services.AddLogging(builder =>
{
    builder.ClearProviders();
});

如果你现在运行应用程序,你将不会在控制台(图 9.6)中看到任何内容:

图 9.6:运行不显示日志的应用程序

图 9.6:运行不显示日志的应用程序

然而,如果你将 AddLogging 修改为包含 ConsoleDebug 日志,如下所示,你将看到如图 9.7 所示的日志:

builder.Services.AddLogging(builder =>
{
    builder.ClearProviders();
    builder.AddConsole();
    builder.AddDebug();
});

现在,向 WeatherForecastController 的错误端点添加错误日志功能。当程序运行时出现罕见情况时,这将抛出异常:

[HttpGet("error")]
public IEnumerable<WeatherForecast> GetError()
{
    _logger.LogError("Whoops");
    throw new Exception("Something went wrong");
}

使用以下命令重启 API:

dotnet run --urls=https://localhost:7021/

现在,调用https://localhost:7021/weatherforecast/error,这将显示记录的消息(比较图 9.6图 9.7):

图 9.7:在终端上显示的错误消息,哎呀

图 9.7:在终端上显示的错误消息,哎呀

AddLogging方法的工作原理

AddLogging方法是如何工作的?AddLogging方法的反编译代码如下所示:

services.AddSingleton<ILoggerFactory, LoggerFactory>();

最好不要自己初始化日志记录器。ILoggerFactory提供了一个功能,可以从一个单一的位置创建日志记录器。虽然ILoggerFactory是一个接口,但LoggerFactory是此接口的实现。AddSingleton是一个方法,指定将创建并使用单个LoggerFactory实例,每当引用ILoggerFactory时。

现在问题来了:为什么在控制器中没有使用ILoggerFactory?在解析控制器实现时,ILoggerFactory在幕后使用。当公开控制器依赖项,如logger时,你不再需要关心它是如何初始化的。这是一个巨大的好处,因为它使得持有依赖项的类既更简单又更灵活。

如果你确实想使用ILoggerFactory而不是Ilogger,你可以有一个接受工厂的构造函数,如下所示:

public WeatherForecastController(ILoggerFactory logger)

然后,你可以用它来创建一个logger,如下所示:

_logger = logger.CreateLogger(typeof(WeatherForecastController).FullName);

这个后者的logger功能与前者相同。

本节讨论了在中央位置管理服务依赖的AddSingleton方法。继续下一节,使用依赖注入解决依赖复杂性。

注入组件的生命周期

AddSingleton方法很有用,因为复杂的应用程序有数百甚至数千个依赖项,通常跨不同组件共享。管理每个初始化都是一个相当大的挑战。依赖注入通过提供一个集中位置来管理依赖项及其生命周期来解决此问题。在继续前进之前,你需要了解更多关于依赖注入生命周期的知识。

.NET 中有三种注入对象的生命周期:

  • 单例:每次应用程序生命周期初始化一次对象

  • 范围:每次请求初始化一次对象

  • 临时:每次引用时初始化对象

为了更好地说明依赖注入和不同的服务生命周期,下一节将重构现有的WeatherForecastController代码。

服务中的依赖注入示例

服务是最高级别逻辑的持有者。控制器本身不应执行任何业务逻辑,而应将请求委托给能够处理它的其他对象。应用此原则,并使用依赖注入重构GetWeekday方法。

首先,为你要将所有逻辑移动到的服务创建一个接口。这样做是为了创建一个抽象,你将后来提供实现。需要一个抽象,因为你希望尽可能多地从控制器中移除逻辑:

public interface IWeatherForecastService
{
    WeatherForecast GetWeekday(int day);
}

当你将一部分代码从控制器移开时,你希望同时处理错误场景。在这种情况下,如果提供的日期不在 17 之间,你将返回一个 404 – not found 错误。然而,在服务级别,没有 HTTP 状态码的概念。因此,你将抛出一个异常,而不是返回一个 HTTP 消息。为了正确处理异常,你将创建一个名为 NoSuchWeekdayException 的自定义异常:

public class NoSuchWeekdayException : Exception
{
    public NoSuchWeekdayException(int day) 
        : base($"'{day}' is not a valid day of a week.") { }
}

接下来,创建一个实现服务的类。你将把你的代码移到这里:

public class WeatherForecastService : IWeatherForecastService
{
    public WeatherForecast GetWeekday(int day)
    {
        if (day < 1 || day > 7)
        {
            throw new NoSuchWeekdayException(day);
        }
        return new WeatherForecast();
    }
}

与之前的代码相比,这里唯一的区别是,你使用了 throw new NoSuchWeekdayException 而不是返回 NotFound

现在,将服务注入到控制器中:

private readonly IWeatherForecastService _weatherForecastService;
private readonly Ilogger _logger;
public WeatherForecastController(IloggerFactory logger, IWeatherForecastService weatherForecastService)
{
    _weatherForecastService = weatherForecastService;
    _logger = logger.CreateLogger(typeof(WeatherForecastController).FullName);
}

响应不同状态码 部分的清理后的控制器方法,具有最少的业务逻辑,现在看起来是这样的:

[HttpGet("weekday/{day}")]
public IActionResult GetWeekday(int day)
{
    try
    {
        var result = _weatherForecastService.GetWeekday(day);
        return Ok(result);
    }
    catch(NoSuchWeekdayException exception)
    {
        return NotFound(exception.Message);
    }
}

虽然看起来可能还是相同的代码;然而,关键点在于控制器不再执行任何业务逻辑。它只是将服务的结果映射回一个 HTTP 响应。

注意

错误处理 部分中,你将回到这里并进一步从控制器中移除代码,使其尽可能轻量。

如果你运行此代码,在调用控制器的任何端点时,你将得到以下异常:

Unable to resolve service for type 'Chapter09.Service.Examples.TemplateApi.Services.IweatherForecastService' while attempting to activate 'Chapter09.Service.Examples.TemplateApi.Controllers.WeatherForecastController'

这个异常表明 WeatherForecastController 无法确定 IWeatherForecastService 的实现。因此,你需要指定哪个实现适合所需的抽象。例如,这可以在 Program 类中如下完成:

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

AddSingleton 方法将此视为 IWeatherForecastServiceWeatherForecastService 实现。在接下来的段落中,你将了解它是如何工作的。

现在你有一个要注入的服务,你可以探索每次调用以下控制器方法时每个注入对服务调用的影响。为此,你将稍微修改 WeatherForecastServiceWeatherForecastController

WeatherForecastService 中,执行以下操作:

  1. 注入一个 logger:

            private readonly ILogger<WeatherForecastService> _logger;
            public WeatherForecastService(ILogger<WeatherForecastService> logger)
            {
                _logger = logger;
            }
    
  2. 当服务初始化时,记录一个随机的 Guid,将构造函数修改如下:

            public WeatherForecastService(ILogger<WeatherForecastService> logger)
            {
                _logger = logger;
                _logger.LogInformation(Guid.NewGuid().ToString());
            }
    

WeatherForecastController 中,执行以下操作:

  1. 注入 WeatherForecastService 的第二个实例:

        public class WeatherForecastController : ControllerBase
        {
            private readonly IWeatherForecastService _weatherForecastService1;
            private readonly IWeatherForecastService _weatherForecastService2;
            private readonly ILogger _logger;
            public WeatherForecastController(ILoggerFactory logger, IWeatherForecastService weatherForecastService1, IWeatherForecastService weatherForecastService2)
            {
                _weatherForecastService1 = weatherForecastService1;
                _weatherForecastService2 = weatherForecastService2;
                _logger = logger.CreateLogger(typeof(WeatherForecastController).FullName);
            }
    
  2. 在获取星期几时调用两个实例:

            [HttpGet("weekday/{day}")]
            public IActionResult GetWeekday(int day)
            {
                try
                {
                    var result = _weatherForecastService1.GetWeekday(day);
                    result = _weatherForecastService1.GetWeekday(day);
                    return Ok(result);
                }
                catch (NoSuchWeekdayException exception)
                {
                    return NotFound(exception.Message);
                }
            }
    

GetWeekday 方法被调用了两次,因为这有助于更好地说明依赖注入的生命周期。现在,是时候探索不同的依赖注入生命周期了。

单例

按以下方式在 Program.cs 中将服务注册为单例:

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

在调用应用程序后,你将看到以下日志在运行代码时生成:

info: Chapter09.Service.Services.WeatherForecastService[0]
      2b0c4e0c-97ff-4472-862a-b6326992d9a6
info: Chapter09.Service.Services.WeatherForecastService[0]
      2b0c4e0c-97ff-4472-862a-b6326992d9a6

如果你再次调用应用程序,你将看到相同的 GUID 已被记录:

info: Chapter09.Service.Services.WeatherForecastService[0]
      2b0c4e0c-97ff-4472-862a-b6326992d9a6
info: Chapter09.Service.Services.WeatherForecastService[0]
      2b0c4e0c-97ff-4472-862a-b6326992d9a6

这证明了服务只初始化了一次。

作用域

按以下方式在 Program.cs 中将服务注册为作用域:

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

在调用应用程序后,你将看到以下日志在运行代码时生成:

info: Chapter09.Service.Services.WeatherForecastService[0]
      921a29e8-8f39-4651-9ffa-2e83d2289f29
info: Chapter09.Service.Services.WeatherForecastService[0]
      921a29e8-8f39-4651-9ffa-2e83d2289f29

再次调用 WeatherForecastService 时,你将看到以下内容:

info: Chapter09.Service.Services.WeatherForecastService[0]
      974e082d-1ff5-4727-93dc-fde9f61d3762
info: Chapter09.Service.Services.WeatherForecastService[0]
      974e082d-1ff5-4727-93dc-fde9f61d3762

这是一个不同的 GUID,已经被记录。这证明了服务每次请求都会初始化一次,但在新的请求上会初始化一个新的实例。

瞬态

Program.cs 中将服务注册为瞬态的方式如下:

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

在调用应用程序后,你应该在运行代码时生成的日志中看到以下内容:

info: Chapter09.Service.Services.WeatherForecastService[0]
      6335a0aa-f565-4673-a5c4-0590a5d0aead
info: Chapter09.Service.Services.WeatherForecastService[0]
      4074f4d3-5e50-4748-9d6f-15fb6a782000

有两个不同的 GUID 被记录证明这两个服务都是使用不同的实例初始化的。在 Web API 之外使用 DI 和 IoC 是可能的。通过 IoC 的 DI 只是一个带有 Web API 模板提供的少量额外功能的库。

注意

如果你想在 ASP.NET Core 之外使用 IoC,请安装以下 NuGet(或其他 IoC 容器):Microsoft.Extensions.DependencyInjection

TryAdd

到目前为止,你已经使用 Add[Lifetime] 函数将实现与其抽象连接起来。然而,在大多数情况下,这并不是最佳实践。通常,你希望单个实现与单个抽象连接。然而,如果你反复调用 Add[Lifetime],例如 AddSingleton 函数,你将在下面创建一个实现实例的集合(重复)。这很少是意图,因此你应该保护自己免受这种情况的影响。

连接依赖项最干净的方式是通过 TryAdd[Lifetime] 方法。在重复依赖项的情况下,它将简单地不会添加重复项。为了说明两种 DIs 版本之间的区别,比较使用不同方法注入的服务数量。在这里,你将注入两个相同的服务作为单例。

在这里,你使用 Add[Lifetime] 服务作为单例:

builder.Services.AddSingleton<IWeatherForecastService, WeatherForecastService>();
Debug.WriteLine("Services count: " + services.Count);
builder.services.AddSingleton<IWeatherForecastService, WeatherForecastService>();
Debug.WriteLine("Services count: " + services.Count);

命令将显示以下输出:

Services count: 156
Services count: 157

在这里,你使用 TryAdd[Lifetime] 服务作为单例:

builder.Services.TryAddSingleton<IWeatherForecastService, WeatherForecastService>();
Debug.WriteLine("Services count: " + services.Count);
builder.Services.TryAddSingleton<IWeatherForecastService, WeatherForecastService>();
Debug.WriteLine("Services count: " + services.Count);

命令将显示以下输出:

Services count: 156
Services count: 156

注意到 Add[Lifetime] 在输出中添加了重复项,而 TryAdd[Lifetime] 没有这样做。由于你不想有重复的依赖项,建议使用 TryAdd[Lifetime] 版本。

你也可以为具体的类进行注入。调用 builder.Services.AddSingleton<WeatherForecastService, WeatherForecastService>(); 是有效的 C# 代码;然而,这并没有太多意义。DI 用于将实现注入到抽象中。在启动服务时这不会工作,因为以下错误将会显示:

Unable to resolve a controller

错误发生是因为仍然需要提供抽象-实现绑定。只有当控制器构造函数中暴露的是具体实现而不是抽象时,它才会工作。在实践中,这种情况很少使用。

你已经了解到通过 TryAdd[Lifetime] 方法是连接依赖项最干净的方式。现在你将创建一个接受原始参数(intstring)的服务,并查看它在 IoC 容器中如何管理其非原始依赖项。

使用 IoC 容器进行手动注入

有一些场景,在注入之前你需要创建一个服务实例。一个例子用例可能是一个在构造函数中有原始参数的服务,换句话说,是一个具有配置好的预报刷新间隔的特定城市的天气预报服务。因此,在这里你不能注入一个字符串或一个整数,但你可以创建一个具有整数和字符串的服务并注入它。

修改WeatherForecastService以包含所述功能:

public class WeatherForecastServiceV2 : IWeatherForecastService
{
    private readonly string _city;
    private readonly int _refreshInterval;
    public WeatherForecastService(string city, int refreshInterval)
    {
        _city = city;
        _refreshInterval = refreshInterval;
    }

返回到Program类,尝试注入一个具有5(小时)刷新间隔的New York服务:

builder.Services.AddSingleton<IWeatherForecastService, WeatherForecastService>(BuildWeatherForecastService);
static WeatherForecastServiceV2 BuildWeatherForecastService(IServiceProvider _)
{
    return new WeatherForecastServiceV2("New York", 5);
}

为了注入服务,就像往常一样,你使用builder.Services.Add[Lifetime]方法的版本。然而,在此基础上,你还提供了一个参数——一个指定如何创建服务的委托。可以通过调用IServiceCollection上的BuildServices方法来访问服务提供者。这个委托接受IServiceProvider作为输入,并使用它来构建一个新的服务。

在这种情况下,你没有使用它,因此按照丢弃操作符(_)命名了参数。函数的其余部分只是简单地返回上一段落的值(为了简洁,你不会添加任何额外的逻辑来使用新值)。如果你有一个更复杂的服务,例如,需要一个其他服务的服务,你可以从IServiceProvider调用.GetService<ServiceType>方法。

BuildCreate是两个常见的函数名。然而,它们不应该被互换使用。当构建单个专用对象时使用Build,而当意图是生成多种类型的多个对象时使用Create

注意

你可以在packt.link/fBFRQ找到此示例使用的代码。

练习 9.02:在 Country API 时区中显示当前时间

在这个练习中,你被要求创建一个 Web API,该 API 提供 UTC 不同时区的日期和时间。通过 URL,你将传递一个介于-12+12之间的数字,并返回该时区的当前时间。

执行以下步骤:

  1. 创建一个名为ICurrentTimeProvider的接口,其中有一个名为DateTime GetTime(string timezone)的方法:

    public interface ICurrentTimeProvider
    {
        DateTime GetTime(string timezoneId);
    }
    
  2. 创建一个名为CurrentTimeUtcProvider的类,实现ICurrentTimeProvider以实现应用程序所需的逻辑:

    public class CurrentTimeUtcProvider : ICurrentTimeProvider
    {
    
  3. 实现将当前DateTime转换为Utc并根据传递的时间区进行偏移的方法:

        public DateTime GetTime(string timezoneId)
        {
            var timezoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezoneId);
            var time = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timezoneInfo);
            return time;
        }
    }
    
  4. 创建一个CurrentTimeProviderController控制器以确保它接受构造函数中的ICurrentTimeProvider

    [ApiController]
    [Route("[controller]")]
    public class CurrentTimeController : ControllerBase
    {
        private readonly ICurrentTimeProvider _currentTimeProvider;
        public CurrentTimeController(ICurrentTimeProvider currentTimeProvider)
        {
            _currentTimeProvider = currentTimeProvider;
        }
    
  5. 创建一个名为IActionResult Get(string timezoneId)HttpGet端点,该端点调用当前时间提供者并返回当前时间:

        [HttpGet]
        public IActionResult Get(string timezoneId)
        {
            var time = _currentTimeProvider.GetTime(timezoneId);
            return Ok(time);
        }
    }
    

请注意,{timezoneId}HttpGet 属性中没有指定。这是因为模式用于端点的 REST 部分;然而,在这个场景中,它作为查询字符串的参数传递。如果字符串包含空格或其他特殊字符,它应该在传递之前进行编码。你可以使用此工具对字符串进行 URL 编码:meyerweb.com/eric/tools/dencoder/

  1. Program 类中注入服务:

    builder.Services.AddSingleton<ICurrentTimeProvider, CurrentTimeUtcProvider>();
    

在这里,你将服务作为单例注入,因为它是无状态的。

  1. 使用 timezoneid 值调用 https://localhost:7021/CurrentTime?timezone=[yourtimezone] 端点。例如,你可以调用以下端点:https://localhost:7021/CurrentTime?timezoneid=Central%20Europe%20Standard%20Time

你将得到显示该时区日期和时间的响应:

"2021-09-18T20:32:29.1619999"

注意

你可以在 packt.link/iqGJL 找到用于此练习的代码。

OpenAPI 和 Swagger

OpenAPI 是一种 REST API 描述格式。它定义了 API 的端点、支持的认证方法、接受的参数以及它所提供的事例请求和响应。REST API 可以与 JSON 和 XML 格式一起工作;然而,JSON 被频繁选择。Swagger 是一组实现 OpenAPI 标准的工具和库。Swagger 生成两样东西:

  • 一个用于调用你的 API 的网页

  • 生成客户端代码

在 .NET 中,有两个库用于与 Swagger 一起工作:

  • NSwag

  • Swashbuckle

使用 Swagger Swashbuckle

在本节中,你将使用 Swashbuckle 来演示测试 API 和生成 API 文档的多种方法之一。因此,通过运行以下命令安装 Swashbuckle.AspNetCore 包:

dotnet add package Swashbuckle.AspNetCore

Program.csbuilder.Build() 调用之前,添加以下代码行:

builder.Services.AddSwaggerGen();

这将注入生成 Swagger 架构和文档测试页所需的 Swagger 服务。

Program.cs 中的 builder.Build() 之后,添加以下内容:

app.UseSwagger();
app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); }); 

第一行支持访问 OpenAPI Swagger 规范,第二行允许在用户友好的网页上访问规范。

现在,按照以下方式运行程序:

dotnet run --urls=https://localhost:7021/

当你导航到 https://localhost:7021/swagger/ 时,你会看到以下屏幕:

图 9.8:用户友好的 Swagger 端点

图 9.8:用户友好的 Swagger 端点

点击任何端点都可以让你向它们发送 HTTP 请求。这个页面可以被配置为包含关于项目的常见信息,例如联系信息、许可证、描述、服务条款等等。

Swagger 的好处不止于此。如果你有注释,你还可以将它们包含在这个页面上。你还可以包含端点产生的所有可能的响应类型。你甚至可以包含示例请求,并在调用 API 时将它们设置为默认值。

创建一个新的端点来保存天气预报,然后另一个端点来检索它。逐一记录这两个方法。因此,首先,更新IWeatherForecastService接口以包含两个新方法,GetWeekdayGetWeatherForecast,如下所示:

    public interface IWeatherForecastService
    {
        WeatherForecast GetWeekday(int day);
        void SaveWeatherForecast(WeatherForecast forecast);
        WeatherForecast GetWeatherForecast(DateTime date);
    }

接下来,向WeatherForecastService添加这些方法的实现。为了保存天气预报,你需要存储,最简单的存储方式是IMemoryCache。在这里,你需要为IMemoryCache添加一个新的字段:

private readonly IMemoryCache _cache;

现在,更新构造函数以注入IMemoryCache

public WeatherForecastService(ILogger<WeatherForecastService> logger, string city, int refreshInterval, IMemoryCache cache)
        {
            _logger = logger;
            _city = city;
            _refreshInterval = refreshInterval;
            _serviceIdentifier = Guid.NewGuid();
            _cache = cache;
        }

然后,创建SaveWeatherForecast方法来保存天气预报:

        public void SaveWeatherForecast(WeatherForecast forecast)
        {
            _cache.Set(forecast.Date.ToShortDateString(), forecast);
        }

创建一个GetWeatherForecast方法来获取天气预报:

        public WeatherForecast GetWeatherForecast(DateTime date)
        {
            var shortDateString = date.ToShortDateString();
            var contains = _cache.TryGetValue(shortDateString, out var entry);
            return !contains ? null : (WeatherForecast) entry;
        }

现在,回到WeatherForecastController并为每个方法创建一个端点,以便你可以使用 HTTP 请求来测试它:

        [HttpGet("{date}")]
        public IActionResult GetWeatherForecast(DateTime date)
        {
            var weatherForecast = _weatherForecastService1.GetWeatherForecast(date);
            if (weatherForecast == null) return NotFound();
            return Ok(weatherForecast);
        }
        [HttpPost]
        public IActionResult SaveWeatherForecast(WeatherForecast weatherForecast)
        {
            _weatherForecastService1.SaveWeatherForecast(weatherForecast);
            return CreatedAtAction("GetWeatherForecast", new { date = weatherForecast.Date.ToShortDateString()}, weatherForecast);
        }

请注意,在创建新的天气预报时,你返回一个CreatedAtAction结果。这返回一个 HTTP 状态码为201的 URI,用于获取创建的资源。指定了,为了稍后获取创建的预报,你可以使用GetWeatherForecast。匿名new { date = weatherForecast.Date.ToShortDateString()}对象指定了调用该操作所需的参数。你传递了Date.ToShortDateString()而不是仅仅一个日期,因为完整的DateTime包含了你不需要的信息。在这里,你只需要一个日期;因此,你明确地切掉了不需要的部分。

通过描述每个方法的功能和它可以返回的状态码来记录每个方法。然后,你将在每个端点上方添加此信息:

        /// <summary>
        /// Gets weather forecast at a specified date.
        /// </summary>
        /// <param name="date">Date of a forecast.</param>
        /// <returns>
        /// A forecast at a specified date.
        /// If not found - 404.
        /// </returns>
        [HttpGet("{date}")]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        [ProducesResponseType(StatusCodes.Status200OK)]
        public IActionResult GetWeatherForecast(DateTime date)
        /// <summary>
        /// Saves a forecast at forecast date.
        /// </summary>
        /// <param name="weatherForecast">Date which identifies a forecast. Using short date time string for identity.</param>
        /// <returns>201 with a link to an action to fetch a created forecast.</returns>
        [HttpPost]
        [ProducesResponseType(StatusCodes.Status201Created)]
        public IActionResult SaveWeatherForecast(WeatherForecast weatherForecast)

你现在已经为这两个端点添加了 XML 文档。使用ProducesResponseType,你指定了端点可以返回哪些状态码。如果你刷新 Swagger 页面,你将看到 Swagger 中的SaveWeatherForecast端点:

图 9.9:Swagger 中的 SaveWeatherForecast 端点

图 9.9:Swagger 中的 SaveWeatherForecast 端点

如果你刷新 Swagger 页面,你将看到 Swagger 中的GetWeatherForecast端点:

图 9.10:Swagger 中的 GetWeatherForecast 端点

图 9.10:Swagger 中的 GetWeatherForecast 端点

你可以看到状态码的添加,但注释去哪里了?默认情况下,Swagger 不会选择 XML 文档。你需要通过配置项目文件来指定它需要做什么。为此,在目标框架属性组下方<Project>内添加以下代码段:

  <PropertyGroup>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

图 9.11:包含 XML 文档的 Swagger 配置

图 9.11:包含 XML 文档的 Swagger 配置

最后,转到Program.cs文件,将service.AddSwaggerGen()替换为以下内容:

            builder.Services.AddSwaggerGen(cfg =>
            {
                var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
                var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
                cfg.IncludeXmlComments(xmlPath);
            });

这是包含 XML 注释在 Swagger 文档中所需的最后一部分代码。现在,刷新页面,你应该会看到包含的注释:

图 9.12:包含 XML 文档的 WeatherForecast Swagger 文档

图 9.12:包含 XML 文档的 WeatherForecast Swagger 文档

注意

您可以在 packt.link/iQK5X 找到此示例使用的代码。

您可以使用 Swagger 做很多事情;您可以将示例请求和响应包含在内,并为参数提供默认值。您甚至可以创建自己的 API 规范标准,并装饰项目命名空间,以便将相同的约定应用到每个控制器及其端点,但这超出了本书的范围。

最后要提到的是,从 Swagger 文档生成客户端的能力。要这样做,请按照以下步骤操作:

  1. 为了下载 swagger.json OpenAPI 文档工件,导航到 https://localhost:7021/swagger/v1/swagger.json

  2. 在页面上任何位置右键单击并选择 另存为 选项。

  3. 然后,按 Enter 键。

  4. 接下来,您将使用此 JSON 生成客户端代码。因此,注册并登录到 app.swaggerhub.com/home(您可以使用您的 GitHub 账户)。

  5. 在新窗口中,点击 创建新 按钮(1):

图 9.13:SwaggerHub 和导入 API 窗口

图 9.13:SwaggerHub 和导入 API 窗口

  1. 选择 导入并记录 API 选项。

  2. 通过点击 浏览 按钮(2)选择您刚刚下载的 Swagger 文件。

  3. 然后,点击 上传文件 按钮:

    注意

    当您选择文件时,导入 按钮(图 9.13 中的 3)更改为 上传文件 按钮(图 9.14 中的 3)。

图 9.14:SwaggerHub 导入按钮更改为上传文件按钮

图 9.14:SwaggerHub 导入按钮更改为上传文件按钮

  1. 在下一屏,请使用默认值保留服务的名称和版本。

  2. 接下来,点击 导入定义 按钮:

图 9.15:SwaggerHub 导入 Swagger 服务定义

图 9.15:SwaggerHub 导入 Swagger 服务定义

  1. 现在,Swagger.json API 规范已导入,您可以使用它生成强类型 C# 客户端代码来调用 API。因此,点击 导出 选项(1)。

  2. 然后,点击 客户端 SDK 选项(2)。

  3. 选择 csharp 选项(3):图 9.16:从 SwaggerHub 导出新的 C# 客户端

图 9.16:从 SwaggerHub 导出新的 C# 客户端

将下载一个 csharp-client-generated.zip 文件。

  1. 提取 csharp-client-generated.zip 文件。

  2. 导航到提取的文件夹并打开 IO.Swagger.sln 文件。您应该看到以下内容:

图 9.17:使用 SwaggerHub 生成的客户端文件

图 9.17:使用 SwaggerHub 生成的客户端文件

生成的客户端代码不仅包含强类型 HTTP 客户端,还包括测试。它还包含一个 README.md 文件,说明如何调用客户端以及许多其他常见开发场景。

现在,出现的一个问题是,当您已经有 Postman 时,是否应该使用 Swagger。虽然 Postman 是用于测试不同类型 Web API 的最受欢迎的工具之一,但 Swagger 远不止是一个测试 API 是否工作的客户端。首先,Swagger 是一个用于记录 API 的工具。从常规代码中,它允许您生成所有可能需要的内容:

  • 测试页面

  • 测试客户端代码

  • 测试文档页面

到目前为止,您已经了解到 Swagger 是一组实现 OpenAPI 标准的工具和库集合,这些工具和库对于测试和记录您的 API 非常有帮助。现在,您可以继续学习错误处理。

错误处理

您已经了解到,由于控制器是代码中的最高级别(直接调用),其内部的代码应该尽可能简洁。特定的错误处理不应该包含在控制器代码中,因为它会增加已经复杂的代码的复杂性。幸运的是,有一种方法可以将异常映射到 HTTP 状态码,并在一个地方设置所有这些——那就是通过Hellang.Middleware.ProblemDetails包。为此,首先通过运行以下命令安装该包:

dotnet add package Hellang.Middleware.ProblemDetails

NoSuchWeekdayException映射到 HTTP 状态码404。在Program.cs文件中,在builder.Build()之前添加以下代码:

            builder.Services.AddProblemDetails(opt =>
            {
                opt.MapToStatusCode<NoSuchWeekdayException>(404);
                opt.IncludeExceptionDetails = (context, exception) => false;
            });

这不仅将异常转换为正确的状态码,还使用ProblemDetails——一个基于 RFC 7807 的标准响应模型——在 HTTP 响应中提供错误。同时,它还排除了错误消息中的异常详情。

在本地开发服务时,了解出了什么问题是无价的。然而,暴露确定错误所需的堆栈跟踪和其他信息可能会暴露您的 Web API 的漏洞。因此,在向发布阶段过渡时,最好将其隐藏。默认情况下,Hellang库已经在上层环境中排除了异常详情,因此您最好不包含该行。为了演示目的和简化响应消息,这里将其包含在内。

在构建演示之前,您还需要关闭默认的开发者异常页面,因为它会覆盖ProblemDetails中的异常。只需从Configure方法中删除以下代码块:

        if (builder.Environment.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

由于您已经有了处理NoSuchWeekdayException的中心位置,因此您可以简化获取给定日期的WeatherForecast的控制器方法:

        [HttpGet("weekday/{day}")]
        public IActionResult GetWeekday(int day)
        {
            var result = _weatherForecastService.GetWeekday(day);
            return Ok(result);
        }

当使用无效的天数值调用端点(例如,9)时,您会得到以下响应:

{
    "type": "/weatherforecast/weekday/9",
    "title": "Not Found",
    "status": 404,
    "traceId": "|41dee286-4c5efb72e344ee2d."
}

这种集中式错误处理方法使得控制器可以摆脱所有的try-catch块。

注意

您可以在packt.link/CntW6找到用于此示例的代码。

现在,您可以映射异常到 HTTP 状态码,并在一个地方设置它们。接下来的这一节将探讨 API 的另一个新增功能,即请求验证。

请求验证

API 的另一个有用功能是请求验证。默认情况下,ASP.NET Core 使用基于所需属性的请求验证器。然而,可能存在一些复杂场景,其中属性组合会导致无效请求或需要自定义错误消息的请求,这时需要进行验证。

.NET 有一个很好的 NuGet 包用于此:FluentValidation.AspNetCore。执行以下步骤来学习如何执行请求验证。在继续之前,通过运行以下命令安装包:

dotnet add package FluentValidation.AspNetCore

此包允许按模型注册自定义验证器。它利用现有的 ASP.NET Core 中间件,因此你只需注入一个新的验证器。为 WeatherForecast 创建一个验证器。

验证器应该继承 AbstractValidator 类。这不是强制性的,但强烈推荐,因为它实现了功能性的常用方法,并为泛型验证提供了默认实现:

public class WeatherForecastValidator : AbstractValidator<WeatherForecast>

通过泛型参数,你指定这是一个针对 WeatherForecast 的验证器。

接下来是验证本身。这是在验证器的构造函数中完成的:

        public WeatherForecastValidator()
        {
            RuleFor(p => p.Date)
                .LessThan(DateTime.Now.AddMonths(1))
                .WithMessage("Weather forecasts in more than 1 month of future are not supported");
            RuleFor(p => p.TemperatureC)
                .InclusiveBetween(-100, 100)
                .WithMessage("A temperature must be between -100 and +100 C.");
        }

FluentValidation 是一个 .NET 库,它完全关于流畅的 API,具有自解释的方法。在这里,你需要一个天气预报日期,不超过一个月。下一个验证是温度在 -100 C100 C 之间。

如果你通过 Swagger ping 你的 API,以下请求将显示:

{
  "date": "2022-09-19T19:34:34.511Z",
  "temperatureC": -111,
  "summary": "string"
}

响应将如下显示:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "|ade14b9-443aaaf79026feec.",
  "errors": {
    "Date": [
      "Weather forecasts in more than 1 month of future are not supported"
    ],
    "TemperatureC": [
      "A temperature must be between -100 and +100 C."
    ]
  }
}

你不必使用 FluentValidation,特别是如果你的 API 简单且没有复杂规则的话。但在企业环境中,强烈建议使用它,因为你可以添加到验证中的详细程度是无限的。

你已经了解了 FluentValidation 以及其有用的场景。下一节将涉及在 ASP.NET 中读取配置的两个选项。

注意

你可以在 packt.link/uOGOe 找到用于此示例的代码。

配置

在 ASP.NET Core Web API 中,你有两种读取配置的选项:

  • IConfiguration: 这是一个全局配置容器。尽管它允许访问所有配置属性,但直接将其注入到其他组件中是不高效的。这是因为它是弱类型的,并且存在你尝试获取不存在的配置属性的风险。

  • IOptions: 这是一个强类型且方便的配置方式,因为配置被分解为组件所需的各个部分。

你可以选择两种选项中的任何一种。在 ASP.NET Core 中使用 IOptions 是最佳实践,因为配置示例将基于它。无论你选择哪种选项,你都需要将配置存储在 appsettings.json 文件中。

将硬编码的配置(天气预测城市和刷新间隔)从构造函数中移除,并将其移动到 appsettings.json 文件中的配置部分:

  "WeatherForecastConfig": {
    "City": "New York",
    "RefreshInterval":  5 
  }

创建一个表示此配置部分的模型:

    public class WeatherForecastConfig
    {
        public string City { get; set; }
        public int RefreshInterval { get; set; }
    }

你不再需要将两个原始值注入到组件中。相反,你将注入 IOptions<WeatherForecastConfig>

public WeatherForecastService(Ilogger<WeatherForecastService> logger, Ioptions<WeatherForecastConfig> config, ImemoryCache cache)

在可以使用 JSON 部分之前,你需要将其绑定。这可以通过通过 IConfiguration(通过 builder.Configuration 属性)找到部分来完成:

builder.Services.Configure<WeatherForecastConfig>(builder.Configuration.GetSection(nameof(WeatherForecastConfig)));

在这种情况下,WeatherForecastConfig 在配置文件中有一个匹配的部分。因此,使用了 nameof。因此,当使用替代的 string 类型时,应首选 nameof。这样,如果类型的名称发生变化,配置将保持一致(否则代码将无法编译)。

记得你之前使用的 BuildWeatherForecastService 方法吗?它的美妙之处在于整个方法可以完全删除,因为服务可以在不需要自定义初始化的情况下创建。如果你编译并运行代码,你将得到相同的响应。

注意

你可以在packt.link/xoB0K找到用于此示例的代码。

ASP.NET Core Web API 是在 .NET Core 框架之上的库集合。你可以在其他类型的应用程序中使用 appsettings.json。无论你选择的项目类型如何,最好使用单独的库。为了通过 JSON 使用配置,你需要安装以下 NuGet 包:

  • Microsoft.Extensions.Configuration

  • Microsoft.Extensions.Configuration.EnvironmentVariables

  • Microsoft.Extensions.Configuration.FileExtensions

  • Microsoft.Extensions.Configuration.Json

  • Microsoft.Extensions.Options

在本节中,你学习了如何使用 IConfigurationIOptions。你的 API 现在已经准备好了,并且它已经包括了典型 Web API 的许多标准组件。下一节将详细说明你如何在代码中处理这种复杂性。

开发环境和配置

应用程序通常需要有两个环境——生产环境和开发环境。你希望应用程序的开发环境拥有预制的设置,更详细的错误消息(如果可能的话),更详细的日志记录,最后,启用调试。所有这些在生产环境中都不需要,你希望保持其简洁。

除了构建配置之外,你通过不同的配置文件来管理环境。appsettings.json 文件是一个基本配置文件,并在所有环境中使用。此配置文件应包含你希望用于生产的配置。

Appsettings.development.json 文件是一个配置文件,当你在调试模式下构建应用程序时将应用此文件。在这里,appsettings.json 仍然会使用,但开发设置将覆盖匹配的部分。一个常见的例子在此处描述。

appsettings.json 包含以下内容:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Information",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "WeatherForecastConfig": {
    "City": "New York",
    "RefreshInterval": 5
  },
  "WeatherForecastProviderUrl": "https://community-open-weather-map.p.rapidapi.com/",
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "ClientId": "2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb",
    "TenantId": "ddd0fd18-f056-4b33-88cc-088c47b81f3e",
    "Audience": "api://2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb"
  }
}

appsettings.development.json 包含以下内容:

{
  "Logging": {
    "LogLevel": {
      "Default": "Trace",
      "Microsoft": "Trace",
      "Microsoft.Hosting.Lifetime": "Trace"
    }
  }
}

然后,将使用的设置将是具有匹配部分的合并文件,如下所示:

{
  "Logging": {
    "LogLevel": {
      "Default": "Trace",
      "Microsoft": "Trace",
      "Microsoft.Hosting.Lifetime": "Trace"
    }
  },
  "AllowedHosts": "*",
  "WeatherForecastConfig": {
    "City": "New York",
    "RefreshInterval": 5
  },
  "WeatherForecastProviderUrl": "https://community-open-weather-map.p.rapidapi.com/",
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "ClientId": "2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb",
    "TenantId": "ddd0fd18-f056-4b33-88cc-088c47b81f3e",
    "Audience": "api://2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb"
  }
}

在下一节中,你将学习如何更干净地管理依赖注入(DI)。

引导

需要处理复杂性,这里所指的复杂性是 Program 类。你需要将其分解成更小的部分,并形成一个指定服务由哪些组件组成的 Bootstrapping 目录。

Program.cs 中分解代码时,建议使用流畅的 API 模式。这是一种模式,其中你可以从单个根对象链式调用多个函数调用。在这种情况下,你将为 IServiceCollection 类型创建几个扩展方法,并逐个链式注入所有模块。

为了减少 Program 类的复杂性,将不同逻辑部分的依赖注入(DI)移动到不同的文件中。接下来的每个步骤都将这样做。因此,将控制器和 API 基线设置拆分到名为 ControllersConfigurationSetup.cs 的新文件中:

    public static class ControllersConfigurationSetup
    {
        public static IserviceCollection AddControllersConfiguration(this IserviceCollection services)
        {
            services
                .AddControllers()
                .AddFluentValidation();
            return services;
        }
    }

现在,将日志代码移动到名为 LoggingSetup.cs 的新文件中:

    public static class LoggingSetup
    {
        public static IServiceCollection AddLoggingConfiguration(this IServiceCollection services)
        {
            services.AddLogging(builder =>
            {
                builder.ClearProviders();
                builder.AddConsole();
                builder.AddDebug();
            });
            return services;
        }
    }

接下来,将请求验证逻辑移动到名为 RequestValidatorsSetup.cs 的新文件中:

    public static class RequestValidatorsSetup
    {
        public static IServiceCollection AddRequestValidators(this IServiceCollection services)
        {
            services.AddTransient<Ivalidator<WeatherForecast>, WeatherForecastValidator>();
            return services;
        }
    }

将 Swagger 设置逻辑移动到名为 SwaggerSetup.cs 的新文件中:

    public static class SwaggerSetup
    {
        public static IServiceCollection AddSwagger(this IServiceCollection services)
        {
            services.AddSwaggerGen(cfg =>
            {
                var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
                var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
                cfg.IncludeXmlComments(xmlPath);
            });
            return services;
        }
    }

将与 WeatherForecast 相关的类的代码注入移动到名为 WeatherServiceSetup.cs 的新文件中:

    public static class WeatherServiceSetup
    {
        public static IServiceCollection AddWeatherService(this IServiceCollection services, IConfiguration configuration)
        {
            services.AddScoped<IWeatherForecastService, WeatherForecastService>(BuildWeatherForecastService);
            services.AddSingleton<ICurrentTimeProvider, CurrentTimeUtcProvider>();
            services.AddSingleton<ImemoryCache, MemoryCache>();
            services.Configure<WeatherForecastConfig>(configuration.GetSection(nameof(WeatherForecastConfig)));
            return services;
        }
        private static WeatherForecastService BuildWeatherForecastService(IserviceProvider provider)
        {
            var logger = provider
                .GetService<IloggerFactory>()
                .CreateLogger<WeatherForecastService>();
            var options = provider.GetService<Ioptions<WeatherForecastConfig>>();
            return new WeatherForecastService(logger, options, provider.GetService<ImemoryCache>());
        }
    }

最后,将 HTTP 状态码的异常映射移动到名为 ExceptionMappingSetup.cs 的新文件中:

    public static class ExceptionMappingSetup
    {
        public static IServiceCollection AddExceptionMappings(this IServiceCollection services)
        {
            services.AddProblemDetails(opt =>
            {
                opt.MapToStatusCode<NoSuchWeekdayException>(404);
            });
            return services;
        }
    }

现在,将所有新类移动到 /Bootstrap 文件夹下:

图 9.18:带有碎片化服务注入的 Bootstrap 文件夹

图 9.18:带有碎片化服务注入的 Bootstrap 文件夹

图 9.18 展示了 Bootstrap 文件夹。这个项目结构本身展示了 API 由什么组成。因此,依赖注入(DI)变得像下面这样简单:

builder.Services
    .AddControllersConfiguration()
    .AddLoggingConfiguration()
    .AddRequestValidators()
    .AddSwagger()
    .AddWeatherService(builder.Configuration)
    .AddExceptionMappings();

在某些情况下,你可能希望多次从构建器传递配置或环境到其他引导方法或应用程序方法。如果你发现自己反复调用 builder.X,那么考虑将每个属性存储在局部变量中,如下所示:

var services = builder.Services;
var configuration = builder.Configuration;
var environment = builder.Environment;

通过这种方式,你将不再反复访问构建器,而是可以直接使用所需的构建器属性。如果你从 .NET Core 迁移到 .NET 6,这特别有用。EnvironmentConfiguration 以前是 Program 类的属性,而 Services 会注入到 ConfigureServices 方法中。在 .NET 6 中,Services 通过 builder 对象访问。然而,使用这种方法,你仍然可以使用这些属性或参数,就像以前一样。

从现在开始,当提到服务、环境或配置时,你将假设你正在从 builder.Servicesbuilder.Environmentbuilder.Configuration 访问它们,相应地。

注意

你可以在 packt.link/iQK5X 找到用于此示例的代码。

调用另一个 API

一个工作产品通常由许多相互通信的 API 组成。为了有效通信,一个网络服务通常需要调用另一个服务。例如,一家医院可能有一个网站(前端),该网站调用 Web API(后端)。这个 Web API 通过调用预订 Web API、计费 Web API 和员工 Web API 来协调事务。员工 Web API 可能会调用库存 API、假日 API 等。

RapidAPI

如第八章所述,在创建和使用 Web API 客户端中,有多种方式可以对其他服务进行 HTTP 调用(尽管 HTTP 不是调用另一个服务的唯一方式)。这次,你将尝试从现有的 API 获取天气预报,并以你自己的方式格式化它。为此,你将使用 RapidAPI Weather API,该 API 可以在rapidapi.com/visual-crossing-corporation-visual-crossing-corporation-default/api/visual-crossing-weather/找到。

注意

RapidAPI 是一个支持许多 API 的平台。网站rapidapi.com/visual-crossing-corporation-visual-crossing-corporation-default/api/visual-crossing-weather/只是一个例子。那里展示的许多 API 都是免费的;然而,请注意,今天免费的 API 明天可能变成付费的。如果在你阅读这一章的时候发生了这种情况,请查看示例,并探索rapidapi.com/category/Weather上的Weather APIs部分。你应该能在那里找到类似的替代方案。

此 API 使用需要 GitHub 账户。按照以下步骤使用 RapidAPI Weather API:

  1. 登录网站rapidapi.com/community/api/open-weather-map/

    注意

    只有在登录状态下,你才能导航到rapidapi.com/community/api/open-weather-map/。因此,请在rapidapi.com/上注册并创建一个账户。如果你需要 API 密钥,这是必须的。接下来登录,选择Weather类别并选择Open Weather链接。

登录网站后,你会看到以下窗口:

图 9.19:rapidapi.com 上 Visual Crossing Weather API 的未订阅测试页面

图 9.19:rapidapi.com 上 Visual Crossing Weather API 的未订阅测试页面

  1. 点击Subscribe to Test按钮以免费获取对 Web API 的调用权限。将打开一个新窗口。

  2. 选择Basic选项,这将允许你每月对该 API 进行 500 次调用。出于教育目的,基本计划应该足够:

图 9.20:带有免费基本计划的 RapidAPI 订阅费用

图 9.20:RapidAPI 订阅费用,免费的基本计划突出显示

你将被重定向到带有Test Endpoint按钮的测试页面(而不是Subscribe to Test按钮)。

  1. 现在,配置请求。第一个配置要求你输入获取天气预报的间隔。你想要一个小时的预报,所以在aggregateHours1)旁边输入1小时。

  2. 接下来是location地址(2)。

图 9.21中,你可以看到城市、州和国家被指定。这些字段要求你输入你的地址。但是,输入你的城市名称也会起作用。

  1. 对于此 API,选择默认的contentType选项为csv3):

图 9.21:GET 天气预报数据请求配置

图 9.21:GET 天气预报数据请求配置

这个 API 很有趣,因为它允许你以不同的格式返回数据——JSON、XML 和 CSV。它仍然是一个 Web API,并且不是那么 RESTful,因为数据响应类型是本地的 CSV。如果你选择 JSON,它看起来会很不自然,并且处理起来会困难得多。

  1. 在下一屏幕上,点击Code Snippets1)然后点击(C#) HttpClient2)来查看为你生成的示例客户端代码。

  2. 接下来,点击Test Endpoint3)发送请求。

  3. 点击Results标签(4)来查看响应(在图 9.22中,其他端点已折叠):

图 9.22:rapidapi.com 带有测试请求页面和 C#示例代码的请求

图 9.22:rapidapi.com 带有测试请求页面和 C#示例代码的请求

此窗口提供了一个优秀的 API。这也是通过提供多种语言和技术创建客户端的多个示例来学习如何调用它的绝佳方式。

像往常一样,你不会直接在客户端中初始化此客户端,而是以某种方式注入客户端。在第八章创建和使用 Web API 客户端中提到,为了有一个静态的HttpClient,这是一个高效的实践。然而,对于 Web API 来说,有一个更好的替代方案——HttpClientFactory

  1. 在做所有这些之前,你需要准备一些事情。首先,更新appsettings.json文件,包括 API 的基本 URL:

    "WeatherForecastProviderUrl": "https://visual-crossing-weather.p.rapidapi.com/"
    

接下来,你需要为从该 API 获取天气详情创建另一个类。为此,你需要一个 API 密钥。你可以在 API 网站上的示例代码片段中找到它:

图 9.23:示例代码片段中的 RapidAPI API 密钥

图 9.23:示例代码片段中的 RapidAPI API 密钥

  1. 将 API 密钥保存为环境变量,因为它是一个秘密,将秘密存储在代码中是不好的做法。所以,将其命名为x-rapidapi-key

  2. 最后,返回的天气预报可能与你的预报大不相同。你可以通过点击Test Endpoint按钮来查看示例响应:

图 9.24:RapidAPI 从 GET 当前天气数据端点获取的示例响应

图 9.24:RapidAPI 从 GET 当前天气数据端点获取的示例响应

  1. 点击Test Endpoint按钮后,复制收到的结果。

  2. 将结果粘贴到toolslick.com/generation/code/class-from-csv

  3. 将类名指定为WeatherForecast,并将其余设置保留为默认值。

  4. 最后,按下GENERATE按钮:

图 9.25:将响应内容粘贴到 https://toolslick.com/generation/code/class-from-csv

图 9.25:将响应内容粘贴到toolslick.com/generation/code/class-from-csv

这将创建两个类,WeatherForecastWeatherForecastClassMap

图 9.26:生成的数据模型和映射类(为了简洁而简化)

图 9.26:生成的数据模型和映射类(为了简洁而简化)

WeatherForecast表示将从该 API 加载数据的对象。

  1. Dtos文件夹下创建一个名为WeatherForecast.cs的文件,并将类粘贴到那里(DTO 将在DTO 和 AutoMapper 映射部分中详细介绍)。

  2. 删除与已存在的WeatherForecast模型没有连接的部分。清理后的模型将如下所示:

    public class WeatherForecast
    {
        public DateTime Datetime { get; set; }
        public string Temperature { get; set; }
        public string Conditions { get; set; }
    }
    

你应该知道WeatherForecastClassMap是一个特殊类。它由CsvHelper库使用,该库用于解析 CSV 文件。你可以自己解析 CSV 文件;然而,CsvHelper使解析变得容易得多。

  1. 要使用CsvHelper,安装其 NuGet 包:

    dotnet add package CsvHelper
    

WeatherForecastCsv表示从 CSV 到 C#对象的映射。

  1. 现在,在ClassMaps文件夹下创建一个名为WeatherForecastClassMap.cs的文件,并将类粘贴到那里。

  2. 仅保留与在步骤 17中编辑的WeatherForecast类匹配的映射:

    public class WeatherForecastClassMap : ClassMap<WeatherForecast>
    {
        public WeatherForecastClassMap()
        {
            Map(m => m.Datetime).Name("Date time");
            Map(m => m.Temperature).Name("Temperature");
            Map(m => m.Conditions).Name("Conditions");
        }
    }
    

    注意

    你可以在packt.link/dV6wXpackt.link/mGJMW找到用于此示例的代码。

在上一节中,你学习了如何从现有的 API 获取天气预报,并使用 RapidAPI Weather API 以你的方式格式化它们。现在,是时候继续到服务客户端,使用创建的模型以及设置,解析 API 响应,并返回当前时间的天气。

服务客户端

现在你已经拥有了创建提供者类所需的所有成分。你在第八章创建和使用 Web API 客户端中学习了,在与另一个 API 通信时,最好为其创建一个单独的组件。因此,这里你将从接口抽象IWeatherForecastProvider开始:

    public interface IWeatherForecastProvider
    {
        Task<WeatherForecast> GetCurrent(string location);
    }

接下来,创建该接口的实现——即一个使用HttpClient进行依赖注入的类:

public class WeatherForecastProvider : IWeatherForecastProvider
    {
        private readonly HttpClient _client;
        public WeatherForecastProvider(HttpClient client)
        {
            _client = client;
        }

要实现一个接口,首先编写一个获取当前天气的方法定义:

public async Task<WeatherForecast> GetCurrent(string location)
{

接下来,创建一个请求以调用 HTTP GET,并使用相对 URI 获取给定位置的 CSV 类型预报:

var request = new HttpRequestMessage
{
    	Method = HttpMethod.Get,
    	RequestUri = new Uri($"forecast?aggregateHours=1&location={location}&contentType=csv", UriKind.Relative),
};

现在,发送一个请求并验证它是否成功:

using var response = await _client.SendAsync(request);
response.EnsureSuccessStatusCode();

如果状态码不在 200-300 范围内,response.EnsureSuccessStatusCode(); 会抛出异常。设置 CSV 读取器以准备反序列化天气预报:

var body = await response.Content.ReadAsStringAsync();
using var reader = new StringReader(body);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<WeatherForecastClassMap>();

你正在向 StringReaderCsvReader 添加 using 语句,因为它们都实现了 IDisposable 接口以释放非托管资源。这发生在函数返回后使用 using 语句时。

最后,反序列化预报:

var forecasts = csv.GetRecords<WeatherForecast>();

这样,你请求 API 从今天开始返回预报,并在未来几天内以每小时间隔停止。第一个返回的预报是当前小时的预报——即你需要的那一个:

return forecasts.First();
}

现在,你将使用 Newtonsoft.Json 进行反序列化。安装以下包以实现此目的:

dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson

通过在 services 对象上追加以下行来更新 AddControllersConfiguration 方法:

.AddNewtonsoftJson();

这行代码用 Newtonsoft.Json 替换了默认序列化器。现在,不需要使用 Newtonsoft.Json;然而,与默认序列化器相比,它是一个更受欢迎且更完整的序列化库。

注意

你可以在 packt.link/jmSwi 找到用于此示例的代码。

到目前为止,你已经学习了如何创建服务客户端并使用它进行基本的 HTTP 调用。这对于掌握基础知识是有效的;然而,API 使用的类应该与它所消耗的 API 的类耦合。在下一节中,你将学习如何使用 DTO 和通过 AutoMapper 的映射来解耦 API 与第三方 API 模型。

DTO 和 AutoMapper 的映射

来自 RapidAPI 的天气预报模型是一个日期传输对象 (DTO)——一个仅用于传输数据且便于序列化的模型。RapidAPI 可能会更改其数据模型,如果发生这种情况,DTO 也会随之改变。如果你只是展示接收到的数据而不需要对其执行任何逻辑操作,那么任何更改都可能没问题。

然而,你通常会将对数据模型的业务逻辑应用。你已经知道数据模型引用分散在多个类中。每当 DTO 发生变化时,一个类可能也需要更改。例如,之前称为 weather 的 DTO 属性现在已更改为 weathers。另一个例子是之前称为 description 的属性现在将称为 message。因此,像这样重命名 DTO 属性将需要你在所有引用的地方进行更改。项目越大,这个问题就越严重。

SOLID 原则的建议是避免此类更改(参考第二章构建高质量面向对象代码)。实现这一目标的一种方法是有两种模型——一种用于领域,另一种用于外部调用。这将需要在来自外部 API 的外部对象与您自己的对象之间进行映射。

映射可以通过手动方式或使用一些流行的库来完成。最受欢迎的映射库之一是 AutoMapper。它允许您使用属性名称从一个对象映射到另一个对象。您也可以创建自己的映射。现在,您将使用此库来配置一个天气预测 DTO 和天气预测模型之间的映射。

因此,首先安装 NuGet:

dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

此库允许您将AutoMapper注入到ServiceCollection中。在这里,AutoMapper使用Profile类来定义映射。

新的映射应该继承Profile类。因此,在新配置文件的构造函数中,使用CreateMap方法提供映射:

    public class WeatherForecastProfile : Profile
    {
        public WeatherForecastProfile()
        {
            CreateMap<Dtos.WeatherForecast, Models.WeatherForecast>()

接下来,为了从CreateMap方法映射每个属性,调用ForMember方法并指定如何进行映射:

                .ForMember(to => to.TemperatureC, opt => opt.MapFrom(from => from.main.temp));

在这里,TemperatureC的值来自 DTO 中的main.temp

对于其他属性,您将所有天气描述连接成一个字符串,并将其称为摘要(BuildDescription):

        private static string BuildDescription(Dtos.WeatherForecast forecast)
        {
            return string.Join(",",
                forecast.weather.Select(w => w.description));
        }

现在,在构建天气预测摘要映射时使用 lambda 方法ForMember

.ForMember(to => to.Summary, opt => opt.MapFrom(from => BuildDescription(from)))

创建一个MapperSetup类,并从AddModelMappings方法注入AutoMapper以提供不同的映射配置文件:

public static class MapperSetup
{
    public static IServiceCollection AddModelMappings(this IServiceCollection services)
    {
        services.AddAutoMapper(cfg =>
        {
            cfg.AddProfile<WeatherForecastProfile>();
        });
        return services;
    }
}

.AddModelMappings()附加到services对象调用。有了这个,您就可以调用mapper.Map<Model.WeatherForecast>(dtoForecast);

注意

您可以在packt.link/fEfdwpackt.link/wDqK6找到用于此示例的代码。

AutoMapper映射库允许您通过默认映射匹配属性名称从一个对象映射到另一个对象。下一节将详细介绍如何使用依赖注入(DI)重用HttpClient

HttpClient DI

继续使用 DI,现在您想要养成使用分段的ConfigureServices方法的习惯。因此,首先创建一个名为HttpClientsSetup的类,然后创建一个用于添加配置的HttpClients的方法:

    public static class HttpClientsSetup
    {
        public static IServiceCollection AddHttpClients(IServiceCollection services)
        {

接下来,对于注入本身,使用AddHttpClient方法:

services.AddHttpClient<IWeatherForecastProvider, WeatherForecastProvider>((provider, client) =>
            {

在前面的章节中提到,密钥应该被隐藏并存储在环境变量中。为了设置每个调用的默认起始 URI,设置BaseAddress(在RapidAPI部分的步骤 10中使用的WeatherForecastProviderUrl)。

在每个请求中附加 API 密钥,获取您存储在环境变量中的 API 密钥,并将其分配给默认头部的x-rapidapi-key

                client.BaseAddress = new Uri(config["WeatherForecastProviderUrl"]);
                var apiKey = Environment.GetEnvironmentVariable("x-rapidapi-key", EnvironmentVariableTarget.User);
                client.DefaultRequestHeaders.Add("x-rapidapi-key", apiKey);
            });

要完成注入构建器模式,您需要返回services对象,如下所示:

return services;

现在,回到Program中的services并附加以下内容:

.AddHttpClients(Configuration)

要集成你刚刚设置的客户端,请转到 WeatherForecastService,并注入 mapperprovider 组件:

public WeatherForecastService(..., IWeatherForecastProvider provider, IMapper mapper)

GetWeatherForecast 方法更改为获取当前小时的缓存预报或从 API 获取新的预报:

        public async Task<WeatherForecast> GetWeatherForecast(DateTime date)
        {
            const string DateFormat = "yyyy-MM-ddthh";
            var contains = _cache.TryGetValue(date.ToString(DateFormat), out var entry);
            if(contains){return (WeatherForecast)entry;}

            var forecastDto = await _provider.GetCurrent(_city);
            var forecast = _mapper.Map<WeatherForecast>(forecastDto);
            forecast.Date = DateTime.UtcNow;
            _cache.Set(DateTime.UtcNow.ToString(DateFormat), forecast);
            return forecast;
        }

此方法与上一个方法类似,首先尝试从缓存中获取一个值。如果值存在,则方法返回该值。但是,如果值不存在,则方法调用预配置城市的 API,将 DTO 预报映射到模型预报,并将其保存到缓存中。

如果你向 https://localhost:7021/WeatherForecast/ 发送一个 HTTP GET 请求,你应该看到以下响应:

{"date":"2021-09-21T20:17:47.410549Z","temperatureC":25,"temperatureF":76,"summary":"clear sky"}

调用相同的端点会产生相同的响应。然而,由于使用了缓存而不是重复调用预报 API,响应时间显著更快。

注意

你可以在packt.link/GMFmm找到此示例使用的代码。

这就结束了本主题的理论部分。在下一节中,你将通过练习将其付诸实践。

练习 9.03:通过调用 Azure Blob 存储执行文件操作

使用 Web API 的一个常见任务是执行各种文件操作,例如下载、上传或删除。在这个练习中,你将重用第八章,构建面向对象的高质量代码中的活动 8.04中的部分 FilesClient,作为调用 Azure Blob 存储的基线客户端,并通过 REST 端点调用其方法来执行以下操作:

  • 下载一个文件。

  • 获取一个带有过期时间的可分享链接。

  • 上传一个文件。

  • 删除一个文件。

执行以下步骤来完成此操作:

  1. FilesClient 提取一个接口并命名为 IFilesService

    public interface IFilesService
        {
            Task Delete(string name);
            Task Upload(string name, Stream content);
            Task<byte[]> Download(string filename);
            Uri GetDownloadLink(string filename);
        }
    

新的界面简化了,因为你将在一个单独的容器上工作。然而,根据要求,你添加了一些新的方法:DeleteUploadDownloadGetDownloadLinkDownload 方法用于以原始形式(即字节)下载文件。

  1. 创建一个名为 Exercises/Exercise03/FilesService.cs 的新类。

  2. 将以下部分复制到packt.link/XC9qG

  3. Client 重命名为 Service

  4. 还将第八章,构建面向对象的高质量代码中的Exercise04引用(用于此章节)更改为Exercise03(一个新引用,用于本章):

    FilesService.cs
    public class FilesService : IFilesService
        {
            private readonly BlobServiceClient _blobServiceClient;
            private readonly BlobContainerClient _defaultContainerClient;
            public FilesClient()
            {
                var endpoint = "https://packtstorage2.blob.core.windows.net/";
                var account = "packtstorage2";
                var key = Environment.GetEnvironmentVariable("BlobStorageKey", EnvironmentVariableTarget.User);
                var storageEndpoint = new Uri(endpoint);
                var storageCredentials = new StorageSharedKeyCredential(account, key);
                _blobServiceClient = new BlobServiceClient(storageEndpoint, storageCredentials);
                _defaultContainerClient = CreateContainerIfNotExists("Exercise03).Result;
            }
            private async Task<BlobContainerClient> CreateContainerIfNotExists(string container)
    
You can find the complete code here: https://packt.link/fNQAX.

构造函数初始化 blobServiceClient 以获取 blobClient,这允许你在 Azure Blob 存储账户中的 Exercice03 目录中执行操作。如果文件夹不存在,blobServiceClient 将为你创建它:

        {
            var lowerCaseContainer = container.ToLower();
            var containerClient = _blobServiceClient.GetBlobContainerClient(lowerCaseContainer);
            if (!await containerClient.ExistsAsync())
            {
                containerClient = await _blobServiceClient.CreateBlobContainerAsync(lowerCaseContainer);
            }
            return containerClient;
        }

注意

为了使前面的步骤生效,你需要一个 Azure 存储账户。因此,请参考第八章,构建面向对象的高质量代码中的活动 8.04

  1. 创建一个名为 ValidateFileExists 的方法来验证文件是否存在于存储中,否则抛出异常(一个之前不存在的小助手方法):

    private static void ValidateFileExists(BlobClient blobClient)
    {
        if (!blobClient.Exists())
        {
            throw new FileNotFoundException($"File {blobClient.Name} in default blob storage not found.");
        }
    }
    
  2. 现在,创建一个名为 Delete 的方法来删除文件:

    public Task Delete(string name)
    {
        var blobClient = _defaultContainerClient.GetBlobClient(name);
        ValidateFileExists(blobClient);
        return blobClient.DeleteAsync();
    }
    

在这里,你将首先获取一个文件客户端,然后检查文件是否存在。如果不存在,则抛出FileNotFoundException异常。如果文件存在,则删除该文件。

  1. 创建UploadFile方法来上传文件:

    public Task UploadFile(string name, Stream content)
    {
        var blobClient = _defaultContainerClient.GetBlobClient(name);
        return blobClient.UploadAsync(content, headers);
    }
    

再次强调,您首先获取一个允许您对文件执行操作的客户端。然后,向其中提供内容和头以上传。

  1. 创建一个用于以字节形式下载文件的Download方法:

            public async Task<byte[]> Download(string filename)
            {
                var blobClient = _defaultContainerClient.GetBlobClient(filename);
                var stream = new MemoryStream();
                await blobClient.DownloadToAsync(stream);
                return stream.ToArray();
            }
    

此方法创建一个内存流并将文件下载到其中。请注意,这在大文件上可能不起作用。

注意

如果您想了解更多关于如何处理大文件的信息,请参阅docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-6.0#upload-large-files-with-streaming

有一种方法可以将原始下载的字节呈现为图像或 JSON,而不是通用可下载内容。通过 HTTP 请求或响应,您可以发送一个指定内容解释方式的头。这个头被称为 Content-Type。每个应用程序都会以不同的方式处理它。在 Swagger 的上下文中,image/png将显示为图像,而application/json将显示为 JSON。

  1. 创建一个GetUri方法来获取blobClient的 URI:

            private Uri GetUri(BlobClient blobClient)
            {
                var sasBuilder = new BlobSasBuilder
                {
                    BlobContainerName = _defaultContainerClient.Name,
                    BlobName = blobClient.Name,
                    Resource = "b",
                    ExpiresOn = DateTimeOffset.UtcNow.AddHours(1)
                };
                sasBuilder.SetPermissions(BlobSasPermissions.Read);
                var sasUri = blobClient.GenerateSasUri(sasBuilder);
                return sasUri;
            }
    

获取 URI 需要使用BlobSasBuilder,通过它可以生成指向 blob 的可分享 URL。通过构建器,指定您要尝试共享的资源类型("b"代表 blob)和过期时间。您需要设置权限(读取权限)并将sasBuilder构建器传递给blobClient客户端以生成sasUri

  1. 现在,使用文件名创建一个文件下载链接:

            public Uri GetDownloadLink(string filename)
            {
                var blobClient = _defaultContainerClient.GetBlobClient(filename);
                var url = GetUri(blobClient);
                return url;
            }
    
  2. ExceptionMappingSetup类和AddExceptionMappings方法中,添加以下映射:

    opt.MapToStatusCode<FileNotFoundException>(404);
    
  3. 创建一个扩展方法来注入FileUploadService模块:

    public static class FileUploadServiceSetup
    {
        public static IServiceCollection AddFileUploadService(this IServiceCollection services)
        {
            services.AddScoped<IFilesService, FilesService>();
            return services;
        }
    }
    

扩展方法是一种简化显示现有接口新方法的方式。

  1. 将其附加到Program.cs中的services以使用FileUploadService模块:

    .AddFileUploadService();
    
  2. 现在,为文件创建一个控制器:

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

在 MVC 架构中,控制器创建是标准的,这允许用户通过 HTTP 请求访问FileService

  1. 然后,注入IFilesService以提供一个接口,通过该接口可以访问文件相关功能:

            private readonly IFilesService _filesService;
            public FileController(IFilesService filesService)
            {
                _filesService = filesService;
            }
    
  2. 接下来,创建一个用于删除文件的端点:

            [HttpDelete("{file}")]
            public async Task<IActionResult> Delete(string file)
            {
                await _filesService.Delete(file);
                return Ok();
            }
    
  3. 创建一个用于下载文件的端点:

      [HttpGet("Download/{file}")]
            public async Task<IActionResult> Download(string file)
            {
                var content = await _filesService.Download(file);
                return new FileContentResult(content, "application/octet-stream ");
            }
    
  4. 创建一个用于获取可分享文件下载链接的端点:

            [HttpGet("Link")]
            public IActionResult GetDownloadLink(string file)
            {
                var link = _filesService.GetDownloadLink(file);
                return Ok(link);
            }
    
  5. 创建一个用于上传文件的端点:

            [HttpPost("upload")]
            public async Task<IActionResult> Upload(IFormFile file)
            {
                await _filesService.UploadFile(file.FileName, file.OpenReadStream());
                return Ok();
            }
    

IFormFile是将小文件传递给控制器的一种常见方式。然而,从IFormFile中,您需要文件内容作为流。您可以使用OpenReadStream方法获取它。Swagger 允许您使用文件资源管理器窗口选择要上传的文件。

  1. 现在运行 API。

您的 Swagger 文档将有一个新的部分,包含控制器方法。以下是每个方法的响应:

  • 上传文件请求:

图 9.27:Swagger 中的上传文件请求

图 9.27:Swagger 中的上传文件请求

  • 上传文件响应:

图 9.28:Swagger 中的上传文件响应

图 9.28:Swagger 中的上传文件响应

  • 获取下载链接请求:

图 9.29:Swagger 中的获取下载链接请求

图 9.29:Swagger 中的获取下载链接请求

  • 获取下载链接响应:

图 9.30:Swagger 中的获取下载链接响应

图 9.30:Swagger 中的获取下载链接响应

  • 下载文件请求:

图 9.31:Swagger 中的下载文件请求

图 9.31:Swagger 中的下载文件请求

  • 下载文件响应:

图 9.32:Swagger 中的下载文件响应

图 9.32:Swagger 中的下载文件响应

  • 删除文件请求:

图 9.33:Swagger 中的删除文件请求

图 9.33:Swagger 中的删除文件请求

  • 删除文件响应:

图 9.34:Swagger 中的删除文件响应

图 9.34:Swagger 中的删除文件响应

这个练习展示了你可以使用 Web API 做的剩余方面。

注意

你可以在packt.link/cTa4a找到用于此练习的代码。

你可以通过网络提供的功能量是巨大的。然而,这也伴随着它自己的大问题。你如何确保你的 API 只被预期的身份消费?在下一节中,你将探索如何保护 Web API。

保护 Web API

不时地,你会在新闻中听到关于重大安全漏洞的消息。在本节中,你将学习如何使用 Azure Active Directory (AAD) 保护公共 API。

Azure Active Directory

Azure Active Directory (AAD) 是微软的云身份和访问管理服务,用于登录到知名应用程序,如 Visual Studio、Office 365 和 Azure,以及内部资源。AAD 使用 OpenID 通过 JavaScript Web Token 提供用户身份。

JWT

JavaScript Web Token (JWT) 是一组个人数据,编码后作为身份验证机制发送。JWT 中编码的单个字段称为声明。

OpenID Connect

OpenID Connect (OIDC) 是用于获取 ID 令牌的协议,该令牌提供用户身份或访问令牌。它是在 OAuth 2 之上的一层,用于获取身份。

OAuth 作为代表某些用户获取访问令牌的手段。使用 OIDC,你获得一个身份;这有一个角色,访问来自该角色。当用户想要登录到网站时,OpenID 可能会要求他们输入他们的凭据。这听起来可能完全一样,就像 OAuth 一样;然而,不要混淆这两个。OpenID 完全是关于获取和验证用户的身份,并授予与角色相关的访问权限。另一方面,OAuth 给予用户访问权限以执行一组有限的操作。

一个现实生活中的类比如下:

  • OpenID:你来到机场并出示你的护照(由政府签发),以确认你的角色(乘客)和身份。你将被授予乘客角色并允许登机。

  • OAuth:你来到机场,工作人员要求你参加一个情绪状态跟踪活动。在你的同意下,机场的工作人员(他人)现在可以跟踪更多你的个人数据。

以下是一个总结:

  • OpenID 提供身份验证并验证你是谁

  • OAuth 是一种授权,允许他人代表你做事情

应用程序注册

使用 Azure 保护 Web API 的第一步是在 AAD 中创建应用程序注册。执行以下步骤以完成此操作:

  1. 在搜索栏中键入active dir以导航到Azure Active Directory

图 9.35:在 portal.azure 中搜索 Azure Active Directory

图 9.35:在 portal.azure 中搜索 Azure Active Directory

  1. 在新窗口中,点击“应用程序注册”选项(1)。

  2. 然后,点击“新建注册”按钮(2):

图 9.36:Azure 应用程序注册

图 9.36:Azure 应用程序注册

  1. 在新窗口中,输入Chapter09WebApi作为名称。

  2. 保持其他设置默认,并点击“注册”按钮:

图 9.37:名为 Chapter09WebApi 的新应用程序注册

图 9.37:名为 Chapter09WebApi 的新应用程序注册

  1. 要访问 API,你需要至少一个作用域或角色。在这个例子中,你将创建一个名为access_as_user的作用域。

  2. 作用域通常可以用来控制 API 的哪些部分对你可访问。为了让作用域对所有用户可用,你需要选择管理员和用户

  3. 在这个简单的例子中,假设令牌有效,你将允许访问一切。因此,选择“作为用户访问所有内容”选项。其他字段的精确值并不重要:

图 9.38:对所有用户可用的 access_as_user 作用域

图 9.38:对所有用户可用的 access_as_user 作用域

使用 Azure 保护 Web API 的第一步是在 AAD 中创建应用程序注册。下一个主题将介绍如何在.NET 中实现 Web API 的安全。

实现 Web API 安全

本节将重点介绍如何通过编程方式获取令牌并使用它。因此,首先安装 NuGet,它使用 Microsoft 身份平台进行 JWT 验证:

dotnet add package Microsoft.Identity.Web

在 Bootstrap 文件夹中,创建SecuritySetup类:

    public static class SecuritySetup
    {
        public static IServiceCollection AddSecurity(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment env)
        {
            services.AddMicrosoftIdentityWebApiAuthentication(configuration);
            return services;
        }
    }

然后,在Program.cs中,将以下内容追加到services

.AddSecurity()

注入的服务是授权中间件所需的。因此,在app上添加以下内容以添加授权中间件:

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

这将在所有带有[Authorize]属性的端点上触发。确保前两行在app.MapControllers();之前放置,否则中间件将无法与你的控制器连接。

appsettings.json中,添加以下配置以链接到您的AzureAd安全配置:

  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "ClientId": "2d8834d3-6a27-47c9-84f1-0c9db3eeb4ba",
    "TenantId": "ddd0fd18-f056-4b33-88cc-088c47b81f3e",
    "Audience": "api://2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb"
  }

最后,在每个控制器上方添加Authorize属性以实现您选择的任何类型的安全:

    [Authorize]
    [ApiController]
    [RequiredScope("access_as_user")]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase

Authorize属性对于任何类型的安全实现都是必不可少的。此属性将执行通用的令牌验证,而[RequiredScope("access_as_user")]将检查是否包含access_as_user作用域。现在您拥有了一个安全的 API。如果您尝试调用WeatherForecast端点,您将收到401 – 未授权错误。

注意

您可以在packt.link/ruj9o找到用于此示例的代码。

在下一节中,您将学习如何通过令牌生成器应用程序生成令牌,并使用它来安全地访问您的 API。

令牌生成器应用程序

要调用 API,您需要通过创建控制台应用程序来生成一个令牌。但在这样做之前,您需要在应用程序注册中配置一项内容。您的控制台应用程序被视为桌面应用程序。因此,在登录时,您需要一个重定向 URI。这个 URI 与代码一起返回,用于获取访问令牌。为了实现这一点,请执行以下步骤:

  1. 从 AAD 的左侧面板中选择认证选项(1)以查看所有外部应用程序的配置。

  2. 接下来,点击添加平台按钮(2)以配置新应用程序(令牌生成器):

图 9.39:带有配置新应用程序选项的认证窗口

图 9.39:带有配置新应用程序选项的认证窗口

  1. 配置平台部分,选择移动和桌面应用程序按钮(3)以注册控制台应用程序令牌生成器:

图 9.40:选择认证的移动和桌面应用程序平台

图 9.40:选择认证的移动和桌面应用程序平台

屏幕上将会打开一个新窗口。

  1. 输入您的自定义重定向 URI,指定在请求令牌时成功登录到 AAD 后返回的位置。在这种情况下,这并不那么重要。所以,输入任何 URL。

  2. 然后,点击配置按钮(4):

图 9.41:配置重定向 URI

图 9.41:配置重定向 URI

这就完成了 AAD 的配置。现在,您已经拥有了所有安全基础设施,可以构建一个控制台应用程序来从 AAD 生成访问令牌:

  1. 首先,创建一个名为Chapter09.TokenGenerator的新项目。这将允许您生成调用 API 所需的授权令牌。

  2. 然后,将其设置为.NET Core 控制台应用程序以保持简单并显示生成的令牌。

  3. 通过运行以下命令添加Microsoft.Identity.Client

    dotnet add package Microsoft.Identity.Client
    

这将允许您稍后请求令牌。

  1. 接下来,在 Program.cs 中创建一个方法来初始化 AAD 应用程序客户端。这将用于提示浏览器登录,就像您登录到 Azure 门户一样:

    static IPublicClientApplication BuildAadClientApplication()
    {
        const string clientId = "2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb"; // Service
        const string tenantId = "ddd0fd18-f056-4b33-88cc-088c47b81f3e";
        const string redirectUri = "http://localhost:7022/token";
        string authority = string.Concat("https://login.microsoftonline.com/", tenantId);
        var application = PublicClientApplicationBuilder.Create(clientId)
            .WithAuthority(authority)
            .WithRedirectUri(redirectUri)
            .Build();
        return application;
    }
    

    注意

    在前面的代码中使用的值将根据 AAD 订阅的不同而有所不同。

如您所见,应用程序使用在 AAD 中配置的 clientIdtenantId

  1. 创建另一个方法来使用需要 Azure 用户登录以获取身份验证令牌的应用程序:

    static async Task<string> GetTokenUsingAzurePortalAuth(IPublicClientApplication application)
    {
    
  2. 现在,定义您需要的范围:

                var scopes = new[] { $"api://{clientId}/{scope}" };
    

如果您不是使用默认值,请将 api://{clientId}/{scope} 替换为您自己的应用程序 ID URI。

  1. 然后,尝试获取缓存的令牌:

                AuthenticationResult result;
                try
                {
                    var accounts = await application.GetAccountsAsync();
                    result = await application.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
                }
    

如果登录是在之前完成的,则需要缓存令牌检索。如果您之前没有登录以获取令牌,您将需要登录到 Azure AD:

            catch (MsalUiRequiredException ex)
            {
                result = await application.AcquireTokenInteractive(scopes)
                    .WithClaims(ex.Claims)
                    .ExecuteAsync();
            }
  1. 将访问令牌作为已登录用户的结果返回,以便您以后可以使用它来访问您的 API:

                return result.AccessToken;
    
  2. 现在,调用这两个方法并打印结果(使用最小 API):

    var application = BuildAadClientApplication();
    var token = await GetTokenUsingAzurePortalAuth(application);
    Console.WriteLine($"Bearer {token}");
    
  3. 最后,当您运行令牌应用程序时,它将要求您登录:

图 9.42:来自 Azure 的登录请求

图 9.42:来自 Azure 的登录请求

成功登录后,您将被重定向到配置的重定向 URI,并显示以下消息:

Authentication complete. You can return to the application. Feel free to close this browser tab.

您将看到令牌将在控制台窗口中返回:

图 9.43:控制台应用程序中的应用程序注册生成的令牌

图 9.43:控制台应用程序中的应用程序注册生成的令牌

现在,您可以使用 jwt.io/ 网站检查令牌。以下屏幕显示两个部分:EncodedDecodedDecoded 部分分为以下部分:

  • HEADER:这包含令牌的类型和用于加密令牌的算法。

  • PAYLOAD:令牌中编码的声明包含信息,例如谁请求了令牌以及授予了哪些访问权限:

图 9.44:使用您的应用程序注册在 jwt.io 网站上编码和解码 JWT 版本

图 9.44:使用您的应用程序注册在 jwt.io 网站上编码和解码 JWT 版本

在本节中,您学习了如何保护未受保护的 API。安全性不仅限于授权令牌。作为一名专业开发者,您必须了解 API 中最常见的漏洞。根据行业趋势,这份列表每四年更新一次,称为开放 Web 应用程序安全项目(OWASP),可在 owasp.org/www-project-top-ten/ 访问。

在下一节中,您将应用 Swagger 与授权令牌一起工作的所需更改。

配置 Swagger 身份验证

要通过 Swagger 传递授权头,您需要添加一些配置。按照以下步骤进行操作:

  1. 为了渲染一个授权按钮,请在SwaggerSetup类中的AddSwagger方法和services.AddSwaggerGen(cfg =>部分内添加以下代码块:

                    cfg.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme()
                    {
                        Name = "Authorization",
                        Type = SecuritySchemeType.ApiKey,
                        Scheme = "Bearer",
                        BearerFormat = "JWT",
                        In = ParameterLocation.Header,
                        Description = $"Example: \"Bearer YOUR_TOKEN>\"",
                    });
    
  2. 为了将带有授权头的令牌值转发,请添加以下代码片段:

                    cfg.AddSecurityRequirement(new OpenApiSecurityRequirement
                    {
                        {
                            new OpenApiSecurityScheme
                            {
                                Reference = new OpenApiReference
                                {
                                    Type = ReferenceType.SecurityScheme,
                                    Id = "Bearer"
                                }
                            },
                            new string[] {}
                        }
                    });
    
  3. 当您导航到localhost:7021/index.xhtml时,您将看到它现在包含Authorize按钮:

图 9.45:带有授权按钮的 Swagger 文档

图 9.45:带有授权按钮的 Swagger 文档

  1. 点击Authorize按钮以允许您输入令牌:

图 9.46:点击授权按钮后的令牌输入

图 9.46:点击授权按钮后的令牌输入

  1. 现在,发送一个请求:

图 9.47:Swagger 生成的状态为 200 的请求

图 9.47:Swagger 生成的状态为 200 的请求

您将看到已添加授权头,并返回了ok响应(HTTP 状态码200)。

在本节中,您添加了一些配置以通过 Swagger 传递授权头。

注意

您可以在packt.link/hMc2t找到此示例使用的代码。

如果您出错并且令牌验证失败,您将收到401 – 未授权403 – 禁止状态码返回(通常没有任何详细信息)。修复此错误可能很头疼。然而,获取有关出错原因的更多信息并不太难。下一节提供了更多详细信息。

令牌验证错误故障排除

要模拟此场景,尝试通过在appsettings.json中更改任何单个符号(例如,最后一个字母更改为b)来使客户端-id 无效。运行请求并查看响应如何显示为401,日志中没有任何其他内容。

所有验证和入站和出站请求都可以通过管道跟踪。您必须做的就是将默认的最小日志级别从info更改为Trace。您可以通过替换appsettings.development.json文件内容来实现这一点:

{
  "Logging": {
    "LogLevel": {
      "Default": "Trace",
      "Microsoft": "Trace",
      "Microsoft.Hosting.Lifetime": "Trace"
    }
  }
}

不要将appsettings.development.jsonappsettings.json混合使用。前者用于整体配置,而后者覆盖配置,但仅在某些环境中生效——在本例中是开发(本地)环境。

如果您再次运行相同的请求,您现在将在控制台看到详细的日志:

Audience validation failed. Audiences: 'api://2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb'. Did not match: validationParameters.ValidAudience: 'api://2d8834d3-6a27-47c9-84f1-0c9db3eeb4bc' or validationParameters.ValidAudiences: 'null'.

深入检查会发现错误如下:

Audience validation failed; Audiences: 'api://2d8834d3-6a27-47c9-84f1-0c9db3eeb4bb'. Did not match validationParameters

此错误指示 JWT 中配置的受众不匹配:

图 9.48:带有错误高亮的令牌验证错误

图 9.48:带有错误高亮的令牌验证错误

现在是时候学习关于 SOA 架构了,在这种架构中,系统的组件作为独立的服务托管。

面向服务架构

软件架构已经走了很长的路——从单体架构发展到面向服务架构(SOA)。SOA 是一种将应用程序的主要层作为独立服务托管的架构。例如,将有一个或多个用于数据访问的 Web API,一个或多个用于业务逻辑的 Web API,以及一个或多个消费所有内容的客户端应用程序。流程将如下所示:客户端应用程序调用业务 Web API,该 API 调用另一个业务 Web API 或数据访问 Web API。

然而,现代软件架构更进一步,引入了一种更进化的架构,称为微服务架构。

微服务架构

微服务架构是应用了单一职责原则的 SOA。这意味着,你不再有作为一层的服务,而是托管了具有单一职责的自包含模块。一个自包含的服务具有数据访问层和业务逻辑层。在这种方法中,每个模块都有许多服务,而不是每个层都有许多服务。

这些自包含模块的目的是允许多个团队同时在不同部分上工作,而不会相互干扰。除此之外,系统中的部分可以独立扩展和托管,且没有单点故障。此外,每个团队都可以自由使用他们最熟悉的任何技术栈,因为所有通信都通过 HTTP 调用进行。

这结束了本主题的理论部分。在接下来的部分中,你将通过一个活动将所学知识付诸实践。

活动九.01:使用微服务架构实现文件上传服务

微服务应该是自包含的,只做一件事。在这个活动中,你将总结将一段代码提取到微服务中的步骤,该微服务管理你通过网页(删除、上传和下载)与文件交互的方式。这应该作为创建新微服务时需要完成的整体有效清单。

执行以下步骤来完成此操作:

  1. 创建一个新的项目。在这种情况下,它将是一个基于.NET 6.0 框架的.NET Core Web API项目。

  2. 命名为Chapter09.Activity.9.01

  3. 现在,添加常用的 NuGet 包:

    • AutoMapper.Extensions.Microsoft.DependencyInjection

    • FluentValidation.AspNetCore

    • Hellang.Middleware.ProblemDetails

    • Microsoft.AspNetCore.Mvc.NewtonsoftJson

    • Microsoft.Identity.Web

    • Swashbuckle.AspNetCore

  4. 接下来,将 Azure Blobs 客户端包包含为Azure.Storage.Blobs

  5. 创建一个或多个控制器以与 Web API 进行通信。在这种情况下,你将FileController移动到Controllers文件夹。

  6. 为了创建一个或多个用于业务逻辑的服务,将FilesService移动到Services文件夹,将FileServiceSetup移动到Bootstrap文件夹。

  7. 然后使用 XML 文档和 Swagger 记录 API。

  8. 更新 csproj 文件以包含 XML 文档。

  9. SwaggerSetup 复制到 Bootstrap 文件夹。

  10. 配置 Controllers。在这种情况下,它将是 ControllersConfigurationSetup 类和 AddControllersConfiguration 方法下的一个单行 services.AddControllers()

  11. 配置问题详细信息错误映射。在这种情况下,你将不会显式处理任何异常。因此,你将保持它在 ExceptionMappingSetup 类和 AddExceptionMappings 以及 services.AddProblemDetails() 方法中的单行。

  12. 保护 API。

  13. 为新服务创建 AAD 应用程序注册。请参考“保护 Web API”部分中的“应用程序注册”子部分。

  14. 根据 Azure AD 应用程序注册客户端、tenantapp ID 更新新服务的配置。

  15. 注入所需的服务并配置 API 管道。

  16. 复制 Program 类。

  17. 由于 ConfigureServices 方法包含额外的服务,你不需要删除它们。保持 Configure 方法不变。

  18. 通过 Swagger 运行服务并上传测试文件。不要忘记首先使用之前学到的更新值生成令牌生成应用程序中的令牌。

  19. 然后,尝试获取你刚刚上传的测试文件。你应该看到状态码 200

    • 获取下载链接请求:

图 9.49:Swagger 中的获取下载链接请求

图 9.49:Swagger 中的获取下载链接请求

  • 获取下载链接响应:

图 9.50:Swagger 中的获取下载链接响应

图 9.50:Swagger 中的获取下载链接响应

注意

该活动的解决方案可以在 packt.link/qclbF 找到。

到目前为止创建的所有服务都需要考虑诸如托管、扩展和可用性等问题。在下一节中,你将了解无服务器和 Azure Functions。

Azure Functions

在前一节中,你了解到微服务架构是一个包含数据访问层和业务逻辑层的自包含服务。采用这种方法,每个模块都有许多服务。然而,与微服务一起工作,尤其是在开始时,可能会感觉像是一项麻烦。它可能会引发以下疑问:

  • “不够大”是什么意思?

  • 你应该在不同的服务器上托管还是在同一台机器上托管?

  • 另一种云托管模型是否更好?

这些问题可能令人感到压倒性。因此,通过 HTTP 调用你的代码的一种简单方法是通过使用 Azure Functions。Azure Functions 是一种无服务器解决方案,它允许你在云中调用你的函数。无服务器并不意味着没有服务器;只是你不需要自己管理它。在本节中,你将尝试将 练习 9.02 中的 CurrentTimeController 移植到 Azure Function。

注意

在继续下一步之前,首先使用以下说明安装 Azure Functions Core Tools:docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v3%2Cwindows%2Ccsharp%2Cportal%2Cbash%2Ckeda#install-the-azure-functions-core-tools。Azure Functions Core Tools 还需要安装 Azure CLI(如果你想要发布 Azure Functions 应用程序,而不是在服务器上)。请按照以下说明操作:docs.microsoft.com/en-us/cli/azure/install-azure-cli-windows?tabs=azure-cli

执行以下步骤以实现此目的:

  1. 在 VS Code 中,点击 扩展 图标 (1)。

  2. 然后,在搜索文本框中搜索 azure function (2)。

  3. 然后,安装 Azure Functions 扩展 (3):

图 9.51:在 VS Code 中搜索 Azure Functions 扩展

图 9.51:在 VS Code 中搜索 Azure Functions 扩展

在左侧将出现一个新的 Azure 选项卡。

  1. 点击新的 Azure 选项卡。

  2. 在新页面上,点击 添加 按钮 (1)。

  3. 选择 创建函数… 选项 (2):

图 9.52:带有创建函数…按钮的 VS Code 中的新 Azure Functions 扩展

图 9.52:带有创建函数…按钮的 VS Code 中的新 Azure Functions 扩展

  1. 在创建函数窗口中,选择 HTTP trigger

  2. 输入名称 GetCurrentTime.Get

  3. 将存放项目的名称命名为 Pact.AzFunction

  4. 在最后一屏,选择 anonymous

在这一点上,没有必要对这个配置进行过多的详细说明。这里要考虑的关键点是,该函数将通过 HTTP 请求公开访问。通过这些步骤创建的新项目将包括新的 Azure Function。

  1. 现在,导航到新项目文件夹的根目录以运行项目。

  2. 然后,按 F5 或点击 开始调试以更新此列表… 消息:

图 9.53:带有待构建项目的 Azure 扩展窗口

图 9.53:带有待构建项目的 Azure 扩展窗口

你会注意到,在构建成功后,消息会变为函数名称:

图 9.54:带有后构建项目的 Azure 扩展窗口

图 9.54:带有后构建项目的 Azure 扩展窗口

VS Code 底部的终端输出窗口显示了以下详细信息:

图 9.55:成功构建后的终端输出

图 9.55:成功构建后的终端输出

  1. 接下来,在 VS Code 探索器中打开 GetCurrentTime.cs

  2. 注意,在 练习 9.01 中,你使用了 GetCurrentTime 代码。你将在这里重用相同的代码:

    namespace Pact.Function
    {
        public static class GetCurrentTime
        {
            [Function("GetCurrentTime")]
            public static HttpResponseData Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData request,
                FunctionContext executionContext)
    

模板名称是根据你之前的配置生成的。Azure Function 通过 [Function("GetCurrentTime")] 属性绑定到 HTTP 端点。

在你继续之前,你可能已经注意到,尽管获取当前时间的函数使用了 timezoneid 变量,但这里还没有这样的变量(目前还没有)。与之前创建的用于将参数传递给 Azure Function 的 REST API 不同,这里你可以通过请求体或查询变量来传递它。这里唯一的问题是,你必须自己解析它,因为没有像控制器方法那样的属性绑定。你需要的是一个简单的字符串,它可以作为查询参数传递。这一行解析请求中的 URI 并从查询字符串中获取 timezoneId 变量。

  1. 使用 timezoneId 变量来获取特定时区的时间:

            {
                var timezoneId = HttpUtility.ParseQueryString(request.Url.Query).Get("timezoneId");
    
  2. 接下来是业务逻辑。因此,使用 timezoneId 变量来获取指定时区的时间:

    var timezoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezoneId);
                var time = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timezoneInfo);
    
  3. 最后,将结果序列化为 HTTP 200 Ok 作为 text/plain 内容类型:

    var response = request.CreateResponse(HttpStatusCode.OK);
                response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
                response.WriteString(time.ToString());
                return response;
    }
    
  4. 运行此代码并导航到 http://localhost:7071/api/GetCurrentTime?timezoneId=Central%20European%20Standard%20Time

你将得到该时区当前的时间,如下所示:

2022-08-07 16:02:03

你现在已经掌握了 Azure Functions 的工作原理——一种在云端调用你的函数的无服务器解决方案。

在这本书中,你已经走过了一段漫长的路程,但随着这个最后活动的结束,你已经掌握了创建自己的现代 C# 应用程序所需的所有概念和技能。

摘要

在本章中,你学习了如何使用 ASP.NET Core Web API 模板构建自己的 REST Web API。你学习了如何使用引导类处理配置的日益增长复杂性。你被介绍到 OpenAPI 标准,以及用于调用 API 以查看是否成功渲染文档的 Swagger 工具。你还深入了解了将异常映射到特定的 HTTP 状态代码,以及如何将 DTO 映射到领域对象以及反之亦然。在章节的后半部分,你练习了使用 AAD 保护 Web API,了解了微服务概念,并创建了一个——通过一个新的专用 Web API 和通过 Azure Function。

了解如何创建和消费 Web API 非常重要,因为那正是大多数软件开发的核心。你可能在某个时候会消费或创建 Web API。即使你不需要自己创建一个,掌握它的来龙去脉也会帮助你作为一个专业开发者。

这标志着 《C# 实战》 的结束。在这本书中,你从 C# 编程的基础学起,从使用算术和逻辑运算符的简单程序开始,然后是越来越复杂的清洁编码、委托和 lambda 表达式、多线程、客户端和服务器 Web API 以及 Razor Pages 应用程序的概念。

这本书的印刷版到此结束,但这并不是您旅程的终点。访问 GitHub 仓库packt.link/sezEm,获取额外章节——第十章“自动化测试”和第十一章“生产就绪的 C#:从开发到部署”——涵盖在深入探讨使用 Nunit(C#最受欢迎的第三方测试库)进行单元测试之前的不同测试形式,熟悉 Git 并使用 GitHub 来远程备份您的代码,启用持续部署(CD)并将代码部署到云端,使用 Microsoft Azure 学习云,以及学习如何使用 GitHub Actions 执行 CI 和 CD,将应用程序更改实时推送到生产环境。

Rayon

Jason Hales

Rayon

Almantas Karpavicius

Rayon

Mateus Viegas

嘿!

我们是这本书的作者 Jason Hales、Almantas Karpavicius 和 Mateus Viegas。我们真心希望您喜欢阅读我们的书,并觉得它对学习 C#很有帮助。

如果您能在亚马逊上留下对《C# Workshop》的评论,分享您的想法,这将真正对我们(以及其他潜在读者!)有所帮助。

访问链接 packt.link/r/1800566492

或者

扫描二维码留下您的评论。

Barcode

您的评论将帮助我们了解这本书中哪些内容做得很好,哪些内容需要改进以供未来版本使用,所以这真的非常感谢。

祝好,

Jason Hales、Almantas Karpavicius 和 Mateus Viegas

Packt Logo

posted @ 2025-10-23 15:07  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报