C--重构指南-全-
C# 重构指南(全)
原文:
zh.annas-archive.org/md5/86522c3a5c750f855fd3744792f9960e译者:飞龙
前言
软件项目很快就会从绿色田野的乌托邦转变为充满遗留代码和技术债务的棕色田野荒地。每个工程师都会遇到由于现有技术债务而比预期更难的项目。本书涵盖了将现有代码重构为更易于维护形式的过程。
在《使用 C#重构》中,我们专注于使用现代 C#和 Visual Studio 功能以可持续的方式安全地偿还技术债务,同时继续为业务创造价值。
本书面向的对象
本书面向两种不同类型的读者。
第一种是职业生涯早期几年的初级和中级 C#开发者。本书将教你所需的编程技术和心态,以在职业生涯中取得进步。你将学习如何安全地重构你的代码,并找到改进代码整体结构的新方法。
第二种读者类型是处理特别棘手的代码库、项目或组织的软件工程师或工程经理。本书将帮助你提出重构的论点,确保你可以安全地进行重构,并提供所有或全无的重写方法的替代方案。
本书还介绍了一些你可能最近没有遇到或考虑过的库和语言特性。我希望这本书能给你带来新的视角、工具和技术,帮助你重构代码并构建更好的代码库。
本书涵盖的内容
第一章,技术债务、代码异味和重构,向读者介绍了技术债务的概念及其原因。本章涵盖了遗留代码及其对开发过程的影响,以及帮助你找到它的代码异味。本章以重构的概念结束,这是本书其余部分的重点。
第二章,重构简介,通过一个示例代码片段,逐步使用内置的重构和自定义操作来展示在 Visual Studio 中重构 C#代码的过程。
第三章,重构代码流和迭代,专注于重构单个代码行和代码块。我们关注程序流程控制、对象实例化、处理集合以及适当地使用 LINQ。
第四章,方法级别的重构,通过重构方法和构造函数到更易于维护的形式,扩展了上一章的范围。在类内保持一致性,构建小型、易于维护的方法是核心关注点。
第五章,面向对象重构,将之前重构章节中的思想应用于整个类级别。这展示了引入接口、继承、多态和其他类一般如何导致更好的代码模式和更易于维护的软件系统。
第六章,单元测试,作为 C#单元测试的入门介绍,快速从单元测试的概念过渡到如何使用 xUnit、NUnit 和 MSTest 编写单元测试的教程。我们还涵盖了参数化测试和单元测试最佳实践。
第七章,测试驱动开发,通过遵循 TDD 过程来改进代码和实施重构,向读者介绍了测试驱动开发和红/绿/重构。这里还讨论了代码生成快速操作。
第八章,使用 SOLID 避免代码反模式,关注了使代码好或坏的因素,以及 SOLID、DRY 和 KISS 等常见模式如何帮助使代码更能抵抗技术债务。
第九章,高级单元测试,涵盖了用于数据生成、模拟、固定现有行为以及通过 A/B 测试安全地做出更改的各种测试库。我们介绍了 Bogus、Fluent Assertions、Moq、NSubstitute、Scientist .NET、Shouldly 和 Snapper。
第十章,防御性编码技术,展示了 C#语言的各种功能,可以使代码更加可靠并抵抗缺陷。本章涵盖了可空性、验证、不可变性、记录类、模式匹配等。
第十一章,使用 GitHub Copilot 进行 AI 辅助重构,向读者介绍了 Visual Studio 中 GitHub Copilot Chat 的最新 AI 工具。本章展示了读者如何使用 GitHub Copilot Chat 生成代码、提供重构建议、编写草稿文档,甚至帮助测试代码。我们还强调了数据隐私问题以及保护公司知识产权的方法。
第十二章,Visual Studio 中的代码分析,通过展示代码分析配置文件如何帮助检测代码中的问题,突出了现代.NET 中内置的代码分析器。我们还探讨了代码指标,并使用这些指标优先考虑技术债务区域。本章最后探讨了 SonarCloud 和 NDepend 工具,这些工具可以帮助跟踪随时间推移的技术债务。
第十三章,创建 Roslyn 分析器,介绍了自定义 Roslyn 分析器,它可以检测代码中的问题。本章引导读者编写他们的第一个分析器,使用 RoslynTestKit 进行单元测试,并使用 Visual Studio 扩展进行部署。
第十四章,使用 Roslyn 分析器重构代码,展示了 Roslyn 分析器也可以修复它们检测到的问题。本章在前一章的基础上继续,通过扩展分析器提供代码修复功能。然后我们讨论了将分析器打包到 NuGet 包中并在 NuGet.org 或其他 NuGet 源上发布它们。
第十五章,沟通技术债务,介绍了以业务领导者能够理解的方式跟踪和报告技术债务的系统化过程。我们讨论了许多常见的重构障碍以及建立信任和透明度的文化,以便业务管理能够理解技术债务所代表的风险。
第十六章,采用代码标准,讨论了确定适合您开发团队的代码标准以及获得开发者认可的过程。本章涵盖了 Visual Studio 中的代码样式、代码清理配置文件以及共享 EditorConfig 文件以促进团队内一致的样式选择。
第十七章,敏捷重构,通过讨论敏捷环境中的重构以及敏捷可能对重构提出的独特挑战来结束本书。我们讨论了在敏捷冲刺中优先考虑和偿还技术债务的方法。本章还涵盖了更大的项目,如升级和重写,以及帮助这些大型项目成功的途径。
要充分利用本书
理想读者应熟悉 C#编程语言和 Visual Studio IDE。了解面向对象编程、类和 LINQ 将特别有帮助。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Visual Studio 2022 v17.8 或更高版本 | Windows |
| .NET 8 SDK |
本书适用于从 2022 年 v17.8 版 Visual Studio 的任何版本,包括 Visual Studio Community。您可以从visualstudio.microsoft.com/downloads/下载 Visual Studio。
您可以从dotnet.microsoft.com/en-us/download/dotnet/8.0下载.NET 8 SDK 的最新版本。
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
许多章节提供了逐步说明,您可以通过使用章节开头的代码来跟随这些说明,从而生成章节最终代码文件夹中的代码。您也可以在阅读本书的同时关注您正在使用的其他代码,并思考这些主题如何应用于该代码。然而,您可能希望在阅读了涵盖安全测试代码的章节之后,再对现实世界的代码库应用您的重构技术。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Refactoring-with-CSharp。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“让我们再次看看之前的IFlightUpdater接口。”
代码块如下设置:
public interface IFlightRepository {
FlightInfo AddFlight(FlightInfo flight);
FlightInfo UpdateFlight(FlightInfo flight);
void CancelFlight(FlightInfo flight);
FlightInfo? FindFlight(string id);
IEnumerable<FlightInfo> GetActiveFlights();
IEnumerable<FlightInfo> GetPendingFlights();
IEnumerable<FlightInfo> GetCompletedFlights();
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public interface IFlightUpdater {
FlightInfo AddFlight(FlightInfo flight);
FlightInfo UpdateFlight(FlightInfo flight);
void CancelFlight(FlightInfo flight);
}
任何命令行输入或输出都应如下编写:
Assert.Equal() Failure Expected: 60 Actual: 50
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“点击下一步,然后为您的测试项目起一个有意义的名称,然后再次点击下一步。”
小贴士或重要提示
看起来是这样的。
联系我们
欢迎读者反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过customercare@packtpub.com给我们发邮件,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 C#进行重构》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但又无法随身携带您的印刷书籍吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
无论在哪里、什么设备上阅读,都可以搜索、复制和粘贴您最喜欢的技术书籍中的代码直接到您的应用程序中。
优惠远不止这些,您还可以获得独家折扣、时事通讯和每日邮箱中的优质免费内容。
按照以下简单步骤获取优惠:
- 扫描二维码或访问以下链接

packt.link/free-ebook/9781835089989
-
提交您的购买证明
-
就这些了!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱。
第一部分:在 Visual Studio 中使用 C#进行重构
在本书的第一部分,我们将讨论技术债务、代码异味和重构的本质。我们将专注于在 Visual Studio 中重构 C#代码的机械过程。
在本部分中,您将学习如何在不改变其功能的情况下安全地更改代码的形式。我们将涵盖高级概念,然后逐步重构单个代码行。之后,我们将扩展到重构整个方法,并了解它们是如何相互作用的。最后,我们将探讨一些面向对象的重构方法,这些方法可以通过改变类之间的交互来真正重塑您的代码。
这一部分的书既可以当作传统书籍阅读,也可以用作每个章节中找到的起始代码重构的逐步教程。
本部分包含以下章节:
-
第一章,技术债务、代码异味和重构
-
第二章,重构简介
-
第三章,重构代码流程和迭代
-
第四章,方法级别的重构
-
第五章,面向对象的重构
第一章:技术债务、代码异味和重构
新软件项目一开始都是干净和乐观的,但很快就会在复杂性和维护难度上增长,直到代码难以理解、脆弱且难以测试。
如果你从事过任何一段时间的代码工作,那么你很可能遇到过这样的代码。事实上,如果你在开发领域工作过一段时间,那么你很可能编写过你现在后悔的代码。
可能是代码难以阅读或理解。也许代码效率低下或容易出错。也许代码是在某些业务假设下构建的,而这些假设后来发生了变化。也许代码简单地不再符合你和你的团队同意的标准。无论原因如何,糟糕的代码似乎在任何一个规模或年龄较大的代码库中无处不在。
这种代码充斥着我们的软件项目,降低了我们的开发速度,导致我们引入错误,并通常使我们的软件工程师感到不那么快乐和高效。
在这本书中,我们将讨论技术债务是如何产生的,以及我们如何通过重构的过程来处理它,这个过程由测试和代码分析引导。
在本章中,我们将涵盖以下主要主题:
-
理解技术债务和遗留代码
-
识别代码异味
-
介绍重构
理解技术债务和遗留代码
虽然计算机科学教育、书籍、教程和在线课程都侧重于从头开始创建新项目,但现实是,你几乎所有的开发工作都将围绕理解、维护和扩展可能不符合你当前标准的现有代码。
这预先存在的代码被称为遗留代码。当你加入一个新项目时,你几乎总是继承了一定量的遗留代码。这可能是一大块现有项目的代码,或者是一组你必须与之协同工作的较小库。
对于术语“遗留代码”有许多不同的定义。在我阅读过的定义中,让我印象深刻的是迈克尔·C·费瑟在《与遗留代码有效工作》一书中提出的定义,即遗留代码是没有测试的代码。
虽然我喜欢迈克尔·C·费瑟的定义,并认为测试至关重要,但正如我们在本书的第二部分中将要看到的,我个人将遗留代码定义为以下内容:
遗留代码是指任何如果今天重写将会显著不同的预先存在的代码。
遗留代码的一个关键因素是它是你目前不完全理解的代码,因此,它的存在引起了一定程度的不安和焦虑。
当你维护旧系统时感到的这种焦虑是被称为技术债务的典型症状。
简而言之,技术债务是遗留代码对未来 开发努力的负面影响。
换句话说,遗留代码在修改时存在一定的内在风险,即可能会发生一些不好的事情。这些不好的事情可能是由于现有代码的脆弱性(或我们对它的理解不足)而引入的 bug,或者开发速度变慢,甚至可能是由于过时的安全实践或已弃用的依赖项而导致的严重问题,如关键 bug 或安全漏洞。
更糟糕的是,技术债务会随着时间的推移而增长——尤其是如果得不到控制的话。
技术债务的来源
在我们继续之前,我想谈谈我在组织中看到的一个常见的混淆点:技术债务与技术不良代码不是一回事。
当然,我们系统中的一些技术债务可能仅仅是低质量的代码。可能是一个经验不足的开发者编写的,并且没有从其他开发者的代码审查中获得适当的益处。有时,项目处于匆忙之中,团队一开始就没有时间正确编写代码,而且从未有机会回去清理它。
有时,为了原型而编写的“快速且粗糙”的代码会在“可丢弃原型”匆忙升级为实际生产应用时进入生产环境,正如我们将在第十五章**:沟通 技术债务 中探讨的那样。
当然,技术债务还有其他原因。
有时,开发团队会误以为他们正在构建软件来完成特定的任务,但随着业务需求的变化和新信息的发现,这个任务会发生变化。在这些情况下,团队通常不会从他们正在编写的代码重新开始。他们只是将旧代码演变以适应手头的新的任务。结果是代码可以工作,但并不理想地适合新的任务。
这种需求的变化在软件开发环境中是正常的,甚至是可以预期的。现代软件开发以敏捷的方式进行,需求和计划会随着时间的推移自然演变,而提前理解它们几乎是不可能的。
即使开发团队完美理解需求并编写了完美的代码,由于软件工程的不断变化,这些代码最终也会成为一种形式的技术债务。
在软件开发中,工具和库会随着时间的推移而变化。在撰写本文时,.NET 8 和 C# 12 是运行 C#代码的最新方式,但这些技术将在未来的某个时刻停止支持,然后被更新的版本所取代。
甚至关于软件的思考方式也可以改变。在过去二十年里,组织从拥有自己的本地服务器转变为使用Azure、AWS或Google Cloud的云托管。随着技术,包括容器化技术如Docker、平台即服务(PaaS)提供如Azure App Services,以及无服务器计算提供如Azure Functions和AWS Lambda,服务器的本质也发生了变化。
现在,像ChatGPT和GitHub Copilot Chat这样的新 AI 技术正准备改变软件开发者甚至意味着什么,这进一步强调了持续变化是软件工程行业核心的重要性。
软件项目的变更
在软件开发中,变化是持续的,可能是不可预测和突然的。所有这些变化导致曾经被认为是完美的代码后来被认为是业务持续成功的重大风险。
换句话说,技术债务在某种程度上是软件开发不可避免的一部分。幸运的是,你可以采取一些措施来减少其积累的速度(我们将在本书的第二部分中讨论)。幸运的是,我们可以通过其症状或“异味”来检测技术债务。
识别代码异味
那么,你怎么知道你的代码是否有问题?
你怎么知道食物是否变质,衣服是否需要清洗,或者尿布是否需要更换?结果是它闻起来很糟糕。
有一些关于“好”代码和“坏”代码构成的指标,我们将在第十二章:Visual Studio 中的代码分析和第十六章:采用代码标准中探讨。有异味的代码在某种程度上可能是主观的。编写了代码段或经常修改该部分的开发者可能会发现代码更易忍受,而首次遇到该代码的开发者可能不会这样认为。
虽然并非所有技术债务都是相同的,但许多遗留代码共享一组常见的症状。
这些症状通常被称为“代码异味”,可能包括以下内容:
-
很难理解它做什么或为什么这样做
-
你或你的团队成员避免与之合作
-
它的修改速度比其他区域慢,或者修改时容易出错
-
很难测试或调试
新代码一开始是良好且纯净的,但在商业环境中实际存在的代码会随着时间的推移而演变,因为需要更多的功能,并引入了额外的特性和修复。在这个过程中,曾经整洁的代码开始积累代码“异味”。
并非所有代码都是平等的,也并非所有代码都能像其他代码一样持久。当然,我们可以做一些事情来使我们的代码更具弹性(正如我们将在第八章**:使用 SOLID 避免代码反模式)中看到)。然而,在某个时候,你那漂亮的新代码将开始变得令人不快,需要通过称为重构的过程来清理。
引入重构
重构是那些对新手程序员来说意义不大的词汇之一,但这里有一个简单的定义:
重构是改变代码的形状或形式而不改变其功能或行为的行为。
这里有两个关键概念:
-
第一个概念是,重构是为了提高现有代码的可维护性而努力。有时,重构意味着引入新的变量、方法或类。有时,重构只是改变了单个代码行的排列方式或使用了哪些语言特性。甚至像重命名变量这样的简单操作也可以被视为一个小型的重构行为。
-
这个定义中的第二个概念是,重构不会改变代码的行为。重构是为了在不改变代码现有行为的情况下,对代码结构进行的一种结构性改变,以引入某些技术优势。如果你重构前一个方法通常返回某个值,而现在返回了不同的值,那么这是一个变化,而不是重构。
重构还应该为工程团队提供一些好处。重构后的代码应该更容易理解,在更改时更不容易出错,并且比原始代码具有更少的技术债务和代码异味。
开发团队产生的每一行代码都应该具有商业价值。重构也不例外,除了它产生的商业价值应该是更易于维护的代码,减少了由于它的存在而产生的问题和延迟。
有时候,我们试图通过重构来改进我们的代码,却意外地引入了新的行为——通常是新的错误。这使得我们的重构变成了软件中的无意变化,可能导致紧急修复以恢复代码的正常状态。
在重构过程中打断代码可能是一个关键问题,并且是未来允许执行重构代码的重大障碍,这反过来又可能让技术债务得以滋生。
在本书的第二部分中,我们将探讨安全重构代码的方法,以避免意外引入错误,而在第四部分中,我们将讨论获得组织对重构代码的支持,以及当缺陷确实出现在重构努力中时应该做什么。
Visual Studio 中的重构工具
幸运的是,现在所有版本的Visual Studio都内置了重构工具,允许您以可靠和可重复的方式快速执行一系列常见的重构操作。
在第二章**:重构简介以及第一部分剩余的章节中,我们将看到许多重构的实际操作。以下是 Visual Studio 为用户提供的一些重构选项的预览:

图 1.1 – Visual Studio 快速操作上下文菜单显示一组重构操作
工具辅助的重构,如这些,有几个原因使其非常出色:
-
它们是快速和高效的
-
它们是可靠和可重复的
-
它们很少引入缺陷
警告
注意,我在谈论由重构工具引入的缺陷时使用了“很少”这个词。在少数情况下,在不考虑其操作的情况下使用内置的重构工具可能会将缺陷引入您的应用程序。我们将在后续章节中遇到这些情况时具体讨论这些领域。
在第一部分的剩余部分,我们将探讨如何快速有效地使用这些工具来重构您的 C#应用程序,并讨论您可能使用每个工具的场景类型。
尽管我们的工具功能强大,但重要的是要记住,这些工具只是重构代码的一种方式。通常,最有效的移除代码异味的方法是结合自己编写代码和使用内置的重构工具。
重构的关键价值是组织的长期健康,但许多重构的障碍可能来自组织本身。为了帮助说明在真实组织中进行重构的实际方面,每一章都将包含一个虚构组织的案例研究。有些章节将完全专注于案例研究中的代码,而其他章节,例如本章,将以专门的案例研究部分结束。这些案例研究部分展示了章节概念应用于虚构组织。
让我们来看看我们的第一个案例研究部分,看看技术债务和遗留代码如何影响一家典型的公司。
案例研究 – Cloudy Skies Airlines
本书剩余部分将遵循名为Cloudy Skies Airlines的航空公司的代码示例,或简称Cloudy Skies。通过这些示例,我们应该能够看到技术债务和重构如何应用于一个“真实”的组织及其软件。
注意
Cloudy Skies 是一家虚构的航空公司,仅为此书的教学目的而创建。任何与任何真实公司的相似之处纯属巧合。此外,我从未在航空业工作过,因此书中提供的代码示例可能与行业实际使用的软件系统有显著差异。
Cloudy Skies 是一家存在了 50 年的航空公司,目前在其机队中运营着超过 500 架喷气式飞机,为其所在地区的约 70 个城市提供服务。
二十年前,航空公司做出了重大举措,开始用其开发团队构建的定制内部应用程序替换其老化的软件系统。Cloudy Skies 选择使用 .NET 和 C#。初始系统表现良好,并导致开发人员生产力和高性能软件应用程序的增加,因此 Cloudy Skies 继续将其应用程序迁移到 .NET。
随着时间的推移,航空公司及其系统不断增长。Cloudy Skies 的工程团队曾经是组织的骄傲和喜悦,也是其未来的关键。
然而,在过去几年中,管理层对其工程团队有些许挫败感。其中一些关键投诉包括以下内容:
-
产品经理们对于对现有系统看似简单的更改所需的大量估计感到沮丧,以及由于实施时间过长和大量错误而导致的软件发布之间的时间不断增加。
-
质量保证部门被软件中日益增长的错误所淹没,同一事物反复出现故障的趋势,以及当应用程序的其他部分发生变化时,似乎无关的区域出现错误。
对于工程团队来说,他们感到正在处理的代码让他们感到压力重重。战略举措多年来一直被搁置,而组织让团队专注于紧急更改或新发布的紧迫截止日期。因此,没有人有时间解决团队面临日益增长的技术债务。
Cloudy Skies 的代码库不断增长,以适应系统添加的每个新功能或“特殊情况”。这种复杂性反过来又使得应用程序更难测试、理解和修改,这导致了新开发人员入职困难和一些经验丰富的开发人员离开组织。
在经历了几次严重的延误和备受瞩目的错误之后,Cloudy Skies 引进了一位新的工程经理,并赋予团队权力进行变革,以确保航空公司在未来几年中能够保持高效和有效。
这位工程经理确定,这些问题的根本原因是技术债务,并且对整个应用程序套件中最关键区域的针对性重构可以显著降低风险并提高团队未来的效率。
值得赞扬的是,管理层表示同意,并允许团队分配资源来偿还技术债务并通过重构提高代码的可维护性。
在本书的其余部分,我们将跟随这个虚构团队偿还技术债务和通过重构铺就通往更好未来的道路的历程。
摘要
历史代码是软件开发项目中时间力量和持续变化的不可避免的结果。这种历史代码成为技术债务的滋生地,这威胁到我们作为开发者的生产力和我们软件的质量。
尽管技术债务可能由于许多原因而产生,但重构是治疗方法。重构将现有代码重构为更易于维护和风险更低的形态,减少我们的技术债务,并帮助我们控制我们的遗留代码。
你对代码中技术债务的原因和影响了解得越多,你发现自己就越能更好地向组织中的其他人解释技术债务,倡导重构,并避免导致代码随时间推移而降低有效性的因素。
在下一章中,我们将通过一系列有针对性的更改来更深入地探讨重构,以改进 Cloudy Skies Airlines 代码库中的一个示例代码片段。
问题
-
技术债务和遗留代码之间的区别是什么?
-
技术债务有哪些原因?
-
技术债务有哪些影响?
-
是否可以避免技术债务?
-
是否有可能达到一个点,你的代码不能再进一步重构?
进一步阅读
你可以在以下网址找到有关技术债务、遗留代码和重构的更多信息:
-
定义技术债务:
killalldefects.com/2019/12/23/defining-technical-debt/ -
识别技术债务:
learn.microsoft.com/en-us/training/modules/identify-technical-debt/ -
技术债务的真实成本:
killalldefects.com/2019/11/09/the-true-cost-of-technical-debt/
第二章:重构简介
学习重构的最佳方式是查看示例。在本章中,我们将使用 C#和 Visual Studio 探索一个示例重构场景,并亲眼看到重构如何在不改变其功能的情况下改变代码的可维护性。
在本章中,我们将介绍以下主要内容:
-
重新整理行李价格计算器
-
在其他编辑器中进行重构
在此过程中,我们将介绍引入局部变量、常量和参数、提取方法和删除不可达/未使用代码的重构,以及讨论在重构工作中的测试的重要性。
技术要求
如果您想跟随本章,可以从 GitHub 克隆本书的代码:github.com/PacktPublishing/Refactoring-with-CSharp。
本章的起始代码可以在克隆存储库后,在Chapter02/Ch2BeginningCode文件夹中找到。
重新整理行李价格计算器
我们将首先检查 Cloudy Skies 航空公司工作人员在行李检查时使用的行李价格计算器,以确定单个客户必须支付的金额。
行李定价规则如下:
-
所有托运行李每件费用为 30 美元
-
乘客检查的第一个行李费用为 40 美元
-
每件随后的托运行李费用为 50 美元
-
如果旅行发生在假期期间,将应用 10%的附加费
此代码位于一个 C# BaggageCalculator类中,我们将在接下来的几个代码块中对其进行审查,从类定义、字段和完整属性开始:
BaggageCalculator.cs:
public class BaggageCalculator {
private decimal holidayFeePercent = 0.1M;
public decimal HolidayFeePercent {
get { return holidayFeePercent; }
set { holidayFeePercent = value; }
}
这是一个简单的类,使用较旧的属性定义样式将holidayFeePercent设置为decimal值(由M后缀标识)为0.1或 10%。
该类还包含一个CalculatePrice方法,该方法返回一个表示行李费用总金额的decimal值:
public decimal CalculatePrice(int bags,
int carryOn, int passengers, DateTime travelTime) {
decimal total = 0;
if (carryOn > 0) {
Console.WriteLine($"Carry-on: {carryOn * 30M}");
total += carryOn * 30M;
}
if (bags > 0) {
if (bags <= passengers) {
Console.WriteLine($"Checked: {bags * 40M}");
total += bags * 40M;
} else {
decimal checkedFee = (passengers * 40M) +
((bags - passengers) * 50M);
Console.WriteLine($"Checked: {checkedFee}");
total += checkedFee;
}
}
if (travelTime.Month >= 11 || travelTime.Month <= 2) {
Console.WriteLine("Holiday Fee: " +
(total * HolidayFeePercent));
total += total * HolidayFeePercent;
}
return total;
}
该逻辑有些复杂,但它与之前描述的业务规则相匹配。
最后,该类以一个CalculatePriceFlat方法结束,该方法是在应用程序的早期版本中引入的,现在不再使用(我们将在后面讨论):
private decimal CalculatePriceFlat(int numBags) {
decimal total = 0;
return 100M;
return numBags * 50M;
}
}
尽管这段代码在世界上并不是最糟糕的,但这是一个随着新规则添加到应用程序中而逐渐增加复杂性、变得难以理解和维护的类。
幸运的是,这个类由一系列通过单元测试支持,并且所有用户都普遍认为它能够正确计算金额。
在本章中,我们将应用一系列有针对性的重构来改进此代码,以防止其在未来成为问题。
将属性转换为自动属性
类的声明从以下HolidayFeePercent属性开始,如下所示:
private decimal holidayFeePercent = 0.1M;
public decimal HolidayFeePercent {
get { return holidayFeePercent; }
set { holidayFeePercent = value; }
}
这段代码是好的,没有任何问题。然而,C#是一种不断发展的语言,开发者通常在可以选择的情况下更喜欢编写和维持更少的代码行。
因此,Microsoft 给了我们编写自动实现属性(通常称为自动属性)的能力,当代码编译时,它会自动生成自己的字段以及获取器和设置器。
当我们可能删除属性及其字段并重新声明时,存在一种可能性,即我们可能会在这样做时犯拼写或大小写错误。相反,让我们看看 Visual Studio 如何能自动为我们完成这项工作。
在 Visual Studio 中,如果你通过使用箭头键或点击属性名称将输入光标移到属性名称上,你会在边缘看到一个轻 bulb,如图图 2.1所示:

图 2.1 – 轻 bulb 快速操作图标
如果你点击这个轻 bulb(或默认按Ctrl + .),将出现快速操作菜单,并列出几个重构选项。
重构选择是上下文相关的,因此只有 Visual Studio 认为与你当前选择的代码相关的那些才会出现。
在这种情况下,第一个选项,使用自动属性,是我们想要的重构操作。见图图 2.2:

图 2.2 – 预览使用自动属性的重构
当选择此选项时,右侧面板将显示此更改将如何影响你的代码的预览。这里列出了它将用红色删除的行和用绿色添加的行。
点击使用自动属性或在键盘上按Enter键将接受建议,并用自动属性版本替换你的代码:
public decimal HolidayFeePercent { get; set; } = 0.1M;
虽然这只是一个简单的重构,但我想要强调重构过程中的几个要点:
-
Visual Studio 负责进行更改,并以自动化方式完成,这种方式避免了人类可能犯的潜在错误或拼写错误。
-
如果你不知道可以将整个属性移动到自动属性中,这个快速操作帮助你发现了这一点。实际上,这些快速操作可以教会你很多关于 C#编程语言的知识,因为它每年都在不断发展和变化。
在 Visual Studio 的重构机制处理完毕后,让我们探索一些额外的重构。
引入局部变量
CalculatePrice方法存在的问题之一是,有几个表达式,如carryOn * 30M和bags * 40M,在方法中多次出现。
这些都是小问题,但可能导致可维护性问题。如果表达式的性质发生了变化,我们就需要修改代码中的多个地方。
通常,你可能想要重构代码的一个原因是你发现自己经常需要修改多个地方以进行单一更改。例如,如果定价结构发生变化,我们应该修改多行代码以支持新的定价模型。我们应该修改的每一行都可能是我们可能未能进行更改的地方。这种遗漏的更改通常会导致错误。
即使我们没有错过任何需要修改的代码,大多数开发者也更愿意在一个地方而不是多个地方进行修改。
引入局部重构可以通过引入包含表达式结果的局部变量来帮助你。
要使用此重构,选择如图 2.3所示的重复表达式:

图 2.3 – 在 Visual Studio 中选择重复的表达式
接下来,通过按 Ctrl + .* 或点击螺丝刀图标来使用快速操作按钮。
关于快速操作图标的说明
快速操作按钮有时显示为灯泡,有时显示为螺丝刀,这取决于你的代码分析规则以及一行所面临的确切问题。它们实际上是相同的选项,但灯泡告诉你存在一个建议的重构,而螺丝刀则表示一个不太关键的考虑重构选项。
一旦上下文菜单打开,使用箭头键导航菜单,通过展开引入局部旁边的右箭头。这将让你查看更多详细选项。

图 2.4 – 深入了解引入局部重构的特殊形式
这里它为你提供了仅为你所选的表达式引入局部变量的能力,或者为该表达式的所有出现这样做。我通常建议使用所有出现选项,但这将取决于你试图改进的上下文。
一旦选择引入局部选项,Visual Studio 将提示你为变量命名(见图 2.5):

图 2.5 – 为你的新局部变量命名
输入你想要的名字,然后按 Enter 键使框消失。
在我的情况下,我将变量命名为 fee 并在两行中替换了它,如下所示:
if (carryOn > 0) {
decimal fee = carryOn * 30M;
Console.WriteLine($"Carry-on: {fee}");
total += fee;
}
虽然这确实使行李费用逻辑更清晰,但在托运行李逻辑中仍然有一个重复的 bags * 40M 表达式,以及一个重复的 total * HolidayFeePercent 表达式。
你可以使用引入局部重构通过将一些逻辑从密集的行中拉出来到它们自己的较小行,使复杂的行更容易理解。
在整个方法中应用引入局部变量的重构会导致方法更长,但更容易理解:
public decimal CalculatePrice(int bags,
int carryOn, int passengers, DateTime travelTime) {
decimal total = 0;
if (carryOn > 0) {
decimal fee = carryOn * 30M;
Console.WriteLine($"Carry-on: {fee}");
total += fee;
}
if (bags > 0) {
if (bags <= passengers) {
decimal firstBagFee = bags * 40M;
Console.WriteLine($"Checked: {firstBagFee}");
total += firstBagFee;
} else {
decimal firstBagFee = passengers * 40M;
decimal extraBagFee = (bags - passengers) * 50M;
decimal checkedFee = firstBagFee + extraBagFee;
Console.WriteLine($"Checked: {checkedFee}");
total += checkedFee;
}
}
if (travelTime.Month >= 11 || travelTime.Month <= 2) {
decimal holidayFee = total * HolidayFeePercent;
Console.WriteLine("Holiday Fee: " + holidayFee);
total += holidayFee;
}
return total;
}
作为一名编程讲师,我看到了许多学生错误地认为实现某事的最短方式总是最好的。
相反,最好的代码往往是那些随着时间的推移更容易维护、更不容易出错、在开发任务中更容易思考的代码。
代码越少往往越容易思考,但当代码过于简洁或过于复杂时,维护起来可能很困难。在简洁性和可读性之间找到一个平衡点,记住很多时候,程序员只是快速浏览代码以寻找特定的部分。
引入常量
在程序运行期间永远不会改变的const值。
然而,引入常量通常用于与引入局部变量不同的目的。虽然引入局部变量倾向于用于减少重复或简化复杂的代码行,但引入常量通常用于从代码中消除魔法数字或魔法字符串。
在编程中,魔法数字是在代码中存在但没有解释其含义或为什么存在的数字。这是不好的,因为后来维护你代码的人不明白为什么选择了这个数字。
CalculatePrice方法有三个魔法数字:30M、40M和50M,代表各种行李费用金额。
为这些引入一个常量与引入一个局部变量相同。只需突出显示数字并打开快速操作菜单,然后选择引入常量,然后在子菜单中选择为所有出现引入常量,如图所示:

图 2.6 – 为所有出现的 40M 十进制字面量引入一个常量
将我们应用程序中的各种魔法数字这样做,并选择合适的名称,结果在类的顶部得到以下新的常量:
private const decimal CarryOnFee = 30M;
private const decimal FirstBagFee = 40M;
private const decimal ExtraBagFee = 50M;
引入这些常量还有一个额外的优点,就是把我们的价格规则集中在一个地方,使新加入团队的开发者更容易发现。
这也使得我们的代码更容易阅读:
if (carryOn > 0) {
decimal fee = carryOn * CarryOnFee;
Console.WriteLine($"Carry-on: {fee}");
total += fee;
}
程序员在阅读代码上花费的时间不成比例地多于编写代码。优化代码以保持可维护性是一个关键习惯,这将帮助你的应用程序随着时间的推移抵抗技术债务。
引入参数
我希望看到更多人使用的重构技术之一是引入 参数重构。
这种重构将方法中的表达式或变量移除,并将其完全从方法中删除,而是将其值作为新的参数添加到方法中。
例如,现在CalculatePrice方法内部有逻辑来确定哪些旅行日期应被视为假日旅行:
if (travelTime.Month >= 11 || travelTime.Month <= 2) {
decimal holidayFee = total * HolidayFeePercent;
Console.WriteLine("Holiday Fee: " + holidayFee);
total += holidayFee;
}
当添加更多假期并考虑不同国家的假期时,这种逻辑可能会变得更加复杂。按照现在的代码编写方式,额外的复杂性需要放入这个if语句中。
相反,为isHoliday引入一个参数,让这个方法的调用者负责告诉这个方法是否是假日旅行。这最终使我们能够让这个方法专注于为客户定价行李,并意识到假期,但它不负责确定什么是假期,什么不是假期。
通过选择你希望移动到参数中的变量或表达式,然后触发快速 操作菜单,可以引入参数:

图 2.7 – 使用快速操作菜单引入参数
在引入参数时,有多种选择。选择直接更新调用点通常是不错的选择——前提是你审查了它生成的代码。
一旦我们引入参数并适当地命名它,假期费用逻辑就变得更容易阅读:
if (isHoliday) {
decimal holidayFee = total * HolidayFeePercent;
Console.WriteLine("Holiday Fee: " + holidayFee);
total += holidayFee;
}
引入参数还改变了方法签名行,添加了一个布尔isHoliday参数:
public decimal CalculatePrice(int bags, int carryOn,
int passengers, DateTime travelTime, bool isHoliday) {
由于这次重构,现在调用CalculatePrice方法的任何代码现在都会计算并传递一个isHoliday的值给该方法。
我发现引入参数特别有助于让一个方法只关注几块关键逻辑。
在你有很多类似的方法来做类似的事情,但只有几个关键细节不同的情况下,这也可以非常有帮助。有时,可以将许多不同的方法合并成一种方法,该方法接受一些细节作为参数。
例如,以下代码可能对不同操作进行日志记录:
Fee.cs
public void ChargeCarryOnBaggageFee(decimal fee) {
Console.WriteLine($"Carry-on Fee: {fee}");
Total += fee;
}
public void ChargeCheckedBaggageFee(decimal fee) {
Console.WriteLine($"Checked Fee: {fee}");
Total += fee;
}
这两个方法都接受一个数值费用,并将收费名称和收费金额写入控制台。实际上,它们唯一的区别就是收费名称。
通过引入参数,可以将这段代码合并成一个单一的方法:
public void ChargeFee(decimal fee, string chargeName) {
Console.WriteLine($"{chargeName}: {fee}");
Total += fee;
}
永远不要低估通过让外部代码提供额外细节来使方法更加通用的价值。
在很大程度上改进了收费逻辑后,让我们继续到最后一个方法,这个方法有几个相关的警告。
移除不可达和未使用的代码
如果你打开了本章的初始代码在 Visual Studio 中,你可能会注意到CalculatePriceFlat和其中的一些变量以灰色显示,并带有许多波浪下划线建议,如图图 2.8所示。

图 2.8 – 带灰色文本的多行代码的 CalculatePriceFlat 方法
Visual Studio 有时可以检测到变量、参数甚至方法没有被使用。如果它这样做,Visual Studio 通常会以更暗淡的色调渲染这些标识符,并经常包括调查或删除这些项目的建议。
在这种情况下,没有任何东西调用CalculatePriceFlat方法,也没有任何东西引用numBags参数。total变量被声明并赋予了一个值,但从那以后再也没有被读取过,并且由于上面的返回行,最后的return语句是不可达的。
这些问题可以通过删除未使用成员、删除未使用变量或删除不可达代码重构来解决。
所有这些重构都做了你期望的事情:它们删除了有问题的代码。
由于没有任何东西调用该方法,整个方法都可以被删除。
删除未使用参数
还有一段早期的代码可以被删除:CalculatePrice方法有一个travelTime参数,在我们引入isHoliday参数之后就不再使用了。
在撰写本文时,Visual Studio 中没有删除未使用参数的功能,但你可以使用我们将在下一章讨论的一些方法级别的重构安全地删除它。
要执行此重构,请选择travelTime参数,然后选择如图所示更改签名…:

图 2.9 – 修改方法签名
点击更改签名…将显示更改签名对话框。
选择travelTime参数并点击删除。参数将在对话框中显示为被划掉:

图 2.10 – 移除 travelTime 后的更改签名对话框
点击确定,对话框将关闭,参数将被删除。
任何引用你的方法的代码也将更新它们的签名,不再为travelTime参数传递任何内容。
删除代码时的陷阱
关于删除代码的一个注意事项:在删除代码时,特别小心删除public成员。有时 Visual Studio 没有意识到所有使用代码的地方。这尤其适用于序列化/反序列化逻辑、用于数据绑定的属性以及使用反射访问的成员。
此外,如果你的代码作为NuGet 包或其他方式在其他项目中共享,可能存在代码之外的部分依赖于某个方法或参数,你的更改可能导致它们的代码无法编译。
测试提醒
测试你做出的任何重构并确保它们不会导致程序行为出现意外的变化,这是你的责任。
这可能听起来很可怕,但不要让这些边缘情况阻止你删除死代码。
我知道许多开发者犹豫不决,担心他们以后可能需要代码。相反,这些开发者要么保留代码不变,要么将整个代码块注释掉。
注释掉死代码的问题在于,它增加了文件中分散注意力且无用的注释的数量。这减少了开发者对现有注释重要性的重视,并增加了开发者必须滚动的次数。
删除死代码。你的代码应该已经在源代码控制中,所以如果你真的需要稍后找到代码,你可以查看历史记录来恢复它——当然,前提是你一开始就将其提交到源代码控制。
提取方法
我们现在的代码看起来相当整洁,但CalculatePrice方法中包含了很多用于行李托运价格计算的逻辑。
这段逻辑足够复杂,我们可以只为这段逻辑提取一个方法,并从现有代码中调用该方法。
要这样做,选择代表你想要提取的方法的代码行。请注意你选择的各个{}实例,因为你的选择必须作为与 Visual Studio 相关的代码块有意义。请参阅以下截图。

图 2.11 – 从代码块中提取方法
一旦选择了代码块,打开快速操作菜单,选择提取方法,然后在提示中命名方法,按下 Enter 键确认您的名称。

图 2.12 – 命名提取的方法
这将在你的代码中添加一个新的方法:
private static decimal ApplyCheckedBagFee(int bags,
int passengers, decimal total) {
if (bags <= passengers) {
decimal firstBagFee = bags * FirstBagFee;
Console.WriteLine($"Checked: {firstBagFee}");
total += firstBagFee;
} else {
decimal firstBagFee = passengers * FirstBagFee;
decimal extraBagFee = (bags - passengers)* ExtraBagFee;
decimal checkedFee = firstBagFee + extraBagFee;
Console.WriteLine($"Checked: {checkedFee}");
total += checkedFee;
}
return total;
}
注意,Visual Studio 默认会将方法设置为private,如果方法不访问类上的实例成员,它还会将方法标记为static。
我通常更喜欢private方法,但你对static的偏好可能会根据你正在处理的方法以及该方法最终是否应该为static而有所不同。
提取方法重构还会从原始方法中删除代码,并用对新方法的调用替换它:
public decimal CalculatePrice(int bags, int carryOn,
int passengers, DateTime travelTime, bool isHoliday) {
decimal total = 0;
if (carryOn > 0) {
decimal fee = carryOn * CarryOnFee;
Console.WriteLine($"Carry-on: {fee}");
total += fee;
}
if (bags > 0) {
total = ApplyCheckedBagFee(bags, passengers, total);
}
if (isHoliday) {
decimal holidayFee = total * HolidayFeePercent;
Console.WriteLine("Holiday Fee: " + holidayFee);
total += holidayFee;
}
return total;
}
这使得CalculatePrice 方法更加简洁易读,并使得思考方法所做的一切变得更加容易。这种降低的复杂性大大提高了方法的长期质量,因为它帮助开发者完全理解该方法,并避免了在维护复杂代码块时可能出现的昂贵错误。
手动重构
到目前为止,我们已经执行了 Visual Studio 支持的许多重构操作。鉴于我们使用的工具质量,这些操作相当安全,但内置工具有一些事情是做不到的。
Visual Studio 功能强大,但它不能像人类一样思考代码(尽管我们将在第十一章 使用 GitHub 的 AI 辅助重构与 Copilot Chat中讨论的令人兴奋的新 AI 功能)。
有时会有机会改进代码,而内置的重构工具无法为你完成。在这些点上,你必须手动进行更改。
我们之前提取的ApplyCheckedBagFee方法是一个好的方法,但还有一些事情可以改进。
首先,该方法接收一个总数,将其增加费用,然后返回这个新总数。如果方法返回费用而不是调整后的总数,其他人更容易理解该方法。
其次,该方法执行了两次相同的Console.WriteLine操作。此外,类中所有其他的WriteLine语句都在CalculatePrice方法中,这使得用户界面稍微难以完全追踪。
让我们修改该方法,使其只返回费用,不需要total参数,并且不记录任何内容:
private static decimal ApplyCheckedBagFee(int bags,
int passengers) {
if (bags <= passengers) {
decimal firstBagFee = bags * FirstBagFee;
return firstBagFee;
} else {
decimal firstBagFee = passengers * FirstBagFee;
decimal extraBagFee = (bags-passengers) * ExtraBagFee;
decimal checkedFee = firstBagFee + extraBagFee;
return checkedFee;
}
}
接下来,我们需要更新调用此方法的代码:
if (bags > 0) {
decimal bagFee = ApplyCheckedBagFee(bags, passengers);
Console.WriteLine($"Checked: {bagFee}");
total += bagFee;
}
注意,结果存储在bagFee变量中,total不再传递给ApplyCheckedBagFee,并且Console.WriteLine现在出现在这个方法中。
此外,ApplyCheckedBagFee的名称可能不再适用,因为该方法不再实际应用费用,而是计算它。在这种情况下,应用重命名方法重构将有助于最终代码有一个更合适的名称。
测试重构后的代码
正如我之前提到的,确保你的重构工作没有改变系统的基本行为,这是你的责任。
在我们的情况下,这意味着BaggageCalculator应该仍然为任何有效的输入集计算与之前相同的价格。
我们用来确定代码是否仍然满足我们需求的许多工具之一是运行单元测试。
我们将在第六章 单元测试中更多地讨论单元测试,但现在,要知道单元测试是验证其他代码按预期工作的代码。
BaggageCalculator有五个可以通过点击测试菜单然后选择运行所有测试来运行的测试。
测试资源管理器窗口应显示所有测试都通过,带有绿色的勾选标记:

图 2.13 – 测试资源管理器中的五个通过测试
如果一个测试现在失败了,而之前没有失败,这是一个好事,因为这意味着测试发现了你在代码行为中引起的问题。调查失败的测试,然后在继续之前解决问题。
我们将在本书的第二部分中更详细地探讨测试,但就目前而言,看起来我们的重构是成功的。
最终代码
本章的最终重构代码可在 github.com/PacktPublishing/Refactoring-with-CSharp 仓库的 Chapter02/Ch2FinalCode 文件夹中找到。
本章中我们产生的代码简单、易读、易于维护。当然,还有一些可以改进的地方,但随着代码复杂性的增加,未来出现问题的可能性较小。
其他编辑器中的重构
在我们结束本章之前,让我们来谈谈除了 Visual Studio 之外的其他编辑器中的重构。
本书主要关注 Visual Studio 中的重构,因为它是当前 .NET 开发者的主要开发环境。然而,还有一些其他编辑器和扩展经常用于 .NET 开发,并提供重构支持:
-
Visual Studio Code
-
JetBrains Rider
-
JetBrains ReSharper (Visual Studio 扩展)
由于 Visual Studio 是主要的编辑体验,这些工具将不会在本书余下的示例中展示。然而,本书余下部分展示的大部分内容也可以使用这些工具实现。
使用 C# 开发工具包在 VS Code 中进行重构
Visual Studio Code (VS Code) 通过其 C# 扩展,正迅速成为 .NET 项目的强大编辑环境。
VS Code 在使用较新的 C# 开发工具包 时真正发挥其优势,它提供了几乎与 Visual Studio 相同的编辑体验,包括解决方案资源管理器。C# 开发工具包与其他 C# 扩展集成,提供与 Visual Studio 中相同的风格的光泡图标,以提供代码建议和重构 快速操作。

图 2.14 – 在 VS Code 中使用 C# 开发工具包进行重构
VS Code 不会提供 Visual Studio 当前所拥有的全部重构选项,但它支持跨平台,可以在 Mac 和 Linux 上运行。
许可证说明
VS Code 是免费的,但 C# 开发工具包扩展需要付费的 Visual Studio 许可证密钥。
我预计,随着 C# 开发工具包的改进以及 VS Code 的跨平台能力,以及它通过 GitHub Codespaces 在一定程度上在浏览器中运行的能力,VS Code 将在 .NET 开发中更加突出地展示。
JetBrains Rider 中的重构
JetBrains Rider 是一个独立的编辑器,它使用的是与流行的 IntelliJ Java 编辑器相同的编辑软件集。
Rider 与大多数 .NET 项目兼容,并内置了一套出色的重构功能。这些功能通常与本书中提到的类似,但具体的命名和用户体验会有所不同。

图 2.15 – JetBrains Rider 中的重构
与 VS Code 一样,Rider 相比 Visual Studio 的一个主要优势是它是完全跨平台的,可以在 macOS 或 Linux 上运行。
使用 ReSharper 在 Visual Studio 中进行重构
如果你喜欢使用 Visual Studio,但又想获得 Rider 提供的同样丰富的重构功能,JetBrains 还提供了一款名为ReSharper的 Visual Studio 扩展。
ReSharper 用增强版本替换了 Visual Studio 的许多功能,包括 Visual Studio 的代码分析和重构工具。

图 2.16 – 在 Visual Studio 中使用 ReSharper 进行重构
现在,Visual Studio 往往拥有 ReSharper 和 Rider 提供的绝大多数重构功能,但 ReSharper 和 Rider 的功能有时可能更先进一些。
摘要
在本章中,我们通过选择一个稍微有些复杂性的类,并应用有针对性的重构来使其更容易阅读、维护和扩展,来探讨了重构。
我们通过遵循一系列可重复的动作,将代码从一种形式转换为另一种形式,而不改变其整体行为或结果,从相对复杂的类转变为相对简单的类。
虽然 Visual Studio 支持非常强大的重构工具,但作为经验丰富的开发者,你需要根据你代码的当前复杂程度和观察到的代码异味来决定何时应用每个单独的重构。
在接下来的三章中,我们将通过探索与方法、类和单个代码行相关的重构来更深入地探讨内置的重构。
问题
-
有哪些方法可以触发代码块中的快速操作?
-
Visual Studio 是否曾表明可以进行或推荐重构?
-
在执行之前,你如何知道快速操作会做什么?
-
Visual Studio 快速操作是否是重构代码的唯一方式?
进一步阅读
你可以在以下网址找到有关 Visual Studio 和其他环境中重构的更多信息:
-
快速操作 概述:
learn.microsoft.com/en-us/visualstudio/ide/quick-actions -
JetBrains Rider 与 Visual Studio(带和不带 ReSharper )比较:
www.jetbrains.com/rider/compare/rider-vs-visual-studio/ -
宣布 Visual Studio Code 的 C#开发工具包:
devblogs.microsoft.com/visualstudio/announcing-csharp-dev-kit-for-visual-studio-code/
第三章:代码流和迭代的重构
虽然第一部分的其他章节专注于可以应用于整个方法或类的重构,但本章专注于提高单个代码行的可读性和效率。
开发者大部分时间都在阅读单个代码行,而只有一小部分时间在修改代码。因此,使我们的代码尽可能易于维护是很重要的。
在本章中,我们将探讨与改进代码小片段相关的一些主题:
-
控制程序流程
-
实例化对象
-
遍历集合
-
重构 LINQ 语句
-
审查和测试重构后的代码
技术要求
本章的起始代码可在 GitHub 的github.com/PacktPublishing/Refactoring-with-CSharp的Chapter03/Ch3BeginningCode文件夹中找到。
重构登机应用程序
本章的代码专注于 Cloudy Skies Airline 的成对应用程序:
-
一个登机状态显示应用程序,根据当前登机组和乘客的机票、军事状态以及他们是否需要帮助通过登机桥,告诉用户是否是登机时间。
-
一个允许航空公司员工查看即将登机的乘客并获取每位乘客登机信息的登机亭应用程序。图 3.1.1 展示了应用程序的实际运行情况:

图 3.1 – 登机亭应用程序
由于我们正在探索的不是单一的应用程序,而是两个应用程序,因此我们将随着本章的进展逐步遇到应用程序代码。然而,如果您想先熟悉一下,请随时在 GitHub 上自行查看。
随着我们进入本章,我们将查看其现有的功能代码,并看看通过使用各种 C#语言特性的小重构步骤如何提高代码的可维护性。
我们将首先看看重构如何改进代码的整体流程。
控制程序流程
新的开发者学习到最基本的事情之一是程序如何按顺序执行代码行,以及if 语句和其他语言特性如何控制接下来执行哪些语句。
在本节中,我们将重点关注BoardingProcessor类的CanPassengerBoard方法。该方法开始得很简单:
public string CanPassengerBoard(Passenger passenger) {
bool isMilitary = passenger.IsMilitary;
bool needsHelp = passenger.NeedsHelp;
int group = passenger.BoardingGroup;
在这里,CanPassengerBoard接受一个Passenger对象并返回一个字符串。该方法还声明了一些局部变量,用于存储从传入的对象中获取的数据片段。
这些变量不是必需的,可以通过执行内联变量重构来移除,我们将在本章后面讨论。然而,由于它们提高了后续代码的可读性,它们的存在在很大程度上是有帮助的。这也是我们有时引入局部变量的原因之一,正如我们在第二章中讨论的那样。
如此逻辑的后续部分会变得难以阅读,如下所示:
if (Status != BoardingStatus.PlaneDeparted) {
if (isMilitary && Status == BoardingStatus.Boarding) {
return "Board Now via Priority Lane";
} else if (needsHelp&&Status==BoardingStatus.Boarding) {
return "Board Now via Priority Lane";
} else if (Status == BoardingStatus.Boarding) {
if (CurrentBoardingGroup >= group) {
if (_priorityLaneGroups.Contains(group)) {
return "Board Now via Priority Lane";
} else {
return "Board Now";
}
} else {
return "Please Wait";
}
} else {
return "Boarding Not Started";
}
} else {
return "Flight Departed";
}
}
此方法主要使用if/else语句,还有一些散布的变量声明和定期的返回语句。这些是计算机编程的基本结构,但需要一点时间才能理解这段代码真正做了什么。
对于那些不想整理逻辑的人来说,此代码遵循以下规则:
-
如果飞机已经起飞,则返回
"``Flight Departed" -
如果飞机尚未开始登机,则返回
"BoardingNot Started" -
如果飞机正在登机且乘客需要帮助或为现役军人,则返回
"Board Now viaPriority Lane" -
如果飞机正在登机而乘客的组尚未登机,则返回
"``请等待" -
如果乘客的组可以登机,告诉他们通过正常通道登机,或者如果他们的登机组是优先组之一,则通过优先通道登机
然而,代码足够复杂,弄清楚这些规则可能需要一些时间,而复杂性导致了不确定性,使得其他人难以完全理解这些规则。
如果你打算维护代码,理解这些规则是很重要的。因此,提高代码的可读性对于代码的长期成功至关重要。
反转 if 语句
简化涉及嵌套if语句的复杂逻辑的最快技巧之一可能是反转if语句并提前返回。
目前,我们的高级逻辑看起来是这样的:
if (Status != BoardingStatus.PlaneDeparted) {
// 17 lines of additional if statements and conditions
} else {
return "Flight Departed";
}
当我们回到与飞机起飞检查相关的else语句时,读者已经忘记了最初的if语句是什么了!
在这里,由于else分支非常简单且易于理解,通过以下操作反转if语句是有帮助的:
-
交换
if块和else块的内容。 -
在
if语句中反转布尔表达式。当反转==时,它变为!=,反之亦然。在执行>或<检查的情况下,您会翻转操作数并切换是否包含相等性。根据这些规则,>=变为<,而<=变为>。
在我们的情况下,我们检查Status != BoardingStatus.PlaneDeparted。在这种情况下,我们将!=改为==并得到以下结果:
Status == BoardingStatus.PlaneDeparted
这些步骤保留了程序现有的行为,但改变了代码中语句的顺序。这可以提高我们源代码的可读性。
如果这听起来很复杂,不要担心,因为 Visual Studio 有一个名为反转 if的快速重构操作,如图图 3**.2所示:

图 3.2 – 反转 if 快速重构操作
在这里执行重构实际上将我们的逻辑更改为以下内容:
if (Status == BoardingStatus.PlaneDeparted) {
return "Flight Departed";
} else {
// 17 lines of additional if statements and conditions
}
由于读者不再需要记住if语句甚至 17 行后的内容,所以这更容易阅读,但代码还可以进一步改进。
在返回语句之后省略 else 语句
由于return语句总是立即退出方法,所以在return语句之后你永远不会显式地需要else语句,因为你知道如果你到达了return语句,if块之后的逻辑不会执行。
这让我们可以删除else关键字及其大括号。然后,我们可以缩进之前在else块中的代码。
结果代码保留了if语句:
if (Status == BoardingStatus.PlaneDeparted) {
return "Flight Departed";
}
在这个语句之后,接下来的代码现在与原始的if语句处于相同的缩进级别,更容易阅读和理解:
if (isMilitary && Status == BoardingStatus.Boarding) {
return "Board Now via Priority Lane";
} else if (needsHelp&&Status == BoardingStatus.Boarding) {
return "Board Now via Priority Lane";
} else if (Status == BoardingStatus.Boarding) {
if (CurrentBoardingGroup >= group) {
if (_priorityLaneGroups.Contains(group)) {
return "Board Now via Priority Lane";
} else {
return "Board Now";
}
} else {
return "Please Wait";
}
} else {
return "Boarding Not Started";
}
如果我们想的话,我们可以重复这个重构几次,因为代码中还有几个if/return/else序列。
我会暂时留下这些,因为还有另一个重构我想展示,可以帮助我们处理这里看到的问题。
重新结构化if语句
看看之前的代码,一些逻辑显得重复:
if (isMilitary && Status == BoardingStatus.Boarding) {
return "Board Now via Priority Lane";
} else if (needsHelp&&Status == BoardingStatus.Boarding) {
return "Board Now via Priority Lane";
} else if (Status == BoardingStatus.Boarding) {
// Code omitted for brevity
} else {
return "Boarding Not Started";
}
在这里,我们有一个if/else链,其中三个不同的事物正在检查航班是否正在登机。尽管这三个if语句各不相同,但它们之间有足够的重叠,这让我质疑我们是否可以减少重复。
我们可以考虑的第一个选项可能是一个简单的引入局部变量重构,就像我们在第二章中看到的那样:
bool isBoarding = Status == BoardingStatus.Boarding;
if (isMilitary && isBoarding) {
return "Board Now via Priority Lane";
} else if (needsHelp && isBoarding) {
return "Board Now via Priority Lane";
} else if (isBoarding) {
// Code omitted for brevity
} else {
return "Boarding Not Started";
}
我认为这个代码更容易阅读,尽管我们因为新的局部变量而多了一行。然而,让我们采取一个稍微不同的方法。
而不是引入一个变量,我们可以重新排列我们的if语句以增加一个嵌套层:
if (Status == BoardingStatus.Boarding) {
if (isMilitary) {
return "Board Now via Priority Lane";
} else if (needsHelp) {
return "Board Now via Priority Lane";
} else {
// Code omitted for brevity
}
} else {
return "Boarding Not Started";
}
在这里,从一组if语句中提取一个公共条件到外部的if语句有助于澄清这些if语句,尽管这样做是以牺牲额外的嵌套级别为代价的。
然而,这种简化有助于发现一些其他的重构机会,例如将isMilitary和needsHelp检查合并,因为如果其中任何一个为真,它们会返回相同的值:
if (isMilitary || needsHelp) {
return "Board Now via Priority Lane";
}
我们还可以在if/return代码之后删除else语句,以便进一步缩进代码,只留下登机组逻辑:
if (CurrentBoardingGroup >= group) {
if (_priorityLaneGroups.Contains(group)) {
return "Board Now via Priority Lane";
} else {
return "Board Now";
}
} else {
return "Please Wait";
}
这看起来像是我们可以反转if并删除else语句以进一步简化代码的另一个地方。记住,我们必须将>=改为<才能做到这一点:
if (CurrentBoardingGroup < group) {
return "Please Wait";
}
if (_priorityLaneGroups.Contains(group)) {
return "Board Now via Priority Lane";
} else {
return "Board Now";
}
如你所见,随着我们简化代码,代码的可读性显著提高。
让我们退一步,看看这些重构之后的条件逻辑:
if (Status == BoardingStatus.PlaneDeparted) {
return "Flight Departed";
}
if (Status == BoardingStatus.Boarding) {
if (isMilitary || needsHelp) {
return "Board Now via Priority Lane";
}
if (CurrentBoardingGroup < group) {
return "Please Wait";
}
if (_priorityLaneGroups.Contains(group)) {
return "Board Now via Priority Lane";
} else {
return "Board Now";
}
} else {
return "Boarding Not Started";
}
代码现在更容易阅读,也更难误解。我们可以反转登机状态检查以提前返回,但在这里我们会做其他的事情。
让我们看看如何通过更分化的语言特性:三元运算符,进一步减少我们的行数。
使用三元运算符
如果你喜欢三元运算符,你可能会注意到在重构过程中我们可以使用它的机会。
对于那些不熟悉或不完全熟悉三元条件运算符的人来说,可以将其视为一种“如果我的条件为真使用这个值,否则使用另一个值”类型的运算符。
三元运算符的语法是boolExpression ? trueValue : falseValue;。
换句话说,你可以像这样编写没有三元运算符的代码:
int value;
if (someCondition) {
value = 1;
} else {
value = 2;
}
然而,相同的代码可以使用单行中的三元运算符来编写:
int value = someCondition ? 1 : 2;
如您所见,三元运算符让我们将六行代码压缩成一行。这种简洁性是那些喜欢在代码中使用三元运算符的人的关键因素。
不太喜欢三元运算符的人经常指出,三元运算符难以阅读——尤其是在快速阅读代码时。换句话说,虽然它们使代码更加简洁,但这种简洁性可能会在长期内减慢你的速度,因为代码的可维护性降低了。
让我们看看我们代码的一小部分,看看三元运算符是如何应用的:
if (CurrentBoardingGroup < group) {
return "Please Wait";
}
if (_priorityLaneGroups.Contains(group)) {
return "Board Now via Priority Lane";
} else {
return "Board Now";
}
在这里,我们正在检查当前登机组是否是优先组,然后根据Contains调用的结果告诉用户使用优先通道登机或正常登机。
由于我们是根据布尔表达式的结果返回单个值,我们可以用以下方式重写代码:
if (CurrentBoardingGroup < group) {
return "Please Wait";
}
return _priorityLaneGroups.Contains(group)
? "Board Now via Priority Lane"
: "Board Now";
这样可以将五行代码缩减到三行,或者如果你想在布尔表达式的同一行上放置?和:部分,则可以缩减到一行代码。
你可能已经注意到,这次重构现在将整个代码块置于一个位置,你可以根据登机组引入另一个三元运算符,return "Please Wait",如果该表达式为真,如果表达式为假,则返回早期三元运算符表达式的结果:
return (CurrentBoardingGroup < group)
? "Please Wait"
: _priorityLaneGroups.Contains(group)
? "Board Now via Priority Lane"
: "Board Now";
虽然这是有效的 C#代码,但我可以证实,如果同事在代码审查时向我展示这样的代码,我可能会忍不住说出一些不太礼貌的话!
小贴士
记住:代码行数少并不总是等于更高的可维护性。
在个人层面,我倾向于在很多地方避免使用三元运算符,并且始终避免将三元运算符链式使用。然而,有时当我感觉某个代码片段适合使用三元运算符时,我也会使用它。
例如,有时一个方法非常简单,如果你使用三元运算符,就可以将其压缩成一行代码。这个特定的更改让你可以使用表达式主体成员功能,我们将在第四章中讨论。
当我使用三元运算符时,我会像之前展示的那样,将三元运算符表达式格式化为三行,第一行包含布尔表达式。第二行将包含?运算符和如果表达式为真时使用的值,第三行将包含:运算符和如果表达式为假时使用的值:
var myVar = booleanExpression
? valueIfTrue
: valueIfFalse;
我发现这种方法在三元运算符的代码更简洁的优点和三元运算符使代码难以快速和准确地阅读的缺点之间找到了一个平衡点。
将 if 语句转换为 switch 语句
这种方法的逻辑现在更容易理解了,简化到这个层面突显了,根据当前的登机状态,我们正在做三件事情之一:
-
如果其状态是
PlaneDeparted,则通知用户航班已起飞 -
检查军事状态、是否需要帮助登机以及
Boarding状态的登机组 -
通知用户其他状态(
NotStarted是目前唯一的其他状态)的登机尚未开始
当与枚举值一起工作时,这种分支逻辑很常见。
在我们的情况下,我们的enum值只有三种状态:
BoardingStatus.cs
public enum BoardingStatus {
NotStarted = 0,
Boarding = 1,
PlaneDeparted = 2,
}
在你发现自己正在检查不同值相同的变量时,你通常可以将它们重写为使用switch 语句。
switch语句本质上是一系列简化的if/else if/else类型的检查,它们都检查相同的值,就像我们的代码检查Status一样。我们很快就会看到一个switch语句的例子,但如果你不熟悉它们,你可以将它们视为编写一系列相关if/else if语句的另一种方式。
这可以手动完成,或者如果你的代码是if/else if/else类型的结构,你可以使用 Visual Studio 中内置的特定重构,如下面的代码所示:
if (Status == BoardingStatus.PlaneDeparted) {
return "Flight Departed";
} else if (Status == BoardingStatus.Boarding) {
if (isMilitary || needsHelp) {
return "Board Now via Priority Lane";
}
if (CurrentBoardingGroup < group) {
return "Please Wait";
}
return _priorityLaneGroups.Contains(group)
? "Board Now via Priority Lane"
: "Board Now";
} else {
return "Boarding Not Started";
}
注意,我在之前的代码中确实添加了else关键字(在上一个代码片段中加粗),以便进入那个if/else if/else结构,这使得 Visual Studio 能够识别我们即将使用的重构。
一旦我们有了这种模式的代码,选择了if语句,如图图 3.3所示:

图 3.3 – 转换为“switch”语句的重构选项
这种重构使我们的基于状态的逻辑更加明显:
switch (Status) {
case BoardingStatus.PlaneDeparted:
return "Flight Departed";
case BoardingStatus.Boarding:
if (isMilitary || needsHelp) {
return "Board Now via Priority Lane";
}
if (CurrentBoardingGroup < group) {
return "Please Wait";
}
return _priorityLaneGroups.Contains(group)
? "Board Now via Priority Lane"
: "Board Now";
default:
return "Boarding Not Started";
}
作为阅读此代码的人,我发现与if/else if/else链相比,这种方法更容易扫描和解释,尽管逻辑功能相同。使用if/else if/else语句时,我可能会注意到逻辑正在多次比较相同的值,而switch语句则使这一点明确。
使用switch语句的另一个好处是,当你的switch比较一个enum值(例如BoardingStatus)并且你缺少一个或多个enum值的 case 时,它会解锁一个内置的重构选项。
这个选项在switch语句的快速操作菜单中显示为添加缺失的 case,如图图 3.4所示:

图 3.4 – 快速操作菜单中的“添加缺失情况”重构选项
警告
我想指出的是,NotStarted 状态应该通过 break 语句跳出 switch,而不是像之前那样通过 default 关键字返回值。
在此情况下,C# 编译器会为我们标记这个错误,因为该方法不会为这个路径返回值,但在 switch 语句中存在 default 情况时添加缺失的情况通常会导致行为发生变化。
在我们的情况下,我们可以将 NotStarted 状态与默认情况合并,得到一个更明确的选项列表:
switch (Status) {
case BoardingStatus.PlaneDeparted:
return "Flight Departed";
case BoardingStatus.Boarding:
if (isMilitary || needsHelp) {
return "Board Now via Priority Lane";
}
if (CurrentBoardingGroup < group) {
return "Please Wait";
}
return _priorityLaneGroups.Contains(group)
? "Board Now via Priority Lane"
: "Board Now";
case BoardingStatus.NotStarted:
default:
return "Boarding Not Started";
}
这段代码现在比之前更容易阅读,并且通过状态逻辑的流程现在一目了然。
在实际应用中,我可能会将默认情况改为抛出异常,明确告诉我特定的 Status 不支持此逻辑。这看起来可能像以下逻辑:
case BoardingStatus.NotStarted:
return "Boarding Not Started";
default:
throw new NotSupportedException($"Unsupported: {Status}");
我还可能想要执行 提取方法 重构——正如我们在 第二章 中所看到的——将处理登机状态的逻辑移动到它自己的方法中。然而,我将推迟这样做,以展示 switch 表达式。
转换为 switch 表达式
Switch 表达式 是对 switch 语句的一种进化,它依赖于 模式匹配 表达式来简化并扩展 switch 语句内部可能实现的功能。
switch 表达式是 C# 中相对较新的功能,于 2019 年作为 C# 8 的一部分发布。尽管在撰写本文时已经过去几年了,但我仍然觉得 switch 表达式足够新,以至于许多 C# 开发者对它们不熟悉或不熟练。
一个简单的 switch 表达式看起来很像 switch 语句:
return Status switch {
BoardingStatus.PlaneDeparted => "Flight Departed",
BoardingStatus.NotStarted => "Boarding Not Started",
BoardingStatus.Boarding => "Board Now",
_ => "Some other status",
};
这些 switch 表达式看起来与 switch 语句非常相似,除了以下方面:
-
它们以你想要评估的值开始,后面跟着
switch关键字,而不是以switch (value)开始 -
我们不使用
case或break关键字 -
单个情况有一些条件,可能在左侧为真,然后是一个箭头符号 (
=>),以及如果左侧的条件为真,则在右侧使用的值。 -
我们没有使用
default关键字,而是用_表示任何其他匹配项。
switch 表达式的一个优点是它们非常简洁,同时仍然具有一定的可读性。然而,switch 表达式的功能远不止我之前所展示的。
你可能已经注意到我之前引入的示例 switch 表达式没有充分处理登机逻辑。具体来说,我们为现役军人、需要登机帮助的人、登机组和优先通道制定了规则,而这些都没有在前面的代码块中表示出来。
让我们看看一个处理这些情况的 switch 表达式:
return Status switch {
BoardingStatus.PlaneDeparted => "Flight Departed",
BoardingStatus.NotStarted => "Boarding Not Started",
BoardingStatus.Boarding when isMilitary || needsHelp
=> "Board Now via Priority Lane",
BoardingStatus.Boarding when CurrentBoardingGroup < group
=> "Please Wait",
BoardingStatus.Boarding when
_priorityLaneGroups.Contains(group)
=> "Board Now via Priority Lane",
BoardingStatus.Boarding => "Board Now",
_ => "Some other status",
};
这段代码与我们在上一节中看到的 switch 表达式略有不同。在这里,Boarding 状态重复了四次,有时还伴随着 when 关键字。
这段代码所做的是使用模式匹配来检查不仅 Status 是 Boarding,而且其他条件也成立。实际上,我们能够检查状态,并在 when 关键字之后可选地检查另一个布尔表达式。
如果两个条件都不成立,switch 表达式将按顺序评估下一行。这使得 switch 表达式成为一组匹配规则,确保第一条规则评估为真。
模式匹配
模式匹配是一种较新的 C# 语法,允许您简洁地检查对象和变量的不同属性和方面。我们将在第十章“防御性编码技术”中更深入地探讨模式匹配语法,但本节将作为对其一些功能的良好介绍。
换句话说,这个 switch 表达式检查以下规则,并对第一个为真的规则做出反应:
-
飞机已经起飞。
-
登机尚未开始。
-
登机已经开始,乘客是现役军人或需要帮助。
-
乘客的登机组尚未被召唤。
-
乘客的登机组正在登机,并且是优先通道组。
-
乘客的登机组正在登机,但他们不在优先登机通道。
-
其他任何状态
switch 表达式简洁,允许您将 switch 语句的结构化清晰性与模式匹配和 when 关键字的力量结合起来,使非常易读的有序逻辑明显。
就像您编程工具箱中的任何工具一样,switch 表达式并不是解决每个问题的方案,您和您的团队可能不会像我一样喜欢阅读 switch 表达式。然而,它们仍然是您工具箱中一个宝贵的工具,可以帮助简化代码,同时保持其易于阅读、维护和扩展。
我们将在第十章中回顾一些模式匹配语法,但让我们继续看看我们可以如何改进处理对象集合的工作。
实例化对象
现在我们已经足够改进了 CanPassengerBoard 方法,让我们看看我们如何创建对象,并看看您可以进行的一些简单改进,这将简化代码中的对象实例化。
术语说明
新的开发者经常被一些开发者常用的短语所困扰。例如,在本节中,我们将讨论实例化对象。这是开发者常用的说法,但这仅仅意味着使用 new 关键字创建类的特定实例的过程。当您看到术语实例化时,您可以简单地将其视为创建某个特定实例的过程。
这部分的代码可能来自任何地方,但我们将关注本章附带测试项目中 PassengerTests.cs 文件中找到的一对方法中的代码。
将 var 替换为显式类型
我想要关注的代码第一行来自我们的一些单元测试:
PassengerTests.cs
var p = Build(first, last);
在这里,我故意省略了代码周围的上下文,以强调一个观点,这个观点是:花点时间,尝试确定 p 变量的数据类型。
p 存储了 Build 的结果,Build 接受一个名为 first 和 last 的参数对,但仅从这一行我们无法自信地断定 p 包含的数据类型。
这是因为 p 是用 var 关键字声明的。var 关键字是一种简写方式,意思是“嘿,编译器,当你编译这段代码时,我希望你确定这个数据类型,并在编译后的代码中将 var 关键字替换为实际的数据类型。”
换句话说,var 通常是为了不输入数据类型的完整名称而简化的快捷方式。然而,它带来一个小小的代价,那就是它使得阅读变量包含的数据类型变得更困难。
这对于具有复杂数据类型(如 IDictionary<Guid, HashSet<string>>)的情况是有意义的,但对于短类型名(如 int)可能会有些荒谬。
var 的其他用法
var 关键字确实有其他用途,超出了我这里所描述的。例如,它可以在大多数代码库中轻松存储 var。
Visual Studio 允许你悬停在变量声明上,并查看实际使用的类型。在这种情况下,p 代表一个 Passenger 对象,但这仍然会减慢你的阅读速度。
相反,我建议你利用内置的 使用显式类型代替 'var' 重构功能。参见 图 3.5:

图 3.5 – 使用显式类型
这使得你的代码更容易阅读:
Passenger p = Build(first, last);
当然,var 存在是有原因的,它被引入是为了解决某些问题,包括赋值语句中的冗余。接下来,我们将看看 目标类型的新 关键字,它为解决这个问题提供了不同的解决方案。
使用目标类型的新简化创建
var 关键字被构建来帮助处理如下变量实例化之类的行:
private Passenger Build(string firstName, string lastName){
Passenger passenger = new Passenger();
passenger.FirstName = firstName;
passenger.LastName = lastName;
return passenger;
}
当我们实例化一个新的 Passenger 对象并将其分配给新的乘客变量时,我们在赋值操作符(=)的左右两侧稍微重复了 Passenger 类的名称。
var 关键字允许我们将创建此对象的简化到仍然可读的语法 var passenger = new Passenger();。在这里,var 允许我们通过缩写用于新变量的类型来简化赋值语句的左侧。
C# 9 引入了目标类型的新关键字,它允许我们通过有效地说明我们正在实例化的类的类型与作为赋值运算符目标的作用变量相同,从而简化赋值运算符右侧的语法。
换句话说,目标类型的新语法是告诉 C# 创建与我们将要存储值的变量相同类型的对象的一种方式。这允许我们避免使用 var 并避免重复:
Passenger passenger = new();
我非常喜欢这种语法,并且倾向于在我的所有代码中使用它。第一次看到这个特性时,它可能会让其他开发者感到一些困惑,但这只是一个微不足道的单次代价,因为它同时保持了代码的简洁和可读性。
小贴士
Visual Studio 在快速操作菜单中提供了一个使用‘new(…)’选项,这将允许你将传统的对象实例化转换为目标类型的新语法。
当我们谈论创建对象时,让我们看看对象初始化器如何在创建对象时帮助设置属性。
使用对象初始化器
让我们再次看看上一个示例中的 Build 方法,同时关注配置创建的乘客对象:
private Passenger Build(string firstName, string lastName){
Passenger passenger = new();
passenger.FirstName = firstName;
passenger.LastName = lastName;
return passenger;
}
这段代码本身并不差,但它确实有点重复。
具体来说,代码通过在每行将 passenger. 放在属性前面,然后在分配值之前为该属性赋值,重复了它配置的对象的信息。
这对于只有两个属性来说非常简洁。但想象一下,如果你有一个具有 10 个或更多属性的大型对象需要配置,这段代码会变得非常重复,甚至可能会分散对正在配置的属性名称的注意力。
虽然使用接受表示属性值的参数的构造函数是一种解决方案(我们将在下一章中探讨),另一种解决方案是使用对象初始化器。正如你可能猜到的,Visual Studio 为此提供了一个快速操作重构,尽管名称对象初始化可以简化(如图 3.6 所示)有些不寻常:

图 3.6 – 简化对象初始化
使用这种重构可以将我们的代码转换成更简洁的格式:
private Passenger Build(string firstName, string lastName){
Passenger passenger = new() {
FirstName = firstName,
LastName = lastName
};
return passenger;
}
我非常喜欢这种语法,并且它与 init 和 required 属性配合得非常好,我们将在第十章“防御性编码技术”中探讨这些属性。然而,使用对象初始化器也有一个缺点:堆栈跟踪。
当你有一个设置对象多个不同属性的初始化器,并且发生异常来计算要存储的值时,异常不会指出错误发生在哪一行代码或哪个属性即将被更新,而只是表明它在初始化器中的某个地方发生了。
另一方面,如果你使用多行设置单个属性,异常详细信息将标识有问题的行。当然,这可能是一个避免在可能产生异常的初始化器中进行计算的论据。
当我们讨论init、required和with表达式时,我们将在第十章中更详细地回顾初始化器,但就目前而言,让我们继续讨论集合。
遍历集合
要开始探索集合,让我们回到BoardingProcessor类,看看它的DisplayPassengerBoardingStatus方法。我们将一次探索这个方法的一部分,从它的方法签名开始:
public void DisplayBoardingStatus(
List<Passenger> passengers, bool? hasBoarded = null) {
在这里,我们可以看到该方法接受一个Passenger对象列表,以及一个可选的可空布尔参数hasBoarded,它可以存储true、false或null。这个hasBoarded参数用于根据其值可选地过滤我们的乘客列表:
-
true:仅包括已经登机的乘客 -
false:仅包括尚未登机的乘客 -
null:不按登机状态过滤(默认选项)
在构建搜索方法时,我经常看到这种可空的过滤参数,我们将在第五章 面向对象重构中更深入地探讨它。
DisplayBoardingStatus中的下一部分代码处理过滤逻辑:
List<Passenger> filteredPassengers = new();
for (int i = 0; i < passengers.Count; i++) {
Passenger p = passengers[i];
if (!hasBoarded.HasValue || p.HasBoarded==hasBoarded) {
filteredPassengers.Add(p);
}
}
这是我们将在本节剩余部分重点关注的代码部分。它通过遍历passengers中的乘客并条件性地将其添加到我们的新乘客名单中,构建了一个与用户选择的过滤选项相匹配的新乘客名单。
术语说明
迭代某个东西是另一个让新开发者感到困惑的术语。它仅仅意味着遍历集合中的每个项目。
该方法剩余部分专注于在登机柜台向代理人显示乘客:
DisplayBoardingHeader();
foreach (Passenger passenger in filteredPassengers) {
string statusMessage = passenger.HasBoarded
? "Onboard"
: CanPassengerBoard(passenger);
Console.WriteLine($"{passenger.FullName,-23} Group {passenger.BoardingGroup}: {statusMessage}");
}
}
实际上,对于我们要显示的每一位乘客,我们都会写出他们的名字、登机组和他们在登机应用上看到的消息,或者如果他们已经登机,则是"Onboard"。
总体而言,这个方法很简单,代码行数不到 20 行,这往往会导致易于维护的代码。
话虽如此,让我们看看我们可以如何改进这段代码。
介绍 foreach
再看看代码,将乘客名单过滤到一个新的乘客名单中:
List<Passenger> filteredPassengers = new();
for (int i = 0; i < passengers.Count; i++) {
Passenger p = passengers[i];
if (!hasBoarded.HasValue || p.HasBoarded == hasBoarded) {
filteredPassengers.Add(p);
}
}
虽然这段代码并不复杂,但让我印象深刻的一点是我们正在使用for循环来枚举乘客。在这个循环内部,我们除了通过索引从列表中获取乘客之外,并没有对索引变量i做任何事情。
当你有一个像这样的for循环,它没有做任何复杂的事情(例如,从列表的任何地方开始,反向循环,或者跳过每隔一个项目),你通常可以用foreach循环来替换这个循环。
要将 for 循环转换为 foreach 循环,您可以选中 for 循环,然后使用 Visual Studio 中内置的 转换为foreach 重构功能(图 3**.7):

图 3.7 – 快速操作菜单中的“转换为‘foreach’”重构选项
这将转换为 foreach 循环,并完全消除了变量声明:
List<Passenger> filteredPassengers = new();
foreach (Passenger p in passengers) {
if (!hasBoarded.HasValue || p.HasBoarded == hasBoarded) {
filteredPassengers.Add(p);
}
}
我在可能的地方都使用 foreach,因为它不仅消除了变量声明和使用索引器的需要,而且使整体代码更容易阅读。
几乎所有 的 for 循环都是从 0 开始,逐个元素地循环到集合的末尾,但并非每个 for 循环都是这样。因此,每次我阅读一个 for 循环时,我都需要检查它是否是一个标准的 for 循环,或者它是否有特殊之处。使用 foreach 循环时,我不需要这样做,因为语法不支持它。这增加了阅读的舒适度和速度,并通过简洁性提高了代码的可维护性。
此外,foreach 循环可以与实现 IEnumerable 的任何东西一起使用,而 for 循环要求它们循环的集合具有索引器。这意味着 foreach 循环可以循环比 for 循环更多的集合类型。
集合接口
.NET 提供了几个集合接口,包括 IEnumerable、ICollection、IList、IReadOnlyList 和 IReadOnlyCollection。了解这些集合类型有助于阅读本书,但不是必需的。请参阅本章末尾的 进一步阅读 部分,以获取有关这些接口的更多信息,但就现在而言,要知道 IEnumerable 接口只是指可以在 foreach 循环中循环的某种东西的一种更复杂的方式。
转换为 for 循环
虽然 foreach 循环很棒,并且在我的大多数情况下是默认循环,但有时你可能想要有一个 for 循环以获得更多控制。如果你需要以非标准方式循环集合,或者需要使用索引变量进行除读取集合变量之外的其他操作,你通常将想要使用 for 循环。
Visual Studio 为我们提供了将 foreach 循环转换为 for 循环的功能。参见 图 3**.8:

图 3.8 – 将 foreach 循环转换回 for 循环
我并不经常使用这种重构,但当你需要时它很方便。
现在,让我们暂时将代码留在 foreach 循环中,看看 LINQ 如何帮助我们改进它。
转换为 LINQ
你可能已经注意到,在 图 3**.8 中,有一对建议将 foreach 循环转换为 LINQ。
IEnumerable。这允许你使用箭头函数对该集合执行快速聚合、转换和过滤操作。
箭头函数
箭头函数(也称为 Lambda 表达式)使用“胖箭头”(=>)语法以缩略格式表示小方法。本书假设你对箭头函数有基本了解。如果你需要更多信息或想要复习箭头函数的工作方式,请参阅本章的进一步阅读部分。
让我们看看当我们使用 foreach 循环的快速操作菜单时,我们的 foreach 循环会发生什么变化:

图 3.9 – 将 foreach 循环转换为使用 LINQ
此重构将我们的 foreach 循环转换成极小的一部分代码:
List<Passenger> filteredPassengers = new();
filteredPassengers.AddRange(passengers.Where(p => !hasBoarded.HasValue || p.HasBoarded == hasBoarded));
此代码获取我们的 passengers 集合并调用 Where 扩展方法。Where 方法将创建并返回一个新的 IEnumerable 序列,其中只包含 passengers,其中箭头函数 p => !hasBoarded.HasValue || p.HasBoarded == hasBoarded 对该乘客返回值为 true。
扩展方法
扩展方法是在静态类中定义的静态方法,允许你构建看起来像为现有类型添加新方法的语法。LINQ 严重依赖于附加到各种接口的扩展方法。我们将在第四章中探讨创建扩展方法。
这不会修改我们的原始集合,而是创建一个新的 Passenger 对象集合,然后将其传递到 filteredPassengers.AddRange 方法中。
虽然这段代码已经很简洁了,但我们可以通过利用泛型 List 类的构造函数来进一步改进它。
List<T> 类有一个构造函数,它接受一个 IEnumerable<T> 接口,并允许你围绕一系列元素高效地创建一个新的列表。这将使我们避免需要 AddRange 调用,并有助于将我们的代码简化到单条语句:
List<Passenger> filteredPassengers =
new(passengers.Where(p => !hasBoarded.HasValue ||
p.HasBoarded == hasBoarded));
如果我们愿意,我们也可以完全删除 filteredPassengers 变量,通过过滤乘客并将其重新赋值回自身:
passengers = passengers.Where(p=>!hasBoarded.HasValue ||
p.HasBoarded==hasBoarded).ToList();
在这里,我们执行 Where 调用来生成包含我们的乘客的 IEnumerable<Passenger> 接口,然后在该 IEnumerable 接口上调用 ToList 方法,将其转换回 List 方法,以便它可以存储在 passengers 参数中。
此外,注意之前使用 filteredPassengers 的任何地方都需要更新为使用 passengers:
foreach (Passenger passenger in passengers) {
string statusMessage = passenger.HasBoarded
? "Onboard"
: CanPassengerBoard(passenger);
Console.WriteLine($"{passenger.FullName,-23} Group
{passenger.BoardingGroup}: {statusMessage}");
}
我喜欢 LINQ,并认为它对于创建简单且可维护的应用程序非常有价值,但如果你不熟悉 LINQ 或不习惯阅读箭头函数(=>)的表示法,它可能需要一些习惯。
话虽如此,我确实在 LINQ 代码中看到了一些常见的错误。因此,在我们结束这一章之前,让我们看看其中的一些。
重构 LINQ 语句
在本章的最后部分,我们将通过关注大多数使用 LINQ 的代码库都将从中受益的一些常见改进,来回顾一些 LINQ 代码中更常见的优化。
选择正确的 LINQ 方法
LINQ 有几种不同的方法来在集合中查找特定的项。
如果你有一个名为people的IEnumerable<Passenger>接口,并且想通过他们的名字找到某人,你可能会编写如下代码:
LinqExamples.cs
PassengerGenerator generator = new();
List<Passenger> people = generator.GeneratePassengers(50);
Passenger me =
people.FirstOrDefault(p => p.FullName == "Matt Eland");
Console.WriteLine($"Matt is in group {me.BoardingGroup}");
这段代码使用了 LINQ 的FirstOrDefault方法,该方法搜索集合直到找到箭头函数评估为true的第一个值。在这个例子中,它会找到第一个FullName设置为"Matt Eland"的人,从FirstOrDefault方法返回该值,并将其存储在名为matt的Passenger变量中。
然而,如果箭头函数没有返回任何true的项,FirstOrDefault将使用Passenger类型的默认值,对于像类这样的引用类型,这将是一个空值。
默认值
在.NET 中,bool类型的默认值是false,数值类型如int和float默认为0,而引用类型包括string、List和其他类默认为null。
换句话说,这个FirstOrDefault调用将会找到如果 Matt 存在于乘客中,则返回他;如果他没有,则返回null。
这个问题在于紧接着的下一行尝试读取matt.BoardingGroup的值。如果我们找到了元素,这没问题,但如果我们没有找到,这段代码在尝试访问BoardingGroup时将引发NullReferenceException错误,这很可能不是其作者的意图。
注意,我们如何修复这段代码取决于我们的期望是什么。
使用 LINQ,当你在一个集合中查找元素时,你需要决定两件事:
-
我是否可以接受多于一个项匹配我的箭头函数,或者我需要确保最多只有一个项返回
true? -
我是否可以接受我寻找的项目根本不存在?
第一个决定决定了你是否调用First或Single。使用First,逻辑将找到与查询匹配的第一个元素并返回它。然而,使用Single,逻辑将超出第一个匹配项,以确定集合中的任何其他元素是否也匹配该表达式。如果有一个匹配该表达式,将抛出一个InvalidOperationException错误,告诉你序列包含多个匹配元素。
大多数开发者不喜欢在运行代码时看到异常。然而,有时你需要知道你的查询是否有多于一个匹配项。一般来说,尽早失败比在更混乱的地方失败要好,那里隐藏着程序最初偏离轨道的地方。
当你在集合中查找元素时,你做出的第二个决定是接受不匹配查询的对象。如果这没问题,那么你通常会调用FirstOrDefault或SingleOrDefault(根据你之前是否允许多个匹配的决定)。然而,如果永远不能接受没有匹配项,那么你将使用First或Single而不是FirstOrDefault或SingleOrDefault。
如果序列中没有匹配的元素,First和Single都会抛出InvalidOperationException错误。如果你使用First或Single,并且集合中没有元素返回箭头函数的true,则会抛出异常。这使得无法处理First或Single的结果中的null值,这对于简化代码非常有帮助。
小贴士
当你的代码在遇到问题时抛出InvalidOperationException错误,这通常比在代码中 30 行后遇到NullReferenceException错误并需要找出值是如何到达预期位置的要有用得多。
防止出现NullReferenceException错误。我们将在第十章中更深入地探讨这个问题。
让我们继续讨论组合 LINQ 方法的方法。
组合 LINQ 方法
LINQ 的一个优点是它允许你通过在另一个 LINQ 方法的结果上调用 LINQ 方法来“链式连接”不同的方法。这让你可以做诸如使用Where过滤到项目子集、使用OrderBy重新排序结果以及通过Select将它们转换成新对象等事情。
然而,随着.NET 的发展,LINQ 为其现有方法增加了一些更专业的重载,这使得一些链式连接的方法变得不必要,甚至效率低下。
以以下代码块为例:
bool anyBoarded =
people.Where(p => p.HasBoarded).Any();
int numBoarded =
people.Where(p => p.HasBoarded).Count();
Passenger firstBoarded =
people.Where(p => p.HasBoarded).First();
初看,这段代码看起来没问题。这三个变量赋值都是过滤然后查看过滤结果。当然,有引入局部变量people.Where(p => p.HasBoarded)的机会,但除此之外,代码在表面上看起来通常没问题。
然而,LINQ 提供了Any、Count、First和其他一些方法的重载版本,这些方法接受一个谓词(这只是一个箭头函数的华丽说法)。
这些重载版本允许你将Where方法和其他方法组合成一个更简洁的格式:
bool anyBoarded = people.Any(p => p.HasBoarded);
int numBoarded = people.Count(p => p.HasBoarded);
Passenger firstBoarded = people.First(p => p.HasBoarded);
这种写法不仅更简洁,而且在某些情况下可能更高效。
例如,在此之前,当我们执行people.Where(p => p.HasBoarded).Any()时,此代码从左到右进行评估,将大量项目过滤成一个更小的项目列表。一旦整个列表被过滤,就会调用Any方法,如果结果列表中至少有一个项目,则返回true。
将其与people.Any(p => p.HasBoarded)版本进行对比。这个方法遍历项目,一旦它看到任何从箭头函数返回true的元素,它就知道它可以停止评估,因为它的最终结果将是 true。
总是寻找使用这些专用 LINQ 重载的机会,因为它们可以产生非常简洁甚至更高效的代码。
使用 Select 进行转换
假设你想要创建一个列表,列出所有尚未登机的乘客的字符串。对于每个名字,你想要将其格式化为乘客的名字和登机组。因此,一个示例条目可能是"Priya Gupta-7"。
你可以像这样编写此代码:
List<string> names = new();
foreach (Passenger p in people) {
if (!p.HasBoarded) {
names.Add($"{p.FullName}-{p.BoardingGroup}");
}
}
然而,LINQ 有一个名为Select的方法,允许你将项目从一种形式转换为另一种形式,这对于这种情况来说非常完美。
小贴士
对于那些有 JavaScript 背景的人来说,Select函数与Map函数类似。
这个Select版本看起来是这样的:
List<string> names =
people.Where(p => !p.HasBoarded)
.Select(p => $"{p.FullName}-{p.BoardingGroup}")
.ToList();
在这里,Where调用将结果过滤到未登机的乘客,而Select调用将那些对象从Passenger对象转换为字符串。
Select不仅限于字符串。你可以选择对你相关的任何数据类型,包括整数、其他对象、列表,甚至是匿名类型或元组。
最终,无论你有一个形状的对象集合,并且你需要这些相同的对象但以不同的形式,Select都是一个值得考虑的好方法。
审查和测试重构后的代码
虽然我们在这章中没有修改很多代码,但我们修改的代码在尺寸上缩小了,因此在阅读、理解和修改过程中变得更加容易。
这就是为什么我们要重构。重构应该积极地提高我们应用程序的可维护性,并偿还那些可能在未来引入错误和延迟的战略性技术债务。
重构后的代码
本章最终的重构代码可在github.com/PacktPublishing/Refactoring-with-Csharp仓库中的Chapter03/Ch3RefactoredCode文件夹中找到。
由于重构的艺术在于在不改变其功能的情况下改变代码的形式,我们必须在继续之前测试应用程序。
我们将在第六章中更多地讨论手动和自动测试,但到目前为止,通过在 Visual Studio 顶部选择测试菜单,然后点击运行****所有测试来运行测试。
这将显示测试资源管理器和一大片绿色勾号,如图图 3.10所示。

图 3.10 – 通过测试本章的代码
现在,让我们总结一下本章学到的内容。
摘要
在本章中,我们探讨了重构技术,以帮助更好地控制程序流程、实例化对象、遍历集合,并通过 LINQ 编写更高效的代码。
我们所讨论的每种重构技术都是你工具箱中的一个工具,它可能在适当的情境下提高你代码的可读性和可维护性。随着你不断练习重构,你会了解更多关于何时应用哪种重构来改进你正在工作的代码。
在下一章中,我们将从改进单个代码行转向一个更大的视角,当我们努力重构整个 C# 代码的方法时。
问题
回答以下问题以测试你对本章知识的掌握:
-
简洁的代码和可读的代码哪个更重要?
-
在你正在工作的项目中浏览代码文件。你注意到你的代码中的
if语句有什么特点? -
嵌套的
if语句使用频率如何? -
你的
if语句的条件中是否有任何逻辑被频繁重复? -
你是否看到任何可以通过反转
if语句或切换到switch语句或switch表达式来改进的地方? -
你认为你的团队在处理集合时是否已经充分利用了 LINQ 的潜力?你看到了哪些改进的机会?
进一步阅读
你可以通过阅读以下资源来找到更多关于本章讨论的材料的信息:
-
Switch 表达式:
learn.microsoft.com/en-US/dotnet/csharp/language-reference/operators/switch-expression -
.NET 集合接口之间的差异:
newdevsguide.com/2022/10/09/understanding-dotnet-collection-interfaces/ -
LINQ 中的查询语法和方法语法:
learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/query-syntax-and-method-syntax-in-linq -
探索数据范围:
learn.microsoft.com/en-us/dotnet/csharp/tutorials/ranges-indexes -
箭头函数和 Lambda 操作符:
learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-operator
第四章:方法级别的重构
在上一章中,我们介绍了改进单个代码行的内容。我们将在此基础上扩展这些课程,涵盖重构整个方法和解决代码如何组合形成更大方法的问题,这些方法随后相互交互。
我们在 第二章 中介绍提取方法重构时已经看到了一些这方面的内容。然而,在本章中,我们将扩展我们的工具集,涵盖重构方法的基础知识,然后在我们介绍以下主要主题时进入更高级的领域:
-
重构航班跟踪器
-
重构方法
-
重构构造函数
-
重构参数
-
重构为函数
-
引入静态方法和扩展方法
技术要求
本章的起始代码可在 GitHub 的 github.com/PacktPublishing/Refactoring-with-CSharp 中的 Chapter04/Ch4BeginningCode 文件夹中找到。
重构航班跟踪器
本章的代码主要关注一个 FlightTracker 类,旨在跟踪和显示商业机场为候机楼乘客提供的出发航班,如图 图 4.1 所示:

图 4.1 – FlightTracker 显示出发航班状态
FlightTracker 类包含许多与管理和显示航班相关的方法。它由表示系统中单个航班的 Flight 类和支持表示航班所有相关状态的 FlightStatus 枚举类支持,如图 图 4.2 所示:

图 4.2 – 展示 FlightTracker 和支持类的类图
我们将在本章中探讨这些代码片段,但就目前而言,我们需要了解 FlightTracker 的关键职责包括以下内容:
-
跟踪航班列表
-
安排新的航班(将其添加到列表中)
-
标记航班为到达、出发或延误
-
显示所有航班
-
通过航班 ID 查找航班
这是一个相当简单的航班跟踪器类,但我们在下一章中将会看到一个稍微复杂一点的版本,届时我们将探讨面向对象的重构。
现在,让我们看看我们可以采取的一些简单步骤来改进这些方法。
重构方法
在本节中,我们将探讨与方法和它们交互相关的一系列重构。我们将从讨论方法访问修饰符开始。
更改方法访问修饰符
在我担任专业 C# 教练的时间里,我注意到我的学生往往不太考虑他们在代码中使用的 访问修饰符。具体来说,我的学生通常会做以下两件事之一:
-
他们默认将所有方法标记为 public,除非有人(通常是本人)建议他们使用不同的访问修饰符
-
他们默认将所有方法标记为 private(或者完全省略访问修饰符,默认为 private),直到编译器给出问题,要求他们使方法更易于访问
这两种方法都不足以简单的原因:我们希望明确声明我们方法的可见级别。这样,每次阅读代码时,你都会通过访问修饰符明确地提醒你其他代码可以访问你正在处理的代码。这在处理可以在类外引用的非私有方法时尤其有用。
访问修饰符
截至 C# 12,C# 有几个访问修饰符来控制其他区域可以引用你的代码。当前的访问修饰符有 public、private、protected、internal、protected internal、private internal 以及新的 file 访问修饰符,该修饰符限制了对单个源文件内某物的访问。虽然这些访问修饰符都有其用途,但为了简单起见,我将主要关注本节中的 public 和 private。
如果我们将方法标记为 public、protected 或 internal,那么应该有很好的理由——通常与该方法是我们打算让他人使用我们代码的主要方式有关。
我们的 FlightTracker 类有一个名为 FindFlightById 的 public 方法,该方法被类中的大多数其他方法使用,但类外部的任何地方都没有使用。此方法通过 ID 查找航班,如果找到则返回:
public Flight? FindFlightById(string id) {
return _flights.Find(f => f.Id == id);
}
在这种情况下,你可能会明确决定将方法标记为 private,限制其在类内的使用,如下面的代码所示:
private Flight? FindFlightById(string id) {
return _flights.Find(f => f.Id == id);
}
通过将此方法标记为 private,你将来在重命名它、更改其工作方式、修改其参数或完全删除它时拥有更大的自由度。
如果类外没有使用该方法,更改访问修饰符通常是安全的。否则,此决定将导致编译器错误。
重命名方法和参数
让我们来看看 FlightTracker 中用于管理航班的三个非常相似的方法:
public Flight? DelayFlight(string fId, DateTime newTime) {
// Details omitted
}
public Flight? MarkFlightArrived(DateTime time, string id){
// Details omitted
}
public Flight? MarkFlightDeparted(string id, DateTime t) {
// Details omitted
}
这些方法都接受一个 DateTime 和一个航班标识符字符串。然而,这些参数的命名以及这些方法本身并不非常一致。
DelayFlight 调用其航班 ID 变量为 fId,以及新的出发时间为 newTime。MarkFlightArrived 使用 time 表示到达时间,而使用 id 表示航班标识符。MarkFlightDeparted 使用 id,但选择了 t 来表示出发时间。
虽然这些命名选择中的一些比其他的好,但同一类方法中命名的不一致性可能会损害其他人有效使用你的代码的能力。这可能会让他们对你的能力感到不那么自信,甚至可能因为对参数或方法代表的内容理解错误而引入错误 – 所有这些都归因于缺乏一致性。
为了解决这个问题,我们可以使用重命名参数重构来重命名单个参数以确保一致性。这可以通过右键单击一个参数并从上下文菜单中选择重命名…来完成,或者在选择参数时按Ctrl + R两次。参见图 4.3:

图 4.3 – 通过上下文菜单激活重命名参数重构
接下来,输入你想要使用的参数的新名称,然后按Enter键完成更改。参见图 4.4:

图 4.4 – 重命名参数
在选择名称时,你希望选择一些清晰且与你在类中已经使用的术语和名称一致的东西。尽可能避免使用非常短的单字母参数(排除一些情况,例如坐标的x和y或其他短参数名的既定用法)。
在这个代码的例子中,我选择将所有航班标识符重命名为id,并选择更明确地命名DateTime参数,以表明参数代表的内容。
我还选择使用相同的重命名工具来重命名整个DelayFlight方法为MarkFlightDelayed,以便与其他类中的方法保持一致性:
public Flight? MarkFlightDelayed(
string id, DateTime newDepartureTime) {
// Details omitted
}
public Flight? MarkFlightArrived(DateTime arrivalTime,
string id) {
// Details omitted
}
public Flight? MarkFlightDeparted(string id,
DateTime departureTime) {
// Details omitted
}
这些名称中的一些可能比我想要的要长一些(尤其是在尝试将代码放入一本书的页面时!),但清晰的参数和方法名称可以节省很多困惑,甚至可以防止以后发生某些错误。
注意
如果参数顺序的不一致让你感到烦恼,不用担心。我们将在本章后面修复参数顺序。
重载方法
让我们转换一下话题,谈谈方法如何协同工作。首先,我们将看看一个重载的例子,然后是一个链式调用的例子。
让我们先看看ScheduleNewFlight方法:
public Flight ScheduleNewFlight(string id, string dest,
DateTime depart, string gate) {
Flight flight = new() {
Id = id,
Destination = dest,
DepartureTime = depart,
Gate = gate,
Status = FlightStatus.Inbound
};
_flights.Add(flight);
return flight;
}
此方法接受四个代表航班信息的参数。它使用它们来实例化一个Flight对象,将航班添加到私有航班列表中,然后返回新创建的Flight对象。
随着系统的增长,合理地预期有人可能想要提供自己的Flight对象。为了适应这一点,你可以重载ScheduleNewFlight方法。
重载
重载是在提供与另一个方法相同名称的方法,但具有不同参数类型集的方法。例如,你可以有一个接受 int 类型的参数的方法,另一个接受两个 string 类型的参数的方法,但你不能有两个都只接受单个 int 参数的方法,即使参数名称不同。从编译器的角度来看,重载方法是完全独立的方法,只是恰好具有相同的名称。
接受 Flight 对象的重载 ScheduleNewFlight 方法可能看起来像以下这样:
public Flight ScheduleNewFlight(Flight flight) {
_flights.Add(flight);
return flight;
}
重载 ScheduleNewFlight 方法是有帮助的,因为它有助于人们发现基于 Visual Studio 的建议进行航班安排的不同选项,如图 图 4.5 所示:

图 4.5 – Visual Studio 建议显示 ScheduleNewFlight 可用的重载
通过提供重载、遵循标准约定、保持方法和参数的一致性和可预测性,你帮助他人发现如何安全有效地使用你的类。
方法链式调用
你可能已经注意到了我们两个 ScheduleNewFlight 重载之间的一些重复行。让我们并排查看它们以供参考:
public Flight ScheduleNewFlight(string id, string dest,
DateTime depart, string gate) {
Flight flight = new() {
Id = id,
Destination = dest,
DepartureTime = depart,
Gate = gate,
Status = FlightStatus.Inbound
};
_flights.Add(flight);
return flight;
}
public Flight ScheduleNewFlight(Flight flight) {
_flights.Add(flight);
return flight;
}
虽然这种重复非常微小,但我可以预见会有新的需求出现,这需要改变这两个地方。例如,业务可能要求每次安排新的航班时,都应该写入日志条目,或者可能需要将新的 LastScheduleChange 属性设置为当前时间。
当这些类型的更改发生时,如果开发者没有更改所有受影响的区域,他们就有引入错误的风险。这意味着代码重复,即使是像这个例子中这样的微小代码重复,如果没有更新所有具有类似逻辑的地方,会导致额外的工作和额外的错误来源。
有助于这一点的是 方法链式调用。方法链式调用是指一个方法调用另一个相关方法,并让它为自己完成工作。
在这种情况下,我们可以修改我们的第一个 ScheduleNewFlight 方法,使其负责创建一个 Flight 对象,然后将该对象传递给其他 ScheduleNewFlight 重载,如下所示:
public Flight ScheduleNewFlight(string id, string dest,
DateTime depart, string gate) {
Flight flight = new() {
Id = id,
Destination = dest,
DepartureTime = depart,
Gate = gate,
Status = FlightStatus.Inbound
};
return ScheduleNewFlight(flight);
}
public Flight ScheduleNewFlight(Flight flight) {
_flights.Add(flight);
return flight;
}
不仅代码更少,而且如果我们需要更改安排新航班时发生的事情,我们现在只需要修改一个地方。
现在我们已经介绍了一些方法重构的基础知识,让我们简要地看看与 构造函数 的相似之处。毕竟,构造函数本质上是一种特殊的方法,当对象被实例化时会被调用。
重构构造函数
当你考虑构造函数的工作时,其存在的全部原因就是将对象置于正确的初始位置。一旦构造函数完成,对象通常被认为可以供其他代码使用。
这意味着构造函数可以非常方便地确保某些信息已就绪。
目前,我们的 Flight 类定义得相当简单,并且只有 .NET 在没有显式构造函数的情况下提供的默认构造函数:
Flight.cs
public class Flight {
public string Id { get; set; }
public string Destination { get; set; }
public DateTime DepartureTime { get; set; }
public DateTime ArrivalTime { get; set; }
public string Gate { get; set; }
public FlightStatus Status { get; set; }
public override string ToString() {
return $"{Id} to {Destination} at {DepartureTime}";
}
}
我们 Flight 类缺少任何显式构造函数的问题在于,没有这些信息之一,航班就没有意义。
虽然较新的 C# 版本已经为我们提供了诸如 required 关键字之类的功能,我们将在 第十章 中探讨,但要求在对象创建时提供某些信息的经典方法是将构造函数作为参数接收。为了演示这一点,让我们添加一个参数化构造函数。
生成构造函数
虽然我们可以手动编写构造函数,但 Visual Studio 提供了一些优秀的代码生成工具,包括一个 生成 构造函数 重构功能。
要使用此重构功能,请选择类并打开 快速操作 菜单。然后,选择 生成构造函数…,如图 图 4**.6 所示:

图 4.6 – 生成构造函数
这将打开一个对话框,允许你选择在创建 Flight 时从构造函数初始化哪些成员,如图 图 4**.7 所示:

图 4.7 – 为构造函数选择所需的成员
在这种情况下,我选择将 Id、Destination 和 DepartureTime 作为构造函数的一部分,并留出其他选项未选中。我还取消选中了 添加空检查 复选框,以防止生成的代码对于这个示例来说过于复杂。
这生成了以下构造函数:
public Flight(string id, string destination,
DateTime departureTime) {
Id = id;
Destination = destination;
DepartureTime = departureTime;
}
生成的代码根据其参数正确地设置了所需的属性。
如果你愿意,你可以返回并生成一个具有不同参数集的新构造函数,因为类可以有任意数量的重载构造函数。
事实上,我们将在下一节中添加另一个构造函数来演示这一点。然而,目前我们遇到了一个需要解决的问题,表现为构建错误:

图 4.8 – 尝试实例化一个 Flight 实例时的构建错误
如果你在添加 Flight 构造函数后尝试构建你的项目,你会看到一个类似于 图 4**.8 中显示的错误。这个“没有给出与所需参数对应的参数”错误存在是因为 ScheduleNewFlight 中的 Flight flight = new() 代码试图调用 Flight 的默认构造函数,但该构造函数已不再存在。
当我们刚才添加构造函数时,这并没有将 Flight 类从没有构造函数的状态转变为只有一个构造函数。相反,我们是从拥有没有任何参数的 .NET 默认构造函数转变为拥有我们生成的新参数的一个构造函数,完全移除了默认构造函数。
我们可以通过显式定义来手动添加默认构造函数:
public Flight() {
}
这个构造函数除了允许通过不向构造函数提供任何参数来实例化类之外,不做任何事情。一旦你声明了自己的构造函数,.NET 就不再为你提供默认构造函数。
为了修复这个编译器错误,我们可以添加一个不带参数的新构造函数,或者我们可以调整 ScheduleNewFlight 代码以使用我们新的构造函数而不是不再存在的默认构造函数。
由于添加新构造函数的部分意图是在对象创建时要求某些信息,因此将 ScheduleNewFlight 改为使用新构造函数更有意义,如下所示:
FlightTracker.cs
public Flight ScheduleNewFlight(string id, string dest,
DateTime depart, string gate) {
Flight flight = new(id, dest, depart) {
Gate = gate,
Status = FlightStatus.Inbound
};
return ScheduleNewFlight(flight);
}
这样做的副作用之一是,我们不再需要在对象初始化器中设置那些属性,因为构造函数会为我们完成这个工作。
链式连接构造函数
之前,我们看到了如何通过链式调用重载的方法来共同工作,以减少代码重复。我还暗示构造函数实际上只是特殊的方法。当你有多个构造函数时,它们的行为就像重载方法一样。
我们可以通过将所有这些概念结合起来,通过链式连接构造函数,所以一个构造函数调用另一个构造函数。
首先,让我们看看一个不这样做的情况的例子:
Flight.cs
public Flight(string id, string destination,
DateTime departureTime) {
Id = id;
Destination = destination;
DepartureTime = departureTime;
}
public Flight(string id, string destination,
DateTime departureTime, FlightStatus status) {
Id = id;
Destination = destination;
DepartureTime = departureTime;
Status = status;
}
这里,我们有 Flight 的两个构造函数,它们几乎完全相同,除了第二个构造函数还接受一个 status 参数。
虽然这并不是大量的重复,但可以通过使用 : this() 语法链式连接构造函数来避免,如下所示:
public Flight(string id, string destination,
DateTime departureTime) {
Id = id;
Destination = destination;
DepartureTime = departureTime;
}
public Flight(string id, string destination,
DateTime departureTime, FlightStatus status)
: this(id, destination, departureTime) {
Status = status;
}
在这种情况下,第二个 Flight 构造函数首先通过使用 : this 调用第一个构造函数。一旦这个调用完成,控制权将返回到第二个构造函数,并执行 Status = status; 这一行。
链式连接构造函数会给你的代码增加一点复杂性,但同时也减少了重复代码,使得你可以在一个地方添加新的初始化逻辑,而多个构造函数都可以利用这个添加。
重构参数
现在我们已经探讨了方法和构造函数的基础知识,让我们谈谈管理参数。这很重要,因为思考不周的参数可能会迅速降低你代码的可维护性。
让我们看看你会在方法的生命周期中想要执行的一些常见重构。
重新排序参数
有时,你会意识到方法中参数的顺序可能不如另一种排列合理。在其他时候,你可能会注意到一些方法接受相同类型的参数,但顺序不一致。在任何情况下,你都会发现自己想要重新排列方法参数。
让我们看看之前看到的 MarkX 方法中的一个实际例子:
FlightTracker.cs
public Flight? MarkFlightDelayed(string id,
DateTime newDepartureTime) {
// Details omitted...
}
public Flight? MarkFlightArrived(DateTime arrivalTime,
string id) {
// Details omitted...
}
public Flight? MarkFlightDeparted(string id,
DateTime departureTime) {
// Details omitted...
}
在这里,我们有三个方法,它们都接受 string 和 DateTime 参数,但它们的顺序不一致。
在这种情况下,查看这三个方法,你决定最直观的顺序是将航班 ID 放在第一位,然后是时间组件作为第二个参数。这意味着 MarkFlightDelayed 和 MarkFlightDeparted 是正确的,但 MarkFlightArrived 需要调整。
你可以通过选择要重构的方法,然后从快速操作菜单中选择更改签名…,在 Visual Studio 的相同重构对话框中添加、删除和重新排列参数,如图 图 4.9 所示:

图 4.9 – 触发更改签名…重构
这将弹出更改签名对话框(见 图 4.10),并允许你使用右上角的上下按钮重新排列参数,直到预览中的顺序符合你的预期:

图 4.10 – 在更改签名对话框中重新排列参数
完成后,点击确定,Visual Studio 将更新你的方法以及所有调用该方法的内容,以使用修订后的参数顺序。
小贴士
使用 C# 有其他方法可以使方法所需的参数更加明确,其中一种方法就是使用 C# 的命名参数功能,允许你通过冒号后跟参数名来指定方法参数,从而使参数使用更加明确。
使用此方法调用我们的 MarkFlightArrived 方法的例子将是 MarkFlightArrived(arrivalTime:DateTime.Now, id:"MyId")。注意,当使用命名参数时,你可以按你喜欢的任何顺序指定参数。有关更多详细信息,请参阅 进一步阅读 部分。
添加参数
有时,你可能想给你的方法添加一个新参数。最自然的事情通常是将其添加到参数列表的末尾。然而,这有两个缺点:
-
新参数如果添加到列表的末尾而不是参数序列的早期,可能不太合理
-
手动添加参数意味着你现在必须手动调整所有调用你的方法的内容,并为参数提供一个新值
让我们看看一个实际例子,看看更改签名对话框如何有所帮助。
MarkFlightArrived 方法目前通过 Id 查找航班,然后更新其到达时间和状态以匹配参数:
public Flight? MarkFlightArrived(string id,
DateTime arrivalTime) {
Flight? flight = FindFlightById(id);
if (flight != null) {
flight.ArrivalTime = arrivalTime;
flight.Status = FlightStatus.OnTime;
Console.WriteLine($"{id} arrived at {Format(arrivalTime)}.");
} else {
Console.WriteLine($"{id} could not be found");
}
return flight;
}
假设我们需要更新这个方法,以便它能够接收飞机应该滑行到的航站楼。虽然我们可以手动将其添加到参数列表的末尾,但这会破坏所有调用此方法的函数。
目前,这并不是很多地方,因为只有测试在调用这个方法。
FlightTrackerTests.cs
Flight? actual =
_target.MarkFlightArrived(flightId, arrivalTime);
然而,Visual Studio 中的更改签名重构工具在点击添加按钮时提供了一个更安全的选项:

图 4.11 – 向 MarkFlightArrived 添加新的航站楼参数
添加参数对话框是 Visual Studio 中较为复杂的对话框之一,但它实际上只需要以下几件事情:
-
正在被添加的参数名称和类型
-
是否需要此参数(稍后将有更多介绍)
-
在已经调用该方法的地方使用该值
在这种情况下,我们的新参数将是一个名为 gate 的 string 类型。调用者必须提供值,而现有的调用者现在应使用 "A4" 字符串。
这种使用 "A4" 的方法可能看起来像是一个随机的字符串,因为它确实是。目前唯一使用这种方法的地方是一个单元测试,在那个测试中航站楼实际上并不重要。如果更多的地方使用这种方法,我可能会选择从上下文推断或引入未定义的TODO变量。
点击确定将再次显示更改签名对话框,其中列出了你的新参数,允许你按需重新排序。在此对话框中点击确定将添加参数到你的方法并更新你的代码。
这将更新 MarkFlightArrived 方法的签名以及调用你的代码的测试:
Flight? actual =
_target.MarkFlightArrived(flightId, arrivalTime, "A4");
在新参数就位后,你可以更新 MarkFlightArrived 方法以使用它来设置航班的 Gate 属性:
public Flight? MarkFlightArrived(string id,
DateTime arrivalTime, string gate) {
Flight? flight = FindFlightById(id);
if (flight != null) {
flight.ArrivalTime = arrivalTime;
flight.Gate = gate;
flight.Status = FlightStatus.OnTime;
Console.WriteLine($"{id} arrived at {Format(arrivalTime)}.");
} else {
Console.WriteLine($"{id} could not be found");
}
return flight;
}
当你发现自己需要扩展方法以接受新参数时,这个工作流程是一个常见的流程。
接下来,让我们看看一些使用可选参数简化方法调用的方法。
引入可选参数
如果你不喜欢更改签名对话框,而更愿意自己编写代码,你可以利用可选参数来安全地在参数列表末尾添加新的参数。
使用可选参数时,你指定一个默认值。调用你的方法的地方可以指定此参数的值,也可以不传递任何值。在没有传递值的情况下,将使用默认值。
注意
由于 C# 中可选参数的工作方式,这仅适用于参数列表末尾的参数。此外,编译器不允许某些类型的默认值,例如新对象和某些字面量。
如果你希望将gate参数声明为可选并默认为"TBD"(代表“待定”),你的方法看起来会像下面这样:
public Flight? MarkFlightArrived(string id,
DateTime arrivalTime, string gate = "TBD") {
// Details omitted...
}
调用你的方法的代码可以保持其先前的状态:
Flight? actual =
_target.MarkFlightArrived(flightId, arrivalTime);
在这里,代码可以编译,但“TBD”将用于gate。
或者,你可以通过为该参数提供一个值来手动指定gate的值:
Flight? actual =
_target.MarkFlightArrived(flightId, arrivalTime, "A4");
可选参数不仅可以用于扩展方法,还可以提供常见的默认值,调用者可以根据需要自定义。
移除参数
目前,代码要求你在安排新航班时指定航站楼:
public Flight ScheduleNewFlight(string id, string dest, DateTime depart, string gate) {
Flight flight = new(id, dest, depart) {
Gate = gate,
Status = FlightStatus.Inbound
};
return ScheduleNewFlight(flight);
}
假设你决定,由于航站楼现在在到达时分配,你不需要在安排新航班时指定gate。
虽然你可以直接从代码中移除gate参数,但这不会更新调用该方法的任何方法,并会导致你必须解决的编译错误。
相反,你可以使用更改签名对话框,选择要删除的参数,然后点击删除,如图图 4.12所示:

图 4.12 – 从 ScheduleNewFlight 中移除 gate 参数
当你点击gate参数时。
当然,这并不是魔法,它将留下依赖于该gate参数的代码或放置在准备传递给ScheduleNewFlight的值的代码。尽管如此,重构在清理方法定义和该方法的直接调用方面做得非常出色。
将重构应用于移除gate参数,结果是一个更简单的方法:
public Flight ScheduleNewFlight(string id, string dest,
DateTime depart) {
Flight flight = new(id, dest, depart) {
Status = FlightStatus.Inbound
};
return ScheduleNewFlight(flight);
}
现在我们已经介绍了方法、构造函数和参数的基础知识,让我们深入了解重构方法的更具冒险性的方面:与函数一起工作。
重构为函数
在本节中,我们将探讨与函数式编程相关的重构方面。函数式编程是一种编程方法,它关注函数及其交互,而不是仅仅关注对象和类。
函数式编程在过去十年中变得更加流行,这种流行趋势影响了 C#语言,并添加了新的语法形式。
我们将探讨与函数式编程相关的几个语法改进,并看看它们如何帮助编写简洁灵活的程序。虽然这不是一本关于函数式编程的书,但在这个章节和第十章“防御性编程技术”中,我们仍将探索这些概念中的一些。
使用表达式主体成员
要开始尝试更函数式的语法,让我们看看FlightTracker中的FindFlightById方法:
private Flight? FindFlightById(string id) {
return _flights.FirstOrDefault(f => f.Id == id);
}
显然,这是一个非常简短的方法,只有一条语句。同时,此方法占据了屏幕的三行。由于开发者通常在每个方法上方和下方留出空白行,因此这个简单的方法占据了屏幕的五行。这五行可能是屏幕可见区域的一个重要部分,如图 图 4.13* 所示:

图 4.13 – 单语句方法的视觉影响
相反,我们可以利用表达式体成员,通过在快速操作菜单上激活使用表达式体进行方法重构来将我们的方法转换为单行声明,如图 图 4.14* 所示:

图 4.14 – 触发方法重构的 Use 表达式体
这将我们的代码转换为以下更简洁的格式:
FindFlightById(string id) =>
_flights.FirstOrDefault(f => f.Id == id);
这种风格仅适用于单行实现,并不适合所有人。然而,如果你用它来编写简单代码,它可以帮助减少在大型文件中许多小方法带来的“滚动惩罚”。
将带有动作的函数作为参数传递
虽然表达式体成员在功能上更接近函数语法而非函数式编程,但让我们转换一下思路,通过将方法视为可以存储在变量中并在其他方法之间传递的动作来体验一下可能实现的内容。
在讨论如何做之前,让我们通过查看 FlightTracker 中的 MarkFlightX 方法来探讨为什么我们要这样做。我们将从 MarkFlightDelayed 方法开始:
public Flight? MarkFlightDelayed(string id,
DateTime newDepartureTime) {
Flight? flight = FindFlightById(id);
if (flight != null) {
flight.DepartureTime = newDepartureTime;
flight.Status = FlightStatus.Delayed;
Console.WriteLine($"{id} delayed until {Format(newDepartureTime)}");
} else {
Console.WriteLine($"{id} could not be found");
}
return flight;
}
此方法执行以下几项离散操作:
-
它通过航班 ID 搜索航班
-
如果找到航班,它将更新航班上的属性并输出延误
-
如果找不到航班,将在控制台写入警告
单独来看,这种方法是可行的。现在让我们看看 MarkFlightDeparted 方法:
public Flight? MarkFlightDeparted(string id,
DateTime departureTime) {
Flight? flight = FindFlightById(id);
if (flight != null) {
flight.DepartureTime = departureTime;
flight.Status = FlightStatus.Departed;
Console.WriteLine($"{id} departed at {Format(departureTime)}.");
} else {
Console.WriteLine($"{id} could not be found");
}
return flight;
}
将此方法与上一个方法进行比较,你会发现它们之间几乎没有差异。该方法仍然必须通过其 ID 查找航班,检查是否找到了航班,并更新航班。此方法中唯一的区别是更新航班的内容以及写入控制台的消息。
让我们通过查看 FlightTracker 中的 MarkFlightArrived 方法来完善对这些方法的探讨:
public Flight? MarkFlightArrived(string id,
DateTime arrivalTime, string gate = "TBD") {
Flight? flight = FindFlightById(id);
if (flight != null) {
flight.ArrivalTime = arrivalTime;
flight.Gate = gate;
flight.Status = FlightStatus.OnTime;
Console.WriteLine($"{id} arrived at {Format(arrivalTime)}.");
} else {
Console.WriteLine($"{id} could not be found");
}
return flight;
}
在这里,模式重复出现。这三个方法之间唯一的重大区别是如果找到航班会发生什么。
以这种方式思考,考虑以下伪代码:
Flight? flight = FindFlightById(id);
if (flight != null) {
ApplyUpdateToFlight(flight);
} else {
Console.WriteLine($"{id} could not be found");
}
return flight;
在这里,ApplyUpdateToFlight 是一个占位符,代表我们可以应用于航班对象的某个方法或函数。这是因为我们采取的 动作 是这里唯一变化的东西。
事实上,.NET 有一个名为 Action 的类可以用来完成这个目的:
private Flight? UpdateFlight(string id,
Action<Flight> updateAction) {
Flight? flight = FindFlightById(id);
if (flight != null) {
updateAction(flight);
} else {
Console.WriteLine($"{id} could not be found");
}
return flight;
}
在这里,updateAction 参数代表一个可以调用的特定函数。它是哪个函数?我们不知道。确切的函数将由调用 UpdateFlight 方法的任何人提供——就像任何其他参数一样。
然而,因为 updateAction 被定义为 Action<Flight>,我们知道该函数接受一个 Flight 类型的单个参数,这就是为什么我们可以在调用此方法时向该函数提供该参数。
为了让 Action 语法更易于理解,让我们看看几个其他的签名:
-
Action<int>– 一个接受单个整数参数的函数 -
Action<string, bool>– 一个接受字符串然后是布尔值的函数 -
Action– 一个不接受任何参数的函数
现在声明 Action 参数在语法上更有意义,让我们看看我们的一个旧方法如何更新以使用这个新方法:
public Flight? MarkFlightDelayed(string id,
DateTime newDepartureTime) {
return UpdateFlight(id, (flight) => {
flight.DepartureTime = newDepartureTime;
flight.Status = FlightStatus.Delayed;
Console.WriteLine($"{id} delayed to {Format(newDepartureTime)}");
});
}
在这里,MarkFlightDelayed 方法直接调用 UpdateFlight 方法,并提供了 (flight) => { } 语法形式的 Action<Flight>。
当 UpdateFlight 方法运行时,它会检查航班是否存在,如果存在,该方法会调用我们提供的箭头函数来实际更新航班。
如果这个语法很难理解,这里有一个用本地变量来持有 Action<Flight> 的不同方式来表示相同的事情:
Action<Flight> updateAction = (flight) => {
flight.DepartureTime = newDepartureTime;
flight.Status = FlightStatus.Delayed;
Console.WriteLine($"{id} delayed to {Format(newDepartureTime)}");
};
return UpdateFlight(id, updateAction);
毫无疑问,作为开发者,即使不声明 Action 变量,也能有一个快乐且富有成效的职业生涯。然而,我发现当我能够以离散的 Action 为术语进行思考时,它可以打开一些非常有趣和灵活的解决方案来解决问题。
使用 Funcs 从 Actions 返回数据
在我们继续讨论静态和扩展方法之前,让我们简要地看看 Funcs。
Action 代表一个可以调用并可能传递参数的 函数。然而,尽管 Actions 不返回任何结果,Funcs 会返回。
让我们考察一个简单的 C# 方法,它将两个数字相加,并将结果显示在方程字符串中:
public void AddAction(int x, int y) {
int sum = x + y;
Console.WriteLine($"{x} + {y} is {sum}");
}
此方法具有 void 返回类型,这意味着它不返回任何值。因此,它可以存储在 Action 中并以这种方式调用:
Action<int, int> myAction = AddAction;
myAction(2, 2);
现在,让我们看看 Add 方法的略微不同的版本:
public string AddFunc(int x, int y) {
int sum = x + y;
return $"{x} + {y} is {sum}";
}
在这里,AddFunc 的返回类型为 string。因为方法不再返回空值,所以它不再被视为 Action,现在被视为 Func,因为它返回了一些值。
因此,如果我们想存储对这个方法的引用,我们需要在 Func 中这样做,如下所示:
Func<int, int, string> myFunc = AddFunc;
string equation = myFunc(2, 2);
Console.WriteLine(equation);
注意,除了使用 Func 而不是 Action 之外,我们现在还有一个代表 Func 返回类型的第三个 Func。在 myFunc 的情况下,第三个泛型类型参数表示 AddFunc 返回一个 string。
Action 和 Func 非常相似,唯一的显著区别是 Func 返回一个值。在实践中,我倾向于在想要完成某事时使用 Action,例如在更新航班的早期示例中。另一方面,我倾向于使用 Func 来确定何时做某事或如何获取所需的特定值。
例如,我可能声明一个接受 Func<Flight, bool> 的方法,它使用它来确定从航班列表中显示的航班:
public void DisplayMatchingFlights(List<Flight> flights,
Func<Flight, bool> shouldDisplay) {
foreach (Flight flight in flights) {
if (shouldDisplay(flight)) {
Console.WriteLine(flight);
}
}
}
此方法为列表中的每个航班调用 shouldDisplay Func,以确定它是否应该显示。只有当 shouldDisplay Func 为该航班返回 true 时,航班才会显示。
这种结构允许同一方法用于不同的场景,包括以下内容:
-
列出即将到来的航班
-
列出延误的航班
-
列出前往特定机场的航班
这些之间的唯一区别是 shouldDisplay 参数的内容。
介绍静态方法和扩展方法
现在我们已经探索了一些方法重构的更多功能方面,让我们看看一些帮助革命化 .NET 的功能:静态方法和扩展方法。
使方法静态
有时,您的类将会有不直接与该类实例成员(字段、属性或非静态方法)工作的方法。例如,FlightTracker 有一个 Format 方法,它将 DateTime 转换为类似于“Wed Jul 12 23:14 PM”的字符串:
private string Format(DateTime time) {
return time.ToString("ddd MMM dd HH:mm tt");
}
在这里,Format 不依赖于除了它提供的参数以外的任何东西来计算结果。正因为如此,我们可以将 Format 作为一个静态方法。
静态方法是与类本身相关联的方法,而不是与类的实例相关联的方法。因此,您不需要实例化类的实例来调用它们。C# 编译器还能够对静态代码进行偶尔的优化,这可能导致代码运行更快。
通常,静态方法也可以被认为是纯方法——也就是说,没有直接副作用的方法,当给定相同的输入时总是产生相同的结果。
如图 4.14 所示,您可以通过在访问修饰符后添加 static 关键字或通过在快速操作菜单中选择使静态选项来将方法标记为静态:

图 4.15 – 将方法移动到静态方法
Format 的静态版本看起来非常相似,并且几乎以相同的方式工作:
private static string Format(DateTime time) {
return time.ToString("ddd MMM dd HH:mm tt");
}
Format 方法仍然可以通过 Format(DateTime.Now) 简单地调用,就像之前一样,但添加静态也允许您从类本身调用它,例如 FlightTracker.Format(DateTime.Now)。
将方法标记为静态有几个优点:
-
编译器可以做出优化,从而实现更快的运行时性能
-
代码可以在不实例化类的情况下调用静态方法
-
静态方法可以转换为扩展方法,我们将在后面看到。
由于这些附加功能,static关键字似乎可以在任何地方使用都是一件好事。不幸的是,static也有一些缺点。将方法标记为static也意味着它不能再调用非static方法或访问实例级数据。
当然,static有很多用途,但仍然有很多开发者认为它令人反感,或者认为过度使用是反模式。
个人而言,我认为static适用于“辅助方法”,在某些情况下,为了简化在测试场景中难以实例化的复杂类的单元测试,也是合适的。然而,我总是尽量避免将字段设置为static,因为static数据可能导致开发和测试应用程序时出现许多问题。
将静态成员移动到另一个类型
有时,静态方法保留在它开始的类中是没有意义的。
例如,我们的Format方法接受任何DateTime并返回适合 Cloudy Skies Airlines 业务需求的定制字符串。这种逻辑目前位于FlightTracker类中,而且与跟踪航班完全无关,可以在应用程序的任何地方都很有用。
在这种情况下,将Format拉入另一个类是有意义的,这样其他开发者可以更容易地发现这些格式化功能。
Visual Studio 为此提供了内置的重构功能。要使用它,请选择一个静态方法并打开快速操作菜单,然后点击将静态成员移动到另一个类型...,如图4.16所示。16:

图4.16 – 将静态成员移动到另一个类型
接下来,您将被提示选择要将静态方法移动到的类型。如果您目前没有适合此目的的类,这可以是新类的名称。对于 Cloudy Skies,没有现有的类型应该拥有这个,因此创建一个名为DateHelpers的类是有意义的。
此外,您将需要检查或取消检查您想要移动的静态方法,并有一个选项来选择依赖项(见图4.17)并选择任何您的选择静态方法调用的方法:

图4.17 – 选择目标类型和要移动的成员
点击确定以移动您选择的方法并创建一个新类。
重要提示
Visual Studio 当前行为是保持您方法当前的可访问修饰符,并将新静态类创建为internal。如果您的该方法为private,这可能会引入编译错误,因为旧位置中的代码将无法访问您的代码。我建议将您的静态类及其方法更改为public以避免问题。
调整修饰符后的结果静态类如下:
public static class DateHelpers {
public static string Format(DateTime time) {
return time.ToString("ddd MMM dd HH:mm tt");
}
}
现在我们有一个专门用于与日期和时间相关的“辅助方法”的专用类。
静态类
如果你不太熟悉静态类,静态类只能包含静态方法,不能被实例化或继承。静态类对于扩展方法是必需的。
我们刚才进行的重构还更新了任何使用旧Format方法的代码,使其指向DateTimeHelpers.Format。例如,在FlightTracker中,MarkFlightArrived方法的航班记录现在显示Console.WriteLine($"{id} arrived at {DateHelpers.Format(arrivalTime)});。
通过将静态成员拉入它们自己的专用类型,我们创建了一个可以存放与日期相关的逻辑并帮助各种类的地方,并且我们使FlightTracker类更加专注于其核心任务,而不是同时关注日期格式化和航班跟踪。
不幸的是,这个改动多少伤害了我们代码的可读性,因为调用者现在必须指定DateHelpers.Format而不是仅仅Format。一个扩展方法可以帮助解决这个问题,就像我们接下来要看到的那样。
创建扩展方法
扩展方法允许你通过添加自己的静态方法来“扩展”现有的类型,这些方法看起来就像该类型的一部分。
这可能听起来有些吓人,但如果你使用过 LINQ,你已经在实际中看到了扩展方法。让我们以FlightTracker中的FindFlightById方法为例:
private Flight? FindFlightById(string id) =>
_flights.FirstOrDefault(f => f.Id == id);
在这里,_flights被定义为List<Flight>。鉴于查找航班 ID 的代码,人们可能会怀疑List必须有一个名为FirstOrDefault的方法;然而,它并没有。
相反,System.Collections.Generic命名空间中的List<T>类型并没有定义FirstOrDefault方法,而是在System.Linq命名空间中一个名为Enumerable的静态类中定义为一个扩展方法。
换句话说,将我们之前的代码重写为显式使用Enumerable类是完全可行的,如下所示:
private Flight? FindFlightById(string id) =>
Enumerable.FirstOrDefault(_flights, f => f.Id == id);
虽然这段代码完全有效,但我从未见过与我共事的人以这种方式编写代码,因为将FirstOrDefault用作扩展方法要直观得多,可读性也更强。
这突出了扩展方法的关键点:扩展方法允许你以似乎那些方法从一开始就存在于对象上的方式向现有类添加新功能,从而产生更直观的代码。
要将方法声明为扩展方法,以下条件必须成立:
-
方法必须是静态的
-
方法必须在静态类内部
-
方法的第一个参数必须以
this关键字开头
我们的DateHelpers类及其Format方法都是静态的,这意味着我们可以通过在方法签名中添加this关键字将方法转换为扩展方法:
public static class DateHelpers {
public static string Format(this DateTime time) {
return time.ToString("ddd MMM dd HH:mm tt");
}
}
将静态方法移动到扩展方法并不意味着你必须将其用作扩展方法,因此我们之前的代码仍然可以编译。然而,为了最大限度地利用我们的扩展方法,我们应该更新之前的代码以利用其新的语法。
让我们再次看看FlightTracker中的MarkFlightArrived方法。这次,如果你删除DateFormatHelpers.Format(arrivalTime),而是写arrivalTime.For,并允许 Visual Studio 的IntelliSense建议值,它将列出你的新扩展方法:

图 4.18 – IntelliSense 建议的新扩展方法
因为arrivalTime是DateTime类型,我们的扩展方法是为了在任意DateTime上工作而构建的,所以我们在.NET 中通过扩展方法的力量编写的新Format方法出现在这里。
重新编写对arrivalTime.Format()的调用会产生正确调用扩展方法的效果,从而带来更易读的体验。
如果你愿意,你仍然可以通过DateHelpers.Format(arrivalTime)调用Format方法。引入扩展方法只是为你提供了另一种语法结构的选择。
扩展方法的主要缺点如下:
-
扩展方法需要使用静态,这有些团队会避免,因为它往往会散布到你的代码中
-
使用扩展方法可能会让人感到困惑
-
新的扩展方法定义在哪里可能会让人感到困惑
幸运的是,Visual Studio 允许你通过简单地按住Ctrl并单击你想要导航到的任何方法、成员或类型来跳转到其定义。或者,你可以选择标识符并按键盘上的F12,或者右键单击它并选择转到定义来导航到扩展方法声明的位置。
审查和测试我们的重构代码
在本章的整个过程中,我们将重复的FlightTracker类重构,以确保其方法签名更加一致,并且尽可能重用常见逻辑。
重构后的代码
本章最终重构的代码可在github.com/PacktPublishing/Refactoring-with-CSharp仓库中的Chapter04/Ch4RefactoredCode文件夹内找到。
在我们继续之前,我们应该确保所有测试仍然通过,通过从测试菜单运行单元测试,然后选择运行所有测试菜单项。
摘要
在本章中,我们看到了如何将各种方法、构造函数和参数重构应用于保持代码的整洁。我们看到了如何通过重载和链式调用方法和构造函数来提供更多选项,同时重命名、添加、删除和重新排序参数有助于确保一致性。
在本章接近尾声时,我们介绍了Actions、Funcs、静态方法和扩展方法,并展示了如何通过将代码视为小型、可重用的函数来更有效地解决某些类型的问题。
在下一章中,我们将介绍面向对象的重构技术,并通过探索如何通过提取类来控制大量参数来回顾本章中的参数重构。
问题
-
你的代码中是否有任何区域,你似乎经常因为参数的顺序或命名而感到困惑?
-
你能否想到你的代码中任何根据相同或类似条件执行稍微不同操作的地方?如果是这样,那么转向使用
Action或Func是否有意义? -
你的代码中是否有一组“辅助方法”,这些方法可能适合将其设置为静态并放入静态类中?如果是这样,转向使用扩展方法是否会改善你代码的其他部分?
进一步阅读
你可以在以下 URL 中找到有关本章讨论的材料更多信息:
-
重构为纯 函数:
learn.microsoft.com/en-us/dotnet/standard/linq/refactor-pure-functions -
使用扩展方法重构 方法:
learn.microsoft.com/en-us/dotnet/standard/linq/refactor-extension-method
第五章:面向对象重构
在上一章中,我们看到了重构如何帮助改进类及其方法。在本章中,我们将通过创造性使用 面向对象编程 (OOP) 来探索更大的图景,将一系列类重构为更易于维护的形式。这些工具将帮助您执行更大、更有影响力的重构,并在改进您的代码方面产生更大的影响。
本章我们将涵盖以下主题:
-
通过重构组织类
-
重构与继承
-
使用抽象控制继承
-
为了更好的封装进行重构
-
使用接口和多态改进类
技术要求
本章的起始代码可在 GitHub 的 github.com/PacktPublishing/Refactoring-with-CSharp 上的 Chapter05/Ch5BeginningCode 文件夹中找到。
重构航班搜索系统
本章的代码专注于云天航空公司的航班调度系统。
航班调度系统是一个简单的系统,它通过 FlightScheduler 类跟踪所有活跃的航班,并允许外部调用者搜索感兴趣的航班。这个类反过来通过 IFlightInfo 实例集合跟踪航班,这些实例可能是 PassengerFlightInfo 或 FreightFlightInfo 实例,具体取决于航班是否载有乘客或货物。
这些类的高级交互可以在 图 5**.1 中看到:

图 5.1 – 云天航空公司航班调度系统涉及的类
代码目前运行正常,甚至有效地使用了多态来跟踪各种不同的航班。话虽如此,还有一些改进的机会,我们将在后面看到。在本章中,我们将进行有针对性的改进,同时展示使用面向对象编程时存在的重构可能性。
通过重构组织类
解决方案存在组织挑战,如文件命名不当或类型存在于错误的文件或命名空间中,这种情况并不少见。
这些问题可能看起来很小,但它们可能会使开发者更难找到他们正在寻找的代码——尤其是在刚加入项目时。
让我们看看几个有助于开发者更容易地导航代码的重构示例。
将类移动到单独的文件中
我看到的一个常见错误是团队将多个类型放在同一个文件中。通常,一个文件从一个类或接口开始,然后开发者决定添加一个相关类型。而不是将新类型放在自己的文件中,类被添加到现有的文件中。一旦这种情况发生在几个小类上,之后往往会像滚雪球一样,随着时间的发展,开发者继续向文件中添加新的类型。
类型
如果你不太熟悉 .NET 世界中“类型”一词的使用,类型是一个通用术语,指代任何由 公共类型系统(CTS)支持的实体。本质上,如果你可以用它来声明一个变量,那么它很可能是一个类型。类型的例子包括类、接口、结构体、枚举以及各种记录类型变体。
飞行调度系统中的 IFlightInfo.cs 文件定义了几个不同的类型:
public interface IFlightInfo {
// Details omitted....
}
public class PassengerFlightInfo : IFlightInfo {
// Details omitted...
}
public class FreightFlightInfo : IFlightInfo {
// Details omitted...
}
虽然这个例子可能看起来不太严重,但一个文件中有多个类型确实会导致一些问题:
-
寻找特定类型的初学者在没有使用搜索功能的情况下很难找到包含该类型的文件。
-
版本控制系统,如 git,跟踪每个文件的更改。当团队必须合并代码或确定任何给定软件版本中发生了什么更改时,这可能会增加混淆。
解决这个问题的方法是将每个类型移动到其专用的文件中。这可以通过访问名称与文件名不匹配的类型的 快速操作 菜单来完成。接下来,选择如 图 5.2 所示的 将类型移动到 [新文件名].cs 选项:

图 5.2 – 将类型移动到自己的文件
选择此选项将从原始文件中移除类型,并创建一个只包含你选择的类型的新的文件。
你需要为 Visual Studio 中每个名称与文件不匹配的类型重复此操作。ReSharper 和 Rider 提供的一些额外重构工具允许你为文件、文件夹或解决方案中的每个类型执行此重构。如果你遇到一个包含数百个类型的单个文件,这会特别方便。
重命名文件和类
有时,你会遇到文件和其中包含的类型名称不匹配的情况。这通常发生在开发者创建了一个新类,然后决定稍后重命名它,但没有使用 Visual Studio 内置的重命名重构功能。
AirportInfo.cs 文件及其 Airport 类是这种情况的一个例子:
namespace Packt.CloudySkiesAir.Chapter5.AirTravel;
public class Airport {
public string Country { get; set; }
public string Code { get; set; }
public string Name { get; set; }
}
通常,解决这个问题是将文件重命名为与类型名称匹配(尽管偶尔你会确定文件名称是正确的),并且类应该重命名为与文件名称匹配。
无论是哪种选择,打开相关类型的 快速操作 菜单,选择 重命名文件 或 重命名类型,以确保文件和类型名称匹配。请参见以下图示:

图 5.3 – 重命名文件或重命名类型的选项
我选择将文件重命名为 Airport.cs,因为任一选项都能确保文件和类型名称相同。这种命名一致性虽是小小的改进,但有助于开发者随着时间的推移更轻松地导航你的项目。
更改命名空间
.NET 使用 命名空间 将类型组织成层次结构。按照惯例,这些命名空间应与 解决方案资源管理器 中的项目文件夹相匹配。
项目将以一个命名空间开始,例如 Packt.CloudySkiesAir.Chapter5,并且项目内部嵌套的每个文件夹都会添加到这个命名空间中。例如,该项目中的 Filters 文件夹应使用 Packt.CloudySkiesAir.Chapter5.Filters 命名空间。
当类没有使用预期的命名空间时,可能会导致混淆。
作为实际示例,让我们看看 Chapter5 项目根目录下的 Airport.cs 文件,如图 图 5**.4 所示:

图 5.4 – Airport 类直接嵌套在项目中的项目
在这个场景中,你可能会期望 Airport 类位于 Packt.CloudySkiesAir.Chapter5 命名空间中。然而,该文件使用了不同的命名空间,如下面的代码所示:
namespace Packt.CloudySkiesAir.Chapter5.AirTravel;
public class Airport {
public string Country { get; set; }
public string Code { get; set; }
public string Name { get; set; }
}
这种差异可以通过手动编辑命名空间声明或使用 快速操作 重构中的 更改命名空间以匹配文件夹结构 来修复,如图 图 5**.5 所示:

图 5.5 – 将命名空间更改为与文件夹结构匹配
我个人建议根据需要使用 using 语句来支持命名空间更改。
避免部分类和区域
在我们继续讨论重构和继承之前,我想谈谈我在处理大型类时在 C# 代码中看到的两个相关的 反模式。
当开发者拥有包含许多不同代码块的大类时,他们可能会倾向于使用多种语言特性来简化文件的组织。
许多开发者使用 #region 预处理器指令来创建可以展开和折叠的代码区域。
例如,你可以使用一个如 #region Stuff I don't want to look at right now 的语句,后面跟着一个单独的 #endregion 语句。这将创建一个可折叠的代码区域,如图 5**.6 中的折叠区域从第 33-84 行所示:

图 5.6 – 代码折叠区域
#region 被视为代码组织中的一个坏习惯;它会导致极其庞大的类,而不是将代码重构为更可维护的模式。
那么,为什么它存在呢?
#region 指令被引入是为了帮助隐藏通常嵌入到旧版 .NET 应用程序中的自动生成代码。这是开发者不期望与之交互的代码,并且通常鼓励不要修改,以免破坏其他东西。
最终,.NET 引入了 部分类 来帮助处理之前在区域中使用的情况。
部分类是在 同一项目 内的 多个文件 中定义的类。这将允许你拥有 FlightScheduler.ItemManagement.cs 和 FlightScheduler.Search.cs 文件,每个文件都包含较大类的一部分。这让你可以在多个文件中定义一个大型类:
public partial class FlightScheduler {
// Details omitted...
}
与区域指令一样,部分类旨在支持自动生成的代码。虽然我个人更喜欢部分类而不是 #region 指令,但我认为当它们用于减少大型类带来的痛苦时,两者都是反模式。
通常,当你的类足够大,以至于你想考虑 #region 或部分类时,你正在违反单一职责原则,你的类应该被拆分成多个更小的类,这些类彼此之间明显不同。
我们将在 第八章,使用 SOLID 避免代码反模式 中讨论单一职责原则和其他设计原则。
重构与继承
现在我们已经介绍了一些重构可以帮助组织代码的方法,让我们深入了解与继承相关的重构。这是一组重构,涉及重写方法、引入继承或修改就地继承关系,以提高代码的可维护性。
重写 ToString
ToString 是任何 .NET 对象都保证拥有的四个方法之一,这是由于 System.Object 上 ToString 的 virtual 定义。此方法在对象转换为字符串时使用,并且对于日志记录和调试目的特别有用。
有时重写 ToString 可以以意想不到的方式简化你的代码。
让我们来看看 FreightFlightInfo.cs 中的 BuildFlightIdentifier 方法。此方法依赖于类型为 Airport 的 DepartureLocation 和 ArrivalLocation 属性来生成一个字符串:
FreightFlightInfo.cs
public string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation.Code}-" +
$"{ArrivalLocation.Code} carrying " +
$"{Cargo} for {CharterCompany}";
需要深入到这些位置属性中才能到达它们的 Code 属性,这很烦人。
如果 Airport 重写了 ToString 方法并返回机场代码,我们就能简化我们代码的可读性:
public string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation}-{ArrivalLocation} " +
$"carrying {Cargo} for {CharterCompany}";
要做到这一点,你可以直接进入 Airport.cs 并手动添加重写,或者使用内置的重构选项通过 生成重写... 重构(见 图 5.7):

图 5.7 – 在类上生成重写
从那里,你需要指定你想要重写的方法或属性。如图所示,你从继承的类中继承的任何抽象或虚拟成员都将可用:

图 5.8 – 选择要重写的成员
选择 ToString() 并点击 确定 会生成一个占位符方法,可以快速替换为实际实现。
在这个类中,ToString 方法应该返回机场代码:
public class Airport {
public string Country { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public override string? ToString() => Code;
}
在此覆盖到位后,现有代码仍然可以使用 Code 属性而不会出现问题。然而,任何之前尝试将 Airport 对象写入控制台中的代码现在将看到其代码而不是类的命名空间和名称。
注意
.NET 中 ToString 的默认实现是返回一个包含命名空间和类型名称的字符串。在这种情况下,它将是 Packt.CloudySkiesAir.Chapter5.AirTravel.Airport。
接下来,我们应该查看 Code 属性当前正在被读取的所有地方,看看是否更易于阅读,可以依赖 ToString 覆盖。
您可以在任何版本的 Visual Studio 2022 中通过右键单击 Code 属性声明并选择 查找所有引用 来执行此操作,如图 图 5**.9 所示:

图 5.9 – 查找所有引用的上下文菜单选项
这将打开一个新的面板,其中突出显示了该属性的 所有引用:

图 5.10 – 查找所有引用的结果
然后,您可以修改这些区域以在适当的地方使用 ToString,例如在以下对 PassengerFlightInfo 的修改中:
public string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation}-{ArrivalLocation} " +
$"carrying {_passengers} people";
在您的对象中覆盖 ToString 的一个额外好处是当在 Visual Studio 调试器中查看时,类的显示会得到改进:

图 5.11 – 在调试工具中显示的 ToString 覆盖
我们将在 第十章**:防御性编码技术 中进一步探讨调试。
生成等价方法
在 C# 中,引用类型的等价性(如类)是通过 引用等价性 来实现的 – 确定两个对象是否位于堆中的相同位置。
有时候比较两个对象的不同属性以查看它们的值是否等效会更方便,即使这两个对象代表堆上的两个不同的位置。
以下来自 FlightScheduler 类的代码展示了其 Search 方法是如何检查确保您正在搜索的机场具有相同的机场代码和国家。注意在确定两个机场等效时的重复逻辑:
if (depart != null) {
results = results.Where(f =>
f.DepartureLocation.Code == depart.Code &&
f.DepartureLocation.Country == depart.Country
);
}
if (arrive != null) {
results = results.Where(f =>
f.ArrivalLocation.Code == arrive.Code &&
f.ArrivalLocation.Country == arrive.Country
);
}
通过用我们自己的定制实现覆盖等价成员,可以简化此代码。
等价成员
.NET 提供了两种确定等价性的方法:Equals 和 GetHashCode。Equals 方法确定两个对象是否等效,而 GetHashCode 用于确定对象在 Dictionary 和 HashSet 中排序到的哪个主要“桶”。
您永远不应该只覆盖这两个方法中的一个;每当您覆盖 Equals 时,您还需要覆盖 GetHashCode。此外,您还想要确保您使用的是一个良好的 GetHashCode 实现,该实现可以均匀且一致地将对象分布到您类中的不同哈希值中。
.NET 还提供了一个 IEquatable<T> 接口,你可以实现它来进行强类型相等性比较,这可以提高性能。在重写相等性成员时,通常推荐实现 IEquatable<T>,但本书中并未详细说明。更多信息请参阅 进一步阅读 部分。
相等性和哈希码可能会变得非常复杂,但幸运的是,我们在 Visual Studio 中有一些非常好的工具来生成相等性成员。只需选择你的类,然后从 快速操作 菜单中选择 生成 Equals 和 GetHashCode…,如图 图 5.12 所示:

图 5.12 – 生成相等性成员重写
一旦选择此选项,Visual Studio 将询问哪些成员应参与相等性和哈希码检查,如图 图 5.13 所示:

图 5.13 – 选择相等性成员
选择必须相等的成员并点击 确定 以生成重写:
Airport.cs
public class Airport {
public string Country { get; set; }
public string Code { get; set; }
public string Name { get; set; }
public override bool Equals(object? obj) {
return obj is Airport airport &&
Country == airport.Country &&
Code == airport.Code;
}
public override int GetHashCode() {
return HashCode.Combine(Country, Code);
}
public override string? ToString() => Code;
}
在这里,Visual Studio 生成了一个与 Equals 实现匹配的模式,比较相关属性。此外,GetHashCode 实现使用较新的 HashCode.Combine 方法来安全地简化你的哈希码生成过程。
更新相等性成员
如果你为你的类添加了新的属性,这些属性应该影响相等性检查,请确保更新 Equals 和 GetHashCode 以包括这些属性。
在放置了自定义相等性成员之后,之前检查机场 Code 和 Country 的代码可以简化为使用相等运算符 (==):
FlightScheduler.cs – 搜索
if (depart != null) {
results=results.Where(f=> f.DepartureLocation == depart);
}
if (arrive != null) {
results=results.Where(f=> f.ArrivalLocation == arrive);
}
重写相等性成员在你有很多在堆上具有相同值的相似对象时非常有用。这可能在处理 Web 服务 或其他发生 反序列化 的地方发生。
相等性和记录
你并不总是需要重写相等性成员来获得基于值的相等性。在 第十章:**防御性编码技术* 中,我们将探讨 record 关键字在控制相等性方面的战略使用。事实上,每当我发现自己正在考虑重写相等性成员时,我通常会决定将我的类做成记录。
提取基类
有时你可能会遇到类之间有高度重复的情况。这些类在概念上是相关的,并且不仅共享相似的成员签名,还共享这些成员的相同实现。
在这些情况下,引入一个定义公共共享代码的基类通常是有意义的。继承然后允许我们从系统中的多个类中移除公共代码,并在集中位置维护它。
在我们的航班调度示例(见图 图 5.14),乘客和货运航班类有几个共享属性:

图 5.14 – 货运和客运航班之间的共享成员
为了解决这个问题,请进入任意一个类,并从快速操作菜单中选择提取基类...:

图 5.15 – 提取基类
接下来,为新类命名,并选择你想要移动到其中的成员,如图图 5.16所示。你也可以决定是否将其中任何成员声明为抽象的,但请注意,这将使你的类也成为抽象类。

图 5.16 – 配置新的基类
一旦你点击确定,新类将被创建:
FlightInfoBase.cs
public class FlightInfoBase {
public Airport ArrivalLocation { get; set; }
public DateTime ArrivalTime { get; set; }
public Airport DepartureLocation { get; set; }
public DateTime DepartureTime { get; set; }
public TimeSpan Duration => DepartureTime - ArrivalTime;
public string Id { get; set; }
}
你开始时的类现在继承自这个新类,而你选择的非抽象成员已从文件中移除:
PassengerFlightInfo.cs
public class PassengerFlightInfo : FlightInfoBase,
IFlightInfo {
private int _passengers;
public void Load(int passengers) =>
_passengers = passengers;
public void Unload() =>
_passengers = 0;
public string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation}-{ArrivalLocation} carrying"
+ $" {_passengers} people";
public override string ToString() =>
BuildFlightIdentifier();
}
提取基类对于促进代码复用非常有帮助,但这只是重构工作的一半;提取基类并没有修改你的其他类。
如果你希望相关的航班类也继承自新类,你必须手动进行更改,指定基类并移除任何被“提升”到该类的成员:
FreightFlightInfo.cs
public class FreightFlightInfo : FlightInfoBase,
IFlightInfo {
public string CharterCompany { get; set; }
public string Cargo { get; set; }
public string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation}-{ArrivalLocation} " +
$"carrying {Cargo} for {CharterCompany}";
public override string ToString() =>
BuildFlightIdentifier();
}
结果是,我们的两个航班类现在专注于它们各自独特的事物。此外,如果需要为每个航班添加新的逻辑,现在可以将其添加到基类中,所有继承的类都将接收它。
将接口实现向上移动到继承树
你可能在最后两个代码示例中注意到一个奇怪的现象,即尽管FreightFlightInfo和PassengerFlightInfo现在都继承自FlightInfoBase,但它们都分别实现了IFlightInfo接口,如图图 5.17所示:

图 5.17 – 客运和货运航班分别实现 IFlightInfo
当每个继承自基类的类都实现了一个接口时,通常有很好的机会将接口实现向上移动到基类本身。
在这种情况下,FlightInfoBase类已经通过IFlightInfo接口定义了所有必需的成员。因此,实现该接口是有意义的,如下所示:
FlightInfoBase.cs
public class FlightInfoBase : IFlightInfo {
public Airport ArrivalLocation { get; set; }
public DateTime ArrivalTime { get; set; }
public Airport DepartureLocation { get; set; }
public DateTime DepartureTime { get; set; }
public TimeSpan Duration => DepartureTime – ArrivalTime;
public string Id { get; set; }
}
在进行更改后,我们可以从PassengerFlightInfo和FreightFlightInfo中移除IFlightInfo实现。这简化了类定义,同时仍然继承了接口实现,如图所示:

图 5.18 – 将 IFlightInfo 接口实现“提升”到 FlightInfoBase 中
通过将接口拉入基类,我们现在保证任何从该类继承的类也将实现IFlightInfo接口。
使用抽象控制继承
现在我们已经介绍了一些关于继承的重构模式,让我们看看如何使用抽象类和其他 C#特性来限制我们的类,并确保它们被适当使用。
使用抽象传达意图
我们当前设计的一个特点是,可以通过编写以下代码简单地实例化一个新的FlightInfoBase实例:
FlightInfoBase flight = new FlightInfoBase();
虽然这可能对你来说没有意义——因为存在一种既不是客运也不是货运的航班,因为FlightInfoBase类没有被标记为抽象类——但这并不阻止任何人实例化它。
要将一个类标记为抽象,请将其签名中的abstract关键字添加:
FlightInfoBase.cs
public abstract class FlightInfoBase : IFlightInfo {
public Airport ArrivalLocation { get; set; }
public DateTime ArrivalTime { get; set; }
public Airport DepartureLocation { get; set; }
public DateTime DepartureTime { get; set; }
public TimeSpan Duration => DepartureTime - ArrivalTime;
public string Id { get; set; }
}
当你不希望任何人实例化类时,将其标记为抽象可以完成几件事情:
-
它传达了该类不打算被实例化的意图
-
编译器现在阻止其他人实例化你的类
-
如我们接下来将看到的,它允许你向你的类添加抽象成员
引入抽象成员
现在,由于FlightInfoBase是抽象的,它为重构打开了新的可能性。
例如,FreightFlightInfo和PassengerFlightInfo都有BuildFlightIdentifier方法和ToString覆盖。

图 5.19 – 飞行信息类中的重复成员
虽然BuildFlightIdentifier方法的实现细节不同,但ToString方法覆盖了BuildFlightIdentifier的结果返回。
我们可以通过使用拉取[成员名称]向上...,如图图 5**.20所示,利用这些共同点将两个方法都拉入基类:

图 5.20 – 将成员拉到基类型
接下来,选择你想要拉入父类的成员,确保为任何你想要将其定义拉入而不将其实现拉入的成员勾选标记为抽象复选框。

图 5.21 – 选择目的地和将成员抽象化
结果是,FlightInfoBase现在有了ToString覆盖以及BuildFlightIdentifier的抽象定义:
FlightInfoBase.cs
public abstract class FlightInfoBase : IFlightInfo {
// Other members omitted...
public abstract string BuildFlightIdentifier();
public override string ToString() =>
BuildFlightIdentifier();
}
由于BuildFlightIdentifier是抽象的,我们的原始方法调用仍然保留,但现在被标记为覆盖:
PassengerFlightInfo.cs
public class PassengerFlightInfo : FlightInfoBase {
// Other members omitted...
public override string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation}-{ArrivalLocation} carrying"
+ $" {_passengers} people";
}
不幸的是,拉取成员向上重构不会修改从同一基类继承的其他类,因此你现在必须手动在其他航班类中添加覆盖:
public class FreightFlightInfo : FlightInfoBase {
// Other members omitted...
public override string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation}-{ArrivalLocation} " +
$"carrying {Cargo} for {CharterCompany}";
}
进行这次重构简化了我们的代码:单个航班类不再需要重写 ToString。更重要的是,如果我们以后添加新的航班类型,编译器将强制它通过 BuildFlightIdentifier 重写提供一个有效的航班标识符。
密封方法和类
当我们谈论抽象、虚拟和重写方法时,我们应该提及 sealed。sealed 关键字几乎有相反的效果。当一个类被标记为 sealed 时,它不能被继承。当一个 方法 被标记为 sealed 时,该方法在继承类中不能进一步重写。sealed 关键字的这两种用法都是为了保护类所做的操作免受外部修改。此外,将成员标记为 sealed 还可能带来一些性能优势。
将抽象方法转换为虚拟方法
有时,你会将一个方法标记为抽象的,后来意识到这个方法许多重写有相似的实现。当这种情况发生时,将方法从 abstract 移动到 virtual 以提供一个其他人可以选择重写的基实现是有意义的。
我们的 FlightInfoBase 类将 BuildFlightIdentifier 定义为抽象:
public abstract string BuildFlightIdentifier();
这意味着这个方法的每个实现都应该与其他的不同。然而,让我们看看这个实际的实现:
-
PassengerFlightInfo.cs
public override string BuildFlightIdentifier() =>$"{Id} {DepartureLocation}-{ArrivalLocation}carrying " +$"{_passengers} people"; -
FreightFlightInfo.cs
public override string BuildFlightIdentifier() =>$"{Id} {DepartureLocation}-{ArrivalLocation}carrying " +$"{Cargo} for {CharterCompany}";
虽然两个方法字符串都构建了,但它们都以航班标识符、出发机场和到达机场开始。
如果我们想要改变所有航班显示这些基本信息的方式,我们需要更改从 FlightInfoBase 继承的所有类。
相反,我们可以修改 FlightInfoBase 以提供一个包含共享信息的良好起点:
public virtual string BuildFlightIdentifier() =>
$"{Id} {DepartureLocation}-{ArrivalLocation}";
这次更改导致了两件事发生:
-
新的航班类不再 需要 重写
BuildFlightIdentifier -
现有的重写可以调用
base.BuildFlightIdentifier()来获取基本航班信息的通用格式
在我们的情况下,继续重写这个方法是有意义的,但现在我们可以修改代码以利用基级别的通用格式化:
-
PassengerFlightInfo.cspublic override string BuildFlightIdentifier() =>base.BuildFlightIdentifier() +$" carrying {_passengers} people"; -
FreightFlightInfo.cspublic override string BuildFlightIdentifier() =>base.BuildFlightIdentifier() +$" carrying {Cargo} for {CharterCompany}";
将我们的抽象类与虚拟方法结合起来,使我们能够将航班格式化逻辑集中在一个地方,同时仍然保持扩展类和修改其行为的自由。
为了更好的封装进行重构
面向对象编程的另一个核心原则是 封装。通过封装,你声明对类中数据的控制,并确保其他人以合理的方式与数据交互,无论是立即还是随着时间的推移。
以下重构处理了组成类的各种数据以及作为参数传递给方法的参数。
封装字段
最简单的封装重构允许你将字段的全部使用封装到一个属性中。
在下面的代码示例中,PassengerFlightInfo类有一个_passengers字段,用于存储航班上的乘客数量,并且当在类中引用乘客数量时,该字段被整个类使用:
public class PassengerFlightInfo : FlightInfoBase {
private int _passengers;
public void Load(int passengers) =>
_passengers = passengers;
public void Unload() =>
_passengers = 0;
public override string BuildFlightIdentifier() =>
base.BuildFlightIdentifier() +
$" carrying {_passengers} people";
}
这段代码并不糟糕,我会在生产应用程序中接受这种逻辑。然而,它确实有几个潜在的缺点:
-
类外部无法读取航班乘客数量。
-
有几个地方修改了
_passengers字段。如果我们想在值每次更改时添加验证或执行某些操作,我们就必须修改几个不同的方法。
将_passengers字段的全部使用封装到属性中,可以帮助我们提供一个集中位置来执行验证,并为类外部的读取提供一个属性。
你可以使用封装字段重构功能在快速操作菜单中快速将现有字段包装成属性:

图 5.22 – 将乘客字段封装为属性
这为你的类添加了一个属性,可以在集中位置读取和修改值:
public sealed class PassengerFlightInfo : FlightInfoBase {
private int _passengers;
public int Passengers {
get => _passengers;
set => _passengers = value;
}
public void Load(int passengers) =>
Passengers = passengers;
public void Unload() =>
Passengers = 0;
public override string BuildFlightIdentifier() =>
base.BuildFlightIdentifier() +
$" carrying {Passengers} people";
}
请记住,这种重构默认会将设置器公开,这将允许类外部的代码修改passengers值。如果你不希望这样,你可以将属性标记为具有private或protected设置。
将参数封装到类中
随着软件系统的增长,会添加更多功能以及支持它们的代码。这可能导致曾经简单的方法变得非常复杂,并且它们需要的信息也越来越多。
在项目早期需要三个参数的方法,在经过大量开发后,突然发现自己需要七个或八个参数才能正常工作,这种情况并不少见。
FlightScheduler的搜索方法就是这样一个例子,因为有很多因素会影响航班搜索:
FlightScheduler.cs
public IEnumerable<IFlightInfo> Search(
Airport? depart, Airport? arrive,
DateTime? minDepartTime, DateTime? maxDepartTime,
DateTime? minArriveTime, DateTime? maxArriveTime,
TimeSpan? minLength, TimeSpan? maxLength) {
此方法目前接受八种不同的信息,这使得对方法的调用非常难以阅读:
IEnumerable<IflightInfo> flights = scheduler.Search(cmh,
dfw, new DateTime(2024,3,1), new DateTime(2024,3,5),
new DateTime(2024,3,10), new DateTime(2024,3,13),
TimeSpan.FromHours(2.5), TimeSpan.FromHours(4.5));
虽然我故意让这个例子读起来有点困难,但根据我的经验,在现实世界中确实存在复杂的方法签名。这些复杂的方法可能会导致由于在阅读参数列表时对传递给哪个参数的值感到困惑而出现微妙的错误。
看着这段代码,很容易想象到有人可能想要搜索与航班相关的新事物,包括低和高价格、机上饮料服务、免费 Wi-Fi 以及所飞行的飞机类型。这些新的搜索功能将进一步扩展方法定义和调用该方法的所有调用者。
解决这个问题的常见方法是将相关信息封装到一个新的类中。在我们的情况下,我们可以定义一个新的FlightSearch类来封装与搜索航班相关的一切:
FlightSearch.cs
public class FlightSearch {
public Airport? Depart { get; set; }
public Airport? Arrive { get; set; }
public DateTime? MinArrive { get; set; }
public DateTime? MaxArrive { get; set; }
public DateTime? MinDepart { get; set; }
public DateTime? MaxDepart { get; set; }
public TimeSpan? MinLength { get; set; }
public TimeSpan? MaxLength { get; set; }
}
这个新类允许我们在一个集中的地方跟踪搜索信息,并显著改进了搜索方法的签名:
FlightScheduler.cs
public IEnumerable<IFlightInfo> Search(FlightSearch s) {
IEnumerable<IFlightInfo> results = _flights;
if (s.Depart != null) {
results =
results.Where(f => f.DepartureLocation == s.Depart);
}
// Other filters omitted for brevity...
return results;
}
添加 FlightSearch 类将方法签名从八个参数缩减为一个。此外,如果未来需要添加新的搜索逻辑,这些信息可以添加到 FlightSearch 对象中,而无需进一步修改 Search 方法的签名。
不幸的是,更改搜索方法的签名会中断直到它们更新为使用新搜索对象的方法调用者。为了解决这个问题,你有几个选择:
-
将所有对
Search方法的调用更新为传递一个FlightSearch对象 -
创建一个临时的
Search方法重载,将FlightSearch对象传递给新方法。
第一个选项有些不言自明,所以让我们看看第二个选项。
在这里,我们将创建一个 Search 方法的重载,它接受八个旧参数,创建一个 FlightSearch 对象,并将其传递给新方法:
[Obsolete("Use the overload that takes a FlightSearch")]
public IEnumerable<IFlightInfo> Search(
Airport? depart, Airport? arrive,
DateTime? minDepartTime, DateTime? maxDepartTime,
DateTime? minArriveTime, DateTime? maxArriveTime,
TimeSpan? minLength, TimeSpan? maxLength) {
FlightSearch searchParams = new() {
Arrive = arrive,
MinArrive = minArriveTime,
MaxArrive = maxArriveTime,
Depart = depart,
MinDepart = minDepartTime,
MaxDepart = maxDepartTime,
MinLength = minLength,
MaxLength = maxLength
};
return Search(searchParams);
}
注意,我们已将此方法标记为已过时。这将警告试图使用它的程序员,并告诉他们应该使用哪个方法(见 图 5**.23)。使用 Obsolete 属性标记事物有助于引导开发者使用更近期的版本。通常,一个方法会被标记为已过时,然后稍后从项目中删除。

图 5.23 – 一个过时的警告,告诉开发者使用哪个方法代替
通过引入一个类,我们能够简化我们的方法,并为随着时间的推移需要增长的数据提供了一个安全的地方:
为常见的参数集引入类可以显著加快团队的开发时间,尤其是当这些相同的对象在整个系统中传递时。
将属性封装到类中
有时你会找到具有相互关联的属性集的类。例如,FlightInfoBase 类需要跟踪飞机起飞或到达的机场以及该事件的日期和时间:
FlightInfoBase.cs
public abstract class FlightInfoBase : IFlightInfo {
public Airport ArrivalLocation { get; set; }
public DateTime ArrivalTime { get; set; }
public Airport DepartureLocation { get; set; }
public DateTime DepartureTime { get; set; }
// Other members omitted ...
}
在这种情况下,关于到达和出发的信息都需要它们的 Airport 和相关的 DateTime 才有意义。如果我们未来需要跟踪航站楼、登机口或跑道,我们需要为到达和出发都添加属性。
因为这些属性集是相互增长的,所以将它们封装在自己的 AirportEvent 类中是有意义的:
public class AirportEvent {
public Airport Location { get; set; }
public DateTime Time { get; set; }
}
现在,如果我们需要扩展我们对每个航段跟踪的信息,我们可以将其添加到这个类中,它将对到达和出发都可用。
当然,为了使这完全工作,我们需要修改 FlightInfoBase 以使用新类而不是单独跟踪其属性:
FlightInfoBase.cs
public abstract class FlightInfoBase : IFlightInfo {
public AirportEvent Arrival { get; set; }
public AirportEvent Departure { get; set; }
public TimeSpan Duration => Departure.Time-Arrival.Time;
public string Id { get; set; }
public virtual string BuildFlightIdentifier() =>
$"{Id} {Departure.Location}-{Arrival.Location}";
public sealed override string ToString() =>
BuildFlightIdentifier();
}
然而,直到我们更新IFlightInfo接口以匹配我们的新签名,这种变化本身还不够:
IFlightInfo.cs
public interface IFlightInfo {
string Id { get; }
AirportEvent Arrival { get; set; }
AirportEvent Departure { get; set; }
TimeSpan Duration { get; }
}
通过这个变化,编译器现在对我们的航班类感到满意,但现在在FlightScheduler的ScheduleFlight方法中出现了编译错误:
FlightScheduler.cs
PassengerFlightInfo flight = new() {
Id = id,
ArrivalLocation = arrive,
ArrivalTime = arriveTime,
DepartureLocation = depart,
DepartureTime = departTime,
};
这种方法仍在尝试设置旧属性,因此需要更新以使用AirportEvent对象:
PassengerFlightInfo flight = new() {
Id = id,
Arrival = new AirportEvent {
Location = arrive,
Time = arriveTime,
},
Departure = new AirportEvent {
Location = depart,
Time = departTime,
},
};
FlightScheduler在搜索方法中由于使用了旧属性,也出现了一些编译错误:
if (s.Depart != null) {
results =
results.Where(f => f.DepartureLocation == s.Depart);
}
这些代码片段需要引用新的属性:
if (s.Depart != null) {
results =
results.Where(f => f.Departure.Location == s.Depart);
}
你可能已经注意到,为了将属性组合成一个新的对象进行这个简单的更改,我们不得不做出许多更改才能使代码再次编译。
在进行这种结构变更时,这很正常,但编译器通过确保你的代码在变更过程中保持结构上的合理性来支持你的重构之旅。实际上,如果没有编译器的帮助,我可能不敢做出一些这些变更。我鼓励你将编译器视为重构之旅中的盟友。
优先使用组合而非继承
让我们通过探讨“优先使用组合而非继承”的指令来结束我们对封装的讨论。这是我职业生涯早期经常听到的一句话,尽管我花了一些时间才理解它的含义和影响。
通过优先使用组合而非继承,我们做出了一个有意识的决策,即类应该“拥有”某些东西,而不是“成为”某些东西。如果一个类可以将其责任转交给另一个对象,而不是依赖继承来使类更加特殊并能够处理特定场景,那么这个类就有东西。
让我们看看航班调度系统,例如。
Cloudy Skies Airlines 已经决定它想要提供包机服务。这些小型航班既载客又载货,由不同的公司支付费用。在这种情况下,包机既不是客运航班也不是货运航班,而是两者兼而有之。
使用继承直接实现这个功能可能看起来像这样:
public class CharterFlightInfo : FlightInfoBase {
public string CharterCompany { get; set; }
public string Cargo { get; set; }
public int Passengers { get; set; }
public override string BuildFlightIdentifier() =>
base.BuildFlightIdentifier() +
$" carrying {Cargo} for {CharterCompany}" +
$" and {Passengers} passengers";
}
注意,这里一个类既有货物也有乘客。
单独来看,这并不糟糕,但如果我们想让我们的包机携带多件货物呢?我们现在需要有一个包含货物字符串及其包机公司的集合(这些公司可能各不相同)。
对此货物或其显示方式的任何定制都需要对这个类进行额外的定制,或者创建一个单独但相关的类,这个类也继承自FlightInfoBase。想象这个系统产生一系列相关类,如BulkCargoFlightInfo、ExpressFlightInfo、MedicalFlightInfo、HazardousCargoFlightInfo等,并不太难。
虽然这种基于继承的方法可以工作,但使用组合将导致更易于维护的代码和更少的类。
组合允许我们说一个单独的航班是由组合的货物项组成的。货物项可以使用简单的CargoItem类来定义:
public class CargoItem {
public string ItemType { get; set; }
public int Quantity { get; set; }
public override string ToString() =>
$"{Quantity} {ItemType}";
}
这种简单的方法存储了项目类型及其数量,并提供了这两个的字符串表示。
然后,我们可以将此方法纳入CharterFlightInfo的替代版本中:
public class CharterFlightInfo : FlightInfoBase {
public List<CargoItem> Cargo { get; } = new();
public override string BuildFlightIdentifier() {
StringBuilder sb = new(base.BuildFlightIdentifier());
if (Cargo.Count != 0) {
sb.Append(" carrying ");
foreach (var cargo in Cargo) {
sb.Append($"{cargo}, ");
}
}
return sb.ToString();
}
}
这种方法允许包机航班由不同的货物项组成。然后,每个项目都使用其ToString方法在BuildFlightIdentifier方法中显示。请参阅以下图表:

图 5.24 – CharterFlightInfo 由 CargoItems 组成
使用CargoItems组合我们的包机航班提供了额外的灵活性。不仅这种组合模式允许包机航班拥有多个货物项,而且它还允许这样做,而无需为不同的货物负载声明不同的类。
使用接口和多态改进类
我们几乎完成了关于面向对象重构的章节。然而,在我们结束这一章之前,让我们讨论一下在哪些地方引入接口和多态可以帮助进一步改进我们的代码。
提取接口
目前,我们的CharterFlightInfo类存储了一个表示其货物的CargoItems 列表:
public class CharterFlightInfo : FlightInfoBase {
public List<CargoItem> Cargo { get; } = new();
// Other members omitted...
}
包机航班包含的每个货物项都必须是CargoItem或从它继承的某个东西。例如,如果我们创建上节中讨论的HazardousCargoItem并尝试将其存储在货物集合中,它必须从CargoItem继承才能编译。
在许多系统中,你不想强迫人们从你的类继承,如果他们想要自定义系统的行为。在这些地方,引入一个接口可能会有所帮助。
让我们通过选择CargoItem类,然后从快速操作菜单中选择提取接口…来用我们的CargoItem类做这件事。

图 5.25 – 提取接口
一旦你做了这件事,如图图 5**.25所示,你现在需要指定哪些类的成员应该包含在接口中,以及接口应该叫什么名字:

图 5.26 – 定制提取的接口
将你的接口命名为ICargoItem,选择ItemType和Quantity,然后在新文件中点击ICargoItem接口:
public interface ICargoItem {
string ItemType { get; set; }
int Quantity { get; set; }
}
这也将修改CargoItem以实现此接口:
public class CargoItem : ICargoItem {
public string ItemType { get; set; }
public int Quantity { get; set; }
public override string ToString() =>
$"{Quantity} {ItemType}";
}
注意,默认情况下,提取接口会在属性上引入获取器和设置器。如果你不希望你的接口暴露修改属性的方式,你可以在接口中从属性定义中移除set:
public interface ICargoItem {
string ItemType { get; }
int Quantity { get; }
}
移除设置器不会阻止你在CargoItem的属性上有一个设置器;它只是意味着你不需要在属性上有一个设置器。
我们有了新的接口,让我们进入并修改CharterFlightInfo以存储ICargoItem而不是CargoItem:
public class CharterFlightInfo : FlightInfoBase {
public List<ICargoItem> Cargo { get; } = new();
// Other members omitted...
}
这个变化使我们能够存储实现该接口的任何内容,并提高了CharterFlightInfo可以存储的内容的灵活性。然而,这也向你的代码中引入了另一个接口,这略微增加了复杂性,并可能在长期内减缓开发时间。
在引入接口时要小心。仅仅为了增加抽象而存在的接口最终会对你的应用程序造成更多的伤害而不是好处。然而,被多个类实现或旨在给另一组开发者提供更大自由度或灵活性的接口最终可以在软件系统中带来很多好处。
当我们探索 SOLID 时,我们将更详细地讨论接口的适当位置第十章。现在,让我们继续探讨 C#接口中的新功能。
提供默认接口实现
当我们在探索接口时,让我们看看默认接口实现如何简化实现接口的体验。
默认接口实现允许你在接口内部提供默认实现。当一个类选择实现此接口时,它不是被迫提供具有默认实现的方法的实现。
让我们通过向ICargoItem添加一个具有默认获取器的ManifestText属性和一个具有默认实现的LogManifest方法来了解这意味着什么:
public interface ICargoItem {
string ItemType { get; }
int Quantity { get; }
string ManifestText => $"{ItemType} {Quantity}";
void LogManifest() {
Console.WriteLine(ManifestText);
}
}
通过将这些新成员添加到接口中,我们通常会破坏实现该接口的任何内容,例如CargoItem类,除非它具有这些成员。然而,因为我们提供了这两个属性的默认实现,所以CargoItem不再必须提供实现。相反,它实际上继承了这些默认实现。
我们仍然可以提供这些新成员的版本。如果我们这样做,那么这个版本将用于替代默认实现:
CargoItem.cs
public class CargoItem : ICargoItem {
public string ItemType { get; set; }
public int Quantity { get; set; }
public void LogManifest() {
Console.WriteLine($"Customized: {ToString()}");
}
public override string ToString() =>
$"{Quantity} {ItemType}";
}
我不太喜欢默认接口实现,因为它们混淆了接口与提供某些成员的契约的概念。
然而,我必须承认,当向接口添加简单成员时,有时添加默认实现是有意义的,这样你就不需要更改接口的现有实现。这可以让你在整个解决方案中避免在接口的多个实现中添加相同的代码。此外,默认接口实现通过提供默认实现减少了尝试实现接口的类所需的工作量。
引入多态
每当你使用接口时,你故意在应用程序中支持多态。这是根据对象的相似性而不是它们的差异来处理不同对象的能力。
之前介绍的与包机航班相关的ICargoItem方法是一个多态的例子。包机航班不关心它有什么类型的货物,只要货物实现了接口即可。这意味着我们可以装满不同类型货物的包机航班,并且这个类与它们一起工作得很好。
本章的代码中还有另一个地方可以从多态中受益:FlightScheduler的Search方法:
public IEnumerable<IFlightInfo> Search(FlightSearch s) {
IEnumerable<IFlightInfo> results = _flights;
if (s.Depart != null) {
results =
results.Where(f => f.Departure.Location == s.Depart);
}
// Many filters omitted...
if (s.MaxLength != null) {
results =
results.Where(f => f.Duration <= s.MaxLength);
}
return results;
}
此方法包含一些非常重复的代码(其中大部分被省略),用于检查搜索对象是否指定了属性。如果指定了属性,潜在的结果将被过滤,仅包括匹配过滤器的那些。
搜索方法使用这种方法根据以下条件进行过滤:
-
出发和到达地点
-
最小/最大出发时间
-
最小/最大到达时间
-
最小/最大飞行时长
想象一下我们可能需要过滤的新事物并不难,例如航班价格、航班是否有饮料服务,甚至是飞机的类型。
另一种方法是接受一个过滤器对象的集合。这些过滤器对象将通过一个共同的FlightFilterBase类和一个ShouldInclude方法来确定每架航班是否应该包含在结果中:
public abstract class FlightFilterBase {
public abstract bool ShouldInclude(IFlightInfo flight);
}
通过这个改动,Search可以被修改为遍历所有过滤器,并且只包括通过所有提供过滤器的结果:
List<IFlightInfo> Search(List<FlightFilterBase> rules) =>
_flights.Where(f => rules.All(r => r.ShouldInclude(f)))
.ToList();
通过多态,这种方法将Search方法从超过 40 行代码缩短到只有 3 行代码。
替代实现
接口也可以很好地替代抽象基类。
通过遵循这个设计,我们可以创建一系列继承自FlightFilterBase的类,以提供特定的过滤功能:

图 5.27 – 不同的过滤器类,有助于简化我们的搜索代码
现在我们有了专门的过滤器,可以过滤掉不符合特定标准的航班。例如,AirportFilter会过滤掉未指定机场的航班:
public class AirportFilter : FlightFilterBase {
public bool IsDeparture { get; set; }
public Airport Airport { get; set; }
public override bool ShouldInclude(IFlightInfo flight) {
if (IsDeparture) {
return flight.Departure.Location == Airport;
}
return flight.Arrival.Location == Airport;
}
}
每个单独的过滤器类都很小,易于理解、维护和测试。
此外,如果我们想在将来添加新的过滤航班的方式,我们只需要添加一个新的继承自FlightFilterBase的类。不需要对Search方法进行修改以支持这一点,因为所有方法需要的是一个过滤器集合。Search方法不需要知道涉及哪些过滤器 - 它只需要调用ShouldInclude方法并解释结果。
我发现多态解决方案非常美丽,并且发现我的编程风格在多年中发生了变化,以寻找更多利用继承或接口实现多态的机会。
审查和测试我们重构的代码
在做出这些更改后,让我们退后一步,看看结果。
我们对一个航班搜索系统进行了重构,通过以下方式使用面向对象编程技术来提高其灵活性和可维护性:
-
将代码重新组织到适当的文件和命名空间中
-
在飞行信息中引入基类并提高代码重用性
-
通过将参数移动到新类中来控制大量参数
-
引入一个新的类来管理关于机场事件的常见信息,包括机场和时间组件
-
添加一个包机航班类,并配备灵活的货物跟踪系统
-
引入一种多态的航班搜索方式,这将随着时间的推移变得更加灵活和易于维护
重构代码
本章的最终重构代码可在github.com/PacktPublishing/Refactoring-with-CSharp存储库中的Chapter05/Ch5RefactoredCode文件夹中找到。
像往常一样,重构代码之前应该先测试代码,以确保重构过程中没有引入新的缺陷。运行解决方案中提供的测试(见图 5.28)显示所有测试都通过,这目前足够了,直到我们到达第二部分并更深入地探讨测试。

图 5.28 – 测试资源管理器显示所有测试通过
摘要
在本章中,我们探讨了如何使用面向对象编程技术,如继承、封装和多态,将代码重构为更易于维护的形式。
重构可能是一项复杂的任务,但许多面向对象编程的基本概念可以结合起来,构建出优雅、灵活且易于维护的解决方案。
这本书的第一部分到此结束。在书的下一部分,我们将探讨测试如何为你提供安全感和自由度,让你可以安全地重构代码,并充满信心地继续前进,相信你的更改已经改进了应用程序而没有破坏任何东西。
问题
-
你的代码是否遵循一个结构良好且一致的命名空间层次结构,每个命名空间中的类数量既不太多也不太少?
-
你的代码中是否有任何部分可以通过使用继承来提高代码重用性而得到改进?
-
你能想到任何可能从多态中受益的重复规则或其他结构吗?
进一步阅读
你可以在以下 URL 中找到有关本章讨论的材料更多信息:
-
C#中的继承:
learn.microsoft.com/en-us/dotnet/csharp/fundamentals/tutorials/inheritance -
密封 修饰符:
learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/sealed -
IEquatable
: learn.microsoft.com/en-us/dotnet/api/system.iequatable-1
第二部分:安全重构
在本书的第二部分,我们将介绍诸如单元测试之类的编码技术,这些技术有助于确保您的重构工作不会导致意外的更改。
本章重点介绍各种测试框架和标准测试实践,然后讨论编程最佳实践和编写 SOLID 代码。
本部分最后两章专注于更高级的测试策略以及 C#语言如何帮助您检测和防止错误传递给用户。
本部分包含以下章节:
-
第六章**,单元测试
-
第七章**,测试驱动开发
-
第八章**,使用 SOLID 避免代码反模式
-
第九章**,高级单元测试
-
第十章**,防御性编码技术
第六章:单元测试
在本书的第一部分,我们介绍了重构的过程和一些更常见的重构技术。现在,是我们退一步,提醒自己重构是什么:重构是改变代码形式或形状的过程,而不改变其行为。
换句话说,我们可以使我们的代码尽可能干净和易于维护,但如果这些更改引入了错误,那么这不算重构,因为重构是关于在不改变其行为的情况下改变代码的形式。为了在不引入错误的情况下改进我们的代码,我们需要一个安全网:单元测试。
在本章中,我们将探讨单元测试,并涵盖以下主要主题:
-
理解测试和单元测试
-
使用 xUnit 测试代码
-
重构单元测试
-
探索其他测试框架
-
采用测试思维
技术要求
本章的起始代码可以从 GitHub 的github.com/PacktPublishing/Refactoring-with-CSharp在Chapter06/Ch6BeginningCode文件夹中获取。
理解测试和单元测试
每当我管理或指导其他开发者,他们想要对系统进行更改时,我会问他们一个问题:“你如何确保你的更改不会 破坏事物?”
这个问题可能看起来很简单,但每个答案都归结为一个单一的概念:测试。
我将测试定义为验证软件功能并检测程序行为中不希望的变化的过程。
这种测试可以由人类执行,例如开发人员或质量保证分析师,或者可以通过软件执行,具体取决于所涉及的测试类型。
测试类型和测试金字塔
测试是一个广泛的领域,包括许多不同类型的活动,如下所示:
-
手动测试,涉及人员手动执行某些活动并验证结果。
-
探索性测试,这是手动测试的一个子集,专注于探索系统对事物的反应,以发现新的错误类型。
-
单元测试,在这种测试中,软件系统的小部分被单独测试。
-
组件测试,其中测试系统的较大组件。
-
集成测试,涉及两个组件,如 API 和数据库,它们一起被测试。
-
端到端测试,在这种测试中,整个系统路径都会被测试。这通常涉及多个组件按顺序交互。
这些活动中的大多数都是自动化测试,其中计算机代码与系统交互以验证其行为。我们将在本章末尾更多地讨论构成良好测试的因素。
自动化测试确实有一些缺点。首先,自动化测试需要时间来创建。通常,必须有人编写代码或使用某些工具来编写测试脚本。其次,这些测试通常需要持续维护,以保持与软件系统变化的关联。最后,这些测试可能会提供一种虚假的安全感。例如,假设一个开发者编写了一个测试来导航到“预订航班”网页并验证是否有空座位显示为可用。即使网页上有明显的错误和不匹配,这个测试也可能通过,仅仅是因为测试只编码了检查网页一小部分。
另一方面,人工测试人员是聪明的。他们有自由意志和主动性,可以对软件做出客观的判断,这是机器无法做到的。他们可以找到没有人想过要为它们编写测试的问题,并且可以提供关于您产品功能的有价值反馈。然而,人们通常比自动化测试慢得多,一旦某个功能准备好测试,质量保证分析师可能需要一些时间来测试它。
自动化和手动测试都有优点和缺点。一个并不比另一个更好;相反,它们结合起来为软件项目中的质量问题的有效解决方案。
软件质量中的一个流行概念是 测试金字塔 的想法。测试金字塔显示了组织可能执行的各种测试类型。此外,如图 图 6.1 所示,金字塔每个部分的宽度表示该类型测试的数量:

图 6.1 – 测试金字塔的一个示例
在一个测试金字塔中,例如这个,底部的项目应该是数量最多的,而金字塔顶部的项目应该是最稀少的。几乎每个测试金字塔的图表在金字塔中列出的测试类型上都有所不同,但它们都同意最常见的形式应该是单元测试,而最不常见的是手动测试。
许多组织在软件开发成熟度早期就犯了错误。当这种情况发生时,他们有很多手动测试,很少的单元测试,通常没有端到端、集成或组件测试。结果,金字塔看起来可能有点像 图 6.2:

图 6.2 – 具有许多手动测试、少量单元测试和没有其他测试的测试金字塔
这个金字塔 应该 看起来很荒谬,因为几乎总是缺乏测试自动化是缓慢过程、延迟发布和软件错误进入生产环境的配方!
您的系统越大,手动测试就越不切实际,手动发现错误的时间就越长。
解决这个问题的方法是 自动化测试,尤其是自动化单元测试。
单元测试
单元测试是代码中的小方法,用于测试系统中其他方法,以验证这些方法在特定场景下是否正确执行。
更简洁地说,单元测试是测试其他代码的代码。
已经熟悉测试了吗?
如果你经常使用单元测试,你可能已经熟悉单元测试了。如果是这样,你可能想快速浏览本章的其余部分,然后继续下一章。
为了说明单元测试的概念,让我们看看一个简单的生成航班状态信息的方法:
public class Flight {
public string BuildMessage(string id, string status){
return $"Flight {id} is {status}";
}
}
虽然这个方法非常简单,但让我们考虑一下我们需要采取哪些步骤来验证它是否正确工作:
-
实例化
Flight类并将该对象存储在变量中。 -
声明一对表示
id和status的字符串变量。 -
从 步骤 1 中调用我们的航班对象的
BuildMessage方法。 -
将 步骤 3 的结果存储在一个新的字符串变量中。
-
验证我们刚刚存储的字符串是否与预期相符。
这基本上就是一个单元测试会做的事情。它会实例化你的类,安排它需要的变量,执行单元测试试图验证的方法,并最终 断言 方法的结果是否符合预期。我们称这种模式为 安排/执行/断言 模式,我们将在本章后面进一步讨论。
为了帮助说明这个概念,这里有一个 BuildMessage 方法的示例测试:
public class FlightTests {
[Fact]
public void GeneratedMessageShouldBeCorrect() {
// Arrange
Flight flight = new();
string id = "CSA1234";
string status = "On Time";
// Act
string message = flight.BuildMessage(id, status);
// Assert
Assert.Equal("Flight CSA1234 is On Time", message);
}
}
不要担心这里的特定语法,因为我们很快就会涉及到这一点。现在,理解 GeneratedMessageShouldBeCorrect 方法是测试一小段代码以验证特定功能的单元测试的例子。
具体来说,此方法验证 Flight 类的 BuildMessage 方法根据它接收的 id 和 status 参数计算并返回准确的状态信息。
此测试可以快速与解决方案中的所有其他测试一起运行,如果 BuildMessage 方法按预期工作,则通过;如果 BuildMessage 的结果有任何变化,则失败,如图 6**.3 所示:

图 6.3 – 一个失败的单元测试
像这样的测试失败是有帮助的,因为它们突出了开发者可能在没有失败的测试标记潜在问题的前提下发布到生产环境中的错误。
在下一节中,我们将通过介绍最流行的单元测试框架:xUnit,来更深入地探讨单元测试。
使用 xUnit 测试代码
xUnit.net,通常简称为 xUnit,是目前 .NET 中最受欢迎的单元测试库,其次是 Attributes,你可以使用它来标识你的测试代码,我们很快就会看到。使用这些属性可以让测试运行器,如 Visual Studio 的 测试资源管理器,识别你的方法为单元测试并运行它们。
本章的代码从到目前为止的章节中的大多数类开始,组织在 Chapter6 的 Chapter6BeginningCode 解决方案 内的各种命名空间中。
解决方案和项目
在 .NET 中,一个项目代表一个具有特定目的的 .NET 代码的独立程序集。不同的项目有不同的类型,从桌面应用程序到 Web 服务器,再到类库和测试项目。另一方面,解决方案将所有这些项目组合成一个相互关联的项目集合。
在本章的剩余部分,我们将为前几章中的几个类编写测试。由于 xUnit 目前是最受欢迎的测试库,让我们首先向解决方案中添加一个新的 xUnit 测试项目。
创建 xUnit 测试项目
要向解决方案添加新项目,请右键单击 解决方案资源管理器 顶部的解决方案名称,位于搜索栏下方,然后选择 添加,接着选择 新建项目…
接下来,搜索 xUnit 并选择带有 C# 标签的 xUnit 测试项目 结果,如图 图 6.4 所示。请注意,还有使用其他语言(如 VB 或 F#)的此测试项目的版本:

图 6.4 – 选择 xUnit 测试项目选项
点击 Chapter6XUnitTests,然后再次点击 下一步。
在此之后,您需要选择要使用的 .NET 版本。由于本书中的代码使用 .NET 8,您可以选中该选项并点击 创建。
这应该在您的编辑器中打开一个新文件,其中包含一些基本的测试代码:
UnitTest1.cs
namespace Chapter6XUnitTests {
public class UnitTest1 {
[Fact]
public void Test1() {
}
}
}
此外,一个新的项目已添加到您的解决方案中,现在在 解决方案资源管理器 中可见,如图 图 6.5 所示:

图 6.5 – 解决方案资源管理器中的测试项目
我们还需要执行几个步骤来测试其他项目中的代码。但在我们这样做之前,可能会让您惊讶的是,xUnit 创建的代码已经是一个可运行的单元测试。
点击 Test1 单元测试,一旦测试运行,它将变成绿色勾选标记,如图 图 6.6 所示:

图 6.6 – 测试资源管理器,测试展开到 Test1 可见的位置
故障排除
如果在运行测试后看不到 测试资源管理器,请点击 视图 菜单,然后选择 测试资源管理器。您可能还需要在运行测试成为选项之前构建解决方案。
注意,我们当前的测试并不算是一个真正的测试,我们还没有涵盖代码或它是如何工作的。我们很快就会到达那里,但首先,让我们完成设置测试的最后一步,并将我们的测试项目连接到 Chapter6 项目。
将 xUnit 测试项目连接到主项目
在.NET 中,项目可以依赖于其他项目中的代码。这允许你在一个项目中定义一个类,另一个项目可以使用该类。为了能够从我们的单元测试项目中测试代码,我们需要能够做到这一点。因此,我们需要从测试项目设置一个项目依赖到Chapter6项目。
在解决方案资源管理器中,在测试项目内的依赖项节点上右键单击,并选择添加项目引用…,如图图 6**.7所示:

图 6.7 – 向我们的测试项目添加项目引用
然后,点击“解决方案资源管理器”中测试项目内的“依赖项”节点旁边的复选标记,并点击Chapter6项目,以便测试项目现在可以引用其他项目中定义的类。
在所有这些准备就绪后,我们就可以编写我们的第一个真正的测试了。
编写你的第一个单元测试
我们的第一批测试将测试我们在第二章中构建的BaggageCalculator类。
BaggageCalculator有一个名为CalculatePrice的方法,其方法签名如下:
public decimal CalculatePrice(int bags, int carryOn,
int passengers, bool isHoliday)
我们还知道这个方法的一些规则:
-
所有手提行李每件费用为 30 美元
-
乘客托运的第一个行李箱的费用为 40 美元
-
每个后续托运的行李箱费用为 50 美元
-
如果旅行发生在假日期间,将应用 10%的附加费
我们无法在单个测试中测试所有这些逻辑,而且我们也不应该尝试这样做。单元测试应该是小的,并且与一个特定的逻辑相关。如果测试失败,这个失败应该告诉你很多关于系统错误的信息。如果单元测试试图做太多,它们就变得难以理解,失败时告诉你更少关于错误的信息。
让我们从将我们的UnitTest1类重命名为BaggageCalculatorTests开始,使用我们在第二章中介绍的重命名重构功能。测试通常以它们测试的类的名称命名。由于我们的类测试BaggageCalculator,让我们将其重命名为BaggageCalculatorTests。
接下来,我们将重命名Test1方法,以反映我们试图验证的内容。这个测试的名称将出现在测试失败中。因此,我的一个基本原则是,如果收到一个测试失败的提醒,其名称本身就应该告诉我出了什么问题。
在我们的案例中,我们试图验证手提行李的定价是否正确。因此,让我们将Test1重命名为类似CarryOnBaggageIsPricedCorrectly的名称。
我们现在的代码如下所示:
namespace Chapter6XUnitTests {
public class BaggageCalculatorTests {
[Fact]
public void CarryOnBaggageIsPricedCorrectly() {
}
}
}
在我们编写测试代码之前,让我们强调几个关键点:
-
首先,我们的方法应用了
Fact属性。这允许 xUnit 告诉测试运行器关于我们的测试的信息,并有效地为潜在的执行注册测试。 -
接下来,
CarryOnBaggageIsPricedCorrectly方法返回void且不接受任何参数。使用Fact属性的测试方法不能接受参数,必须返回void或Task以进行异步测试。我们将在本章后面讨论Theory和InlineData,因为它们允许你向单元测试传递参数。 -
最后,类和方法都是
public的。为了单元测试出现在测试运行器中,两者都必须是public。
现在我们已经介绍了一些单元测试的基本机制,让我们遵循arrange/act/assert模式来构建我们的测试。
使用 Arrange/Act/Assert 组织测试
arrange/act/assert 模式是一个结构化模式,用于编写测试。遵循arrange/act/assert时,你执行以下步骤:
-
通过声明变量安排你需要用于测试的事情。
-
执行你试图测试的具体事情。
-
断言你的操作产生了预期的结果。
让我们从整理代码开始。由于我们要在BaggageCalculator类上测试CalculatePrice方法,我们需要实例化一个行李计算器的实例。
我们还知道我们需要传递已检查的和托运行李的数量,以及乘客数量以及旅行是否在假日季节。这些值应该是我们认为最相关或最具代表性的测试值,因此它们取决于我们的判断。
用变量声明填充我们的arrange部分会产生以下代码:
[Fact]
public void CarryOnBaggageIsPricedCorrectly() {
// Arrange
BaggageCalculator calculator = new();
int carryOnBags = 2;
int checkedBags = 0;
int passengers = 1;
bool isHoliday = false;
在这里,我们正在设置执行act阶段所需的一切。此外,请注意,我包含了一个// Arrange注释来将相关代码分组。这是我和许多其他我知道的开发者在测试代码中做的事情,以帮助组织测试。
现在我们已经设置了变量,我们可以对我们要测试的代码:CalculatePrice方法进行操作。为此,我们必须调用该方法,并存储它返回的decimal值:
// Act
decimal result = calculator.CalculatePrice(checkedBags,
carryOnBags, passengers, isHoliday);
与arrange部分不同,act部分非常简短,通常只有一行长。这是因为act部分专注于你试图测试的事情。我们调用之前实例化的计算器对象上的测试方法,并传递它执行工作所需的参数。
被测试的系统
在我们的例子中,calculator变量存储了我们正在测试的类的实例。这通常被称为即将测试的对象的sut变量名。
这里有趣的是:从我们的测试角度来看,我们不在乎它是如何完成工作的。我们只关心我们给方法一组输入,并期望得到特定的输出。
我们在assert部分通过断言一些事情是真实的来验证这种行为。如果这些事情最终不是真实的,我们的测试将失败。如果所有这些事情最终都是真实的,测试将通过。
断言通常使用Assert类来验证值是否与其预期值匹配。在我们的案例中,场景有 2 个托运行李,没有其他行李。每个托运行李 30 美元,这应该总计 60 美元,所以我们的测试代码如下:
// Assert
Assert.Equal(60m, result);
Equal方法的第一参数是预期值。这是你期望你的结果应该是的值。你不应该在代码中计算这个值;否则,你可能会在测试你正在测试的代码时重复相同的潜在不良逻辑!
第二个参数是实际值,这几乎总是你在act部分调用你的方法的结果。
通常,对测试新手来说,他们期望第一个参数是实际值,第二个参数是预期值。然而,这是不正确的,会导致测试失败时值被颠倒的困惑测试。
例如,如果结果是 50,并且我们像之前那样正确地用Assert.Equal(60m, result);验证了它,你会看到这样的失败:
Assert.Equal() Failure
Expected: 60
Actual: 50
这很有帮助,并告诉开发者出了什么问题。
如果你混淆了两个参数并编写了Assert.Equal(result, 60m);,你会得到这样一个更令人困惑的消息:
Assert.Equal() Failure
Expected: 50
Actual: 60
这个错误在过去给我带来了很多困惑和头发脱落。请自己帮个忙,记住第一个参数总是你期望结果为的值。
在第九章 高级单元测试中,我们将介绍使用Shouldly和FluentAssertions库编写断言的更简洁方法。现在,请记住预期的值先写,实际的值后写。
其他 Assert 方法
Assert类除了Assert.Equal之外还有更多方法。你还可以使用Assert.True和Assert.False来验证布尔条件是否为真或假。Assert.Null和Assert.NotNull可以帮助验证某个东西是否为 null。Assert.Contains和Assert.DoesNotContain将验证集合中元素的存在或不存在。这些只是通过Assert类可用的方法中的一部分。对于这些消息中的每一个,你还可以提供一个自定义的失败消息,当断言导致你的测试失败时使用。
现在我们已经添加了第一个单元测试,让我们具体谈谈什么使测试通过,什么使测试失败。
理解测试和异常
每次运行的单元测试都会通过——除非它遇到了让它失败的东西。
那个失败可能是Assert语句与预期值不匹配,或者可能是你的程序或测试抛出了异常而没有捕获它。
当你调查Assert方法的实现时,你会发现当它们的条件不满足时,它们都会抛出异常。当这些异常被抛出时,测试运行器会捕获它们并使测试失败,适当地显示失败信息和堆栈跟踪。
这就是为什么一个空的测试即使没有任何Assert语句也会通过,这也是为什么你通常永远不会在单元测试中编写try/catch块,除非你明确地试图验证某种异常处理逻辑。
带着对导致测试失败的因素的理解,让我们编写第二个测试。
添加额外的测试方法
就像类可以在其中包含多个方法一样,测试类也可以在其中包含多个测试方法。这是因为从每个意义上说,单元测试只是代码。单元测试存在于每个方面都很普通的类中,除了它们存在于一个特殊的项目类型中,并且单个单元测试方法在声明方法之前有[Fact]。
让我们通过添加一个针对下一个场景的测试来举例说明:第一个托运行李费用为 40 美元。这个测试看起来是这样的:
[Fact]
public void FirstCheckedBagShouldCostExpectedAmount() {
// Arrange
BaggageCalculator calculator = new();
int carryOnBags = 0;
int checkedBags = 1;
int passengers = 1;
bool isHoliday = false;
// Act
decimal result = calculator.CalculatePrice(checkedBags,
carryOnBags, passengers, isHoliday);
// Assert
Assert.Equal(40m, result);
}
这个测试与之前的测试有很多相似之处,但关键的区别在于携带行李和托运行李的数量已更改,以匹配我们正在测试的新场景,并且预期的总价现在是 40 美元而不是 60 美元。
你编写的每个测试都应该不同。然而,如果你开始注意到测试之间存在很多共性,那么可能就是时候重构你的单元测试了。
重构单元测试
单元测试是代码,就像其他类型的代码一样,如果得不到适当的尊重和主动重构,它们的质量可能会随着时间的推移而下降。
因此,当你看到代码中存在诸如在大多数测试中出现的重复代码这样的代码异味时,这是一个迹象,表明你的测试需要重构。
在本节中,我们将探讨几种重构测试代码的方法。
使用Theory和InlineData参数化测试
当我们考虑两个测试之间的相似性时,它们只基于传递给我们要测试的方法的值以及我们期望的结果值而有所不同。
考虑到我们的测试方法,这是一个很明显的例子,如果能有一个参数可以输入到一个测试方法中,就能代表多个单元测试,每个测试都略有不同,但代码相似,那就太好了。
如您可能从之前的内容中回忆起来,使用Fact的单元测试不能有任何参数。然而,xUnit 给了我们另一个属性叫做Theory,它允许我们将数据作为参数传递给单元测试。
有多种不同的方式向这些参数提供数据,但最常见的方式是使用InlineData属性在方法旁边提供测试参数数据。
以下是一个使用Theory和InlineData来测试围绕行李定价的四个不同场景的示例,使用相同的测试代码:
[Theory]
[InlineData(0, 0, 1, false, 0)]
[InlineData(2, 3, 2, false, 190)]
[InlineData(2, 1, 1, false, 100)]
[InlineData(2, 3, 2, true, 209)]
public void BaggageCalculatorCalculatesCorrectPrice(
int carryOnBags, int checkedBags, int passengers,
bool isHoliday, decimal expected) {
// Arrange
BaggageCalculator calculator = new();
// Act
decimal result = calculator.CalculatePrice (checkedBags, carryOnBags, passengers, isHoliday);
// Assert
Assert.Equal(expected, result);
}
虽然这只是一个单一的方法,但每个InlineData行代表一个独特的单元测试,如图图 6.8所示,它将作为单独的测试出现在测试运行器中:

图 6.8 – 测试资源管理器中基于理论的四个测试被单个测试分组
尽管使用 理论 而不是 事实 可能一开始更难阅读,但可维护性的优势是巨大的。首先,参数化测试减少了代码重复。其次,如果你以后需要更新测试,你只需要更新一个方法,而不是使用 事实 编写相同测试时需要更新的许多单独的方法。
使用构造函数和字段初始化测试代码
理论 并不是提高测试代码的唯一方法。如果你发现你的测试做了很多可以共享的工作,你可以引入私有方法来帮助组织你的测试代码。
例如,假设你想测试来自 第五章 的 FlightScheduler 类,并且你想从测试通过 ScheduleFlight 将航班添加到计划中,并在调用 GetAllFlights 时显示该航班开始。
要做到这一点,你已经创建了一个 FlightSchedulerTests 类,并且正在编写一个 ScheduleFlightShouldAddFlight 单元测试。
当你开始编写测试时,你会注意到 ScheduleFlight 方法需要一个 IFlightInfo 实例,而这个实例反过来又需要几个 AirportEvent 对象。这些 AirportEvent 对象需要它们自己的 Airport 实例。
这些依赖关系导致你编写了大量 安排 代码来为测试设置:
[Fact]
public void ScheduleFlightShouldAddFlight() {
// Arrange
Airport airport1 = new() {
Code = "DNA",
Country = "United States",
Name = "Dotnet Airport"
};
Airport airport2 = new() {
Code = "CSI",
Country = "United Kingdom",
Name = "C# International Airport"
};
FlightScheduler scheduler = new();
PassengerFlightInfo flight = new() {
Id = "CS2024",
Status = FlightStatus.OnTime,
Departure = new AirportEvent() {
Location = airport1,
Time = DateTime.Now,
},
Arrival = new AirportEvent() {
Location = airport2,
Time = DateTime.Now.AddHours(2)
}
};
这大量的代码并不一定是 坏 的,但它确实分散了测试方法的其他部分,该部分执行调度并验证航班是否已添加:
// Act
scheduler.ScheduleFlight(flight);
// Assert
IEnumerable<IFlightInfo> result =
scheduler.GetAllFlights();
Assert.NotNull(result);
Assert.Contains(flight, result);
}
虽然 安排 部分很长并不是世界末日,但其他测试可能需要创建自己的 PassengerFlightInfo、Airport 或 AirportEvent,这会导致测试之间出现非常相似的代码。
为了帮助提高我们安排方法的可读性,我们可以引入两个机场的字段,并在构造函数中设置它们:
public class FlightSchedulerTests {
private readonly Airport _airport1;
private readonly Airport _airport2;
public FlightSchedulerTests() {
_airport1 = new() {
Code = "DNA",
Country = "United States",
Name = "Dotnet Airport"
};
_airport2 = new() {
Code = "CSI",
Country = "United Kingdom",
Name = "C# International Airport"
};
}
当 xUnit 运行你的测试代码时,它将为该类中的每个单元测试实例化一次 FlightSchedulerTests 类。这意味着构造函数或字段初始化器中的任何逻辑都会在运行该类中的任何测试时运行。
这让我们可以显著简化测试的 安排 部分:
// Arrange
FlightScheduler scheduler = new();
PassengerFlightInfo flight = new() {
Id = "CS2024",
Status = FlightStatus.OnTime,
Departure = new AirportEvent() {
Location = _airport1,
Time = DateTime.Now
},
Arrival = new AirportEvent() {
Location = _airport2,
Time = DateTime.Now.AddHours(2)
}
};
这个过程可以根据需要重复。例如,如果你想在不同测试之间重用相同的 PassengerFlightInfo,你可以在构造函数中添加一个 _flight 字段并初始化它。
重构过程不是关于最小化 安排 部分的大小;它是关于在保持代码重复低的同时,让其他阅读你代码的开发者能够看到测试的重要方面。
使用方法共享测试代码
你可以使用另一种技术来保持你的代码集中,那就是从你的测试代码中提取可重用方法,以帮助完成常见的 安排 任务。
例如,如果你想测试移除航班是否正确地从调度器中移除航班,你需要一个与刚才我们讨论的测试非常相似的测试。
当你这么想的时候,这两个测试并不太关心被添加的航班的详细信息——它们关心的是,当航班被安排时,它应该出现在航班列表中,而当航班被移除时,它应该不再被包含。
为了实现这一点,我们可以提取一个方法来创建我们的Flight对象。这个方法可以接受一个航班标识符并返回创建的航班,如下所示:
private PassengerFlightInfo CreateFlight(string id)
=> new() {
Status = FlightStatus.OnTime,
Id = id,
Departure = new AirportEvent() {
Location = _airport1,
Time = DateTime.Now
},
Arrival = new AirportEvent() {
Location = _airport2,
Time = DateTime.Now.AddHours(2)
}
};
我们之前的测试现在可以调用这个方法来创建其航班:
[Fact]
public void ScheduleFlightShouldAddFlight() {
// Arrange
FlightScheduler scheduler = new();
PassengerFlightInfo flight = CreateFlight("CS2024");
// Act
scheduler.ScheduleFlight(flight);
// Assert
IEnumerable<IFlightInfo> result =
scheduler.GetAllFlights();
Assert.NotNull(result);
Assert.Contains(flight, result);
}
你觉得这个方法有多专注?你可以快速阅读它,并了解测试的意图,而无需关注创建航班的所有必要机制。
测试void方法
我经常遇到的一个问题是“如何测试void方法,因为它们不返回任何内容?”大多数时候,当你编写测试时,你会测试方法的返回值,但针对void方法,你测试的是该方法的副作用。这个ScheduleFlight测试就是一个例子,说明了如何测试void方法。在我们的案例中,安排航班的副作用应该是当我们从调度器获取所有航班时,航班会出现在列表中。
现在,让我们看看航班移除测试,它使用了相同的方法:
[Fact]
public void RemoveShouldRemoveFlight() {
// Arrange
FlightScheduler scheduler = new();
PassengerFlightInfo flight = CreateFlight("CS2024");
scheduler.ScheduleFlight(flight);
// Act
scheduler.RemoveFlight(flight);
// Assert
IEnumerable<IFlightInfo> result =
scheduler.GetAllFlights();
Assert.NotNull(result);
Assert.DoesNotContain(flight, result);
}
这个方法专注于安排航班然后移除它,并验证航班不再出现在航班列表中。如果添加和移除航班没有从航班列表中移除它,那将是一个错误,测试将失败。
在测试类之间共享方法
如果你发现许多测试类都会从相同的“辅助”方法中受益,例如CreateFlight,你可能想要考虑将这些辅助方法移动到测试项目的静态类中。这种模式有时被称为ObjectMother或 Builder 模式,在进一步阅读部分有更详细的描述。
或者,你可以引入一个基测试类,将你的共享方法移动到那个类中,然后让测试从那个类继承。测试类和测试项目就像正常代码一样,本书第一部分中使用的许多重构技巧也可以帮助你改进测试。
在我们结束本章关于采用测试思维的讨论之前,让我们简要地看看另一对流行的 C#测试框架。
探索其他测试框架
除了 xUnit 之外,最受欢迎的测试框架是NUnit和MSTest。
这两个框架与 xUnit 的操作方式非常相似,但在声明单元测试时使用的语法略有不同。
我有机会在专业和休闲方面使用所有三个主要的测试框架,我可以告诉你,这些差异在很大程度上是表面的。话虽如此,你会发现某些框架具有某些特定功能,这些功能可能不在其他框架中。
使用 NUnit 进行测试
在这三个测试框架中,NUnit 的语法是我最喜欢的,因为它使用 Test 名称来表示既不需要参数的单元测试(相当于 xUnit 的 Fact),也需要参数的单元测试(相当于 xUnit 的 Theory)。
这里有一个参数化测试,用于验证 PassengerFlightInfo 上的 Load 方法:
public class PassengerFlightTests {
[TestCase(6)]
public void AddPassengerShouldAdd(int passengers) {
// Arrange
PassengerFlightInfo flight = new();
// Act
flight.Load(passengers);
// Assert
int actual = flight.Passengers;
Assert.AreEqual(passengers, actual);
Assert.That(actual, Is.EqualTo(passengers));
}
}
在 NUnit 中,Test 和 TestCase 替代了 Theory 和 InlineData。如果这个测试不是参数化的,TestCase 将变为 Test。
这个测试的断言部分有一点不同。首先要注意的是,NUnit 的断言方法是 Assert.AreEqual 而不是 Assert.Equal。虽然这只是细微的差别,但我发现代码读起来更好。
在 Assert.AreEqual 行下面是 Assert.That 行。这是 NUnit 的较新的单元测试约束模型;它读起来更流畅,并减少了在断言中混淆预期值和实际值等参数的可能性。两种编写 NUnit 测试的方式都是有效的,并且运行良好。
最后一点需要注意:在 NUnit 中,测试类中的所有测试共享同一个类实例。这意味着存储在测试的字段或属性中的值将被该测试类中的所有测试共享。这与为每个运行的测试创建新测试类实例的 xUnit 不同。
在探索了 NUnit 之后,让我们来看看 MSTest。
使用 MSTest 进行测试
MSTest 的官方名称是 Visual Studio 单元测试框架,但该框架在社区中以及微软内部文档中都被广泛称为 MSTest。
MSTest V2
由于 MSTest 与 NUnit 和 xUnit 之间缺乏功能一致性,MSTest 几乎十年来的声誉都很差。但在 2016 年,微软对 MSTest 进行了修订,称之为 MSTest V2,并将许多改进引入到框架中,使其现在与竞争对手处于同一水平。
与 NUnit 一样,MSTest 使用单个 TestMethod 属性来标记参数化和非参数化的单元测试。然而,与 NUnit 和 xUnit 不同,MSTest 还需要在类本身上使用 TestClass 属性来使单个测试可发现。这是在编写 MSTest 测试时需要注意的事情,因为它是你可能会错过以使测试不在测试运行器中显示的另一个因素。
让我们看看 MSTest 中一个示例参数化测试,该测试验证了 BoardingProcessor 类中的 Passenger 类的 FullName 属性:
[TestClass]
public class PassengerTests {
[TestMethod]
[DataRow("Calvin", "Allen", "Calvin Allen")]
[DataRow("Matthew", "Groves", "Matthew Groves")]
[DataRow("Sam", "Gomez", "Sam Gomez")]
[DataRow("Brad", "Knowles", "Brad Knowles")]
public void PassengerNameShouldBeCorrect(string first,
string last, string expected) {
// Arrange
Passenger passenger = new() {
FirstName = first,
LastName = last,
};
// Act
string fullName = passenger.FullName;
// Assert
Assert.AreEqual(expected, fullName);
}
}
在这里,这个参数化测试评估了本书每位技术审查者的姓名,从 DataRow 中获取,就像 xUnit 中的 InlineData 或 NUnit 中的 TestCase 一样。
虽然 MSTest 的语法不同,但它与其他测试框架之间有许多相似之处。
MSTest 与 NUnit 之间的主要区别在于包含TestClass属性以及分别使用TestMethod和DataRow名称代替Test和TestCase。甚至在两个框架中Assert.AreEqual方法的命名也是相同的。
最终,这三个测试框架都非常相似,并在实现高质量软件的目标中发挥着强大的作用。我发现我可以在这三个框架中的任何一个框架中有效地工作。虽然我倾向于更喜欢 NUnit 的语法,但我使用 xUnit 来处理新项目,因为 xUnit 在很大程度上已经成为社区标准。
我的建议是选择你最喜欢的语法库,并将其用于你的项目,并将你的精力集中在编写良好的测试和采取测试心态上。
采取测试心态
让我们退一步,谈谈为什么一本关于重构的书会围绕测试展开整整一系列章节。原因是需要重构的代码通常更加易变,在更改时更容易出错。由于重构的艺术在于在不改变软件行为的情况下改变其形式,因此在重构时引入错误是不受欢迎且不可接受的。
这就是测试发挥作用的地方。测试为你和你团队提供所需的信心,以便能够改进代码。你的遗留代码可能已经或尚未包含测试,因此在进行任何测试工作之前,确保存在良好测试的责任和必要性落在你的身上。
这需要你采取测试心态。这个短语指的是在开发过程的开始就将测试视为软件开发和重构的一个关键组成部分,而不是事后考虑。
尽管我们将在下一章详细探讨这个概念,当我们讨论测试驱动开发时,让我们简要提及一些有助于你在组织中成功进行测试并采取测试心态的考虑因素。
将测试融入工作流程
测试应该是软件工程师日常生活中的一个标准部分。
这意味着每当你对系统进行任何更改时,都应该考虑进行测试,无论这些更改是新增功能、修复错误还是通过重构来偿还技术债务。
这需要从将测试视为繁琐或应该做的事情的心态转变为将测试视为对代码库甚至对整个组织具有内在价值的思维。这是因为测试通过充当代码库的“活文档”、提供对未来某些类型错误的防护网以及增强你和你团队对所编写代码的信心来提供价值。
当然,你可能会遇到一些测试起来非常困难的软件片段。这些可能是与用户界面一起工作的代码片段,或者可能是与其他系统有非常强依赖关系的代码片段。
我们将在本节稍后和第八章和第九章中再次讨论依赖关系,但通常使用专门的工具和库进行用户界面测试,并且根据你是在测试 Web、桌面还是移动应用程序而有所不同。因此,用户界面测试超出了本书的范围。然而,隔离依赖通常是这个过程的一个重要部分。
隔离依赖
当我们谈论隔离依赖时,这意味着当我们测试一段代码时,测试它不应该改变其他任何东西。
例如,当我们试图验证安排航班会将航班添加到系统中的航班列表时,我们不希望每次运行我们的单元测试时系统都发送带有航班确认的电子邮件!
这样的例子可能看起来像这样:
public class FlightScheduler {
private readonly EmailClient _email = new();
public void ScheduleFlight(Flight flight) {
// other logic omitted...
_email.SendMessage($"Flight {flight.Id} confirmed");
}
}
在这里,FlightScheduler有一个EmailClient类,每次安排航班时都会在客户端上调用SendMessage。这是FlightScheduler对EmailClient类的强依赖,会导致在测试此代码时发送电子邮件的不希望出现的副作用。
发送电子邮件或与文件系统或数据库交互等副作用通常在单元测试中是不希望的,我们将在稍后讨论。
虽然让系统能够执行这些操作是好事,但我们希望在不产生我们不喜欢的副作用的情况下,单独测试我们的代码单元。我们可以通过一种称为依赖注入的过程来解决这个问题,在这个过程中,一个类不再负责创建它需要的依赖,而是从其他地方获取它们。
FlightScheduler的一个更可测试的版本可能看起来像这样:
public class FlightScheduler {
private readonly IEmailClient _email;
public FlightScheduler(IEmailClient email) {
_email = email;
}
public void ScheduleFlight(Flight flight) {
// other logic omitted...
_email.SendMessage($"Flight {flight.Id} confirmed");
}
}
在这里,对EmailClient类的依赖是通过构造函数注入到这个类中的,并使用一个新的IEmailClient接口,这样我们就可以为测试使用这个接口的不同实现。这个针对测试的版本不会产生发送电子邮件的负面副作用,使其更可接受。
依赖注入及其相关术语,控制反转和依赖倒置,是复杂的话题,需要一些时间来掌握。因此,我们将在第八章中重新讨论它们,使用 SOLID 避免代码反模式。此外,经验丰富的测试人员可能会大声疾呼,一个模拟框架,如 Moq 或 NSubstitute,可以帮助解决一些这些问题。我们将在第七章中介绍这些库。
现在,让我们继续讨论构成良好和不良测试的其他因素。
评估良好和不良测试
良好的单元测试应该是这样的:
-
运行速度快:如果测试需要几分钟才能运行,开发者就不会运行它们。
-
可靠和可重复:测试不应该随机失败或通过或失败,这取决于星期几、一天中的时间或之前运行了哪些其他测试。
-
相互独立:一个测试永远不应该影响另一个测试的通过或失败,并且测试不需要按特定顺序运行。
-
隔离性:它们应该与数据库、磁盘上的文件、云资源或外部 API 等依赖项保持独立。这些事情不仅会减慢你的测试速度,而且如果我们正在测试这些交互,那么那是一个集成测试,而不是单元测试。
-
可读性:测试作为如何与你的类交互的示例。此外,当测试失败时,其失败应该容易理解。
-
便携性:测试不应该需要显著的机器设置,并且应该在任何开发者的机器上或在持续集成/持续交付(CI/CD)管道中的另一台机器上可运行。
相比之下,糟糕的测试需要时间来运行,是“不可靠的”并且随机失败,不能并行运行或按顺序运行,在理解它们测试的内容或为什么测试它们方面很困难,并且需要大量手动配置才能可靠地运行。
通常,你希望优先考虑许多小型单元测试,这些测试运行速度快、易于理解且可靠,而不是更雄心勃勃的测试,这些测试一次测试太多东西,导致测试缓慢,且测试失败不明确、不可靠。
关于代码覆盖率
我不能不介绍代码覆盖率就谈论单元测试。代码覆盖率是作为任何单元测试一部分运行的代码行。如果一个测试导致代码行运行,它被认为是已覆盖的;否则,它被认为是未覆盖的。
几种工具可以计算代码覆盖率,包括 Visual Studio Enterprise 和 JetBrains ReSharper,我们简要讨论了第二章。如果你有 Visual Studio Enterprise,你可以通过选择测试菜单然后分析所有测试的代码覆盖率来计算代码覆盖率。这将显示被单元测试覆盖和未覆盖的代码行,如图图 6.9所示:

图 6.9 – Visual Studio Enterprise 中代码覆盖率结果的概述
这些覆盖率结果将突出显示任何未由单元测试覆盖的行,例如PassengerFlightInfo中的Unload方法的代码,如图图 6.10所示:

图 6.10 – 覆盖的行以蓝色突出显示,而未测试的行以红色突出显示(行 14)
代码覆盖率是那些可能引起分歧的话题之一。一方面,代码覆盖率为你提供了一个指标,显示了你的代码有多少部分被任何测试执行。这为你提供了一个有意义的衡量单元测试安全网范围的方法。
然而,代码覆盖率可能会产生误导。仅仅运行一行代码并不意味着该行的效果已被单元测试验证。这可能导致您对单元测试产生错误的安全感。
此外,当组织优先考虑提高代码覆盖率百分比或要求新工作必须达到一定最低代码覆盖率的工作时,这可能导致测试集中在软件系统风险较低的部分。例如,您需要编写一个单元测试来验证当将 null 值传递给方法时抛出 ArgumentNullException 错误的代码,还是您的时间花在其他地方更好?
通常,应用程序中最关键的区域可能已经出现在代码覆盖率指标中,但没有测试验证这些行是否正确工作。
我个人的感觉是,代码覆盖率是许多有用的指标之一,但不应被用来显著影响开发团队的行为。
请参阅 进一步阅读 部分,了解更多关于代码覆盖率和如何开始计算它的信息。
我们将在 第十二章 中探索其他指标,Visual Studio 中的代码分析,但在此,让我们以一些关于单元测试的总结来结束本章。
摘要
单元测试是验证重构代码不会引入错误、记录类文档以及防止未来出现错误的有效方式。
单元测试是测试其他代码的代码。在 .NET 中,项目单元测试通常使用 xUnit、NUnit 或 MSTest 来执行。每个测试框架都提供断言,用于验证代码是否正确行为,或者在实际值与预期值不匹配时失败测试。
当我们编写单元测试时,我们通常按照 arrange/act/assert 模式来组织我们的测试,在 arrange 步骤中设置要测试的对象,在 act 步骤中执行单个操作,并在 assert 步骤中验证操作结果的正确性。
在下一章中,我们将通过测试驱动开发来探索更多的测试。
问题
回答以下问题以测试您对本章知识的了解:
-
您最喜欢哪种单元测试框架的语法?
-
您的应用程序中最复杂的部分是什么?它们被测试了吗?
-
您会如何测试一个计算申请者信用评分的方法?
-
您如何测试一个
void方法? -
您可以做些什么来帮助测试代码保持整洁和可读性?
进一步阅读
您可以通过查看以下资源来找到有关本章讨论的材料更多信息:
-
Visual Studio Test Explorer:
learn.microsoft.com/en-us/visualstudio/test/run-unit-tests-with-test-explorer -
xUnit:
xunit.net/ -
NUnit:
nunit.org/ -
MSTest:
learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-mstest
第七章:测试驱动开发
让我们通过深入探讨测试驱动开发来继续我们的讨论,以确保我们软件过程的质量。
虽然这是一本关于重构和测试驱动开发的书籍,主要目的是为了未来的开发和修复 bug,但它有一些关键的教训要教给我们关于软件质量,并且 Visual Studio 提供的支持测试驱动开发的工具在重构过程中也能极大地帮助。
在本章中,我们将涵盖以下主要主题:
-
什么是测试驱动开发?
-
使用 Visual Studio 进行测试驱动开发
-
何时使用测试驱动开发
技术要求
本章的起始代码可以从 GitHub 的github.com/PacktPublishing/Refactoring-with-CSharp在Chapter07/Ch7BeginningCode文件夹中获取。
什么是测试驱动开发?
测试驱动开发(TDD)是指在编写新功能或实施新修复的代码之前,先编写测试的过程。
在 TDD 下,你首先为你要实现的功能或为重现你即将修复的 bug 编写一个测试。你以最理想的方式做这件事,这甚至可能涉及到测试开始时还不存在的类或方法。
接下来,你进行最小的工作量,使你的代码能够成功编译。这并不是说它运行得完美或者完成了它试图做的事情,实际上,你试图从一个红色的失败测试开始,这表明你的功能或修复不起作用。
考虑到这一点,你还没有实现新功能或对代码进行修复。所以,测试应该是一个失败的测试。
接下来,你编写最小的代码,使你的测试通过。在这个步骤中,你正在做你需要做的事情来满足你试图解决的具体要求。一旦完成,你的测试应该变成绿色的通过测试。
之后,你重构你添加的代码以实现你的功能或修复,同时也要重构你的测试代码;注意继续运行你的单元测试,以确保你没有破坏任何东西。
一旦你对你的新代码和测试的状态感到满意,你就可以查看当前工作项上的下一个要求,为那个要求编写一个测试,然后重复这个过程,直到你满足了所有要求。这个过程在图 7.1中得到了说明:

图 7.1 – 测试驱动开发周期
因为你是从一个失败的红色测试开始的,然后过渡到绿色的通过测试,然后在你开始新的要求之前重构你的代码,所以 TDD 有时被称为红/绿/ 重构。
这个过程有几个关键的好处:
-
你可以通过从测试开始来确信你的代码解决了问题。
-
以这种方式编写的代码保证会被你的测试覆盖。
-
当你从其他人如何调用你的代码开始时,往往会引导出更直观的类设计,供其他人以后使用。
这个过程及其结果,通过一个实际例子会更有意义。所以,让我们跳入一些代码,为 Cloudy Skies Airlines 实现一个新功能。
使用 Visual Studio 进行测试驱动开发
我们将以一个几乎空的控制台项目和已经链接到主项目的支持 xUnit 测试项目开始这一章,如图 第六章 所示。这个项目的结构可以在 图 7**.2 中看到:

图 7.2 – 仅显示几个文件的解决方案资源管理器
在本节剩余的部分,我们将为 Cloudy Skies Airlines 添加一个新的类来跟踪常旅客里程。
我们将要解决的要求(按顺序)是:
-
当创建一个新的常旅客账户时,它应该从 100 英里起始余额开始。
-
你应该能够向常旅客账户添加里程。
-
只要这不会导致余额为负,你应该能够标记里程为已兑换。
这些要求并不复杂,但它们应该作为简要探索 TDD 的起点。
我们将从起始余额要求开始。
设置起始余额
我们的第一个要求涉及账户从已经注册的 100 英里开始。
在 TDD 的指导下,我们应该从一个失败的测试开始。幸运的是,我们已经有了一个 MilesTrackerTests.cs 文件,这为我们提供了一个良好的起点。
然而,我们在 Chapter7 项目中没有类来表示里程追踪器,这在我们编写第一个测试的安排部分时给我们带来了问题。
虽然现在创建类可能会有点“作弊”的诱惑,但让我们遵循严格的 TDD 方法,并按照我们希望与类交互的方式编写测试代码,知道这个类还不存在,这将在不久的将来给我们带来一些编译错误。
这样的测试可能看起来像这样:
[Fact]
public void NewAccountShouldHaveStartingBalance() {
// Arrange
int expectedMiles = 100;
// Act
MileageTracker tracker = new();
// Assert
Assert.Equal(expectedMiles, tracker.Balance);
}
这个测试设置了一个预期的起始里程变量,尝试实例化一个 MileageTracker,然后断言这个新追踪器的 Balance 属性应该是预期的金额。
这是一个简单、简洁且易于阅读的测试,但存在一些小问题:MileageTracker 和其 Balance 属性在我们的代码中尚不存在,这意味着我们的代码无法编译。
生成类
在 TDD 编码时创建新类和新属性时出现的编译问题是很正常的,也是可以预料的。幸运的是,Visual Studio 为我们提供了一个快速操作重构功能。
在你的操作部分选择 MileageTracker 并打开快速操作菜单。从那里注意各种选项来生成此类型,如图 图 7**.2 所示:

图 7.3 – 快速操作以生成新的类型
这些选项,如图所示,非常好,但其中大多数都会在测试项目中创建新的类,这不是我们想要的。由于我们想要自定义正在创建的新类型,请选择 生成 新类型…
这将打开 生成类型 对话框,允许您选择新生成类型的类型、名称和位置。将 项目 更改为 Chapter7 并选择创建一个新文件,如图 图 7.4 所示:

图 7.4 – 在 Chapter7 项目中生成新的类
接下来,点击 MileageTracker.cs 文件到主项目。
目前这个类里面什么都没有,很无聊,但当我们处理下一个编译错误时,我们会很快添加内容。
生成成员
回到我们的测试中,现在 操作 部分没有问题,但我们仍然在 断言 部分对 Balance 的引用上有编译错误,如图 图 7.5 所示:

图 7.5 – C# 编译器指出 MileageTracker 有 Balance 属性
幸运的是,Visual Studio 给我们提供了生成属性的工具。现在让我们这样做,这样我们的代码至少可以编译。
选择 Balance,然后打开 快速操作 菜单,选择 生成属性‘Balance’,如图 图 7.6 所示:

图 7.6 – 生成新的属性
这样做会导致 Balance 被定义。如果您按住 Ctrl 并单击 Balance,它将带您导航到 MileageTracker.cs,我们将看到类是如何定义的:
public class MileageTracker {
public IEnumerable<object> Balance { get; set; }
}
在这里,Visual Studio 必须猜测 Balance 属性的类型,并且猜测得非常糟糕。由于这否则会导致编译错误,将 Balance 改为 int:
public class MileageTracker {
public int Balance { get; set; }
}
随着这个更改,代码现在应该可以编译,但在我们运行测试之前,让我们再做一个更改。
记住,TDD 要求我们编写最少的代码来完成我们想要做的事情吗?技术上讲,Visual Studio 通过为我们的 Balance 属性生成获取器和设置器违反了这一原则。在这个测试中,我们只需要获取 Balance,而不需要通过这个属性来设置它。所以,让我们通过移除设置器来保护这个 Balance:
public class MileageTracker {
public int Balance { get; }
}
在有了这段额外的封装并代码编译无误后,让我们运行我们的测试。当你这样做时,你应该会看到测试失败,指出它期望 Balance 为 100,但实际上是 0,如图 图 7.7 所示:

图 7.7 – 我们的第一个失败的测试
在 TDD 下,这正是我们想要的。我们做了最少的努力来得到一个理想的测试编译,而这个测试失败了,因为我们还没有完全实现这个功能。
从红色变为绿色,并进行重构
让我们现在实现这个功能。
虽然我们知道我们的 MileageTracker 将需要一些额外的功能,但让我们通过编写尽可能少的代码来实现这个功能:
public class MileageTracker {
public int Balance { get; } = 100;
}
现在默认将新的 MileageTracker 实例的起始余额设置为 100,这符合我们的需求,并在重新运行测试时使测试变为绿色并通过。
在绿色测试之后,我们现在寻找重构的机会。虽然我们的测试代码是最小的,但 MileageTracker 中确实有一个 魔法数字。魔法数字是代表某种未记录的业务或技术需求的 代码异味。
让我们通过引入一个常量来修复它:
public class MileageTracker {
private const int SignUpBonus = 100;
public int Balance { get; } = SignUpBonus;
}
这段代码现在更容易被其他人理解,消除了代码异味。
命名
在软件工程中给事物命名是困难的。可能您对这个类或我引入的 SignUpBonus const 的名字与我选择的名称不同。这没关系。一个名字最重要的地方是它能向其他开发者传达意图,并且不会与系统中的其他事物混淆。虽然 StartingBalance 这个名字对我这个常量来说已经足够好了,但我选择了 SignUpBonus,因为它更清楚地记录了起始余额的业务案例。
再次运行测试后,结果再次是一个绿色的通过测试,并且没有其他明显的重构目标,所以我们继续到下一个需求。
添加里程和生成方法
我们下一个需求是 您应该能够向常旅客账户添加里程。
让我们回到我们的测试中,为这个需求添加一个新的测试。这里我们再次选择最直观的语法,然后稍后使代码编译并通过测试:
[Fact]
public void AddMileageShouldIncreaseBalance() {
// Arrange
MileageTracker tracker = new();
// Act
tracker.AddMiles(50);
// Assert
Assert.Equal(150, tracker.Balance);
}
这个测试实例化了一个 MileageTracker,然后尝试使用尚未创建的 AddMiles 方法添加 50 英里,并在验证余额为 150(100 英里起始里程加上我们刚刚添加的 50 英里)之前进行。
当然,在 MileageTracker 中没有 AddMiles 方法。让我们通过选择 AddMiles 并从 快速操作 菜单中选择 生成方法AddMiles 来添加一个,如图 图 7.8 所示:

图 7.8 – 添加新方法
添加此方法会导致它以以下实现被创建:
public void AddMiles(int v) {
throw new NotImplementedException();
}
显然,这不是该方法应该做的事情。然而,让我们遵循严格的 TDD,一步一步地完成这些动作。
由于我们的代码现在可以编译,我们可以运行测试并验证它是否按预期失败。
一旦我们确信我们有一个可以检测到我们编写的失败代码的测试,我们就只编写使测试通过所需的最少代码。这确保了我们的测试足以在以后找到代码的实际问题。
AddMiles 的一个通过实现可能看起来像这样:
public class MileageTracker {
private const int SignUpBonus = 100;
public int Balance { get; set; } = SignUpBonus;
public void AddMiles(int miles) {
Balance += miles;
}
}
}
如您所见,代码现在可以编译,并导致绿色测试。这意味着我们应该继续根据需要重构我们的代码。
测试代码仍然干净,我看到的唯一重构可能是使用我们在第四章中介绍的表达式-bodied 成员。然而,我将保留代码的当前形式,因为类仍然非常简单。
那个需求完成之后,让我们继续到最后一个关于兑换里程的需求。
兑换里程和重构测试
我们最终的需求是你应该能够在不会导致负余额的情况下标记里程数为已兑换。这个需求比上一个需求复杂一些,因为它附带了一个条件。
如同之前,让我们先编写一个测试用例:
[Fact]
public void RedeemMileageShouldDecreaseBalance() {
// Arrange
MileageTracker tracker = new();
tracker.AddMiles(900);
// Act
tracker.RedeemMiles(250);
// Assert
Assert.Equal(750, tracker.Balance);
}
这个测试应该看起来非常类似于我们之前的AddMiles测试,除了它调用了一个新的RedeemMiles方法。
让我们使用前面展示的生成方法重构来生成那个空的RedeemMiles方法,并允许代码编译。
这应该会导致如图 7.9所示的红色失败测试,因为该方法中默认的throw new NotImplementedException行:

图 7.9 – 移除里程测试因抛出异常而失败
然而,通过镜像我们对AddMiles所做的操作,从红色变为绿色在这里又是微不足道的:
public class MileageTracker {
private const int SignUpBonus = 100;
public int Balance { get; set; } = SignUpBonus;
public void AddMiles(int miles) {
Balance += miles;
}
public void RedeemMiles(int miles) {
Balance -= miles;
}
}
}
这样我们的测试就通过了,因此我们继续寻找重构选项。这段代码并不差,所以我们继续寻找下一个需求。
在这种情况下,我们没有完全满足我们试图解决的问题的需求,因为我们没有涵盖尝试提取比账户中更多的里程数的情况。让我们为那种情况编写一个新的测试用例:
[Fact]
public void RedeemMileageShouldPreventNegativeBalance() {
// Arrange
MileageTracker tracker = new();
int startingBalance = tracker.Balance;
// Act
tracker.RedeemMiles(2500);
// Assert
Assert.Equal(startingBalance, tracker.Balance);
}
此测试创建一个账户并记录其初始余额。然后,测试尝试提取比账户初始余额更多的里程数,并验证最终余额是否等于初始余额。
这不依赖于跟踪器中的任何新方法。因此,我们的代码无需更改即可编译。然而,运行此测试会导致失败,指出预期余额为 100,但实际为-2400。
拿着红色的测试用例,让我们修改RedeemMiles方法,使其测试变为绿色:
public void RedeemMiles(int miles) {
if (Balance >= miles) {
Balance -= miles;
}
}
现在,我们检查是否有足够的里程来满足请求,并且只有在该条件满足的情况下才减少里程。
再次运行测试,结果是一组全部通过测试,如图 7.10所示:

图 7.10 – 四个关于里程的通过测试
拿着通过测试用例,我们现在来看重构。由于MileageTracker简洁明了,我们将继续查看我们的测试。
关于异常怎么办?
目前,如果你请求比预期更多的里程,RedeemMiles将静默失败,这可能会让你作为开发者感到一些警觉。在现实世界的应用中,你可能希望这个方法要么返回一个布尔值来指示兑换是否成功,要么在兑换不可能时抛出一个异常。这两种情况都可以在 TDD(测试驱动开发)中作为额外的实现要求来处理,例如,“如果我们尝试兑换比可能的更多的里程,应该抛出InvalidOperationException”。
观察我们的测试,我们可以看到RemoveMileageShouldDecreaseBalance和RemoveMileageShouldPreventNegativeBalance确实做了类似的事情。
由于测试之间的重复,我们应该将这些测试合并为一个Theory,其中InlineData行代表单个测试用例。这看起来可能如下所示:
[Theory]
[InlineData(900, 250, 750)]
[InlineData(0, 2500, 100)]
public void RedeemMileageShouldResultInCorrectBalance(
int addAmount, int redeemAmount, int expectedBalance) {
// Arrange
MileageTracker tracker = new();
tracker.AddMiles(addAmount);
// Act
tracker.RedeemMiles(redeemAmount);
// Assert
Assert.Equal(expectedBalance, tracker.Balance);
}
此表单允许许多测试向余额添加一个初始金额,兑换一定数量的里程,然后验证结果是否与预期余额相符。这也让我们能够轻松地添加新场景,一旦我们识别出它们。
然而,方法的名称不如我们为单个Fact测试使用的更具体名称有意义。
通过测试和重构完成,我们现在可以继续进行这个功能中的下一个需求,或者继续处理我们队列中的下一个工作项。让我们通过讨论在项目中的高级 TDD(测试驱动开发)及其适用时机来结束这一章节。
何时使用测试驱动开发
TDD(测试驱动开发)并不总是适合每个任务。有些任务,如高度可视的用户界面设计可能不太适合 TDD(测试驱动开发)的工作流程,而其他任务,如修复在生产中观察到的错误或向计算中添加新的特殊情况,几乎非常适合 TDD(测试驱动开发)。
使用 TDD(测试驱动开发)的结果是代码通常更容易理解,测试覆盖率完美或接近完美,并且鼓励在开发过程中进行重构。
许多开发者遵循 TDD(测试驱动开发),但并不像本章所述那样严格。例如,他们可能不会仅仅生成一个方法,而是会继续实现该方法并编写额外的参数验证代码,而这些代码并不是他们特定测试所必需的。
这种对 TDD(测试驱动开发)的偏离很常见,通常也是可接受的,尽管这通常会导致添加一些没有支持测试的代码。
最终,这取决于你和你团队决定什么最适合你们的工作,但我可以告诉你,我在其中使用 TDD(测试驱动开发)的项目往往能迅速达到更高的质量水平,鼓励更多的重构,并且有更好的长期成功。
摘要
在本章中,我们介绍了测试驱动开发(TDD)并展示了其过程,即只编写最少的代码来达到一个失败的测试——用最少的代码使测试通过——然后,在继续下一个需求或工作项之前,根据需要重构所有代码。
我们也看到了 Visual Studio 中的快速操作功能,这些功能允许你生成类型、属性和方法,并支持你遵循 TDD 的努力。
在下一章中,我们将讨论可能导致代码难以维护的反模式和有助于使你的代码健壮且易于维护的 SOLID 原则。
问题
-
你代码的哪些领域适合使用 TDD?
-
哪些领域可能更难应用 TDD?
进一步阅读
你可以在以下网址找到关于本章讨论的材料更多信息:
-
测试驱动开发(TDD)入门指南:
learn.microsoft.com/en-us/visualstudio/test/quick-start-test-driven-development-with-test-explorer -
TDD 是否已死?:
martinfowler.com/articles/is-tdd-dead/
第八章:使用 SOLID 原则避免代码反模式
正确的设计原则可以防止你的代码迅速过时。虽然编写代码有许多正确的方法,但也有一些反模式和代码异味构成了错误的编写方式。
此外,社区已经确定了一些原则,在构建软件时应牢记,这有助于你的代码尽可能长时间地抵抗技术债务的积累。在本章中,我们将介绍这些原则中的许多,包括著名的 SOLID 缩写,并探讨它们如何帮助你构建能够积极抵抗逐渐走向过时代码的软件。
在本章中,我们将介绍以下主题:
-
识别 C#代码中的反模式
-
编写 SOLID 代码
-
考虑其他架构原则
识别 C#代码中的反模式
我经常发现自己告诉新程序员,要构建好的软件,你必须首先构建大量的非常糟糕的软件,并从中学习。
虽然这个说法有些玩笑成分,但其中确实有一些真理:几乎每个开发者都能识别出编写错误的方式,并发现使其难以工作的因素,这样做有助于你在下一次编写更好的代码。
当你的代码质量不佳时,你内心通常会有所察觉。你会看到一些你不喜欢的细节:重复的代码片段、命名或参数顺序的不一致性、传递过多的参数、方法,甚至是一些太大而难以有效管理的类。
这些症状就是我们通常所说的代码异味,我们将在本节稍后重新讨论。
代码异味之外,还有一种称为反模式的东西,即与社区建议显著偏离的代码。不幸的是,并非所有反模式都容易察觉或自行发现,有些甚至在完全探索之前似乎对个人或团队来说是好主意。
我看到的一些常见的 C#反模式包括抛出和捕获一个Exception错误而不是特定类型的Exception错误,没有释放实现IDisposable接口的资源,以及效率低下的语言集成查询(LINQ)语句。关于这些反模式的更多细节,请参阅本章的进一步阅读部分。
在这本书中,要涵盖的反模式太多了,而且.NET 开发的既定实践随着时间的推移而演变。正因为这种持续的变化,Visual Studio 提供了代码分析工具,以帮助发现和修复违反社区标准的行为。这些工具包括代码分析规则集和内置的Roslyn 分析器,我们将在第十二章,Visual Studio 中的代码分析中更详细地介绍。
代码中的问题并不都是针对 C# 代码的。许多问题源于类之间的交互、相互传递数据、管理变量以及一般的结构。这些问题甚至在你开始看到系统规模随着新功能的添加而扩大时,也会出现在你打算“结构良好”的代码中。
幸运的是,即使是新开发者也天生具有识别难以遵循的代码、需要比应有的更多工作来维护和扩展的代码,或者涉及过度重复的代码的能力。这些类型的代码问题通常被称为 代码异味。
代码异味是什么?
代码异味是当前架构存在一些缺陷且需要进行重构的强烈指标。当你遇到这些症状时,包括你编写的代码,请注意这些症状。了解使代码难以工作的原因将帮助你编写更好的代码,并将现有代码重构为更好的形式。
现在,让我们继续讨论编写 SOLID 代码,这可以帮助你避免一些常见的代码异味,并构建健壮、可维护和可测试的代码。
编写 SOLID 代码
SOLID 是由迈克尔·费瑟斯(Michael Feathers)引入的一个缩写,总结了罗伯特·C·马丁(Robert C. Martin)的话。SOLID 的目的是为开发者提供一套原则,引导他们编写更易于维护且能抵抗技术债务的代码。
SOLID 代码的五个原则是:
-
单一职责 原则 (SRP)
-
开闭 原则 (OCP)
-
里氏替换 原则 (LSP)
-
接口隔离 原则 (ISP)
-
依赖倒置 原则 (DIP)
在本节中,我们将涵盖这五个原则。
单一职责原则
单一职责原则 (SRP)表示一个类应该只负责一件事情。以下是一些遵循 SRP 的类的例子:
-
负责将应用程序数据保存到特定文件格式的类
-
专门用于对数据库表或一组表执行查询的数据库访问类
-
提供 REST 方法以与飞行数据交互的 API 控制器
-
代表你应用程序特定部分的用户界面的类
类通过在同一个类中尝试做多种类型的事情来违反 SRP。更正式地说,如果修改一个类的原因超过一个,那么这个类就违反了 SRP。
例如,如果一个类负责跟踪用户界面中一组项目的状态、响应用户按钮点击、解析用户输入以及异步获取数据,那么这个类很可能违反了 SRP。
违反单一职责原则(SRP)的类往往会被频繁修改,随着时间的推移复杂性增加,并且与其他系统中的类相比,它们会变得非常大。这些类可能难以完全理解或充分测试,并且随着复杂性的增加,可能会变得脆弱和充满错误。
我用来帮助检测 SRP 违反的一种方法是添加一个类级别注释,讨论类的责任。例如,以下 XML 注释描述了本书第一部分的FlightScheduler类:
/// <summary>
/// This class is responsible for tracking information
/// about current and pending flights
/// </summary>
public class FlightScheduler {
// Details omitted
}
在这里,FlightScheduler类的责任是明确的:它存在是为了跟踪系统中的活跃和待处理的航班。修改此类的原因应与这些航班的跟踪相关,而不是与其他主题相关。
因此,每当我在定义一个新类时,我倾向于在所有类中添加类级别注释,以帮助该类在其生命周期中保持对其任务的专注。
但如果你有一个已经存在且违反 SRP(单一责任原则)的类怎么办?
当你有一个负责多项事物的类时,我喜欢查看该类目前负责的所有内容,并将它们分组到相关的成员组中。例如,如果一个类有 10 个字段,25 个方法,和 6 个属性,我可能会逐一检查它们,并试图找到那些事物共同解决的问题。
例如,如果FlightScheduler类违反了 SRP,它可能包含以下成员:
-
航班调度和取消
-
为航班分配机组人员
-
为乘客预订航班
-
更改乘客的座位分配
-
将乘客转移到不同的航班
-
为管理层生成航班调度文档
显然,这个类负责多种类型的事物。在一个生产系统中,这个类可能长达 2,000 行或更多,难以完全理解和充分测试。此外,对类的一个区域的更改可能会以意想不到的方式影响其他区域。
通过查看一个类处理的事物的组,你通常可以识别出几个关键组。我喜欢这样做,然后专注于那些与类的核心目标不明确相关的最大相关责任组。一旦确定了这些分组,你就可以提取一个新的类来管理这些方面。你的原始类可以引用这个类,或者如果需要,将其存储为字段,或者新类可以完全独立于旧类运行。
在FlightScheduler的情况下,我会说航班调度和取消是类的核心部分,而类中目前的其他方面可能更适合放在别处。查看那些其他区域,有几个与为乘客管理航班预订相关的事物,因此在这种情况下,可能需要引入一个FlightBookingManager类来包含这些相关的逻辑片段。
通过迭代地引入与类核心责任无关的新类,你可以将大型类缩小到可管理的规模,并抵抗那些在忽略 SRP(单一责任原则)的类中发现的复杂性、质量和可测试性问题。
单一职责原则不仅适用于类,也适用于方法。一个方法应该有一个它负责的单个核心任务,并且这个目的应该通过方法名来传达。当一个方法负责多个事物或开始变得太大时,这可能是一个很好的迹象,表明你可能需要提取一个方法并将一些逻辑从原始方法中拉出来,以保持方法的大小可维护。
个人而言,如果我能向年轻时的自己——或者大多数早期/中级开发者传授一个编程原则,那将是单一职责原则(SRP)在保持代码易于理解、测试、扩展和维护方面的重要性。
我个人的指导原则是努力使类不超过 200 行代码,方法不超过 20 行代码,但这都是挑战,根据你维护的代码的性质,这些指导原则可能会有例外——记住,这些是原则和指导原则,而不是严格规则或戒律。
如果你只记得 SOLID 原则中的一个,那就记住单一职责原则(SRP);它对你的应用程序的健康至关重要。 然而,还有四个原则需要探索。
开放封闭原则
当类开放于扩展但封闭于修改时,我们说它们遵循开放封闭原则(OCP)。
这个原则最初是为 C++模块编写的,它不像其他 SOLID 原则那样干净地转换为 C#,但这本质上是一个关于在设计类时遵循面向对象编程(OOP)原则的原则。
如果你构建了遵循开放封闭原则的东西,你就是在设计一个类,使其行为可以通过继承它的其他类、可定制的属性或参数,或者通过组合(将你的类组合成其他对象,这些对象会改变其行为)来扩展。
在第五章中,我们讨论了使用组合的例子:面向对象重构,并涉及到为航班提供不同的货物项目。
本节剩余部分将专注于使用继承来实现开放封闭原则。
在 C#中,默认情况下方法不允许被重写。这意味着你需要明确选择允许他人通过将它们声明为virtual来重写你的方法。
反驳观点
我听说一些开发者认为,在没有类重写的情况下将方法声明为virtual会让人困惑,给代码添加了不必要的关键字,甚至略微损害了代码在运行时的性能。所有这些事情都可能成立,但如果你处于一个无法预测他人如何使用你的代码,并且你知道他们无法修改你的源代码的场景中,将关键方法标记为virtual通常是一个好主意。在这些场景中,virtual提供了额外的灵活性。
记住,SOLID 原则是在构建软件时需要记住的指导原则,而不是你需要始终遵循的严格规则。
作为 OCP 的一个具体示例,让我们看看一个代表乘客通过 Cloudy Skies Airlines 旅行的航班行程信息的ItineraryManager类样本:
public class ItineraryManager {
public int MilesAccumulated {get; private set;}
public FlightInfo? Flight {get; private set;}
public virtual void FlightCompleted(FlightInfo? next) {
if (Flight != null) {
AccumulateMiles(Flight.Miles);
}
Flight = next;
}
public virtual void ChangeFlight(FlightInfo newFlight,
bool isInvoluntary) =>
Flight = newFlight;
public void AccumulateMiles(int miles) =>
MilesAccumulated += miles;
}
在这里,我们有一个跟踪乘客累积的总里程以及乘客计划乘坐的下一航班(当他们的旅行完成时,这可能是null)的类。该类有两个与处理完成的航班以及取消的航班相关的virtual方法。此外,该类还有一个非virtual方法AccumulateMiles,它更新乘客在本次旅行中累积的里程。
虽然这个类满足了航空公司的需求,但假设航空公司想引入一种新的逻辑来奖励客户,每当客户完成一次航班时,他们可以获得 100 额外里程,并且当乘客被强制转移到新的航班时,奖励该航班的预定里程。
在 OCP 的指导下,如果我们假设类是可修改的,我们应该能够这样做而不必修改我们的基类。结果是我们可以用以下RewardsItineraryManager类来实现这一点:
public class RewardsItineraryManager : ItineraryManager {
private const int BonusMilesPerFlight = 100;
public override void FlightCompleted(FlightInfo? next) {
base.FlightCompleted(next);
AccumulateMiles(BonusMilesPerFlight);
}
public override void ChangeFlight(FlightInfo newFlight, bool isInvoluntary) {
if (isInvoluntary && Flight != null) {
AccumulateMiles(Flight.Miles);
}
base.ChangeFlight(newFlight, isInvoluntary);
}
}
在不修改我们的基类的情况下,我们可以通过我们的新类扩展ItineraryManager的实现,这个新类遵循略微不同的逻辑。多态的神奇之处在于,我们可以在接受ItineraryManager类的地方使用RewardsItineraryManager类,进一步支持 OCP 的封闭性原则。
Liskov 替换原则
Liskov 替换原则(LSP)表示,多态代码不应该需要知道它正在处理的具体类型的对象。
这仍然是一个相当模糊的描述,所以让我们再次看看之前的FlightCompleted方法:
public virtual void FlightCompleted(FlightInfo? next) {
if (Flight != null) {
AccumulateMiles(Flight.Miles);
}
Flight = next;
}
此方法接收一个航班并将其存储在Flight属性中。如果之前在该Flight属性中存储了航班,代码将调用该航班的Miles属性调用AccumulateMiles方法。
应用程序有多个从FlightInfo继承的类:PassengerFlightInfo和CargoFlightInfo。这意味着我们的next参数可能是这三个类中的任何一个——或者是从它们继承的任何其他类。
LSP 表示,任何有效的FlightInfo实例在调用其Miles属性(或任何其他方法)时都不应该出错。例如,这个版本的CargoFlightInfo会违反 LSP,因为当调用其Miles属性时会出错:
public class CargoFlightInfo : FlightInfo {
public decimal TonsOfCargo { get; set; }
public override int RewardMiles =>
throw new NotSupportedException();
}
实质上,遵循 LSP 时,方法不应该有任何理由需要知道它正在处理FlightInfo的哪个子类。
因为 LSP 关注多态性,所以它适用于.NET 代码中的类继承和接口实现。
说到接口,让我们继续讨论 ISP。
接口隔离原则
接口分离原则(ISP)是一种优雅的说法,意味着你应该优先选择许多专注于相关功能的小型专业接口,而不是一个包含你类所做所有事情的庞大接口。
例如,假设我们有一个FlightRepository类,该类管理对单个航班的数据库访问。在许多系统中,这个类可能实现了一个IFlightRepository接口,该接口可以定义如下,其中类中的所有公共成员都是接口的一部分:
public interface IFlightRepository {
FlightInfo AddFlight(FlightInfo flight);
FlightInfo UpdateFlight(FlightInfo flight);
void CancelFlight(FlightInfo flight);
FlightInfo? FindFlight(string id);
IEnumerable<FlightInfo> GetActiveFlights();
IEnumerable<FlightInfo> GetPendingFlights();
IEnumerable<FlightInfo> GetCompletedFlights();
}
如您所见,这管理了与航班相关的常见操作,并提供了一些查找许多航班信息的方式。在一个更真实世界的例子中,可能需要多年内添加许多额外的功能来支持新特性。
在我的.NET 代码经验中,每个主要类通常都有一个包含该类所有公共方法的庞大接口。这个接口通常以它所基于的类命名,并且主要存在是为了通过依赖注入(DI)来支持可测试性,我们将在下一章中涉及。
然而,这种方法通常违反了 ISP。因为我们的接口是围绕类而不是离散的功能集设计的,所以引入一个满足某些但不是所有这些功能的新类变得更加困难。
例如,假设 Cloudy Skies Airlines 想要与另一家子公司航空公司的系统集成。它不需要添加、更新或删除航班,但它确实想要一种搜索航班的方式。在IFlightRepository接口下,AddFlight、UpdateFlight和CancelFlight方法可能需要什么也不做,或者在调用时抛出NotSupportedException错误。顺便说一句,在更大的接口中抛出不支持的方法调用异常,将违反之前提到的 LSP。
与每个主要类型对应一个庞大接口的做法不同,ISP 提倡为紧密相关的功能使用小型接口。在FlightRepository的例子中,它本质上做两件事:
-
添加、编辑和删除航班
-
搜索现有航班
如果我们想要引入接口,我们可以为这些相关的独立功能集引入接口,如下所示:
public interface IFlightUpdater {
FlightInfo AddFlight(FlightInfo flight);
FlightInfo UpdateFlight(FlightInfo flight);
void CancelFlight(FlightInfo flight);
}
public interface IFlightProvider {
FlightInfo? FindFlight(string id);
IEnumerable<FlightInfo> GetActiveFlights();
IEnumerable<FlightInfo> GetPendingFlights();
IEnumerable<FlightInfo> GetCompletedFlights();
}
在这个例子中,我们的FlightRepository类将实现IFlightUpdater接口和IFlightProvider接口。如果我们想要与另一家航空公司的系统集成,但没有修改其航班的能力,那么IFlightProvider接口可以不实现IFlightUpdater接口。
通过将我们的接口分割成表示不同功能集的小接口,我们使得提供这些功能的替代实现以及之后测试我们的代码变得更加容易。
我们已经几次提到了依赖注入(DI);让我们通过涵盖 DIP 来更详细地探讨这个主题,并完善我们的 SOLID 原则。
依赖倒置原则
依赖倒置原则(DIP)指出,你的代码通常应该依赖于抽象而不是依赖于具体的实现。
为了说明这一点,让我们看看一个FlightBookingManager类,它帮助乘客预订航班。这个类需要注册预订请求并发送预订确认消息。这是它的当前代码:
public class FlightBookingManager {
private readonly SpecificMailClient _email;
public FlightBookingManager(string connectionString) {
_email = new SpecificMailClient(connectionString);
}
public bool BookFlight(Passenger passenger,
PassengerFlightInfo flight, string seat) {
if (!flight.IsSeatAvailable(seat)) {
return false;
}
flight.AssignSeat(passenger, seat);
string message = "Your seat is confirmed";
_email.SendMessage(passenger.Email, message);
return true;
}
}
这段代码允许乘客通过检查座位是否可用,然后预订该座位并使用_email字段发送消息来预订航班。这个字段在构造函数中设置为SpecificMailClient的新实例,这是一个虚构的类,代表电子邮件客户端的一些非常具体的实现。构造函数需要获取连接字符串来实例化这个类。
这段代码违反了依赖倒置原则(DIP),因为我们的FlightBookingManager类与一个特定的电子邮件客户端紧密耦合。如果我们想对这个类编写单元测试,这个类总是会尝试向那个电子邮件客户端发送消息,这在测试时通常不是你想要的。
此外,如果组织想要更换电子邮件提供商,你需要切换到不同的电子邮件客户端,FlightBookingManager类将需要随着系统中任何其他与SpecificMailClient类紧密耦合的地方一起改变。
依赖倒置通过让我们的类依赖它们所依赖的具体事物的抽象来实现这一点。这通常是通过依赖一个基类,如EmailClientBase,然后继承它,或者通过接受一个接口,如IEmailClient,具体客户端可以实现它。
我们通常将这些依赖作为构造函数参数接受。我们FlightBookingManager类的这个版本看起来会是这样:
public class FlightBookingManager {
private readonly IEmailClient _email;
public FlightBookingManager(IEmailClient email) {
_email = email;
}
public bool BookFlight(Passenger passenger,
PassengerFlightInfo flight, string seat) {
if (!flight.IsSeatAvailable(seat)) {
return false;
}
flight.AssignSeat(passenger, seat);
string message = "Your seat is confirmed";
_email.SendMessage(passenger.Email, message);
return true;
}
}
在这里,我们不再接受连接字符串,而是接受一个IEmailClient类。这意味着我们的类不需要知道它正在处理哪种实现,也不需要知道如何实例化该类的实例,不需要连接字符串,不需要在特定的电子邮件提供商更改时进行更改,并且可以通过传递一个模拟电子邮件客户端而不是真实的一个来更容易地进行测试(我们将在下一章讨论 Moq 时更多地讨论这一点)。
从其他东西中接受依赖的过程被称为依赖倒置,对于新和中级开发者来说,这通常是一个令人畏惧的话题,但就其核心而言,依赖倒置完全是关于类从外部获取它们的依赖,而不是必须自己创建特定的实例。
遵循 DIP 会导致代码更加易于维护、灵活和可测试。
这就结束了 SOLID 中的五个原则,但在结束这一章之前,我们还有几个设计原则要介绍。
考虑其他架构原则
在我们结束这一章之前,让我分享三个在我自己的软件开发之旅中帮助我的简要原则。
学习 DRY 原则
不要重复自己(DRY)是软件开发中的一个重要原则。DRY 原则的核心是确保你不会在应用程序的代码中重复相同的模式。编写代码需要花费时间,阅读和维护也需要时间,而且错误不可避免地会在每行代码中以一定的速率发生。因此,你希望努力在一个集中的地方一次性解决问题,然后重用那个解决方案。
让我们看看一些违反 DRY 原则的示例代码。这段代码接受一个"CSA1234,CMH,ORD"并将其转换为FlightInfo对象:
public FlightInfo ReadFlightFromCsv(string csvLine) {
string[] parts = csvLine.Split(',');
const string fallback = "Unknown";
FlightInfo flight = new();
if (parts.Length > 0) {
flight.Id = parts[0]?.Trim() ?? fallback;
} else {
flight.Id = fallback;
}
if (parts.Length > 1) {
flight.DepartureAirport = parts[1]?.Trim() ?? fallback;
} else {
flight.DepartureAirport = fallback;
}
if (parts.Length > 2) {
flight.ArrivalAirport = parts[2]?.Trim() ?? fallback;
} else {
flight.ArrivalAirport = fallback;
}
// Other parsing logic omitted
return flight;
}
注意到解析 CSV 字符串每一部分的逻辑是如何被包裹在针对null值和部分数组为空的检查中的。这段代码非常重复,很容易想象如果 CSV 数据中添加了新字段,进行更改的开发者会直接复制和粘贴这五行代码。
重复代码模式,如这个问题,有几个问题:
-
它鼓励复制和粘贴,这往往会产生糟糕的代码或由于在粘贴时应该更改而未更改的事情而导致错误
-
如果解析单个字段的逻辑需要改变(例如,为了防止空字符串),现在需要在很多地方进行更改
我们可以通过提取包含解析字段逻辑的方法来解决这个问题:
private string ReadFromCsv(string[] parts, int index,
string fallback = "Unknown") {
if (parts.Length > index) {
return parts[index]?.Trim() ?? fallback;
} else {
return fallback;
}
}
public FlightInfo ReadFlightFromCsv(string csvLine) {
string[] parts = csvLine.Split(',');
FlightInfo flight = new();
flight.Id = ReadFromCsv(parts, 0);
flight.DepartureAirport = ReadFromCsv(parts, 1);
flight.ArrivalAirport = ReadFromCsv(parts, 2);
// Other parsing logic omitted
return flight;
}
不仅这个新版本更容易维护,而且它还减少了代码量,并有助于将注意力集中在不同部分之间逻辑不同的部分。这提高了代码的可读性,同时减少了你犯错误的可能性。
KISS 原则
“保持简单,傻瓜”(缩写为KISS,有时也称为“保持简单,愚蠢”)是一个关注软件系统复杂性的原则。作为软件工程师,我们有时会过度思考,使事情变得极其复杂,而实际上并不需要这样。KISS 原则鼓励你尽可能保持你的代码和类简单,只有在真正必要时才扩展复杂性。
通常,你的系统中复杂性越高,添加新功能、诊断问题、 onboard 新团队成员和解决面向客户的问题所需的时间就越长。随着应用程序中移动部件的增加,也有更多可能出错的事情,这意味着复杂性有真正的机会创建面向客户的问题——所有这些都是为了解决你组织几年内可能不会遇到的问题的潜在解决方案。
复杂性往往会随着时间的推移而增长,很少会减少(尤其是在数据库模式中)。保持简单,直到你看到迫切和有说服力的理由来增加复杂性。
理解高内聚和低耦合
最后,让我们通过回顾你在软件工程中偶尔会听到的两个术语来结束本章:内聚和耦合。
内聚与类的不同部分如何与同一事物相关联有关。在一个高内聚的类中,几乎所有的类部分都面向同一类型的功能。让我们再次看看之前提到的IFlightUpdater接口作为例子:
public interface IFlightUpdater {
FlightInfo AddFlight(FlightInfo flight);
FlightInfo UpdateFlight(FlightInfo flight);
void CancelFlight(FlightInfo flight);
}
一个实现了这个接口中的所有内容且没有添加其他成员的类是一个很好的高内聚例子,因为该接口中的所有成员都与处理同一类型的项目相关。一个低内聚的类会从这些方法开始,但也会添加许多与预订航班、生成报告、搜索数据或其他功能相关的方法。
具有低内聚的类通常也违反了 SRP。
耦合指的是单个类与其他类紧密配对的程度。一个类需要了解的其他类越多,它的耦合度就越高。具有更高耦合度的类由于依赖项较多,测试起来更困难,并且随着相关类随时间演变而需要更频繁地修改。
DIP 为类提供了一个很好的方法来减少它们的耦合。
因此,当你听到人们谈论希望有高内聚和低耦合时,他们是在提倡具有非常专注于特定领域并且尽可能少地依赖其他类的类。当这种组合满足时,类往往非常专注且易于维护。
摘要
在本章中,我们讨论了代码异味和反模式。正确的设计原则可以帮助保持你的代码集中和最小化,并减缓它自然积累复杂性的速度。这有助于保持你的代码良好形态并抵抗技术债务的积累。
质量编程最常见的原则是 SOLID,遵循单一职责原则(SRP),使代码易于扩展而难以修改,Liskov 替换原则(LSP)提倡使用多态代码实现低耦合,接口隔离原则关注多个较小的接口而不是一个较大的接口,以及依赖倒置原则(DIP),它讨论通过让类从类外获取它们所需的东西来减少耦合。
现在我们已经确定了如何编写 SOLID 代码,我们将探讨一些高级测试技术,这些技术可以帮助测试使用这些原则构建的代码。
问题
-
SRP 如何影响内聚性?
-
你的代码中哪些区域违反了 SRP 或 DRY?
-
DI 的优势是什么?它是如何影响耦合的?
进一步阅读
你可以在以下 URL 中找到有关本章讨论的材料更多信息:
-
C#中的 SOLID 原则及示例:
www.c-sharpcorner.com/UploadFile/damubetha/solid-principles-in-C-Sharp/ -
开发者持续使用的 15 个最糟糕的 C#反模式(以及如何避免它们):
methodpoet.com/worst-anti-patterns/ -
C#中的 Top 10 Dotnet 异常反模式:
newdevsguide.com/2022/11/06/exception-anti-patterns-in-csharp/ -
使用实现IDisposable的对象:
learn.microsoft.com/en-us/dotnet/standard/garbage-collection/using-objects -
LINQ 的注意事项和陷阱:
dev.to/samfieldscc/linq-37k3
第九章:高级单元测试
正如我们所见,测试非常强大,可以给你在相对安全的情况下有效地重构代码的自由。有时,代码的编写方式使得测试变得困难,你需要一些额外的工具。在本章中,我们将探讨一些流行的.NET 库,这些库可以提高你测试的可读性,并为你提供更多的测试代码选项——包括那些具有复杂数据或依赖关系的棘手类。
本章我们将探讨以下主题:
-
使用 Shouldly 创建可读性强的测试
-
使用 Bogus 生成测试数据
-
使用 Moq 和 NSubstitute 模拟依赖项
-
使用 Snapper 固定测试
-
Scientist .NET 的实验
技术要求
本章的代码可以从 GitHub 的Chapter09文件夹中获取,链接为 https://github.com/PacktPublishing/Refactoring-with-CSharp。
库会随着新版本的发布而发生变化,其中一些变化可能会影响本章中的代码。因此,以下是本章编写时使用的库的确切名称和版本:
-
Bogus 34.0.2
-
FluentAssertions 6.11.0
-
Moq 4.20.2
-
NSubstitute 5.0.0
-
Scientist 2.0.0
-
Shouldly 4.2.1
-
Snapper 2.4.0
使用 Shouldly 创建可读性强的测试
在第六章中,我们看到了如何使用Assert类通过如下代码来验证现有类的行为:
Assert.Equal(35, passengerCount);
这段代码验证passengerCount是否等于35,如果不同则测试失败。
不幸的是,这段代码有两个问题:
-
断言方法首先接受预期值,然后是实际值。这与大多数人思考事物的方式不同,可能会导致令人困惑的测试失败消息,正如我们在第六章中看到的。
-
这段代码用英语读起来并不十分流畅,这可能会在你阅读测试时减慢你的速度。
几个开源库通过引入一系列扩展方法,为编写单元测试中的断言提供替代语法,来解决这一问题。
其中最受欢迎的库是 FluentAssertions 和 Shouldly。虽然 FluentAssertions 是迄今为止最受欢迎的库,但我发现 Shouldly 的阅读起来更自然,所以我们将从它开始。
在查看 FluentAssertions 的类似示例之前,让我们先看看如何安装 Shouldly 以及如何开始使用其语法。
安装 Shouldly NuGet 包
Shouldly 不是 Visual Studio 中任何项目模板默认包含的库。因此,我们需要将其添加到我们的项目中。
在 Visual Studio 中,我们使用名为NuGet 包管理器的包管理器来从包源(如nuget.org)安装外部依赖项。
如果你曾经使用过 JavaScript 进行编程,那么这个概念与 JavaScript 包管理器如 Yarn 或 NPM 非常相似。虽然其他包管理器会下载代码并让你进行编译,但 NuGet 会下载 编译 版本的代码,并允许你的代码引用那些项目中定义的内容,而不会减慢你的构建过程。
要安装一个包,在 解决方案资源管理器 中右键点击 Chapter9Tests 项目,然后选择 管理 NuGet 包。
接下来,在搜索栏中点击 Shouldly。你的搜索结果应该类似于 图 9**.1 中的那些:

图 9.1 – 显示 Shouldly 结果的 NuGet 包管理器
你应该在左侧的列表中看到一个名为 Shouldly 的条目,作者是 Jake Ginnivan 等。通过点击它来选择它。右侧的详细信息将列出有关此包的信息,包括其许可条款和依赖项。
小贴士
总是检查你正在寻找的作者和包的确切名称,因为许多包有类似的名字。
使用右侧详细信息区域的 Version 下拉菜单,你可以选择要安装的库的特定版本。通常,保持为最新稳定版本是没问题的,但偶尔你可能需要为了兼容性选择一个早期版本。
当你点击 安装 时,Shouldly 及其依赖项将自动下载并安装到你的项目中。在安装包时,可能会打开一个窗口显示各种许可条款或依赖项,如 图 9**.2 所示。请仔细阅读这些内容,特别是如果你在办公场所使用库的话:

图 9.2 – 安装 Shouldly 所需的依赖项
现在我们已经安装了 Shouldly,让我们学习如何使用它。
使用 Shouldly 编写可读的断言
在 PassengerTests.cs 文件中,有一个现有的 PassengerFullNameShouldBeAccurate 测试,它会实例化一个 Passenger 对象,从对象的 FullName 字段中获取值,并确保生成的名称与预期值匹配,如下面的代码所示:
[Fact]
public void PassengerFullNameShouldBeAccurate() {
// Arrange
Passenger passenger = new() {
FirstName = "Dot",
LastName = "Nette",
};
// Act
string name = passenger.FullName;
// Assert
Assert.Equal("Dot Nette", name);
}
使用 Shouldly,我们可以使这个断言变得更加易读。
首先,让我们通过在该文件的末尾添加一个 using 语句来添加一个 Usings.cs 文件:
global using Xunit;
global using Shouldly;
这 global using 指令允许你在 Chapter9Tests 项目的任何地方使用 Shouldly 命名空间中的内容。换句话说,它等同于在项目的每个文件顶部都有一个 using Shouldly; 语句。
现在我们已经安装了 Shouldly 并导入了其命名空间,我们可以通过使用 Shouldly 提供的许多扩展方法之一来重写之前的断言,如下所示:
[Fact]
public void PassengerFullNameShouldBeAccurate() {
// Arrange
Passenger passenger = new() {
FirstName = "Dot",
LastName = "Nette",
};
// Act
string name = passenger.FullName;
// Assert
name.ShouldBe("Dot Nette");
}
在这里,Shouldly 为 string 类型添加了一个 ShouldBe 扩展方法,使我们能够以非常可读的方式调用此方法。此代码在功能上等同于 Assert.Equal,但可读性显著更高。此外,使用这种方式处理问题时,你混淆预期值和实际值的可能性要小得多。
Shouldly 提供了各种扩展方法,包括 ShouldBe、ShouldNotBe、ShouldBeGreaterThan/ShouldBeLessThan、ShouldContain、ShouldNotBeNull/ShouldBeNull、ShouldStartWith/ShouldEndWith 等。
为了说明这一点,让我们看看一个没有使用 Shouldly 编写的更复杂的测试:
[Fact]
public void ScheduleFlightShouldAddFlight() {
// Arrange
FlightScheduler scheduler = new();
PassengerFlightInfo flight = _flightFaker.Generate();
// Act
scheduler.ScheduleFlight(flight);
// Assert
var result = scheduler.GetAllFlights();
Assert.NotNull(result);
Assert.Equal(1, result.Count());
Assert.Contains(flight, result);
}
此代码使用 FlightScheduler 和 Bogus 库(我们将在本章后面讨论)来安排航班。一旦航班被安排,代码将获取所有航班,并通过 Should().Be(60m) 语法断言结果集合不为空,只有一个项目,并且我们安排的航班在该集合中。
这段代码并不糟糕,但我仍然更喜欢 Shouldly 版本:
[Fact]
public void ScheduleFlightShouldAddFlight() {
// Arrange
FlightScheduler scheduler = new();
PassengerFlightInfo flight = _flightFaker.Generate();
// Act
scheduler.ScheduleFlight(flight);
// Assert
var result = scheduler.GetAllFlights();
result.ShouldNotBeNull();
result.Count().ShouldBe(1);
result.ShouldContain(flight);
}
通常,我发现 Shouldly 库具有更一致的参数排序,并导致更可读的测试。正因为如此,我在尽可能的地方使用 Shouldly,这使我更加高效。
练习
作为一项练习,我鼓励你使用本章的起始代码,将各种测试转换为使用 Shouldly 而不是标准断言。在实验过程中,你可以自由地尝试其他断言。本章的最终代码使用 Shouldly,如果你想要检查你的答案。
在我们了解 Shouldly 还能做什么之前,让我们看看 FluentAssertions,这是一个流行的库,它在功能上与 Shouldly 类似。
使用 FluentAssertions 编写可读性强的断言
FluentAssertions 做的是 Shouldly 做的事情,但其语法的处理方式不太倾向于调用单个方法,如 Shouldly 的 ShouldContain。相反,FluentAssertions 更倾向于将多个方法调用链式连接起来以产生类似的结果。
让我们通过一个行李定价系统的测试来举例说明:
[Fact]
public void CarryOnBaggageIsPricedCorrectly() {
// Arrange
BaggageCalculator calculator = new();
int carryOnBags = 2;
int checkedBags = 0;
int passengers = 1;
bool isHoliday = false;
// Act
decimal result = calculator.CalculatePrice(checkedBags,
carryOnBags, passengers, isHoliday);
// Assert
result.Should().Be(60m);
}
此代码创建了 BaggageCalculator,然后向该计算器的 CalculatePrice 方法发送一系列因素,在通过 Should().Be(60m) 语法进行断言之前执行。
在我们深入探讨这个话题之前,我应该指出,与 Shouldly 一样,FluentAssertions 也不是预安装的。你需要使用 NuGet 包管理器安装 FluentAssertions,就像你之前安装 Shouldly 一样。你还需要在你的代码文件中添加一个 using FluentAssertions; 语句,以便看到 FluentAssertions 扩展方法。
现在我们已经了解了如何开始使用 FluentAssertions,让我们更仔细地看看那个 result.Should().Be(60m) 语法。
FluentAssertions 中的大多数操作都源于 Should 方法。请注意,FluentAssertions 中有多个 Should 方法,每个方法都与你可能处理的一种特定类型的数据相关。
这些Should方法返回一个强类型对象,例如在计算器断言的情况下是NumericAssertions<decimal>。这些断言对象包含各种约束方法,允许你进行有针对性的断言,如Be、NotBe、BeLessThan、BePositive、BeOneOf等。
FluentAssertions 方法有几个优点:
-
由于它们都通过
Should()进行,因此更容易找到断言方法。 -
约束方法允许你组合断言,例如
result.Should().BePositive().And.BeInRange(50, 70)。
不幸的是,FluentAssertions 的学习曲线略高,并且比 Shouldly 更冗长,这可能会导致测试的可读性略低。
最终,选择哪种风格取决于你和你所在的团队,但 Shouldly 和 FluentAssertions 都可以显著提高你测试的可读性以及编写测试体验的愉悦感。
在我们介绍下一个新库之前,让我们再谈一谈 Shouldly 可能有所帮助的另一个功能。
使用 Shouldly 测试性能
人们发现自己需要重构代码的一个原因是为了寻找提高已知运行缓慢的代码性能的方法。
想象一下,你正在遵循测试驱动开发(TDD),正在调查一个迭代列表项所需时间过长的不接受代码。
TDD 的第一步是编写一个失败的测试,所以你现在需要编写一个测试,如果方法的性能太慢,这个测试就会失败。
我们将在稍后讨论为什么你可能不想围绕性能编写测试的原因,但首先让我们探讨如何进行性能测试。
为了使涉及执行过慢代码的测试失败,你需要能够测量该代码运行所需的时间。为此,你可以创建一个Stopwatch对象,启动它,停止它,然后验证该计时器的持续时间,如下所示:
[Fact]
public void ScheduleFlightShouldNotBeSlow() {
// Arrange
FlightScheduler scheduler = new();
PassengerFlightInfo flight = _flightFaker.Generate();
int maxTime = 100;
Stopwatch stopwatch = new();
// Act
stopwatch.Start();
scheduler.ScheduleFlight(flight);
stopwatch.Stop();
long milliSeconds = stopwatch.ElapsedMilliseconds;
// Assert
milliSeconds.ShouldBeLessThanOrEqualTo(maxTime);
}
如果ScheduleFlight的运行时间超过 100 毫秒(0.1 秒),这段代码将会失败,但这种方法有几个缺点:
-
这种方法需要大量的设置代码。在这种情况下,测试方法中超过一半的代码都是关于
Stopwatch的。 -
测试在方法完成之前等待方法,如果方法需要 10 秒才能完成,测试将等待整个时间。这是低效的,因为一旦超过 100 毫秒的阈值,测试将永远不会通过。
Shouldly 提供了一个更紧凑的Should.CompleteIn方法,它解决了这两个问题:
[Fact]
public void ScheduleFlightShouldNotBeSlow() {
// Arrange
FlightScheduler scheduler = new();
PassengerFlightInfo flight = _flightFaker.Generate();
TimeSpan maxTime = TimeSpan.FromMilliseconds(100);
// Act
Action testAction = () => scheduler.ScheduleFlight(flight);
// Assert
Should.CompleteIn(testAction, maxTime);
}
这段代码创建了一个动作来安排航班,Shouldly 将在测试过程中调用这个动作。这个动作只有在传递给Should.CompleteIn方法时才会被调用,这个方法还要求允许方法运行的最大时间量。
当 Shouldly 运行你的操作时,它会内部跟踪经过的时间,一旦达到那个阈值,就会取消你的操作并使测试失败。这导致测试代码更加紧凑,不会超过最大允许的时间。
因此,现在我们已经知道了如何使用 Shouldly 或普通的.NET 和Stopwatch编写简单的性能测试,让我们谈谈为什么你可能不想这样做。
好的测试应该是快速的,并产生可重复的结果。测试将在各种不同的机器和不同的情况下运行,例如当处理器相对较少的工作要做或当处理器完全超载时。测试也可能在隔离或并行的情况下运行,与其他测试一起。此外,在使用.NET 时,看到性能的运行间变化是正常的。
所有这些意味着性能测试可能会比你想象的更加混乱,最大允许的持续时间是你应该仔细考虑的事情。如果你的测试是在持续集成/持续交付(CI/CD)管道中运行的(它们应该如此,正如我们在本书的第四部分中将要讨论的),那么构建机器的 CPU 和内存特性可能根本不像开发工作站的。为了应对这种情况,你可能需要选择一个比平时显著更高的数字,以避免由于缓慢的测试环境导致的随机失败。另一方面,如果你将超时设置得太长,你将无法检测到真正的性能问题。
我的总体立场是,由于性能指标的不确定性和可能运行测试的机器种类繁多,性能测试很少应该被编码到单元测试中。相反,我倾向于更喜欢定期使用像Visual Studio Enterprise或JetBrains dotTrace这样的专用工具对那些真正对性能至关重要的区域进行性能分析。
话虽如此,性能测试确实有其价值,但你可能会花费比你预期的更多时间来找到一个好的最大测试持续时间数字。
让我们继续介绍另一个使测试更容易的库:Bogus。
使用 Bogus 生成测试数据
在第六章中,我提到测试是一种文档形式,它解释了你的系统应该如何工作。
考虑到这一点,看看以下测试,它测试了Passenger和BoardingProcessor类之间的交互:
[Fact]
public void BoardingMessageShouldBeAccurate() {
// Arrange
Passenger passenger = new() {
BoardingGroup = 7,
FirstName = "Dot",
LastName = "Nette",
MailingCity = "Columbus",
MailingStateOrProvince = "Ohio",
MailingCountry = "United States",
MailingPostalCode = "43081",
Email = "noreply@packt.com",
RewardsId = "CSA88121",
RewardMiles = 360,
IsMilitary = false,
NeedsHelp = false,
};
BoardingProcessor boarding =
new(BoardingStatus.Boarding, group:3);
// Act
string message = boarding.BuildMessage(passenger);
// Assert
message.ShouldBe("Please Wait");
}
在调用BuildMessage之前,在安排阶段需要大量的设置。但哪些设置方面是重要的?Passenger对象的哪些部分有助于这个人被允许登机,而不是被告知等待?
虽然创建看起来准确的测试对象很重要,但将无关紧要的属性与重要属性混合在一起可能会导致难以解释测试数据的重要性或为什么测试应该通过而不是失败。
Bogus 是一个生成不同类型真实随机数据的库。Bogus 通过为你提供一种生成对象中不那么关键部分随机数据的好方法来解决此问题。
这同时具有两个好处:一方面,它将你的注意力集中在测试的更关键部分,另一方面,它生成随机数据来测试你的断言,即其他属性的值真正无关紧要。
与本章中的其他库一样,Bogus 必须通过 NuGet 安装,然后在using Bogus;语句中引用。
让我们看看之前测试中 Bogus 的Arrange部分:
// Arrange
Faker<Passenger> faker = new();
faker.RuleFor(p => p.FirstName, f => f.Person.FirstName)
.RuleFor(p => p.LastName, f => f.Person.LastName)
.RuleFor(p => p.Email, f => f.Person.Email)
.RuleFor(p => p.MailingCity, f => f.Address.City())
.RuleFor(p => p.MailingCountry, f => f.Address.Country())
.RuleFor(p => p.MailingState, f =>f.Address.State())
.RuleFor(p => p.MailingPostalCode, f=>f.Address.ZipCode())
.RuleFor(p => p.RewardsId, f => f.Random.String2(8))
.RuleFor(p => p.RewardMiles,
f => f.Random.Number(int.MaxValue));
Passenger passenger = faker.Generate();
passenger.BoardingGroup = 7;
passenger.NeedsHelp = false;
passenger.IsMilitary = false;
如你很可能注意到的,这段代码与之前的代码有很大不同。它使用 Bogus 中的Faker<Passenger>对象,每次调用Generate()方法时都会生成一个不同的随机Passenger对象。
这些Passenger对象将使用 Bogus 的随机数据库生成合理的测试数据,如图9**.3所示:

图 9.3 – 一个具有某种现实值的随机乘客
这种工作方式是,你可以设置规则,当Faker使用RuleFor方法看到某个特定的属性时,将遵循这些规则。
使用RuleFor,你可以在第一个参数中指定想要编程响应的属性,然后在第二个参数中指定一个函数来获取值。
例如,RuleFor(p => p.Email, f => f.Person.Email)这一行有两个函数参数。第一个参数使用p来表示Passenger对象,并关注该对象的Email属性。第二个参数接收一个Faker实例作为f,函数可以选择使用它来生成Faker在生成人物时将使用的数据。
Faker包含许多不同类型的数据,从虚假的公司名称到 ZIP 代码,再到产品名称、IP 地址,甚至包括黑客语言和“狂言”如评论等荒谬事物。
现在,如果你仔细查看Faker生成的数据,它并不总是有道理。例如,图 9**.3中列出的人居住在明尼苏达州的 Larsonland,邮政编码为 78950,国家为“科科斯(基林)群岛”。单独来看,这些信息都是合理的,但这些不同的属性之间存在着极大的冲突。
如果你需要你的数据有意义,你需要编写更细致的规则来描述这些属性之间的交互。尽管存在这些限制,Bogus 仍然为你提供了一个很好的方法,为那些无关紧要的数据添加随机性。
通常,当使用 Bogus 时,你会在单独的方法或测试构造函数中创建你的Faker实例,这显著简化了你的代码:
[Fact]
public void BoardingMessageShouldBeAccurate() {
Faker<Passenger> faker = BuildPersonFaker();
Passenger passenger = faker.Generate();
passenger.BoardingGroup = 7;
passenger.NeedsHelp = false;
passenger.IsMilitary = false;
BoardingProcessor boarding =
new(BoardingStatus.Boarding, group: 3);
// Act
string message = boarding.BuildMessage(passenger);
// Assert
message.ShouldBe("Please Wait");
}
注意这种方法如何最小化 Bogus 在其中的作用,并关注随机生成的人如何进一步配置。这有助于你看到,对于尚未登机的人来说,重要的因素如下:
-
他们比当前组有更高的登机优先级
-
它们不是军事人员
-
他们不需要帮助登机
Bogus 不仅仅用于测试。例如,我已经成功地使用 Bogus 进行用户界面原型设计和为小型游戏项目生成数据。然而,Bogus 是您测试工具箱中的一个宝贵补充。
让我们继续探讨使用成对模拟库来隔离依赖的方法。
使用 Moq 和 NSubstitute 进行模拟依赖
到目前为止,我们已经查看了一些可以提高测试可读性的库。在本节中,我们将探讨模拟框架,并看看库如何帮助您更有效地测试代码。
理解模拟库的需求
让我们通过回顾上一章中讨论依赖注入时引入的FlightBookingManager示例来讨论为什么需要模拟框架:
public class FlightBookingManager {
private readonly IEmailClient _email;
public FlightBookingManager(IEmailClient email) {
_email = email;
}
public bool BookFlight(Passenger passenger,
FlightInfo flight, string seat) {
if (!flight.IsSeatAvailable(seat)) {
return false;
}
flight.AssignSeat(passenger, seat);
string message = "Your seat is confirmed";
return _email.SendMessage(passenger.Email, message);
}
}
在这里,这个类在创建FlightBookingManager时需要IEmailClient。客户端随后存储在_email字段中,并在预订航班时使用它来发送消息。将IEmailClient作为构造函数的参数传递是依赖注入的一个例子,它允许我们的类与实现IEmailClient接口的任何东西一起工作。
不幸的是,这也意味着为了测试这个类,我们必须提供一个IEmailClient的实现,即使我们并没有明确测试与电子邮件相关的内容。
由于我们通常在单元测试代码时不想发送电子邮件,这意味着我们需要一个IEmailClient的单独实现。我们可以通过声明一个类并使用最小实现来实现IEmailClient接口来创建一个。
假设IEmailClient被定义为以下内容:
public interface IEmailClient {
bool SendMessage(string email, string message);
}
你可以创建一个满足这个要求的TestEmailClient:
public class TestEmailClient : IEmailClient {
public bool SendMessage(string email, string message)
=> true;
}
在这里,测试客户端的实现非常简单,只做了编译代码所需的最小工作,在这种情况下是返回 true,表示消息已成功发送。这种类型的类有时被称为测试替身、测试存根,或者简单地称为模拟对象。这些名称是因为这些类看起来像是用于测试目的的真实实现,但没有全部的功能。在本章中,我将把这些称为模拟对象,因为这有助于使模拟框架在以后更有意义。
这让我们能够使用我们创建的TestEmailClient模拟对象来编写测试:
[Fact]
public void BookingFlightShouldSucceedForEmptyFlight() {
// Arrange
TestEmailClient emailClient = new();
FlightBookingManager manager = new(emailClient);
Passenger passenger = GenerateTestPassenger();
FlightInfo flight = GenerateEmptyFlight("Paris",
"Toronto");
// Act
bool booked = manager.BookFlight(passenger, flight,"2B");
// Assert
booked.ShouldBeTrue();
}
在这里,我们可以通过提供TestEmailClient而不是真实的电子邮件客户端来安全地测试航班而不发送电子邮件。
不幸的是,模拟对象有其缺点。假设我们想要编写另一个测试来验证尝试预订已被占用的座位不会发送电子邮件。在这种情况下,我们需要创建另一个具有不同实现的模拟对象。
在这种情况下,如果SendMessage方法被调用,我们希望测试失败,因此该方法应该抛出异常或使用Assert.Fail方法来导致测试失败,如下所示:
public class SendingNotAllowedEmailClient : IEmailClient {
public bool SendMessage(string email, string message) {
Assert.Fail("You should not have sent an email");
return false;
}
}
让我们考虑一个更复杂的情况。假设你想要验证BookFlight方法只调用一次其IEmailClient上的SendMessage方法。
我们可以通过构建一个具有调用次数计数器的专用模拟对象来测试这一点,但这会增加我们测试代码的复杂性,而我们不一定需要。如果IEmailClient的定义有任何变化,实现该接口的所有模拟对象也需要更新。
由于许多测试都需要模拟对象,并且每个测试都测试了略有不同的事情,因此手动编写和维护模拟对象可能会非常繁琐。这正是模拟库存在要解决的问题。
虽然在.NET 中存在几个流行的模拟库,但多年来最受欢迎的库是 Moq。在查看替代方案之前,我们将探索 Moq。
使用 Moq 创建模拟对象
Moq,根据其创造者的说法,发音为“Mock”或“Mock-you”,是一个围绕使用 LINQ 创建、配置和验证模拟对象行为的模拟库。
就像本章中的其他库一样,你需要从 NuGet 包管理器安装 Moq,并通过using Moq;语句将其导入到你的文件中。
使用 Moq,你不需要自己创建模拟对象;相反,你告诉 Moq 你想要实现或继承的接口或类,Moq 会自动创建一个满足这些要求的对象。
让我们回顾一下本章前面使用 Moq 的航班预订测试:
[Fact]
public void BookingFlightShouldSucceedForEmptyFlight() {
// Arrange
Mock<IEmailClient> clientMock = new();
IEmailClient emailClient = clientMock.Object;
FlightBookingManager manager = new(emailClient);
Passenger passenger = GenerateTestPassenger();
FlightInfo flight = GenerateEmptyFlight("Hamburg",
"Cairo");
// Act
bool booked = manager.BookFlight(passenger, flight,"2B");
// Assert
booked.ShouldBeTrue();
}
在这里,我们实例化一个名为clientMock的Mock实例,它将以IEmailClient的形式创建一个新的模拟对象。然后我们调用clientMock上的Object属性,Moq 库会自动生成一个以最简单的方式实现IEmailClient的对象。
在本例中,由于我们不关心电子邮件客户端的工作原理,所以我们只需要做这些来生成一个简单的模拟对象,我们可以将其传递给FlightBookingManager。这不仅代码更少,而且我们可以在定义我们的模拟对象的同时保持测试方法,并且如果IEmailClient的定义有任何变化,我们不需要更新模拟对象,因为 Moq 会为我们处理这些。
当然,Moq 可以做很多事情,所以让我们看看如何使用它来配置模拟对象的行为。
编程 Moq 返回值
默认情况下,Moq 模拟对象上的方法将返回该类型的默认值。例如,返回bool对象的方法将返回false,而返回int对象的方法将返回0。
有时,你需要 Moq 返回不同的值。在这些情况下,你可以通过调用 Moq 的 Setup 方法来设置你的模拟对象。例如,如果你需要 SendMessage 方法对于传入的任何值都返回 true 而不是 false,你可以编写以下代码:
Mock<IEmailClient> mockClient = new();
mockClient.Setup(c => c.SendMessage(It.IsAny<string>(),
It.IsAny<string>())
).Returns(true);
IEmailClient emailClient = mockClient.Object;
在这里,Setup 方法要求你告诉它你正在配置的方法或属性。由于我们正在配置 SendMessage 方法,我们在箭头函数中指定它。
接下来,Moq 需要知道何时应用这条规则。你可以编程你的模拟对象根据不同的参数返回不同的响应,因此你可以为同一方法的不同参数值设置一个 Setup 调用。
在我们的案例中,我们希望该方法始终返回 true,无论传入什么,所以我们使用 Moq 的 It.IsAny 语法来指定。
在我们完成对 Moq 的讨论之前,我们将查看一个最终的例子,并教你如何验证给定方法在模拟对象上被调用的次数。
验证 Moq 调用
有时,你想测试一个方法的行为,并验证调用一个方法导致它调用另一个对象上的某个方法。Moq 允许你通过验证一个方法被调用特定次数来实现这一点。
这可能包括验证一个方法没有被调用,这在确保在无法预订座位的情况下不发送电子邮件的例子中可能很有帮助。
为了实现这一点,我们可以调用 Moq 的 Verify 方法,如下所示,这验证了在预订航班时电子邮件只发送了一次:
[Fact]
public void BookingFlightShouldSendEmails() {
// Arrange
Mock<IEmailClient> mockClient = new();
mockClient.Setup(c => c.SendMessage(It.IsAny<string>(),
It.IsAny<string>())).Returns(true);
IEmailClient emailClient = mockClient.Object;
FlightBookingManager manager = new(emailClient);
Passenger passenger = GenerateTestPassenger();
FlightInfo flight = GenerateEmptyFlight("Sydney","LA");
// Act
bool result= manager.BookFlight(passenger,flight,"2C");
// Assert
result.ShouldBeTrue();
mockClient.Verify(c => c.SendMessage(passenger.Email,
It.IsAny<string>()), Times.Once);
mockClient.VerifyNoOtherCalls();
}
在这里,我们在我们的 Mock 实例上调用 Verify 来验证 SendMessage 方法恰好被调用一次,并且带有乘客的电子邮件地址和任何电子邮件正文。如果该方法没有被调用或被多次调用,这将使我们的测试失败。
换句话说,这一行 Verify 保护我们免受系统在应该发送电子邮件时没有发送,以及可能发送过多电子邮件的情况。
接下来,代码调用 VerifyNoOtherCalls。如果我们的 IEmailClient 上的某个其他方法被调用,而这个方法没有被之前的 Verify 语句验证,这个方法将使测试失败。这可以方便地确保代码不会对你提供的对象执行意外操作。
关于验证行为的说明
开发者社区在历史上一直对在单元测试中验证调用代码是否调用你的代码中的其他部分是否是良好实践存在分歧。反对验证测试行为的人认为,如果方法产生了正确的结果,那么方法如何实现某事并不重要。反对方则认为,有时,你方法期望的结果是调用外部代码,例如我们这里的代码,它调用了 SendMessage 调用。你和你的团队将需要决定何时在测试中使用 Verify。
Moq 在一开始使用时可能看起来比较复杂,但你不需要使用它的所有功能就能从中受益。正如我们之前看到的,只需使用 Moq 生成简单的模拟对象,就能在维护随着时间的增长而不断增加的手动创建的模拟对象时节省大量工作。
你并不总是需要使用 Moq 的 Setup 或 Verify 方法,但当你需要时,它们非常有帮助。
几年来,Moq 一直是 .NET 中的主流模拟库,但最近,NSubstitute 正在逐渐流行起来。这导致你可能在工作中更可能遇到它作为 Moq 的替代品。让我们简要地探讨 NSubstitute 并看看它是如何使用不同的语法实现与 Moq 相似的功能的。
使用 NSubstitute 进行模拟
NSubstitute 是与 Moq 类似的模拟库,但其方法是在可能的情况下避免箭头函数,并优先选择看起来更像标准方法调用的代码。
与本章中的其他库一样,你需要通过 NuGet 包管理器安装 NSubstitute,然后通过 using NSubstitute; 语句导入它。
一旦安装并导入 NSubstitute,你就可以在代码中使用它,如下所示:
[Fact]
public void BookingFlightShouldSendEmailsNSubstitute() {
// Arrange
IEmailClient emailClient= Substitute.For<IEmailClient>();
emailClient.SendMessage(Arg.Any<string>(),
Arg.Any<string>()
).Returns(true);
FlightBookingManager manager = new(emailClient);
Passenger passenger = GenerateTestPassenger();
FlightInfo flight = GenerateEmptyFlight("Sydney","LA");
// Act
bool result = manager.BookFlight(passenger, flight,"2C");
// Assert
result.ShouldBeTrue();
emailClient.Received()
.SendMessage(passenger.Email,
Arg.Any<string>());
}
注意 NSubstitute 的 Substitute.For 方法返回你正在创建的对象,而不是像 Moq 中的 Mock<IEmailClient> 那样创建一个对象。这个变化使得你的代码稍微简单一些,但也意味着你现在需要调用 Received() 和 DidNotReceive() 等方法来访问验证方法。
通常,NSubsitute 与 Moq 非常相似,但语法更简单。这种简单性有其优点,尤其是在代码可读性和降低新开发者的学习曲线方面。不幸的是,这有时是以 NSubstitute 没有与 Moq 相同的全套功能为代价的。
现在我们已经探讨了模拟库,让我们转向完全不同类型的单元测试。
使用 Snapper 进行测试桩
假设你继承了一些复杂的遗留代码,这些代码返回一个具有许多属性的对象。其中一些属性可能反过来又包含其他具有自己嵌套属性的自定义对象。你刚开始与这段代码一起工作,需要做出一些更改,但现在还没有测试,甚至不确定哪些属性是重要的验证点。
我已经看到过这种场景几次,可以证明一个名为 Snapper 的特殊测试库是解决这个问题的绝佳方案。
Snapper 所做的是创建一个对象的快照并将其存储到磁盘上的 JSON 文件中。当 Snapper 下次运行时,它会生成另一个快照,并将其与之前存储的快照进行比较。如果快照有任何不同,Snapper 将会失败测试并提醒你这个问题。
Snapper 和 Jest
对于那些有 JavaScript 背景的人来说,Snapper 是受到 JavaScript 的 Jest 测试库中发现的快照测试功能的启发。
让我们看看 Snapper 的一个示例测试是什么样的。
如往常一样,首先,我们通过 NuGet 安装 Snapper 并添加一个 using Snapper; 语句。
之后,我们将针对一个复杂对象 FlightManifest 编写测试:
[Fact]
public void FlightManifestShouldMatchExpectations() {
// Arrange
FlightInfo flight = GenerateEmptyFlight("Alta", "Laos");
Passenger p1 = new("Dot", "Netta");
Passenger p2 = new("See", "Sharp");
flight.AssignSeat(p1, "1A");
flight.AssignSeat(p2, "1B");
LegacyManifestGenerator generator = new();
// Act
FlightManifest manifest = generator.Build(flight);
// Assert
manifest.ShouldMatchSnapshot();
}
在这里,我们调用 ShouldMatchSnapshot 来验证对象是否与当前快照匹配。
这将首次生成快照,但随后的运行将比较对象的快照与存储的快照。如果生成的快照不同,你将看到测试失败,并显示差异的详细信息,例如当乘客姓名更改时发生的差异,如 图 9.4 所示。4*:

图 9.4 – 一个失败的快照测试,显示了两个属性之间的差异
有时候,你会添加新的属性或者意识到存储的快照是基于有问题的数据,你将想要更新你的快照。你可以通过临时添加一个 UpdateSnapshots 属性到你的测试方法中来实现,如下所示:
[Fact]
[UpdateSnapshots]
public void FlightManifestShouldMatchExpectations() {
之后,重新运行你的测试以更新存储的快照,然后移除 UpdateSnapshots 属性。这一步很重要,因为包含 UpdateSnapshots 的测试永远不会使快照测试失败,而是每次都替换快照。
快照测试并非适用于每个项目和每个团队。它是一个非常有用的广泛安全网,你可以将其作为复杂返回值的第一个测试,但它作为记录系统行为的测试则远没有那么有用。此外,快照测试可能非常脆弱,会导致测试因一些微不足道的事情(例如,两个本质上相同的数据集的修改日期不同)而失败。
尽管如此,我发现 Snapper 和快照测试在尝试将测试引入遗留系统的特别复杂区域时,可以是一个合适的开局。
现在,让我们以一个类似的库来结束这一章,这个库可以帮助你比较几个不同的实现之间的差异。
Scientist .NET 的实验
Scientist .NET 是 GitHub 构建的一个库,用于科学地重构应用程序的关键部分。
假设你有一个对业务至关重要的应用程序部分,但它有大量的技术债务。你想重构它,但又害怕破坏任何东西,你现有的测试不足以解决这些担忧,但你又不确定需要添加哪些测试。在你的估计中,唯一能让你对你的新代码感到满意的事情是看到它在生产中的表现。
这正是 Scientist .NET 帮助的地方。Scientist .NET 允许你将新代码与它希望替换的遗留代码一起部署,并比较这两段代码的结果。或者,Scientist .NET 可以用于单元测试,以验证组件的旧版本和新版本是否达到相同的结果。
这个概念在下一刻可能会更加清晰。让我们来看一个具体的例子,这个例子是关于用RewrittenManifestGenerator替换LegacyManifestGenerator。
和之前一样,我们需要从 NuGet 安装 Scientist 包,然后在我们的文件顶部添加一个using GitHub;语句。
接下来,让我们看看比较两个清单生成器的科学实验:
[Fact]
public void FlightManifestExperimentWithScientist() {
FlightInfo flight = GenerateEmptyFlight("Alta", "Laos");
Passenger p1 = new("Dot", "Netta");
Passenger p2 = new("See", "Sharp");
Scientist.Science<FlightManifest>("Manifest", exp => {
exp.Use(() => {
LegacyManifestGenerator generator = new();
return generator.Build(flight);
});
exp.Try(() => {
RewrittenManifestGenerator generator = new();
return generator.Build(flight);
});
exp.Compare((a, b)=> a.Arrival == b.Arrival &&
a.Departure == b.Departure &&
a.PassengerCount==b.PassengerCount
);
exp.ThrowOnMismatches = true;
});
}
这段代码很多,所以让我们一点一点地解开这里的一切。
首先,Scientist.Science<FlightManifest>这一行告诉科学家你正在启动一个新的实验,该实验将返回FlightManifest。在这个例子中,我们忽略了这个结果值,但在生产场景中,你可能会将结果分配给一个变量,并在调用科学家之后处理它。
科学家要求你在Science调用的第一个参数中命名每个实验,因为你可能正在进行多个实验。这个实验简单地命名为“Manifest”。
接下来,科学家要求你配置你即将进行的实验。你可能需要配置一些东西,但在这里,我们指定了四个我们将依次讨论的不同事项。
首先,我们调用Use方法来告诉实验将使用什么作为Scientist.Science调用的结果。这应该是你想要替换的系统中的旧实现。
接下来,我们需要给科学家提供一到多个备选实现,以便与旧系统中的“控制”版本进行比较。我们通过一个看起来非常类似于Use方法的Try方法来完成这个操作,但它代表的是实验版本。
科学家对这两个版本所做的是调用两个实现,比较两个结果,并将指标发送到称为结果发布者的地方。这个过程在图 9.5中得到了说明:

图 9.5 – Scientist .NET 执行实验
科学家总是返回在Use期间定义的旧版本的版本,所以你的新实现不会影响现有的逻辑,你将能够识别出新旧实现不匹配的情况。这允许你在不冒任何逻辑错误影响最终用户的风险下验证你新逻辑的行为。
一旦你满意你的新实现没有问题,你就可以从你的代码中移除 Scientist 和旧实现,并用新实现来替换它们。
为了让科学家知道两个结果是否等效,它需要知道如何比较它们。你可以通过Compare方法来配置这一点,该方法接受一个函数,该函数将返回一个bool对象,指示两个对象是否应被视为等效。
最后,我们的代码将 ThrowOnMismatches 设置为 true。您可以在 Scientist 中设置此属性,以便在实验和控制对于给定输入不匹配时抛出异常。这仅适用于像我们这里的单元测试这样的场景,并且不适用于在生产应用程序中使用 Scientist。
相反,您将实现 Scientist 的 IResultPublisher 接口,并将 Scientist.ResultPublisher 设置为您的自定义结果发布者。这将允许您将不匹配报告到数据库、Azure 上的 App Insights 或您可能考虑用于捕获这些不匹配的其他机制。关于结果发布者的内容超出了本书的范围,但请参阅本章的 进一步阅读 部分,以获取更多资源。
Scientist .NET 是一个您不会经常使用的复杂解决方案,但它允许您比较两种不同的算法实现如何针对各种输入进行性能比较,无论是在单元测试场景中还是在生产应用程序中。我亲眼看到 Scientist .NET 使团队能够收集他们需要的资料,成功重构高度复杂的代码,而不会影响最终用户。
警告
重要的是要注意,当您在 Scientist 中运行实验时,您的 Use 语句中的原始版本以及您在 Try 调用中定义的任何实验都将被调用。这意味着如果您的代码有任何副作用,例如向数据库插入或发送电子邮件,这些操作将发生两次。这可能会导致数据库中插入重复行或发送重复的电子邮件。
您可以通过提供实验版本的模拟对象作为其依赖项来避免这种缺点,而不是提供数据库客户端或电子邮件提供者的真实版本。
摘要
在本章中,我们看到了几个不同的开源库,这些库可以提高您测试的可读性和功能。
-
Shouldly 和 FluentAssertions 为您提供了编写断言的可读性语法。
-
Bogus 允许您为不重要的值生成随机测试数据。
-
Moq 和 NSubstitute 帮助您隔离依赖并提供用于测试的替代实现。
-
Snapper 和 Scientist .NET 有助于捕捉复杂对象以微妙方式发生变化的问题。
并非每个项目都能从这些库中的每一个都受益。然而,了解您可用的工具将帮助您在重构和维护代码以及扩展测试时。
虽然在不使用这些库的情况下可以完成本章中的所有事情,但所有这些库都代表了致力于解决特定技术问题的成熟社区项目。
在下一章中,我们将通过讨论使用现代 C# 的防御性编码实践来结束本书的这一部分。
问题
-
您的测试代码的哪些领域可以更具可读性?本章中是否有任何库可能有所帮助?
-
模拟库如 Moq 和 NSubstitute 如何帮助进行测试?
-
你是否在你的代码中看到了任何复杂度足够高,以至于 Snapper 或 Scientist .NET 可能能够帮助的区域?
进一步阅读
你可以在以下网址找到关于本章讨论的库的更多信息:
-
Shouldly:
github.com/shouldly/shouldly -
FluentAssertions:
fluentassertions.com/ -
Bogus:
github.com/bchavez/Bogus -
Moq:
github.com/moq/moq -
NSubstitute:
nsubstitute.github.io/ -
Snapper:
github.com/theramis/Snapper -
Scientist .NET:
github.com/scientistproject/Scientist.net
第十章:防御性编码技术
代码几乎是有机的,在其生命周期中随着新功能的添加、修复的实施以及定期发生的重构而演变。随着代码的变化和开发者的加入与离开,有些变化可能会引入错误。
在本书的第二部分中,我们讨论了在这些问题达到生产之前检测这些错误的测试策略。在本章中,我们将讨论一些额外的技术,这些技术有助于开发者在开发过程中捕获和解决错误。在这个过程中,我们还将探讨 C#的一些新功能及其在保持代码稳定和健康中的作用。
在本章中,我们将涵盖以下主题:
-
验证输入
-
防止空值
-
超越类
-
高级类型使用
技术要求
本章的起始代码可以从 GitHub 的github.com/PacktPublishing/Refactoring-with-CSharp中的Chapter10/Ch10BeginningCode文件夹获取。
本章中的代码与 REST API 通信,这将需要一个活跃的互联网连接。
介绍多云天空 API
我们虚构的示例组织“多云天空”已经存在一套以公共REST API形式存在的网络服务。这个 API 旨在允许感兴趣的机构通过 API 获取关于多云天空航班的详细信息。然而,大量的支持工单证明,组织在使用 API 和以批准的方式使用它时遇到了困难。
作为回应,多云天空构建了一个.NET 库,以帮助其他人更容易地使用 API。
这个库的早期测试很有希望,但一些开发者仍然遇到一些令人困惑的错误,最终似乎与传递给库的数据有关。
开发团队决定,通过提前验证公共方法的参数,可以帮助他们更快地发现并解决问题,从而提高他们库的采用率。我们将在下一节中探讨这个变化。
验证输入
输入验证是在执行请求的工作之前验证代码的任何输入(如参数或当前属性值)是否正确的行为。我们通过验证公共方法来早期检测潜在问题。
为了说明这一点的重要性,让我们看看一个没有验证其输入的方法:
public FlightInfo? GetFlight(string id, string apiKey) {
RestRequest request = new($"/flights/{id.ToLower()}");
request.AddHeader("x-api-key", apiKey);
LogApiCall(request.Resource);
return _client.Get<FlightInfo?>(request);
}
GetFlight方法接受一个id参数,表示航班号,例如“CSA1234”,而apiKey参数代表一个必须提供的令牌,用于与 API 交互并获取响应。将令牌想象成多云天空向希望与其 API 交互的感兴趣机构发放的数字钥匙卡。每个发送到多云天空 API 的请求都必须包含一个令牌以进行身份验证并获取结果。
id参数很重要,因为它用于识别我们感兴趣的航班。此参数通过使用 RestSharp 库向 URL 添加,该库是现代.NET 中与 Web 服务交互的许多方式之一,用于向代码执行 HTTP GET 请求。
不要慌张!
如果任何 Web 服务代码或身份验证令牌的处理超出了你的舒适区,不要担心。虽然这些是在你成长过程中应该学习的概念,但 Web API 的实际机制对于本章来说并不重要。相反,我们专注于参数验证。
既然我们已经确定了这个方法的作用,让我们谈谈它如何可以做得更好。
首先,对于字符串的任何值都是有效的,无论是id还是apiKey。这包括 null、空或空白字符串等值。虽然你可能认为开发者不会尝试这些参数的值,但我可以想到一些合理的理由,某人可能会尝试其中一个:
-
有些人可能会尝试为
id参数传递 null,认为这将获取下一班航班、所有航班,甚至可能是随机航班。 -
没有 API 密钥的开发者可能会认为 API 密钥仅适用于修改服务器上数据的请求,或者在没有 API 密钥的情况下以低量与 API 交互。
虽然这两个假设对于这个 API 都是不正确的,但我可以想象一个不了解系统的人会尝试其中任何一个。在 Cloudy Skies 的情况下,不提供有效的 API 密钥将导致服务器返回 401 未授权错误。
另一方面,不提供id参数会导致代码在尝试将id转换为小写时出现NullReferenceException错误,如图图 10.1所示:

图 10.1 – 当调用 ToLower 时,由于 id 为 null 导致的 NullReferenceException 错误
这两个错误都是试图与该代码交互的开发者可能会遇到的问题,而且这两个错误都没有充分地告诉开发者他们在传递的参数中犯了错误。让我们通过验证来修复这个问题。
执行基本验证
验证的目标是在早期检测到不良输入,并在不良数据进一步进入我们的系统之前明确指出这些问题。在构建库时,这意味着我们希望在代码中尽可能早地验证发送给我们的参数,最好是在其他开发人员将与之交互的公共方法中。
这里是GetFlight的一个版本,它执行了一些额外的验证步骤:
public FlightInfo? GetFlight(string id, string apiKey) {
if (string.IsNullOrEmpty(apiKey)) {
throw new ArgumentNullException("apiKey");
}
if (string.IsNullOrEmpty(id)) {
throw new ArgumentNullException("id");
}
if (!id.StartsWith("CSA",
StringComparison.OrdinalIgnoreCase)) {
throw new ArgumentOutOfRangeException("id", "Cannot
lookup non-CSA flights");
}
RestRequest request = new($"/flights/{id.ToLower()}");
request.AddHeader("x-api-key", apiKey);
LogApiCall(request.Resource);
return _client.Get<FlightInfo?>(request);
}
在这里,我们检查apiKey或id是否为 null 或空字符串。如果是这样,我们抛出一个ArgumentNullException错误,告诉调用此方法的任何人他们没有为特定参数提供有效的值。
我们还检查 id 是否指向带有 Cloudy Skies Airline 前缀的航班。如果不是,由于系统没有跟踪这个航班,这个航班将永远不会被找到。在这种情况下,使用 ArgumentOutOfRangeException 错误来提醒调用者是有意义的。这种异常类型也常与超出方法可接受范围的数字或日期一起使用。
我们真的应该在这里抛出异常吗?
许多新开发者认为异常是坏事。大多数开发者都讨厌遇到异常,抛出异常确实可能相对较慢。考虑到这些因素,当你得到无效值时,有时,最佳选择是抛出一个突出问题的特定异常。这有助于快速捕获错误,并防止无效值进一步进入系统。
你可能已经注意到,修改后的代码与该方法中的其他逻辑相比有很多验证。有几种方法可以改进这一点,我们将在接下来的章节中看到,但让我们逐步实现这个目标。我们将从查看引用不良参数值的好方法开始。
使用 nameof 关键字
目前,代码使用如下方式验证参数并抛出异常:
throw new ArgumentNullException("apiKey");
在这个例子中,"apiKey" 指的是参数的名称,这有助于开发者识别异常所抱怨的参数。
现在,如果有人后来将那个参数重命名为 apiToken,会发生什么?这个更改不会导致编译器错误,异常仍然可以抛出。不幸的是,异常将引用不再存在的旧 apiKey 参数名称,这可能会让遇到错误的开发者感到困惑。
为了帮助解决这个问题,C# 提供了 nameof 关键字,其语法如下:
public FlightInfo? GetFlight(string id, string apiKey) {
if (string.IsNullOrEmpty(apiKey)) {
throw new ArgumentNullException(nameof(apiKey));
}
当你的代码编译时,nameof 关键字会评估它所使用的参数、方法或类的名称。然后,包含该 nameof 评估结果的字符串将被包含在编译后的代码中。换句话说,它与之前的代码相同——只不过如果参数被重命名,我们的代码将无法编译,直到 nameof 关键字更新为引用重命名的参数。
这允许我们依赖编译器来帮助我们确保我们的参数验证使用了正确的参数名称,即使这些参数在未来被重命名。
让我们介绍一种更简洁的抛出异常的方法。
使用守卫子句进行验证
目前,我们的验证逻辑由一个 if 语句和一个条件 throw 语句组成。这种验证非常常见,当验证复杂时,它可能会占用很多代码行。因此,.NET 现在以 守卫子句 的形式提供了一种更简洁的与之交互的方式。
我们可以通过调用 ArgumentException.ThrowIfNullOrEmpty 将验证简化为单行代码,如下所示:
public FlightInfo? GetFlight(string id, string apiKey) {
ArgumentException.ThrowIfNullOrEmpty(id, nameof(id));
此方法将检查传入参数的值,如果值为空,则抛出ArgumentNullException错误;如果值为空字符串,则抛出ArgumentException错误。
目前.NET 中内置的这些验证并不多,但如果你喜欢这个想法,并且想要对负值或数字或日期范围等进行验证,你将喜欢 Steve Smith 的优秀的GuardClauses 库。
使用 GuardClauses 库的保护子句
为了帮助增强内置的保护子句,Steve Smith 创建了Ardalis.GuardClauses库。
要使用 GuardClauses 库,通过 NuGet 包管理器安装 Ardallis.GuardClauses 的最新版本,就像我们在前面的章节中所做的那样。
接下来,将using Ardalis.GuardClauses;添加到你的.cs文件顶部。
一旦安装并引用,你将能够使用保护语法,如下面的代码所示:
public Flights GetFlightsByMiles(int maxMiles,
string apiKey) {
Guard.Against.NegativeOrZero(maxMiles);
Guard.Against.NullOrWhiteSpace(apiKey);
// Other logic omitted…
}
在这里,GuardClauses 库在Guard.Against语法内部提供了各种静态方法,允许你验证许多事物。
如果验证条件满足——例如,当调用NegativeOrZero时maxMiles为4——程序将正常继续。然而,如果条件不满足,将抛出一个包含违反条件参数名称的ArgumentException错误。
我发现这个库易于编写和阅读,它还导致了高效且有效的保护子句,这些子句需要最少的努力。
GuardClauses 库的完整范围超出了本书的范围,但你可以安装它并查看可用的方法,或者查看本章末尾进一步阅读部分中引用的文档。
但等等——还有更多!
本书出色的技术审稿人正确地指出了流行的FluentValidation 库,该库提供了一组丰富的验证规则,可以应用于你的类。你可以在进一步阅读部分了解更多关于这个库的信息。
在我们继续之前,我想指出Ardalis.GuardClauses库的一个你可能没有注意到的方面。
假设你使用Guard.Against.Null(apiKey);调用保护子句。
如果这个验证规则失败,它将抛出一个ArgumentException错误。这个异常将有一个ParamName属性,其值为apiKey。此外,生成的消息将按名称提及apiKey参数,即使你在调用 保护子句时没有提供该名称。
这是因为库使用了CallerArgumentExpression属性,我们将在下一节中探讨。
使用 CallerMemberInformation 属性
nameof关键字在消除后来被重命名的字符串引用方面取得了如此成功,以至于 C#发展出了四个独立的属性,可以告诉你关于任何给定方法的信息。
这些属性都应用于方法参数。像 nameof 关键字一样,这些属性在编译时进行评估,并在最终编译代码中用 string 或 int 类型替换。
可用的四个调用者成员属性如下:
-
CallerFilePath 包含一个字符串,包含在编译代码的机器上调用方法的代码文件的名称和路径
-
int类型的行号用于方法调用 -
CallerMemberName 包含发生方法调用时的方法或属性名称
-
CallerArgumentExpression 在评估表达式之前,将传递给方法的表达式转换为字符串
让我们以 LogApiCall 为例来展示这一点:
public static void LogApiCall(string url,
[CallerFilePath] string file = "",
[CallerLineNumber] int line = 0,
[CallerMemberName] string name = "",
[CallerArgumentExpression(nameof(url))] string expr = "")
{
Console.WriteLine($"Making API Call to {url}");
Console.WriteLine("Called in:");
Console.WriteLine($"{file}:{line} at {name}");
Console.WriteLine($"Url expression: {expr}");
}
此方法接受五个参数,其中第一个是标准字符串参数,其余四个使用各种调用者成员信息属性。注意这些属性都指定了默认值。当未指定这些参数的值时,编译器将用它在编译期间检测到的值替换每个参数。
让我们看看一个示例调用:
public IEnumerable<FlightInfo> GetFlightsByMiles(
int maxMiles, string apiKey) {
// Validation omitted...
string url = $"/flights/uptodistance/{maxMiles}";
RestRequest request = new(url);
request.AddHeader("x-api-key", apiKey);
LogApiCall(request.Resource);
IEnumerable<FlightInfo>? response =
_client.Get<IEnumerable<FlightInfo>>(request);
return response ?? Enumerable.Empty<FlightInfo>();
}
注意当 LogApiCall 被调用时,只指定了字符串参数。其余参数由于每个参数上的属性,在编译期间提供了值。
此外,请注意用于获取该字符串的表达式是 request.Resource。这个表达式是 CallerArgumentExpression 属性用来生成其字符串的原因,因为 CallerArgumentExpression 属性需要另一个参数的名称。在这种情况下,我们指定了 [CallerArgumentExpression(nameof(url))],以便它查看传递给 url 参数的表达式——方法是接受的第一个参数。
当此代码运行时,我们将在控制台中看到以下消息记录:
Making API Call to /flights/uptodistance/500
Called in:
C:\RefactorBook\Chapter10\CloudySkiesFlightProvider.cs:51
at GetFlightsByMiles
Url expression: request.Resource
如您所见,它记录了我硬盘上文件的完整路径,以及 LogApiCall 方法调用的行号。
request.Resource 的表达式是用于调用该方法的精确代码字符串,如下所示:
LogApiCall(request.Resource);
调用者成员信息属性对于某些类型的事情非常有用,例如日志记录和验证,或者某些特定的场景,例如在 Windows Presentation Foundation (WPF)应用程序中引发 INotifyProperty 改变。
现在我们已经充分探讨了与我们的方法参数一起工作,让我们看看现代 C# 如何让我们安全地处理空值。
防止空值
英国计算机科学家托尼·霍尔(Tony Hoare)通常被认为是编程中空引用的发明者。2008 年,他因它而著名地道歉,称其为他的“十亿美元的错误”。这是由于在多种编程语言中,当代码尝试与当前持有空值的变量交互时,发生了无数的错误和崩溃。虽然我不能责怪托尼·霍尔,但空引用确实可能很危险。
在 .NET 中,这以 NullReferenceException 错误的形式出现,正如我们在本章前面所看到的。每次尝试调用包含 null 值的变量的方法或评估其属性时,都会出现 NullReferenceException 错误。
在 C# 8 之前,开发者需要明确意识到任何引用类型都可能包含 null 值,并编写条件逻辑,如下面的代码所示:
if (flight != null) {
Console.WriteLine($"Flight {flight.Id}: {flight.Status}");
}
这种检查可空性然后条件性采取行动的模式在 C# 中变得普遍,因为当它没有这样做时,开发者会遇到 NullReferenceException 错误。不幸的是,这导致了代码中到处都有 null 检查,包括许多永远不会遇到 null 的地方。
在 C# 8 中,引入了可空引用类型,这有助于开发者了解何时何地可能会遇到 null 值,以便他们会有积极的提醒来防止在这些地方出现 null 值。此外,这些改进使得在预期不会出现 null 的地方移除不必要的 null 检查变得更加容易。
在 C# 8 及更高版本中,当启用可空性分析时,您可以通过在类型指示符后添加 ? 来指示任何引用类型可能是 null,就像这里对 FlightInfo 所示的那样:
public FlightInfo? GetFlight(string id, string apiKey) {
ArgumentException.ThrowIfNullOrEmpty(id);
ArgumentException.ThrowIfNullOrEmpty(apiKey);
RestRequest request = new($"/flights/{id.ToLower()}");
request.AddHeader("x-Api-key", apiKey);
LogApiCall(request.Resource);
return client.Get<FlightInfo?>(request);
}
在这种情况下,这表示 GetFlight 方法将返回一个 FlightInfo 实例或一个 null 值。此外,这也表示 id 和 apiKey 参数将始终有一个非 null 的字符串。如果这些参数接受 null 值,它们将被声明为 string? Id, string? apiKey。
重要提示
C# 中的可空性分析不会阻止您将 null 传递给声明不接受 null 值的对象,也不会阻止您从声明返回非 null 返回类型的方法中返回 null 值。相反,可空性分析将这些情况标记为警告,这将帮助您解决这些问题。我们将在 第十二章 中更多地讨论代码分析警告。
如果我们想表示 GetFlight 永远不会返回 null,我们需要从 FlightInfo 返回类型中移除 ? 并验证 API 的结果不是 null:
public FlightInfo GetFlight(string id, string apiKey) {
ArgumentException.ThrowIfNullOrEmpty(id);
ArgumentException.ThrowIfNullOrEmpty(apiKey);
RestRequest request = new($"/flights/{id.ToLower()}");
request.AddHeader("x-api-key", apiKey);
LogApiCall(request.Resource);
FlightInfo? flightInfo=_client.Get<FlightInfo?>(request);
if (flightInfo == null) {
string message = $"Could not find flight {id}";
throw new InvalidOperationException(message);
}
return flightInfo;
}
通过 _client.Get 对 API 的请求仍然可能返回一个可空值,因此代码现在必须检查 null,并在遇到 null 值时条件性地抛出异常。然而,这保证了代码只返回非 null 值,这是在启用可空性分析时 FlightInfo 返回类型所指示的。
让我们看看如何在 Visual Studio 中启用和禁用可空性分析。
在 C# 中启用可空性分析
自 .NET 6 以来,新的项目默认启用可空引用类型。
然而,您可以通过在项目的 .csproj 文件中添加 <Nullable>enable</Nullable> 节点来在任何使用 C# 8 或更高版本的项目中启用可空引用类型:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Packt.CloudySkiesAir</RootNamespace>
</PropertyGroup>
</Project>
您可以使用文本编辑器(如记事本)或双击解决方案资源管理器中的项目节点来在 Visual Studio 内部编辑此文件。
如果您不希望在整个项目中启用空值分析,您可以使用预处理语句如#nullable enable和#nullable disable来启用和禁用空值分析。例如,以下代码暂时禁用了类定义的空值分析:
#nullable disable
public class FlightInfo {
public string Id { get; set; }
public FlightStatus Status { get; set; }
public string Origin { get; set; }
public string Destination { get; set; }
public DateTime DepartureTime { get; set; }
public DateTime ArrivalTime { get; set; }
public int Miles { get; set; }
public override string ToString() =>
$"{Id} from {Origin} to {Destination} " +
$"on {DepartureTime}. Status: {Status}";
}
#nullable restore
我鼓励您使用项目级别的空值分析,并在可能的情况下避免使用#nullable。我认识很多开发者每次看到预处理语句都会感到恶心。我的观点是,#nullable应该保留在您将大型项目迁移到使用空值分析,但尚未准备好在整个项目中启用它的情况下使用。
使用空值运算符
之前,我们讨论了?如何表示一个类型可能包含 null 值,但在 C#中还有几个与空值相关的运算符您应该了解。
首先,!非空断言运算符告诉 C#某个值不会是 null,并忽略该值的空值警告。
我常用这个功能是在处理Console.ReadLine()时。此方法指示它可能返回 null 值,但在实际操作中,在正常操作中它永远不会返回 null。这可以通过!来抑制,如下所示:
Console.WriteLine("Enter a flight #: ");
string id = Console.ReadLine()!;
在这里,我们将ReadLine(定义为具有string?结果并将其存储在string中)作为例子。!运算符表示string?结果应被视为string。
其他空值运算符包括以下内容:
-
?运算符仅在对象不是 null 时才条件性地调用方法。例如,_conn?.Dispose()仅在_conn不是 null 时调用Dispose方法。 -
??运算符在某个值可能为 null 的情况下使用备份值。例如,int miles = flight?.Miles ?? 0;使用空条件运算符和空合并运算符安全地从航班中获取Miles,或者在不存在航班时使用0。 -
??=运算符仅在变量已经是 null 时才将值赋给变量。例如,message ??= "An unexpected error has occurred";仅在message是 null 时才设置新的错误消息。这允许我们有效地用备份值替换 null 值。
空值分析和空值运算符的组合帮助我们以简洁的方式就 null 值做出明智的决策。这使我们的代码保持高效和专注,同时引导我们围绕处理代码中的 null 值制定一致的战略。
让我们更广泛地看看我们可以在类级别进行哪些更改,以帮助设计更健壮的应用程序。
超越类
在 C# 9 及更高版本中,微软通过记录类型、只读属性、主构造函数等方式,为开发者提供了更多与类一起工作的选项。
在本节中,我们将探讨这些新的 C#结构如何改进你的类的设计。
倾向于使用不可变类
近年来,不可变类越来越受欢迎。这种不可变性指的是在对象创建后无法更改对象的能力。
这意味着一旦对象存在,你无法修改其状态,而是只能创建新的对象,这些新对象与原始对象类似。如果你熟悉在.NET 中使用字符串和 DateTime 对象,你已经在字符串的ToLower方法和 DateTime 的AddDays方法上看到了这个概念,这些方法返回一个新对象而不是修改原始对象。
让我们看看一个小类,它代表一个当前可变的登机牌,然后将其转换为不可变类:
public class BoardingPass {
public FlightInfo Flight { get; set; }
public string Passenger { get; set; }
public int Group { get; set; }
public string Seat { get; set; }
}
这是一个“普通的 C#对象”,具有具有获取器和设置器的属性。从逻辑上考虑这个类,有几个问题:
-
没有任何东西阻止
Flight、Passenger或Seat具有空值。 -
一旦创建了一个登机牌,属性如
passenger、boarding group、seat甚至flight都可以被更改。在航空业务中,这并不合理,因为需要发放新的登机牌来更改这些信息。
我们可以通过移除设置器并添加一个带有验证的构造函数来改变这个对象,使其不可变并要求这些参数有有效的值:
public BoardingPass(FlightInfo flight, string passenger,
string seat, int group) {
ArgumentNullException.ThrowIfNull(flight);
ArgumentException.ThrowIfNullOrEmpty(passenger);
ArgumentException.ThrowIfNullOrEmpty(seat);
if (group < 1 || group > 8) {
throw new ArgumentOutOfRangeException(nameof(group));
}
Flight = flight;
Passenger = passenger;
Seat = seat;
Group = group;
}
现在这个构造函数要求在对象创建时所有属性都存在有效值。同时,属性设置器的移除确保了类保持有效且不能被更改。
如果需要,我们可以在BoardingPass类中添加新方法,以类似各种字符串和 DateTime 方法的方式创建并返回一个具有与原始对象类似特征的新BoardingPass对象。然而,“with 表达式”给我们提供了一个更有趣的方式来做到这一点,正如我们将在本章后面看到的。
虽然一开始使用不可变性可能看起来比有益更不方便,但使用不可变类有几个关键优势:
-
不可变类可以在创建时进行验证,并确保它们处于有效状态。一旦创建,这种有效状态就不会改变。
-
当对象可以在你的代码的任何地方被修改时,这会使追踪一个对象何时被多个其他类引用变得更加困难。不可变对象防止这种情况发生。
-
有些概念作为不可变对象更有意义,例如文档的早期版本或机场乘客的登机牌。
-
由于不可变对象不会改变,它们可以在多线程应用程序中可靠地使用。如果没有不可变性,你需要依赖使用
Interlocked、lock关键字或线程安全集合来避免错误。
当然,对于具有许多属性的类,必须在构造函数中指定所有属性可能会很繁琐。此外,你的项目中的每个类都不需要是不可变的。对于那些将受益于不可变性的类,C# 的必需关键字和只读属性有助于减轻这种负担。
使用必需和只读属性
将每个属性作为参数添加到类构造函数中的缺点是,你的构造函数可能会变得比你想要的更大。此外,创建需要许多构造函数参数的对象既繁琐又容易出错,并且创建单个对象可能会很繁琐和令人困惑,尤其是当需要许多构造函数参数时。
另一方面,对象初始化器可能更易读,但直到最近,它们还没有一种确保属性存在的方法。
看看创建 BoardingPass 对象的两种方式,看看哪一种对你来说更易读:
BoardingPass p1 = new(myFlight, "Amleth Hamlet", "2B", 1);
BoardingPass p2 = {
Flight = myFlight,
Passenger = "Amleth Hamlet",
Seat = "2B",
Group = 1
};
在 p2 中使用的对象初始化器版本更易读且易于维护,尤其是随着你想要在类中设置的属性数量随着时间的推移而增长。
这种方法的传统缺点是,使用对象初始化器的开发者可能会忘记设置重要的必需属性。C# 11 引入了 required 关键字,如果在对象初始化或构造函数中省略了 Passenger 属性时没有显式初始化必需属性,则编译将失败,如 图 10.2 所示。2*:

图 10.2 – 由于未设置 Passenger 属性导致的编译错误
为了实现这一点,我们可以在类中添加 required 到任何我们想要确保在对象初始化器完成时显式设置的属性定义。以下版本的 BoardingPass 特性具有必需属性:
public class BoardingPass {
public required FlightInfo Flight { get; init; }
public required string Passenger { get; init; }
public required int Group { get; init; }
public required string Seat { get; init; }
}
你可能也注意到,这个类定义将这些属性定义为 {get; init;} 而不是 {get;} 或 {get; set;}。虽然传统的 get; set; 组合允许在任意时间更改属性,但这违反了不可变性。get; 版本移除了在除构造函数之外的地方设置属性的能力,这意味着定义为 get; 的属性不能在对象初始化器中设置。
在 C# 9 中添加的新 get; init; 组合允许在构造函数或初始化器中设置属性,但不再允许在对象初始化后设置。这有助于我们支持我们的不可变类设计,同时不限制用户使用构造函数。
我认为对象初始化器是 .NET 的未来,如今在设计用于不可变性的类时,往往更倾向于使用 get; init; 的必需属性。
说到未来,让我们看看 C# 12 中的一个全新的特性:引用类型的主构造函数。
主构造函数
主构造函数是必须调用来初始化类并提供在类中自动创建字段的方法的构造函数。我们稍后会详细讨论“必须调用”这个短语的含义,但让我们先来看一个简单的例子:
public class BoardingPass(string Passenger) {
public required FlightInfo Flight { get; init; }
public required int Group { get; init; }
public required string Seat { get; init; }
public override string ToString() =>
$"{Passenger} in group {Group} " +
$"for seat {Seat} of {Flight.Id}";
}
这个版本的BoardingPass在类声明后立即有括号和参数列表。这是类的首选构造函数。
在主构造函数中声明的任何参数都可以作为只读属性使用。这使得主构造函数大致等同于以下 C#代码:
public class BoardingPass {
public BoardingPass(string passenger) {
this.Passenger = passenger;
}
public string Passenger {get; init; }
// Other members omitted for brevity...
}
主构造函数的优势在于它们非常简洁,不需要你定义构造函数或字段定义。
主构造函数可以与其他构造函数一起使用,尽管你声明的任何其他构造函数都必须使用this关键字调用主构造函数,如下所示:
public class BoardingPass(string Passenger) {
public BoardingPass(FlightInfo flight, string passenger)
: this(passenger) {
Flight = flight;
}
// other members omitted for brevity...
}
实际上,你的主构造函数必须始终被调用——要么单独调用,要么通过this关键字从另一个构造函数中调用。
主构造函数不仅限于类,从 C# 9 开始,也存在于记录中。
将类转换为记录类
在整本书中,我多次提到了记录类,但没有定义它们,也没有详细说明为什么你想使用一个。
要理解记录类,让我们简要谈谈类中的相等性。默认情况下,如果两个对象都位于堆中的相同内存地址,则认为它们相等。
这意味着默认情况下,具有相同属性的两个单独对象不相等。例如,以下代码将这两个登机牌评估为彼此不同:
BoardingPass pass1 = new("Amleth Hamlet") {
Flight = nextFlight,
Seat = "2B",
Group = 2
};
BoardingPass pass2 = new("Amleth Hamlet") {
Flight = nextFlight,
Seat = "2B",
Group = 2
};
Console.WriteLine(pass1 == pass2); // false
你可以通过在BoardingPass类上重写Equals和GetHashCode来改变这种行为,就像我们在第五章中所做的那样。然而,记录类型为我们提供了一种更简单的方式来管理这一点。
记录类类似于正常的 C#类,除了相等性是通过比较所有属性来实现的。换句话说,记录类就像是重写了Equals和GetHashCode的正常 C#类。
让我们重新声明登机牌为一个记录类:
public record class BoardingPass(string Passenger) {
public required FlightInfo Flight { get; init; }
public required int Group { get; init; }
public required string Seat { get; init; }
public override string ToString() =>
$"{Passenger} in group {Group} " +
$"for seat {Seat} of {Flight.Id}";
}
现在,我们只需使用它们的值就可以成功比较两个登机牌:
BoardingPass pass1 = new("Amleth Hamlet") {
Flight = nextFlight,
Seat = "2B",
Group = 2
};
BoardingPass pass2 = new("Amleth Hamlet") {
Flight = nextFlight,
Seat = "2B",
Group = 2
};
Console.WriteLine(pass1 == pass2); // true
这两个类被认为是相等的,因为它们携带相同的值。请注意,Flight属性引用一个FlightInfo对象,它仍然是一个标准的 C#类,并使用传统的引用相等性。这意味着登机牌必须指向内存中的相同FlightInfo对象;否则,它们将不被视为相等。这可以通过将FlightInfo也改为记录类来改变。
我建议使用记录类来比较小型对象。它们也可能对那些可能频繁实例化的类有所帮助,例如来自数据库或外部 API 调用的对象。
让我们继续谈谈我最喜欢的创建对象的新方法:with表达式。
使用with表达式克隆对象
with表达式与不可变记录配合得非常好,它允许您在不修改原始记录的情况下克隆并稍微调整源记录。
假设哈姆雷特在航班上 2B 座的登机牌需要更改。系统可以使用以下代码实例化一个新的登机牌,它与原始登机牌完全一样,只是座位是 2C:
BoardingPass pass = new("Amleth Hamlet") {
Flight = nextFlight,
Seat = "2B",
Group = 2
};
BoardingPass newPass = pass with { Seat = "3B" };
这将基于原始登机牌创建一个新的登机牌,但有一个属性略有不同。
如果我们想将哈姆雷特移动到新的座位但更早的登机组,我们也可以通过列出额外的属性来实现,如下所示:
BoardingPass newPass2 = pass with {Seat = "3B", Group = 1};
我认为with表达式是使用 C#中的记录类工作时最令人兴奋的事情之一,我非常喜欢语言在简化对象创建方面的方向。
这种引用属性值的方式并不仅限于with表达式,正如我们将在下一节中通过模式匹配看到的那样。
高级类型使用
在本章的最后一节中,我们将看到新旧语言特性如何帮助您构建更好的类型。
探索模式匹配
结果表明,我们可以使用之前在表达式中使用的相同语法风格,通过模式匹配有条件地匹配不同的对象。
为了解释我的意思,让我们从一个遍历不同登机牌的例子开始:
List<BoardingPass> passes = PassGenerator.Generate();
foreach (BoardingPass pass in passes) {
if (pass is { Group: 1 or 2 or 3,
Flight.Status: FlightStatus.Pending
}) {
Console.WriteLine($"{pass.Passenger} board now");
} else if (pass is { Flight.Status: FlightStatus.Active
or FlightStatus.Completed
}) {
Console.WriteLine($"{pass.Passenger} flight missed");
} else {
Console.WriteLine($"{pass.Passenger} please wait");
}
}
此代码遍历一组登机牌,并执行以下三种操作之一:
- 如果航班状态是
Pending且乘客在 1、2 或 3 组,我们会告诉他们登机。
如果航班状态是Active或Completed,我们会告诉乘客他们错过了航班。
如果这两种情况都不成立,则航班状态必须是Pending,但乘客的组没有登机,因此我们会告诉他们等待。
代码有点随意,尤其是在处理登机组方面,但它展示了模式匹配的一些功能。
使用模式匹配,您可以在if语句中评估对象的一个或多个属性,以简洁地同时检查多个事项。
虽然您可以在if语句中使用模式匹配,但它们也常用于switch表达式,正如我们在第三章中看到的。我们可以将之前的代码重写为switch表达式,如下所示:
List<BoardingPass> passes = PassGenerator.Generate();
foreach (BoardingPass pass in passes) {
string message = pass switch {
{ Flight.Status: FlightStatus.Pending,
Group: 1 or 2 or 3 }
=> $"{pass.passenger} board now",
{ Flight.Status: not FlightStatus.Pending }
=> $"{pass.passenger} flight missed",
_ => $"{pass.passenger} please wait",
};
Console.WriteLine(message);
}
在这里,我们可以看到switch表达式概念与模式匹配的强大功能相结合,以设置message变量中的字符串。请注意,为了简洁和说明not关键字在否定或反转模式匹配表达式中的用法,代码使用not FlightStatus.Pending而不是FlightStatus.Active or FlightStatus.Completed。
虽然这段代码需要一些调整才能学会阅读,但在这个语法中几乎没有“浪费”。几乎每一行代码都是围绕必须为真的条件或当它们为真时要使用的值来组织的。此外,这种语法比正常的 C#逻辑更容易处理更复杂的场景,例如或和不是语句。
当然,就像任何新的 C#语言特性一样,如果你和你团队的可读性成本太高,你可以自由地完全避免 switch 表达式和模式匹配。
现在我们已经看到了模式匹配和 switch 表达式在 C#最新版本中的协同工作方式,让我们来看看 C#最早期的增强功能之一:泛型。
使用泛型来减少重复
泛型是每个.NET 开发者每天都会遇到并与之工作的一个概念。
当你与List<string>(发音为“字符串列表”)一起工作时,你正在使用一个泛型List对象,它可以持有特定类型的对象——在这种情况下,字符串。
泛型通过指定至少一个类型参数来实现,该参数进入类或方法中,并允许类或方法围绕该类型进行结构化。
为了说明泛型的优势,让我们看看一个非常简单的FlightDictionary类,它使用字典存储通过其标识符的FlightInfo对象,并包含一些简单的控制台日志记录:
public class FlightDictionary {
private readonly Dictionary<string, FlightInfo> _items =
new();
public bool Contains(string identifier)
=> _items.ContainsKey(identifier);
public void AddItem(string id, FlightInfo item) {
Console.WriteLine($"Adding {id}");
_items[id] = item;
}
public FlightInfo? GetItem(string id) {
if (Contains(id)) {
Console.WriteLine($"Found {id}");
return _items[id];
}
Console.WriteLine($"Could not find {id}");
return null;
}
}
这个类是新的集合类的一个起点,就像.NET 提供的Dictionary类一样。它允许外部调用者通过字符串标识符添加、检索和检查FlightInfo。
虽然这段代码非常简单,并且缺少了我期望从真实集合类中看到的一些功能,但它足以说明通过提出以下问题来体现泛型的必要性:如果我们非常喜欢这个类来处理FlightInfo对象,那么我们是否希望用它来处理BoardingPass对象?
经常会出现这样的情况,有人会复制粘贴FlightDictionary类来创建一个新的BoardingPassDictionary类,如下所示:
public class BoardingPassDictionary {
private readonly Dictionary<string, BoardingPass> _items
= new();
public bool Contains(string identifier)
=> _items.ContainsKey(identifier);
public void AddItem(string id, BoardingPass item) {
Console.WriteLine($"Adding {id}");
_items[id] = item;
}
public BoardingPass? GetItem(string id) {
if (Contains(id)) {
Console.WriteLine($"Found {id}");
return _items[id];
}
Console.WriteLine($"Could not find {id}");
return null;
}
}
这两个类之间的唯一区别是存储的项目类型。
泛型让我们能够声明一个类,它接受用于不同操作的类型的参数。
现在,让我们看看这个类的更可重用版本,它接受用于每个项目键的类型以及用于值的泛型类型参数:
public class LoggingDictionary<TKey, TValue> {
private readonly Dictionary<TKey, TValue> _items
= new();
public bool Contains(TKey identifier)
=> _items.ContainsKey(identifier);
public void AddItem(TKey id, TValue item) {
Console.WriteLine($"Adding {id}");
_items[id] = item;
}
public TValue? GetItem(TKey id) {
if (Contains(id)) {
Console.WriteLine($"Found {id}");
return _items[id];
}
Console.WriteLine($"Could not find {id}");
return default(TValue);
}
}
这个类的实现依赖于两个泛型类型参数:TKey和TValue。这些参数可以是任何你想要的名字,但惯例是使用T。
使用这个类,可以创建一个新的LoggingDictionary,以支持你可能想要支持的任何类型,语法如下:
LoggingDictionary<string, BoardingPass> passDict = new();
LoggingDictionary<string, FlightInfo> flightDict = new();
泛型是自.NET Framework 2.0 以来就存在的东西,但今天它仍然为类添加可重用性提供了价值。
让我们以对 C# 12 的一个新功能:类型别名的简要概述来结束这一章。
使用 using 指令引入类型别名
假设你正在开发一个系统,你需要处理一组不确定的类型,并且可能在未来需要更改这些类型。或者,你可能有一个常规需求,需要一些看起来很糟糕的类型,例如在整个类中处理 List<string, Dictionary<Passenger, List<FlightInfo>>>。
对于后一个问题的一个方法可能是将你的类引入以隐藏一些这种复杂性,但在 C# 12 中有一个新的选项是使用 using 语句。
让我们看看如何简化 CloudySkiesFlightProvider.cs 中的某些代码,以减少 IEnumerable<FlightInfo> 出现的地方。我们将使用 GetFlightsByMiles 方法作为示例:
public IEnumerable<FlightInfo> GetFlightsByMiles(
int maxMiles, string apiKey) {
RestRequest request =
new($"/flights/uptodistance/{maxMiles}");
request.AddHeader("x-api-key", apiKey);
LogApiCall(request.Resource);
IEnumerable<FlightInfo>? response =
_client.Get<IEnumerable<FlightInfo>>(request);
return response ?? Enumerable.Empty<FlightInfo>();
}
这段代码并不差,但想象一下,如果你非常强烈地不喜欢在所有地方看到 IEnumerable<FlightInfo>,你更愿意为这个定义一个自定义类型。
使用 C# 12,你可以在文件的 using 语句中添加以下行:
using Flights = System.Collections.Generic.IEnumerable<
Packt.CloudySkiesAir.Chapter10.FlightInfo>;
通过这一变化,你现在可以将你的方法更改为使用你的新类型别名:
public Flights GetFlightsByMiles(int maxMiles,
string apiKey) {
RestRequest request =
new($"/flights/uptodistance/{maxMiles}");
request.AddHeader("x-api-key", apiKey);
LogApiCall(request.Resource);
Flights? response = _client.Get<Flights>(request);
return response ?? Enumerable.Empty<FlightInfo>();
}
这段代码并没有改变你在该方法中处理 IEnumerable<FlightInfo>,但它确实减少了你需要输入的代码量,并简化了读取代码。
此外,如果你想要在这些地方更改到不同的类型,你现在只需要修改 using 语句来使用不同的类型。
我不确定隐藏底层类型是否比潜在的混淆更有益,但我可以想象一些可能有所帮助的地方,尤其是在处理复杂的泛型类型或与元组(多个值的集合)一起工作时。
时间将证明类型别名的效果以及它们最佳的使用位置,但我很高兴我们现在有了这个选项。
摘要
在本章中,我们探讨了确保你的类通过诸如参数验证、调用者成员信息、可空性分析以及使用现代 C# 功能(如记录类、主构造函数、模式匹配和带有 required 和 init 关键字的增强属性)等手段安全且可重用的各种方法。
这些语言特性帮助你更早地检测问题,更有效地处理对象,并总体上减少代码行数。
这本书的 第二部分 结束了。在 第三部分 中,我们将探讨人工智能和代码分析工具如何帮助你和你团队可持续地构建更好的软件。
问题
回答以下问题以测试你对本章知识的了解:
-
抛出异常如何对你的代码有益?
-
你可以用哪些不同的方式在 C# 中声明一个属性?
-
你可以用哪些不同的方式在 C# 中实例化一个对象?
-
类和记录类之间的区别是什么?
进一步阅读
你可以在以下 URL 中找到有关本章讨论的功能的更多信息:
-
Fluent Validation 库:
github.com/FluentValidation/FluentValidation -
调用成员 信息:
learn.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/caller-information -
主构造函数和别名使用:
devblogs.microsoft.com/dotnet/check-out-csharp-12-preview/ -
现代 C#中的安全空值:
newdevsguide.com/2023/02/25/csharp-nullability/ -
C#中的类、结构和记录:
learn.microsoft.com/en-us/dotnet/csharp/fundamentals/object-oriented/ -
选择异常或 验证:
ardalis.com/guard-clauses-and-exceptions-or-validation/
第三部分:使用 AI 和代码分析的高级重构
本书第三部分专注于使用人工智能和 Visual Studio 内置的现代代码分析功能的高级重构技术。
本章介绍了 GitHub Copilot Chat 作为重构、生成、检查、文档化和测试代码的方法。
我们接着通过介绍代码分析工具和规则集以及一些可以帮助捕获额外问题的第三方工具,广泛地覆盖了 Visual Studio 的代码分析功能。最后,我们探讨了 Visual Studio 的代码分析如何基于 Roslyn 分析器,通过构建和部署我们自己的 Roslyn 分析器作为 Visual Studio 扩展和 NuGet 包来展开。
本章将深入理解代码分析问题,以及新的生产力工具,帮助检测和解决代码中的问题。
本部分包含以下章节:
-
第十一章**,使用 GitHub Copilot 进行 AI 辅助重构
-
第十二章**,Visual Studio 中的代码分析
-
第十三章**,创建 Roslyn 分析器
-
第十四章**,使用 Roslyn 分析器重构代码
第十一章:使用 GitHub Copilot 进行 AI 辅助重构
变化是技术的常态,这在 .NET 生态系统中尤为如此。每年,Microsoft 都会发布一个新的 .NET 和 C# 版本,其中包含新功能,以保持语言在技术变化中的兴奋、有用和相关性。但过去两年中,对 .NET 开发影响最大的变化可能并非来自主要语言版本,而是来自人工智能领域,例如 GitHub Copilot 和 ChatGPT 这样的 AI 代理。
在本章中,我们将探讨 GitHub Copilot 如何集成到 Visual Studio 中,并将类似 ChatGPT 的对话式 AI 带入您的编辑器。我们还将探讨这一技术带来的有趣可能性,以及考虑是否将这项新技术纳入我们的工具集时必须注意的一些事项。
本章将涵盖以下主题:
-
介绍 GitHub Copilot
-
在 Visual Studio 中开始使用 GitHub Copilot
-
使用 GitHub Copilot Chat 进行重构
-
使用 GitHub Copilot Chat 撰写文档
-
使用 GitHub Copilot Chat 生成测试想法
-
理解 GitHub Copilot 的局限性
技术要求
本章的起始代码可在 GitHub 的 github.com/PacktPublishing/Refactoring-with-CSharp 上的 Chapter11/Ch11BeginningCode 文件夹中找到。
介绍 GitHub Copilot
2021 年,GitHub 宣布了一款名为 GitHub Copilot 的新人工智能工具。GitHub Copilot 是一个编辑器扩展,可以集成到不同的编辑器中,包括 JetBrains Rider、VS Code 和 Visual Studio 2022 的所有版本。
GitHub Copilot 所做的是查看您刚刚输入的代码,并为它认为您即将输入的代码生成预测。如果它有一个预测,并且您当前没有在输入,GitHub Copilot 会以灰色文本的形式在您的光标前显示预测,供您评估并可能添加到您的代码中,如图 图 11**.1 所示:

图 11.1 – GitHub Copilot 在开发者输入代码时建议添加的代码
Copilot 通过使用一个经过各种不同编程语言(包括 C#、F#、JavaScript 和 SQL)中的代码片段训练的预测机器学习模型来实现这一点。
理解 GitHub 的预测模型
如果这听起来很熟悉,那是因为 GitHub Copilot 的模型是一个围绕一种名为 transformers 的新颖模型训练技术构建的专用机器学习模型。
2017 年在一篇名为 Attention is All You Need 的论文中引入的 Transformer,允许机器学习模型在保留不同文本片段之间关系上下文的情况下,在更大的文本体上进行训练。(research.google/pubs/pub46201/)
这项创新导致了诸如 Google BERT(为 Google 搜索预测提供动力的技术)、MidJourney 和 DALL-E(可以从文本提示生成艺术)等技术的出现,以及 OpenAI 的极为流行的 ChatGPT,它可以模仿与人类的对话。
基于转换器的模型现在通常被称为大型语言模型(LLMs)。它们的超级能力是记住文本中的模式,并生成模仿其模型内部吸收的模式的新文本。
你是否曾想过 GPT 代表什么?
GPT 缩写(在 ChatGPT、GPT-4 和类似系统中找到)代表生成预训练转换器。换句话说,这是一个基于转换器的模型,用于生成新内容,并且该模型是在大量数据上训练的。
这些 LLM 接受文本提示并生成某种形式的输出。在聊天 LLM 中,提示可能是一个问题,例如“什么是.NET?”输出可能是对.NET 的简短描述,如图 11.2 中 Bing Chat 的交互所示(www.bing.com/)):

图 11.2 – Bing Chat 在接收到简短提示后描述.NET
LLM 中并没有内置智能理解。这些模型不会思考或有自己的想法,而是使用数学来识别它们收到的文本与模型训练时所接触的大量文本之间的相似性。
虽然 LLM 系统在某些时候可能看起来非常智能,但这是因为它们正在模仿它们所训练的各种书籍、博客文章、推文和其他材料作者的智能。
GitHub Copilot 使用一个名为Codex的 LLM。Codex 模型由 OpenAI 生产,并且不是在博客文章或推文上训练,而是在开源软件仓库上训练。
这意味着当你将某些内容输入到你的编辑器时,你输入的文本可以用作提示来预测你可能输入的下一条代码。这与 Google 搜索预测搜索词中的下几个单词或 ChatGPT 生成文本回复的方式非常相似。
我们将在本章末尾更详细地讨论 GitHub Copilot 中开源代码的使用以及是否在职场项目中使用 GitHub Copilot 是合适的。现在,让我们继续探讨 GitHub Copilot 的一些新特性。
用 GitHub Copilot Chat 开始对话
GitHub 通过引入GitHub Copilot Chat扩展了 Copilot 的代码生成功能。GitHub Copilot Chat 让你能够直接在编辑器中与 ChatGPT 这样的对话式 AI 代理进行交互。
这意味着你可以在 Visual Studio 中与 LLM 聊天,并执行以下操作:
-
让它解释一段代码
-
使用文本提示生成新代码
-
向 Copilot 询问提高代码质量的方法
-
让 Copilot 为方法草拟单元测试或文档
我甚至使用 Copilot 生成故意混乱的代码来练习重构。
与 GitHub Copilot Chat 的典型交互涉及用户选择一些代码,然后按 Alt + / 以开始对话。例如,图 11**.3 显示了用户在 GitHub Copilot Chat 中输入文本提示,并选择了一小段代码:

图 11.3 – 向 GitHub Copilot Chat 提问
从那里,GitHub Copilot Chat 生成一个文本响应,并将其显示给用户,如图 图 11**.4 所示:

图 11.4 – GitHub Copilot 生成代码块的说明
如果你觉得自己对 C# 经验丰富,无法充分利用这个功能,我想向你保证,它不仅限于基本的编程。有时在维护代码时,你会遇到没有意义且你没有任何文档可以告诉你开发者试图做什么的奇怪方法调用。当这种情况发生时,Chat 的意见在理解编写代码的开发者的意图方面非常有价值。
当然,Chat 可以用来生成代码,正如我们将在下一节中看到的。
对我来说,底线是使用 GitHub Copilot Chat 编程不仅能增强我作为开发者的能力,还能帮助我保持专注,因为我没有太多理由去查看文档或离开我的编辑器。Chat LLM 内置的自动化能力加上这种额外的专注力,GitHub Copilot Chat 对我的生产力和能力有着显著的提升。
我怀疑你也会喜欢 GitHub Copilot Chat,那么让我们看看如何开始使用它。
在 Visual Studio 中开始使用 GitHub Copilot
为了使用 GitHub Copilot,你需要有一个 GitHub 账户。如果你还没有,你可以在 github.com/signup 注册一个免费的 GitHub 账户。
GitHub Copilot 还要求你使用 Visual Studio 2022 版本 17.4.4 或更高版本。如果你还没有安装 Visual Studio,你可以在 visualstudio.microsoft.com/downloads/ 下载一个副本。
如果你需要更新或检查你的 Visual Studio 版本,一个快速完成任务的方法是从 Windows 菜单启动 Visual Studio 安装程序。这将让你看到当前版本,并可选择更新你的 Visual Studio 版本,如图 图 11**.5 所示:

图 11.5 – 从 Visual Studio 安装程序更新 Visual Studio
一旦你有了 GitHub 账户和最新的 Visual Studio 版本,你就可以安装 GitHub Copilot 扩展了。
安装和激活 GitHub Copilot
要安装 GitHub Copilot,启动 Visual Studio,选择 扩展 菜单,然后选择 管理扩展。接下来,搜索 GitHub Copilot 并下载并安装扩展程序,如图 图 11**.6 所示:

图 11.6 – 在 Visual Studio 中安装 GitHub Copilot
接下来,您需要在 Visual Studio 中登录 GitHub,以将扩展程序链接到您的 GitHub 账户。请按照docs.github.com/en/copilot/getting-started-with-github-copilot?tool=visualstudio中的说明进行操作,以获取最新的操作指南。
GitHub Copilot Chat 目前是 GitHub Copilot 的一个独立扩展。如果您想尝试 Chat,我建议您单独安装 Copilot 并确保它首先正常工作。完成此操作后,重复安装 Chat 扩展程序的过程。
一些 GitHub Copilot 功能,如 Chat,可能需要启用或进行额外配置。您可以通过转到 工具 菜单并选择 选项… 来这样做,然后在列表中找到 GitHub 节点。
获取 GitHub Copilot 访问权限
虽然 GitHub 本身是免费的,但 GitHub Copilot 是一个高级功能,需要您拥有 GitHub Premium 许可证或成为 GitHub Copilot for Business 账户的一部分。我们将在本章末尾更多地讨论 Copilot for Business 的好处。
在撰写本文时,GitHub 对个人用户收取 10 美元/月费用,对 Copilot for Business 账户的每个用户收取 19 美元/月费用。与任何新兴技术一样,定价和可用性可能会随时间变化。
现在我们已经介绍了如何安装和获取对 Copilot 的访问权限,让我们看看它是如何工作的。
使用 GitHub Copilot 生成建议
在本章代码的 Program.cs 文件中,输入一条注释,例如 // 填充一个随机数字列表,然后移动到下一行。
接下来,输入字母 Ra 并等待片刻再继续。如果一切配置正确,您应该会看到类似于我在 图 11**.7 中遇到的建议。

图 11.7 – GitHub Copilot 在随机帮助
在这里,GitHub Copilot 根据其在该区域观察到的上下文提出了一些代码建议。以我的情况为例,它的建议是 Random rand = new Random();,这是一段有效的 C# 代码。
在您的情况下,它可能会提出不同的建议,包括可能甚至没有意义或无法编译的建议。
请记住,GitHub Copilot 等大型语言模型并不智能,但它们会记住训练数据中的模式和趋势。有时这些趋势是有效的,而有时它们看起来合理,但参考了根本不存在的属性或功能。
由于 GitHub Copilot 和类似系统是在旧代码上训练的,您有时会注意到 Copilot 生成过时代码或使用过时 API 的代码。Copilot 生成带有错误、安全漏洞、性能问题或其他不良内容的代码也是完全可能的。作为程序员,您有责任识别好代码和坏代码。
现在我们已经了解了与 Copilot 一起工作的基础知识,让我们看看这与通过 GitHub Copilot Chat 进行重构有什么关系。
与 GitHub Copilot Chat 交互
在安装并配置了 GitHub Copilot Chat 之后,让我们再次尝试使用随机数字列表进行实验。
删除在填充随机数字列表注释之后添加的任何代码。然后,将您的输入光标移动到注释下面的行,就像您即将在那里开始输入一行代码一样。
从这里,让我们通过选择视图然后选择GitHub Copilot Chat来显示 GitHub Copilot Chat 窗口。您应该会看到GitHub Copilot Chat窗格,如图 图 11.8 所示:

图 11.8 – GitHub Copilot Chat 窗格
在文本框中输入 生成一个包含 10 个随机数字的列表 并按 Enter。如果有任何运气,您应该会看到类似于 图 11.9 的内容:

图 11.9 – GitHub Copilot Chat 的代码建议
如果您曾经与 ChatGPT 或类似的对话式 AI 代理交互过,这应该与那种体验非常相似。在这种情况下,Copilot Chat 生成了一些代码,我们可以通过点击第一个按钮来复制代码,或者点击插入按钮将其直接添加到编辑器中。
在点击 Main 方法后。点击接受,代码将被插入。
小贴士
如果您不喜欢使用 GitHub Copilot Chat 窗格,您可以通过 Alt + / 键盘快捷键在任何时候调出 GitHub Copilot Chat 建议。
如果 GitHub Copilot Chat 似乎不起作用,请打开输出视图并选择显示来自:GitHub Copilot Chat,如图 图 11.10 所示:

图 11.10 – GitHub Copilot Chat 的诊断信息
这项诊断信息帮助我找到了几个问题,但同样经常的解决方案是简单地重新打开 Visual Studio。幸运的是,这种诊断信息很少需要,但知道在哪里找到它总是好的。
现在我们已经看到了 Copilot 的工作,让我们用它来重构一些代码。
使用 GitHub Copilot Chat 进行重构
由于 GitHub Copilot Chat 是在开源存储库上训练的,它已经接触到了很多人关于代码的写作。正因为如此,它提供有用见解的可能性很高。
为了看到这一点,我们将重构名为 RefactorMe.cs 的文件,其外观如下:
namespace Packt.CloudySkiesAir.Chapter11;
public class RefactorMe {
public void DisplayRandomNumbers() {
List<int> numbers = new List<int>();
for (int i = 1; i <= 10; i++) {
Random rand = new Random();
int n = rand.Next(1, 101);
numbers.Add(n);
}
String output = string.Join(", ", numbers.ToArray());
Console.WriteLine(output);
}
}
这段代码存在一些故意的低效之处,甚至有时我会遇到一个与Random相关的潜在危险错误。我会给你几段代码,让你看看是否能找出问题所在,但让我们也看看 GitHub Copilot Chat 是否能发现这个问题。
选择 DisplayRandomNumbers 方法,然后按 Alt + / 组合键打开聊天提示。接下来,询问 Copilot “你将如何改进this code?”
当我询问 Copilot 时,我得到了几个建议,如图图 11所示:

图 11.11 – GitHub Copilot Chat 作为代码审查员
检视我遇到的建议(可能与你在进行相同实验后看到的建议不同),我可以将其总结为以下几点:
-
为了性能原因,将
Random声明在循环外部 -
由于你知道列表的大小,请将其声明为
new List<int>(10) -
使用
foreach和Enumerable.Range而不是for循环
不仅 GitHub Copilot Chat 产生了改进的想法,甚至还建议了以下代码来满足其建议:
public void DisplayRandomNumbers() {
List<int> numbers = new List<int>(10);
Random rand = new Random();
foreach (int i in Enumerable.Range(0, 10)) {
int n = rand.Next(1, 101);
numbers.Add(n);
}
string output = string.Join(", ", numbers);
Console.WriteLine(output);
}
在这里,Copilot 建议了一些我正在考虑的改进,例如将 Random 移出循环之外,以及一些我没有考虑的,例如使用Enumerable.Range。
出了什么 bug?
如果你对提到的潜在错误感到好奇,它与在循环中实例化Random有关。每次你运行new Random(),它都会使用当前系统时间作为随机种子来生成新的数字。如果你在快速循环中这样做,时钟保持不变,导致每次迭代都产生相同的“随机”数字序列。
查看推荐的代码,我发现了一些改进的机会,例如将n变量重命名为更有意义的名称,使用目标类型new来实例化对象,以及使用_运算符丢弃未使用的i变量。
在 GitHub 和我自己之间,我们为此方法编写的最终代码如下:
public void DisplayRandomNumbers() {
List<int> numbers = new(10);
Random rand = new();
foreach (int _ in Enumerable.Range(0, 10)) {
int number = rand.Next(1, 101);
numbers.Add(number);
}
string output = string.Join(", ", numbers);
Console.WriteLine(output);
}
由此产生的代码更加简洁,在列表分配方面略为高效,最终对于一小段代码来说,代表了一个更好的结果。
本节的目的并非向您展示如何生成随机数,而是让您看到聊天作为一位“无脑”编程伙伴所能提供的潜在价值。这位伙伴可以审查您的代码并提出建议。这些建议并不总是有道理,甚至可能无法编译,但它们可以在同事不在场时为您提供快速的外部视角。
GitHub Copilot Chat 作为代码审查员
GitHub Copilot Chat 在重构方面的价值并不仅限于代码生成。你还可以向 GitHub Copilot Chat 提出如下问题:
-
你能否像一位资深工程师一样审查这段代码?
-
该方法可以进行哪些性能优化?
-
这种方法会在哪里遇到错误?
-
有没有减少或合并行数的方法,而不会损害整体的可读性?
当然,重要的是要记住,你实际上是在从本质上是一个被美化的自动完成/句子预测引擎的 LLM 那里获得建议,而不是一个具有智能或原创思想的实体。
有趣的是,我注意到多次向 GitHub Copilot Chat 询问关于方法的意见可以产生不同的结果。这些结果甚至可以改变 Copilot 最初提供的原始建议的意见!尽管如此,这仍然可以提供多种观点。
在我们继续之前,让我们看看另一个重构代码的例子。
GitHub Copilot Chat 的目标重构
这个重构示例主要关注BaggageCalculator.cs文件。这个文件包含了从第二章结尾的BaggageCalculator类的最终版本。
作为快速提醒,这个类有一个CalculatePrice方法,它根据已登记的和随身携带的行李数量以及他们是否在假日旅行来计算和显示客户的行李费用。
支持公共CalculatePrice方法的是私有的静态ApplyCheckedBagFee方法,它计算已登记行李的费用。
我们将主要关注CalculatePrice方法,它看起来有点重复:
public decimal CalculatePrice(int bags, int carryOn,
int passengers, bool isHoliday) {
decimal total = 0;
if (carryOn > 0) {
decimal fee = carryOn * CarryOnFee;
Console.WriteLine($"Carry-on: {fee}");
total += fee;
}
if (bags > 0) {
decimal bagFee = ApplyCheckedBagFee(bags, passengers);
Console.WriteLine($"Checked: {bagFee}");
total += bagFee;
}
if (isHoliday) {
decimal holidayFee = total * HolidayFeePercent;
Console.WriteLine("Holiday Fee: " + holidayFee);
total += holidayFee;
}
return total;
}
看着这段代码,我们看到有三个类似的块,它们检查一个条件,计算费用,显示该费用,然后将其添加到total中。
这段代码的重复性让我觉得这是一个可能得到改进的代码异味。一个解决方案是为这三个块中的每一个提取一个方法,但这些方法之间仍然非常相似。
相反,我自然会想,是否有一个可能涉及Action或Func的解决方案,但我并不立即确定这样的解决方案可能是什么样子。
幸运的是,我们可以通过选择整个方法并告诉 GitHub Copilot Chat“重构这个方法,使用一个写入费用并将其添加到总金额的 Action,这样三个块就”不那么重复“来询问 Copilot。
对我来说,这导致了以下方法:
public decimal CalculatePrice(int bags, int carryOn,
int passengers, bool isHoliday) {
decimal total = 0;
Action<decimal> addFeeToTotal = fee => {
Console.WriteLine($"Fee: {fee}");
total += fee;
};
if (carryOn > 0) {
decimal fee = carryOn * CarryOnFee;
addFeeToTotal(fee);
}
if (bags > 0) {
decimal bagFee = ApplyCheckedBagFee(bags, passengers);
addFeeToTotal(bagFee);
}
if (isHoliday) {
decimal holidayFee = total * HolidayFeePercent;
Console.WriteLine("Holiday Fee: " + holidayFee);
addFeeToTotal(holidayFee);
}
return total;
}
在这里,Copilot 引入了一个局部addFeeToTotal变量,它存储一个将fee写入控制台并增加total的Action。然后它从三个分支中的每一个调用这个Action。
然而,这个重构在几个方面是不正确的。首先,现在显示的消息现在以Fee开头,而不是适当的费用名称。其次,重构没有删除假日费用的WriteLine,所以fee会被显示两次。
然而,重构确实给了我们一个关于代码如何改进的想法。稍加整理,你最终会得到一个更正确的方法:
public decimal CalculatePrice(int bags, int carryOn,
int passengers, bool isHoliday) {
decimal total = 0;
Action<string, decimal> addFeeToTotal = (name, fee) => {
Console.WriteLine($"{name}: {fee}");
total += fee;
};
if (carryOn > 0) {
decimal fee = carryOn * CarryOnFee;
addFeeToTotal("Carry-on", fee);
}
if (bags > 0) {
decimal bagFee = ApplyCheckedBagFee(bags, passengers);
addFeeToTotal("Checked", bagFee);
}
if (isHoliday) {
decimal holidayFee = total * HolidayFeePercent;
addFeeToTotal("Holiday Fee", holidayFee);
}
return total;
}
这段代码现在工作正常并减少了重复。在这种情况下,Copilot 能够提出一个前进的方向,但准确实现它而不引入错误超出了它当前的能力。
这个限制强调了测试的需求以及 Copilot 作为人类程序员的 伙伴 而不是 替代者 的角色。
提醒
记住,GitHub Copilot Chat、ChatGPT 以及基于大型语言模型的其他生成式 AI 系统只是预测机器,它们生成遵循其训练数据模式文本。没有任何保证这些生成的值是正确的、最优的或无错误的。
现在我们已经讨论了几种重构场景,让我们看看我们还能用 GitHub Copilot Chat 做些什么。
使用 GitHub Copilot Chat 起草文档
这些年来,我了解到开发者并不总是喜欢为他们的代码编写文档。虽然有些代码确实像开发者所声称的那样具有自文档性,但其他区域则需要适当的文档。
在 C# 中,我们使用 XML 文档来记录公共方法,例如 DisplayRandomNumbers 方法的示例注释:
/// <summary>
/// Displays a sequence of 10 random numbers.
/// </summary>
public void DisplayRandomNumbers() {
这条特殊格式的注释被 Visual Studio 解释为在编辑器中显示额外的帮助。当你尝试调用你的方法时,这些额外信息会出现在编辑器中,如图 图 11.12 所示:

图 11.12 – Visual Studio 显示包含方法注释的工具提示
尽管我们刚才看到的示例文档相对简单,但当涉及到返回值和参数时,文档会变得稍微复杂一些。
让我们使用 GitHub Copilot Chat 来记录一个方法。我们将从 DocumentMe.cs 中的 AddEvenNumbers 方法开始:
public int AddEvenNumbers(int[]? numbers, int total = 0) {
if (numbers == null || numbers.Length == 0) {
string message = "There must be at least 1 element";
throw new ArgumentException(message, nameof(numbers));
}
return total + numbers.Where(n => n % 2 == 0).Sum();
}
此方法接受一个数字数组,以及可选地添加到结果总和中的数字。如果至少提供了一个数字,则方法返回该数组中所有偶数的总和,加上可选的 total 参数。如果没有提供元素,则将抛出 ArgumentException。
现在你已经阅读了我的描述,让我们看看 GitHub Copilot 如何描述它。按 Alt + / 打开聊天界面,然后告诉 Copilot Document AddEvenNumbers。Copilot 应该建议如 图 11.12 预览中所示的文档更改:

图 11.13 – GitHub Copilot 建议的文档
点击 接受,评论将被添加到你的方法中。
对于我来说,生成的文档相当不错:
/// <summary>
/// Adds up even numbers in an array. Throws an
/// ArgumentException if the array is null or empty.
/// </summary>
/// <param name="numbers">
/// The array of numbers to add.
/// </param>
/// <param name="total">
/// The starting total to add to. Defaults to 0.
/// </param>
/// <returns>
/// The total of all even numbers in the array.
/// </returns>
这是一份非常准确的文档。我唯一会做的修改是添加以下行 XML 文档来记录潜在的异常:
/// <exception cref="ArgumentException">Thrown when the array is null or empty.</exception>
这会将异常添加到方法提示中显示的列表中,如图 图 11.13 所示:

图 11.14 – Visual Studio 中的异常文档
传达异常允许其他代码以适当的方式捕获它们。
人类生成的文档通常会比 AI 生成的文档更好,但当人类和 AI 可以一起工作时,它可以是一个巨大的生产力提升。
在下一节中,我们将看到这些生产力提升如何应用于测试。
使用 GitHub Copilot 聊天生成测试存根
在本章的最后技术部分,让我们看看一个方法,该方法可以在一个数字序列中找到最大的数字,前提是这个数字在某处不包含“7”,例如 71 或 17。这个方法位于TestMe.cs内部:
public static class TestMe {
public static int CalculateLargestNumberWithoutASeven(
INumberProvider provider) {
IEnumerable<int> numbers = provider.GenerateNumbers();
return numbers.Where(x => !x.ToString().Contains("7"))
.Max();
}
}
这个CalculateLargestNumberWithoutASeven方法接受一个INumberProvider,允许我们调用GenerateNumbers并获取一系列整数。
接下来,方法查看生成的序列,找到字符串表示中不包含“7”的数字,然后返回最大的数字。
依赖注入
作为简要的复习,我们的方法实际上是通过外部参数将INumberProvider的依赖注入其中。这意味着代码可以与实现该接口的任何东西一起工作,而无需了解其细节。
虽然这个方法看起来在现实世界中似乎毫无用处,但请思考一下你将如何测试这个方法。具体来说,你会如何调用这个方法?你会给它什么作为INumberProvider?你期望它返回什么值?
当你在思考这个问题的时候,让我们看看 GitHub Copilot 会如何处理,通过打开 GitHub Copilot 聊天窗格并输入Generate tests for CalculateLargestNumberWithoutASeven.。
注意
虽然我通常更喜欢使用Alt + /方法与 Copilot 交互,但如果您想让 Copilot 生成新文件,您应该使用 GitHub Copilot 聊天窗格以获得最佳效果。
对于我来说,Copilot 生成了一个用于新测试类的 C#代码。我很快就会分享这段代码,但对我来说,代码生成中最有趣的是推荐底部三个按钮,如图 11.14 所示:

图 11.15 – GitHub Copilot 提供创建新文件的功能
这三个按钮分别允许您将新代码复制到剪贴板、创建新文件以及在当前编辑器中插入代码。
由于我们希望测试生活在测试项目中,请点击创建 新文件。
这将在您的测试项目中创建一个新文件,其中包含 Copilot 生成的任何测试。对我来说,它生成了两个测试,如图 11.16 所示:

图 11.16 – GitHub Copilot 聊天生成的 XUnit 测试对
测试在这里并不是最重要的,所以我不想专注于代码,除了对 Copilot 在请求测试时的策略做一些观察:
-
Copilot 使用 xUnit 和 Moq 生成了成对的测试,这两个测试已经安装在了测试项目中。这些测试编译并通过。
-
第一次测试确保了当提供 null 输入时,方法会抛出异常。
-
第二次测试提供了一系列随机数字,并断言该方法返回了没有七的最大数字。
-
两个测试都使用了 Moq 来创建一个假的
INumberProvider,该提供器被编程为生成所需的数字序列。
那么,我们是否发现了允许我们忘记编写测试的银弹?可能不是。
虽然这两个测试都验证了某些合法的内容,但它们的可读性可以更好。此外,测试没有考虑所有应该测试的路径。例如,它没有测试空序列的元素,只有一个数字,一个数字中包含七,只有负数,或者最大数字中包含七的情况。这些都是合法的情况,一个人类测试员可能会考虑。
因此,GitHub Copilot 不会免除你测试代码(以及思考你的测试)的责任,但它也不是完全没有价值的。
GitHub Copilot 在识别测试用例和考虑测试特别难以测试的类的新方法方面具有很多价值。我已经把它看作是一个催化剂——或者说是一个同伴——它帮助你在编写自己的测试时获得动力。
既然我们已经看到了 GitHub Copilot 提供的价值,让我们来谈谈它的局限性。
理解 GitHub Copilot 的局限性
到这一章的这个阶段,许多读者可能都在想:“这很好,但我真的能在我的工作中使用它吗?”这是一个合理的问题,所以让我们来谈谈两个常见的反对意见:源代码的隐私和公共代码的许可问题。
数据隐私与 GitHub Copilot
许多考虑 GitHub Copilot 的组织担心,将 AI 工具集成到他们的代码编辑器中意味着将他们的代码暴露给 GitHub。有些人还提出了这样的可能性:GitHub 可能会在未来使用组织的私有代码来生成新的大型语言模型,这些新模型可能会根据组织的专有逻辑生成代码。
这些都是合理的担忧,具体取决于你使用的 GitHub Copilot 版本,它们可能有一定的依据。
在GitHub Copilot for Individuals中,你发送给 GitHub Copilot 的提示,包括周围的代码和 Copilot 的建议代码,可能会被保留以供分析,除非你在设置中禁用了代码片段收集。
可以通过取消选中允许 GitHub 使用我的代码片段进行产品改进复选框来在github.com/settings/copilot中禁用此设置,如图图 11.17所示。

图 11.17 – GitHub Copilot 设置
虽然 GitHub Copilot for Individuals 默认存在一些数据隐私问题,但如果您正在处理敏感代码,这些问题可以轻松选择退出。
还应注意的是,GitHub Copilot for Individuals 还会收集有关 GitHub Copilot 使用情况的遥测数据,以检测该服务被使用的频率并检测和解决错误。
另一方面,GitHub Copilot for Business 默认为私有,还提供了额外的组织范围策略设置,企业可以配置这些设置以全局启用或禁用 Copilot。这些功能还可以用于防止 Copilot 为您组织中的每个人生成与已知公共代码匹配的代码。
根据 GitHub Copilot Trust Center,“GitHub Copilot [for business] 不使用提示或建议来训练 AI 模型。这些输入不会被保留或用于 GitHub Copilot 的 AI 模型训练过程中。” 这意味着您发送给 GitHub Copilot 的代码以及它为您生成的建议对人类来说是私有的,并且不会用于让他人了解您的代码库。
免责声明
本书旨在帮助您了解 GitHub Copilot 的基础知识,并基于对早期技术的最佳理解编写。与任何技术一样,GitHub Copilot 不断发展和成长。随着其发展,隐私政策、数据保留政策以及定价模式可能会随着时间的推移而变化。读者在做出任何使用决策之前,应鼓励核实本章中的信息与 GitHub 提供的当前信息。
GitHub 隐私部门负责人 Glory Francke 表示:“我们只处理您的代码以提供服务。代码不会被保留,人类眼睛看不到它,并且不会被用于任何 AI 模型改进”(GitHub Copilot Trust Center – resources.github.com/copilot-trust-center/)。
通常,我发现 GitHub Copilot Trust Center 是解决企业对工具安全性、隐私和可访问性担忧的一个非常有用的工具。您可以在本章的 进一步阅读 部分了解更多关于信任中心的信息,但就目前而言,让我们更多地讨论 GitHub Copilot 和公共代码。
关于 GitHub Copilot 和公共代码的担忧
大多数开源代码都附有许可证,规定了开发者在使用源代码时必须遵守的条款。开发者可以选择几种常见的许可证,例如 MIT 许可证、Apache 许可证、GNU 通用公共许可证等。
虽然许多这些许可证非常宽松,但其中一些包含要求采取额外行动的条款,例如归因于源代码、使您组织的代码开源,或者不能在商业软件项目中使用该代码。
由于这种限制,以及 GitHub Copilot 是在开源软件代码上训练的,因此存在 GitHub Copilot 可能会意外生成与公共存储库中代码相同的小概率。
由于这个担忧,GitHub Copilot 现在允许个人和企业阻止生成与已知公共代码相同的代码。此外,GitHub 目前正在推出一个名为 GitHub Copilot 代码引用的新功能,该功能允许您检测 Copilot 是否建议了公共代码。此功能让您能够释放 Copilot 的全部创造力,同时让您看到代码所在的存储库以及这些存储库的许可证。
在撰写本章时,此功能尚未对 GitHub Copilot for Visual Studio 可用,但很可能在本书出版后不久,此功能将添加到 Visual Studio 中。
让我们以一个关于我们虚构的航空公司 GitHub Copilot Chat 的案例研究来结束这一章节。
案例研究:Cloudy Skies Airlines
在 Cloudy Skies Airlines,AI 的使用最初是从个别开发者开始的,这在新技术和生产力工具中很常见。詹姆斯是团队中一个热心的年轻开发者,他与同事分享了他是如何尝试使用 GitHub Copilot 的,感觉更有能力,更有动力,甚至学到了新东西。他的同事都很兴奋,但他的经理 Mya 有一些顾虑。
邀请首席技术官(CTO)Mya 和詹姆斯展示了该工具的功能,并讨论了它是如何工作的。CTO 担心法律合规性和公司知识产权的安全性。因此,在团队调查这项技术的含义期间,Copilot 和其他 AI 工具的使用被暂时暂停。
经过一些研究和 GitHub Copilot 信任中心的帮助,Cloudy Skies Airlines 团队同意了一个多阶段计划:
-
试点程序:包括詹姆斯在内的一小群开发者将尝试使用 GitHub Copilot,且在两周内禁用代码片段收集功能。
-
审查:团队将评估试点计划对生产力、代码质量和一般开发者反馈的影响,并决定是否采用该工具。
-
推广:如果 GitHub Copilot 被发现是有益的,它将根据技术审查的结果,要么在组织范围内允许个人使用并制定指南,要么通过 GitHub Copilot for Business 账户进行管理。
试点计划中的开发者报告称,他们更容易专注于代码,采用有助于加快“无聊”编程方面的实践,并从 Copilot 生成的代码中学习了一些新的实践和概念。
因此,Cloudy Skies Airlines 拥抱了 GitHub Copilot,并开通了 GitHub Copilot for Business 账户,以确保禁用了代码片段收集,并在组织层面设置了关于公共代码源等事项的适当政策。
摘要
在本章中,我们看到了 GitHub Copilot 和 GitHub Copilot Chat 如何帮助开发者理解、重构、文档化甚至测试他们的代码。
我们讨论了 GitHub Copilot 并非一个智能 AI 统治者,而是一个围绕开源存储库中发现的文本模式构建的预测模型。因此,它生成的代码可能甚至无法编译,并可能包含安全漏洞、错误、性能问题或其他不良影响。
我们在本章结束时讨论了组织在安全和合规方面必须关注的隐私和开源许可问题,以及 GitHub Copilot 如何帮助组织满足这些需求。
在下一章中,我们将探讨 Visual Studio 中的代码分析,并看看代码分析如何帮助您检测代码中的潜在问题和重构目标。
问题
-
GitHub Copilot 和 GitHub Copilot Chat 是如何工作的?
-
您如何解决与 Copilot 相关的数据隐私和合规性问题?
进一步阅读
您可以在以下网址找到有关 GitHub Copilot 的更多信息:
-
关于 GitHub Copilot Visual Studio 扩展:
learn.microsoft.com/en-us/visualstudio/ide/visual-studio-github-copilot-extension -
GitHub Copilot 信任中心:
resources.github.com/copilot-trust-center/ -
GitHub Copilot Chat:
docs.github.com/en/copilot/github-copilot-chat/about-github-copilot-chat
第十二章:Visual Studio 中的代码分析
到目前为止,我们已经介绍了如何以安全、有效、可靠和高效的方式重构我们的代码。
在本章中,我们将使用代码度量工具和代码分析工具确定可能需要重构的代码区域。在这个过程中,我们将涵盖以下主题:
-
在 Visual Studio 中计算代码度量
-
在 Visual Studio 中执行代码分析
-
探索高级代码分析工具
技术要求
本章的起始代码可在 GitHub 上找到,地址为 github.com/PacktPublishing/Refactoring-with-CSharp,位于 Chapter12/Ch12BeginningCode 文件夹中。
在 Visual Studio 中计算代码度量
我曾经工作过的每个代码库都有一两个可维护性热点。这些区域经常更改,比代码的其他区域具有更高的复杂度,并且对软件项目代表严重的质量风险。
这些区域通常是重构中最关键的,并且它们通常很容易通过代码度量发现。
代码度量计算了关于您 C# 代码中每个文件、类、方法和属性的几个有用的统计数据。这使您能够发现代码中的热点,这些热点的复杂度显著高于其他部分,或者可维护性较低。代码度量甚至可以帮助您找到太大且可能违反我们讨论过的单一职责原则(SRP)的类第八章。
要计算代码度量,请在 Visual Studio 中打开您的解决方案,然后点击分析菜单,接着点击计算代码度量,然后点击针对解决方案,如图 图 12.1* 所示:

图 12.1 – 计算代码度量
这将打开代码度量结果面板,如图 图 12.2* 所示:

图 12.2 – 代码度量结果
此面板显示了解决方案的层次结构视图,以及以下六个度量:
-
源代码行数:类或方法的代码行数。
-
可执行代码行数:忽略空白行和注释的源代码行数。
-
if语句、循环、switch 语句和类似类型的分支指令会使此值增加 1。 -
可维护性指数:基于循环复杂度、代码行数和方法中执行的操作数计算得出的值。此值范围从 0 到 100,表示您的代码的可维护性。0 到 9 的值是坏的,10 到 20 是警告区域,21 及以上是值得关注区域。
-
System.Object,所有类最终都从中继承。 -
类耦合:您的代码所依赖的其他类的数量。
这些度量中的每一个都是单独有用的,但结合起来,它们描绘了一个更全面的图景。
维护性指数为你提供了一个代码区域的快速指标。与其他列不同,这些列汇总了类、命名空间或项目中的所有代码的值,而维护性指数则作为一个平均值,可以帮助你快速钻入问题区域。
圈复杂度可以识别出难以测试或难以理解的地方,因为它确定了通过方法的不同路径数。图 12.3展示了CalculatePrice方法的圈复杂度:

图 12.3 – 计算圈复杂度
在这里,CalculatePrice方法的圈复杂度为 4。所有方法都以 1 的圈复杂度开始,代表方法中的单一路径。每个分支语句,如这里的if语句,都会将圈复杂度增加 1,从而总共达到 4。
我发现圈复杂度通常很有用,并试图将其保持在尽可能低。请记住,圈复杂度对使用switch语句的方法有偏见,因为每个case语句都会增加复杂性。只有一行或两行代码的简单switch语句通常不难维护,所以将圈复杂度视为代码质量的一个指标。微软建议每个方法的最大圈复杂度为 10,但根据我的经验,我通常对 7 或更低的圈复杂度感到最满意。
继承深度和类耦合可以帮助你识别出你可能过度使用继承或与其他类耦合度过高的地方,正如我们在第八章中讨论的那样。微软鼓励最大继承深度为 6,最大类耦合度为 9。
代码行数指标非常有用。我发现,一个类中有很多行代码通常是它违反单一职责原则(SRP)并需要重构的最明显迹象之一。同样,如果一个方法太大,通常很难理解、维护和测试。
我试图将类的代码行数控制在 200 行以下,方法控制在 20 行或更少。在这两种情况下,我都会寻找可以从中提取到方法或类中的内容,并且除非我能够先从代码中提取逻辑,否则我会犹豫是否要使用新逻辑扩展已经很大的类或方法。
请记住,这些都是我发现通常有效的通用指南。这些不是你必须始终遵循的具体规则。
我鼓励你花些时间查看本章示例代码或你维护的代码的代码指标。就本章的代码而言,我最关心以下方法:
-
Flight.Baggage命名空间中的BaggageCalculator.CalculatePrice具有 58 的维护性指数、4 的圈复杂度和 26 行源代码 -
FlightScheduler.Search,该函数接收Flight.Scheduling命名空间中的FlightSearch对象,其可维护性指数为 48,循环复杂度为 9,类耦合度为 11,源代码行数为 37
这两种方法都因具有多个if语句而被指标标记,但它们本身并不复杂。但如果其中任何一个需要显著增长,我希望看到像我们在第五章中应用的那种重构,将复杂性从这些方法移至其他对象。
现在我们已经讨论了代码指标,让我们看看代码分析如何为我们提供另一种看待代码的方式。
在 Visual Studio 中执行代码分析
微软知道,随着 C#和.NET 的变化,跟上这种广泛且不断变化的语言的演变标准可能非常困难。
为了解决这个问题,微软为我们提供了代码指标之外的工具,即分析器,这些分析器会检查我们的 C#代码中的问题。这些分析器检查我们的代码,标记潜在的问题和优化。这有助于确保我们的代码符合标准,并且是安全、可靠和可维护的。
使用默认规则集分析您的解决方案
要查看分析器的实际效果,请在 Visual Studio 中构建本章的解决方案,并注意输出窗格中出现的三个警告,如图图 12.4所示:

图 12.4 – 显示警告的构建结果概述
这三条线代表与 CS8618 代码分析规则相关的单独编译器警告,我们将在稍后查看。
在我们这样做之前,点击视图菜单,然后选择错误列表。你应该会看到以更易读的格式格式化的相同警告,如图图 12.5所示:

图 12.5 – 错误列表中编译警告的概述
如果这些警告没有显示,请确保错误、警告和消息按钮被选中,如图图 12.5所示。
由于这些警告都与Airport.cs相关,让我们回顾一下它的代码:
public class Airport {
public string Country { get; set; }
public string Code { get; set; }
public string Name { get; set; }
// Non-relevant code omitted...
}
当你在 Visual Studio 中查看此代码时,你会在这三个属性下面看到“绿色波浪线”。如图图 12.6所示,将鼠标悬停在任何一个“波浪线”上会显示警告或建议的详细信息:

图 12.6 – 与 Name 属性相关的 CS8618 编译器警告
在这种情况下,警告告诉我们这三个属性是非可空的,这意味着它们被声明为string而不是string?,正如我们在第十章中讨论可空性分析时提到的。
由于 .NET 中任何 string 属性的默认值都是 null,并且 Airport 类没有初始化这些三个属性的任何逻辑,编译器警告告诉我们,当创建 Airport 实例时,它们将在我们告诉它不能为 null 的属性中具有 null 值!
.NET 中的可空性分析
记住,尽管字符串是引用类型并且可以是 null,但 C# 中的可空性分析表明属性是否在任何时间点预期具有 null 值。在这里,string 类型指示符表示我们从不期望这些属性具有 null 值。另一方面,string? 类型指示符将表示我们可能期望 null 值。参见 第十章 了解有关 C# 中可空性分析的更多信息。
解决此编译器警告有几种方法:
-
将这些属性的默认值设置为空字符串
-
将这些属性更改为
string?而不是string -
添加一个构造函数来设置这些属性的非 null 值
-
将这些属性标记为
required以确保它们在创建时必须设置
如此所示,最简单的修复方法是将这些属性标记为 required:
public class Airport {
public required string Country { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
// Non-relevant code omitted...
}
这解决了三个代码分析警告,留下了两个不那么严重的建议供我们调查,这两个建议都与 Airport 的 Equals 方法有关:
public override bool Equals(object? obj) {
Airport? otherAirport = obj as Airport;
if (otherAirport == null)
return false;
string otherName = otherAirport.Name;
string otherCountry = otherAirport.Country;
string otherCode = otherAirport.Code;
return Country == otherCountry &&
Code == otherCode;
}
第一个警告是 IDE0019,建议在声明 otherAirport 时使用模式匹配。幸运的是,这个 analyzer 提供了一个 Airport? 类型,揭示了 使用模式匹配 快速操作,如图 图 12.7 所示:

图 12.7 – 应用 Use pattern matching 重构
应用此重构解决了建议并使我们的代码更加简洁:
if (obj is not Airport otherAirport)
return false;
最后剩下的警告是 IDE0059: 对otherName的不必要赋值。这表明我们已声明了一个变量并给该变量赋值,但从那时起再也没有使用过该变量,如下面的 otherName 所示:
string otherName = otherAirport.Name;
string otherCountry = otherAirport.Country;
string otherCode = otherAirport.Code;
return Country == otherCountry &&
Code == otherCode;
看着这段代码,很难确定 otherName 是否应该包含在等价检查中,或者变量是否根本不需要。在这种情况下,你可能会询问业务利益相关者机场是否可能具有多个名称但仍然是同一个机场。如果你得到“是”的回答,那么修复方法将是删除 otherName 变量,而“否”则表明应在 return 语句中添加 Name 检查。
在没有收集更多关于你正在建模的业务领域信息的情况下,修复代码问题并不总是显而易见的。
配置代码分析规则集
.NET 中有大量且不断增长的 analyzers,并非每个 analyzer 都具有相同的重要性水平。因此,Microsoft 提供了不同的 analyzers 集合,以便你可以从最有用的 analyzers 的小子集开始,随着成熟度的增长逐渐扩展到额外的 analyzers 集合。
让我们通过在解决方案资源管理器中右键单击Chapter12项目并选择属性来查看Chapter12项目的代码分析设置。
这将打开项目的属性视图。此视图列出了与项目关联的所有可配置属性,可以从顶部到底部滚动或使用左侧的导航窗格进行导航。
在导航窗格中单击代码分析;您应该看到项目的代码分析设置,如图图 12.8所示:

图 12.8 – 项目的代码分析设置
如您从运行构建设置中看到的那样,编译器会在每次构建项目时分析代码。
使用的确切分析器集由分析级别设置控制,对于新项目默认为最新。
Visual Studio 支持多种分析规则集,但让我们关注以“最新”开头的四个规则集,因为这些是最新可用的规则集,这些规则集中的模式将帮助您了解其他规则选项。这些选项如下:
-
最新:默认的规则集。这是一个旨在适用于任何类型项目的规则集。
-
最新最小:最新中的所有内容加上额外的规则。这代表 Microsoft 推荐在项目中使用的最小规则集。
-
最新推荐:最新最小中的所有内容加上一些额外的规则。这包含一套旨在帮助您维护在任何地区安全可靠运行的业务应用的健壮规则集。
-
最新全部:启用所有可用规则。并非每个规则都可能适用于您试图构建的应用程序,但它最大化了构建健壮和可靠应用程序的机会。
让我们看看当我们把我们的项目从最新更改为最新推荐然后构建会发生什么。
响应代码分析规则
在将项目更改为使用最新推荐规则集后,将出现三个新的警告,如图图 12.9所示:

图 12.9 – 移动到更严格的规则集后的新编译器警告
让我们从第一个警告开始。这对应于Flight类,目前仅在几行代码中定义:
public class Flight {
public string BuildMessage(string id, string status) {
return $"Flight {id} is {status}";
}
}
CA1822 警告告诉我们成员‘BuildMessage’不访问实例数据,可以被标记 为静态。
这个分析器建议我们将BuildMessage方法设置为static,因为它不处理Flight类的任何特定信息。
在这种情况下,将方法设置为static可能会使其更容易测试,并允许编译器进行一些性能优化。
我们可以通过执行我们在 第四章 中提到的 将方法设为静态 重构来解决这个问题,但相反,让我们来探讨如何抑制特定的警告。
在这个例子中,让我们假设我们打算在未来的某个时刻让 BuildMessage 处理实例特定的属性,但我们还没有做到这一点。正因为如此,我们希望警告消失,而不需要将方法设为静态。
使用 BuildMessage 方法,然后选择 抑制或配置问题 子菜单。从那里,选择 抑制 CA1822。这将显示抑制问题的三种不同选项,如图 图 12.10 所示:

图 12.10 – 抑制代码分析警告的选项
这些选项如下:
-
在你的代码上方和下方使用
#pragma语句来临时禁用代码分析警告 -
在抑制文件中:这会创建一个包含代码的单独文件,告诉代码分析不要关心这个特定方法的具体问题
-
在方法上方使用的
SuppressMessageAttribute抑制代码分析问题
这三种方法都可以抑制问题,但它们以不同的方式做到这一点。我通常更喜欢避免预处理指令,如 #pragma,以获得更干净、更易于维护的代码。这留下了抑制文件和属性方法。
抑制文件的优点是代码分析抑制不会使你的源代码杂乱,而是存在于一个单独的文件中。然而,这也是它们的缺点。通过将抑制隐藏在另一个文件中,你减少了在未来解决它们的可能性,因为它们是“眼不见,心不烦”。
使用 using 语句对 System.Diagnostic.CodeAnalysis 进行操作会产生以下文件:
using System.Diagnostics.CodeAnalysis;
namespace Packt.CloudySkiesAir.Chapter12.Flight;
public class Flight {
[SuppressMessage("Performance",
"CA1822:Mark members as static",
Justification = "Intend to work with instance data in future release")]
public string BuildMessage(string id, string status) {
return $"Flight {id} is {status}";
}
}
在方法上方使用的 SuppressMessage 属性将代码分析问题的类别标记为“性能”。接下来,它命名了被抑制的个别分析规则,然后提供了一个理由。
这种解释是一个字符串,用于向你的同事(以及未来的你)说明你为什么认为代码分析规则现在不应被处理,并且应从代码分析结果列表中排除。
我绝不会在没有提供有效抑制理由的情况下抑制代码分析警告。如果一个规则重要到有人为其提供分析器,那么它应该被解决,或者我应该有一个有效的理由来解释我选择忽略它的原因。如果你在想,“我不想处理它”不是一个有效的理由。
在解决了第一个警告之后,让我们一起看看其他两个相关的警告。
第一个警告是 CA1305,它与 DateHelpers 类相关,如下所示:
public static class DateHelpers {
public static string Format(this DateTime time) {
return time.ToString("ddd MMM dd HH:mm tt");
}
}
这个警告指出,ToString调用可能会根据用户的区域设置和语言设置产生不同的结果。我的设置,作为一个在美国说英语的人,可能不同于使用相同代码但以法语为主要区域设置的人。
下一个警告是在CharterFlightInfo中的BuildFlightIdentifier:
public class CharterFlightInfo : FlightInfoBase {
public List<ICargoItem> Cargo { get; } = new();
public override string BuildFlightIdentifier() {
StringBuilder sb = new(base.BuildFlightIdentifier());
if (Cargo.Count != 0) {
sb.Append(" carrying ");
foreach (var cargo in Cargo) {
sb.Append($"{cargo}, ");
}
}
return sb.ToString();
}
}
这个警告抱怨一个类似本地化问题,指出StringBuilder.Append的行为可能会根据用户的区域设置而有所不同。
推荐规则与最小和默认规则
这些格式化规则是规则示例,这些规则并非对所有项目都相关。出于这个原因,这些规则在默认或最小规则集中没有启用:您创建的并非所有应用程序都需要在运行时保持一致。如果您正在构建一个爱好应用程序或仅在单个服务器或办公室运行的应用程序,这个规则可能对您来说并不重要。然而,如果您正在构建分布在全球范围内、面向所有文化背景的客户的应用程序,这将是一个您关心的规则。
解决这两个警告的方法是提供一个显式的文化,您希望用于格式化字符串。这改变了我们的追加代码到以下行:
sb.Append(CultureInfo.InvariantCulture, $"{cargo}, ");
我们的日期格式化代码以类似的方式改变:
CultureInfo culture = CultureInfo.InvariantCulture;
return time.ToString("ddd MMM dd HH:mm tt", culture);
经过这些更改,我们现在不再有代码分析警告。让我们通过查看一种确保我们保持无警告的方法来完成本节。
将警告视为错误
我遇到过很多开发者,他们对待警告就像开车时对待速度限制一样:他们忽略它们,以不安全的车速驶过。
确保开发者确保他们的代码没有警告的几种方法。也许最容易的方法就是告诉 C#编译器将任何警告视为编译器错误。
您可以通过右键单击项目并选择属性来让 C#编译器将所有警告视为错误,就像我们之前做的那样。从那里,在导航窗格中展开构建,然后单击错误和警告。一旦这样做,您应该会看到类似图 12.11的内容:

图 12.11 – 配置项目的错误和警告
您可以选择将警告视为错误,以便所有警告都导致错误。
由于开发者会关注那些完全阻止代码运行的问题,因此任何警告如果阻止他们构建代码肯定会引起他们的注意!在使用时请小心,因为他们可能不会对这种中断的严重性感到高兴。
一个不那么极端的选择是配置将特定警告视为错误设置,并包括您认为应该始终解决的特定警告标识符。
例如,如果我们想强制开发人员对将方法 static(CA1822)的建议做出响应,您可以通过设置 $(WarningsAsErrors);NU1605;CA1822 来实现,这样做,任何发生警告且未被抑制的地方都会导致编译器错误。
现在我们已经介绍了 Visual Studio 的代码分析功能,让我们来看看另外一对与 C# 代码配合良好的第三方工具选项。
探索高级代码分析工具
内置的代码分析和代码度量工具对于想要定位不良代码并确保代码遵循 .NET 项目的最佳实践工程师来说非常好,但它们缺少一些企业级功能。
在本节中,我们将探讨两款不同的商业分析工具,我发现它们为 .NET 项目提供了额外的价值:SonarCloud 和 NDepend。
我不会介绍如何设置这些工具,因为这两个工具都有全面的文档,我在本章末尾的 进一步阅读 部分提供了链接。相反,我们将专注于专门的代码分析工具可以提供的洞察力,这些洞察力超出了 Visual Studio 中可用的内容。
使用 SonarCloud 和 SonarQube 跟踪代码度量
SonarCloud 和 SonarQube 是由 SonarSource 提供的一对商业代码分析工具。这两个产品都会查看包含各种流行编程语言代码的 Git 仓库,并生成一系列建议。
SonarCloud 和 SonarQube 之间的主要区别在于,SonarCloud 是由 SonarSource 维护的服务器上托管和分析的,而 SonarQube 是你可以安装在你服务器上的软件。
这两款软件都可以分析 Git 仓库中的代码,并在可靠性、可维护性、安全性和代码重复方面的问题区域提供热图。这些视图为你提供了一个简单的图形表示,有助于轻松标记问题区域,如图 图 12.12 所示:


这些工具内置了分析器,可以分析您的代码,并标记出需要修复的可靠性、安全性和性能问题。
一旦问题被标记,您可以使用图 图 12.13 中显示的网页用户界面将其分配给团队成员,添加注释,或将其标记为已解决或忽略:


对我来说,SonarCloud 和 SonarQube 有几个主要的卖点:
-
它们以一种非常用户友好的方式帮助非开发人员暴露技术债务。工程经理或首席技术官可以在他们的网络浏览器中查看项目,并了解薄弱区域,而无需安装 Visual Studio。这有助于使技术债务透明化。
-
由 SonarCloud 和 SonarQube 标记的项目往往值得调查,甚至可能比由 Visual Studio 代码分析器标记的项目更值得。
-
使用这些工具,您通常会获得良好的结果,无需额外配置,尽管配置可供您自定义。
SonarCloud 和 SonarQube 是基于项目代码行数定价的商业产品。SonarCloud 还可以免费用于任何公共 GitHub 仓库。
由于本书中的代码在 GitHub 上是公开的,您可以在 sonarcloud.io/summary/overall?id=IntegerMan_Refactoring-with-CSharp 上查看其代码分析结果。我还强烈建议您创建一个账户,并让 SonarCloud 分析您编写或熟悉的开源代码,以便熟悉设置和分析过程,并查看它给出的建议。
虽然 SonarCloud 和 SonarQube 不是特定于 .NET 的工具,但我确实发现它们与 .NET 项目配合得很好,这就是为什么它们在这本书中被突出显示。
接下来,让我们看看一个专门为 .NET 和 C# 项目构建的工具:NDepend。
使用 NDepend 进行深入的 .NET 分析
NDepend 是一款专为帮助架构师和软件工程师从他们的 C# 项目中获得最大效益而设计的强大工具。
NDepend 可以作为 Visual Studio 扩展(如 GitHub Copilot Chat)、独立应用程序或集成到 Azure DevOps 构建管道中的构建代理运行。
当 NDepend 运行其分析时,它会生成一个 HTML 报告(如图 12.14 所示)并在 Visual Studio 的仪表板视图中填充相同的信息:

图 12.14 – NDepend 报告显示代码分析结果
本报告突出了项目违反的代码分析规则数量、当前的单元测试代码覆盖率百分比以及指标随时间的变化情况。
尝试一下
您可以在本书的 GitHub 仓库中的 Chapter12/Ch12FinalCode/NDependOut/NDependReport.html 文件中查看本章的 NDepend 报告样本。
如果您和您的工程团队正在尝试回答诸如“我们是在变得更好还是更差?”,“我们面临的主要问题是什么?”,或“哪些领域需要最迫切地修复?”等问题,NDepend 将帮助您。
与 SonarCloud 类似,NDepend 在一系列称为“规则”的分析器上运行。这些规则使用 LINQ 对代表您源代码的模型进行编写。默认规则包含其源代码并提供自定义,以满足您团队的需求。您还可以编写自己的规则——就像我们在下一章中将要编写的 Roslyn 分析器一样。
这些规则还允许您比较自上次基准以来代码的变化情况,并估计解决它们所代表的技术债务所需的时间。
NDepend 的优势不仅限于其主要报告、规则列表和规则违规列表。NDepend 的真正优势在于其数据可视化。
依赖矩阵是 NDepend 最初为人所知的功能,它允许您看到不同命名空间和类型的二维矩阵,如图 12.15 所示:

图 12.15 – NDepend 依赖矩阵
这个矩阵有助于您检测相互依赖的命名空间或类型。当不同类型或命名空间相互依赖时,这通常表示软件架构划分不当,并且当存在违规时,NDepend 会使其高度可见。
然而,NDepend 的可视化并不止于此。我最喜欢的 NDepend 内置可视化是它的热视图,它允许您以分层树的形式查看项目中的类型或方法,其中不同的矩形代表不同的类型或方法。
这种视图类似于数据可视化工具中的树图,但每个矩形都是根据 NDepend 计算的各种指标着色和定制的。这些指标远远超出了 Visual Studio 自身计算的指标,包括代码行数、圈复杂度、单元测试覆盖率百分比,甚至文件中的注释数量。
如 图 12.16 所示的此热图是我找到的帮我聚焦潜在问题代码的最直观方式——并且能够将问题区域直观地传达给关键利益相关者:

图 12.16 – NDepend 热图显示代码行数和圈复杂度
NDepend 还提供依赖关系图视图。此图允许您看到程序集、命名空间、类型、方法、属性、事件以及甚至字段如何丰富和交互式地相互交互,如图 12.17 所示:

图 12.17 – Chapter12 项目中的命名空间和类型交互
这使您能够可视化您的软件架构,并将该架构传达给团队中的其他成员。这在欢迎新开发者加入时尤其方便。
图形视图还允许您发现问题区域,例如过度依赖其他类型的类型、相互依赖的不同命名空间,以及可能违反 SRP 的类。
根据我的经验,NDepend 需要一些额外的时间来配置和调查,但它代表了一种非常有效的可视化、沟通和导航代码中问题区域的方法。
让我们通过探索我们虚构组织中的代码分析来结束本章。
案例研究 – Cloudy Skies Airline
Cloudy Skies Airlines 知道他们有很多技术债务和代码问题,但不确定应该优先考虑哪些区域。每个工程师对什么最重要都有自己的看法。正如您所料,这些看法通常受每个工程师最近工作的内容的影响。
为了解决这个问题,工程领导转向数据。他们开始分析 Visual Studio 中可用的代码指标,并编制出大多数代码分析警告似乎所在的位置。
工程管理随后将问题区域与过去 3 个月内发生变化的区域以及组织预期将需要改变以支持团队即将推出的计划的区域进行了比较。这种方法帮助工程管理优先考虑战略区域的技术债务解决方案,这些区域支持业务目标。
为了解决警告积压,开发人员被赋予了新的命令:您提交的每个提交都不应增加活动代码分析警告的数量。警告数量的减少或保持不变是可以接受的,但在代码审查中增加是不被接受的。
这项政策增加了对代码分析警告的认识,警告随着时间的推移逐渐减少。一旦团队习惯了关注警告,他们就转向了一个更大的代码分析规则集。这导致了一系列新的警告出现,但这些警告有助于识别潜在或实际的问题,以及应用程序的优化。
为了帮助提供代码健康状况的洞察,该组织目前正在评估 SonarCloud 和 NDepend,为团队提供一个质量仪表板,帮助他们关注关键区域并确保质量持续保持高水平。
摘要
在本章中,我们看到了代码指标和代码分析工具如何帮助您发现代码中的问题区域,遵循最佳实践,并优先考虑技术债务的区域。这将帮助您了解您和您的团队所面临的挑战。一旦您知道了您所面临的挑战区域,您就可以专注于未来的修复工作。这也有助于您优先考虑技术债务的区域,并将这些区域传达给其他人。
这些内置分析器非常实用,实际上您还可以自己构建一些。在接下来的两个章节中,我们将这样做,因为我们将构建自己的代码分析器,它可以检测并自动修复问题。
问题
回答以下问题以测试您对本章知识的掌握:
-
您认为哪些区域是您代码中最有问题的地方?
-
代码指标对这些问题区域说了些什么?
-
循环复杂度是什么?它是如何计算的?
-
在选择代码分析规则集时,您应该考虑哪些因素?
进一步阅读
您可以在以下 URL 中找到有关代码分析的更多信息:
-
代码度量值:
learn.microsoft.com/en-us/visualstudio/code-quality/code-metrics-values -
.NET 源代码分析概述:
learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview -
SonarCloud:
www.sonarsource.com/products/sonarcloud/ -
NDepend:
www.ndepend.com/
第十三章:创建 Roslyn 分析器
在上一章中,我们介绍了使用代码分析器来检测代码中的问题。但是,当你的团队存在任何现有分析规则都无法检测到的常见问题时,会发生什么呢?
事实上,现代 C#提供了一种通过称为Roslyn 分析器的方式来构建自定义分析器的方法。在本章中,我们将通过构建自己的分析器来了解 Roslyn 分析器是如何实际工作的。
本章涵盖了以下主题:
-
理解 Roslyn 分析器
-
创建 Roslyn 分析器
-
使用
RoslynTestKit测试 Roslyn 分析器 -
将分析器作为 Visual Studio 扩展共享
技术要求
与其他章节不同,我们不会从示例代码开始。相反,我们将从一个空白解决方案开始,并逐渐向该解决方案中添加新的项目。
本章的起始空解决方案和最终代码可在 GitHub 的github.com/PacktPublishing/Refactoring-with-CSharp的Chapter13文件夹中找到。
理解 Roslyn 分析器
在我们深入探讨 Roslyn 分析器是什么之前,让我们先谈谈 Roslyn。
Roslyn是重新构想后的.NET 编译平台的代号,该平台与 Visual Studio 2015 一同发布。由于“.NET 编译平台”这个名字太长,大多数人将其称为 Roslyn 编译器或简称 Roslyn。
在 Roslyn 之前,如果某个工具想要理解 C#、VB 或 F#源代码,开发者需要为这些代码文件编写自己的语言解析器。这需要大量的时间和复杂性,并且每次这些编程语言发生变化时,都需要重复这项工作。这导致工具支持新语言特性的速度较慢,生产力下降,并出现错误。
Roslyn 编译器的一个明确目标是以标准化的方式提供代码结构的可见性。这样,插件就可以使用 Roslyn API 与代码工作,获取有关代码的实时信息,而无需编写自己的解析器。
为了做到这一点,项目可以创建Roslyn 分析器,这些分析器集成到代码分析和编译过程中。这让你可以做以下事情:
-
当代码中存在反模式时提供警告和错误
-
集成到快速操作菜单中,允许开发者使用既定解决方案自动修复已知问题
-
提供重构功能,从而提高开发者的生产力
你一直都在使用 Roslyn 分析器,通过各种在 Visual Studio 中看到的代码警告、建议和快速操作重构。
你可以通过访问解决方案资源管理器,然后展开一个项目的依赖项节点,接着展开其分析器节点和特定的分析器程序集,来探索项目中的内置分析器,如图13.1所示:

图 13.1 – 解决方案资源管理器中的代码分析器
在本章的剩余部分,我们将创建我们自己的 Roslyn 分析器,但在我们这样做之前,让我们谈谈 Roslyn 是如何看待 C#代码的。
安装扩展开发工作负载和 DGML 编辑器
当您使用 Roslyn 分析器进行开发时,Visual Studio 的两个新增功能将帮助您创建和调试自己的分析器。让我们通过从 Windows 开始菜单启动Visual Studio 安装程序来安装这些功能。接下来,选择您的 Visual Studio 安装并点击修改。
这将弹出一个包含可用工作负载和功能的列表。这些功能会随时间变化,但您需要确保在工作负载选项卡中勾选了Visual Studio 扩展开发工作负载,如图图 13.2所示:

图 13.2 – 安装 Visual Studio 扩展开发和工作负载 DGML 编辑器
接下来,在单个组件选项卡中找到DGML 编辑器,并在点击修改以安装附加组件之前也勾选它。
当您尝试为 Visual Studio 创建 VSIX 扩展项目时,Visual Studio 扩展开发工作负载非常有用。此类项目允许您向 Visual Studio 添加自定义用户界面元素、分析器和新功能。我们将在本章的其余部分和下一章中定期讨论 VSIX 扩展。
DGML 编辑器使用有向图标记语言(DGML)与 Visual Studio 一起显示交互式可视化。它还安装了一个非常有用的视图,将帮助我们更好地理解 Roslyn:语法可视化器。
介绍语法可视化器
语法可视化器是 Visual Studio 中的一个视图,它允许您从 Roslyn API 的角度查看源代码的结构。
要查看此功能,请在您的编辑器中打开一个 C#文件,然后通过点击视图菜单,接着是其他窗口,然后是语法可视化器来打开语法可视化器。
这应该显示与您编辑器中的代码相对应的各种节点层次结构,如图图 13.3所示:

图 13.3 – 语法可视化器与当前代码选择同步
在您的代码中点击各种关键字、变量、方法和值,并观察语法可视化器如何根据您的选择进行更改。
这是一种非常好的理解 Roslyn API 中代码结构的方式,但该工具在您不确定 Roslyn API 中的哪个类引用了您想要与之工作的代码元素类型时也非常有用。
现在我们对 Roslyn API 有了一定的了解,让我们创建我们的第一个 Roslyn 分析器。
创建 Roslyn 分析器
当人们遇到现有分析器无法解决的代码中的常见问题时,他们会创建自定义的 Roslyn 分析器。这些自定义分析器有助于强制执行特定组织或团队认为有用的规则。然而,这些特定组织的规则通常对更大的 .NET 社区来说不太相关。
这里有一些例子,说明你可能想要构建自定义分析器的情况:
-
你的团队遇到了太多来自
int.Parse等操作导致的FormatException错误,并希望将int.TryParse作为他们的标准。 -
由于文件很大且内存有限,你的团队希望避免使用
File.ReadAllText方法,而改用基于流的方案。 -
你的团队要求所有类都必须重写
ToString方法,以改善调试和日志记录体验。
注意,这些方法都与样式或语法无关。相反,这些分析器处理的是团队特定的关于如何最佳使用 .NET 的决策。我们将在 第十六章 采用 代码标准 中探讨强制执行样式和语法选择的方法。
假设 Cloudy Skies Airlines 在调试和代码故障排除上花费了大量的时间,并怀疑在更多地方重写 ToString 将会为他们的团队带来更好的开发者体验。
注意
在所有类中重写 ToString 并非一个既定的最佳实践。这样做可能存在一些性能上的缺点,但在这个章节中,我们将假设这个规则对 Cloudy Skies 团队来说是合理的。
在本章的剩余部分,我们将从空白解决方案开始创建这个分析器。
将分析器项目添加到我们的解决方案中
虽然 Visual Studio 中内置了创建 Roslyn 分析器的模板,但这些模板比较老旧,并且隐藏了一些实现细节。相反,我们将通过从空白解决方案开始创建和部署 Roslyn 分析器的步骤进行讲解。
我们将首先添加一个包含我们的分析器的类库。类库是一种特殊的项目类型,它为其他项目提供代码,但不能独立运行。
从 Chapter13BeginningCode 解决方案开始,我们在 解决方案资源管理器 中右键单击解决方案,然后选择 添加,接着选择 新建项目…。
从那里,我们将选择我们想要创建的项目类型,选择使用 C# 语言创建的 类库 项目,如图 图 13**.4 所示,然后点击 下一步:

图 13.4 – 将 C# 类库项目添加到我们的解决方案中
警告
有多个不同语言的类库项目,名称都为 Class Library。在列表中寻找绿色的 C# 图标和 C# 标签。
接下来,我们需要为我们的类库提供一个名称。这个库将包含我们本章创建的代码分析器,所以让我们称它为 Packt.Analyzers,因为项目的名称将成为项目的默认命名空间。
在此之后,你将被要求选择项目应使用的框架。选择 .NET Standard 2.0 并点击 创建。新项目将被添加到你的解决方案中。
为什么选择 .NET Standard?
与本书中的其他项目不同,我们在这里使用 .NET Standard。这是一个专为在多种不同的 .NET 运行时上运行而设计的 .NET 特殊版本。这使得 .NET Standard 成为当你不知道你的代码将在哪个版本的 .NET 上运行时的一个很好的选择。更多信息请参阅 进一步阅读 部分。
要创建 Roslyn 分析器,我们需要向我们的类库添加几个 NuGet 包。为此,在 解决方案资源管理器 中右键单击类库,然后选择 管理 NuGet 包…。
一旦你进入 Microsoft.CodeAnalysis 包,如图 图 13.5 所示:

图 13.5 – 安装 Microsoft.CodeAnalysis 的 4.0.1 版本
注意,版本 4.0.1 并不是这个包的最新版本。这个特定版本是为了避免与我们将要使用的测试库发生冲突而选择的。
现在包已经安装,我们就可以开始创建我们的 Roslyn 分析器了。
定义代码分析规则
让我们首先将 Class1.cs 文件重命名为 ToStringAnalyzer.cs 并用以下内容替换其内容:
using System;
using System.Linq;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
namespace Packt.Analyzers {
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ToStringAnalyzer : DiagnosticAnalyzer {
}
}
这是我们需要的最基本的编译分析器。让我们来看看这里有什么。
首先,ToStringAnalyzer 类从 DiagnosticAnalyzer 继承,这是所有提供警告给用户的 Roslyn 分析器的基类。
该类有一个 DiagnosticAnalyzer 属性,表示分析器适用于用 C# 编写的代码。
注意
可以编写适用于 C#、F#、Visual Basic 或这些语言的组合的分析器。
从抽象的 DiagnosticAnalyzers 类继承强制我们重写 SupportedDiagnostics 属性和 Initialize 方法。现在让我们以最简单的方式来做这件事:
public override ImmutableArray<DiagnosticDescriptor>
SupportedDiagnostics => null;
public override void Initialize(AnalysisContext con) {
}
SupportedDiagnostics 属性返回 ImmutableArray,它包含分析器提供给编辑器的所有诊断规则。在我们的情况下,我们希望它返回用户可能会看到的警告,如果规则被违反。
让我们添加一个新属性并更新我们的 SupportedDiagnostics 属性,如图所示:
public static readonly DiagnosticDescriptor Rule =
new DiagnosticDescriptor(
id: "CSA1001",
title: "Override ToString()",
messageFormat: "Override ToString on {0}",
category: "Maintainability",
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true,
description: "Override ToString to help debugging.");
public override ImmutableArray<DiagnosticDescriptor>
SupportedDiagnostics => ImmutableArray.Create(Rule);
在这里,我们添加了一个静态的 Rule 属性,它定义了定义我们规则的 DiagnosticDescriptor 对象。然后这个规则被包含在 SupportedDiagnostics 属性中。
本地化说明
DiagnosticDescriptor对象可以使用原始字符串创建,就像我们在这里使用的那样,或者通过使用LocalizableString参数。LocalizableString在不同语言中表现更好,所以如果你试图创建一个打算在全球范围内使用的 Roslyn 分析器,你将想要使用它。
如果规则被违反,此代码定义的DiagnosticDescriptor对象将显示在错误列表窗格和构建输出中。规则需要以下部分:
-
CSA代表 Cloudy Skies Airlines。 -
标题:代码分析警告的简称。这是当规则被违反时在工具提示中显示的内容。
-
消息格式:一个可格式化的字符串,它将在 Visual Studio 工具提示中显示。
-
命名、性能、可维护性、安全性、可靠性、设计和使用。 -
隐藏、信息、警告或错误。 -
默认启用:规则是否以启用状态开始。
-
描述:规则的详细描述以及为什么它很重要。当规则违反被展开时,这将在错误列表窗格中显示。
当其他代码需要引用你的确切规则定义时,将你的规则定义为单独的属性是有帮助的。
现在我们已经定义了规则,让我们编写检测规则被违反的代码。
使用我们的 Roslyn 分析器分析符号
让我们先从构建我们的Initialize方法开始:
public override void Initialize(AnalysisContext con) {
con.ConfigureGeneratedCodeAnalysis(
GeneratedCodeAnalysisFlags.None);
con.EnableConcurrentExecution();
con.RegisterSymbolAction(Analyze, SymbolKind.NamedType);
}
这种方法现在还增加了几个额外的功能:
-
首先,我们配置分析器在分析目的上忽略任何自动生成的代码。这些是用户没有编写的文件,而是由各种工具生成的,因此分析它们没有意义。
-
其次,我们告诉 Roslyn,同时评估多个代码片段使用这个规则是可以的。从性能的角度来看,这始终是首选选项。
最后,我们告诉分析器,在代码分析过程中遇到任何命名的Type时,我们都希望了解它。具体来说,代码应该为检测到的每个Type调用一个新的Analyze方法。
我们还没有编写那个Analyze方法,所以现在让我们来编写它:
private static void Analyze(
SymbolAnalysisContext con) {
INamedTypeSymbol sym = (INamedTypeSymbol)con.Symbol;
IMethodSymbol toString =
sym.GetMembers()
.OfType<IMethodSymbol>()
.FirstOrDefault(m => m.Name == "ToString"
&& m.IsOverride
&& m.Parameters.Length == 0);
if (toString == null) {
Diagnostic diagnostic = Diagnostic.Create(
Rule, sym.Locations[0], sym.Name);
con.ReportDiagnostic(diagnostic);
}
}
这段代码不易编写或阅读,所以在讨论如何编写分析器代码之前,我们先来了解一下。
首先,因为我们知道这个方法是在命名类型上被调用的,所以我们可以将 Roslyn 给出的符号强制转换为INamedTypeSymbol,这让我们可以进一步查询。
使用这个符号,我们可以使用GetMembers请求所有成员,例如属性和方法。接下来,我们可以使用 LINQ 将这些过滤到仅包含方法的那些。一旦我们有了这些,我们可以使用FirstOrDefault来查看是否有名为ToString的方法,它接受零个参数并且是重写的。
为什么不检查返回类型?
我们可以检查返回类型是否为字符串,但 C#编译器不允许有相同参数但返回类型不同的多个方法。我们还知道所有对象都有string ToString(),所以返回类型将是string。
如果我们没有找到ToString的重写,我们的分析器应该将其标记为违反规则。它通过创建一个引用我们之前定义的Rule属性、符号的名称和位置的Diagnostic对象来实现。在这里,符号将是一个没有重写ToString的Type定义。
在我们开始验证分析器是否工作之前,让我们谈谈编写分析器代码。
编写 Roslyn Analyzers 的技巧
根据我的经验,Roslyn Analyzers 是编写代码中较为困难的部分。使用 Roslyn,您将以完全不同的方式看待您的 C#代码。
您编写的每个分析器都可能分析的内容与上一个完全不同,这使得讨论 Roslyn 中可用的选项范围变得困难。
我发现两个关键点对于编写 Roslyn Analyzers 非常有帮助:
-
查看其他 Roslyn Analyzers:有很多其他的 Roslyn Analyzers(包括内置在.NET 中的),大多数都是开源的。这意味着您可以找到一个与您感兴趣的内容相似的现有分析器,然后查看其源代码并做类似的事情。
在本章的进一步阅读部分,您可以找到一些流行的 Roslyn Analyzers 集合。
-
使用
Analyze方法,您可以给 Copilot 一个提示,例如“我想找到这个类型中包含的所有方法”或“我如何检查这个类型是否被标记为公共的?”您仍然需要提供高级指导,但根据我的经验,Copilot 在帮助您编写复杂和不熟悉的分析器代码方面可以非常有效。
现在我们已经构建了我们的 Roslyn Analyzer,让我们看看如何确保它能够正常工作。
使用 RoslynTestKit 测试 Roslyn Analyzers
在本章的末尾,我们将展示如何在您的项目中使用 Roslyn Analyzers,但我们将首先围绕现有的分析器编写单元测试。
从高层次来看,我们希望使用我们的分析器测试两件事:
-
分析器不会对违反其规则的代码进行触发。
-
分析器正确地标记了它应该标记的代码。
我们将通过在新的单元测试项目中进行的两个单元测试来完成这项工作。
添加 Roslyn Analyzer 测试项目
我们的测试可以使用MSTest、xUnit或NUnit编写。我们将使用 xUnit 以确保一致性。
我们首先通过右键点击解决方案并选择添加然后新建项目…来向解决方案中添加一个新的 xUnit 项目,就像我们之前做的那样。
然后,选择Packt.Analyzers.Tests的 C#版本并点击下一步。当提示框架时,选择.NET 8.0并点击创建。
一旦项目创建完成,通过右键点击Packt.Analyzers.Tests项目并选择添加项目引用…来添加对Packt.Analyzers的引用,如图图 13.6所示。6*:

图 13.6 – 添加项目引用…
在Packt.Analyzers旁边勾选复选框,然后点击确定。这将允许您从测试项目中引用您的分析器。
接下来,我们需要添加对RoslynTestKit NuGet 包的引用。这是一个测试框架无关的库,它允许我们通过扩展某些测试固定类来对 Roslyn 分析器进行单元测试,正如我们稍后将会看到的。
右键点击Packt.Analyzers.Tests并点击SmartAnalyzers.RoslynTestKit。
故障排除安装问题
你可能会遇到Microsoft.CodeAnalysis和SmartAnalyzers.RoslynTestKit最新版本之间的冲突。请参阅本章节 GitHub 上的最终代码,以获取解决此问题的推荐 NuGet 包版本。
在项目设置完成后,让我们创建我们的测试固定器。
创建 AnalyzerTestFixture
我们首先将UnitTest1.cs重命名为ToStringAnalyzerTests.cs,并用以下代码替换其内容:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using RoslynTestKit;
namespace Packt.Analyzers.Tests;
public class ToStringAnalyzerTests : AnalyzerTestFixture {
protected override string LanguageName
=> LanguageNames.CSharp;
protected override DiagnosticAnalyzer CreateAnalyzer()
=> new ToStringAnalyzer();
}
这个类从RoslynTestKit中的AnalyzerTestFixture继承。这迫使类提供它所使用的语言以及创建我们想要测试的分析器的方法。由于我们使用 C#,我们返回LanguageNames.CSharp作为语言。在CreateAnalyzer中,我们从Packt.Analyzers项目实例化并返回我们的ToStringAnalyzer实例。
这让RoslynTestKit知道如何创建我们的分析器以及我们正在使用的语言,但我们还没有定义一个测试。现在让我们编写我们的第一个测试。
验证我们的 Roslyn 分析器不会标记好的代码
我们的第一项测试将是确保不违反我们的分析器的代码不会被标记为规则违规。我们将通过定义一个包含有效代码的字符串来测试这一点,并验证分析器没有发现任何问题。
我们如下声明“好的”代码:
public const string GoodCode = @"
using System;
public class Flight
{
public string Id {get; set;}
public string DepartAirport {get; set;}
public string ArriveAirport {get; set;}
public override string ToString() => Id;
}";
这个多行字符串定义了 C#的一个简单类声明,其中包含对ToString方法的覆盖。因为ToString被覆盖,我们的规则不应该在这个类定义中找到问题。
我们可以用以下代码来验证这一点:
[Fact]
public void AnalyzerShouldNotFlagGoodCode() {
NoDiagnostic(GoodCode, ToStringAnalyzer.Rule.Id);
}
在这里,我们使用RoslynTestKit's AnalyzerTestFixture类中的NoDiagnostic方法来检查代码没有违反我们的规则。
RoslynTestKit需要知道我们正在检查的规则的 ID,因此我们使用我们在ToStringAnalyzer上之前定义的Rule属性来提供其id值。
现在我们的测试没有问题通过,让我们继续进行第二个测试。
验证我们的 Roslyn 分析器会标记坏代码
为了验证不良代码会触发分析器规则,我们将使用类似的方法:我们将传递已知的坏代码并确保规则被触发。
这稍微复杂一些,因为我们想确保规则在代码中的正确符号上被触发。因此,当我们定义我们的坏代码时,我们需要添加[|和|]标记来表示应该标记哪个符号,如下所示:
public const string BadCode = @"
using System;
public class [|Flight|]
{
public string Id {get; set;}
public string DepartAirport {get; set;}
public string ArriveAirport {get; set;}
}";
这段代码没有重写 ToString 方法,所以 Flight 类应该被标记为规则违规。我们可以使用 HasDiagnostic 方法来验证这一点:
[Fact]
public void AnalyzerShouldFlagViolations() {
HasDiagnostic(BadCode, ToStringAnalyzer.Rule.Id);
}
这段代码与我们验证良好代码的方法非常相似,如果规则没有被触发或者没有明确为 Flight 符号触发,它将会失败。
我们可以继续通过添加额外的示例和反例来扩展我们的测试,但让我们简要地谈谈调试我们的 Roslyn 分析器。
调试 Roslyn 分析器
当你编写 Roslyn 分析器时,你很可能不会第一次就写对。
单元测试有助于检测分析器中的失败,但让我们谈谈如何调试 Roslyn 分析器。
我推荐的 Roslyn 分析器方法是遵循本章的方法:创建一个包含你的分析器和测试项目的类库。
如果你的分析器没有正确触发某些代码,你可以在分析器代码中设置断点,并通过右键单击特定测试用例并选择 调试 来逐步执行特定实例,如图 图 13**.7 所示:

图 13.7 – 调试特定测试用例
我发现这种方法在分析特定测试用例时非常有帮助。在这些情况下,我可以看到分析器从测试场景中遇到的精确对象。从那里,我编写了足够的代码,使分析器能够处理该场景。一旦分析器处理了该测试用例,我通常就准备好在更广泛的代码范围内尝试分析器了,我们将在下一节讨论。
将分析器作为 Visual Studio 扩展共享
一旦你准备好在更多代码上尝试分析器或与你的同事共享它,就有几种选项可供选择:
-
将分析器作为 NuGet 包部署,我们将在下一章讨论
-
创建一个 Visual Studio 安装程序 (VSIX) 来本地安装分析器
-
创建一个新的项目,并通过编辑
.csproj文件并添加一个Analyzer节来显式引用分析器,如图所示:
<ItemGroup>
<Analyzer Include="..\some\path\Your.Analyzer.dll" />
</ItemGroup>
如果你有一个大型解决方案并且希望你的分析器只应用于该解决方案中的其他项目,你可能考虑采用这种方法。然而,我发现这种方法存在错误,并且需要频繁重新加载 Visual Studio 以使分析器更改生效,所以我们将在本章结束时使用 VSIX 方法。
为你的 Roslyn 分析器创建一个 Visual Studio 扩展 (VSIX)
Visual Studio 扩展项目 (VSIX 项目) 允许你将各种功能打包成一个扩展,然后可以将其安装到 Visual Studio 中。
让我们创建一个新的 VSIX 项目,将我们的分析器添加到其中,然后在 Visual Studio 的新实例中使用它。
我们将像往常一样开始:在 解决方案资源管理器 中右键单击解决方案,选择 添加,然后选择 新建项目…。
接下来,选择 Packt.Analyzers.Installer 并点击 创建。
这个空项目包含一个单独的 source.extension.vsixmanifest 文件,我们将称之为清单。这是我们需要唯一的一个文件。双击它以打开设计器,如图 13.8* 所示:

图 13.8 – 设计视图中的清单
这将打开元数据视图,其中包含你可以配置的不同设置。我们将忽略这些设置,并点击左侧侧边栏上的 资产 选项卡。
资产 选项卡指定了扩展中包含的不同组件。我们希望包含我们的分析器,因此点击 新建 以打开 添加新资产 对话框。
接下来,指定 Packt.Analyzers 项目,如图 13.9* 所示:

图 13.9 – 将 Roslyn 分析器作为资产添加到你的 VSIX 项目中
点击 确定;你的分析器现在应该出现在资产列表中。
通过这个更改,我们的 VSIX 项目现在已准备好使用。要测试此项目,请右键单击 Packt.Analyzers.Installer 项目并选择 设置为启动项目。接下来,运行你的项目 – 将打开一个新的实验性 Visual Studio 实例。
注意
在运行项目后,Visual Studio 打开可能需要几分钟时间。打开的 Visual Studio 版本是专门为开发扩展而构建的,需要额外的时间来启动。不建议使用此版本的 Visual Studio 进行实际开发。相反,使用它来测试你的扩展,然后关闭它。
几分钟后,将打开一个新的 Visual Studio 实例,其中已安装你的 VSIX 项目。使用此实例的 Visual Studio,你可以打开任何其他项目,本章中构建的 Roslyn 分析器将处于活动状态。
具体来说,我们的分析器将在没有重写 ToString 的类上显示建议,例如图 13.10* 中的 SkillController 类:

图 13.10 – 我们的 Roslyn 分析器建议重写 ToString
你的分析器的警告也会显示在错误列表中,尽管如果你已经将它们标记为具有严重性,如我们在本章中所做的那样,你需要确保消息显示在这些结果中。请参阅图 13.10* 中突出显示的消息过滤器按钮。
DebuggerDisplay 属性与 ToString 重写之间的比较
本章以 ToString 为例,重写 ToString 方法可以帮助改善调试器的体验。另一种方法是,在你的类定义上方添加 [DebuggerDisplay] 属性来描述它在调试器中的显示方式,而无需重写 ToString。
一旦你对测试结果满意,请关闭 Visual Studio 的新实例。
构建和测试您的安装程序将在您的扩展项目 bin/Debug 文件夹中创建一个 Packt.Analyzers.Installer.vsix 文件。这个 .vsix 文件将允许其他人安装您的自定义扩展并在他们的项目中使用您的分析器。
注意
您还可以在 Visual Studio 市场中分发您的安装程序。这将使扩展公开可用,并使其他人更容易找到和下载。
每次您更新您的分析器时,您都需要分享扩展的新版本,并且您的团队需要升级。这使得通过 .vsix 文件管理 Roslyn 分析器具有挑战性。
幸运的是,NuGet 包提供了一种更好的方式来共享 Roslyn 分析器,我们将在下一章中看到。
摘要
在本章中,我们创建了我们的第一个 Roslyn 分析器,使用 RoslynTestKit 进行测试,并构建了一个 VSIX 扩展以将其集成到 Visual Studio 中。
我们看到了 Roslyn 分析器如何驱动我们在 Visual Studio 中交互的所有警告,以及您和您的团队如何创建新的 Roslyn 分析器来检测和标记对您的团队及其代码库独特的问题。
在下一章中,我们将看到如何使用 Roslyn 分析器来修复它们发现的问题,并帮助安全地重构您的代码。
问题
-
Roslyn 分析器是如何工作的?
-
您何时想创建自己的 Roslyn 分析器?
-
您如何验证 Roslyn 分析器是否正确工作?
进一步阅读
您可以在以下网址找到关于本章所涵盖主题的更多信息:
-
Roslyn 分析器:
learn.microsoft.com/en-us/visualstudio/code-quality/roslyn-analyzers-overview -
安装第三方 分析器:
learn.microsoft.com/en-us/visualstudio/code-quality/install-roslyn-analyzers -
Awesome Roslyn:
github.com/ironcev/awesome-roslyn -
.NET 标准:
learn.microsoft.com/en-us/dotnet/standard/net-standard
这里有一些在 GitHub 上流行的开源 Roslyn 分析器:
-
Roslyn 分析器: https://github.com/dotnet/roslyn-analyzers
第十四章:使用 Roslyn 分析器重构代码
在上一章中,我们看到了如何构建 Roslyn 分析器来标记代码中的问题。在本章中,我们将通过提供用户可以调用的快速操作来修复代码问题,从而改进我们的分析器,让它们能够修复代码问题。我们还将讨论一些额外的部署 Roslyn 分析器的方法,这些方法可以提高您为团队成员提供一致体验的能力。
本章涵盖了以下内容:
-
构建 Roslyn 分析器代码修复
-
使用 RoslynTestKit 测试代码修复
-
将 Roslyn 分析器作为 NuGet 包发布
技术要求
在本章中,我们将从第十三章结束的地方开始。
本章的起始代码可以从 GitHub 的github.com/PacktPublishing/Refactoring-with-CSharp中的Chapter14/Ch14BeginningCode文件夹获取。
案例研究 – Cloudy Skies Airlines
在第十三章中,我们构建了一个ToStringAnalyzer,它可以检测没有重写ToString方法的类。这导致 Visual Studio 编辑器中的建议和在错误列表中的消息。
Cloudy Skies Airlines 已内部部署此工具并发现它通常很有帮助,但还有一些需要改进的地方:
-
尽管分析器标记了
ToString重写规则的违规行为,但并非每个开发者都在解决这个问题。在内部讨论时,一些开发者表示他们不想花时间解决这个问题。此外,一些新开发者没有完全理解这个规则或修复它的样子会是什么样子。 -
每当创建一个新的分析器或解决现有分析器中的错误时,必须创建一个新的 VSIX 文件。然后开发者需要下载并安装它以获取更新版本。正因为如此,团队很难知道哪些开发者安装了分析器,或者每个开发者使用的是哪个版本。
在本章中,我们将解决这些问题。我们将探讨创建和测试一个能够自动解决检测到的问题的代码修复提供者。之后,我们将探索通过NuGet 包发布分析器,并展示它们如何帮助您的团队能够获得一致的分析器体验。
构建 Roslyn 分析器代码修复
Roslyn 分析器允许你为用户提供选项,自动修复分析器在代码中检测到的问题。他们通过称为代码修复提供者的东西来完成这项工作,它可以以自动化的方式修改你的文档,以解决诊断警告。
想象一下:诊断分析器,就像我们的OverrideToStringAnalyzer,帮助检测团队代码中的问题。另一方面,代码修复提供者为你提供了一种修复这些问题的方法。
并非所有诊断分析器都会有代码修复提供者,但根据我的经验,那些也提供代码修复提供者的分析器往往会被更早和更一致地解决。
让我们看看它是如何工作的。
创建一个 CodeFixProvider
首先,我们将在 Packt.Analyzers 类库中添加一个新的类。我们将把这个类命名为 ToStringCodeFix。用以下代码替换其内容以实现基本的代码修复:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading.Tasks;
namespace Packt.Analyzers {
[Shared]
[ExportCodeFixProvider(LanguageNames.CSharp,
Name = nameof(ToStringCodeFix))]
public class ToStringCodeFix : CodeFixProvider {
public override ImmutableArray<string>
FixableDiagnosticIds =>
ImmutableArray.Create(ToStringAnalyzer.Rule.Id);
public override FixAllProvider GetFixAllProvider()
=> WellKnownFixAllProviders.BatchFixer;
public async override Task RegisterCodeFixesAsync(
CodeFixContext context) {
throw new NotImplementedException();
}
}
}
这是我们需要的最小代码量,以便有一个可编译的代码修复提供者。在我们构建这个类的其余部分之前,让我们看看这里已经有什么了。
首先,我们声明一个继承自 CodeFixProvider 的 ToStringCodeFix 类。CodeFixProvider 是用于为一个或多个诊断提供修复的抽象类。
注意,我们给代码修复命名为 ToStringCodeFix,以与它提供的代码修复的 ToStringAnalyzer 类相匹配。这是一个我喜欢遵循的约定,以帮助清楚地关联分析器和它们的代码修复。
该类有两个属性被分配给它:
-
ExportCodeFixProviderAttribute告诉 Roslyn,该类代表一个代码修复,代码修复的名称以及代码修复应用到的语言。 -
SharedAttribute本身不做任何事情,但它是为了让 Roslyn 在 Visual Studio 中舒适地注册你的代码修复所必需的。
这两个属性应该出现在你创建的每一个代码修复中。未能使用它们将导致你的代码修复提供者对某些用户不可见(别问我怎么知道的)。
ToStringCodeFix 类目前有三个成员:
-
ToStringAnalyzer规则,这意味着它表示它可以修复该问题。 -
WellKnownFixAllProviders.BatchFixer,我们告诉 Visual Studio 允许用户尝试修复文件、项目或甚至解决方案中该类型的所有问题。 -
RegisterCodeFixesAsync:这是我们注册代码修复并告诉 Visual Studio 如果用户选择应用它应该做什么的地方。
我们的大部分逻辑将在 RegisterCodeFixesAsync 中,所以现在让我们实现这个方法。
注册代码修复
RegisterCodeFixesAsync 的任务是解释违反我们设置的诊断规则的代码,并注册一个让用户可以修复它的操作。
实现这一点的代码相当复杂,所以让我们分部分来看。第一部分与解释诊断违规发生在文档中的哪个位置有关:
public async override Task RegisterCodeFixesAsync(
CodeFixContext context) {
Diagnostic diagnostic = context.Diagnostics.First();
TextSpan span = diagnostic.Location.SourceSpan;
Document doc = context.Document;
这里,我们得到一个包含有关代码分析诊断违规信息的 CodeFixContext 对象。
这些 Diagnostic 对象包含有关触发规则的文档中确切文本范围的信息。在我们的情况下,这应该是没有重写 ToString 的类的名称文本。
接下来,我们获取包含违规的Document的引用。将Document想象成你解决方案中某个地方的源代码文件。分析器和代码修复可以查看整个解决方案,所以这个Document有助于缩小范围到包含违规代码的文件。
使用这个Document,我们可以访问语法树及其type声明:
SyntaxNode root = await doc
.GetSyntaxRootAsync(context.CancellationToken)
.ConfigureAwait(false);
TypeDeclarationSyntax typeDec =
root.FindToken(span.Start)
.Parent
.AncestorsAndSelf()
.OfType<TypeDeclarationSyntax>()
.First();
在这里,我们正在获取表示我们文档基础的SyntaxRoot元素,然后根据文档中该文本 span 的位置找到类的声明。
这使我们能够从我们在 span 中拥有的原始文本跳转到表示Type声明的对象。拥有这个对象允许我们进行更改并提供修复。
方法末尾部分注册了修复问题的代码操作:
CodeAction fix = CodeAction.Create(
title: "Override ToString",
createChangedDocument: c => FixAsync(doc, typeDec)
);
context.RegisterCodeFix(fix, diagnostic);
}
此代码创建一个CodeAction并将其注册为对诊断规则的修复。此修复有一个标题,表示用户将在我们尚未看到的FixAsync方法中看到的文本。
其他选项
CodeAction.Create有几个重载和可选参数,允许您更改整个解决方案而不是单个文档,或者在多个代码修复具有相同标题时解决冲突。
现在我们已经注册了我们的代码修复,让我们看看修复操作是如何工作的。
使用代码修复修改文档
实现我们的代码修复的最终步骤是FixAsync方法。此方法的工作是修改Document,使其不再违反诊断规则。
在我们的情况下,修复将生成如下代码:
public override string ToString()
{
throw new NotImplementedException();
}
很遗憾,在这里直接编写原始 C#代码比使用 Roslyn API 构建它要容易得多。
要使用 Roslyn 添加此功能,我们将遵循以下步骤:
-
创建一个抛出
NotImplementedException的方法体。 -
创建与方法(
public和override)一起使用的修饰符列表。 -
使用适当的名称和返回类型创建一个方法声明,并确保这个方法有修饰符列表和方法体。
-
创建一个具有新方法的
Type声明版本。 -
在
Document中找到Type声明并将其替换为我们新的声明。
让我们看看这是如何工作的,从声明新方法体的代码开始:
private Task<Document> FixAsync(Document doc,
TypeDeclarationSyntax typeDec) {
const string exType = "NotImplementedException";
IdentifierNameSyntax exId =
SyntaxFactory.IdentifierName(exType);
BlockSyntax methodBody = SyntaxFactory.Block(
SyntaxFactory.ThrowStatement(
SyntaxFactory.ObjectCreationExpression(exId)
.WithArgumentList(SyntaxFactory.ArgumentList())
)
);
如您所见,在 Roslyn 中声明任何内容的代码可能会变得有点密集。然而,当你退一步看时,这段代码只是在声明一个方法块,该块实例化并抛出NotImplementedException。
接下来,我们将定义使用此方法体的方法定义:
SyntaxToken[] modifiers = new SyntaxToken[] {
SyntaxFactory.Token(SyntaxKind.PublicKeyword),
SyntaxFactory.Token(SyntaxKind.OverrideKeyword)
};
SyntaxToken returnType =
SyntaxFactory.Token(SyntaxKind.StringKeyword);
MethodDeclarationSyntax newMethod =
SyntaxFactory.MethodDeclaration(
SyntaxFactory.PredefinedType(returnType),
SyntaxFactory.Identifier("ToString")
)
.WithModifiers(SyntaxFactory.TokenList(modifiers))
.WithBody(methodBody);
这段代码与上一个块几乎一样密集,但它实际上只是声明了方法。此方法结合了string返回类型、名为ToString的名称、public和override修饰符以及我们在上一个块中声明的主体。
修复的最终步骤是修改编辑器的代码以使用我们的代码修复。我们使用以下代码来完成此操作:
TypeDeclarationSyntax newType =
typeDec.AddMembers(newMethod);
SyntaxNode root = typeDec.SyntaxTree.GetRoot();
SyntaxNode newRoot = root.ReplaceNode(typeDec, newType);
Document newDoc = doc.WithSyntaxRoot(newRoot);
return Task.FromResult(newDoc);
}
这段代码创建了一个新的Type声明版本,其中包含我们的新方法。然后我们在Document中找到旧的Type声明,并用新的一个替换它。这创建了一个新的Document,然后我们从我们的代码修复中返回它,Visual Studio 相应地更新我们的代码。
这样,我们现在有一个工作的代码修复。我们如何知道它正在工作?我们测试它!
使用 RoslynTestKit 测试代码修复
在第十三章中,我们看到了RoslynTestKit库如何帮助您的诊断分析器适当地标记代码问题。在本章中,我们将重新访问该库以验证我们新的代码修复。
我们将首先在我们的测试项目中创建一个名为ToStringCodeFixTests的新类,这是由于我们的常见命名约定。
这个类将首先声明一个测试固定装置,就像它对分析器所做的那样:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Diagnostics;
using RoslynTestKit;
namespace Packt.Analyzers.Tests;
public class ToStringCodeFixTests : CodeFixTestFixture {
protected override string LanguageName
=> LanguageNames.CSharp;
protected override CodeFixProvider CreateProvider()
=> new ToStringCodeFix();
protected override IReadOnlyCollection<DiagnosticAnalyzer>
CreateAdditionalAnalyzers()
=> new[] { new ToStringAnalyzer() };
与以前一样,我们的测试类继承自测试固定装置,但这次它是CodeFixTestFixture,因为我们正在测试一个代码修复。
同样,我们还需要指定我们的代码修复会影响 C#编程语言,并通过CreateProvider方法提供对我们类的引用。
与以前不同,我们还需要通过CreateAdditionalAnalyzers方法提供我们正在测试的代码分析器。编译器允许您不重写此方法,但如果您忘记这样做,您的分析器在接下来的步骤中永远不会触发,所以请确保在这里包含您的分析器。
接下来,我们通过提供一个坏代码块和一个好代码块来测试我们的代码修复,并验证代码修复是否成功地将坏代码转换为好代码:
public const string BadCode = @"
using System;
public class [|Flight|]
{
public string Id {get; set;}
public string DepartAirport {get; set;}
public string ArriveAirport {get; set;}
}";
public const string GoodCode = @"
using System;
public class Flight
{
public string Id {get; set;}
public string DepartAirport {get; set;}
public string ArriveAirport {get; set;}
public override string ToString()
{
throw new NotImplementedException();
}
}";
[Fact]
public void CodeFixShouldMoveBadCodeToGood() {
string ruleId = ToStringAnalyzer.Rule.Id;
TestCodeFix(BadCode, GoodCode, ruleId);
}
}
这段代码与上一章中的代码有些相似。就像分析器一样,我们需要使用[|和|]标记来表示触发修复的位置,就像我们在[|Flight|]中看到的那样。
实际的验证步骤是通过TestCodeFix方法调用来进行的。此方法调用将使用代码修复将您的坏代码转换为新的形式,然后将其与预期的良好代码进行比较。
这个比较非常敏感,任何额外的空格、换行符或任何差异都将导致测试失败,并在两个字符串之间突出显示观察到的差异,如图图 14**.1所示:

图 14.1 – 由于样式选择导致的字符串差异的测试失败
假设您的格式是一致的,您的测试现在应该通过了,这证明了您有一个好的代码修复。
如果您愿意,现在可以启动您的 VSIX 扩展项目,并在 Visual Studio 中验证代码修复。之后,您可以与同事或.NET 社区的人分享 VSIX 文件,他们就可以访问您的分析器和其修复。
然而,正如我们很快就会看到的,VSIX 部署有一些缺点。让我们通过查看使用 NuGet 包以更受控的方式共享您的代码修复来结束这一章。
将 Roslyn 分析器作为 NuGet 包发布
使用 VSIX 文件共享代码分析器是可行的,但并不是一个理想的解决方案。
由于 VSIX 文件必须手动安装和更新,这意味着在软件工程师团队中,您永远无法确定谁安装了扩展,或者谁使用的是哪个版本的扩展。
因为每个开发者都必须自己安装并保持 VSIX 更新,这使得吸纳新团队成员、发布新的分析器或代码修复,或为现有分析器中发现的问题发布补丁变得更加困难。
幸运的是,有一个更好的选择:NuGet 包部署。
理解 NuGet 包部署
分析器和代码修复可以被打包成 NuGet 包并部署到 NuGet 源,这样其他人就可以找到它们。一旦进入 NuGet 源,团队中的任何开发者都可以将包安装到一个或多个项目中。
一旦安装了 NuGet 包,任何打开项目的开发者都会自动通过几乎不可见的 NuGet 包还原步骤下载该包。如果您安装了一个 NuGet 包,然后添加、提交并推送更改,其他开发者会在他们拉取您的更改并在 Visual Studio 中打开项目时自动看到已安装的包。
这意味着您的团队中只需要一个开发者安装任何 NuGet 包,包括包含 Roslyn 分析器的包。此外,如果您需要更新包以包含新的分析器,团队中的任何开发者都可以更新已安装包的版本。
通过使用 Roslyn 分析器的 NuGet 包部署,您的分析器将变为:
-
容易安装
-
容易更新
-
在团队所有开发者中一致可用
-
故意与项目关联
最后一点很有趣。使用 VSIX 部署,分析器适用于开发者在他们的机器上打开的任何代码。分析器与您团队源代码之间没有正式的联系,但如果开发者安装了 VSIX 分析器,他们会看到其建议。
使用 NuGet 包,您明确指定了哪些分析器应该分析哪些项目,因为您通过 NuGet 安装过程明确地将它们关联起来。这意味着您可以在解决方案中的任何项目中查看,并了解所有项目开发者应该应用哪些分析器规则,这是通过 VSIX 部署很难实现的。
由于这些原因,我强烈建议将您的分析器和代码修复作为 NuGet 包部署。
让我们看看这是如何操作的。
构建 NuGet 包
Visual Studio 为您提供了一种轻松打包大多数.NET 项目的方法:只需在解决方案资源管理器中右键单击一个项目,选择属性,然后在导航器中的包下找到常规选项卡。从那里,您可以选择在构建操作期间生成包文件复选框,如图图 14.2所示:

图 14.2 – 在 Visual Studio 中启用 NuGet 包创建
当这个复选框被勾选时,构建后你应该能在你的构建输出中看到以下内容:
1>Successfully created package 'C:\PacktBook\Chapter14\Ch14BeginningCode\Packt.Analyzers\bin\Debug\Packt.Analyzers.1.0.0.nupkg'.
1>Done building project "Packt.Analyzers.csproj".
通用刀片还允许你配置与包关联的许多元数据部分。这让你可以指定一个自述文件或一个徽标,输入你需要任何法律信息,等等。这些信息将在用户考虑安装你的包时可见。
配置用于向公众发布 NuGet 包时需要考虑的许多事情超出了本书的范围,但额外的资源列在本章末尾的进一步阅读部分。
不幸的是,当为 Roslyn 分析器构建包时,你需要自定义比 Visual Studio 在属性用户界面中提供的更多的内容。
双击.csproj文件中的Packt.Analyzers,并将其替换为以下内容:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<IncludeBuildOutput>false</IncludeBuildOutput>
<Authors>YourName</Authors>
<Company>YourCompany</Company>
<PackageId>YourCompany.Analyzers</PackageId>
<PackageVersion>1.0.0</PackageVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Description>
Sample analyzer with fix from "Refactoring with C#"
by Matt Eland via Packt Publishing.
</Description>
<PackageProjectUrl>
https://github.com/PacktPublishing/Refactoring-with-CSharp
</PackageProjectUrl>
<RepositoryUrl>https://github.com/PacktPublishing/
Refactoring-with-CSharp</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis"
Version="4.0.1" />
<None Include="$(OutputPath)\Packt.Analyzers.dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
</Project>
这些额外的元数据部分自定义了你的包将被如何安装。让我们分别讨论每个相关更改:
-
GeneratePackageOnBuild与在属性页上勾选复选框以在构建时构建包相同。
-
ItemGroup部分。 -
Packt.Analyzers,我建议使用你的名字,而不是Packt,以避免在发布时发生冲突。 -
PackageVersion是包的发布版本号。包的最新版本通常是人们使用 NuGet 安装的版本。
-
PackageLicenseExpression是可选的,但它允许你告诉其他人,如果你的包有任何开源许可,那么适用于包的使用。各种许可类型及其法律影响超出了本书的范围。
-
描述是对包所做之事以及为什么有人可能想要安装它的简短、友好的描述。
-
RepositoryUrl是可选的,它告诉其他人包代码在哪里可用。
这个文件真正关键的部分是ItemGroup中的None元素。这一步告诉打包过程将分析器项目的编译 DLL 放入 NuGet 包的analyzers/dotnet/cs目录中。
这个目录是一个特殊的目录,当.NET 从各种来源加载 Roslyn 分析器时会查看它。如果它在那里看不到你的分析器,那么这些分析器将不会被加载。
配置这些步骤并保存文件后,重新构建项目,你应该能在你的Packt.Analyzers项目的bin\Debug或bin\Release目录中看到创建的 NuGet 包。
调试与发布构建
当发布软件时,你将想要使用Release配置而不是Debug配置。Debug配置抑制了某些编译器优化,并添加了额外的构建副产品,这些副产品有助于你调试应用程序。Release构建通常更小、更快,并且通常推荐使用。你可以使用 Visual Studio 的主工具栏更改哪个配置是活动的。
一旦创建了 .nupkg 文件,你就可以将其发布供他人使用。
部署 NuGet 包
现在我们有了 .nupkg 文件,我们可以将其部署到任何 NuGet 资源库。这可以是你自己在你组织内设置的资源库,GitHub 上的私有 NuGet 注册表,或者像 NuGet.org 上的公共 NuGet 资源库。
由于 NuGet.org 是共享开源代码包的标准场所,我们将在本章中探索此路径。如果你的代码是专有的,并且你只想在组织内部共享,它不应该上传到 NuGet.org。
NuGet 托管选项
如果你想在 NuGet.org 之外托管你的 NuGet 包,你有几种选择,包括设置私有 NuGet 服务器或使用 GitHub 提供的团队共享 NuGet 存储库服务。有关更多信息,请参阅 进一步阅读 部分。
要开始,导航到 NuGet.org,创建一个用户,然后以该用户身份登录。
一旦你完成认证,点击 上传 选项卡开始上传 NuGet 包的过程。这将允许你拖放或点击 浏览… 来找到你的 NuGet 包,如图 图 14**.3 所示:

图 14.3 – 上传 NuGet 包
如果你需要帮助找到你的 .nupkg 文件,它应该位于 \bin\Debug 文件夹或 \bin\Release 文件夹中,具体取决于你是否以 Debug 或 Release 模式构建项目。
小贴士
当与他人共享代码时,始终最好发布 Release 版本。
一旦你选择了你的 NuGet 包,页面将更新为它检测到的关于你的包的信息。这包括版本号、许可文件、readme 文件和其他信息。虽然最好在 Visual Studio 中配置这些值,但某些内容,如 readme 文件,可以在发布前在此处进行自定义。
如果某些内容看起来不正确,你可以创建一个新的 .nupkg 文件并上传该文件。
一旦你对预览屏幕上的信息满意,点击 NuGet.org 将开始检查你的文件是否有任何有害内容,并对包进行索引以便他人可以导入。
此过程通常需要 5 到 15 分钟,但可能会有所不同。如果你想检查包的状态,可以刷新 图 14**.4 中找到的包详细信息页面来检查状态。

图 14.4 – NuGet.org 检查和索引包
一旦此过程完成,你就可以在 Visual Studio 中引用该包了。
引用 NuGet 包
一旦你的包在 NuGet.org 上发布,你就可以在任何兼容的 .NET 项目中引用它。
为了证明这一点,打开前一章的解决方案或创建一个新的控制台应用程序。接下来,在 解决方案资源管理器 中选择该项目的 管理 NuGet 包…。
一旦 NuGet 包管理器 出现,转到 浏览 选项卡,通过名称搜索你的包。假设名称正确且你的包已完成索引,你应该能在 图 14.5* 中看到该包:

图 14.5 – 在 NuGet 包管理器中引用你的包
点击 安装 以安装你包的最新发布版本,并注意根据你创建 NuGet 包时的选择出现的依赖项和许可条款。
一旦你的包安装完成,你的分析器现在将保持活跃状态,并将在 解决方案资源管理器 中项目 依赖项 节点的 分析器 节点内显示,如图 图 14.6* 所示:

图 14.6 – 我们的分析器包已安装并在项目中激活
分析器也将对项目中的任何类都保持活跃状态,并提供建议和代码修复。
一旦你提交并推送你的更改到项目,团队中的其他人将拉取对新 NuGet 依赖项的引用。Visual Studio 然后将恢复你的 NuGet 包并在本地安装分析器供你的同事使用。
如果你需要更新你的 NuGet 包,你可以创建包的新版本并将其上传到 NuGet.org。一旦新版本被索引,你将能够通过 NuGet 包管理器更新已安装的包版本。
NuGet 部署过程使得在你的项目中安装和更新包变得容易,这些包随后可供团队中的每个开发者使用。这就是为什么这个流程是我推荐与你的团队共享 Roslyn 分析器的默认方法。
将 CodeFixProvider 打包为扩展
如果你想要将你的代码修复打包为 VSIX 扩展,你可以像我们在 第十三章 中做的那样进行,只需进行一个额外的更改。
要使你的 CodeFixProvider 在扩展中工作,你需要在安装程序的清单中添加一个 托管扩展性框架 (MEF) 资产。
要这样做,转到安装程序项目清单的 资产 面板并点击 新建。
接下来,选择 Microsoft.VisualStudio.MefComponent 作为类型,指定源为 当前解决方案中的项目,并指定你的分析器项目作为项目(见 图 14.7* 中的示例)。

图 14.7 – 将 MEF 组件资产添加到安装程序清单中
此更改将确保安装程序正确注册你的代码修复。
根据我的经验,通常通过 NuGet 包而不是 VSIX 安装程序来维护分析器更容易,但两种部署模型都有其优点。选择最适合你的安装、更新和安全需求的方法。
摘要
在本章中,我们看到了如何扩展 Roslyn Analyzers 以提供代码修复,以及它们已经提供的诊断信息。
代码修复通过解释您的代码的树结构并对该结构进行修改来实现,从而生成新的文档或解决方案。Visual Studio 然后通过更新源代码对这些更改做出反应。
这意味着代码修复可以自动对您的代码进行预配置的修改,以可重复且安全的方式解决已知问题。
我们还讨论了如何通过 NuGet 包部署将您的 Roslyn Analyzers 封装成包并与其他开发者共享——无论是您团队中的其他开发者还是全球的开发者。
这本书的第三部分到此结束。在本书的最后一部分,我们将探讨在现实世界组织和团队中重构代码时遇到的独特挑战和机遇。
问题
-
DiagnosticAnalyzer和CodeFixProvider之间有什么关系? -
如何测试代码修复?
-
与 VSIX 部署相比,NuGet 部署有哪些优势?
进一步阅读
您可以在以下网址找到有关本章材料的更多信息:
-
开始使用语法 转换:
learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/get-started/syntax-transformation -
配置和发布 NuGet 包:
learn.microsoft.com/en-us/nuget/quickstart/create-and-publish-a-package-using-visual-studio?tabs=netcore-cli -
托管自己的 NuGet 源:
learn.microsoft.com/en-us/nuget/hosting-packages/overview -
在 GitHub 上使用 NuGet:
docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-nuget-registry
第四部分:企业级重构
在本书的第四和最后一部分,我们关注重构的社会方面:向他人传达技术债务、作为工程组织采用代码标准,以及在敏捷环境中进行重构。
说服一个大型团队或组织认识到重构的重要性可能是一场关键的战斗,因此本部分探讨了软件工程师如何与业务领导者合作。这些章节包含确保重构真正发生以及首先重构正确技术债务区域的关键技巧和窍门。
我们特别关注敏捷环境中的重构,以及如何处理那些感觉需要完全重写的大型重构场景。
本部分包含以下章节:
-
第十五章**,沟通技术债务
-
第十六章**,采纳代码标准
-
第十七章**,敏捷重构
第十五章:沟通技术债务
大多数开发者都曾在无法偿还技术债务的环境中工作过,这不是因为任务的难度,而是因为组织优先级、恐惧、紧急截止日期以及对技术债务对其软件的全面影响的缺乏清晰理解。
在本章中,我们将探讨一些可能阻止你和你的团队解决技术债务的因素,并介绍一些帮助组织理解和重视重构过程的方法。
我们将涵盖以下主要主题:
-
克服重构的障碍
-
沟通技术债务
-
优先考虑技术债务
-
获得组织支持
克服重构的障碍
当我与技术社区的开发者交谈时,几乎每个人都有一段故事,讲述他们被告知不允许花时间重构他们的代码。
有时这项命令来自高层管理,有时来自产品管理或参与敏捷流程的人。然而,同样经常的是,指示来自工程领导,如团队领导或工程经理。
这种情况的原因可能因组织和你正在工作的项目而异,但一些常见的原因包括以下内容:
-
有一个紧急截止日期,团队必须专注于实现它
-
重构代码被认为不会提供任何商业价值
-
变更将涉及应用程序中技术债务很高的风险区域,存在引入错误的风险
-
开发者被告知“不要担心代码的质量;这只是一个原型,不会投入生产”(通常会的)
-
团队得到保证“不要担心代码的质量;我们将完全重写这个应用程序”(你通常不会)
让我们讨论一些这些反对意见。
紧急截止日期
“我们面临截止日期”的反对意见是许多团队非常常见的一个。有时,团队确实面临一个不能错过的关键截止日期。在这些时候,常常是“全员出动”,人们在高压环境下工作,通常还要加班。在这种情况下,花时间解决技术债务可能会对团队及其按时完成任务的机会造成干扰。
换句话说,有时,这种反对意见是合理的,在特定和有限的时间内对业务是有意义的。
然而,这些高度紧急的时间段会导致技术债务以非常高的速度积累,因为开发者没有时间以正确的方式做事。虽然团队可能在短时间内完成一些惊人的事情,但这些事情很少以能够经受时间考验的、可维护的代码的方式完成。
此外,许多组织从紧急截止日期到紧急截止日期,导致团队在无法偿还的情况下以惊人的速度积累技术债务。
有时候,截止日期无法改变或避免,例如财政年度结束、交易会或其他会议的截止日期。为了在特定日期之前完成关键的商业目标,短期内积累技术债务也可能具有战略上的好处。
然而,作为软件工程师或工程领导者,你有责任清晰、简洁、定期地向管理层沟通技术债务及其影响。一旦管理层充分理解了障碍,你必须与他们合作,制定长期补救措施和工作安排。
我们将在本章后面更详细地讨论这个补救过程。
“不要触碰高风险代码”
当你想到某些代码部分过于脆弱,以至于不需要触碰时,这种反对意见是可笑的。毕竟,如果代码已经退化到连尝试改进都感到害怕的地步,那么重构的需求可能已经被推迟了一段时间。
虽然这段代码触碰起来很危险,但不重构它,当团队最终被迫对其进行更改时,可能会导致灾难性的后果。让我们来分析一下反对重构这段代码的论点。
在这种情况下,核心担忧通常是以下恐惧的组合:
-
触碰这段代码很可能会引入错误
-
我们不明白这段代码应该如何工作
-
没有任何测试能够捕捉到可能引入的缺陷
我发现,这种反对意见通常发生在关键人物离开团队,而没有人了解那些个人维护的复杂领域之后。相关的代码通常几乎没有文档,单元测试也非常少,如果有的话。
这些担忧并不意味着你不能成功改进或替换相关的代码。事实上,我们在本书第二部分中讨论的一些关于测试代码的策略,可以显著帮助解决这种反对意见背后的恐惧。
首先,在做出任何更改之前,你可以围绕你要更改的代码编写单元测试。我们曾在第九章中探讨的一些高级测试工具,如 Snapper 和 Scientist .NET,可以帮助你完成这项工作。
以分阶段推出或提供回滚选项的方式部署软件也可以帮助缓解一些担忧,正如我们在第十七章**敏捷重构中将要看到的,当我们讨论诸如功能标志和蓝绿部署等内容时。
“这段代码即将被废弃,不要在上面浪费时间”
通常在软件项目的开始、原型阶段,或者在确定是否必须替换或退役整个应用程序的结束阶段,会提出特定的代码是临时的,你不必担心其质量的反对意见。
这通常发生在团队想要通过构建一个快速的“废弃”原型来测试一个概念,这个原型可以探索一个概念或证明一个行动方案是可行的。
不幸的是,许多“废弃”原型幸存下来,成为未来应用程序的基础,尽管它们是为了快速证明一个概念而构建的,并且有意设计成不必担心性能、安全或可靠性。
一个好的原型可以让人对项目如此兴奋,以至于以下情况可能发生:
-
他们忘记了他们处理的不“是真实”的软件,原型只是为了临时“废弃”。
-
他们认为原型中提供的功能已经完成。
-
项目有一个紧急的截止日期
虽然确实有合理的论点认为将废弃的原型提升为真实应用程序是管理不善的症状,但让我们谈谈开发团队成员可以做的关于这件事的有益事情。
首先,了解你的“废弃”原型有很大机会被视为可以工作的软件。一些团队使用粗略的样式或草图风格的用户界面,例如图 15.1,来帮助其他人记住应用程序只是一个原型:

图 15.1 – 一个用户界面线框图
第二,你可以将所有代码视为需要重构、测试和文档的生产代码,相应地降低原型设计速度,这在一定程度上违背了快速构建原型的想法。
第三,如果废弃的原型被提升为可操作的软件,首先的工作应该是根据需要重构原型,使其成为应用程序未来发展的基础。
生命终结的应用程序
另一个代码被视为非永久性的情况是,你正在开发的应用程序要么是其生命周期的结束,即将退役,或者人们决定当前的技术债务需要完全重写。
在真正是生命周期的结束,并且不会维护很长时间的应用程序的情况下,技术债务可能不是一个关键问题——假设应用程序实际上在不久的将来会下线。在这种情况下,团队应该大致知道应用程序何时会下线,并且这个日期应该定期确认。
小贴士
定期与管理层检查应用程序的停用状态非常重要。如果停用日期被推迟或完全退役应用程序的决定似乎不太确定,你可以改变你的立场,在重构工作中更加积极。
如果你有一个技术债务如此之高,以至于你认为没有重写就无法解决的应用程序,请务必小心。我见过许多团队认为他们的应用程序将被退役并由继任者取代,结果重写被推迟得越来越远,甚至完全取消。
如果你依赖重写来结束你的技术债务,我强烈建议你有一个重写开始和旧项目退役的估计日期。虽然软件估计可能具有挑战性(正如许多关于该主题的书籍所证明的那样),但没有一个可信的替代方案上线的时间表而不改进现有的代码库是不负责任的。
在过去 20 年里,作为一名软件工程师,我见证了数十个软件项目。在这段时间里,我只看到两个项目被完全重写。其中一个项目是由于技术必要性,因为其技术将在某个日期后不再起作用,另一个项目涉及一位主要工程师的非凡努力,他因维护旧版本的应用程序而感到沮丧。
如果你和你的团队假设不会发生完全重写,而是专注于逐步偿还技术债务,你们会更好。
我们将在第十七章**,《敏捷重构》*中更多地讨论逐步更新和替换应用程序的策略。
“只做最基本的工作”
有时你会听到类似这样的话:“你为什么花这么多时间重构或测试?只需完成完成任务所需的工作。”
这些陈述可能由几个不同的原因引起:
-
项目进度落后
-
由于过去的延误而对开发团队缺乏信任
-
对重构重要性的缺乏理解
每当我遇到这种反对意见时,我会想起我之前听到的一个露营类比。
当你去露营时,人们期望你离开营地时,营地状况与你发现时一样好,甚至更好。在营地,你被期望不要随意丢弃垃圾,尽管这样做比花时间清理自己更快捷。这有时被称为童子军规则。
其次,如果你去露营发现你的营地很乱,花些时间清理营地而不是在垃圾堆上搭帐篷是完全合理的!
将这个类比应用到开发中,当你去进行变更时,你可能需要修改一些不符合当前标准的代码区域,或者需要测试或总体上需要清理的区域。对文件进行变更时包括修复其他无关的工作内容是合理的。
假设你正在对影响应用程序中多个地方的一小部分变更进行工作。你发现其中一个地方有相当多的技术债务,并且可能需要几天的时间来清理以满足当前标准。在这种情况下,适当的做法是在该区域实施小变更,并在下一次站立会议中讨论所需的额外重构。通常,团队会为这个更大的重构工作创建一个新的单独的工作项。
敏捷重构
我们将在本章后面更详细地讨论跟踪技术债务,以及敏捷环境中的重构第十七章**,敏捷重构*。
虽然清理代码很重要,但尽量保持你正在进行的清理工作与你要处理的工作项规模成比例。
“重构不提供商业价值”
我遇到的最危险的反对重构的论点之一就是假设重构对开发团队之外没有任何价值。
也就是说,通常有一个隐含的假设,即只有当开发者添加功能或修复错误时,他们才为组织提供价值。在这种心态下,诸如单元测试、重构和文档等事情都是开发者所做的浪费活动,但它们并没有为组织提供有意义的价值。
这是一个危险的假设,因为管理者通常因最小化浪费和最大化对组织可能的价值而获得奖励。当重构和测试不被领导层重视时,组织就会用短期内的提升(如交付新功能)来换取技术债务的积累。这会导致长期后果,因为技术债务会迅速蔓延,开发速度放缓,并且几乎每次变更都会引入错误。
导致重构工作贬值的一个原因是新功能对管理层来说是可见的,并且通常是可以理解的,而技术债务是他们只听说但看不到的东西。
作为开发者或工程领导者,你可以做任何事情来帮助管理层理解技术债务的范围和影响,这将有助于解决这个异议。
在下一节中,我们将探讨帮助提高非开发者对技术债务可见性的方法。
沟通技术债务
向非开发者解释技术债务可能具有挑战性。即使管理层信任开发团队,经理们也难以理解工程师们处理的问题或技术债务如何减慢软件工程过程,并在应用程序更改时引入巨大的质量风险。
技术债务作为风险
在我的职业生涯中,我了解到虽然管理层难以理解技术债务,但他们对某些事情的理解要好得多:风险。
这可能听起来很奇怪,但我发现帮助管理层理解技术债务的最佳方式是以风险管理术语来呈现它。
您系统中的每个技术债务方面都有概率和影响。
技术债务的概率是指该部分技术债务在开发过程中或应用程序在生产环境中运行时影响开发团队的可能性。
影响是指如果它确实影响了开发者或部署的应用程序,技术债务会对事物造成多大的伤害。
例如,一个具有中等复杂性的关键区域中的代码,如果缺乏测试,可能产生问题的概率较低或中等,但如果这些问题出现,将产生关键影响。也就是说,代码目前没有造成问题,但我们认为它将来可能会以某种方式更改,从而引入我们由于系统复杂性而无法捕获的漏洞。如果这种情况发生,我们相信对最终用户的影响将是严重的。
当您可以用影响和概率来表示代码库中的每个风险时,这允许管理层开始理解当前技术债务所代表的风险水平。
创建风险登记册
这些风险条目应排列成电子表格或其他一系列跟踪项(如系统中的工作项),称为风险登记册。风险登记册成为管理者和开发领导者审查软件工程项目中当前风险的中心位置。
您的风险登记册可能从包括以下信息中受益:
-
ID – 风险的唯一标识符
-
FlightManager的ScheduleFlight方法” -
状态 – 风险是否处于开放状态、正在修复过程中或已关闭
-
概率 – 风险影响未来开发或系统用户的可能性
-
影响 – 风险实现时的严重程度
-
优先级 – 根据风险的概率和影响确定的风险优先级
Cloudy Skies Airlines 的一个示例风险登记册可能看起来如下:

图 15.2 – 一个示例风险登记册
你的登记册不必仅限于这些列。风险分配给的人员、风险所在的区域或组件以及解决它的估计工作量都是你可能想要考虑添加的字段,具体取决于你的需求。
当延迟或生产问题不可避免地发生时,你可以指向风险登记册中现有的风险。这应该有助于管理层理解风险已经转化为问题。
风险与问题
在风险管理术语中,风险是可能在问题发生时出现的事情,而问题则是已经通过实际发生而显现出来的风险。
这有助于抵制责怪参与变更的工程师的诱惑,并有助于将对话集中在现有技术债务中存在的风险上。
通过与管理层共同建立风险登记册,你可以让他们积极参与管理和技术债务的解决过程。这是一个持续的过程,包括定期的风险审查会议,团队必须积极维护登记册,以便在发现新的风险或对现有风险的可能影响或概率的看法发生变化时。
在这些风险审查会议中,小组应审查当前的风险登记册,并讨论自上个月以来发生的变化。
风险登记册的替代方案
我理解并非每个开发者、工程领导者甚至高级管理层成员都愿意使用正式的风险登记册。
如果你更喜欢一个更简单的流程,你可以通过尝试以下任何一项来获得相似的价值:
-
在 Word 文档中有一个简单的项目符号列表——可能按主要项目或区域组织
-
在工作项跟踪软件中创建一个新的技术风险类型的项目,例如Jira或Azure DevOps
-
定期向开发者和业务利益相关者发送包含“最想得到的 10 项技术债务”的简报
风险登记册的格式并不是过程最重要的部分。过程的重要部分是,你的团队在发现技术债务时积极列出,并定期与管理层审查,以让他们参与解决过程。
优先考虑技术债务
跟踪和沟通技术债务是偿还债务过程中的关键部分。然而,这只是过程中的一个步骤。
当相关代码被修改时,重构代码作为偿还技术债务的策略可能是可行的,但这种方法不适用于解决较大的技术债务或与软件整体设计相关的债务。
在第十七章“敏捷重构”中,我们将更详细地讨论如何在敏捷环境中管理这些较大的工作部分,但就目前而言,让我们看看你是如何确定哪些技术债务应该优先考虑的。
你应该优先处理最有可能发生并且一旦发生会造成最大伤害的项目。换句话说,如果你有一个高概率的风险,你应该优先考虑它。此外,你还应该优先处理那些影响大的技术债务。
使用风险评分计算风险优先级
我看到一些组织根据他们跟踪的每个技术风险的影響和概率创建了一个风险评分。这个风险评分是一个数学方程,其中技术债务发生的概率用 0 到 1 之间的数字表示,1 表示 100%肯定会发生,0 表示永远不会发生。
这导致了一个公式,你可以通过将技术债务的概率乘以其影响来计算其优先级。这个公式如下:
risk = impact * probability
例如,一个高概率、低影响的技术债务项目可能有一个 0.9 的概率评分和 3 的影响评分,从而得到 2.7 的风险评分。
单位和风险评分
2.7 是什么意思?嗯,除非你选择用小时或美元来表示影响,否则我们实际上并没有测量任何有形的东西,所以我把这个数字简单地称为“风险评分”,代表业务预期由于技术债务项目存在而产生的整体预期负面影响。这对于比较两个风险是很有用的。
让我们看看一个具有高影响、低概率的技术债务项目的不同场景,其概率评分为 0.15,影响评分为 21,从而得到 3.15 的风险评分。
在这里,组织通常会关注第二个项目,因为它的整体风险评分 3.15 高于第一个项目的 2.7 风险评分,这意味着它对组织的威胁更大。
对这种方法进行进一步的细化可能还会考虑解决一项技术债务所需估计的小时数,因此可以优先处理那些可以更快解决的项,而不是那些需要更长时间才能解决的等效项。
“直觉”方法
用精确的数值来量化事物可能很困难,有时估计可能更像是一种愿望,而不是科学的预测。我确实认为对风险进行一些粗略的量化是有价值的,但通常,团队成员会对某些项目的规模相对于其他项目有更深的“直觉”。
我的立场是,数值指南可能会有所帮助,但你的大脑可以指出其他重要但难以衡量的事情。
小贴士
我的经验法则是你应该专注于修复让你最害怕的事情。如果你的代码中有一个区域让你夜不能寐,那么通常从那里开始是一个好主意。
这并不是说你应该停止所有新的开发直到技术债务得到解决(尽管在某些严重情况下这可能是必要的)。我的意思是,当你可以选择解决什么时,你应该选择你团队认为对组织成功构成最大威胁的领域。一旦你解决了最大的问题,继续解决下一个,然后是下一个,同时继续支持业务的需求。
获得组织支持
我们已经看到我们可以如何跟踪和优先处理技术债务,我们也看到让管理层参与跟踪技术风险的过程可以帮助建立信任和理解,但让我们谈谈那些开发领导必须向管理层“推销”重大重构工作的场景。
这些对话可能会很有压力,并且代表了软件项目中的一个关键转折点。在这些高风险的对话中,你的目标是简洁、尊重地传达以下内容:
-
团队面临的问题及其未解决的影响
-
提出的解决方案(或一系列供考虑的解决方案)
-
重构工作在开发者时间上的成本
-
重构工作的进度表
-
你希望管理层做什么
注意,你的目标在这里不是让他们同意你提出的建议。你的目标是让他们理解问题,并与你一起确定何时以及如何解决它。
当你的焦点是无论如何都要达成自己的目标时,这可能会导致信任的丧失,开发和管理层之间敌意的增长,以及开发者无法从商业需求的角度思考的感觉。
相反,如果你认为管理层合作伙伴有合法的见解和可以为组织增加价值,对话可以变得不同——这是一个工程和管理层为了业务长期和短期需求而共同努力的伙伴关系。
安排对话
在你甚至可以就问题进行对话之前,你需要能够有效地传达问题及其潜在解决方案的范围。
这需要一些考虑和规划。你不需要为这个项目制定详细的项目计划,但你确实需要思考项目的范围、需要改变的部分以及需要参与的人。
你还需要考虑你团队当前的项目以及你打算涉及的这些人目前正在做什么,或者即将做什么。
记住,为了你的组织能够对你的重构工作说“是”,他们需要在重构工作期间对其他事情说“不”。
一旦你对问题的范围及其解决方案有了足够的理解,你应该将其带给管理层。这可以是工程领导和管理层之间定期检查会议的一部分,或者作为单独的会议。
你如何处理会议邀请将取决于你要接触的人。
一些领导者可能愿意你到他们的办公室或直接给他们发消息,说些类似于“我对项目有一些担忧。你有没有 30 分钟的时间可以详细谈谈这个问题?”的话。
另一方面,其他领导者可能希望一提到话题就进行对话。因此,我建议你为对话做好准备,并找到一个他们日程上看起来空闲的时间。
预测问题和反对意见
当你向管理层展示你的担忧和选项时,你应该考虑到他们可能会提出的问题或反对意见。准备好深入探讨当前问题的技术细节以及你提出的解决方案。
管理层通常还希望了解项目的时间表细节。这不仅可以包括你预计重构工作需要多长时间,还可以包括项目可以等待多长时间 才开始。
记住,大多数组织都计划在至少下一个季度进行一些主要项目。进行重构工作通常需要重新安排其他领域的当前和计划中的工作。例如,查看图 15.3,这是关于网络、服务和集成团队按季度划分的主要项目的样本分解:

图 15.3 – 按团队和季度分解的项目路线图
虽然集成团队可能希望在第二季度花时间解决一些技术债务,但这样做可能会风险服务团队计划中的与新的供应商连接的工作,并可能延迟集成团队自己计划在第三季度开始的服务工作。
对于你团队面临的问题的紧迫性要诚实。有时答案是它可以等待,但延迟的时间越长,团队面临的惩罚就越大。在其他时候,重构工作可能需要解决团队在当前系统中遇到的紧迫和已经紧迫的问题。
此外,还要考虑到你正在与之交谈的人的背景以及他们交谈的人。如果你正在和一个非常注重安全的人交谈,而你还没有考虑过你变更的安全影响,那么这次对话可能不会顺利。
你不需要对每个问题都有答案,说“我不知道;让我调查一下然后告诉你”是可以接受的。
核心问题是,项目进度的任何变动都是严肃的事情,如果你看起来没有花时间去思考最明显的问题,这不会在管理层中赢得信任。
不同的领导有不同的方法
我在管理层遇到了很多不同的人,令人惊讶的是,两位有技能的领导者之间可以有多么不同。
一些领导者非常注重分析,并且非常以数据为导向,他们喜欢仔细研究报告和电子表格。其他人则更注重人际关系,他们不是那么受原始数字的影响,而是受具体故事的影响,这些故事说明了某件事如何影响特定个人。
对于专注于整体数据的领导者,我通常会展示关键指标并突出有趣的发现。我经常提供所有相关数据供他们进一步分析,无论是主动提供还是应要求提供。
一个可能的指标可能是我们在过去 3 个冲刺中花了 15 个小时来处理这个问题,或者上季度的 15%的 bug 可以追溯到这个区域。
在分享具体故事方面,我通常会准备两三个例子来说明问题如何影响开发者、最终用户或其他相关利益相关者。这可能是“上一次冲刺,Priya 试图开发我们认为只需几个小时的新功能,但由于这种架构方式,实际上她花了 3 天时间”,或者“Garret 是一位非常称职的开发者,但他试图修改这个代码区域,结果由于代码缺乏可维护性,最终导致了这个关键的生产错误”。
一种对某个人有效的方法可能对另一个人影响甚微。因此,我发现,在就重大重构努力进行这些关键对话时,准备一些有趣的指标和一些相关的场景是最好的。
沟通的重要性
我希望你在本章中学到的一点是,虽然你想要解决技术债务,但你的目标是组织的短期和长期成功。
这意味着关于技术债务的任何对话都应该是双向对话,双方都能倾听对方并让自己的声音被听到。
有时,企业合法的短期需求是尽可能快地交付产品或与外部合作伙伴或机构达成截止日期。
作为一名工程领导者,你的目标是确保管理层理解技术债务所代表的影响、紧迫性和风险,以及小型和大型重构努力的重要性。然而,你的关注点通常集中在代码上,而管理层的关注点则集中在战略举措上,甚至仅仅是维持业务的运营和保持灯亮。这两个角色及其视角对于一个健康组织来说都至关重要。
最后,你真正追求的是工程和管理层之间开放而诚实的沟通,管理层能够欣赏技术债务的风险和影响,而工程团队能够理解组织面临的压力。
这份沟通从信任开始,尊重管理层在引导整个组织朝着目标前进、平衡相互竞争的优先事项和需求方面做出的贡献。
案例研究 – Cloudy Skies Airlines
在我们结束这一章之前,让我们看看我们从 Cloudy Skies Airlines 的案例研究中得出的结论。
主开发人员布莱恩一直在调查应用预订和支付处理部分日益增多的问题。
这些问题最初被认为是孤立的,但似乎在高峰使用时段发生,当时许多客户正在尝试预订航班或修改现有的航班预订。
经过调查,布莱恩和他的团队发现这些问题与系统的当前设计和架构有关。虽然系统可以处理旧的用户数量,但它由于当前的低效率,根本无法适当地扩展以处理高峰工作量。
通常,这样的系统可以扩展到有多个服务器并行运行,负载均衡器在它们之间分配流量(参见图 15.4):

图 15.4 – 负载均衡器将请求分配到不同的应用服务器
然而,系统并未设计为在不进行大量重工作的情况下支持同时运行多个应用副本。
虽然团队能够对短期性能和稳定性进行一些改进以解决当前问题,但他们明白,随着业务的增长,这些问题将再次浮现——尤其是在高峰旅行季节。
经过仔细考虑,团队制定了一个计划,允许系统并行运行多个副本,但需要进行大量的重工作。
其中一位工程师还建议可能从服务器完成所有工作并向用户返回成功响应的模型,转变为快速验证请求并将其放入队列进行处理的模型。这种方法将处理传入请求的峰值,但需要改变当前请求处理的方式。
带着这些想法以及对当前问题范围和可能解决方案的知识,布莱恩安排了与首席技术官玛迪的会议。
在会议期间,布莱恩概述了性能问题、团队为恢复服务所采取的最近步骤,以及随着业务增长和旺季临近,它再次发生的可能性。
一旦布莱恩确信玛迪理解了问题的基本原理,他就概述了两种可能的补救计划,以及他个人建议坚持相对简单的更改,允许应用服务器并行支持多个副本。
玛迪就扩展性提出了几个技术问题,特别是关于为什么当前系统无法同时运行多个副本的原因。在布莱恩解释了可能导致这些问题之后,玛迪理解了推理和补救的需要,对话转向了安排会议。
团队接下来的重点是整合一家新收购的子公司航空公司到 Cloudy Skies 系统中,如图15.5所示:

图 15.5 – 显示按季度划分的主要项目和当前日期的计划进度表
在审查问题后,Maddie 和 Brian 都认为,在即将到来的高峰旅行季节,实施长期解决方案以解决可扩展性问题更为重要。
Maddie 将其他高管带入关于计划具体细节的讨论中,Brian 回答了他们的问题,同时团队开始规划所需的架构变更和应用程序如何根据需要扩展以处理额外的流量负载的技术细节。
经过短暂的延迟,项目得到了批准,Brian 团队的大多数成员被分配到这项工作中,理解到之前计划的工作将比原定计划晚开始,以便为新可扩展性项目腾出空间,如图15.6所示:

图 15.6 – 近期添加可扩展性项目的调整后的进度表
Brian 和 Maddie 继续检查工作的进展,并在高峰旅行时间到来之前解决了可扩展性问题。
同时,一些团队成员能够在新子公司集成到 Cloudy Skies 系统中取得进展。随着工程师完成对可扩展性问题的处理,他们转而投入到那个项目中,导致该项目最初计划交付日期只有轻微延迟。
最后,业务得到了一个更稳定和可扩展的系统,以及他们计划中的新子公司集成,此外,管理层和软件工程团队之间的沟通渠道也得到了改善。
摘要
在本章中,我们探讨了重构代码和偿还技术债务的常见反对意见以及一些原因和补救措施。
我们还讨论了向管理层传达技术债务的问题,特别是将技术债务视为对组织系统和生产力的风险的观点。我们还介绍了使用风险登记册来跟踪技术债务随时间变化并提高非开发者对技术债务可见性的想法。
我们以讨论优先处理技术债务、从管理层获得对更大重构项目的许可,以及在修复工作中建立管理层信任、沟通和伙伴关系的重要性结束会议。
在下一章中,我们将探讨代码标准在长期最小化技术债务的价值以及如何选择现有的标准或建立自己的标准。
问题
-
你目前在争取时间优先处理技术债务方面遇到了哪些障碍?
-
如果管理层理解了你正在处理的问题,他们如何在时间、资源或组织支持方面帮助你?
-
你和你的团队可以采取哪些措施来建立与管理的协作关系?
-
管理层对技术债务及其风险的理解程度如何?
-
对于你来说,正式跟踪技术债务作为风险是否有意义?
进一步阅读
你可以在以下网址找到更多关于将技术债务视为风险、与工程领导层沟通以及一般风险管理的技术债务思考:
-
将技术债务视为 风险:
killalldefects.com/2019/12/24/technical-debt-as-risks/ -
逃离技术债务的黑洞:
www.atlassian.com/agile/software-development/technical-debt -
如何使用技术债务 登记册:
blog.logrocket.com/product-management/how-to-use-technical-debt-register/ -
与技术债务沟通管理:
devops.com/communicating-with-management-about-technical-debt/
第十六章:采用代码规范
在本章中,我们将讨论建立具有适当灵活性的清晰代码规范的重要性。我们还将介绍 Visual Studio 中的一些内置工具,这些工具将帮助您的团队采用一致的编码规范。这反过来又可以帮助您在代码审查期间专注于正确的事情。
本章涵盖了以下主题:
-
理解代码规范
-
建立代码规范
-
Visual Studio 中的格式化和代码清理
-
使用
EditorConfig应用代码规范
技术要求
本章的起始代码可在 GitHub 上找到,地址为github.com/PacktPublishing/Refactoring-with-CSharp,在Chapter16/Ch16BeginningCode文件夹中。
理解代码规范
在本章中,我们将探讨代码规范的概念。
代码规范是团队一致决定应用于团队创建的任何新代码的规范集。
这些标准在解决争议、关注真正重要的领域、减少团队自然积累的技术债务以及帮助偿还现有技术债务方面发挥着重要作用。
代码规范的重要性
作为一名开发者,我经历过的最令人沮丧的事情之一是,当我将经过深思熟虑的更改发送给另一位开发者进行审查时,我听到了以下这样的评论:
-
我不喜欢你那种花括号格式
-
你的缩进不符合我的。我使用空格而不是制表符
-
我希望你能使用
var而不是 类型
在这些情况下,相关的开发者忽略了更改的实质,而是专注于更改的风格——特别是当风格与他们的偏好不同时。
解决这个问题的方法是采用一套您和您的团队一致同意的代码规范。这些规范确立了团队对未来新代码的关注点。这些标准还可能包含团队风格和代码偏好的理由。
以下是一些可能包含的代码规范决策示例:
-
我们使用文件作用域的命名空间,因为它们导致较少的嵌套
-
单元测试类应该以它们测试的类命名
-
我们更喜欢在实例化对象时使用目标类型
new -
类定义应该清晰组织,并从字段开始,然后是构造函数、属性,最后是方法
这些规范不必过于僵化,以至于开发者没有做出任何决定,或者生活在不断违反它们的恐惧中。
您的代码规范应该足够具体,以解决主要的争议和困惑点。这有助于您以最大化向组织提供价值的方式创建和维护代码。
代码规范如何影响重构
当你和你的团队就一套明确的、达成共识的标准进行讨论时,它为重构打开了大门。
没有一套标准,当你谈论旧代码时,你可能会说“我非常不喜欢这一点”,或者“这不是我会写的样子”,或者“这看起来组织得很差”。
这些事情可能是真的,但它们并不是重构的强有力论据。
相反,当你可以说“这个类在这些方面违反了我们的代码标准”时,对话就会变得更加高效。这尤其在你能够确定一些标准是关键的同时,其他标准虽然重要但不太关键时。
我认为代码标准中的一些方面是至关重要的,值得深入修改以使代码符合新的标准。对我来说,这些领域通常围绕着对IDisposable资源的处理和采用适当的异常管理实践。
无论你和你的团队达成什么共识,这都是至关重要的。这些标准将影响你的优先级和你在维护代码时所做的决策。违反标准的问题可能会被分配给人们去修复,而无需其他理由去修改相关的代码。我们将在本书的最后一章中进一步讨论这一点。
将代码标准应用于现有代码
非关键标准用于指导开发者每天的工作。所有代码更改都应遵守这些代码标准。通常,这些标准鼓励开发者更新附近不符合标准的现有代码。
例如,你的团队可能有一个代码标准,即当你能帮助时不要使用var关键字(或者如果你喜欢,始终优先选择var)。团队的期望是,随着开发者编写新代码,新代码将遵守这一规则。
当标准被定义时,团队有时会期望你更改的代码附近的代码也会更新以符合标准。这尤其适用于同一方法中的代码。毕竟,你已经投入了努力测试你的新代码以验证所做的更改。这种测试工作可以帮助捕捉到重构其余方法时引入的问题。
随着时间的推移,这些代码标准将有助于降低团队积累技术债务的速度。对现有代码的持续改进也将有助于减少经常变更区域的技术债务。
建立代码标准
因此,既然我已经说服你代码标准如何减少团队冲突、聚焦代码审查和指导重构工作,那么让我们来谈谈这些标准从何而来以及我们如何在团队中采用它们。
集体代码标准
每个软件开发团队都已经有了代码标准。
我这么说是因为每个软件开发团队根据定义至少有一个开发者。每个开发者,无论他们是否意识到这一点,都有自己的内部化代码标准。
他们可能没有考虑过他们的偏好或无法列出它们,但如果你单独查看你团队中的每个开发者和他们编写的代码,将会发现其中存在一定程度的连贯性。
团队遇到的问题不是他们没有标准,而是他们有太多的标准。每个开发者都根据自己的内部标准和偏好进行操作,而团队现在必须聚集在一起,相互交流和互动各自独特的风格和偏好。
通常,团队会倾向于某些风格,因为开发者往往会模仿代码文件中现有的风格。随着时间的推移和团队的增长,通常会在某些选择上产生冲突。当这种情况发生时,你的团队将需要决定,没有定义任何集体标准的创造性自由是否值得由这些不同的偏好引起的摩擦和干扰。
最终,大多数团队会围绕那些真正对团队重要的事情正式化一套标准。让我们来谈谈应该把哪些内容列入清单。
选择什么重要
编程是一项创造性工作,所以我们不希望对开发者编写代码的方式施加过多的限制。另一方面,当规则太少时,可能会导致某些代码区域显得有些杂乱无章,这些区域适合某个开发者的偏好,但不适合更大的团队。
那么,一个开发团队如何确定其标准中应该包含什么内容呢?
我喜欢从确保团队安全的标准开始。这些涉及遵循既定的最佳实践,例如在 .NET 的 框架设计指南(见 进一步阅读 获取更多信息)中定义的。这些实践较少围绕个人意见。这使得它们具有高影响力,同时相对较少地涉及戏剧性。
接下来,看看你的团队在代码审查中遇到的主要难题。如果你厌倦了关于制表符与空格的讨论——无论是 { 是否应该单独占一行,还是 var 的使用——这些都是需要考虑添加到团队标准中的事项。
如果这些领域是分歧的主要来源,你有几个选择:
-
在争议领域选择立场并将其作为团队采用
-
将对这一主题没有官方立场作为你团队的官方立场
选择立场并将其作为团队采用可能会引起暂时的争论和伤害感情。从长远来看,采用立场通常会带来好处,因为你的团队可以以一致的风格运作。虽然开发者可能会觉得自己的地位或价值被低估,但大多数人随着时间的推移会自然地接受新的风格,尽管在某些情况下,当开发者对某个话题有强烈的感受或认为他们的意见没有被考虑时,这可能会导致人员流动。
你可能认为明确表示你的团队对代码的一个方面没有立场不会带来很大好处。然而,我见过这种方法对团队之间的对话产生了巨大影响。通过明确对主题没有政策,有争议的话题现在变成了可以迅速解决的问题。
而不是争论var是否应该出现在你的代码中,团队可以指出其标准,说明个人开发者可以在这个问题上做出自己的选择。这使你的团队超越了有争议的领域,转向更富有成效的话题。主要的缺点是整体代码的一致性会降低。
一致性的价值
遵循一致的风格和设计决策的代码感觉更加专业,使开发者更容易在之前未工作过的领域工作,并使开发者保持高效和专注于代码的功能而不是其形式。
确保在创建代码标准和确定这些标准中包含工程团队。这可以通过让整个团队参与或选择代表组织中工程师各种经验和偏好的子集来实现。此外,如果你有可能会对新的风格反应特别强烈的人,确保他们的担忧得到充分听取,并在可能的情况下让他们参与这个过程。
代码标准的来源
有时候,制定自己的标准可能过于困难或具有争议性,或者你可能发现自己在创建代码标准时不知道从何开始。
当这种情况发生时,我建议从一套既定的代码标准开始,并根据需要对其进行定制。
在第十二章中,我们介绍了内置的代码分析规则集以及如何逐步将你的规则集从最新规则集移动到最新最低规则集,然后是最新推荐规则集,最后是所有最新规则集。这些代码分析规则可以帮助强制执行最佳实践。
如果你希望事情更加正式,微软已经记录了 C#编码约定和框架设计指南,这些为你的团队提供了一个良好的起点。这两个文档在本章的进一步阅读部分有引用,并且是关于.NET 和 C#的宝贵、常青的智慧来源。
代码标准的演变
我提到“常青”,因为 C#不是一种停滞的语言。每年 11 月,微软都会发布新的 C#版本,其中包含基于前一年改进的新语言特性。这使得 C#语言在随时间演变的过程中感觉更加自然。
此外,我们编程的上下文随着时间的推移而变化。当 .NET 首次推出时,它本质上是为主要进行 Windows 桌面开发的开发者提供生产力提升的工具。从那时起,我们看到了 .NET 变得开源和跨平台。同时,许多组织已经从本地数据中心迁移,因为基于 Azure 和 AWS 等平台的云计算已经成为常态。
在 C# 的原始时代被认为是最佳实践的事情,随着新语言特性的出现和 .NET 平台的增长而逐渐失去了人气。
我从 .NET 的开始就一直在使用它,并在我的编码风格中感受到了这一点。在这本书中,我讨论了 var,因为它是一个容易讨论的语言特性,但它也是一个很好的例子,说明了 C# 随着时间的推移是如何变化的。
在 var 之前,您会这样声明一个 Guid 键和 int 值的字典:
Dictionary<Guid, int> data = new Dictionary<Guid, int>();
当 var 被引入时,标准转向使用 var 来简化您的声明,因为类型是明显的:
var data = new Dictionary<Guid, int>();
这导致了更少的重复语法并提高了开发者的生产力,同时仍然保持了类型明显。
随着最近添加的目标类型 new,我的偏好变成了如下使用:
Dictionary<Guid, int> data = new();
我在这里分享我自己的个人标准之路,因为它是一个缩影,展示了工程团队将会经历的过程。
您将适应标准,然后 C# 将随着时间的推移而变化,您将调整标准以保持同步。您现在可能认为的“最佳实践”可能在实施几个月后可能并不适用。面对团队面临的障碍发生变化也是自然的。当这种情况发生时,这迫使您和您的团队采用新的策略来克服这些障碍。
随着时间的推移改变您的标准是正常的。这是语言不断发展和我们日常编程工作上下文不断变化的标志。
将标准整合到您的流程中
代码标准会影响软件开发中的几个不同地方,从您如何构建新功能到维护代码的方式。
您的代码标准应该清晰地记录并存储在中央位置,例如团队维基或共享文档。这些标准应该传达给新加入团队的开发者,以帮助他们熟悉团队对代码标准的期望。
在讨论了代码变更实质的所有其他问题之后,代码标准也应该在代码审查过程中得到加强。这些问题应该在代码获得批准和工作项完成之前得到解决,但不应以惩罚的方式进行。
重要的是要理解,对于团队中的新开发者来说,内化代码标准需要一些时间。在您的开发者开始以团队标准为标准思考之前,通常需要几个月的时间。
有助于这一点的做法是将工具集成到流程中,使您的团队能够在代码提交同行评审之前轻松验证其代码是否符合标准。代码分析规则和 Roslyn 分析器可以帮助做到这一点,但 Visual Studio 还提供了一些额外的工具,可以在代码达到人工评审之前帮助标准化代码:代码格式化和.``editorconfig文件。
Visual Studio 中的格式化和代码清理
结果表明,Visual Studio 可以通过内置功能自动排列甚至以一致的方式清理您的代码。
格式化文档
实现这一点的最简单方法之一是使用格式文档功能,可以通过按Ctrl + K然后Ctrl + D,或者通过打开编辑菜单,然后转到高级并选择格式文档,如图图 16.1所示。1*:

图 16.1 – 格式化活动编辑器文档
这将更改您当前文件中的代码,以匹配您在 Visual Studio 中配置的首选项。
这些设置可以通过打开工具菜单然后选择选项…进行配置。从那里,展开文本编辑器、C#、代码样式和格式化节点,直到您看到关于缩进、新行、间距和换行的各种首选项。
这些设置选项卡允许您配置 Visual Studio 的格式化首选项并预览格式选择,如图图 16.2所示。2*:

图 16.2 – 更改 Visual Studio 格式化捕获语句的方式
一旦您自定义了设置,这些设置将在您使用格式 文档功能时使用。
许多开发者早期就学会了使用Ctrl + K和Ctrl + D快捷键来格式化文档,并习惯性地使用它们,但实际上您可以让 Visual Studio 自动应用代码清理。
自动格式化文档
Visual Studio 有一个代码清理功能,允许您在文件保存时手动或自动格式化代码。
这是通过using语句完成的,将您类中的成员按更一致的方式排序,并将您的代码格式首选项应用到文件中。
要配置代码清理配置文件,请再次转到选项对话框,这次在文本编辑器节点中找到代码清理,如图图 16.3所示。3*:

图 16.3 – 保存文件时启用代码清理
从这里,您可以选择在保存时运行代码清理配置文件,以自动应用您的清理配置文件。
我还建议您点击配置代码清理来查看您的清理配置文件。
这显示了每个配置文件中将应用哪些修复程序,如图图 16.4所示,并允许您配置代码清理操作中包含和不包含的内容:

图 16.4 – 配置代码清理配置文件
在保存时自动清理代码可能会有所帮助,但它也有一些缺点。如果您的代码一段时间内没有清理,您的清理操作可能会在文件中创建许多更改。当多个作者试图修改同一文件或查看更改时,这可能会在 git 中造成混淆。
配置代码样式设置
信不信由你,当我们之前介绍了 C#的新行和缩进设置时,这并不是 Visual Studio 能做的极限。
Visual Studio 提供了一个代码样式设置部分,允许您配置围绕 C#中找到的大多数语言功能的个人偏好。
这些设置可以在选项对话框的文本编辑器、C#、代码样式和常规下找到,如图图 16.5所示:

图 16.5 – 在 Visual Studio 中配置代码样式规则
在这个用户界面中,您可以配置您关心的规则,每个规则上的偏好,以及您对每个规则的关心程度。注意从设置生成.editorconfig 文件按钮,我们稍后会详细讨论。
对于每条规则,您可以选择规则是否仅作为重构选项出现,Visual Studio 是否通过标识符上的绿色下划线微妙地建议该规则,或者 Visual Studio 是否应该更加激进,例如使用编译器警告或编译器错误来处理违反标准的行为。
有很多这样的设置,但它们允许您微调您对 C#功能的首选以及您希望它们如何格式化的个人偏好。
然而,这些是您个人的设置,它们将应用于您在自己的机器上工作的代码。在下一节中,我们将讨论如何使这些设置适用于您的整个团队。
使用 EditorConfig 应用代码标准
让我们看看您如何将选项对话框中找到的相同的代码样式设置通过一个.editorconfig文件附加到一个项目上。
包含应用于您项目中的样式和语言使用规则的.editorconfig文件。任何违反您的EditorConfig规则的行为都将导致 Visual Studio 编辑器中的编译器警告和建议。
Visual Studio 之外的 EditorConfig 文件
在撰写本文时,.editorconfig文件在 Visual Studio 和 JetBrains Rider 中原生支持。在 VS Code 中,只要您安装了 C#开发工具包和 EditorConfig for VS Code 扩展,EditorConfig 文件就受到支持。有关在 VS Code 和 JetBrains Rider 中启用这些功能的说明,请参阅进一步阅读部分。
EditorConfig 文件的关键好处是,它允许所有参与项目开发的人员使用一致的一组格式和样式首选项进行工作。
检查我们的起始代码
我们将要格式化的代码位于我们的 第十六章 解决方案中,该方案包含一个 FlightQueryDecoder 控制台应用程序和一个相关的 xUnit 测试项目。这段代码在本章中是最小的,并且围绕 FlightQueryParser 类展开。
让我们从 FlightQueryParser 的前半部分开始,它将航班搜索字符串,例如 AD08FEBDENLHR,解析为 FlightQuery 对象:
namespace Packt.FlightQueryDecoder;
public class FlightQueryParser
{
public FlightQuery ParseQuery(string query) {
if (query.StartsWith("AD") && query.Length == 13)
{
var flightQuery = new FlightQuery {
Date = DateTime.Parse(query.Substring(2, 5)),
Origin = query.Substring(7, 3),
Destination = query.Substring(10, 3)
};
return flightQuery;
}
else {
throw new ArgumentException("Invalid query format");
}
}
实际逻辑不是重点。我想向你强调的是代码在块内格式化的不一致性。
让我们看看文件的一半,它将一个航班搜索结果字符串,例如 DEN LHR 05:50P 09:40A E0/789 8:50,转换为 FlightQueryResult:
public FlightQueryResult ParseResult(string result)
{
var fqr = new FlightQueryResult();
var segments = result.Split(' ',
StringSplitOptions.RemoveEmptyEntries
| StringSplitOptions.TrimEntries);
fqr.Origin = segments[0];
fqr.Destination = segments[1];
string today = DateTime.Today.ToShortDateString();
fqr.DepartureTime = DateTime.Parse(
today + " "+segments[2] + 'M');
string seg3 = segments[3];
fqr.ArrivalTime = DateTime.Parse($"{today} {seg3}M");
fqr.AircraftTypeDesignator = segments[4];
fqr.FlightDuration = TimeSpan.Parse(segments[5]);
return fqr;
}
}
虽然这段代码故意写得不好,格式也不一致,以作为示例,但我相信你在现实世界中已经看到过很多同样格式不一致的大文件。
现在我们已经介绍了这段代码及其不同的样式选择,让我们将 .editorconfig 文件添加到项目中,看看它如何有助于强制执行样式。
添加 EditorConfig
要添加 .editorconfig 文件,请右键单击 Packt.FlightQueryDecoder 项目,然后选择 添加,然后选择 新建 EditorConfig 或 新建 EditorConfig (IntelliCode)。
什么是 EditorConfig (IntelliCode)?
默认选项的 .editorconfig 文件与 IntelliCode 选项有所不同,后者分析你的项目,并从你在当前代码中观察到的约定生成 .editorconfig 文件。两者都是为你的项目创建起点的好选择。
根据你选择的选项,你可能需要选择 .editorconfig 文件应该位于哪个文件夹中。如果你被提示,请选择 Packt.FlightQueryDecoder 文件夹的默认选项。
一旦完成,你应该会在 解决方案资源管理器 中看到一个新的 .editorconfig 文件。
在我们继续使用这个 .editorconfig 文件之前,值得指出的是,基于你当前的代码样式选择创建的 .editorconfig 文件。这允许你自定义你的样式,然后从这些选择中创建一个 .editorconfig 文件。
现在我们有了 .editorconfig 文件,让我们来自定义它。
自定义 EditorConfigs
双击 .editorconfig 文件以打开其属性视图。
你将看到一个带有标签的编辑器,允许你自定义与空白、代码样式、命名样式和 Roslyn 分析器相关的各种属性。
这里有许多选项,所以我们将专注于其中几个非常具体的选项。
前往 代码样式 选项卡,然后向下滚动到底部,找到 var 首选项 组。
从这里,你可以声明你团队的偏好以及违反这些偏好的严重性。例如,如果你的团队想避免使用 var,你可以将所有三个 var 规则设置为首选显式类型,并将严重性提高到警告或错误,如图 图 16**.6 所示:

图 16.6 – 定制项目中的 var 偏好
保存此文件并返回到 FlightQueryParser.cs,你应该现在会在你的编辑器中看到违反这些规则的警告和错误,如图 图 16**.7 所示:

图 16.7 – 基于代码样式规则对使用 var 的 Visual Studio 警告
这些规则违规不会导致你的代码无法编译,但它们会出现在 错误列表 视图中,如图 图 16**.8 所示:

图 16.8 – 出现在错误列表中的代码违规
由于 .editorconfig 文件在提交代码时被添加到源控制中,你的团队中的其他开发者将拉取该文件并看到与你机器上完全相同的样式首选项和警告。
这使得代码标准在开发过程中变得明显,并减少了重要代码更改的同行评审陷入关于开括号的适当位置或使用 var 的讨论的可能性。
摘要
代码标准对于帮助你的团队专注于生产性事物并确保源代码可以被团队中的所有开发者轻松维护非常重要。
虽然代码标准不需要包括一切,但将关于常见争议事项或团队希望确保每个更改都遵循的最佳实践进行编码化可能是有帮助的。
Visual Studio 提供了许多功能,可以帮助确保代码库的一致性和高质量,包括代码格式化、代码清理配置文件、保存时格式化、代码分析警告配置文件、编辑器级别的代码样式以及 EditorConfigs 来配置编辑器内的代码样式。
在本书的最后一章中,我们将讨论作为更大组织的一部分以及作为敏捷软件开发团队的一部分进行代码重构。
问题
-
你如何确定你的团队应该采用哪些代码标准?
-
你有哪些方法可以处理关于样式规则的争议?
-
有哪些选项可以配置 Visual Studio 代码的格式化方式?
-
新 EditorConfig (IntelliCode) 选项做什么?
进一步阅读
你可以在以下网址找到关于本章材料的更多信息:
-
框架设计 指南:
learn.microsoft.com/en-us/dotnet/standard/design-guidelines/ -
.NET 编码风格 指南:
learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions -
使用 EditorConfig 创建可移植的、自定义的编辑器设置:
learn.microsoft.com/en-us/visualstudio/ide/create-portable-custom-editor-options -
适合初学者的 EditorConfig 设置:
newdevsguide.com/2022/11/22/beginner-friendly-csharp/ -
在 VS Code 中使用 C# 开发套件支持 EditorConfig:
code.visualstudio.com/docs/csharp/formatting-linting#_how-to-support-editorconfig-with-c-dev-kit -
在 JetBrains Rider 中使用 EditorConfig:
www.jetbrains.com/help/rider/Using_EditorConfig.html
第十七章:敏捷重构
在本章的最后部分,我们将讨论重构作为敏捷团队的一部分,成功进行大规模的重构工作,在事情出错时进行恢复,并纳入部署策略以确保它们不会再次出错。
虽然可以通过处理小块有问题的代码赢得许多小的重构战役,但如果你无法解决大规模的设计问题,你可能会输掉整体的“战争”。本章探讨了如何从冲刺到冲刺,继续与你的代码进行小的重构战役并取得胜利。我们还将涵盖确保你的应用程序具有正确设计的大规模战略战役——并在它不正确时将其纠正为更好的设计。
本章涵盖了以下主题:
-
敏捷环境中的重构
-
通过敏捷重构策略取得成功
-
完成大规模重构
-
重构出错时的恢复
-
部署大规模重构
敏捷环境中的重构
几乎我合作的所有开发团队都使用某种形式的敏捷软件开发来管理以短期冲刺形式进行的工作,包括任何重构工作。
在本节中,我们将介绍敏捷工作流程的基本知识以及重构如何适应这种环境。这很重要,因为如果重构工作无法适应敏捷工作流程,那么重构就不会发生。
敏捷团队的关键要素
敏捷软件开发在敏捷软件开发宣言(通常称为敏捷宣言)中正式编纂,并源于以下核心偏好:
-
个人和交互 胜过流程和工具
-
工作软件 胜过详尽的文档
-
客户协作 胜过合同谈判
-
响应变化 胜过遵循计划
遵循这些指导原则,敏捷的确切“风味”因团队而异,但大多数团队采用以下关键组件:
-
冲刺:在固定时间段的冲刺期间进行工作。这些冲刺的时间从 1 到 4 周不等,但通常为 2 周。
-
用户故事:工作以工作项或用户故事的形式跟踪。许多团队要求任何代码更改至少与一个工作项相关联。
-
待办事项:每个冲刺的工作来自团队之前审查和细化的优先级待办事项中的用户故事。
具体的细节、角色和名称可能因组织而异,但这些真理通常适用。
这个过程创建了一个迭代和循环的过程,其中团队在一个冲刺中处理业务认为最重要的工作项,同时优先考虑和细化下一个冲刺的项目,如图 图 17.1 所示:

图 17.1 – 敏捷软件开发周期
敏捷开发目前是我们发现的最佳企业级软件开发方法论,但它确实在重构方面带来了一些独特的挑战。请参阅本章末尾的进一步阅读部分,以获取有关敏捷的更多资源。
理解重构的障碍
敏捷开发有利于团队专注于对业务重要的事项,并处理优先级较高的待办事项列表。不幸的是,敏捷可能不是主动重构工作的最佳开发模式。
大多数组织要求所有代码更改至少与一个用户故事相关联,并且当开发者有额外容量时,预期他们正在处理用户故事。
这让工程师陷入了两难境地,他们知道需要重构的代码区域,并且拥有重构它们的技术技能和知识,但在他们团队的边界内,主动改进代码是不被接受的。
这会导致技术债务累积,并最终通过减缓工作项来降低团队的速度。这也导致引入了更多的错误,因为团队没有被允许积极管理其遗留代码中固有的风险。
这并不是说敏捷不好。敏捷是我们迄今为止发现的最佳软件工程团队管理工作流程;然而,它有一些限制,必须解决这些问题,以帮助组织实现短期和长期的成功。
成功实施敏捷重构策略
在敏捷环境中,持续的重构很重要,所以让我们谈谈确保代码定期重构的一些方法。
专门的工作项用于重构努力
记住,你和你的团队编写的每一行代码都应该带来商业价值,包括你的重构努力。
重构专注于通过解决已知的技术风险和改善团队在未来相关领域工作中实现的速度来为业务带来价值。
考虑到这些事实,将重构努力表示为冲刺中的用户故事是有意义的。就像一个开发者可能会得到一个关于与合作伙伴的新外部系统集成的故事一样,另一个开发者可能会得到一个重构和围绕数据访问层建立额外测试的故事。
在第十五章中,我们讨论了在风险登记册中跟踪技术债务。在那个章节中,我没有明确指出,但你可以使用跟踪你的用户故事的相同系统来跟踪你的已知技术风险,作为一种特殊类型的用户故事,如图17.2所示:

图 17.2 – Azure DevOps 中的技术债务项
这些技术债务用户故事应该看起来就像普通用户故事,并且具有相同的打磨和精炼程度。然而,这些用户故事应该有不同的类型或属性,具有不同的值,以便您可以在您的待办事项和冲刺中识别技术债务项。
此外,编写这些技术债务项的责任应该由团队的开发者承担,而不是产品负责人,尽管团队仍然需要向产品负责人解释这个项目是什么,修复它所需的粗略工作量,以及这个变化试图解决的风险。
健康的敏捷团队应该采取短期和长期项目的混合,技术债务项目通常属于长期项目。
可能会有时候你只能做短期工作,也可能会有时候你与一个不理解你技术债务风险的产品负责人合作。第十五章中的建议可能有助于解决这个问题,但有时可能没有简单的答案。
在这些时候,你可能需要转向重构任何变化代码的策略。
随着代码变化重构代码
在我的职业生涯中,我处理的大部分技术债务都来自于有意识地决定重构我接触到的任何代码。
这种重构变化代码的方法有几个关键优势:
-
它确保最频繁更改的区域得到重构。
-
由于我正在那个区域工作,我知道我将测试相关的代码。这意味着这些测试工作将有助于捕捉到作为重构一部分可能解决的问题。
-
这不需要为小型、琐碎的重构工作创建单独的用户故事。
在我的经验中,将清理和测试你接触到的区域的代码作为你的政策,随着时间的推移将导致代码库变得更加干净。
这种方法有其局限性:当你在一个代码区域进行微小更改且代码需要严肃的重构努力时,通常是不负责任地将你的工作范围扩展到一定程度之外。
此外,一些重构工作无法在单个冲刺的背景下完成,需要更多的战略思考和规划。
重构冲刺
我曾经一两次遇到过一种重构冲刺的概念。重构冲刺遵循农业中轮作的心态。
我不是农民,但我对轮作的理解是,你可以使用一个田地几个季节,但时间久了,那个田地开始失去土壤中的养分价值,随着时间的推移变得不那么肥沃。
为了应对这种情况,农民学会了让这些田地休耕,并在一段时间内不种植任何作物,如图图 17.3所示:

图 17.3 – 多年轮作作物
在敏捷开发中,你可能会花几个冲刺的时间处理正常的工作项,但在几个冲刺之后,你引入一个重构冲刺,团队的努力将集中在最关心代码区域的重构上。
在重构冲刺中,开发团队能够承担比标准冲刺中可能尝试的更大规模的工作。
这也有助于重新激发开发者的活力,并为他们准备围绕关键长期业务目标的下一系列冲刺。
在实际操作中,我不确定这些冲刺是否能够定期有效地工作,但我看到在特殊情况下,团队从这些冲刺中获得了巨大的益处。这些重构冲刺可以用来解决更大的问题,或者在完成一项重大举措后作为团队充电的方式。我也看到这些冲刺被用作在假日季节保持团队参与度的手段。
重构休假
我合作过的多数团队都无法承担所有开发者主要专注于重构工作,即使只是一个冲刺。
这样的团队可能希望将重构冲刺的想法缩小规模,使其仅适用于单个团队成员。
我把这个概念称为重构休假,其中开发者实际上在短时间内从团队中分离出来,专注于重构项目,然后在下一个冲刺中重新加入更大的团队。
在未来的冲刺中,另一位开发者有机会花一个冲刺的时间来处理重构工作,而其他人则处理传统的工作项,如图 17.4 所示:

图 17.4 – 在几个冲刺中轮换的开发者休假
在这个模型下,开发者想要承担的重构工作应该事先获得批准,并由团队中的其他开发者进行审查和测试。
在“休假”期间的开发者仍然应该能够回答问题并处理紧急事项。唯一的主要变化是他们在一个冲刺中的工作将自我导向,以实现已知的重构目标。
这与重构冲刺的一些相同的士气提升效果相似,但规模较小。这也帮助团队避免过度依赖团队中的任何一个人,因为人们经常轮流进入和退出休假。
虽然这个模型在小规模和中等规模的重构中可能取得成功,但在大规模重构中效果较差。我们将在下一节讨论成功进行大规模重构的方法。
完成大规模重构
根据我的经验,成功执行大规模重构是软件工程中最具挑战性的任务之一。
我将大规模重构定义为替换应用程序或应用程序的主要架构层。将应用程序从一个数据库技术迁移到另一个,用 gRPC API 替换 REST API,从 Web 表单升级到 Blazor,或替换整个服务层都是这种类型的例子。
为什么大规模重构如此困难
这些项目具有挑战性,因为它们通常需要比单个冲刺更长的时间才能完成,并且必须与多年来开发的软件保持功能对等。
此外,软件工程项目因其难以准确估计而臭名昭著,这也是开发者更喜欢敏捷软件开发而不是更传统的项目管理方法(如瀑布)的原因之一。软件开发项目的延误可能难以预测,并以意外技术障碍的形式出现,例如其他组件或平台之前未知的限制或缓慢开发进程中的微妙错误。
由于这些因素,大规模重构比中等重构更难实现。
一旦完成,这些努力的成果在转移到生产环境时可能会令人畏惧,因为它们代表了如此大的变化。在本章的后面部分,我们将讨论几种降低这种风险的方法,但决定替换或升级应用程序的主要部分并不是没有质量风险的。
当团队选择完全重写或替换软件项目而不是重构它们时,这个问题变得更加明显。
重写陷阱
重写将大型重构努力的全部问题放大了至少 10 倍。
在这种情况下,你正在替换一个已经使用了一段时间的应用程序,通常拥有大量的活跃用户和既定功能。
在保持生产中的错误和其他必须发生以保持业务顺利运行的短期工作的同时,重新实现多年的功能可能是一场斗争。
当一个团队正在积极地进行重写时,他们通常认为对当前正在替换的系统进行有针对性的重构价值很小。这意味着如果重写被取消或搁置,团队将无法从他们的投资中获得任何价值,并且仍然需要支持一个遗留系统。
由于软件项目难以估计和管理,重写通常比预期的要花费更长的时间。在这段时间里,你的工程师主要在从事重写工作,这从其他倡议中夺取了资源。
记住,重写通常在它活跃在生产环境并且人们实际使用它之前不会给业务或用户带来任何内在价值。这就是为什么如此少的重写项目能够成功。
你可以通过提供部分重写的早期预览来解决这个问题,但这并不总是可能的,如果重写中还没有重要功能,这可能不是最佳的用户体验。
提修斯之船的教训
有一个关于希腊英雄提修斯的思想实验与重构软件相关。
在这个思想实验中,我们的英雄提修斯的提修斯之船,提修斯本人,通过海上长途航行开始了漫长的旅程。在整个漫长的航行中,船员逐渐用备用材料以及他们在航行中制作或找到的材料替换了船上的部分部件。这种情况持续了一段时间,直到他回家时,他的船上没有一块原始船的部件。
这个思想实验提出了一个问题:回家的船是否还是同一艘,如果不是,它是在什么时候停止成为那艘船的?
虽然这些问题很有趣,但这个概念与软件工程相关。
使用重构,我们可以在技术债务在各个领域占据主导地位时,替换我们虚拟“船”的“板”。随着我们逐渐重构最需要重构的组件,我们不断演进我们的软件,以保持其随时间的相关性。
这就是为什么我认为在编写代码时重构代码是软件工程中一个至关重要的实践。技术债务是软件的一个不可避免的现实,你必须每次更改时都牢记在心,通过尽可能防止其占据主导地位,并通过重构偿还现有债务区域。
注意
渐进式重构只能走这么远。渐进式重构可能有助于保持你的虚拟“船”浮在水面上,但不会把一艘划船变成一艘游轮或潜艇。
更明确地说,重构不能帮助你从一个过时的技术过渡到一个更现代的技术。让我们看看可能有助于这一点的工具。
使用.NET 升级助手升级项目
随着新的.NET 版本推出和.NET 生态系统内新技术的出现,跟上这些变化可能是一个挑战。
为了解决这个问题,微软推出了.NET 升级助手,它可以帮助你安全地升级和现代化你的应用程序。在撰写本文时,这个工具对以下技术编写的项目很有用:
-
ASP.NET
-
通用 Windows 平台(UWP)
-
Windows 通信基础(WCF)
-
Windows 窗体
-
Windows 表现层基础(WPF)
.NET 升级助手可以像图 17.5所示那样作为全局工具或 Visual Studio 扩展安装:

图 17.5 – 在 Visual Studio 中安装.NET 升级助手
一旦安装了扩展,你将能够在解决方案资源管理器中右键单击一个项目,并选择升级。
从那里,你将能够配置项目的一组选项,这些选项将根据你使用的不同技术而有所不同。你还可以配置升级尝试的范围,并包括或排除你选择的文件。
一旦升级运行,你将看到更新了的项目和文件列表,并在日志中看到详细信息,如图 17.6 所示:

图 17.6 – .NET 升级助手正在运行
在尝试升级之前,你应该确保你的项目已经正确备份并置于源控制中,你可能需要自己解决某些问题,但这个工具对于以自动化方式开始升级非常有用。
对于无法使用.NET 升级助手轻松升级的应用程序,你可能需要一些更具创造性的策略,我们将在下一节中讨论。
重构与绞杀榕模式
2004 年,马丁·福勒在一篇题为StranglerFigApplication的帖子中,将绞杀榕模式介绍给了软件社区。
在这篇帖子中,马丁·福勒描述了某些无花果树,如图 17.7中所示,如何围绕其他树木缠绕并逐渐替换其他树木的结构:

图 17.7 – 安基特·巴特拉杰拍摄的无花果树照片
随着时间的推移,这个绞杀榕逐渐承担了越来越多的树的结构,它实际上变成了一棵全新的树。
在这个比喻中,树代表你试图替换的遗留应用程序,而绞杀榕的各种藤蔓则代表你的重写工作。
在这个模型下,你并不是试图重写整个应用程序并用全新重写的应用程序来替换它。
相反,你只需取应用程序的一个垂直切片,包括一组核心特性和行为,并在新技术中实现它们的新版本。这可能是一个网页或一组 API 端点,具体取决于你正在编写的内容。
一旦你在新技术中重写了这个功能,你将重定向该区域的流量从旧应用程序到新应用程序。这允许你逐步向用户发布新应用程序的部分,在生产环境中验证这些内容,然后承担应用程序的另一个垂直切片。
技术细节
有几种技术可以帮助实现替换应用程序垂直切片的目标。Azure API 管理可以帮助将网络流量引导到 API 管理中的适当端点。我也看到有人使用另一个反向代理(YARP)在这些工作中取得了成功。这两个链接可以在进一步阅读部分找到。
随着你重写工作的扩展和验证其有效性,你可以移除原始应用程序的部分,这样你就不需要维护它们了。
对于您的新应用程序尚未支持的区域,您可以将其链接回旧应用程序上的现有区域。
与全面重写相比,Strangler fig方法有一些关键优势:
-
它允许您分阶段迭代地交付您的重写
-
它在敏捷环境中工作得更好
-
它有助于在等待全面重写之前早期验证风险区域
-
如果您愿意,它允许您从原始代码中删除替换的代码
-
它可以与原始版本并行推出作为预览
也许这个模式的最大好处是,它的成功概率比尝试全面重写要高得多。
让我们谈谈当重构不太成功时应该做什么。
当重构出错时的恢复
有时,尽管您尽了最大努力,重构工作仍会失败。这可能是由于测试中的差距或对新技术的错误假设,但您重构尝试中的一部分将会失败。
失败的重构的影响
失败的重构既令人沮丧,也是对未来重构工作的严重挑战。毕竟,重构的一个重大障碍是认为遗留代码如此脆弱,以至于触摸它就会将其破坏。当您更改代码并使其崩溃时,您会使得未来更改代码变得更加困难。
当重构失败时,您有时可以快速修补以解决您引入的问题。在这种情况下,代码被重构,服务得到恢复,但您已经失去了一些团队的信任。
有时,重构失败会导致代码回滚到您重构之前的版本。有时,您将有机会进行更改,添加额外的测试,并重试此重构,而有时,团队将决定重构太危险了,无法再次尝试,您将失去一段时间内改进代码的机会。
最终,这次对话归结为业务对您不犯错误的信任程度。
软件开发中的错误会发生,因为人们是不完美的,会犯错误,没有意识到就做出假设,并且不知道一切。
在敏捷环境中建立安全性
作为技术人员,您想做的事情是创建一个环境,在那里错误很少见,并且可以在它们达到生产环境之前轻松且安全地捕捉到。
您可以做一些事情来降低重构时破坏软件的概率:
-
测试:单元测试、手动测试以及让您的同事在不同的环境中测试您的代码可以帮助您捕捉到许多错误和一些假设。
-
代码审查:在将更改发布到集成和生产环境之前进行审查的团队可以捕捉到错误的假设、错误和糟糕的编码实践。代码审查也是团队分享知识和技巧的机会,以及在整个开发团队中共享代码库知识。
-
代码分析:使用我们在本书的第三部分中讨论的.NET 中记录的最佳实践,并遵守你团队的标准,可以防止团队之前遇到的问题再次发生。
-
自动化测试:测试非常重要,我把它放在这里两次,但这次我要强调的是,任何要合并到发布分支的变化都需要运行自动化测试并通过,然后才能继续。这确保了测试可以可靠和重复地运行。
-
主动监控:定期监控错误和警告日志可以帮助你在生产环境和预发布环境中早期发现问题。
当问题发生时,要诚实和透明,并遵循以下步骤:
-
确认问题确实存在。
-
对问题有足够的了解以解决它。
-
解决问题并恢复服务。
-
确定你是如何防止问题发生的。
当你将绕过你的防御性实践视为改进流程和识别差距的方法时,它就成为了你团队的学习机会。
不幸的是,这些学习机会确实伴随着由于问题而失去他人信任的代价。
我发现,公开和诚实地沟通以下事情有助于促进理解并一定程度上恢复失去的信任:
-
你的团队在发布前验证项目不会引起问题的步骤。
-
缺陷的本质以及它是如何绕过你的团队的。
-
你为解决问题和恢复服务所做的工作。
-
你正在做些什么来确保类似的事情在未来不会成为问题。
这种方法尊重每个人,分享理解,提供提问和建议的机会,并确保他们知道应用程序的质量对你和你的团队来说非常重要。
在我们关闭这一章节以及整本书之前,让我们讨论一些你可能希望在部署软件时考虑的有用实践。
部署大规模重构。
让我们讨论一些部署代码的方法,这些方法可以帮助你捕捉到任何在成为大问题之前滑过的缺陷。
使用功能标志。
功能标志是控制功能是否激活的配置设置。
当你推送包含新功能的代码时,该代码不必立即可用。你可以像往常一样部署,但在配置中禁用新功能区域。
一旦你确信软件的其他部分按预期工作,你就可以启用新功能。如果该功能最终出现问题,你可以通过将功能标志切换回非活动状态来快速禁用它。
当你在发布实际功能时,功能标志很有帮助,但你也可以在主要重构工作中使用它们。例如,一个功能标志可能控制系统使用LegacyBookingSystem还是RevisedBookingSystem。
小贴士
功能标志库与 A/B 测试库如 Scientist .NET 配合得很好,我们已在 第九章 中介绍过。
流行的功能标志工具包括 Azure App Configuration 和 Launchdarkly,但微软还提供了一个名为 .NET 功能管理 的开源功能管理库。
.NET 功能管理功能强大,可以直接集成到你的 .NET 应用程序中,尽管它缺少一些商业软件产品可能具有的网页监控功能。
功能标志增加了你的应用程序的复杂性,但为你提供了在功能上线时的选择。这让你可以启用一个功能,在生产环境中评估其正确性,然后要么禁用它,修复观察到的任何问题,或者保持开启状态。
分阶段发布和蓝绿部署
分阶段发布 或 蓝绿部署 将功能标志的概念提升到了一个新的层次。在这个模型中,你有不同的服务器集合,通常被称为蓝色和绿色环境。
在蓝绿部署中,你可能开始时让 100%的用户使用一个环境。在这段时间里,你用新的更新修补另一个服务器,并验证它看起来运行正确,如图 图 17.8 所示:

图 17.8 – 在绿色环境更新时,用户正在使用蓝色环境
一旦你确认新服务器运行正常且没有问题,你就可以开始将一部分用户转移到新服务器上。
这个用户子集代表了真实的生产流量,可以用它来监控你的新版本在少量用户中的行为,如图 图 17.9 所示:

图 17.9 – 蓝色环境运行大多数用户,而一小部分用户在绿色环境中
如果新环境开始出现问题,你可以快速将用户从该服务器转移到旧服务器,然后关闭新环境进行维护,直到你解决了问题并准备好再次尝试。
警告
当迁移到新版本然后回滚到旧版本时,你必须特别注意确保任何数据库迁移仍然能够适当工作。例如,Entity Framework 的上下文脚本工具可以帮助你完成这项工作。
如果新环境运行没有问题,你可以逐渐将用户从旧环境“排空”到新环境。最终,你的旧环境将变为空,可以关闭直到下一次部署,如图 图 17.10 所示。10*:

图 17.10 – 绿色环境处理所有流量,蓝色环境关闭
下次部署发生时,角色将颠倒,一旦蓝色环境更新到下一个版本,用户将从绿色环境转移到蓝色环境。
这听起来很复杂,在某种程度上确实如此,但很多这种复杂性可以通过自动化并由您的云提供商管理。例如,Azure 在其许多服务中提供了蓝/绿部署,如进一步阅读部分所述。
一旦您迁移到蓝/绿部署模型,复杂性就变得在很大程度上无关紧要,相反,蓝/绿部署成为您质量工具箱中的另一个工具。
持续集成和持续交付的价值
所有这些以部署和功能管理形式增加的复杂性一开始可能显得令人畏惧,但这种成熟度有助于团队在非常高的水平上表现,并减少任何失败对最终用户的影响。
这种复杂性可能是一个问题,但幸运的是,持续集成和持续交付(CI/CD)可以帮助管理它。
CI 是指在软件更改时验证其正确性。这意味着在即将将更改合并到集成分支时,运行代码分析、单元测试和任何其他必要的检查。
CD 专注于以可重复和可靠的方式自动化软件应用的部署。而不是从一位专业开发者的机器上执行部署,部署是通过通常在云环境中运行的自动化脚本来完成的。持续交付允许您以可重复和可靠的方式将软件部署到您想要的任何环境。
CI/CD 的一些解释还包括通过 基础设施即代码(IaC)工具,如 Terraform 或 Bicep。IaC 用于根据 IaC 脚本配置具有相同资源、安全权限和配置设置的云环境。这意味着部署可以用来创建缺失的云资源并保护资源,通常使您的团队能够一致地创建新环境变得更容易。
当你将这些工具和流程结合起来时,你将得到一个定义明确且自动化的管道,该管道检查新代码的正确性,运行测试以确保更改不会破坏任何东西,并且可以将更改部署到您想要的任何环境——所有这些都不存在人为错误的可能性。
一旦你拥有足够广泛的单元和集成测试库,CI/CD 就允许你以你感到舒适的任何速度进行部署,这也是一些团队如果愿意的话,每天可以部署数百次的原因。
这种程度的流程成熟度给团队提供了快速创新的自由。这些额外的质量检查和自动化安全网进一步支持重构工作,通过消除对进行保持软件清洁和健康所需更改的恐惧。
案例研究 – Cloudy Skies Airlines
在我们结束本书之际,让我们最后回顾一下我们的案例研究公司:Cloudy Skies 航空公司。
Cloudy Skies 从他们害怕触及的难以维护的系统开始,担心引入关键错误。他们对代码库中的技术债务以及团队在过去一年中遇到的质量问题进行了系统性的审查。
因此,团队能够优先处理技术债务的关键领域列表,并确定缺乏单元测试的关键区域。Cloudy Skies 进行了几次重构冲刺,首先解决最关键的区域,并高度重视扩展他们的单元测试。
一旦解决了大部分质量热点问题,Cloudy Skies 就回归到标准的敏捷开发节奏,但每个冲刺将大约 30%的工作分配给偿还技术债务。
Cloudy Skies 使用的许多系统都已过时,但 Cloudy Skies 能够使用.NET 升级助手快速现代化其中大部分。
对于难以升级的应用程序,开发团队开始遵循绞杀榕模式来构建新的应用程序,以覆盖旧应用程序的垂直切片,并在可能的情况下使用 YARP 等工具将流量路由到新应用程序。
所有这些都得到了信任和透明度文化的支持,以及通过功能标志和 CI/CD 的现代应用程序管理流程。
虽然开发者们还需要一段时间才能完全为自己的代码感到自豪,但 Cloudy Skies 正朝着正确的方向前进。团队重新赢得了更大组织的尊重,增加的稳定性和敏捷性正帮助业务驶向晴朗的远方。
摘要
在本章中,我们探讨了在敏捷环境中重构的独特挑战以及如何在敏捷冲刺中包含重构工作的策略。
我们还探讨了实现大规模重构的方法以及当事情没有按计划进行时的应对策略。
本章还涉及了一些部署和自动化流程,这些流程可以减少问题对最终用户的影响,并通过功能标志、蓝绿部署和 CI/CD 实践最小化人为错误的风险。
向更可持续的软件迈进
本书带您从技术债务的本质到重构流程的探讨。我们讨论了如何安全地测试和构建您的软件,以及如何评估代码以遵循最佳实践、优先级排序和沟通技术债务。
我们还讨论了 C#语言和 Visual Studio 的功能如何支持您在这条通往更可持续软件开发的道路上。
每年,随着微软在年初公布新的 C#预览功能并在年底发布它们,我们的世界都会发生一点变化。
这些能力为我们提供了广泛的能力来解决今天和明天的开发问题,但现实是软件开发仍在不断变化。
软件和软件开发每年都在变得更加复杂。与此同时,许多团队陷入了维护昨天代码的困境。
不必是这样的。你可以使你的软件现代化,并且你可以以敏捷和负责任的方式做到这一点,同时满足你业务及其客户的需求。
我现在以某种形式编写软件已经超过 35 年了。新开发者认为经验越多,错误越少。虽然这有些道理,但我个人发现,我越有经验,就越不相信自己不会犯错误的能力。
为自己和其他人留出犯错的空间。错误会发生,bug 也会进入生产环境,但当他们发生时,你需要从中学习。
我衷心希望你能从每一章中学到新的东西。此外,我希望你从这本书中带着希望离开——希望你的代码能成为你快乐的源泉,或者至少让你不那么害怕改变。
通过本书中概述的实践,我相信你和你团队可以通过成功重构 C# 来达到更好的地方。
问题
-
在敏捷环境中如何减少技术债务?
-
为什么大规模的重写很难?有哪些流程可以帮助解决这个问题?
-
你现在在部署和测试软件方面看到了哪些差异?
进一步阅读
您可以在以下网址找到有关本章材料的更多信息:
-
敏捷软件开发宣言 StranglerFigApplication 帖子:
martinfowler.com/bliki/StranglerFigApplication.html -
Azure API 管理:
learn.microsoft.com/en-us/azure/api-management/api-management-key-concepts -
.NET 升级 助手:
learn.microsoft.com/en-us/dotnet/core/porting/upgrade-assistant-overview -
.NET 功能 管理:
github.com/microsoft/FeatureManagement-Dotnet -
Azure 容器应用中的蓝绿部署:
learn.microsoft.com/en-us/azure/container-apps/blue-green-deployment


浙公网安备 33010602011771号