C--秘籍-全-

C# 秘籍(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我为什么写这本书

在职业生涯中,我们收集了许多工具。无论是概念、技术、模式还是可重用代码,这些工具帮助我们完成工作。我们收集的越多,越好,因为我们有很多问题要解决,要构建的应用程序也很多。C# Cookbook 通过提供各种配方,为您的工具集增添了一笔。

事物随时间变化,包括编程语言。截至本书写作时,C# 编程语言已经超过 20 年,并且在其生命周期内软件开发已经发生了变化。可以写很多配方,这本书承认了 C#随时间的演变以及现代 C#代码使我们的生产力更高的事实。

本书充满了我在整个职业生涯中使用的配方。除了阐述问题、呈现代码和解释解决方案外,每次讨论还包括更深入的洞察,解释为什么每个配方都很重要。在整本书中,我避免了对流程的倡导或绝对声明“你必须这样做”,因为在创建软件时我们需要做出权衡。事实上,你会发现有几次讨论关于一个配方的后果或权衡。这尊重了你可以考虑一个配方在多大程度上适合你的事实。

本书的受众

本书假定你已经了解基本的 C#语法。也就是说,这里有适合各个开发者水平的配方。无论你是初学者、中级还是高级开发者,都应该能找到适合你的内容。如果你是架构师,可能会有一些有趣的配方帮助你迅速掌握最新的 C#技术。

本书的组织方式

在为这本书进行头脑风暴时,整个焦点都集中在回答“C# 开发人员需要做什么?”的问题上。查看列表时,某些模式浮现并演变成章节:

  • 当我编写代码时,我做的第一件事之一就是构建类型并组织应用程序。因此,我写了第一章,展示如何创建和组织类型。你将看到处理模式的配方,因为这是我编码的方式。

  • 创建类型后,我们添加类型成员,如方法及其包含的逻辑,这是第二章中自然的一类配方。

  • 代码有何用处,除非它能正常工作?这就是为什么我添加了第三章,其中包含有助于提高代码质量的配方。虽然这一章节充满了有用的配方,你可能会想查看一个展示如何使用可空引用类型的配方。

虽然第一章到第三章遵循“C# 开发人员需要做什么?”的主题,从第四章到书的结尾,我进行了分离,专注于技术特定的重点:

  • 许多人认为语言集成查询(LINQ)是一个数据库技术。虽然 LINQ 对于与数据库一起工作很有用,但它也非常适用于内存数据操作和查询。这就是为什么第四章讨论了使用名为 LINQ to Objects 的内存提供程序的功能。

  • 反射是 C# 1 的一部分,但动态编程在 C# 4 中稍后出现。我认为在第五章中讨论这两种技术很重要,甚至展示动态编程在某些情况下可能比反射更好。还请查看 Python 互操作食谱。

  • 异步编程是 C#的一个重要补充,表面上看似乎很简单。第六章涵盖了涉及几个你可能不知道的重要功能的异步食谱。

  • 所有应用程序都使用数据,无论是保护、解析还是序列化。第七章包括涵盖数据处理不同需求的多个食谱。它侧重于一些用于处理数据的新库和算法。

  • C#语言在模式匹配领域在最近几个版本中发生了最大的变化之一。有太多模式匹配的内容,我成功地填充了第八章,仅用于模式匹配的食谱。

  • C#继续发展,第九章涵盖了专门针对 C# 9 的食谱。我们将研究一些新特性并讨论如何应用它们。虽然我在讨论中提供了见解,但请记住,有时新功能在后续版本中可能更加完善。如果您对最前沿感兴趣,这些食谱非常有趣。

本书使用的约定

本书使用以下印刷约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

固定宽度

用于程序清单,以及段落内用于引用诸如变量或函数名称、数据库、数据类型、环境变量、语句和关键字等程序元素。

固定宽度加粗

显示用户应按字面输入的命令或其他文本。

固定宽度斜体

显示应替换为用户提供值或由上下文确定的值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素指示警告或注意事项。

使用代码示例

补充资料(代码示例,练习等)可在https://github.com/JoeMayo/csharp-nine-cookbook下载。

如果您有技术问题或使用代码示例时出现问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书附带示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则您无需征得我们的许可。例如,编写一个使用本书中多个代码片段的程序不需要许可。销售或分发来自 O’Reilly 书籍的示例代码需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码整合到产品文档中需要许可。

我们感谢,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“C# Cookbook by Joe Mayo (O’Reilly). Copyright 2022 Mayo Software, LLC, 978-1-492-09369-5.”

如果您认为您对示例代码的使用超出了合理使用范围或上述授权,请随时通过 permissions@oreilly.com 联系我们。

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频内容。有关更多信息,请访问 http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • Gravenstein Highway North 1005 号

  • Sebastopol, CA 95472

  • 800-998-9938 (美国或加拿大)

  • 707-829-0515 (国际或本地)

  • 707-829-0104 (传真)

我们为这本书制作了一个网页,列出勘误、示例和任何其他信息。您可以访问 https://oreil.ly/c-sharp-cb

通过邮件 bookquestions@oreilly.com 来评论或提问关于本书的技术问题。

关于我们的书籍和课程的新闻和信息,请访问 http://oreilly.com

在 Facebook 找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 观看我们:http://www.youtube.com/oreillymedia

致谢

从概念到交付,有许多人参与创建新书。我想要感谢那些在 C# Cookbook 上帮助的人们。

高级内容采购编辑阿曼达·奎因为这本书的概念提供了帮助,并在我概述其内容时提供了反馈意见。内容开发编辑安吉拉·鲁菲诺帮助我制定了标准和工具,对我的写作提出了反馈,并在整个过程中给予了极大的帮助。技术编辑巴萨姆·阿卢吉利、奥克塔维奥·埃尔南德斯和沙德曼·库德奇卡纠正了错误,提供了出色的见解,并分享了新的想法。制作编辑和供应商协调员凯瑟琳·托泽及时通知我新的早期发布情况,并协调其他生产事项。编辑贾斯汀·比林在改善我的写作方面做得非常出色。这本书的实现离不开幕后的许多人的支持。

我想要感谢大家。我对你们的贡献心存感激,祝愿你们一切顺利。

第一章:构建类型和应用

开发人员的首要任务之一是设计、组织和创建新类型。本章通过提供几种有用的方法来帮助完成这些任务,包括设置项目、管理对象生命周期和建立模式。

建立架构

在初次设置项目时,你需要考虑整体架构。有一个叫做关注点分离的概念,即应用程序的每个部分都有特定的目的(例如,UI 层与用户交互,业务逻辑层管理规则,数据层与数据源交互)。每一层都有自己的目的或职责,并包含执行其操作的代码。

除了促进更松散耦合的代码之外,关注点分离还使开发人员更容易处理代码,因为更容易找到特定操作发生的位置。这使得添加新功能和维护现有代码更加容易。其好处包括更高质量的应用程序和更高效的工作。因此,从一开始就做好准备是值得的,这也是为什么我们有第 1.5 节。

与松散耦合代码相关的还有控制反转(IoC),它有助于解耦代码并促进可测试性。第 1.2 节解释了其工作原理。在关于确保质量的章节第三章中,您将了解 IoC 如何适用于单元测试。

应用模式

我们编写的大部分代码是交易脚本(Transaction Script),用户通过 UI 与之交互,代码执行数据库中的创建、读取、更新或删除(CRUD)操作,并返回结果。有时,我们需要处理对象之间复杂的交互,这些问题难以组织。我们需要其他模式来解决这些难题。

本章以比较非正式的方式介绍了一些有用的模式。其核心思想是,你会有一些代码可以重命名和调整以适应你的目的,以及一个关于何时使用某个模式的理由。在阅读每个模式时,试着思考你已经编写的其他代码或其他情况,看看该模式如何简化代码。

如果你遇到不同系统的不同 API 并需要在它们之间切换的问题,你会对阅读第 1.8 节感兴趣。它展示了如何构建一个单一接口来解决这个问题。

管理对象生命周期

我们执行的其他重要任务与对象生命周期相关,即在内存中实例化对象,将对象保留在内存中进行处理,并在不再需要对象时释放该内存。第 1.3 节和 1.4 节的配方展示了一些漂亮的工厂模式,让您能够将对象创建与代码解耦。这与前面提到的 IoC 概念是一致的。

通过流接口管理对象创建是一种方法,您可以通过方法包含可选设置,并在对象构建之前进行验证。

另一个重要的对象生命周期考虑是处置。考虑过度资源消耗的缺点,包括内存使用、文件锁定以及任何持有操作系统资源的其他对象。这些问题通常导致应用程序崩溃,并且很难检测和修复。执行适当的资源清理非常重要,这是我们在本书中将要讨论的第一个方法。

1.1 管理对象生命周期末端

问题

由于过度资源使用,您的应用程序正在崩溃。

解决方案

这里是具有原始问题的对象:

using System;
using System.IO;

public class DeploymentProcess
{
    StreamWriter report = new StreamWriter("DeploymentReport.txt");

    public bool CheckStatus()
    {
        report.WriteLine($"{DateTime.Now} Application Deployed.");

        return true;
    }
}

这是解决问题的方法:

using System;
using System.IO;

public class DeploymentProcess : IDisposable
{
    bool disposed;

    readonly StreamWriter report = new StreamWriter("DeploymentReport.txt");

    public bool CheckStatus()
    {
        report.WriteLine($"{DateTime.Now} Application Deployed.");

        return true;
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // disposal of purely managed resources goes here
            }

            report?.Close();
            disposed = true;
        }
    }

    ~DeploymentProcess()
    {
        Dispose(disposing: false);
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }
}

这是Main方法,使用此对象:

static void Main(string[] args)
{
    using (var deployer = new DeploymentProcess())
    {
        deployer.CheckStatus();
    }
}

讨论

此代码中的问题出在StreamWriter report上。每当使用某种资源(如report文件引用)时,您需要释放(或处理)该资源。此处的特定问题发生在应用程序通过StreamWriter请求文件句柄来自 Windows 操作系统。该应用程序拥有该文件句柄,并且 Windows 期望拥有的应用程序释放该句柄。如果您的应用程序关闭而没有释放该句柄,Windows 将阻止所有应用程序(包括随后运行的您的应用程序)访问该文件。在最糟糕的情况下,所有内容都会在一个难以找到的场景中崩溃,这涉及多人数小时调试关键生产问题。这是因为 Windows 认为文件仍在使用中。

解决方案是实现处置模式,其中包括添加代码以便于释放资源。解决方案代码实现了IDisposable接口。IDisposable仅指定了Dispose()方法,无需参数,但除了添加该方法之外,还需执行更多操作,包括另一个Dispose方法重载,用于跟踪要执行的处置类型以及一个可选的终结器。

复杂化实现的是控制处置逻辑的字段和参数:disposeddisposingdisposed字段确保此对象仅被处置一次。在Dispose(bool)方法内部,有一个if语句,确保如果disposedtrue(对象已处置),则不会执行任何处置逻辑。通过Dispose(bool)的第一次,disposed将为false,并且if块中的代码将执行。确保您还设置disposedtrue,以确保此代码不再运行——不这样做的后果将暴露于像ObjectDisposedException这样的不可预测错误。

disposing参数告诉Dispose(bool)它是如何被调用的。请注意,Dispose()(无参数)和最终器调用Dispose(bool)。当Dispose()调用Dispose(bool)时,disposing 为true。如果调用代码正确编写,这使得在using语句中实例化DeploymentProcess或在try/finally块的finally中包装它变得很容易。

最终器会使用disposing设置为false调用Dispose(bool),这意味着它不是由调用应用程序代码运行的。Dispose(bool)方法使用disposing值来确定是否应释放托管资源。无论是Dispose()还是最终器调用Dispose(bool),非托管资源都会被释放。

让我们澄清一下最终器(finalizer)的作用。当.NET CLR 垃圾收集器(GC)清理内存中的对象时,它会执行对象的最终器。GC 可以多次通过对象,调用最终器是它执行的最后几件事情之一。由.NET CLR 实例化和管理的托管对象,你无法控制它们何时释放,这可能导致无序释放的情况发生。你必须检查 disposing 值,以防止在依赖对象被 GC 首先释放时出现Object​Dispose⁠d​Exception

最终器给你的是清理非托管资源的方法。非托管资源,如StreamWriter获取的文件句柄,不属于.NET CLR,而属于 Windows 操作系统。有些情况下,开发人员可能需要显式调用 Win32/64 动态链接库(DLL)以获取 OS 或第三方设备的句柄。你需要最终器的原因是,如果对象没有正确释放,没有其他方法释放该句柄,这可能会因为需要释放托管对象而导致系统崩溃。因此,最终器是一个备用机制,确保需要释放非托管资源的代码会执行。

很多应用程序没有使用非托管资源的对象。在这种情况下,甚至不要添加最终器。最终器会增加对象的开销,因为 GC 必须进行账户处理来识别是否有最终器对象,并在多次通过集合时调用它们。省略最终器可以避免这种情况。

顺便说一句,在Dispose()方法中记得调用GC.SuppressFinalize。这是另一个优化,告诉 GC 不要为此对象调用最终器,因为当应用程序调用IDisposable.Dispose()时,所有资源(托管和非托管)都已释放。

注意

通常情况下,即使类没有终结器,也应该在 Dispose() 中调用 GC.SuppressFinalize。尽管如此,还是有一些微妙之处可能会引起您的兴趣。如果一个类既是 sealed 且没有终结器,可以安全地省略对 GC.SuppressFinalize 的调用。然而,未 sealed 的类可能会被另一个包含终结器的类继承。在这种情况下,调用 GC.SuppressFinalize 可以防止不当的实现。

对于没有终结器的类,GC.SuppressFinalize 没有效果。如果选择省略对 GC.SuppressFinalize 的调用并且类有一个终结器,CLR 将调用该终结器。

Main 方法显示如何正确使用 DeploymentProcess 对象。它实例化并在 using 语句中包装该对象。对象在 using 语句块结束之前存在于内存中。此时,程序调用 Dispose() 方法。

1.2 移除显式依赖关系

问题

您的应用程序紧密耦合且难以维护。

解决方案

定义您需要的类型:

public class DeploymentArtifacts
{
    public void Validate()
    {
        System.Console.WriteLine("Validating...");
    }
}

public class DeploymentRepository
{
    public void SaveStatus(string status)
    {
        System.Console.WriteLine("Saving status...");
    }
}

interface IDeploymentService
{
    void PerformValidation();
}

public class DeploymentService : IDeploymentService
{
    readonly DeploymentArtifacts artifacts;
    readonly DeploymentRepository repository;

    public DeploymentService(
        DeploymentArtifacts artifacts,
        DeploymentRepository repository)
    {
        this.artifacts = artifacts;
        this.repository = repository;
    }

    public void PerformValidation()
    {
        artifacts.Validate();
        repository.SaveStatus("status");
    }
}

并像这样启动应用程序:

using Microsoft.Extensions.DependencyInjection;
using System;

class Program
{
    readonly IDeploymentService service;

    public Program(IDeploymentService service)
    {
        this.service = service;
    }

    static void Main()
    {
        var services = new ServiceCollection();

        services.AddTransient<DeploymentArtifacts>();
        services.AddTransient<DeploymentRepository>();
        services.AddTransient<IDeploymentService, DeploymentService>();

        ServiceProvider serviceProvider =
            services.BuildServiceProvider();

        IDeploymentService deploymentService =
            serviceProvider.GetRequiredService<IDeploymentService>();

        var program = new Program(deploymentService);

        program.StartDeployment();
    }

    public void StartDeployment()
    {
        service.PerformValidation();
        Console.WriteLine("Validation complete - continuing...");
    }
}

讨论

紧密耦合 这个术语通常指的是代码的一部分被赋予实例化其使用的类型(依赖项)的责任。这要求代码知道如何构建、管理生命周期并包含依赖项的逻辑。这使得代码的目的是解决其存在问题的代码变得分散。在不同的类中重复依赖项的实例化。这使得代码变得脆弱,因为依赖接口的更改会影响到需要实例化该依赖项的所有其他代码。此外,实例化其依赖项的代码使得进行正确的单元测试变得困难,甚至不可能。

解决方案是依赖注入,这是一种在一个地方定义依赖类型实例化并公开其他类型可以使用的服务来获取这些依赖项的技术。执行依赖注入有几种方法:服务定位器和控制反转(IoC)。何时使用哪种方法是一个活跃的讨论;让我们避免涉及理论领域。为了简化,此解决方案使用了 IoC,这是一种常见且直接的方法。

具体解决方案要求您拥有依赖于其他依赖类型的类型,配置类型构造函数以接受依赖项,引用一个库来帮助管理 IoC 容器,并使用容器声明如何实例化类型。以下段落解释了这是如何工作的。图 1-1 显示了解决方案的对象关系和 IoC 操作的顺序。

解决方案的 IoC

图 1-1. 解决方案的 IoC

解决方案是一个实用工具,帮助管理部署过程,验证部署过程是否配置正确。它有一个 DeploymentService 类来运行该过程。注意 DeploymentService 构造函数接受 DeploymentArtifactsDeploymentRepository 类。DeploymentService 并不实例化这些类 —— 而是通过注入方式提供。

要注入这些类,可以使用一个 IoC 容器,它是一个帮助自动实例化类型并提供依赖项类型实例的库。解决方案中的 IoC 容器,如 using 声明中所示,是 Microsoft.Extensions.DependencyInjection 命名空间,你可以引用同名的 NuGet 包。

虽然我们希望为应用程序中的每种类型注入所有依赖项,但你仍然必须直接实例化 IoC 容器,这就是为什么 Main 方法实例化 ServiceCollection 作为服务的原因。然后使用 services 实例添加所有依赖项,包括 DeploymentService

IoC 容器可以帮助管理对象的生命周期。这个解决方案使用了 AddTransient,这意味着容器在每次请求类型时应创建一个新的实例。管理对象生命周期的另外两个例子是 AddSingleton,它仅实例化对象一次并将该实例传递给所有对象;以及 AddScoped,它更多地控制对象的生命周期。在 ASP.NET 中,AddScoped 设置为当前请求。随着时间的推移,你将需要更深入地考虑对象的生命周期应该是什么,并更深入地研究这些选项。目前来说,通过 AddTransient 开始是很简单的。

BuildServiceProvider 的调用将 services(一个 ServiceCollection)转换为 ServiceProvider。术语 IoC 容器 指的是这个 ServiceProvider 实例 —— 它实例化和定位要注入的类型。

你可以看到容器正在调用 GetRequiredService 返回实现 IDeploymentService 接口的实例。回到 ServiceCollection,注意到有一个 AddTransientDeploymentService 类与 IDeploymentService 接口关联起来。这意味着 GetRequiredService 将返回一个 DeploymentService 的实例。

最后,Main 实例化 Program,带有新的 DeploymentService 实例。

回到 DeploymentService 的构造函数,你可以看到它期望使用 DeploymentArtifactsDeploymentRepository 的实例来调用。因为我们使用了 IoC 容器来实例化 DeploymentService,IoC 容器也知道如何实例化它的依赖项,这些依赖项也已添加到 ServiceCollection 中,使用了 AddTransient 进行调用。这个解决方案仅使用了三种类型;你可以构建比这更深层次的对象依赖图。

此外,请注意DeploymentService构造函数将注入的实例保存在readonly字段中,以便DeploymentService成员可以使用它们。

IoC 的优点在于只在一个地方进行实例化,您无需在构造函数或需要依赖项新实例的成员中编写所有代码。这使得您的代码更松散耦合且更易于维护。它还通过使类型更具单元测试可能性来提高质量。

参见

Recipe 3.1,“编写单元测试”

1.3 将对象创建委托给类

问题

您正在使用 IoC,但您要实例化的类型没有接口,并且您有复杂的构造要求。

解决方案

我们要实例化这个类:

using System;

public class ThirdPartyDeploymentService
{
    public void Validate()
    {
        Console.WriteLine("Validated");
    }
}

我们将用这个类来进行 IoC:

public interface IValidatorFactory
{
    ThirdPartyDeploymentService CreateDeploymentService();
}

这是IValidatorFactory的实现:

public class ValidatorFactory : IValidatorFactory
{
    public ThirdPartyDeploymentService CreateDeploymentService()
    {
        return new ThirdPartyDeploymentService();
    }
}

然后像这样实例化工厂:

public class Program
{
    readonly ThirdPartyDeploymentService service;

    public Program(IValidatorFactory factory)
    {
        service = factory.CreateDeploymentService();
    }

    static void Main()
    {
        var factory = new ValidatorFactory();
        var program = new Program(factory);
        program.PerformValidation();
    }

    void PerformValidation()
    {
        service.Validate();
    }
}

讨论

如 Recipe 1.2 中所述,IoC 是一种最佳实践,因为它解耦了依赖关系,使代码更易于维护、更具适应性和更易于测试。问题在于即使有最佳计划,也会出现异常和导致困难的情况。其中之一是在没有接口的情况下尝试使用第三方 API 时。

解决方案展示了ThirdPartyDeploymentService类。您可以查看代码以及其功能。但实际上,即使您通过反射或反汇编器阅读代码,也无济于事,因为您无法添加自己的接口。即使ThirdPartyDeploymentService是开源的,您也必须权衡是否要分叉库以进行自己的修改——这种权衡是因为您的修改在面对原始开源库的新功能和维护时可能变得脆弱。例如,.NET Framework 中的System.Net.HttpClient类就没有接口。最终,您需要评估情况并做出适合您的决定,但这里描述的工厂类可以是一个有效的解决方法。

要了解工厂类的工作原理,请观察IValidatorFactory接口。这是我们用于 IoC 的接口。接下来,看看ValidatorFactory类如何实现IValidatorFactory接口。它的CreateDeploymentService方法实例化并返回ThirdPartyDeploymentService。这就是工厂的作用:为我们创建对象。

注意

这与代理模式相关。ValidatorFactory控制对ThirdPartyDeploymentService实例的访问。但是,与其返回一个用于控制ThirdPartyDeploymentService成员访问的对象,CreateDeploymentService返回一个直接的ThirdPartyDeploymentService实例。

简化这个示例,代码不使用 IoC 容器——虽然在使用 IoC 时通常会同时使用工厂。相反,Main 方法实例化 ValidatorFactory 并将该实例传递给 Program 构造函数,这是示例的重要部分。

检查构造函数如何获取 IValidatorFactory 引用并调用 CreateDeploymentService。现在我们已经能够注入依赖项并保持所寻求的松耦合。

另一个好处是,由于 ThirdPartyDeploymentService 是在工厂类中实例化的,您可以在不影响消费代码的情况下对类实例化进行任何未来更改。

参见

食谱 1.2,“消除显式依赖项”

1.4 将对象创建委托给方法

问题

您需要一个插件框架,并且需要在应用程序逻辑之外的某个地方结构化对象实例化。

解决方案

这是带有对象创建契约的抽象基类:

public abstract class DeploymentManagementBase
{
    IDeploymentPlugin deploymentService;

    protected abstract IDeploymentPlugin CreateDeploymentService();

    public bool Validate()
    {
        if (deploymentService == null)
            deploymentService = CreateDeploymentService();

        return deploymentService.Validate();
    }
}

这些是几个实例化相关插件类的类:

public class DeploymentManager1 : DeploymentManagementBase
{
    protected override IDeploymentPlugin CreateDeploymentService()
    {
        return new DeploymentPlugin1();
    }
}

public class DeploymentManager2 : DeploymentManagementBase
{
    protected override IDeploymentPlugin CreateDeploymentService()
    {
        return new DeploymentPlugin2();
    }
}

插件类实现了 IDeploymentPlugin 接口:

public interface IDeploymentPlugin
{
    bool Validate();
}

这里是正在实例化的插件类:

public class DeploymentPlugin1 : IDeploymentPlugin
{
    public bool Validate()
    {
        Console.WriteLine("Validated Plugin 1");
        return true;
    }
}

public class DeploymentPlugin2 : IDeploymentPlugin
{
    public bool Validate()
    {
        Console.WriteLine("Validated Plugin 2");
        return true;
    }
}

最后,这是它们如何完美结合在一起的方式:

class Program
{
    readonly DeploymentManagementBase[] deploymentManagers;

    public Program(DeploymentManagementBase[] deploymentManagers)
    {
        this.deploymentManagers = deploymentManagers;
    }

    static DeploymentManagementBase[] GetPlugins()
    {
        return new DeploymentManagementBase[]
        {
            new DeploymentManager1(),
            new DeploymentManager2()
        };
    }

    static void Main()
    {
        DeploymentManagementBase[] deploymentManagers = GetPlugins();

        var program = new Program(deploymentManagers);

        program.Run();
    }

    void Run()
    {
        foreach (var manager in deploymentManagers)
            manager.Validate();
    }
}

讨论

插件系统无处不在。Excel 可以消费和发出不同类型的文档,Adobe 可以处理多种图像类型,Visual Studio Code 有许多扩展。这些都是插件系统,无论插件是否只能通过供应商或第三方获得,它们都利用了相同的概念——代码必须能够适应处理新的抽象对象类型。

尽管前面的示例在我们的日常生活中无处不在,但许多开发人员不会构建那些类型的系统。话虽如此,插件模型是增强我们应用程序可扩展性的强大机会。应用程序集成是一个频繁使用的用例,其中您的应用程序需要从客户、其他部门或其他企业消费文档。当然,Web 服务和其他类型的 API 很受欢迎,但需要消费 Excel 电子表格是正常的。一旦这样做,有人会有不同格式的数据,如 CSV、JSON、制表符分隔等。另一方面,经常需要以多个用户需要消费的格式导出数据。

在这种精神下,该解决方案演示了插件系统允许应用程序添加支持新部署类型的情况。这是一个典型的情况,您已经构建了处理您知道的部署工件的系统,但是这个系统非常有用,每个人都希望添加自己的部署逻辑,这在编写原始需求时是不可预见的。

在解决方案中,每个DeploymentManager都实现了抽象基类DeploymentManagementBaseDeploymentManagementBase编排逻辑,而派生的DeploymentManager类只是其关联插件的工厂。请注意,DeploymentManagementBase使用多态性让派生类实例化其各自的插件类。

提示

如果这变得有点复杂,您可能需要查看配方 1.2 和 1.3。这是比那高一个抽象级别。

解决方案展示了实现IDeploymentPlugin接口的两个类。DeploymentManagementBase类消费IDeploymentPlugin接口,将调用委托给实现该接口的插件类的方法。请注意Validate如何调用IDeploymentPlugin实例上的Validate方法。

Program不知道插件类。它操作DeploymentManagementBase的实例,正如Main调用GetPlugins并接收DeploymentManagementBase实例数组所示。Program不关心插件。为简化演示,GetPluginsProgram中的一个方法,但可以是另一个具有选择要使用的插件机制的类。请注意Run方法如何遍历DeploymentManagementBase实例。

注意

如果您在使用接口的所有其他地方,DeploymentManagementBase实现接口可能会使 IoC 更一致。也就是说,一个抽象基类通常适用于大多数 IoC 容器、模拟和单元测试工具。

总结一下,DeploymentManagementBase封装了所有功能,并委托工作给插件类。编写插件的代码是部署管理器、插件接口和插件类。消费代码只与一组DeploymentManagementBase一起工作,并且对特定插件实现毫不知情。

这就是力量所在。每当您或允许的任何第三方希望为新类型的部署扩展系统时,他们就会这样做:

  1. 创建一个新的实现IDeploymentPlugin接口的DeploymentPlugin类。

  2. 创建一个新的从DeploymentManagementBase派生的DeploymentManagement类。

  3. 实现DeploymentManagement.CreateDeploymentService方法以实例化并返回新的DeploymentPlugin

最后,GetPlugins方法或您选择的其他逻辑将该新代码添加到其插件集合中以进行操作。

参见

配方 1.2,“移除显式依赖项”

配方 1.3,“将对象创建委托给类”

1.5 设计应用层

问题

您正在设置一个新的应用程序,并且不确定如何结构化项目。

解决方案

这里是一个数据访问层类:

public class GreetingRepository
{
    public string GetNewGreeting() => "Welcome!";

    public string GetVisitGreeting() => "Welcome back!";
}

这里是一个业务逻辑层类:

public class Greeting
{
    GreetingRepository greetRep = new GreetingRepository();

    public string GetGreeting(bool isNew) =>
        isNew ? greetRep.GetNewGreeting() : greetRep.GetVisitGreeting();
}

这两个类属于 UI 层的一部分:

public class SignIn
{
    Greeting greeting = new Greeting();

    public void Greet()
    {
        Console.Write("Is this your first visit? (true/false): ");
        string newResponse = Console.ReadLine();

        bool.TryParse(newResponse, out bool isNew);

        string greetResponse = greeting.GetGreeting(isNew);

        Console.WriteLine($"\n*\n* {greetResponse} \n*\n");
    }
}

public class Menu
{
    public void Show()
    {
        Console.WriteLine(
            "*------*\n" +
            "* Menu *\n" +
            "*------*\n" +
            "\n" +
            "1\. ...\n" +
            "2\. ...\n" +
            "3\. ...\n" +
            "\n" +
            "Choose: ");
    }
}

这是应用程序的入口点(UI 层的一部分):

class Program
{
    SignIn signIn = new SignIn();
    Menu menu = new Menu();

    static void Main()
    {
        new Program().Start();
    }

    void Start()
    {
        signIn.Greet();
        menu.Show();
    }
}

讨论

有无数种设置和规划新项目结构的方式,其中一些方法比其他方法更好。与其将这个讨论视为最终结论,不如把它看作是一些具有权衡的选项,这些选项可以帮助你思考自己的方法。

这里的反模式是大块混乱(BBoM)架构。BBoM 是指开发者打开一个项目并在应用程序的同一层添加所有代码的情况。虽然这种方法可能有助于快速原型开发,但从长远来看会带来严重的复杂性问题。随着时间的推移,大多数应用程序需要新增功能和修复 bug。问题在于代码开始混合在一起,通常会出现大量重复,被称为意大利面代码。严肃地说,没有人愿意维护这样的代码,你应该避免它。

警告

在时间紧迫时,人们很容易认为创建一个快速原型可能是可以接受的时间使用方式。然而,要抵制这种冲动。在 BBoM 原型项目上进行维护的成本很高。在意大利面代码上添加新功能或修复 bug 的时间远远超过了看似快速原型的初期收益。由于重复,修复一个地方的 bug 会在应用程序的其他部分留下相同的 bug。这不仅意味着开发者必须多次修复 bug,而且整个 QA、部署、客户发现、帮助台服务和管理的生命周期都会因多次不必要的周期而浪费时间。本节内容帮助你避免这种反模式。

这里需要理解的主要概念是关注点分离。通常听到的是简化为分层架构,在其中你有 UI、业务逻辑和数据层,每个部分都按其所含代码类型命名。本节采用分层方法,旨在展示如何实现关注点分离及其相关的好处。

注意

有时人们会认为分层架构的理念要求将应用程序通信路由通过各层,或者某些操作仅限于其层次。这并不完全正确或实际。例如,业务逻辑可以在不同的层中找到,比如在 UI 层中用于验证用户输入的规则以及处理特定请求的逻辑。另一个违背通信模式的例外是当用户需要在表单上选择一组操作时,没有任何业务逻辑参与,UI 层可以直接从数据层请求项目列表。我们要的是关注点分离,以增强代码的可维护性;任何不合理的教条/理想主义限制都会与这一目标背道而驰。

解决方案从数据访问层GreetingRepository开始。这模拟了存储库模式,它是一个抽象层,使调用代码不需要考虑如何检索数据。理想情况下,创建一个单独的数据项目可以在需要访问相同数据的另一个项目中重复使用该数据访问层,这带来了额外的重用优势。有时你能够重用,有时不能,尽管你总是能够减少重复并知道数据访问逻辑所在的好处。

业务逻辑层有一个Greeting类。注意它如何使用isNew参数来确定调用GreetingRepository的哪个方法。每当你发现自己需要编写处理用户请求的逻辑时,考虑将该代码放入另一个被视为业务逻辑层一部分的类中。如果你已经有这样的代码,请将其重构为一个名为逻辑类型的独立对象。

最后,还有 UI 层,由SignInMenu类组成。这些类处理与用户的交互,但将任何逻辑委托给业务逻辑层。Program可能被视为 UI 层的一部分,尽管它仅在其他 UI 层类之间进行交互/导航,并不执行 UI 操作本身。

注意

我写解决方案的方式是让你在使用类的定义之前看到它。然而,在实际设计时,你可能会从 UI 层开始,然后逐步通过业务逻辑和数据访问进行工作。

在这段代码中,关注点分离有几个方面。Greeting​Re⁠pository只关注数据,特别是Greeting数据。例如,如果应用程序需要在Menu中显示数据,你需要另一个名为MenuRepository的类来执行Menu数据的 CRUD 操作。Greeting只处理Greeting数据的业务逻辑。如果Menu有自己的业务逻辑,你可以考虑为其创建一个单独的业务逻辑层类,但前提是有意义。正如你在 UI 层中看到的那样,SignIn仅处理与用户登录应用程序的交互,而Menu仅处理显示和选择用户想要做什么的交互。美妙的地方在于,现在你或其他任何人都可以轻松进入应用程序,并找到涉及需要解决的主题的代码。

图 1-2、1-3 和 1-4 展示了如何将每个层结构化为 Visual Studio 解决方案。图 1-2 适用于非常简单的应用程序,比如一个不太可能有很多功能的实用程序。在这种情况下,将层保持在同一个项目中是可以接受的,因为代码量不大,而且任何额外的东西都没有实际的好处。

简单应用的项目布局

图 1-2. 简单应用的项目布局

图 1-3 展示了如何组织一个稍大且随时间增长的项目,为了讨论方便,我将其大致称为中型项目。请注意它具有单独的数据访问层。其目的在于可能的重用性。某些项目为不同的客户提供不同的 UI。例如,可能有一个聊天机器人或移动应用用于用户访问数据,但为管理员提供一个 Web 应用。将数据访问层作为单独的项目使这种情况成为可能。请注意SystemApp.ConsoleSystemApp.Data有一个程序集引用。

项目布局以分离 UI 和数据层

图 1-3. 项目布局以分离 UI 和数据层

对于更大型的企业应用程序,您将希望按照图 1-4 的方式将层分开。要解决的问题是希望在代码段之间有更清晰的分隔,以鼓励松耦合。大型应用程序通常变得复杂且难以管理,除非以鼓励最佳实践的方式控制架构。

关注点分离的项目布局

图 1-4. 关注点分离的项目布局

对于企业场景,此示例较小。但是,想象一下不断增长的应用程序的复杂性。随着添加新的业务逻辑,您将开始发现可以重用的代码。此外,您自然会有一些可以独立运行的代码,例如用于访问外部 API 的服务层。这里的机会在于创建一个可在其他应用程序中有用的可重用库。因此,您将希望将任何可重用的内容重构为自己的项目。在不断增长的项目中,您很少能够预料到应用程序将支持的每个方面或功能,监视这些变化并重构将有助于保持代码、项目和架构的健康性。

1.6 从方法返回多个值

问题

你需要从方法中返回多个值,使用经典方法如out参数或返回自定义类型并不直观。

解决方案

ValidationStatus有一个析构函数:

public class ValidationStatus
{
    public bool Deployment { get; set; }
    public bool SmokeTest { get; set; }
    public bool Artifacts { get; set; }

    public void Deconstruct(
        out bool isPreviousDeploymentComplete,
        out bool isSmokeTestComplete,
        out bool areArtifactsReady)
    {
        isPreviousDeploymentComplete = Deployment;
        isSmokeTestComplete = SmokeTest;
        areArtifactsReady = Artifacts;
    }
}

DeploymentService展示了如何返回元组:

public class DeploymentService
{
    public
    (bool deployment, bool smokeTest, bool artifacts)
    PrepareDeployment()
    {
        ValidationStatus status = Validate();

        (bool deployment, bool smokeTest, bool artifacts) = status;

        return (deployment, smokeTest, artifacts);
    }

    ValidationStatus Validate()
    {
        return new ValidationStatus
        {
            Deployment = true,
            SmokeTest = true,
            Artifacts = true
        };
    }
}

下面是如何使用返回的元组:

class Program
{
    readonly DeploymentService deployment = new DeploymentService();
    static void Main(string[] args)
    {
        new Program().Start();
    }

    void Start()
    {
        (bool deployed, bool smokeTest, bool artifacts) =
            deployment.PrepareDeployment();

        Console.WriteLine(
            $"\nDeployment Status:\n\n" +
            $"Is Previous Deployment Complete? {deployed}\n" +
            $"Is Previous Smoke Test Complete? {smokeTest}\n" +
            $"Are artifacts for this deployment ready? {artifacts}\n\n" +
            $"Can deploy: {deployed && smokeTest && artifacts}");
    }
}

讨论

历史上,从方法返回多个值的典型方法是创建自定义类型或添加多个out参数。创建一个仅用于返回值一次的自定义类型总感觉有些浪费。另一种选择,使用多个out参数,也感觉笨拙。使用元组更加优雅。元组是一种值类型,允许你将数据组合成一个单独的对象,而无需声明单独的类型。

注意

本节描述的元组类型是 C# 7.0 的一个新特性。它别名.NET 的ValueTuple,这是一个可变的值类型,其成员是字段。相比之下,.NET Framework 有一个Tuple类,它是一个不可变的引用类型,其成员是属性。ValueTupleTuple都命名成员为Item1Item2,...,ItemN;相反,你可以为 C#元组成员提供更有意义的名称。

如果使用早于 4.7 版的.NET 版本,必须显式引用System.ValueTuple NuGet 包。

解决方案展示了元组的几个不同方面,解构以及如何从方法返回一个元组。ValidationStatus类有一个Deconstruct方法,C#使用它来从类的实例生成一个元组。这个类在这个示例中并不是必需的,但它确实演示了一种将类转换为元组的有趣方式。

DeploymentService类展示了如何返回一个元组。注意,PrepareDeployment方法的返回类型是一个元组。元组返回类型中的属性名称是可选的,不过有意义的变量名可以使代码更易读。

代码调用Validate,它返回一个ValidationStatus实例。下一行,将status分配给元组,使用解构器返回一个元组实例。PrepareDeployment使用这些值向调用者返回一个新的元组。

PrepareDeployment的解决方案实现展示了与元组的工作机制,这对学习很有用,尽管不是非常优雅。在实践中,从方法中返回status将更加清晰,因为解构器将隐式运行。

Program中的Start方法展示了如何调用PrepareDeployment并消耗它返回的元组。

1.7 从传统类型转换为强类型类

问题

你有一个操作object类型值的传统类型,并且需要现代化为强类型实现。

解决方案

这里有一个我们将使用的Deployment类:

public class Deployment
{
    string config;

    public Deployment(string config)
    {
        this.config = config;
    }

    public bool PerformHealthCheck()
    {
        Console.WriteLine(
            $"Performed health check for config {config}.");
        return true;
    }
}

这里有一个传统的CircularQueue集合:

public class CircularQueue
{
    int current = 0;
    int last = 0;
    object[] items;

    public CircularQueue(int size)
    {
        items = new object[size];
    }

    public void Add(object obj)
    {
        if (last >= items.Length)
            throw new IndexOutOfRangeException();

        items[last++] = obj;
    }

    public object Next()
    {
        current %= last;
        object item = items[current];
        current++;

        return item;
    }
}

这段代码展示了如何使用传统集合:

public class HealthChecksObjects
{
    public void PerformHealthChecks(int cycles)
    {
        CircularQueue checks = Configure();

        for (int i = 0; i < cycles; i++)
        {
            Deployment deployment = (Deployment)checks.Next();
            deployment.PerformHealthCheck();
        }
    }

    private CircularQueue Configure()
    {
        var queue = new CircularQueue(5);

        queue.Add(new Deployment("a"));
        queue.Add(new Deployment("b"));
        queue.Add(new Deployment("c"));

        return queue;
    }
}

接下来,将传统集合重构为泛型集合:

public class CircularQueue<T>
{
    int current = 0;
    int last = 0;
    T[] items;

    public CircularQueue(int size)
    {
        items = new T[size];
    }

    public void Add(T obj)
    {
        if (last >= items.Length)
            throw new IndexOutOfRangeException();

        items[last++] = obj;
    }

    public T Next()
    {
        current %= last;
        T item = items[current];
        current++;

        return item;
    }
}

使用展示如何使用新的泛型集合的代码:

public class HealthChecksGeneric
{
    public void PerformHealthChecks(int cycles)
    {
        CircularQueue<Deployment> checks = Configure();

        for (int i = 0; i < cycles; i++)
        {
            Deployment deployment = checks.Next();
            deployment.PerformHealthCheck();
        }
    }

    private CircularQueue<Deployment> Configure()
    {
        var queue = new CircularQueue<Deployment>(5);

        queue.Add(new Deployment("a"));
        queue.Add(new Deployment("b"));
        queue.Add(new Deployment("c"));

        return queue;
    }
}

这里是演示代码,展示了两种集合的使用方式:

class Program
{
    static void Main(string[] args)
    {
        new HealthChecksObjects().PerformHealthChecks(5);
        new HealthChecksGeneric().PerformHealthChecks(5);
    }
}

讨论

C#的第一个版本没有泛型。相反,我们有一个System.Collections命名空间,其中包含像DictionaryListStack这样的集合,这些集合操作的是object类型的实例。如果集合中的实例是引用类型,那么从对象到对象的转换性能是可以忽略的。然而,如果你想管理值类型的集合,装箱/拆箱的性能代价将随着集合的增大或执行的操作增多而变得更加严重。

Microsoft 一直打算在 C# 2 中添加泛型,最终也确实实现了。但在此期间,开发人员需要编写大量非泛型代码,例如集合、优先队列和树数据结构。还有像委托这样的类型,它们是方法引用和异步通信的主要手段,操作对象。有很长一段非泛型代码列表已经编写,并且很可能在您的职业生涯中会遇到其中的一些。

作为 C#开发人员,我们欣赏强类型代码的好处,它使查找和修复编译时错误更容易,使应用程序更易于维护并提高质量。因此,您可能强烈希望重构给定的某段非泛型代码,使其也能够使用泛型。

过程基本上是这样的:每当看到object类型时,将其转换为泛型类型。

解决方案展示了一个Deployment对象,该对象对部署的工件执行健康检查。由于我们有多个工件,我们还需要在一个集合中持有多个Deployment实例。该集合是一个(部分实现的)循环队列,还有一个HealthCheck类,该类循环遍历队列并定期与下一个Deployment实例执行健康检查。

HealthCheckObject操作旧的非泛型代码,而HealthCheckGeneric操作新的泛型代码。两者之间的区别在于,HealthCheckObjectConfigure方法实例化一个非泛型的CircularQueue,而HealthCheckGenericConfigure方法实例化一个泛型的CircularQueue<T>。我们的主要任务是将CircularQueue转换为CircularQueue<T>

因为我们正在处理一个集合,第一步是向类CircularQueue<T>添加类型参数。然后查找代码中使用object类型的地方,并将其转换为类类型参数T

  1. object items[]字段转换为T items[]

  2. 在构造函数中,实例化一个新的T[]而不是object[]

  3. Add方法的参数从object更改为T

  4. Next方法的返回类型从object更改为T

  5. Next方法中,将object item变量更改为T item

object类型更改为T后,您将获得一个新的强类型泛型集合。

Program类演示了这两个集合如何工作。

1.8 使类适应您的接口

问题

您有一个与您的代码功能相似的第三方库,但它没有相同的接口。

解决方案

这是我们要使用的接口:

public interface IDeploymentService
{
    void Validate();
}

以下是实现该接口的几个类:

public class DeploymentService1 : IDeploymentService
{
    public void Validate()
    {
        Console.WriteLine("Deployment Service 1 Validated");
    }
}

public class DeploymentService2 : IDeploymentService
{
    public void Validate()
    {
        Console.WriteLine("Deployment Service 2 Validated");
    }
}

这是一个未实现IDeploymentService的第三方类:

public class ThirdPartyDeploymentService
{
    public void PerformValidation()
    {
        Console.WriteLine("3rd Party Deployment Service 1 Validated");
    }
}

这是实现IDeploymentService的适配器:

public class ThirdPartyDeploymentAdapter : IDeploymentService
{
    ThirdPartyDeploymentService service = new ThirdPartyDeploymentService();

    public void Validate()
    {
        service.PerformValidation();
    }
}

此代码显示如何通过使用适配器包含第三方服务:

class Program
{
    static void Main(string[] args)
    {
        new Program().Start();
    }

    void Start()
    {
        List<IDeploymentService> services = Configure();

        foreach (var svc in services)
            svc.Validate();
    }

    List<IDeploymentService> Configure()
    {
        return new List<IDeploymentService>
        {
            new DeploymentService1(),
            new DeploymentService2(),
            new ThirdPartyDeploymentAdapter()
        };
    }
}

讨论

适配器是一个类,它包装另一个类,并使用您需要的接口暴露包装类的功能。

有各种情况需要使用适配器类。如果您有一组实现接口的对象,并且想要使用不符合您代码接口的第三方类会怎么样?如果您的代码是为第三方 API 编写的,比如支付服务,并且您知道最终想要切换到具有不同 API 的不同提供商会怎么样?如果您需要通过平台调用服务(P/Invoke)或组件对象模型(COM)互操作使用本地代码,并且不希望该接口的细节渗入到您的代码中会怎么样?这些情况都是考虑使用适配器的良好候选者。

解决方案中有实现IDeploymentServiceDeploymentService类。您可以在ProgramStart方法中看到,它仅操作实现了IDeploymentService的实例。

之后的某个时候,您需要将ThirdPartyDeploymentService集成到应用程序中。然而,它没有实现IDeploymentService,而且您没有ThirdPartyDeploymentService的代码。

ThirdPartyDeploymentAdapter类解决了这个问题。它实现了IDeploymentService接口,并实例化了自己的ThirdPartyDeploymentService副本,Validate方法委托调用了ThirdPartyDeploymentService。请注意,ProgramConfigure方法将一个ThirdPartyDeploymentAdapter实例添加到Start操作的集合中。

这是一个演示,向您展示如何设计适配器。在实践中,ThirdPartyDeploymentServicePerform​Vali⁠dation方法可能具有不同的参数和不同的返回类型。ThirdPartyDeploymentAdapterValidate方法将负责准备参数并重新塑造返回值,以确保它们符合适当的IDeploymentService接口。

1.9 设计自定义异常

问题

.NET Framework 库没有符合您需求的异常类型。

解决方案

这是一个自定义异常:

[Serializable]
public class DeploymentValidationException : Exception
{
    public DeploymentValidationException() :
        this("Validation Failed!", null, ValidationFailureReason.Unknown)
    {
    }

    public DeploymentValidationException(
        string message) :
        this(message, null, ValidationFailureReason.Unknown)
    {
    }

    public DeploymentValidationException(
        string message, Exception innerException) :
        this(message, innerException, ValidationFailureReason.Unknown)
    {
    }

    public DeploymentValidationException(
        string message, ValidationFailureReason reason) :
        this(message, null, reason)
    {
    }

    public DeploymentValidationException(
        string message,
        Exception innerException,
        ValidationFailureReason reason) :
        base(message, innerException)
    {
        Reason = reason;
    }

    public ValidationFailureReason Reason { get; set; }

    public override string ToString()
    {
        return
            base.ToString() +
            $" - Reason: {Reason} ";
    }
}

并且这是该异常属性的枚举类型:

public enum ValidationFailureReason
{
    Unknown,
    PreviousDeploymentFailed,
    SmokeTestFailed,
    MissingArtifacts
}

这段代码显示了如何抛出自定义异常:

public class DeploymentService
{
    public void Validate()
    {
        throw new DeploymentValidationException(
            "Smoke test failed - check with qa@example.com.",
            ValidationFailureReason.SmokeTestFailed);
    }
}

并且这段代码捕获了自定义异常:

class Program
{
    static void Main()
    {
        try
        {
            new DeploymentService().Validate();
        }
        catch (DeploymentValidationException ex)
        {
            Console.WriteLine(
                $"Message: {ex.Message}\n" +
                $"Reason: {ex.Reason}\n" +
                $"Full Description: \n {ex}");
        }
    }
}

讨论

C#异常的美妙之处在于它们是强类型的。当您的代码捕获它们时,您可以为仅针对该类型的异常编写特定的处理逻辑。.NET Framework 有一些异常,如ArgumentNullException,在平均代码库中可以得到一些重复使用(您可以自行抛出),但通常您需要抛出一个具有语义和数据的异常,以便开发人员更有机会弄清楚为何方法无法完成其预期目的。

解决方案中的异常是 DeploymentValidationException,表示在验证阶段的部署过程中出现问题。它派生自 Exception。根据你的自定义异常框架的扩展程度,你可以为其创建自己的基础异常以构建异常的层次结构,并从中分类派生异常树。这样做的好处是,你可以在 catch 块中灵活地捕获更一般或特定的异常。尽管如此,如果你只需要一些自定义异常,那么异常层次结构的额外设计工作可能有些多余。

前三个构造函数与 Exception 类的选项相同,用于消息和内部异常。你还需要自定义构造函数以便使用你的自定义数据进行实例化。

注意

在过去,关于自定义异常应该派生自 Exception 还是 ApplicationException 曾有过讨论,其中 Exception 用于 .NET 类型层次结构,而 ApplicationException 用于自定义异常层次结构。然而,随着时间的推移,这种区别变得模糊了,一些 .NET Framework 类型同时从两者派生,而没有明显的一致性或理由。因此,目前看来,从 Exception 派生是可以接受的。

DeploymentValidationException 具有一个属性,其枚举类型为 Validation​Fai⁠lureReason。除了有关抛出异常原因的独特语义外,自定义异常的另一个目的是包含重要的异常处理和/或调试信息。

覆盖 ToString 也是个好主意。日志框架可能只会接收 Exception 引用,从而调用 ToString。就像本例中一样,你希望确保你的自定义数据包含在字符串输出中。这样可以确保人们可以阅读异常的完整状态,包括堆栈跟踪。

Program Main 方法演示了能够处理特定类型而不是可能不适合或通用的 Exception 类型的好处。

1.10 使用复杂配置构造对象

问题

你需要构建一个具有复杂配置选项的新类型,而无需不必要地扩展构造函数。

解决方案

这是我们想要构建的 DeploymentService 类:

public class DeploymentService
{
    public int StartDelay { get; set; } = 2000;
    public int ErrorRetries { get; set; } = 5;
    public string ReportFormat { get; set; } = "pdf";

    public void Start()
    {
        Console.WriteLine(
            $"Deployment started with:\n" +
            $"    Start Delay:   {StartDelay}\n" +
            $"    Error Retries: {ErrorRetries}\n" +
            $"    Report Format: {ReportFormat}");
    }
}

这个类是构建 DeploymentService 实例的类:

public class DeploymentBuilder
{
    DeploymentService service = new DeploymentService();

    public DeploymentBuilder SetStartDelay(int delay)
    {
        service.StartDelay = delay;
        return this;
    }

    public DeploymentBuilder SetErrorRetries(int retries)
    {
        service.ErrorRetries = retries;
        return this;
    }

    public DeploymentBuilder SetReportFormat(string format)
    {
        service.ReportFormat = format;
        return this;
    }

    public DeploymentService Build()
    {
        return service;
    }
}

这是如何使用 DeploymentBuilder 类的方法:

class Program
{
    static void Main()
    {
        DeploymentService service =
            new DeploymentBuilder()
                .SetStartDelay(3000)
                .SetErrorRetries(3)
                .SetReportFormat("html")
                .Build();

        service.Start();
    }
}

讨论

在 Recipe 1.9 中,DeploymentValidationException 类有多个构造函数。通常情况下,这不是问题。前三个构造函数是异常类的典型约定。后续的构造函数为初始化新字段添加了新的参数。

然而,如果你设计的类有很多选项,并且有很强的可能性需要新功能,会怎么样呢?此外,开发人员将希望根据需要选择配置类。想象一下,为每个添加到类中的新选项创建新构造函数会带来指数级的增长。在这种情况下,构造函数几乎没有用处。建造者模式可以解决这个问题。

实现建造者模式的对象示例包括 ASP.NET 的ConfigSettings和 Recipe 1.2 中的ServiceCollection——虽然代码并非完全按照流式处理编写,但可以,因为它遵循建造者模式。

解决方案有一个DeploymentService类,这正是我们想要构建的。如果开发人员没有配置给定的值,则其属性具有默认值。一般来说,建造者创建的类还将具有其他用于其预期目的的方法和成员。

DeploymentBuilder类实现了建造者模式。请注意,除了Build方法外,所有方法都返回相同类型DeploymentBuilder的同一实例(this),并使用参数配置了使用DeploymentBuilder实例化的DeploymentService字段。Build方法返回DeploymentService实例。

如何配置和实例化是DeploymentBuilder的实现细节,可以根据需要进行变化。你也可以接受任何你需要的参数类型并进行配置。此外,你可以收集配置数据,并仅在运行Build方法时实例化目标类。另一个优势是参数设置的顺序不重要。你可以根据自己的需要灵活设计建造者的内部。

最后,请注意Main方法如何实例化DeploymentBuilder,使用其流畅的接口进行配置,并调用Build方法返回DeploymentService实例。这个示例使用了每个方法,但这并非必需,因为你可以选择使用一些,全部或者不使用。

另请参阅

Recipe 1.2,“消除显式依赖项”

Recipe 1.9,“设计自定义异常”

第二章:编码算法

我们每天都在编码,思考我们要解决的问题,确保我们的算法正确运行。这就是应该的方式,现代工具和软件开发工具包越来越多地释放我们的时间,专注于这一点。即便如此,C#、.NET 和编码中的某些特性仍然显著影响效率、性能和可维护性。

性能

本章的几个主题讨论了应用程序性能,如高效处理字符串、缓存数据或延迟实例化类型直到需要时。在一些简单的场景中,这些事情可能并不重要。然而,在需要性能和规模的复杂企业应用程序中,关注这些技术可以帮助避免生产中的昂贵问题。

可维护性

如何组织代码显著影响其可维护性。在第一章的讨论基础上,您将看到一种新的模式和策略,并理解它们如何简化算法并使应用程序更易扩展。另一节讨论了如何在自然发生的分层数据中使用递归。收集这些技术,并思考最佳算法的方法,可以显著提升代码的可维护性和质量。

思维模式

本章的几个部分可能在特定环境下很有趣,展示了解决问题的不同思考方式。你可能不会每天都使用正则表达式,但在需要时它们非常有用。另一部分讨论了如何将时间转换为/从 Unix 时间,展望了.NET 作为跨平台语言的未来,我们需要一种特定的思维方式来设计算法,这可能是我们以前从未考虑过的环境。

2.1 高效处理字符串

问题

分析器指示您的代码中有问题,它迭代地构建了一个大字符串,您需要提升性能。

解决方案

这是我们将要使用的InvoiceItem类:

public class InvoiceItem
{
    public decimal Cost { get; set; }
    public string Description { get; set; }
}

这种方法生成演示的示例数据:

static List<InvoiceItem> GetInvoiceItems()
{
    var items = new List<InvoiceItem>();
    var rand = new Random();
    for (int i = 0; i < 100; i++)
        items.Add(
            new InvoiceItem
            {
                Cost = rand.Next(i),
                Description = "Invoice Item #" + (i+1)
            });

    return items;
}

有两种处理字符串的方法。首先是低效的方法:

static string DoStringConcatenation(List<InvoiceItem> lineItems)
{
    string report = "";

    foreach (var item in lineItems)
        report += $"{item.Cost:C} - {item.Description}\n";

    return report;
}

下面是更高效的方法:

static string DoStringBuilderConcatenation(List<InvoiceItem> lineItems)
{
    var reportBuilder = new StringBuilder();

    foreach (var item in lineItems)
        reportBuilder.Append($"{item.Cost:C} - {item.Description}\n");

    return reportBuilder.ToString();
}

Main方法将所有这些联系在一起:

static void Main(string[] args)
{
    List<InvoiceItem> lineItems = GetInvoiceItems();

    DoStringConcatenation(lineItems);

    DoStringBuilderConcatenation(lineItems);
}

讨论

我们有不同的原因需要将数据收集到更长的字符串中。报告,无论是基于文本还是通过 HTML 或其他标记格式化,都需要组合文本字符串。有时我们将项目添加到电子邮件中,或者手动构建 PDF 内容作为电子邮件附件。其他时候,我们可能需要以非标准格式导出数据用于遗留系统。开发人员在需要时经常使用字符串连接,而StringBuilder则是更优的选择。

字符串连接是直观且编码速度快的操作,这就是为什么有那么多人这样做。然而,字符串连接也可能会降低应用程序的性能。问题出在每次连接都需要进行昂贵的内存分配。让我们看看如何用错误的方法和正确的方法来构建字符串。

DoStringConcatenation 方法中的逻辑从每个 InvoiceItem 中提取 CostDescription 并将其连接到一个增长的字符串中。连接几个字符串可能不会被注意到。但是,想象一下如果有 25、50 或 100 行甚至更多。使用类似本章节解决方案的示例,Recipe 3.10 展示了字符串连接是一个指数级耗时操作,会严重影响应用程序性能。

注意

在同一个表达式内进行连接,例如,string1 + string2,C# 编译器可以优化这段代码。而循环连接则会导致性能急剧下降。

DoStringBuilderConcatenation 方法解决了这个问题。它使用了位于 System.Text 命名空间中的 StringBuilder 类。它使用了构建器模式,如 Recipe 1.10 中描述的那样,每个 AppendText 都将新字符串添加到 StringBuilder 实例 reportsBuilder 中。在返回之前,该方法调用 ToString 方法将 StringBuilder 的内容转换为字符串。

小贴士

一般来说,一旦您超过四个字符串连接,使用 StringBuilder 就能获得更好的性能。

幸运的是,.NET 生态系统中有许多 .NET Framework 库和第三方库,可以帮助处理常见格式的字符串。尽可能使用这些库,因为它们通常经过优化,可以节省时间并使代码更易于阅读。例如,表 2-1 展示了一些常见格式的库。

表 2-1. 数据格式和库

Data format Library
JSON.NET 5 System.Text.Json
JSON ⇐ .NET 4.x Json.NET
XML LINQ to XML
CSV LINQ to CSV
HTML System.Web.UI.HtmlTextWriter
PDF 各种商业和开源提供者
Excel 各种商业和开源提供者

还有一个想法:自定义搜索和过滤面板通常用于为用户提供查询企业数据的简单方式。开发人员经常使用字符串连接来构建结构化查询语言(SQL)查询。虽然字符串连接更简单,但除了性能之外,它的问题在于安全性。字符串连接的 SQL 语句打开了 SQL 注入攻击的机会。在这种情况下,StringBuilder 不是一个解决方案。相反,你应该使用一个数据库库来对用户输入进行参数化,以避免 SQL 注入。有 ADO.NET、LINQ 提供程序和其他第三方数据库库可以为你进行输入值参数化。对于动态查询,使用数据库库可能更难,但是可以做到。你可能需要认真考虑使用 LINQ,我在 第四章 中有讨论。

另请参阅

配方 1.10,“构造具有复杂配置的对象”

配方 3.10,“性能测量”

第四章,“使用 LINQ 进行查询

2.2 简化实例清理

问题

旧的 using 语句会导致不必要的嵌套,你希望清理和简化代码。

解决方案

这个程序有用于读写文本文件的 using 语句:

class Program
{
    const string FileName = "Invoice.txt";

    static void Main(string[] args)
    {
        Console.WriteLine(
            "Invoice App\n" +
            "-----------\n");

        WriteDetails();

        ReadDetails();
    }

    static void WriteDetails()
    {
        using var writer = new StreamWriter(FileName);

        Console.WriteLine("Type details and press [Enter] to end.\n");

        string detail;
        do
        {
            Console.Write("Detail: ");
            detail = Console.ReadLine();
            writer.WriteLine(detail);
        }
        while (!string.IsNullOrWhiteSpace(detail));
    }

    static void ReadDetails()
    {
        Console.WriteLine("\nInvoice Details:\n");

        using var reader = new StreamReader(FileName);

        string detail;
        do
        {
            detail = reader.ReadLine();
            Console.WriteLine(detail);
        }
        while (!string.IsNullOrWhiteSpace(detail));
    }
}

讨论

在 C# 8 之前,using 语句的语法要求对 IDisposable 对象进行实例化并且包含一个封闭的代码块。在运行时,当程序执行到封闭的代码块时,会调用实例化对象的 Dispose 方法。如果需要多个 using 语句同时操作,开发人员通常会将它们嵌套,导致除了正常语句嵌套之外还有额外的空间。对一些开发人员来说,这种模式已经足够恼人,以至于微软为语言添加了一个功能来简化 using 语句。

在解决方案中,你可以看到新的 using 语句语法出现在几个地方:在 WriteDetails 中实例化 StreamWriter 和在 ReadDetails 中实例化 StreamReader。在这两种情况下,using 语句都是单行的。括号和花括号已经消失,每个语句以分号结尾。

using 语句的作用域是其封闭的代码块,在执行到封闭的代码块的末尾时调用 using 对象的 Dispose 方法。在解决方案中,封闭的代码块是方法,这会导致每个 using 对象的 Dispose 方法在方法结束时被调用。

单行 using 语句的不同之处在于它适用于既实现 IDisposable 接口又实现可释放模式的对象。在这个上下文中,可释放模式意味着对象不实现 IDisposable,但它有一个无参数的 Dispose 方法。

另请参阅

配方 1.1,“管理对象的生命周期”

2.3 保持逻辑局部化

问题

算法具有复杂的逻辑,最好将其重构为另一个方法,但这些逻辑实际上只在一个地方使用。

解决方案

程序使用了CustomerTypeInvoiceItem

public enum CustomerType
{
    None,
    Bronze,
    Silver,
    Gold
}

public class InvoiceItem
{
    public decimal Cost { get; set; }
    public string Description { get; set; }
}

此方法生成并返回一组演示发票:

static List<InvoiceItem> GetInvoiceItems()
{
    var items = new List<InvoiceItem>();
    var rand = new Random();
    for (int i = 0; i < 100; i++)
        items.Add(
            new InvoiceItem
            {
                Cost = rand.Next(i),
                Description = "Invoice Item #" + (i + 1)
            });

    return items;
}

最后,Main方法展示了如何使用局部函数:

static void Main()
{
    List<InvoiceItem> lineItems = GetInvoiceItems();

    decimal total = 0;

    foreach (var item in lineItems)
        total += item.Cost;

    total = ApplyDiscount(total, CustomerType.Gold);

    Console.WriteLine($"Total Invoice Balance: {total:C}");

    decimal ApplyDiscount(decimal total, CustomerType customerType)
    {
        switch (customerType)
        {
            case CustomerType.Bronze:
                return total - total * .10m;
            case CustomerType.Silver:
                return total - total * .05m;
            case CustomerType.Gold:
                return total - total * .02m;
            case CustomerType.None:
            default:
                return total;
        }
    }
}

讨论

当代码仅与单个方法相关且希望隔离该代码时,局部方法非常有用。隔离代码的原因包括赋予一组复杂逻辑以意义、重用逻辑和简化调用代码(也许是一个循环),或者允许异步方法在等待封闭方法之前抛出异常。

解决方案中的Main方法具有一个名为ApplyDiscount的局部方法。此示例演示了局部方法如何简化代码。如果您检查ApplyDiscount中的代码,可能不会立即清楚其目的是什么。然而,通过将该逻辑分离到自己的方法中,任何人都可以阅读方法名称并知道逻辑的目的是什么。这是通过表达意图并使该逻辑局部化来使代码更易于维护的一个很好的方法,而其他开发人员不需要搜索可能在未来维护后移动的类方法。

2.4 在多个类上执行相同操作

问题

应用程序必须是可扩展的,以添加新的插件功能,但不希望为新的类重写现有代码。

解决方案

这是几个类共同实现的常见接口:

public interface IInvoice
{
    bool IsApproved();

    void PopulateLineItems();

    void CalculateBalance();

    void SetDueDate();
}

这里有几个实现了IInvoice接口的类:

public class BankInvoice : IInvoice
{
    public void CalculateBalance()
    {
        Console.WriteLine("Calculating balance for BankInvoice.");
    }

    public bool IsApproved()
    {
        Console.WriteLine("Checking approval for BankInvoice.");
        return true;
    }

    public void PopulateLineItems()
    {
        Console.WriteLine("Populating items for BankInvoice.");
    }

    public void SetDueDate()
    {
        Console.WriteLine("Setting due date for BankInvoice.");
    }
}

public class EnterpriseInvoice : IInvoice
{
    public void CalculateBalance()
    {
        Console.WriteLine("Calculating balance for EnterpriseInvoice.");
    }

    public bool IsApproved()
    {
        Console.WriteLine("Checking approval for EnterpriseInvoice.");
        return true;
    }

    public void PopulateLineItems()
    {
        Console.WriteLine("Populating items for EnterpriseInvoice.");
    }

    public void SetDueDate()
    {
        Console.WriteLine("Setting due date for EnterpriseInvoice.");
    }
}

public class GovernmentInvoice : IInvoice
{
    public void CalculateBalance()
    {
        Console.WriteLine("Calculating balance for GovernmentInvoice.");
    }

    public bool IsApproved()
    {
        Console.WriteLine("Checking approval for GovernmentInvoice.");
        return true;
    }

    public void PopulateLineItems()
    {
        Console.WriteLine("Populating items for GovernmentInvoice.");
    }

    public void SetDueDate()
    {
        Console.WriteLine("Setting due date for GovernmentInvoice.");
    }
}

此方法使用实现了IInvoice接口的对象填充集合:

static IEnumerable<IInvoice> GetInvoices()
{
    return new List<IInvoice>
    {
        new BankInvoice(),
        new EnterpriseInvoice(),
        new GovernmentInvoice()
    };
}

Main方法具有操作IInvoice接口的算法:

static void Main(string[] args)
{
    IEnumerable<IInvoice> invoices = GetInvoices();

    foreach (var invoice in invoices)
    {
        if (invoice.IsApproved())
        {
            invoice.CalculateBalance();
            invoice.PopulateLineItems();
            invoice.SetDueDate();
        }
    }
}

讨论

随着开发人员职业的进展,他们很可能会遇到客户希望应用程序具有“可扩展性”的要求。尽管即使对经验丰富的架构师来说,确切的含义也不精确,但普遍理解的是,“可扩展性”应该成为应用程序设计的一个主题。我们通常通过识别随时间可以和将会发生变化的应用程序区域来朝这个方向发展。设计模式可以帮助实现这一点,比如食谱 1.3 中的工厂类、食谱 1.4 中的工厂方法以及食谱 1.10 中的构建器。类似地,本节中描述的策略模式有助于组织可扩展性的代码。

策略模式在同时处理多种对象类型并希望它们可互换,并且希望只编写一次可以对每个对象执行相同操作的代码时非常有用。从面向对象的角度来看,这是接口多态性。我们每天使用的软件是策略模式的典型例子。办公应用程序有不同的文档类型,并允许开发人员编写自己的插件。浏览器有开发人员可以编写的插件。您每天使用的编辑器和集成开发环境(IDE)具有插件功能。

该解决方案描述了在银行、企业和政府领域中操作不同类型发票的应用程序。每个领域都有其自己的与法律或其他要求相关的业务规则。使其可扩展的原因在于,将来我们可以添加另一个处理另一个领域发票的类。

使其工作的关键是IInvoice接口。它包含每个实现类必须定义的必需方法(或合同)。你可以看到,BankInvoiceEnterpriseInvoiceGovernmentInvoices都实现了IInvoice

GetInvoices模拟了您将从数据源填充发票的情况。每当需要通过添加新的IInvoice派生类型来扩展框架时,这是唯一会改变的代码。因为所有类都是IInvoice,所以它们都可以通过同一个IEnumerable<IInvoice>集合返回。

注意

即使GetInvoices实现是在List<IInvoice>上操作的,它却从GetInvoices中返回了一个IEnumerable<IInvoice>。通过在这里返回一个接口IEnumerable<T>,调用者不对底层集合实现做任何假设。这样一来,如果将来GetInvoices的另一个实现更适合另一种实现类型,那么代码就可以更改而不更改方法签名,并且不会破坏调用代码。

最后,请检查Main方法。它迭代每个IInvoice对象,调用其方法。Main不关心具体的实现是什么,因此其代码永远不需要改变以适应特定实例的逻辑。你不需要为特殊情况编写ifswitch语句,这样会在维护时导致代码复杂难以维护。任何未来的更改将涉及Main如何与IInvoice接口一起工作。与发票相关的业务逻辑的任何更改都限于发票类型本身。这样易于维护,也容易确定逻辑的存在和应有的位置。此外,通过添加实现IInvoice的新插件类,还可以轻松扩展。

参见

Recipe 1.3,“将对象创建委托给一个类”

配方 1.4,“将对象创建委托给方法”

配方 1.10,“使用复杂配置构造对象”

2.5 检查类型的相等性

问题

你需要在集合中搜索对象,而默认相等性无法胜任。

解决方案

Invoice 类实现了 IEquatable<T> 接口:

public class Invoice : IEquatable<Invoice>
{
    public int CustomerID { get; set; }

    public DateTime Created { get; set; }

    public List<string> InvoiceItems { get; set; }

    public decimal Total { get; set; }

    public bool Equals(Invoice other)
    {
        if (ReferenceEquals(other, null))
            return false;

        if (ReferenceEquals(this, other))
            return true;

        if (GetType() != other.GetType())
            return false;

        return
            CustomerID == other.CustomerID &&
            Created.Date == other.Created.Date;
    }

    public override bool Equals(object other)
    {
        return Equals(other as Invoice);
    }

    public override int GetHashCode()
    {
        return (CustomerID + Created.Ticks).GetHashCode();
    }

    public static bool operator ==(Invoice left, Invoice right)
    {
        if (ReferenceEquals(left, null))
            return ReferenceEquals(right, null);

        return left.Equals(right);
    }

    public static bool operator !=(Invoice left, Invoice right)
    {
        return !(left == right);
    }
}

此代码返回一个 Invoice 类的集合:

static List<Invoice> GetAllInvoices()
{
    DateTime date = DateTime.Now;

    return new List<Invoice>
    {
        new Invoice { CustomerID = 1, Created = date },
        new Invoice { CustomerID = 2, Created = date },
        new Invoice { CustomerID = 1, Created = date },
        new Invoice { CustomerID = 3, Created = date }
    };
}

使用 Invoice 类的方法如下:

static void Main(string[] args)
{
    List<Invoice> allInvoices = GetAllInvoices();

    Console.WriteLine($"# of All Invoices: {allInvoices.Count}");

    var invoicesToProcess = new List<Invoice>();

    foreach (var invoice in allInvoices)
    {
        if (!invoicesToProcess.Contains(invoice))
            invoicesToProcess.Add(invoice);
    }

    Console.WriteLine($"# of Invoices to Process: {invoicesToProcess.Count}");
}

讨论

引用类型的默认相等性语义是引用相等性,而值类型的是值相等性。引用相等性意味着当比较对象时,这些对象仅在它们的引用指向同一个确切的对象实例时才相等。值相等性发生在比较对象的每个成员之前,这两个对象才被视为相等。引用相等性的问题在于,有时你有两个相同类的实例,但实际上想要比较它们的对应成员是否相等。值相等性可能也会带来问题,因为有时你可能只想检查对象的部分内容是否相等。

为解决默认相等性不足的问题,解决方案在 Invoice 上实现了自定义相等性。Invoice 类实现了 IEquatable<T> 接口,其中 TInvoice。尽管 IEquatable<T> 要求实现 Equals(T other) 方法,你还应该实现 Equals(object other)GetHashCode() 方法以及 ==!= 操作符,以确保在所有情况下都有一致的相等性定义。

在选择一个良好的哈希码时涉及很多科学问题,这超出了本书的范围,因此解决方案的实现是最小的。

注意

C# 9.0 记录(Records)默认为你提供了 IEquatable<T> 逻辑。然而,记录(Records)提供了值相等性,如果需要更具体的实现 IEquatable<T>,你需要自行实现。例如,如果你的对象具有不影响对象标识的自由文本字段,为何要浪费资源进行不必要的字段比较?另一个问题(可能更少见)可能是记录的某些部分基于时间原因会有所不同,例如临时时间戳、状态或全局唯一标识符(GUID),这将导致对象在处理过程中永远不相等。

相等性实现避免了重复的代码。!= 操作符调用并取反 == 操作符。== 操作符检查引用并在两个引用都为 null 时返回 true,在只有一个引用为 null 时返回 false== 操作符和 Equals(object other) 方法都调用 Equals(Invoice other) 方法。

当前实例显然不是 null,因此 Equals(Invoice other) 只检查 other 引用,如果它是 null,则返回 false。然后检查 thisother 是否具有引用相等性,这显然意味着它们是相等的。然后,如果对象不是相同类型,则不被认为是相等的。最后,返回要比较的值的结果。在这个例子中,唯一有意义的是 CustomerIDDate

注意

Equals(Invoice other) 方法中可以改变的一部分是类型检查。您可能会根据应用程序的要求有不同的看法。例如,如果希望即使 other 是派生类型也能检查相等性,则更改逻辑以接受派生类型。

Main 方法处理发票,确保我们不会将重复的发票添加到列表中。循环调用集合的 Contains 方法,检查对象的相等性。如果没有匹配的对象,Contains 将新的 Invoice 实例添加到 invoicesToProcess 列表中。运行程序时,在 allInvoices 中存在四张发票,但只有三张添加到 invoicesToProcess 中,因为在 allInvoices 中有一个重复(基于 CustomerIDCreated)。

2.6 处理数据层次结构

问题

应用程序需要处理层次数据,而迭代方法过于复杂和不自然。

解决方案

这是我们要处理的数据格式:

public class BillingCategory
{
    public int ID { get; set; }
    public string Name { get; set; }
    public int? Parent { get; set; }
}

此方法返回一组层次相关的记录:

static List<BillingCategory> GetBillingCategories()
{
    return new List<BillingCategory>
    {
        new BillingCategory { ID = 1, Name = "First 1",  Parent = null },
        new BillingCategory { ID = 2, Name = "First 2",  Parent = null },
        new BillingCategory { ID = 4, Name = "Second 1", Parent = 1 },
        new BillingCategory { ID = 3, Name = "First 3",  Parent = null },
        new BillingCategory { ID = 5, Name = "Second 2", Parent = 2 },
        new BillingCategory { ID = 6, Name = "Second 3", Parent = 3 },
        new BillingCategory { ID = 8, Name = "Third 1",  Parent = 5 },
        new BillingCategory { ID = 8, Name = "Third 2",  Parent = 6 },
        new BillingCategory { ID = 7, Name = "Second 4", Parent = 3 },
        new BillingCategory { ID = 9, Name = "Second 5", Parent = 1 },
        new BillingCategory { ID = 8, Name = "Third 3",  Parent = 9 }
    };
}

这是将扁平数据转换为层次形式的递归算法:

static List<BillingCategory> BuildHierarchy(
     List<BillingCategory> categories, int? catID, int level)
{
    var found = new List<BillingCategory>();

    foreach (var cat in categories)
    {
        if (cat.Parent == catID)
        {
            cat.Name = new string('\t', level) + cat.Name;
            found.Add(cat);
            List<BillingCategory> subCategories =
                BuildHierarchy(categories, cat.ID, level + 1);
            found.AddRange(subCategories);
        }
    }

    return found;
}

Main 方法运行程序并打印层次数据:

static void Main(string[] args)
{
    List<BillingCategory> categories = GetBillingCategories();

    List<BillingCategory> hierarchy =
        BuildHierarchy(categories, catID: null, level: 0);

    PrintHierarchy(hierarchy);
}

static void PrintHierarchy(List<BillingCategory> hierarchy)
{
    foreach (var cat in hierarchy)
        Console.WriteLine(cat.Name);
}

讨论

很难判断您将如何多次遇到迭代算法,其复杂逻辑和循环操作的条件。forforeachwhile 这样的循环是熟悉且经常使用的,即使有更优雅的解决方案存在。我并不是在暗示循环有什么问题,它们是语言工具集的重要部分。然而,对于给定情况,扩展我们的思维以探索其他可能更优雅和可维护的代码技术是有用的。有时候,像集合的 ForEach 操作符上的 lambda 表达式这样的声明性方法是简单明了的。LINQ 是处理内存中对象集合的良好解决方案,这是 第四章 的主题。递归是本节的另一种选择。

我在这里要表达的主要观点是,我们需要根据具体情况编写使用最自然的技术的算法。很多算法确实自然地使用循环,如遍历集合。其他任务可能需要递归。处理层次结构的一类算法可能非常适合使用递归。

此解决方案展示了递归简化处理和使代码清晰的一个领域。它基于计费处理类别列表。请注意,BillingCategory类具有IDParent两个属性。这些属性管理层次结构,其中Parent标识父类别。任何具有null ParentBillingCategory都是顶级类别。这是单表关系数据库(DB)表示的分层数据。

GetBillingCategories展示了BillingCategories如何从数据库中获取。它是一个平面结构。请注意,Parent属性引用它们的父BillingCategory的 ID。关于数据的另一个重要事实是父子之间没有明确的排序。在实际应用中,你将从给定的类别集开始,并随后添加新的类别。同样,随着时间在代码和数据的维护中变化,这会改变我们对算法设计的方法,从而复杂化迭代解决方案。

这个解决方案的目的是将平面类别表示转换为另一个列表,该列表表示类别之间的层次关系。这是一个简单的解决方案,但你可以想象一个基于对象的表示,其中父类别包含一个子类别集合。执行此操作的递归算法是BuildHierarchy方法。

BuildHierarchy方法接受三个参数:categoriescatIDlevelcategories参数是来自数据库的平面集合,每次递归调用都会接收到对同一集合的引用。一个潜在的优化可能是移除已经处理过的类别,尽管演示避免了任何会分散注意力的内容。catID参数是当前BillingCategoryID,代码正在寻找其Parent匹配catID的所有子类别,正如foreach循环内部的if语句所示。level参数有助于管理每个类别的视觉表示。if块内的第一条语句使用level确定在类别名称前加上多少制表符(\t)。每次递归调用BuildHierarchy时,我们会增加level,以便子类别比其父类别缩进更多。

算法使用相同的类别集合调用BuildHierarchy。此外,它使用当前类别的ID,而不是catID参数。这意味着它递归调用BuildHierarchy,直到达到最底层的类别。它会通过foreach循环完成且没有新的类别,因为当前(最底层)类别没有子类别时,确定它位于层次结构的底部。

到达底部后,BuildHierarchy 返回并继续 foreach 循环,收集 catID 下的所有类别——即它们的 ParentcatID。然后将任何匹配的子类别附加到调用 BuildHierarchyfound 集合中。这将继续,直到算法达到顶层并处理所有根类别。

注意

此解决方案中的递归算法称为深度优先搜索(DFS)。

到达顶层后,BuildHierarchy 将整个集合返回给其原始调用者,即 MainMain 最初使用整个平面 categories 集合调用 BuildHierarchy。它将 catID 设置为 null,表示 BuildHierarchy 应从根级别开始。level 参数为 0,表示我们不希望在根级别类别名称上使用任何制表符前缀。这是输出:

First 1
        Second 1
        Second 5
                Third 3
First 2
        Second 2
                Third 1
First 3
        Second 3
                Third 2
        Second 4

回顾 GetBillingCategories 方法时,您可以看到视觉表示与数据匹配的方式。

2.7 从/到 Unix 时间的转换

问题

服务将日期信息发送为自 Linux 纪元以来的秒或滴答,需要转换为 C#/.NET DateTime

解决方案

这里是我们将使用的一些值:

static readonly DateTime LinuxEpoch =
    new DateTime(1970, 1, 1, 0, 0, 0, 0);
static readonly DateTime WindowsEpoch =
    new DateTime(0001, 1, 1, 0, 0, 0, 0);
static readonly double EpochMillisecondDifference =
    new TimeSpan(
        LinuxEpoch.Ticks - WindowsEpoch.Ticks).TotalMilliseconds;

这些方法转换为和从 Linux 纪元时间戳:

public static string ToLinuxTimestampFromDateTime(DateTime date)
{
    double dotnetMilliseconds = TimeSpan.FromTicks(date.Ticks).TotalMilliseconds;

    double linuxMilliseconds = dotnetMilliseconds - EpochMillisecondDifference;

    double timestamp = Math.Round(
        linuxMilliseconds, 0, MidpointRounding.AwayFromZero);

    return timestamp.ToString();
}

public static DateTime ToDateTimeFromLinuxTimestamp(string timestamp)
{
    ulong.TryParse(timestamp, out ulong epochMilliseconds);
    return LinuxEpoch + +TimeSpan.FromMilliseconds(epochMilliseconds);
}

Main 方法演示如何使用这些方法:

static void Main()
{
    Console.WriteLine(
        $"WindowsEpoch == DateTime.MinValue: " +
        $"{WindowsEpoch == DateTime.MinValue}");

    DateTime testDate = new DateTime(2021, 01, 01);

    Console.WriteLine($"testDate: {testDate}");

    string linuxTimestamp = ToLinuxTimestampFromDateTime(testDate);

    TimeSpan dotnetTimeSpan =
        TimeSpan.FromMilliseconds(long.Parse(linuxTimestamp));
    DateTime problemDate =
        new DateTime(dotnetTimeSpan.Ticks);

    Console.WriteLine(
        $"Accidentally based on .NET Epoch: {problemDate}");

    DateTime goodDate = ToDateTimeFromLinuxTimestamp(linuxTimestamp);

    Console.WriteLine(
        $"Properly based on Linux Epoch: {goodDate}");
}

讨论

有时开发人员在数据库中表示日期/时间数据为毫秒或滴答。滴答以 100 纳秒为单位计量。毫秒和滴答都表示从预定义纪元开始的时间,这是计算平台的最小日期。对于.NET,纪元是 01/01/0001 00:00:00,对应解决方案中的 WindowsEpoch 字段。这与 DateTime.MinValue 相同,但以这种方式定义使示例更加明确。对于 MacOS,纪元是 1904 年 1 月 1 日,对于 Linux,纪元是 1970 年 1 月 1 日,如解决方案中的 LinuxEpoch 字段所示。

注意

关于将 DateTime 值表示为毫秒或滴答作为适当设计存在各种意见。但是,我将这场辩论留给其他人和场合。我的习惯是使用我正在使用的数据库的 DateTime 格式。我还将 DateTime 转换为 UTC,因为许多应用程序需要存在超出本地时区,并且您需要一致的可转换表示。

越来越多的开发人员可能会遇到需要构建跨平台解决方案或与基于不同纪元的第三方系统集成的情况,例如,Twitter API 在其 2020 年版本 2.0 中开始使用基于 Linux 纪元的毫秒数。解决方案示例受到处理来自 Twitter API 响应的毫秒的代码启发。.NET Core 的发布为 C#开发人员提供了控制台和 ASP.NET MVC Core 应用程序的跨平台能力。.NET 5 继续跨平台故事,.NET 6 的路线图包括第一个丰富的 GUI 界面,代号 Maui。如果您习惯于仅在 Microsoft 和.NET 平台上工作,这应表明事物继续沿着未来开发所需的类型思维发展。

ToLinuxTimestampFromDateTime接受一个.NET DateTime并将其转换为 Linux 时间戳。Linux 时间戳是从 Linux 纪元开始的毫秒数。由于我们在毫秒级别工作,TimeSpanDateTime的刻度转换为毫秒。为了进行转换,我们从.NET 时间和等效 Linux 时间之间的毫秒数中减去了数量,在EpochMillisecondDifference中通过从 Linux 纪元减去.NET(Windows)纪元进行预计算。转换后,我们需要将值四舍五入以消除过多的精度。默认的Math.Round使用所谓的银行家舍入,这通常不是我们所需要的,因此使用带有MidpointRounding.AwayFromZero的重载进行我们期望的舍入。解决方案将最终值作为字符串返回,您可以根据您的实现需求进行更改。

ToDateTimeFromLinuxTimestamp方法非常简单。将其转换为ulong后,它从毫秒创建一个新的时间戳,并将其加到 LinuxEpoch 上。以下是Main方法的输出:

WindowsEpoch == DateTime.MinValue: True
testDate: 1/1/2021 12:00:00 AM
Accidentally based on .NET Epoch: 1/2/0052 12:00:00 AM
Properly based on Linux Epoch: 1/1/2021 12:00:00 AM

正如您所看到的,DateTime.MinValue与 Windows 纪元相同。使用 2021 年 1 月 1 日作为一个好日期(至少我们希望如此),Main通过将该日期正确转换为 Linux 时间戳来开始。然后显示了处理该日期的错误方法。最后,它调用ToDateTimeFromLinuxTimestamp,执行正确的转换。

2.8 缓存频繁请求的数据

问题

网络延迟导致应用程序运行缓慢,因为静态且经常使用的数据经常被获取。

解决方案

这是将被缓存的数据类型:

public class InvoiceCategory
{
    public int ID { get; set; }

    public string Name { get; set; }
}

这是检索数据的存储库的接口:

public interface IInvoiceRepository
{
    List<InvoiceCategory> GetInvoiceCategories();
}

这是检索和缓存数据的存储库:

public class InvoiceRepository : IInvoiceRepository
{
    static List<InvoiceCategory> invoiceCategories;

    public List<InvoiceCategory> GetInvoiceCategories()
    {
        if (invoiceCategories == null)
            invoiceCategories = GetInvoiceCategoriesFromDB();

        return invoiceCategories;
    }

    List<InvoiceCategory> GetInvoiceCategoriesFromDB()
    {
        return new List<InvoiceCategory>
        {
            new InvoiceCategory { ID = 1, Name = "Government" },
            new InvoiceCategory { ID = 2, Name = "Financial" },
            new InvoiceCategory { ID = 3, Name = "Enterprise" },
        };
    }
}

这是使用该存储库的程序:

class Program
{
    readonly IInvoiceRepository invoiceRep;

    public Program(IInvoiceRepository invoiceRep)
    {
        this.invoiceRep = invoiceRep;
    }

    void Run()
        List<InvoiceCategory> categories =
            invoiceRep.GetInvoiceCategories();

        foreach (var category in categories)
            Console.WriteLine(
                $"ID: {category.ID}, Name: {category.Name}");
    }

    static void Main()
    {
        new Program(new InvoiceRepository()).Run();
    }
}

讨论

根据您使用的技术,可能有很多通过 CDN、HTTP 和数据源解决方案等机制缓存数据的选项。每种方法都有其适用的场景和目的,本节仅介绍了一种快速简单的数据缓存技术,适用于许多情况。

您可能遇到过一种情况,即在许多不同地方使用一组数据。这些数据通常是查找列表或业务规则数据的性质。在日常工作中,我们构建包含这些数据的查询,可以直接选择查询,也可以作为数据库表连接的形式存在。直到有人开始抱怨应用程序性能时,我们才会注意到这一点。分析可能会显示,有大量查询不断请求相同的数据集。如果可行,您可以将这些数据缓存在内存中,以避免网络延迟因对相同数据集的过多查询而加剧。

这并不是一个适用于所有情况的通用解决方案,因为您必须考虑在您的情况下是否实际可行。例如,在内存中保存过多数据是不现实的,这会引起其他可扩展性问题。理想情况下,这是一个有限且相对较小的数据集,例如发票类别。这些数据不应该经常更改,因为如果您需要实时访问动态数据,这种方法就行不通了。如果基础数据源发生更改,则缓存可能会保留旧的陈旧数据。

解决方案展示了一个InvoiceCategory类,我们将对其进行缓存。它是一个查找列表,每个对象仅有两个值,是一个有限且相对较小的集合,并且不经常变化。可以想象,每次发票查询以及包含查找列表的管理或搜索界面都需要这些数据。通过删除额外的连接并在数据库查询后加入缓存数据,可以加快发票查询速度并减少数据传输量。

解决方案中有一个InventoryRepository,实现了IInvoiceRepository接口。尽管对于这个例子来说这并不是严格必要的,但它支持展示 IoC 的另一个例子,正如 Recipe 1.2 中讨论的那样。

InvoiceRepository类具有一个invoiceCategories字段,用于保存InvoiceCategory的集合。GetInvoiceCategories方法通常会进行数据库查询并返回结果。但是,在这个例子中,只有在invoiceCategoriesnull时才执行数据库查询,并将结果缓存到invoiceCategories中。这样,后续请求将获取缓存版本,而不需要进行数据库查询。

注意

invoiceCategories 字段是静态的,因为你只想要一个单一的缓存。在无状态的 web 场景中,如 ASP.NET 中,Internet Information Services (IIS) 进程会不可预测地回收,并建议开发人员不要依赖静态变量。这种情况不同,因为如果回收清除了 invoiceCategories,使其为 null,下一个查询将重新填充它。

Main 方法使用 IoC 实例化 InvoiceRepository 并对 InvoiceCategory 集合执行查询。

另请参阅

第 1.2 节,“移除显式依赖”

2.9 延迟类型实例化

问题

一个类具有大量的实例化要求,通过延迟实例化只在必要时节省资源使用。

解决方案

这是我们将要处理的数据:

public class InvoiceCategory
{
    public int ID { get; set; }

    public string Name { get; set; }
}

这是存储库接口:

public interface IInvoiceRepository
{
    void AddInvoiceCategory(string category);
}

这是我们延迟实例化的存储库:

public class InvoiceRepository : IInvoiceRepository
{
    public InvoiceRepository()
    {
        Console.WriteLine("InvoiceRepository Instantiated.");
    }

    public void AddInvoiceCategory(string category)
    {
        Console.WriteLine($"for category: {category}");
    }
}

此程序展示了几种延迟初始化存储库的方法:

class Program
{
    public static ServiceProvider Container;

    readonly Lazy<InvoiceRepository> InvoiceRep =
        new Lazy<InvoiceRepository>();

    readonly Lazy<IInvoiceRepository> InvoiceRepFactory =
        new Lazy<IInvoiceRepository>(CreateInvoiceRepositoryInstance);

    readonly Lazy<IInvoiceRepository> InvoiceRepIoC =
        new Lazy<IInvoiceRepository>(CreateInvoiceRepositoryFromIoC);

    static IInvoiceRepository CreateInvoiceRepositoryInstance()
    {
        return new InvoiceRepository();
    }

    static IInvoiceRepository CreateInvoiceRepositoryFromIoC()
    {
        return Container.GetRequiredService<IInvoiceRepository>();
    }

    static void Main()
    {
        Container =
            new ServiceCollection()
                .AddTransient<IInvoiceRepository, InvoiceRepository>()
                .BuildServiceProvider();

        new Program().Run();
    }

    void Run()
    {
        IInvoiceRepository viaLazyDefault = InvoiceRep.Value;
        viaLazyDefault.AddInvoiceCategory("Via Lazy Default \n");

        IInvoiceRepository viaLazyFactory = InvoiceRepFactory.Value;
        viaLazyFactory.AddInvoiceCategory("Via Lazy Factory \n");

        IInvoiceRepository viaLazyIoC = InvoiceRepIoC.Value;
        viaLazyIoC.AddInvoiceCategory("Via Lazy IoC \n");
    }
}

讨论

有时您会遇到启动开销大的对象。它们可能需要一些初始计算,或者需要等待一段时间才能获取数据,因为网络延迟或依赖性于性能不佳的外部系统。这可能会对应用程序启动速度造成严重的负面影响。想象一下,一个应用程序由于启动太慢而失去潜在客户,甚至是企业用户因等待时间而受到影响。虽然您可能无法修复性能瓶颈的根本原因,但另一种选择可能是将该对象的实例化延迟到需要时。例如,如果您真的不需要立即使用该对象,可以立即显示启动屏幕。

解决方案演示了如何使用 Lazy<T> 延迟对象实例化。所涉及的对象是 InvoiceRepository,我们假设它在构造函数逻辑上有问题,导致延迟实例化。

Program 有三个字段,其类型为 Lazy<InvoiceRepository>,展示了三种不同的实例化方式。第一个字段 InvoiceRep,实例化一个没有参数的 Lazy<Invoice​Re⁠pository>。它假设 InvoiceRepository 有一个默认构造函数(无参数),并在代码访问 Value 属性时调用它来创建一个新实例。

InvoiceRepFactory 字段实例引用了 CreateInvoiceRepository​In⁠stance 方法。当代码访问此字段时,它调用 CreateInvoiceRepositor⁠y​Instance 来构造对象。由于它是一个方法,你在构建对象时有很大的灵活性。

除了其他两个选项之外,InvoiceRepIoC 字段显示了如何在 IoC 中使用延迟实例化。注意,Main 方法构建了一个 IoC 容器,如第 1.2 节中所述。CreateInvoiceRepositoryFromIoC 方法使用该 IoC 容器请求 InvoiceRepository 的实例。

最后,Run 方法展示如何通过 Lazy<T>.Value 属性访问字段。

另请参阅

菜谱 1.2,“移除显式依赖关系”

2.10 解析数据文件

问题

应用程序需要从自定义外部格式中提取数据,而字符串类型操作导致代码复杂且效率低下。

解决方案

这是我们将要处理的数据类型:

public class InvoiceItem
{
    public decimal Cost { get; set; }
    public string Description { get; set; }
}

public class Invoice
{
    public string Customer { get; set; }
    public DateTime Created { get; set; }
    public List<InvoiceItem> Items { get; set; }
}

此方法返回我们要提取并转换为发票的原始字符串数据:

static string GetInvoiceTransferFile()
{
    return
        "Creator 1::8/05/20::Item 1\t35.05\t" +
        "Item 2\t25.18\tItem 3\t13.13::Customer 1::Note 1\n" +
        "Creator 2::8/10/20::Item 1\t45.05" +
        "::Customer 2::Note 2\n" +
        "Creator 1::8/15/20::Item 1\t55.05\t" +
        "Item 2\t65.18::Customer 3::Note 3\n";
}

这些是用于构建和保存发票的实用方法:

static Invoice GetInvoice(
    string matchCustomer, ..., string matchItems)
{
    List<InvoiceItem> lineItems = GetLineItems(matchItems);

    DateTime.TryParse(matchCreated, out DateTime created);

    var invoice =
        new Invoice
        {
            Customer = matchCustomer,
            Created = created,
            Items = lineItems
        };
    return invoice;
}

static List<InvoiceItem> GetLineItems(string matchItems)
{
    var lineItems = new List<InvoiceItem>();

    string[] itemStrings = matchItems.Split('\t');

    for (int i = 0; i < itemStrings.Length; i += 2)
    {
        decimal.TryParse(itemStrings[i + 1], out decimal cost);
        lineItems.Add(
            new InvoiceItem
            {
                Description = itemStrings[i],
                Cost = cost
            });
    }

    return lineItems;
}

static void SaveInvoices(List<Invoice> invoices)
{
    Console.WriteLine($"{invoices.Count} invoices saved.");
}

此方法使用正则表达式从原始字符串数据中提取值:

static List<Invoice> ParseInvoices(string invoiceFile)
{
    var invoices = new List<Invoice>();

    Regex invoiceRegEx = new Regex(
        @"^.+?::(?<created>.+?)::(?<items>.+?)::(?<customer>.+?)::.+");

    foreach (var invoiceString in invoiceFile.Split('\n'))
    {
        Match match = invoiceRegEx.Match(invoiceString);

        if (match.Success)
        {
            string matchCustomer = match.Groups["customer"].Value;
            string matchCreated = match.Groups["created"].Value;
            string matchItems = match.Groups["items"].Value;

            Invoice invoice =
                GetInvoice(matchCustomer, matchCreated, matchItems);
            invoices.Add(invoice);
        }
    }

    return invoices;
}

Main 方法运行演示:

static void Main(string[] args)
{
    string invoiceFile = GetInvoiceTransferFile();

    List<Invoice> invoices = ParseInvoices(invoiceFile);

    SaveInvoices(invoices);
}

讨论

有时,我们会遇到不符合标准数据格式的文本数据。它可能来自现有的文档文件、日志文件或外部和遗留系统。通常,我们需要接收这些数据并处理以便存储到数据库中。本节将解释如何使用正则表达式进行处理。

解决方案展示了我们想要生成的数据格式是一个带有 InvoiceItem 集合的 InvoiceGetInvoiceTransferFile 方法展示了数据的格式。演示表明数据可能来自已经生成了该格式的遗留系统,使用 C# 代码来接收比在该系统中添加支持更好的格式更容易。我们要提取的具体数据是 created 日期、发票 itemscustomer 名称。注意换行符 (\n) 分隔记录,双冒号 (::) 分隔发票字段,制表符 (\t) 分隔发票项字段。

GetInvoiceGetLineItems 方法从提取的数据构造对象,并用于将对象构建与正则表达式提取逻辑分离。

ParseInvoices 方法使用正则表达式从输入字符串中提取数值。RegEx 构造函数参数包含用于提取数值的正则表达式字符串。

虽然讨论整个正则表达式的内容超出了范围,但这里是该字符串的功能:

  • ^ 表示从字符串的开头开始。

  • .+?:: 匹配所有字符,直到下一个发票字段分隔符 (::)。换句话说,它忽略了匹配的内容。

  • (?<created>.+?)::(?<items>.+?)::(?<customer>.+?):: 类似于 .+?)::,但进一步通过给定名称提取值到组中。例如,(?<created>.+?):: 表示将提取所有匹配的数据并放入名为“created”的组中。

  • .+ 匹配所有剩余字符。

foreach 循环依赖于字符串中的 \n 分隔符来处理每个发票。Match 方法执行正则表达式匹配,提取数值。如果匹配成功,代码从组中提取数值,调用 GetInvoice 方法,并将新发票添加到 invoices 集合中。

您可能已经注意到,我们使用GetLineItemsmatchItems参数中提取数据,来自正则表达式items字段。我们本可以使用更复杂的正则表达式来处理这个问题。然而,这是有意为之,用来对比展示在这种情况下正则表达式处理更为优雅的解决方案。

提示

作为增强功能,如果您关心数据丢失或者想知道正则表达式或原始数据格式中是否存在 bug,可以记录任何match.Successfalse的情况。

最后,应用程序将新的行项目返回给调用代码Main,以便保存它们。

第三章:确保质量

所有的最佳实践、复杂算法和模式,在代码工作正常的情况下毫无意义。我们都希望构建尽可能最好的应用程序并最小化错误。本章的主题围绕可维护性、错误预防和编写正确的代码。

在团队合作时,其他开发人员必须与您编写的代码一起工作。他们会添加新功能并修复错误。如果您编写的代码易于阅读,那么它将更易于维护,即其他开发人员将能够阅读和理解它。即使您是唯一的开发人员,回顾过去编写的代码也可能是一种新体验。增强的可维护性导致引入较少的新错误,并且任务周转更快。较少的错误意味着较少的软件生命周期成本,为其他增值功能提供更多时间。正是这种可维护性的精神激励了本章内容。

与可维护性类似,错误预防是一个重要的质量概念。用户可以并且将使用应用程序发现我们从未想过会发生的一个错误。章节 3.1 和 3.4 提供了帮助的关键工具。正确的异常处理是一项重要的技能,您也将学到这一点。

质量的另一个特征是确保代码正确,单元测试是一种重要的实践。虽然单元测试已经存在很长时间,但它并不是一个解决的问题。许多开发人员仍然不写单元测试。然而,这是一个如此重要的主题,本章的第一部分将向您展示如何编写单元测试。

3.1 编写单元测试

问题

质量保证专业人员在集成测试期间持续发现问题,您希望减少检查到的错误数量。

解决方案

这是测试的代码:

public enum CustomerType
{
    Bronze,
    Silver,
    Gold
}

public class Order
{
    public decimal CalculateDiscount(
        CustomerType custType, decimal amount)
    {
        decimal discount;

        switch (custType)
        {
            case CustomerType.Silver:
                discount = amount * 1.05m;
                break;
            case CustomerType.Gold:
                discount = amount * 1.10m;
                break;
            case CustomerType.Bronze:
            default:
                discount = amount;
                break;
        }

        return discount;
    }
}

单独的测试项目包含单元测试:

public class OrderTests
{
 [Fact]
    public void
    CalculateDiscount_WithBronzeCustomer_GivesNoDiscount()
    {
        const decimal ExpectedDiscount = 5.00m;

        decimal actualDiscount =
            new Order().CalculateDiscount(CustomerType.Bronze, 5.00m);

        Assert.Equal(ExpectedDiscount, actualDiscount);
    }

 [Fact]
    public void
    CalculateDiscount_WithSilverCustomer_GivesFivePercentDiscount()
    {
        const decimal ExpectedDiscount = 5.25m;

        decimal actualDiscount =
            new Order().CalculateDiscount(CustomerType.Silver, 5.00m);

        Assert.Equal(ExpectedDiscount, actualDiscount);
    }

 [Fact]
    public void
    CalculateDiscount_WithGoldCustomer_GivesTenPercentDiscount()
    {
        const decimal ExpectedDiscount = 5.50m;

        decimal actualDiscount =
            new Order().CalculateDiscount(CustomerType.Gold, 5.00m);

        Assert.Equal(ExpectedDiscount, actualDiscount);
    }
}

讨论

要测试的代码是系统的被测代码(SUT),测试它的代码称为单元测试。单元测试通常在一个独立的项目中,引用 SUT,通过不将测试代码与生产代码一起发布来避免膨胀可交付组件的大小。要测试的单元通常是类、记录或结构类型。解决方案中有一个Order类(SUT),其中有一个CalculateDiscount方法。单元测试确保CalculateDiscount能够正确操作。

有几个众所周知的单元测试框架,您可以尝试几个,并选择最喜欢的一个使用。这些示例使用了 XUnit。大多数单元测试框架与 Visual Studio 和其他 IDE 集成。

单元测试框架帮助使用属性识别单元测试代码。一些框架为测试类添加了一个属性,但 XUnit 没有。对于 XUnit,您只需要为单元测试添加一个[Fact]属性,它将与您正在使用的 IDE 或其他工具配合使用。XUnit 的作者希望减少过度使用属性,并使 F#(以及其他.NET 语言)更容易使用该框架。

注意

单元测试框架使用属性来识别测试是很有趣的。它们使用了一个名为reflection的.NET 特性。Recipe 5.1 展示了如何使用 reflection 在代码中处理属性,以便您可以构建自己的工具。

单元测试的命名约定指示了它们的目的,使其易于阅读。OrderTests类指示其单元测试操作Order类。单元测试方法的命名模式如下:

    <MethodToTest>_<Condition>_<ExpectedOutcome>

第一个单元测试,CalculateDiscount_WithBronzeCustomer_GivesNoDiscount,遵循以下模式:

  • CalculateDiscount是要测试的方法。

  • WithBronzeCustomer指定了这个特定测试的输入中的独特之处。

  • GivesNoDiscount是要验证的结果。

单元测试的组织使用了一种称为安排、执行和断言(AAA)的格式。以下讨论涵盖了测试格式的每个部分。

安排部分创建了测试发生所需的所有类型。在这些单元测试中,安排创建了一个const ExpectedDiscount。在更复杂的情况下,安排部分将实例化输入参数,以建立适当的测试条件。在这个例子中,条件非常简单,它们被写成了执行部分的常量参数。

执行部分是一个方法调用,如果有的话,会传入参数,创建要测试的条件。在这些示例中,执行部分实例化了一个Order实例,并调用CalculateDiscount,传入适当的参数值,将响应分配给actualDiscount

Assert类属于 XUnit 测试框架。恰如其名,Assert语句用于测试的断言部分。请注意我为actualDiscountExpectedDiscount使用的命名约定。Assert类有几种方法,其中Equal非常受欢迎,因为它允许您比较您在执行部分期望的结果和实际收到的结果。

您从单元测试中可能获得的好处包括更好的代码设计,验证代码是否符合预期,防止回归,部署验证和文档编制。关键词在于可能,因为不同的人和/或团队选择他们想要从单元测试中获得的好处。

更好的代码设计来自于在编写代码之前编写测试。你可能听说过这种技术在敏捷或行为驱动开发(BDD)环境中被讨论过。通过让开发者提前考虑预期行为,可能会产生更清晰的设计。另一方面,你可能希望在编写代码之后编写单元测试。开发者们以两种方式编写代码和单元测试,对于哪种方式更可取存在不同意见。但无论如何,拥有测试,比起没有测试,更有可能提高代码质量。

第二点验证代码是否达到预期目的是最大的好处。对于像服务于代码文档的简单方法来说,这并不是什么大问题。然而,对于复杂的算法或像确保客户获得正确折扣这样关键的任务来说,单元测试确实发挥了重要作用。

另一个重要的好处是防止回归。当代码发生变化时,你或其他开发者可能会意外改变代码的原始意图,引入错误。通过在修改代码后运行单元测试,可以在源头找到并修复错误,而不是由质量保证专业人员或(更糟糕的是)客户在后期发现。

随着现代化的 DevOps,我们有能力通过持续部署来自动化构建。你可以将单元测试运行添加到 DevOps 管道中,这样可以在与其余代码合并之前捕获错误。拥有更多的单元测试可以通过这种技术减少开发者破坏构建的可能性。

最后,你还有另一层文档。这就是为什么单元测试的命名约定如此重要。如果另一个不熟悉应用程序的开发者需要理解代码,单元测试可以解释代码应该具有的正确行为。

如果你还没有使用单元测试,本讨论将帮助你入门。你可以通过搜索 XUnit 和其他单元测试框架来了解它们的工作原理。如果你还没有这样做,请查看食谱 1.2,其中描述了使代码更具可测试性的技术。

参见

食谱 1.2,“移除显式依赖项”

食谱 5.1,“使用反射读取属性”

3.2 版本化接口的安全性

问题

你需要在一个库中安全地更新一个接口,而不会破坏已部署的代码。

解决方案

更新前的接口:

public interface IOrder
{
    string PrintOrder();
}

更新后的接口:

public interface IOrder
{
    string PrintOrder();

    decimal GetRewards() => 0.00m;
}

CompanyOrder 更新前:

public class CompanyOrder : IOrder
{
    public string PrintOrder()
    {
        return "Company Order Details";
    }
}

CompanyOrder 更新后:

public class CompanyOrder : IOrder
{
    decimal total = 25.00m;

    public string PrintOrder()
    {
        return "Company Order Details";
    }

    public decimal GetRewards()
    {
        return total * 0.01m;
    }
}

CustomerOrder 更新前后:

class CustomerOrder : IOrder
{
    public string PrintOrder()
    {
        return "Customer Order Details";
    }
}

这是类型的使用方式:

class Program
{
    static void Main()
    {
        var orders = new List<IOrder>
        {
            new CustomerOrder(),
            new CompanyOrder()
        };

        foreach (var order in orders)
        {
            Console.WriteLine(order.PrintOrder());
            Console.WriteLine($"Rewards: {order.GetRewards()}");
        }
    }
}

讨论

在 C# 8 之前,我们无法向现有接口添加新成员,而不改变实现该接口的所有类型。如果这些实现类型位于同一代码库中,这是可以修复的更改。然而,对于框架库,开发人员依赖于接口与该库进行交互,这将是一个破坏性变更。

解决方案描述了如何更新接口以及其影响。这个场景适用于可能希望将之前赚取的一些奖励点数应用到当前订单的客户。

查看IOrder,您可以看到更新后版本添加了GetRewards方法。从历史上看,接口是不允许有实现的。然而,在新版本的IOrder中,GetRewards方法有一个默认实现,返回$0.00作为奖励。

解决方案还介绍了CompanyOrder类的前后版本,其中后版本包含了GetRewards的实现。现在,任何通过CompanyOrder实例调用GetRewards的代码将执行CompanyOrder的实现,而不是默认的实现。

相比之下,解决方案展示了一个同样实现了IOrderCustomerOrder类。这里的区别在于CustomerOrder没有改变。任何通过CompanyOrder实例调用GetRewards的代码将执行默认的IOrder实现。

Program Main方法展示了这是如何工作的。orders是一个IOrder列表,包含CustomerOrderCompanyOrder的运行时实例。foreach循环遍历orders,调用IOrder的方法。如前所述,对于CompanyOrder实例调用GetRewards会使用该类的实现,而CustomerOrder则使用默认的IOrder实现。

本质上,这个变化意味着如果开发人员在自己的类中实现IOrder,比如CustomerOrder,他们的代码在更新到最新版本时不会中断。

3.3 简化参数验证

问题

你总是在寻找简化代码的方法,包括参数验证。

解决方案

冗长的参数验证语法:

static void ProcessOrderOld(string customer, List<string> lineItems)
{
    if (customer == null)
    {
        throw new ArgumentNullException(
            nameof(customer), $"{nameof(customer)} is required.");
    }

    if (lineItems == null)
    {
        throw new ArgumentNullException(
            nameof(lineItems), $"{nameof(lineItems)} is required.");
    }

    Console.WriteLine($"Processed {customer}");
}

简洁的参数验证语法:

static void ProcessOrderNew(string customer, List<string> lineItems)
{
    _ = customer ?? throw new ArgumentNullException(
        nameof(customer), $"{nameof(customer)} is required.");
    _ = lineItems ?? throw new ArgumentNullException(
        nameof(lineItems), $"{nameof(lineItems)} is required.");

    Console.WriteLine($"Processed {customer}");
}

讨论

公共方法的第一行代码通常涉及参数验证,有时可能会很冗长。此部分展示了如何节省几行代码,以免混淆原方法的目的代码。

解决方案有两种参数验证技术:冗长和简洁。冗长的方法是典型的,代码确保参数不为空,并在其他情况下抛出异常。在这种单行抛出语句中,括号并不是必需的,但是如果编码标准要求括号出现,某些开发人员/团队可能仍然会喜欢它们,以避免未来维护错误,特别是对于应该在if块中的语句。

简短的方法是可以节省几行代码的替代方法。它依赖于 C#的新功能:变量丢弃_和合并运算符??

注意

使用合并运算符和丢弃进行简化的参数验证适合单行。然而,为了书本格式,需要使用两行。

在验证customer的行上,代码以丢弃的赋值开头,因为我们需要一个表达式。合并运算符是一个检测表达式为null时执行下一条语句的保护。

提示

此示例是用于参数评估。但是,在代码遇到设置为null的变量并需要抛出无效条件或本不应发生的情况时,还有其他场景。此技术让您可以快速处理单行代码。

参见

第 3.4 节,“保护代码免受 NullReferenceException”

3.4 保护代码免受 NullReferenceException

问题

您正在构建一个可重用库,并需要传达可空引用语义。

解决方案

这是旧式代码,不处理空引用:

public class OrderLibraryNonNull
{
    // nullable property
    public string DealOfTheDay { get; set; }

    // method with null parameter
    public void AddItem(string item)
    {
        Console.Write(item.ToString());
    }

    // method with null return value
    public List<string> GetItems()
    {
        return null;
    }

    // method with null type parameter
    public void AddItems(List<string> items)
    {
        foreach (var item in items)
            Console.WriteLine(item.ToString());
    }
}

以下项目文件启用了新的可空引用特性:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>netcoreapp3.1</TargetFramework>
        <RootNamespace>Section_03_04</RootNamespace>
        <Nullable>enable</Nullable>
    </PropertyGroup>
</Project>

下面是更新后的库代码,涉及可空引用的通信:

public class OrderLibraryWithNull
{
    // nullable property
    public string? DealOfTheDay { get; set; }

    // method with null parameter
    public void AddItem(string? item)
    {
        _ = item ?? throw new ArgumentNullException(
            nameof(item), $"{nameof(item)} must not be null");

        Console.Write(item.ToString());
    }

    // method with null return value
    public List<string>? GetItems()
    {
        return null;
    }

    // method with null type parameter
    public void AddItems(List<string?> items)
    {
        foreach (var item in items)
            Console.WriteLine(item?.ToString() ?? "None");
    }
}

这是一个忽略可空引用的旧式消费代码示例:

static void HandleWithNullNoHandling()
{
    var orders = new OrderLibraryWithNull();

    string deal = orders.DealOfTheDay;
    Console.WriteLine(deal.ToUpper());

    orders.AddItem(null);
    orders.AddItems(new List<string> { "one", null });

    foreach (var item in orders.GetItems().ToArray())
        Console.WriteLine(item.Trim());
}

图 3-1 显示了用户在使用忽略可空引用的代码时看到的警告墙。

Visual Studio 中显示多个可空引用警告的错误窗口

图 3-1. Visual Studio 中的可空引用警告

最后,这是消费代码如何正确对待具有适当检查和验证的可重用库的示例:

static void HandleWithNullAndHandling()
{
    var orders = new OrderLibraryWithNull();

    string? deal = orders.DealOfTheDay;
    Console.WriteLine(deal?.ToUpper() ?? "Deals");

    orders.AddItem(null);
    orders.AddItems(new List<string?> { "one", null });

    List<string>? items = orders.GetItems();

    if (items != null)
        foreach (var item in items.ToArray())
            Console.WriteLine(item.Trim());
}

讨论

如果你已经使用 C#编程一段时间,很可能遇到过NullReferenceExceptions。当引用仍为 null 的变量的成员时会发生NullReferenceException,实质上是试图使用尚不存在的对象。C# 8 首次引入了可空引用,通过减少抛出的NullReferenceException异常数量来帮助编写更高质量的代码。整个概念围绕着在编译时通知开发人员变量为 null 的情况,可能导致抛出NullReferenceException。这种情况基于需要为其他开发人员编写可重用库,可能是一个单独的类库或 NuGet 包。您的目标是让他们知道库中可能发生空引用的位置,以便他们编写代码来防止NullReferenceException

为了演示,解决方案展示了不通知空引用的库代码。本质上,这是旧式代码,展示了 C# 8 之前开发人员会编写的代码。您还将看到如何配置项目以支持 C# 8 的可空引用。然后,您将了解如何更改该库代码,以向可能消费它的开发人员传达空引用。最后,您将看到两个消费代码的示例:一个不处理空引用,另一个显示如何防止空引用。

在第一个解决方案示例中,OrderLibraryNonNull类具有参数或返回类型为引用类型(例如stringList<string>)的成员。在可空和非可空上下文中,这段代码不会生成任何警告。即使在可空上下文中,引用类型也没有标记为可空,并且危险地传达给用户,他们永远不会收到NullReferenceException。然而,由于可能会出现NullReferenceExceptions,我们不希望再这样编写我们的代码了。

在解决方案中的 XML 清单是项目文件,其中包含/Project/PropertyGroup/Nullable元素。将其设置为true将项目置于可空上下文中。将单独的类库放入可空上下文可能会为类库开发人员提供警告,但代码的使用者永远不会看到这些警告。

OrderLibraryWithNull的下一个解决方案代码片段修复了这个问题。与OrderLibraryNonNull进行比较,以区分它们的不同之处。在评估空引用时,逐个成员地遍历类型,思考参数和返回值如何影响库的消费者,特别是在空引用方面。存在许多不同的空场景,但这个例子涵盖了三种常见情况:属性类型、方法参数类型和泛型参数类型,下面的段落中有详细解释。

注意

有时,一个方法确实不会返回空引用。这时候不使用可空操作符来告知使用者不需要检查空引用是有意义的。

DealOfTheDay展示了属性类型空引用的情景。它的getter属性返回一个string,这个值可能为空。使用可空操作符?来修复这些问题,并返回string?

AddItems类似,只是它接受一个string参数,演示了方法参数的情况。由于string可以为null,将其更改为string?也让编译器了解了。请注意,我使用了 Recipe 3.3 中描述的简化参数检查。

有时,您可能会遇到可空的泛型参数类型。GetItems方法返回一个List<string>,而List<T>是引用类型。因此,将其更改为List<string>?可以解决问题。

最后,这里有一个有点棘手的例子。AddItems 中的 items 参数是一个 List<string>。可以轻松进行参数检查以测试 null 参数,但是省略可空操作符也是一种好方法,以告知用户不应传递 null 值。

也就是说,如果 List<string> 中的一个值是 null 怎么办?在这种情况下,它是一个 List<string>,但是对于用户可以传递 Dictionary<string, string> 的场景,其中值可以是 null,那么就像例子中对 List<string?> 所做的那样,注释类型参数,表示允许值为 null。因为你知道参数可以为 null,在引用其成员之前检查是非常重要的,以避免 NullReferenceException

现在你有了一个对消费者有用的库代码。然而,只有消费者也将其项目置于可为空的上下文中,才能发挥其作用,如项目文件中所示。

HandleWithNullNoHandling 方法展示了在 C# 8 之前开发者可能编写的代码。然而,一旦将项目置于可为空的上下文中,将收到多个警告,如在 Visual Studio 错误列表窗口中显示的警告墙所示。与 HandleWithNullAndHandling 方法进行比较,对比非常明显。

整个过程是级联的,所以从方法顶部开始,逐步向下工作:

  1. 因为 DealOfTheDay 的 getter 可能返回 null,将 deal 的类型设置为 string?

  2. 由于 deal 可能为 null,使用空引用操作符和合并操作符确保 Console.WriteLine 有合理的内容可写。

  3. 传递给 AddItems 的类型需要是 List<string?>,以表明你知道一个项可能为 null

  4. orders.GetItems 内联到 foreach 循环中,改为将其重构为一个新变量。这样可以检查 null 以避免使用 null 迭代器。

参见

Recipe 3.3, “简化参数验证”

3.5 避免神奇的字符串

问题

const 字符串在应用程序的多个位置存在,并且你需要一种方法来更改它而不会破坏其他代码。

解决方案

这是一个 Order 对象:

public class Order
{
    public string DeliveryInstructions { get; set; }

    public List<string> Items { get; set; }
}

这里是一些常量:

public class Delivery
{
    public const string NextDay = "Next Day";
    public const string Standard = "Standard";
    public const string LowFare = "Low Fare";

    public const int StandardDays = 7;
}

这是使用 Order 和常量计算交付天数的程序:

static void Main(string[] args)
{
    var orders = new List<Order>
    {
        new Order { DeliveryInstructions = Delivery.LowFare },
        new Order { DeliveryInstructions = Delivery.NextDay },
        new Order { DeliveryInstructions = Delivery.Standard },
    };

    foreach (var order in orders)
    {
        int days;

        switch (order.DeliveryInstructions)
        {
            case Delivery.LowFare:
                days = 15;
                break;
            case Delivery.NextDay:
                days = 1;
                break;
            case Delivery.Standard:
            default:
                days = Delivery.StandardDays;
                break;
        }

        Console.WriteLine(order.DeliveryInstructions);
        Console.WriteLine($"Expected Delivery Day(s): {days}");
    }
}

讨论

开发软件一段时间后,大多数开发者都见过一些神奇的值,这些是直接写入表达式的文本值和数字值。从原始开发者的角度来看,它们可能不是一个大问题。然而,从维护开发者的角度来看,这些文本值并不立即显得合理。就像它们神奇地从无处出现一样,或者感觉代码之所以工作是因为这些文本值的含义并不明显。

目标是编写能够让未来的维护人员理解的代码。否则,由于试图弄清楚某些看似随机的数字而浪费的时间,项目成本会增加。解决方案通常是用一个变量替换文字值,其名称表达了值的语义或存在的原因。一种普遍认为可读性良好的代码比注释更具可维护性的生命周期更长。

更进一步,本地常量有助于提高方法的可读性,但常量通常是可重复使用的。解决方案示例演示了如何将一些可重复使用的常量放置在它们自己的类中,以便代码的其他部分重复使用。

除了itemsOrder类还有一个DeliveryInstructions属性。在这里,我们假设有一组有限的交货说明。

Delivery类具有NextDayStandardLowFareconst string值,描述了订单应该如何交付。此外,请注意该类有一个StandardDays值,设置为7。你更愿意阅读哪种程序——使用7还是使用名为StandardDays的常量?这使得代码更易读,正如在Program类中所示。

注意

你可能首先考虑Delivery类中的const string值更适合用枚举。但请注意它们有空格。而且,它们将与order一起写入。虽然有技术可以将枚举用作string,但这很简单。

在某些场景中,你需要一个特定的string值进行查找。这是一个主观的问题,取决于你认为适合某项任务的工具。如果发现枚举更方便的情况,请使用该路线。

Program类使用OrdersDelivery来计算交货所需的天数,基于订单的DeliveryInstructions。列表中有三个订单,每个订单对DeliveryInstructions有不同的设置。foreach循环遍历这些订单,使用switch语句根据DeliveryInstructions设置交货天数。

注意到有序列表构造和switch语句都使用了Delivery中的常量。如果没有这样做,到处都会有strings。现在,借助 IntelliSense 支持,编码变得更加容易,没有重复,因为string只在一个地方,减少了打字错误的机会。而且,如果需要更改strings,只需在一个地方进行修改。此外,你还能获得 IDE 重构支持,以便在应用程序中改变常量出现的所有地方。

3.6 自定义类字符串表示

问题

在调试器中的类表示、字符串参数和日志文件都是不可读的,你希望自定义它们的外观。

解决方案

下面是一个具有自定义ToString方法的类:

public class Order
{
    public int ID { get; set; }

    public string CustomerName { get; set; }

    public DateTime Created { get; set; }

    public decimal Amount { get; set; }

    public override string ToString()
    {
        var stringBuilder = new StringBuilder();

        stringBuilder.Append(nameof(Order));
        stringBuilder.Append(" {\n");

        if (PrintMembers(stringBuilder))
            stringBuilder.Append(" ");

        stringBuilder.Append("\n}");

        return stringBuilder.ToString();
    }

    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("  " + nameof(ID));
        builder.Append(" = ");
        builder.Append(ID);
        builder.Append(", \n");
        builder.Append("  " + nameof(CustomerName));
        builder.Append(" = ");
        builder.Append(CustomerName);
        builder.Append(", \n");
        builder.Append("  " + nameof(Created));
        builder.Append(" = ");
        builder.Append(Created.ToString("d"));
        builder.Append(", \n");
        builder.Append("  " + nameof(Amount));
        builder.Append(" = ");
        builder.Append(Amount);

        return true;
    }
}

下面是使用的示例:

class Program
{
    static void Main(string[] args)
    {
        var order = new Order
        {
            ID = 7,
            CustomerName = "Acme",
            Created = DateTime.Now,
            Amount = 2_718_281.83m
        };

        Console.WriteLine(order);
    }
}

这是输出:

Order {
  ID = 7,
  CustomerName = Acme,
  Created = 1/23/2021,
  Amount = 2718281.83
}

讨论

有些类型很复杂,在调试器中查看实例很麻烦,因为您需要深入多个级别来检查值。现代 IDE 使这一过程更加轻松,但有时更希望有更可读的类表示。

这就是重写ToString方法的用处。ToString是所有类型派生自的Object类型的方法。默认实现是类型的完全限定名称,在解决方案中Order类的名称是Section_03_06.Order。由于它是虚方法,您可以重写它。

实际上,Order类使用自己的表示方式重写了ToString。如第 2.1 节所述,实现使用StringBuilder。格式使用对象名称,大括号内是属性,如输出中所示。

Main中,演示代码通过Console.WriteLine生成此输出。这是因为如果参数不是stringConsole.WriteLine会调用对象的ToString方法。

另请参阅

第 2.1 节,“高效处理字符串”

3.7 重新抛出异常

问题

应用程序抛出异常,但消息缺少信息,您需要确保处理过程中所有相关数据都是可用的。

解决方案

此对象抛出一个异常:

public class Orders
{
    public void Process()
    {
        throw new IndexOutOfRangeException(
            "Expected 10 orders, but found only 9.");
    }
}

这里有处理异常的不同方法:

public class OrderOrchestrator
{
    public static void HandleOrdersWrong()
    {
        try
        {
            new Orders().Process();
        }
        catch (IndexOutOfRangeException ex)
        {
            throw new InvalidOperationException(ex.Message);
        }
    }

    public static void HandleOrdersBetter1()
    {
        try
        {
            new Orders().Process();
        }
        catch (IndexOutOfRangeException ex)
        {
            throw new InvalidOperationException("Error Processing Orders", ex);
        }
    }

    public static void HandleOrdersBetter2()
    {
        try
        {
            new Orders().Process();
        }
        catch (IndexOutOfRangeException)
        {
            throw;
        }
    }

    public static void DontHandleOrders()
    {
        new Orders().Process();
    }
}

此程序测试每种异常处理方法:

class Program
{
    static void Main(string[] args)
    {
        AppDomain.CurrentDomain.UnhandledException +=
            (object sender, UnhandledExceptionEventArgs e) =>
            System.Console.WriteLine("\n\nUnhandled Exception:\n" + e);

        try
        {
            OrderOrchestrator.HandleOrdersWrong();
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine("Handle Orders Wrong:\n" + ex);
        }

        try
        {
            OrderOrchestrator.HandleOrdersBetter1();
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine("\n\nHandle Orders Better #1:\n" + ex);
        }

        try
        {
            OrderOrchestrator.HandleOrdersBetter2();
        }
        catch (IndexOutOfRangeException ex)
        {
            Console.WriteLine("\n\nHandle Orders Better #2:\n" + ex);
        }

        OrderOrchestrator.DontHandleOrders();
    }
}

下面是输出:

Handle Orders Wrong:
System.InvalidOperationException: Expected 10 orders, but found only 9.
   at Section_03_07.OrderOrchestrator.HandleOrdersWrong() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 15
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 11

Handle Orders Better #1:
System.InvalidOperationException: Error Processing Orders
 ---> System.IndexOutOfRangeException: Expected 10 orders, but found only 9.
   at Section_03_07.Orders.Process() in
   /CSharp9Cookbook/Chapter03/Section-03-07/Orders.cs:line 9
   at Section_03_07.OrderOrchestrator.HandleOrdersBetter1() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 23
   --- End of inner exception stack trace ---
   at Section_03_07.OrderOrchestrator.HandleOrdersBetter1() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 27
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 20

Handle Orders Better #2:
System.IndexOutOfRangeException: Expected 10 orders, but found only 9.
   at Section_03_07.Orders.Process() in
   /CSharp9Cookbook/Chapter03/Section-03-07/Orders.cs:line 9
   at Section_03_07.OrderOrchestrator.HandleOrdersBetter2() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 35
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 29

Unhandled Exception:
System.UnhandledExceptionEventArgs
Unhandled exception. System.IndexOutOfRangeException:
   Expected 10 orders, but found only 9.
   at Section_03_07.Orders.Process() in
   /CSharp9Cookbook/Chapter03/Section-03-07/Orders.cs:line 9
   at Section_03_07.OrderOrchestrator.DontHandleOrders() in
   /CSharp9Cookbook/Chapter03/Section-03-07/OrderOrchestrator.cs:line 45
   at Section_03_07.Program.Main(String[] args) in
   /CSharp9Cookbook/Chapter03/Section-03-07/Program.cs:line 40

讨论

有多种处理异常的方法,其中一些比其他方法更好。从故障排除的角度来看,我们通常希望记录具有足够有意义信息的异常日志,以帮助解决问题。这正是本节的目的,确定应采用哪种更好的解决方案。

Orders类的Process方法抛出IndexOutOfRangeException,而OrderOrchestrator类以几种不同的方式处理该异常:其中一种是应该避免的,而另外两种则更好,具体取决于您的情况。

HandleOrdersWrong方法获取原始异常的Message属性,并使用该消息作为输入抛出新的InvalidOperationException。该场景模拟了分析情况并尝试抛出比原始异常更具意义或提供更多信息的异常的情况。然而,这会导致另一个问题,即丢失关键的堆栈跟踪信息,这些信息对于解决问题至关重要。在实践中,异常可能通过多个级别抛出并通过不同路径到达。您可以在堆栈跟踪中看到此问题,在输出中显示异常的堆栈跟踪源自OrderOrchestrator.HandleOrdersWrong方法,而非其真正源自Orders.Process

警告

另一件绝对不应该做的事情是像这样重新抛出原始异常:

try
{
    OrderOrchestrator.HandleOrdersWrong();
}
catch (InvalidOperationException ex)
{
    throw ex;
}

这种方法的问题在于重新抛出原始异常会导致丢失堆栈跟踪。没有原始堆栈跟踪,试图调试程序的开发人员将不知道异常的起源位置。此外,原始异常可能与您收到的异常不同,可能包含更详细的信息,而没有人会看到。

HandleOrdersBetter1方法通过向innerException参数添加额外的参数ex改进了这种情况。这样做的好处在于现在可以抛出带有附加数据的异常,并保留整个堆栈跟踪。您可以在输出中看到异常路径始于Orders.Process(由--- End of inner exception stack trace ---分隔)。

HandleOrdersBetter2仅抛出原始异常。这里的假设是逻辑无法处理异常或记录并重新抛出。如输出所示,堆栈跟踪也源自Orders.Process

处理异常有很多策略,本文涵盖了其中一个方面。在这种情况下,考虑保留用于后续调试的原始堆栈跟踪,您应该重新抛出异常。无论如何,都要考虑您的情景及其对您来说是否有意义。

有时,可能会遇到代码抛出异常但没有处理策略的情况。OrderOrchestrator.DontHandleOrders不执行任何处理,而Main方法未用try/catch保护。在这种情况下,仍可以通过向AppDomain.​Cur⁠rentDomain.UnhandledException添加事件处理程序来拦截异常,正如在Main方法末尾所示。在运行任何代码之前,您需要分配事件处理程序,否则将无法处理异常。

参见

Recipe 1.9, “设计自定义异常”

3.8 管理进程状态

问题

用户启动了一个进程,但发生异常后,用户界面状态未更新。

解决方案

此方法会抛出异常:

static void ProcessOrders()
{
    throw new ArgumentException();
}

这是您不应编写的代码:

static void Main()
{
    Console.WriteLine("Processing Orders Started");

    ProcessOrders();

    Console.WriteLine("Processing Orders Complete");
}

取而代之,这是您应编写的代码:

static void Main()
{
    try
    {
        Console.WriteLine("Processing Orders Started");

        ProcessOrders();
    }
    catch (ArgumentException ae)
    {
        Console.WriteLine('\n' + ae.ToString() + '\n');
    }
    finally
    {
        Console.WriteLine("Processing Orders Complete");
    }
}

讨论

问题陈述提到发生了异常,这是正确的。但是,从用户角度来看,他们将不会收到解释问题发生以及其工作未完成的消息或状态。这是因为在第一个Main方法中,如果在ProcessOrder期间抛出异常,"Processing Orders Complete"消息不会显示给用户。

这是一个try/finally块的良好使用案例,第二个Main方法使用了它。将所有应在try块中运行的代码和最终状态放在finally块中。如果发生异常,可以捕获它,记录下来,并告知用户他们的任务未成功。

虽然这是控制台应用程序的示例,但对于 UI 代码也是一个好的技术。在启动进程时,您可能会有一个类似沙漏或进度指示器的等待通知。关闭通知也是 finally 块可以帮助的任务。

参见

第 3.9 节,“构建弹性网络连接”

第 3.10 节,“性能测量”

3.9 构建弹性网络连接

问题

该应用程序与不稳定的后端服务通信,您希望防止其失败。

解决方案

此方法会抛出异常:

static async Task GetOrdersAsync()
{
    throw await Task.FromResult(
        new HttpRequestException(
            "Timeout", null, HttpStatusCode.RequestTimeout));
}

这是一种处理网络错误的技术:

public static async Task Main()
{
    const int DelayMilliseconds = 500;
    const int RetryCount = 3;

    bool success = false;
    int tryCount = 0;

    try
    {
        do
        {
            try
            {
                Console.WriteLine("Getting Orders");
                await GetOrdersAsync();

                success = true;
            }
            catch (HttpRequestException hre)
                when (hre.StatusCode == HttpStatusCode.RequestTimeout)
            {
                tryCount++;

                int millisecondsToDelay = DelayMilliseconds * tryCount;
                Console.WriteLine(
                    $"Exception during processing—" +
                    $"delaying for {millisecondsToDelay} milliseconds");

                await Task.Delay(millisecondsToDelay);
            }

        } while (tryCount < RetryCount);
    }
    finally
    {
        if (success)
            Console.WriteLine("Operation Succeeded");
        else
            Console.WriteLine("Operation Failed");
    }
}

这是输出的内容:

    Getting Orders
    Exception during processing - delaying for 500 milliseconds
    Getting Orders
    Exception during processing - delaying for 1000 milliseconds
    Getting Orders
    Exception during processing - delaying for 1500 milliseconds
    Operation Failed

讨论

每当您进行进程外工作时,都有可能出现错误或超时。通常您无法控制您正在交互的应用程序,编写防御性代码非常重要。特别是进行网络操作的代码由于延迟、超时或硬件问题而容易出现与连接的两端代码质量无关的错误。

此解决方案通过 GetOrdersAsync 模拟了网络连接问题。它抛出一个带有 RequestTimeout 状态的 HttpRequestExceptionMain 方法展示了如何减轻这些问题的方法。目标是在尝试之间以一定的延迟重试连接。

首先,请注意 success 初始化为 falsetry/finallyfinally 块让用户根据 success 的结果了解操作的结果。在 try/do/try 的嵌套中,try 块的最后一行将 success 设置为 true,因为所有逻辑都完成了——如果之前发生了异常,程序将无法达到那一行。

do/while 循环重试 RetryCount 次。我们将 tryCount 初始化为 0,并在 catch 块中递增它。因为如果发生错误,我们知道我们将重试,并且希望确保不超过指定的重试次数。RetryCount 是一个 const,初始化为 3。您可以根据需要调整 RetryCount 的次数。如果操作时间敏感,您可能希望限制重试并发送关键错误的通知。另一个场景可能是,您知道连接的另一端最终会恢复在线,并可以将 RetryCount 设置为非常高的数字。

每当出现异常时,通常不希望立即重新发起请求。一个超时的原因可能是另一端的扩展能力不强,过多的请求可能会使服务器不堪重负。此外,一些第三方 API 会对客户端进行速率限制,连续的请求会消耗速率限制计数。一些 API 提供商甚至可能因为过多的连接请求而阻止您的应用程序。

DelayMilliseconds有助于您的重试策略,初始化为500毫秒。如果发现重试仍然太快,您可以调整这个值。如果单个延迟时间有效,那么您可以使用它。然而,许多情况需要线性或指数回退策略。您可以看到,解决方案使用了线性回退,将DelayMilliseconds乘以tryCount。由于tryCount初始化为0,我们首先递增它。

提示

您可能希望将重试记录为警告,而不是错误。管理员、质量保证或任何查看日志(或报告)的人可能会感到不必要地惊慌。他们看到看起来像错误的东西,而您的应用程序正在对典型的网络行为做出适当的反应和修复。

或者,您可能需要使用指数退避策略,例如将DelayMilliseconds提高到tryCount的幂次方——Math.Pow(DelayMilliseconds, tryCount)。您可以进行实验,例如记录错误并定期审查,以查看对您的情况最有效的方法。

3.10 测量性能

问题

您知道几种编写算法的方式,并需要测试哪种算法性能最佳。

解决方案

这是我们将操作的对象类型:

public class OrderItem
{
    public decimal Cost { get; set; }
    public string Description { get; set; }
}

这是创建OrderItem列表的代码:

static List<OrderItem> GetOrderItems()
{
    const int ItemCount = 10000;

    var items = new List<OrderItem>();
    var rand = new Random();

    for (int i = 0; i < ItemCount; i++)
        items.Add(
            new OrderItem
            {
                Cost = rand.Next(i),
                Description = "Order Item #" + (i + 1)
            });

    return items;
}

这是一个效率低下的字符串连接方法:

static string DoStringConcatenation(List<OrderItem> lineItems)
{
    var stopwatch = new Stopwatch();

    try
    {
        stopwatch.Start();

        string report = "";

        foreach (var item in lineItems)
            report += $"{item.Cost:C} - {item.Description}\n";

        Console.WriteLine(
            $"Time for String Concatenation: " +
            $"{stopwatch.ElapsedMilliseconds}");

        return report;
    }
    finally
    {
        stopwatch.Stop();
    }
}

这是更快的StringBuilder方法:

static string DoStringBuilderConcatenation(List<OrderItem> lineItems)
{
    var stopwatch = new Stopwatch();
    try
    {
        stopwatch.Start();

        var reportBuilder = new StringBuilder();

        foreach (var item in lineItems)
            reportBuilder.Append($"{item.Cost:C} - {item.Description}\n");

        Console.WriteLine(
            $"Time for String Builder Concatenation: " +
            $"{stopwatch.ElapsedMilliseconds}");

        return reportBuilder.ToString();
    }
    finally
    {
        stopwatch.Stop();
    }
}

此代码驱动演示:

static void Main()
{
    List<OrderItem> lineItems = GetOrderItems();

    DoStringConcatenation(lineItems);

    DoStringBuilderConcatenation(lineItems);
}

这是输出:

    Time for String Concatenation: 1137
    Time for String Builder Concatenation: 2

讨论

第 2.1 节讨论了StringBuilder相对于字符串连接的优势,强调性能是主要驱动因素。然而,它并未解释如何通过代码测量性能。本节建立在此基础上,展示了如何通过代码测量算法性能。

提示

随着我们的计算机每年(或更少)变得越来越快,StringBuilder方法的结果将接近0。要体验两种方法之间时间差异的真实大小,可以在GetOrderItemsItemCount中再加一个0

StringConcatenationStringBuilderConcatenation方法中,您会发现StopWatch的实例,它位于System.Diagnostics命名空间中。

调用Start启动计时器,Stop停止计时器。注意,算法使用try/finally,如第 3.8 节所述,以确保计时器停止。

Console.WriteLine在每个算法末尾使用stopwatch.ElapsedMilliseconds显示算法使用的时间。

如输出所示,StringBuilder和字符串连接之间的运行时间差异是显著的。

参见

第 2.1 节,“高效处理字符串”

第 3.8 节,“管理进程状态”

第四章:使用 LINQ 进行查询

LINQ 自 C# 3 开始就已经存在。它为开发人员提供了一种查询数据源的方式,使用带有 SQL 风格的语法。因为 LINQ 是语言的一部分,您可以在 IDE 中体验到语法高亮和智能感知等功能。

LINQ 通常被认为是一个用于查询数据库的工具,其目标是减少所谓的 阻抗不匹配,即数据库数据表示与 C# 对象之间的差异。事实上,我们可以为任何数据技术构建 LINQ 提供程序。事实上,作者为 Twitter API 编写了一个开源提供程序,名为 LINQ to Twitter

本章的示例采用了一种不同的方法。它们不使用外部数据源,而是使用专门针对内存数据源的提供程序,称为 LINQ to Objects。尽管可以使用 C# 循环和命令式逻辑执行任何内存数据操作,但通常使用 LINQ 可以简化代码,因为它具有声明性的特性——指定要做什么而不是如何做。每个部分都有一个或多个实体(要查询的对象)的独特表示,以及设置了用于查询的 InMemoryContext 的内存数据。

本章中有几个简单的示例,如转换对象形状和简化查询。然而,也有一些重要的观点可以澄清和简化您的代码。

从不同数据源中汇集代码可能导致混乱的代码。关于连接、左连接和分组的部分描述了如何简化这些场景。还有一个相关的部分用于处理集合操作。

开发人员在构建带有连接字符串的查询时,会出现搜索表单和查询的严重安全问题。虽然这听起来可能是一个快速简单的解决方案,但通常代价过高。本章包含几节,展示了 LINQ 延迟执行如何让您动态构建查询。另一节解释了一种重要的搜索查询技术,以及它如何让您能够使用表达树生成动态子句。

4.1 转换对象形状

问题

您希望数据呈现自定义形状,与原始数据源不同。

解决方案

这里是需要重塑的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

这段代码执行了重新塑形数据的投影:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesPersonLookup =
            (from person in context.SalesPeople
             select (person.ID, person.Name))
            .ToList();

        Console.WriteLine("Sales People\n");

        salesPersonLookup.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

在 LINQ 中,将对象形状转换称为 投影。您可能希望这样做的几个常见原因包括创建查找列表、创建视图或视图模型对象,或将数据传输对象(DTO)转换为您的应用更好处理的格式。

当使用 LINQ to Entities 进行数据库查询(一种不同于数据库的提供程序)或使用 DTOs 消费数据时,数据通常以表示原始数据源的格式到达。然而,如果你想要处理领域数据或绑定到 UI,纯数据表示形式可能不具有正确的形状。此外,数据表示通常具有对象关系模型(ORM)或数据访问库的属性和语义。一些开发人员试图将这些数据对象绑定到他们的 UI,因为他们不想创建新的对象类型。尽管这可以理解,因为没有人愿意比必要工作更多,但问题是 UI 代码通常需要不同形状的数据,并且需要自己的验证和属性。因此,问题在于你为两种不同目的使用一个对象。理想情况下,一个对象应该具有单一职责,而这样混合使用通常会导致代码混乱,难以维护。

解决方案展示的另一种场景是仅需查找列表的情况,带有 ID 和可显示值。当填充 UI 元素如复选框列表、单选按钮组、组合框或下拉框时,这非常有用。如果需要的只是 ID 和一些显示给用户的内容,查询整个实体将会很浪费且慢(尤其是在跨进程或跨网络的数据库连接中)。

解决方案的 Main 方法展示了这一点。它查询了 InMemoryContextSalesPeople 属性,这是一个 SalesPerson 列表,而 select 子句重新将结果重新塑形为 IDName 的元组。

注意

解决方案中的 select 子句使用了一个元组。然而,你也可以将请求的字段投影(仅投影请求的字段)到一个匿名类型、一个 SalesPerson 类型或一个新的自定义类型中。

虽然这是一个内存操作,但这种技术的好处在于使用 LINQ to Entities 这样的库查询数据库时体现出来。在这种情况下,LINQ to Entities 将 LINQ 查询转换为仅请求 select 子句中指定的字段的数据库查询。

4.2 数据连接

问题

你需要从不同的源中提取数据到一条记录中。

解决方案

下面是要连接的实体:

public class Product
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Type { get; set; }

    public decimal Price { get; set; }

    public string Region { get; set; }
}

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    List<Product> products =
        new List<Product>
        {
            new Product
            {
                ID = 1,
                Name = "Product 1",
                Price = 123.45m,
                Type = "Type 2",
                Region = "Region #1",
            },
            new Product
            {
                ID = 2,
                Name = "Product 2",
                Price = 456.78m,
                Type = "Type 2",
                Region = "Region #2",
            },
            new Product
            {
                ID = 3,
                Name = "Product 3",
                Price = 789.10m,
                Type = "Type 3",
                Region = "Region #1",
            },
            new Product
            {
                ID = 4,
                Name = "Product 4",
                Price = 234.56m,
                Type = "Type 2",
                Region = "Region #1",
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;

    public List<Product> Products => products;
}

下面是连接实体的代码:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesProducts =
            (from person in context.SalesPeople
             join product in context.Products on
             (person.Region, person.ProductType)
             equals
             (product.Region, product.Type)
             select new
             {
                Person = person.Name,
                Product = product.Name,
                product.Region,
                product.Type
             })
            .ToList();

        Console.WriteLine("Sales People\n");

        salesProducts.ForEach(salesProd =>
            Console.WriteLine(
                $"Person: {salesProd.Person}\n" +
                $"Product: {salesProd.Product}\n" +
                $"Region: {salesProd.Region}\n" +
                $"Type: {salesProd.Type}\n"));
    }
}

讨论

当数据来自多个源时,LINQ 连接非常有用。一个公司可能已经合并,你需要从每个数据库中提取数据,你可能正在使用微服务架构,数据来自不同的服务,或者一些数据是在内存中创建的,你需要将其与数据库记录关联起来。

通常情况下,无法使用 ID,因为如果数据来自不同的源,它们永远不会匹配。你唯一能期望的是一些字段能够对应上。话虽如此,如果有单个字段匹配,那就太好了。解决方案的 Main 方法使用了 RegionProductType 的组合键,并依赖于元组中的值相等性。

注意

select子句使用匿名类型进行自定义投影。关于形状化对象数据的另一个示例在 Recipe 4.1 中讨论。

即使此示例使用元组作为复合键,您也可以使用匿名类型获得相同的结果。元组使用稍少的语法。

参见

Recipe 4.1,“形状化对象形状”

4.3 执行左连接

问题

您需要在两个数据源上进行连接,但其中一个数据源没有匹配记录。

解决方案

这里是要执行左连接的实体:

public class Product
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Type { get; set; }

    public decimal Price { get; set; }

    public string Region { get; set; }
}

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    List<Product> products =
        new List<Product>
        {
            new Product
            {
                ID = 1,
                Name = "Product 1",
                Price = 123.45m,
                Type = "Type 2",
                Region = "Region #1",
            },
            new Product
            {
                ID = 2,
                Name = "Product 2",
                Price = 456.78m,
                Type = "Type 2",
                Region = "Region #2",
            },
            new Product
            {
                ID = 3,
                Name = "Product 3",
                Price = 789.10m,
                Type = "Type 3",
                Region = "Region #1",
            },
            new Product
            {
                ID = 4,
                Name = "Product 4",
                Price = 234.56m,
                Type = "Type 2",
                Region = "Region #1",
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;

    public List<Product> Products => products;
}

以下代码执行左连接操作:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesProducts =
            (from product in context.Products
             join person in context.SalesPeople on
             (product.Region, product.Type)
             equals
             (person.Region, person.ProductType)
             into prodPersonTemp
             from prodPerson in prodPersonTemp.DefaultIfEmpty()
             select new
             {
                Person = prodPerson?.Name ?? "(none)",
                Product = product.Name,
                product.Region,
                product.Type
             })
            .ToList();

        Console.WriteLine("Sales People\n");

        salesProducts.ForEach(salesProd =>
            Console.WriteLine(
                $"Person: {salesProd.Person}\n" +
                $"Product: {salesProd.Product}\n" +
                $"Region: {salesProd.Region}\n" +
                $"Type: {salesProd.Type}\n"));
    }
}

这是输出:

Sales People

Person: First Person
Product: Product 1
Region: Region #1
Type: Type 2

Person: Fourth Person
Product: Product 1
Region: Region #1
Type: Type 2

Person: (none)
Product: Product 2
Region: Region #2
Type: Type 2

Person: (none)
Product: Product 3
Region: Region #1
Type: Type 3

Person: First Person
Product: Product 4
Region: Region #1
Type: Type 2

Person: Fourth Person
Product: Product 4
Region: Region #1
Type: Type 2

讨论

此解决方案类似于在 Recipe 4.3 中讨论的join,不同之处在于Main方法的 LINQ 查询。注意into prodPersonTemp子句。这是联接数据的临时持有者。第二个from子句(into下方)查询prodPersonTemp.DefaultIfEmpty()

DefaultIfEmpty()导致左连接,其中prodPerson范围变量接收所有产品对象和仅匹配的人员对象。

第一个from子句指定查询的左侧,Productsjoin子句指定查询的右侧,SalesPeople,这些可能没有匹配值。

注意select子句如何检查prodPerson?.Name是否为null,并将其替换为(none)。这样确保输出指示没有匹配项,而不是依赖后续代码来检查 null。

展示左连接结果在解决方案输出中。注意,产品 1 和产品 4 的输出有一个人员条目。然而,产品 2 和产品 3 没有匹配的人员,显示为(none)

4.4 数据分组

问题

您需要将数据聚合到自定义组中。

解决方案

这里是要分组的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Second City",
                Name = "Fourth Person",
                PostalCode = "56788",
                Region = "Region #2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

以下代码对数据进行分组:

class Program
{
    static void Main()
    {
        var context = new InMemoryContext();

        var salesPeopleByRegion =
            (from person in context.SalesPeople
             group person by person.Region
             into personGroup
             select personGroup)
            .ToList();

        Console.WriteLine("Sales People by Region");

        foreach (var region in salesPeopleByRegion)
        {
            Console.WriteLine($"\nRegion: {region.Key}");

            foreach (var person in region)
                Console.WriteLine($"  {person.Name}");
        }
    }
}

讨论

分组在需要数据层次结构时很有用。它在数据的父/子关系中创建一个父类别和子对象(表示该类别中的数据记录)之间的关系。

在解决方案中,每个SalesPerson都有一个Region属性,其值在InMemoryContext数据源中重复。这有助于显示如何将多个SalesPerson实体分组到单个区域中。

Main方法查询中,有一个group by子句,指定范围变量person进行分组,以及键Region进行分组。personGroup保存结果。在这个例子中,select子句使用整个personGroup,而不是进行自定义投影。

salesPeopleByRegion中是一组顶级对象,代表每个组。每个组都有属于该组的对象集合,如下所示:

Key (Region):
    Items (IEnumerable<SalesPerson>)
注意

针对数据库的 LINQ 提供程序,如针对 SQL Server 的 LINQ to Entities,返回非物化查询的 IQueryable<T>。物化发生在您使用 Count()ToList() 等运算符时,实际执行查询并返回 intList<T>。相比之下,LINQ to Objects 返回的非物化类型是 IEnumerable<T>

foreach 循环演示了此组结构及其如何使用。在顶层,每个对象都有一个 Key 属性。因为原始查询是按 Region 进行的,所以该键将具有 Region 的名称。

嵌套的 foreach 循环在组上迭代,读取该组中的每个 SalesPerson 实例。您可以看到它打印出该组中每个 SalesPerson 实例的 Name

4.5 构建增量查询

问题

您需要根据用户的搜索条件定制查询,但不希望串联字符串。

解决方案

这是要查询的类型:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

此代码构建动态查询:

class Program
{
    static void Main()
    {
        SalesPerson searchCriteria = GetCriteriaFromUser();

        List<SalesPerson> salesPeople = QuerySalesPeople(searchCriteria);

        PrintResults(salesPeople);
    }

    static SalesPerson GetCriteriaFromUser()
    {
        var person = new SalesPerson();

        Console.WriteLine("Sales Person Search");
        Console.WriteLine("(press Enter to skip an entry)\n");

        Console.Write($"{nameof(SalesPerson.Address)}: ");
        person.Address = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.City)}: ");
        person.City = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Name)}: ");
        person.Name = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.PostalCode)}: ");
        person.PostalCode = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.ProductType)}: ");
        person.ProductType = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Region)}: ");
        person.Region = Console.ReadLine();

        return person;
    }

    static List<SalesPerson> QuerySalesPeople(SalesPerson criteria)
    {
        var ctx = new InMemoryContext();

        IEnumerable<SalesPerson> salesPeopleQuery =
            from people in ctx.SalesPeople
            select people;

        if (!string.IsNullOrWhiteSpace(criteria.Address))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.Address == criteria.Address);

        if (!string.IsNullOrWhiteSpace(criteria.City))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.City == criteria.City);

        if (!string.IsNullOrWhiteSpace(criteria.Name))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.Name == criteria.Name);

        if (!string.IsNullOrWhiteSpace(criteria.PostalCode))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.PostalCode == criteria.PostalCode);

        if (!string.IsNullOrWhiteSpace(criteria.ProductType))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.ProductType == criteria.ProductType);

        if (!string.IsNullOrWhiteSpace(criteria.Region))
            salesPeopleQuery = salesPeopleQuery.Where(
                person => person.Region == criteria.Region);

        List<SalesPerson> salesPeople = salesPeopleQuery.ToList();

        return salesPeople;
    }

    static void PrintResults(List<SalesPerson> salesPeople)
    {
        Console.WriteLine("\nSales People\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

从安全的角度来看,开发人员可以做的最糟糕的事情之一就是从用户输入构建一个串联的字符串,以此作为 SQL 语句发送到数据库。问题在于字符串串联允许用户的输入被解释为查询的一部分。在大多数情况下,人们只想执行搜索。然而,有恶意用户有意地探测系统的这种漏洞。他们不必是专业黑客,因为有很多初学者(通常被称为脚本小子)想要练习并玩得开心。在最糟糕的情况下,黑客可以访问私人或专有信息,甚至接管一台机器。一旦进入网络中的一台机器,黑客就在内部,并且可以攀爬到其他计算机并接管您的网络。这个特定问题被称为SQL 注入攻击,本节解释了如何避免这种情况。

注意

从安全角度来看,理论上没有一台计算机可以百分之百安全,因为总有一定程度的努力,无论是物理的还是虚拟的,都可以破解计算机。实际上,安全措施可能会增长到一个成本高得无法承受的程度,包括建设、购买和维护。你的目标是对系统进行威胁评估(超出本书范围),足以阻止潜在的黑客。在大多数情况下,如果未能执行典型的攻击,如 SQL 注入,黑客将评估攻击你的系统的成本,然后转向耗时或成本更低的其他系统。本节提供了解决高成本安全灾难的低成本选项。

本节的场景设想了一个用户可以执行搜索的情况。他们填写数据,应用程序根据用户输入的条件动态构建查询。

在解决方案中,Program 类有一个名为 GetCriteriaFromUser 的方法。此方法的目的是为 SalesPerson 内的每个字段询问匹配值。这些值成为构建动态查询的标准。如果任何字段为空,则不会包含在最终查询中。

QuerySalesPeople 方法从 ctx.SalesPeople 开始一个 LINQ 查询。然而,请注意,这不像前几节那样放在括号中或调用 ToList 操作符。调用 ToList 会实现查询,导致其执行。但是,在这里我们没有这样做 - 代码只是在构建查询。这就是为什么 salesPersonQuery 具有 IEnumerable<SalesPerson> 类型,表示它是 LINQ 到对象的结果,而不是通过调用 ToList 获得的 List<SalesPerson>

注意

此配方利用了 LINQ 的一项功能,称为 延迟查询执行,它允许您构建查询,直到您告诉它执行。除了促进动态查询构建外,延迟执行还非常高效,因为仅发送一个查询到数据库,而不是每次算法调用特定的 LINQ 操作符时都发送查询。

使用 salesPersonQuery 引用,代码检查每个 SalesPerson 字段是否有值。如果用户为该字段输入了值,则代码使用 Where 操作符检查与用户输入的值是否相等。

注意

在前几节中,您已经看到了使用语言语法的 LINQ 查询。但是,本节利用了另一种使用 LINQ 的方式,即通过流畅接口称为 方法语法。这与您在 Recipe 1.10, “Constructing Objects with Complex Configuration” 中了解到的建造者模式非常相似。

到目前为止,唯一发生的事情是我们动态构建了一个 LINQ 查询,并且由于延迟执行的原因,该查询尚未运行。最后,代码在 salesPersonQuery 上调用 ToList,实现了查询。由于此方法的返回类型,这将返回一个 List<SalesPerson>

现在,算法已经构建并执行了一个动态查询,从 SQL 注入攻击中受到保护。这种保护来自于 LINQ 提供程序始终将用户输入参数化,因此它将被视为参数数据,而不是查询的一部分。作为一个副作用,您还拥有一个强类型代码的方法,不必担心意外和难以找到的拼写错误。

参见

Recipe 1.10, “构建具有复杂配置的对象”

4.6 查询不同对象

问题

您有一个对象列表,其中包含重复项,并且需要将其转换为唯一对象的不同列表。

解决方案

这是一个不支持不同查询的对象:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

下面是如何修复该对象以支持不同查询的方法:

public class SalesPersonComparer : IEqualityComparer<SalesPerson>
{
    public bool Equals(SalesPerson x, SalesPerson y)
    {
        return x.ID == y.ID;
    }

    public int GetHashCode(SalesPerson obj)
    {
        return obj.GetHashCode();
    }
}

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

此代码按独特对象进行过滤:

class Program
{
    static void Main(string[] args)
    {
        var salesPeopleWithoutComparer =
            (from person in new InMemoryContext().SalesPeople
             select person)
            .Distinct()
            .ToList();

        PrintResults(salesPeopleWithoutComparer, "Without Comparer");

        var salesPeopleWithComparer =
            (from person in new InMemoryContext().SalesPeople
             select person)
            .Distinct(new SalesPersonComparer())
            .ToList();

        PrintResults(salesPeopleWithComparer, "With Comparer");
    }

    static void PrintResults(List<SalesPerson> salesPeople, string title)
    {
        Console.WriteLine($"\n{title}\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

有时你会有一个包含重复实体的列表,可能是因为某些应用程序处理或数据库查询类型导致的。通常,你需要一个唯一对象的列表。例如,你正在使用不允许重复的 Dictionary 集合进行实体化。

LINQ 的 Distinct 运算符帮助获取唯一对象的列表。乍一看,这很容易,就像 Main 方法的第一个查询所示,使用了 Distinct() 运算符。请注意,它没有参数。然而,检查结果会显示,数据中仍然存在与开始时相同的重复项。

问题及随后的解决方案可能不会立即显而易见,因为它依赖于结合了几个不同的 C# 概念。首先,考虑一下 Distinct 应该如何区分对象之间的差异——它必须执行比较。接下来,考虑到 SalesPerson 的类型是 class。这很重要,因为类是引用类型,具有引用相等性。当 Distinct 进行引用比较时,没有两个对象引用是相同的,因为每个对象都有一个唯一的引用。最后,你需要编写代码来比较 SalesPerson 实例,以确定它们是否相等,并告诉 Distinct 这段代码。

SalesPerson 类是一个基本类,具有属性,并且不包含任何指示如何执行相等性的语法。相反,SalesPersonComparer 实现了 IEqualityComparer<SalesPerson>SalesPerson 类不起作用,因为它具有引用相等性。然而,实现了 IEqualityComparer<SalesPerson>SalesPersonComparer 类能够正确比较,因为它具有一个 Equals 方法。在这种情况下,检查 ID 是否足以确定实例是否相等,假设每个实体来自具有唯一 ID 字段的同一数据源。

SalesPersonComparer 知道如何比较 SalesPerson 实例,但这并不是问题的终点,因为它与查询没有任何关联。如果你在 Main 中运行第一个没有参数的查询 Distinct(),结果仍然会有重复。问题在于 Distinct 不知道如何比较对象,因此默认使用实例类型 class,正如前面解释的那样,它是引用类型。

解决方案是在 Main 中使用第二个带有 Distinct(new SalesPersonComparer()) 调用的查询。这使用了带有 IEqualityComparer<T> 参数的 Distinct 运算符的重载。由于 SalesPersonComparer 实现了 IEqualityComparer<SalesPerson>,这个方法可以实现。

参见

第 2.5 节,“检查类型相等性”

4.7 简化查询

问题

查询变得过于复杂,需要使其更易读。

解决方案

这是要查询的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }

    public string TotalSales { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2",
                TotalSales = "654.32"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3",
                TotalSales = "765.43"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1",
                TotalSales = "876.54"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2",
                TotalSales = "987.65"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2",
                TotalSales = "109.87"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

以下显示了如何简化查询投影:

class Program
{
    static void Main(string[] args)
    {
        decimal TotalSales = 0;

        var salesPeopleWithAddresses =
            (from person in new InMemoryContext().SalesPeople
             let FullAddress =
             $"{person.Address}\n" +
             $"{person.City}, {person.PostalCode}"
             let salesOkay =
                 decimal.TryParse(person.TotalSales, out TotalSales)
             select new
             {
                person.ID,
                person.Name,
                FullAddress,
                TotalSales
             })
            .ToList();

        Console.WriteLine($"\nSales People and Addresses\n");

        salesPeopleWithAddresses.ForEach(person =>
            Console.WriteLine(
                $"{person.ID}. {person.Name}: {person.TotalSales:C}\n" +
                $"{person.FullAddress}\n"));
    }
}

讨论

有时 LINQ 查询会变得复杂。如果代码仍然难以阅读,那么维护也很困难。一种选择是转为命令式语言并将查询重写为循环。另一种选择是使用 let 子句进行简化。

在解决方案中,Main 方法具有一个查询,该查询将投影到匿名类型中。有时查询会因为在投影中有子查询或其他逻辑,例如在 let 子句中构建的 FullAddress,而变得复杂。如果没有这种简化,代码可能会完全进入投影中。

另一个可能遇到的情景是解析来自字符串的对象输入。示例中使用了 TryParselet 子句中,这在投影中是不可能的。这有点棘手,因为 out 参数 TotalSales 是在查询之外的。我们忽略 TryParse 的结果,但现在可以在投影中分配 TotalSales

4.8 操作集合

问题

您想要将两组对象组合在一起,避免重复。

解决方案

这是要查询的实体:

public class SalesPerson : IEqualityComparer<SalesPerson>
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }

    public bool Equals(SalesPerson x, SalesPerson y)
    {
        return x.ID == y.ID;
    }

    public int GetHashCode(SalesPerson obj)
    {
        return ID.GetHashCode();
    }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

这段代码展示了如何执行集合操作:

class Program
{
    static InMemoryContext ctx = new InMemoryContext();

    static void Main()
    {
        System.Console.WriteLine("\nLINQ Set Operations");

        DoUnion();
        DoExcept();
        DoIntersection();

        System.Console.WriteLine("\nComplete.\n");
    }

    static void DoUnion()
    {
        var dataSource1 =
            (from person in ctx.SalesPeople
             where person.ID < 3
             select person)
            .ToList();

        var dataSource2 =
            (from person in ctx.SalesPeople
             where person.ID > 2
             select person)
            .ToList();

        List<SalesPerson> union =
            dataSource1
                .Union(dataSource2, new SalesPerson())
                .ToList();

        PrintResults(union, "Union Results");
    }

    static void DoExcept()
    {
        var dataSource1 =
            (from person in ctx.SalesPeople
             select person)
            .ToList();

        var dataSource2 =
            (from person in ctx.SalesPeople
             where person.ID == 4
             select person)
            .ToList();

        List<SalesPerson> union =
            dataSource1
                .Except(dataSource2, new SalesPerson())
                .ToList();

        PrintResults(union, "Except Results");
    }

    static void DoIntersection()
    {
        var dataSource1 =
            (from person in ctx.SalesPeople
             where person.ID < 4
             select person)
            .ToList();

        var dataSource2 =
            (from person in ctx.SalesPeople
             where person.ID > 2
             select person)
            .ToList();

        List<SalesPerson> union =
            dataSource1
                .Intersect(dataSource2, new SalesPerson())
                .ToList();

        PrintResults(union, "Intersect Results");
    }

    static void PrintResults(List<SalesPerson> salesPeople, string title)
    {
        Console.WriteLine($"\n{title}\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

在配方 4.2 中,我们讨论了从两个不同数据源中连接数据的概念。示例在相同的精神中操作,并展示了基于集合的不同操作。

第一个方法 DoUnion 获取两组数据,并通过 ID 进行有意义的过滤以确保重叠。从第一个数据源的引用中,代码调用 Union 运算符并以第二个数据源作为参数。这将导致从两个数据源获取的数据集,包括重复数据。

DoExcept 方法类似于 DoUnion,但使用 Except 运算符。这将导致第一个数据源中所有对象的集合。然而,任何在第二个数据源中的对象,即使它们曾经在第一个数据源中,也不会出现在结果中。

最后,DoIntersect 在结构上类似于 DoUnionDoExcept。然而,它查询的对象只存在于两个数据源中。如果某个对象只存在于一个数据源中而不在另一个数据源中,则不会出现在结果中。这种操作称为集合理论中的差异

LINQ 具有许多标准运算符,就像集合运算符一样,非常强大。在执行 LINQ 查询中的任何复杂操作之前,最好查看标准运算符,看看是否存在可以简化任务的内容。

参见

配方 4.2,“连接数据”

配方 4.3,“执行左连接”

4.9 用表达式树构建查询过滤器

问题

LINQ 的 where 子句通过 AND 条件组合,但您需要一个动态的 where,它作为 OR 条件工作。

解决方案

这是要查询的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

这里有一个过滤的 OR 操作的扩展方法:

public static class CookbookExtensions
{
    public static IEnumerable<TParameter> WhereOr<TParameter>(
        this IEnumerable<TParameter> query,
        Dictionary<string, string> criteria)
    {
        const string ParamName = "person";

        ParameterExpression paramExpr =
            Expression.Parameter(typeof(TParameter), ParamName);

        Expression accumulatorExpr = null;

        foreach (var criterion in criteria)
        {
            MemberExpression paramMbr =
                LambdaExpression.PropertyOrField(
                    paramExpr, criterion.Key);

            MemberExpression leftExpr =
                Expression.Property(
                    paramExpr,
                    typeof(TParameter).GetProperty(criterion.Key));
            Expression rightExpr =
                Expression.Constant(criterion.Value, typeof(string));
            Expression equalExpr =
                Expression.Equal(leftExpr, rightExpr);

            accumulatorExpr = accumulatorExpr == null
                ? equalExpr
                : Expression.Or(accumulatorExpr, equalExpr);
        }

        Expression<Func<TParameter, bool>> allClauses =
            Expression.Lambda<Func<TParameter, bool>>(
                accumulatorExpr, paramExpr);

        Func<TParameter, bool> compiledClause = allClauses.Compile();

        return query.Where(compiledClause);
    }
}

这是消耗新扩展方法的代码:

class Program
{
    static void Main()
    {
        SalesPerson searchCriteria = GetCriteriaFromUser();

        List<SalesPerson> salesPeople = QuerySalesPeople(searchCriteria);

        PrintResults(salesPeople);
    }

    static SalesPerson GetCriteriaFromUser()
    {
        var person = new SalesPerson();

        Console.WriteLine("Sales Person Search");
        Console.WriteLine("(press Enter to skip an entry)\n");

        Console.Write($"{nameof(SalesPerson.Address)}: ");
        person.Address = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.City)}: ");
        person.City = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Name)}: ");
        person.Name = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.PostalCode)}: ");
        person.PostalCode = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.ProductType)}: ");
        person.ProductType = Console.ReadLine();

        Console.Write($"{nameof(SalesPerson.Region)}: ");
        person.Region = Console.ReadLine();

        return person;
    }

    static List<SalesPerson> QuerySalesPeople(SalesPerson criteria)
    {
        var ctx = new InMemoryContext();

        var filters = new Dictionary<string, string>();

        IEnumerable<SalesPerson> salesPeopleQuery =
            from people in ctx.SalesPeople
            select people;

        if (!string.IsNullOrWhiteSpace(criteria.Address))
            filters[nameof(criteria.Address)] = criteria.Address;

        if (!string.IsNullOrWhiteSpace(criteria.City))
            filters[nameof(criteria.City)] = criteria.City;

        if (!string.IsNullOrWhiteSpace(criteria.Name))
            filters[nameof(criteria.Name)] = criteria.Name;

        if (!string.IsNullOrWhiteSpace(criteria.PostalCode))
            filters[nameof(criteria.PostalCode)] = criteria.PostalCode;

        if (!string.IsNullOrWhiteSpace(criteria.ProductType))
            filters[nameof(criteria.ProductType)] = criteria.ProductType;

        if (!string.IsNullOrWhiteSpace(criteria.Region))
            filters[nameof(criteria.Region)] = criteria.Region;

        salesPeopleQuery =
            salesPeopleQuery.WhereOr<SalesPerson>(filters);

        List<SalesPerson> salesPeople = salesPeopleQuery.ToList();

        return salesPeople;
    }

    static void PrintResults(List<SalesPerson> salesPeople)
    {
        Console.WriteLine("\nSales People\n");

        salesPeople.ForEach(person =>
            Console.WriteLine($"{person.ID}. {person.Name}"));
    }
}

讨论

Recipe 4.5 展示了在 LINQ 中动态查询的强大功能。然而,这并不是你可以做的全部。使用表达式树,您可以利用 LINQ 进行任何类型的查询。如果标准运算符不能提供您需要的内容,您可以使用表达式树。本节正是如此,展示了如何使用表达式树来运行动态的 WhereOr 操作。

WhereOr 的动机源于标准的 Where 运算符组合为 AND 比较的事实。在 Recipe 4.5 中,所有这些 Where 运算符都具有隐式的 AND 关系。这意味着给定实体必须具有与用户在标准中指定的每个字段相等的值才能匹配。在本节中,通过 WhereOr,所有字段都具有 OR 关系,仅需要匹配一个字段即可包含在结果中。

在解决方案中,GetCriteriaFromUser 方法获取每个 SalesPerson 属性的值。QuerySalesPeople 启动了一个延迟执行的查询,正如 Recipe 4.5 中所解释的那样,并构建了一个 Dictionary<string, string> 类型的过滤器。

CookbookExtensions 类有一个接受过滤器的 WhereOr 扩展方法。WhereOr 要完成的高级描述来自于它需要为调用者返回一个 IEnumerable<SalesPerson>,以完成 LINQ 查询。

首先,转到 WhereOr 的底部,注意它返回带有 Where 运算符的查询,并且有一个名为 compiledQuery 的参数。请记住,LINQ 的 Where 运算符接受一个带有参数和谓词的 C# lambda 表达式。我们希望一个过滤器,如果对象的任何一个字段匹配基于输入条件,则 compiledQuery 必须评估为以下形式的 lambda:

person => person.Field1 == "val1" || ... || person.FieldN == "valN"

这是一个带有 OR 运算符的 lambda 表达式,它使用 Dictionary<string, string> criteria 参数的每个值。为了从算法的顶部到底部,我们需要构建一个表达式树,该树评估为此形式的 lambda。Figure 4-1 显示了此代码的作用。

使用 OR 运算符分隔子句构建 Where 表达式

图 4-1. 使用 OR 运算符分隔子句构建 Where 表达式

Figure 4-1 展示了解决方案创建的表达式树。在这里,我们假设用户想要查询四个值:CityNameProductTypeRegion。表达式树按深度优先、从左到右的方式读取,每个框表示一个节点。因此,LINQ 沿着左侧向下遍历树,直到找到叶子节点,即 City 表达式。然后它向上移动到找到 OR,再向右移动找到 Name 表达式,并构建 OR 表达式。到目前为止,LINQ 已构建了以下子句:

City == 'MyCity' || Name == 'Joe'

LINQ 继续读取表达式树,直到最终构建以下子句:

City == 'MyCity' || Name == 'Joe' || ProductType == 'Widgets' || Region == 'West'

回到解决方案代码,WhereOr 首先创建了一个 ParameterExpression。这是 lambda 中的 person 参数。它是每个比较表达式的参数,因为它表示 TParameter,在本例中是 SalesPerson 的实例。

注意

此示例称为 ParameterExpressionperson。但是,如果这是一个通用的可重用扩展方法,您可能会给它一个更一般的名称,例如 parameterTerm,因为 TParameter 可以是任何类型。在此示例中选择 person 是为了澄清 ParameterExpression 在本例中表示一个 SalesPerson 实例。

Expression accumulatorExpr,正如其名称所示,收集 lambda 主体的所有子句。

foreach 语句循环遍历 Dictionary 集合,返回 KeyValuePair 实例,这些实例具有 KeyValue 属性。如 QuerySalesPeople 方法所示,Key 属性是 SalesPerson 属性的名称,而 Value 属性是用户输入的内容。

对于 lambda 的每个子句,左侧是对 SalesPerson 实例上属性的引用(例如 person.Name)。为了创建这个,代码使用 paramExpr 实例化了 paramMbr(即 person)。这成为 leftExpr 的参数。rightExpr 表达式是一个常量,它保存了要比较的值及其类型。然后,我们需要使用左侧和右侧表达式(分别是 leftExprrightExpr)完成表达式与 Equals 表达式。

最后,我们需要将该表达式与其他表达式进行 OR 运算。在 foreach 循环的第一次迭代中,accumulatorExpr 将为 null,因此我们只分配第一个表达式。在后续表达式中,我们使用 OR 表达式将新的 Equals 表达式附加到 accumulatorExpr

遍历每个输入字段后,我们形成了最终的 LambdaExpression,它添加了在每个 Equals 表达式左侧使用的参数。请注意,结果是一个 Expression<Func<TParameter, bool>>,它的参数类型与原始查询的 lambda 委托类型匹配,即 Func<SalesPerson, bool>

现在我们有一个动态构建的表达式树,准备转换为可运行的代码,这是 Expression.Compile 方法的任务。这给了我们一个编译的 lambda,我们可以传递给 Where 子句。

调用代码从 WhereOr 方法接收 IEnumerable<SalesPerson>,并通过调用 ToList 材料化查询。这产生了一个 SalesPerson 对象的列表,这些对象至少匹配用户指定的一个条件。

参见

Recipe 4.5, “Building Incremental Queries”

4.10 并行查询

问题

您希望提高性能,您的查询可能会从多线程中获益。

解决方案

这是要查询的实体:

public class SalesPerson
{
    public int ID { get; set; }

    public string Name { get; set; }

    public string Address { get; set; }

    public string City { get; set; }

    public string PostalCode { get; set; }

    public string Region { get; set; }

    public string ProductType { get; set; }
}

这是数据源:

public class InMemoryContext
{
    List<SalesPerson> salesPeople =
        new List<SalesPerson>
        {
            new SalesPerson
            {
                ID = 1,
                Address = "123 1st Street",
                City = "First City",
                Name = "First Person",
                PostalCode = "45678",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 2,
                Address = "234 2nd Street",
                City = "Second City",
                Name = "Second Person",
                PostalCode = "56789",
                Region = "Region #2",
                ProductType = "Type 3"
            },
            new SalesPerson
            {
                ID = 3,
                Address = "345 3rd Street",
                City = "Third City",
                Name = "Third Person",
                PostalCode = "67890",
                Region = "Region #3",
                ProductType = "Type 1"
            },
            new SalesPerson
            {
                ID = 4,
                Address = "678 9th Street",
                City = "Fourth City",
                Name = "Fourth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
            new SalesPerson
            {
                ID = 5,
                Address = "678 9th Street",
                City = "Fifth City",
                Name = "Fifth Person",
                PostalCode = "90123",
                Region = "Region #1",
                ProductType = "Type 2"
            },
        };

    public List<SalesPerson> SalesPeople => salesPeople;
}

此代码显示如何执行并行查询:

class Program
{
    static void Main()
    {
        List<SalesPerson> salesPeople = new InMemoryContext().SalesPeople;
        var result =
            (from person in salesPeople.AsParallel()
             select ProcessPerson(person))
            .ToList();
    }

    static SalesPerson ProcessPerson(SalesPerson person)
    {
        Console.WriteLine(
            $"Starting sales person " +
            $"#{person.ID}. {person.Name}");

        // complex in-memory processing
        Thread.Sleep(500);

        Console.WriteLine(
            $"Completed sales person " +
            $"#{person.ID}. {person.Name}");

        return person;
    }
}

讨论

本节考虑可以从并发中受益的查询。想象一下,您有一个 LINQ 到对象的查询,其中数据存储在内存中。也许每个实例的工作需要密集处理,代码在多线程/多核 CPU 上运行,并且/或者需要花费相当大的时间。在并行中运行查询可能是一个选择。

Main 方法执行一个查询,与任何其他查询类似,除了在数据源上使用 AsParallel 操作符。这样做的效果是让 LINQ 确定如何分割工作并并行操作每个范围变量。图 4-2 显示了这个查询在做什么。

PLINQ 在集合成员中并行运行

图 4-2. PLINQ 在集合成员中并行运行

图 4-2 显示了左侧的 salesPeople 集合。当查询运行时,它会并行处理多个集合对象,这些对象由从 salesPeople 指向每个 SalesPerson 实例的分割箭头表示。处理完成后,查询将每个对象的处理响应组合成一个名为 result 的新集合。

注意

此示例使用一种名为并行 LINQ (PLINQ) 的 LINQ 技术。在幕后,PLINQ 对查询进行评估,进行各种运行时优化,如并行度。它甚至足够智能,可以判断在给定机器上启动新线程的开销是否比同步运行更快。

此示例还演示了另一种使用方法返回对象的投影类型。这里的假设是密集处理发生在 ProcessPerson 中,该方法使用 Thread.Sleep 模拟非平凡处理。

在实践中,您需要进行一些测试,以确定您是否真正从并行性中受益。配方 3.10 展示了如何使用 System.Diagnostics.StopWatch 类来测量性能。如果成功,这可能是提升应用程序性能的一种简单方式。

参见

配方 3.10,“性能测量”

第五章:实现动态和反射

反射允许代码查看类型的内部并检查其细节和成员。这对于希望给用户最大灵活性以提交对象以执行某些自动操作的库和工具非常有用。一个常见的进行反射的代码示例是单元测试框架。如 Recipe 3.1 所述,单元测试使用具有属性的类来指示哪些方法是测试方法。单元测试框架使用反射来查找测试类,定位测试方法并执行测试。

本章示例基于一个动态报表构建应用程序。它使用反射来读取类的属性,访问类型成员并执行方法。本章的前四节展示了如何做到这一点。

除了反射之外,另一种灵活处理代码的方式是 C# 中的一个特性,称为动态。在 C# 中,我们编写的大部分代码都是强类型的,这对于提高生产力和可维护性非常有益。尽管如此,C# 中有一个dynamic关键字,允许开发人员假设对象具有某种结构。这很像动态编程语言(如 JavaScript 和 Python),开发人员根据文档访问对象,该文档指定了对象具有哪些成员。因此,他们只需编写使用这些成员的代码。动态代码允许 C# 做到同样的事情。

在执行需要 COM 互操作的操作时,动态特别有用,有一个部分专门解释了其工作原理。您将看到动态如何能够显著减少和简化代码,相比反射的冗长和复杂性。还有一些类型允许我们构建本质上是动态的类型。此外,还有一个动态语言运行时(DLR),它允许在 C# 和动态语言(如 Python)之间进行互操作,您将看到两个关于 C# 和 Python 互操作的部分。

5.1 使用反射读取属性

问题

您希望您的库的消费者在传递对象时具有最大的灵活性,但他们仍然需要传达对象的重要细节。

解决方案

这是一个Attribute类,表示报表列元数据:

[AttributeUsage(
 AttributeTargets.Property | AttributeTargets.Method,
 AllowMultiple = false)]
public class ColumnAttribute : Attribute
{
    public ColumnAttribute(string name)
    {
        Name = name;
    }

    public string Name { get; set; }

    public string Format { get; set; }
}

这个类表示要显示的记录,并使用了以下属性:

public class InventoryItem
{
 [Column("Part #")]
    public string PartNumber { get; set; }

 [Column("Name")]
    public string Description { get; set; }

 [Column("Amount")]
    public int Count { get; set; }

 [Column("Price")]
    public decimal ItemPrice { get; set; }

 [Column("Total")]
    public decimal CalculateTotal()
    {
        return ItemPrice * Count;
    }
}

Main 方法展示了如何实例化和传递数据:

static void Main()
{
    var inventory = new List<object>
    {
        new InventoryItem
        {
            PartNumber = "1",
            Description = "Part #1",
            Count = 3,
            ItemPrice = 5.26m
        },
        new InventoryItem
        {
            PartNumber = "2",
            Description = "Part #2",
            Count = 1,
            ItemPrice = 7.95m
        },
        new InventoryItem
        {
            PartNumber = "3",
            Description = "Part #3",
            Count = 2,
            ItemPrice = 23.13m
        },
    };

    string report = new Report().Generate(inventory);

    Console.WriteLine(report);
}

这个Report类有用于构建报表头和生成报表的方法:

public class Report
{
    // contains Generate and GetHeaders methods
}

这个方法是Report类的一个成员,使用反射来找到所有类型成员:

public string Generate(List<object> items)
{
    _ = items ??
        throw new ArgumentNullException(
            $"{nameof(items)} is required");

    MemberInfo[] members =
        items.First().GetType().GetMembers();

    var report = new StringBuilder("# Report\n\n");

    report.Append(GetHeaders(members));

    return report.ToString();
}

这个方法是Report类的一个成员,使用反射来读取类型的属性:

const string ColumnSeparator = " | ";

StringBuilder GetHeaders(MemberInfo[] members)
{
    var columnNames = new List<string>();
    var underscores = new List<string>();

    foreach (var member in members)
    {
        var attribute =
            member.GetCustomAttribute<ColumnAttribute>();

        if (attribute != null)
        {
            string columnTitle = attribute.Name;
            string dashes = "".PadLeft(columnTitle.Length, '-');

            columnNames.Add(columnTitle);
            underscores.Add(dashes);
        }
    }

    var header = new StringBuilder();

    header.AppendJoin(ColumnSeparator, columnNames);
    header.Append("\n");

    header.AppendJoin(ColumnSeparator, underscores);
    header.Append("\n");

    return header;
}

下面是输出结果:

# Report

Total | Part # | Name | Amount | Price
----- | ------ | ---- | ------ | -----

讨论

属性,即元数据,通常存在于代码工具中。本节中的解决方案采用类似的方法,其中 ColumnAttribute 是报表中数据列的元数据。你可以看到 AttributeUsage 指定了可以将 ColumnAttribute 应用于属性或方法。考虑报表列可能支持的功能,这个属性归结为两个典型的特征:NameFormat。因为 C# 属性名称可能不代表列标题的文本,Name 允许你指定任何你想要的内容。此外,如果不指定字符串格式,DateTimedecimal 列将采用默认显示方式,通常这并非你所期望的。这本质上解决了报表库的使用者想要传递任何类型对象的问题,通过使用 ColumnAttribute 共享重要的细节。

InventoryItem 展示了 ColumnAttribute 的工作原理。注意位置属性 Name 如何与属性和方法的名称不同。Recipe 5.2 展示了 Format 属性如何工作的例子,而本节仅集中于如何提取和显示 Markdown 格式列的元数据。

注意

从架构上讲,你应该把这个项目看作两个独立的应用程序。有一个可重用的报表库,任何人都可以提交对象。报表库包括一个 Report 类和 ColumnAttribute 属性。然后有一个消费应用程序,即 Main 方法。为简单起见,本演示的源代码将所有代码放在同一个项目中,但在实践中,这些应该是分开的。

Main 方法实例化一个包含 InventoryItem 实例的 List<object>。这些数据通常来自数据库或其他数据源。它实例化 Report,传递数据,并打印结果。

Generate 方法属于 Report 类。注意它接受一个 List<object>,这就是为什么 Main 传递了一个 List<object>。实质上,Report 希望能够操作任何对象类型。

在验证输入项后,Generate 使用反射来发现传递对象中存在的成员。你看,我们再也无法知道因为对象不是强类型的,而且我们希望能够在可以传递任何类型时具有最大的灵活性。这是反射的一个好案例。话虽如此,我们不再保证所有项实例是相同类型,这必须是一个隐含的约定,而不是由代码强制执行的。Recipe 5.3 通过展示如何使用泛型来同时具有类型安全性和使用泛型的能力来解决这个问题,使用接口可能是另一种方法。

我们假设所有对象都相同,并且 Generateitems 上调用 First,因为所有对象在 items 中具有相同的属性。然后 Generate 在第一个项目上调用 GetTypeType 实例是执行反射的门户。

获得 Type 实例之后,您可以查询有关类型的任何信息,并使用特定实例。此示例调用 GetMembers 获取 MemberInfo[]MemberInfo 包含有关特定类型成员的所有信息,例如其名称和类型。在此示例中,MemberInfo[] 包含从 Main 传入的 InventoryItem 的属性和方法:PartNumberDescriptionCountItemPriceCalculateTotal

因为报告是 Markdown 文本的字符串,并且有很多连接操作,解决方案使用 StringBuilder。第 2.1 节 解释了为什么这是一个好方法。

因为我们关注属性,此解决方案仅打印报告头部,本章后面的部分将详细解释根据您的需求生成报告主体的多种不同方法。 GetHeader 方法接收 MemberInfo[] 并使用反射来确定这些标题应该是什么。

在 Markdown 中,我们用管道符号 | 分隔表头,并添加下划线,这就是为什么我们在 columnNamesunderscores 中有两个数组。 foreach 循环检查每个 MemberInfo,调用 GetCustomAttribute。注意 GetCustomAttribute 的类型参数是 ColumnAttribute —— 成员可能有多个属性,但我们只需要这一个。从 GetCustomAttribute 返回的实例是 ColumnAttribute,所以我们可以访问它的属性,比如 Name。代码使用 Name 填充 columnNames,并添加与 Name 长度相同的下划线。

最后,GetHeaders 使用管道符号 | 连接值并返回结果头部。通过调用链追溯回来,Generate 添加了 GetHeaders 的结果,而 Main 打印了标题,您可以在解决方案输出中看到。

参见

第 2.1 节,“高效处理字符串”

第 5.2 节,“使用反射访问类型成员”

5.2 使用反射访问类型成员

问题

您需要检查对象以查看可以读取哪些属性。

解决方案

这个类表示要显示的记录:

public class InventoryItem
{
 [Column("Part #")]
    public string PartNumber { get; set; }

 [Column("Name")]
    public string Description { get; set; }

 [Column("Amount")]
    public int Count { get; set; }

 [Column("Price", Format = "{0:c}")]
    public decimal ItemPrice { get; set; }
}

这是一个包含每个报告列元数据的类:

public class ColumnDetail
{
    public string Name { get; set; }

    public ColumnAttribute Attribute { get; set; }

    public PropertyInfo PropertyInfo { get; set; }
}

此方法收集数据以填充列元数据:

Dictionary<string, ColumnDetail> GetColumnDetails(
    List<object> items)
{
     object itemInstance = items.First();
     Type itemType = itemInstance.GetType();
     PropertyInfo[] itemProperties = itemType.GetProperties();

    return
        (from prop in itemProperties
         let attribute = prop.GetCustomAttribute<ColumnAttribute>()
         where attribute != null
         select new ColumnDetail
         {
             Name = prop.Name,
             Attribute = attribute,
             PropertyInfo = prop
         })
        .ToDictionary(
            key => key.Name,
            val => val);
}

这是使用 LINQ 更简洁获取头部数据的方式:

StringBuilder GetHeaders(
    Dictionary<string, ColumnDetail> details)
{
    var header = new StringBuilder();

    header.AppendJoin(
        ColumnSeparator,
        from detail in details.Values
        select detail.Attribute.Name);

    header.Append("\n");

    header.AppendJoin(
        ColumnSeparator,
        from detail in details.Values
        let length = detail.Attribute.Name.Length
        select "".PadLeft(length, '-'));

    header.Append("\n");

    return header;
}

此方法使用反射从对象属性中提取值:

(object, Type) GetReflectedResult(
    object item, PropertyInfo property)
{
    object result = property.GetValue(item);
    Type type = property.PropertyType;

    return (result, type);
}

此方法使用反射检索和格式化属性数据:

List<string> GetColumns(
    IEnumerable<ColumnDetail> details,
    object item)
{
    var columns = new List<string>();

    foreach (var detail in details)
    {
        PropertyInfo member = detail.PropertyInfo;
        string format =
            string.IsNullOrWhiteSpace(
                detail.Attribute.Format) ?
                "{0}" :
                detail.Attribute.Format;

        (object result, Type columnType) =
            GetReflectedResult(item, member);

        switch (columnType.FullName)
        {
            case "System.Decimal":
                columns.Add(
                    string.Format(format, (decimal)result));
                break;
            case "System.Int32":
                columns.Add(
                    string.Format(format, (int)result));
                break;
            case "System.String":
                columns.Add(
                    string.Format(format, (string)result));
                break;
            default:
                break;
        }
    }

    return columns;
}

此方法组合和格式化所有数据行:

StringBuilder GetRows(
    List<object> items,
    Dictionary<string, ColumnDetail> details)
{
    var rows = new StringBuilder();

    foreach (var item in items)
    {
        List<string> columns =
            GetColumns(details.Values, item);

        rows.AppendJoin(ColumnSeparator, columns);

        rows.Append("\n");
    }

    return rows;
}

最后,这个方法使用所有其他方法来构建完整的报告:

const string ColumnSeparator = " | ";

public string Generate(List<object> items)
{
    var report = new StringBuilder("# Report\n\n");

    Dictionary<string, ColumnDetail> columnDetails =
        GetColumnDetails(items);
    report.Append(GetHeaders(columnDetails));
    report.Append(GetRows(items, columnDetails));

    return report.ToString();
}

这是输出:

|| Total | Part # | Name | Amount | Price ||
| $15.78 | 1 | Part #1 | 3 | 5.26 |
| $7.95 | 2 | Part #2 | 1 | 7.95 |
| $46.26 | 3 | Part #3 | 2 | 23.13 |

讨论

解决方案中的报表库接收一个List<object>,以便消费者可以发送任何类型的对象。由于输入对象没有强类型,Report类需要执行反射以从每个对象中提取数据。Recipe 5.1 解释了Main方法如何传递这些数据以及解决方案如何生成标题。本节关注数据,解决方案不会重复来自 Recipe 5.1 的确切代码。

InventoryItem类使用ColumnAttribute属性。请注意,ItemPrice现在具有名为Format的属性,指定该列应在报表中格式化为货币。

在反射过程中,我们需要从对象中提取一组数据,以帮助报表布局和格式化。ColumnDetail对此很有帮助,因为在处理每列时,我们需要知道:

  • Name以确保我们正在处理正确的列

  • 用于格式化列数据的Attribute

  • 用于获取属性数据的PropertyInfo

GetColumnDetails方法为每列填充一个ColumnDetail。获取数据中的第一个对象,获取其类型,然后在类型上调用GetProperties以获取PropertyInfo[]。与 Recipe 5.1 不同,后者调用GetMembers以获取MemberInfo[],此方法仅从类型获取属性,而不是其他任何成员。

提示

除了GetMembersGetProperties之外,Type还具有其他反射方法,仅获取构造函数、字段或方法。如果需要限制正在处理的成员类型,则这些方法会很有用。

因为反射返回一组对象(在本解决方案中为PropertyInfo[]),我们可以使用 LINQ to Objects 进行更声明性的方法。这就是GetColumnDetails所做的事情,将投影到ColumnDetails实例中,并返回以列名为键,ColumnDetail为值的Dictionary

注意

如您稍后在解决方案中看到的那样,代码通过Dictionary<string, ColumnDetail>迭代,假设列及其数据按反射查询返回的顺序排列。但是,请想象一下未来的实现,其中ColumnAttribute具有Order属性,或者消费者可以传递include/exclude列元数据,这并不保证列的顺序与反射返回的顺序相匹配。在这种情况下,具有字典是至关重要的,可以根据您正在处理的列查找ColumnDetail元数据。尽管这个示例中没有涉及这一点以减少复杂性并集中于原始问题陈述,但它可能会给您一些如何扩展类似功能的想法。

GetHeaders方法与 Recipe 5.1 完全相同,只是它以 LINQ 语句编写,以减少和简化代码。

GetReflectedResult 返回一个元组,(object, Type)。其任务是从其 PropertyInfo 中提取属性的值和属性的类型。在这里,item 是实际的对象实例,property 是该属性的反射元数据。使用 property,代码使用 item 作为参数调用 GetValue —— 从 item 中读取该属性。再次强调,我们使用反射,并不知道属性的类型,因此将其放在类型对象中。PropertyInfo 还具有 PropertyType,我们可以从中获取 Type 对象。

警告

该应用程序使用反射将属性数据放入 object 类型的变量中。如果属性类型是值类型(例如 intdoubledecimal),则会产生装箱开销,这会影响应用程序的性能。如果您执行这种操作数百万次,您可能需要重新审视您的需求,并分析是否这对您的情况是一个好方法。尽管如此,这只是一份报告。考虑一下您可能为了向人显示数据而在报告中包含多少记录。在这种情况下,任何性能问题都将是微不足道的。这是灵活性与性能之间的经典权衡;您只需考虑它如何影响您的情况。

GetColumns 方法在遍历给定对象的每一列时使用 GetReflectedResultColumnDetail 的集合很有用,为当前列提供 PropertyInfo。如果列的 ColumnAttribute 不包括 Format 属性,则格式默认为无格式。switch 语句根据 GetReflectedResult 返回的 Type 对象对对象应用格式。

注意

为简单起见,在 GetColumnsswitch 语句中仅包含解决方案中的类型,尽管您可以想象它包含所有内置类型。我们可以使用反射调用 ToString,并带有格式说明符和类型,在 Recipe 5.4 中讨论,以减少代码量。然而,某些情况下,额外的复杂性并不增加价值。在这种情况下,我们只涵盖了一组有限的内置类型,一旦编写了该代码,它将不太可能更改。我对这种权衡的看法是,有时过于聪明会导致难以阅读且写作时间更长的代码。

最后,GetRows 为每一行调用 GetColumns 并返回给 Generate。然后,在调用了 GetHeadersGetRows 后,Generate 将结果追加到 StringBuilder 并将字符串返回给调用者,其中包含整个报告,您可以在解决方案输出中看到。

参见

Recipe 5.1, “使用反射读取属性”

Recipe 5.4, “使用反射调用方法”

5.3 使用反射实例化类型成员

问题

您需要实例化泛型类型,但事先不知道类型或类型参数。

解决方案

解决方案生成根据此枚举唯一格式化的报告:

public enum ReportType
{
    Html,
    Markdown
}

这里是一个可重用的生成报告的基类:

public abstract class GeneratorBase<TData>
{
    public string Generate(List<TData> items)
    {
        StringBuilder report = GetTitle();

        Dictionary<string, ColumnDetail> columnDetails =
            GetColumnDetails(items);
        report.Append(GetHeaders(columnDetails));
        report.Append(GetRows(items, columnDetails));

        return report.ToString();
    }

    protected abstract StringBuilder GetTitle();

    protected abstract StringBuilder GetHeaders(
        Dictionary<string, ColumnDetail> details);

    protected abstract StringBuilder GetRows(
        List<TData> items,
        Dictionary<string, ColumnDetail> details);

    Dictionary<string, ColumnDetail> GetColumnDetails(
        List<TData> items)
    {
        TData itemInstance = items.First();
        Type itemType = itemInstance.GetType();
        PropertyInfo[] itemProperties = itemType.GetProperties();

        return
            (from prop in itemProperties
             let attribute = prop.GetCustomAttribute<ColumnAttribute>()
             where attribute != null
             select new ColumnDetail
             {
                 Name = prop.Name,
                 Attribute = attribute,
                 PropertyInfo = prop
             })
            .ToDictionary(
                key => key.Name,
                val => val);
    }

    protected List<string> GetColumns(
        IEnumerable<ColumnDetail> details,
        TData item)
    {
        var columns = new List<string>();

        foreach (var detail in details)
        {
            PropertyInfo member = detail.PropertyInfo;
            string format =
                string.IsNullOrWhiteSpace(
                    detail.Attribute.Format) ?
                    "{0}" :
                    detail.Attribute.Format;

            (object result, Type columnType) =
                GetReflectedResult(item, member);

            switch (columnType.Name)
            {
                case "Decimal":
                    columns.Add(
                        string.Format(format, (decimal)result));
                    break;
                case "Int32":
                    columns.Add(
                        string.Format(format, (int)result));
                    break;
                case "String":
                    columns.Add(
                        string.Format(format, (string)result));
                    break;
                default:
                    break;
            }
        }

        return columns;
    }

    (object, Type) GetReflectedResult(TData item, PropertyInfo property)
    {
        object result = property.GetValue(item);
        Type type = property.PropertyType;

        return (result, type);
    }
}

此类使用基类生成 Markdown 报告:

public class MarkdownGenerator<TData> : GeneratorBase<TData>
{
    const string ColumnSeparator = " | ";

    protected override StringBuilder GetTitle()
    {
        return new StringBuilder("# Report\n\n");
    }

    protected override StringBuilder GetHeaders(
        Dictionary<string, ColumnDetail> details)
    {
        var header = new StringBuilder();

        header.AppendJoin(
            ColumnSeparator,
            from detail in details.Values
            select detail.Attribute.Name);

        header.Append("\n");

        header.AppendJoin(
            ColumnSeparator,
            from detail in details.Values
            let length = detail.Attribute.Name.Length
            select "".PadLeft(length, '-'));

        header.Append("\n");

        return header;
    }

    protected override StringBuilder GetRows(
        List<TData> items,
        Dictionary<string, ColumnDetail> details)
    {
        var rows = new StringBuilder();

        foreach (var item in items)
        {
            List<string> columns =
                GetColumns(details.Values, item);

            rows.AppendJoin(ColumnSeparator, columns);

            rows.Append("\n");
        }

        return rows;
    }
}

而这个类则使用基类生成 HTML 报告:

public class HtmlGenerator<TData> : GeneratorBase<TData>
{
    protected override StringBuilder GetTitle()
    {
        return new StringBuilder("<h1>Report</h1>\n");
    }

    protected override StringBuilder GetHeaders(
        Dictionary<string, ColumnDetail> details)
    {
        var header = new StringBuilder("<tr>\n");

        header.AppendJoin(
            "\n",
            from detail in details.Values
            let columnName = detail.Attribute.Name
            select $"  <th>{columnName}</th>");

        header.Append("\n</tr>\n");

        return header;
    }

    protected override StringBuilder GetRows(
        List<TData> items,
        Dictionary<string, ColumnDetail> details)
    {
        StringBuilder rows = new StringBuilder();
        Type itemType = items.First().GetType();

        foreach (var item in items)
        {
            rows.Append("<tr>\n");

            List<string> columns =
                GetColumns(details.Values, item);

            rows.AppendJoin(
                "\n",
                from columnValue in columns
                select $"  <td>{columnValue}</td>");

            rows.Append("\n</tr>\n");
        }

        return rows;
    }
}

这种方法,来自Report类,管理报告生成过程:

public string Generate(List<TData> items, ReportType reportType)
{
    GeneratorBase<TData> generator = CreateGenerator(reportType);

    string report = generator.Generate(items);

    return report;
}

这是一个从Report类中使用枚举来确定要生成的报告格式的方法:

GeneratorBase<TData> CreateGenerator(ReportType reportType)
{
    Type generatorType;

    switch (reportType)
    {
        case ReportType.Html:
            generatorType = typeof(HtmlGenerator<>);
            break;
        case ReportType.Markdown:
            generatorType = typeof(MarkdownGenerator<>);
            break;
        default:
            throw new ArgumentException(
                $"Unexpected ReportType: '{reportType}'");
    }

    Type dataType = typeof(TData);
    Type genericType = generatorType.MakeGenericType(dataType);

    object generator = Activator.CreateInstance(genericType);

    return (GeneratorBase<TData>)generator;
}

这是另一种通过约定来确定要生成的报告格式的方法:

GeneratorBase<TData> CreateGenerator(ReportType reportType)
{
    Type dataType = typeof(TData);

    string generatorNamespace = "Section_05_03.";
    string generatorTypeName = $"{reportType}Generator`1";
    string typeParameterName = $"[[{dataType.FullName}]]";

    string fullyQualifiedTypeName =
        generatorNamespace +
        generatorTypeName +
        typeParameterName;

    Type generatorType = Type.GetType(fullyQualifiedTypeName);

    object generator = Activator.CreateInstance(generatorType);

    return (GeneratorBase<TData>)generator;
}

Main方法传递数据并指定要生成的报告格式:

static void Main()
{
    var inventory = new List<InventoryItem>
    {
        new InventoryItem
        {
            PartNumber = "1",
            Description = "Part #1",
            Count = 3,
            ItemPrice = 5.26m
        },
        new InventoryItem
        {
            PartNumber = "2",
            Description = "Part #2",
            Count = 1,
            ItemPrice = 7.95m
        },
        new InventoryItem
        {
            PartNumber = "3",
            Description = "Part #3",
            Count = 2,
            ItemPrice = 23.13m
        },
    };

    string report =
        new Report<InventoryItem>()
        .Generate(inventory, ReportType.Markdown);

    Console.WriteLine(report);
}

这是输出结果:

# Report

Part # | Name | Amount | Price
------ | ---- | ------ | -----
1 | Part #1 | 3 | $5.26
2 | Part #2 | 1 | $7.95
3 | Part #3 | 2 | $23.13

讨论

Recipe 5.2 基于一个通用对象类型创建报告,这导致我们失去了我们习惯的类型安全性。本节通过使用泛型来修复这个问题,并展示如何使用反射来实例化具有泛型类型参数的对象。

前面几节的概念是生成 Markdown 格式的报告。但是,如果报告生成器能够根据您选择的任何格式生成报告,它可能会更有用。本示例重构了 Recipe 5.2 中的示例,以提供 Markdown 和 HTML 输出报告。

枚举ReportType指定要生成的报告输出类型:HtmlMarkdown。因为我们可以生成多种格式,所以我们需要为每种格式单独创建类:HtmlGeneratorMarkdownGenerator。此外,我们不想重复代码,所以每个格式生成类都继承自GeneratorBase

注意,GeneratorBase是一个抽象类(无法实例化),具有抽象和已实现的方法。在GeneratorBase中已实现的方法具有与输出格式无关的代码,并且所有派生的生成器类都将使用这些方法:GetColumnsGetColumnDetailsGetReflectedResult。根据定义,派生的生成器类必须覆盖这些特定于格式的抽象方法:GetTitleGetHeadersGetRows。查看HtmlGeneratorMarkdownGenerator,你可以看到这些抽象方法的覆盖实现。

现在,让我们将这一切整合起来,使其有意义。当程序启动时,在 Report 实例上调用的第一个方法是 Generate,在 GeneratorBase 中。注意 Generate 调用的顺序:GetTitleGetColumnDetailsGetHeaders,然后是 GetRows。这基本上与 Recipe 5.2 中描述的顺序相同。你可以想象通过编写标题、获取其余报告的元数据、编写标题,并逐行编写报告的每一行来生成报告。为了实现代码重用并创建未来添加报告格式的可扩展框架,我们有一个通用的抽象基类 GeneratorBase,以及理解格式的派生类。以 MarkdownGenerator 为例,这是一个示例:

  1. 外部代码调用 GeneratorBase.Generate

  2. Generator.Generate 调用 MarkdownGenerator.GetTitle

  3. Generator.Generate 调用 Generator.GetColumnDetails

  4. Generator.Generate 调用 MarkdownGenerator.GetHeader

  5. Generator.Generate 调用 MarkdownGenerator.GetRows

  6. MarkdownGenerator.GetRows 调用 Generator.GetColumns

  7. Generator.GetColumns 调用 Generator.GetReflectedResult

  8. MarkdownGenerator.GetRows 完成,返回至 Generator.Generate

  9. Generator.Generate 将报告返回给调用代码。

HtmlGenerator 的工作方式完全相同,未来的任何报告格式也将如此。事实上,Recipe 5.6 通过添加第三种格式来扩展此示例,以支持创建 Excel 报告。

注意

该解决方案使用一种称为模板模式的模式。在这种模式中,基类实现通用逻辑,并将实现特定工作委托给派生类。这体现了面向对象的多态原则。

我们可以扩展这个框架,而无需重写样板逻辑,这使得这种方法变得可行。Recipe 5.6 展示了其工作原理。

GenerateBase 类故意声明为 abstract,因为唯一的工作方式是通过派生类的实例。Report.Generate 方法调用 GeneratorBase.Generate。在此之前,它必须确定通过 CreateGenerator 实例化哪个具体的 GeneratorBase 派生类,其中有两个示例。

CreateGenerator的第一个示例检查ReportType枚举,以查看通过switch语句生成哪种类型的报告。正如前面部分所述,执行反射需要Type对象,typeof操作符就是这样做的。请注意,我们传递了一个带有<>后缀的泛型类型,而没有泛型类型。之后,我们使用typeof操作符获取传递给Report类的类型参数TData的类型。现在我们有了泛型类型和其类型参数的类型。接下来,我们需要将泛型类型和其参数类型结合在一起,以获得一个完全构造的类型(例如,对于HtmlHtmlGenerator<TData>)。一旦你有了完全构造的类型,你可以使用Activator类调用CreateInstance来实例化该类型。通过GeneratorBase派生类型的新实例,CreateGenerate返回到ReportGenerate,在新实例上调用Generate。正如你之前学到的,GeneratorBase为所有派生实例实现了Generate

这是使用反射来实例化泛型类型的一种方法,如问题陈述所指定的。不过,需要考虑的一点是,你是否希望在将来添加更多格式支持?你将不得不返回到Report类并更改switch语句,这是一种通过代码更改的配置。如果你希望只编写一次Report类并且永远不再修改它呢?此外,如果你更喜欢通过约定优于配置的原则进行设计呢?.NET 中约定优于配置的一个很好的例子是 ASP.NET MVC。ASP.NET MVC 的一些约定包括控制器放在Controllers文件夹中,视图放在Views文件夹中。另一个约定是控制器名称是 URL 路径,其名称带有Controller后缀。这些事情只是因为这是约定而工作。CreateGenerator的第二个例子使用了约定优于配置的方法。

注意,CreateGenerator的第二个实现会构建一个带有命名空间和类型名称的完全限定类型名(例如,对于HtmlSection_05_03.HtmlGenerator)。还请注意,ReportType枚举成员与类名完全匹配。这意味着将来任何时候,你都可以创建一个新格式,从GeneratorBase派生,并将前缀添加到ReportType,以Generator作为后缀,它将起作用。除非添加新功能,否则不需要再次触及Report类。

在获取类型对象之后,两个CreateGenerator示例都调用Activator.CreateInstance返回一个新实例给Report.Generate

最后,看看Main方法,这个报告库的用户所需做的就是传入数据和他们想要生成的ReportType

参见

5.2 节,“使用反射访问类型成员”

5.6 节,“与 Office 应用程序进行 Interop”

5.4 使用反射调用方法

问题

您收到的对象具有需要调用的方法。

解决方案

列元数据类具有MemberInfo属性:

public class ColumnDetail
{
    public string Name { get; set; }

    public ColumnAttribute Attribute { get; set; }

    public MemberInfo MemberInfo { get; set; }
}

这个类,要进行反射,有属性和一个方法:

public class InventoryItem
{
 [Column("Part #")]
    public string PartNumber { get; set; }

 [Column("Name")]
    public string Description { get; set; }

 [Column("Amount")]
    public int Count { get; set; }

 [Column("Price", Format = "{0:c}")]
    public decimal ItemPrice { get; set; }

 [Column("Total", Format = "{0:c}")]
    public decimal CalculateTotal()
    {
        return ItemPrice * Count;
    }
}

此方法调用GetMembers以处理MemberInfo实例:

Dictionary<string, ColumnDetail> GetColumnDetails(
    List<object> items)
{
    return
        (from member in
            items.First().GetType().GetMembers()
         let attribute =
            member.GetCustomAttribute<ColumnAttribute>()
         where attribute != null
         select new ColumnDetail
         {
             Name = member.Name,
             Attribute = attribute,
             MemberInfo = member
         })
        .ToDictionary(
            key => key.Name,
            val => val);
}

此方法使用MemberInfo类型来确定如何检索值:

(object, Type) GetReflectedResult(
    Type itemType, object item, MemberInfo member)
{
    object result;
    Type type;

    switch (member.MemberType)
    {
        case MemberTypes.Method:
            MethodInfo method =
                itemType.GetMethod(member.Name);
            result = method.Invoke(item, null);
            type = method.ReturnType;
            break;
        case MemberTypes.Property:
            PropertyInfo property =
                itemType.GetProperty(member.Name);
            result = property.GetValue(item);
            type = property.PropertyType;
            break;
        default:
            throw new ArgumentException(
                "Expected property or method.");
    }

    return (result, type);
}

讨论

本章前几节主要使用属性作为报表输入。在本节中,我们将修改Recipe 5.2中的示例,并添加我们需要通过反射调用的方法。

第一个变化是ColumnDetail有一个MemberInfo属性,该属性保存了任何类型成员的元数据。

InventoryItem类有一个CalculateTotal方法。它将ItemPriceCount相乘,显示该数量物品的总价格。

在 LINQ 语句中,GetColumnDetails的变化在于它迭代了GetMembers的结果,这是一个MemberInfo[]。与Recipe 5.2不同,我们使用MemberInfo。这对于解决方案是必需的,因为我们希望获取属性和方法的信息。

最后,GetReflectedResult有一个switch语句来确定如何获取成员的值。由于参数是MemberInfo,我们查看MemberType属性来确定是否在处理属性或方法。无论哪种情况,我们都必须调用GetPropertyGetMethod来获取PropertyInfoMethodInfo。对于方法,调用Invoke方法,参数是item作为要调用方法的对象实例。Invoke的第二个参数是null,表示此示例中的方法CalculateTotal没有参数。如果需要传递参数,请将object[]放入Invoke的第二个参数中,按照方法期望的顺序传递成员。如同Recipe 5.2一样,在Proper⁠ty​Info实例上调用GetValue,使用item作为对象引用来获取该属性的值。

总结一下,每当您需要通过反射调用对象的方法时,获取其Type对象,获取一个MethodInfo(即使您需要从MemberInfo中获取中间步骤),然后调用Invoke方法,并将对象实例作为参数传递给MethodInfo

参见

Recipe 5.2,“使用反射访问类型成员”

5.5 用动态代码替换反射

问题

当您使用反射时,但了解某个类型的一些成员,并希望简化代码时。

解决方案

此类包含报表的数据列表:

public class Inventory
{
    public string Title { get; set; }

    public List<object> Data { get; set; }
}

这是填充数据的Main方法:

static void Main()
{
    var inventory = new Inventory
    {
        Title = "Inventory Report",
        Data = new List<object>
        {
            new InventoryItem
            {
                PartNumber = "1",
                Description = "Part #1",
                Count = 3,
                ItemPrice = 5.26m
            },
            new InventoryItem
            {
                PartNumber = "2",
                Description = "Part #2",
                Count = 1,
                ItemPrice = 7.95m
            },
            new InventoryItem
            {
                PartNumber = "3",
                Description = "Part #3",
                Count = 2,
                ItemPrice = 23.13m
            },
        }
    };

    string report = new Report().Generate(inventory);

    Console.WriteLine(report);
}

此方法使用反射来提取属性的值:

public string Generate(object reportDetails)
{
    Type reportType = reportDetails.GetType();
    PropertyInfo titleProp = reportType.GetProperty("Title");
    string title = (string)titleProp.GetValue(reportDetails);

    var report = new StringBuilder($"# {title}\n\n");

    PropertyInfo dataProp = reportType.GetProperty("Data");
    List<object> items =
        (List<object>)dataProp.GetValue(reportDetails);

    Dictionary<string, ColumnDetail> columnDetails =
        GetColumnDetails(items);
    report.Append(GetHeaders(columnDetails));
    report.Append(GetRows(items, columnDetails));

    return report.ToString();
}

这个类提取相同的属性值,但使用dynamic

public string Generate(dynamic reportDetails)
{
    string title = reportDetails.Title;

    var report = new StringBuilder(
        $"# {title}\n\n");

    List<object> items = reportDetails.Data;

    Dictionary<string, ColumnDetail> columnDetails =
        GetColumnDetails(items);
    report.Append(GetHeaders(columnDetails));
    report.Append(GetRows(items, columnDetails));

    return report.ToString();
}

讨论

这个解决方案的概念再次是为报告库的用户提供对他们想要使用的类型的最大控制权。但是,如果你确实有一些限制怎么办?例如,必须有一种设置报告标题的方法,并且你需要知道那个属性是什么。这个解决方案通过告诉用户提供一个具有TitleData属性的对象来与用户妥协。Title包含报告标题,Data包含报告行。只要他们提供这些属性,他们可以使用任何他们想要的对象。如果输入对象上有其他我们不关心的属性,它不会影响报告库。

我们将使用的类是Inventory,包含一个Title字符串和一个Data集合。Main方法填充一个Inventory实例并将其传递给Generate

我们有两个Generate的示例:一个使用反射,另一个使用动态。在获取类型后,第一个示例调用GetPropertyGetValue来获取每个属性的值。方法的其余部分与配方 5.2 中的操作方式相同。

正如你所见,反射可能会很冗长,进行许多方法调用和类型转换。这是使用dynamic的一个好案例。我们知道TitleData是存在的,那为什么不直接访问它们呢?这就是第二个示例的做法。首先,注意reportDetails参数的类型是dynamic。然后观察代码如何调用TitleData,并将它们放入强类型变量中。

注意

dynamic类型仍然是object类型,但是由 DLR 在后台执行了一些额外的魔法。

在开发过程中,由于dynamic不知道它正在处理的类型,所以你不能获得 IntelliSense,但是你可以得到可读的代码。在你了解到传递给代码的类型的成员时,dynamic是反射的更好机制。

参见

配方 5.2,“使用反射访问类型成员”

5.6 使用 Office 应用程序进行互操作

问题

您需要使用尽可能简单的代码填充 Excel 电子表格中的对象数据。

解决方案

这是一个为 Excel 添加额外成员的枚举:

public enum ReportType
{
    Html,
    Markdown,
    ExcelTyped,
    ExcelDynamic
}

不使用dynamic的 Excel 报告生成器:

public class ExcelTypedGenerator<TData> : GeneratorBase<TData>
{
    ApplicationClass excelApp;
    Workbook wkBook;
    Worksheet wkSheet;

    public ExcelTypedGenerator()
    {
        excelApp = new ApplicationClass();
        excelApp.Visible = true;

        wkBook = excelApp.Workbooks.Add(Missing.Value);
        wkSheet = (Worksheet)wkBook.ActiveSheet;
    }

    protected override StringBuilder GetTitle()
    {
        wkSheet.Cells[1, 1] = "Report";

        return new StringBuilder("Added Title...\n");
    }

    protected override StringBuilder GetHeaders(
        Dictionary<string, ColumnDetail> details)
    {
        ColumnDetail[] values = details.Values.ToArray();

        for (int i = 0; i < values.Length; i++)
        {
            ColumnDetail detail = values[i];
            wkSheet.Cells[3, i+1] = detail.Attribute.Name;
        }

        return new StringBuilder("Added Header...\n");
    }

    protected override StringBuilder GetRows(
        List<TData> items,
        Dictionary<string, ColumnDetail> details)
    {
        const int DataStartRow = 4;

        int rows = items.Count;
        int cols = details.Count;

        var data = new string[rows, cols];

        for (int i = 0; i < rows; i++)
        {
            List<string> columns =
                GetColumns(details.Values, items[i]);

            for (int j = 0; j < cols; j++)
            {
                data[i, j] = columns[j];
            }
        }

        int FirstCol = 'A';
        int LastExcelCol = FirstCol + cols - 1;
        int LastExcelRow = DataStartRow + rows - 1;
        string EndRangeCol = ((char)LastExcelCol).ToString();
        string EndRangeRow = LastExcelRow.ToString();

        string EndRange = EndRangeCol + EndRangeRow;
        string BeginRange = "A" + DataStartRow.ToString();

        var dataRange = wkSheet.get_Range(BeginRange, EndRange);
        dataRange.Value2 = data;

        wkBook.SaveAs(
            "Report.xlsx", Missing.Value, Missing.Value,
            Missing.Value, Missing.Value, Missing.Value,
            XlSaveAsAccessMode.xlShared, Missing.Value, Missing.Value,
            Missing.Value, Missing.Value, Missing.Value);

        return new StringBuilder(
            "Added Data...\n" +
            "Excel file created at Report.xlsx");
    }
}

使用动态生成 Excel 报告:

public class ExcelDynamicGenerator<TData> : GeneratorBase<TData>
{
    ApplicationClass excelApp;
    dynamic wkBook;
    Worksheet wkSheet;

    public ExcelDynamicGenerator()
    {
        excelApp = new ApplicationClass();
        excelApp.Visible = true;

        wkBook = excelApp.Workbooks.Add();
        wkSheet = wkBook.ActiveSheet;
    }

    protected override StringBuilder GetTitle()
    {
        wkSheet.Cells[1, 1] = "Report";

        return new StringBuilder("Added Title...\n");
    }

    protected override StringBuilder GetHeaders(
        Dictionary<string, ColumnDetail> details)
    {
        ColumnDetail[] values = details.Values.ToArray();

        for (int i = 0; i < values.Length; i++)
        {
            ColumnDetail detail = values[i];
            wkSheet.Cells[3, i+1] = detail.Attribute.Name;
        }

        return new StringBuilder("Added Header...\n");
    }

    protected override StringBuilder GetRows(
        List<TData> items,
        Dictionary<string, ColumnDetail> details)
    {
        const int DataStartRow = 4;

        int rows = items.Count;
        int cols = details.Count;

        var data = new string[rows, cols];

        for (int i = 0; i < rows; i++)
        {
            List<string> columns =
                GetColumns(details.Values, items[i]);

            for (int j = 0; j < cols; j++)
            {
                data[i, j] = columns[j];
            }
        }

        int FirstCol = 'A';
        int LastExcelCol = FirstCol + cols - 1;
        int LastExcelRow = DataStartRow + rows - 1;
        string EndRangeCol = ((char)LastExcelCol).ToString();
        string EndRangeRow = LastExcelRow.ToString();

        string EndRange = EndRangeCol + EndRangeRow;
        string BeginRange = "A" + DataStartRow.ToString();

        var dataRange = wkSheet.get_Range(BeginRange, EndRange);
        dataRange.Value2 = data;

        wkBook.SaveAs(
            "Report.xlsx",
            XlSaveAsAccessMode.xlShared);

        return new StringBuilder(
            "Added Data...\n" +
            "Excel file created at Report.xlsx");
    }
}

讨论

这个示例基于配方 5.3 中的多报告格式生成代码,简要说明了如何添加另一种报告类型。这个解决方案展示了如何实现。

首先,注意ReportType枚举有两个额外的成员:ExcelTypedExcelDynamic。两者都使用一致的约定,其中ExcelTyped创建一个ExcelTypedGenerator实例,而ExcelDynamic创建一个ExcelDynamicGenerator实例。区别在于,ExcelTypedGenerator使用强类型代码生成 Excel 报告,而ExcelDynamicGenerator使用动态代码生成 Excel 报告。

提示

您可以使用这样的技术来自动化任何 Microsoft Office 应用程序。关键是确保您已通过 Visual Studio Installer 安装了 Visual Studio Tools for Office(VSTO)。这将安装所谓的主互操作程序集(PIAs)。安装后,您可以在您的 Visual Studio 安装文件夹下找到这些 PIAs(例如,我的机器上的文件夹是C:\Program Files (x86)\Microsoft Visual Studio\Shared\Visual Studio Tools for Office\PIA),并使用与您安装的 Microsoft Office 版本对应的版本。如果您的 Office 版本较旧,无法安装 VSTO,请搜索下载选项

要查看这两个示例之间的区别,请逐个成员地查看。特别是 ExcelTypedGenerator 具有强类型字段,因此每当不使用参数并且需要对返回类型执行转换时,必须使用 Missing.Value 占位符。注意 GetRows 方法末尾的 SaveAs 方法调用,这特别繁琐。

相比之下,将这些示例与 ExcelDynamicGenerator 代码进行比较。将 wkBook 字段设置为 dynamic,而不是强类型,可以改变代码。不再需要 Missing.Value 占位符或类型转换。代码编写起来更容易,阅读起来也更容易。

参见

Recipe 5.3,“使用反射实例化类型成员”

5.7 创建一个固有动态类型

问题

您的数据以专有格式存在,但希望通过对象访问成员而无需自行解析。

解决方案

这个类保存要显示在报告中的数据:

public class LogEntry
{
 [Column("Log Date", Format = "{0:yyyy-MM-dd hh:mm}")]
    public DateTime CreatedAt { get; set; }

 [Column("Severity")]
    public string Type { get; set; }

 [Column("Location")]
    public string Where { get; set; }

 [Column("Message")]
    public string Description { get; set; }
}

这些方法获取日志数据并返回具有该数据的 DynamicObject 类型列表:

static List<dynamic> GetData()
{
    string headers = "Date|Severity|Location|Message";

    string logData = GetLogData();

    return
        (from line in logData.Split('\n')
         select new DynamicLog(headers, line))
        .ToList<dynamic>();
}

static string GetLogData()
{
    return
"2022-11-12 12:34:56.7890|INFO|Section_05_07.Program|Got this far\n" +
"2022-11-12 12:35:12.3456|ERROR|Section_05_07.Report|Index out of range\n" +
"2022-11-12 12:55:34.5678|WARNING|Section_05_07.Report|Please check this";
}

这个类是一个 DynamicObject,它知道如何读取日志文件并动态公开属性:

public class DynamicLog : DynamicObject
{
    Dictionary<string, string> members =
        new Dictionary<string, string>();

    public DynamicLog(string headerString, string logString)
    {
        string[] headers = headerString.Split('|');
        string[] logData = logString.Split('|');

        for (int i = 0; i < headers.Length; i++)
            members[headers[i]] = logData[i];
    }

    public override bool TryGetMember(
        GetMemberBinder binder, out object result)
    {
        result = members[binder.Name];
        return true;
    }

    public override bool TryInvokeMember(
        InvokeMemberBinder binder, object[] args, out object result)
    {
        return base.TryInvokeMember(binder, args, out result);
    }

    public override bool TrySetMember(
        SetMemberBinder binder, object value)
    {
        members[binder.Name] = (string)value;
        return true;
    }
}

Main 方法消耗动态数据,填充数据对象,并获取新报告:

static void Main()
{
    List<dynamic> logData = GetData();

    var tempDateTime = DateTime.MinValue;
    List<object> inventory =
        (from log in logData
         let canParse =
            DateTime.TryParse(
                log.Date, out tempDateTime)
         select new LogEntry
         {
             CreatedAt = tempDateTime,
             Type = log.Severity,
             Where = log.Location,
             Description = log.Message
         })
        .ToList<object>();

    string report = new Report().Generate(inventory);

    Console.WriteLine(report);
}

讨论

DynamicObject 类型是 .NET Framework 的一部分,支持用于与动态语言的互操作性的 DLR。这是一种特殊的类型,允许任何人调用类型成员,并且它可以拦截调用并根据您编程的方式行为。与其挥手列举使用 DynamicObject 的几种方法,这个解决方案专注于需要一个对象来处理专有数据的问题。在这个解决方案中,数据是日志文件格式。在这里,我们将使用 DynamicObject 提供数据,并使用来自 Recipe 5.2 的报告库来显示日志数据。

LogEntry 类表示报告中的一行。我们无法将 DynamicObject 实例传递给 Report,因为没有办法反射它并提取属性。任何解决方法都很麻烦,使用 DynamicObject 处理数据并生成 LogEntry 对象的集合,然后将它们传递给 Report 更为简单。

GetLogData 方法展示了日志文件的样子。GetData 定义了一个 headers 字符串,它是每个日志文件条目的元数据。LINQ 查询遍历日志的每一行,结果是一个 List<dynamic>。投影使用头部和日志条目实例化一个新的 DynamicLog 实例。

DynamicLog 类型继承自 DynamicObject,仅实现了它需要的方法。DynamicLog 的实现展示了其中一些成员:TryGetMemberTryInvokeMemberTrySetMember。解决方案没有使用 TryInvokeMember,但我把它保留在那里以展示 DynamicObject 不仅仅与属性一起工作,还有其他重载。Dictionary<string, string> members 中保存着日志中每个字段的值,键来自头部,值来自日志文件中相同位置的字符串。

构造函数填充成员。它在每个字段的 (|) 分隔符上分割,并通过头部迭代,直到 members 每列都有一个条目。TryGetMembers 方法通过 out object result 参数从字典读取返回值。记得在成功时返回 true,因为返回 false 表示无法执行操作,用户将收到运行时异常。TrySetMember 用值填充字典。

GetMemberBinderSetMemberBinder 包含被访问属性的元数据。例如,以下代码将调用 TryGetMember

string severity = log.Severity;

假设 logDynamicLog 的一个实例,则 GetMemberBinderName 属性将是 Severity。它将索引字典并返回分配给该键的任何值。类似地,以下代码将调用 TrySetMember

log.Severity = "ERROR";

在这种情况下,binder.Name 将是 Severity,它将更新字典中该键的值为 ERROR

这意味着现在我们有一个对象,您可以在其中设置您选择的属性名称,并提供相同格式的任何日志文件(管道分隔符)。每次您想要适应管道分隔格式的日志文件时,无需自定义类。

GetData 返回一个 List<dynamic>。因为它是一个动态对象,并且我们已经知道属性名称应该是什么(它们与头部匹配),我们可以通过在动态对象上仅指定属性名称来投影为 LogEntry 实例。此外,您可以在配置文件或数据库中指定这些头部,这些头部可以是数据驱动的,并且每次都可以更改。也许您甚至想要能够更改文件的分隔符来处理更多文件类型。正如您所看到的,这在 DynamicObject 中非常容易实现。

参见

Recipe 5.2, “使用反射访问类型成员”

5.8 动态添加和删除类型成员

问题

您希望一个完全动态的对象,可以在运行时添加成员,就像在 JavaScript 中一样。

解决方案

此方法使用 ExpandoObject 收集数据:

static List<dynamic> GetData()
{
    const int Date = 0;
    const int Severity = 1;
    const int Location = 2;
    const int Message = 3;

    var logEntries = new List<dynamic>();

    string logData = GetLogData();

    foreach (var line in logData.Split('\n'))
    {
        string[] columns = line.Split('|');

        dynamic logEntry = new ExpandoObject();

        logEntry.Date = columns[Date];
        logEntry.Severity = columns[Severity];
        logEntry.Location = columns[Location];
        logEntry.Message = columns[Message];

        logEntries.Add(logEntry);
    }

    return logEntries;
}

static string GetLogData()
{
    return
        "2022-11-12 12:34:56.7890|INFO" +
        "|Section_05_07.Program|Got this far\n" +
        "2022-11-12 12:35:12.3456|ERROR" +
        "|Section_05_07.Report|Index out of range\n" +
        "2022-11-12 12:55:34.5678|WARNING" +
        "|Section_05_07.Report|Please check this";
}

Main 方法将 List<dynamic> 转换为 List<LogEntry> 并获取报告:

static void Main()
{
    List<dynamic> logData = GetData();

    var tempDateTime = DateTime.MinValue;
    List<object> inventory =
        (from log in logData
         let canParse =
            DateTime.TryParse(
                log.Date, out tempDateTime)
         select new LogEntry
         {
             CreatedAt = tempDateTime,
             Type = log.Severity,
             Where = log.Location,
             Description = log.Message
         })
        .ToList<object>();

    string report = new Report().Generate(inventory);

    Console.WriteLine(report);
}

讨论

这类似于 Recipe 5.7 中的 DynamicObject 示例,但它涵盖了一个更简单的情况,您不需要那么多的灵活性。如果您预先知道文件格式,并且知道它不会更改,但希望以一种简单的方式将数据提取到动态对象中,而不必每次都创建一个新类型发送到报告中,那该怎么办?

在这种情况下,您可以使用 ExpandoObject,它是 .NET Framework 的一种类型,允许您动态添加和移除类型成员,与 JavaScript 中的操作相同。

在解决方案中,GetData 方法实例化一个 ExpandoObject,将其分配给 dynamic 类型的 logEntry。然后,它会动态添加属性,并使用解析的日志文件数据填充这些属性。

Main 方法从 GetData 接受一个 List<dynamic>。只要每个对象具有它所期望的属性,一切都会很好。

参见

Recipe 5.7, “Creating an Inherently Dynamic Type”

5.9 从 C# 调用 Python 代码

问题

您有一个 C# 程序,并且想要使用一些 Python 代码,但不想重写它。

解决方案

此 Python 文件包含我们需要使用的代码:

import sys
sys.path.append(
    "/System/Library/Frameworks/Python.framework" +
    "/Versions/Current/lib/python2.7")

from random import *

class SemanticAnalysis:
    @staticmethod
    def Eval(text):
        val = random()
        return val < .5

此类表示社交媒体数据:

public class Tweet
{
 [Column("Screen Name")]
    public string ScreenName { get; set; }

 [Column("Date")]
    public DateTime CreatedAt { get; set; }

 [Column("Text")]
    public string Text { get; set; }

 [Column("Semantic Analysis")]
    public string Semantics { get; set; }
}

Main 方法获取数据并生成报告:

static void Main()
{
    List<object> tweets = GetTweets();

    string report = new Report().Generate(tweets);

    Console.WriteLine(report);
}

这些是 IronPython NuGet 包的必需命名空间的一部分:

using IronPython.Hosting;
using Microsoft.Scripting.Hosting;

此方法设置了 Python 互操作:

static List<object> GetTweets()
{
    ScriptRuntime py = Python.CreateRuntime();
    dynamic semantic = py.UseFile("../../../Semantic.py");
    dynamic semanticAnalysis = semantic.SemanticAnalysis();

    DateTime date = DateTime.UtcNow;

    var tweets = new List<object>
    {
        new Tweet
        {
            ScreenName = "SomePerson",
            CreatedAt = date.AddMinutes(5),
            Text = "Comment #1",
            Semantics = GetSemanticText(semanticAnalysis, "Comment #1")
        },
        new Tweet
        {
            ScreenName = "SomePerson",
            CreatedAt = date.AddMinutes(7),
            Text = "Comment #2",
            Semantics = GetSemanticText(semanticAnalysis, "Comment #2")
        },
        new Tweet
        {
            ScreenName = "SomePerson",
            CreatedAt = date.AddMinutes(12),
            Text = "Comment #3",
            Semantics = GetSemanticText(semanticAnalysis, "Comment #3")
        },
    };

    return tweets;
}

此方法通过 dynamic 实例调用 Python 代码:

static string GetSemanticText(dynamic semantic, string text)
{
    bool result = semantic.Eval(text);
    return result ? "Positive" : "Negative";
}

讨论

在这个示例中,您正在处理社交媒体数据。报告中的一个项目是语义分析,告诉用户的推文是积极的还是消极的。您有一个很棒的语义分析 AI 模型,但它是用 TensorFlow 在 Python 模块中构建的。能够重用该代码而不是重写它将非常有帮助。

这就是 DLR 的作用所在,因为它允许您从 C# 中调用 Python(以及其他动态语言)。考虑到建立一个机器学习模型(或任何其他类型的模块)可能需要很多个月的时间,跨语言重用代码的优势是巨大的。

Python 文件中的 SemanticAnalysis 类模拟一个模型,返回正面结果为 true 或负面结果为 false

Main 方法调用 GetTweets 来获取数据,并使用 Report 类,与 Recipe 5.2 中相同。从 GetTweets 返回的 List<object> 包含可以与报告生成器一起工作的 Tweet 对象。

提示

要设置这一点,您需要引用 IronPython 包,您可以在 NuGet 上找到它。您还可能发现通过 Visual Studio 安装程序安装 Python 工具对于 Visual Studio 会很有用。

GetTweets方法需要一个对 Python SemanticAnalysis类的引用。调用CreateRuntime创建一个 DLR 引用。然后,您需要通过UseFile指定 Python 文件的位置。之后,您可以实例化SemanticAnalysis类。每个Tweet实例通过调用GetSemanticText,传递SemanticAnalysis引用和text来设置Semantics属性。

GetSemanticText方法调用Eval并以text作为其参数,返回一个bool结果,然后将其翻译为报告友好的“Positive”或“Negative”字符串。

在几行代码中,您看到了重用用动态语言编写的重要代码有多容易。DLR 支持的语言包括 Ruby 和 JavaScript 等。

参见

Recipe 5.2,“使用反射访问类型成员”

5.10 从 Python 调用 C#代码

问题

您有一个 Python 程序,并且想要使用 C#代码,但不想重写它。

解决方案

这是需要使用报告生成器的主要 Python 应用程序:

import clr, sys

sys.path.append(
    r"C:\Path Where You Cloned The Project" +
    "\Chapter05\Section-05-10\bin\Debug")
clr.AddReference(
    r"C:\Path Where You Cloned The Project" +
    \Chapter05\Section-05-10\bin\Debug\PythonToCS.dll")

from PythonToCS import Report
from PythonToCS import InventoryItem
from System import Decimal

inventory = [
    InventoryItem("1", "Part #1", 3, Decimal(5.26)),
    InventoryItem("2", "Part #2", 1, Decimal(7.95)),
    InventoryItem("3", "Part #1", 2, Decimal(23.13))]

rpt = Report()

result = rpt.GenerateDynamic(inventory)

print(result)

这个类有一个构造函数,以便在 Python 中更容易使用:

public class InventoryItem
{
    public InventoryItem(
        string partNumber, string description,
        int count, decimal itemPrice)
    {
        PartNumber = partNumber;
        Description = description;
        Count = count;
        ItemPrice = itemPrice;
    }

 [Column("Part #")]
    public string PartNumber { get; set; }

 [Column("Name")]
    public string Description { get; set; }

 [Column("Amount")]
    public int Count { get; set; }

 [Column("Price", Format = "{0:c}")]
    public decimal ItemPrice { get; set; }
}

这是 Python 代码调用以生成报告的 C#方法:

public string GenerateDynamic(dynamic[] items)
{
    List<object> inventory =
        (from item in items
         select new InventoryItem
         (
             item.PartNumber,
             item.Description,
             item.Count,
             item.ItemPrice
         ))
        .ToList<object>();

    return Generate(inventory);
}

讨论

在 Recipe 5.9 中,场景是从 C#调用 Python。在这个问题中的场景相反,我有一个 Python 应用程序,并且需要能够生成报告。然而,报告生成器是用 C#编写的。报告库已经投入了大量工作,因此重写为 Python 没有意义。幸运的是,DLR 允许我们用 Python 调用那个 C#代码。

报告与 Recipe 5.2 中使用的相同,而且 C#代码具有相同的InventoryItem类。

小贴士

要设置这个,您可能需要安装pythonnet package

>pip install pythonnet

通过导入clrsys来设置 Python 代码,并调用sys.path.append来引用 C# DLL 所在的路径,然后调用clr.AddReference来添加对要使用的 C# DLL 的引用。

在 Python 中,每当您需要从框架或自定义程序集中使用.NET 类型时,请使用from Namespace import type语法,它大致相当于 C#的using声明。在 C#源代码中,命名空间是PythonToCS,代码使用它来导入ReportInventoryItem的引用。它还使用System命名空间来获取对Decimal类型的引用,该类型别名为 C#的decimal类型。

在 Python 中,每当您使用方括号[]时,您正在创建一个名为list的数据结构。它是一个具有 Python 语义的对象集合。在这个例子中,我们正在创建一个InventoryItem列表,并将其赋值给名为inventory的变量。

注意,在InventoryItem构造函数的最后一个参数itemPrice中我们使用了Decimal。Python 没有decimal的概念,会将该值作为float传递,这会导致错误,因为 C# 中的 InventoryItem 将该参数定义为decimal

接下来,Python 代码实例化了Reportrpt,并调用了GenerateDynamic,传递了inventory。这将在Report中调用GenerateDynamic,并自动将 Python 的list转换为 C# 的dynamic[]items。因为items中的每个对象都是dynamic,我们可以使用 LINQ 语句查询它,动态访问投影中每个对象的名称。

最后,GenerateDynamically调用Generate,应用程序返回一个报告,Python 代码打印该报告。

另请参见

Recipe 5.2,“使用反射访问类型成员”

Recipe 5.9,“从 C# 调用 Python 代码”

第六章:异步编程

过去,大多数人编写的代码都是同步的。诸如并发性、线程池和并行编程等问题曾是专业专家的领域,有时甚至这些专家也会出错。历史上的互联网论坛、UseNet 甚至书籍上都充满了警告,不要尝试多线程,除非你知道你在做什么,并且确实有强烈的需求。然而,现在情况已经改变。

2010 年,微软推出了任务并行库(TPL),大大简化了编写多线程代码。这与多线程/多核 CPU 架构的普及同时出现。TPL 的一个基本组件是Task类,它表示承诺在单独的线程上执行某些工作,并返回结果。有趣的是,PLINQ 也在同一时间框架内推出,它在第 4.10 节中有详细介绍。TPL 仍然是开发人员工具箱中处理内部 CPU 密集型多线程的重要组成部分。

基于 TPL 的Task概念,微软在 C# 4 中通过专门的语言语法引入了异步。虽然自 C# 1 以来我们已经有了异步编程,通过委托,但它更复杂且效率更低。在 C# 5 中,通过引入async/await关键字,异步编程变得简化,使得代码及其执行顺序非常类似于同步代码。除了简化之外,C#异步的一个主要用例是跨进程通信,与 TPL 擅长处理的内部 CPU 密集型工作形成对比。当进行跨进程操作时,考虑访问文件系统、进行数据库查询或调用 REST API。在幕后,异步管理这些操作的线程,使其不会阻塞,并提高应用程序的性能和可伸缩性。使用异步,我们可以以简单的方式推理逻辑,同时享受异步操作的好处和复杂性。

自其推出以来,微软不断通过语言特性和.NET Framework 库改进异步。本章介绍了这些新功能,如异步Main方法、新的ValueTask类型、异步迭代器和异步处理。还有一些异步的原始能力值得特别关注,例如编写安全的异步库、管理并发的异步任务、取消和进度报告。

本章的主题是结账,客户在购物车中有产品,他们已经开始结账流程,代码需要处理每个结账请求。我们将从控制台应用程序中正确使用异步开始。

6.1 创建异步控制台应用程序

问题

在控制台应用程序中需要使用一个库,但它只有异步 API。

解决方案

该课程有异步方法:

public class CheckoutService
{
    public async Task<string> StartAsync()
    {
        await ValidateAddressAsync();
        await ValidateCreditAsync();
        await GetShoppingCartAsync();
        await FinalizeCheckoutAsync();

        return "Checkout Complete";
    }

    async Task ValidateAddressAsync()
    {
        // perform address validation
    }

    async Task ValidateCreditAsync()
    {
        // ensure credit is good
    }

    async Task GetShoppingCartAsync()
    {
        // get contents of shopping cart
    }

    async Task FinalizeCheckoutAsync()
    {
        // complete checkout transaction
    }
}

下面是编写异步控制台应用程序的旧方法:

class Program
{
    static void Main(string[] args)
    {
        var checkoutSvc = new CheckoutService();
        string result = string.Empty;

        Task<string> startedTask = checkoutSvc.StartAsync();
        startedTask.Wait();
        result = startedTask.Result;

        Console.WriteLine($"Result: {result}");
    }
}

下面是编写异步控制台应用程序的推荐方法:

static async Task Main()
{
    var checkoutSvc = new CheckoutService();

    string result = await checkoutSvc.StartAsync();

    Console.WriteLine($"Result: {result}");
}

讨论

初次引入时,异步几乎随处可见并且立即有用。然而,在某些边缘情况下,比如 Main 方法以及 catchfinally 块,不能使用异步。幸运的是,Microsoft 在 C# 7.1 中修复了这个问题,并在 .NET Framework 的其他部分增加了更多支持,例如 ASP.NET MVC 中的异步 ActionResult。Recipe 6.3 展示了异步迭代器如何解决另一个异步问题。

本节描述的一个显著的异步增强是异步 Main。问题在于,就像解决方案中的 CheckoutService 类一样,许多 .NET Framework 类型和第三方库都是为异步编写的。然而,没有异步 Main,开发人员必须编写问题代码。为了演示问题,解决方案包括 Main 方法的两个版本:旧的同步方式和新的异步方法。

在旧的同步技术中,开发人员被迫使用 Wait()Result,这是典型的异步反模式,因为会导致线程阻塞、潜在的线程死锁和竞争条件。Recipe 6.4 解释了一种情况,如果编写这样的代码可能会导致死锁(以及如何避免)。这些都是异步方法返回的 Task 类型的成员。不幸的是,在第一次异步迭代中,如果想要编写命令行实用程序、文本应用程序或演示应用程序,这是唯一的选择。

解决方案中的第二个 Main 显示了新的语法,包括 async 修饰符和 Task 返回类型。我们只需 await 调用 checkoutSvc.StartAsync(),代码就可以正常工作。

提示

如你所知,Main 可以返回 voidint。这个使用 Task 的示例是为了 void 返回而设计的。你可以将其改为 Task<int> 以返回 int

本质上,Microsoft 没有推荐一种安全的方式从同步代码调用异步代码。因此,这是一个受欢迎的补充,大大简化了编写调用异步代码的控制台应用程序的过程。还要注意,从 MainCheckoutService.StartAsync 再到其他 CheckoutService 方法的整个调用链都是异步的。理想情况下,整个调用链都应该是异步的,但偶尔你会有一个异步方法只调用同步方法;你可以在 Recipe 6.6 中了解更多相关信息。

参见

Recipe 6.3, “创建异步迭代器”

Recipe 6.4, “编写安全的异步库”

Recipe 6.6, “从异步代码调用同步代码”

6.2 减少异步返回值的内存分配

问题

你希望减少异步代码的内存消耗。

解决方案

这是在异步方法中使用 ValueTask 而不是 Task 的方法:

public class CheckoutService
{
    public async ValueTask<string> StartAsync()
    {
        await ValidateAddressAsync();
        await ValidateCreditAsync();
        await GetShoppingCartAsync();
        await FinalizeCheckoutAsync();

        return "Checkout Complete";
    }

    async ValueTask ValidateAddressAsync()
    {
        // perform address validation
    }

    async ValueTask ValidateCreditAsync()
    {
        // ensure credit is good
    }

    async ValueTask GetShoppingCartAsync()
    {
        // get contents of shopping cart
    }

    async ValueTask FinalizeCheckoutAsync()
    {
        // complete checkout transaction
    }
}

这是消耗该类的应用程序:

class Program
{
    static async Task Main()
    {
        var checkoutSvc = new CheckoutService();

        string result = await checkoutSvc.StartAsync();

        Console.WriteLine($"Result: {result}");
    }
}

讨论

自从异步开始以来,我们一直通过 TaskTask<T> 返回类型。这种方式一直有效,并且将来也会继续适用于任何异步代码。然而,随着时间的推移,人们发现了特定情况,这些情况打开了关于 Task 是引用类型以及运行时如何缓存 Tasks 的新性能机会。

Task 类根据定义是一个引用类型。这意味着每次异步方法返回 Task 时,运行时都会分配堆内存。正如你所知,值类型在定义它们的地方分配内存,但它们不会引起垃圾收集器的开销。

或许不太明显,Tasks 的另一个特性是运行时会对其进行缓存。而不是使用 await 方法,可以直接引用从 async 方法返回的 Task。有了这个 Task 的引用,你可以对多个任务进行并发调用。你也可以多次调用该任务。这里的重点是运行时已经缓存了任务,导致了更多的内存使用。

如前所述,在普通编码中,Task 的使用是正常的,也许你并不在意。然而,考虑到高性能场景,会分配大量的 Task 对象,你可能有兴趣找到提升性能和可扩展性的方法。该解决方案模拟了一个可能会关注这个问题的概念。想象一下,一个企业每天需要处理大量的购物车结账操作。在这种情况下,消除任何对象分配、垃圾收集和内存压力可能都是有益的。

为了解决这些问题,Microsoft 添加了对 ValueTask(以及 ValueTask<T>)作为异步返回类型的支持。顾名思义,ValueTask 是一个值类型。因为它是一个值类型,在这种情况下,它只会在栈上分配内存。根据值类型的定义,它并不会有独立的堆分配或者为了这个值而进行的垃圾收集。

此外,运行时不会缓存 ValueType,这导致了更少的内存分配和缓存管理。这在高性能/可扩展性场景中非常有效。解决方案中的 CheckoutService 展示了如何使用 ValueTask:只需在 Task 的位置使用它。这里的假设是代码总是会 await 方法,而不会尝试重用 ValueTask。在解决方案中,确实是这样。

注意

如果你正在为其他开发人员编写可重用的库,请考虑 ValueTask 是否合适。通过使用 ValueTask,你消除了消费代码执行并发任务操作或其他高级场景的能力,这种情况下 Task 提供了最大的灵活性。

就像大多数事物一样,存在权衡。运行时Task缓存对于ValueTask不再适用的所有场景都不再是选项。使用ValueTask时,不能将操作组合或在第一次之后重用ValueTask。菜谱 6.7 和 6.8 展示了ValueTask无法使用的几种情况。

总结一下,当性能和可扩展性成为问题时,请使用ValueTask,在其他时间则可以自由使用Task

参见

菜谱 6.7,“等待并行任务完成”

菜谱 6.8,“处理并行任务完成时的情况”

6.3 创建异步迭代器

问题

你正在处理异步代码,而经典的同步迭代器将不起作用。

解决方案

这是结帐过程的数据:

public class CheckoutRequest
{
    public Guid ShoppingCartID { get; set; }

    public string Name { get; set; }

    public string Card { get; set; }

    public string Address { get; set; }
}

这是每个请求的结帐过程:

public class CheckoutService
{
    public async ValueTask<string> StartAsync(CheckoutRequest request)
    {
        return
            $"Checkout Complete for Shopping " +
            $"Basket: {request.ShoppingCartID}";
    }
}

异步迭代器处理每个请求:

public class CheckoutStream
{
    public async IAsyncEnumerable<CheckoutRequest> GetRequestsAsync()
    {
        while (true)
        {
            IEnumerable<CheckoutRequest> requests =
                await GetNextBatchAsync();

            foreach (var request in requests)
                yield return request;

            await Task.Delay(1000);
        }
    }

    async Task<IEnumerable<CheckoutRequest>> GetNextBatchAsync()
    {
        return new List<CheckoutRequest>
        {
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "123 4th St",
                Card = "1234 5678 9012 3456",
                Name = "First Card Name"
            },
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "789 1st Ave",
                Card = "2345 6789 0123 4567",
                Name = "Second Card Name"
            },
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "123 4th St",
                Card = "1234 5678 9012 3456",
                Name = "First Card Name"
            },
        };
    }
}

最后,应用程序消耗迭代器来处理每个请求:

static async Task Main()
{
    var checkoutSvc = new CheckoutService();
    var checkoutStrm = new CheckoutStream();

    await foreach (var request in checkoutStrm.GetRequestsAsync())
    {
        string result = await checkoutSvc.StartAsync(request);

        Console.WriteLine($"Result: {result}");
    }
}

讨论

虽然迭代器对于像List<T>这样的.NET Framework 集合或者你自己编写的自定义集合至关重要,但它们也可以是有用的抽象,隐藏复杂的数据获取逻辑。该解决方案演示了一个相关的场景,迭代器可能会有用的地方——处理一个CheckoutRequests流,就像它是一个集合一样。

解决方案的一个重要方面是,将太多的Check​ou⁠t​Request实例保存在内存中是不切实际的。如果一个系统不断接收订单,它需要进行扩展。在这个解决方案中,我们设想了一个轮询实现,持续获取下一批CheckoutRequests。这样可以减少内存压力,并且迭代器提供了一个抽象,隐藏了程序接收订单的复杂细节。

在异步的早期阶段,执行像这样的任务会更加复杂,因为轮询是异步的,会发出一个进程外的请求。显然可以找到一个让这种情况同步发生的库,但这忽略了异步的好处。该解决方案通过IAsyncEnumerable提供了一个新的异步流接口来解决这个问题。

CheckoutStream类有一个名为GetRequestsAsync的迭代器,返回IAsyncEnumerable<CheckoutRequest>。这相当于同步迭代器的异步版本IEnumerable<T>。虽然在此演示中,while循环永远继续下去,你需要手动停止应用程序,菜谱 6.9 展示了如何优雅地取消该过程。此迭代器获取一个新的CheckoutRequests批次,生成批次中的每个项目,并在获取下一批之前休眠一秒钟。为了演示目的,休眠使用了Task.Delay

注意

yield 关键字是语法糖,用于将类型成员转换为迭代器。包括 IAsync​Enumer⁠able​<T> 在内的 IEnumerable<T> 类型具有 MoveNextCurrent 成员,其中 MoveNextCurrent 加载为它读取的下一个值。在幕后,当 C# 编译器看到一个迭代器时,它会生成一个新的类,其中包含 MoveNextCurrent 成员。当在 GetRequestsAsync 中使用 yield return request 等方式时,C# 编译器实例化该新类,调用 MoveNext 并返回 Current

GetNextBatchAsync 方法仅返回 CheckoutRequests 列表。但是,请想象这实际上是一个异步调用到一个准备好下一组 CheckoutRequest 实例的网络端点、队列或服务总线。食谱 1.9、3.7 和 3.9 展示了在执行此操作时您关心的一些问题。通过将所有这些复杂性移入迭代器,应用代码可以以更简单的方式消耗数据。

Main 方法展示了如何消耗异步迭代器。首先要注意的是 foreach 循环上的 async 修饰符。这是 C# 中异步流的一个新补充。正如您所见,它允许 foreachIAsyncEnumerable<T> 迭代器一起工作。

参见

第 1.9 节,“设计自定义异常”

第 3.7 节,“重新抛出异常”

第 3.9 节,“构建弹性网络连接”

第 6.9 节,“取消异步操作”

6.4 编写安全的异步库

问题

您的异步代码与 UI 线程引起死锁。

解决方案

此类将代码从 UI 线程上移:

public class CheckoutService
{
    public async Task<string> StartAsync()
    {
        await ValidateAddressAsync().ConfigureAwait(false);
        await ValidateCreditAsync().ConfigureAwait(false);
        await GetShoppingCartAsync().ConfigureAwait(false);
        await FinalizeCheckoutAsync().ConfigureAwait(false);

        return "Checkout Complete";
    }

    async Task ValidateAddressAsync()
    {
        // perform address validation
    }

    async Task ValidateCreditAsync()
    {
        // ensure credit is good
    }

    async Task GetShoppingCartAsync()
    {
        // get contents of shopping cart
    }

    async Task FinalizeCheckoutAsync()
    {
        // complete checkout transaction
    }
}

下面是调用它的程序:

static async Task Main()
{
    var checkoutSvc = new CheckoutService();

    string result = await checkoutSvc.StartAsync();

    Console.WriteLine($"Result: {result}");
}

讨论

UI 技术(如 Windows Forms、Windows Presentation Foundation(WPF)和 WinUI)运行在单线程上 —— UI 线程上。这简化了开发人员在处理 UI 代码时需要做的工作。但是,如果您使用异步或编写多线程逻辑,事情很容易出错。特别是,如果另一个线程尝试对 UI 进行任何操作或在 UI 线程的相同逻辑中运行,您就有可能发生竞态条件和死锁。要了解问题有多糟糕,请考虑您的应用程序通常在开发、QA 和生产环境中运行良好。然后,毫无征兆地,UI 卡住了,客户开始抱怨,而您无法重现问题。

注意

在某些情况下,根据您使用的 UI 和 .NET 版本,当从非 UI 线程访问 UI 时可能会出现以下异常:

System.InvalidOperationException:
     'The calling thread cannot access this object
     because a different thread owns it.'

这很好,因为至少您知道存在问题。

Recipe 6.1 解释了如何通过调用Wait或在Task上分配Result可能会导致死锁。问题出在WaitResult会阻塞 UI 线程,等待响应。异步调用的代码执行完毕并返回,试图在同一个线程上运行。然而,正如刚才提到的,UI 线程被阻塞,导致死锁。

解决方案在CheckoutService.StartAsync方法中修复了这个问题。请注意它如何调用ConfigureAwait(false)——这段代码与 Recipe 6.1 中的解决方案唯一的区别就是这样做可以将执行从调用线程(UI 线程)迁移到新线程上。现在,当线程从异步调用返回时,它不会导致死锁。

注意

当等待一个Task时,默认条件是ConfigureAwait(true)。只有在实践工程之外的高级场景中才需要更改此默认设置。如果在代码中看到它,可能需要质疑为什么会有这种需求。

这里需要强调的一个重点是,问题陈述明确提到libraries。在编写库时,希望代码能够独立于调用方运行。因此,库代码必须独立于调用方,并且不知道调用方是谁。这就是 Recipe 1.5 中所述的例子,分离关注点非常重要。如果库代码不涉及 UI 操作(它绝对不应该涉及),就能避免线程问题,如竞态条件和死锁。

如果一个await使用了ConfigureAwait(false),那么该方法中的所有await也应该使用。原因是有些方法执行速度非常快,会同步执行,并且ConfigureAwait(false)不会调度线程。如果另一个await异步运行但没有使用ConfigureAwait(false),你会遇到与未调用ConfigureAwait(false)时相同的线程问题。

警告

Visual Studio 分析器在所有缺少ConfigureAwait(false)的非 UI 代码上设置警告。可能会觉得增加这些配置很麻烦,但你仍然应该这样做。即使你认为方法的第一个await保证会异步执行,逻辑在维护过程中可能会发生变化,而你可能会无意中引发线程问题。最安全的方法是保持分析器启用并遵循推荐。

ConfigureAwait(false)的另一个好处是它稍微提高了效率。默认的ConfigureAwait(true)会为设置回调而产生开销,该回调会将已完成的线程调度到 UI 线程上。而ConfigureAwait(false)则避免了这种情况。

关于在库代码中使用 ConfigureAwait(false) 适当性的问题,有时您不希望使用它。特别是在事件处理程序中,特别是在 UI 代码中,您不希望调用 ConfigureAwait(false)。想一想事件处理程序及其功能。它是响应某些用户操作(如按钮点击)而调用的,它设置状态、更新等待指示器、禁用用户不应与之交互的控件,发起调用,然后重置 UI。所有这些工作都在 UI 线程上进行,正如它应该的那样。在这种情况下,您不希望使用 ConfigureAwait(false) 将其调度到 UI 线程之外,因为这将导致多线程 UI 问题。

虽然库代码不应该知道 UI 的存在,但有时代码应该传达进度或状态。与其直接访问 UI 代码,还有另一种通信状态的方式,如下一节讨论的内容。

另请参阅

食谱 1.5,“设计应用程序层”

食谱 6.5,“异步更新进度”

6.5 异步更新进度

问题

您需要在不阻塞 UI 线程的情况下显示来自异步任务的状态。

解决方案

此类包含进度状态信息:

public class CheckoutRequestProgress
{
    public int Total { get; set; }

    public string Message { get; set; }
}

此方法报告进度:

public async IAsyncEnumerable<CheckoutRequest>
    GetRequestsAsync(IProgress<CheckoutRequestProgress> progress)
{
    int total = 0;

    while (true)
    {
        List<CheckoutRequest> requests =
            await GetNextBatchAsync().ConfigureAwait(false);

        total += requests.Count;

        foreach (var request in requests)
            yield return request;

        progress.Report(
            new CheckoutRequestProgress
            {
                Total = total,
                Message = "New Batch of Checkout Requests"
            });

        await Task.Delay(1000).ConfigureAwait(false);
    }
}

下面是初始化和消耗进度更新的程序:

static async Task Main()
{
    var checkoutSvc = new CheckoutService();
    var checkoutStrm = new CheckoutStream();

    IProgress<CheckoutRequestProgress> progress =
        new Progress<CheckoutRequestProgress>(p =>
        {
            Console.WriteLine(
                $"\n" +
                $"Total: {p.Total}, " +
                $"{p.Message}" +
                $"\n");
        });

    await foreach (var request in
        checkoutStrm.GetRequestsAsync(progress))
    {
        string result = await checkoutSvc.StartAsync(request);

        Console.WriteLine($"Result: {result}");
    }
}

讨论

如 食谱 6.4 所述,库代码永远不应直接更新 UI。如果正确编写,它将在单独的线程上运行,并且对其调用者毫不知情。尽管如此,业务层或库代码可能希望通知调用者进度或状态。解决方案展示了一个场景,其中迭代器使用 CheckoutRequestProgress 类定义的进度信息更新 UI。基本上,库代码定义了它提供的进度信息类型,调用代码与之配合使用。在这种情况下,它是处理的订单总数和某些表示状态的消息。

GetRequestAsync 方法接受一个名为 progressIProgress<CheckoutRequestProgress> 参数。IProgress<T> 是 .NET Framework 的一部分,Progress<T> 类实现了 IProgress<T> 接口。使用 progress 实例,GetRequestsAsync 调用 Report 方法,并传递一个已填充属性的 CheckoutRequestProgress 实例。这将进度发送到 UI 中的处理程序。

Main 方法通过实例化 Progress<CheckoutRequest​Pro⁠gress> 并将其分配给 progress,一个 IProgress<CheckoutRequestProgress>,设置了报告。Progress<T> 构造函数接受一个 Action 委托,并且 Main 分配了一个写入控制台进度的 lambda。每当 GetRequestsAsync 调用 Report 方法时,此 lambda 就会执行。回到起点,Mainprogress 作为参数传递给 Get​Re⁠questsAsync 调用,以便它可以引用同一个对象进行报告。

你可能已经注意到GetRequestAsync在异步运行,并且GetNextBatchAsyncTask.Delay上的await也调用了ConfigureAwait(false)。如果该代码在除了 UI 线程之外的其他线程上运行,死锁的可能性是多少?没有,因为Progress<T>将回调调度到 UI 线程,所以代码可以安全地与 UI 进行交互。请记住,库代码GetRequest​Async对于Process<T>构造函数的Action参数的 lambda 参数没有任何了解。这意味着 lambda 可以安全地访问任何需要显示进度的 UI 代码。

参见

第 6.4 节,“编写安全的异步库”

6.6 调用同步代码从异步代码

问题

您的异步方法中唯一的代码是同步的,并且您希望以异步方式await它。

解决方案

这节课展示了如何从同步逻辑中返回异步结果:

public class CheckoutService
{
    public async Task<string> StartAsync()
    {
        await ValidateAddressAsync().ConfigureAwait(false);
        await ValidateCreditAsync().ConfigureAwait(false);
        await GetShoppingCartAsync().ConfigureAwait(false);
        await FinalizeCheckoutAsync().ConfigureAwait(false);

        return "Checkout Complete";
    }

    async Task<bool> ValidateAddressAsync()
    {
        bool result = true;
        return await Task.FromResult(result);
    }

    async Task<bool> ValidateCreditAsync()
    {
        bool result = true;
        return await Task.FromResult(result);
    }

    async Task<bool> GetShoppingCartAsync()
    {
        bool result = true;
        return await Task.FromResult(result);
    }

    async Task FinalizeCheckoutAsync()
    {
        await Task.CompletedTask;
    }
}

这是运行应用程序的代码:

static async Task Main()
{
    var checkoutSvc = new CheckoutService();

    string result = await checkoutSvc.StartAsync();

    Console.WriteLine($"Result: {result}");
}

讨论

为了简化,本章的前几节从异步代码中调用同步代码。你可能已经注意到,Visual Studio(与其他 IDE 相同)在异步方法没有任何await时会显示绿色波浪线。您还会收到以下警告:

CS1998: This async method lacks 'await' operators
and will run synchronously.
Consider using the 'await' operator to await non-blocking API calls,
or 'await Task.Run(...)' to do CPU-bound work on a background thread.

编译器发出这个警告是件好事,因为这可能是个错误。有可能你忘记了在异步方法调用上添加await修饰符。在这种情况下,程序不会在等待的方法处停止执行。异步方法和调用它的代码都会运行。如果没有等待的异步方法可能不会在程序退出时完成。

另一个问题是,如果未等待的异步方法抛出异常,那么它将无法捕获,因为调用代码继续运行。使用async void方法时也会遇到类似的问题,因为你无法await它们,也没有办法捕获异常。

警告

本章中的几处地方描述了与异步代码相关的编译器警告。在许多情况下,这些警告代表错误条件。我经常遇到应用程序有着无法管理的警告墙。好像开发人员以某种方式不认为警告是个问题或者没有在意。了解忘记await异步方法或未添加ConfigureAwait(false)的严重后果,正如第 6.4 节所述,可能会促使您优先清理和维护警告。

有时异步方法内部的代码确实是同步的。它原本可能是异步的,但在维护时被修改了,或者你必须实现一个接口。在这种情况下,你有几种方法。一种方法是在调用链中删除async/await关键字,直到达到需要异步的更高级别方法。如果有多个调用者等待该方法,或者它是多个应用程序的公共接口的一部分,你可能不想立即进行重构。另一种方法,在解决方案中演示的是await Task.FromResult<T>

你可以在CheckoutService中的StartAsync方法中看到它是如何工作的,其中每个方法返回等待Task.FromResult<T>的结果。Task.FromResult<T>方法是泛型的,因此可以用于任何类型。

当方法需要返回一个值时,await Task.FromResult<T>是有效的。然而,FinalizeTaskAsync方法只返回Task。请注意,该方法只是简单地等待Task.CompletedTask

你可能会认为这是多余的工作,只是为了消除一个警告。虽然这是事实,但考虑一下其好处。你确实消除了警告,并且享受到了保持警告墙修剪带来的生产力提升。更重要的是,代码明确表达了其意图,进行维护的开发人员清楚地看到没有由于缺少await而导致的错误——代码是正确的。

参见

Recipe 6.4, “编写安全的异步库”

6.7 等待并行任务完成

问题

你有多个任务,同时运行,并且需要等待它们全部完成后才能继续。

解决方案

这段代码运行并行任务:

public class CheckoutService
{
    class WhenAllResult
    {
        public bool IsValidAddress { get; set; }
        public bool IsValidCredit { get; set; }
        public bool HasShoppingCart { get; set; }
    }

    public async Task<string> StartAsync()
    {
        var checkoutTasks =
            new List<Task<(string, bool)>>
            {
                ValidateAddressAsync(),
                ValidateCreditAsync(),
                GetShoppingCartAsync()
            };

        Task<(string method, bool result)[]> allTasks =
            Task.WhenAll(checkoutTasks);

        if (allTasks.IsCompletedSuccessfully)
        {
            WhenAllResult whenAllResult = GetResultsAsync(allTasks);

            await FinalizeCheckoutAsync(whenAllResult);

            return "Checkout Complete";
        }
        else
        {
            throw allTasks.Exception;
        }
    }

    WhenAllResult GetResultsAsync(
        Task<(string method, bool result)[]> allTasks)
    {
        var whenAllResult = new WhenAllResult();

        foreach (var (method, result) in allTasks.Result)
            switch (method)
            {
                case nameof(ValidateAddressAsync):
                    whenAllResult.IsValidAddress = result;
                    break;
                case nameof(ValidateCreditAsync):
                    whenAllResult.IsValidCredit = result;
                    break;
                case nameof(GetShoppingCartAsync):
                    whenAllResult.HasShoppingCart = result;
                    break;
            }

        return whenAllResult;
    }

    async Task<(string, bool)> ValidateAddressAsync()
    {
        //throw new ArgumentException("Testing!");

        return await Task.FromResult(
            (nameof(ValidateAddressAsync), true));
    }

    async Task<(string, bool)> ValidateCreditAsync()
    {
        return await Task.FromResult(
            (nameof(ValidateCreditAsync), true));
    }

    async Task<(string, bool)> GetShoppingCartAsync()
    {
        return await Task.FromResult(
            (nameof(GetShoppingCartAsync), true));
    }

    async Task<bool> FinalizeCheckoutAsync(WhenAllResult result)
    {
        Console.WriteLine(
            $"{nameof(WhenAllResult.IsValidAddress)}: " +
            $"{result.IsValidAddress}");
        Console.WriteLine(
            $"{nameof(WhenAllResult.IsValidCredit)}: " +
            $"{result.IsValidCredit}");
        Console.WriteLine(
            $"{nameof(WhenAllResult.HasShoppingCart)}: " +
            $"{result.HasShoppingCart}");

        bool success = true;
        return await Task.FromResult(success);
    }
}

这里是请求和处理并行任务结果的应用程序:

static async Task Main()
{
    try
    {
        var checkoutSvc = new CheckoutService();

        string result = await checkoutSvc.StartAsync();

        Console.WriteLine($"Result: {result}");
    }
    catch (AggregateException aEx)
    {
        foreach (var ex in aEx.InnerExceptions)
            Console.WriteLine($"Unable to complete: {ex}");
    }
}

讨论

在执行如购物车结账等操作时,你不希望用户等待应用程序太长时间才返回。依次运行太多操作可能会增加等待时间。改善用户体验的一种方法是并发地运行独立操作。

在解决方案中,CheckoutService有四个不同的异步服务。在这里我们假设其中三个操作,ValidateAddressAsyncValidateCreditAsyncGetShoppingCartAsync,彼此之间没有任何依赖关系。这使它们成为同时运行的良好候选者。

StartAsync方法通过创建一个List<Task>来实现这一点。如果你回忆起来,等待一个方法实际上就是在返回的Task上进行await。没有await,每个方法都会返回一个Task,但其逻辑直到等待该任务后才会运行。

Task类有一个WhenAll方法,其目的是并发地运行由checkoutTasks参数指定的所有任务。WhenAll会等待所有Tasks完成后才返回。

等待具有返回类型的单个方法,从角度来看很简单,因为你可以将返回值分配给单个变量。然而,在并行运行任务时,你需要关联响应,因为WhenAll同时返回所有任务。假设哪些任务发生在集合的哪个位置可能会出现错误,并且在维护时可能会很繁琐。代码需要知道哪个响应对应哪个Task

解决方案通过元组实现,其中string是方法的名称,bool是响应。该元组及其内容的选择是为了这个演示而特定的,你可以根据你的应用程序适当调整任务类型。这让我们知道哪个任务对应哪个结果。GetResultsAsync方法通过迭代任务数组,并根据每个响应的方法参数构建WhenAllResult来实现这一点。

注意,ValidateAddressAsync的第一行是一个被注释的语句,抛出了一个ArgumentException。取消注释并重新运行应用程序会在调用WhenAll时导致异常。Main方法通过对AggregateExceptioncatch处理该异常。由于所有任务都在并行运行,其中一个或多个任务可能会抛出异常。AggregateException收集这些异常。通常情况下,你可以在InnerException属性中查找异常详细信息。然而,AggregateException还有另一个属性,即InnerExceptions。它们之间的区别在于AggregateException的属性是复数形式,这是有意为之的。为了正确调试,你可以在InnerExceptions属性中找到所有异常。

参见

Recipe 6.8, “处理并行任务的完成情况”

6.8 处理并行任务的完成情况

问题

你以为调用Task.WhenAny会高效利用资源来处理任务完成时的结果,但实际上成本和性能都很糟糕。

解决方案

这是一个顺序实现,用于调用多个任务:

public async Task<string> StartBigONAsync()
{
    (_, bool addressResult) = await ValidateAddressAsync();
    (_, bool creditResult) = await ValidateCreditAsync();
    (_, bool cartResult) = await GetShoppingCartAsync();

    await FinalizeCheckoutAsync(
        new AllTasksResult
        {
            IsValidAddress = addressResult,
            IsValidCredit = creditResult,
            HasShoppingCart = cartResult
        });

    return "Checkout Complete";
}

这是一个并行实现,用于调用多个任务:

public async Task<string> StartBigO1Async()
{
    var checkoutTasks =
        new List<Task<(string, bool)>>
        {
            ValidateAddressAsync(),
            ValidateCreditAsync(),
            GetShoppingCartAsync()
        };

    Task<(string method, bool result)[]> allTasks =
        Task.WhenAll(checkoutTasks);

    if (allTasks.IsCompletedSuccessfully)
    {
        AllTasksResult allResult = GetResults(allTasks);

        await FinalizeCheckoutAsync(allResult);

        return "Checkout Complete";
    }
    else
    {
        throw allTasks.Exception;
    }
}

下一个实现将任务并行处理,但在每个任务返回时处理它们:

public async Task<string> StartBigONSquaredAsync()
{
    var checkoutTasks =
        new List<Task<(string, bool)>>
        {
            ValidateAddressAsync(),
            ValidateCreditAsync(),
            GetShoppingCartAsync()
        };

    var allResult = new AllTasksResult();

    while (checkoutTasks.Any())
    {
        Task<(string, bool)> task = await Task.WhenAny(checkoutTasks);
        checkoutTasks.Remove(task);

        GetResult(task, allResult);
    }

    await FinalizeCheckoutAsync(allResult);

    return "Checkout Complete";
}

该方法展示了如何获取首个完成的任务:

async Task<(string method, bool result)> ValidateCreditAsync()
{
    var checkoutTasks =
        new List<Task<(string, bool)>>
        {
            CheckInternalCreditAsync(),
            CheckAgency1CreditAsync(),
            CheckAgency2CreditAsync()
        };

    Task<(string, bool)> task = await Task.WhenAny(checkoutTasks);

    (_, bool result) = task.Result;

    return await Task.FromResult(
        (nameof(ValidateCreditAsync), result));
}

Main方法提供了选择从哪个方法开始:

static async Task Main()
{
    try
    {
        var checkoutSvc = new CheckoutService();

        string result = await checkoutSvc.StartBigO1Async();
        //string result = await checkoutSvc.StartBigONAsync();
        //string result = await checkoutSvc.StartBigONSquaredAsync();

        Console.WriteLine($"Result: {result}");
    }
    catch (AggregateException aEx)
    {
        foreach (var ex in aEx.InnerExceptions)
            Console.WriteLine($"Unable to complete: {ex}");
    }
}

讨论

本节问题探讨了Task.WhenAny的作用。如果你尝试使用Task.WhenAny来处理任务返回时的处理,可能会感到意外,因为它的工作方式不符合你的预期。

在大部分情况下,这个解决方案的概念和组织方式与 Recipe 6.7 类似——不同之处在于,这个解决方案展示了运行任务的不同方法,并解释了你需要知道的以做出适当的设计决策。

StartBigONAsync 方法的运行方式类似本章前面顺序运行的部分。其性能为 O(N),因为它依次处理 N 个任务。

Recipe 6.7 展示了如何在任务之间不互相依赖时加快程序执行。它使用了 Task.WhenAll,在 StartBigO1Async 中展示。性能提升来自其近似 O(1) 的性能——不需要执行 N 次操作,只执行 1 次。更准确地说,这是 O(2),因为 FinalizeCheckout​A⁠sync 在其他三个任务完成后运行。

除了 Task.WhenAll,你还可以使用 Task.WhenAny。可能自然而然地认为 Task.WhenAny 是并行运行多个任务并能在其他任务运行时处理每个任务的好方法。然而,Task.WhenAny 并不像你想象的那样工作。看看 StartBigONSquaredAsync 并按照以下逻辑操作:

  1. while 循环在 checkoutTasks 仍有内容时迭代。

  2. Task.WhenAny 启动所有任务并行运行。

  3. 最快的任务返回。

  4. 自从该任务返回后,从 checkoutTasks 中移除它,以免再次运行。

  5. 收集该任务的结果。

  6. 再次在剩余任务上执行循环,或在 checkoutTasks 为空时停止。

在该算法中的第一个令人惊讶的心理障碍是错误地认为后续循环操作相同的任务,每个任务完成后返回。实际情况是每个后续循环都会启动一组全新的任务。这就是异步工作的方式——你可以多次 await 一个任务,但每个 await 都会启动一个新的任务。这意味着代码在每次循环时都会连续启动剩余任务的新实例。这种循环模式与 Task.WhenAny,不像 Task.WhenAll 那样,不会产生你可能预期的 O(1) 性能,而是 O(N²)。尽管此解决方案只有三个任务,但想象一下任务列表增长时性能会如何逐渐下降。

注意

本章讨论了使用大 O 表示法的性能问题。特别是在查看 O(N²) 的算法时,存在一种过多操作会破坏性能的阈值。Recipe 3.10 展示了如何测量应用程序的性能,并根据你的性能要求找到该阈值。

另外,考虑在一段时间内对检查操作次数进行乘法运算,这些任务的数量会增加。你的应用性能不仅会变差,还可能通过过多的网络流量和端点服务器处理来减慢服务器。这不仅可能影响你自己的系统,还可能影响同时运行的其他系统。此外,考虑到网络请求可能是云服务在消耗计划上的情况,这可能会非常昂贵。在这种特定的用例中,除非使用较少的任务并且影响最小,否则可能被视为反模式。

警告

在互联网上,您会找到解释Task.WhenAny 作为一种在并行运行任务并在完成时处理每个任务的技术的文章。虽然这对于一些任务可能有效,但本节讲述了在该用例中使用Task.WhenAny 的危险性。

话虽如此,Task.WhenAny 在某些情况下非常有效——第一个任务获胜。在解决方案中,ValidateCreditAsync 方法展示了这种策略。场景是您有多个源来判断客户是否有良好的信用,而且来自任何一个源的响应都是可靠的。每个服务具有不同的性能特征,您只关心返回最快的那个。您可以丢弃其余的响应。这保持了 O(1)的性能。

ValidateCreditAsync 包含要运行的任务列表。Task.WhenAny 并行运行这些任务,并返回第一个完成的任务。代码处理该任务并返回。

本解决方案的副作用是除了返回的第一个任务之外,其他任务仍在继续运行。但是,您无法访问它们,因为只返回一个任务。对于这种情况,您并不关心这些任务,但应停止它们以避免使用比必要更多的资源。您可以在下一节关于取消任务的部分中了解如何做到这一点。

参见

3.10 章节,“性能测量”的配方

6.7 章节,“等待并行任务完成”的配方

6.9 章节,“取消异步操作”

6.9 取消异步操作

问题

您正在进行异步过程,并需要停止它。

解决方案

该类演示了取消任务的多种方法:

public class CheckoutStream
{
    CancellationToken cancelToken;

    public CheckoutStream(CancellationToken cancelToken)
    {
        this.cancelToken = cancelToken;
    }

    public async IAsyncEnumerable<CheckoutRequest> GetRequestsAsync(
        IProgress<CheckoutRequestProgress> progress)
    {
        int total = 0;

        while (true)
        {
            var requests = new List<CheckoutRequest>();

            try
            {
                requests = await GetNextBatchAsync();
            }
            catch (OperationCanceledException)
            {
                break;
            }

            total += requests.Count;

            foreach (var request in requests)
            {
                if (cancelToken.IsCancellationRequested)
                    break;

                yield return request;
            }

            progress.Report(
                new CheckoutRequestProgress
                {
                    Total = total,
                    Message = "New Batch of Checkout Requests"
                });

            if (cancelToken.IsCancellationRequested)
                break;

            await Task.Delay(1000);
        }

        if (cancelToken.IsCancellationRequested)
            progress.Report(
                new CheckoutRequestProgress
                {
                    Total = total,
                    Message = "Process Cancelled!"
                });
    }

    async Task<List<CheckoutRequest>> GetNextBatchAsync()
    {
        if (cancelToken.IsCancellationRequested)
            throw new OperationCanceledException();

        var requests = new List<CheckoutRequest>
        {
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "123 4th St",
                Card = "1234 5678 9012 3456",
                Name = "First Card Name"
            },
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "789 1st Ave",
                Card = "2345 6789 0123 4567",
                Name = "Second Card Name"
            },
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "123 4th St",
                Card = "1234 5678 9012 3456",
                Name = "First Card Name"
            },
        };

        return await Task.FromResult(requests);
    }
}

这是初始化取消并展示如何取消的应用程序:

static async Task Main()
{
    var cancelSource = new CancellationTokenSource();
    var checkoutStrm = new CheckoutStream(cancelSource.Token);
    var checkoutSvc = new CheckoutService();

    IProgress<CheckoutRequestProgress> progress =
        new Progress<CheckoutRequestProgress>(p =>
        {
            Console.WriteLine(
                $"\n" +
                $"Total: {p.Total}, " +
                $"{p.Message}" +
                $"\n");
        });

    int count = 1;

    await foreach (var request in
        checkoutStrm.GetRequestsAsync(progress))
    {
        string result = await checkoutSvc.StartAsync(request);

        Console.WriteLine($"Result: {result}");

        if (count++ >= 10)
            break;

        if (count >= 5)
            cancelSource.Cancel();
    }
}

讨论

6.3 章节具有一个永不结束的while 循环异步迭代器。这在演示中起作用,但实际应用程序通常需要一种停止长时间运行过程的方式。想象一下弹出具有正在进行的过程状态并提供取消按钮的对话框,允许您停止操作。任务取消自 TPL 引入以来一直存在,并且在取消异步操作中也是至关重要的。

在解决方案中,Main 方法展示了如何初始化取消。Cancel​la⁠tionTokenSourcecancelSource,提供了取消令牌和取消控制。请看CheckoutStream 构造函数的参数是通过cancelSourceToken 属性设置的Cancellation​To⁠ken

因为 cancelSource 可以管理其范围内所有代码的取消,所以可以将 CancellationToken 作为参数传递给任何具有 CancellationToken 参数的构造函数或方法,允许您从单个位置 cancelSource 取消任何操作。解决方案没有按钮,并在处理了 10 个 CheckoutRequests 后取消。您可以看到 count 变量在每个循环中增加,检查请求的数量,并在 10 之后中断循环。由于对 count >= 5 的检查,程序永远不会达到 10,因此调用 cancelSource.Cancel()

cancelSource.Cancel 的调用发送了应取消处理的消息,但您仍然需要编写能够识别取消需求的代码。尽早取消是正确的,而 GetRequestsAsync 在多个检查中使用了 cancelToken.IsCancellationRequested。当在传递 CancelTokenCancellationTokenSource 实例上调用 Cancel 时,IsCancellationRequested 属性为 true

在循环内部,IsCancellationRequested 中断。在循环外部,IsCancellationRequested 发送一个 IProgress<T> 状态消息,以通知调用者操作已经正确取消。

GetNextBatchAsync 方法展示了另一种处理取消的方式,即抛出 OperationCancelledException。如果你回想一下,方法抛出异常是因为它无法完成设计的操作。在这种情况下,GetNextBatchAsync 没有检索记录,因此这可能是一个语义上正确的响应方式。即使这不是您会做出的设计决策,也要考虑到 GetNextBatchAsync 可能会 await 另一个方法,传递其 cancelToken。当取消时,等待的异步方法可能会抛出 OperationCancelledException。因此,在处理取消时,可以安全地预期和处理 OperationCancelledException。解决方案通过将对 GetNextBatchAsync 的调用包装在 try/catch 中来执行此操作,从而中断循环,并让现有代码向调用者报告取消状态。

在取消操作时,您可能还需要清理资源。下一节 食谱 6.10 将讨论如何做到这一点。

参见

食谱 6.3,“创建异步迭代器”

食谱 6.10,“处理异步资源释放”

6.10 处理异步资源释放

问题

您有一个必须释放资源的异步处理过程。

解决方案

此类展示了如何正确实现异步释放模式:

public class CheckoutStream : IAsyncDisposable, IDisposable
{
    CancellationTokenSource cancelSource = new CancellationTokenSource();
    CancellationToken cancelToken;
    ILogger log = new ConsoleLogger();

    FileStream asyncDisposeObj = new FileStream(
        "MyFile.txt", FileMode.OpenOrCreate, FileAccess.Write);
    HttpClient syncDisposeObj = new HttpClient();

    public CheckoutStream()
    {
        this.cancelToken = cancelSource.Token;
    }

    public async IAsyncEnumerable<CheckoutRequest> GetRequestsAsync(
        IProgress<CheckoutRequestProgress> progress)
    {
        int total = 0;

        while (true)
        {
            var requests = new List<CheckoutRequest>();

            try
            {
                requests = await GetNextBatchAsync();
            }
            catch (OperationCanceledException)
            {
                break;
            }

            total += requests.Count;

            foreach (var request in requests)
            {
                if (cancelToken.IsCancellationRequested)
                    break;

                yield return request;
            }

            progress.Report(
                new CheckoutRequestProgress
                {
                    Total = total,
                    Message = "New Batch of Checkout Requests"
                });

            if (cancelToken.IsCancellationRequested)
                break;

            await Task.Delay(1000);
        }
    }

    async Task<List<CheckoutRequest>> GetNextBatchAsync()
    {
        if (cancelToken.IsCancellationRequested)
            throw new OperationCanceledException();

        var requests = new List<CheckoutRequest>
        {
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "123 4th St",
                Card = "1234 5678 9012 3456",
                Name = "First Card Name"
            },
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "789 1st Ave",
                Card = "2345 6789 0123 4567",
                Name = "Second Card Name"
            },
            new CheckoutRequest
            {
                ShoppingCartID = Guid.NewGuid(),
                Address = "123 4th St",
                Card = "1234 5678 9012 3456",
                Name = "First Card Name"
            },
        };

        return await Task.FromResult(requests);
    }

    public async ValueTask DisposeAsync()
    {
        await DisposeAsyncCore();

        Dispose(disposing: false);
        GC.SuppressFinalize(this);
    }

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            syncDisposeObj?.Dispose();
            (asyncDisposeObj as IDisposable)?.Dispose();
        }

        DisposeThisObject();
    }

    protected virtual async ValueTask DisposeAsyncCore()
    {
        if (asyncDisposeObj is not null)
        {
            await asyncDisposeObj.DisposeAsync().ConfigureAwait(false);
        }

        if (syncDisposeObj is IAsyncDisposable disposable)
        {
            await disposable.DisposeAsync().ConfigureAwait(false);
        }
        else
        {
            syncDisposeObj.Dispose();
        }

        DisposeThisObject();

        await log.WriteAsync("\n\nDisposed!");
    }

    void DisposeThisObject()
    {
        cancelSource.Cancel();

        asyncDisposeObj = null;
        syncDisposeObj = null;
    }
}

这是演示如何使用异步可释放对象的应用程序:

static async Task Main()
{
    await using var checkoutStrm = new CheckoutStream();

    var checkoutSvc = new CheckoutService();

    IProgress<CheckoutRequestProgress> progress =
        new Progress<CheckoutRequestProgress>(p =>
        {
            Console.WriteLine(
                $"\n" +
                $"Total: {p.Total}, " +
                $"{p.Message}" +
                $"\n");
        });

    int count = 1;

    await foreach (var request in
        checkoutStrm.GetRequestsAsync(progress))
    {
        string result = await checkoutSvc.StartAsync(request);

        Console.WriteLine($"Result: {result}");

        if (count++ >= 10)
            break;
    }
}

讨论

Recipe 1.1 描述了释放资源的模式以及它如何解决对象生命周期结束时释放资源的问题。这对于同步代码很有效,但对于异步代码则不然。本节展示了如何使用异步释放模式释放异步资源。

在解决方案中,CheckoutStream有两个字段:一个FileStreamasyncDisposeObj,和一个HttpClientsyncDisposeObj。通常,它们应该具有表示它们在应用程序中用途的名称,但在这种情况下,它们的名称表示它们在解决方案中如何帮助遵循复杂的逻辑集。正如它们的名称所示,asyncDisposeObj引用必须异步处理的资源;syncDisposeObj引用必须同步处理的资源。同时考虑异步和同步处理非常重要,因为这解释了为什么它们的处理过程现在是交织在一起的。

对于异步和同步释放,CheckoutService分别实现了IAsyncDisposableIDisposable。正如在 Recipe 1.1 中讨论的那样,IDisposable指定类必须实现没有参数的Dispose,并且我们添加了一个带有bool参数的虚拟Dispose(bool)以及一个可选的析构函数来实现该模式。解决方案没有实现可选的析构函数。对于IAsyncDisposableCheckoutService实现了必需的DisposeAsync方法和一个虚拟的DisposeAsyncCore方法,两者都没有参数。

异步和同步两条处理路径都可能运行,因此它们都必须准备好释放资源。在同步路径上,Dispose(bool)不仅调用syncDisposeObjDispose,还尝试调用asyncDisposeObjDispose。请注意,Dispose(bool)还调用DisposeThisObject,其中包含异步路径需要调用的相同代码,从而减少了重复。

虽然DisposeDisposeAsync是接口成员,但Dispose(bool)DisposeAsyncCore是约定俗成的。还要注意它们都是virtual的。这是模式的一部分,派生类可以通过重写这些方法并调用它们,通过base.Dispose(bool)base.DisposeAsyncCore来确保释放整个继承层次结构中的资源。

DisposeDisposeAsync都调用Dispose(bool),但DisposeAsyncdisposing参数设置为false。如果回想一下,disposingDispose(bool)在设置为true时释放托管资源的标志。请记住,Dispose(bool)是同步路径。相反,DisposeAsync调用DisposeAsyncCore释放异步资源。

Dispose(true)一样,DisposeAsyncCore尝试释放所有托管资源。异步情况很明显。然而,同步对象有几种可能性。如果同步对象当前或将来实现了IAsyncDisposable,那么在代码处于异步路径时尝试调用DisposeAsync是更好的选择。否则,调用同步路径,使用Dispose

正如前面提到的,Dispose(bool)DisposeAsyncCore都调用DisposeThisObject。在解决方案场景中,GetRequestsAsync迭代器实现了取消操作,如第 6.9 节中所解释的那样。根据情况,可能在处理释放过程中取消是个不错的选择。例如,如果代码需要保存其最新的良好状态或者与网络端点有闭包协议,思考清楚是很重要的,而且释放和异步释放模式能帮助到你。

最后,请注意Main方法如何在CheckoutStream实例上等待使用语句。这与第 2.2 节中讨论的相同的using语句类似,只是现在有一个await。这确保代码在Main方法结束时调用DisposeAsync

参见

第 1.1 节,“管理对象生命周期结束”

第 2.2 节,“简化实例清理”

第 6.9 节,“取消异步操作”

第七章:操控数据

每个应用程序都使用数据,并且我们需要将数据从一种形式转换为另一种形式。本章提供了关于数据转换的多个主题,如机密管理、JSON 序列化和 XML 序列化。

机密信息是我们不希望向第三方公开的数据,例如密码或 API 密钥。本章包括三节关于管理这些机密信息的内容,包括哈希处理、加密和隐藏存储。

当今我们处理的许多数据都是以 JSON 格式。在现代框架中,基本的序列化/反序列化操作很简单,如果你同时拥有数据的消费者和提供者,这一切会更加简单。但是当你处理第三方数据时,你无法控制数据的一致性或标准。因此,本章的 JSON 部分深入探讨了定制方法,帮助你处理任何你需要的 JSON 格式数据。

最后,尽管 JSON 在当前互联网数据格式中占据主导地位,但仍然有大量 XML 数据需要处理,这是 XML 章节的主题。你将看到 LINQ 的另一种风格,称为 LINQ to XML,它可以完全控制序列化/反序列化过程。

7.1 生成密码哈希

问题

你需要安全地存储用户密码。

解决方案

这种方法生成一个随机盐来保护秘密:

static byte[] GenerateSalt()
{
    const int SaltLength = 64;

    byte[] salt = new byte[SaltLength];
    var rngRand = new RNGCryptoServiceProvider();

    rngRand.GetBytes(salt);

    return salt;
}

接下来的两种方法使用该盐生成哈希值:

static byte[] GenerateMD5Hash(string password, byte[] salt)
{
    byte[] passwordBytes = Encoding.UTF8.GetBytes(password);

    byte[] saltedPassword =
        new byte[salt.Length + passwordBytes.Length];

    using var hash = new MD5CryptoServiceProvider();

    return hash.ComputeHash(saltedPassword);
}

static byte[] GenerateSha256Hash(string password, byte[] salt)
{
    byte[] passwordBytes = Encoding.UTF8.GetBytes(password);

    byte[] saltedPassword =
        new byte[salt.Length + passwordBytes.Length];

    using var hash = new SHA256CryptoServiceProvider();

    return hash.ComputeHash(saltedPassword);
}

这里是如何使用方法生成哈希值:

static void Main(string[] args)
{
    Console.WriteLine("\nPassword Hash Demo\n");

    Console.Write("What is your password? ");
    string password = Console.ReadLine();

    byte[] salt = GenerateSalt();

    byte[] md5Hash = GenerateMD5Hash(password, salt);
    string md5HashString = Convert.ToBase64String(md5Hash);
    Console.WriteLine($"\nMD5:    {md5HashString}");

    byte[] sha256Hash = GenerateSha256Hash(password, salt);
    string sha256HashString = Convert.ToBase64String(sha256Hash);
    Console.WriteLine($"\nSHA256: {sha256HashString}");
}

讨论

ASP.NET Identity 对密码和组/角色管理提供了很好的支持,这应该是在规划新项目时考虑的要点之一。然而,在某些情况下,例如当你必须使用 ASP.NET Identity 不支持的数据库或必须使用具有自己自制密码管理系统的现有数据库时,ASP.NET Identity 可能不是最佳选择。

在构建自定义密码管理解决方案时,最佳实践是使用盐对密码进行哈希处理。哈希是将密码单向转换为一串无法理解的字符。每次你对特定密码进行哈希处理时,都会得到相同的哈希值。然而,与加密的重要区别在于你无法解密哈希值——没有办法将哈希值翻译回原始密码。这就引出了一个问题:如何知道用户输入了正确的密码。由于本书主要讲解 C#,数据库开发超出了本书的范围。尽管如此,在这里我们列出了验证密码所需的步骤:

  1. 创建用户账户时,对密码进行哈希处理,并将哈希值与用户名一起存储在数据库中。

  2. 当用户登录时,他们提供用户名和密码。

  3. 使用用户名,你的代码进行数据库查询,并检索匹配的哈希值。

  4. 对用户输入的密码进行哈希处理。

  5. 比较哈希密码。

  6. 如果密码哈希匹配,则验证成功——否则验证失败。

安全性是一场持续的猫鼠游戏。我们刚刚学会用哈希保护密码,黑客们就寻找方法突破它。最终,我们能做的最好的事情就是找到一定程度的安全性,使得黑客因我们需要保护信息而愿意获取它的成本变得非常高昂。您能承受多少安全性成本?

幸运的是,有一个简单的方法来加强密码安全性。在处理哈希密码的安全最佳实践中,包含盐(salt)是一种方法,盐是追加到密码后的随机字节数组。我们将盐和用户名、密码一起保存在数据库中。这对于防范彩虹表攻击非常有效,详见注释。解决方案中的GenerateSalt方法生成一个随机的 64 字节值。盐可以防止彩虹表攻击,并迫使黑客转向更为计算密集的字典攻击。

注释

如果黑客侵入您的系统或者找到了存储密码的表格的副本,那么常见的攻击有两种:字典攻击和彩虹表攻击。

在字典攻击中,黑客拥有一个包含单词和短语的字典列表,并逐个对列表中的项进行哈希处理,然后与数据库表进行比较。尽管有所有的复杂规则和遵循这些规则的人数,总会有些人使用单个单词密码。剧透警告:对于那些认为用符号/数字字符替换会变得聪明的人,这种方法行不通;黑客的字典和算法已经考虑到了这一点。

彩虹表攻击是字典攻击的另一种变体,其区别在于彩虹表已经对常见单词进行了哈希处理,因此他们只需进行简单的比较即可快速地遍历密码表。

GenerateMD5HashGenerateSha256哈希方法都接受一个密码和一个盐。这两种方法都将密码转换为byte[],连接密码和盐,然后生成一个哈希。MD5 和 SHA256 实现之间的语法差异在于MD5CryptoServiceProviderSHA256​Cryp⁠to​ServiceProvider

在实践中,有不同的原因使用特定的哈希算法。.NET 框架有几种哈希算法,你可以通过查找HashAlgorithm并检查其派生类来找到这些算法。许多最近的实现使用 SHA256 哈希,因为它比早期的哈希算法提供更好的保护。我包括 MD5 算法是为了说明你并不总是能够选择算法,因为密码表可能已经使用 MD5 创建。在这种情况下,对用户的不便可能会阻止他们重新输入密码以适应另一种哈希算法。

Main 方法演示如何使用这些算法生成哈希值。这里的一个有趣之处是调用 Convert.ToBase64String。每当你在不同地方传输数据时,传输机制都有一套基于特殊字符的协议和格式。如果哈希字节中的字符在传输过程中转换为特殊字符,软件将会出现问题。解决这个问题的标准方法是使用一种名为 Base64 的数据格式,它生成的字符不会与特殊的数据格式或传输协议字符冲突。

7.2 加密和解密机密信息

问题

你有需要在静态状态下加密的 API 密钥。

解决方案

这个类用于加密和解密机密信息:

public class Crypto
{
    public byte[] Encrypt(string plainText, byte[] key)
    {
        using Aes aes = Aes.Create();
        aes.Key = key;

        using var memStream = new MemoryStream();
        memStream.Write(aes.IV, 0, aes.IV.Length);

        using var cryptoStream = new CryptoStream(
            memStream,
            aes.CreateEncryptor(),
            CryptoStreamMode.Write);

        byte[] plainTextBytes = Encoding.UTF8.GetBytes(plainText);

        cryptoStream.Write(plainTextBytes);
        cryptoStream.FlushFinalBlock();

        memStream.Position = 0;

        return memStream.ToArray();
    }

    public string Decrypt(byte[] cypherBytes, byte[] key)
    {
        using var memStream = new MemoryStream();
        memStream.Write(cypherBytes);
        memStream.Position = 0;

        using var aes = Aes.Create();

        byte[] iv = new byte[aes.IV.Length];
        memStream.Read(iv, 0, iv.Length);

        using var cryptoStream = new CryptoStream(
            memStream,
            aes.CreateDecryptor(key, iv),
            CryptoStreamMode.Read);

        int plainTextByteLength = cypherBytes.Length - iv.Length;
        var plainTextBytes = new byte[plainTextByteLength];
        cryptoStream.Read(plainTextBytes, 0, plainTextByteLength);

        return Encoding.UTF8.GetString(plainTextBytes);
    }
}

这是一个生成随机密钥的方法:

static byte[] GenerateKey()
{
    const int KeyLength = 32;

    byte[] key = new byte[KeyLength];
    var rngRand = new RNGCryptoServiceProvider();

    rngRand.GetBytes(key);

    return key;
}

使用 Crypto 类和随机密钥加密和解密机密信息的方法如下:

static void Main()
{
    var crypto = new Crypto();

    Console.Write("Please enter text to encrypt: ");
    string userPlainText = Console.ReadLine();

    byte[] key = GenerateKey();

    byte[] cypherBytes = crypto.Encrypt(userPlainText, key);

    string cypherText = Convert.ToBase64String(cypherBytes);

    Console.WriteLine($"Cypher Text: {cypherText}");

    string decryptedPlainText = crypto.Decrypt(cypherBytes, key);

    Console.WriteLine($"Plain Text: {decryptedPlainText}");
}

讨论

我们经常有需要保护的秘密信息——API 密钥或其他敏感信息。加密是在静止状态下保护信息的方法。在保存之前,我们对数据进行加密,然后在检索加密数据后,我们对其进行解密以供使用。

在解决方案中,Crypto 类具有加密和解密数据的方法。key 参数是加密/解密算法使用的秘密值。我们将使用称为 对称密钥加密 的技术,其中我们使用单个密钥来加密/解密所有数据。显然,你不应将加密密钥存储在与数据相同的位置,因为如果黑客能够读取数据,他们还需要找出加密密钥所在的位置。在此演示中,GenerateKey 方法生成一个随机的 32 位密钥,加密提供程序所需。

加密提供程序是使用特殊算法加密/解密数据的代码。解决方案示例使用了先进加密标准 (AES),这是一种现代和安全的加密算法。

在保存数据时,你将 plainText 字符串与 key 一起传递到 Encrypt 方法中。调用 AES.Create 返回 AES 的一个实例。存储在数据库中的值是连接的初始化向量 (IV) 和加密文本。注意 memStream 首先从 AES 实例加载 IV 的方式。

CryptoStream 的三个参数是 memStream(包含 IV)、ICryptoTransform(通过调用 AES.CreateEncryptor 返回)、以及 Crypto​S⁠treamMode(指示我们正在向流中写入)。cryptoStream 实例将加密后的字节追加到 memStream 中的 IV。我们使用数据的 byte[] 表示,包括 plainText。在 cryptoStream 上调用 Write 执行加密,在调用 FlushFinalBlock 确保所有字节都被处理并推送到 memStream 中。

Decrypt 方法反转了这个过程。除了与加密时相同的 key 外,还有一个 cypherBytes 参数。如果您回忆一下 Encrypt 过程,加密值包括 IV 和附加的加密值,这些是 cypherBytes 的内容。加载了 cypherBytesmemStream 后,代码将 memStream 重新定位到开头,并将 IV 提取到 iv 中。这样 memStream 就位于 IV 的长度处,加密值从这里开始。

这里,cryptoStream 使用了加密文本(memStream 适当位置)。在这里,ICryptoTransform 不同,因为我们使用 ivkey 调用 CreateDecryptor。此外,CryptoStreamMode 需要设置为 Read。在 cryptoStream 上调用 Read 执行解密操作。

Main 方法展示了如何使用 EncryptDecrypt 方法。请注意,它们都使用相同的 keyConvert.ToBase64String 确保我们可以处理数据,避免随机字节被意外解释。例如,如果将二进制文件打印到控制台,可能会听到响声,因为某些字节被解释为响铃字符。此外,在传输数据时,Base64 可以避免字节被解释为传输协议或格式字符,从而破坏代码。

7.3 隐藏开发秘密

问题

您需要避免将密码和 API 密钥等秘密信息意外提交到源代码控制中。

解决方案

这是项目文件:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <RootNamespace>Section_07_03</RootNamespace>
    <UserSecretsId>d3d91a8b-d440-414a-821e-7f11eec48f32</UserSecretsId>
    </PropertyGroup>

    <ItemGroup>
    <PackageReference
        Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
    </ItemGroup>
</Project>

以下代码展示了如何轻松添加支持隐藏秘密的代码:

class Program
{
    static void Main()
    {
        var config = new ConfigurationBuilder()
            .AddUserSecrets<Program>()
            .Build();

        string key = "CSharpCookbook:ApiKey";
        Console.WriteLine($"{key}: {config[key]}");
    }
}

讨论

这是很常见的问题,开发人员意外将数据库连接字符串从配置文件提交到源代码控制中。另一个常见问题是,开发人员在在线论坛寻求帮助时,在其代码示例中意外留下了秘密。希望能清楚地看到这些错误可能对应用程序甚至业务造成严重损害。

其中一种解决方案是使用 Secret Manager。虽然 Secret Manager 通常与 ASP.NET 关联紧密,因为它具有内置的配置支持,但您可以将其与任何类型的应用程序一起使用。该解决方案展示了如何在控制台应用程序中轻松使用 Secret Manager。

注意

这是在开发环境中非常有用的功能。在生产环境中,您可能希望使用更安全的选项,例如,如果您部署到 Azure,则可能会使用密钥保管库(Key Vault)。将机密保存在环境变量中是避免将其存储在代码或配置中的另一种方式。

有些项目类型,如 ASP.NET,已经支持确保不会意外将开发代码部署到生产环境中,例如:

if (env.IsDevelopment())
{
    config.AddUserSecrets<Program>();
}

您可以使用 dotnet CLI 配置应用程序以使用 Secret Manager。第一步是通过命令行更新项目:

dotnet user-secrets init

这在项目文件中添加了一个UserSecretsID标签,如前所示。该 GUID 标识了存储秘密的文件系统位置。在这个示例中,该位置是:

%APPDATA%\Microsoft\UserSecrets\d3d91a8b-d440-414a-821e-7f11eec48f32
\secrets.json

在 Windows 上或:

~/.microsoft/usersecrets/d3d91a8b-d440-414a-821e-7f11eec48f32
/secrets.json

对于 Linux 或 macOS 机器。

设置完成后,您可以开始添加秘密,就像这样(与项目文件夹相同的位置):

dotnet user-secrets set "CSharpCookbook:ApiKey" "mYaPIsECRET"

您可以通过查看secrets.json或以下命令来验证已保存的秘密:

dotnet user-secrets list

Main方法展示了如何读取秘密管理器的密钥。记得引用Microsoft.Extensions.Hosting NuGet 包。只需在新的ConfigurationBuilder上调用AddUserSecrets。在其上调用Build返回一个IConfigurationRoot实例,提供索引器支持以读取键。

7.4 生成 JSON

问题

你需要自定义 JSON 输出格式。

解决方案

此代码显示了PurchaseOrder的外观:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
 [JsonPropertyName("serialNo")]
    public string SerialNumber { get; set; }

 [JsonPropertyName("description")]
    public string Description { get; set; }

 [JsonPropertyName("qty")]
    public float Quantity { get; set; }

 [JsonPropertyName("amount")]
    public decimal Price { get; set; }
}

public class PurchaseOrder
{
 [JsonPropertyName("company")]
    public string CompanyName { get; set; }
 [JsonPropertyName("address")]
    public string Address { get; set; }
 [JsonPropertyName("phone")]
    public string Phone { get; set; }

 [JsonPropertyName("status")]
    public PurchaseOrderStatus Status { get; set; }

 [JsonPropertyName("other")]
    public Dictionary<string, string> AdditionalInfo { get; set; }

 [JsonPropertyName("details")]
    public List<PurchaseItem> Items { get; set; }
}

此代码序列化了PurchaseOrder

public class PurchaseOrderService
{
    public void View(PurchaseOrder po)
    {
        var jsonOptions = new JsonSerializerOptions
        {
            WriteIndented = true
        };

        string poJson = JsonSerializer.Serialize(po, jsonOptions);

        // send HTTP request

        Console.WriteLine(poJson);
    }
}

这是如何填充PurchaseOrder的方法:

static PurchaseOrder GetPurchaseOrder()
{
    return new PurchaseOrder
    {
        CompanyName = "Acme, Inc.",
        Address = "123 4th St.",
        Phone = "555-835-7609",
        AdditionalInfo = new Dictionary<string, string>
        {
            { "terms", "Net 30" },
            { "poc", "J. Smith" }
        },
        Items = new List<PurchaseItem>
        {
            new PurchaseItem
            {
                Description = "Widget",
                Price = 13.95m,
                Quantity = 5,
                SerialNumber = "123"
            }
        }
    };
}

Main方法驱动该过程:

static void Main()
{
    PurchaseOrder po = GetPurchaseOrder();
    new PurchaseOrderService().View(po);
}

这是输出结果:

{
  "company": "Acme, Inc.",
  "address": "123 4th St.",
  "phone": "555-835-7609",
  "status": 0,
  "other": {
    "terms": "Net 30",
    "poc": "J. Smith"
  },
  "details": [
    {
      "serialNo": "123",
      "description": "Widget",
      "qty": 5,
      "amount": 13.95
    }
  ]
}

讨论

只需调用JsonSerializer.Serialize,来自System.Text.Json命名空间,这是将对象序列化为 JSON 的简单快速方法。如果您拥有应用程序的生产和消费部分,这可能是简单和快速的选择。但通常情况下,您会消费一个指定其自己 JSON 数据格式的第三方 API。此外,其命名约定与 C# Pascal 大小写属性名称不匹配。本节显示如何执行这些序列化器输出的自定义。

注意

Microsoft 在.NET Core 3 中引入了System.Text.Json命名空间。此前,一个广受欢迎的选择是得到了出色支持的Newtonsoft.Json库。

在解决方案场景中,我们希望将 JSON 文档发送到 API,但属性名称不匹配。这就是为什么PurchaseOrder(及其支持类型)使用JsonPropertyName属性装饰属性。JsonSerializer使用JsonPropertyName指定输出属性名称。

PurchaseOrderService有一个View方法,可以序列化PurchaseOrder。默认情况下,序列化器输出是单行的,我们希望看到格式化输出。代码使用了一个JsonSerializerOption,其中WriteIndented设置为true,产生了解决方案中显示的输出。

Main方法驱动该过程,获取一个新的PurchaseOrder,然后调用View打印出结果。

有时,API 会有机地增长,它们的命名约定缺乏一致性,这使得自定义输出的理想方法。但是,如果您使用的是具有一致命名约定的 API,7.5 章解释了如何构建转换器以避免为每个属性都装饰JsonPropertyName

参见

7.5 章,“消费 JSON”

7.5 消费 JSON

问题

您需要读取不符合默认反序列化选项的 JSON 对象。

解决方案

这是PurchaseOrder的外观:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public float Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }

 [JsonConverter(typeof(PurchaseOrderStatusConverter))]
    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

这是一个自定义JsonConverter类:

public class PurchaseOrderStatusConverter
    : JsonConverter<PurchaseOrderStatus>
{
    public override PurchaseOrderStatus Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        string statusString = reader.GetString();

        if (Enum.TryParse(
            statusString,
            out PurchaseOrderStatus status))
        {
            return status;
        }
        else
        {
            throw new JsonException(
                $"{statusString} is not a valid " +
                $"{nameof(PurchaseOrderStatus)} value.");
        }
    }

    public override void Write(
        Utf8JsonWriter writer,
        PurchaseOrderStatus value,
        JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }
}

这是自定义的 JSON 命名策略:

public class SnakeCaseNamingPolicy : JsonNamingPolicy
{
    public override string ConvertName(string name)
    {
        var targetChars = new List<char>();
        char[] sourceChars = name.ToCharArray();

        char first = sourceChars[0];
        if (char.IsUpper(first))
            targetChars.Add(char.ToLower(first));
        else
            targetChars.Add(first);

        for (int i = 1; i < sourceChars.Length; i++)
        {
            char ch = sourceChars[i];

            if (char.IsUpper(ch))
            {
                targetChars.Add('_');
                targetChars.Add(char.ToLower(ch));
            }
            else
            {
                targetChars.Add(ch);
            }
        }

        return new string(targetChars.ToArray());
    }
}

此类模拟请求,返回格式化为 JSON 的数据:

public class PurchaseOrderService
{
    public string Get(int poID)
    {
        // get HTTP request

        return @"{
""company_name"": ""Acme, Inc."",
""address"": ""123 4th St."",
""phone"": ""555-835-7609"",
""additional_info"": {
 ""terms"": ""Net 30"",
 ""poc"": ""J. Smith"",
},
""status"": ""Processing"",
""items"": [
 {
 ""serial_number"": ""123"",
 ""description"": ""Widget"",
 ""quantity"": 5,
 ""price"": 13.95
 }
]
}";
    }
}

Main 方法显示了如何使用自定义转换器、选项和策略:

static void Main()
{
    string poJson =
        new PurchaseOrderService()
            .Get(poID: 123);

    var jsonOptions = new JsonSerializerOptions
    {
        AllowTrailingCommas = true,
        Converters =
        {
            new PurchaseOrderStatusConverter()
        },
        PropertyNameCaseInsensitive = true,
        PropertyNamingPolicy = new SnakeCaseNamingPolicy(),
        WriteIndented = true
    };

    PurchaseOrder po =
        JsonSerializer
        .Deserialize<PurchaseOrder>(poJson, jsonOptions);

    Console.WriteLine($"{po.CompanyName}");
    Console.WriteLine($"{po.AdditionalInfo["terms"]}");
    Console.WriteLine($"{po.Items[0].Description}");

    string poJson2 = JsonSerializer.Serialize(po, jsonOptions);

    Console.WriteLine(poJson2);
}

这是输出结果:

Acme, Inc.
Net 30
Widget
{
  "company_name": "Acme, Inc.",
  "address": "123 4th St.",
  "phone": "555-835-7609",
  "status": "Processing",
  "additional_info": {
    "terms": "Net 30",
    "poc": "J. Smith"
  },
  "items": [
    {
      "serial_number": "123",
      "description": "Widget",
      "quantity": 5,
      "price": 13.95
    }
  ]
}

讨论

JsonSerializer 具有用于生成驼峰命名属性名的内置转换器,通过 JsonInitializerOptions,像这样:

var serializeOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

这处理了许多情况,但是如果第三方 API 没有使用帕斯卡命名或驼峰命名属性名称怎么办?此解决方案包括对由下划线分隔单词的蛇形命名属性名称的支持。例如,SnakeCase 变成了 snake_case。除了新的命名策略外,该解决方案还包括其他定制,包括对 enum 的支持。

注意,PurchaseOrder 不会使用 JsonPropertyName 装饰任何属性。相反,我们使用自定义命名策略,在 SnakeCaseNamingPolicy 类中定义,该类派生自 JsonNamingPolicyConvertName 中的算法假定它已收到帕斯卡命名规则的属性名。它迭代字符,查找大写字符。遇到大写字符时,它会附加下划线 _,将字母小写,并将小写字母附加到结果中。否则,它附加字符,该字符已经是小写的。

Main 方法实例化了 JsonSerializerOptions,将 PropertyNamingPolicy 设置为 SnakeCaseNamingPolicy 的实例。这将命名策略应用于所有属性名称,生成蛇形命名的属性名称。

提示

与许多情况一样,您可能会遇到规则的例外情况,其中 JSON 属性不符合蛇形命名规则。在这种情况下,使用 JsonPropertyName 属性,如 Recipe 7.4 中所述,覆盖该属性的命名策略。

您可能已经注意到,在 Main 中,JsonSerializerOptions 还具有其他定制。AllowTrailingCommas 很有趣,因为有时您会收到包含列表的 JSON 数据,列表中的最后一项有一个尾随逗号。这会破坏反序列化,将 AllowTrailingCommas 设置为 true 可以忽略尾随逗号。

PropertyNameCaseInsensitive 是一种选择,不考虑属性名称的格式。在反序列化时,它允许小写属性名称与它们的大写等效项匹配。当传入的 JSON 属性名称可能不一致时,这是有用的。

默认情况下,JsonSerializer 生成单行 JSON 文档。设置 WriteIndented 可以格式化文本以提高可读性,如输出中所示。

其中一个属性 Converters 是一个类型集合,用于在属性上进行自定义转换。PurchaseOrderStatusConverterJsonConverter<T> 派生,允许将 Status 属性反序列化为 PurchaseOrderStatus 枚举。有两种方法可以应用它:在 JsonSerialization 选项中或通过属性。将转换器添加到 JsonSerializationOptionsConverter 集合中会为所有 PurchaseOrderStatus 属性类型应用转换。此外,PurchaseOrder 类使用 JsonConverter 属性装饰 Status 属性。我在解决方案中添加了这两种方法,以便你能看到它们各自的工作方式。将转换器添加到 Converters 集合中就足够了。但是,如果你想要为特定属性应用不同的转换器,或者需要为不同的属性使用不同的转换器,那么请使用 JsonConverter 属性,因为它优先于 Converters 集合。

Main 方法展示了如何在反序列化和序列化中使用相同的 JsonSerializationOptions

参见

7.4 节,“生成 JSON”

7.6 处理 JSON 数据

问题

收到了无法干净反序列化为对象的 JSON 数据。

解决方案

这是一个 PurchaseOrder 的样例:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public double Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }
    public string Terms { get; set; }
    public string POC { get; set; }

    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

这个类模拟了返回 JSON 数据的请求:

public class PurchaseOrderService
{
    public string Get(int poID)
    {
        // get HTTP request

        return @"{
""company_name"": ""Acme, Inc."",
""address"": ""123 4th St."",
""phone"": ""555-835-7609"",
""additional_info"": {
 ""terms"": ""Net 30"",
 ""poc"": ""J. Smith""
},
""status"": ""Processing"",
""items"": [
 {
 ""serial_number"": ""123"",
 ""description"": ""Widget"",
 ""quantity"": 5,
 ""price"": 13.95
 }
]
}";
    }
}

这里是支持自定义反序列化的类:

public static class JsonConversionExtensions
{
    public static bool IsNull(this JsonElement json)
    {
        return
            json.ValueKind == JsonValueKind.Undefined ||
            json.ValueKind == JsonValueKind.Null;
    }

    public static string GetString(
        this JsonElement json,
        string propertyName,
        string defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element))
            return element.GetString() ?? defaultValue;

        return defaultValue;
    }

    public static int GetInt(
        this JsonElement json,
        string propertyName,
        int defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetInt32(out int value))
            return value;

        return defaultValue;
    }

    public static ulong GetULong(
        this string val,
        ulong defaultValue = default)
    {
        return string.IsNullOrWhiteSpace(val) ||
            !ulong.TryParse(val, out ulong result)
                ? defaultValue
                : result;
    }

    public static ulong GetUlong(
        this JsonElement json,
        string propertyName,
        ulong defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetUInt64(out ulong value))
            return value;

        return defaultValue;
    }

    public static long GetLong(
        this JsonElement json,
        string propertyName,
        long defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetInt64(out long value))
            return value;

        return defaultValue;
    }

    public static bool GetBool(
        this JsonElement json,
        string propertyName,
        bool defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull())
            return element.GetBoolean();

        return defaultValue;
    }

    public static double GetDouble(
        this string val,
        double defaultValue = default)
    {
        return string.IsNullOrWhiteSpace(val) ||
            !double.TryParse(val, out double result)
                ? defaultValue
                : result;
    }

    public static double GetDouble(
        this JsonElement json,
        string propertyName,
        double defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetDouble(out double value))
            return value;

        return defaultValue;
    }

    public static decimal GetDecimal(
        this JsonElement json,
        string propertyName,
        decimal defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull() &&
            element.TryGetDecimal(out decimal value))
            return value;

        return defaultValue;
    }

    public static TEnum GetEnum<TEnum>
        (this JsonElement json,
        string propertyName,
        TEnum defaultValue = default)
        where TEnum: struct
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element) &&
            !element.IsNull())
        {
            string enumString = element.GetString();

            if (enumString != null &&
                Enum.TryParse(enumString, out TEnum num))
                return num;
        }

        return defaultValue;
    }
}

Main 方法展示了如何执行自定义反序列化:

static void Main()
{
    string poJson =
        new PurchaseOrderService()
            .Get(poID: 123);

    JsonElement elm = JsonDocument.Parse(poJson).RootElement;

    JsonElement additional = elm.GetProperty("additional_info");
    JsonElement items = elm.GetProperty("items");

    if (additional.IsNull() || items.IsNull())
        throw new ArgumentException("incomplete PO");

    var po = new PurchaseOrder
    {
        Address = elm.GetString("address", "none"),
        CompanyName = elm.GetString("company_name", string.Empty),
        Phone = elm.GetString("phone", string.Empty),
        Status = elm.GetEnum("status", PurchaseOrderStatus.Received),
        Terms = additional.GetString("terms", string.Empty),
        POC = additional.GetString("poc", string.Empty),
        AdditionalInfo =
            (from jElem in additional.EnumerateObject()
             select jElem)
            .ToDictionary(
                key => key.Name,
                val => val.Value.GetString()),
        Items =
            (from jElem in items.EnumerateArray()
             select new PurchaseItem
             {
                 Description = jElem.GetString("description"),
                 Price = jElem.GetDecimal("price"),
                 Quantity = jElem.GetDouble("quantity"),
                 SerialNumber = jElem.GetString("serial_number")
             })
            .ToList()
    };

    Console.WriteLine($"{po.CompanyName}");
    Console.WriteLine($"{po.Terms}");
    Console.WriteLine($"{po.AdditionalInfo["terms"]}");
    Console.WriteLine($"{po.Items[0].Description}");
}

讨论

虽然在序列化和反序列化中使用 JsonSerializer 是首选,但有时你不会得到 JSON 和 C# 对象之间干净的一对一结构匹配。例如,你可能需要从不同格式的不同源获取数据,并有一个单一的 C# 对象进行填充。其他时候,你可能有一个分层的 JSON 文档,并希望将其扁平化为你自己的对象。另一种常见情况是已经使用一个版本工作的对象,而 API 的新版本改变了结构。在某种程度上,这些都是同一个问题的多个视角,你可以通过自定义反序列化来解决。

System.Text.Json 命名空间中用于自定义反序列化的两种类型是 JsonDocumentJsonElementMain 方法展示了如何使用 JsonDocument 解析 JSON 输入,并通过 RootElement 属性获取 JsonElement。之后,我们只需处理 JsonElement 的成员。

JsonElement 有多个成员,包括 GetStringGetInt64,用于进行转换。仅仅依赖这些成员存在的问题是数据通常不干净。即使你拥有应用程序的生产者和消费者端,获得完全干净的数据可能也是难以实现的。为了解决这个问题,我创建了 JsonConversionExtensions 类。

在概念上,JsonConversionExtensions 包装了许多模板代码,你需要调用它们来确保你正在读取的数据是你所期望的。它还有一个可选的默认值概念。

解决第一个问题的技巧是,JsonElement中的null值不表示为nullIsNull方法检查ValueKind属性,检查UndefinedNull属性是否为 true。这是其他转换方法中使用的重要方法。

浏览其余的方法,你会看到一个熟悉的模式。每个方法都会检查元素的IsNull,然后使用一个或多个TryGetXxxIsNull的组合来获取值。这样做是安全的,在值为null或类型错误时避免异常。没错,一些 API 文档中的值是一个类型,运行时返回另一个类型,将数字设置为null,并省略属性。

每个方法都有一个默认参数。如果代码无法提取真实值,它将使用defaultValuedefaultValue参数是可选的,会回到返回类型的 C# default

Main方法展示了如何使用JsonElementJsonConversionExtensions类构造对象。你可以看到代码如何使用GetXxx方法填充每个属性。

几个有用的JsonElement方法是EnumerateObjectEnumerateArray。在前面的章节中,JsonSerializer将 JSON additional_info对象反序列化为 C#字典。这是处理具有可变信息对象的方法,你不知道对象属性是什么。在 API 返回单个错误 JSON 响应中,每个属性是一个错误的代码或描述。在PurchaseOrder示例中,这表示可以添加不适合预定义属性的杂项信息的地方。要手动读取这些属性,请使用EnumerateObject。它返回对象中的每个属性/值对。你可以看到 LINQ 语句如何通过从EnumerateObject返回的每个JsonProperty提取KeyValue来创建一个新字典。

EnumerateArray返回列表的每个元素。在解决方案中,我们将从EnumerateArray返回的每个JsonElement投影到一个新的PurchaseOrderItem实例中。

JsonConversionExtensions是不完整的,因为它不包括日期。由于DateTime处理是一个特例,我从这个示例中分离了它;你可以在 Recipe 7.10 中找到更多信息。

参见

Recipe 7.10,“灵活的 DateTime 读取”

7.7 消费 XML

问题

你需要将 XML 文档转换为对象。

解决方案

这是一个PurchaseOrder的样子:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public float Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }

    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

该方法模拟了返回 XML 数据的请求:

static string GetXml()
{
    return @"
<PurchaseOrder ">
 <Address>123 4th St.</Address>
 <CompanyName>Acme, Inc.</CompanyName>
 <Phone>555-835-7609</Phone>
 <Status>Received</Status>
 <AdditionalInfo>
 <Terms>Net 30</Terms>
 <POC>J. Smith</POC>
 </AdditionalInfo>
 <Items>
 <PurchaseItem SerialNumber=""123"">
 <Description>Widget</Description>
 <Price>13.95</Price>
 <Quantity>5</Quantity>
 </PurchaseItem>
 </Items>
</PurchaseOrder>";
}

Main方法展示了如何将 XML 反序列化为对象:

static void Main(string[] args)
{
    XNamespace or = "https://www.oreilly.com";

    XName address = or + nameof(PurchaseOrder.Address);
    XName company = or + nameof(PurchaseOrder.CompanyName);
    XName phone = or + nameof(PurchaseOrder.Phone);
    XName status = or + nameof(PurchaseOrder.Status);
    XName info = or + nameof(PurchaseOrder.AdditionalInfo);
    XName poItems = or + nameof(PurchaseOrder.Items);
    XName purchaseItem = or + nameof(PurchaseItem);
    XName description = or + nameof(PurchaseItem.Description);
    XName price = or + nameof(PurchaseItem.Price);
    XName quantity = or + nameof(PurchaseItem.Quantity);
    XName serialNum = nameof(PurchaseItem.SerialNumber);

    string poXml = GetXml();

    XElement poElmt = XElement.Parse(poXml);

    PurchaseOrder po =
        new PurchaseOrder
        {
            Address = (string)poElmt.Element(address),
            CompanyName = (string)poElmt.Element(company),
            Phone = (string)poElmt.Element(phone),
            Status =
                Enum.TryParse(
                    (string)poElmt.Element(nameof(po.Status)),
                    out PurchaseOrderStatus poStatus)
                ? poStatus
                : PurchaseOrderStatus.Received,
            AdditionalInfo =
                (from addInfo in poElmt.Element(info).Descendants()
                 select addInfo)
                .ToDictionary(
                    key => key.Name.LocalName,
                    val => val.Value),
            Items =
                (from item in poElmt
                                .Element(poItems)
                                .Descendants(purchaseItem)
                 select new PurchaseItem
                 {
                     Description = (string)item.Element(description),
                     Price =
                        decimal.TryParse(
                            (string)item.Element(price),
                            out decimal itemPrice)
                        ? itemPrice
                        : 0m,
                     Quantity =
                        float.TryParse(
                            (string)item.Element(quantity),
                            out float qty)
                        ? qty
                        : 0f,
                     SerialNumber = (string)item.Attribute(serialNum)
                 })
                .ToList()
        };

    Console.WriteLine($"{po.CompanyName}");
    Console.WriteLine($"{po.AdditionalInfo["Terms"]}");
    Console.WriteLine($"{po.Items[0].Description}");
    Console.WriteLine($"{po.Items[0].SerialNumber}");
}

讨论

在 JSON 成为主导数据格式之前,XML 无处不在。在处理配置文件、项目文件或可扩展应用标记语言(XAML)等方面,XML 仍然非常明显。还有相当数量的遗留代码,包括广泛使用 XML 的 Windows Communication Foundation(WCF)Web 服务。暂时而言,掌握如何处理 XML 是一项宝贵的技能,而 LINQ to XML 是一个优秀的工具。

解决方案展示了如何将 XML 反序列化为 PurchaseOrder 对象。Main 方法首先设置命名空间。XML 中的命名空间很常见,代码创建了一个命名空间标签 orXNamespace 类型有一个转换器,将字符串转换为命名空间。XNamespace 还重载了 + 运算符,允许您给元素打上特定的命名空间标签,创建一个新的 XName。代码为每个元素设置了一个 XName,以使 PurchaseOrder 的构造更易于阅读。

每个元素都有一个命名空间,serialNum 除外,它是一个属性。数据属性不需要用命名空间注释,因为它们位于包含元素的命名空间中。唯一的例外是,如果您想要向元素添加命名空间属性,将其放入新的命名空间中。

在获取 XML 后,Main 调用 XElement.Parse 获取一个新的 XElement 来处理。XElement 具有移动文档和读取所需内容所需的所有轴方法。此示例通过使用 AttributeElementDescendants 轴方法按层次移动文档来保持简单。

Element 方法帮助读取当前元素下的子元素。Descendants 方法深入一级,访问指定元素的子元素。从 GetXml 返回的 XML 中,PurchaseOrder 是根元素,由 poElmt 表示。查看 PurchaseOrderpoElmt.Element(address) 读取 PurchaseOrder 的子元素 Address。如您所知,address 是一个命名空间限定的 XName

填充 AdditionalInfoItems 属性展示了如何使用 Descendants。我们使用 Element 来读取子元素,使用 Descendants 来获取该元素的子元素列表。对于 AdditionalInfoDescendants 是可变元素和值,并且我们不传递 XName 参数。对于 Items,我们需要传递 purchaseItemXNameDescendants,以便对每个对象进行操作。

我们使用 Attribute 方法来填充每个 PurchaseOrderItemSerialNumber 属性。

此对象构造的有趣部分是在 TryParse 操作中声明 out 参数的能力。这使我们可以在对象构造时内联编码分配。在此 C# 特性之前,我们需要在对象构造外部声明变量,这在像解决方案中的 LINQ 投影中填充 Items 属性时并不自然。

参见

Recipe 7.8, “生成 XML”

7.8 XML 生成

问题

你需要将一个对象转换为 XML。

解决方案

这是一个PurchaseOrder的样子:

public enum PurchaseOrderStatus
{
    Received,
    Processing,
    Fulfilled
}

public class PurchaseItem
{
    public string SerialNumber { get; set; }

    public string Description { get; set; }

    public float Quantity { get; set; }

    public decimal Price { get; set; }
}

public class PurchaseOrder
{
    public string CompanyName { get; set; }
    public string Address { get; set; }
    public string Phone { get; set; }

    public PurchaseOrderStatus Status { get; set; }

    public Dictionary<string, string> AdditionalInfo { get; set; }

    public List<PurchaseItem> Items { get; set; }
}

这个方法模拟一个数据请求,返回一个PurchaseOrder

static PurchaseOrder GetPurchaseOrder()
{
    return new PurchaseOrder
    {
        CompanyName = "Acme, Inc.",
        Address = "123 4th St.",
        Phone = "555-835-7609",
        AdditionalInfo = new Dictionary<string, string>
        {
            { "Terms", "Net 30" },
            { "POC", "J. Smith" }
        },
        Items = new List<PurchaseItem>
        {
            new PurchaseItem
            {
                Description = "Widget",
                Price = 13.95m,
                Quantity = 5,
                SerialNumber = "123"
            }
        }
    };
}

Main方法展示了如何将PurchaseOrder实例序列化为 XML:

static void Main(string[] args)
{
    PurchaseOrder po = GetPurchaseOrder();

    XNamespace or = "https://www.oreilly.com";

    XElement poXml =
        new XElement(or + nameof(PurchaseOrder),
            new XElement(
                or + nameof(PurchaseOrder.Address),
                po.Address),
            new XElement(
                or + nameof(PurchaseOrder.CompanyName),
                po.CompanyName),
            new XElement(
                or + nameof(PurchaseOrder.Phone),
                po.Phone),
            new XElement(
                or + nameof(PurchaseOrder.Status),
                po.Status),
            new XElement(
                or + nameof(PurchaseOrder.AdditionalInfo),
                (from info in po.AdditionalInfo
                 select
                     new XElement(
                         or + info.Key,
                         info.Value))
                .ToList()),
            new XElement(
                or + nameof(PurchaseOrder.Items),
                (from item in po.Items
                 select new XElement(
                     or + nameof(PurchaseItem),
                     new XAttribute(
                         nameof(PurchaseItem.SerialNumber),
                         item.SerialNumber),
                     new XElement(
                         or + nameof(PurchaseItem.Description),
                         item.Description),
                     new XElement(
                         or + nameof(PurchaseItem.Price),
                         item.Price),
                     new XElement(
                         or + nameof(PurchaseItem.Quantity),
                         item.Quantity)))
                .ToList()));

    Console.WriteLine(poXml);
}

这里是输出结果:

<PurchaseOrder xmlns="https://www.oreilly.com">
  <Address>123 4th St.</Address>
  <CompanyName>Acme, Inc.</CompanyName>
  <Phone>555-835-7609</Phone>
  <Status>Received</Status>
  <AdditionalInfo>
    <Terms>Net 30</Terms>
    <POC>J. Smith</POC>
  </AdditionalInfo>
  <Items>
    <PurchaseItem SerialNumber="123">
      <Description>Widget</Description>
      <Price>13.95</Price>
      <Quantity>5</Quantity>
    </PurchaseItem>
  </Items>
</PurchaseOrder>

讨论

Recipe 7.7 将一个 XML 文档反序列化为一个PurchaseOrder对象。这一部分则相反—将PurchaseOrder序列化为 XML 文档。

我们从XNamespace开始,用作每个元素的XName参数,以保持所有元素在同一个命名空间中。

解决方案通过调用XElementXAttribute构建 XML 文档。我们唯一使用XAttribute的地方是每个PurchaseOrderItem元素的SerialNumber属性。

从视觉上看,你可以看到 LINQ 到 XML 查询子句的布局与其生成的 XML 输出具有相同的分层结构。解决方案使用了两个XElement构造函数重载。如果一个元素是一个底层节点,没有子元素,第二个参数是元素的值。然而,如果元素是一个父元素,有子元素,第二个参数是一个新的XElement

LINQ 语句用于AdditionalInfoItems,生成一个新的XElement

参见

Recipe 7.7, “消费 XML”

7.9 编码和解码 URL 参数

问题

你正在使用一个需要符合 RFC 3986 的 API 进行操作。

解决方案

这里是一个正确编码 URL 参数的类:

public class Url
{
    /// <summary>
    /// Implements Percent Encoding according to RFC 3986
    /// </summary>
    /// <param name="value">string to be encoded</param>
    /// <returns>Encoded string</returns>
    public static string PercentEncode(
        string? value, bool isParam = true)
    {
        const string IsParamReservedChars = @"`!@#$^&*+=,:;'?/|\[] ";
        const string NoParamReservedChars = @"`!@#$^&*()+=,:;'?/|\[] ";

        var result = new StringBuilder();

        if (string.IsNullOrWhiteSpace(value))
            return string.Empty;

        var escapedValue = EncodeDataString(value);

        var reservedChars =
            isParam ? IsParamReservedChars : NoParamReservedChars;

        foreach (char symbol in escapedValue)
        {
            if (reservedChars.IndexOf(symbol) != -1)
                result.Append(
                    '%' +
                    string.Format("{0:X2}", (int)symbol).ToUpper());
            else
                result.Append(symbol);
        }

        return result.ToString();
    }

    /// <summary>
    /// URL-encode a string of any length.
    /// </summary>
    static string EncodeDataString(string data)
    {
        // the max length in .NET 4.5+ is 65520
        const int maxLength = 65519;

        if (data.Length <= maxLength)
            return Uri.EscapeDataString(data);

        var totalChunks = data.Length / maxLength;

        var builder = new StringBuilder();
        for (var i = 0; i <= totalChunks; i++)
        {
            string? chunk =
                i < totalChunks ?
                    data[(maxLength * i)..maxLength] :
                    data[(maxLength * i)..];

            builder.Append(Uri.EscapeDataString(chunk));
        }
        return builder.ToString();
    }
}

此方法解析 URL,编码参数并重建 URL:

static string EscapeUrlParams(string originalUrl)
{
    const int Base = 0;
    const int Parms = 1;
    const int Key = 0;
    const int Val = 1;
    string[] parts = originalUrl.Split('?');
    string[] pairs = parts[Parms].Split('&');

    string escapedParms =
        string.Join('&',
            (from pair in pairs
             let keyVal = pair.Split('=')
             let encodedVal = Url.PercentEncode(keyVal[Val])
             select $"{keyVal[Key]}={encodedVal}")
            .ToList());

    return $"{parts[Base]}?{escapedParms}";
}

Main方法比较不同的编码方式:

static void Main()
{
    const string OriginalUrl =
        "https://myco.com/po/search?company=computers+";
    Console.WriteLine($"Original:    '{OriginalUrl}'");

    string escapedUri = Uri.EscapeUriString(OriginalUrl);
    Console.WriteLine($"Escape URI:  '{escapedUri}'");

    string escapedData = Uri.EscapeDataString(OriginalUrl);
    Console.WriteLine($"Escape Data: '{escapedData}'");

    string escapedUrl = EscapeUrlParams(OriginalUrl);
    Console.WriteLine($"Escaped URL: '{escapedUrl}'");
}

生成这个输出:

Original:    'https://myco.com/po/search?company=computers+'
Escape URI:  'https://myco.com/po/search?company=computers+'
Escape Data: 'https%3A%2F%2Fmyco.com%2Fpo%2Fsearch%3Fcompany
%3Dcomputers%2B'
Escaped URL: 'https://myco.com/po/search?company=computers%2B'

讨论

如果你同时构建网络通信的消费者和生产者部分,比如内部企业应用,编码的正确性可能并不重要,因为这两部分使用同一个库。然而,某些第三方 API 要求严格遵守 RFC 3986。你可能首先想到的是.NET 中的System.Uri类有EscapeUriStringEscapeDataString方法。不幸的是,这些方法并没有始终正确实现 RFC 3986。虽然.NET 5+跨平台并且看起来实现良好,但是.NET Framework 的早期版本针对不同技术并没有这样做。为了解决这个问题,我在解决方案中创建了Url类。

注意

RFC 3986 是定义互联网 URL 编码的标准。RFC 代表“请求评论”,标准通常以 RFC 后跟一些唯一编号。

PercentEncode 将值参数的每个字符替换为带有百分号(%)前缀的两位十六进制表示。第一个操作是调用 EscapeDataString。该方法调用 Uri.EscapeDataStringUri.EscapeDataString 的一个问题是长度限制,因此该方法会将输入分块以确保所有数据都被编码。方法的思路是让 Uri.EscapeDataString 处理大部分的转换工作,并让 PercentEncode 补充那些未被编码的字符。

PercentEncode 还有一个第二个参数 isParam,用于指示是否应编码括号。它默认为 true,用户可以将其设置为 false 以防止编码括号,这是 IsParamReservedCharsNoParamReservedChars 之间唯一的区别。如果该方法发现未编码的字符,它会手动进行编码。

我们只对查询字符串参数值进行编码,因为基本 URL、段和参数名称是不需要编码的值。EscapeUrlParameters 方法通过将 URL 与参数分离,并迭代每个参数来实现此目的。对于每个参数,它将参数名与其值分开,并对值调用 PercentEncode。在对值进行编码之后,代码重建并返回 URL。

Main 方法展示了不同类型的编码,阐明了为什么选择了自定义编码方法。请注意,Uri.EscapeUriString 没有对 + 符号进行编码。使用 Uri.EscapeDataString 对整个 URL 进行了编码,这并不是您想要的。将 URL 拆分并对每个值进行编码可以完美解决问题。

请记住,在 .NET 5+ 应用程序中可能会获得良好的结果。但是,如果在旧的 .NET Framework 版本中进行跨平台工作,Uri.EscapeUriStringUri.EscapeDataString 的结果可能不一致,很可能会导致错误。无论使用哪个框架/技术版本,仅对参数值进行编码的技术是一个常见的需求。

7.10 灵活的日期时间读取

问题

您需要解析可能以多种不同格式出现的 DateTime 值。

解决方案

这些扩展方法有助于解析日期:

public static class StringExtensions
{
    static readonly string[] dateFormats =
    {
        "ddd MMM dd HH:mm:ss %zzzz yyyy",
        "yyyy-MM-dd\\THH:mm:ss.000Z",
        "yyyy-MM-dd\\THH:mm:ss\\Z",
        "yyyy-MM-dd HH:mm:ss",
        "yyyy-MM-dd HH:mm"
    };

    public static DateTime GetDate(
        this string date,
        DateTime defaultValue)
    {
        return string.IsNullOrWhiteSpace(date) ||
            !DateTime.TryParseExact(
                    date,
                    dateFormats,
                    CultureInfo.InvariantCulture,
                    DateTimeStyles.AssumeUniversal |
                    DateTimeStyles.AdjustToUniversal,
                    out DateTime result)
                ? defaultValue
                : result;
    }

    public static DateTime GetDate(
        this JsonElement json,
        string propertyName,
        DateTime defaultValue = default)
    {
        string? date = json.GetString(propertyName);
        return date?.GetDate(defaultValue) ?? defaultValue;
    }

    public static string? GetString(
        this JsonElement json,
        string propertyName,
        string? defaultValue = default)
    {
        if (!json.IsNull() &&
            json.TryGetProperty(propertyName, out JsonElement element))
            return element.GetString() ?? defaultValue;

        return defaultValue;
    }

    public static bool IsNull(this JsonElement json)
    {
        return
            json.ValueKind == JsonValueKind.Undefined ||
            json.ValueKind == JsonValueKind.Null;
    }
}

Main 方法展示了如何提取和解析 JSON 文档中的日期:

static void Main()
{
    const string TweetID = "1305895383260782593";
    const string CreatedDate = "created_at";

    string tweetJson = GetTweet(TweetID);

    JsonElement tweetElem = JsonDocument.Parse(tweetJson).RootElement;

    DateTime created = tweetElem.GetDate(CreatedDate);

    Console.WriteLine($"Created Date: {created}");
}

static string GetTweet(string tweetID)
{
    return @"{
 ""text"": ""Thanks @github for approving sponsorship for
 LINQ to Twitter: https://t.co/jWeDEN07HN"",
 ""id"": ""1305895383260782593"",
 ""author_id"": ""15411837"",
 ""created_at"": ""2020-09-15T15:44:56.000Z""
 }";
}

讨论

在使用第三方 API 时,您可能会遇到数据表示的偶发不一致。一个问题区域是解析日期。不同的 API 使用不同的日期格式,甚至在同一个 API 中,也可能用不同的格式表示不同的日期属性。解决方案中的 StringExtensions 类帮助解决了这个问题。

注意

我从 7.6 节 中的 JsonConversionExtensions 中提取了 StringExtensions 成员。

解决方案包括一个dateFormats数组,其中包含日期格式字符串的实例。这些都是此代码可以容纳的所有可能日期格式。GetDate方法在调用TryParseExact时使用dateFormats。每当遇到新的日期格式(例如,如果 API 提供了新版本并更新了日期格式),请将其添加到dateFormats中。

最佳实践是将日期表示为 UTC 值,因此DateTimeStyles参数反映了这一假设。

函数GetDate有两个重载,取决于您需要传递string还是JsonElementJsonElement的重载使用GetString扩展方法,并将结果转发给另一个GetDate方法。

这些方法是安全的,因为您必须考虑糟糕的数据。它们检查null,使用TryParse,并在无法读取有效值时返回default值。如果未提供defaultValue,则返回类型的default是可选的。

参见

Recipe 7.6,“使用 JSON 数据”

第八章:模式匹配

历来,开发人员用各种逻辑检查和比较实现业务规则。有时这些规则很复杂,自然而然地导致代码难以编写、阅读和维护。想想你多少次遇到多分支逻辑、多变量比较和多层嵌套的情况。

为了帮助简化这种复杂性,现代编程语言已经开始引入模式匹配——这些语言的特性通过声明性语法帮助将事实与结果匹配。在 C# 中,模式匹配体现为一个不断增长的功能列表,特别是从 C# 7 开始。

本章的主题围绕酒店调度和使用模式进行业务规则。标准通常围绕客户类型如铜牌、银牌或金牌展开,金牌客户由于更频繁的酒店住宿而获得更多积分,是最高级别。

本章讨论了属性、元组和类型的模式匹配。还有一些关于逻辑操作的部分,它们支持和简化多条件模式。令人惊讶的是,C# 从 v1.0 开始就具有某种形式的模式匹配。本章的第一部分讨论了 isas 操作符,并展示了 is 操作符的新增强功能。

8.1 安全地转换实例

问题

您的遗留代码是弱类型的,依赖于过程式模式,并且需要重构。

解决方案

这里是一个接口及其实现类,用于生成我们正在寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

这是一个代表返回的遗留非类型化实例数据的方法:

static ArrayList GetWeakTypedSchedules()
{
    var list = new ArrayList();

    list.Add(new BronzeSchedule());
    list.Add(new SilverSchedule());
    list.Add(new GoldSchedule());

    return list;
}

并且这段代码处理遗留集合:

static void ProcessLegacyCode()
{
    ArrayList schedules = GetWeakTypedSchedules();

    foreach (var schedule in schedules)
    {
        if (schedule is IRoomSchedule)
        {
            IRoomSchedule roomSchedule = (IRoomSchedule)schedule;
            roomSchedule.ScheduleRoom();
        }

        //
        // alternatively
        //

        IRoomSchedule asRoomSchedule = schedule as IRoomSchedule;

        if (asRoomSchedule != null)
            asRoomSchedule.ScheduleRoom();

        //
        // even better
        //

        if (schedule is IRoomSchedule isRoomSchedule)
            isRoomSchedule.ScheduleRoom();
    }
}

这里有更现代的代码,返回一个强类型集合:

static List<IRoomSchedule> GetStrongTypedSchedules()
{
    return new List<IRoomSchedule>
    {
        new BronzeSchedule(),
        new SilverSchedule(),
        new GoldSchedule()
    };
}

并且这段代码处理强类型集合:

static void ProcessModernCode()
{
    List<IRoomSchedule> schedules = GetStrongTypedSchedules();

    foreach (var schedule in schedules)
    {
        schedule.ScheduleRoom();

        if (schedule is GoldSchedule gold)
            Console.WriteLine(
                $"Extra processing for {gold.GetType()}");
    }
}

Main 方法调用旧版和现代版:

static void Main()
{
    ProcessLegacyCode();
    ProcessModernCode();
}

讨论

asis 操作符在 C# 1 中就已经出现了;您可能已经知道并/或者已经使用过它们。简单回顾一下,is 操作符告诉您一个对象的类型是否与正在匹配的类型相同。as 操作符将引用类型对象转换为指定类型。如果转换后的实例不是指定的类型,则 as 操作符返回 null。这个例子还展示了最近 C# 添加的功能,允许使用 is 操作符进行类型检查和转换。

我们今天大部分编写的代码使用泛型集合,越来越不需要使用弱类型集合。我敢说,你可能会采纳一个经验法则,以泛型集合作为默认选择,除非无法避免使用弱类型集合。必须使用弱类型集合的一个重要情况是维护已经使用它们的遗留代码。泛型直到 C# 2 才添加,因此你可能会遇到一些使用弱类型集合的旧代码。另一个例子是当你需要或希望使用使用弱类型集合的库时。实际上,你可能不想重写该代码,因为需要的时间和资源——特别是如果它已经经过测试且运行良好。

在解决方案中,GetWeakTypedSchedules 方法返回一个 ArrayList,这是一个弱类型集合,因为它仅对 Object 类型的实例进行操作。ProcessLegacyCode 方法调用 GetWeakTypedSchedules 并展示了如何使用 asis 运算符。

第一个 foreach 循环中的第一个 if 语句使用 is 运算符来确定对象是否为 IRoomSchedule。如果是,它使用转型运算符获取 IRoomSchedule 实例并调用 GetSchedule。如果我们已经知道集合包含 IRoomSchedule 实例,为什么还需要 is 运算符呢?为什么不直接进行转换?问题在于,并不保证集合中的类型是什么。如果开发人员意外加载了一个不是 IRoomSchedule 的对象进入集合怎么办?is 运算符提高了代码的可靠性。

is 运算符相对的是 as 运算符。在解决方案中,schedule as IRoomSchedule 执行转换。如果结果不是 null,则对象是 IRoomSchedule。这种方法可能性能更好,因为 is 操作既检查类型又需要转换,而 as 运算符只需要转换和 null 检查。

最后一个 if 语句演示了更新的 is 运算符语法。它既进行类型检查又进行转换,并将结果赋给 isRoomSchedule 变量。如果 schedule 不是 IRoomSchedule,则 isRoomSchedule 变量为 null,但由于 is 运算符返回了一个 bool 结果,我们不需要额外的 null 检查。

GetStrongTypedSchedulesProcessModernCode 展示了今天你可能想要编写的代码。请注意,由于强类型化,它具有更少的仪式感。每个类都实现了相同的接口,集合就是该接口,允许你编写有效地操作每个对象的代码。

这个例子还展示了新的is运算符在当前代码中可以很有用(不仅限于旧代码)。在ProcessModernCode中,即使所有对象都实现了IRoomScheduleis运算符也让我们能够检查GoldSchedule并进行一些额外处理。

8.2 捕获筛选后的异常

问题

你需要处理相同异常类型的不同条件逻辑。

解决方案

这是一个演示抛出异常的演示类:

public class Scheduler
{
    public void ScheduleRoom(string arg1, string arg2)
    {
        _ = arg1 ?? throw new ArgumentNullException(nameof(arg1));
        _ = arg2 ?? throw new ArgumentNullException(nameof(arg2));
    }
}

这个Main方法使用异常过滤器来进行清晰的处理:

static void Main()
{
    try
    {
        Console.Write("Choose (1) arg1 or (2) arg2? ");
        string arg = Console.ReadLine();

        var scheduler = new Scheduler();

        if (arg == "1")
            scheduler.ScheduleRoom(null, "arg2");
        else
            scheduler.ScheduleRoom("arg1", null);
    }
    catch (ArgumentNullException ex1)
        when (ex1.ParamName == "arg1")
    {
        Console.WriteLine("Invalid arg1");
    }
    catch (ArgumentNullException ex2)
        when (ex2.ParamName == "arg2")
    {
        Console.WriteLine("Invalid arg2");
    }
}

讨论

C#中一个有趣的补充,与模式匹配相关,是异常过滤器。如你所知,catch块根据抛出的异常类型进行操作。然而,当同一类型的异常由于不同原因可能被抛出时,有时能够区分每个原因的处理方式会很有用。虽然你可以在catch块中添加ifswitch语句,但过滤器提供了一种清晰分离和简化不同逻辑的方式。

在解决方案中,我们希望根据参数是null来过滤ArgumentNullExceptionScheduleRoom方法检查每个参数,如果任一参数为null,则抛出ArgumentNullException

Main方法在try/catch块中包装对ScheduleRoom的调用。这个例子有两个catch块,每个均为ArgumentNullException类型。两者之间的区别在于过滤器,由when子句指定。when子句的参数是一个布尔表达式。在解决方案中,表达式比较了ParamName与其处理的参数名。

8.3 简化switch分配

问题

你想根据某些条件返回一个值,但不想从每一个switch分支中返回。

解决方案

这里是一个接口及其实现类,是我们寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

这个枚举将在即将进行的逻辑中使用:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

这个类展示了switch语句和新的switch表达式:

public class Scheduler
{
    public IRoomSchedule CreateStatement(
        ScheduleType scheduleType)
    {
        switch (scheduleType)
        {
            case ScheduleType.Gold:
                return new GoldSchedule();
            case ScheduleType.Silver:
                return new SilverSchedule();
            case ScheduleType.Bronze:
            default:
                return new BronzeSchedule();
        }
    }

    public IRoomSchedule CreateExpression(
        ScheduleType scheduleType) =>
            scheduleType switch
            {
                ScheduleType.Gold => new GoldSchedule(),
                ScheduleType.Silver => new SilverSchedule(),
                ScheduleType.Bronze => new BronzeSchedule(),
                _ => new BronzeSchedule()
            };
}

Main方法测试代码:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    var scheduler = new Scheduler();

    IRoomSchedule scheduleStatement =
        scheduler.CreateStatement(scheduleType);
    scheduleStatement.ScheduleRoom();

    IRoomSchedule scheduleExpression =
        scheduler.CreateExpression(scheduleType);
    scheduleExpression.ScheduleRoom();
}

讨论

switch语句从 C# 1 开始存在,而最近的增加是switch表达式。switch表达式的主要语法特性是一种简写表示法和将结果分配给变量的能力。如果你思考过你使用switch语句的所有情况,你可能会注意到产生值或新实例是一个常见主题。switch表达式简化了这个主题,并通过模式匹配进一步改进它。

解决方案有两个例子:一个是switch语句,一个是switch表达式。两者都依赖于ScheduleType枚举来进行条件判断,并根据此条件生成一个IRoomSchedule类型的结果。

CreateStatement方法使用了switch语句,其中有针对ScheduleType枚举的每个成员的case分支。注意它如何从方法中返回值,以及它需要普通的块体语法(带有花括号)。

CreateExpression方法使用了新的switch表达式。请注意,该方法可以是命令体(带箭头),返回表达式。与switch关键字后面的括号中的参数不同,参数位于switch关键字之前。此外,与case子句不同,案例模式匹配出现在箭头之前,箭头后的结果表达式。默认情况是丢弃模式_

每当参数与案例模式匹配时,switch表达式返回结果。在解决方案中,模式是ScheduleType枚举的值。switch表达式的结果是方法的结果,因为方法的命令语法指定了switch表达式。

如果您有一个用例,其中逻辑需要处理每个情况,但不需要返回值,那么经典的switch语句可能更合适。但是,如果您可以使用模式匹配并需要返回值,那么switch表达式可能是一个很好的选择。

8.4 切换属性值

问题

您需要基于强类型类属性的业务规则。

解决方案

这是一个具有我们需要评估的属性的类:

public class Room
{
    public int Number { get; set; }
    public string RoomType { get; set; }
    public string BedSize { get; set; }
}

这个枚举是评估的结果:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

该方法获取我们需要的数据:

static List<Room> GetRooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 333,
            BedSize = "King",
            RoomType = "Suite"
        },
        new Room
        {
            Number = 222,
            BedSize = "King",
            RoomType = "Regular"
        },
        new Room
        {
            Number = 111,
            BedSize = "Queen",
            RoomType = "Regular"
        },
    };
}

该方法使用该数据并根据匹配模式返回枚举:

const int RoomNotAvailable = -1;

static int AssignRoom(ScheduleType scheduleType)
{
    foreach (var room in GetRooms())
    {
        ScheduleType roomType = room switch
        {
            { BedSize: "King", RoomType: "Suite" }
                => ScheduleType.Gold,
            { BedSize: "King", RoomType: "Regular" }
                => ScheduleType.Silver,
            { BedSize: "Queen", RoomType: "Regular" }
                => ScheduleType.Bronze,
            _ => ScheduleType.Bronze
        };

        if (roomType == scheduleType)
            return room.Number;
    }

    return RoomNotAvailable;
}

Main方法驱动程序:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    int roomNumber = AssignRoom(scheduleType);

    if (roomNumber == RoomNotAvailable)
        Console.WriteLine("Room not available.");
    else
        Console.WriteLine($"The room number is {roomNumber}.");
}

讨论

以前,switch语句根据单个参数的值匹配情况。现在,您可以根据对象属性的值进行参数匹配。

解决方案使用Room类的实例作为AssignRoom方法中switch表达式的参数。模式是具有参数属性和匹配值的对象。返回的结果基于参数属性匹配哪种模式而确定。

该程序的目标是为客户找到一个可用的房间。AssignRoom的目的是返回与特定调度类型关联的第一个房间。这就是为什么AssignRoom比较roomTypescheduleType,如果它们匹配,则返回的原因。

属性模式匹配是一种很好的方法,因为它易于阅读。这可能会转化为更可维护的代码。一个权衡是,如果要匹配许多属性,则可能会很冗长。下一个配方提供了更短的语法。

参见

配方 8.5,“切换元组”

8.5 切换元组

问题

您需要基于业务规则,并且更喜欢更短的语法。

解决方案

这个类很有趣,因为它有一个析构函数:

public class Room
{
    public int Number { get; set; }
    public string RoomType { get; set; }
    public string BedSize { get; set; }

    public void Deconstruct(out string size, out string type)
    {
        size = BedSize;
        type = RoomType;
    }
}

这是程序将生成的枚举:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

这是程序将使用的数据:

static List<Room> GetRooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 333,
            BedSize = "King",
            RoomType = "Suite"
        },
        new Room
        {
            Number = 222,
            BedSize = "King",
            RoomType = "Regular"
        },
        new Room
        {
            Number = 111,
            BedSize = "Queen",
            RoomType = "Regular"
        },
    };
 }

并且这个方法使用从类析构函数返回的元组来确定要返回的枚举:

static int AssignRoom(ScheduleType scheduleType)
{
    foreach (var room in GetRooms())
    {
        ScheduleType roomType = room switch
        {
            ("King", "Suite") => ScheduleType.Gold,
            ("King", "Regular") => ScheduleType.Silver,
            ("Queen", "Regular") => ScheduleType.Bronze,
            _ => ScheduleType.Bronze
        };

        if (roomType == scheduleType)
            return room.Number;
    }

    return RoomNotAvailable;
}

Main方法驱动程序:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    int roomNumber = AssignRoom(scheduleType);

    if (roomNumber == RoomNotAvailable)
        Console.WriteLine("Room not available.");
    else
        Console.WriteLine($"The room number is {roomNumber}.");
}

讨论

在整本书中,你已经看到了元组在管理一组值时有多么有用,而无需所有自定义类型的繁文缛节。元组的快速语法使它们成为简单模式匹配的理想选择。

在这个例子中,我们有一个自定义类型Room。注意Room有一个自定义的解构器,在这个解决方案中我们将使用它。GetRooms方法返回一个List<Room>AssignRooms使用了那个集合。然而,由于解构器的存在,我们可以将每个房间用作switch表达式参数,它足够聪明以使用解构器生成用于模式匹配的元组。

除了通过解构器使用元组外,这个演示与 Recipe 8.4 相同。在这个例子中,元组提供了更简洁的语法。属性模式更冗长但更易读。一个考虑因素是,如果你匹配标量值,比如boolint,属性模式更好地记录了文档。如果你匹配字符串或枚举,元组可能在可读性和更短语法方面提供了最佳选择。因为两种方法之间的选择是情境性的,最好在每种情况下评估权衡,看看哪种对你更有意义。

参见

Recipe 8.4,“基于属性值切换”

8.6 位置切换

问题

你需要基于值的业务规则,但不想创建一个新的一次性类。

解决方案

这个枚举是我们将要寻找的结果:

public enum ScheduleType
{
    None,
    Bronze,
    Silver,
    Gold
}

这是一个用于指定决策标准的类:

public class Room
{
    public int Number { get; set; }
    public string RoomType { get; set; }
    public string BedSize { get; set; }
}

这些方法模拟了从两个源获取数据的情况:

static List<Room> GetHotel1Rooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 333,
            BedSize = "King",
            RoomType = "Suite"
        },
        new Room
        {
            Number = 111,
            BedSize = "Queen",
            RoomType = "Regular"
        },
    };
}

static List<Room> GetHotel2Rooms()
{
    return new List<Room>
    {
        new Room
        {
            Number = 222,
            BedSize = "King",
            RoomType = "Regular"
        },
    };
}

这个方法将这些数据源连接起来,生成一个元组列表:

static
    List<(int no, string size, string type)>
    GetRooms()
{
    var rooms = GetHotel1Rooms().Union(GetHotel2Rooms());
    return
        (from room in rooms
         select (
            room.Number,
            room.BedSize,
            room.RoomType
         ))
        .ToList();
}

这个方法展示了基于位置模式匹配的业务逻辑:

static int AssignRoom(ScheduleType scheduleType)
{
    foreach (var room in GetRooms())
    {
        ScheduleType roomType = room switch
        {
            (_, "King", "Suite") => ScheduleType.Gold,
            (_, "King", "Regular") => ScheduleType.Silver,
            (_, "Queen", "Regular") => ScheduleType.Bronze,
            _ => ScheduleType.Bronze
        };

        if (roomType == scheduleType)
            return room.no;
    }

    return RoomNotAvailable;
}

Main方法驱动这个过程:

static void Main()
{
    Console.Write(
        "Choose (1) Bronze, (2) Silver, or (3) Gold: ");
    string choice = Console.ReadLine();

    Enum.TryParse(choice, out ScheduleType scheduleType);

    int roomNumber = AssignRoom(scheduleType);

    if (roomNumber == RoomNotAvailable)
        Console.WriteLine("Room not available.");
    else
        Console.WriteLine($"The room number is {roomNumber}.");
}

讨论

这里的解决方案类似于 Recipe 8.5,因为它也使用元组进行模式匹配。这个解决方案的不同之处在于它探讨了当你有两个不同数据源的情况,分别在GetHotel1RoomsGetHotel2Rooms中展示,模拟了通常会是数据库查询的情况。这种情况可能发生在公司合并或形成合作伙伴关系时,它们的数据相似但并非完全相同。

GetRooms方法展示了如何使用 LINQ 的Union运算符来合并这两个列表。方法构建了一个元组集合,而不是为我们需要的值组合创建一个新类型。

AssignRooms调用GetRooms时,你不需要在对象上使用解构器,因为你已经在使用元组。如果你正在使用第三方类型而无法修改其成员,这是一种有用的技术。

AssignRoom内部,switch表达式使用元组进行匹配。在这里立即引人注目的是第一个参数,表示Room.Number属性 - 每个模式都有一个丢弃符号。显然,这在GetRooms中可以被省略,但我以这种方式编写是为了阐明几个观点:值的位置必须匹配,并且每个值都是必需的。

元组模式要求值位于正确的位置(例如,不能交换NumberSize)。每个模式值的位置必须与相应元组位置匹配。相比之下,属性模式可以任意顺序且在不同情况下不同。

对于元组,您必须包括元组每个位置的值。因此,即使在模式中不使用位置,也必须至少指定丢弃参数。属性模式没有此限制,允许您添加或忽略模式中想要的任何属性。

参见

8.5 "元组切换"的食谱

8.7 值范围切换

问题

您的业务规则是连续的,而不是离散的。

解决方案

这是一个接口,以及实现类,我们正在寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

此方法使用关系模式匹配来生成结果:

const int SilverPoints = 5000;
const int GoldPoints = 20000;

static IRoomSchedule GetSchedule(int points) =>
    points switch
    {
        >= GoldPoints => new GoldSchedule(),
        >= SilverPoints => new SilverSchedule(),
        < SilverPoints => new BronzeSchedule()
    };

Main方法驱动整个过程:

static void Main()
{
    Console.Write("How many points? ");
    string response = Console.ReadLine();

    if (!int.TryParse(response, out int points))
    {
        Console.WriteLine($"'{response}' is invalid!");
        return;
    }

    IRoomSchedule schedule = GetSchedule(points);

    schedule.ScheduleRoom();
}

讨论

本章的前几节探讨了基于离散值的模式匹配。模式必须精确匹配才能成功。但是,有很多情况下,值是连续的,而不是离散的。本节中的解决方案就是一个例子,酒店客户的积分范围可能各不相同。

在解决方案中,积分从 0 到 4,999 的客户为青铜。积分从 5,000 到 19,999 的客户为银。积分达到或超过 20,000 的客户为金。解决方案中的SilverPointsGoldPoints常量定义了边界。

Main方法询问客户的积分数,并将该值传递给GetSchedule。这个值可能会变化,这取决于一个人预订房间或使用其他酒店服务的次数。因此,GetSchedule根据这些积分使用switch表达式。而不是使用离散模式进行匹配,GetSchedule使用关系运算符。

第一个模式询问points是否等于或高于GoldPoints。如果不是,points必须更少,并且代码检查是否等于或高于SilverPoints。由于我们已经评估了GoldPoints情况,所以这意味着范围在SilverPointsGoldPoints之间。最后一个情况,低于SilverPoints,说明了青铜的含义,但您可以很容易地用丢弃模式替换它,因为其他两种情况处理了所有其他可能性,而青铜是唯一剩下的。

8.8 复杂条件切换

问题

您的业务规则是多条件的。

解决方案

这是一个接口,以及实现类,我们正在寻找的结果:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

本课程描述了使用的标准:

public class Customer
{
    public int Points { get; set; }

    public bool HasFreeUpgrade { get; set; }
}

此方法生成具有各种值的模拟数据,以演示我们的逻辑:

static List<Customer> GetCustomers() =>
    new List<Customer>
    {
        new Customer
        {
            Points = 25000,
            HasFreeUpgrade = false
        },
        new Customer
        {
            Points = 10000,
            HasFreeUpgrade = true
        },
        new Customer
        {
            Points = 1000,
            HasFreeUpgrade = true
        },
    };

这是一个使用switch表达式中复杂逻辑的方法:

static IRoomSchedule GetSchedule(Customer customer) =>
    customer switch
    {
        Customer c
            when
                c.Points >= GoldPoints
                    ||
                (c.Points >= SilverPoints && c.HasFreeUpgrade)
            => new GoldSchedule(),

        Customer c
            when
                c.Points >= SilverPoints
                    ||
                (c.Points < SilverPoints && c.HasFreeUpgrade)
            => new SilverSchedule(),

        Customer c
            when
                c.Points < SilverPoints
            => new BronzeSchedule(),

        _ => new BronzeSchedule()
    };

Main方法遍历结果:

static void Main()
{
    foreach (var customer in GetCustomers())
    {
        IRoomSchedule schedule = GetSchedule(customer);
        schedule.ScheduleRoom();
    }
}

讨论

有时候条件非常复杂,无法通过本章前面展示的技术解决问题。例如,本节中的解决方案需要涉及多个属性的多个条件。在这里,我们使用switch表达式和when子句来指定匹配。

此场景基于Customer类型,指示点数数量以及客户是否有免费升级。免费升级可能来自于比赛或酒店促销活动。在安排房间时,我们希望确保客户获得与其点数水平相称的房间。此外,如果他们有免费升级选项,则会获得升级到下一个更高级别的房间。为简便起见,我们方便地忽略了金牌是否有免费升级。

GetSchedule方法操作Customer的一个实例。金牌和银牌的情况都会导致相应级别的房间。此外,||运算符表示,如果customer处于下一个较低级别,但HasFreeUpgradetrue,则结果是此较高级别的房间。

使用这样的逻辑会很快变得复杂。请注意使用换行和其他间距来增加结果的对称性和一致性以方便阅读。

当逻辑比离散模式匹配复杂时,这种技术可以帮助您。您可能希望考虑使用if语句的阈值作为更好的实现。一个考虑因素是维护,因为将每个逻辑片段分解出来有助于调试,而单个表达式具有多个条件可能不会立即明显。

8.9 使用逻辑条件

问题

您希望多条件逻辑更易读。

解决方案

这是一个作为标准使用的类:

public class Customer
{
    public int Points { get; set; }

    public int Month { get; set; }
}

此方法模拟数据源:

static List<Customer> GetCustomers() =>
    new List<Customer>
    {
        new Customer
        {
            Points = 25000,
            Month = 1
        },
        new Customer
        {
            Points = 10000,
            Month = 12
        },
        new Customer
        {
            Points = 10000,
            Month = 11
        },
        new Customer
        {
            Points = 1000,
            Month = 2
        },
    };

此方法在switch表达式中实现了业务规则和条件逻辑:

const int SilverPoints = 5000;
const int GoldPoints = 20000;

const int May = 5;
const int Sep = 9;
const int Dec = 12;

static decimal GetDiscount(Customer customer) =>
    (customer.Points, customer.Month) switch
    {
        (>= GoldPoints, not Dec and > Sep or < May) => 0.15m,
        (>= GoldPoints, Dec) => 0.10m,
        (>= SilverPoints, not (Dec or <= Sep and >= May)) => 0.05m,
        _ => 0.0m
    };

Main方法驱动此过程:

static void Main()
{
    foreach (var customer in GetCustomers())
    {
        decimal discount = GetDiscount(customer);
        Console.WriteLine(discount);
    }
}

讨论

Recipe 8.8 描述了如何向switch表达式添加复杂逻辑。在这里,我指的是涉及两个或更多属性的多个条件。这与本章先前使用的属性和元组模式进行简单模式匹配形成对比。在这些对比方法之间的某处,是一个需要逻辑隔离在各个属性内的适度方法。

此解决方案中感兴趣的属性是 Customer 类的 PointsMonth。与前几节类似,Points 属性有助于为至少具有一定数量点数的客户预订房间。另一个条件 Month 是客户想要预订房间的月份。由于季节性供需,一些月份会留给酒店更多的空房。因此,此应用程序根据积分提供激励,鼓励客户在空房较多的月份预订房间。

在解决方案中,你可以看到 GoldPointsSilverPoints 常量,用于确定客户的等级。同时,还有 MaySepDec 这些繁忙月份的常量。逻辑是在非繁忙月份给予折扣。

GetDiscount 方法的 switch 表达式的模式匹配基于两个属性:PointsMonth。请注意,这段代码不依赖于对象解构,而原始参数是一个类,而不是元组。GetDiscountswitch 表达式创建了一个内联元组。

模式本身依赖于 Points 的关系运算符,就像 Recipe 8.7 中一样。

Month 模式使用了新的 C# 9 逻辑运算符:andnotor。第一个表达式确保客户在冬季月份(SepMay 之间,除了 Dec)期间享受折扣。第二个模式表示金牌客户在 Dec 仍然享受折扣,但是折扣从 15% 变为 10%。

最后一个模式在逻辑上等同于第一个模式,并使用了德摩根定理。也就是说,它否定了整个结果,并交换了 andor。因为最后一个示例将 not 应用于整个表达式,所以它使用了括号。而在第一个模式中,not 仅应用于 Dec

参见

Recipe 8.7,“在值范围上进行切换”

Recipe 8.8,“使用复杂条件进行切换”

8.10 类型切换

问题

你需要对象的类型来做出决定。

解决方案

这里有一个接口,以及实现了我们需要的结果的类:

public interface IRoomSchedule
{
    void ScheduleRoom();
}

public class GoldSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Gold Room");
}

public class SilverSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Silver Room");
}

public class BronzeSchedule : IRoomSchedule
{
    public void ScheduleRoom() =>
        Console.WriteLine("Scheduling Bronze Room");
}

以下类型代表条件:

public class Customer {}

public class GoldCustomer : Customer {}

public class SilverCustomer : Customer {}

public class BronzeCustomer : Customer {}

此方法模拟了一个数据源:

static List<Customer> GetCustomers() =>
    new List<Customer>
    {
        new GoldCustomer(),
        new SilverCustomer(),
        new BronzeCustomer()
    };

下面是一个根据类型模式匹配实现逻辑的方法:

static IRoomSchedule GetSchedule(Customer customer) =>
    customer switch
    {
        GoldCustomer => new GoldSchedule(),
        SilverCustomer => new SilverSchedule(),
        BronzeCustomer => new BronzeSchedule(),
        _ => new BronzeSchedule()
    };

Main 方法通过数据迭代来执行模式匹配逻辑:

static void Main()
{
    foreach (var customer in GetCustomers())
    {
        IRoomSchedule schedule = GetSchedule(customer);
        schedule.ScheduleRoom();
    }
}

讨论

过去,唯一能够根据类型做出决策的方式是使用 if 语句或将对象类型转换为 string,并使用带有 string 情况的 switch 语句。多年来对 C# 的一个常见请求是允许使用类型 case 的 switch 语句,现在我们终于有了。

解决方案包含一组类:GoldCustomerSilverCustomerBronzeCustomer,它们都是从 Customer 派生的。我们在这个程序中的目标是根据匹配的类类型安排一个房间。

GetSchedule 方法通过接受一个类型为 Customer 的对象来进行调度,而 switch 表达式针对从 Customer 派生的每个类都有一个模式。你只需要指定每个类的名称,switch 表达式会根据对象类型进行匹配。

第九章:检视最近的 C#语言亮点

C#编程语言不断发展。前几章讨论了从 C# 1 到 C# 8 的主题。例外是在第八章中的模式匹配,其中一些模式是在 C# 9 中引入的。本章主要关注 C# 9,例外是 Recipe 9.9 的主题,这是一个 C# 8 的功能。

本章的一个核心概念是不可变性——能够创建和操作不会改变的类型。不可变性对于安全的多线程操作非常重要,也有助于减轻认知负担,因为你知道,你将一个类型传递给的代码不会改变(突变)对象的内容。

示例场景是处理地址,如邮寄地址或送货地址。在许多情况下,地址是一个值,意味着它没有身份。地址作为与客户或公司等实体关联的一组数据(值)存在。另一方面,实体确实有一个身份,通常在数据库中建模为 ID 字段。因为我们将其视为一个值,所以地址成为不可变性的一个有用候选,因为我们不希望该值在设置后发生变化。

C# 9 的一个有趣特性称为模块初始化。想想我们如何使用构造函数初始化类型或Main初始化应用程序。模块初始化允许你在程序集范围内编写初始化代码,你将看到它是如何工作的。

另一个 C# 9 的主题是简化代码。你将看到如何写出不带命名空间或类的代码,消除在Main方法中启动应用程序时的繁文缛节。另一个简化是在实例化对象时,使用新的特性来推断类型上下文。让我们从简化应用程序启动开始。

9.1 简化应用程序启动

问题

你需要尽可能地消除应用程序入口的代码。

解决方案

这是一个顶层程序:

using System;

Console.WriteLine("Address Info:\n");

Console.Write("Street: ");
string street = Console.ReadLine();

Console.Write("City: ");
string city = Console.ReadLine();

Console.Write("State: ");
string state = Console.ReadLine();

Console.Write("Zip: ");
string zip = Console.ReadLine();

Console.WriteLine($@"
 Your address is:

 {street}
 {city}, {state} {zip}");

这是 C#编译器生成的代码:

using System;
using System.Runtime.CompilerServices;

[CompilerGenerated]
internal static class <Program>$
{
  private static void <Main>$(string[] args)
  {
    Console.WriteLine("Address Info:\n");
    Console.Write("Street: ");
    string street = Console.ReadLine();
    Console.Write("City: ");
    string city = Console.ReadLine();
    Console.Write("State: ");
    string state = Console.ReadLine();
    Console.Write("Zip: ");
    string zip = Console.ReadLine();
    Console.WriteLine(
      "\r\n    Your address is:\r\n\r\n    " + street +
      "\r\n    " + city + ", " + state + " " + zip);
  }
}

讨论

我们写的大部分代码都是样板代码——我们一遍又一遍地复制的标准语法。在具有Main方法的控制台应用程序中,你会有一个命名空间,通常与项目名称匹配,一个名为Program的类和一个Main方法。虽然你可以自由删除命名空间并重命名类,但人们很少这样做。开发人员多年来一直意识到这一点,在 C# 9 中,我们不再需要这些样板代码。

解决方案展示了新的顶级语句功能,代码中不需要命名空间、类或Main方法。这是启动应用程序所需的最少代码。示例代码请求地址详细信息并打印结果,其功能与所写的代码完全相同。

提示

如果你在教授某人如何在 C# 中编程,顶级语句可以使任务更加简单。你不需要解释方法,因为你不写Main方法。你不需要解释一个类,它是一个具有成员和更多的对象。你可以省略命名空间的讨论以及所有关于命名和组织的微妙之处。与其浅显地横跨(或忽略)所有这些复杂细节,你可以在学生准备好时稍后讨论它们。

顶级语句可以替代Main方法。解决方案显示了编译器生成的代码。它具有CompilerGenerated属性、类和Main方法。命名约定与 Visual Studio、.NET CLI 和其他 IDE 为控制台应用程序生成的典型样板代码匹配。

有趣的是,你只能将顶级语句放在单个文件中。如果尝试将它们放在多个文件中,你将遇到以下编译器错误:

CS8802  Only one compilation unit can have top-level statements.

9.2 减少实例化语法

问题

对象实例化过于冗长和啰嗦。

解决方案

我们要实例化这个类:

public class Address
{
    public Address() { }

    public Address(
        string street,
        string city,
        string state,
        string zip)
    {
        Street = street;
        City = city;
        State = state;
        Zip = zip;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string Zip { get; set; }
}

以下是实例化该类的不同方式:

class Program
{
    // doesn't work at this level
    // var address = new Address();

    // this still works
    Address addressOld = new Address();

    // new target typed field
    Address addressNew = new();

    static void Main()
    {
        // these still work
        var addressLocalVar = new Address();
        Address addressLocalOld = new Address();

        // new target typed local variable
        Address addressLocalNew = new();

        // target typed with object ini
        Address addressObjectInit = new()
        {
            Street = "123 4th St.",
            City =   "My City",
            State =  "ZZ",
            Zip =    "55555-3333"
        };

        // target typed with ctor init
        Address addressCtorInit = new(
            street: "567 8th Ave.",
            city:   "Some Place",
            state:  "YY",
            zip:    "12345-7890");
    }
}

讨论

最初,C# 有一种实例化变量的方法:声明类型、变量名、new 操作符、类型和带括号的构造函数参数列表。你可以在解决方案中看到这一点,通过addressOld字段和addressLocalOld变量。

在 C# 3 的范例下,我们需要一个强类型变量(var关键字)来保存匿名类型,特别是用于 LINQ 查询。var变量需要赋值,并成为一个强类型的分配类型。有些人看到var看起来像 JavaScript 的var,并不习惯使用它。然而,正如前面所述,C# 的var变量是强类型的,这意味着你不能将变量声明为不同的类型。

除了 LINQ,var的一个方便用法是在类型实例化中出现。开发人员认识到通过使用var消除定义变量时的冗余是可行的。你可以在解决方案中看到这是如何工作的,例如addressLocalVar变量。

由于使用var在对象实例化中减少代码的流行,开发人员开始寻求在字段中使用相同的体验。然而,你不能在字段中使用var,这在解决方案中的address字段中有所示。

C# 9 通过称为目标类型的新特性解决了冗余问题。而不是使用var,目标类型的新声明了类型、标识符和带参数的new关键字。addressNew字段和addressLocalNew变量展示了这是如何工作的。现在,你可以实例化字段,而无需在同一语句中指定相同类型两次的冗余。

目标类型的新实例化是永远做的相同类型实例化的快捷语法。这意味着你仍然可以使用对象初始化程序和构造函数重载,分别显示在addressObjectInitaddressCtorInit中。

现在我们有了目标类型的新特性,有理由更喜欢使用它而不是var。第一个原因是开发者在避免使用var时存在认知犹豫,因为它与 JavaScript 的var同名 —— 尽管我们知道 C#的var是强类型的。另一个原因是,由于我们可以在变量和字段中都使用目标类型的新特性,我们在实例化类型时有了语法上的一致性。一些开发者认为在同一代码中混合使用var和目标类型的新特性会让人分心或感觉凌乱。

注意

即使你不在类型实例化时使用var,它仍然很有用。在进行 LINQ 查询时,你可以在同一方法中用匿名类型投影重新塑造数据,这就需要用到var来存储结果。

最后,将目标类型的新特性引入语言中并不一定意味着偏好直接对象实例化。正如配方 1.2 中解释的那样,IoC 是一种强大的机制,用于解耦代码,促进关注点分离,并使代码更易于测试。

另请参阅

配方 1.2, “移除显式依赖”

9.3 初始化不可变状态

问题

你需要在实例化期间仅填充不可变的属性。

解决方案

这里有一个具有不可变状态的类:

public class Address
{
    public Address() { }

    public Address(
        string street,
        string city,
        string state,
        string zip)
    {
        Street = street;
        City = city;
        State = state;
        Zip = zip;
    }

    public string Street { get; init; }
    public string City { get; init; }
    public string State { get; init; }
    public string Zip { get; init; }
}

以下是实例化不可变类的几种方式:

static void Main(string[] args)
{
    Address addressObjectInit = new()
    {
        Street = "123 4th St.",
        City = "My City",
        State = "ZZ",
        Zip = "55555-3333"
    };

    // not allowed
    //addressObjectInit.City = "A Locality";

    // target typed with ctor init
    Address addressCtorInit = new(
        street: "567 8th Ave.",
        city: "Some Place",
        state: "YY",
        zip: "12345-7890");

    // not allowed
    //addressCtorInit.Zip = "98765";
}

讨论

不可变性,即创建和操作不会改变的类型的能力,对代码的质量和正确性越来越重要。想象一下这样的情况:你将一个对象传递给一个方法,并得到相同类型的对象作为返回值。假设你不拥有该方法的代码,那么你如何知道该方法对你给定的对象做了什么?除了反编译或信任文档,你不知道。然而,如果对象是不可变的,它就不会改变,你就知道该方法没有做任何改变。

另一个不可变性的用例是多线程。死锁和竞争条件的现实长期困扰着开发者。在死锁场景中,不同线程等待对方释放另一个线程需要的资源以进行更改。在竞争条件场景中,你无法知道哪个线程会首先修改对象,导致对象状态不一致。在每种情况下,不可变性简化了情景,因为任何线程都无法更改现有对象 —— 它们必须依赖自己的副本。多线程是如此复杂的话题,无法在此深入讨论,但关键是不可变性是解决方案的一部分。

在解决方案中,Address类是不可变的。你可以用需要的数据实例化它,但其内容在那之后不能改变。请注意,属性有 getter 但没有 setter。相反,它们有 initters。initters 允许你实例化对象,但随后不能更改它。

Main 方法展示了这是如何工作的。 addressObjectInit 变量可以正常实例化,但是设置其任何属性,包括 City,都将无法通过编译。 addressCtorInit 变量展示了类似的情况。

如果您有现有类,使属性成为只读可以很有用。但是,如果您正在使用 C# 9 构建新类型,您也可以定义记录类型,正如下一篇配方所讨论的那样。

参见

配方 9.4,“创建不可变类型”

9.4 创建不可变类型

问题

您需要一个不可变的引用类型,但不想编写所有的管道代码。

解决方案

这是一个 C# 记录:

record Address(
    string Street,
    string City,
    string State,
    string Zip);

此代码显示了如何使用该记录:

static void Main(string[] args)
{
    var addressClassic = new Address(
        Street: "567 8th Ave.",
        City: "Some Place",
        State: "YY",
        Zip: "12345-7890");

    // or

    Address addressCtorInit = new(
        Street: "567 8th Ave.",
        City: "Some Place",
        State: "YY",
        Zip: "12345-7890");

    // not allowed
    //addressCtorInit.Street = "333 2nd St.";

    Console.WriteLine(
        $"Value Equal:     " +
        $"{addressClassic == addressCtorInit}");
    Console.WriteLine(
        $"Reference Equal: " +
        $"{ReferenceEquals(addressClassic, addressCtorInit)}");

    Console.WriteLine(
        $"{nameof(addressClassic)}: {addressClassic}");
    Console.WriteLine(
        $"{nameof(Address)}:        {addressCtorInit}");
}

这是输出结果:

Value Equal:     True
Reference Equal: False
addressClassic: Address
{
    Street = 567 8th Ave., City = Some Place,
    State = YY, Zip = 12345-7890
}
Address:        Address
{
    Street = 567 8th Ave., City = Some Place,
    State = YY, Zip = 12345-7890
}

这是 C# 编译器生成的合成代码:

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
using Section_09_04;

internal class Address : IEquatable<Address>
{
    protected virtual Type EqualityContract
    {
 [CompilerGenerated]
        get
        {
            return typeof(Address);
        }
    }

    public string Street { get; set; }

    public string City { get; set; }

    public string State { get; set; }

    public string Zip { get; set; }

    public Address(string Street, string City, string State, string Zip)
    {
        this.Street = Street;
        this.City = City;
        this.State = State;
        this.Zip = Zip;
        base..ctor();
    }

    public override string ToString()
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("Address");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(" ");
        }
        stringBuilder.Append("}");
        return stringBuilder.ToString();
    }

    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("Street");
        builder.Append(" = ");
        builder.Append((object?)Street);
        builder.Append(", ");
        builder.Append("City");
        builder.Append(" = ");
        builder.Append((object?)City);
        builder.Append(", ");
        builder.Append("State");
        builder.Append(" = ");
        builder.Append((object?)State);
        builder.Append(", ");
        builder.Append("Zip");
        builder.Append(" = ");
        builder.Append((object?)Zip);
        return true;
    }

    public static bool operator !=(Address? r1, Address? r2)
    {
        return !(r1 == r2);
    }

    public static bool operator ==(Address? r1, Address? r2)
    {
        return (object)r1 == r2 || (r1?.Equals(r2) ?? false);
    }

    public override int GetHashCode()
    {
        return
        (((EqualityComparer<Type>.Default.GetHashCode(EqualityContract)
        * -1521134295
        + EqualityComparer<string>.Default.GetHashCode(Street))
        * -1521134295
        + EqualityComparer<string>.Default.GetHashCode(City))
        * -1521134295
        + EqualityComparer<string>.Default.GetHashCode(State))
        * -1521134295
        + EqualityComparer<string>.Default.GetHashCode(Zip);
    }

    public override bool Equals(object? obj)
    {
        return Equals(obj as Address);
    }

    public virtual bool Equals(Address? other)
    {
        return (object)other != null
        && EqualityContract == other!.EqualityContract
        && EqualityComparer<string>.Default.Equals(Street, other!.Street)
        && EqualityComparer<string>.Default.Equals(City, other!.City)
        && EqualityComparer<string>.Default.Equals(State, other!.State)
        && EqualityComparer<string>.Default.Equals(Zip, other!.Zip);
    }

    public virtual Address <Clone>$()
    {
        return new Address(this);
    }

    protected Address(Address original)
    {
        Street = original.Street;
        City = original.City;
        State = original.State;
        Zip = original.Zip;
    }

    public void Deconstruct(
        out string Street, out string City,
        out string State, out string Zip)
    {
        Street = this.Street;
        City = this.City;
        State = this.State;
        Zip = this.Zip;
    }
}

讨论

配方 9.3 讨论了不可变性及其好处,以及如何创建不可变类。如果您有现有类型并希望将它们迁移到不可变状态,则这种方法非常有效。然而,对于想要使新代码和类型变为不可变的情况,建议考虑使用记录。

记录在 C# 9 中被引入为创建简单不可变类型的一种方式。解决方案展示了如何在 Address 记录中实现这一点。声明类型为记录,为其命名,列出属性,并以分号终止。尽管这看起来可能类似于定义构造函数或方法,但参数定义了这种新类型的属性,并遵循了常见的帕斯卡命名约定。

解决方案展示了如何实例化 Address 记录。请注意,addressCtorInit 变量不允许更改其状态,包括 Street 属性。

有关记录的一个有趣事实是它们是具有值语义的引用类型。解决方案显示,使用 == 比较 addressClassicaddressCtorInit 结果为 true。这表明了值相等性,因为两个记录的属性是相同的。但请注意 ReferenceEquals 的比较结果。它是 false,因为记录是引用类型,每个都指向内存中的不同对象。

虽然声明 Address 记录是简短快捷的,但这是 C# 编译器生成的实际代码的大幅简化。解决方案展示了带有许多成员的合成代码。该类型是一个类,并且具有一个构造函数重载,用于填充每个属性的参数。

值相等性的关键在于 IEquatable<Address> 的实现。该类具有弱类型和强类型 Equals 方法。配方 2.5 展示了如何实现 IEquatable<T>,这与此实现有些相似之处。一个区别是存储在 EqualityContract 属性中的类型。由于 C# 生成的类在 EqualsGetHashCode 中使用 EqualityContract,因此消除冗余是有道理的。

如果你读过 Recipe 3.6,你可能会觉得ToStringPrintMembers的实现很熟悉。这两个实现几乎完全相同。请注意,PrintMembersvirtual的,这允许派生类型将它们的值添加到输出中。

最后,生成的类包括一个Clone方法用于获取浅复制,一个用于将值表示为元组的解构方法,以及一个复制构造函数用于复制另一个记录。还有一个方便的方法可以获得当前对象的副本,但带有修改,下面将讨论这一点。

另请参阅

Recipe 2.5, “检查类型相等性”

Recipe 3.6, “自定义类字符串表示”

Recipe 9.3, “初始化不可变状态”

9.5 简化不可变类型赋值

问题

您需要更改对象的一个属性,但不想改变原始对象。

解决方案

我们将使用这个记录:

record Address(
    string Street,
    string City,
    string State,
    string Zip);

以下代码展示了如何复制该记录:

static void Main(string[] args)
{
    Address addressPre = new(
        Street: "567 8th Ave.",
        City: "Some Place",
        State: "YY",
        Zip: "12345-7890");

    Address addressPost =
        addressPre with
        {
            Street = "569 8th Ave."
        };

    Console.WriteLine($"Pre:  {addressPre}");
    Console.WriteLine($"Post: {addressPost}");

    Console.WriteLine(
        $"Value Equal: " +
        $"{addressPre == addressPost}");
}

讨论

如同在 Recipe 9.4 中讨论的那样,记录类型具有普通构造函数、复制构造函数和克隆方法,用于创建相同类型的新记录。这些选项很容易涵盖一个场景:获取一个具有修改的类型。也就是说,如果你希望对象上的所有内容都保持不变,只改变一个或两个属性,本节展示了一种简单的方法来实现这一点。

由于记录是不可变的,您不能修改它们的任何属性。您可以始终实例化一个新的记录并提供所有属性,但是如果只有一个属性发生变化,尤其是如果它是一个具有许多属性的对象,这种做法是很浪费的。

C# 9 中的解决方案使用了with表达式。您有一个现有记录,添加一个with表达式,仅更改需要更改的属性。这样可以获得一个带有所需更改的新记录类型的实例。

解决方案在addressPre变量上执行此操作。with表达式使用一个属性赋值块来指定需要更改的属性。此示例更改了一个属性。您也可以像使用对象初始化程序一样设置多个属性,通过逗号分隔的列表。

另请参阅

Recipe 9.4, “创建不可变类型”

9.6 为记录重用设计

问题

你需要避免重复功能。

解决方案

这是一个抽象基本记录:

public abstract record AddressBase(
    string Street,
    string City,
    string State,
    string Zip);

这两个记录派生自该抽象基本记录:

public record MailingAddress(
    string Street,
    string City,
    string State,
    string Zip,
    string Email,
    bool PreferEmail)
    : AddressBase(Street, City, State, Zip);

public record ShippingAddress : AddressBase
{
    public ShippingAddress(
        string street,
        string city,
        string state,
        string zip,
        string deliveryInstructions)
        : base(street, city, state, zip)
    {
        if (street.Contains("P.O. Box"))
            throw new ArgumentException(
                "P.O. Boxes aren't allowed");

        DeliveryInstructions = deliveryInstructions;
    }

    public string DeliveryInstructions { get; init; }
}

这是如何使用这些记录的方法:

static void Main(string[] args)
{
    MailingAddress mailAddress = new(
        Street: "567 8th Ave.",
        City: "Some Place",
        State: "YY",
        Zip: "12345-7890",
        Email: "me@example.com",
        PreferEmail: true);

    ShippingAddress shipAddress = new(
        street: "567 8th Ave.",
        city: "Some Place",
        state: "YY",
        zip: "12345-7890",
        deliveryInstructions: "Ring Doorbell");

    Console.WriteLine($"Mail: {mailAddress}");
    Console.WriteLine($"Ship: {shipAddress}");

    Console.WriteLine(
        $"Derived types equal: " +
        $"{mailAddress == shipAddress}");

    AddressBase mailBase = mailAddress;
    AddressBase shipBase = shipAddress;
    Console.WriteLine(
        $"Base types equal: " +
        $"{mailBase == shipBase}");
}

讨论

在 C#中实现重用的一种方式是通过继承。记录支持与类相同的继承方式。

解决方案有一个名为 AddressBase 的记录。顾名思义,AddressBase 旨在作为基础记录。AddressBase 还是抽象的,阻止直接实例化。它具有所有派生类型通用的属性。

MailingAddressShippingAddress 派生自 AddressBase,使用类似类的继承语法。不同之处在于继承的记录声明包括参数列表,指示从派生记录中匹配基础记录的哪些参数。

MailingAddress 是基于 AddressBase 特化的,具有两个新属性:EmailPreferEmailShippingAddress 也是基于 AddressBase 特化的,额外增加了一个 DeliveryInstructions 属性。

ShippingAddress 的定义不同,因为它显式定义了成员,而不是使用默认的记录语法。它有一个构造函数,类似于 C# 类,将参数传递给基类 AddressBaseShippingAddress 构造函数中包含验证代码,用于防止无效初始化而抛出异常。在这种情况下,它强制执行逻辑,即邮政信箱不是交付商品的地方。构造函数还初始化了 DeliveryInstructions 属性。这表明,虽然默认记录语法简化了代码,但你仍然可以根据需要定制记录。

在定制记录时,可以添加任何类可能具有的成员。此外,还可以重写等式、ToString 输出或构造函数的默认实现。此外,像 ShippingAddress 一样的定制并不会阻止 C# 编译器生成默认记录实现。

9.7 返回不同的方法覆盖类型

问题

要覆盖基类方法,但需要返回更具体的类型。

解决方案

这是我们想要处理的记录:

public abstract record AddressBase(
    string Street,
    string City,
    string State,
    string Zip);

public record MailingAddress(
    string Street,
    string City,
    string State,
    string Zip,
    string Email,
    bool PreferEmail)
    : AddressBase(Street, City, State, Zip);

public record ShippingAddress : AddressBase
{
    public ShippingAddress(
        string street,
        string city,
        string state,
        string zip,
        string deliveryInstructions)
        : base(street, city, state, zip)
    {
        if (street.Contains("P.O. Box"))
            throw new ArgumentException(
                "P.O. Boxes aren't allowed");

        DeliveryInstructions = deliveryInstructions;
    }

    public string DeliveryInstructions { get; init; }
}

这个基类有一个方法,返回一个基础记录:

abstract class DeliveryBase
{
    public abstract AddressBase GetAddress(string name);
}

这些类具有返回派生记录的方法:

class Communications : DeliveryBase
{
    public override MailingAddress GetAddress(string name)
    {
        return new(
            Street: "567 8th Ave.",
            City: "Some Place",
            State: "YY",
            Zip: "12345-7890",
            Email: "me@example.com",
            PreferEmail: true);
    }
}

class Shipping : DeliveryBase
{
    public override ShippingAddress GetAddress(string name)
    {
        return new(
            street: "567 8th Ave.",
            city: "Some Place",
            state: "YY",
            zip: "12345-7890",
            deliveryInstructions: "Ring Doorbell");
    }
}

该代码展示了如何使用那些返回派生记录的派生类。

static void Main(string[] args)
{
    Communications comm = new();
    MailingAddress mailAddr = comm.GetAddress("Person A");
    Console.WriteLine(mailAddr);

    Shipping ship = new();
    ShippingAddress shipAddr = ship.GetAddress("Person B");
    Console.WriteLine(shipAddr);
}

讨论

以前方法覆盖要求返回与基类虚方法返回类型相同。问题在于,派生类经常需要从其覆盖中返回特定信息。替代方案很丑陋:

  1. 创建一个新的非多态方法。

  2. 返回基础类型。

  3. 返回一个从基础返回类型派生的类型,并期望调用者进行转换。

这些选择都不是最佳的,幸运的是,C# 9 通过协变返回类型提供了解决方案。

解决方案有两组类型层次结构:一组用于返回类型,另一组用于方法多态性。AddressBase 及其两个派生记录 MailingAddressShippingAddress 代表返回类型。DeliveryBase 类及其派生类 CommunicationsShipping 具有多态操作的 GetAddress 方法。

注意

注意GetAddress的实现如何返回目标类型的新实例。编译器通过上下文推断类型,这些示例中的返回类型是它。您可以在食谱 9.2 中了解更多关于目标类型新实例的信息。

在 C# 9 之前,在 CommunicationsShipping 中的 GetAddress 将被迫返回 AddressBase。然而,通过查看解决方案实现,在 CommunicationsShipping 中的 GetAddress 分别返回 MailingAddressShippingAddress

参见

食谱 9.2,“减少实例化语法”

9.8 实现迭代器作为扩展方法

问题

您需要在一个您无法访问代码的第三方类型上添加一个迭代器。

解决方案

这里有一个我们无法访问第三方库的类型的定义:

public record Address(
    string Street,
    string City,
    string State,
    string Zip);

这个类有一个针对该类型的枚举器扩展方法:

public static class AddressExtensions
{
    public static IEnumerator<string> GetEnumerator(
        this Address address)
    {
        yield return address.Street;
        yield return address.City;
        yield return address.State;
        yield return address.Zip;
        yield break;
    }
}

这是如何使用该枚举器的方法:

class Program
{
    static void Main()
    {
        IEnumerable<Address> addresses = GetAddresses();

        foreach (var address in addresses)
        {
            foreach (var line in address)
                Console.WriteLine(line);

            Console.WriteLine();
        }
    }

    static IEnumerable<Address> GetAddresses()
    {
        return new List<Address>
        {
            new Address(
                Street: "567 8th Ave.",
                City: "Some Place",
                State: "YY",
                Zip: "12345-7890"),
            new Address(
                Street: "569 8th Ave.",
                City: "Some Place",
                State: "YY",
                Zip: "12345-7890")
        };
    }
}

讨论

有时,将迭代器添加到对象中会很方便。这样做可以将解剖、转换和返回对象数据的关注点与希望集中在解决业务问题的消费代码分离开来。如果您拥有对象的代码并希望循环访问其内容,则添加迭代器。然而,如果对象来自第三方且您无法访问其代码,则通常被迫在业务代码中添加冗余逻辑。在 C# 9 中,您现在可以作为扩展方法添加 GetEnumerator 方法。

在解决方案中,Address 记录是我们想要迭代的对象。更具体地说,我们想要遍历 Address 记录的成员,类似于您可以遍历 JavaScript 对象的属性的方式。

AddressExtensions 方法有一个名为 GetEnumerator 的扩展方法,接受一个 Address 参数并返回一个 IEnumerable<T>this 参数的使用方式与任何其他扩展方法相同,指定要操作的类型和实例。迭代器的模式是该方法必须命名为 GetEnumerator,并且必须返回一个 IEnumerator<T>。类型 T 可以是您选择的任何类型——您所需要的类型。在这个例子中,Tstring。这意味着您需要将每个属性转换为一个字符串,在 Address 中这并不是问题,因为所有属性都已经是字符串。与 C# 迭代器实现一致,AddressExtensionsGetEnumerator 方法使用 yield return 返回每个值,并使用 yield break 表示迭代结束。

在获取 Address 列表之后,Main 方法有一个嵌套的 foreach 循环,其中内部的 foreachAddress 的一个实例上进行迭代。由于扩展方法的存在,foreach 在处理 address 时与数组和集合的操作方式相同——不需要额外的语法。

9.9 切片数组

问题

您想使用范围来浏览数据。

解决方案

我们将使用这个记录:

public record Address(
    string Street,
    string City,
    string State,
    string Zip);

这个方法填充了一个记录数组:

Address[] GetAddresses()
{
    int count = 15;
    List<Address> addresses = new();

    for (int i = 0; i < count; i++)
    {
        string streetSuffix =
            i switch
            {
                0 => "st",
                1 => "nd",
                2 => "rd",
                _ => "th"
            };

        addresses.Add(
            new(
            Street: $"{i+100} {i+1}{streetSuffix} St.",
            City: "My Place",
            State: "ZZ",
            Zip: "12345-7890"));
    }

    return addresses.ToArray();
}

这个方法通过切片数组记录进行分页:

public IEnumerable<Address[]> GetAddresses(int perPage)
{
    Address[] addresses = GetAddresses();

    for (int i = 0, j = i+perPage;
         i < addresses.Length;
         i+=perPage, j+=perPage)
    {
        yield return addresses[i..j];
    }
}

这段代码迭代记录的各个页面:

static void Main()
{
    AddressService addressSvc = new();

    foreach (var addresses in
        addressSvc.GetAddresses(perRow: 3))
    {
        foreach (var address in addresses)
        {
            Console.WriteLine(address);
        }

        Console.WriteLine("\nNew Page\n");
    }
}

讨论

自从 C# 8 以来,对数组进行切片变得更加容易。指定开始索引,连接两个点,并指定最后索引。

这个解决方案从分页的角度看待切片问题。我们使用的一些应用程序按行数或行中的列数分页。这个解决方案每页显示三个Address实例。

GetAddresses方法有两个重载版本。第一个无参数版本生成唯一的地址。

第二个GetAddresses重载是一个接受int参数perPage的迭代器,指示方法一次返回多少个Address实例。获取Address实例列表后,for循环控制对列表的迭代。for初始化器将i设置为第一个Addressj设置为最后一个Address加一。由于i是范围的开始,for条件确保i不超过数组的大小。for增量器调整ij到下一组Address实例(即下一页)。

GetAddresses(int perPage)是一个迭代器,表明它返回类型为IEnumerable<Address[]>并使用yield return来返回结果。而 9.8 节展示了如何将迭代器作为扩展方法添加,本例假设你可以访问代码并直接将迭代器添加到代码中更为合适。

Main方法展示了如何使用GetAddresses(int perPage)迭代器,返回从原始Address[]中切片的页面。

参见

9.8 节,“将迭代器实现为扩展方法”

9.10 初始化整个模块

问题

你需要 IoC 来在类库上工作,而不依赖于调用者正确实现。

解决方案

这是一个返回记录的存储库:

public record Address(
    string Street,
    string City,
    string State,
    string Zip);

public interface IAddressRepository
{
    List<Address> GetAddresses();
}

public class AddressRepository : IAddressRepository
{
    public List<Address> GetAddresses() =>
        new List<Address>
        {
            new (
                Street: "123 4th St.",
                City: "My Place",
                State: "ZZ",
                Zip: "12345-7890"),
            new (
                Street: "567 8th Ave.",
                City: "Some Place",
                State: "YY",
                Zip: "12345-7890"),
            new (
                Street: "567 8th Ave.",
                City: "Some Place",
                State: "YY",
                Zip: "12345-7890")
        };
}

这个模块初始化器配置了一个 IoC 容器:

class Initializer
{
    internal static ServiceProvider Container { get; set; }

 [ModuleInitializer]
    internal static void InitAddressUtilities()
    {
        var services = new ServiceCollection();
        services.AddTransient<AddressService>();
        services.AddTransient<IAddressRepository, AddressRepository>();
        Container = services.BuildServiceProvider();
    }
}

这个服务依赖于 IoC 容器:

public class AddressService
{
    readonly IAddressRepository addressRep;

    public AddressService(IAddressRepository addressRep) =>
        this.addressRep = addressRep;

    public static AddressService Create() =>
        Initializer.Container.GetRequiredService<AddressService>();

    public List<Address> GetAddresses() =>
        (from address in addressRep.GetAddresses()
         select address)
        .Distinct()
        .ToList();
}

这个Main方法是一个使用该服务的客户端:

static void Main()
{
    AddressService addressSvc = AddressService.Create();

    addressSvc
        .GetAddresses()
        .ForEach(address =>
            Console.WriteLine(address));
}

讨论

C# 9 添加了一个称为模块初始化的功能。基本上,这允许您为一个程序集添加任何类型的初始化代码。此初始化代码在程序集中的任何其他代码之前运行。

乍一看,这听起来可能有些奇怪,因为控制台、Windows 窗体和 WPF 应用程序都有Main方法。即使所有版本的 ASP.NET 和 Web API 也有启动代码。我并不是说这些技术没有用武之地,但对于普通的专业开发人员来说,这似乎是一个罕见的事件。

注意

自 C# 1 以来存在的另一种初始化技术是使用静态构造函数。静态构造函数只有在代码通过类型或实例访问类成员时才运行。因此,静态构造函数不能有效替代模块初始化,因为可能永远不会访问该类的成员,静态构造函数也永远不会运行。

话虽如此,有一组涉及类库的用例。问题始终是您不知道消费代码将如何使用您的库。您可以记录并设置一个合同,该合同表示用户必须以某种方式调用某些方法或启动库,这是您能够保证对初始化进行任何类型控制的最接近方法。

模块初始化的变化使得您可以更好地控制如何初始化库代码,而不管用户做了什么。该解决方案解决了确保 IoC 在任何代码运行之前得到初始化的问题。

AddressService 类提供了两种实例化自身的方式,可以通过 IoC 或使用 Create 方法。用户可以选择是否使用 IoC。好处在于 IoC 对于库开发者来说也成为一个选项,便于编写单元测试和编写可维护的代码。

Recipe 1.2 解释了 IoC 的工作原理,使用 Microsoft.Extensions.DependencyInjection,而此解决方案使用相同的库和技术。主要区别在于 IoC 容器的配置位置。

Initializer 类有一个名为 InitAddressUtilities 的方法。ModuleInitializer 属性表明 InitAddressUtilities 是此类库的模块初始化代码。InitAddressUtilities 方法将在类库中的任何其他代码之前运行。

注意

在 .NET 早期,模块是将代码组合到单个文件中的一种方式,用于模块化。您可以将模块组合成一个装配体,其中装配体定义为一个或多个模块及一个额外的清单。清单包含 CLR 的元数据,其详细信息繁多且对当前重点不重要。

使用模块在很大程度上是一种理论能力,因为大多数代码都编译为一个装配体的单一模块。这是 C# 编译器和 Visual Studio 的默认行为。事实上,你必须费劲去创建一个潜在有用的模块。虽然 ModuleInitializer 中有“module”一词,但实际情况是它适用于装配体级别的初始化。

因为 InitAddressUtilities 方法已经运行,所以 AddressService 中的 Create 方法可以依赖于 Initializer.Container 具有有效的容器引用来解析 AddressService 实例。

参见

Recipe 1.2,“Removing Explicit Dependencies”

第十章:概要

从很多方面来看,这本书反映了我自己的职业生涯。每一个配方或多或少都代表了多年来我和其他人在解决问题时的解决方案。然而,它不仅如此,因为每一个配方、章节乃至整本书背后的思维过程都是启发性的。我们今天编写代码的方式已经发生了很大变化。我们编写的应用程序类型也不同了。

C#语言诞生于互联网发展的时代,早期 2000 年代的互联网泡沫几乎是不可想象的。它的创立动机源于微软与 Sun Microsystems 之间关于 Java 编程语言的法律纠纷。微软需要一种面向组件的编程语言来支持新的.NET 平台。当时正值分布式计算的初期阶段,专有远程技术和 XML Web 服务的愿景已经来去匆匆。如今是一个截然不同的世界。

在接下来的几年里,我们见证了彻底改变计算机界面貌的革命。移动电话演变为拥有比原始 IBM PC 更多计算能力的智能手机。应用程序和整个企业从托管服务转向云端。客户端/服务器和初期的分布式计算模型发展成为采用微服务架构和无服务器计算的大规模本地云应用程序。我们构建的应用程序类型也不同了。

因此,随着计算世界的演变,我们使用的编程语言和工具必须接纳这种变化。这本书的目标就是如此,我希望它能帮助到你。能与你共享这段旅程我感到非常荣幸,祝愿你在 C#开发职业生涯中一切顺利。

posted @ 2024-06-18 17:53  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报