C# 秘籍(一)
原文:
zh.annas-archive.org/md5/a8f8c1cbab144b65246bf82de72f5bb5译者:飞龙
前言
我为什么写这本书
在职业生涯中,我们收集了许多工具。无论是概念、技术、模式还是可重用代码,这些工具帮助我们完成工作。我们收集的越多,越好,因为我们有很多问题要解决,要构建的应用程序也很多。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方法重载,用于跟踪要执行的处置类型以及一个可选的终结器。
复杂化实现的是控制处置逻辑的字段和参数:disposed和disposing。disposed字段确保此对象仅被处置一次。在Dispose(bool)方法内部,有一个if语句,确保如果disposed为true(对象已处置),则不会执行任何处置逻辑。通过Dispose(bool)的第一次,disposed将为false,并且if块中的代码将执行。确保您还设置disposed为true,以确保此代码不再运行——不这样做的后果将暴露于像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 首先释放时出现ObjectDisposedException。
最终器给你的是清理非托管资源的方法。非托管资源,如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 操作的顺序。
图 1-1. 解决方案的 IoC
解决方案是一个实用工具,帮助管理部署过程,验证部署过程是否配置正确。它有一个 DeploymentService 类来运行该过程。注意 DeploymentService 构造函数接受 DeploymentArtifacts 和 DeploymentRepository 类。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,注意到有一个 AddTransient 将 DeploymentService 类与 IDeploymentService 接口关联起来。这意味着 GetRequiredService 将返回一个 DeploymentService 的实例。
最后,Main 实例化 Program,带有新的 DeploymentService 实例。
回到 DeploymentService 的构造函数,你可以看到它期望使用 DeploymentArtifacts 和 DeploymentRepository 的实例来调用。因为我们使用了 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都实现了抽象基类DeploymentManagementBase。DeploymentManagementBase编排逻辑,而派生的DeploymentManager类只是其关联插件的工厂。请注意,DeploymentManagementBase使用多态性让派生类实例化其各自的插件类。
提示
如果这变得有点复杂,您可能需要查看配方 1.2 和 1.3。这是比那高一个抽象级别。
解决方案展示了实现IDeploymentPlugin接口的两个类。DeploymentManagementBase类消费IDeploymentPlugin接口,将调用委托给实现该接口的插件类的方法。请注意Validate如何调用IDeploymentPlugin实例上的Validate方法。
Program不知道插件类。它操作DeploymentManagementBase的实例,正如Main调用GetPlugins并接收DeploymentManagementBase实例数组所示。Program不关心插件。为简化演示,GetPlugins是Program中的一个方法,但可以是另一个具有选择要使用的插件机制的类。请注意Run方法如何遍历DeploymentManagementBase实例。
注意
如果您在使用接口的所有其他地方,DeploymentManagementBase实现接口可能会使 IoC 更一致。也就是说,一个抽象基类通常适用于大多数 IoC 容器、模拟和单元测试工具。
总结一下,DeploymentManagementBase封装了所有功能,并委托工作给插件类。编写插件的代码是部署管理器、插件接口和插件类。消费代码只与一组DeploymentManagementBase一起工作,并且对特定插件实现毫不知情。
这就是力量所在。每当您或允许的任何第三方希望为新类型的部署扩展系统时,他们就会这样做:
-
创建一个新的实现
IDeploymentPlugin接口的DeploymentPlugin类。 -
创建一个新的从
DeploymentManagementBase派生的DeploymentManagement类。 -
实现
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 层,由SignIn和Menu类组成。这些类处理与用户的交互,但将任何逻辑委托给业务逻辑层。Program可能被视为 UI 层的一部分,尽管它仅在其他 UI 层类之间进行交互/导航,并不执行 UI 操作本身。
注意
我写解决方案的方式是让你在使用类的定义之前看到它。然而,在实际设计时,你可能会从 UI 层开始,然后逐步通过业务逻辑和数据访问进行工作。
在这段代码中,关注点分离有几个方面。GreetingRepository只关注数据,特别是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.Console与SystemApp.Data有一个程序集引用。
图 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类,它是一个不可变的引用类型,其成员是属性。ValueTuple和Tuple都命名成员为Item1、Item2,...,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命名空间,其中包含像Dictionary、List和Stack这样的集合,这些集合操作的是object类型的实例。如果集合中的实例是引用类型,那么从对象到对象的转换性能是可以忽略的。然而,如果你想管理值类型的集合,装箱/拆箱的性能代价将随着集合的增大或执行的操作增多而变得更加严重。
Microsoft 一直打算在 C# 2 中添加泛型,最终也确实实现了。但在此期间,开发人员需要编写大量非泛型代码,例如集合、优先队列和树数据结构。还有像委托这样的类型,它们是方法引用和异步通信的主要手段,操作对象。有很长一段非泛型代码列表已经编写,并且很可能在您的职业生涯中会遇到其中的一些。
作为 C#开发人员,我们欣赏强类型代码的好处,它使查找和修复编译时错误更容易,使应用程序更易于维护并提高质量。因此,您可能强烈希望重构给定的某段非泛型代码,使其也能够使用泛型。
过程基本上是这样的:每当看到object类型时,将其转换为泛型类型。
解决方案展示了一个Deployment对象,该对象对部署的工件执行健康检查。由于我们有多个工件,我们还需要在一个集合中持有多个Deployment实例。该集合是一个(部分实现的)循环队列,还有一个HealthCheck类,该类循环遍历队列并定期与下一个Deployment实例执行健康检查。
HealthCheckObject操作旧的非泛型代码,而HealthCheckGeneric操作新的泛型代码。两者之间的区别在于,HealthCheckObject的Configure方法实例化一个非泛型的CircularQueue,而HealthCheckGeneric的Configure方法实例化一个泛型的CircularQueue<T>。我们的主要任务是将CircularQueue转换为CircularQueue<T>。
因为我们正在处理一个集合,第一步是向类CircularQueue<T>添加类型参数。然后查找代码中使用object类型的地方,并将其转换为类类型参数T:
-
将
object items[]字段转换为T items[]。 -
在构造函数中,实例化一个新的
T[]而不是object[]。 -
将
Add方法的参数从object更改为T。 -
将
Next方法的返回类型从object更改为T。 -
在
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)互操作使用本地代码,并且不希望该接口的细节渗入到您的代码中会怎么样?这些情况都是考虑使用适配器的良好候选者。
解决方案中有实现IDeploymentService的DeploymentService类。您可以在Program的Start方法中看到,它仅操作实现了IDeploymentService的实例。
之后的某个时候,您需要将ThirdPartyDeploymentService集成到应用程序中。然而,它没有实现IDeploymentService,而且您没有ThirdPartyDeploymentService的代码。
ThirdPartyDeploymentAdapter类解决了这个问题。它实现了IDeploymentService接口,并实例化了自己的ThirdPartyDeploymentService副本,Validate方法委托调用了ThirdPartyDeploymentService。请注意,Program的Configure方法将一个ThirdPartyDeploymentAdapter实例添加到Start操作的集合中。
这是一个演示,向您展示如何设计适配器。在实践中,ThirdPartyDeploymentService的PerformValidation方法可能具有不同的参数和不同的返回类型。ThirdPartyDeploymentAdapter的Validate方法将负责准备参数并重新塑造返回值,以确保它们符合适当的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 具有一个属性,其枚举类型为 ValidationFailureReason。除了有关抛出异常原因的独特语义外,自定义异常的另一个目的是包含重要的异常处理和/或调试信息。
覆盖 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 中提取 Cost 和 Description 并将其连接到一个增长的字符串中。连接几个字符串可能不会被注意到。但是,想象一下如果有 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 |
| 各种商业和开源提供者 | |
| 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 保持逻辑局部化
问题
算法具有复杂的逻辑,最好将其重构为另一个方法,但这些逻辑实际上只在一个地方使用。
解决方案
程序使用了CustomerType和InvoiceItem:
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接口。它包含每个实现类必须定义的必需方法(或合同)。你可以看到,BankInvoice、EnterpriseInvoice和GovernmentInvoices都实现了IInvoice。
GetInvoices模拟了您将从数据源填充发票的情况。每当需要通过添加新的IInvoice派生类型来扩展框架时,这是唯一会改变的代码。因为所有类都是IInvoice,所以它们都可以通过同一个IEnumerable<IInvoice>集合返回。
注意
即使GetInvoices实现是在List<IInvoice>上操作的,它却从GetInvoices中返回了一个IEnumerable<IInvoice>。通过在这里返回一个接口IEnumerable<T>,调用者不对底层集合实现做任何假设。这样一来,如果将来GetInvoices的另一个实现更适合另一种实现类型,那么代码就可以更改而不更改方法签名,并且不会破坏调用代码。
最后,请检查Main方法。它迭代每个IInvoice对象,调用其方法。Main不关心具体的实现是什么,因此其代码永远不需要改变以适应特定实例的逻辑。你不需要为特殊情况编写if或switch语句,这样会在维护时导致代码复杂难以维护。任何未来的更改将涉及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> 接口,其中 T 是 Invoice。尽管 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。然后检查 this 和 other 是否具有引用相等性,这显然意味着它们是相等的。然后,如果对象不是相同类型,则不被认为是相等的。最后,返回要比较的值的结果。在这个例子中,唯一有意义的是 CustomerID 和 Date。
注意
Equals(Invoice other) 方法中可以改变的一部分是类型检查。您可能会根据应用程序的要求有不同的看法。例如,如果希望即使 other 是派生类型也能检查相等性,则更改逻辑以接受派生类型。
Main 方法处理发票,确保我们不会将重复的发票添加到列表中。循环调用集合的 Contains 方法,检查对象的相等性。如果没有匹配的对象,Contains 将新的 Invoice 实例添加到 invoicesToProcess 列表中。运行程序时,在 allInvoices 中存在四张发票,但只有三张添加到 invoicesToProcess 中,因为在 allInvoices 中有一个重复(基于 CustomerID 和 Created)。
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);
}
讨论
很难判断您将如何多次遇到迭代算法,其复杂逻辑和循环操作的条件。for、foreach 和 while 这样的循环是熟悉且经常使用的,即使有更优雅的解决方案存在。我并不是在暗示循环有什么问题,它们是语言工具集的重要部分。然而,对于给定情况,扩展我们的思维以探索其他可能更优雅和可维护的代码技术是有用的。有时候,像集合的 ForEach 操作符上的 lambda 表达式这样的声明性方法是简单明了的。LINQ 是处理内存中对象集合的良好解决方案,这是 第四章 的主题。递归是本节的另一种选择。
我在这里要表达的主要观点是,我们需要根据具体情况编写使用最自然的技术的算法。很多算法确实自然地使用循环,如遍历集合。其他任务可能需要递归。处理层次结构的一类算法可能非常适合使用递归。
此解决方案展示了递归简化处理和使代码清晰的一个领域。它基于计费处理类别列表。请注意,BillingCategory类具有ID和Parent两个属性。这些属性管理层次结构,其中Parent标识父类别。任何具有null Parent的BillingCategory都是顶级类别。这是单表关系数据库(DB)表示的分层数据。
GetBillingCategories展示了BillingCategories如何从数据库中获取。它是一个平面结构。请注意,Parent属性引用它们的父BillingCategory的 ID。关于数据的另一个重要事实是父子之间没有明确的排序。在实际应用中,你将从给定的类别集开始,并随后添加新的类别。同样,随着时间在代码和数据的维护中变化,这会改变我们对算法设计的方法,从而复杂化迭代解决方案。
这个解决方案的目的是将平面类别表示转换为另一个列表,该列表表示类别之间的层次关系。这是一个简单的解决方案,但你可以想象一个基于对象的表示,其中父类别包含一个子类别集合。执行此操作的递归算法是BuildHierarchy方法。
BuildHierarchy方法接受三个参数:categories、catID和level。categories参数是来自数据库的平面集合,每次递归调用都会接收到对同一集合的引用。一个潜在的优化可能是移除已经处理过的类别,尽管演示避免了任何会分散注意力的内容。catID参数是当前BillingCategory的ID,代码正在寻找其Parent匹配catID的所有子类别,正如foreach循环内部的if语句所示。level参数有助于管理每个类别的视觉表示。if块内的第一条语句使用level确定在类别名称前加上多少制表符(\t)。每次递归调用BuildHierarchy时,我们会增加level,以便子类别比其父类别缩进更多。
算法使用相同的类别集合调用BuildHierarchy。此外,它使用当前类别的ID,而不是catID参数。这意味着它递归调用BuildHierarchy,直到达到最底层的类别。它会通过foreach循环完成且没有新的类别,因为当前(最底层)类别没有子类别时,确定它位于层次结构的底部。
到达底部后,BuildHierarchy 返回并继续 foreach 循环,收集 catID 下的所有类别——即它们的 Parent 是 catID。然后将任何匹配的子类别附加到调用 BuildHierarchy 的 found 集合中。这将继续,直到算法达到顶层并处理所有根类别。
注意
此解决方案中的递归算法称为深度优先搜索(DFS)。
到达顶层后,BuildHierarchy 将整个集合返回给其原始调用者,即 Main。Main 最初使用整个平面 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 纪元开始的毫秒数。由于我们在毫秒级别工作,TimeSpan将DateTime的刻度转换为毫秒。为了进行转换,我们从.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方法通常会进行数据库查询并返回结果。但是,在这个例子中,只有在invoiceCategories为null时才执行数据库查询,并将结果缓存到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<InvoiceRepository>。它假设 InvoiceRepository 有一个默认构造函数(无参数),并在代码访问 Value 属性时调用它来创建一个新实例。
InvoiceRepFactory 字段实例引用了 CreateInvoiceRepositoryInstance 方法。当代码访问此字段时,它调用 CreateInvoiceRepositoryInstance 来构造对象。由于它是一个方法,你在构建对象时有很大的灵活性。
除了其他两个选项之外,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 集合的 Invoice。GetInvoiceTransferFile 方法展示了数据的格式。演示表明数据可能来自已经生成了该格式的遗留系统,使用 C# 代码来接收比在该系统中添加支持更好的格式更容易。我们要提取的具体数据是 created 日期、发票 items 和 customer 名称。注意换行符 (\n) 分隔记录,双冒号 (::) 分隔发票字段,制表符 (\t) 分隔发票项字段。
GetInvoice 和 GetLineItems 方法从提取的数据构造对象,并用于将对象构建与正则表达式提取逻辑分离。
ParseInvoices 方法使用正则表达式从输入字符串中提取数值。RegEx 构造函数参数包含用于提取数值的正则表达式字符串。
虽然讨论整个正则表达式的内容超出了范围,但这里是该字符串的功能:
-
^表示从字符串的开头开始。 -
.+?::匹配所有字符,直到下一个发票字段分隔符 (::)。换句话说,它忽略了匹配的内容。 -
(?<created>.+?)::、(?<items>.+?)::和(?<customer>.+?)::类似于.+?)::,但进一步通过给定名称提取值到组中。例如,(?<created>.+?)::表示将提取所有匹配的数据并放入名为“created”的组中。 -
.+匹配所有剩余字符。
foreach 循环依赖于字符串中的 \n 分隔符来处理每个发票。Match 方法执行正则表达式匹配,提取数值。如果匹配成功,代码从组中提取数值,调用 GetInvoice 方法,并将新发票添加到 invoices 集合中。
您可能已经注意到,我们使用GetLineItems从matchItems参数中提取数据,来自正则表达式items字段。我们本可以使用更复杂的正则表达式来处理这个问题。然而,这是有意为之,用来对比展示在这种情况下正则表达式处理更为优雅的解决方案。
提示
作为增强功能,如果您关心数据丢失或者想知道正则表达式或原始数据格式中是否存在 bug,可以记录任何match.Success为false的情况。
最后,应用程序将新的行项目返回给调用代码Main,以便保存它们。