C--代码整洁指南-全-

C# 代码整洁指南(全)

原文:zh.annas-archive.org/md5/0768F2F2E3C709CF4014BAB4C5A2161B

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读《C#中的清晰代码》。你将学习如何识别问题代码,尽管它可以编译,但不利于可读性、可维护性和可扩展性。你还将了解各种工具和模式,以及重构代码使其更清晰的方法。

本书适合对象

这本书面向对 C#编程语言有一定了解的计算机程序员,他们希望在 C#中识别问题代码并编写清晰的代码时得到指导。主要读者群将从研究生到中级程序员,但即使是高级程序员也可能会发现这本书有价值。

本书涵盖内容

第一章《C#中的编码标准和原则》探讨了一些良好的代码与糟糕的代码。当你阅读本章时,你将了解为什么需要编码标准、原则、方法和代码约定。你将学习模块化和设计准则 KISS、YAGNI、DRY、SOLID 和奥卡姆剃刀。

第二章《代码审查-流程和重要性》带领你了解代码审查的流程,并提供其重要性的原因。在本章中,你将了解准备代码进行审查的流程,领导代码审查,知道什么需要审查,知道何时发送代码进行审查,以及如何提供和回应审查反馈。

第三章《类、对象和数据结构》涵盖了类组织、文档注释、内聚性、耦合性、迪米特法则和不可变对象和数据结构等广泛主题。在本章结束时,你将能够编写良好组织且只有单一职责的代码,为代码的使用者提供相关文档,并使代码具有可扩展性。

第四章《编写清晰的函数》帮助你了解函数式编程,如何保持方法的小型化,以及如何避免代码重复和多个参数。在完成本章之后,你将能够描述函数式编程,编写函数式代码,避免编写超过两个参数的代码,编写不可变的数据对象和结构,保持方法的小型化,并编写符合单一职责原则的代码。

第五章《异常处理》涵盖了已检查和未检查的异常,空指针异常以及如何避免它们,同时还涵盖了业务规则异常,提供有意义的数据,以及构建自定义异常。

第六章《单元测试》带领你使用 SpecFlow 使用行为驱动开发(BDD)软件方法和使用 MSTest 和 NUnit 使用测试驱动开发(TDD)。你将学习如何使用 Moq 编写模拟(伪造)对象,以及如何使用 TDD 软件方法编写失败的测试,使测试通过,然后在通过后重构代码。

第七章《端到端系统测试》指导你通过一个示例项目手动进行端到端测试的过程。在本章中,你将进行端到端(E2E)测试,代码和测试工厂,代码和测试依赖注入,以及测试模块化。你还将学习如何利用模块化。

第八章《线程和并发》侧重于理解线程生命周期;向线程添加参数;使用ThreadPool、互斥体和同步线程;使用信号量处理并行线程;限制ThreadPool使用的线程和处理器数量;防止死锁和竞争条件;静态方法和构造函数;可变性和不可变性;以及线程安全。

第九章,设计和开发 API,帮助您了解 API 是什么,API 代理,API 设计指南,使用 RAML 进行 API 设计以及 Swagger API 开发。在本章中,您将使用 RAML 设计一个与语言无关的 API,并在 C#中开发它,并使用 Swagger 记录您的 API。

第十章,使用 API 密钥和 Azure Key Vault 保护 API,向您展示如何获取第三方 API 密钥,将该密钥存储在 Azure Key Vault 中,并通过您将构建和部署到 Azure 的 API 检索它。然后,您将实现 API 密钥身份验证和授权以保护您自己的 API。

第十一章,解决横切关注点,介绍了使用 PostSharp 来解决横切关注点的方法,使用方面和属性构成了面向方面的开发的基础。您还将学习如何使用代理和装饰器。

第十二章,使用工具改善代码质量,向您介绍了各种工具,这些工具将帮助您编写高质量的代码并改善现有代码的质量。您将接触到代码度量和代码分析,快速操作,JetBrains 工具 dotTrace Profiler 和 Resharper,以及 Telerik JustDecompile。

第十三章,重构 C#代码-识别代码异味,是两章中的第一章,带您了解不同类型的问题代码,并向您展示如何修改它以成为易于阅读,维护和扩展的清洁代码。每章都按字母顺序列出代码问题。在这里,您将涵盖类依赖关系,无法修改的代码,集合和组合爆炸等主题。

第十四章,重构 C#代码-实现设计模式,带您了解创建和结构设计模式的实现。在这里,简要介绍了行为设计模式。然后,您将对清洁代码和重构进行一些最终思考。

为了充分利用本书

大多数章节可以独立阅读,顺序不限。但为了充分利用本书,建议按照提供的顺序阅读章节。在阅读章节时,请按照说明执行任务。然后,在完成章节时,回答问题并进行推荐的进一步阅读,以加强所学知识。为了充分利用本书的内容,建议您满足以下要求:

本书涵盖的软件/硬件 要求
Visual Studio 2019 Windows 10, macOS
Atom Windows 10, macOS, Linux: atom.io/
Azure 资源 Azure 订阅:azure.microsoft.com/en-gb/
Azure Key Vault Azure 订阅:azure.microsoft.com/en-gb/
Morningstar API rapidapi.com/integraatio/api/morningstar1获取您自己的 API 密钥
Postman Windows 10, macOS, Linux: www.postman.com/

在开始阅读和逐章阅读的过程中,如果您已经具备以下条件,将会很有帮助。

如果您使用的是本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库(链接在下一节中提供)访问代码。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。

您应该具有使用 Visual Studio 2019 社区版或更高版本的基本经验,以及基本的 C#编程技能,包括编写控制台应用程序。许多示例将以 C#控制台应用程序的形式呈现。主要项目将使用 ASP.NET。如果您能够使用框架和核心编写 ASP.NET 网站,那将会很有帮助。但不用担心-您将被引导完成所需的步骤。

下载示例代码文件

您可以从您的帐户在www.packt.com下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support注册,以便文件直接发送到您的邮箱。

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

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

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

  3. 点击“代码下载”。

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

文件下载完成后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Clean-Code-in-C-。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781838982973_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“InMemoryRepository类实现了IRepositoryGetApiKey()方法。这将返回一个 API 密钥字典。这些密钥将存储在我们的_apiKeys字典成员变量中。”

代码块设置如下:

using CH10_DividendCalendar.Security.Authentication;
using System.Threading.Tasks;

namespace CH10_DividendCalendar.Repository
{
    public interface IRepository
    {
        Task<ApiKey> GetApiKey(string providedApiKey);
    }
}

任何命令行输入或输出都将被写成如下形式:

az group create --name "<YourResourceGroupName>" --location "East US"

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。例如:“要创建应用服务,请右键单击您创建的项目,然后从菜单中选择发布。”

警告或重要说明会出现在这样。

技巧和窍门会出现在这样。

第一章:C#中的编码标准和原则

C#中编码标准和原则的主要目标是让程序员通过编写性能更好、更易于维护的代码来提高他们的技能。在本章中,我们将看一些好代码的例子,并对比一些坏代码的例子。这将很好地引出我们为什么需要编码标准、原则和方法的讨论。然后,我们将继续考虑命名、注释和格式化源代码的约定,包括类、方法和变量。

一个大型程序可能相当难以理解和维护。对于初级程序员来说,了解代码及其功能可能是一个令人望而却步的任务。团队可能会发现很难在这样的项目上共同工作。从测试的角度来看,这可能会使事情变得相当困难。因此,我们将看一下如何使用模块化将程序分解为更小的模块,这些模块共同工作以产生一个完全可测试的解决方案,可以同时由多个团队进行开发,并且更容易阅读、理解和文档化。

我们将通过查看一些编程设计准则来结束本章,主要是 KISS、YAGNI、DRY、SOLID 和奥卡姆剃刀。

本章将涵盖以下主题:

  • 编码标准、原则和方法的必要性

  • 命名约定和方法

  • 注释和格式化

  • 模块化

  • KISS

  • YAGNI

  • DRY

  • SOLID

  • 奥卡姆剃刀

本章的学习目标是让您做到以下几点:

  • 了解为什么坏代码会对项目产生负面影响。

  • 了解好代码如何积极影响项目。

  • 了解编码标准如何改进代码以及如何强制执行它们。

  • 了解编码原则如何提高软件质量。

  • 了解方法论如何促进清洁代码的开发。

  • 实施编码标准。

  • 选择假设最少的解决方案。

  • 减少代码重复,编写 SOLID 代码。

技术要求

要在本章中处理代码,您需要下载并安装 Visual Studio 2019 社区版或更高版本。可以从visualstudio.microsoft.com/下载这个集成开发环境。

您可以在github.com/PacktPublishing/Clean-Code-in-C-[.]找到本书的代码。我已将它们全部放在一个单一的解决方案中,每个章节都是一个解决方案文件夹。您将在相关的章节文件夹中找到每个章节的代码。如果要运行项目,请记得将其分配为启动项目。

好代码与坏代码

好代码和坏代码都可以编译。这是要理解的第一件事。要理解的下一件事是,坏代码之所以糟糕是有原因的,同样,好代码之所以好也是有原因的。让我们在下面的比较表中看一些原因:

好代码 坏代码
适当的缩进。 不正确的缩进。
有意义的注释。 陈述显而易见的注释。
API 文档注释。 为糟糕的代码辩解的注释。被注释掉的代码行。
使用命名空间进行适当的组织。 使用命名空间进行不适当的组织。
良好的命名约定。 糟糕的命名约定。
只做一件工作的类。 做多个工作的类。
只做一件事的方法。 做很多事情的方法。
不超过 10 行的方法,最好不超过 4 行。 超过 10 行的方法。
方法不超过两个参数。 方法超过两个参数。
适当使用异常。 使用异常来控制程序流程。
可读性代码。 难以阅读的代码。
松散耦合的代码。 紧密耦合的代码。
高内聚性。 低内聚性。
对象被清理干净。 对象被搁置不管。
避免使用Finalize()方法。 使用Finalize()方法。
正确的抽象级别。 过度工程。
在大类中使用区域。 在大类中缺乏区域。
封装和信息隐藏。 直接暴露信息。
面向对象的代码。 意大利面代码。
设计模式。 设计反模式。

这是一个相当详尽的列表,不是吗?在接下来的部分中,我们将看看这些特性以及好代码和坏代码之间的差异如何影响你的代码的性能。

糟糕的代码

现在我们将简要介绍我们之前列出的每个不良编码实践,具体说明这些实践如何影响你的代码。

不正确的缩进

不正确的缩进可能导致代码变得非常难读,特别是如果方法很大的话。为了让代码易于人类阅读,我们需要正确的缩进。如果代码缺乏正确的缩进,很难看出代码的哪一部分属于哪个块。

默认情况下,Visual Studio 2019 在括号和大括号关闭时会正确格式化和缩进你的代码。但有时,它会错误地格式化代码,以提醒你你写的代码中包含异常。但如果你使用简单的文本编辑器,那么你就必须手动进行格式化。

错误缩进的代码也很耗时,当它本可以很容易避免时,这也是对编程时间的一种沮丧的浪费。让我们看一个简单的代码例子:

public void DoSomething()
{
for (var i = 0; i < 1000; i++)
{
var productCode = $"PRC000{i}";
//...implementation
}
}

前面的代码看起来并不那么好,但它仍然是可读的。但是你添加的代码行数越多,代码就变得越难读。

很容易错过一个闭合括号。如果你的代码没有正确缩进,那么找到缺失的括号就会变得更加困难,因为你很难看出哪个代码块缺少了闭合括号。

显而易见的注释

我见过程序员对显而易见的注释感到非常不满,因为他们觉得这些注释是居高临下的。在我参与的编程讨论中,程序员们表示他们不喜欢注释,认为代码应该是自解释的。

我能理解他们的情绪。如果你能像读书一样读懂没有注释的代码,那么这就是一段非常好的代码。如果你已经声明了一个变量是字符串,那为什么还要添加// string这样的注释呢?让我们看一个例子:

public int _value; // This is used for storing integer values.

我们知道值通过其int类型来保存整数。所以真的没有必要说明显而易见的事情。你所做的只是浪费时间和精力,以及使代码变得混乱。

借口糟糕的注释

你可能有一个紧迫的截止日期要满足,但是像// 我知道这段代码很糟糕,但至少它能工作!这样的注释真的很糟糕。不要这样做。这显示了缺乏专业精神,可能会让其他程序员感到不满。

如果你真的被迫让某些东西快速运行,那就提出一个重构的工单,并将其作为// TODO: PBI23154 重构代码以符合公司编码规范这样的 TODO 注释的一部分。然后你或者其他被分配处理技术债务的开发人员可以接手产品待办事项PBI)并重构代码。

这里有另一个例子:

...
int value = GetDataValue(); // This sometimes causes a divide by zero error. Don't know why!
...

这真的很糟糕。好吧,谢谢你告诉我们这里会发生除零错误。但你提出了一个 bug 工单吗?你尝试找出问题并修复它了吗?如果所有正在项目中积极工作的人都不碰那段代码,他们怎么会知道有错误的代码存在呢?

至少你应该在代码中加上一个// TODO:注释。这样至少这个注释会出现在任务列表中,开发人员可以收到通知并进行处理。

注释掉的代码行

如果你注释掉一些代码来尝试一些东西,那没问题。但是如果你要使用替换代码而不是注释掉的代码,那么在提交之前删除注释掉的代码。一两行注释掉的代码并不那么糟糕。但是当你有很多行注释掉的代码时,它会分散注意力,使代码难以维护;甚至会导致混乱:

/* No longer used as has been replaced by DoSomethinElse().
public void DoSomething()
{
    // ...implementation...
}
*/

为什么?为什么?如果它已经被替换并且不再需要,那就删除它。如果你的代码在版本控制中,并且你需要恢复这个方法,那么你可以随时查看文件的历史记录并恢复这个方法。

命名空间的不当组织

在使用命名空间时,不要包含应该放在其他地方的代码。这样会使找到正确的代码变得非常困难甚至不可能,特别是在大型代码库中。让我们看看这个例子:

namespace MyProject.TextFileMonitor
{
    + public class Program { ... }
    + public class DateTime { ... }
    + public class FileMonitorService { ... }
    + public class Cryptography { ... }
}

我们可以看到前面的代码中所有的类都在一个命名空间下。然而,我们有机会添加三个更好地组织这些代码的命名空间:

  • MyProject.TextFileMonitor.Core:定义常用成员的核心类将放置在这里,比如我们的DateTime类。

  • MyProject.TextFileMonitor.Services:所有充当服务的类都将放置在这个命名空间中,比如FileMonitorService

  • MyProject.TextFileMonitor.Security:所有与安全相关的类都将放置在这个命名空间中,包括我们示例中的Cryptography类。

糟糕的命名约定

在 Visual Basic 6 编程时代,我们曾经使用匈牙利命名法。我记得我第一次转到 Visual Basic 1.0 时使用它。现在不再需要使用匈牙利命名法。而且,它会让你的代码看起来很丑。所以,现代的做法是使用NameLabelNameTextBoxSaveButton,而不是使用lblNametxtNamebtnSave这样的名称。

使用晦涩的名称和与代码意图不符的名称会使阅读代码变得相当困难。ihridx是什么意思?它的意思是Human Resources Index,是一个整数。真的!避免使用mystringmyintmymethod这样的名称。这样的名称真的没有任何意义。

在名称中也不要使用下划线,比如Bad_Programmer。这会给开发人员造成视觉压力,并且使代码难以阅读。只需删除下划线。

不要在类级别和方法级别使用相同的代码约定。这会使变量的范围难以确定。变量名称的一个好的约定是对变量名称使用驼峰命名法,比如alienSpawn,对方法、类、结构和接口名称使用帕斯卡命名法,比如EnemySpawnGenerator

遵循良好的变量命名约定,你应该通过在成员变量前加下划线来区分局部变量(在构造函数或方法中包含的变量)和成员变量(在构造函数和方法之外的类顶部放置的变量)。我在工作中使用过这种编码约定,它确实非常有效,程序员似乎也喜欢这种约定。

做多项工作的类

一个好的类应该只做一件事。一个类连接到数据库,获取数据,操作数据,加载报告,将数据分配给报告,显示报告,保存报告,打印报告和导出报告,这样做的工作太多了。它需要重构为更小、更有组织的类。这样的全面类很难阅读。我个人觉得它们令人望而生畏。如果你遇到这样的类,将功能组织成区域。然后将这些区域中的代码移动到执行一个工作的新类中。

让我们来看一个做多件事情的类的例子:

public class DbAndFileManager
{
 #region Database Operations

 public void OpenDatabaseConnection() { throw new 
  NotImplementedException(); }
 public void CloseDatabaseConnection() { throw new 
  NotImplementedException(); }
 public int ExecuteSql(string sql) { throw new 
  NotImplementedException(); }
 public SqlDataReader SelectSql(string sql) { throw new 
  NotImplementedException(); }
 public int UpdateSql(string sql) { throw new 
  NotImplementedException(); }
 public int DeleteSql(string sql) { throw new 
  NotImplementedException(); }
 public int InsertSql(string sql) { throw new 
  NotImplementedException(); }

 #endregion

 #region File Operations

 public string ReadText(string filename) { throw new 
  NotImplementedException(); }
 public void WriteText(string filename, string text) { throw new 
  NotImplementedException(); }
 public byte[] ReadFile(string filename) { throw new 
  NotImplementedException(); }
 public void WriteFile(string filename, byte[] binaryData) { throw new 
  NotImplementedException(); }

 #endregion
}

正如你在前面的代码中所看到的,这个类做了两件主要的事情:它执行数据库操作和文件操作。现在代码被整齐地组织在正确命名的区域内,用于在类内逻辑上分离代码。但是单一职责原则SRP)被打破了。我们需要从重构这段代码开始,将数据库操作分离出来,放到一个名为DatabaseManager的自己的类中。

然后,我们将数据库操作从DbAndFileManager类中移除,只留下文件操作,然后将DbAndFileManager类重命名为FileManager。我们还需要考虑每个文件的命名空间,以及是否应该修改它们,使得DatabaseManager放在Data命名空间中,FileManager放在FileSystem命名空间中,或者在你的程序中的等价位置。

以下代码是将DbAndFileManager类中的数据库代码提取到自己的类中,并放在正确的命名空间中的结果:

using System;
using System.Data.SqlClient;

namespace CH01_CodingStandardsAndPrinciples.GoodCode.Data
{
    public class DatabaseManager
    {
        #region Database Operations

        public void OpenDatabaseConnection() { throw new 
         NotImplementedException(); }
        public void CloseDatabaseConnection() { throw new 
         NotImplementedException(); }
        public int ExecuteSql(string sql) { throw new 
         NotImplementedException(); }
        public SqlDataReader SelectSql(string sql) { throw new 
         NotImplementedException(); }
        public int UpdateSql(string sql) { throw new 
         NotImplementedException(); }
        public int DeleteSql(string sql) { throw new 
         NotImplementedException(); }
        public int InsertSql(string sql) { throw new 
         NotImplementedException(); }

        #endregion
    }
}

文件系统代码的重构结果是FileSystem命名空间中的FileManager类,如下面的代码所示:

using System;

namespace CH01_CodingStandardsAndPrinciples.GoodCode.FileSystem
{
    public class FileManager
    {
         #region File Operations

         public string ReadText(string filename) { throw new 
          NotImplementedException(); }
         public void WriteText(string filename, string text) { throw new 
          NotImplementedException(); }
         public byte[] ReadFile(string filename) { throw new 
          NotImplementedException(); }
         public void WriteFile(string filename, byte[] binaryData) { throw 
          new NotImplementedException(); }

         #endregion
    }
}

我们已经看到了如何识别做太多事情的类,以及如何将它们重构为只做一件事。现在让我们重复这个过程,看看做很多事情的方法。

做很多事情的方法

我发现自己在许多层级的缩进中迷失,这些缩进中做了很多事情。排列组合令人费解。我想重构代码以使维护更容易,但我的前辈禁止了。我清楚地看到,通过将代码分配给不同的方法,该方法可以变得更小。

举个例子。在这个例子中,该方法接受一个字符串。然后对该字符串进行加密和解密。它也很长,这样你就可以看到为什么方法应该保持简短:

public string security(string plainText)
{
    try
    {
        byte[] encrypted;
        using (AesManaged aes = new AesManaged())
        {
            ICryptoTransform encryptor = aes.CreateEncryptor(Key, IV);
            using (MemoryStream ms = new MemoryStream())
                using (CryptoStream cs = new CryptoStream(ms, encryptor, 
                 CryptoStreamMode.Write))
                {
                    using (StreamWriter sw = new StreamWriter(cs))
                        sw.Write(plainText);
                    encrypted = ms.ToArray();
                }
        }
        Console.WriteLine($"Encrypted data: 
         {System.Text.Encoding.UTF8.GetString(encrypted)}");
        using (AesManaged aesm = new AesManaged())
        {
            ICryptoTransform decryptor = aesm.CreateDecryptor(Key, IV);
            using (MemoryStream ms = new MemoryStream(encrypted))
            {
                using (CryptoStream cs = new CryptoStream(ms, decryptor, 
                 CryptoStreamMode.Read))
                {
                    using (StreamReader reader = new StreamReader(cs))
                        plainText = reader.ReadToEnd();
                }
            }
        }
        Console.WriteLine($"Decrypted data: {plainText}");
    }
    catch (Exception exp)
    {
        Console.WriteLine(exp.Message);
    }
    Console.ReadKey();
    return plainText;
}

如你在前面的方法中所看到的,它有 10 行代码,很难阅读。此外,它做了不止一件事。这段代码可以分解为两个分别执行单个任务的方法。一个方法会对字符串进行加密,另一个方法会解密字符串。这很好地说明了为什么方法不应该超过 10 行代码。

超过 10 行代码的方法

大方法不易阅读和理解。它们也可能导致非常难以找到的错误。大方法的另一个问题是它们可能会失去原始意图。当你遇到由注释分隔和代码包裹在区域中的大方法时,情况会变得更糟。

如果你必须滚动阅读一个方法,那么它就太长了,可能会导致程序员的压力和误解。这反过来可能会导致修改破坏代码或意图,或者两者都会。方法应该尽可能小。但是需要行使常识,因为你可以将小方法的问题推到第 n度,直到它变得过分。获得正确平衡的关键是确保方法的意图非常清晰和简洁地实现。

前面的代码是为什么你应该保持方法简短的一个很好的例子。小方法易于阅读和理解。通常,如果你的代码超过 10 行,它可能会做得比预期的更多。确保你的方法命名它们的意图,比如OpenDatabaseConnection()CloseDatabaseConnection(),并且它们要坚持它们的意图,不要偏离它们。

现在我们要看一下方法参数。

具有两个以上参数的方法

具有许多参数的方法往往变得有些难以控制。除了难以阅读之外,很容易将一个值传递给错误的参数并破坏类型安全。

随着参数数量的增加,测试方法变得越来越复杂,主要原因是你有更多的排列组合要应用到你的测试用例上。可能会错过一个在生产中会导致问题的用例。

使用异常来控制程序流程

用异常来控制程序流程可能会隐藏代码的意图。它们也可能导致意外和意想不到的结果。你的代码已经被编程成期望一个或多个异常,这表明你的设计是错误的。在第五章中更详细地介绍了一个典型情况,异常处理

典型情况是当企业使用业务规则异常BREs)时。一个方法将执行一个动作,预期会抛出一个异常。程序流程将根据异常是否被抛出来确定。一个更好的方法是使用可用的语言结构来执行返回布尔值的验证检查。

以下代码显示了使用 BRE 来控制程序流程:

public void BreFlowControlExample(BusinessRuleException bre)
{
    switch (bre.Message)
    {
        case "OutOfAcceptableRange":
            DoOutOfAcceptableRangeWork();
            break;
        default:
            DoInAcceptableRangeWork();
            break;
    }
}

该方法接受BusinessRuleException。根据异常中的消息,BreFlowControlExample()要么调用DoOutOfAcceptableRangeWork()方法,要么调用DoInAcceptableRangeWork()方法。

通过布尔逻辑来控制流程是一个更好的方法。让我们看一下以下BetterFlowControlExample()方法:

public void BetterFlowControlExample(bool isInAcceptableRange)
{
    if (isInAcceptableRange)
        DoInAcceptableRangeWork();
    else
        DoOutOfAcceptableRangeWork();
}

BetterFlowControlExample()方法中,一个布尔值被传递到方法中。这个布尔值用于确定要执行哪条路径。如果条件在可接受范围内,那么将调用DoInAcceptableRangeWork()。否则,将调用DoOutOfAcceptableRangeWork()方法。

接下来,我们将考虑难以阅读的代码。

难以阅读的代码

像千层饼和意大利面代码这样的代码真的很难阅读或跟踪。糟糕命名的方法也可能是一个痛点,因为它们可能会掩盖方法的意图。如果方法很大,并且链接的方法被一些不相关的方法分开,那么方法会进一步被混淆。

千层饼代码,也更常见地称为间接引用,指的是抽象层次,其中某物是按名称而不是按动作来引用的。分层在面向对象编程OOP)中被广泛使用,并且效果很好。然而,使用的间接引用越多,代码就会变得越复杂。这可能会让项目中的新程序员很难理解代码。因此,必须在间接引用和易理解性之间取得平衡。

意大利面代码指的是紧密耦合、内聚性低的一团乱麻。这样的代码很难维护、重构、扩展和重新设计。但好的一面是,它在编程上更加程序化,因此阅读和跟踪起来会更容易。我记得曾经在一个 VB6 GIS 程序上作为初级程序员工作,这个程序被公司购买并用于营销目的。我的技术总监和他的高级程序员之前曾试图重新设计软件,但失败了。所以他们把这个任务交给了我,让我重新设计这个程序。但当时我并不擅长软件分析和设计,所以我也失败了。

代码太复杂,难以理解和分组到相关项目中,而且太大了。事后看来,我最好是列出程序所做的一切,按功能对列表进行分组,然后在甚至不看代码的情况下列出一系列要求。

所以我在重新设计软件时学到的教训是,无论如何都要避免看代码。写下程序的所有功能,以及它应该包括的新功能。将列表转化为一组软件需求,附带任务、测试和验收标准,然后按照规格进行编程。

紧密耦合的代码

紧密耦合的代码很难测试,也很难扩展或修改。依赖于系统内其他代码的代码也很难重用。

紧密耦合的一个例子是在参数中引用具体类类型而不是引用接口。当引用具体类时,对具体类的任何更改直接影响引用它的类。因此,如果您为连接到 SQL Server 的客户端创建了一个数据库连接类,然后接受需要 Oracle 数据库的另一个客户端,那么具体类将必须针对该特定客户端及其 Oracle 数据库进行修改。这将导致代码的两个版本。

客户越多,所需的代码版本就越多。这很快变得难以维护,而且在财务上非常昂贵。想象一下,您的数据库连接类有 100,000 个不同的客户使用类的 30 个变体中的 1 个,并且它们都存在已经确定并影响它们所有的相同错误。这是 30 个类必须具有相同的修复措施,经过测试,打包和部署。这是很多维护开销,而且在财务上非常昂贵。

通过引用接口类型并使用数据库工厂构建所需的连接对象,可以克服这种特定情况。然后,客户可以在配置文件中设置连接字符串,并将其传递给工厂。工厂将为指定连接字符串中指定的特定数据库类型生成实现连接接口的具体连接类。

以下是紧密耦合代码的糟糕示例:

public class Database
{
    private SqlServerConnection _databaseConnection;

    public Database(SqlServerConnection databaseConnection)
    {
        _databaseConnection = databaseConnection;
    }
}

从示例中可以看出,我们的数据库类与使用 SQL Server 绑定,并且需要硬编码更改才能接受任何其他类型的数据库。我们将在后面的章节中涵盖代码重构,包括实际的代码示例。

低内聚

低内聚由执行各种不同任务的不相关代码组成。例如,一个实用程序类包含许多不同的实用程序方法,用于处理日期,文本,数字,进行文件输入和输出,数据验证以及加密和解密。

对象挂在那里

当对象挂在内存中时,它们可能导致内存泄漏。

静态变量可能以几种方式导致内存泄漏。如果您没有使用DependencyObjectINotifyPropertyChanged,那么您实际上是在订阅事件。公共语言运行时CLR)通过PropertyDescriptors AddValueChanged事件使用ValueChanged事件创建强引用,这导致存储引用绑定到的对象的PropertyDescriptor

除非取消订阅绑定,否则会导致内存泄漏。使用静态变量引用不会被释放的对象也会导致内存泄漏。静态变量引用的任何对象都被垃圾收集器标记为不可收集。这是因为引用对象的静态变量是垃圾收集GC)根,任何是 GC 根的东西都被垃圾收集器标记为不要收集

当您使用捕获类成员的匿名方法时,会引用类实例。这会导致类实例的引用在匿名方法保持活动的同时保持活动。

在使用非托管代码COM)时,如果不释放任何托管和非托管对象并显式释放任何内存,那么会导致内存泄漏。

在不使用弱引用、删除未使用的缓存或限制缓存大小的情况下,无限期缓存的代码最终会耗尽内存。

如果在永远不终止的线程中创建对象引用,也会导致内存泄漏。

不是匿名引用类的事件订阅。当这些事件保持订阅状态时,对象将继续存在于内存中。因此,除非在不需要时取消订阅事件,否则可能会导致内存泄漏。

使用 Finalize()方法

虽然终结器可以帮助释放未正确处理的对象的资源,并有助于防止内存泄漏,但它们也有许多缺点。

您不知道何时会调用终结器。它们将与图上所有依赖项一起被垃圾收集器提升到下一代,并且直到垃圾收集器决定这样做之前,它们不会被垃圾收集。这意味着对象可能会长时间停留在内存中。使用终结器可能会导致内存不足异常,因为您可能会比垃圾收集速度更快地创建对象。

过度设计

过度设计可能是一场噩梦。最大的原因是,作为一个普通人,浏览一个庞大的系统,试图理解它,如何使用它,以及各个部分的功能是一个耗时的过程。当没有文档时,您对系统还很陌生,甚至使用它比您长时间的人也无法回答您的问题时,情况就更加如此。

当您被要求在设定的截止日期内进行工作时,这可能是一个主要的压力原因。

学会保持简单,愚蠢

一个很好的例子是我曾经工作过的一个地方。我必须为一个接受来自服务的 JSON 的 Web 应用编写一个测试,允许一个子类进行测试,然后将结果的评分传递给另一个服务。根据公司政策,我没有按照 OOP、SOLID 或 DRY 的要求进行操作。但是我通过在非常短的时间内使用 KISS 和过程式编程与事件完成了工作。我因此受到了惩罚,并被迫使用他们自己开发的测试播放器进行重写。

因此,我开始学习他们的测试播放器。没有文档,也没有遵循 DRY 原则,很少有人真正理解它。与我的受罚系统相比,我的新版本需要使用他们的系统,因此花了几周的时间来构建,因为它没有做我需要它做的事情,而且我也不被允许修改它来做我需要它做的事情。因此,我在等待有人做所需的工作时被拖慢了速度。

我的第一个解决方案满足了业务需求,并且是一个独立的代码片段,不关心其他任何事情。第二个解决方案满足了开发团队的技术要求。项目的持续时间超过了截止日期。任何超过截止日期的项目都会比计划的成本更高。

我想要用我的受罚系统表达的另一点是,它比被重写为使用通用测试播放器的新系统要简单得多,更容易理解。

您并不总是需要遵循 OOP、SOILD 和 DRY。有时候不遵循反而更好。毕竟,您可以编写最美丽的 OOP 系统。但在底层,您的代码被转换为更接近计算机理解的过程式代码!

大类中缺乏区域

大量区域的大类很难阅读和跟踪,特别是当相关方法没有分组在一起时。区域对于在大类中对类似成员进行分组非常有用。但是如果您不使用它们,它们就没有用处!

失去意图的代码

如果您正在查看一个类,并且它正在做几件事情,那么您如何知道它的原始意图是什么?例如,如果您正在寻找一个日期方法,并且在代码的输入/输出命名空间的文件类中找到它,那么日期方法是否在正确的位置?不是。其他不了解您的代码的开发人员会很难找到该方法吗?当然会。看看这段代码:

public class MyClass 
{
    public void MyMethod()
    {
        // ...implementation...
    }

    public DateTime AddDates(DateTime date1, DateTime date2)
    {
        //...implementation...
    }

    public Product GetData(int id)
    {
        //...implementation...
    }
}

类的目的是什么?名称没有给出任何指示,MyMethod 做什么?该类似乎还在进行日期操作和获取产品数据。AddDates 方法应该在专门管理日期的类中。GetData 方法应该在产品的视图模型中。

直接暴露信息

直接暴露信息的类是不好的。除了产生可能导致错误的紧密耦合之外,如果要更改信息类型,就必须在使用的每个地方更改类型。另外,如果要在赋值之前执行数据验证怎么办?举个例子:

public class Product
{
    public int Id;
    public int Name;
    public int Description;
    public string ProductCode;
    public decimal Price;
    public long UnitsInStock
}

在上述代码中,如果要将 UnitsInStock 从类型 long 更改为类型 int,则必须更改 每个 引用它的代码。对 ProductCode 也是一样。如果新的产品代码必须遵循严格的格式,如果字符串可以直接由调用类分配,您将无法验证产品代码。

良好的代码

既然您知道不应该做什么,现在是时候简要了解一些良好的编码实践,以便编写令人愉悦、高性能的代码。

适当的缩进

当您使用适当的缩进时,阅读代码会变得更加容易。您可以通过缩进看出代码块的开始和结束位置,以及哪些代码属于这些代码块:

public void DoSomething()
{
    for (var i = 0; i < 1000; i++)
    {
        var productCode = $"PRC000{i}";
        //...implementation
    }
}

在上述简单示例中,代码看起来很好,易于阅读。您可以清楚地看到每个代码块的开始和结束位置。

有意义的注释

有意义的注释是表达程序员意图的注释。当代码正确但可能不容易被新手理解,甚至在几周后也是如此时,这样的注释是有用的。这样的注释可以真正有帮助。

API 文档注释

一个好的 API 是具有易于遵循的良好文档的 API。API 注释是 XML 注释,可用于生成 HTML 文档。HTML 文档对于想要使用您的 API 的开发人员很重要。文档越好,开发人员越有可能想要使用您的 API。举个例子:

/// <summary>
/// Create a new <see cref="KustoCode"/> instance from the text and globals. Does not perform 
/// semantic analysis.
/// </summary>
/// <param name="text">The code text</param>
/// <param name="globals">
///   The globals to use for parsing and semantic analysis. Defaults to <see cref="GlobalState.Default"/>
/// </param>.
 public static KustoCode Parse(string text, GlobalState globals = null) { ... }

Kusto 查询语言项目的这段摘录是 API 文档注释的一个很好的例子。

使用命名空间进行适当的组织

适当组织并放置在适当的命名空间中的代码可以在寻找特定代码片段时为开发人员节省大量时间。例如,如果您正在寻找与日期和时间相关的类和方法,最好有一个名为 DateTime 的命名空间,一个名为 Time 的类用于与时间相关的方法,以及一个名为 Date 的类用于与日期相关的方法。

以下是命名空间的适当组织的示例:

名称 描述
CompanyName.IO.FileSystem 该命名空间包含定义文件和目录操作的类。
CompanyName.Converters 该命名空间包含执行各种转换操作的类。
CompanyName.IO.Streams 该命名空间包含用于管理流输入和输出的类型。

良好的命名约定

遵循 Microsoft C# 命名约定是很好的。对于命名空间、类、接口、枚举和方法,请使用帕斯卡命名法。对于变量名和参数名,请使用驼峰命名法,并确保使用下划线前缀来命名成员变量。

看看这个示例代码:

using System;
using System.Text.RegularExpressions;

namespace CompanyName.ProductName.RegEx
{
  /// <summary>
  /// An extension class for providing regular expression extensions 
  /// methods.
  /// </summary>
  public static class RegularExpressions
  {
    private static string _preprocessed;

    public static string RegularExpression { get; set; }

    public static bool IsValidEmail(this string email)
    {
      // Email address: RFC 2822 Format. 
      // Matches a normal email address. Does not check the 
      // top-level domain.
      // Requires the "case insensitive" option to be ON.
      var exp = @"\A(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.
       [a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:a-z0-9?\.)+a-z0-9?)\Z";
      bool isEmail = Regex.IsMatch(email, exp, RegexOptions.IgnoreCase);
      return isEmail;
    }

    // ... rest of the implementation ...

  }
}

它展示了命名空间、类、成员变量、类、参数和局部变量的命名约定的合适示例。

只做一件事的类

一个好的类是一个只做一件事的类。当您阅读类时,其意图是清晰的。只有应该在该类中的代码才在该类中,没有其他东西。

只做一件事的方法

方法应该只做一件事。你不应该有一个做多件事的方法,比如解密字符串和执行字符串替换。方法的意图应该是清晰的。只做一件事的方法更容易小、易读和有意义。

方法不超过 10 行,最好不超过 4 行

理想情况下,你应该有不超过 4 行代码的方法。然而,这并不总是可能的,所以你应该努力使方法的长度不超过 10 行,以便它们易于阅读和维护。

方法不超过两个参数

最好是有没有参数的方法,但有一个或两个也可以。如果开始有超过两个参数,你需要考虑你的类和方法的责任:它们是否承担了太多?如果你确实需要超过两个参数,那么最好传递一个对象。

任何超过两个参数的方法都可能变得难以阅读和理解。最多只有两个参数使得代码更易读,而一个对象作为单个参数比具有多个参数的方法更易读。

异常的正确使用

永远不要使用异常来控制程序流程。以一种不会引发异常的方式处理可能触发异常的常见条件。一个好的类设计应该能够避免异常。

通过使用try/catch/finally异常来恢复异常和/或释放资源。在捕获异常时,使用可能在你的代码中抛出的特定异常,这样你就可以获得更详细的信息来记录或帮助处理异常。

有时,使用预定义的.NET 异常类型并不总是可能的。在这种情况下,将需要生成自定义异常。用单词Exception作为自定义异常类的后缀,并确保包括以下三个构造函数:

  • Exception(): 使用默认值

  • Exception(string): 接受一个字符串消息

  • Exception(string, exception): 接受一个字符串消息和一个内部异常

如果必须抛出异常,不要返回错误代码,而是返回带有有意义信息的异常。

可读的代码

代码越易读,开发者就越喜欢使用它。这样的代码更容易学习和使用。随着开发者在项目中的进出,新手将能够轻松阅读、扩展和维护代码。易读的代码也不太容易出错和不安全。

松散耦合的代码

松散耦合的代码更容易测试和重构。如果需要,你也可以更容易地交换和更改松散耦合的代码。代码重用是松散耦合代码的另一个好处。

让我们使用一个糟糕的例子,一个数据库被传递了一个 SQL Server 连接。我们可以通过引用一个接口而不是具体类型,使得相同的类松散耦合。让我们看一下之前重构的糟糕例子的好例子:

public class Database
{
    private IDatabaseConnection _databaseConnection;

    public Database(IDatabaseConnection databaseConnection)
    {
        _databaseConnection = datbaseConnection;
    }
}

正如你在这个相当基本的例子中所看到的,只要传入的类实现了IDatabaseConnection接口,我们就可以为任何类型的数据库连接传入任何类。因此,如果我们在 SQL Server 连接类中发现了一个 bug,只有 SQL Server 客户端会受到影响。这意味着具有不同数据库的客户端将继续工作,我们只需要在一个类中修复 SQL Server 客户端的代码。这减少了维护开销,从而降低了总体维护成本。

高内聚

正确分组的常见功能被认为是高度内聚的。这样的代码很容易找到。例如,如果你查看Microsoft System.Diagnostics命名空间,你会发现它只包含与诊断相关的代码。在Diagnostics命名空间中包含集合和文件系统代码是没有意义的。

对象被清理干净

在使用可处理类时,您应该始终调用Dispose()方法,以清理处于使用中的任何资源。这有助于消除内存泄漏的可能性。

有时您可能需要将对象设置为null以使其超出范围。一个例子是一个静态变量,它保存对您不再需要的对象的引用。

using语句也是一种很好的清洁方式来使用可处理对象,因为当对象不再在范围内时,它会自动被处理,所以你不需要显式调用Dispose()方法。让我们来看一下接下来的代码:

using (var unitOfWork = new UnitOfWork())
{
 // Perform unit of work here.
}
// At this point the unit of work object has been disposed of.

代码在using语句中定义了一个可处理对象,并在打开和关闭大括号之间执行所需的操作。在大括号退出之前,对象会自动被处理。因此,无需手动调用Dispose()方法,因为它会自动调用。

避免 Finalize()方法

在使用不受管理的资源时,最好实现IDisposable接口,并避免使用Finalize()方法。不能保证最终器何时运行。它们可能不会按您期望的顺序或时间运行。相反,在Dispose()方法中处理不受管理的资源更好且更可靠。

正确的抽象级别

当您仅向更高级别公开需要公开的内容,并且不在实现中迷失时,您就具有了正确的抽象级别。

如果您发现自己在实现细节中迷失了方向,那么您已经过度抽象了。如果您发现多个人不得不同时在同一个类中工作,那么您就没有足够的抽象。在这两种情况下,都需要重构以使抽象达到正确的水平。

在大类中使用区域

区域对于在大类中对项目进行分组非常有用,因为它们可以被折叠起来。阅读大类并不得不在方法之间来回跳转可能会令人望而生畏,因此在类中对相互调用的方法进行分组是一种很好的方法。在处理代码时,可以根据需要折叠和展开这些方法。

从迄今为止我们所看到的内容可以看出,良好的编码实践使得代码更易读和更易维护。我们现在将看一下编码标准和原则的必要性,以及一些软件方法论,如 SOLID 和 DRY。

编码标准、原则和方法论的必要性

大多数软件今天都是由多个团队的程序员编写的。正如您所知,我们都有自己独特的编码方式,我们都有某种形式的编程思想。您可以很容易地找到关于各种软件开发范式的编程辩论。但共识是,如果我们都遵守一组给定的编码标准、原则和方法论,那么作为程序员,这确实会让我们的生活更轻松。

让我们更详细地回顾一下这些意思。

编码标准

编码标准规定了必须遵守的几个要点和禁忌。这些标准可以通过诸如 FxCop 之类的工具或通过同行代码审查手动执行。所有公司都有自己的编码标准必须遵守。但在现实世界中,您会发现,当企业期望满足截止日期时,这些编码标准可能会被抛到一边,因为截止日期可能比实际代码质量更重要。这通常通过将任何所需的重构添加到错误列表作为技术债务来解决,以便在发布后解决。

微软有自己的编码标准,大多数情况下这些是被采纳的标准,可以根据每个企业的需求进行修改。以下是一些在线找到的编码标准的例子:

当跨团队或同一团队的人遵守编码标准时,您的代码库将变得统一。统一的代码库更容易阅读、扩展和维护。它也更不容易出错。如果存在错误,也更容易找到,因为代码遵循一套所有开发人员都遵守的标准准则。

编码原则

编码原则是一组编写高质量代码、测试和调试代码以及对代码进行维护的准则。原则可能因程序员和编程团队而异。

即使您是一个孤独的程序员,也可以通过定义自己的编码原则并坚持它们来为自己提供光荣的服务。如果您在一个团队中工作,那么达成一套编码标准对于所有人都是非常有益的,可以使共享代码的工作更加容易。

在本书中,您将看到诸如 SOLID、YAGNI、KISS 和 DRY 等编码原则的示例,所有这些都将被详细解释。但现在,SOLID代表单一职责原则、开闭原则、里氏替换原则、接口隔离原则依赖反转原则YAGNI代表你不会需要它KISS代表保持简单,愚蠢DRY代表不要重复自己

编码方法论

编码方法论将软件开发过程分解为许多预定义阶段。每个阶段都将与之相关的一些步骤。不同的开发人员和开发团队将遵循自己的编码方法论。编码方法论的主要目的是从最初的概念、编码阶段到部署和维护阶段的流程。

在本书中,您将习惯于使用 SpecFlow 进行测试驱动开发TDD)和行为驱动开发BDD),以及使用 PostSharp 进行面向方面的编程AOP)。

编码约定

最好实施微软的 C#编码约定。您可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/inside-a-program/coding-conventions上查看它们。

通过采用微软的编码约定,您可以确保以正式接受和商定的格式编写代码。这些 C#编码约定帮助人们专注于阅读您的代码,而不是专注于布局。基本上,微软的编码标准促进了最佳实践。

模块化

将大型程序分解为较小的模块是非常有意义的。小模块易于测试,更容易重用,并且可以独立于其他模块进行操作。小模块也更容易扩展和维护。

模块化程序可以分为不同的程序集和程序集内的不同命名空间。模块化程序在团队环境中也更容易操作,因为不同的模块可以由不同的团队进行操作。

在同一个项目中,通过添加反映命名空间的文件夹来将代码模块化。命名空间必须只包含与其名称相关的代码。因此,例如,如果您有一个名为FileSystem的命名空间,则与文件和目录相关的类型应放置在该文件夹中。同样,如果您有一个名为Data的命名空间,则只有与数据和数据源相关的类型应放置在该命名空间中。

正确模块化的另一个美好之处是,如果你保持模块小而简单,它们就更容易阅读。除了编码之外,大部分程序员的生活都花在阅读和理解代码上。因此,代码越小、正确模块化,就越容易阅读和理解。这会导致对代码的更深入理解,并提高开发人员对代码的接受和使用。

KISS

你可能是计算机编程世界的超级天才。你可能能够编写出让其他程序员只能惊叹地盯着它并流口水的代码。但其他程序员只看代码就知道它是什么吗?如果你在 10 周后发现了这段代码,当时你深陷于不同代码的海洋中,需要满足截止日期,你能清楚地解释你的代码做了什么以及你选择编码方法的理由吗?你有没有考虑过你可能需要在将来进一步处理这段代码?

你是否曾经编写过一些代码,然后离开,几天后再看它,然后对自己说,“我没写这种垃圾,是吗?我当时在想什么!?”我知道我曾经有过这种经历,我的一些前同事也有。

在编写代码时,保持代码简单且易于阅读,即使新手初级程序员也能理解。通常,初级程序员需要阅读、理解和维护代码。代码越复杂,初级程序员需要花费的时间就越长。甚至高级程序员也可能在复杂系统中遇到困难,以至于他们离开寻找其他工作,这样对大脑和身心的负担就会减轻。

例如,如果你正在开发一个简单的网站,问问自己几个问题。它真的需要使用微服务吗?你正在处理的旧项目真的很复杂吗?有可能简化它以便更容易维护吗?在开发新系统时,你需要写一个健壮、可维护和可扩展的解决方案,需要的最少的移动部件是什么?

YAGNI

YAGNI 是编程敏捷世界中的一种纪律,规定程序员在绝对需要之前不应添加任何代码。一个诚实的程序员会根据设计编写失败的测试,然后只编写足够的生产代码使测试工作,最后重构代码以消除任何重复。使用 YAGNI 软件开发方法,你将你的类、方法和总代码行数保持在绝对最低限度。

YAGNI 的主要目标是防止计算机程序员过度设计软件系统。如果不需要,就不要增加复杂性。你必须记住只编写你需要的代码。不要编写你不需要的代码,也不要为了实验和学习而编写代码。将实验和学习代码保留在专门用于这些目的的沙盒项目中。

DRY

我说不要重复自己! 如果你发现自己在多个地方写了相同的代码,那么这绝对是重构的候选。你应该查看代码,看看它是否可以变成通用的,并放在一个辅助类中供整个系统使用,或者放在一个库中供其他项目使用。

如果你在多个地方有相同的代码,并且发现代码有错误需要修改,那么你必须在其他地方修改代码。在这种情况下,很容易忽视需要修改的代码。结果就是发布的代码在一些地方修复了问题,但在其他地方仍然存在。

这就是为什么在遇到重复代码时,尽快删除它是个好主意,因为如果不这样做,它可能会在将来造成更多问题。

SOLID

SOLID 是一组旨在使软件更易于理解和维护的五个设计原则。软件代码应该易于阅读和扩展,而无需修改现有代码的部分。五个 SOLID 设计原则如下:

  • 单一责任原则:类和方法应该只执行单一职责。组成单一责任的所有元素应该被分组在一起并封装起来。

  • 开闭原则:类和方法应该对扩展开放,对修改关闭。当需要对软件进行更改时,您应该能够扩展软件而不修改任何代码。

  • 里氏替换原则:您的函数有一个指向基类的指针。它必须能够使用任何从基类派生的类而不知道它。

  • 接口隔离原则:当您有大型接口时,使用它们的客户端可能不需要所有的方法。因此,使用接口隔离原则ISP),您将方法提取到不同的接口中。这意味着您不再有一个大接口,而是有许多小接口。类可以实现只有它们需要的方法的接口。

  • 依赖反转原则:当您有一个高级模块时,它不应该依赖于任何低级模块。您应该能够在不影响使用它们的高级模块的情况下自由切换低级模块。高级和低级模块都应该依赖于抽象。

抽象不应该依赖于细节,但细节应该依赖于抽象。

当声明变量时,您应该始终使用静态类型,如接口或抽象类。然后可以将实现接口或继承自抽象类的具体类分配给变量。

奥卡姆剃刀

奥卡姆剃刀陈述如下:实体不应该被无必要地增加。换句话说,这基本上意味着最简单的解决方案很可能是正确的。因此,在软件开发中,违反奥卡姆剃刀原则是通过进行不必要的假设并采用最不简单的解决方案来实现的。

软件项目通常建立在一系列事实和假设之上。事实很容易处理,但假设是另一回事。在解决软件项目问题时,通常作为团队讨论问题和潜在解决方案。在选择解决方案时,您应该始终选择假设最少的项目,因为这将是最准确的实施选择。如果有一些公平的假设,您需要做的假设越多,您的设计解决方案就越有可能存在缺陷。

移动部件较少的项目出现问题的可能性较小。因此,通过保持项目小,尽可能少地做出假设,除非有必要,并且只处理事实,您遵守了奥卡姆剃刀原则。

总结

在本章中,您已经对好代码和坏代码有了介绍,希望您现在明白了为什么好代码很重要。您还提供了微软 C#编码约定的链接,以便您可以遵循微软的最佳编码实践(如果您还没有这样做的话)。

您还简要介绍了各种软件方法,包括 DRY、KISS、SOLID、YAGNI 和奥卡姆剃刀。

使用模块化,您已经看到了使用命名空间和程序集模块化代码的好处。这些好处包括独立团队能够独立工作在独立模块上,以及代码的可重用性和可维护性。

在下一章中,我们将看一下同行代码审查。有时可能会令人不快,但同行代码审查有助于通过确保他们遵守公司编码程序来使程序员受到约束。

问题

  1. 坏代码的一些结果是什么?

  2. 好代码的一些结果是什么?

  3. 写模块化代码的一些好处是什么?

  4. DRY 代码是什么?

  5. 写代码时为什么要 KISS?

  6. SOLID 的首字母缩写代表什么?

  7. 解释 YAGNI。

  8. 奥卡姆剃刀是什么?

进一步阅读

  • 自适应代码:使用设计模式和 SOLID 原则进行敏捷编码,第二版,作者是 Gary McLean Hall。

  • 使用 C#和.NET Core 的设计模式实践,作者是 Jeffrey Chilberto 和 Gaurav Aroraa。

  • 可维护软件构建,C#版,作者是 Rob can der Leek,Pascal can Eck,Gijs Wijnholds,Sylvan Rigal 和 Joost Visser。

  • 关于软件反模式的良好信息,包括一个反模式的长列表,可以在en.wikibooks.org/wiki/Introduction_to_Software_Engineering/Architecture/Anti-Patterns找到。

  • 关于设计模式的良好信息,包括一个链接到图表和实现源代码的设计模式列表,可以在en.wikipedia.org/wiki/Software_design_pattern找到。

第二章:代码审查 - 流程和重要性

任何代码审查的主要动机都是为了提高代码的整体质量。代码质量非常重要。这几乎是不言而喻的,特别是如果您的代码是团队项目的一部分或者对其他人可访问,比如通过托管协议的开源开发者和客户。

如果每个开发人员都可以随心所欲地编写代码,最终会得到以许多不同方式编写的相同类型的代码,最终代码将变得难以管理。这就是为什么有必要制定编码标准政策,概述公司的编码实践和应遵循的代码审查程序。

进行代码审查时,同事们将审查其他同事的代码。同事们会理解犯错误是人之常情。他们将检查代码中的错误、违反公司编码规范的编码以及在语法上正确但可以改进以使其更易读、更易维护或更高效的代码。

因此,在本章中,我们将详细介绍以下主题以了解代码审查流程:

  • 为审查准备代码

  • 领导代码审查

  • 知道要审查什么

  • 知道何时发送代码进行审查

  • 提供和回应审查反馈

请注意,对于为审查准备代码知道何时发送代码进行审查部分,我们将从程序员的角度进行讨论。对于领导代码审查知道要审查什么部分,我们将从代码审查人员的角度进行讨论。然而,至于提供和回应审查反馈部分,我们将涵盖程序员代码审查人员的观点。

本章的学习目标是让您能够做到以下几点:

  • 了解代码审查及其好处

  • 参与代码审查

  • 提供建设性的批评

  • 积极回应建设性的批评

在我们深入讨论这些话题之前,让我们先了解一下一般的代码审查流程。

代码审查流程

进行代码审查的正常程序是确保您的代码能够编译并满足设定的要求。它还应该通过所有单元测试和端到端测试。一旦您确信能够成功编译、测试和运行您的代码,那么它就会被检入到当前的工作分支。检入后,您将发出一个拉取请求。

然后同行审阅人将审阅您的代码并分享评论和反馈。如果您的代码通过了代码审查,那么您的代码审查就完成了,然后您可以将您的工作分支合并到主干。否则,同行审查将被拒绝,并且您将需要审查您的工作并解决评论中提出的问题。

以下图表显示了同行代码审查流程:

为审查准备代码

为代码审查做准备有时可能会很麻烦,但它确实能够提高代码的整体质量,使其易于阅读和维护。这绝对是一个值得团队开发人员作为标准编码程序执行的实践。这是代码审查流程中的一个重要步骤,因为完善这一步骤可以节省审查人员在进行审查时的大量时间和精力。

在准备代码进行审查时,请记住以下一些标准要点:

  • 始终牢记代码审查:在开始任何编程时,您应该牢记代码审查。因此,保持您的代码简洁。如果可能的话,将您的代码限制在一个功能上。

  • 确保所有的测试都通过,即使你的代码能够构建:如果你的代码能够构建,但是测试失败了,那么立即处理导致测试失败的原因。然后,当测试按预期通过时,你可以继续进行。确保所有单元测试都通过,并且端到端测试也通过了所有的测试。非常重要的是确保所有的测试都完成并且通过了,因为发布能够工作但测试失败的代码可能会导致在代码投入生产时出现一些非常不满意的客户。

  • 记住 YAGNI:在编写代码时,确保只添加满足需求或正在开发的功能的必要代码。如果你现在不需要它,那就不要编写它。只有在需要时才添加代码,而不是提前添加。

  • 检查重复代码:如果你的代码必须是面向对象的,并且符合 DRY 和 SOLID 原则,那么请检查自己的代码,看看是否包含任何过程性或重复的代码。如果有的话,花时间重构它,使其成为面向对象的、DRY 和 SOLID 的代码。

  • 使用静态分析器:已经配置为执行公司最佳实践的静态代码分析器将检查你的代码,并突出显示遇到的任何问题。确保你不要忽略信息和警告。这些可能会在后续引起问题。

最重要的是,只有在你确信你的代码满足业务需求、符合编码标准并且通过了所有测试时才提交你的代码。如果你将代码作为持续集成CI)流程的一部分提交,而你的代码构建失败了,那么你需要解决 CI 流程提出的问题。当你能够提交你的代码并且 CI 通过时,那么你可以发起一个拉取请求。

领导代码审查

在进行代码审查时,重要的是有合适的人员在场。参加同行代码审查的人员将与项目经理商定。负责提交代码进行审查的程序员将出席代码审查,除非他们远程工作。在远程工作的情况下,审阅者将审查代码,然后接受拉取请求、拒绝拉取请求,或者在采取进一步行动之前向开发人员提出一些问题。

进行代码审查的合适负责人应具备以下技能和知识:

  • 成为技术权威:领导代码审查的人应该是一个技术权威,了解公司的编码准则和软件开发方法。同时,他们对正在审查的软件有一个良好的整体理解也是非常重要的。

  • 具备良好的软技能:作为代码审查的负责人,必须是一个热情鼓励的个体,能够提供建设性的反馈。审查程序员代码的人必须具备良好的软技能,以确保审阅者和被审阅代码的人之间没有冲突。

  • 不要过于批判:代码审查的负责人不应过于批判,并且必须能够解释他们对程序员代码的批评。如果领导者接触过不同的编程风格,并且能够客观地查看代码以确保其满足项目的要求,那将非常有用。

根据我的经验,同行代码审查总是在团队使用的版本控制工具中进行拉取请求。程序员将代码提交到版本控制,然后发出拉取请求。同行代码审阅者将在拉取请求中审查代码。建设性的反馈将以评论的形式附加到拉取请求上。如果拉取请求存在问题,审阅者将拒绝更改请求并评论需要程序员解决的具体问题。如果代码审查成功,审阅者可能会添加评论以提供积极的反馈,合并拉取请求并关闭它。

程序员需要注意审阅者的任何评论,并加以采纳。如果需要重新提交代码,程序员需要确保在重新提交之前已经解决了审阅者的所有评论。

保持代码审查简短是个好主意,不要一次审查太多行。

由于代码审查通常始于拉取请求,我们将看看如何发出拉取请求,然后回应拉取请求。

发出拉取请求

当你完成编码并对代码质量和构建有信心时,你可以根据你使用的源代码控制系统推送或提交你的更改。当你的代码被推送后,你可以发出拉取请求。发出拉取请求后,对代码感兴趣的其他人会收到通知并能够审查你的更改。然后可以讨论这些更改,并就可能需要进行的任何更改发表评论。实质上,你推送到源代码控制存储库并发出拉取请求是启动同行代码审查流程的开始。

要发出拉取请求,你只需(在提交或推送代码后)点击版本控制的拉取请求选项卡。然后会出现一个按钮,你可以点击“新拉取请求”。这将把你的拉取请求添加到等待相关审阅者处理的队列中。

在接下来的截图中,我们将看到通过 GitHub 请求和完成拉取请求的过程:

  1. 在你的 GitHub 项目页面上,点击拉取请求选项卡:

  1. 然后,点击“新拉取请求”按钮。这将显示“比较更改”页面:

  1. 如果你满意,然后点击“创建拉取请求”按钮开始拉取请求。然后会出现“打开拉取请求”屏幕:

  1. 写下关于拉取请求的评论。为代码审阅者提供所有必要的信息,但保持简洁明了。有用的评论包括对所做更改的说明。根据需要修改“审阅者”、“受让人”、“标签”、“项目”和“里程碑”字段。然后,一旦你对拉取请求的细节满意,点击“创建拉取请求”按钮创建拉取请求。你的代码现在准备好由同行审阅了。

回应拉取请求

由于审阅者负责在分支合并之前审查拉取请求,我们最好看看如何回应拉取请求:

  1. 首先克隆要审查的代码副本。

  2. 审查拉取请求中的评论和更改。

  3. 检查基本分支是否存在冲突。如果有冲突,那么您将不得不拒绝拉取请求并附上必要的评论。否则,您可以审查更改,确保代码构建无错误,并确保没有编译警告。在这个阶段,您还将注意代码异味和任何潜在的错误。您还将检查测试构建、运行是否正确,并为要合并的功能提供良好的测试覆盖。除非您满意,否则请进行任何必要的评论并拒绝拉取请求。当满意时,您可以添加您的评论,并通过单击合并拉取请求按钮来合并拉取请求,如下所示:

  1. 现在,通过输入评论并单击确认合并按钮来确认合并:

  1. 一旦拉取请求已合并并关闭,可以通过单击删除分支按钮来删除分支,如下截图所示:

在前一节中,您看到被审阅者提出拉取请求,要求在合并之前对其代码进行同行审查。在本节中,您已经了解了如何审查拉取请求并将其作为代码审查的一部分完成。现在,我们将看看在回应拉取请求时进行同行代码审查时应该审查什么。

反馈对被审阅者的影响

在审查同行代码时,您还必须考虑到反馈可能是积极的或消极的。负面反馈不提供有关问题的具体细节。审阅者关注的是被审阅者而不是问题。审阅者不向被审阅者提供改进代码的建议,而且审阅者的反馈旨在伤害被审阅者。

被审阅者收到的这种负面反馈会冒犯他们。这会产生负面影响,并可能导致他们开始怀疑自己。被审阅者内部产生缺乏动力的情况,这可能对团队产生负面影响,因为工作没有按时完成或达到所需水平。审阅者和被审阅者之间的不良情绪也会影响团队,并可能导致对整个团队产生负面影响的压抑氛围。这可能导致其他同事变得缺乏动力,最终导致整个项目遭受损失。

最后,到了被审阅者已经受够了的地步,离开去别的地方找新职位摆脱这一切。项目随后在时间和财务上都遭受损失,因为需要花费时间和金钱来寻找替代者。然后找到的人还必须接受系统和工作程序以及指南的培训。以下图表显示了审阅者对被审阅者的负面反馈:

相反,审阅者对被审阅者的积极反馈产生相反的效果。当审阅者向被审阅者提供积极反馈时,他们关注的是问题,而不是人。他们解释为什么提交的代码不好,以及可能引起的问题。然后审阅者会建议被审阅者改进代码的方法。审阅者提供的反馈只是为了提高被审阅者提交的代码的质量。

当被审查者收到积极(建设性)的反馈时,他们会以积极的方式回应。他们会接受审阅者的评论,并以适当的方式回答任何问题,提出任何相关问题,然后根据审阅者的反馈更新代码。修改后的代码然后重新提交进行审查和接受。这对团队有积极的影响,因为氛围保持积极,工作按时完成并达到所需的质量。以下图表显示了审阅者对被审阅者的积极反馈的结果:

要记住的一点是,你的反馈可以是建设性的,也可以是破坏性的。作为审阅者,你的目标是建设性的,而不是破坏性的。一个快乐的团队是一个高效的团队。一个士气低落的团队是无法高效工作的,对项目也是有害的。因此,始终努力通过积极的反馈来保持一个快乐的团队。

积极批评的一种技巧是反馈三明治技巧。你从赞扬好的地方开始,然后提出建设性的批评,最后再次赞扬。如果团队中有成员对任何形式的批评都反应不好,这种技巧就非常有用。你在处理人际关系的软技能和交付高质量代码的软件技能一样重要。不要忘记这一点!

我们现在将继续看一下我们应该审查的内容。

知道要审查什么

在审查代码时,必须考虑不同的方面。首先,被审查的代码应该只是程序员修改并提交审查的代码。这就是为什么你应该经常提交小的代码。少量的代码更容易审查和评论。

让我们来看看代码审阅者应该评估的不同方面。

公司的编码准则和业务需求

所有被审查的代码都应该符合公司的编码准则和代码所要满足的业务需求。所有新代码都应该遵循公司采用的最新编码标准和最佳实践。

业务需求有不同的类型。这些需求包括业务和用户/利益相关者的需求,以及功能和实施需求。无论代码要满足的需求类型是什么,都必须对其进行全面检查,以确保满足需求的正确性。

例如,如果用户/利益相关者的需求规定“作为用户,我想要添加一个新的客户账户”,那么审查的代码是否满足这一要求中列出的所有条件?如果公司的编码准则规定所有代码必须包括测试正常流程和异常情况的单元测试,那么是否已经实现了所有必需的测试?如果对任何一个问题的答案是“否”,那么必须对代码进行评论,程序员必须解决评论,并重新提交代码。

命名约定

应该检查代码是否遵循了各种代码结构的命名约定,比如类、接口、成员变量、局部变量、枚举和方法。没有人喜欢难以解读的神秘名称,尤其是当代码库很大时。

以下是审阅者应该问的一些问题:

  • 名称是否足够长,以便人类阅读和理解?

  • 它们是否与代码的意图相关,但又足够简短,不会惹恼其他程序员?

作为审阅者,你必须能够阅读并理解代码。如果代码难以阅读和理解,那么在合并之前它确实需要重构。

格式

格式化对于使代码易于理解至关重要。命名空间、大括号和缩进应根据指南使用,并且代码块的开始和结束应该易于识别。

以下是审阅者在审查中应考虑询问的一组问题:

  • 代码是否应使用空格或制表符缩进?

  • 是否使用了正确数量的空格?

  • 是否有任何代码行太长,应该分成多行?

  • 换行呢?

  • 遵循样式指南,每行只有一个语句吗?每行只有一个声明吗?

  • 连续行是否正确缩进了一个制表符?

  • 方法是否用一行分隔?

  • 组成单个表达式的多个子句是否用括号分隔?

  • 类和方法是否干净且简洁,并且它们只做它们应该做的工作?

测试

测试必须易于理解,并覆盖大部分用例。它们必须覆盖正常的执行路径和异常用例。在测试代码时,审阅者应检查以下内容:

  • 程序员是否为所有代码提供了测试?

  • 有没有未经测试的代码?

  • 所有测试都有效吗?

  • 任何测试失败了吗?

  • 代码是否有足够的文档,包括注释、文档注释、测试和产品文档?

  • 您是否看到任何突出的东西,即使它在隔离环境中可以编译和工作,但在集成到系统中时可能会引起错误?

  • 代码是否有良好的文档以帮助维护和支持?

让我们看看流程如何进行:

未经测试的代码可能在测试和生产过程中引发意外异常。但与未经测试的代码一样糟糕的是不正确的测试。这可能导致难以诊断的错误,可能会让客户感到恼火,并且会给您带来更多的工作。错误是技术债务,业务上是被贬低的。此外,您可能已经编写了代码,但其他人可能需要阅读它,因为他们维护和扩展项目。为同事提供一些文档始终是一个好主意。

现在,关于客户,他们将如何知道您的功能在哪里以及如何使用它们?用户友好的良好文档是一个好主意。记住,并非所有用户都可能具有技术知识。因此,要迎合可能需要援助的非技术人员,但不要显得居高临下。

作为审查代码的技术权威,您是否发现了可能会成为问题的代码异味?如果是的话,您必须标记、评论和拒绝拉取请求,并让程序员重新提交他们的工作。

作为审阅者,您应该检查这些异常是否被用于控制程序流,并且引发的任何错误是否具有对开发人员和接收错误信息的客户有帮助的有意义消息。

架构指南和设计模式

必须检查新代码,以确定它是否符合项目的架构指南。代码应遵循公司采用的任何编码范例,如 SOLID、DRY、YAGNI 和 OOP。此外,可能的话,代码应采用适当的设计模式。

这就是四人帮GoF)模式发挥作用的地方。 GOF 包括《设计模式:可复用面向对象软件的元素》一书的四位作者。作者是 Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides。

如今,设计模式在大多数,如果不是所有的面向对象编程语言中都被广泛使用。Packt 出版社有涵盖设计模式的书籍,包括 Praseen Pai 和 Shine Xavier 合著的.NET 设计模式。这是一个我推荐您访问的非常好的资源:www.dofactory.com/net/design-patterns。该网站涵盖了每个 GoF 模式,并提供了定义、UML 类图、参与者、结构代码以及一些模式的真实代码。

GoF 模式包括创建、结构和行为设计模式。创建设计模式包括抽象工厂、生成器、工厂方法、原型和单例。结构设计模式包括适配器、桥接、组合、装饰器、外观、享元和代理。行为设计模式包括责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法和访问者。

代码还应该被正确组织并放置在正确的命名空间和模块中。还要检查代码是否过于简单或过度工程化。

性能和安全性

可能需要考虑的其他事项包括性能和安全性:

  • 代码的性能如何?

  • 是否有需要解决的瓶颈?

  • 代码是否以一种方式编程,以防止 SQL 注入攻击和拒绝服务攻击?

  • 代码是否经过适当验证,以保持数据的干净,以便只有有效的数据存储在数据库中?

  • 您是否检查了用户界面、文档和拼写错误的错误消息?

  • 您是否遇到任何魔术数字或硬编码的值?

  • 配置数据是否正确?

  • 是否意外泄露了任何机密信息?

全面的代码审查将包括所有前述方面及其各自的审查参数。但让我们找出何时进行代码审查才是正确的时间。

知道何时发送代码进行审查

代码审查应在开发完成后、程序员将代码传递给质量保证部门之前进行。在将任何代码检入版本控制之前,所有代码都应该能够在没有错误、警告或信息的情况下构建和运行。您可以通过以下方式确保这一点:

  • 您应该对程序运行静态代码分析,以查看是否存在任何问题。如果收到任何错误、警告或信息,请解决每个问题。不要忽视它们,因为它们可能会在后续过程中引起问题。您可以在 Visual Studio 2019 项目属性选项卡的代码分析页面上访问代码分析配置对话框。右键单击您的项目,然后选择属性|代码分析。

  • 您还应确保所有测试都能成功运行,并且应该确保所有新代码都能完全覆盖正常和异常用例,以测试代码对您正在处理的规范的正确性。

  • 如果您在工作场所采用了持续开发软件实践,将您的代码集成到更大的系统中,那么您需要确保系统集成成功,并且所有测试都能够正常运行。如果遇到任何错误,那么您必须在继续之前修复它们。

当您的代码完成、完全文档化并且您的测试工作正常,系统集成也没有任何问题时,那就是进行同行代码审查的最佳时机。一旦您的同行代码审查获得批准,您的代码就可以传递给质量保证部门。以下图表显示了软件开发生命周期SDLC)从代码开发到代码生命周期结束的过程:

程序员根据规格编写软件。他们将源代码提交到版本控制存储库并发出拉取请求。请求将被审查。如果请求失败,那么将以评论的形式拒绝请求。如果代码审查通过,那么代码将部署到 QA 团队进行他们自己的内部测试。发现的任何错误都将被提出给开发人员进行修复。如果内部测试通过 QA,那么它将被部署到用户验收测试UAT)。

如果 UAT 失败,那么将与 DevOps 团队提出错误,他们可能是开发人员或基础架构。如果 UAT 通过 QA,那么它将部署到暂存环境。暂存是负责在生产环境中部署产品的团队。当软件交到客户手中时,如果他们遇到任何错误,他们会提出错误报告。然后开发人员开始修复客户的错误,流程重新开始。一旦产品寿命结束,它将退出服务。

提供和回应审阅反馈

值得记住的是,代码审查旨在符合公司指南的代码整体质量。因此,反馈应该是建设性的,而不应该被用作放下或尴尬同事的借口。同样,审阅者的反馈不应该被个人化,对审阅者的回应应该专注于适当的行动和解释。

以下图表显示了发出拉取请求PR),进行代码审查,并接受或拒绝 PR 的过程:

作为审阅者提供反馈

职场欺凌可能是一个问题,编程环境也不例外。没有人喜欢自以为了不起的程序员。因此,审阅者具有良好的软技能和非常圆滑是很重要的。请记住,有些人很容易感到冒犯,会误解事情。因此,了解您正在处理的人以及他们可能如何回应;这将帮助您谨慎选择您的方法和措辞。

作为同行代码审阅者,您将负责理解需求,并确保代码符合该要求。因此,请寻找以下问题的答案:

  • 您能够阅读和理解代码吗?

  • 您能看到任何潜在的错误吗?

  • 是否做出了任何权衡?

  • 如果是这样,为什么要做出这些权衡?

  • 这些权衡是否会产生任何技术债务,需要在项目的后续阶段考虑进去?

一旦您的审查完成,您将有三类反馈可供选择:积极的、可选的和关键的。通过积极的反馈,您可以对程序员做得非常好的地方进行表扬。这是提高编程团队士气的好方法,因为编程团队的士气通常很低。可选的反馈对于帮助计算机程序员根据公司指南提高他们的编程技能非常有用,并且可以帮助改善正在开发的软件的整体健康状况。

最后,我们有关键反馈。关键反馈对于已经确定的任何问题是必要的,在代码可以被接受并传递给 QA 部门之前必须解决。这是需要您谨慎选择措辞以避免冒犯任何人的反馈。重要的是,您的关键评论要针对具体的问题,并提供有效的理由支持反馈。

作为被审阅者回应反馈

作为被审阅的程序员,您必须有效地向审阅者传达代码的背景。您可以通过进行小的提交来帮助他们。少量的代码比大量的代码更容易审查。审查的代码越多,错过的东西就越容易滑过去。在等待您的代码被审查时,您不能对其进行任何进一步的更改。

你可以猜到,你将从审查者那里收到积极的、可选的或者关键的反馈。积极的反馈有助于增强你对项目的信心以及士气。建立在此基础上,继续保持良好的实践。你可以选择是否采取可选的反馈,但与审查者讨论总是一个好主意。

对于关键的反馈,你必须认真对待并采取行动,因为这些反馈对项目的成功至关重要。重要的是你以礼貌和专业的方式处理关键的反馈。不要因为审查者的评论而感到冒犯;它们并不是针对个人的。这对于新程序员和缺乏信心的程序员尤为重要。

一旦收到审查者的反馈,请立即采取行动,并确保根据需要与他们讨论。

总结

在本章中,我们讨论了进行代码审查的重要性,以及准备代码进行审查和作为程序员如何回应审查者评论的完整过程,以及如何领导代码审查以及作为代码审查者进行审查时要注意的事项。可以看到同行代码审查中明显有两个角色。这些是审查者和被审查者。审查者是进行代码审查的人,而被审查者是被审查代码的人。

你还看到了作为审查者如何对你的反馈进行分类,以及在向其他程序员提供反馈时软技能的重要性。作为被审查者,你的代码正在接受审查,你看到了建立在积极和可选反馈上的重要性,以及对关键反馈采取行动的重要性。

到目前为止,你应该已经很好地理解了为什么进行定期的代码审查很重要,以及为什么在代码传递给 QA 部门之前应该进行代码审查。同行代码审查确实需要时间,对于审查者和被审查者都可能会感到不舒服。但从长远来看,它们有助于打造易于扩展和维护的高质量产品,也有助于更好地重用代码。

在下一章中,我们将学习如何编写清晰的类、对象和数据结构。你将看到我们如何组织我们的类,确保我们的类只负责一个职责,并对我们的类进行注释以帮助生成文档。然后,我们将研究内聚性和耦合性,为变更设计,以及迪米特法则。然后,我们将研究不可变对象和数据结构,隐藏数据,并在对象中公开方法,最后研究数据结构。

问题

  1. 同行代码审查中涉及的两个角色是什么?

  2. 谁同意参与同行代码审查的人员?

  3. 在请求同行代码审查之前,你如何节省审查者的时间和精力?

  4. 在审查代码时,你必须注意哪些事项?

  5. 反馈有哪三类?

进一步阅读

第三章:类、对象和数据结构

在本章中,我们将讨论组织、格式化和注释类。我们还将讨论编写符合迪米特法则的干净的 C#对象和数据结构。此外,我们还将讨论不可变对象和数据结构,以及在System.Collections.Immutable命名空间中定义不可变集合的接口和类。

我们将涵盖以下广泛的主题:

  • 组织类

  • 用于文档生成的注释

  • 内聚性和耦合性

  • 迪米特法则

  • 不可变对象和数据结构

在本章中,你将学到以下技能:

  • 如何有效地使用命名空间组织你的类。

  • 当你学会用单一职责来编程时,你的类会变得更小更有意义。

  • 在编写自己的 API 时,你可以通过提供注释来帮助文档生成工具,从而提供良好的开发者文档。

  • 由于高内聚性和低耦合性,你编写的任何程序都将易于修改和扩展。

  • 最后,你将能够应用迪米特法则并编写和使用不可变的数据结构。

因此,让我们开始看看如何通过使用命名空间有效地组织我们的类。

技术要求

你可以在 GitHub 上访问本章的代码,网址为github.com/PacktPublishing/Clean-Code-in-C-/tree/master/CH03

组织类

你会注意到一个干净项目的标志是它会有组织良好的类。文件夹将用于将属于一起的类分组。此外,文件夹中的类将被封装在与程序集名称和文件夹结构匹配的命名空间中。

每个接口、类、结构和枚举都应该在正确的命名空间中有自己的源文件。源文件应该在适当的文件夹中逻辑分组,源文件的命名空间应该与程序集名称和文件夹结构匹配。以下截图展示了一个干净的文件夹和文件结构:

在实际源文件中,不要有多个接口、类、结构或枚举。原因是这样会使定位项变得困难,尽管我们有智能感知来帮助我们。

在考虑你的命名空间时,遵循公司名称、产品名称、技术名称的帕斯卡命名规则,然后是由空格分隔的组件的复数名称。以下是一个示例:

FakeCompany.Product.Wpf.Feature.Subnamespace {} // Product, technology and feature specific.

以公司名称开头的原因是它有助于避免命名空间类。因此,如果微软和 FakeCompany 都有一个名为System的命名空间,你想要使用哪个System可以通过公司名称来区分。

接下来,任何能够在多个项目中重用的代码项最好放在可以被多个项目访问的单独的程序集中:

FakeCompany.Wpf.Feature.Subnamespace {} /* Technology and feature specific. Can be used across multiple products. */

在代码中使用测试时,比如进行测试驱动开发TDD),最好将测试类放在单独的程序集中。测试程序集应该始终以被测试的程序集名称结尾的Tests命名空间。

FakeCompany.Core.Feature {} /* Technology agnostic and feature specific. Can be used across multiple products. */

永远不要将不同程序集的测试放在同一个测试程序集中。始终保持它们分开。

此外,命名空间和类型不应该使用相同的名称,因为这可能会产生编译器冲突。在为公司名称、产品名称和缩写形式命名空间时,可以省略复数形式。

总结一下,组织类时要牢记以下规则:

  • 遵循公司名称、产品名称、技术名称的帕斯卡命名规则,然后是由空格分隔的组件的复数名称。

  • 将可重用的代码项放在单独的程序集中。

  • 不要使用相同的名称作为命名空间和类型。

  • 不要将公司和产品名称以及缩写形式变为复数。

我们将继续讨论类的责任。

一个类应该只有一个责任

责任是分配给类的工作。在 SOLID 原则集中,S 代表单一责任原则SRP)。当应用于类时,SRP 规定类必须只处理正在实现的功能的一个方面。该单个方面的责任应完全封装在类内。因此,您不应该将超过一个责任应用于一个类。

让我们看一个例子来理解为什么:

public class MultipleResponsibilities() 
{
    public string DecryptString(string text, 
     SecurityAlgorithm algorithm) 
    { 
        // ...implementation... 
    }

    public string EncryptString(string text, 
     SecurityAlgorithm algorithm) 
    { 
        // ...implementation... 
    }

    public string ReadTextFromFile(string filename) 
    { 
        // ...implementation... 
    }

    public string SaveTextToFile(string text, string filename) 
    { 
        // ...implementation... 
    }
}

正如您在前面的代码中所看到的,对于MultipleResponsibilities类,我们已经实现了我们的加密功能,包括DecryptStringEncryptString方法。我们还实现了文件访问,包括ReadTextFromFileSaveTextToFile方法。这个类违反了 SRP 原则。

因此,我们需要将这个类分成两个类,一个用于加密和另一个用于文件访问:

namespace FakeCompany.Core.Security
{
    public class Cryptography
    {    
        public string DecryptString(string text, 
         SecurityAlgorithm algorithm) 
        { 
            // ...implementation... 
        }

        public string EncryptString(string text, 
         SecurityAlgorithm algorithm) 
        { 
            // ...implementation... 
        }  
    }
}

正如我们现在从前面的代码中所看到的,通过将EncryptStringDecryptString方法移动到核心安全命名空间中的自己的Cryptography类中,我们已经使得在不同产品和技术组中重用代码来加密和解密字符串变得容易。Cryptography类也符合 SRP。

在下面的代码中,我们可以看到Cryptography类的SecurityAlgorithm参数是一个枚举,并已放置在自己的源文件中。这有助于保持代码整洁、最小化和良好组织:

using System;

namespace FakeCompany.Core.Security
{
    [Flags]
    public enum SecurityAlgorithm
    {
        Aes,
        AesCng,
        MD5,
        SHA5
    }
}

现在,在下面的TextFile类中,我们再次遵守 SRP,并且有一个很好的可重用的类,位于适当的核心文件系统命名空间中。TextFile类可以在不同产品和技术组中重复使用:

namespace FakeCompany.Core.FileSystem
{
    public class TextFile
    {
        public string ReadTextFromFile(string filename) 
        { 
            // ...implementation... 
        }

        public string SaveTextToFile(string text, string filename) 
        { 
            // ...implementation... 
        }
    }
}

我们已经看过了类的组织和责任。现在让我们来看看为了其他开发人员的利益而对类进行注释。

用于文档生成的注释

始终为您的源代码编写文档是一个好主意,无论是内部项目还是将由其他开发人员使用的外部软件。内部项目因为开发人员的流失而受到影响,通常缺乏或几乎没有可用于帮助新开发人员快速上手的文档。许多第三方 API 由于开发人员文档的糟糕状态而难以起步或接受速度低于预期,通常由于采用者因开发人员文档的糟糕状态而感到沮丧而放弃 API。

在每个源代码文件的顶部包括版权声明并对您的命名空间、接口、类、枚举、结构、方法和属性进行注释始终是一个好主意。您的版权注释应该在源文件中首先出现,在using语句之上,并采用以/*开头和以*/结尾的多行注释形式:

/**********************************************************************************
 * Copyright 2019 PacktPub
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of 
 * this software and associated documentation files (the "Software"), to deal in 
 * the Software without restriction, including without limitation the rights to use, 
 * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 
 * Software, and to permit persons to whom the Software is furnished to do so, 
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all 
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
 * SOFTWARE. 
 *********************************************************************************/

using System;

/// <summary>
/// The CH3.Core.Security namespace contains fundamental types used 
/// for the purpose of implementing application security.
/// </summary>
namespace CH3.Core.Security
{
    /// <summary>
    /// Encrypts and decrypts provided strings based on the selected 
    /// algorithm.
    /// </summary>
    public class Cryptography
    {
        /// <summary>
        /// Decrypts a string using the selected algorithm.
        /// </summary>
        /// <param name="text">The string to be decrypted.</param>
        /// <param name="algorithm">
        /// The cryptographic algorithm used to decrypt the string.
        /// </param>
        /// <returns>Decrypted string</returns>
        public string DecryptString(string text, 
         SecurityAlgorithm algorithm)
        {
            // ...implementation... 
            throw new NotImplementedException();
        }

        /// <summary>
        /// Encrypts a string using the selected algorithm.
        /// </summary>
        /// <param name="text">The string to encrypt.</param>
        /// <param name="algorithm">
        /// The cryptographic algorithm used to encrypt the string.
        /// </param>
        /// <returns>Encrypted string</returns>
        public string EncryptString(string text, 
         SecurityAlgorithm algorithm)
        {
            // ...implementation... 
            throw new NotImplementedException();
        }
    }
}

前面的代码示例提供了一个带有文档化的命名空间和类以及文档化方法的示例。您将看到命名空间和包含的成员的文档注释以///开头,并直接位于被评论的项目上方。当您键入三个正斜杠时,Visual Studio 会根据下面的行自动生成 XML 标签。

例如,在前面的代码中,命名空间只有一个摘要,类也是如此,但两个方法都包含一个摘要,一些参数注释和一个返回注释。

下表包含了您可以在文档注释中使用的不同 XML 标签。

标签 部分 目的
<c> <c> 将文本格式化为代码
<code> <code> 作为输出提供源代码
<example> <example> 提供示例
<exception> <exception> 描述方法可能抛出的异常
<include> <include> 包含来自外部文件的 XML
<list> <list> 添加列表或表格
<para> <para> 为文本添加结构
<param> <param> 描述构造函数或方法的参数
<paramref> <paramref> 标记一个词以识别它是一个参数
<permission> <permission> 描述成员的安全可访问性
<remarks> <remarks> 提供额外信息
<returns> <returns> 描述返回类型
<see> <see> 添加超链接
<seealso> <seealso> 添加一个参见条目
<summary> <summary> 总结类型或成员
<value> <value> 描述值
<typeparam> 描述类型参数
<typeparamref> 标记一个词以识别它是一个类型参数

从上表可以清楚地看出,您有很多空间来记录您的源代码。因此,充分利用可用的标签来记录您的代码是一个好主意。文档越好,其他开发人员就能更快更容易地掌握使用代码。

现在是时候看看内聚性和耦合性了。

内聚性和耦合性

在设计良好的 C#程序集中,代码将被正确地分组在一起。这就是高内聚性低内聚性是指将不相关的代码分组在一起。

您希望相关的类尽可能独立。一个类对另一个类的依赖性越高,耦合性就越高。这就是紧密耦合。类之间相互独立程度越高,内聚性就越低。这就是低内聚。

因此,在一个定义良好的类中,您希望有高内聚性和低耦合性。我们现在将看一些紧密耦合的例子,然后是低耦合。

紧密耦合的例子

在下面的代码示例中,TightCouplingA类打破了封装性,并直接访问了_name变量。_name变量应该是私有的,并且只能由其封闭类中的属性或方法修改。Name属性提供了getset方法来验证_name变量,但这是毫无意义的,因为这些检查可以被绕过,属性也不会被调用:

using System.Diagnostics;

namespace CH3.Coupling
{
    public class TightCouplingA
    {
        public string _name;

        public string Name
        {
            get
            {
                if (!_name.Equals(string.Empty))
                    return _name;
                else
                    return "String is empty!";
            }
            set
            {
                if (value.Equals(string.Empty))
                    Debug.WriteLine("String is empty!");
            }
        }
    }
}

另一方面,在下面的代码中,TightCouplingB类创建了TightCouplingA的一个实例。然后,它通过直接访问_name成员变量并将其设置为null,然后直接访问并将其值打印到调试输出窗口,直接在这两个类之间引入了紧密耦合:

using System.Diagnostics;

namespace CH3.Coupling
{
    public class TightCouplingB
    {
        public TightCouplingB()
        {
            TightCouplingA tca = new TightCouplingA();
            tca._name = null;
            Debug.WriteLine("Name is " + tca._name);
        }
    }
}

现在让我们看一下使用低耦合的相同简单示例。

低耦合的例子

在这个例子中,我们有两个类,LooseCouplingALooseCouplingBLooseCouplingA声明了一个名为_name的私有实例变量,并通过一个公共属性设置这个变量。

LooseCouplingB创建了LooseCouplingA的一个实例,并获取和设置Name的值。因为无法直接设置_name数据成员,所以对该数据成员的设置和获取值的检查是通过属性进行的。

因此,我们有一个松散耦合的例子。让我们看一下名为LooseCouplingALooseCouplingB的两个类,展示了这一点:

using System.Diagnostics;

namespace CH3.Coupling
{
    public class LooseCouplingA
    {
        private string _name;
        private readonly string _stringIsEmpty = "String is empty";

        public string Name
        {
            get
            {
                if (_name.Equals(string.Empty))
                    return _stringIsEmpty;
                else
                    return _name;
            }

            set
            {
                if (value.Equals(string.Empty))
                    Debug.WriteLine("Exception: String length must be 
                     greater than zero.");
            }
        }
    }
}

LooseCouplingA类中,我们将_name字段声明为私有,因此阻止直接修改数据。_name数据通过Name属性间接访问:


using System.Diagnostics;

namespace CH3.Coupling
{
    public class LooseCouplingB
    {
        public LooseCouplingB()
        {
            LooseCouplingA lca = new LooseCouplingA();
            lca = null;
            Debug.WriteLine($"Name is {lca.Name}");
        }
    }
}

LooseCouplingB类无法直接访问LooseCouplingB类的_name变量,因此通过属性修改数据成员。

好吧,我们已经看过耦合性,现在知道如何避免紧密耦合的代码并实现松散耦合的代码。所以现在,是时候让我们看一些低内聚性和高内聚性的例子了。

低内聚性的例子

当一个类具有多个职责时,就说它是一个低内聚的类。看一下下面的代码:

namespace CH3.Cohesion
{
    public class LowCohesion
    {
        public void ConnectToDatasource() { }
        public void ExtractDataFromDataSource() { }
        public void TransformDataForReport() { }
        public void AssignDataAndGenerateReport() { }
        public void PrintReport() { }
        public void CloseConnectionToDataSource() { }
    }
}

正如我们所看到的,前面的类至少有三个职责:

  • 连接到数据源和断开连接

  • 提取数据并将其转换为报告插入准备好

  • 生成报告并打印输出

你会清楚地看到这是如何违反 SRP 的。接下来,我们将把这个类分解为三个遵守 SRP 的类。

高内聚的例子

在这个例子中,我们将把LowCohesion类分解为三个遵守 SRP 的类。这些将被称为ConnectionDataProcessorReportGenerator。让我们看看在实现这三个类之后代码变得多么清晰。

在以下类中,你可以看到该类中的方法只与连接到数据源相关:

namespace CH3.Cohesion
{
     public class Connection
     {
         public void ConnectToDatasource() { }
         public void CloseConnectionToDataSource() { }
     }
}

类本身被命名为Connection,所以这是一个高内聚的类的例子。

在以下代码中,DataProcessor类包含两个方法,通过从数据源中提取数据并将数据转换为报告插入而处理数据:

namespace CH3.Cohesion
{
     public class DataProcessor
     {
         public void ExtractDataFromDataSource() { }
         public void TransformDataForReport() { }
     }
}

因此,这是另一个高内聚类的例子。

在以下代码中,ReportGenerator类只有与生成和输出报告相关的方法:

namespace CH3.Cohesion
{
    public class ReportGenerator
    {
        public void AssignDataAndGenerateReport() { }
        public void PrintReport() { }
    }
}

同样,这是另一个高内聚类的例子。

查看这三个类的每一个,我们可以看到它们只包含与其单一职责相关的方法。因此,这三个类都是高内聚的。

现在是时候看看我们如何通过使用接口而不是类来设计我们的代码,以便可以使用依赖注入和控制反转将代码注入到构造函数和方法中。

为变更设计

在设计变更时,你应该将what改变为how

what是业务的需求。任何经验丰富的参与软件开发角色的人都会告诉你,需求经常变化。因此,软件必须能够适应这些变化。业务不关心软件和基础设施团队如何实现需求,只关心需求准确地按时和按预算完成。

另一方面,软件和基础设施团队更关注如何满足业务需求。无论采用何种技术和流程来实现需求,软件和目标环境必须能够适应不断变化的需求。

但这还不是全部。你会发现,软件版本经常因为错误修复和新功能而改变。随着新功能的实施和重构的进行,软件代码变得过时并最终过时。此外,软件供应商有软件路线图,这是他们应用生命周期管理的一部分。最终,软件版本会被淘汰,供应商不再支持。这可能会迫使从当前不再受支持的版本迁移到新支持的版本,这可能会带来必须解决的破坏性变化。

面向接口的编程

面向接口的编程IOP)帮助我们编写多态代码。在面向对象编程中,多态性被定义为不同类具有相同接口的不同实现。因此,通过使用接口,我们可以改变软件以满足业务需求。

让我们考虑一个数据库连接的例子。一个应用程序可能需要连接到不同的数据源。但无论使用何种数据库,数据库代码如何保持不变呢?答案在于使用接口。

你有不同的数据库连接类,它们实现了相同的数据库连接接口,但它们各自有自己版本的实现方法。这就是多态。然后数据库接受一个数据库连接参数,该参数是数据库连接接口类型。然后你可以将任何实现数据库连接接口的数据库连接类型传递给数据库。让我们编写这个示例,以便更清楚地说明这些事情。

首先创建一个简单的.NET Framework 控制台应用程序。然后按照以下方式更新Program类:

static void Main(string[] args)
{
    var program = new Program();
    program.InterfaceOrientedProgrammingExample();
}

private void InterfaceOrientedProgrammingExample()
{
    var mongoDb = new MongoDbConnection();
    var sqlServer = new SqlServerConnection();
    var db = new Database(mongoDb);
    db.OpenConnection();
    db.CloseConnection();
    db = new Database(sqlServer);
    db.OpenConnection();
    db.CloseConnection();
}

在这段代码中,Main()方法创建了Program类的一个新实例,然后调用了InterfaceOrientedProgrammingExample()方法。在该方法中,我们实例化了两个不同的数据库连接,一个是 MongoDB,一个是 SQL Server。然后我们使用 MongoDB 连接实例化数据库,打开数据库连接,然后关闭它。然后我们使用相同的变量实例化一个新的数据库,并传入一个 SQL Server 连接,然后打开连接并关闭连接。正如你所看到的,我们只有一个Database类和一个构造函数,但Database类可以与实现所需接口的任何数据库连接一起工作。因此,让我们添加IConnection接口:

public interface IConnection
{
    void Open();
    void Close();
}

该接口只有两个名为Open()Close()的方法。添加实现该接口的 MongoDB 类:

public class MongoDbConnection : IConnection
{
    public void Close()
    {
        Console.WriteLine("Closed MongoDB connection.");
    }

    public void Open()
    {
        Console.WriteLine("Opened MongoDB connection.");
    }
}

我们可以看到该类实现了IConnection接口。每个方法都会在控制台打印一条消息。现在添加SQLServerConnection类:

public class SqlServerConnection : IConnection
{
    public void Close()
    {
        Console.WriteLine("Closed SQL Server Connection.");
    }

    public void Open()
    {
        Console.WriteLine("Opened SQL Server Connection.");
    }
}

Database类也是一样。它实现了IConnection接口,对于每个方法调用,都会在控制台打印一条消息。现在来看Database类,如下所示:

public class Database
{
    private readonly IConnection _connection;

    public Database(IConnection connection)
    {
        _connection = connection;
    }

    public void OpenConnection()
    {
        _connection.Open();
    }

    public void CloseConnection()
    {
        _connection.Close();
    }
}

Database类接受一个IConnection参数。这设置了_connection成员变量。OpenConnection()方法打开数据库连接,CloseConnection()方法关闭数据库连接。现在是运行程序的时候了。你应该在控制台窗口中看到以下输出:

Opened MongoDB connection.
Closed MongoDB connection.
Opened SQL Server Connection.
Closed SQL Server Connection.

现在,你可以看到编程接口的优势。你可以看到它们如何使我们能够扩展程序,而无需修改现有的代码。这意味着如果我们需要支持更多的数据库,那么我们只需要编写更多实现IConnection接口的连接对象。

现在你知道了接口的工作原理,我们可以看看如何将它们应用到依赖注入和控制反转中。依赖注入帮助我们编写干净的、松耦合且易于测试的代码,而控制反转使得根据需要可以互换软件实现,只要这些实现实现了相同的接口。

依赖注入和控制反转

在 C#中,我们有能力使用依赖注入DI)和控制反转IoC)来应对不断变化的软件需求。这两个术语确实有不同的含义,但通常可以互换使用来表示相同的事物。

使用 IoC,你可以编写一个通过调用模块来完成任务的框架。IoC 容器用于保持模块的注册。这些模块在用户请求或配置请求它们时加载。

DI 将类的内部依赖项移除。依赖对象然后由外部调用者注入。IoC 容器使用 DI 将依赖对象注入到对象或方法中。

在本章中,你将找到一些有用的资源,这些资源将帮助你理解 IoC 和 DI。然后你将能够在你的程序中使用这些技术。

让我们看看如何在没有任何第三方框架的情况下实现我们自己的简单 DI 和 IoC。

依赖注入的示例

在这个例子中,我们将自己编写一个简单的 DI。我们将有一个ILogger接口,它将有一个带有字符串参数的单一方法。然后,我们将产生一个名为TextFileLogger的类,它实现了ILogger接口,并将一个字符串输出到文本文件。最后,我们将有一个Worker类,它将演示构造函数注入和方法注入。让我们看看代码。

以下接口有一个方法,将用于实现类根据方法的实现输出消息:

namespace CH3.DependencyInjection
{
     public interface ILogger
     {
         void OutputMessage(string message);
     }
}

TexFileLogger类实现了ILogger接口,并将消息输出到文本文件:

using System;

namespace CH3.DependencyInjection
{
    public class TextFileLogger : ILogger
    {
        public void OutputMessage(string message)
        {
            System.IO.File.WriteAllText(FileName(), message);
        }

        private string FileName()
        {
            var timestamp = DateTime.Now.ToFileTimeUtc().ToString();
            var path = Environment.GetFolderPath(Environment
             .SpecialFolder.MyDocuments);
            return $"{path}_{timestamp}";
        }
    }
}

Worker类提供了构造函数 DI 和方法 DI 的示例。请注意参数是一个接口。因此,任何实现该接口的类都可以在运行时注入:

namespace CH3.DependencyInjection
{
     public class Worker
     {
         private ILogger _logger;

         public Worker(ILogger logger)
         {
             _logger = logger;
             _logger.OutputMessage("This constructor has been injected 
              with a logger!");
         }

         public void DoSomeWork(ILogger logger)
         {
             logger.OutputMessage("This methods has been injected 
              with a logger!");
         }
     }
}

DependencyInject方法运行示例以展示 DI 的工作原理:

        private void DependencyInject()
        {
            var logger = new TextFileLogger();
            var di = new Worker(logger);
            di.DoSomeWork(logger);
        }

正如你在刚才看到的代码中所看到的,我们首先生成了TextFileLogger类的一个新实例。然后将这个对象注入到工作者的构造函数中。然后我们调用DoSomeWork方法并传入TextFileLogger实例。在这个简单的例子中,我们看到了如何通过构造函数和方法将代码注入到一个类中。

这段代码的好处在于它消除了工作者和TextFileLogger实例之间的依赖关系。这使得我们可以很容易地用实现ILogger接口的任何其他类型的记录器替换TextFileLogger实例。因此,我们可以使用,例如,事件查看器记录器或甚至数据库记录器。使用 DI 是减少代码耦合的好方法。

现在我们已经看到了 DI 的工作,我们也应该看看 IoC。我们现在就来看看。

IoC 的一个例子

在这个例子中,我们将使用 IoC 容器注册依赖项。然后我们将使用 DI 来注入必要的依赖项。

在下面的代码中,我们有一个 IoC 容器。容器将依赖项注册到字典中,并从配置元数据中读取值:

using System;
using System.Collections.Generic;

namespace CH3.InversionOfControl
{
    public class Container
    {
        public delegate object Creator(Container container);

        private readonly Dictionary<string, object> configuration = new 
         Dictionary<string, object>();
        private readonly Dictionary<Type, Creator> typeToCreator = new 
         Dictionary<Type, Creator>();

        public Dictionary<string, object> Configuration
        {
            get { return configuration; }
        }

        public void Register<T>(Creator creator)
        {
            typeToCreator.Add(typeof(T), creator);
        }

        public T Create<T>()
        {
            return (T)typeToCreatortypeof(T);
        }

        public T GetConfiguration<T>(string name)
        {
            return (T)configuration[name];
        }
    }
}

然后,我们创建一个容器,并使用容器来配置元数据,注册类型,并创建依赖项的实例:

private void InversionOfControl()
{
    Container container = new Container();
    container.Configuration["message"] = "Hello World!";
    container.Register<ILogger>(delegate
    {
        return new TextFileLogger();
    });
    container.Register<Worker>(delegate
    {
        return new Worker(container.Create<ILogger>());
    });
}

接下来,我们将看看如何使用迪米特法则将对象的知识限制在只知道它的近亲。这将帮助我们编写一个干净的 C#代码,避免使用导航列车。

迪米特法则

迪米特法则旨在消除导航列车(点计数),并且还旨在提供松散耦合的良好封装代码。

理解导航列车的方法违反了迪米特法则。例如,看一下下面的代码:

report.Database.Connection.Open(); // Breaks the Law of Demeter.

代码的每个单元应该具有有限的知识量。这些知识应该只涉及相关的代码。根据迪米特法则,你必须告诉而不是询问。使用这个法则,你只能调用一个或多个以下对象的方法:

  • 作为参数传递

  • 本地创建

  • 实例变量

  • 全局变量

实施迪米特法则可能很困难,但告诉而不是询问有其优势。这样做的一个好处是解耦你的代码。

看到违反迪米特法则的坏例子以及遵守迪米特法则的例子是很好的,所以我们将在接下来的部分中看到这一点。

迪米特法则的好例子和坏例子(链接)

在好的例子中,我们有报告的实例变量。在报告变量对象实例上,调用了打开连接的方法。这不违反法律。

以下代码是一个Connection类,其中有一个打开连接的方法:

namespace CH3.LawOfDemeter
{
    public class Connection
    {
        public void Open()
        {
            // ... implementation ...
        }
    }
}

Database类创建一个新的Connection对象并打开连接:

namespace CH3.LawOfDemeter
{
    public class Database
    {
        public Database()
        {
            Connection = new Connection();
        }

        public Connection Connection { get; set; }

        public void OpenConnection()
        {
            Connection.Open();
        }
    }
}

Report类中,实例化了一个Database对象,然后打开了与数据库的连接:

namespace CH3.LawOfDemeter
{
    public class Report
    {
        public Report()
        {
            Database = new Database();
        }

        public Database Database { get; set; }

        public void OpenConnection()
        {
            Database.OpenConnection();
        }
    }
}

到目前为止,我们已经看到了遵守迪米特法则的好代码。但以下是违反这一法则的代码。

Example类中,迪米特法则被打破,因为我们引入了方法链,如report.Database.Connection.Open()

namespace CH3.LawOfDemeter
{
    public class Example
    {
        public void BadExample_Chaining()
        {
            var report = new Report();
            report.Database.Connection.Open();
        }

        public void GoodExample()
        {
            var report = new Report();
            report.OpenConnection();
        }
    }
}

在这个糟糕的例子中,对报告实例变量调用了Database getter。这是可以接受的。但然后调用了返回不同对象的Connection getter。这违反了迪米特法则,最后调用打开连接也是如此。

不可变对象和数据结构

不可变类型通常被认为只是值类型。对于值类型,当它们被设置时,不希望它们发生变化是有道理的。但是您也可以有不可变对象类型和不可变数据结构类型。不可变类型是一种在初始化后其内部状态不会改变的类型。

不可变类型的行为不会使其他程序员感到惊讶,因此符合最小惊讶原则POLA)。不可变类型的 POLA 符合度遵守与客户之间达成的任何合同,并且因为它是可预测的,程序员会发现很容易推断其行为。

由于不可变类型是可预测且不会改变,您不会遇到任何令人不快的惊喜。因此,您不必担心由于以某种方式被更改而导致的任何不良影响。这使得不可变类型非常适合在线程之间共享,因为它们是线程安全的,无需进行防御性编程。

当您创建一个不可变类型并使用对象验证时,您将获得一个在该对象的生命周期内有效的对象。

让我们看一个 C#中不可变类型的例子。

不可变类型的例子

现在我们将看一个不可变对象。以下代码中的Person对象有三个私有成员变量。这些变量只能在构造函数中设置。一旦设置,它们在对象的其余生命周期内将无法修改。每个变量只能通过只读属性进行读取:

namespace CH3.ImmutableObjectsAndDataStructures
{
    public class Person
    {
        private readonly int _id;
        private readonly string _firstName;
        private readonly string _lastName;

        public int Id => _id;
        public string FirstName => _firstName;
        public string LastName => _lastName;
        public string FullName => $"{_firstName} {_lastName}";
        public string FullNameReversed => $"{_lastName}, {_firstName}";

        public Person(int id, string firstName, string lastName)
        {
            _id = id;
            _firstName = firstName;
            _lastName = lastName;
        }
    }
}

现在我们已经看到编写不可变对象和数据结构有多么容易,我们将看看对象中的数据和方法。

对象应该隐藏数据并公开方法

对象的状态存储在成员变量中。这些成员变量是数据。数据不应直接可访问。您应该只通过公开的方法和属性提供对数据的访问。

为什么要隐藏数据并公开方法?

隐藏数据并公开方法在面向对象编程世界中被称为封装。封装将类的内部工作隐藏在外部世界之外。这使得更改值类型而不破坏依赖于该类的现有实现变得容易。数据可以被设置为可读/可写、可写或只读,这样可以更灵活地访问和使用数据。您还可以验证输入,从而防止数据接收无效值。封装还使得测试类变得更容易,并且可以使类更具可重用性和可扩展性。

让我们看一个例子。

封装的例子

以下代码示例显示了一个封装的类。Car对象是可变的。它具有在构造函数初始化后获取和设置数据值的属性。构造函数和设置属性执行参数的验证。如果值无效,则抛出无效参数异常,否则将传回值并设置数据值:

using System;

namespace CH3.Encapsulation
{
    public class Car
    {
        private string _make;
        private string _model;
        private int _year;

        public Car(string make, string model, int year)
        {
            _make = ValidateMake(make);
            _model = ValidateModel(model);
            _year = ValidateYear(year);
        }

        private string ValidateMake(string make)
        {
            if (make.Length >= 3)
                return make;
            throw new ArgumentException("Make must be three 
             characters or more.");
        }

        public string Make
        {
            get { return _make; }
            set { _make = ValidateMake(value); }
        }

        // Other methods and properties omitted for brevity.
    }
}

前面代码的好处是,如果您需要更改获取或设置数据值的代码的验证,您可以这样做而不会破坏实现。

数据结构应该公开数据并且没有方法

结构与类的不同之处在于它们使用值相等而不是引用相等。除此之外,结构和类之间没有太大的区别。

关于数据结构是否应该将变量公开还是隐藏在 get 和 set 属性后,存在争论。选择权完全取决于你,但我个人认为即使在结构中也最好隐藏数据,并且只通过属性和方法提供访问。在拥有干净的数据结构并且安全的情况下,有一个例外,那就是一旦创建,结构体不应允许方法和 get 属性对其进行改变。这样做的原因是对临时数据结构的更改将被丢弃。

现在让我们看一个简单的数据结构示例。

数据结构的一个例子

以下代码是一个简单的数据结构:

namespace CH3.Encapsulation
{
    public struct Person
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }

        public Person(int id, string firstName, string lastName)
        {
            Id = id;
            FirstName = firstName;
            LastName = lastName;
        }
    }
}

正如你所看到的,数据结构与类并没有太大的不同,它有构造函数和属性。

通过这一章的学习,我们将回顾我们所学到的知识。

总结

在本章中,我们学习了如何在文件夹和包中组织我们的命名空间,以及良好的组织如何帮助防止命名空间类。然后我们转向类和责任,并探讨了为什么类应该只有一个责任。我们还研究了内聚性和耦合性,以及为什么具有高内聚性和低耦合性是重要的。

良好的文档需要对公共成员进行正确的注释,并且我们学习了如何使用 XML 注释来实现这一点。还讨论了为什么应该为更改而设计的重要性,并提供了 DI 和 IoC 的基本示例。

德米特法则告诉你不要与陌生人交谈,只与直接朋友交谈,以及如何避免链式调用。最后,我们研究了对象和数据结构,以及它们应该隐藏和公开的内容。

在下一章中,我们将简要介绍 C#中的函数式编程以及如何编写简洁的方法。我们还将学习避免在方法中使用超过两个参数,因为参数过多的方法会变得难以管理。此外,我们还将学习避免重复,因为重复可能是一个麻烦的错误源,即使在一个地方修复了,但在代码的其他地方仍然存在。

问题

  1. 我们如何在 C#中组织我们的类?

  2. 一个类应该有多少个责任?

  3. 如何在代码中为文档生成器添加注释?

  4. 内聚性是什么意思?

  5. 耦合是什么意思?

  6. 内聚性应该高还是低?

  7. 耦合应该是紧密的还是松散的?

  8. 有哪些机制可以帮助你设计以便进行更改?

  9. 什么是 DI?

  10. 什么是 IoC?

  11. 使用不可变对象的一个好处是什么?

  12. 对象应该隐藏和显示什么?

  13. 结构应该隐藏和显示什么?

进一步阅读

第四章:编写干净的函数

干净的函数是小方法(它们有两个或更少的参数)并且避免重复。理想的方法没有参数,也不修改程序的状态。小方法不太容易出现异常,因此你将编写更加健壮的代码,从长远来看,你将有更少的错误需要修复。

函数式编程是一种将计算视为数学计算的软件编码方法。本章将教你将计算视为数学函数的评估的好处,以避免改变对象的状态。

大方法(也称为函数)阅读起来笨拙且容易出错,因此编写小方法有其优势。因此,我们将看看如何将大方法分解为小方法。在本章中,我们将介绍 C#中的函数式编程以及如何编写小而干净的方法。

构造函数和具有多个参数的方法可能会变得非常麻烦,因此我们需要寻找解决方法来处理和传递多个参数,以及如何避免使用超过两个参数。减少参数数量的主要原因是它们可能变得难以阅读,会让其他程序员感到烦恼,并且如果参数足够多的话会造成视觉压力。它们也可能表明该方法试图做太多的事情,或者你需要考虑重构你的代码。

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

  • 理解函数式编程

  • 保持方法小

  • 避免重复

  • 避免多个参数

通过本章的学习,你将具备以下技能:

  • 描述函数式编程是什么

  • 在 C#编程语言中提供现有的函数式编程示例

  • 编写函数式的 C#代码

  • 避免编写超过两个参数的方法

  • 编写不可变的数据对象和结构

  • 保持你的方法小

  • 编写符合单一职责原则(SRP)的代码

让我们开始吧!

理解函数式编程

函数式编程与其他编程方法的唯一区别在于函数不修改数据或状态。在深度学习、机器学习和人工智能等场景中,当需要对相同的数据集执行不同的操作时,你将使用函数式编程。

.NET Framework 中的 LINQ 语法是函数式编程的一个例子。因此,如果你想知道函数式编程是什么样子,如果你以前使用过 LINQ,那么你已经接触过函数式编程,并且应该知道它是什么样子的。

由于函数式编程是一个深入的主题,关于这个主题存在许多书籍、课程和视频,所以我们在本章中只会简要涉及这个主题,通过查看纯函数和不可变数据。

纯函数只能对传入的数据进行操作。因此,该方法是可预测的,避免产生副作用。这对程序员有好处,因为这样的方法更容易推理和测试。

一旦初始化了一个不可变的数据对象或数据结构,其中包含的数据值将不会被修改。因为数据只是被设置而不是修改,你可以很容易地推断出数据是什么,它是如何设置的,以及任何操作的结果会是什么,给定了输入。不可变数据也更容易测试,因为你知道你的输入是什么,以及期望的输出是什么。这使得编写测试用例变得更容易,因为你不需要考虑那么多事情,比如对象状态。不可变对象和结构的好处在于它们是线程安全的。线程安全的对象和结构可以作为良好的数据传输对象(DTOs)在线程之间传递。

但是如果结构包含引用类型,它们仍然可以是可变的。解决这个问题的一种方法是使引用类型成为不可变的。C# 7.2 增加了对readonly structImmutableStruct的支持。因此,即使我们的结构包含引用类型,我们现在也可以使用这些新的 C# 7.2 构造来使具有引用类型的结构成为不可变的。

现在,让我们来看一个纯函数的例子。对象属性的唯一设置方式是通过构造函数在构造时进行。这个类是一个Player类,其唯一工作是保存玩家的姓名和他们的最高分。提供了一个方法来更新玩家的最高分:

public class Player
{
    public string PlayerName { get; }
    public long HighScore { get; }

    public Player(string playerName, long highScore)
    {
        PlayerName = playerName;
        HighScore = highScore;
    }

    Public Player UpdateHighScore(long highScore)
    {
        return new Player(PlayerName, highScore);
    }

}

请注意,UpdateHighScore方法不会更新HighScore属性。相反,它通过传入已在类中设置的PlayerName变量和方法参数highScore来实例化并返回一个新的Player类。您现在已经看到了一个非常简单的示例,说明如何在不改变其状态的情况下编写软件。

函数式编程是一个非常庞大的主题,对于过程式和面向对象的程序员来说,它需要进行思维转变,这可能非常困难。由于这超出了本书的范围(深入探讨函数式编程的主题),我们鼓励您自行查阅 PacktPub 提供的函数式编程资源。

Packt 有一些非常好的书籍和视频,专门教授功能编程的顶级知识。您将在本章末尾的进一步阅读部分找到一些 Packt 功能编程资源的链接。

在我们继续之前,我们将看一些 LINQ 示例,因为 LINQ 是 C#中函数式编程的一个例子。有一个例子数据集会很有帮助。以下代码构建了一个供应商和产品列表。我们将首先编写Product结构:

public struct Product
{
    public string Vendor { get; }
    public string ProductName { get; }
    public Product(string vendor, string productName)
    {
        Vendor = vendor;
        ProductName = productName;
    }
}

现在我们有了结构体,我们将在GetProducts()方法中添加一些示例数据:

public static List<Product> GetProducts()
{
    return new List<Products>
    {
        new Product("Microsoft", "Microsoft Office"),
        new Product("Oracle", "Oracle Database"),
        new Product("IBM", "IBM DB2 Express"),
        new Product("IBM", "IBM DB2 Express"),
        new Product("Microsoft", "SQL Server 2017 Express"),
        new Product("Microsoft", "Visual Studio 2019 Community Edition"),
        new Product("Oracle", "Oracle JDeveloper"),
        new Product("Microsoft", "Azure"),
        new Product("Microsoft", "Azure"),
        new Product("Microsoft", "Azure Stack"),
        new Product("Google", "Google Cloud Platform"),
        new Product("Amazon", "Amazon Web Services")
    };
}

最后,我们可以开始在我们的列表上使用 LINQ。在前面的示例中,我们将获得一个按供应商名称排序的产品的不同列表,并打印出结果:

class Program
{
    static void Main(string[] args)
    {
        var vendors = (from p in GetProducts()
                        select p.Vendor)
                        .Distinct()
                        .OrderBy(x => x);
        foreach(var vendor in vendors)
            Console.WriteLine(vendor);
        Console.ReadKey();
    }
}

在这里,我们通过调用GetProducts()获取供应商列表,并仅选择Vendor列。然后,我们过滤列表,使其只包括一个供应商,通过调用Distinct()方法。然后,通过调用OrderBy(x => x)按字母顺序对供应商列表进行排序,其中x是供应商的名称。在获得排序后的不同供应商列表后,我们遍历列表并打印供应商的名称。最后,我们等待用户按任意键退出程序。

函数式编程的一个好处是,您的方法比其他类型的编程方法要小得多。接下来,我们将看一下为什么保持方法小巧是有益的,以及我们可以使用的技术,包括函数式编程。

保持方法的小巧

在编写干净和可读的代码时,保持方法小巧是很重要的。在 C#世界中,最好将方法保持在10 行以下。最佳长度不超过4 行。保持方法小巧的一个好方法是考虑是否应该捕获错误或将其传递到调用堆栈的更高层。通过防御性编程,您可能会变得过于防御,这可能会增加您发现自己编写的代码量。此外,捕获错误的方法将比不捕获错误的方法更长。

让我们考虑以下可能会抛出ArgumentNullException的代码:

        public UpdateView(MyEntities context, DataItem dataItem)
        {
            InitializeComponent();
            try
            {
                DataContext = this;
                _dataItem = dataItem;
                _context = context;
                nameTextBox.Text = _dataItem.Name;
                DescriptionTextBox.Text = _dataItem.Description;
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex);
                throw;
            }
        }

在上面的代码中,我们可以清楚地看到有两个位置可能会引发ArgumentNullException。可能引发ArgumentNullException的第一行代码是nameTextBox.Text = _dataItem.Name;;可能引发相同异常的第二行代码是DescriptionTextBox.Text = _dataItem.Description;。我们可以看到异常处理程序在发生异常时捕获异常,将其写入控制台,然后简单地将其抛回堆栈。

请注意,从人类阅读的角度来看,有8 行代码形成了try/catch块。

你可以通过编写自己的参数验证器,用一行文本完全替换try/catch异常处理。为了解释这一点,我们将提供一个例子。

让我们首先看一下ArgumentValidator类。这个类的目的是抛出一个带有包含空参数的方法名称的ArgumentNullException

using System;
namespace CH04.Validators
{
    internal static class ArgumentValidator
    {
        public static void NotNull(
            string name, 
            [ValidatedNotNull] object value
        )
        {
            if (value == null)
                throw new ArgumentNullException(name);
        }
    }

    [AttributeUsage(
        AttributeTargets.All, 
        Inherited = false, 
        AllowMultiple = true)
    ]
    internal sealed class ValidatedNotNullAttribute : Attribute
    {
    }
}

现在我们有了我们的空验证类,我们可以对我们的方法中的空值参数执行新的验证方式。所以,让我们看一个简单的例子:

public ItemsUpdateView(
    Entities context, 
    ItemsView itemView
)
{
    InitializeComponent();
    ArgumentValidator.NotNull("ItemsUpdateView", itemView);
    // ### implementation omitted ###
}

正如你可以清楚地看到的,我们用一个一行代码替换了整个try catch块。当这个验证检测到空参数时,会抛出一个ArgumentNullException,阻止代码继续执行。这使得代码更容易阅读,也有助于调试。

现在,我们将看一下如何使用缩进格式化函数,使其易于阅读。

缩进代码

一个非常长的方法在任何时候都很难阅读和跟踪,特别是当你不得不多次滚动方法才能到达底部时。但是,如果方法没有正确格式化并且缩进级别不正确,那么这将是一个真正的噩梦。

如果你遇到任何格式不良的方法代码,那么作为专业程序员,在你做任何其他事情之前,要把代码整理好是你自己的责任。大括号之间的任何代码被称为代码块。代码块内的代码应该缩进一级。代码块内的代码块也应该缩进一级,如下面的例子所示:

public Student Find(List<Student> list, int id) 
{          
Student r = null;foreach (var i in list)          
{             
if (i.Id == id)                   
    r = i;          }          return r;     
}

上面的例子展示了糟糕的缩进和糟糕的循环编程。在这里,你可以看到正在搜索学生列表,以便找到并返回具有指定 ID 的学生,该 ID 作为参数传递。一些程序员感到恼火并降低了应用程序的性能,因为在上面的代码中,即使找到了学生,循环仍在继续。我们可以改进上面的代码的缩进和性能如下:

public Student Find(List<Student> list, int id) 
{          
    Student r = null;
    foreach (var i in list)          
    {             
        if (i.Id == id)                  
        {
            r = i; 
            break;         
        }      
    }
    return r;         
}

在上面的代码中,我们改进了格式,并确保代码正确缩进。我们在for循环中添加了break,以便在找到匹配项时终止foreach循环。

现在不仅代码更易读,而且性能也更好。想象一下,代码正在针对一个校园有 73,000 名学生的大学以及远程学习进行运行。考虑一下,如果学生与 ID 匹配是列表中的第一个,那么如果没有break语句,代码将不得不运行 72,999 次不必要的计算。你可以看到break语句对上面的代码性能有多大的影响。

我们将返回值保留在原始位置,因为编译器可能会抱怨并非所有代码路径都返回一个值。这也是我们添加break语句的原因。很明显,正确的缩进提高了代码的可读性,从而帮助程序员理解代码。这使程序员能够进行任何他们认为必要的更改。

避免重复

代码可以是DRYWET。WET 代码代表每次写,是 DRY 的相反,DRY 代表不要重复自己。WET 代码的问题在于它是bug的完美候选者。假设您的测试团队或客户发现了一个 bug 并向您报告。您修复了 bug 并传递了它,但它会在您的计算机程序中遇到该代码的次数一样多次回来咬您。

现在,我们通过消除重复来 DRY 我们的 WET 代码。我们可以通过提取代码并将其放入方法中,然后以一种可访问所有需要它的计算机程序区域的方式将方法集中起来。

举个例子。假设您有一个费用项目集合,其中包含NameAmount属性。现在,考虑通过Name获取费用项目的十进制Amount

假设您需要这样做 100 次。为此,您可以编写以下代码:

var amount = ViewModel
    .ExpenseLines
    .Where(e => e.Name.Equals("Life Insurance"))
    .FirstOrDefault()
    .Amount;

没有理由您不能写相同的代码 100 次。但有一种方法可以只写一次,从而减少代码库的大小并提高您的生产力。让我们看看我们可以如何做到这一点:

public decimal GetValueByName(string name)
{
    return ViewModel
        .ExpenseLines
        .Where(e => e.Name.Equals(name))
        .FirstOrDefault()
        .Amount;
}

要从ViewModel中的ExpenseLines集合中提取所需的值,您只需将所需值的名称传递给GetValueName(string name)方法,如下面的代码所示:

var amount = GetValueByName("Life Insurance");

那一行代码非常易读,获取值的代码行包含在一个方法中。因此,如果出于任何原因(例如修复 bug)需要更改方法,您只需在一个地方修改代码。

编写良好的函数的下一个逻辑步骤是尽可能少地使用参数。在下一节中,我们将看看为什么我们不应该超过两个参数,以及如何处理参数,即使我们需要更多。

避免多参数

Niladic 方法是 C#中理想的方法类型。这种方法没有参数(也称为参数)。Monadic 方法只有一个参数。Dyadic 方法有两个参数。Triadic 方法有三个参数。具有三个以上参数的方法称为多参数方法。您应该尽量保持参数数量最少(最好少于三个)。

在 C#编程的理想世界中,您应尽力避免三参数和多参数方法。这不是因为它是糟糕的编程,而是因为它使您的代码更易于阅读和理解。具有大量参数的方法可能会给程序员带来视觉压力,并且也可能成为烦恼的根源。随着添加更多参数,IntelliSense 也可能变得难以阅读和理解。

让我们看一个更新用户帐户信息的多参数方法的不良示例:

public void UpdateUserInfo(int id, string username, string firstName, string lastName, string addressLine1, string addressLine2, string addressLine3, string addressLine3, string addressLine4, string city, string postcode, string region, string country, string homePhone, string workPhone, string mobilePhone, string personalEmail, string workEmail, string notes) 
{
    // ### implementation omitted ###
}

UpdateUserInfo方法所示,代码难以阅读。我们如何修改该方法,使其从多参数方法转变为单参数方法?答案很简单 - 我们传入一个UserInfo对象。首先,在修改方法之前,让我们看一下我们的UserInfo类:

public class UserInfo
{
    public int Id { get;set; }
    public string Username { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string AddressLine1 { get; set; }
    public string AddressLine2 { get; set; }
    public string AddressLine3 { get; set; }
    public string AddressLine4 { get; set; }
    public string City { get; set; }
    public string Region { get; set; }
    public string Country { get; set; }
    public string HomePhone { get; set; }
    public string WorkPhone { get; set; }
    public string MobilePhone { get; set; }
    public string PersonalEmail { get; set; }
    public string WorkEmail { get; set; }
    public string Notes { get; set; }
}

现在我们有一个包含所有需要传递给UpdateUserInfo方法的信息的类。UpdateUserInfo方法现在可以从多参数方法转变为单参数方法,如下所示:

public void UpdateUserInfo(UserInfo userInfo)
{
    // ### implementation omitted ###
}

前面的代码看起来好多了吗?它更小,更易读。经验法则应该是少于三个参数,理想情况下是零。如果您的类遵守 SRP,则考虑实现参数对象模式,就像我们在这里所做的那样。

实施 SRP

您编写的所有对象和方法应该最多只有一个职责,而不再有其他。对象可以有多个方法,但这些方法在组合时应该都朝着它们所属的对象的单一目的工作。方法可以调用多个方法,每个方法都做不同的事情。但方法本身应该只做一件事。

一个了解和做得太多的方法被称为上帝方法。同样,一个了解和做得太多的对象被称为上帝对象。上帝对象和方法很难阅读、维护和调试。这样的对象和方法通常会多次重复相同的错误。擅长编程技艺的人会避免上帝对象和上帝方法。让我们看一个做了不止一件事的方法:

public void SrpBrokenMethod(string folder, string filename, string text, emailFrom, password, emailTo, subject, message, mediaType)
{
    var file = $"{folder}{filename}";
    File.WriteAllText(file, text);
    MailMessage message = new MailMessage();  
    SmtpClient smtp = new SmtpClient();  
    message.From = new MailAddress(emailFrom);  
    message.To.Add(new MailAddress(emailTo));  
    message.Subject = subject;  
    message.IsBodyHtml = true;  
    message.Body = message;  
    Attachment emailAttachment = new Attachment(file); 
    emailAttachment.ContentDisposition.Inline = false; 
    emailAttachment.ContentDisposition.DispositionType =        
        DispositionTypeNames.Attachment; 
    emailAttachment.ContentType.MediaType = mediaType;  
    emailAttachment.ContentType.Name = Path.GetFileName(filename); 
    message.Attachments.Add(emailAttachment);
    smtp.Port = 587;  
    smtp.Host = "smtp.gmail.com";
    smtp.EnableSsl = true;  
    smtp.UseDefaultCredentials = false;  
    smtp.Credentials = new NetworkCredential(emailFrom, password);  
    smtp.DeliveryMethod = SmtpDeliveryMethod.Network;  
    smtp.Send(message);
}

SrpBrokenMethod显然做了不止一件事,因此它违反了 SRP。我们现在将这个方法分解为多个只做一件事的较小方法。我们还将解决该方法的多参数性质的问题。

在我们开始将方法分解为只做一件事的较小方法之前,我们需要查看方法执行的所有操作。该方法首先将文本写入文件。然后创建电子邮件消息,分配附件,最后发送电子邮件。因此,我们需要以下方法:

  • 将文本写入文件

  • 创建电子邮件消息

  • 添加电子邮件附件

  • 发送电子邮件

查看当前方法,我们有四个参数传递给它来写入文本到文件:一个用于文件夹,一个用于文件名,一个用于文本,一个用于媒体类型。文件夹和文件名可以合并为一个名为filename的单个参数。如果filenamefolder是在调用代码中分开使用的两个变量,则可以将它们作为单个插值字符串传递到方法中,例如$"{folder}{filename}"

至于媒体类型,这可以在构造时私下设置在一个结构体内。我们可以使用该结构体来设置我们需要的属性,以便我们可以将该结构体作为单个参数传递进去。让我们看一下实现这一点的代码:

    public struct TextFileData
    {
        public string FileName { get; private set; }
        public string Text { get; private set; }
        public MimeType MimeType { get; }        

        public TextFileData(string filename, string text)
        {
            Text = text;
            MimeType = MimeType.TextPlain;
            FileName = $"{filename}-{GetFileTimestamp()}";
        }

        public void SaveTextFile()
        {
            File.WriteAllText(FileName, Text);
        }

        private static string GetFileTimestamp()
        {
            var year = DateTime.Now.Year;
            var month = DateTime.Now.Month;
            var day = DateTime.Now.Day;
            var hour = DateTime.Now.Hour;
            var minutes = DateTime.Now.Minute;
            var seconds = DateTime.Now.Second;
            var milliseconds = DateTime.Now.Millisecond;
            return $"{year}{month}{day}@{hour}{minutes}{seconds}{milliseconds}";
        }
    }

TextFileData构造函数通过调用GetFileTimestamp()方法并将其附加到FileName的末尾来确保FileName的值是唯一的。要保存文本文件,我们调用SaveTextFile()方法。请注意,MimeType在内部设置为MimeType.TextPlain。我们本可以简单地将MimeType硬编码为MimeType = "text/plain";,但使用enum的优势在于代码是可重用的,而且您不必记住特定MimeType的文本或在互联网上查找它的好处。现在,我们将编写enum并为enum值添加描述:

[Flags]
public enum MimeType
{
    [Description("text/plain")]
    TextPlain
}

好吧,我们有了我们的enum,但现在我们需要一种方法来提取描述,以便可以轻松地分配给一个变量。因此,我们将创建一个扩展类,它将使我们能够获取enum的描述。这使我们能够设置MimeType,如下所示:

MimeType = MimeType.TextPlain;

没有扩展方法,MimeType的值将为0。但是通过扩展方法,MimeType的值为"text/plain"。现在您可以在其他项目中重用这个扩展,并根据需要构建它。

我们将编写的下一个类是Smtp类,其职责是通过Smtp协议发送电子邮件:

    public class Smtp
    {
        private readonly SmtpClient _smtp;

        public Smtp(Credential credential)
        {
            _smtp = new SmtpClient
            {
                Port = 587,
                Host = "smtp.gmail.com",
                EnableSsl = true,
                UseDefaultCredentials = false,
                Credentials = new NetworkCredential(
                 credential.EmailAddress, credential.Password),
                DeliveryMethod = SmtpDeliveryMethod.Network
            };
        }

        public void SendMessage(MailMessage mailMessage)
        {
            _smtp.Send(mailMessage);
        }
    }

Smtp类有一个构造函数,它接受一个Credential类型的参数。这个凭据用于登录到电子邮件服务器。服务器在构造函数中配置。当调用SendMessage(MailMessage mailMessage)方法时,消息被发送。

让我们编写一个DemoWorker类,将工作分成不同的方法:

    public class DemoWorker
    {
        TextFileData _textFileData;

        public void DoWork()        
        {
            SaveTextFile();
            SendEmail();
        }

        public void SendEmail()
        {
            Smtp smtp = new Smtp(new Credential("fakegmail@gmail.com", 
             "fakeP@55w0rd"));
            smtp.SendMessage(GetMailMessage());
        }

        private MailMessage GetMailMessage()
        {
            var msg = new MailMessage();
            msg.From = new MailAddress("fakegmail@gmail.com");
            msg.To.Add(new MailAddress("fakehotmail@hotmail.com"));
            msg.Subject = "Some subject";
            msg.IsBodyHtml = true;
            msg.Body = "Hello World!";
            msg.Attachments.Add(GetAttachment());
            return msg;
        }

        private Attachment GetAttachment()
        {
            var attachment = new Attachment(_textFileData.FileName);
            attachment.ContentDisposition.Inline = false;
            attachment.ContentDisposition.DispositionType = 
             DispositionTypeNames.Attachment;
            attachment.ContentType.MediaType = 
             MimeType.TextPlain.Description();
            attachment.ContentType.Name = 
             Path.GetFileName(_textFileData.FileName);
            return attachment;
        }

        private void SaveTextFile()
        {
            _textFileData = new TextFileData(
                $"{Environment.SpecialFolder.MyDocuments}attachment", 
                "Here is some demo text!"
            );
            _textFileData.SaveTextFile();
        }
    }

DemoWorker类展示了发送电子邮件消息的更清晰版本。负责保存附件并通过电子邮件作为附件发送的主要方法称为DoWork()。这个方法只包含两行代码。第一行调用SaveTextFile()方法,而第二行调用SendEmail()方法。

SaveTextFile()方法创建一个新的TextFileData结构,并传入文件名和一些文本。然后调用TextFileData结构中的SaveTextFile()方法,负责将文本保存到指定的文件中。

SendEmail()方法创建一个新的Smtp类。Smtp类有一个Credential参数,而Credential类有两个字符串参数用于电子邮件地址和密码。电子邮件和密码用于登录 SMTP 服务器。一旦 SMTP 服务器被创建,就会调用SendMessage(MailMessage mailMessage)方法。

这个方法需要传入一个MailMessage对象。因此,我们有一个名为GetMailMethod()的方法,它构建一个MailMessage对象,然后将其传递给SendMessage(MailMessage mailMessage)方法。GetMailMethod()通过调用GetAttachment()方法向MailMessage添加附件。

从这些修改中可以看出,我们的代码现在更加简洁和易读。这是良好质量的代码的关键,它必须易于阅读和理解。这就是为什么你的方法应该尽可能小而干净,参数尽可能少的原因。

你的方法是否违反了 SRP?如果是,你应该考虑将方法分解为尽可能多的方法来承担责任。这就结束了关于编写清晰函数的章节。现在是时候总结你所学到的知识并测试你的知识了。

总结

在本章中,您已经看到函数式编程如何通过不修改状态来提高代码的安全性,这可能会导致错误,特别是在多线程应用程序中。通过保持方法小而有意义的名称,以及不超过两个参数,您已经看到您的代码有多么清晰和易于阅读。您还看到了我们如何消除代码中的重复部分以及这样做的好处。易于阅读的代码比难以阅读和解释的代码更容易维护和扩展!

我们现在将继续并看一下异常处理的主题。在下一章中,您将学习如何适当地使用异常处理,编写自己的自定义 C#异常以提供有意义的信息,并编写避免引发NullPointerExceptions的代码。

问题

  1. 你如何称呼一个没有参数的方法?

  2. 你如何称呼一个有一个参数的方法?

  3. 你如何称呼一个有两个参数的方法?

  4. 你如何称呼一个有三个参数的方法?

  5. 你如何称呼一个有超过三个参数的方法?

  6. 应该避免哪两种方法类型,为什么?

  7. 用通俗的语言来说,什么是函数式编程?

  8. 函数式编程有哪些优点?

  9. 函数式编程的一个缺点是什么?

  10. 什么是 WET 代码,为什么应该避免?

  11. 什么是 DRY 代码,为什么应该使用它?

  12. 你如何去除 WET 代码中的重复部分?

  13. 为什么方法应该尽可能小?

  14. 如何在不实现try/catch块的情况下实现验证?

进一步阅读

以下是一些额外资源,让您可以深入了解 C#函数式编程的领域:

第五章:异常处理

在上一章中,我们看了函数。尽管程序员尽力编写健壮的代码,但函数最终会产生异常。这可能是由于许多原因,例如缺少文件或文件夹,空值或空值,无法写入位置,或者用户被拒绝访问。因此,在本章中,您将学习使用异常处理产生清晰的 C#代码的适当方法。首先,我们将从算术OverflowExceptions的检查和未经检查的异常开始。我们将看看它们是什么,为什么使用它们,以及它们在代码中的一些示例。

然后,我们将看看如何避免NullPointerReference异常。之后,我们将研究为特定类型的异常实现特定业务规则。在对异常和异常业务规则有了新的理解之后,我们将开始构建自己的自定义异常,然后最后看看为什么我们不应该使用异常来控制计算机程序的流程。

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

  • 检查和未经检查的异常

  • 避免NullPointerExceptions

  • 业务规则异常

  • 异常应提供有意义的信息

  • 构建自定义异常

在本章结束时,您将具备以下技能:

  • 您将能够理解 C#中的检查和未经检查的异常,以及它们的原因。

  • 您将能够理解什么是OverflowException以及如何在编译时捕获它们。

  • 您将了解什么是NullPointerExceptions以及如何避免它们。

  • 您将能够编写自己的自定义异常,为客户提供有意义的信息,并帮助您和其他程序员轻松识别和解决引发的任何问题。

  • 您将能够理解为什么不应该使用异常来控制程序流程。

  • 您将知道如何使用 C#语句和布尔检查来替换业务规则异常,以控制程序流程。

检查和未经检查的异常

在未经检查的模式下,算术溢出会被忽略。在这种情况下,无法分配给目标类型的高阶位将从结果中丢弃。

默认情况下,C#在运行时执行非常量表达式时处于未经检查的上下文中。但是编译时常量表达式始终默认进行检查。在检查模式下遇到算术溢出时,会引发OverflowException。未经检查异常被使用的一个原因是为了提高性能。检查异常可能会稍微降低方法的性能。

经验法则是确保在检查上下文中执行算术运算。任何算术溢出异常都将被视为编译时错误,然后您可以在发布代码之前修复它们。这比发布代码然后不得不修复客户运行时错误要好得多。

在未经检查的模式下运行代码是危险的,因为您对代码进行了假设。假设并非事实,它们可能导致在运行时引发异常。运行时异常会导致客户满意度降低,并可能产生严重的后续异常,以某种方式对客户产生负面影响。

允许应用程序继续运行,即使发生了溢出异常,从商业角度来看是非常危险的。原因在于数据可能会处于不可逆转的无效状态。如果数据是关键的客户数据,那么这对企业来说可能会非常昂贵,你不希望承担这样的责任。

考虑以下代码。这段代码演示了在客户银行业务中未经检查的溢出有多糟糕:

private static void UncheckedBankAccountException()
{
    var currentBalance = int.MaxValue;
    Console.WriteLine($"Current Balance: {currentBalance}");
    currentBalance = unchecked(currentBalance + 1);
    Console.WriteLine($"Current Balance + 1 = {currentBalance}");
    Console.ReadKey();
}

想象一下,当客户看到将 1 英镑加到他们的银行余额 2,147,483,647 英镑时,他们的脸上会有多么恐慌!

现在,是时候用一些代码示例演示检查和未检查异常了。首先,启动一个新的控制台应用程序并声明一些变量:

static byte y, z;

前面的代码声明了两个字节,我们将在算术代码示例中使用。现在,添加CheckedAdd()方法。如果在添加两个数字时遇到算术溢出导致的结果太大无法存储为字节,此方法将引发一个检查过的OverflowException

private static void CheckedAdd()
{
    try
    {
        Console.WriteLine("### Checked Add ###");
        Console.WriteLine($"x = {y} + {z}");
        Console.WriteLine($"x = {checked((byte)(y + z))}");
    }
    catch (OverflowException oex)
    {
        Console.WriteLine($"CheckedAdd: {oex.Message}");
    }
}

然后,编写CheckedMultiplication()方法。如果在乘法过程中检测到算术溢出,导致的数字大于一个字节,将引发检查过的OverflowException

private static void CheckedMultiplication()
{
    try
    {
        Console.WriteLine("### Checked Multiplication ###");
        Console.WriteLine($"x = {y} x {z}");
        Console.WriteLine($"x = {checked((byte)(y * z))}");
    }
    catch (OverflowException oex)
    {
        Console.WriteLine($"CheckedMultiplication: {oex.Message}");
    }
}

接下来,添加UncheckedAdd()方法。此方法将忽略由于加法而发生的任何溢出,因此不会引发OverflowException。溢出的结果将存储为一个字节,但值将是不正确的:

private static void UncheckedAdd()
{
    try
    {
         Console.WriteLine("### Unchecked Add ###");
         Console.WriteLine($"x = {y} + {z}");
         Console.WriteLine($"x = {unchecked((byte)(y + z))}");
    }
    catch (OverflowException oex)
    {
         Console.WriteLine($"CheckedAdd: {oex.Message}");
    }
}

现在,我们添加UncheckedMultiplication()方法。当遇到溢出时,此方法不会抛出OverflowException。异常将被简单地忽略。这将导致一个不正确的数字被存储为字节:

private static void UncheckedMultiplication()
{
    try
    {
         Console.WriteLine("### Unchecked Multiplication ###");
         Console.WriteLine($"x = {y} x {z}");
         Console.WriteLine($"x = {unchecked((byte)(y * z))}");
    }
    catch (OverflowException oex)
    {
        Console.WriteLine($"CheckedMultiplication: {oex.Message}");
    }
}

最后,是时候修改我们的Main(string[] args)方法,以便我们可以初始化变量并执行方法。在这里,我们将最大值添加到y变量和2添加到z变量。然后,我们运行CheckedAdd()CheckedMultiplication()方法,这两个方法都会生成OverflowException()。这是因为y变量包含了一个字节的最大值。

因此,通过添加或乘以2,您超出了存储变量所需的地址空间。接下来,我们将运行UncheckedAdd()UncheckedMultiplication()方法。这两种方法都忽略溢出异常,将结果分配给x变量,并忽略任何溢出的位。最后,我们在用户按下任意键时打印一条消息,然后退出:

static void Main(string[] args)
{
    y = byte.MaxValue;
    z = 2;
    CheckedAdd();
    CheckedMultiplication();
    UncheckedAdd();
    UncheckedMultiplication();
    Console.WriteLine("Press any key to exit.");
    Console.ReadLine();
}

当我们运行前面的代码时,我们得到以下输出:

如您所见,当我们使用检查异常时,当遇到OverflowException时会引发异常。但当我们使用未检查异常时,不会引发异常。

从前面的截图可以看出,意外值可能导致问题,并且使用未检查异常可能导致某些行为。因此,在执行算术运算时的经验法则必须始终使用检查异常。

现在,让我们继续看一个程序员经常遇到的非常常见的异常,称为NullPointerException

避免 NullPointerExceptions

NullReferenceException是大多数程序员经历过的常见异常。当尝试访问null对象的属性或方法时,会引发此异常。

为了防止计算机程序崩溃,程序员们常用的做法是使用try{...}catch (NullReferenceExceptionre){...}块。这是防御性编程的一部分。但问题是,很多时候错误只是记录重新抛出。此外,还进行了很多不必要的计算。

处理ArgumentNullExceptions的一个更好的方法是实现ArgumentNullValidator。方法的参数通常是null对象的来源。在使用参数之前测试方法的参数并且如果发现它们因任何原因无效,则抛出适当的Exception是有意义的。在ArgumentNullValidator的情况下,您将把此验证器放在方法的顶部,然后测试每个参数。如果发现任何参数为null,则会抛出NullReferenceException。这将节省计算并消除了将方法代码包装在try...catch块中的需要。

为了明确事物,我们将编写ArgumentNullValidator并在一个方法中使用它来测试方法的参数:

public class Person
{
    public string Name { get; }
    public Person(string name)
    {
         Name = name;
    }
}

在上面的代码中,我们创建了一个名为Name的只读属性的Person类。这将是我们将用于传递到示例方法中以引发NullReferenceException的对象。接下来,我们将为验证器创建我们的Attribute,称为ValidatedNotNullAttribibute

[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
internal sealed class ValidatedNotNullAttribute : Attribute { }

现在我们有了我们的Attribute,是时候编写验证器了:

internal static class ArgumentNullValidator
{
    public static void NotNull(string name, 
     [ValidatedNotNull] object value)
    {
        if (value == null)
        {
            throw new ArgumentNullException(name);
        }
    }
}

ArgumentNullValidator接受两个参数:

  • 对象的名称

  • 对象本身

检查对象是否为null。如果是null,则抛出ArgumentNullException,并传入对象的名称。

以下方法是我们的try/catch示例方法。请注意,我们记录了一条消息并抛出了异常。然而,我们没有使用声明的异常参数,因此按理说应该将其删除。您会经常在代码中看到这种情况。这是不必要的,应该删除以整理代码:

private void TryCatchExample(Person person)
{
    try
    {
        Console.WriteLine($"Person's Name: {person.Name}");
    }
    catch (NullReferenceException nre)
    {
        Console.WriteLine("Error: The person argument cannot be null.");
        throw;
    }
}

接下来,我们将编写一个将使用ArgumentNullValidator的示例方法。我们将其称为ArgumentNullValidatorExample

private void ArgumentNullValidatorExample(Person person)
{
    ArgumentNullValidator.NotNull("Person", person);
    Console.WriteLine($"Person's Name: {person.Name}");
    Console.ReadKey();
}

请注意,我们已经从包括大括号在内的九行代码减少到了只有两行。我们也不会在验证之前尝试使用该值。现在我们需要做的就是修改我们的Main方法来运行这些方法。通过注释掉其中一个方法并运行程序来测试每个方法。这样做时,最好逐步执行代码以查看发生了什么。

以下是运行TryCatchExample方法的输出:

以下是运行ArgumentNullValidatorExample的输出:

如果您仔细研究前面的屏幕截图,您会发现在使用ArgumentNullValidatorExample时我们只记录了一次错误。当使用TryCatchExample抛出异常时,异常被记录了两次。

第一次,我们有一个有意义的消息,但第二次,消息是神秘的。然而,由调用方法Main记录的异常并不神秘。事实上,它非常有帮助,因为它向我们显示了Person参数的值不能为null

希望这一部分向您展示了在使用构造函数和方法之前检查参数的价值。通过这样做,您可以看到参数验证器如何减少您的代码,从而使其更易读。

现在,我们将看看如何为特定异常实现业务规则。

业务规则异常

技术异常是由计算机程序由于程序员的错误和/或环境问题(例如磁盘空间不足)而抛出的异常。

但是业务规则异常是不同的。业务规则异常意味着这种行为是预期的,并且用于控制程序流程,而实际上,异常应该是程序的正常流程的例外,而不是方法的预期输出。

例如,想象一个在 ATM 机上从账户中取出 100 英镑的人,账户里没有钱,也没有透支的能力。ATM 接受用户的 100 英镑取款请求,因此发出Withdraw(100);命令。Withdraw方法检查余额,发现账户资金不足,因此抛出InsufficientFundsException()

您可能认为拥有这样的异常是一个好主意,因为它们是明确的,并有助于识别问题,以便在收到这样的异常时执行非常具体的操作——但不是!这不是一个好主意。

在这种情况下,当用户提交请求时,应检查所请求的金额是否可以取款。如果可以,那么交易应该继续进行,如用户所请求的那样。但是,如果验证检查确定无法继续进行交易,那么程序应该按照正常的程序流程取消交易,并通知发出请求的用户,而不引发异常。

我们刚刚看到的取款情景表明,程序员已经正确考虑了程序的正常流程和不同的结果。程序流程已经适当地使用布尔检查编码,以允许成功的取款交易并防止不允许的取款交易。

让我们看看如何使用业务规则异常BREs)来实现不允许透支的银行账户的取款。然后,我们将看看如何实现相同的场景,但是使用正常的程序流程而不是使用 BREs。

启动一个新的控制台应用程序,并添加两个名为BankAccountUsingExceptionsBankAccountUsingProgramFlow的文件夹。使用以下代码更新您的void Main(string[] args)方法:

private static void Main(string[] args)
{
    var usingBrExceptions = new UsingBusinessRuleExceptions();
    usingBrExceptions.Run();
    var usingPflow = new UsingProgramFlow();
    usingPflow.Run();
}

前面的代码运行每个情景。UsingBusinessRuleExceptions()演示了异常作为控制程序流程的预期输出的使用,而UsingProgramFlow()演示了在不使用异常条件的情况下控制程序流程的干净方式。

现在我们需要一个类来保存我们的活期账户信息。因此,在您的 Visual Studio 控制台项目中添加一个名为CurrentAccount的类,如下所示:

internal class CurrentAccount
{
    public long CustomerId { get; }
    public decimal AgreedOverdraft { get; }
    public bool IsAllowedToGoOverdrawn { get; }
    public decimal CurrentBalance { get; }
    public decimal AvailableBalance { get; private set; }
    public int AtmDailyLimit { get; }
    public int AtmWithdrawalAmountToday { get; private set; }
}

该类的属性只能通过构造函数内部或外部设置。现在,添加一个以客户标识符作为唯一参数的构造函数:

public CurrentAccount(long customerId)
{
    CustomerId = customerId;
    AgreedOverdraft = GetAgreedOverdraftLimit();
    IsAllowedToGoOverdrawn = GetIsAllowedToGoOverdrawn();
    CurrentBalance = GetCurrentBalance();
    AvailableBalance = GetAvailableBalance();
    AtmDailyLimit = GetAtmDailyLimit();
    AtmWithdrawalAmountToday = 0;
}

当前账户构造函数初始化所有属性。如前面的代码所示,一些属性是使用方法初始化的。让我们依次实现每个方法:

private static decimal GetAgreedOverdraftLimit()
{
    return 0;
}

GetAgreedOverdraftLimit()返回账户上约定的透支限额的值。在本例中,它被硬编码为零。但在实际情况中,它将从配置文件或其他数据存储中提取实际数字。这将允许非技术用户更新约定的透支限额,而无需开发人员更改代码。

GetIsAllowedToGoOverdrawn()确定账户是否可以透支,即使没有经过同意,有些银行是允许的。在这种情况下,我们只需返回false来确定账户无法透支:

private static bool GetIsAllowedToGoOverdrawn()
{
    return false;
}

为了本例的目的,我们将在GetCurrentBalance()方法中将用户的账户余额设置为 250 英镑:

private static decimal GetCurrentBalance()
{
    return 250.00M;
}

作为我们示例的一部分,我们需要确保即使用户的账户余额为 250 英镑,但其可用余额小于该金额,他们也无法取出超过可用余额的金额,因为这将导致透支。为此,我们将在GetAvailableBalance()方法中将可用余额设置为 173.64 英镑:

private static decimal GetAvailableBalance()
{
    return 173.64M;
}

在英国,ATM 机要么允许您最多取款 200 英镑,要么允许您最多取款 250 英镑。因此,在GetAtmDailyLimit()方法中,我们将将 ATM 每日限额设置为 250 英镑:

private static int GetAtmDailyLimit()
{
    return 250;
}

让我们通过使用业务规则异常和正常程序流程来处理程序中的不同条件,编写我们两种情景的代码。

示例 1 - 使用业务规则异常处理条件

向项目添加一个名为 UsingBusinessRuleExceptions 的新类,然后添加以下 Run() 方法:

public class UsingBusinessRuleExceptions
{
    public void Run()
    {
        ExceedAtmDailyLimit();
        ExceedAvailableBalance();
    }
}

Run() 方法调用两个方法:

  • 第一个方法称为 ExceedAtmDailyLimit()。该方法故意超出了允许从 ATM 提取的每日金额。ExceedAtmDailyLimit() 导致 ExceededAtmDailyLimitException

  • 其次,调用 ExceedAvailableBalance() 方法,该方法故意引发 InsufficientFundsException。添加 ExceedAtmDailyLimit() 方法:

private void ExceedAtmDailyLimit()
{
     try
     {
            var customerAccount = new CurrentAccount(1);
            customerAccount.Withdraw(300);
            Console.WriteLine("Request accepted. Take cash and card.");
      }
      catch (ExceededAtmDailyLimitException eadlex)
      {
            Console.WriteLine(eadlex.Message);
      }
}

ExceedAtmDailyLimit() 方法创建一个新的 CustomerAccount 方法,并传入客户的标识符,表示为数字 1。然后,尝试提取 £300。如果请求成功,那么将在控制台窗口打印消息 Request accepted. Take cash and card.。如果请求失败,那么该方法会捕获 ExceededAtmLimitException 并将异常消息打印到控制台窗口:

private void ExceedAvailableBalance()
{
    try
    {
        var customerAccount = new CurrentAccount(1);
        customerAccount.Withdraw(180);
        Console.WriteLine("Request accepted. Take cash and card.");
    }
    catch (InsufficientFundsException ifex)
    {
        Console.WriteLine(ifex.Message);
    }
}

ExceedAvailableBalance() 方法创建一个新的 CurrentAccount 并传入客户标识符,表示为数字 1。然后尝试提取 £180。由于 GetAvailableMethod() 返回 £173.64,该方法导致 InsufficientFundsException

通过这样,我们已经看到了如何使用业务规则异常来管理不同的条件。现在,让我们看看如何以正常的程序流程管理相同的条件,而不使用异常。

示例 2 - 使用正常程序流程处理条件

添加一个名为 UsingProgramFlow 的类,然后向其中添加以下代码:

public class UsingProgramFlow
{
    private int _requestedAmount;
    private readonly CurrentAccount _currentAccount;

    public UsingProgramFlow()
    {
        _currentAccount = new CurrentAccount(1);
    }
}

UsingProgramFlow 类的构造函数中,我们将创建一个新的 CurrentAccount 类并传入客户标识符。接下来,我们将添加 Run() 方法:

public void Run()
{
    _requestedAmount = 300;
    Console.WriteLine($"Request: Withdraw {_requestedAmount}");
    WithdrawMoney();
    _requestedAmount = 180;
    Console.WriteLine($"Request: Withdraw {_requestedAmount}");
    WithdrawMoney();
    _requestedAmount = 20;
    Console.WriteLine($"Request: Withdraw {_requestedAmount}");
    WithdrawMoney();
}

Run() 方法三次设置 _requestedAmount 变量。每次这样做时,在调用 WithdrawMoney() 方法之前,将在控制台窗口上打印提取的金额的消息。现在,添加 ExceedsDailyLimit() 方法:

private bool ExceedsDailyLimit()
{
    return (_requestedAmount > _currentAccount.AtmDailyLimit)
        || (_requestedAmount + _currentAccount.AtmWithdrawalAmountToday > _currentAccount.AtmDailyLimit);
}

ExceedDailyLimit() 方法如果 _requestedAmount 超过每日 ATM 提款限额,则返回 true。否则,返回 false。现在,添加 ExceedsAvailableBalance() 方法:

private bool ExceedsAvailableBalance()
{
    return _requestedAmount > _currentAccount.AvailableBalance;
}

ExceedsAvailableBalance() 方法如果请求的金额超过了可提取的金额,则返回 true。最后,我们来到最后一个方法,称为 WithdrawMoney()

private void WithdrawMoney()
{
    if (ExceedsDailyLimit())
        Console.WriteLine("Cannot exceed ATM Daily Limit. Request denied.");
    else if (ExceedsAvailableBalance())
        Console.WriteLine("Cannot exceed available balance. You have no agreed 
         overdraft facility. Request denied.");
    else
        Console.WriteLine("Request granted. Take card and cash.");
}

WithdrawMoney() 方法不使用 BREs 来控制程序流程。相反,该方法调用布尔验证方法来确定程序流程。如果 _requestedAmount 超过了由调用 ExceedsDailyLimit() 确定的 ATM 每日限额,则请求被拒绝。否则,将进行下一个检查,以查看 _requestedAmount 是否超过了 AvailableBalance。如果是,则拒绝请求。如果不是,则执行授予请求的代码。

我希望您能看到,使用可用逻辑控制程序的流程比期望抛出异常更有意义。代码更清晰,更正确。异常应该保留给不属于业务需求的特殊情况。

当正确引发适当的异常时,对它们提供有意义的信息非常重要。晦涩的错误消息对任何人都没有好处,实际上可能会给最终用户或开发人员增加不必要的压力。现在,我们将看看如何在计算机程序引发的任何异常中提供有意义的信息。

异常应该提供有意义的信息

声明“没有错误”并终止程序的关键错误根本没有用。我亲身经历过实际的“没有错误”关键异常。这是一个阻止应用程序工作的关键异常。然而,消息告诉我们没有错误。好吧,如果没有错误,那么为什么屏幕上会出现关键异常警告?为什么我无法继续使用应用程序?显然,要引发关键异常,必须在某个地方发生了关键异常。但是在哪里,为什么?

当这些异常深植于你正在使用的框架或库中(你无法控制),并且你无法访问源代码时,这样的异常会变得更加恼人。这些异常导致程序员因沮丧而说出负面的话。我曾经有过这样的经历,也见过同事有同样的情况。沮丧的主要原因之一是代码引发了错误,用户或程序员已经被通知,但没有有用的信息来建议问题所在或查找位置,甚至采取什么补救措施。

异常必须提供对技术挑战者尤其友好的信息。在开发阅读障碍测试和评估软件的时候,我和许多教师和 IT 技术人员一起工作过。

可以说,许多各种能力水平的 IT 技术人员和教师在回应软件异常消息时经常一无所知。

我支持的软件的许多最终用户一直困惑的一个错误是错误 76:路径未找到。这是一个古老的微软异常,早在 Windows 95 时代就存在,今天仍然存在。对于引发此异常的软件的最终用户来说,错误消息是完全无用的。最终用户知道哪个文件和位置找不到,并知道应采取什么步骤来解决问题将是有用的。

一个潜在的解决方案是实施以下步骤:

  1. 检查位置是否存在。

  2. 如果位置不存在或访问被拒绝,则根据需要显示文件保存或打开对话框。

  3. 将用户选择的位置保存到配置文件以供将来使用。

  4. 在同一段代码的后续运行中,使用用户设置的位置。

但是,如果你要保留错误消息,那么你至少应该提供缺失的位置和/或文件的名称。

有了这些说法,现在是时候看看我们如何构建自己的异常,以提供对最终用户和程序员有用的信息了。但请注意:你必须小心,不要透露敏感信息或数据。

构建自定义异常

Microsoft .NET Framework 已经有许多可以引发的异常,你可以捕获。但可能会有一些情况,你需要一个提供更详细信息或在术语上更加用户友好的自定义异常。

因此,我们现在将看看构建自定义异常的要求是什么。构建自定义异常其实非常简单。你只需要给你的类一个以Exception结尾的名称,并继承自System.Exception。然后,你需要添加三个构造函数,如下面的代码示例所示:

    public class TickerListNotFoundException : Exception
    {
        public TickerListNotFoundException() : base()
        {
        }

        public TickerListNotFoundException(string message)
            : base(message)
        {
        }

        public TickerListNotFoundException(
            string message, 
            Exception innerException
        )
            : base(message, innerException)
        {
        }
    }

TickerListNotFoundException继承自System.Exception类。它包含三个必需的构造函数:

  • 一个默认构造函数

  • 一个接受异常消息文本字符串的构造函数

  • 一个接受异常消息文本字符串和Exception对象的构造函数

现在,我们将编写并执行三种方法,这些方法将使用我们自定义异常的每个构造函数。您将能够清楚地看到使用自定义异常来创建更有意义的异常的好处:

static void Main(string[] args)
{
    ThrowCustomExceptionA();
    ThrowCustomExceptionB();
    ThrowCustomExceptionC();
}

上述代码显示了我们更新的Main(string[] args)方法,该方法已更新以依次执行我们的三种方法。这将测试每个自定义异常的构造函数:

private static void ThrowCustomExceptionA()
{
    try
    {
        Console.WriteLine("throw new TickerListNotFoundException();");
        throw new TickerListNotFoundException();
    }
    catch (Exception tlnfex)
    {
        Console.WriteLine(tlnfex.Message);
    }
}

ThrowCustomExceptionA()方法通过使用默认构造函数抛出一个新的TickerListNotFoundException。当您运行代码时,打印到控制台窗口的消息会通知用户已抛出CH05_CustomExceptions.TickerListNotFoundException

private static void ThrowCustomExceptionB()
{
    try
    {
        Console.WriteLine("throw new 
         TickerListNotFoundException(Message);");
        throw new TickerListNotFoundException("Ticker list not found.");
    }
    catch (Exception tlnfex)
    {
        Console.WriteLine(tlnfex.Message);
    }
}

ThrowCustomExceptionB()通过使用接受文本消息的构造函数抛出一个新的TickerListNotFoundException。在这种情况下,最终用户被告知找不到股票列表:

private static void ThrowCustomExceptionC()
{
    try
    {
        Console.WriteLine("throw new TickerListNotFoundException(Message, 
         InnerException);");
        throw new TickerListNotFoundException(
            "Ticker list not found for this exchange.",
            new FileNotFoundException(
                "Ticker list file not found.",
                @"F:\TickerFiles\LSE\AimTickerList.json"
            )
        );
    }
    catch (Exception tlnfex)
    {
        Console.WriteLine($"{tlnfex.Message}\n{tlnfex.InnerException}");
    }
}

最后,ThrowCustomExceptionC()方法通过使用接受文本消息和内部异常的构造函数抛出TickerListNotFoundException。在我们的示例中,我们提供了一个有意义的消息,说明在该交易所找不到股票列表。内部的FileNotFoundException通过提供未找到的特定文件的名称来扩展这一点,这恰好是伦敦证券交易所LSE)上的 Aim 公司的股票列表。

在这里,我们可以看到创建自定义异常的真正优势。但在大多数情况下,使用.NET Framework 中的内在异常应该就足够了。自定义异常的主要好处是它们是更有意义的异常,有助于调试和解决问题。

以下是 C#异常处理最佳实践的简要列表:

  • 使用 try/catch/finally 块来从错误中恢复或释放资源。

  • 处理常见条件而不抛出异常。

  • 设计类以避免异常。

  • 抛出异常而不是返回错误代码。

  • 使用预定义的.NET 异常类型。

  • 异常类的名称以单词Exception结尾。

  • 在自定义异常类中包含三个构造函数。

  • 确保在代码远程执行时可用异常数据。

  • 使用语法正确的错误消息。

  • 在每个异常中包含本地化的字符串消息。

  • 在自定义异常中根据需要提供额外的属性。

  • 放置 throw 语句,以便堆栈跟踪将有所帮助。

  • 使用异常生成器方法。

  • 当方法由于异常而无法完成时,恢复状态。

现在,是时候总结我们在异常处理方面学到的内容了。

总结

在本章中,您了解了已检查异常和未检查异常。已检查异常可以防止算术溢出条件进入任何生产代码,因为它们在编译时被捕获。未检查异常在编译时不被检查,通常会进入生产代码。这可能导致一些难以跟踪的错误在您的代码中通过意外数据值并最终导致抛出异常,导致程序崩溃。

然后,您了解了常见的NullPointerException以及如何使用自定义AttributeValidator类来验证传入的参数,这些类放置在方法的顶部。这使您能够在验证失败时提供有意义的反馈。从长远来看,这将导致更健壮的程序。

然后,我们讨论了使用BREs来控制程序流程。您将学习如何通过期望异常输出来控制程序流程。然后,您将看到如何通过使用条件检查而不是使用异常来更好地控制计算机代码的流程。

讨论随后转向提供有意义的异常消息的重要性以及如何实现这一点;也就是说,通过编写继承自Exception类并实现所需的三个参数的自定义异常。通过提供的示例,你学会了如何使用自定义异常以及它们如何帮助更好地调试和解决问题。

所以,现在是时候通过回答一些问题来检验你所学到的知识了。如果你希望扩展本章学到的知识,还有进一步的阅读材料。

在下一章中,我们将学习单元测试以及如何先编写测试使其失败。然后,我们将编写足够的代码使测试通过,并在继续进行下一个单元测试之前对工作代码进行重构。

问题

  1. 什么是已检查异常?

  2. 什么是未检查异常?

  3. 算术溢出异常是什么?

  4. 什么是NullPointerException

  5. 你如何验证空参数以改进你的整体代码?

  6. BRE 代表什么?

  7. BRE 是好还是坏的实践,你为什么这样认为?

  8. BRE 的替代方案是什么,它是好还是坏,你为什么这样认为?

  9. 你如何提供有意义的异常消息?

  10. 编写自定义异常的要求是什么?

进一步阅读

第六章:单元测试

之前,我们讨论了异常处理,如何正确实施以及在问题发生时对客户和程序员有何用处。在本章中,我们将看看程序员如何实施他们自己的质量保证(QA),以提供健壮的、不太可能在生产中产生异常的优质代码。

我们首先看看为什么应该测试我们自己的代码,以及什么样的测试才算是好测试。然后,我们看看 C#程序员可以使用的几种测试工具。然后,我们转向单元测试的三大支柱:失败、通过和重构。最后,我们看看多余的单元测试以及为什么它们应该被删除。

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

  • 理解好测试的原因

  • 理解测试工具

  • TDD 方法实践-失败、通过和重构

  • 删除多余的测试、注释和无用代码

到本章结束时,你将获得以下技能:

  • 能够描述良好代码的好处

  • 能够描述不进行单元测试可能带来的潜在负面影响

  • 能够安装和使用 MSTest 来编写和运行单元测试

  • 能够安装和使用 NUnit 来编写和运行单元测试

  • 能够安装和使用 Moq 来编写虚假(模拟)对象

  • 能够安装和使用 SpecFlow 来编写符合客户规范的软件

  • 能够编写失败的测试,然后使其通过,然后进行任何必要的重构

技术要求

要访问本章的代码文件,你可以访问以下链接:github.com/PacktPublishing/Clean-Code-in-C-/tree/master/CH06

理解好测试的原因

作为程序员,如果你对一个你觉得有趣的新开发项目感到高度积极,那是很不错的。但是,如果你被叫去处理一个错误,那会非常令人沮丧。如果不是你的代码,你对代码背后的完整理解也不足,那情况会更糟。如果是你自己的代码,你会有那种“我在想什么?”的时刻!你越是被叫去处理现有代码的维护工作,你就越能体会到进行单元测试的必要性。随着这种认识的增长,你开始看到学习测试方法和技术(如测试驱动开发(TDD)和行为驱动开发(BDD))的真正好处。

当你在其他人的代码上担任维护程序员一段时间后,你会看到好的、坏的和丑陋的代码。这样的代码可以让你积极地学习,让你明白编程的更好方式是什么,以及为什么不应该这样做。糟糕的代码会让你大喊“不。就是不行!”丑陋的代码会让你眼睛发红,头脑麻木。

直接与客户打交道,为他们提供技术支持,你会看到良好的客户体验对业务成功有多么关键。相反,你也会看到糟糕的客户体验如何导致一些非常沮丧、愤怒和极其粗鲁的客户;以及由于客户退款和因社交媒体和评论网站上的恶劣客户抱怨而导致销售迅速流失的情况。

作为技术负责人,你有责任进行技术代码审查,以确保员工遵守公司的编码准则和政策,分类错误,并协助项目经理管理你负责领导的人员。作为技术负责人,高水平的项目管理、需求收集和分析、架构设计和清晰的编程是很重要的。你还需要具备良好的人际交往能力。

你的项目经理只关心按照业务需求按时按预算交付项目。他们真的不关心你如何编写软件,只关心你能否按时按约定预算完成工作。最重要的是,他们关心发布的软件是否完全符合业务要求——不多也不少——以及软件是否达到非常高的专业水准,因为代码的质量同样可以提升或摧毁公司品牌。当项目经理对你很苛刻时,你知道业务正在给他们施加更大的压力。这种压力会传递给你。

作为技术负责人,你处于项目经理和项目团队之间。在日常工作中,你将主持 Scrum 会议并处理问题。这些问题可能是编码人员需要分析人员的资源,测试人员等待开发人员修复错误,等等。但最困难的工作将是进行同行代码审查并提供建设性反馈,以达到期望的结果而不冒犯人。这就是为什么你应该非常认真地对待清晰的编码,因为如果你批评一个人的代码,如果你自己的代码不合格,你就会招致反弹。此外,如果软件测试失败或出现大量错误,你将成为项目经理的责骂对象。

因此,作为技术负责人,鼓励 TDD 是一个好主意。最好的方法是以身作则。现在我知道,即使是受过学位教育和经验丰富的程序员也可能对 TDD 持保留态度。最常见的原因之一是学习和实践起来可能很困难,而且在代码变得更加复杂时,TDD 可能会显得更加耗时。我曾经从那些不喜欢单元测试的同事那里听到过这种反对意见。

但作为一个程序员,如果你想真正自信(一旦你编写了一段代码,你就能对其质量有信心,并且不会被退回来修复自己的错误),那么 TDD 是提升自己作为程序员水平的绝佳方式。当你学会在开始编程之前先进行测试,这很快就会成为习惯性。作为程序员,这样的习惯对你非常有用和有益,尤其是当你需要找新工作时,因为许多就业机会都在招聘具有 TDD 或 BDD 经验的人。

在编写代码时需要考虑的另一件事是,简单的、非关键的记事应用中的错误并不是世界末日。但如果你在国防或医疗领域工作呢?想象一下,一种大规模杀伤性武器被编程以朝特定方向击中敌方领土上的特定目标,但出现了问题,导致导弹瞄准了你盟友的平民人口。或者,想象一下,如果你的亲人因为医疗设备软件中的错误而处于危急生命支持状态,最终死亡,而这是你自己的错。然后,再想想,如果一架载有乘客的客机上的安全软件出现问题,导致飞机坠毁在人口密集区,造成机上和地面的人员伤亡,会发生什么?

软件越关键,就越需要认真对待单元测试技术(如 TDD 和 BDD)。我们将在本章后面讨论 BDD 和 TDD 工具。在编写软件时,想象一下如果你是客户,如果你编写的代码出现问题,你会受到什么影响。这会如何影响你的家人、朋友和同事?此外,想想如果你对关键故障负责的话,会有哪些道德和法律责任。

作为程序员,了解为什么应该学会测试自己的代码是很重要的。他们说“程序员永远不应该测试自己的代码”是对的。但这只适用于代码已经完成并准备好进入生产测试之前的情况。因此,在代码仍在编程过程中,程序员应该始终测试自己的代码。然而,一些企业时间非常紧迫,以至于适当的质量保证经常被牺牲,以便企业能够率先上市。

对于企业来说,率先上市可能非常重要,但第一印象至关重要。如果一个企业率先上市,而产品存在严重缺陷并被全球广播,这可能会对企业产生长期的负面影响。因此,作为程序员,你必须非常谨慎,并尽力确保如果软件存在缺陷,你不是责任人。当企业出现问题时,责任人将会受到惩罚。在不粘锅管理中,管理人员会把推动荒谬的截止日期的罪责从自己身上转嫁到不得不满足截止日期并做出牺牲的程序员身上。

因此,作为程序员,你测试自己的代码并经常测试是非常重要的,特别是在将其发布给测试团队之前。这就是为什么你被积极鼓励过渡到根据你当前正在实施的规范编写你的测试的思维方式和习惯行为。你的测试应该一开始就失败。然后你只需编写足够的代码来使测试通过,然后根据需要重构你的代码。

开始使用 TDD 或 BDD 可能很困难。但一旦掌握了,TDD 和 BDD 就会变得很自然。你可能会发现,从长远来看,你留下的代码更加清晰易读,易于维护。你可能还会发现,你对修改代码而不破坏它的能力也大大提高了。显然,从某种意义上来说,代码更多了,因为你有生产方法和测试方法。但实际上,你可能会写更少的代码,因为你不会添加你认为可能需要的额外代码!

想象一下自己坐在电脑前,手头有一份软件规范需要翻译成可运行的软件。许多程序员有一个坏习惯,我过去也曾犯过,那就是他们直接开始编码,而没有进行任何真正的设计工作。根据我的经验,这实际上会延长开发代码的时间,并经常导致更多的错误和难以维护和扩展的代码。事实上,尽管对一些程序员来说似乎违反直觉,但适当的规划和设计实际上会加快编码速度,特别是考虑到维护和扩展。

这就是测试团队的作用。在我们进一步讨论之前,让我们描述一下用例、测试设计、测试用例和测试套件,以及它们之间的关系。

用例解释了单个操作的流程,比如添加客户记录。测试设计将包括一个或多个测试用例,用于测试单个用例可能发生的不同情景。测试用例可以手动进行,也可以是由测试套件执行的自动化测试。测试套件是用于发现和运行测试并向最终用户报告结果的软件。编写用例将是业务分析师的角色。至于测试设计、测试用例和测试套件,这将是专门的测试团队的责任。开发人员无需担心编写用例、测试设计或测试用例,并在测试套件中执行它们。开发人员必须专注于编写和使用他们的单元测试来编写失败的代码,然后运行,并根据需要进行重构。

软件测试人员与程序员合作。这种合作通常从项目开始时开始,并持续到最后。开发团队和测试团队将通过共享每个产品待办事项的测试用例来合作。这个过程通常包括编写测试用例。为了通过测试,它们必须满足测试标准。这些测试用例通常将使用手动测试和一些测试套件自动化的组合来运行。

在开发阶段,测试人员编写他们的 QA 测试,开发人员编写他们的单元测试。当开发人员将他们的代码提交给测试团队时,测试团队将运行他们的一系列测试。这些测试的结果将反馈给开发人员和项目利益相关者。如果遇到问题,这被称为技术债务。开发团队将不得不考虑解决测试团队提出的问题所需的时间。当测试团队确认软件已经达到所需的质量水平时,代码将被传递给基础设施以发布到生产环境中。

假设我们正在启动一个全新的项目(也称为绿地项目),我们将选择适当的项目类型并选中包括测试项目的选项。这将创建一个解决方案,包括我们的主要项目和测试项目。

我们创建的项目类型和要实施的项目特性将取决于用例。用例在系统分析期间用于识别、确认和组织软件需求。从用例中,测试用例可以分配给验收标准。作为程序员,您可以使用这些用例及其测试用例来为每个测试用例编写自己的单元测试。然后,您的测试将作为测试套件的一部分运行。在 Visual Studio 2019 中,您可以从“视图|测试资源管理器”菜单中访问测试资源管理器。当您构建项目时,将会发现测试。发现测试后,它们将在测试资源管理器中显示。然后,您可以在测试资源管理器中运行和/或调试您的测试。

值得注意的是,在这个阶段,设计测试并提出适当数量的测试用例将是测试人员的责任,而不是开发人员的责任。一旦软件离开开发人员的手,他们还负责 QA。但是,单元测试代码的责任仍然是开发人员的责任,这就是测试用例可以在编写代码的单元测试中提供真正帮助和动力的地方。

创建解决方案时,您要做的第一件事是打开提供的测试类。在该测试类中,您编写必须完成的伪代码。然后,您逐步执行伪代码,并添加测试方法,测试必须完成的每个步骤,以便达到完成软件项目的目标。您编写的每个测试方法都是为了失败。然后,您只需编写足够的代码来通过测试。然后,一旦测试通过,您就可以在进行下一个测试之前重构代码。因此,您可以看到,单元测试并不是什么高深的科学。但是,编写一个好的单元测试需要什么呢?

任何正在测试中的代码都应该提供特定的功能。一个功能接受输入并产生输出。

在正常运行的计算机程序中,一个方法(或函数)将具有可接受范围的输入和输出,以及不可接受范围的输入和输出。因此,完美的单元测试将测试最低可接受值,最高可接受值,并提供超出可接受值范围的测试用例,无论高低。

单元测试必须是原子的,这意味着它们只能测试一件事。由于方法可以在同一个类中链接在一起,甚至可以跨多个程序集中的多个类进行链接,因此为了保持它们的原子性,通常有必要为受测试的类提供虚假或模拟对象。输出必须确定它是通过还是失败。良好的单元测试绝对不能是不确定的。

测试的结果应该是可重复的,即在特定条件下,它要么总是通过,要么总是失败。也就是说,同一个测试一遍又一遍地运行时,每次运行都不应该有不同的结果。如果有的话,那么它就不是可重复的。单元测试不应该依赖于其他测试在它们之前运行,并且它们应该与其他方法和类隔离开来。您还应该力求使单元测试在毫秒内运行。任何需要一秒或更长时间才能运行的测试都太长了。如果代码运行时间超过一秒,那么您应该考虑重构或实现一个用于测试的模拟对象。由于我们是忙碌的程序员,单元测试应该易于设置,不需要大量编码或配置。以下图表显示了单元测试的生命周期:

在本章中,我们将编写单元测试和模拟对象。但在此之前,我们需要了解一些作为 C#程序员可用的工具。

理解测试工具

我们将在 Visual Studio 中查看的测试工具有MSTestNUnitMoqSpecFlow。每个测试工具都会创建一个控制台应用程序和相关的测试项目。NUnit 和 MSTest 是单元测试框架。NUnit 比 MSTest 早得多,因此与 MSTest 相比,它具有更成熟和功能齐全的 API。我个人更喜欢 NUnit 而不是 MSTest。

Moq 与 MSTest 和 NUnit 不同,因为它不是一个测试框架,而是一个模拟框架。模拟框架会用虚拟(假的)实现替换项目中的真实类,用于测试目的。您可以将 Moq 与 MSTest 或 NUnit 一起使用。最后,SpecFlow 是一个 BDD 框架。您首先使用用户和技术人员都能理解的业务语言在一个特性文件中编写一个特性。然后为该特性生成一个步骤文件。步骤文件包含实现该特性所需的方法作为步骤。

通过本章结束时,您将了解每个工具的作用,并能够在自己的项目中使用它们。因此,让我们开始看看 MSTest。

MSTest

在本节中,我们将安装和配置 MSTest 框架。我们将编写一个带有测试方法并初始化的测试类。我们将执行程序集设置和清理、类清理和方法清理,并进行断言。

要在 Visual Studio 的命令行中安装 MSTest 框架,您需要通过 Tools | NuGet Package Manager | Package Manager Console 打开 Package Manager Console:

然后,运行以下三个命令来安装 MSTest 框架:

install-package mstest.testframework
install-package mstest.testadapter
install-package microsoft.net.tests.sdk

或者,您可以添加一个新项目,并在 Solution Explorer 的 Context | Add 菜单中选择 Unit Test Project (.NET Framework)。请参阅以下截图。在命名测试项目时,接受的标准是以<ProjectName>.Tests的形式。这有助于将它们与测试关联起来,并将它们与受测试的项目区分开来:

以下代码是在将 MSTest 项目添加到解决方案时生成的默认单元测试代码。正如您所看到的,该类导入了Microsoft.VisualStudio.TestTools.UnitTesting命名空间。[TestClass]属性标识 MS 测试框架,该类是一个测试类。[TestMethod]属性标记该方法为测试方法。所有具有[TestMethod]属性的类都将出现在测试播放器中。[TestClass][TestMethod]属性是强制性的:

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace CH05_MSTestUnitTesting.Tests
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
        }
    }
}

还有其他方法和属性可以选择组合以生成完整的测试执行工作流程。这些包括[AssemblyInitialize][AssemblyCleanup][ClassInitialize][ClassCleanup][TestInitialize][TestCleanup]。正如它们的名称所暗示的那样,初始化属性用于在运行测试之前在程序集、类和方法级别执行任何初始化。同样,清理属性在测试运行后在方法、类和程序集级别执行以执行任何必要的清理操作。我们将依次查看每个属性,并在运行最终代码时将它们添加到您的项目中,以便了解它们的执行顺序。

WriteSeparatorLine()方法是一个辅助方法,用于分隔我们的测试方法输出。这将帮助我们更容易地跟踪我们的测试类中发生的情况:

private static void WriteSeparatorLine()
{
    Debug.WriteLine("--------------------------------------------------");
}

可选地,分配[AssemblyInitialize]属性以在执行测试之前执行代码:

[AssemblyInitialize]
public static void AssemblyInit(TestContext context)
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: AssemblyInitialize");
    Debug.WriteLine("Executes once before the test run.");
}

然后,您可以选择分配[ClassInitialize]属性以在执行测试之前执行一次代码:

[ClassInitialize]
public static void TestFixtureSetup(TestContext context)
{
    WriteSeparatorLine();
    Console.WriteLine("Optional: ClassInitialize");
    Console.WriteLine("Executes once for the test class.");
}

然后,通过将[TestInitialize]属性分配给设置方法,在每个单元测试之前运行设置代码:

[TestInitialize]
public void Setup()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: TestInitialize");
    Debug.WriteLine("Runs before each test.");
}

当您完成测试运行后,可以选择分配[AssemblyCleanup]属性以执行任何必要的清理操作:

[AssemblyCleanup]
public static void AssemblyCleanup()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: AssemblyCleanup");
    Debug.WriteLine("Executes once after the test run.");
}

标记为[ClassCleanup]的可选方法在类中的所有测试执行后运行一次。您无法保证此方法何时运行,因为它可能不会立即在所有测试执行后运行:

[ClassCleanup]
public static void TestFixtureTearDown()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: ClassCleanup");
    Debug.WriteLine("Runs once after all tests in the class have been 
     executed.");
    Debug.WriteLine("Not guaranteed that it executes instantly after all 
     tests the class have executed.");
}

在每个测试运行后执行清理操作,将[TestCleanup]属性应用于测试清理方法:

[TestCleanup]
public void TearDown()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: TestCleanup");
    Debug.WriteLine("Runs after each test.");
    Assert.Fail();
}

现在我们的代码已经就位,构建它。然后,从“测试”菜单中,选择“测试资源管理器”。您应该在测试资源管理器中看到以下测试。正如您从以下截图中所看到的,该测试尚未运行:

因此,让我们运行我们唯一的测试。哦不!我们的测试失败了,如下截图所示:

按照下面的片段中所示更新TestMethod1()代码,然后再次运行测试:

[TestMethod]
public void TestMethod1()
{
    WriteSeparatorLine();
    Debug.WriteLine("Required: TestMethod");
    Debug.WriteLine("A test method to be run by the test runner.");
    Debug.WriteLine("This method will appear in the test list.");
    Assert.IsTrue(true);
}

您可以看到测试在测试资源管理器中已通过,如下截图所示:

因此,从先前的截图中,您可以看到尚未执行的测试为蓝色,失败的测试为红色,通过的测试为绿色。从“工具”|“选项”|“调试”|“常规”,选择将所有输出窗口文本重定向到“立即窗口”。然后,选择“运行”|“调试所有测试”。

当您运行测试并将输出打印到“立即窗口”时,将清楚地看到属性的执行顺序。以下截图显示了我们测试方法的输出:

正如您已经看到的,我们使用了两个Assert方法——Assert.Fail()Assert.IsTrue(true)Assert类非常有用,因此了解单元测试类中可用的方法是很值得的。这些可用的方法列在下面并进行描述:

方法 描述
Assert.AreEqual() 测试指定的值是否相等,并在两个值不相等时引发异常。
Assert.AreNotEqual() 测试指定的值是否不相等,并在两个值相等时引发异常。
Assert.ArtNotSame() 测试指定的对象是否引用不同的对象,并在两个输入引用相同对象时引发异常。
Assert.AreSame() 测试指定的对象是否都引用同一个对象,并在两个输入不引用相同对象时引发异常。
Assert.Equals() 此对象将始终使用Assert.Fail抛出异常。因此,我们可以使用Assert.AreEqual代替。
Assert.Fail() 抛出AssertFailedException异常。
Assert.Inconclusive() 抛出AssertInconclusiveException异常。
Assert.IsFalse() 测试指定的条件是否为假,并在条件为真时引发异常。
Assert.IsInstanceOfType() 测试指定的对象是否是预期类型的实例,并在预期类型不在对象的继承层次结构中时引发异常。
Assert.IsNotInstanceOfType() 测试指定的对象是否是错误类型的实例,并在指定类型在对象的继承层次结构中时引发异常。
Assert.IsNotNull() 测试指定的对象是否非 null,并在其为 null 时引发异常。
Assert.IsNull() 测试指定的对象是否为 null,并在其不为 null 时引发异常。
Assert.IsTrue() 测试指定的条件是否为真,并在条件为假时引发异常。
Assert.ReferenceEquals() 确定指定的对象实例是否是同一个实例。
Assert.ReplaceNullChars() 用"\\0"替换空字符('\0')。
Assert.That() 获取Assert功能的单例实例。
Assert.ThrowsException() 测试由委托操作指定的代码是否引发了类型为T的给定异常(而不是派生类型),如果代码没有引发异常,或引发了除T之外的类型的异常,则引发AssertFailedException。简而言之,这需要一个委托,并断言它引发了带有预期消息的预期异常。
Assert.ThrowsExceptionAsync() 测试由委托操作指定的代码是否引发了类型为T的给定异常(而不是派生类型),如果代码没有引发异常,或引发了除T之外的类型的异常,则引发AssertFailedException

现在我们已经看过了 MSTest,是时候看看 NUnit 了。

NUnit

如果在 Visual Studio 中未安装 NUnit,则可以通过 Extensions | Manage Extensions 下载并安装它。之后,创建一个新的 NUnit 测试项目(.NET Core)。以下代码包含了 NUnit 创建的默认类,名为Tests

public class Tests
{
    [SetUp]
    public void Setup()
    {
    }

    [Test]
    public void Test1()
    {
        Assert.Pass();
    }
}

Test1方法中可以看出,测试方法也使用了Assert类,就像 MSTest 用于测试代码断言一样。 NUnit Assert 类为我们提供了以下方法(请注意,以下表中标记为[NUnit]的方法是特定于 NUnit 的;其他所有方法也存在于 MSTest 中):

方法 描述
Assert.AreEqual() 验证两个项是否相等。如果它们不相等,则引发异常。
Assert.AreNotEqual() 验证两个项是否不相等。如果它们相等,则引发异常。
Assert.AreNotSame() 验证两个对象是否不引用同一个对象。如果是,则引发异常。
Assert.AreSame() 验证两个对象是否引用同一个对象。如果不是,则引发异常。
Assert.ByVal() [NUnit] 对实际值应用约束,如果约束满足则成功,并在失败时引发断言异常。在私有 setter 导致 Visual Basic 编译错误的罕见情况下,用作That的同义词。
Assert.Catch() [NUnit] 验证委托在调用时是否抛出异常,并返回该异常。
Assert.Contains() [NUnit] 验证值是否包含在集合中。
Assert.DoesNotThrow() [NUnit] 验证方法是否不会抛出异常。
Assert.Equal() [NUnit] 不要使用。请改用Assert.AreEqual()
Assert.Fail() 抛出AssertionException
Assert.False() [NUnit] 验证条件是否为假。如果条件为真,则抛出异常。
Assert.Greater() [NUnit] 验证第一个值是否大于第二个值。如果不是,则抛出异常。
Assert.GreaterOrEqual() [NUnit] 验证第一个值是否大于或等于第二个值。如果不是,则抛出异常。
Assert.Ignore() [NUnit] 抛出带有传入消息和参数的IgnoreException。这会导致测试被报告为被忽略。
Assert.Inconclusive() 抛出带有传入消息和参数的InconclusiveException。这会导致测试被报告为不确定。
Assert.IsAssignableFrom() [NUnit] 验证对象是否可以分配给给定类型的值。
Assert.IsEmpty() [NUnit] 验证值(如字符串或集合)是否为空。
Assert.IsFalse() 验证条件是否为假。如果为真,则抛出异常。
Assert.IsInstanceOf() [NUnit] 验证对象是否是给定类型的实例。
Assert.NAN() [NUnit] 验证值是否不是一个数字。如果是,则抛出异常。
Assert.IsNotAssignableFrom() [NUnit] 验证对象是否不可从给定类型分配。
Assert.IsNotEmpty() [NUnit] 验证字符串或集合是否不为空。
Asserts.IsNotInstanceOf() [NUnit] 验证对象不是给定类型的实例。
Assert.InNotNull() 验证对象是否不为 null。如果为 null,则抛出异常。
Assert.IsNull() 验证对象是否为 null。如果不是,则抛出异常。
Assert.IsTrue() 验证条件是否为真。如果为假,则抛出异常。
Assert.Less() [NUnit] 验证第一个值是否小于第二个值。如果不是,则抛出异常。
Assert.LessOrEqual() [NUnit] 验证第一个值是否小于或等于第二个值。如果不是,则抛出异常。
Assert.Multiple() [NUnit] 包装包含一系列断言的代码,应该全部执行,即使它们失败。失败的结果将被保存,并在代码块结束时报告。
Assert.Negative() [NUnit] 验证数字是否为负数。如果不是,则抛出异常。
Assert.NotNull() [NUnit] 验证对象是否不为 null。如果为 null,则抛出异常。
Assert.NotZero() [NUnit] 验证数字是否不为零。如果为零,则抛出异常。
Assert.Null() [NUnit] 验证对象是否为 null。如果不是,则抛出异常。
Assert.Pass() [NUnit] 抛出带有传入消息和参数的SuccessException。这允许测试被提前结束,并将成功结果返回给 NUnit。
Assert.Positive() [NUnit] 验证数字是否为正数。
Assert.ReferenceEquals() [NUnit] 不要使用。抛出InvalidOperationException
Assert.That() 验证条件是否为真。如果不是,则抛出异常。
Assert.Throws() 验证委托在调用时是否抛出特定异常。
Assert.True() [NUnit] 验证条件是否为真。如果不是,则调用异常。
Assert.Warn() [NUnit] 使用提供的消息和参数发出警告。
Assert.Zero() [NUnit] 验证数字是否为零。

NUnit 的生命周期始于TestFixtureSetup,在第一个测试SetUp之前执行。然后,在每个测试之前执行SetUp。每个测试执行完毕后,执行TearDown。最后,在最后一个测试TearDown之后执行TestFixtureTearDown。我们现在将更新Tests类,以便我们可以调试并看到 NUnit 的生命周期在运行中:

using System;
using System.Diagnostics;
using NUnit.Framework;

namespace CH06_NUnitUnitTesting.Tests
{
    [TestFixture]
    public class Tests : IDisposable
    {
        public TestClass()
        {
            WriteSeparatorLine();
            Debug.WriteLine("Constructor");
        }

        public void Dispose()
        {
            WriteSeparatorLine();
            Debug.WriteLine("Dispose"); 
        } 
    }
}

我们已经在类中添加了[TestFixture]并实现了IDisposable接口。[TextFixture]属性对于非参数化和非泛型的夹具是可选的。只要至少有一个方法被标记为[Test][TestCase][TestCaseSource]属性,类就会被视为[TextFixture]

WriteSeparatorLine()方法作为我们调试输出的分隔符。这个方法将在Tests类中所有方法的顶部调用:

private static void WriteSeparatorLine()
{
 Debug.WriteLine("--------------------------------------------------");
}

标有[OneTimeSetUp]属性的方法将在该类中的任何测试运行之前运行一次。这里将执行所有不同测试所需的任何初始化:

[OneTimeSetUp]
public void OneTimeSetup()
{
    WriteSeparatorLine();
    Debug.WriteLine("OneTimeSetUp");
    Debug.WriteLine("This method is run once before any tests in this 
     class are run.");
}

标有[OneTimeTearDown]属性的方法在所有测试运行后运行一次,并在类被处理之前运行:

[OneTimeTearDown]
public void OneTimeTearDown()
{
    WriteSeparatorLine();
    Debug.WriteLine("OneTimeTearDown");
    Debug.WriteLine("This method is run once after all tests in this 
    class have been run.");
    Debug.WriteLine("This method runs even when an exception occurs.");
}

标有[Setup]属性的方法在每个测试方法之前运行一次:

[SetUp]
public void Setup()
{
    WriteSeparatorLine();
    Debug.WriteLine("Setup");
    Debug.WriteLine("This method is run before each test method is run.");
}

标有[TearDown]属性的方法在每个测试方法完成后运行一次:

[TearDown]
public void Teardown()
{
    WriteSeparatorLine();
    Debug.WriteLine("Teardown");
    Debug.WriteLine("This method is run after each test method 
     has been run.");
    Debug.WriteLine("This method runs even when an exception occurs.");
}

Test2()方法是一个测试方法,由[Test]属性表示,并且将作为第二个测试方法运行,由[Order(1)]属性确定。这个方法抛出InconclusiveException

  [Test]
  [Order(1)]
  public void Test2()
  {
      WriteSeparatorLine();
      Debug.WriteLine("Test:Test2");
      Debug.WriteLine("Order: 1");
      Assert.Inconclusive("Test 2 is inconclusive.");
  }

Test1()方法是一个测试方法,由[Test]属性表示,并且将作为第一个测试方法运行,由[0rder(0)]属性确定。这个方法通过SuccessException

[Test]
[Order(0)]
public void Test1()
{
    WriteSeparatorLine();
    Debug.WriteLine("Test:Test1");
    Debug.WriteLine("Order: 0");
    Assert.Pass("Test 1 passed with flying colours.");
}

Test3()方法是一个测试方法,由[Test]属性表示,并且将作为第三个测试方法运行,由[Order(2)]属性确定。这个方法抛出AssertionException

[Test]
[Order(2)]
public void Test3()
{
    WriteSeparatorLine();
    Debug.WriteLine("Test:Test3");
    Debug.WriteLine("Order: 2");
    Assert.Fail("Test 1 failed dismally.");
}

当你调试所有测试时,你的立即窗口应该看起来像下面的截图:

你现在已经接触过 MSTest 和 NUnit,并且已经看到了每个框架的测试生命周期。现在是时候看一下 Moq 了。

从 NUnit 方法表和 MSTest 方法表的比较中可以看出,NUnit 可以实现更精细的单元测试,执行性能更好,因此比 MSTest 更广泛地使用。

Moq

单元测试应该只测试被测试的方法。参见下图。如果被测试的方法调用其他方法,这些方法可以是当前类中的方法,也可以是不同类中的方法,那么不仅测试方法,其他方法也会被测试:

克服这个问题的一种方法是使用模拟(虚假)对象。模拟对象只会测试你想要测试的方法,你可以让模拟对象按你想要的方式工作。如果你要编写自己的模拟对象,你很快就会意识到这需要大量的工作。这在时间敏感的项目中可能是不可接受的,而且你的代码变得越复杂,你的模拟对象也变得越复杂。

你最终会放弃这个糟糕的工作,或者你会寻找一个适合你需求的模拟框架。Rhino Mocks 和 Moq 是.NET Framework 的两个模拟框架。在本章中,我们只会看 Moq,它比 Rhino Mocks 更容易学习和使用。有关 Rhino Mocks 的更多信息,请访问hibernatingrhinos.com/oss/rhino-mocks

在使用 Moq 进行测试时,我们首先添加模拟对象,然后配置模拟对象执行某些操作。然后我们断言配置是否起作用,并且模拟对象是否被调用。这些步骤使我们能够确定模拟对象是否正确设置。Moq 只生成测试替身。它不测试代码。您仍然需要一个像 NUnit 这样的测试框架来测试您的代码。

我们现在将看一个使用 Moq 和 NUnit 的例子。

创建一个新的控制台应用程序,命名为CH06_Moq。添加以下接口和类——IFooBarBazUnitTests。然后,通过 Nuget 包管理器,安装 Moq、NUnit 和 NUnit3TestAdapter。使用以下代码更新Bar类:

namespace CH06_Moq
{
    public class Bar
    {
        public virtual Baz Baz { get; set; }
        public virtual bool Submit() { return false; }
    }
}

Bar类有一个虚拟属性,类型为Baz,以及一个名为Submit()的虚拟方法,返回值为false。现在按照以下方式更新Baz类:

namespace CH06_Moq
{
    public class Baz
    {
        public virtual string Name { get; set; }
    }
}

Baz类有一个名为Name的单个虚拟属性,类型为字符串。修改IFoo文件,包含以下源代码:

namespace CH06_Moq
{
    public interface IFoo
    {
        Bar Bar { get; set; }
        string Name { get; set; }
        int Value { get; set; }
        bool DoSomething(string value);
        bool DoSomething(int number, string value);
        string DoSomethingStringy(string value);
        bool TryParse(string value, out string outputValue);
        bool Submit(ref Bar bar);
        int GetCount();
        bool Add(int value);
    }
}

IFoo接口有许多属性和方法。正如您所看到的,该接口引用了Bar类,我们知道Bar类包含对Baz类的引用。我们现在将开始更新我们的UnitTests类,使用 NUnit 和 Moq 测试我们新创建的接口和类。修改UnitTests类文件,使其看起来像下面的代码:

using Moq;
using NUnit.Framework;
using System;

namespace CH06_Moq
{
    [TestFixture]
    public class UnitTests
    {
    }
}

现在,添加AssertThrows方法,断言是否抛出了指定的异常:

public bool AssertThrows<TException>(
    Action action,
    Func<TException, bool> exceptionCondition = null
) where TException : Exception
    {
        try
        {
            action();
        }
        catch (TException ex)
        {
            if (exceptionCondition != null)
            {
                return exceptionCondition(ex);
            }
            return true;
        }
        catch
        {
            return false;
        }
        return false;
    }

AssertThrows方法是一个通用方法,如果您的方法抛出指定的异常,它将返回true,如果没有抛出异常,则返回false。在本章的后续测试异常时,我们将使用这个方法。现在,添加DoSomethingReturnsTrue()方法:

[Test]
public void DoSomethingReturnsTrue()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomething("ping")).Returns(true);
    Assert.IsTrue(mock.Object.DoSomething("ping"));
}

DoSomethingReturnsTrue()方法创建了IFoo接口的一个新的模拟实现。然后设置DoSomething()方法接受包含单词"ping"的字符串,并返回true。最后,该方法断言当DoSomething()方法被调用时,传入文本"ping",方法返回值为true。我们现在将实现一个类似的测试方法,如果值为"tracert",则返回false

[Test]
public void DoSomethingReturnsFalse()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomething("tracert")).Returns(false);
    Assert.IsFalse(mock.Object.DoSomething("tracert"));
}

DoSomethingReturnsFalse()方法遵循与DoSomethingReturnsFalse()方法相同的过程。我们创建一个IFoo接口的模拟对象,设置它在参数值为"tracert"时返回false,然后断言参数值为"tracert"时返回false。接下来,我们将测试我们的参数:

[Test]
public void OutArguments()
{
    var mock = new Mock<IFoo>();
    var outString = "ack";
    mock.Setup(foo => foo.TryParse("ping", out outString)).Returns(true);
    Assert.AreEqual("ack", outString);
    Assert.IsTrue(mock.Object.TryParse("ping", out outString));
}

OutArguments()方法创建了IFoo接口的一个实现。然后声明一个将用作输出参数的字符串,并赋值为"ack"。接下来,设置IFoo模拟对象的TryParse()方法,对输入值"ping"返回true,并输出字符串值"ack"。然后我们断言outString等于值"ack"。最后的检查断言TryParse()对输入值"ping"返回true

[Test]
public void RefArguments()
{
    var instance = new Bar();
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.Submit(ref instance)).Returns(true);
    Assert.AreEqual(true, mock.Object.Submit(ref instance));
}

RefArguments()方法创建了Bar类的一个实例。然后,创建了IFoo接口的一个模拟实现。然后设置Submit()方法,如果传入的引用类型是Bar类型,则返回true。然后我们断言传入的参数是Bar类型的true。在我们的AccessInvocationArguments()测试方法中,我们创建了IFoo接口的一个新实现:

[Test]
public void AccessInvocationArguments()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomethingStringy(It.IsAny<string>()))
        .Returns((string s) => s.ToLower());
    Assert.AreEqual("i like oranges!", mock.Object.DoSomethingStringy("I LIKE ORANGES!"));
}

然后设置DoSomethingStringy()方法将输入转换为小写并返回。最后,我们断言返回的字符串是传入的字符串转换为小写后的字符串:

[Test]
public void ThrowingWhenInvokedWithSpecificParameters()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomething("reset"))
        .Throws<InvalidOperationException>();
    mock.Setup(foo => foo.DoSomething(""))
        .Throws(new ArgumentException("command"));
    Assert.IsTrue(
        AssertThrows<InvalidOperationException>(
            () => mock.Object.DoSomething("reset")
        )
    );
    Assert.IsTrue(
        AssertThrows<ArgumentException>(
            () => mock.Object.DoSomething("")
        )
    );
    Assert.Throws(
        Is.TypeOf<ArgumentException>()
          .And.Message.EqualTo("command"),
          () => mock.Object.DoSomething("")
    );
 }

在我们的最终测试方法ThrowingWhenInvokedWithSpecificParameters()中,我们创建了IFoo接口的一个模拟实现。然后配置DoSomething()方法,在传入值为"reset"时抛出InvalidOperationException

当传入空字符串时,会抛出一个ArgumentException异常。然后我们断言当输入值为"reset"时会抛出InvalidOperationException。当输入值为空字符串时,我们断言会抛出ArgumentException,并断言ArgumentException的消息为"command"

你已经看到了如何使用一个名为 Moq 的模拟框架来创建模拟对象,以使用 NUnit 测试你的代码。现在我们要看的最后一个工具是SpecFlow。SpecFlow 是一个 BDD 工具。

SpecFlow

用户关注的行为测试是 BDD 的主要功能,这些测试是在编码之前编写的。BDD 是一种从 TDD 演变而来的软件开发方法。你可以从一系列特性开始 BDD。特性是用正式的商业语言编写的规范。这种语言可以被项目中的所有利益相关者理解。一旦特性被同意和生成,开发人员就需要为特性语句开发步骤定义。一旦步骤定义被创建,下一步就是创建外部项目来实现特性并添加引用。然后,步骤定义被扩展以实现特性的应用代码。

这种方法的一个好处是,作为程序员,你可以保证按照业务的要求交付成果,而不是按照你认为他们要求的交付成果。这可以为企业节省大量资金和时间。过去的历史表明,许多项目因为业务团队和编程团队之间对需要交付的内容缺乏清晰度而失败。BDD 有助于在开发新特性时减轻这种潜在风险。

在本章的这一部分中,我们将使用 BDD 软件开发方法来开发一个非常简单的计算器示例,使用 SpecFlow。

我们将首先编写一个特性文件,作为我们的规范和验收标准。然后我们将从特性文件中生成我们的步骤定义,以生成我们所需的方法。一旦我们的步骤定义生成了所需的方法,我们将为它们编写代码,以完成我们的特性。

创建一个新的类库,并添加以下包——NUnit、NUnit3TestAdapter、SpecFlow、SpecRun.SpecFlow 和 SpecFlow.NUnit。添加一个名为Calculator的新的 SpecFlow Feature 文件:

Feature: Calculator
  In order to avoid silly mistakes
  As a math idiot
  I want to be told the sum of two numbers

@mytag
Scenario: Add two numbers
  Given I have entered 50 into the calculator
  And I have entered 70 into the calculator
  When I press add
  Then the result should be 120 on the screen

在创建Calculator.feature文件时,上述文本会自动添加到文件中。因此,我们将使用这个作为我们学习使用 SpecFlow 进行 BDD 的起点。在撰写本文时,值得注意的是 SpecFlow 和 SpecMap 已被Tricentis收购。Tricentis 表示 SpecFlow、SpecFlow+和 SpecMap 都将保持免费,所以现在是学习和使用 SpecFlow 和 SpecMap 的好时机,如果你还没有这样做的话。

现在我们有了我们的特性文件,我们需要创建步骤定义,将我们的特性请求与我们的代码绑定。在代码编辑器中右键单击,会弹出上下文菜单。选择生成步骤定义。你应该会看到以下对话框:

为类名输入CalculatorSteps。点击生成按钮生成步骤定义并保存文件。打开CalculatorSteps.cs文件,你应该会看到以下代码:

using TechTalk.SpecFlow;

namespace CH06_SpecFlow
{
    [Binding]
    public class CalculatorSteps
    {
        [Given(@"I have entered (.*) into the calculator")]
        public void GivenIHaveEnteredIntoTheCalculator(int p0)
        {
            ScenarioContext.Current.Pending();
        }

        [When(@"I press add")]
        public void WhenIPressAdd()
        {
            ScenarioContext.Current.Pending();
        }

        [Then(@"the result should be (.*) on the screen")]
        public void ThenTheResultShouldBeOnTheScreen(int p0)
        {
            ScenarioContext.Current.Pending();
        }
    }
}

步骤文件的内容与特性文件的比较如下截图所示:

实现特性的代码必须在一个单独的文件中。创建一个新的类库,命名为CH06_SpecFlow.Implementation。然后,添加一个名为Calculator.cs的文件。在 SpecFlow 项目中添加对新创建的库的引用,并在CalculatorSteps.cs文件的顶部添加以下行:

private Calculator _calculator = new Calculator();

现在,我们可以扩展我们的步骤定义,以便它们实现应用程序代码。在CalculatorSteps.cs文件中,用数字替换所有的p0参数。这使参数要求更加明确。在Calculate类的顶部,添加两个名为FirstNumberSecondNumber的公共属性,如下面的代码所示:

public int FirstNumber { get; set; }
public int SecondNumber { get; set; }

CalculatorSteps类中,更新GivenIHaveEnteredIntoTheCalculator()方法如下:

[Given(@"I have entered (.*) into the calculator")]
public void GivenIHaveEnteredIntoTheCalculator(int number)
{
    calculator.FirstNumber = number;
}

现在,如果尚不存在,添加第二个方法GivenIHaveAlsoEnteredIntoTheCalculator(),并将number参数分配给计算器的第二个数字:

public void GivenIHaveAlsoEnteredIntoTheCalculator(int number)
{
    calculator.SecondNumber = number;
}

CalculatorSteps类的顶部和任何步骤之前添加private int result;。将Add()方法添加到Calculator类中:

public int Add()
{
    return FirstNumber + SecondNumber;
}

现在,更新CalculatorSteps类中的WhenIPressAdd()方法,并用调用Add()方法的结果更新result变量:

[When(@"I press add")]
public void WhenIPressAdd()
{
    _result = _calculator.Add();
}

接下来,修改ThenTheResultShouldBeOnTheScreen()方法如下:

[Then(@"the result should be (.*) on the screen")]
public void ThenTheResultShouldBeOnTheScreen(int expectedResult)
{
    Assert.AreEqual(expectedResult, _result);
}

构建您的项目并运行测试。您应该看到测试通过。只编写了通过功能所需的代码,并且您的代码已通过测试。

您可以在specflow.org/docs/找到更多关于 SpecFlow 的信息。我们已经介绍了一些可用于开发和测试代码的工具。现在是时候看一个真正简单的例子,演示我们如何使用 TDD 进行编码。我们将首先编写失败的代码。然后,我们将编写足够的代码使测试通过。最后,我们将重构代码。

TDD 方法实践-失败,通过和重构

在本节中,您将学习编写失败的测试。然后,您将学习编写足够的代码使测试通过,然后如果必要,您将执行任何需要进行的重构。

让我们深入了解 TDD 的实际例子之前,让我们考虑一下为什么我们需要 TDD。在前一节中,您看到了我们如何创建功能文件并从中生成步骤文件,以编写满足业务需求的代码。确保您的代码满足业务需求的另一种方法是使用 TDD。通过 TDD,您从一个失败的测试开始。然后,您只编写足够的代码使测试通过,并在需要时对新代码进行重构。这个过程重复进行,直到所有功能都被编码。

但是,为什么我们需要 TDD 呢?

业务软件规格是由与项目利益相关者合作设计新软件或对现有软件进行扩展和修改的业务分析师组合起来的。一些软件是关键的,不能出现错误。这样的软件包括处理私人和商业投资的金融系统;需要功能软件才能工作的医疗设备,包括关键的生命支持和扫描设备;交通管理和导航系统的交通信号软件;太空飞行系统;以及武器系统。

好的,但 TDD 在哪里适用呢?

好吧,你已经得到了编写软件规范的任务。你需要做的第一件事是创建你的项目。然后,你为你要实现的功能编写伪代码。然后,你继续为每个伪代码编写测试。测试失败。然后,你编写必要的代码使测试通过,然后根据需要重构你的代码。你正在编写经过充分测试和健壮的代码。你能够保证你的代码在隔离环境中按预期执行。如果你的代码是一个更大系统的组件,那么测试团队将负责测试你的代码的集成,而不是你。作为开发人员,你已经赢得了对代码的信心,可以将其发布给测试团队。如果测试团队发现了以前被忽视的用例,他们会与你分享。然后,你将编写进一步的测试并使其通过,然后将更新后的代码发布给他们。这种工作方式确保了代码的最高标准,并且可以信任它按照给定输入的预期输出进行工作。最后,TDD 使软件进展可衡量,这对经理来说是个好消息。

现在是我们进行 TDD 的小演示的时候了。在这个例子中,我们将使用 TDD 来开发一个简单的日志记录应用程序,可以处理内部异常,并将异常记录到一个带有时间戳的文本文件中。我们将编写程序并使测试通过。一旦我们编写了程序并使所有测试通过,然后我们将重构我们的代码,使其可重用和更易读,当然,我们将确保我们的测试仍然通过。

  1. 创建一个新的控制台应用程序,并将其命名为CH06_FailPassRefactor。添加一个名为UnitTests的类,其中包含以下伪代码:
using NUnit.Framework;

namespace CH06_FailPassRefactor
{
    [TestFixture]
    public class UnitTests
    {
        // The PseudoCode.
        // [1] Call a method to log an exception.
        // [2] Build up the text to log including 
        // all inner exceptions.
        // [3] Write the text to a file with a timestamp.
    }
}
  1. 我们将编写我们的第一个单元测试来满足条件[1]。在我们的单元测试中,我们将测试创建Logger变量,调用Log()方法,并通过测试。所以,让我们写代码:
// [1] Call a method to log an exception.
[Test]
public void LogException()
{
    var logger = new Logger();
    var logFileName = logger.Log(new ArgumentException("Argument cannot be null"));
    Assert.Pass();
}

这个测试不会运行,因为项目无法构建。这是因为Logger类不存在。因此,在项目中添加一个名为Logger的内部类。然后运行你的测试。构建仍然会失败,测试也不会运行,因为现在缺少Log()方法。所以让我们在Logger类中添加Log()方法。然后,我们将尝试再次运行我们的测试。这次,测试应该成功。

  1. 在这个阶段,我们将执行任何必要的重构。但由于我们刚刚开始,没有需要重构的地方,所以我们可以继续进行下一个测试。

我们的代码生成日志消息并保存到磁盘的功能将包含私有成员。使用 NUnit,你不测试私有成员。这种思想是,如果你必须测试私有成员,那么你的代码肯定有问题。所以,我们将继续进行下一个单元测试,确定日志文件是否存在。在编写单元测试之前,我们将编写一个返回具有内部异常的异常的方法。我们将在我们的单元测试中将返回的异常传递给Log()方法:

private Exception GetException()
{
    return new Exception(
        "Exception: Main exception.",
        new Exception(
            "Exception: Inner Exception.",
            new Exception("Exception: Inner Exception Inner Exception")
        )
    );
}
  1. 现在,我们已经有了GetException()方法,我们可以编写我们的单元测试来检查日志文件是否存在:
[Test]
public void CheckFileExists()
{
    var logger = new Logger();
    var logFile = logger.Log(GetException());
    FileAssert.Exists(logFile);
}
  1. 如果我们构建我们的代码并运行CheckFileExists()测试,它将失败,所以我们需要编写代码使其成功。在Logger类中,将private StringBuilder _stringBuilder;添加到Logger类的顶部。然后,修改Log()方法,并在Logger类中添加以下方法:
private StringBuilder _stringBuilder;

public string Log(Exception ex)
{
    _stringBuilder = new StringBuilder();
    return SaveLog();
}

private string SaveLog()
{
    var fileName = $"LogFile{DateTime.UtcNow.GetHashCode()}.txt";
    var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
    var file = $"{dir}\\{fileName}";
    return file;
}
  1. 我们已经调用了Log()方法,并生成了一个日志文件。现在,我们只需要将文本记录到文件中。根据我们的伪代码,我们需要记录主异常和所有内部异常。让我们编写一个检查日志文件是否包含消息"Exception: Inner Exception Inner Exception"的测试:
[Test]
public void ContainsMessage()
{
    var logger = new Logger();
    var logFile = logger.Log(GetException());
    var msg = File.ReadAllText(logFile);
    Assert.IsTrue(msg.Contains("Exception: Inner Exception Inner Exception"));
}
  1. 现在,我们知道测试将会失败,因为字符串生成器是空的,所以我们将在Logger类中添加一个方法,该方法将接受一个异常,记录消息,并检查异常是否有内部异常。如果有,那么它将使用参数isInnerException调用自身:
private void BuildExceptionMessage(Exception ex, bool isInnerException)
{
    if (isInnerException)
        _stringBuilder.Append("Inner Exception: ").AppendLine(ex.Message);
    else
        _stringBuilder.Append("Exception: ").AppendLine(ex.Message);
    if (ex.InnerException != null)
       BuildExceptionMessage(ex.InnerException, true);
}
  1. 最后,更新Logger类的Log()方法以调用我们的BuildExceptionMessage()方法:
public string Log(Exception ex)
{
    _stringBuilder = new StringBuilder();
    _stringBuilder.AppendLine("-----------------------
      -----------------");
    BuildExceptionMessage(ex, false);
    _stringBuilder.AppendLine("-----------------------
      -----------------");
    return SaveLog();
}

现在我们所有的测试都通过了,我们有一个完全正常运行的程序,但是这里有一个重构的机会。名为BuildExceptionMessage()的方法是可以重复使用的候选方法,特别是在调试时非常有用,尤其是当您有一个带有内部异常的异常时,所以我们将把该方法移动到自己的方法中。请注意,Log()方法也正在构建要记录的文本的开头和结尾部分。

我们可以并且将把这个移到BuildExceptionMessage()方法中:

  1. 创建一个新的类并将其命名为Text。在构造函数中添加一个私有的StringBuilder成员变量并对其进行实例化。然后,通过添加以下代码来更新类:
public string ExceptionMessage => _stringBuilder.ToString();

public void BuildExceptionMessage(Exception ex, bool isInnerException)
{
    if (isInnerException)
    {
        _stringBuilder.Append("Inner Exception: ").AppendLine(ex.Message);
    }
    else
    {
        _stringBuilder.AppendLine("--------------------------------------------------------------");
        _stringBuilder.Append("Exception: ").AppendLine(ex.Message);
    }
    if (ex.InnerException != null)
        BuildExceptionMessage(ex.InnerException, true);
    else
        _stringBuilder.AppendLine("--------------------------------------------------------------");
}
  1. 现在我们有一个有用的Text类,它可以从带有内部异常的异常中返回有用的异常消息,但是我们也可以重构SaveLog()方法中的代码。我们可以将生成唯一哈希文件名的代码提取到自己的方法中。因此,让我们向Text类添加以下方法:
public string GetHashedTextFileName(string name, SpecialFolder folder)
{
    var fileName = $"{name}-{DateTime.UtcNow.GetHashCode()}.txt";
    var dir = Environment.GetFolderPath(folder);
    return $"{dir}\\{fileName}";
}
  1. GetHashedTextFileName() 方法接受用户指定的文件名和特殊文件夹。然后在文件名末尾添加连字符和当前 UTC 日期的哈希码。然后添加.txt文件扩展名并将文本分配给fileName变量。然后将调用者请求的特殊文件夹的绝对路径分配给dir变量,然后将路径和文件名返回给用户。此方法保证返回唯一的文件名。

  2. 用以下代码替换Logger类的主体:

        private Text _text;

        public string Log(Exception ex)
        {
            BuildMessage(ex);
            return SaveLog();
        }

        private void BuildMessage(Exception ex)
        {
            _text = new Text();
            _text.BuildExceptionMessage(ex, false);
        }

        private string SaveLog()
        {
            var filename = _text.GetHashedTextFileName("Log", 
              Environment.SpecialFolder.MyDocuments);
            File.WriteAllText(filename, _text.ExceptionMessage);
            return filename;
        }

该类仍然在做同样的事情,但是它更清洁、更小,因为消息和文件名的生成已经移动到一个单独的类中。如果您运行代码,它的行为方式是相同的。如果您运行测试,它们都会通过。

在这一部分中,我们编写了失败的单元测试,然后修改它们使其通过。然后,我们重构了代码,使其更加清晰,这导致我们编写的代码可以在同一项目或其他项目中重复使用。现在让我们简要地看一下多余的测试。

删除多余的测试、注释和死代码

正如书中所述,我们对编写清晰的代码很感兴趣。随着我们的程序和测试的增长以及开始重构,一些代码将变得多余。任何多余的代码并且没有被调用的代码都被称为死代码。一旦识别出死代码,就应该立即删除。死代码不会在编译后的代码中执行,但它仍然是需要维护的代码库的一部分。带有死代码的代码文件比它们需要的要长。除了使文件变得更大之外,它还可能使阅读源代码变得更加困难,因为它可能打断代码的自然流程,并给阅读它的程序员增加困惑和延迟。不仅如此,对于项目中的新程序员来说,最不希望的是浪费宝贵的时间来理解永远不会被使用的死代码。因此最好是摆脱它。

至于注释,如果做得当,它们可以非常有用,特别是 API 注释对 API 文档生成特别有益。但有些注释只会给代码文件增加噪音,令人惊讶的是,很多程序员会因此感到非常恼火。有一群程序员会对一切都做注释。另一群则什么都不注释,因为他们认为代码应该像读书一样。还有一些人采取平衡的态度,只在必要时才对代码做注释。

当你看到这样的注释时——“这会偶尔生成一个随机 bug。不知道为什么。但欢迎你来修复它!”——警钟应该响起。首先,写下这条注释的程序员应该坚持在代码上工作,直到找出生成 bug 的条件,然后修复 bug。如果你知道写下这条注释的程序员是谁,那就把代码还给他们去修复,并删除注释。我在多个场合看到过这样的代码,也看到过网上对这些注释表达强烈情绪的评论。我想这是应对懒惰程序员的一种方式。如果他们不是懒惰,而只是经验不足,那么这是一个很好的学习任务,可以学习问题诊断和解决的艺术。

如果代码已经经过检查和批准,你发现有一些代码块被注释掉了,那就把它们删除。这些代码仍然存在于版本控制历史中,如果需要的话,你可以从那里检索出来。

代码应该像读书一样,所以你不应该让你的代码变得晦涩难懂,只是为了给同事留下好印象,因为我保证,当你几周后回到自己的代码时,你会摸着头想知道自己的代码是做什么的,为什么要这样写。我见过很多初学者犯这个错误。

冗余测试也应该被移除。你只需要运行必要的测试。对于冗余代码的测试没有价值,可能会浪费大量时间。此外,如果你的公司有在云中运行测试的 CI/CD 流水线,那么冗余测试和死代码会给构建、测试和部署流水线增加业务成本。这意味着你上传、构建、测试和部署的代码行数越少,公司在运行成本上的支出就越少。记住,在云中运行进程是要花钱的,企业的目标是尽量少花钱,但赚取大量利润。

现在我们完成了这一章,让我们总结一下我们学到的东西。

总结

我们首先看了开发人员编写单元测试以开发质量保证代码的重要性。我们确定了软件中可能出现的理论问题,包括生命损失和昂贵的诉讼。然后讨论了单元测试和什么是好的单元测试。我们确定了一个好的单元测试必须是原子的、确定性的、可重复的和快速的。

接下来,我们将看一下开发人员可用的辅助 TDD 和 BDD 的工具。我们讨论了 MSTest 和 NUnit,并提供了示例,展示了如何实施 TDD。然后,我们看了如何使用一个名为 Moq 的模拟框架与 NUnit 一起测试模拟对象。我们的工具介绍最后以 SpecFlow 结束——这是一个 BDD 工具,允许我们用业务语言编写功能,技术人员和非技术人员都能理解,以确保业务得到的是业务想要的。

接着,我们使用 失败、通过和重构 方法,通过一个非常简单的 TDD 示例来使用 NUnit,最后看了为什么我们应该删除不必要的注释、冗余测试和死代码。

在本章的最后,您将找到有关测试软件程序的进一步资源。在下一章中,我们将看一下端到端测试。但在那之前,您可能也可以尝试以下问题,看看您对单元测试有多少了解。

问题

  1. 什么是一个好的单元测试?

  2. 一个好的单元测试不应该是什么?

  3. TDD 代表什么?

  4. BDD 代表什么?

  5. 什么是单元测试?

  6. 什么是模拟对象?

  7. 什么是虚拟对象?

  8. 列出一些单元测试框架。

  9. 列出一些模拟框架。

  10. 列出一个 BDD 框架。

  11. 应该从源代码文件中删除什么?

进一步阅读

第七章:端到端系统测试

端到端(E2E)系统测试是对整个系统进行自动化测试。作为程序员,您的代码单元测试只是整个系统的一个小因素。因此,在本章中,我们将讨论以下主题:

  • 执行端到端测试

  • 编码和测试工厂

  • 编码和测试依赖注入

  • 测试模块化

在本章结束时,您将获得以下技能:

  • 能够定义端到端测试

  • 能够执行端到端测试

  • 能够解释工厂是什么,以及如何使用它们

  • 能够理解依赖注入是什么,以及如何使用它

  • 能够理解模块化是什么,以及如何利用它

端到端测试

因此,您已经完成了项目,所有单元测试都通过了。但是,您的项目是更大系统的一部分。需要测试更大的系统,以确保您的代码以及其接口的其他代码都按预期工作。在隔离测试的代码集成到更大的系统时,可能会出现故障,并且在添加新代码时可能会破坏现有系统,因此执行端到端测试(也称为集成测试)非常重要。

集成测试负责测试从头到尾的完整程序流程。集成测试通常从“需求收集阶段”开始。您首先收集和记录系统的各种需求。然后设计所有组件并为每个子系统设计测试,然后为整个系统设计端到端测试。然后,根据要求编写代码并实施自己的单元测试。一旦代码完成并且所有测试都通过,代码就会在测试环境中集成到整个系统中,并执行端到端测试。通常,端到端测试是手动进行的,尽管在可能的情况下,它们也可以自动化。以下图表显示了一个包含两个子系统和数据库的系统。在端到端测试中,所有这些模块都将进行手动测试,使用自动化测试,或两种方法都使用:

每个系统的输入和输出是测试的主要焦点。您必须问自己,“每个系统是否传递了正确的信息?”

此外,在构建端到端测试时有三件事需要考虑:

  • 会有哪些“用户功能”,每个功能将执行哪些步骤?

  • 每个功能及其各个步骤将有什么“条件”?

  • 我们将为哪些“不同的场景”构建测试用例?

每个子系统将提供一个或多个功能,并且每个功能将按特定顺序执行多个操作。这些操作将接收输入并提供输出。您还必须确定功能和函数之间的关系,然后确定函数是“可重用”还是“独立”的。

考虑在线测试产品的场景。老师和学生将登录系统。如果老师登录,他们将进入管理控制台,如果学生登录,他们将进入测试菜单进行一个或多个测试。在这种情况下,我们实际上有三个子系统:

  • 登录系统

  • 管理系统

  • 测试系统

在上述系统中有两种执行流程。我们有管理员流程和测试流程。必须为每个流程建立条件和测试用例。我们将使用这个非常简单的评估系统登录场景作为我们的 E2E 示例。在现实世界中,E2E 将比本章节更复杂。本章的主要目的是让您思考 E2E 测试以及如何最好地实现它,因此我们将尽量简化事情,以便复杂性不会妨碍我们试图实现的目标,即手动测试必须相互交互的三个模块。

本节的目标是构建三个控制台应用程序,组成完整的系统:登录模块、管理模块和测试模块。然后一旦它们被构建,我们将手动测试它们。接下来的图表显示了系统之间的交互。我们将从登录模块开始:

登录模块(子系统)

我们系统的第一部分要求老师和学生都使用用户名和密码登录系统。任务列表如下:

  1. 输入用户名。

  2. 输入密码。

  3. 按取消(这将重置用户名和密码)。

  4. 按下确定。

  5. 如果用户名无效,则在登录页面上显示错误消息。

  6. 如果用户有效,则执行以下操作:

  • 如果用户是老师,则加载管理控制台。

  • 如果用户是学生,则加载测试控制台。

让我们从创建一个控制台应用程序开始。将其命名为CH07_Logon。在Program.cs类中,用以下代码替换现有代码:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace CH07_Logon
{
    internal static class Program
    {
        private static void Main(string[] args)
        {
            DoLogin("Welcome to the test platform");
        }
    }
}

DoLogin()方法将使用传入的字符串作为标题。由于我们还没有登录,标题将设置为“欢迎来到测试平台”。我们需要添加DoLogin()方法。该方法的代码如下:

private static void DoLogin(string message)
{
    Console.WriteLine("----------------------------");
    Console.WriteLine(message);
    Console.WriteLine("----------------------------");
    Console.Write("Enter your username: ");
    var usr = Console.ReadLine();
    Console.Write("Enter your password: ");
    var pwd = ReadPassword();
    ValidateUser(usr, pwd);
}

先前的代码接受一条消息。该消息用作控制台窗口中的标题。然后提示用户输入他们的用户名和密码。ReadPassword()方法读取所有输入,并用星号替换过滤的字母以隐藏用户的输入。然后通过调用ValidateUser()方法验证用户名和密码。

我们接下来必须添加ReadPassword()方法,如下所示的代码:

public static string ReadPassword()
{
    return ReadPassword('*');
}

这个方法非常简单。它调用同名的重载方法,并传入密码掩码字符。让我们实现重载的ReadPassword()方法:

        public static string ReadPassword(char mask)
        {
            const int enter = 13, backspace = 8, controlBackspace = 127;
            int[] filtered = { 0, 27, 9, 10, 32 };
            var pass = new Stack<char>();
            char chr = (char)0;
            while ((chr = Console.ReadKey(true).KeyChar) != enter)
            {
                if (chr == backspace)
                {
                    if (pass.Count > 0)
                    {
                        Console.Write("\b \b");
                        pass.Pop();
                    }
                }
                else if (chr == controlBackspace)
                {
                    while (pass.Count > 0)
                    {
                        Console.Write("\b \b");
                        pass.Pop();
                    }
                }
                else if (filtered.Count(x => chr == x) <= 0)
                {
                    pass.Push((char)chr);
                    Console.Write(mask);
                }
            }
            Console.WriteLine();
            return new string(pass.Reverse().ToArray());
        }

重载的ReadPassword()方法接受密码掩码。该方法将每个字符添加到堆栈中。除非按下的键是Enter键,否则将检查按下的键是否是Delete键。如果用户按下Delete键,则从堆栈中删除输入的最后一个字符。如果输入的字符不在过滤列表中,则将其推送到堆栈上。然后将密码掩码写入屏幕。一旦按下Enter键,就会在控制台窗口中写入一个空行,并且堆栈的内容被反转,以字符串形式返回。

我们需要为该子系统编写的最后一个方法是ValidateUser()方法:

private static void ValidateUser(string usr, string pwd)
{
    if (usr.Equals("admin") && pwd.Equals("letmein"))
    {
        var process = new Process();
        process.StartInfo.FileName = @"..\..\..\CH07_Admin\bin\Debug\CH07_Admin.exe";
        process.StartInfo.Arguments = "admin";
        process.Start();
    }
    else if (usr.Equals("student") && pwd.Equals("letmein"))
    {
        var process = new Process();
        process.StartInfo.FileName = @"..\..\..\CH07_Test\bin\Debug\CH07_Test.exe";
        process.StartInfo.Arguments = "test";
        process.Start();
    }
    else
    {
        Console.Clear();
        DoLogin("Invalid username or password");
    }
}

ValidateUser()方法检查用户名和密码。如果它们验证为管理员,则加载管理员页面。如果它们验证为学生,则加载学生页面。否则,清除控制台,通知用户凭据错误,并提示他们重新输入凭据。

在成功登录操作执行后,相关子系统被加载,然后登录子系统终止。现在我们已经编写了登录模块,我们将编写我们的管理模块。

管理模块(子系统)

管理子系统是进行所有系统管理的地方。这包括以下内容:

  • 导入学生

  • 导出学生

  • 添加学生

  • 删除学生

  • 编辑学生档案

  • 将测试分配给学生

  • 更改管理员密码

  • 备份数据

  • 恢复数据

  • 擦除所有数据

  • 查看报告

  • 导出报告

  • 保存报告

  • 打印报告

  • 注销

在这个练习中,我们不会实现任何这些功能。我会让你作为一个有趣的练习来完成。我们感兴趣的是管理员模块在成功登录时加载。如果管理员模块在未登录的情况下加载,则会显示错误消息。然后当用户按下一个键时,他们将被带到登录模块。成功登录是指用户成功以管理员身份登录,并且调用了 admin 可执行文件并带有admin 参数

在 Visual Studio 中创建一个控制台应用程序,并将其命名为CH07_Admin。更新Main()方法如下:

private static void Main(string[] args)
{
    if ((args.Count() > 0) && (args[0].Equals("admin")))
    {
        DisplayMainScreen();
    }
    else
    {
        DisplayMainScreenError();
    }
}

Main()方法检查参数计数是否大于0,并且数组中的第一个参数是 admin。如果是,通过调用DisplayMainScreen()方法显示主屏幕。否则,调用DisplayMainScreenError()方法,警告用户他们必须登录才能访问系统。是时候写DisplayMainScreen()方法了:

private static void DisplayMainScreen()
{
    Console.WriteLine("------------------------------------");
    Console.WriteLine("Test Platform Administrator Console");
    Console.WriteLine("------------------------------------");
    Console.WriteLine("Press any key to exit");
    Console.ReadKey();
    Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}

正如你所看到的,DisplayMainScreen()方法非常简单。它显示一个标题和一个按任意键退出的消息,然后等待按键。按下按键后,程序将外壳转到登录模块并退出。现在,对于DisplayMainScreenError()方法:

private static void DisplayMainScreenError()
{
    Console.WriteLine("------------------------------------");
    Console.WriteLine("Test Platform Administrator Console");
    Console.WriteLine("------------------------------------");
    Console.WriteLine("You must login to use the admin module.");
    Console.WriteLine("Press any key to exit");
    Console.ReadKey();
    Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}

从这个方法中,您可以看到模块是在未登录的情况下启动的。这是不允许的。因此,当用户按下任意键时,用户将被重定向到登录模块,他们可以登录以使用管理员模块。我们的最终模块是测试模块。让我们开始写吧。

测试模块(子系统)

测试系统由一个菜单组成。该菜单显示学生必须执行的测试列表,并提供退出测试系统的选项。该系统的功能包括以下内容:

  • 显示一个要完成的测试菜单。

  • 从菜单中选择一个项目开始测试。

  • 在测试完成后,保存结果并返回菜单。

  • 当测试完成时,从菜单中删除它。

  • 当用户退出测试模块时,他们将返回到登录模块。

与之前的模块一样,我会让你玩一下,并添加上述功能。我们在这里感兴趣的主要是确保只有在用户登录后才能运行测试模块。当模块退出时,加载登录模块。

测试模块或多或少是管理员模块的一个重新整理,所以我们将匆匆忙忙地通过这一部分,以便到达我们需要去的地方。更新Main()方法如下:

 private static void Main(string[] args)
 {
     if ((args.Count() > 0) && (args[0].Equals("test")))
     {
         DisplayMainScreen();
     }
     else
     {
         DisplayMainScreenError();
     }
}

现在添加DisplayMainScreen()方法:

private static void DisplayMainScreen()
{
    Console.WriteLine("------------------------------------");
    Console.WriteLine("Test Platform Student Console");
    Console.WriteLine("------------------------------------");
    Console.WriteLine("Press any key to exit");
    Console.ReadKey();
    Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}

最后,编写DisplayMainScreenError()方法:

private static void DisplayMainScreenError()
{
    Console.WriteLine("------------------------------------");
    Console.WriteLine("Test Platform Student Console");
    Console.WriteLine("------------------------------------");
    Console.WriteLine("You must login to use the student module.");
    Console.WriteLine("Press any key to exit");
    Console.ReadKey();
    Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}

现在我们已经编写了所有三个模块,我们将在下一节中对它们进行测试。

使用 E2E 测试我们的三模块系统

在这一部分,我们将对我们的三模块系统进行手动 E2E 测试。我们将测试登录模块,以确保它只允许有效的登录访问管理员模块或测试模块。当有效的管理员登录到系统时,他们应该看到管理员模块,并且登录模块应该被卸载。当有效的学生登录到系统时,他们应该看到测试模块,并且登录模块应该被卸载。

如果我们尝试加载管理员模块而没有先登录,我们应该被警告我们必须登录。按下任意键应该卸载管理员模块并加载登录模块。尝试在没有登录的情况下使用测试模块应该与管理员模块的行为相同。我们应该被警告除非我们登录否则不能使用测试模块,按下任意键应该加载登录模块并卸载测试模块。

现在让我们通过手动测试过程:

  1. 确保所有项目都已构建,然后运行登录模块。您应该看到以下屏幕:

  1. 输入错误的用户名和/或密码,然后按Enter,您将看到以下屏幕:

  1. 现在,输入admin作为用户名,letmein作为密码,然后按Enter。您应该会看到成功登录的管理员模块屏幕:

  1. 按任意键退出,您应该再次看到登录模块:

  1. 输入student作为用户名,letmein作为密码。按Enter,您应该会看到学生模块:

  1. 现在加载管理员模块而不登录,您应该会看到以下内容:

  1. 按任意键将返回登录模块。现在加载测试模块而不登录,您应该会看到以下内容:

我们已经成功地手动进行了系统的端到端测试,该系统由三个模块组成。这绝对是进行端到端测试时的最佳方式。您的单元测试将非常有用,使得这个阶段相当简单。到达这个阶段时,您的错误应该已经被捕获和处理。但是,像往常一样,总会有可能遇到问题,这就是为什么手动运行整个系统是一个好主意。这样,您可以通过视觉看到您的交互,系统是否按预期行为。

更大的系统使用工厂和依赖注入。在本章的后续部分,我们将分别查看它们,从工厂开始。

工厂

工厂是使用工厂方法模式实现的。此模式的目的是允许创建对象而不指定它们的类。这是通过调用工厂方法来实现的。工厂方法的主要目标是创建类的实例。

您可以在以下情况下使用工厂方法模式:

  • 当类无法预测必须实例化的对象类型

  • 当子类必须指定要实例化的对象类型

  • 当类控制其对象的实例化

考虑以下图表:

如前图所示,您有以下项目:

  • Factory,提供了返回类型的FactoryMethod()的接口

  • ConcreteFactory,覆盖或实现FactoryMethod()以返回具体类型

  • ConcreteObject,继承或实现基类或接口

现在是进行演示的好时机。想象一下,您有三个不同的客户。每个客户都需要使用不同的关系数据库作为后端数据源。您的客户使用的数据库将是 Oracle Database、SQL Server 和 MySQL。

作为端到端测试的一部分,您将需要针对每个数据源进行测试。但是如何编写程序一次,使其适用于这些数据库中的任何一个?这就是“工厂”方法模式发挥作用的地方。

在安装过程中或通过应用程序的初始配置,用户可以指定他们希望用作数据源的数据库。这些信息可以存储在配置文件中,作为加密的数据库连接字符串。当应用程序启动时,它将读取数据库连接字符串并对其进行解密。然后将数据库连接字符串传递到工厂方法中。最后,将选择、实例化并返回适当的数据库连接对象供应用程序使用。

现在您已经有了一些背景知识,让我们在 Visual Studio 中创建一个.NET Framework 控制台应用程序,并将其命名为CH07_Factories。将App.cong文件中的代码替换为以下内容:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <startup> 
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
  </startup>
  <connectionStrings>
    <clear />
    <add name="SqlServer"
         connectionString="Data Source=SqlInstanceName;Initial Catalog=DbName;Integrated Security=True"
         providerName="System.Data.SqlClient"
    />
    <add name="Oracle"
         connectionString="Data Source=OracleInstance;User Id=usr;Password=pwd;Integrated Security=no;"
         providerName="System.Data.OracleClient"
    />
    <add name="MySQL"
         connectionString="Server=MySqlInstance;Database=MySqlDb;Uid=usr;Pwd=pwd;"
         providerName="System.Data.MySqlClient"
    />
 </connectionStrings>
</configuration>

正如你所看到的,前面的代码已经在配置文件中添加了connectionStrings元素。在该元素中,我们清除了任何现有的连接字符串,然后添加了我们将在应用程序中使用的三个数据库连接字符串。为了简化本节的内容,我们使用了未加密的连接字符串,但在生产环境中,请确保您的连接字符串是加密的!

在这个项目中,我们不会使用Program类中的Main()方法。我们将启动Factory类,如下所示:

namespace CH07_Factories
{
    public abstract class Factory
    {
        public abstract IDatabaseConnection FactoryMethod();
    }
}

前面的代码是我们的抽象工厂,其中有一个抽象的FactoryMethod(),返回一个IDatabaseConnection类型。由于它还不存在,我们接下来会添加它:

namespace CH07_Factories
{
    public interface IDatabaseConnection
    {
        string ConnectionString { get; }
        void OpenConnection();
        void CloseConnection();
    }
}

在这个接口中,我们有一个只读的连接字符串,一个名为OpenConnection()的方法来打开数据库连接,以及一个名为CloseConnection()的方法来关闭已打开的数据库连接。到目前为止,我们有了我们的抽象Factory和我们的IDatababaseConnection接口。接下来,我们将创建我们的具体数据库连接类。让我们从 SQL Server 数据库连接类开始:

public class SqlServerDbConnection : IDatabaseConnection
{
    public string ConnectionString { get; }
    public SqlServerDbConnection(string connectionString)
    {
        ConnectionString = connectionString;
    }
    public void CloseConnection()
    {
        Console.WriteLine("SQL Server Database Connection Closed.");
    }
    public void OpenConnection()
    {
        Console.WriteLine("SQL Server Database Connection Opened.");
    }
}

正如你所看到的,SqlServerDbConnection类完全实现了IDatabaseConnection接口。构造函数以connectionString作为单个参数。只读的ConnectionString属性然后被赋值为connectionStringOpenConnection()方法只是在控制台上打印。

然而,在实际实现中,连接字符串将被用于连接到字符串中指定的有效数据源。一旦数据库连接打开,就必须关闭。关闭数据库连接将由CloseConnection()方法执行。接下来,我们重复前面的过程,为 Oracle 数据库连接和 MySQL 数据库连接进行实现:

public class OracleDbConnection : IDatabaseConnection
{
    public string ConnectionString { get; }
    public OracleDbConnection(string connectionString)
    {
        ConnectionString = connectionString;
    }
    public void CloseConnection()
    {
        Console.WriteLine("Oracle Database Connection Closed.");
    }
    public void OpenConnection()
    {
        Console.WriteLine("Oracle Database Connection Closed.");
    }
}

我们现在已经放置了OracleDbConnection类。所以,我们需要实现的最后一个类是MySqlDbConnection类:

public class MySqlDbConnection : IDatabaseConnection
{
    public string ConnectionString { get; }
    public MySqlDbConnection(string connectionString)
    {
        ConnectionString = connectionString;
    }
    public void CloseConnection()
    {
        Console.WriteLine("MySQL Database Connection Closed.");
    }
    public void OpenConnection()
    {
        Console.WriteLine("MySQL Database Connection Closed.");
    }
}

有了这个,我们已经添加了我们的具体类。唯一剩下的事情就是创建我们的ConcreteFactory类,它继承了抽象的Factory类。你需要引用System.Configuration.ConfigurationManager NuGet 包:

using System.Configuration;

namespace CH07_Factories
{
    public class ConcreteFactory : Factory
    {
        private static ConnectionStringSettings _connectionStringSettings;

        public ConcreteFactory(string connectionStringName)
        {
            GetDbConnectionSettings(connectionStringName);
        }

        private static ConnectionStringSettings GetDbConnectionSettings(string connectionStringName)
        {
            return ConfigurationManager.ConnectionStrings[connectionStringName];
        }
    }
}

正如你所看到的,这个类使用了System.Configuration命名空间。ConnectionStringSettings的值存储在_connectionStringSettings成员变量中。这是在接受connectionStringName的构造函数中设置的。名称被传递到GetDbConnectionSettings()方法中。敏锐的读者会发现构造函数中的一个明显错误。

方法被调用了,但成员变量没有被设置。然而,当我们开始运行我们尚未编写的测试时,我们将注意到这个疏忽并加以修复。GetDbConnectionSettings()方法使用ConfigurationManagerConnectionStrings[]数组中读取所需的连接字符串。

现在,是时候通过添加FactoryMethod()来完成我们的ConcreteClass了:

public override IDatabaseConnection FactoryMethod()
{
    var providerName = _connectionStringSettings.ProviderName;
    var connectionString = _connectionStringSettings.ConnectionString;
    switch (providerName)
    {
        case "System.Data.SqlClient":
            return new SqlServerDbConnection(connectionString);
        case "System.Data.OracleClient":
            return new OracleDbConnection(connectionString);
        case "System.Data.MySqlClient":
            return new MySqlDbConnection(connectionString);
        default:
            return null;
    }
}

我们的FactoryMethod()返回一个IDatabaseConnection类型的具体类。在类的开头,成员变量被读取并存储在providerNameconnectionString中。然后使用 switch 来确定要构建和传回什么类型的数据库连接。

现在我们可以测试我们的工厂,看它是否能够处理我们客户使用的不同类型的数据库。这个测试可以手动进行,但为了这个练习的目的,我们将编写自动化测试。

创建一个新的 NUnit 测试项目。添加对CH07_Factories项目的引用。然后添加System.Configuration.ConfigurationManager NuGet 包。将类重命名为UnitTests.cs。现在,添加第一个测试,如下所示:

[Test]
public void IsSqlServerDbConnection()
{
    var factory = new ConcreteFactory("SqlServer");
    var connection = factory.FactoryMethod();
    Assert.IsInstanceOf<SqlServerDbConnection>(connection);
}

这个测试是针对 SQL Server 数据库连接的。它创建了一个新的 ConcreteFactory() 实例,并传入了 "SqlServer"connectionStringName 值。然后工厂通过 FactoryMethod() 实例化并返回正确的数据库连接对象。最后,连接对象被断言以测试它确实是 SqlServerDbConnection 类型的实例。我们需要再为其他数据库连接编写前面的测试两次,所以现在让我们添加 Oracle 数据库连接测试:

[Test]
public void IsOracleDbConnection()
{
    var factory = new ConcreteFactory("Oracle");
    var connection = factory.FactoryMethod();
    Assert.IsInstanceOf<OracleDbConnection>(connection);
}

测试通过了 "Oracle"connectionStringName 值。进行了断言来测试返回的连接对象是否是 OracleDbConnection 类型。最后,我们有我们的 MySQL 数据库连接测试:

[Test]
public void IsMySqlDbConnection()
{
    var factory = new ConcreteFactory("MySQL");
    var connection = factory.FactoryMethod();
    Assert.IsInstanceOf<MySqlDbConnection>(connection);
}

测试通过了 "MySQL"connectionStringName 值。进行了断言来测试返回的连接对象是否是 MySqlDbConnection 类型。如果我们现在运行测试,它们都会失败,因为 _connectionStringSettings 变量没有被设置,所以让我们来修复这个问题。修改你的 ConcreteFactory 构造函数如下:

public ConcreteFactory(string connectionStringName)
{
    _connectionStringSettings = GetDbConnectionSettings(connectionStringName);
}

如果你现在运行所有的测试,它们应该可以工作。如果 NUnit 没有获取到连接字符串,那么它会在一个不同的 App.config 文件中查找,而不是你期望的那个。在读取连接字符串的那一行之前添加以下行:

var filepath = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None).FilePath;

这将告诉你 NUnit 正在查找你的连接字符串设置。如果文件不存在,你可以手动创建它,并从你的主 App.config 文件中复制内容。但这样做的问题是,该文件很可能会在下次构建时被删除。因此,为了使更改永久生效,你可以向你的测试项目添加后期构建事件命令行。

要做到这一点,右键单击你的测试项目,选择属性。然后在属性选项卡上,选择“构建事件”。在后期构建事件命令行中,添加以下命令:

xcopy "$(ProjectDir)App.config" "$(ProjectDir)bin\Debug\netcoreapp3.1\" /Y /I /R

以下截图显示了项目属性对话框的“构建事件”页面,其中包含了后期构建事件命令行:

这将在测试项目输出文件夹中创建缺失的文件。在你的系统上,该文件可能被命名为 testhost.x86.dll.config,因为在我的系统上是这样的。现在,你的构建应该可以工作了。

如果你改变 FactoryMethod() 中某个 case 的返回类型,你会发现你的测试失败,如下截图所示:

将代码改回正确的类型,这样你的代码现在就可以通过了。

我们已经看到了如何手动 E2E 测试系统,以及如何使用软件工厂,并且我们如何自动测试我们的工厂是否按预期工作。现在我们将看看依赖注入以及如何进行 E2E 测试。

依赖注入

依赖注入DI)帮助你通过将代码的行为与其依赖项分离来生成松耦合的代码,这导致了更易于测试、扩展和维护的可读代码。代码更易于阅读,因为你遵循了单一职责原则。这也导致了更小的代码。更小的代码更容易维护和测试,因为我们依赖于抽象而不是实现,所以我们可以根据需要更轻松地扩展代码。

以下是你可以实现的 DI 类型:

  • 构造函数注入

  • 属性/设置器注入

  • 方法注入

穷人的 DI 是不使用容器构建的。然而,推荐的最佳实践是使用 DI 容器。简单来说,DI 容器是一个注册框架,它在请求时实例化依赖项并注入它们。

现在,我们将为我们的 DI 示例编写自己的依赖容器、接口、服务和客户端。然后我们将为依赖项目编写测试。请记住,即使测试应该首先编写,在我遇到的大多数业务情况下,它们是在软件编写后编写的!因此,在这种情况下,我们将在编写所需软件后编写测试。当您雇用多个团队时,其中一些团队使用 TDD,而另一些团队不使用 TDD,或者您使用第三方代码而没有测试时,这种情况经常发生。

我们之前提到过,E2E 最好手动完成,自动化很困难,但您可以自动化系统的测试,以及进行手动测试。如果您的目标是多个数据源,这将特别有用。

首先,您需要准备好一个依赖容器。依赖容器保留类型和实例的注册。在使用之前,您需要注册类型。当需要使用对象的实例时,您将其解析为变量并注入(传递)到构造函数、方法或属性中。

创建一个新的类库,命名为CH07_DependencyInjection。添加一个名为DependencyContainer的新类,并添加以下代码:

public static readonly IDictionary<Type, Type> Types = new Dictionary<Type, Type>();
public static readonly IDictionary<Type, object> Instances = new Dictionary<Type, object>();

public static void Register<TContract, TImplementation>()
{
    Types[typeof(TContract)] = typeof(TImplementation);
}

public static void Register<TContract, TImplementation>(TImplementation instance)
{
    Instances[typeof(TContract)] = instance;
}

在此代码中,我们有两个字典,其中包含类型和实例。我们还有两种方法。一种用于注册我们的类型,另一种用于注册我们的实例。现在我们已经有了注册和存储类型和实例的代码,我们需要一种在运行时解析它们的方法。将以下代码添加到DependencyContainer类中:

public static T Resolve<T>()
{
    return (T)Resolve(typeof(T));
}

此方法传入一个类型。它调用解析类型的方法并返回该类型的实例。因此,现在让我们添加该方法:

public static object Resolve(Type contract)
{
    if (Instances.ContainsKey(contract))
    {
        return Instances[contract];
    }
    else
    {
        Type implementation = Types[contract];
        ConstructorInfo constructor = implementation.GetConstructors()[0];
        ParameterInfo[] constructorParameters = constructor.GetParameters();
        if (constructorParameters.Length == 0)
        {
            return Activator.CreateInstance(implementation);
        }
        List<object> parameters = new List<object>(constructorParameters.Length);
        foreach (ParameterInfo parameterInfo in constructorParameters)
        {
            parameters.Add(Resolve(parameterInfo.ParameterType));
        }
        return constructor.Invoke(parameters.ToArray());
    }
}

Resolve()方法检查Instances字典是否包含与合同匹配的实例。如果是,则返回该实例。否则,创建并返回一个新实例。

现在,我们需要一个接口,我们要注入的服务将实现该接口。我们将其命名为IService。它将有一个返回字符串的单个方法,该方法将被命名为WhoAreYou()

public interface IService
{
    string WhoAreYou();
}

我们要注入的服务将实现上述接口。我们的第一个类将被命名为ServiceOne,并且该方法将返回字符串"CH07_DependencyInjection.ServiceOne()"

public class ServiceOne : IService
{
    public string WhoAreYou()
    {
        return "CH07_DependencyInjection.ServiceOne()";
    }
}

第二个服务与第一个服务相同,只是它被称为ServiceTwo,并且该方法返回字符串"CH07_DependencyInjection.ServiceTwo()"

public class ServiceTwo : IService
{
    public string WhoAreYou()
    {
        return "CH07_DependencyInjection.ServiceTwo()";
    }
}

依赖容器、接口和服务类现在已经就位。最后,我们将添加客户端,该客户端将用作演示对象,通过 DI 来使用我们的服务。我们的类将演示构造函数注入、属性注入和方法注入。将以下代码添加到类的顶部:

private IService _service;

public Client() { }

_service成员变量将用于存储我们注入的服务。我们有一个默认构造函数,以便我们可以测试我们的属性和方法注入。添加接受并设置IService成员的构造函数:

public Client (IService service) 
{
    _service = service;
}

接下来,我们将添加用于测试属性注入和构造函数注入的属性:

public IService Service
{
    get { return _service; }
    set
    {
        _service = value;
    }
}

然后我们将添加一个调用WhoAreYou()的方法注入对象。Service属性允许设置和检索_service成员变量。最后,我们将添加GetServiceName()方法:

public string GetServiceName(IService service)
{
    return service.WhoAreYou();
}

GetServiceName()方法在IService类的注入实例上调用。此方法返回传入的服务的完全限定名称。现在我们将编写单元测试来测试功能。添加一个测试项目并引用依赖项目。将测试项目命名为CH07_DependencyInjection.Tests,并将UnitTest1重命名为UnitTests

我们将编写测试来检查我们实例的注册和解析是否有效,并且正确的类是否通过构造函数注入、setter 注入和方法注入。我们的测试将测试ServiceOneServiceTwo的注入。让我们从编写以下Setup()方法开始:

[TestInitialize]
public void Setup()
{
    DependencyContainer.Register<ServiceOne, ServiceOne>();
    DependencyContainer.Register<ServiceTwo, ServiceTwo>();
}

在我们的Setup()方法中,我们注册了IService类的两个实现,即ServiceOne()ServiceTwo()。现在我们将编写两个测试方法来测试依赖容器:

[TestMethod]
public void DependencyContainerTestServiceOne()
{
    var serviceOne = DependencyContainer.Resolve<ServiceOne>();
    Assert.IsInstanceOfType(serviceOne, typeof(ServiceOne));
}

[TestMethod]
public void DependencyContainerTestServiceTwo()
{
    var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
    Assert.IsInstanceOfType(serviceTwo, typeof(ServiceTwo));
}

这两种方法都调用Resolve()方法。该方法检查类型的实例。如果实例存在,则返回它。否则,将实例化并返回。现在是时候为serviceOneserviceTwo编写构造函数注入测试了:

[TestMethod]
public void ConstructorInjectionTestServiceOne()
{
    var serviceOne = DependencyContainer.Resolve<ServiceOne>();
    var client = new Client(serviceOne);
    Assert.IsInstanceOfType(client.Service, typeof(ServiceOne));
}

[TestMethod]
public void ConstructorInjectionTestServiceTwo()
{
    var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
    var client = new Client(serviceTwo);
    Assert.IsInstanceOfType(client.Service, typeof(ServiceTwo));
}

在这两个构造函数测试方法中,我们从容器注册表中解析相关服务。然后将服务传递到构造函数中。最后,使用Service属性,我们断言通过构造函数传入的服务是预期服务的实例。让我们编写测试以显示属性 setter 注入按预期工作:

[TestMethod]
public void PropertyInjectTestServiceOne()
{
    var serviceOne = DependencyContainer.Resolve<ServiceOne>();
    var client = new Client();
    client.Service = serviceOne;
    Assert.IsInstanceOfType(client.Service, typeof(ServiceOne));
}

[TestMethod]
public void PropertyInjectTestServiceTwo()
{
    var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
    var client = new Client();
    client.Service = serviceTwo;
    Assert.IsInstanceOfType(client.Service, typeof(ServiceOne));
}

为了测试 setter 注入是否解析了我们想要的类,使用默认构造函数创建一个客户端,然后将解析后的实例分配给Service属性。接下来,我们断言服务是否是预期类型的实例。最后,对于我们的测试,我们只需要测试我们的方法注入:

[TestMethod]
public void MethodInjectionTestServiceOne()
{
    var serviceOne = DependencyContainer.Resolve<ServiceOne>();
    var client = new Client();
    Assert.AreEqual(client.GetServiceName(serviceOne), "CH07_DependencyInjection.ServiceOne()");
}

[TestMethod]
public void MethodInjectionTestServiceTwo()
{
    var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
    var client = new Client();
    Assert.AreEqual(client.GetServiceName(serviceTwo), "CH07_DependencyInjection.ServiceTwo()");
}

在这里,我们再次解析我们的实例。使用默认构造函数创建一个新的客户端,并断言传入的解析实例和调用GetServiceName()方法返回传入实例的正确标识。

模块化

系统由一个或多个模块组成。当一个系统包含两个或多个模块时,您需要测试它们之间的交互,以确保它们按预期一起工作。让我们考虑一下以下图表中 API 的系统:

从前面的图表中可以看出,我们有一个客户端通过 API 访问云中的数据存储。客户端向 HTTP 服务器发送请求。请求经过身份验证。一旦经过身份验证,请求就被授权访问 API。客户端发送的数据被反序列化,然后传递到业务层。业务层然后在数据存储上执行读取、插入、更新或删除操作。数据然后通过业务层从数据库传回客户端,然后通过序列化层传回客户端。

正如您所看到的,我们有许多相互交互的模块。我们有以下内容:

  • 安全(身份验证和授权)与序列化(序列化和反序列化)进行交互

  • 与包含所有业务逻辑的业务层进行交互的序列化

  • 业务逻辑层与数据存储进行交互

如果我们看一下前面的三点,我们可以看到可以编写许多测试来自动化 E2E 测试过程。许多测试本质上是单元测试,这些测试成为我们的集成测试套件的一部分。现在让我们考虑一些。我们能够测试以下内容:

  • 正确的登录

  • 登录错误

  • 授权访问

  • 未经授权的访问

  • 数据序列化

  • 数据反序列化

  • 业务逻辑

  • 数据库读取

  • 数据库更新

  • 数据库插入

  • 数据库删除

从这些测试中可以看出,它们是集成测试的单元测试。那么,我们可以编写哪些集成测试呢?嗯,我们可以编写以下测试:

  • 发送读取请求。

  • 发送插入请求。

  • 发送编辑请求。

  • 发送删除请求。

这四个测试可以使用正确的用户名和密码以及格式良好的数据请求进行编写,也可以针对无效的用户名或密码和格式不良的数据请求进行编写。

因此,您可以通过使用单元测试来测试每个模块中的代码,然后使用仅测试两个模块之间交互的测试来执行集成测试。您还可以编写执行完整 E2E 操作的测试。

尽管可以使用代码测试所有这些,但你必须手动运行系统,以验证一切是否按预期工作。

所有这些测试都成功完成后,您可以放心地将代码发布到生产环境。

现在我们已经介绍了 E2E 测试(也称为集成测试),让我们花点时间总结一下我们学到的东西。

总结

在本章中,我们了解了什么是 E2E 测试。我们看到我们可以编写自动化测试,但我们也意识到了从最终用户的角度手动测试完整应用程序的重要性。

当我们研究工厂时,我们看到了它们在数据库连接方面的使用示例。我们考虑了一个场景,即我们的应用程序将允许用户使用他们选择的数据库。我们加载连接字符串,然后根据该连接字符串实例化并返回相关的数据库连接对象供使用。我们看到了如何可以针对每个不同数据库的每个用例测试我们的工厂。工厂可以在许多不同的场景中使用,现在您知道它们是什么,如何使用它们,最重要的是,您知道如何测试它们。

DI 使单个类能够与接口的多个不同实现一起工作。当我们编写自己的依赖容器时,我们看到了这一点。我们创建的接口由两个类实现,添加到依赖注册表中,并在依赖容器调用时解析。我们实施了单元测试,以测试构造函数注入、属性注入和方法注入的不同实现。

然后,我们看了模块。一个简单的应用程序可能只包含一个模块,但随着应用程序复杂性的增加,组成该应用程序的模块也会增加。模块数量的增加也增加了出错的机会。因此,测试模块之间的交互非常重要。模块本身可以使用单元测试进行测试。模块之间的交互可以使用更复杂的测试进行测试,从头到尾运行完整的场景。

在下一章中,我们将探讨在处理线程和并发时的最佳实践。但首先,让我们测试一下你对本章内容的了解。

问题

  1. 什么是 E2E 测试?

  2. E2E 测试的另一个术语是什么?

  3. 在 E2E 测试期间我们应该采用什么方法?

  4. 工厂是什么,为什么我们要使用它们?

  5. DI 是什么?

  6. 为什么我们应该使用依赖容器?

进一步阅读

  • Manning 的书《.NET 中的依赖注入》将在向您介绍.NET DI 之前,引导您了解各种 DI 框架。

第八章:线程和并发

进程本质上是在操作系统上执行的程序。这个进程由多个执行线程组成。执行线程是由进程发出的一组命令。能够同时执行多个线程的能力称为多线程。在本章中,我们将研究多线程和并发。

多个线程被分配一定的执行时间,并且每个线程都由线程调度程序按轮换方式执行。线程调度程序使用一种称为时间片分配的技术来调度线程,然后在预定的时间将每个线程传递给 CPU 执行。

并发是能够同时运行多个线程的能力。这可以在具有多个处理器核心的计算机上实现。计算机的处理器核心越多,就可以同时执行更多的执行线程。

当我们在本章中研究并发和线程时,我们将遇到阻塞、死锁和竞争条件的问题。您将看到我们如何使用清晰的编码技术来克服这些问题。

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

  • 理解线程生命周期

  • 添加线程参数

  • 使用线程池

  • 使用互斥对象与同步线程

  • 使用信号量处理并行线程

  • 限制线程池中的处理器和线程数量

  • 防止死锁

  • 防止竞争条件

  • 理解静态构造函数和方法

  • 可变性、不可变性和线程安全

  • 同步方法依赖

  • 使用Interlocked类进行简单状态更改

  • 一般建议

通过学习本章并发编程技能,您将获得以下技能:

  • 理解和讨论线程生命周期的能力

  • 理解和使用前台和后台线程的能力

  • 通过线程池限制线程数量和设置并发使用的处理器数量的能力

  • 理解静态构造函数和方法对多线程和并发的影响

  • 考虑可变性和不可变性及其对线程安全的影响的能力

  • 理解竞争条件的原因以及如何避免它们的能力

  • 理解死锁的原因以及如何避免它们的能力

  • 使用Interlocked类执行简单的状态更改的能力

要运行本章中的代码,您需要一个.NET Framework 控制台应用程序。除非另有说明,所有代码将放在Program类中。

理解线程生命周期

C#中的线程有一个相关的生命周期。线程的生命周期如下:

当线程启动时,它进入运行状态。在运行时,线程可以进入等待睡眠加入停止挂起状态。线程也可以被中止。中止的线程进入停止状态。您可以通过分别调用Suspend()Resume()方法来挂起和恢复线程。

当调用Monitor.Wait(object obj)方法时,线程将进入等待状态。然后当调用Monitor.Pulse(object obj)方法时,线程将继续。通过调用Thread.Sleep(int millisecondsTimeout)方法,线程进入睡眠模式。一旦经过了经过的时间,线程就会返回到运行状态。

Thread.Join()方法使线程进入等待状态。加入的线程将保持在等待状态,直到所有依赖线程都完成运行,然后它将进入运行状态。但是,如果任何依赖线程被中止,那么这个线程也会被中止并进入停止状态。

已完成或已中止的线程无法重新启动。

线程可以在前台或后台运行。让我们先看看前台和后台线程,从前台线程开始:

  • 前台线程:默认情况下,线程在前台运行。只要至少有一个前台线程正在运行,进程就会继续运行。即使Main()完成了,但前台线程正在运行,应用程序进程仍将保持活动状态,直到前台线程终止。创建前台线程非常简单,如下面的代码所示:
var foregroundThread = new Thread(SomeMethodName);
foregroundThread.Start();
  • 后台线程:创建后台线程的方式与创建前台线程的方式相同,只是您还必须显式地将线程设置为后台运行,如下所示:
var backgroundThread = new Thread(SomeMethodName);
backgroundThread.IsBackground = true;
backgroundThread.Start();

后台线程用于执行后台任务并保持用户界面对用户的响应。当主进程终止时,任何正在执行的后台线程也将被终止。但是,即使主进程终止,任何正在运行的前台线程也将运行到完成。

在下一节中,我们将看一下线程参数。

添加线程参数

在线程中运行的方法通常具有参数。因此,在线程中执行方法时,了解如何将方法参数传递到线程中是有用的。

假设我们有以下方法,它将两个整数相加并返回结果:

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

正如您所看到的,该方法很简单。有两个名为ab的参数。这两个参数将需要传递到线程中,以便Add()方法能够正确运行。我们将添加一个示例方法来实现这一点:

private static void ThreadParametersExample()
{
    int result = 0;
    Thread thread = new Thread(() => { result = Add(1, 2); });
    thread.Start();
    thread.Join();
    Message($"The addition of 1 plus 2 is {result}.");
}

在这个方法中,我们声明一个初始值为0的整数。然后我们创建一个调用带有12参数值的Add()方法的新线程,然后将结果赋给整数变量。然后线程开始,我们等待它通过调用Join()方法执行完毕。最后,我们将结果打印到控制台窗口。

让我们添加我们的Message()方法:

internal static void Message(string message)
{
    Console.WriteLine(message);
}

Message()方法只是接受一个字符串并将其输出到控制台窗口。现在我们只需要更新Main()方法,如下所示:

static void Main(string[] args)
{
    ThreadParametersExample();
    Message("=== Press any Key to exit ===");
    Console.ReadKey();
}

在我们的Main()方法中,我们调用我们的示例方法,然后等待用户按任意键退出。您应该看到以下输出:

正如你所看到的,12是传递给加法方法的方法参数,3是线程返回的值。我们将要看的下一个主题是使用线程池。

使用线程池

线程池通过在应用程序初始化期间创建一组线程来提高性能。当需要线程时,它被分配一个单独的任务。该任务将被执行。执行完毕后,线程将返回到线程池以便重用。

由于在.NET 中创建线程是昂贵的,我们可以通过使用线程池来提高性能。每个进程都有一定数量的线程,这取决于可用的系统资源,如内存和 CPU。但是,我们可以增加或减少线程池使用的线程数量。通常最好让线程池负责使用多少线程,而不是手动设置这些值。

创建线程池的不同方法如下:

  • 使用任务并行库TPL)(在.NET Framework 4.0 及更高版本)

  • 使用ThreadPool.QueueUserWorkItem()

  • 使用异步委托

  • 使用BackgroundWorker

作为经验法则,您应该只在服务器端应用程序中使用线程池。对于客户端应用程序,根据需要使用前台和后台线程。

在本书中,我们只会看一下TPLQueueUserWorkItem()方法。您可以在www.albahari.com/threading/上查看如何使用其他两种方法。我们接下来将看一下 TPL。

任务并行库

C#中的异步操作由任务表示。C#中的任务由 TPL 中的Task类表示。正如你从名称中可以得知的那样,任务并行使多个任务能够同时执行,我们将在接下来的小节中学习。我们将首先看一下Parallel类的Invoke()方法。

Parallel.Invoke()

在我们的第一个示例中,我们将使用Parallel.Invoke()来调用三个单独的方法。添加以下三个方法:

private static void MethodOne()
{
    Message($"MethodOne Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

private static void MethodTwo()
{
    Message($"MethodTwo Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

private static void MethodThree()
{
    Message($"MethodThree Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

如你所见,这三个方法几乎是相同的,除了它们的名称和通过我们之前编写的Message()方法打印到控制台窗口的消息。现在,我们将添加UsingTaskParallelLibrary()方法来并行执行这三个方法:

private static void UsingTaskParallelLibrary()
{
    Message($"UsingTaskParallelLibrary Started: Thread Id = ({Thread.CurrentThread.ManagedThreadId})");
    Parallel.Invoke(MethodOne, MethodTwo, MethodThree);
    Message("UsingTaskParallelLibrary Completed.");
}

在这个方法中,我们向控制台窗口写入一条消息,指示方法的开始。然后,我们并行调用MethodOneMethodTwoMethodThree方法。然后,我们向控制台窗口写入一条消息,指示方法已经到达结束,然后我们等待按键退出方法。运行代码,你应该看到以下输出:

在上面的截图中,你可以看到线程一被重用。现在让我们转到Parallel.For()循环。

Parallel.For()

在我们下一个 TPL 示例中,我们将看一个简单的Parallel.For()循环。将以下方法添加到新的.NET Framework 控制台应用程序的Program类中:

private static void Method()
{
    Message($"Method Executed: Thread Id({Thread.CurrentThread.ManagedThreadId})");
}

这个方法只是在控制台窗口输出一个字符串。现在,我们将创建执行Parallel.For()循环的方法:

private static void UsingTaskParallelLibraryFor()
{
    Message($"UsingTaskParallelLibraryFor Started: Thread Id = ({Thread.CurrentThread.ManagedThreadId})");
    Parallel.For(0, 1000, X => Method());
    Message("UsingTaskParallelLibraryFor Completed.");
}

在这个方法中,我们循环从01000,调用Method()。你将看到线程如何在不同的方法调用中被重用,如下面的截图所示:

现在,我们将看看如何使用ThreadPool.QueueUserWorkItem()方法。

ThreadPool.QueueUserWorkItem()

ThreadPool.QueueUserWorkItem()方法接受WaitCallback方法并将其排队准备执行。WaitCallback是一个代表回调方法的委托,将由线程池线程执行。当线程可用时,该方法将被执行。让我们添加一个简单的例子。我们将首先添加WaitCallbackMethod

private static void WaitCallbackMethod(Object _)
{
    Message("Hello from WaitCallBackMethod!");
}

这个方法接受一个对象类型。然而,由于参数将不被使用,我们使用丢弃变量(_)。一条消息被打印到控制台窗口。现在,我们只需要调用这个方法的代码:

private static void ThreadPoolQueueUserWorkItem()
{
    ThreadPool.QueueUserWorkItem(WaitCallbackMethod);
    Message("Main thread does some work, then sleeps.");
    Thread.Sleep(1000);
    Message("Main thread exits.");
}

如你所见,我们使用ThreadPool类通过调用QueueUserWorkItem()方法将WaitCallbackMethod()排队到线程池中。然后我们在主线程上做一些工作。主线程然后进入睡眠状态。一个线程从线程池中可用,WaitCallBackMethod()被执行。然后线程被返回到线程池中以便重用。执行返回到主线程,然后完成并终止。

在下一节中,我们将讨论线程锁定对象,即互斥对象mutexes)。

使用互斥体与同步线程

在 C#中,互斥体是一个跨多个进程工作的线程锁定对象。只有能够请求或释放资源的进程才能修改互斥体。当互斥体被锁定时,进程将不得不在队列中等待。当互斥体被解锁时,它就可以被访问。多个线程可以以同步的方式使用相同的互斥体。

使用互斥体的好处是,互斥体是在进入关键代码之前获取的简单锁。当关键代码退出时,该锁将被释放。因为在任何时候只有一个线程在关键代码中,数据将保持一致状态,因为不会出现竞争条件。

使用互斥体有一些缺点:

  • 当现有线程获得锁并且要么进入睡眠状态要么被抢占(无法完成任务)时,线程饥饿就会发生。

  • 当互斥体被锁定时,只有获得锁的线程才能解锁它。没有其他线程可以锁定或解锁它。

  • 一次只允许一个线程进入关键代码段。CPU 时间可能会被浪费,因为互斥体的正常实现可能导致忙等待状态。

现在,我们将编写一个演示互斥体使用的程序。启动一个新的.NET Framework 控制台应用程序。将以下行添加到类的顶部:

private static readonly Mutex _mutex = new Mutex();

在这里,我们声明了一个名为_mutex的原始类型,我们将使用它进行进程间同步。现在,添加一个方法来演示使用互斥体进行线程同步:

private static void ThreadSynchronisationUsingMutex()
{
    try
    {
        _mutex.WaitOne();
        Message($"Domain Entered By: {Thread.CurrentThread.Name}");
        Thread.Sleep(500);
        Message($"Domain Left By: {Thread.CurrentThread.Name}");
    }
    finally
    {
        _mutex.ReleaseMutex();
    }
}

在这个方法中,当前线程被阻塞,直到当前等待句柄接收到信号。然后,当给出信号时,下一个线程可以安全地进入。完成后,其他线程将被解除阻塞,以尝试获得互斥体的所有权。接下来,添加MutexExample()方法:

private static void MutexExample()
{
    for (var i = 1; i <= 10; i++)
    {
        var thread = new Thread(ThreadSynchronisationUsingMutex)
        {
            Name = $"Mutex Example Thread: {i}"
        };
        thread.Start();
    }
}

在这个方法中,我们创建 10 个线程并启动它们。每个线程执行ThreadSynchronisationUsingMutex()方法。现在,最后更新Main()方法:

static void Main(string[] args)
{
    SemaphoreExample();
    Console.ReadKey();
}

Main()方法运行我们的互斥体示例。输出应该类似于以下截图中的输出:

再次运行示例,可能会得到不同的线程编号。如果它们是相同的数字,则它们可能以不同的顺序排列。

现在我们已经看过互斥体,让我们看看信号量。

使用信号量处理并行线程

在多线程应用程序中,一个非负数,称为信号量,在具有12的线程之间共享。在同步方面,1指定等待2指定信号。我们可以将信号量与一些缓冲区相关联,每个缓冲区可以由不同的进程同时处理。

因此,本质上,信号量是可以通过等待和信号操作来修改的整数和二进制原始类型的信号机制。如果没有空闲资源,那么需要资源的进程应执行等待操作,直到信号量值大于 0。信号量可以有多个程序线程,并且可以被任何对象更改,获取资源或释放资源。

使用信号量的优势在于多个线程可以访问关键代码段。信号量在内核中执行,并且与机器无关。如果使用信号量,关键代码段将受到多个进程的保护。与互斥体不同,信号量永远不会浪费处理时间和资源。

与互斥体一样,信号量也有自己的一系列缺点。优先级反转是最大的缺点之一,当高优先级线程被迫等待低优先级拥有线程释放信号量时就会发生。

如果低优先级线程在释放之前被中优先级线程阻止完成,这种情况会更加复杂。这被称为无界优先级反转,因为我们无法预测高优先级线程的延迟。使用信号量时,操作系统必须跟踪所有等待和信号调用。

信号量是按照惯例使用的,但并非强制。您需要按正确的顺序执行等待和信号操作;否则,您的代码可能会出现死锁。由于使用信号量的复杂性,有时可能无法获得互斥体。在大型系统中失去模块化也是另一个缺点,信号量容易出现编程错误,导致死锁和互斥体违规。

现在我们将编写一个演示使用信号量的程序:

private static readonly Semaphore _semaphore = new Semaphore(2, 4); 

我们添加了一个新的信号量变量。第一个参数表示可以同时授予的信号量的初始请求数。第二个参数表示可以同时授予的信号量的最大请求数。添加StartSemaphore()方法:

private static void StartSemaphore(object id)
{
    Console.WriteLine($"Object {id} wants semaphore access.");
 try
 {
 _semaphore.WaitOne();
 Console.WriteLine($"Object {id} gained semaphore access.");
 Thread.Sleep(1000);
 Console.WriteLine($"Object {id} has exited semaphore.");
 }
 finally
 {
 _semaphore.Release();
 }
}

当前线程被阻塞,直到当前等待句柄接收到一个信号。然后线程可以执行它的工作。最后,信号量被释放,计数返回到之前的计数。现在,添加SemaphoreExample()方法:

private static void SemaphoreExample()
{
    for (int i = 1; i <= 10; i++)
    {
        Thread t = new Thread(StartSemaphore);
        t.Start(i);
    }
}

这个例子生成 10 个线程,这些线程执行StartSemaphore()方法。让我们更新Main()方法来运行这段代码:

static void Main(string[] args)
{
    SemaphoreExample();
    Console.ReadKey();
}

Main()方法调用SemaphoreExample(),然后等待用户按键退出。你应该看到以下输出:

让我们继续看如何限制线程池中处理器和线程的数量。

限制线程池中处理器和线程的数量

有时候你可能需要限制计算机程序使用的处理器和线程的数量。

为了减少程序使用的处理器数量,你获取当前进程并设置它的处理器亲和性值。例如,假设我们有一台四核计算机,我们想将使用限制在前两个核心上。前两个核心的二进制值是11,在整数形式中是3。现在,让我们添加一个方法到一个新的.NET Framework 控制台应用程序,并称其为AssignCores()

private static void AssignCores(int cores)
{
    Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(cores);
}

我们向方法传递一个整数。这个整数值将被.NET Framework 转换为一个二进制值。这个二进制值将使用由1值确定的处理器。对于二进制值为0,处理器将不会被使用。因此,由二进制数表示的机器码,01106)将使用核心2311003)将使用核心12001112)将使用核心34

如果你需要关于二进制的复习,请参考www.computerhope.com/jargon/b/binary.htm

现在,为了设置最大线程数,我们在ThreadPool类上调用SetMaxThreads()方法。这个方法接受两个参数,都是整数。第一个参数是线程池中工作线程的最大数量,第二个参数是线程池中异步 I/O 线程的最大数量。现在,我们将添加我们的方法来设置最大线程数:

private static void SetMaxThreads(int workerThreads, int asyncIoThreads)
{
    ThreadPool.SetMaxThreads(workerThreads, asyncIoThreads);
}

正如你所看到的,设置程序中线程的最大数量和处理器是非常简单的。大多数情况下,你不需要在程序中这样做。手动设置线程和/或处理器数量的主要原因是如果你的程序遇到性能问题。如果你的程序没有遇到性能问题,最好不要设置线程或处理器的数量。

下一个我们将要看的主题是死锁。

防止死锁

死锁发生在两个或更多线程执行并且互相等待对方完成时。当计算机程序出现这个问题时,会出现挂起的情况。对于最终用户来说,这可能非常糟糕,并且可能导致数据的丢失或损坏。一个例子是执行两批数据输入,其中一半事务崩溃并且无法回滚。这是不好的;让我用一个例子来解释为什么。

考虑一个重大的银行交易,将 100 万英镑从客户的企业银行账户中取出,用于支付女王陛下的税务和海关(HMRC)的税单。资金从企业账户中取出,但在将资金存入 HMRC 银行账户之前,发生了死锁。没有恢复选项,因此必须终止并重新启动应用程序。因此,企业银行账户减少了 100 万英镑,但 HMRC 税单尚未支付。客户仍然有责任支付税单。但已从账户中取出的资金会发生什么?因此,您可以看到由于可能引起的问题,消除死锁发生的可能性的重要性。

为了简化问题,我们将处理两个线程,如下图所示:

我们将称我们的线程为 Thread 1 和 Thread 2,我们的资源为 Resource 1 和 Resource 2。Thread 1 在 Resource 1 上获取锁。Thread 2 在 Resource 2 上获取锁。Thread 1 需要访问 Resource 2,但必须等待,因为 Thread 2 已锁定 Resource 2。Thread 2 需要访问 Resource 1,但必须等待,因为 Thread 1 已锁定 Resource 1。这导致 Thread 1 和 Thread 2 都处于等待状态。由于没有线程可以继续,直到另一个线程释放其资源,因此两个线程都处于死锁状态。当计算机程序处于死锁状态时,它会挂起,强制您终止该程序。

死锁的代码示例将是说明这一点的好方法,因此在下一节中,我们将编写一个死锁示例。

编写死锁示例

理解这一点的最好方法是通过一个工作示例。我们将编写一些代码,其中包含两个具有两个不同锁的方法。它们将锁定彼此方法需要的对象。因为每个线程锁定了另一个线程需要的资源,所以它们都将进入死锁状态。一旦我们的示例工作正常,我们将修改代码,使我们的代码能够从死锁情况中恢复并继续。

创建一个新的.NET Framework 控制台应用程序,并将其命名为CH08_Deadlocks。我们将需要两个对象作为成员变量,因此让我们添加它们:

static object _object1 = new object();
 static object _object2 = new object();

这些对象将用作我们的锁对象。我们将有两个线程,每个线程将执行自己的方法。现在,在您的代码中添加Thread1Method()

private static void Thread1Method()
 {
     Console.WriteLine("Thread1Method: Thread1Method Entered.");
     lock (_object1)
     {
         Console.WriteLine("Thread1Method: Entered _object1 lock. Sleeping...");
         Thread.Sleep(1000);
         Console.WriteLine("Thread1Method: Woke from sleep");
         lock (_object2)
         {
             Console.WriteLine("Thread1Method: Entered _object2 lock.");
         }
         Console.WriteLine("Thread1Method: Exited _object2 lock.");
     }
     Console.WriteLine("Thread1Method: Exited _object1 lock.");
 }

Thread1Method()_object1上获取锁。然后休眠 1 秒。醒来时,在_object2上获得锁。然后该方法退出两个锁并终止。

Thread2Method()_object2上获取锁。然后休眠 1 秒。醒来时,在_object1上获得锁。然后该方法退出两个锁并终止:

private static void Thread2Method()
 {
     Console.WriteLine("Thread2Method: Thread1Method Entered.");
     lock (_object2)
     {
         Console.WriteLine("Thread2Method: Entered _object2 lock. Sleeping...");
         Thread.Sleep(1000);
         Console.WriteLine("Thread2Method: Woke from sleep.");
         lock (_object1)
         {
             Console.WriteLine("Thread2Method: Entered _object1 lock.");
         }
         Console.WriteLine("Thread2Method: Exited _object1 lock.");
     }
     Console.WriteLine("Thread2Method: Exited _object2 lock.");
 }

好吧,我们现在已经有了两种方法来演示死锁。我们只需要编写调用它们的代码,以一种会导致死锁的方式。让我们添加DeadlockNoRecovery()方法:

private static void DeadlockNoRecovery()
 {
     Thread thread1 = new Thread((ThreadStart)Thread1Method);
     Thread thread2 = new Thread((ThreadStart)Thread2Method);

     thread1.Start();
     thread2.Start();

     Console.WriteLine("Press any key to exit.");
     Console.ReadKey();
 }

DeadlockNoRecovery()方法中,我们创建两个线程。每个线程分配一个不同的方法。然后启动每个线程。然后,程序暂停,直到用户按下键。现在,更新Main()方法并运行您的代码:

static void Main()
 {
     DeadlockNoRecovery();
 }

运行程序时,您应该看到以下输出:

如您所见,因为thread1已锁定_object1,所以thread2无法获取_object1的锁。同样,因为thread2已锁定_object2,所以thread1无法获取_object2的锁。因此,两个线程都处于死锁状态,程序挂起。

我们现在将编写一些代码,演示如何避免发生这种死锁情况。我们将使用Monitor.TryLock()方法尝试在一定毫秒数内获得锁。然后我们将使用Monitor.Exit()退出成功的锁。

现在,添加DeadlockWithRecovery()方法:

private static void DeadlockWithRecovery()
 {
     Thread thread4 = new Thread((ThreadStart)Thread4Method);
     Thread thread5 = new Thread((ThreadStart)Thread5Method);

     thread4.Start();
     thread5.Start();

     Console.WriteLine("Press any key to exit.");
     Console.ReadKey();
 }

DeadlockWithRecovery()方法创建两个前台线程。然后启动线程,在控制台打印一条消息,并等待用户按键退出。现在我们将为Thread4Method()添加代码:

private static void Thread4Method()
 {
     Console.WriteLine("Thread4Method: Entered _object1 lock. Sleeping...");
     Thread.Sleep(1000);
     Console.WriteLine("Thread4Method: Woke from sleep");
     if (!Monitor.TryEnter(_object1))
     {
         Console.WriteLine("Thead4Method: Failed to lock _object1.");
         return;
     }
     try
     {
         if (!Monitor.TryEnter(_object2))
         {
             Console.WriteLine("Thread4Method: Failed to lock _object2.");
             return;
         }
         try
         {
             Console.WriteLine("Thread4Method: Doing work with _object2.");
         }
         finally
         {
             Monitor.Exit(_object2);
             Console.WriteLine("Thread4Method: Released _object2 lock.");
         }
     }
     finally
     {
         Monitor.Exit(_object1);
         Console.WriteLine("Thread4Method: Released _object2 lock.");
     }
 }

Thread4Method()休眠 1 秒。然后尝试锁定_object1。如果无法锁定_object1,则从方法返回。如果成功锁定_object1,则尝试锁定_object2。如果无法锁定_object2,则从方法返回。如果成功锁定_object2,则对_object2执行必要的工作。然后释放_object2的锁,然后释放_object1的锁。

我们的Thread5Method()方法做的事情完全相同,只是对象_object1_object2以相反的顺序被锁定:

private static void Thread5Method()
 {
     Console.WriteLine("Thread5Method: Entered _object2 lock. Sleeping...");
     Thread.Sleep(1000);
     Console.WriteLine("Thread5Method: Woke from sleep");
     if (!Monitor.TryEnter(_object2))
     {
         Console.WriteLine("Thead5Method: Failed to lock _object2.");
         return;
     }
     try
     {
         if (!Monitor.TryEnter(_object1))
         {
             Console.WriteLine("Thread5Method: Failed to lock _object1.");
             return;
         }
         try
         {
             Console.WriteLine("Thread5Method: Doing work with _object1.");
         }
         finally
         {
             Monitor.Exit(_object1);
             Console.WriteLine("Thread5Method: Released _object1 lock.");
         }
     }
     finally
     {
         Monitor.Exit(_object2);
         Console.WriteLine("Thread5Method: Released _object2 lock.");
     }
 }

现在,将DeadlockWithRecovery()方法调用添加到你的Main()方法中:

static void Main()
 {
     DeadlockWithRecovery();
 }

然后运行你的代码几次。大多数情况下,你会看到以下截图中的情况,所有锁都已成功获取:

然后按任意键,程序将退出。如果继续运行程序,最终会发现一个锁定失败。程序在Thread5Method()中无法获取_object2的锁。但是,如果按任意键,程序将退出。如你所见,通过使用Monitor.TryEnter(),你可以尝试锁定一个对象。但如果未获得锁定,则可以在程序挂起的情况下采取其他操作。

在下一节中,我们将看看如何防止竞争条件。

防止竞争条件

当多个线程使用相同的资源产生不同的结果,这是由于每个线程的时间安排不同,这被称为竞争条件。我们现在将演示这一点。

在我们的演示中,将有两个线程。每个线程将调用一个打印字母的方法。一个方法将使用大写字母打印字母表。第二个方法将使用小写字母打印字母表。从演示中,我们将看到输出是错误的,每次运行程序时,输出都将是错误的。

首先,添加ThreadingRaceCondition()方法:

static void ThreadingRaceCondition()
 {
     Thread T1 = new Thread(Method1);
     T1.Start();
     Thread T2 = new Thread(Method2);
     T2.Start();
 }

ThreadingRaceCondition()产生两个线程并启动它们。它还引用两个方法。Method1()以大写字母打印字母表,Method2()以小写字母打印字母表。让我们添加Method1()Method2()

static void Method1()
 {
     for (_alphabetCharacter = 'A'; _alphabetCharacter <= 'Z'; _alphabetCharacter ++)
     {
         Console.Write(_alphabetCharacter + " ");
     }
 }

private static void Method2()
 {
     for (_alphabetCharacter = 'a'; _alphabetCharacter <= 'z'; _alphabetCharacter++)
     {
         Console.Write(_alphabetCharacter + " ");
     }
 }

Method1()Method2()都引用_alphabetCharacter变量。因此,在类的顶部添加该成员:

private static char _alphabetCharacter;

现在,更新MainMethod()

static void Main(string[] args)
 {
     Console.WriteLine("\n\nRace Condition:");
     ThreadingRaceCondition();
     Console.WriteLine("\n\nPress any key to exit.");
     Console.ReadKey();
 }

我们现在已经准备好演示竞争条件的代码。如果多次运行程序,你会发现结果不是我们期望的。甚至会看到不属于字母表的字符:

不完全是我们期望的,是吗?

我们将使用 TPL 来解决这个问题。TPL 的目标是简化并行性并发性。由于今天大多数计算机都有两个或更多处理器,TPL 将动态地扩展并发度,以充分利用所有可用的处理器。

TPL 还执行工作分区、线程池中的线程调度、取消支持、状态管理等。本章的进一步阅读部分中可以找到官方 Microsoft TPL 文档的链接。

你将看到上述问题的解决方案是多么简单。我们有一个运行Method1()的任务。任务然后继续执行Method2()。然后我们调用Wait()等待任务完成执行。现在,在你的源代码中添加ThreadingRaceConditionFixed()方法:

static void ThreadingRaceConditionFixed()
 {
     Task
         .Run(() => Method1())
         .ContinueWith(task => Method2())
         .Wait();
 }

修改你的Main()方法如下:

static void Main(string[] args)
 {
     //Console.WriteLine("\n\nRace Condition:");
     //ThreadingRaceCondition();
     Console.WriteLine("\n\nRace Condition Fixed:");
     ThreadingRaceConditionFixed();
     Console.WriteLine("\n\nPress any key to exit.");
     Console.ReadKey();
 }

现在运行代码。如果多次运行,你会发现输出总是相同的,如下截图所示:

到目前为止,我们已经了解了线程是什么以及如何在前台和后台使用它们。我们还看了死锁以及如何使用Monitor.TryEnter()解决它们。最后,我们看了什么是竞争条件以及如何使用 TPL 解决它们。

现在,我们将继续查看静态构造函数和方法。

理解静态构造函数和方法

如果多个类需要同时访问一个属性实例,则其中一个线程将被要求运行静态构造函数(也称为类型初始化程序)。在等待类型初始化程序运行时,所有其他线程将被锁定。一旦类型初始化程序运行,被锁定的线程将被解锁,并能够访问Instance属性。

静态构造函数是线程安全的,因为它们保证每个应用程序域只运行一次。它们在访问任何静态成员和执行任何类实例化之前执行。

如果静态构造函数中引发异常并且逃逸,那么将生成TypeInitializationException,这会导致 CLR 退出您的程序。

在任何线程可以访问一个类之前,静态初始化程序和静态构造函数必须执行完成。

静态方法只在类型级别保留方法及其数据的单个副本。这意味着相同的方法及其数据将在不同实例之间共享。应用程序中的每个线程都有自己的堆栈。传递给静态方法的值类型是在调用线程的堆栈上创建的,因此它们是线程安全的。这意味着如果两个线程调用相同的代码并传递相同的值,那么该值将有两个副本,分别在每个线程的堆栈上。因此,多个线程不会相互影响。

但是,如果您有一个访问成员变量的静态方法,那么它就不是线程安全的。两个不同的线程调用相同的方法,因此两者都将访问成员变量。在线程之间发生进程或上下文切换;每个线程将访问并修改成员变量。这会导致竞争条件,正如您在本章前面看到的那样。

如果将引用类型传递给静态方法,也会遇到问题,因为不同的线程将可以访问相同的引用类型。这也会导致竞争条件。

在使用将在多个线程之间使用的静态方法时,避免访问成员变量并且不要传递引用类型。只要传递原始类型并且不修改状态,静态方法就是线程安全的。

现在我们已经讨论了静态构造函数和方法,我们将运行一些示例代码。

向我们的示例代码添加静态构造函数

启动一个新的.NET Framework 控制台应用程序。向项目添加一个名为StaticConstructorTestClass的类。然后,添加一个名为_message的只读静态字符串变量:

public class StaticConstructorTestClass
{
    private readonly static string _message;
}

Message()方法通过_message变量将消息返回给调用者。现在让我们编写Message()方法:

public static string Message()
{
    return $"Message: {_message}";
}

该方法返回存储在_message变量中的消息。现在,我们需要编写我们的构造函数:

static StaticConstructorTestClass()
{
    Console.WriteLine("StaticConstructorTestClass static constructor started.");
    _message = "Hello, World!";
    Thread.Sleep(1000);
    _message = "Goodbye, World!";
    Console.WriteLine("StaticConstructorTestClass static constructor finished.");
}

在我们的构造函数中,我们向屏幕写入一条消息。然后,我们设置成员变量并让线程休眠一秒钟。然后,我们再次设置消息并向控制台写入另一条消息。现在,在Program类中,更新Main()方法如下:

static void Main(string[] args)
{
    var program = new Program();
    program.StaticConstructorExample();
    Thread.CurrentThread.Join();
}

我们的Main()方法实例化Program类。然后调用StaticConstructorExample()方法。当程序停止并且我们可以看到结果时,我们加入线程。您可以在以下截图中看到输出:

现在我们将看一些静态方法的示例。

向我们的示例代码添加静态方法

我们现在将看看线程安全的静态方法和非线程安全的方法是如何运作的。向新的.NET Framework 控制台应用程序添加一个名为StaticExampleClass的新类。然后,添加以下代码:

public static class StaticExampleClass
{
    private static int _x = 1;
    private static int _y = 2;
    private static int _z = 3;
}

在我们的类顶部,我们添加了三个整数——_x_y_z——分别为123。这些变量可以在线程之间修改。现在,我们将添加一个静态构造函数来打印这些变量的值:

static StaticExampleClass()
{
    Console.WriteLine($"Constructor: _x={_x}, _y={_y}, _z={_z}");
}

如您所见,静态构造函数只是将变量的值打印到控制台窗口。我们的第一个方法将是一个名为ThreadSafeMethod()的线程安全方法:

internal static void ThreadSafeMethod(int x, int y, int z)
{
    Console.WriteLine($"ThreadSafeMethod: x={x}, y={y}, z={z}");
    Console.WriteLine($"ThreadSafeMethod: {x}+{y}+{z}={x+y+z}");
}

这个方法是线程安全的,因为它只对值参数进行操作。它不与成员变量交互,也不包括任何引用值。因此,无论传入什么值,您都将获得预期的结果。

这意味着无论是单个线程还是数百万个线程访问该方法,每个线程的输出都将是您期望的,即使发生上下文切换。以下截图显示了输出:

既然我们已经看过了线程安全的方法,现在我们应该看看非线程安全的方法。到目前为止,您已经知道操作引用值或静态成员变量的静态方法是不线程安全的。

在我们的下一个示例中,我们将使用一个与ThreadSafeMethod()具有相同三个参数的方法,但这次我们将设置成员变量,输出一条消息,睡一会儿,然后醒来再次打印出值。将以下NotThreadSafeMethod()方法添加到StaticExampleClass中:

internal static void NotThreadSafeMethod(int x, int y, int z)
{
    _x = x;
    _y = y;
    _z = z;
    Console.WriteLine(
        $"{Thread.CurrentThread.ManagedThreadId}-NotThreadSafeMethod: _x={_x}, _y={_y}, _z={_z}"
    );
    Thread.Sleep(300);
    Console.WriteLine(
        $"{Thread.CurrentThread.ManagedThreadId}-ThreadSafeMethod: {_x}+{_y}+{_z}={_x + _y + _z}"
    );
}

在这个方法中,我们将成员变量设置为传入方法的值。然后,我们将这些值输出到控制台窗口,并睡眠 300 毫秒。然后,在醒来后,我们再次打印出这些值。在Program类中,更新Main()方法如下:

static void Main(string[] args)
{
    var program = new Program();
    program.ThreadUnsafeMethodCall();
    Console.ReadKey();
}

Main()方法中,我们实例化程序类,调用ThreadUnsafeMethodCall(),然后等待用户按键退出。因此,让我们将ThreadUnsafeMethodCall()添加到Program类中:

private void ThreadUnsafeMethodCall()
{
    for (var index = 0; index < 10; index++)
    {
        var thread = new Thread(() =>
        {
            StaticExampleClass.NotThreadSafeMethod(index + 1, index + 2, index + 3);
        });
        thread.Start();
    }
}

该方法产生 10 个调用StaticExampleClassNotThreadSafeMethod()的线程。如果运行代码,您将看到类似于以下截图的输出:

如您所见,输出不是我们所期望的。这是由于来自不同线程的污染。这很好地引出了下一节关于可变性、不可变性和线程安全性。

可变性、不可变性和线程安全

可变性是多线程应用程序中错误的来源。可变 bug 通常是由值被更新并在线程之间共享引起的数据 bug。为了消除可变性 bug 的风险,最好使用不可变类型。多个线程同时对一段代码的安全执行称为线程安全。在处理多线程程序时,重要的是您的代码是线程安全的。如果您的代码消除了竞态条件、死锁以及可变性引起的问题,那么您的代码就是线程安全的。

不可修改的对象在创建后无法修改。一旦创建,如果使用正确的线程同步在线程之间传递,所有线程将看到对象的相同有效状态。不可变对象允许您在线程之间安全地共享数据。

可修改的对象在创建后可以修改。可变对象的数据值可以在线程之间更改。这可能导致严重的数据损坏。因此,即使程序不崩溃,也可能使数据处于无效状态。因此,在处理多个执行线程时,重要的是您的对象是不可变的。在第三章中,类、对象和数据结构,我们介绍了为不可变对象创建和使用不可变数据结构。

为了确保线程安全,不要使用可变对象,通过引用传递参数,或修改成员变量——只通过值传递参数,只操作参数变量。不要访问成员变量。不可变结构是在对象之间传递数据的一种良好且线程安全的方式。

我们将简要介绍可变性、不可变性和线程安全,以下是一些示例。我们将从线程安全的可变性开始。

编写可变且非线程安全的代码

展示多线程应用程序中的可变性,我们将编写一个新的.NET Framework 控制台应用程序。在应用程序中添加一个名为MutableClass的新类,其中包含以下代码:

internal class MutableClass
{
    private readonly int[] _intArray;

    public MutableClass(int[] intArray)
    {
        _intArray = intArray;
    }

    public int[] GetIntArray()
    {
        return _intArray;
    }
}

在我们的MutableClass类中,我们有一个以整数数组作为参数的构造函数。然后,将成员整数数组分配给传递到构造函数的数组。GetIntArray()方法返回整数数组成员变量。如果您查看这个类,您可能不会认为它是可变的,因为一旦数组传递到构造函数中,类就没有提供修改它的方法。然而,传递到构造函数的整数数组是可变的。GetIntArray()方法返回对可变数组的引用。

在我们的Program类中,我们将添加MutableExample()方法来展示整数数组是可变的:

private static void MutableExample()
{
    int[] iar = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    var mutableClass = new MutableClass(iar);

    Console.WriteLine($"Initial Array: {iar[0]}, {iar[1]}, {iar[2]}, {iar[3]}, {iar[4]}, {iar[5]}, {iar[6]}, {iar[7]}, {iar[8]}, {iar[9]}");

    for (var x = 0; x < 9; x++)
    {
        var thread = new Thread(() =>
            {
                iar[x] = x + 1;
                var ia = mutableClass.GetIntArray();
                Console.WriteLine($"Array [{x}]: {ia[0]}, {ia[1]}, {ia[2]}, {ia[3]}, {ia[4]}, {ia[5]}, {ia[6]}, {ia[7]}, {ia[8]}, {ia[9]}");
            });
            thread.Start();
    }
}

在我们的MutableExample()方法中,我们声明并初始化了一个从09的整数数组。然后,我们声明了MutableClass的一个新实例,并传入整数数组。接下来,我们打印出修改前的初始数组内容。然后,我们循环九次。对于每次迭代,我们增加由当前循环计数值x指定的数组的索引,使其等于x + 1。之后,我们启动线程。现在,更新Main()方法,如下所示:

static void Main(string[] args)
{
    MutableExample();
    Console.ReadKey();
}            

我们的Main()方法只是调用MutableExample(),然后等待按键。运行代码,您应该看到以下截图中的内容:

如您所见,即使在创建和运行线程之前我们只创建了一个MutableClass的实例,改变本地数组也会修改MutableClass实例中的数组。这证明了数组是可变的,因此它们不是线程安全的。

现在我们将从线程安全的不可变性开始。

编写不可变且线程安全的代码

在我们的不可变性示例中,我们将再次创建一个.NET Framework 控制台应用程序,并使用相同的数组。添加一个名为ImmutableStruct的类,并修改代码,如下所示:

internal struct ImmutableStruct
{ 
    private ImmutableArray<int> _immutableArray;

    public ImmutableStruct(ImmutableArray<int> immutableArray)
    {
        _immutableArray = immutableArray;
    }

    public int[] GetIntArray()
    {
        return _immutableArray.ToArray<int>();
    }
}

我们使用ImmutableArray而不是普通的整数数组。一个不可变数组被传递到构造函数,并赋值给_immutableArray成员变量。我们的GetIntArray()方法将不可变数组作为普通整数数组返回。

Program类中添加ImmutableExample()数组:

private static void ImmutableExample()
{
    int[] iar = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    var immutableStruct = new ImmutableStruct(iar.ToImmutableArray<int>());

    Console.WriteLine($"Initial Array: {iar[0]}, {iar[1]}, {iar[2]}, {iar[3]}, {iar[4]}, {iar[5]}, {iar[6]}, {iar[7]}, {iar[8]}, {iar[9]}");

    for (var x = 0; x < 9; x++)
    {
        var thread = new Thread(() =>
        {
            iar[x] = x + 1;
            var ia = immutableStruct.GetIntArray();
            Console.WriteLine($"Array [{x}]: {ia[0]}, {ia[1]}, {ia[2]}, {ia[3]}, {ia[4]}, {ia[5]}, {ia[6]}, {ia[7]}, {ia[8]}, {ia[9]}");
        });
        thread.Start();
    }
}

在我们的ImmutableExample()方法中,我们创建一个整数数组,将其作为不可变数组传递给ImmutableStruct的构造函数。然后,我们打印修改前的本地数组内容。然后,我们循环九次。在每次迭代中,我们访问数组中当前迭代计数的位置,并将当前迭代计数加一的值添加到数组中该位置的变量中。

然后,我们通过调用GetIntArray()immutableStruct数组的副本赋给一个本地变量。然后,我们继续打印返回数组的值。最后,我们启动线程。从您的Main()方法中调用ImmutableExample()方法,然后运行代码。您应该看到以下输出:

如您所见,通过更新本地数组,数组的内容并未被修改。我们的程序的这个版本表明我们的程序是线程安全的。

让我们简要回顾一下我们在下一节中学到的关于线程安全的知识。

理解线程安全

正如您在前两节中看到的,编写多线程代码时非常重要要小心。编写线程安全的代码可能非常困难,特别是在较大的项目中。您必须特别小心处理集合、通过引用传递参数以及在静态类中访问成员变量。多线程应用程序的最佳实践是仅传递不可变类型,不要访问静态成员变量,如果必须执行任何不是线程安全的代码,则使用锁定、互斥体或信号量锁定代码。尽管您在本章中已经看到了这样的代码,我们将通过一些代码片段快速回顾一下。

以下代码片段显示了如何使用readonly struct编写不可变类型:

public readonly struct ImmutablePerson
{
    public ImmutablePerson(int id, string firstName, string lastName)
    {
        _id = id;
        _firstName = firstName;
        _lastName = lastName;
    }

    public int Id { get; }
    public string FirstName { get;
    public string LastName { get { return _lastName; } }
}

在我们的ImmutablePerson结构中,我们有一个公共构造函数,它接受一个整数作为 ID,以及名字和姓氏的字符串。我们将idfirstNamelastName参数分配给成员只读变量。对数据的唯一访问是通过只读属性。这意味着没有办法修改数据。由于数据一旦创建就无法修改,因此被归类为线程安全。因为它是线程安全的,所以不能被不同的线程修改。修改数据的唯一方法是创建一个包含新数据的新结构。

结构体可以是可变的,就像类一样。但是,为了传递不希望被修改的数据,只读结构体是一个很好的、轻量级的选择。它们比类更快地创建和销毁,因为它们被添加到堆栈中,除非它们是堆中的类的一部分。

在之前,我们看到了集合是可变的。但是,还有一个名为System.Collections.Immutable的不可变集合的命名空间。以下表列出了此命名空间中的各种项目:

System.Collections.Immutable命名空间包含许多不可变集合,您可以在线程之间安全使用。有关更多详细信息,请参阅docs.microsoft.com/en-us/dotnet/api/system.collections.immutable?view=netcore-3.1

在 C#中使用锁对象非常简单,如下面的代码片段所示:

public class LockExample
{
    public object _lock = new object();

    public void UnsafeMethod() 
    {
        lock(_lock)
        {
            // Execute unsafe code.
        }
    }
}

我们创建并实例化了_lock成员变量。然后,在执行不是线程安全的代码时,我们将代码包装在锁中,并传入_lock变量作为锁对象。当线程进入锁时,所有其他线程都被禁止执行代码,直到线程离开锁。使用此代码的一个问题是线程可能进入死锁状态。解决这个问题的一种方法是使用互斥体。

您可以使用同步原语进行进程间同步。首先,在需要保护的代码所在的类顶部添加以下代码:

private static readonly Mutex _mutex = new Mutex();

然后,要使用互斥体,您需要使用以下try/catch块包装需要保护的代码:

try
{
    _mutex.WaitOne();
    // ... Do work here ...
}
finally
{
    _mutex.ReleaseMutex();
}

在上面的代码中,WaitOne()方法会阻塞当前线程,直到等待句柄接收到信号。一旦互斥体被发出信号,WaitOne()方法将返回true。调用线程然后拥有互斥体。然后,调用线程可以访问受保护的资源。在受保护资源上完成工作后,通过调用ReleaseMutex()释放互斥体。ReleaseMutex()finally块中调用,因为您不希望线程因任何原因引发异常而保持资源被锁定。因此,始终在finally块中释放互斥体。

保护资源访问的另一种机制是使用信号量。信号量的编码方式与互斥体非常相似,它们执行保护资源的相同角色。信号量和互斥体之间的主要区别在于互斥体是一种锁定机制,而信号量是一种信号机制。要使用信号量而不是锁和互斥体,请在类的顶部添加以下行:

private static readonly Semaphore _semaphore = new Semaphore(2, 4); 

我们现在添加了一个新的信号量变量。第一个参数表示可以同时授予的信号量的初始请求数。第二个参数表示可以同时授予的信号量的最大请求数。然后您将保护对资源的访问,如下所示:

try
{
    _semaphore.WaitOne();
    // ... Do work here ...
}
finally
{
    _semaphore.Release();
}

当前线程被阻塞,直到当前等待句柄接收到信号。然后线程可以执行其工作。最后,信号量被释放。

在本章中,您已经看到如何使用锁定、互斥体和信号量来锁定不是线程安全的代码。还要记住,后台线程在进程完成和终止时终止,而前台线程将继续执行直到完成。如果您有任何必须在不被终止的线程中途运行完成的代码,那么最好使用前台线程而不是后台线程。

下一节涵盖了同步方法依赖关系。

同步方法依赖关系

要同步您的代码,使用锁定语句,就像我们之前所做的那样。您还可以在项目中引用System.Runtime.CompilerServices命名空间。然后,您可以在方法和属性中添加[MethodImpl(MethodImplOptions.Synchronized)]注释。

以下是应用于方法的[MethodImpl(MethodImplOptions.Synchronized)]注释的示例:

[MethodImpl(MethodImplOptions.Synchronized)]
 public static void ThisIsASynchronisedMethod()
 {
      Console.WriteLine("Synchronised method called.");
 }

以下是使用[MethodImpl(MethodImplOptions.Synchronized)]与属性的示例:

private int i;
 public int SomeProperty
 {
     [MethodImpl(MethodImplOptions.Synchronized)]
     get { return i; }
     [MethodImpl(MethodImplOptions.Synchronized)]
     set { i = value; }
 }

正如您所看到的,很容易遇到死锁或竞争条件,但使用Monitor.TryEnter()Task.ContinueWith()同样容易克服死锁和竞争条件。

在下一节中,我们将看看 Interlocked 类。

使用 Interlocked 类

在多线程应用程序中,在线程调度程序上下文切换过程中可能会出现错误。一个主要的问题是不同线程更新相同变量。mscorlib程序集中System.Threading.Interlocked类的方法有助于防止这类错误。Interlocked类的方法不会抛出异常,因此在应用简单状态更改时比使用之前看到的lock语句更有效。

Interlocked 类中可用的方法如下:

  • CompareExchange:比较两个变量并将结果存储在不同的变量中

  • Add:将两个Int32Int64整数变量相加,并将结果存储在第一个整数中

  • Decrement:减少Int32Int64整数变量的值并存储它们的结果

  • Increment:增加Int32Int64整数变量的值并存储它们的结果

  • Read:读取Int64类型的整数变量

  • Exchange:在变量之间交换值

现在我们将编写一个简单的控制台应用程序来演示这些方法。首先创建一个新的.NET Framework 控制台应用程序。将以下行添加到Program类的顶部:

private static long _value = long.MaxValue;
private static int _resourceInUse = 0;

_value变量将用于演示使用互锁方法更新变量。_resourceInUse变量用于指示资源是否正在使用。添加CompareExchangeVariables()方法:

private static void CompareExchangeVariables()
{
    Interlocked.CompareExchange(ref _value, 123, long.MaxValue);
}

在我们的CompareExchangeVariables()方法中,我们调用CompareExchange()方法来比较_valuelong.MaxValue。如果两个值相等,则用123的值替换_value。现在我们将添加我们的AddVariables()方法:

private static void AddVariables()
{
    Interlocked.Add(ref _value, 321);
}

AddVariables()方法调用Add()方法来访问_value成员变量,并将其更新为_value321的值。接下来,我们将添加DecrementVariable()方法:

private static void DecrementVariable()
{
    Interlocked.Decrement(ref _value);
}

这个方法调用Decrement()方法,该方法将_value成员变量减 1。我们的下一个方法是IncrementValue()

private static void IncrementVariable()
{
    Interlocked.Increment(ref _value);
}

在我们的IncrementVariable()方法中,我们通过调用Increment()方法来增加_value成员变量。接下来我们将编写的方法是ReadVariable()方法:

private static long ReadVariable()
{
    // The Read method is unnecessary on 64-bit systems, because 64-bit 
    // read operations are already atomic. On 32-bit systems, 64-bit read 
    // operations are not atomic unless performed using Read.
    return Interlocked.Read(ref _value);
}

由于 64 位读操作是原子的,调用Interlocked.Read()方法是不必要的。但是,在 32 位系统上,为了使 64 位读操作是原子的,你需要调用Interlocked.Read()方法。添加PerformUnsafeCodeSafely()方法:

private static void PerformUnsafeCodeSafely()
{
    for (int i = 0; i < 5; i++)
    {
        UseResource();
        Thread.Sleep(1000);
    }
}

PerformUnsafeCodeSafely()方法循环五次。循环的每次迭代调用UseResource()方法,然后线程休眠 1 秒。现在,我们将添加UseResource()方法:

static bool UseResource()
{
    if (0 == Interlocked.Exchange(ref _resourceInUse, 1))
    {
        Console.WriteLine($"{Thread.CurrentThread.Name} acquired the lock");
        NonThreadSafeResourceAccess();
        Thread.Sleep(500);
        Console.WriteLine($"{Thread.CurrentThread.Name} exiting lock");
        Interlocked.Exchange(ref _resourceInUse, 0);
        return true;
    }
    else
    {
        Console.WriteLine($"{Thread.CurrentThread.Name} was denied the lock");
        return false;
    }
}

UseResource()方法防止在资源正在使用时获取锁,这由_resourceInUse变量标识。我们首先通过调用Exchange()方法将_resourceInUse成员变量的值设置为1Exchange()方法返回一个整数,我们将其与0进行比较。如果Exchange()返回的值是0,那么该方法没有在使用中。

如果方法正在使用中,那么我们输出一条消息,通知用户当前线程被拒绝了锁。

如果方法没有在使用中,那么我们输出一条消息,通知用户当前线程已获得锁。然后我们调用NonThreadSafeResourceAccess()方法,然后让线程休眠半秒,模拟工作。

当线程唤醒时,我们输出一条消息,通知用户当前线程已退出锁。然后,我们通过调用Exchange()方法释放锁,并将_resourceInUse的值设置为0。添加NonThreadSafeResourceAccess()方法:

private static void NonThreadSafeResourceAccess()
{
    Console.WriteLine("Non-thread-safe code executed.");
}

NonThreadSafeResourceAccess()是在锁的安全性中执行非线程安全代码的地方。在我们的方法中,我们只是用一条消息通知用户。在运行代码之前,我们要做的最后一件事是更新我们的Main()方法,如下所示:

static void Main(string[] args)
{
    CompareExchangeVariables();
    AddVariables();
    DecrementVariable();
    IncrementVariable();
    ReadVariable();
    PerformUnsafeCodeSafely();
}

我们的Main()方法调用测试Interlocked方法的方法。运行代码,你应该看到类似以下的东西:

我们现在将讨论一些一般性建议。

一般建议

在这最后一节中,我们将看一下微软针对多线程应用的一些一般性建议。它们包括以下内容:

  • 避免使用Thread.Abort来终止其他线程。

  • 使用互斥体、ManualResetEventAutoResetEventMonitor来同步多个线程之间的活动。

  • 在可能的情况下,为你的工作线程使用线程池。

  • 如果有任何工作线程被阻塞,那么使用Monitor.PulseAll来通知所有线程工作线程状态的改变。

  • 避免使用这个,类型实例和字符串实例,包括字符串字面量作为lock对象。避免使用lock对象的类型。

  • 实例锁可能导致死锁,因此在使用时要小心。

  • 对于进入监视器的线程,使用try/finally块,以便在finally块中,通过调用Monitor.Exit()确保线程离开监视器。

  • 为不同的资源使用不同的线程。

  • 避免将多个线程分配给同一资源。

  • I/O 任务应该有自己的线程,因为它们在执行 I/O 操作时会阻塞。这样,你可以让其他线程运行。

  • 用户输入应该有自己专用的线程。

  • 通过使用System.Threading.Interlocked类的方法来改进简单状态改变的性能,而不是使用锁语句。

  • 对于频繁使用的代码,避免同步,因为它可能导致死锁和竞争条件。

  • 默认情况下,使静态数据线程安全。

  • 实例数据默认情况下不是线程安全的;否则,会降低性能,增加锁竞争,并引入可能发生竞争条件和死锁的可能性。

  • 避免使用会改变状态的静态方法,因为它们会导致线程错误。

这就结束了我们对线程和并发性的探讨。让我们总结一下我们学到的内容。

摘要

在本章中,我们介绍了什么是线程以及如何使用它。我们看到了死锁和竞争条件问题的实际情况,并了解了如何使用锁语句和 TPL 库来防止这些特殊情况。我们还讨论了静态构造函数、静态方法、不可变对象和可变对象的线程安全性。我们看到了为什么使用不可变对象是在线程之间传输数据的线程安全方式,并回顾了一些与线程工作相关的一般建议。

我们还看到了使代码线程安全可以带来很多好处。在下一章中,我们将看一下设计有效的 API。但现在,您可以通过回答以下问题来测试自己的知识,并通过参考提供的链接来进一步阅读。

问题

  1. 什么是线程?

  2. 单线程应用中有多少个线程?

  3. 有哪些类型的线程?

  4. 哪个线程会在程序退出时立即终止?

  5. 哪个线程会一直运行直到完成,即使程序退出了?

  6. 什么代码可以让线程休眠半毫秒?

  7. 如何实例化一个调用名为Method1的方法的线程?

  8. 如何将线程设置为后台线程?

  9. 什么是死锁?

  10. 如何退出使用Monitor.TryEnter(objectName)获取的锁?

  11. 如何从死锁中恢复?

  12. 什么是竞争条件?

  13. 防止竞争条件的一种方法是什么?

  14. 什么使得静态方法不安全?

  15. 静态构造函数是否线程安全?

  16. 谁负责管理一组线程?

  17. 什么是不可变对象?

  18. 为什么在线程应用中更喜欢不可变对象而不是可变对象?

进一步阅读

第九章:设计和开发 APIs

应用程序编程接口APIs)在如今的许多方面从未像现在这样重要。APIs 用于连接政府和机构共享数据,并以协作的方式解决商业和政府问题。它们用于医生诊所和医院实时共享患者数据。当您连接到您的电子邮件并通过 Microsoft Teams、Microsoft Azure、Amazon Web Services 和 Google Cloud Platform 等平台与同事和客户进行协作时,您每天都在使用 APIs。

每次您使用计算机或手机与某人聊天或进行视频通话时,您都在使用 API。当流媒体视频会议、进入网站技术支持聊天或播放您喜爱的音乐和视频时,您都在使用 API。因此,作为程序员,了解 API 是什么以及如何设计、开发、保护和部署它们是至关重要的。

在本章中,我们将讨论 API 是什么,它们如何使您受益,以及为什么有必要了解它们。我们还将讨论 API 代理、设计和开发指南,如何使用 RAML 设计 API 以及如何使用 Swagger 文档 API。

本章涵盖以下主题:

  • 什么是 API?

  • API 代理

  • API 设计指南

  • 使用 RAML 进行 API 设计

  • Swagger API 开发

本章将帮助您获得以下技能:

  • 了解 API 以及为什么您需要了解它们

  • 了解 API 代理以及我们为什么使用它们

  • 在设计自己的 API 时了解设计指南

  • 使用 RAML 设计自己的 API

  • 使用 Swagger 来记录您的 API

通过本章结束时,您将了解良好 API 设计的基础,并掌握推动 API 能力所需的知识。了解 API 是什么很重要,因此我们将从这一点开始本章。但首先,请确保您实现以下技术要求,以充分利用本章。

技术要求

我们将在本章中使用以下技术来创建 API:

  • Visual Studio 2019 社区版或更高版本

  • Swashbuckle.AspNetCore 5 或更高版本

  • Swagger (swagger.io)

  • Atom (atom.io)

  • MuleSoft 的 API Workbench

什么是 API?

APIs是可重用的库,可以在不同应用程序之间共享,并可以通过 REST 服务提供(在这种情况下,它们被称为RESTful APIs)。

表述状态转移REST)由 Roy Fielding 于 2000 年引入。

REST 是一种由约束组成的架构风格。总共有六个约束在编写 REST 服务时应该考虑。这些约束如下:

  • 统一接口:用于识别资源,并通过表示来操作这些资源。消息使用超媒体并且是自描述的。超媒体作为应用程序状态的引擎HATEOAS)被用来包含关于客户端可以执行的下一步操作的信息。

  • 客户端-服务器:这个约束通过封装利用信息隐藏。因此,只有客户端将要使用的 API 调用将是可见的,所有其他 API 将被保持隐藏。RESTful API 应该独立于系统的其他部分,使其松散耦合。

  • 无状态:这表示 RESTful API 没有会话或历史。如果客户端需要会话或历史,那么客户端必须在请求中提供所有相关信息给服务器。

  • 可缓存:这个约束意味着资源必须声明自己是可缓存的。这意味着资源可以被快速访问。因此,我们的 RESTful API 变得更快,服务器负载减少。

  • 分层系统:分层系统约束规定每个层必须只做一件事。每个组件只应知道它需要使用的内容以便进行功能和任务的执行。组件不应该了解它不使用的系统部分。

  • 可选的可执行代码:可执行代码约束是可选的。此约束确定服务器可以临时扩展或自定义客户端的功能,通过传输可执行代码。

因此,在设计 API 时,最好假设最终用户是具有任何经验水平的程序员。他们应该能够轻松获取 API,阅读相关信息,并立即投入使用。

不要担心创建完美的 API。API 通常会随着时间的推移而不断发展,如果您曾经使用过 Microsoft 的 API,您会知道它们经常进行升级。将来将删除的功能通常会用注释标记,告知用户不要使用特定的属性或方法,因为它们将在将来的版本中被删除。然后,当它们不再被使用时,通常会在最终删除之前用过时的注释标记进行标记。这告诉 API 的用户升级使用过时功能的任何应用程序。

为什么要使用 REST 服务进行 API 访问?嗯,许多公司通过在线提供 API 并对其收费而获得巨大利润。因此,RESTful API 可以是一项非常有价值的资产。Rapid API (rapidapi.com/)提供免费和付费的 API 供使用。

您的 API 可以永久保持在原位。如果您使用云提供商,您的 API 可以具有高度可扩展性,并且您可以通过免费或订阅的方式使其普遍可用。您可以通过简单的接口封装所有复杂的工作,并暴露所需的内容,因为您的 API 将是小型且可缓存的,所以非常快速。现在让我们来看看 API 代理以及为什么要使用它们。

API 代理

API 代理是位于客户端和您的 API 之间的类。它本质上是您和将使用您的 API 的开发人员之间的 API 合同。因此,与其直接向开发人员提供 API 的后端服务(随着您对其进行重构和扩展,可能会发生故障),不如向 API 的使用者提供保证,即使后端服务发生变化,API 合同也将得到遵守。

以下图表显示了客户端、API 代理、实际访问的 API 以及 API 与数据源之间的通信:

本节将编写一个演示实现代理模式的控制台应用程序。我们的示例将具有一个接口,该接口将由 API 和代理实现。API 将返回实际消息,代理将从 API 获取消息并将其传递给客户端。代理还可以做的远不止简单调用 API 方法并返回响应。它们可以执行身份验证、授权、基于凭据的路由等等。但是,我们的示例将保持在绝对最低限度,以便您可以看到代理模式的简单性。

启动一个新的.NET Framework 控制台应用程序。添加ApisInterfacesProxies文件夹,并将HelloWorldInterface接口放入Interfaces文件夹中:

public interface HelloWorldInterface
{
    string GetMessage();
}

我们的接口方法GetMessage()以字符串形式返回一条消息。代理和 API 类都将实现这个接口。HelloWorldApi类实现了HelloWorldInterface,所以将其添加到Apis文件夹中:

internal class HelloWorldApi : HelloWorldInterface
{
    public string GetMessage()
    {
        return "Hello World!";
    }
}

正如您所看到的,我们的 API 类实现了接口并返回了一个"Hello World!"的消息。我们还将类设置为内部类。这可以防止外部调用者访问此类的内容。现在,我们将HelloWorldProxy类添加到Proxies文件夹中:

    public class HelloWorldProxy : HelloWorldInterface
    {
        public string GetMessage()
        {
            return new HelloWorldApi().GetMessage();
        }
    }

我们的代理类设置为public,因为此类将由客户端调用。代理类将调用 API 类中的GetMessage()方法,并将响应返回给调用者。现在剩下的事情就是修改我们的Main()方法:

static void Main(string[] args)
{
    Console.WriteLine(new HelloWorldProxy().GetMessage());
    Console.ReadKey();
}

我们的Main()类调用HelloWorldProxy代理类的GetMessage()方法。我们的代理类调用 API 类,并将返回的方法打印在控制台窗口中。然后控制台等待按键后退出。

运行代码并查看输出;您已成功实现了 API 代理类。您可以使代理尽可能简单或复杂,但您在这里所做的是成功的基础。

在本章中,我们将构建一个 API。因此,让我们讨论一下我们将要构建的内容,然后开始着手处理它。完成项目后,您将拥有一个可以生成 JSON 格式的月度股息支付日历的工作 API。

API 设计指南

有一些基本的指南可供遵循,以编写有效的 API—例如,您的资源应使用复数形式的名词。因此,例如,如果您有一个批发网站,那么您的 URL 将看起来像以下虚拟链接:

  • http://wholesale-website.com/api/customers/1

  • http://wholesale-website.com/api/products/20

上述 URL 将遵循api/controller/id的控制器路由。在业务域内的关系方面,这些关系也应反映在 URL 中,例如http://wholesale-website.com/api/categories/12/products—此调用将返回类别12的产品列表。

如果您需要将动词用作资源,则可以这样做。在进行 HTTP 请求时,使用GET检索项目,HEAD仅检索标头,POST插入或保存新资源,PUT替换资源,DELETE删除资源。通过使用查询参数使资源保持精简。

在分页结果时,应向客户端提供一组现成的链接。RFC 5988 引入了链接标头。在规范中,国际资源标识符(IRI)是两个资源之间的类型化连接。有关更多信息,请参阅www.greenbytes.de/tech/webdav/rfc5988.html。链接标头请求的格式如下:

  • <https://wholesale-website.com/api/products?page=10&per_page=100>; rel="next"

  • <https://wholesale-website.com/api/products?page=11&per_page=100>; rel="last"

您的 API 的版本可以在 URL 中进行版本控制。因此,每个资源将具有相同资源的不同 URL,如以下示例:

  • https://wholesale-website.com/api/v1/cart

  • https://wholesale-website.com/api/v2/cart

这种版本控制方式非常简单,可以轻松找到正确的 API 版本。

JSON 是首选的资源表示。它比 XML 更易于阅读,而且体积更小。当您使用POSTPUTPATCH动词时,还应要求将内容类型标头设置为 application/JSON,或抛出415HTTP 状态码(表示不支持的媒体类型)。Gzip 是一种单文件/流无损数据压缩实用程序。默认使用 Gzip 可以节省带宽的很大比例,并始终将 HTTP Accept-Encoding标头设置为gzip

始终为您的 API 使用 HTTPS(TLS)。调用者的身份验证应始终在标头中完成。我们在设置 API 时看到了这一点,当我们使用 API 访问密钥设置了x-api-key标头。每个请求都应进行身份验证和授权。未经授权的访问应导致HTTP 403 Forbidden响应。还应使用正确的 HTTP 响应代码。因此,如果请求成功,请使用200状态代码,如果找不到资源,请使用404,依此类推。有关 HTTP 状态代码的详尽列表,请访问httpstatuses.com/。OAuth 2.0 是授权的行业标准协议。您可以在oauth.net/2/上阅读有关它的所有信息。

API 应提供有关其使用的文档和示例。文档应始终与当前版本保持最新,并且应具有视觉吸引力和易于阅读。我们将在本章后面看一下 Swagger,以帮助我们创建文档。

您永远不知道您的 API 何时需要扩展。因此,这应该从一开始就考虑进去。在下一章的股息日历 API项目中,您将看到我们如何实现限流,每月只能调用一次 API,在特定日期。但是,根据您自己的需求,您可以有效地想出 1001 种不同的方法来限制您的 API,但这应该在项目开始时完成。因此,一旦开始新项目,就要考虑可扩展性

出于安全和性能原因,您可能决定实现 API 代理。API 代理将客户端与直接访问您的 API 断开连接。代理可以访问同一项目中的 API 或外部 API。通过使用代理,您可以避免暴露数据库架构。

对客户端的响应不应与数据库的结构匹配。这可能会成为黑客的绿灯。因此,应避免数据库结构和发送回客户端的响应之间的一对一映射。您还应该向客户端隐藏标识符,因为客户端可以使用它们手动访问数据。

API 包含资源。资源是可以以某种方式操作的项目。资源可以是文件或数据。例如,学校数据库中的学生是可以添加、编辑或删除的资源。视频文件可以被检索和播放,音频文件也可以。图像也是资源,报告模板也是,它们将在呈现给用户之前被打开、操作和填充数据。

通常,资源形成项目的集合,例如学校数据库中的学生。StudentsStudent类型的集合的名称。可以通过 URL 访问资源。URL 包含到资源的路径。

URL 被称为API 端点。API 端点是资源的地址。可以通过带有一个或多个参数的 URL 或不带任何参数的 URL 访问此资源。URL 应该只包含复数名词(资源的名称),不应包含动词或操作。参数可用于标识集合中的单个资源。如果数据集将非常庞大,则应使用分页。对于超出 URI 长度限制的带参数的请求,可以将参数放在POST请求的正文中。

动词是 HTTP 请求的一部分。POST动词用于添加资源。要检索一个或多个资源,您可以使用GET动词。PUT更新或替换一个或多个资源,PATCH更新或修改一个资源或集合。DELETE删除一个资源或集合。

您应该始终确保适当地提供和响应 HTTP 状态代码。有关完整的 HTTP 状态代码列表,请访问httpstatuses.com/

至于字段、方法和属性名称,您可以使用任何您喜欢的约定,但必须保持一致并遵循公司的指南。在 JSON 中通常使用驼峰命名约定。由于您将在 C#中开发 API,最好遵循行业标准的 C#命名约定。

由于您的 API 将随着时间的推移而发展,最好采用某种形式的版本控制。版本控制允许消费者使用特定版本的 API。当 API 的新版本实施破坏性更改时,这可能非常重要以提供向后兼容性。通常最好在 URL 中包含版本号,如 v1 或 v2。无论您使用什么方法来为 API 版本,只需记住要保持一致

如果您将使用第三方 API,您需要保持 API 密钥的机密性。实现这一点的一种方法是将密钥存储在诸如 Azure Key Vault 之类的密钥库中,该库需要进行身份验证和授权。您还应该使用您选择的方法保护自己的 API。如今一个常见的方法是通过使用 API 密钥。在下一章中,您将看到如何使用 API 密钥和 Azure Key Vault 来保护第三方密钥和您自己的 API。

明确定义的软件边界

理智的人都不喜欢意大利面代码。它很难阅读、维护和扩展。因此,在设计 API 时,您可以通过明确定义的软件边界来解决这个问题。在领域驱动设计DDD)中,一个明确定义的软件边界被称为有界上下文。在业务术语中,有界上下文是业务运营单位,如人力资源、财务、客户服务、基础设施等。这些业务运营单位被称为领域,它们可以被分解成更小的子领域。然后,这些子领域可以被进一步分解成更小的子领域。

通过将业务分解为业务运营单位,领域专家可以在这些特定领域受雇。在项目开始时可以确定一个共同的语言,以便业务了解 IT 术语,IT 员工了解业务术语。如果业务和 IT 员工的语言是一致的,由于双方的误解,错误的余地就会减少。

将一个重大项目分解为子领域意味着您可以让较小的团队独立地在项目上工作。因此,大型开发团队可以分成较小的团队,同时在各种项目上并行工作。

DDD 是一个很大的主题,本章不涉及。然而,更多信息的链接已经发布在本章的进一步阅读部分。

API 应该暴露的唯一项目是形成合同和 API 端点的接口。其他所有内容都应该对订阅者和消费者隐藏。这意味着即使是大型数据库也可以被分解,以便每个 API 都有自己的数据库。鉴于如今标准的网站可以是多么庞大和复杂,我们甚至可以拥有微服务、微数据库和微前端。

微前端是网页的一个小部分,根据用户交互动态检索和修改。该前端将与一个 API 进行交互,而该 API 将访问一个微数据库。这在单页应用程序SPAs)方面是理想的。

单页应用是由单个页面组成的网站。当用户发起操作时,只更新网页的必需部分;页面的其余部分保持不变。例如,网页有一个 aside。这个 aside 显示广告。这些广告以 HTML 的形式存储在数据库中。aside 被设置为每 5 秒自动更新一次。当 5 秒时间到时,aside 请求 API 分配一个新的广告。然后 API 使用任何已经存在的算法从数据库中获取要显示的新广告。然后 HTML 文档被更新,aside 也被更新为新的广告。下图显示了典型的单页应用程序生命周期:

这个 aside 是一个明确定义的软件边界。它不需要知道显示在其中的页面的任何内容。它所关心的只是每 5 秒显示一个新的广告:

先前的图表显示了一个单页应用通过 API 代理与一个 RESTful API 进行通信,API 能够访问文档和数据库。

组成 aside 的唯一组件是 HTML 文档片段、微服务和数据库。这些可以由一个小团队使用他们喜欢和熟悉的任何技术来处理。完整的单页应用程序可能由数百个微文档、微服务和微数据库组成。关键点在于这些服务可以由任何技术组成,并且可以由任何团队独立工作。也可以同时进行多个项目。

在我们的边界上下文中,我们可以使用以下软件方法来提高我们代码的质量:

  • 单一职责开闭里氏替换接口隔离依赖反转SOLID)原则

  • 不要重复自己DRY

  • 你不会需要它YAGNI

  • 保持简单,愚蠢KISS

这些方法可以很好地协同工作,消除重复代码,防止编写不需要的代码,并保持对象和方法的简洁。我们为类和方法开发的原因是它们应该只做一件事,并且做得很好。

命名空间用于执行逻辑分组。我们可以使用命名空间来定义软件边界。命名空间越具体,对程序员越有意义。有意义的命名空间帮助程序员分割代码,并轻松找到他们正在寻找的内容。使用命名空间来逻辑分组接口、类、结构和枚举。

在接下来的部分,您将学习如何使用 RAML 设计 API。然后,您将从 RAML 文件生成一个 C# API。

理解良好质量 API 文档的重要性

在项目中工作时,有必要了解已经使用的所有 API。这是因为您经常会写已经存在的代码,这显然会导致浪费。不仅如此,通过编写自己版本的已经存在的代码,现在您有两份做同样事情的代码。这增加了软件的复杂性,并增加了维护开销,因为必须维护两个版本的代码。这也增加了错误的可能性。

在跨多个技术和存储库的大型项目中,团队人员流动性高,尤其是没有文档存在的情况下,代码重复成为一个真正的问题。有时,可能只有一两个领域专家,大多数团队根本不了解系统。我以前就曾参与过这样的项目,它们真的很难维护和扩展。

这就是为什么 API 文档对于任何项目都是至关重要的,无论其大小如何。在软件开发领域,人们会离开,尤其是在其他地方提供更有利可图的工作时。如果离开的人是领域专家,那么他们将带走他们的知识。如果没有文档存在,那么新加入项目的开发人员将不得不通过阅读代码来陡峭地学习项目。如果代码混乱复杂,这可能会给新员工带来真正的头痛。

因此,由于缺乏系统知识,程序员倾向于或多或少地从头开始编写他们需要的代码以按时交付给业务。这通常会导致重复的代码和未被利用的代码重用。这会导致软件变得复杂且容易出错,这种软件最终变得难以扩展和维护。

现在,您了解了为什么 API 必须进行文档化。良好文档化的 API 将使程序员更容易理解,并更有可能被重复使用,从而减少了代码重复的可能性,并产生了难以扩展或维护的代码。

您还应该注意任何标记为弃用或过时的代码。弃用的代码将在未来版本中被移除,而过时的代码已不再使用。如果您正在使用标记为弃用或过时的 API,则应优先处理此代码。

现在您了解了良好质量 API 文档的重要性,我们将看一下一个名为 Swagger 的工具。Swagger 是一个易于使用的工具,用于生成外观漂亮、高质量的 API 文档。

Swagger API 开发

Swagger 提供了一套围绕 API 开发的强大工具。使用 Swagger,您可以做以下事情:

  • 设计:设计您的 API 并对其进行建模,以符合基于规范的标准。

  • 构建:构建一个稳定且可重用的 C# API。

  • 文档:为开发人员提供可以交互的文档。

  • 测试:轻松测试您的 API。

  • 标准化:使用公司指南对 API 架构应用约束。

我们将在 ASP.NET Core 3.0+项目中启动 Swagger。因此,请在 Visual Studio 2019 中创建项目。选择 Web API 和无身份验证设置。在我们继续之前,值得注意的是,Swagger 会自动生成外观漂亮且功能齐全的文档。设置 Swagger 所需的代码非常少,这就是为什么许多现代 API 使用它的原因。

在我们可以使用 Swagger 之前,我们首先需要在项目中安装对其的支持。要安装 Swagger,您必须安装Swashbuckle.AspNetCore依赖包的 5 版或更高版本。截至撰写本文时,NuGet 上可用的版本是 5.3.3。安装完成后,我们需要将要使用的 Swagger 服务添加到服务集合中。在我们的情况下,我们只会使用 Swagger 来记录我们的 API。在Startup.cs类中,将以下行添加到ConfigureServices()方法中:

services.AddSwaggerGen(swagger =>
{
    swagger.SwaggerDoc("v1", new OpenApiInfo { Title = "Weather Forecast API" });
});

在我们刚刚添加的代码中,Swagger 文档服务已分配给了服务集合。我们的 API 版本是v1,API 标题是Weather Forecast API。现在我们需要更新Configure()方法,在if语句之后立即添加我们的 Swagger 中间件,如下所示:

app.UseSwagger();
app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "Weather Forecast API");
});

在我们的Configure()方法中,我们正在通知我们的应用程序使用 Swagger 和 Swagger UI,并为Weather Forecast API分配我们的 Swagger 端点。接下来,您需要安装Swashbuckle.AspNetCore.NewtonsoftNuGet 依赖包(截至撰写本文时的版本为 5.3.3)。然后,将以下行添加到您的ConfigureServices()方法中:

services.AddSwaggerGenNewtonsoftSupport();

我们为我们的 Swagger 文档生成添加了 Newtonsoft 支持。这就是使 Swagger 运行起来的全部内容。因此,运行你的项目,然后导航到https://localhost:PORT_NUMBER/swagger/index.html。你应该看到以下网页:

现在我们将看一下为什么我们应该传递不可变的结构而不是可变的对象。

传递不可变的结构而不是可变的对象

在这一部分,你将编写一个计算机程序,处理 100 万个对象和 100 万个不可变的结构。你将看到在性能方面,结构比对象更快。我们将编写一些代码,处理 100 万个对象需要 1440 毫秒,处理 100 万个结构需要 841 毫秒。这是 599 毫秒的差异。这样一个小的时间单位听起来可能不多,但当处理大型数据集时,使用不可变的结构而不是可变的对象将会带来很大的性能改进。

可变对象中的值也可以在线程之间修改,这对业务来说可能非常糟糕。想象一下你的银行账户里有 15000 英镑,你支付房东 435 英镑的房租。你的账户有一个可以透支的限额。现在,在你支付 435 英镑的同时,另一个人正在支付 23000 英镑给汽车公司买一辆新车。汽车购买者的线程修改了你账户上的值。因此,你最终支付给房东 23000 英镑,使你的银行余额欠 8000 英镑。我们不会编写一个可变数据在线程之间被修改的示例,因为这在第八章中已经涵盖过了,线程和并发

本节的要点是,结构比对象更快,不可变的结构是线程安全的。

在创建和传递对象时,结构比对象更高效。你也可以使结构不可变,这样它们就是线程安全的。在这里,我们将编写一个小程序。这个程序将有两个方法——一个将创建 100 万个人对象,另一个将创建 100 万个人结构。

添加一个新的.NET Framework 控制台应用程序,名为CH11_WellDefinedBoundaries,以及以下PersonObject类:

public class PersonObject
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

这个对象将用于创建 100 万个人对象。现在,添加PersonStruct

    public struct PersonStruct
    {
        private readonly string _firstName;
        private readonly string _lastName;

        public PersonStruct(string firstName, string lastName)
        {
            _firstName = firstName;
            _lastName = lastName;
        }

        public string FirstName => _firstName;
        public string LastName => _lastName;
    }

这个结构是不可变的,readonly属性是通过构造函数设置的,并用于创建我们的 100 万个结构。现在,我们可以修改程序来显示对象和结构创建之间的性能。添加CreateObject()方法:

private static void CreateObjects()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    var people = new List<PersonObject>();
    for (var i = 1; i <= 1000000; i++)
    {
        people.Add(new PersonObject { FirstName = "Person", LastName = $"Number {i}" });
    }
    stopwatch.Stop();
    Console.WriteLine($"Object: {stopwatch.ElapsedMilliseconds}, Object Count: {people.Count}");
    GC.Collect();
}

正如你所看到的,我们启动了一个秒表,创建了一个新列表,并向列表中添加了 100 万个人对象。然后我们停止了秒表,将结果输出到窗口,然后调用垃圾收集器来清理我们的资源。现在让我们添加我们的CreateStructs()方法:

private static void CreateStructs()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    var people = new List<PersonStruct>();
    for (var i = 1; i <= 1000000; i++)
    {
        people.Add(new PersonStruct("Person", $"Number {i}"));
    }
    stopwatch.Stop();
    Console.WriteLine($"Struct: {stopwatch.ElapsedMilliseconds}, Struct Count: {people.Count}");
    GC.Collect();
}

我们的结构在这里做了与CreateObjects()方法类似的事情,但是创建了一个结构列表,并向列表中添加了 100 万个结构。最后,修改Main()方法,如下所示:

static void Main(string[] args)
{
    CreateObjects();
    CreateStructs();
    Console.WriteLine("Press any key to exit.");
    Console.ReadKey();
}

我们调用我们的两种方法,然后等待用户按任意键退出。运行程序,你应该看到以下输出:

正如你从之前的截图中所看到的,创建 100 万个对象并将它们添加到对象列表中花费了 1,440 毫秒,而创建 100 万个结构并将它们添加到结构列表中只花费了 841 毫秒。

因此,不仅可以使结构不可变和线程安全,因为它们不能在线程之间修改,而且与对象相比,它们的性能也更快。因此,如果你正在处理大量数据,结构可以节省大量处理时间。不仅如此,如果你的云计算服务按执行时间计费,那么使用结构而不是对象将为你节省金钱。

现在让我们来看看为将要使用的 API 编写第三方 API 测试。

测试第三方 API

为什么我应该测试第三方 API 呢?这是一个很好的问题。你应该测试第三方 API 的原因是,就像你自己的代码一样,第三方代码也容易出现编程错误。我记得曾经在为一家律师事务所建立的文件处理网站上遇到了一些真正困难。经过多次调查,我发现问题是由于我使用的 Microsoft API 中嵌入的有错误的 JavaScript 导致的。下面的截图是 Microsoft 认知工具包的 GitHub Issues 页面,其中有 738 个未解决的问题:

正如你从 Microsoft 认知工具包中看到的,第三方 API 确实存在问题。这意味着作为程序员,你有责任确保你使用的第三方 API 能够正常工作。如果遇到任何 bug,那么告知第三方是一个良好的做法。如果 API 是开源的,并且你可以访问源代码,甚至可以检查代码并提交你自己的修复。

每当你在第三方代码中遇到 bug,而这些 bug 又无法及时解决以满足你的截止日期时,你可以选择编写一个包装类,该类具有与第三方类相同的构造函数、方法和属性,并使它们调用第三方类上的相同构造函数、方法和属性,但你需要编写第三方属性或方法的无 bug 版本。第十一章,“解决横切关注点”,提供了关于代理模式和装饰器模式的部分,这将帮助你编写包装类。

测试你自己的 API

在第六章,“单元测试”,和第七章,“端到端系统测试”中,你看到了如何测试你自己的代码,还有代码示例。你应该始终测试自己的 API,因为对 API 的质量完全信任是很重要的。因此,作为程序员,你应该在交付给质量保证之前对代码进行单元测试。质量保证应该进行集成和回归测试,以确保 API 达到公司约定的质量水平。

你的 API 可能完全符合业务要求,没有 bug;但当它与系统集成时,在某些情况下会发生你无法测试的奇怪情况吗?在开发团队中,我经常遇到这样的情况,代码在一个人的电脑上可以工作,但在其他电脑上却不能。然而,这似乎并没有逻辑上的原因。这些问题可能会非常令人沮丧,甚至需要花费大量时间才能找到问题的根源。但你希望在将代码交给质量保证之前解决这些问题,而且在发布到生产环境之前更是如此。处理客户 bug 并不总是一种愉快的经历。

测试你的程序应该包括以下内容:

  • 当给定正确的值范围时,被测试的方法会输出正确的结果。

  • 当给定不正确的值范围时,该方法会提供适当的响应而不会崩溃。

记住,你的 API 应该只包括业务要求,并且不应该使内部细节对客户可见。这就是 Scrum 项目管理方法中的产品积压的用处。

产品积压是你和你的团队将要处理的新功能和技术债务的列表。产品积压中的每个项目都将有描述和验收标准,如下图所示:

你的单元测试是围绕验收标准编写的。你的测试将包括正常执行路径和异常执行路径。以这个截图为例,我们有两个验收标准:

  • 成功从第三方 API 获取数据。

  • 数据已成功存储在 Cosmos DB 中。

在这两个验收标准中,我们知道我们将调用获取数据的 API。这些数据将来自第三方。一旦获取,数据将存储在数据库中。从表面上看,我们必须处理的这个规范相当模糊。在现实生活中,我发现这种情况经常发生。

鉴于规范的模糊性,我们将假设规范是通用的,并适用于不同的 API 调用,并且我们可以假设返回的数据是 JSON 数据。我们还假设返回的 JSON 数据将以其原始形式存储在 Cosmos DB 数据库中。

那么,我们可以为我们的第一个验收标准写什么测试?嗯,我们可以写以下测试用例:

  1. 当给定一个带参数列表的 URL 时,断言当提供所有正确的信息时,我们会收到200的状态和GET请求返回的 JSON。

  2. 当未经授权的GET请求被发出时,我们会收到401的状态。

  3. 断言当经过身份验证的用户被禁止访问资源时,我们会收到403的状态。

  4. 当服务器宕机时,我们会收到500的状态。

我们可以为我们的第二个验收标准写什么测试?嗯,我们可以写以下测试用例:

  1. 断言拒绝对数据库的未经授权访问。

  2. 断言 API 在数据库不可用的情况下能够优雅地处理。

  3. 断言授予对数据库的授权访问。

  4. 断言 JSON 插入数据库成功。

因此,即使从如此模糊的规范中,我们已经能够获得八个测试用例。在它们之间,所有这些情况都测试了成功地往返到第三方服务器,然后进入数据库。它们还测试了过程可能失败的各个点。如果所有这些测试都通过,我们对我们的代码完全有信心,并且在离开我们作为开发人员的手时,它将通过质量控制。

在下一节中,我们将看看如何使用 RAML 设计 API。

使用 RAML 进行 API 设计

在这一部分,我们将讨论使用 RAML 设计 API。你可以从 RAML 网站(raml.org/developers/design-your-api)获得关于 RAML 各个方面的深入知识。我们将通过在 Atom 中使用 API Workbench 设计一个非常简单的 API 来学习 RAML 的基础知识。我们将从安装开始。

第一步是安装软件包。

安装 Atom 和 MuleSoft 的 API Workbench

让我们看看如何做到这一点:

  1. atom.io安装 Atom。

  2. 然后,点击Install a Package

  1. 然后搜索api-workbench by mulesoft并安装它:

  1. 如果你在Packages|Installed Packages下找到它,安装就成功了。

现在我们已经安装了软件包,让我们继续创建项目。

创建项目

让我们看看如何做到这一点:

  1. 点击File|Add Project Folder

  2. 创建一个新文件夹或选择一个现有的文件夹。我将创建一个名为C:\Development\RAML的新文件夹并打开它。

  3. 在你的项目文件夹中添加一个名为Shop.raml的新文件。

  4. 右键单击文件,然后选择Add New|Create New API

  5. 给它任何你想要的名字,然后点击Ok。你现在刚刚创建了你的第一个 API 设计。

如果你看一下 RAML 文件,你会发现它的内容是人类可读的文本。我们刚刚创建的 API 包含一个简单的GET命令,返回一个包含单词"Hello World"的字符串:

#%RAML 1.0
title: Pet Shop
types:
  TestType:
    type: object
    properties:
      id: number
      optional?: string
      expanded:
        type: object
        properties:
          count: number
/helloWorld:
  get:
    responses:
      200:
        body:
          application/json:
            example: |
              {
                "message" : "Hello World"
              }

这是 RAML 代码。您会看到它与 JSON 非常相似,因为代码是简单的、可读的代码,它是缩进的。删除文件。从“包”菜单中,选择“API Workbench | 创建 RAML 项目”。填写“创建 RAML 项目”对话框,如下面的屏幕截图所示:

此对话框中的设置将生成以下 RAML 代码:

#%RAML 1.0
title: Pet Shop
version: v1
baseUri: /petshop
types:
  TestType:
    type: object
    properties:
      id: number
      optional?: string
      expanded:
        type: object
        properties:
          count: number
/helloWorld:
  get:
    responses:
      200:
        body:
          application/json:
            example: |
              {
                "message" : "Hello World"
              }

您查看的最后一个 RAML 文件和第一个 RAML 文件之间的主要区别是插入了versionbaseUri属性。这些设置还会更新您的“Project”文件夹的内容,如下所示:

有关此主题的非常详细的教程,请访问apiworkbench.com/docs/。此 URL 还提供了如何添加资源和方法、填写方法体和响应、添加子资源、添加示例和类型、创建和提取资源类型、添加资源类型参数、方法参数和特性、重用特性、资源类型和库、添加更多类型和资源、提取库等详细信息,远远超出了本章的范围。

既然我们有了一个与语言实现无关的设计,那么我们如何在 C#中生成我们的 API 呢?

从我们的通用 RAML 设计规范生成我们的 C# API

您至少需要安装 Visual Studio 2019 社区版。然后确保关闭 Visual Studio。还要下载并安装 Visual Studio 的MuleSoftInc.RAMLToolsforNET工具。安装了这些工具后,我们现在将按照生成我们先前指定的 API 的骨架框架所需的步骤进行。这将通过添加 RAML/OAS 合同并导入我们的 RAML 文件来实现:

  1. 在 Visual Studio 2019 中,创建一个新的.NET Framework 控制台应用程序。

  2. 右键单击项目,选择“添加 RAML/OAS 合同”。这将打开以下对话框:

  1. 点击“上传”,然后选择您的 RAML 文件。然后将呈现“导入 RAML/OAS”对话框。填写对话框如下所示,然后点击“导入”:

您的项目现在将使用所需的依赖项进行更新,并且新的文件夹和文件将被添加到您的控制台应用程序中。您将注意到三个根文件夹,称为ContractsControllersModels。在Contracts文件夹中,我们有我们的 RAML 文件和IV1HelloWorldController接口。它包含一个方法:Task<IHttpActionResult> Get()。v1HelloWorldController 类实现了 Iv1HelloWorldController 接口。让我们来看看控制器类中实现的Get()方法:

/// <summary>
/// /helloWorld
/// </summary>
/// <returns>HelloWorldGet200</returns>
public async Task<IHttpActionResult> Get()
{
    // TODO: implement Get - route: helloWorld/helloWorld
    // var result = new HelloWorldGet200();
    // return Ok(result);
    return Ok();
}

在上面的代码中,我们可以看到代码注释掉了HelloWorldGet200类的实例化和返回结果。HelloWorldGet200类是我们的模型类。我们可以更新我们的模型,使其包含我们想要的任何数据。在我们的简单示例中,我们不会太过于烦恼;我们只会返回"Hello World!"字符串。将取消注释的行更新为以下内容:

return Ok("Hello World!");

“Ok()方法返回OkNegotiatedContentResult类型。我们将从Program类中的Main()方法中调用此Get()方法。更新Main()`方法,如下所示:

static void Main(string[] args)
{
    Task.Run(async () =>
    {
        var hwc = new v1HelloWorldController();
        var response = await hwc.Get() as OkNegotiatedContentResult<string>;
        if (response is OkNegotiatedContentResult<string>)
        {
            var msg = response.Content;
            Console.WriteLine($"Message: {msg}");
        }
    }).GetAwaiter().GetResult();
    Console.ReadKey();
}

由于我们在静态方法中运行异步代码,因此我们必须将工作添加到线程池队列中。然后执行我们的代码并等待结果。一旦代码返回,我们只需等待按键,然后退出。

我们在控制台应用程序中创建了一个 MVC API,并根据我们导入的 RAML 文件执行了 API 调用。这个过程对于 ASP.NET 和 ASP.NET Core 网站也适用。现在我们将从现有 API 中提取 RAML。

从本章前面的股息日历 API 项目中加载。然后,右键单击该项目并选择提取 RAML。然后,一旦提取完成,运行您的项目。将 URL 更改为https://localhost:44325/raml。提取 RAML 时,代码生成过程会向您的项目添加一个RamlController类,以及一个 RAML 视图。您将看到您的 API 现在已经记录在案,如 RAML 视图所示:

通过使用 RAML,您可以设计一个 API,然后生成结构,也可以反向工程一个 API。RAML 规范帮助您设计 API,并通过修改 RAML 代码进行更改。如果您想了解更多信息,可以查看raml.org网站,以了解如何充分利用 RAML 规范。现在,让我们来看看 Swagger 以及如何在 ASP.NET Core 3+项目中使用它。

好了,我们现在已经到了本章的结尾。现在,我们将总结我们所取得的成就和所学到的知识。

总结

在本章中,我们讨论了 API 是什么。然后,我们看了如何使用 API 代理作为我们和 API 使用者之间的合同。这可以保护我们的 API 免受第三方的直接访问。接下来,我们看了一些改进 API 质量的设计准则。

然后,我们讨论了 Swagger,并了解了如何使用 Swagger 记录天气 API。然后介绍了测试 API,并看到了为什么测试您的代码以及您在项目中使用的任何第三方代码是有益的。最后,我们看了如何使用 RAML 设计一个与语言无关的 API,并将其翻译成一个使用 C#的工作项目。

在下一章中,我们将编写一个项目来演示如何使用 Azure Key Vault 保护密钥,并使用 API 密钥保护我们自己的 API。但在那之前,让我们让您的大脑运转一下,看看您学到了什么。

问题

  1. API 代表什么?

  2. REST 代表什么?

  3. REST 的六个约束是什么?

  4. HATEOAS 代表什么?

  5. RAML 是什么?

  6. Swagger 是什么?

  7. 术语“良好定义的软件边界”是什么意思?

  8. 为什么您应该了解您正在使用的 API?

  9. 结构体和对象哪个性能更好?

  10. 为什么应该测试第三方 API?

  11. 为什么应该测试您自己的 API?

  12. 您如何确定要为您的代码编写哪些测试?

  13. 列举三种将代码组织成良好定义的软件边界的方法。

进一步阅读

第十章:使用 API 密钥和 Azure Key Vault 保护 API

在本章中,我们将看到如何在 Azure Key Vault 中保存秘密。我们还将研究如何使用 API 密钥来通过身份验证和基于角色的授权保护我们自己的密钥。为了获得 API 安全性的第一手经验,我们将构建一个完全功能的 FinTech API。

我们的 API 将使用私钥(在 Azure Key Vault 中安全保存)提取第三方 API 数据。然后,我们将使用两个 API 密钥保护我们的 API;一个密钥将在内部使用,第二个密钥将由外部用户使用。

本章涵盖以下主题:

  • 访问 Morningstar API

  • 将 Morningstar API 存储在 Azure Key Vault 中

  • 在 Azure 中创建股息日历 ASP.NET Core Web 应用程序

  • 发布我们的 Web 应用程序

  • 使用 API 密钥保护我们的股息日历 API

  • 测试我们的 API 密钥安全性

  • 添加股息日历代码

  • 限制我们的 API

您将了解良好 API 设计的基础知识,并掌握推动 API 能力所需的知识。本章将帮助您获得以下技能:

  • 使用客户端 API 密钥保护 API

  • 使用 Azure Key Vault 存储和检索秘密

  • 使用 Postman 执行发布和获取数据的 API 命令

  • 在 RapidAPI.com 上申请并使用第三方 API

  • 限制 API 使用

  • 编写利用在线财务数据的 FinTech API

在继续之前,请确保您实施以下技术要求,以充分利用本章。

技术要求

在本章中,我们将使用以下技术编写 API:

进行 API 项目-股息日历

学习的最佳方式是通过实践。因此,我们将构建一个可用的 API 并对其进行安全保护。API 不会完美无缺,还有改进的空间。但是,您可以自由地实施这些改进,并根据需要扩展项目。这里的主要目标是拥有一个完全运作的 API,只做一件事:返回列出当前年度将支付的所有公司股息的财务数据。

我们将在本章中构建的股息日历 API 是一个使用 API 密钥进行身份验证的 API。根据使用的密钥,授权将确定用户是内部用户还是外部用户。然后,控制器将根据用户类型执行适当的方法。只有内部用户方法将被实现,但您可以自由地实施外部用户方法,作为训练练习。

内部方法从 Azure Key Vault 中提取 API 密钥,并执行对第三方 API 的各种 API 调用。数据以JavaScript 对象表示法JSON)格式返回,反序列化为对象,然后处理以提取未来的股息支付,并将其添加到股息列表中。然后将此列表以 JSON 格式返回给调用者。最终结果是一个 JSON 文件,其中包含当前年度的所有计划股息支付。然后,最终用户可以将这些数据转换为可以使用 LINQ 查询的股息列表。

我们将在本章中构建的项目是一个 Web API,它从第三方金融 API 返回处理过的 JSON。我们的项目将从给定的股票交易所获取公司列表。然后,我们将循环遍历这些公司以获取它们的股息数据。然后将处理股息数据以获取当前年份的数据。因此,我们最终将返回给 API 调用者的是 JSON 数据。这些 JSON 数据将包含公司列表及其当前年份的股息支付预测。然后,最终用户可以将 JSON 数据转换为 C#对象,并对这些对象执行 LINQ 查询。例如,可以执行查询以获取下个月的除权支付或本月到期的支付。

我们将使用的 API 将是 Morningstar API 的一部分,该 API 可通过 RapidAPI.com 获得。您可以注册一个免费的 Morningstar API 密钥。我们将使用登录系统来保护我们的 API,用户将使用电子邮件地址和密码登录。您还需要 Postman,因为我们将使用它来发出 API 的POSTGET请求到股息日历 API。

我们的解决方案将包含一个项目,这将是一个 ASP.NET Core 应用程序,目标是.NET Framework Core 3.1 或更高版本。现在我们将讨论如何访问 Morningstar API。

访问 Morningstar API

转到rapidapi.com/integraatio/api/morningstar1并请求 API 访问密钥。该 API 是 Freemium API。这意味着您可以在有限的时间内免费使用一定数量的调用,之后需要支付使用费用。花些时间查看 API 及其文档。当您收到密钥时,注意定价计划并保持密钥的机密性。

我们感兴趣的 API 如下:

  • GET /companies/list-by-exchange:此 API 返回指定交易所的国家列表。

  • GET /dividends:此 API 获取指定公司的所有历史和当前股息支付信息。

API 请求的第一部分是GET HTTP 动词,用于检索资源。API 请求的第二部分是要GET的资源,在这种情况下是/companies/list-by-exchange。正如我们在前面列表的第二个项目符号中所看到的,我们正在获取/dividends资源。

您可以在浏览器中测试每个 API,并查看返回的数据。我建议您在继续之前先这样做。这将帮助您对我们将要处理的内容有所了解。我们将使用的基本流程是获取属于指定交易所的公司列表,然后循环遍历它们以获取股息数据。如果股息数据有未来的支付日期,那么股息数据将被添加到日历中;否则,它将被丢弃。无论公司有多少股息数据,我们只对第一条记录感兴趣,这是最新的记录。

现在您已经拥有 API 密钥(假设您正在按照这些步骤进行),我们将开始构建我们的 API。

在 Azure Key Vault 中存储 Morningstar API 密钥

我们将使用 Azure Key Vault 和托管服务标识(MSI)与 ASP.NET Core Web 应用程序。因此,在继续之前,您将需要 Azure 订阅。对于新客户,可在azure.microsoft.com/en-us/free上获得免费 12 个月的优惠。

作为 Web 开发人员,不将机密存储在代码中非常重要,因为代码可以被反向工程。如果代码是开源的,那么上传个人或企业密钥到公共版本控制系统存在危险。解决这个问题的方法是安全地存储机密,但这会引发一个困境。要访问机密密钥,我们需要进行身份验证。那么,我们如何克服这个困境呢?

我们可以通过为我们的 Azure 服务启用 MSI 来克服这一困境。因此,Azure 会生成一个服务主体。用户开发的应用程序将使用此服务主体来访问 Microsoft Azure 上的资源。对于服务主体,您可以使用证书或用户名和密码,以及任何您选择的具有所需权限集的角色。

控制 Azure 帐户的人控制每项服务可以执行的具体任务。通常最好从完全限制开始,只有在需要时才添加功能。以下图表显示了我们的 ASP.NET Core Web 应用程序、MSI 和 Azure 服务之间的关系:

Azure Active DirectoryAzure AD)被 MSI 用于注入服务实例的服务主体。一个名为本地元数据服务的 Azure 资源用于获取访问仁牌,并将用于验证服务访问 Azure 密钥保管库。

然后,代码调用可用于获取访问令牌的 Azure 资源上的本地元数据服务。然后,我们的代码使用从本地 MSI 端点提取的访问令牌来对 Azure 密钥保管库服务进行身份验证。

打开 Azure CLI 并输入az login以登录到 Azure。一旦登录,我们就可以创建一个资源组。Azure 资源组是逻辑容器,用于部署和管理 Azure 资源。以下命令在East US位置创建一个资源组:

az group create --name "<YourResourceGroupName>" --location "East US"

在本章的其余部分中都使用此资源组。现在我们将继续创建我们的密钥保管库。创建密钥保管库需要以下信息:

  • 密钥保管库的名称,这是一个 3 到 24 个字符长的字符串,只能包含0-9a-zA-Z-(连字符)字符

  • 资源组的名称

  • 位置——例如,East USWest US

在 Azure CLI 中,输入以下命令:

az keyvault create --name "<YourKeyVaultName>" --resource-group "<YourResourceGroupName> --location "East US"

目前只有您的 Azure 帐户被授权在新的保管库上执行操作。如有必要,您可以添加其他帐户。

我们需要添加到项目中的主要密钥是MorningstarApiKey。要将 Morningstar API 密钥添加到您的密钥保管库中,请输入以下命令:

az keyvault secret set --vault-name "<YourKeyVaultName>" --name "MorningstarApiKey" --value "<YourMorningstarApiKey>"

您的密钥保管库现在存储了您的 Morningstar API 密钥。要检查该值是否正确存储,请输入以下命令:

az keyvault secret show --name "MorningstarApiKey" --vault-name "<YourKeyVaultName>"

现在您应该在控制台窗口中看到您的密钥显示,显示存储的密钥和值。

在 Azure 中创建股息日历 ASP.NET Core Web 应用程序

要完成项目的这一阶段,您需要安装了 ASP.NET 和 Web 开发工作负载的 Visual Studio 2019:

  1. 创建一个新的 ASP.NET Core Web 应用程序:

  1. 确保 API 选择了No Authentication

  1. 单击“创建”以创建您的新项目。然后运行您的项目。默认情况下,定义了一个示例天气预报 API,并在浏览器窗口中输出以下 JSON 代码:
[{"date":"2020-04-13T20:02:22.8144942+01:00","temperatureC":0,"temperatureF":32,"summary":"Balmy"},{"date":"2020-04-14T20:02:22.8234349+01:00","temperatureC":13,"temperatureF":55,"summary":"Warm"},{"date":"2020-04-15T20:02:22.8234571+01:00","temperatureC":3,"temperatureF":37,"summary":"Scorching"},{"date":"2020-04-16T20:02:22.8234587+01:00","temperatureC":-2,"temperatureF":29,"summary":"Sweltering"},{"date":"2020-04-17T20:02:22.8234602+01:00","temperatureC":-13,"temperatureF":9,"summary":"Cool"}]

接下来,我们将发布我们的应用程序到 Azure。

发布我们的 Web 应用程序

在我们可以发布我们的 Web 应用程序之前,我们将首先创建一个新的 Azure 应用服务来发布我们的应用程序。我们将需要一个资源组来包含我们的 Azure 应用服务,以及一个指定托管位置、大小和特性的新托管计划,用于托管我们的应用程序的 Web 服务器群。因此,让我们按照以下要求进行处理:

  1. 确保您从 Visual Studio 登录到 Azure 帐户。要创建应用服务,请右键单击刚创建的项目,然后从菜单中选择“发布”。这将显示“选择发布目标”对话框,如下所示:

  1. 选择 App Service | 创建新的,并点击创建配置文件。创建一个新的托管计划,如下例所示:

  1. 然后,确保您提供一个名称,选择一个订阅,并选择您的资源组。建议您还设置“应用程序洞察”设置:

  1. 点击“创建”以创建您的应用服务。创建完成后,您的“发布”屏幕应如下所示:

  1. 在这个阶段,您可以点击站点 URL。这将在浏览器中加载您的站点 URL。如果您的服务成功配置并运行,您的浏览器应该显示以下页面:

  1. 让我们发布我们的 API。点击“发布”按钮。当网页运行时,它将显示一个错误页面。修改 URL 为https://dividend-calendar.azurewebsites.net/weatherforecast。网页现在应该显示天气预报 API 的 JSON 代码:
[{"date":"2020-04-13T19:36:26.9794202+00:00","temperatureC":40,"temperatureF":103,"summary":"Hot"},{"date":"2020-04-14T19:36:26.9797346+00:00","temperatureC":7,"temperatureF":44,"summary":"Bracing"},{"date":"2020-04-15T19:36:26.9797374+00:00","temperatureC":8,"temperatureF":46,"summary":"Scorching"},{"date":"2020-04-16T19:36:26.9797389+00:00","temperatureC":11,"temperatureF":51,"summary":"Freezing"},{"date":"2020-04-17T19:36:26.9797403+00:00","temperatureC":3,"temperatureF":37,"summary":"Hot"}]

我们的服务现在已经上线。如果您登录到 Azure 门户并访问您的托管计划的资源组,您将看到四个资源。这些资源如下:

  • 应用服务dividend-calendar

  • 应用程序洞察dividend-calendar

  • 应用服务计划DividendCalendarHostingPlan

  • 密钥保管库:无论你的密钥保管库叫什么。在我的案例中,它叫Keys-APIs,如下所示:

如果您从 Azure 门户主页(portal.azure.com/#home)点击您的应用服务,您将看到您可以浏览到您的服务,以及停止、重新启动和删除您的应用服务:

现在我们已经在应用程序中使用了应用程序洞察,并且我们的 Morningstar API 密钥已经安全存储,我们可以开始构建我们的股息日历。

使用 API 密钥保护我们的股息日历 API

为了保护我们的股息日历 API 的访问,我们将使用客户端 API 密钥。有许多方法可以与客户共享客户端密钥,但我们将不在这里讨论它们。你可以想出自己的策略。我们将专注于如何使客户能够经过身份验证和授权访问我们的 API。

为了保持简单,我们将使用存储库模式。存储库模式有助于将我们的程序与底层数据存储解耦。这种模式提高了可维护性,并允许您更改底层数据存储而不影响程序。对于我们的存储库,我们的密钥将在一个类中定义,但在商业项目中,您可以将密钥存储在数据存储中,如 Cosmos DB、SQL Server 或 Azure 密钥保管库。您可以决定最适合您需求的策略,这也是我们使用存储库模式的主要原因,因为您可以控制自己需求的底层数据源。

设置存储库

我们将从设置我们的存储库开始:

  1. 在您的项目中添加一个名为Repository的新文件夹。然后,添加一个名为IRepository的新接口和一个将实现IRepository的类,名为InMemoryRepository。修改您的接口,如下所示:
using CH09_DividendCalendar.Security.Authentication;
using System.Threading.Tasks;

namespace CH09_DividendCalendar.Repository
{
    public interface IRepository
    {
        Task<ApiKey> GetApiKey(string providedApiKey);
    }
}
  1. 这个接口定义了一个用于检索 API 密钥的方法。我们还没有定义ApiKey类,我们将在稍后进行。现在,让我们实现InMemoryRepository。添加以下using语句:
using CH09_DividendCalendar.Security.Authentication;
using CH09_DividendCalendar.Security.Authorisation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
  1. 当我们开始添加身份验证和授权类时,将创建security命名空间。修改Repository类以实现IRepository接口。添加将保存我们的 API 密钥的成员变量,然后添加GetApiKey()方法:
    public class InMemoryRepository : IRepository
    {
        private readonly IDictionary<string, ApiKey> _apiKeys;

        public Task<ApiKey> GetApiKey(string providedApiKey)
        {
            _apiKeys.TryGetValue(providedApiKey, out var key);
            return Task.FromResult(key);
        }
    }
  1. InMemoryRepository类实现了IRepositoryGetApiKey()方法。这将返回一个 API 密钥的字典。这些密钥将存储在我们的_apiKeys字典成员变量中。现在,我们将添加我们的构造函数:
public InMemoryRepository()
{
    var existingApiKeys = new List<ApiKey>
    {
        new ApiKey(1, "Internal", "C5BFF7F0-B4DF-475E-A331-F737424F013C", new DateTime(2019, 01, 01),
            new List<string>
            {
                Roles.Internal
            }),
        new ApiKey(2, "External", "9218FACE-3EAC-6574-C3F0-08357FEDABE9", new DateTime(2020, 4, 15),
            new List<string>
            {
                Roles.External
            })
        };

    _apiKeys = existingApiKeys.ToDictionary(x => x.Key, x => x);
}
  1. 我们的构造函数创建了一个新的 API 密钥列表。它为内部使用创建了一个内部 API 密钥,为外部使用创建了一个外部 API 密钥。然后将列表转换为字典,并将字典存储在_apiKeys中。因此,我们现在已经有了我们的存储库。

  2. 我们将使用一个名为X-Api-Key的 HTTP 标头。这将存储客户端的 API 密钥,该密钥将传递到我们的 API 进行身份验证和授权。在项目中添加一个名为Shared的新文件夹,然后添加一个名为ApiKeyConstants的新文件。使用以下代码更新文件:

namespace CH09_DividendCalendar.Shared
{
    public struct ApiKeyConstants
    {
        public const string HeaderName = "X-Api-Key";
        public const string MorningstarApiKeyUrl 
            = "https://<YOUR_KEY_VAULT_NAME>.vault.azure.net/secrets/MorningstarApiKey";
    }
}

这个文件包含两个常量——标头名称,用于建立用户身份的时候使用,以及 Morningstar API 密钥的 URL,它存储在我们之前创建的 Azure 密钥保管库中。

  1. 由于我们将处理 JSON 数据,我们需要设置我们的 JSON 命名策略。在项目中添加一个名为Json的文件夹。然后,添加一个名为DefaultJsonSerializerOptions的类:
using System.Text.Json;

namespace CH09_DividendCalendar.Json
{
    public static class DefaultJsonSerializerOptions
    {
        public static JsonSerializerOptions Options => new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            IgnoreNullValues = true
        };
    }
}

DefaultJsonSerializerOptions类将我们的 JSON 命名策略设置为忽略空值并使用驼峰命名法。

我们现在将开始为我们的 API 添加身份验证和授权。

设置身份验证和授权

我们现在将开始为身份验证和授权的安全类工作。首先澄清一下我们所说的身份验证和授权的含义是很好的。身份验证是确定用户是否被授权访问我们的 API。授权是确定用户一旦获得对我们的 API 的访问权限后拥有什么权限。

添加身份验证

在继续之前,将一个Security文件夹添加到项目中,然后在该文件夹下添加AuthenticationAuthorisation文件夹。我们将首先添加我们的Authentication类;我们将添加到Authentication文件夹的第一个类是ApiKey。向ApiKey添加以下属性:

public int Id { get; }
public string Owner { get; }
public string Key { get; }
public DateTime Created { get; }
public IReadOnlyCollection<string> Roles { get; }

这些属性存储与指定 API 密钥及其所有者相关的信息。这些属性是通过构造函数设置的:

public ApiKey(int id, string owner, string key, DateTime created, IReadOnlyCollection<string> roles)
{
    Id = id;
    Owner = owner ?? throw new ArgumentNullException(nameof(owner));
    Key = key ?? throw new ArgumentNullException(nameof(key));
    Created = created;
    Roles = roles ?? throw new ArgumentNullException(nameof(roles));
}

构造函数设置 API 密钥属性。如果一个人身份验证失败,他们将收到一个Error 403 Unauthorized的消息。因此,现在让我们定义我们的UnauthorizedProblemDetails类:

public class UnauthorizedProblemDetails : ProblemDetails
{
    public UnauthorizedProblemDetails(string details = null)
    {
        Title = "Forbidden";
        Detail = details;
        Status = 403;
        Type = "https://httpstatuses.com/403";
    }
}

这个类继承自Microsoft.AspNetCore.Mvc.ProblemDetails类。构造函数接受一个string类型的单个参数,默认为null。如果需要,您可以将详细信息传递给这个构造函数以提供更多信息。接下来,我们添加AuthenticationBuilderExtensions

public static class AuthenticationBuilderExtensions
{
    public static AuthenticationBuilder AddApiKeySupport(
        this AuthenticationBuilder authenticationBuilder, 
        Action<ApiKeyAuthenticationOptions> options
    )
    {
        return authenticationBuilder
            .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>            
                (ApiKeyAuthenticationOptions.DefaultScheme, options);
    }
}

这个扩展方法将 API 密钥支持添加到身份验证服务中,在Startup类的ConfigureServices方法中设置。现在,添加ApiKeyAuthenticationOptions类:

public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
    public const string DefaultScheme = "API Key";
    public string Scheme => DefaultScheme;
    public string AuthenticationType = DefaultScheme;
}

ApiKeyAuthenticationOptions类继承自AuthenticationSchemeOptions类。我们将默认方案设置为使用 API 密钥身份验证。我们授权的最后一部分是构建我们的ApiKeyAuthenticationHandler类。顾名思义,这是用于验证 API 密钥,确保客户端被授权访问和使用我们的 API 的主要类:

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private const string ProblemDetailsContentType = "application/problem+json";
    private readonly IRepository _repository;
}

我们的ApiKeyAuthenticationHandler类继承自AuthenticationHandler并使用ApiKeyAuthenticationOptions。我们将问题详细信息(异常信息)的内容类型定义为application/problem+json。我们还使用_repository成员变量提供了 API 密钥存储库的占位符。下一步是声明我们的构造函数:

public ApiKeyAuthenticationHandler(
    IOptionsMonitor<ApiKeyAuthenticationOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder,
    ISystemClock clock,
    IRepository repository
) : base(options, logger, encoder, clock)
{
    _repository = repository ?? throw new ArgumentNullException(nameof(repository));
}

我们的构造函数将ApiKeyAuthenticationOptionsILoggerFactoryUrlEncoderISystemClock参数传递给基类。明确地,我们设置了存储库。如果存储库为空,我们将抛出一个带有存储库名称的空参数异常。让我们添加我们的HandleChallengeAsync()方法:

protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
    Response.StatusCode = 401;
    Response.ContentType = ProblemDetailsContentType;
    var problemDetails = new UnauthorizedProblemDetails();
    await Response.WriteAsync(JsonSerializer.Serialize(problemDetails, 
        DefaultJsonSerializerOptions.Options));
}

当用户挑战失败时,HandleChallengeAsync()方法返回一个Error 401 Unauthorized的响应。现在,让我们添加我们的HandleForbiddenAsync()方法:

protected override async Task HandleForbiddenAsync(AuthenticationProperties properties)
{
    Response.StatusCode = 403;
    Response.ContentType = ProblemDetailsContentType;
    var problemDetails = new ForbiddenProblemDetails();
    await Response.WriteAsync(JsonSerializer.Serialize(problemDetails, 
        DefaultJsonSerializerOptions.Options));
}

当用户权限检查失败时,HandleForbiddenAsync()方法返回Error 403 Forbidden响应。现在,我们需要添加一个最终的方法,返回AuthenticationResult

protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
    if (!Request.Headers.TryGetValue(ApiKeyConstants.HeaderName, out var apiKeyHeaderValues))
        return AuthenticateResult.NoResult();
    var providedApiKey = apiKeyHeaderValues.FirstOrDefault();
    if (apiKeyHeaderValues.Count == 0 || string.IsNullOrWhiteSpace(providedApiKey))
        return AuthenticateResult.NoResult();
    var existingApiKey = await _repository.GetApiKey(providedApiKey);
    if (existingApiKey != null) {
        var claims = new List<Claim> {new Claim(ClaimTypes.Name, existingApiKey.Owner)};
        claims.AddRange(existingApiKey.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
        var identity = new ClaimsIdentity(claims, Options.AuthenticationType);
        var identities = new List<ClaimsIdentity> { identity };
        var principal = new ClaimsPrincipal(identities);
        var ticket = new AuthenticationTicket(principal, Options.Scheme);
        return AuthenticateResult.Success(ticket);
    }
    return AuthenticateResult.Fail("Invalid API Key provided.");
}

我们刚刚编写的代码检查我们的标头是否存在。如果标头不存在,则AuthenticateResult()返回None属性的布尔值true,表示此请求未提供任何信息。然后我们检查标头是否有值。如果没有提供值,则return值表示此请求未提供任何信息。然后我们使用客户端密钥从我们的存储库中获取我们的服务器端密钥。

如果服务器端的密钥为空,则返回一个失败的AuthenticationResult()实例,表示提供的 API 密钥无效,如Exception类型的Failure属性中所标识的那样。否则,用户被视为真实,并被允许访问我们的 API。对于有效的用户,我们为他们的身份设置声明,然后返回一个成功的AuthenticateResult()实例。

所以,我们已经解决了我们的身份验证问题。现在,我们需要处理我们的授权。

添加授权

我们的授权类将被添加到Authorisation文件夹中。使用以下代码添加Roles结构:

public struct Roles
{
    public const string Internal = "Internal";
    public const string External = "External";
}

我们期望我们的 API 在内部和外部都可以使用。但是,对于我们的最小可行产品,只实现了内部用户的代码。现在,添加Policies结构:

public struct Policies
{
    public const string Internal = nameof(Internal);
    public const string External = nameof(External);
}

在我们的Policies结构中,我们添加了两个将用于内部和外部客户端的策略。现在,我们将添加ForbiddenProblemDetails类:

public class ForbiddenProblemDetails : ProblemDetails
{
    public ForbiddenProblemDetails(string details = null)
    {
        Title = "Forbidden";
        Detail = details;
        Status = 403;
        Type = "https://httpstatuses.com/403";
    }
}

如果一个或多个权限对经过身份验证的用户不可用,这个类提供了禁止的问题详细信息。如果需要,您可以将一个字符串传递到这个类的构造函数中,提供相关信息。

对于我们的授权,我们需要为内部和外部客户端添加授权要求和处理程序。首先,我们将添加ExternalAuthorisationHandler类:

public class ExternalAuthorisationHandler : AuthorizationHandler<ExternalRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, 
        ExternalRequirement requirement
    )
    {
        if (context.User.IsInRole(Roles.External))
            context.Succeed(requirement);
        return Task.CompletedTask;
}
 public class ExternalRequirement : IAuthorizationRequirement
 {
 }

ExternalRequirement类是一个空类,实现了IAuthorizationRequirement接口。现在,添加InternalAuthorisationHandler类:

public class InternalAuthorisationHandler : AuthorizationHandler<InternalRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, 
        InternalRequirement requirement
    )
    {
        if (context.User.IsInRole(Roles.Internal))
            context.Succeed(requirement);
        return Task.CompletedTask;
    }
}

InternalAuthorisationHandler类处理内部要求的授权。如果上下文用户被分配到内部角色,则授予权限。否则,将拒绝权限。让我们添加所需的InternalRequirement类:

public class InternalRequirement : IAuthorizationRequirement
{
}

在这里,InternalRequirement类是一个空类,实现了IAuthorizationRequirement接口。

现在我们已经将我们的身份验证和授权类放在了适当的位置。所以,现在是时候更新我们的Startup类,将security类连接起来。首先修改Configure()方法:

public void Configure(IApplicationBuilder app, IHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseRouting();
    app.UseAuthentication();
 app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

Configure()方法将异常页面设置为开发人员页面(如果我们处于开发中)。然后请求应用程序使用routing将 URI 与我们的控制器中的操作匹配。然后通知应用程序应该使用我们的身份验证和授权方法。最后,从控制器映射应用程序端点。

我们需要更新的最后一个方法来完成我们的 API 密钥身份验证和授权是ConfigureServices()方法。我们需要做的第一件事是添加我们的具有 API 密钥支持的身份验证服务:

services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = ApiKeyAuthenticationOptions.DefaultScheme;
    options.DefaultChallengeScheme = ApiKeyAuthenticationOptions.DefaultScheme;
}).AddApiKeySupport(options => { });

在这里,我们设置了默认的身份验证方案。我们使用我们的扩展密钥AddApiKeySupport(),如在我们的AuthenticationBuilderExtensions类中定义的那样,返回Microsoft.AspNetCore.Authentication.AuthenticationBuilder。我们的默认方案设置为 API 密钥,如在我们的ApiKeyAuthenticationOptions类中配置的那样。API 密钥是一个常量值,通知身份验证服务我们将使用 API 密钥身份验证。现在,我们需要添加我们的授权服务:

services.AddAuthorization(options =>
{
    options.AddPolicy(Policies.Internal, policy => policy.Requirements.Add(new InternalRequirement()));
    options.AddPolicy(Policies.External, policy => policy.Requirements.Add(new ExternalRequirement()));
});

在这里,我们正在设置我们的内部和外部策略和要求。这些定义在我们的PoliciesInternalRequirementExternalRequirement类中。

好了,我们已经添加了所有的 API 密钥安全类。因此,我们现在可以使用 Postman 测试我们的 API 密钥身份验证和授权是否有效。

测试我们的 API 密钥安全性

在本节中,我们将使用 Postman 测试我们的 API 密钥身份验证和授权。在您的Controllers文件夹中添加一个名为DividendCalendar的类。更新类如下:

[ApiController]
[Route("api/[controller]")]
public class DividendCalendar : ControllerBase
{
    [Authorize(Policy = Policies.Internal)]
    [HttpGet("internal")]
    public IActionResult GetDividendCalendar()
    {
        var message = $"Hello from {nameof(GetDividendCalendar)}.";
        return new ObjectResult(message);
    }

    [Authorize(Policy = Policies.External)]
    [HttpGet("external")]
    public IActionResult External()
    {
        var message = "External access is currently unavailable.";
        return new ObjectResult(message);
    }
}

这个类将包含我们的股息日历 API 代码功能。尽管在我们的最小可行产品的初始版本中不会使用外部代码,但我们将能够测试我们的内部和外部身份验证和授权。

  1. 打开 Postman 并创建一个新的GET请求。对于 URL,请使用https://localhost:44325/api/dividendcalendar/internal。点击发送:

  1. 如您所见,在 API 请求中没有 API 密钥,我们得到了预期的401 未经授权状态,以及我们在ForbiddenProblemDetails类中定义的禁止 JSON。现在,添加x-api-key头,并使用C5BFF7F0-B4DF-475E-A331-F737424F013C值。然后,点击发送:

  1. 现在您将获得一个200 OK的状态。这意味着 API 请求已成功。您可以在正文中看到请求的结果。内部用户将看到Hello from GetDividendCalendar。再次运行请求,但更改 URL,使路由为外部而不是内部。因此,URL 应为https://localhost:44325/api/dividendcalendar/external

  1. 您应该收到一个403 禁止的状态和禁止的 JSON。这是因为 API 密钥是有效的 API 密钥,但路由是为外部客户端而设,外部客户端无法访问内部 API。将x-api-key头值更改为9218FACE-3EAC-6574-C3F0-08357FEDABE9。然后,点击发送:

您将看到您的状态是200 OK,并且正文中有当前无法访问外部的文本。

好消息!我们使用 API 密钥身份验证和授权的基于角色的安全系统已经经过测试并且有效。因此,在我们实际添加我们的 FinTech API 之前,我们已经实施并测试了我们的 API 密钥,用于保护我们的 FinTech API。因此,在编写我们实际 API 的一行代码之前,我们已经将 API 的安全性放在首位。现在,我们可以认真开始构建我们的股息日历 API 功能,知道它是安全的。

添加股息日历代码

我们的内部 API 只有一个目的,那就是建立今年要支付的股息数组。然而,您可以在此项目的基础上构建,将 JSON 保存到文件或某种类型的数据库中。因此,您只需要每月进行一次内部调用,以节省 API 调用的费用。然而,外部角色可以根据需要从您的文件或数据库中访问数据。

我们已经为我们的股息日历 API 准备好了控制器。这个安全性是为了防止未经身份验证和未经授权的用户访问我们的内部GetDividendCalendar()API 端点。因此,现在我们所要做的就是生成股息日历 JSON,我们的方法将返回。

为了让您看到我们将要努力实现的目标,请查看以下截断的 JSON 响应:

[{"Mic":"XLON","Ticker":"ABDP","CompanyName":"AB Dynamics PLC","DividendYield":0.0,"Amount":0.0279,"ExDividendDate":"2020-01-02T00:00:00","DeclarationDate":"2019-11-27T00:00:00","RecordDate":"2020-01-03T00:00:00","PaymentDate":"2020-02-13T00:00:00","DividendType":null,"CurrencyCode":null},

...

{"Mic":"XLON","Ticker":"ZYT","CompanyName":"Zytronic PLC","DividendYield":0.0,"Amount":0.152,"ExDividendDate":"2020-01-09T00:00:00","DeclarationDate":"2019-12-10T00:00:00","RecordDate":"2020-01-10T00:00:00","PaymentDate":"2020-02-07T00:00:00","DividendType":null,"CurrencyCode":null}]

这个 JSON 响应是一个股息数组。股息由MicTickerCompanyNameDividendYieldAmountExDividendDateDeclarationDateRecordDatePaymentDateDividendTypeCurrencyCode字段组成。在您的项目中添加一个名为Models的新文件夹,然后添加以下代码的Dividend类:

public class Dividend
{
    public string Mic { get; set; }
    public string Ticker { get; set; }
    public string CompanyName { get; set; }
    public float DividendYield { get; set; }
    public float Amount { get; set; }
    public DateTime? ExDividendDate { get; set; }
    public DateTime? DeclarationDate { get; set; }
    public DateTime? RecordDate { get; set; }
    public DateTime? PaymentDate { get; set; }
    public string DividendType { get; set; }
    public string CurrencyCode { get; set; }
}

让我们看看每个字段代表什么:

  • Mic: ISO 10383 市场识别代码MIC),这是股票上市的地方。有关更多信息,请参阅www.iso20022.org/10383/iso-10383-market-identifier-codes

  • Ticker: 普通股的股票市场代码。

  • CompanyName: 拥有该股票的公司的名称。

  • DividendYield: 公司年度股利与股价的比率。股利收益率以百分比计算,并使用股利收益率=年度股利/股价公式计算。

  • Amount: 每股支付给股东的金额。

  • ExDividendDate: 在此日期之前,您必须购买股票才能收到下一个股利支付。

  • DeclarationDate: 公司宣布支付股利的日期。

  • RecordDate: 公司查看其记录以确定谁将收到股利的日期。

  • PaymentDate: 股东收到股利支付的日期。

  • DividendType: 这可以是,例如,现金股利财产股利股票股利分红股清算股利

  • CurrencyCode: 金额将支付的货币。

我们在Models文件夹中需要的下一个类是Company类:

public class Company
    {
        public string MIC { get; set; }
        public string Currency { get; set; }
        public string Ticker { get; set; }
        public string SecurityId { get; set; }
        public string CompanyName { get; set; }
    }

MicTicker字段与我们的Dividend类相同。在不同的 API 调用之间,API 使用不同的货币标识符名称。这就是为什么我们在Dividend中有CurrencyCode,在Company中有Currency。这有助于 JSON 对象映射过程,以便我们不会遇到格式化异常。

这些字段分别代表以下内容:

  • Currency: 用于定价股票的货币

  • SecurityId: 普通股的股票市场安全标识符

  • CompanyName: 拥有该股票的公司的名称

我们接下来的Models类称为Companies。这个类用于存储在初始 Morningstar API 调用中返回的公司。我们将循环遍历公司列表,以进行进一步的 API 调用,以获取每家公司的记录,以便我们随后进行 API 调用以获取公司的股利:

 public class Companies
 {
     public int Total { get; set; }
     public int Offset { get; set; }
     public List<Company> Results { get; set; }
     public string ResponseStatus { get; set; }
 }

这些属性分别定义以下内容:

  • Total: 从 API 查询返回的记录总数

  • Offset: 记录偏移量

  • Results: 返回的公司列表

  • ResponseStatus: 提供详细的响应信息,特别是如果返回错误的话

现在,我们将添加Dividends类。这个类保存了股利的列表,这些股利是通过股利的 Morningstar API 响应返回的:

public class Dividends
{
        public int Total { get; set; }
        public int Offset { get; set; }
        public List<Dictionary<string, string>> Results { get; set; }
        public ResponseStatus ResponseStatus { get; set; }
    }

这些属性与之前定义的相同,除了Results属性,它定义了返回指定公司的股利支付列表。

我们需要添加到我们的Models文件夹中的最后一个类是ResponseStatus类。这主要用于存储错误信息:

public class ResponseStatus
{
    public string ErrorCode { get; set; }
    public string Message { get; set; }
    public string StackTrace { get; set; }
    public List<Dictionary<string, string>> Errors { get; set; }
    public List<Dictionary<string, string>> Meta { get; set; }
}

该类的属性如下:

  • ErrorCode: 错误的编号

  • Message: 错误消息

  • StackTrace: 错误诊断

  • Errors: 错误列表

  • Meta: 错误元数据列表

我们现在已经准备好了所有需要的模型。现在,我们可以开始进行 API 调用,以建立我们的股利支付日历。在控制器中,添加一个名为FormatStringDate()的新方法,如下所示:

private DateTime? FormatStringDate(string date)
{
    return string.IsNullOrEmpty(date) ? (DateTime?)null : DateTime.Parse(date);
}

该方法接受一个字符串日期。如果字符串为 null 或空,则返回 null。否则,解析字符串并传回一个可空的DateTime值。我们还需要一个方法,从 Azure 密钥保管库中提取我们的 Morningstar API 密钥:

private async Task<string> GetMorningstarApiKey()
{
    try
    {
        AzureServiceTokenProvider azureServiceTokenProvider = new AzureServiceTokenProvider();
        KeyVaultClient keyVaultClient = new KeyVaultClient(
            new KeyVaultClient.AuthenticationCallback(
                azureServiceTokenProvider.KeyVaultTokenCallback
            )
        );
        var secret = await keyVaultClient.GetSecretAsync(ApiKeyConstants.MorningstarApiKeyUrl)
                                         .ConfigureAwait(false);
        return secret.Value;
    }
    catch (KeyVaultErrorException keyVaultException)
    {
        return keyVaultException.Message;
    }
}

GetMorningstarApiKey()方法实例化AzureServiceTokenProvider。然后,它创建一个新的KeyVaultClient对象类型,执行加密密钥操作。然后,该方法等待从 Azure 密钥保管库获取 Morningstar API 密钥的响应。然后,它传回响应值。如果在处理请求时发生错误,则返回KeyVaultErrorException.Message

在处理股息时,我们首先从证券交易所获取公司列表。然后,我们循环遍历这些公司,并对该证券交易所中的每家公司进行另一个调用以获取每家公司的股息。因此,我们将从通过 MIC 获取公司列表的方法开始。请记住,我们使用RestSharp库。因此,如果您还没有安装它,现在是一个很好的时机。

private Companies GetCompanies(string mic)
{
    var client = new RestClient(
        $"https://morningstar1.p.rapidapi.com/companies/list-by-exchange?Mic={mic}"
    );
    var request = new RestRequest(Method.GET);
    request.AddHeader("x-rapidapi-host", "morningstar1.p.rapidapi.com");
    request.AddHeader("x-rapidapi-key", GetMorningstarApiKey().Result);
    request.AddHeader("accept", "string");
    IRestResponse response = client.Execute(request);
    return JsonConvert.DeserializeObject<Companies>(response.Content);
}

我们的GetCompanies()方法创建一个新的 REST 客户端,指向检索上市公司列表的 API URL。请求的类型是GET请求。我们为GET请求添加了三个头部,分别是x-rapidapi-hostx-rapidapi-keyaccept。然后,我们执行请求并通过Companies模型返回反序列化的 JSON 数据。

现在,我们将编写返回指定交易所和公司的股息的方法。让我们从添加GetDividends()方法开始:

private Dividends GetDividends(string mic, string ticker)
{
    var client = new RestClient(
        $"https://morningstar1.p.rapidapi.com/dividends?Ticker={ticker}&Mic={mic}"
    );
    var request = new RestRequest(Method.GET);
    request.AddHeader("x-rapidapi-host", "morningstar1.p.rapidapi.com");
    request.AddHeader("x-rapidapi-key", GetMorningstarApiKey().Result);
    request.AddHeader("accept", "string");
    IRestResponse response = client.Execute(request);
    return JsonConvert.DeserializeObject<Dividends>(response.Content);
}

GetDividends()方法与GetCompanies()方法相同,只是请求返回指定股票交易所和公司的股息。 JSON 反序列化为Dividends对象的实例并返回。

对于我们的最终方法,我们需要将我们的最小可行产品构建到BuildDividendCalendar()方法中。这个方法是构建股息日历 JSON 的方法,将返回给客户端:

private List<Dividend> BuildDividendCalendar()
{
    const string MIC = "XLON";
    var thisYearsDividends = new List<Dividend>();
    var companies = GetCompanies(MIC);
    foreach (var company in companies.Results) {
        var dividends = GetDividends(MIC, company.Ticker);
        if (dividends.Results == null)
            continue;
        var currentDividend = dividends.Results.FirstOrDefault();
        if (currentDividend == null || currentDividend["payableDt"] == null)
            continue;
        var dateDiff = DateTime.Compare(
            DateTime.Parse(currentDividend["payableDt"]), 
            new DateTime(DateTime.Now.Year - 1, 12, 31)
        );
        if (dateDiff > 0) {
            var payableDate = DateTime.Parse(currentDividend["payableDt"]);
            var dividend = new Dividend() {
                Mic = MIC,
                Ticker = company.Ticker,
                CompanyName = company.CompanyName,
                ExDividendDate = FormatStringDate(currentDividend["exDividendDt"]),
                DeclarationDate = FormatStringDate(currentDividend["declarationDt"]),
                RecordDate = FormatStringDate(currentDividend["recordDt"]),
                PaymentDate = FormatStringDate(currentDividend["payableDt"]),
                Amount = float.Parse(currentDividend["amount"])
            };
            thisYearsDividends.Add(dividend);
        }
    }
    return thisYearsDividends;
}

在这个 API 的版本中,我们将 MIC 硬编码为"XLON"——伦敦证券交易所。然而,在未来的版本中,这个方法和公共端点可以更新为接受request参数的 MIC。然后,我们添加一个list变量来保存今年的股息支付。然后,我们执行我们的 Morningstar API 调用,以提取当前在指定 MIC 上市的公司列表。一旦列表返回,我们循环遍历结果。对于每家公司,我们然后进行进一步的 API 调用,以获取指定 MIC 和股票的完整股息记录。如果公司没有列出股息,那么我们继续下一个迭代并选择下一个公司。

如果公司有股息记录,我们获取第一条记录,这将是最新的股息支付。我们检查可支付日期是否为null。如果可支付日期为null,那么我们继续下一个迭代,选择下一个客户。如果可支付日期不为null,我们检查可支付日期是否大于上一年的 12 月 31 日。如果日期差大于 1,那么我们将向今年的股息列表添加一个新的股息对象。一旦我们遍历了所有公司并建立了今年的股息列表,我们将列表传回给调用方法。

在运行项目之前的最后一步是更新GetDividendCalendar()方法以调用BuildDividendCalendar()方法:

[Authorize(Policy = Policies.Internal)]
[HttpGet("internal")]
public IActionResult GetDividendCalendar()
{
    return new ObjectResult(JsonConvert.SerializeObject(BuildDividendCalendar()));
}

GetDividendCalendar()方法中,我们从今年的股息序列化列表返回一个 JSON 字符串。因此,如果您在 Postman 中使用内部x-api-key变量运行项目,那么大约 20 分钟后,将返回以下 JSON:

[{"Mic":"XLON","Ticker":"ABDP","CompanyName":"AB Dynamics PLC","DividendYield":0.0,"Amount":0.0279,"ExDividendDate":"2020-01-02T00:00:00","DeclarationDate":"2019-11-27T00:00:00","RecordDate":"2020-01-03T00:00:00","PaymentDate":"2020-02-13T00:00:00","DividendType":null,"CurrencyCode":null},

...

{"Mic":"XLON","Ticker":"ZYT","CompanyName":"Zytronic PLC","DividendYield":0.0,"Amount":0.152,"ExDividendDate":"2020-01-09T00:00:00","DeclarationDate":"2019-12-10T00:00:00","RecordDate":"2020-01-10T00:00:00","PaymentDate":"2020-02-07T00:00:00","DividendType":null,"CurrencyCode":null}]

这个查询确实需要很长时间才能运行,大约 20 分钟左右,结果会在一年的时间内发生变化。因此,我们可以使用的一种策略是限制 API 每月运行一次,然后将 JSON 存储在文件或数据库中。然后,这个文件或数据库记录就是您要更新的外部方法调用并传回给外部客户端。让我们将 API 限制为每月运行一次。

限制我们的 API

在暴露 API 时,您需要对其进行节流。有许多可用的方法来做到这一点,例如限制同时用户的数量或限制在给定时间内的调用次数。

在这一部分,我们将对我们的 API 进行节流。我们将用来节流 API 的方法是限制我们的 API 每月只能在当月的 25 日运行一次。将以下一行添加到您的appsettings.json文件中:

"MorningstarNextRunDate":  null,

这个值将包含下一个 API 可以执行的日期。现在,在项目的根目录添加AppSettings类,然后添加以下属性:

public DateTime? MorningstarNextRunDate { get; set; }

这个属性将保存MorningstarNextRunDate键的值。接下来要做的是添加我们的静态方法,该方法将被调用以在appsetting.json文件中添加或更新应用程序设置:

public static void AddOrUpdateAppSetting<T>(string sectionPathKey, T value)
{
    try
    {
        var filePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
        string json = File.ReadAllText(filePath);
        dynamic jsonObj = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
        SetValueRecursively(sectionPathKey, jsonObj, value);
        string output = Newtonsoft.Json.JsonConvert.SerializeObject(
            jsonObj, 
            Newtonsoft.Json.Formatting.Indented
        );
        File.WriteAllText(filePath, output);
    }
    catch (Exception ex)
    {
        Console.WriteLine("Error writing app settings | {0}", ex.Message);
    }
}

AddOrUpdateAppSetting()尝试获取appsettings.json文件的文件路径。然后从文件中读取 JSON。然后将 JSON 反序列化为dynamic对象。然后我们调用我们的方法递归设置所需的值。然后,我们将 JSON 写回同一文件。如果遇到错误,则将错误消息输出到控制台。让我们编写我们的SetValueRecursively()方法:

private static void SetValueRecursively<T>(string sectionPathKey, dynamic jsonObj, T value)
{
    var remainingSections = sectionPathKey.Split(":", 2);
    var currentSection = remainingSections[0];
    if (remainingSections.Length > 1)
    {
        var nextSection = remainingSections[1];
        SetValueRecursively(nextSection, jsonObj[currentSection], value);
    }
    else
    {
        jsonObj[currentSection] = value;
    }
}

SetValueRecursively()方法在第一个撇号字符处拆分字符串。然后递归处理 JSON,向下移动树。当它到达需要的位置时,也就是找到所需的值时,然后设置该值并返回该方法。将ThrottleMonthDay常量添加到ApiKeyConstants结构中:

public const int ThrottleMonthDay = 25;

当 API 请求发出时,此常量用于我们的日期检查。在DividendCalendarController中,添加ThrottleMessage()方法:

private string ThrottleMessage()
{
    return "This API call can only be made once on the 25th of each month.";
}

ThrottleMessage()方法只是返回消息,"此 API 调用只能在每月的 25 日进行一次。"。现在,添加以下构造函数:

public DividendCalendarController(IOptions<AppSettings> appSettings)
{
    _appSettings = appSettings.Value;
}

这个构造函数为我们提供了访问appsettings.json文件中的值。将以下两行添加到您的Startup.ConfigureServices()方法的末尾:

var appSettingsSection = Configuration.GetSection("AppSettings");
services.Configure<AppSettings>(appSettingsSection);

这两行使AppSettings类能够在需要时动态注入到我们的控制器中。将SetMorningstarNextRunDate()方法添加到DividendCalendarController类中:

private DateTime? SetMorningstarNextRunDate()
{
    int month;
    if (DateTime.Now.Day < 25)
        month = DateTime.Now.Month;
    else
        month = DateTime.Now.AddMonths(1).Month;
    var date = new DateTime(DateTime.Now.Year, month, ApiKeyConstants.ThrottleMonthDay);
    AppSettings.AddOrUpdateAppSetting<DateTime?>(
        "MorningstarNextRunDate",
        date
    );
    return date;
}

SetMorningstarNextRunDate()方法检查当前月份的日期是否小于25。如果当前月份的日期小于25,则将月份设置为当前月份,以便 API 可以在当月的 25 日运行。否则,对于大于或等于25的日期,月份将设置为下个月。然后组装新日期,然后更新appsettings.jsonMorningstarNextRunDate键,返回可空的DateTime值:

private bool CanExecuteApiRequest()
{
    DateTime? nextRunDate = _appSettings.MorningstarNextRunDate;
    if (!nextRunDate.HasValue) 
        nextRunDate = SetMorningstarNextRunDate();
    if (DateTime.Now.Day == ApiKeyConstants.ThrottleMonthDay) {
        if (nextRunDate.Value.Month == DateTime.Now.Month) {
            SetMorningstarNextRunDate();
            return true;
        }
        else {
            return false;
        }
    }
    else {
        return false;
    }
}

CanExecuteApiRequest()AppSettings类中获取MorningstarNextRunDate值的当前值。如果DateTime?没有值,则将该值设置并分配给nextRunDate本地变量。如果当前月份的日期不等于ThrottleMonthDay,则返回false。如果当前月份不等于下次运行日期的月份,则返回false。否则,我们将下一个 API 运行日期设置为下个月的 25 日,并返回true

最后,我们更新我们的GetDividendCalendar()方法,如下所示:

[Authorize(Policy = Policies.Internal)]
[HttpGet("internal")]
public IActionResult GetDividendCalendar()
{
    if (CanExecuteApiRequest())
        return new ObjectResult(JsonConvert.SerializeObject(BuildDividendCalendar()));
    else
        return new ObjectResult(ThrottleMessage());
}

现在,当内部用户调用 API 时,他们的请求将被验证,以查看是否可以运行。如果运行,则返回股息日历的序列化 JSON。否则,我们返回throttle消息。

这就完成了我们的项目。

好了,我们完成了我们的项目。它并不完美,还有我们可以做的改进和扩展。下一步是记录我们的 API 并部署 API 和文档。我们还应该添加日志记录和监控。

日志记录对于存储异常详细信息以及跟踪我们的 API 的使用方式非常有用。 监控是一种监视我们的 API 健康状况的方法,这样我们可以在出现问题时收到警报。 这样,我们可以积极地保持我们的 API 正常运行。 我将让您根据需要扩展 API。 这对您来说将是一个很好的学习练习。

下一章将涉及横切关注点。 它将让您了解如何使用方面和属性来处理日志记录和监视。

让我们总结一下我们学到的东西。

总结

在本章中,您注册了一个第三方 API 并收到了自己的密钥。 API 密钥存储在您的 Azure 密钥保险库中,并且不被未经授权的客户端访问。 然后,您开始创建了一个 ASP.NET Core Web 应用程序并将其发布到 Azure。 然后,您开始使用身份验证和基于角色的授权来保护 Web 应用程序。

我们设置的授权是使用 API 密钥执行的。 在这个项目中,您使用了两个 API 密钥——一个用于内部使用,一个用于外部使用。 我们使用 Postman 应用程序进行了 API 和 API 密钥安全性的测试。 Postman 是一个非常好的有用的工具,用于测试各种 HTTP 谓词的 HTTP 请求和响应。

然后,您添加了股息日历 API 代码,并基于 API 密钥启用了内部和外部访问。 项目本身执行了许多不同的 API 调用,以建立一份预计向投资者支付股息的公司列表。 项目然后将对象序列化为 JSON 格式,返回给客户端。 最后,该项目被限制为每月运行一次。

因此,通过完成本章,您已经创建了一个 FinTech API,可以每月运行一次。 该 API 将为当年提供股息支付信息。 您的客户可以对此数据进行反序列化,然后对其执行 LINQ 查询,以提取满足其特定要求的数据。

在下一章中,我们将使用 PostSharp 来实现面向方面的编程AOP)。 通过我们的 AOP 框架,我们将学习如何在应用程序中管理常见功能,如异常处理,日志记录,安全性和事务。 但在那之前,让我们让您的大脑思考一下您学到了什么。

问题

  1. 哪个 URL 是托管您自己的 API 并访问第三方 API 的良好来源?

  2. 保护 API 所需的两个必要部分是什么?

  3. 声明是什么,为什么应该使用它们?

  4. 您用 Postman 做什么?

  5. 为什么应该使用存储库模式来管理数据存储?

进一步阅读

第十一章:解决横切关注点

在编写清晰代码时,您需要考虑两种类型的关注点-核心关注点和横切关注点。核心关注点是软件的原因以及为什么开发它。横切关注点是不属于业务需求的关注点,但必须在代码的所有区域中进行处理,如下图所示:

正是横切关注点,我们将在本章中通过构建一个可重用的类库来进行覆盖,您可以修改或扩展它以满足您的需求。横切关注点包括配置管理、日志记录、审计、安全、验证、异常处理、仪表、事务、资源池、缓存以及线程和并发。我们将使用装饰者模式和 PostSharp Aspect Framework 来帮助我们构建我们的可重用库,该库在编译时注入。

当您阅读本章时,您将看到属性编程如何导致使用更少的样板代码,以及更小、更可读、更易于维护和扩展的代码。这样,您的方法中只留下了所需的业务代码和样板代码。

我们已经讨论了许多这些想法。然而,它们在这里再次提到,因为它们是横切关注点。

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

  • 装饰者模式

  • 代理模式

  • 使用 PostSharp 应用 AOP。

  • 项目-横切关注点可重用库

通过本章结束时,您将具备以下技能:

  • 实现装饰者模式。

  • 实现代理模式。

  • 使用 PostSharp 应用 AOP。

  • 构建您自己的可重用 AOP 库,以解决您的横切关注点。

技术要求

要充分利用本章,您需要安装 Visual Studio 2019 和 PostSharp。有关本章的代码文件,请参阅github.com/PacktPublishing/Clean-Code-in-C-/tree/master/CH11。让我们从装饰者模式开始。

装饰者模式

装饰者设计模式是一种结构模式,用于在不改变其结构的情况下向现有对象添加新功能。原始类被包装在装饰类中,并在运行时向对象添加新的行为和操作:

Component接口及其包含的成员由ConcreteComponent类和Decorator类实现。ConcreteComponent实现了Component接口。Decorator类是一个实现Component接口并包含对Component实例的引用的抽象类。Decorator类是组件的基类。ConcreteDecorator类继承自Decorator类,并为组件提供装饰器。

我们将编写一个示例,将一个操作包装在try/catch块中。trycatch都将向控制台输出一个字符串。创建一个名为CH10_AddressingCrossCuttingConcerns的新.NET 4.8 控制台应用程序。然后,添加一个名为DecoratorPattern的文件夹。添加一个名为IComponent的新接口:

public interface IComponent {
   void Operation();
}

为了保持简单,我们的接口只有一个void类型的操作。现在我们已经有了接口,我们需要添加一个实现接口的抽象类。添加一个名为Decorator的新抽象类,它实现了IComponent接口。添加一个成员变量来存储我们的IComponent对象:

private IComponent _component;

存储IComponent对象的_component成员变量是通过构造函数设置的,如下所示:

public Decorator(IComponent component) {
    _component = component;
}

在上述代码中,构造函数设置了我们将要装饰的组件。接下来,我们添加我们的接口方法:

public virtual void Operation() {
    _component.Operation();
}

我们将Operation()方法声明为virtual,以便可以在派生类中重写它。现在,我们将创建我们的ConcreteComponent类,它实现IComponent

public class ConcreteComponent : IComponent {
    public void Operation() {
        throw new NotImplementedException();
    }
}

如您所见,我们的类包括一个操作,它抛出NotImplementedException。现在,我们可以写关于ConcreteDecorator类:

public class ConcreteDecorator : Decorator {
    public ConcreteDecorator(IComponent component) : base(component) { }
}

ConcreteDecorator类继承自Decorator类。构造函数接受一个IComponent参数,并将其传递给基类构造函数,然后设置成员变量。接下来,我们将重写Operation()方法:

public override void Operation() {
    try {
        Console.WriteLine("Operation: try block.");
        base.Operation();
    } catch(Exception ex)  {
        Console.WriteLine("Operation: catch block.");
        Console.WriteLine(ex.Message);
    }
}

在我们重写的方法中,我们有一个try/catch块。在try块中,我们向控制台写入一条消息,并执行基类的Operation()方法。在catch块中,当遇到异常时,会写入一条消息,然后是错误消息。在我们可以使用我们的代码之前,我们需要更新Program类。将DecoratorPatternExample()方法添加到Program类中:

private static void DecoratorPatternExample() {
    var concreteComponent = new ConcreteComponent();
    var concreteDecorator = new ConcreteDecorator(concreteComponent);
    concreteDecorator.Operation();
}

在我们的DecoratorPatternExample()方法中,我们创建一个新的具体组件。然后,我们将其传递给一个新的具体装饰器的构造函数。然后,我们在具体装饰器上调用Operation()方法。将以下两行添加到Main()方法中:

DecoratorPatternExample();
Console.ReadKey();

这两行执行我们的示例,然后等待用户按键退出。运行代码,您应该看到与以下截图相同的输出:

这就结束了我们对装饰器模式的讨论。现在,是时候来看看代理模式了。

代理模式

代理模式是一种结构设计模式,提供作为客户端使用的真实服务对象的替代对象。代理接收客户端请求,执行所需的工作,然后将请求传递给服务对象。代理对象可以与服务对象互换,因为它们共享相同的接口:

您希望使用代理模式的一个例子是当您有一个您不想更改的类,但您需要添加额外的行为时。代理将工作委托给其他对象。除非代理是服务的派生类,否则代理方法应最终引用Service对象。

我们将看一个非常简单的代理模式实现。在您的Chapter 11项目的根目录下添加一个名为ProxyPattern的文件夹。添加一个名为IService的接口,其中包含一个处理请求的方法:

public interface IService {
    void Request();
}

Request()方法执行执行请求的工作。代理和服务都将实现这个接口来使用Request()方法。现在,添加Service类并实现IService接口:

public class Service : IService {
    public void Request() {
        Console.WriteLine("Service: Request();");
    }
}

我们的Service类实现了IService接口,并处理实际的服务Request()方法。这个Request()方法将被Proxy类调用。实现代理模式的最后一步是编写Proxy类:

public class Proxy : IService {
    private IService _service;

    public Proxy(IService service) {
        _service = service;
    }

    public void Request() {
        Console.WriteLine("Proxy: Request();");
        _service.Request();
    }
}

我们的Proxy类实现了IService,并具有一个接受单个IService参数的构造函数。客户端调用Proxy类的Request()方法。Proxy.Request()方法将执行所需的操作,并负责调用_service.Request()。为了看到这一点,让我们更新我们的Program类。在Main()方法中添加ProxyPatternExample()调用。然后,添加ProxyPatternExample()方法:

private static void ProxyPatternExample() {
    Console.WriteLine("### Calling the Service directly. ###");
    var service = new Service();
    service.Request();
    Console.WriteLine("## Calling the Service via a Proxy. ###");
    new Proxy(service).Request();
}

我们的测试方法运行Service类的Request()方法。然后,通过Proxy类的Request()方法运行相同的方法。运行项目,您应该看到以下内容:

现在您已经对装饰器和代理模式有了工作理解,让我们来看看使用 PostSharp 的 AOP。

使用 PostSharp 的 AOP

AOP 可以与 OOP 一起使用。方面是应用于类、方法、参数和属性的属性,在编译时,将代码编织到应用的类、方法、参数或属性中。这种方法允许程序的横切关注从业务源代码移动到类库中。关注点在需要时作为属性添加。然后编译器在运行时编织所需的代码。这使得您的业务代码保持简洁和可读。在本章中,我们将使用 PostSharp。您可以从www.postsharp.net/download下载它。

那么,AOP 如何与 PostSharp 一起工作呢?

您需要将 PostSharp 包添加到项目中。然后,您可以使用属性对代码进行注释。C#编译器将您的代码构建成二进制代码,然后 PostSharp 分析二进制代码并注入方面的实现。尽管二进制代码在编译时被修改并注入了代码,但您的项目源代码保持不变。这意味着您可以保持代码的整洁、简洁,从而使长期内维护、重用和扩展现有代码库变得更加容易。

PostSharp 有一些非常好的现成模式供您利用。这些模式涵盖了Model-View-ViewModelMVVM)、缓存、多线程、日志和架构验证等。但好消息是,如果没有符合您要求的内容,那么您可以通过扩展方面框架和/或架构框架来自动化自己的模式。

使用方面框架,您可以开发简单或复合方面,将其应用于代码,并验证其使用。至于架构框架,您可以开发自定义的架构约束。在我们深入研究横切关注之前,让我们简要地看一下如何扩展方面和架构框架。

在编写方面和属性时,您需要添加PostSharp.Redist NuGet 包。完成后,如果发现您的属性和方面不起作用,那么右键单击项目并选择添加 PostSharp 到项目。完成此操作后,您的方面应该可以工作。

扩展方面框架

在本节中,我们将开发一个简单的方面并将其应用于一些代码。然后,我们将验证我们方面的使用。

开发我们的方面

我们的方面将是一个由单个转换组成的简单方面。我们将从原始方面类派生我们的方面。然后,我们将重写一些称为建议的方法。如果您想知道如何创建复合方面,可以在doc.postsharp.net/complex-aspects上阅读如何做到这一点。

在方法执行前后注入行为

OnMethodBoundaryAspect方面实现了装饰器模式。您已经在本章前面看到了如何实现装饰器模式。通过这个方面,您可以在目标方法执行前后执行逻辑。以下表格提供了OnMethodBoundaryAspect类中可用的建议方法列表:

建议 描述
OnEntry(MethodExecutionArgs) 在方法执行开始时使用,用户代码之前。
OnSuccess(MethodExecutionArgs) 在方法执行成功(即没有异常返回)后使用,用户代码之后。
OnException(MethodExecutionArgs) 在方法执行失败并出现异常后使用,用户代码之后。相当于catch块。
OnExit(MethodExecutionArgs) 在方法执行退出时使用,无论成功与否或出现异常。此建议在用户代码之后以及当前方面的OnSuccess(MethodExecutionArgs)OnException(MethodExecutionArgs)方法之后运行。相当于finally块。

对于我们简单的方面,我们将查看所有正在使用的方法。在开始之前,将 PostSharp 添加到您的项目中。如果您已经下载了 PostSharp,可以右键单击您的项目,然后选择添加 PostSharp 到项目。之后,添加一个名为Aspects的新文件夹到您的项目中,然后添加一个名为LoggingAspect的新类:

[PSerializable]
public class LoggingAspect : OnMethodBoundaryAspect { }

[PSerializeable]属性是一个自定义属性,当应用于类型时,会导致 PostSharp 生成一个供PortableFormatter使用的序列化器。现在,重写OnEntry()方法:

public override void OnEntry(MethodExecutionArgs args) {
    Console.WriteLine("The {0} method has been entered.", args.Method.Name);
}

OnEntry()方法在任何用户代码之前执行。现在,重写OnSuccess()方法:

public override void OnSuccess(MethodExecutionArgs args) {
    Console.WriteLine("The {0} method executed successfully.", args.Method.Name);
}

OnSuccess()方法在用户代码完成时执行。重写OnExit()方法:

public override void OnExit(MethodExecutionArgs args) {
    Console.WriteLine("The {0} method has exited.", args.Method.Name);
} 

OnExit()方法在用户方法成功或失败完成并退出时执行。它相当于一个finally块。最后,重写OnException()方法:

public override void OnException(MethodExecutionArgs args) { 
    Console.WriteLine("An exception was thrown in {0}.", args.Method.Name); 
}

OnException()方法在方法执行失败并出现异常时执行,执行在任何用户代码之后。它相当于一个catch块。

下一步是编写两个可以应用LoggingAspect的方法。我们将添加SuccessfulMethod()

[LoggingAspect]
private static void SuccessfulMethod() {
    Console.WriteLine("Hello World, I am a success!");
}

SuccessfulMethod()使用LoggingAspect并在控制台上打印一条消息。现在,让我们添加FailedMethod()

[LoggingAspect]
private static void FailedMethod() {
    Console.WriteLine("Hello World, I am a failure!");
    var x = 1;
    var y = 0;
    var z = x / y;
}

FailedMethod()使用LoggingAspect并在控制台上打印一条消息。然后,它执行了一个除零操作,导致DivideByZeroException。从您的Main()方法中调用这两种方法,然后运行您的项目。您应该看到以下输出:

此时,调试器将导致程序退出。就是这样。正如您所看到的,创建自己的 PostSharp 方面以满足您的需求是一个简单的过程。现在,我们将看看如何添加我们自己的架构约束。

扩展架构框架

架构约束是采用必须在所有模块中遵守的自定义设计模式。我们将实现一个标量约束,用于验证代码的元素。

我们的标量约束,称为BusinessRulePatternValidation,将验证从BusinessRule类派生的任何类必须具有名为Factory的嵌套类。首先添加BusinessRulePatternValidation类:

[MulticastAttributeUsage(MulticastTargets.Class, Inheritance = MulticastInheritance.Strict)] 
public class BusinessRulePatternValidation : ScalarConstraint { }

MulticastAttributeUsage指定此验证方面只能与允许类和继承的类一起使用。让我们重写ValidateCode()方法:

public override void CodeValidation(object target)  { 
    var targetType = (Type)target; 
    if (targetType.GetNestedType("Factory") == null) { 
        Message.Write( 
            targetType, SeverityType.Warning, 
            "10", 
            "You must include a 'Factory' as a nested type for {0}.", 
            targetType.DeclaringType, 
            targetType.Name); 
    } 
} 

我们的ValidateCode()方法检查目标对象是否具有嵌套的Factory类型。如果Factory类型不存在,则会向输出窗口写入异常消息。添加BusinessRule类:

 [BusinessRulePatternValidation]
 public class BusinessRule  { }

BusinessRule类是空的,没有Factory。它有我们分配给它的BusinessRulePatternValidation属性,这是一个架构约束。构建您的项目,您将在输出窗口中看到消息。我们现在将开始构建一个可重用的类库,您可以在自己的项目中扩展和使用它来解决横切关注点,使用 AOP 和装饰器模式。

项目-横切关注点可重用库

在本节中,我们将通过编写一个可重用库来解决各种横切关注点的问题。它的功能有限,但它将为您提供进一步扩展项目所需的知识。您将创建的类库将是一个.NET 标准库,以便可以用于同时针对.NET Framework 和.NET Core 的应用程序。您还将创建一个.NET Framework 控制台应用程序,以查看库的运行情况。

首先创建一个名为CrossCuttingConcerns的新.NET 标准类库。然后,在解决方案中添加一个名为TestHarness的.NET Framework 控制台应用程序。我们将添加可重用的功能来解决各种问题,从缓存开始。

添加缓存关注点

缓存是一种用于提高访问各种资源时性能的存储技术。使用的缓存可以是内存、文件系统或数据库。您使用的缓存类型将取决于项目的需求。为了演示,我们将使用内存缓存来保持简单。

CrossCuttingConcerns项目中添加一个名为Caching的文件夹。然后,添加一个名为MemoryCache的类。向项目添加以下 NuGet 包:

  • PostSharp

  • PostSharp.Patterns.Common

  • PostSharp.Patterns.Diagnostics

  • System.Runtime.Caching

使用以下代码更新MemoryCache类:

public static class MemoryCache {
    public static T GetItem<T>(string itemName, TimeSpan timeInCache, Func<T> itemCacheFunction) {
        var cache = System.Runtime.Caching.MemoryCache.Default;
        var cachedItem = (T) cache[itemName];
        if (cachedItem != null) return cachedItem;
        var policy = new CacheItemPolicy {AbsoluteExpiration = DateTimeOffset.Now.Add(timeInCache)};
        cachedItem = itemCacheFunction();
        cache.Set(itemName, cachedItem, policy);
        return cachedItem;
    }
}

“GetItem()”方法接受缓存项的名称itemName,缓存项保留在缓存中的时间长度timeInCache,以及在将项目放入缓存时调用的函数itemCacheFunction。在TestHarness项目中添加一个新类,命名为TestClass。然后,添加“GetCachedItem()”和“GetMessage()”方法,如下所示:

public string GetCachedItem() {
    return MemoryCache.GetItem<string>("Message", TimeSpan.FromSeconds(30), GetMessage);
}

private string GetMessage() {
    return "Hello, world of cache!";
}

“GetCachedItem()”方法从缓存中获取名为"Message"的字符串。如果它不在缓存中,那么它将由“GetMessage()”方法存储在缓存中 30 秒。

Program类中更新您的“Main()”方法,调用“GetCachedItem()”方法,如下所示:

var harness = new TestClass();
Console.WriteLine(harness.GetCachedItem());
Console.WriteLine(harness.GetCachedItem());
Thread.Sleep(TimeSpan.FromSeconds(1));
Console.WriteLine(harness.GetCachedItem());

第一次调用“GetCachedItem()”将项目存储在缓存中,然后返回它。第二次调用从缓存中获取项目并返回它。睡眠线程使缓存无效,因此最后一次调用在返回项目之前将项目存储在缓存中。

添加文件日志功能

在我们的项目中,日志记录、审计和仪表化过程将它们的输出发送到文本文件。因此,我们需要一个类来管理如果文件不存在则添加文件,然后将输出添加到这些文件并保存它们。在类库中添加一个名为FileSystem的文件夹。然后,添加一个名为LogFile的类。将该类设置为public static,并添加以下成员变量:

private static string _location = string.Empty;
private static string _filename = string.Empty;
private static string _file = string.Empty;

_location 变量被分配为条目程序集的文件夹。_filename 变量被分配为带有文件扩展名的文件名。我们需要在运行时添加Logs文件夹(如果不存在)。因此,我们将在FileSystem类中添加“AddDirectory()”方法:

private static void AddDirectory() {
    if (!Directory.Exists(_location))
        Directory.CreateDirectory("Logs");
}

“AddDirectory()”方法检查位置是否存在。如果不存在,则创建该目录。接下来,我们需要处理如果文件不存在则添加文件的情况。因此,添加“AddFile()”方法:

private static void AddFile() {
    _file = Path.Combine(_location, _filename);
    if (File.Exists(_file)) return;
    using (File.Create($"Logs\\{_filename}")) {

    }
}

在“AddFile()”方法中,我们将位置和文件名组合在一起。如果文件名已经存在,那么我们退出方法;否则,我们创建文件。如果我们不使用using语句,当我们创建我们的第一条记录时,我们将遇到IOException,但随后的保存将会很好。因此,通过使用using语句,我们避免了异常并记录了数据。现在我们可以编写一个实际将数据保存到文件的方法。添加“AppendTextToFile()”方法:

public static void AppendTextToFile(string filename, string text) {
    _location = $"{Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location)}\\Logs";
    _filename = filename;
    AddDirectory();
    AddFile();
    File.AppendAllText(_file, text);
}

“AppendTextToFile()”方法接受文件名和文本,并将位置设置为条目程序集的位置。然后,它确保文件和目录存在。然后,它将文本保存到指定的文件中。现在我们已经处理了文件日志功能,现在我们可以继续查看我们的日志关注。

添加日志关注

大多数应用程序都需要某种形式的日志记录。通常的日志记录方法是控制台、文件系统、事件日志和数据库。在我们的项目中,我们只关注控制台和文本文件日志记录。在类库中添加一个名为Logging的文件夹。然后,添加一个名为ConsoleLoggingAspect的文件,并更新如下:

[PSerializable]
public class ConsoleLoggingAspect : OnMethodBoundaryAspect { }

[PSerializable] 属性通知 PostSharp 生成一个供 PortableFormatter 使用的序列化器。ConsoleLoggingAspect 继承自 OnMethodBoundaryAspectOnMethodBoundaryAspect 类有我们可以重写的方法,以在方法主体执行之前、之后、成功执行时以及遇到异常时添加代码。我们将重写这些方法以向控制台输出消息。当涉及调试时,这可能是一个非常有用的工具,以查看代码是否实际被调用,以及它是否成功完成或遇到异常。我们将从重写 OnEntry() 方法开始:

public override void OnEntry(MethodExecutionArgs args) {
    Console.WriteLine($"Method: {args.Method.Name}, OnEntry().");
}

OnEntry() 方法在我们的方法体执行之前执行,并且我们的重写打印出已执行的方法的名称和它自己的名称。接下来,我们将重写 OnExit() 方法:

public override void OnExit(MethodExecutionArgs args) {
    Console.WriteLine($"Method: {args.Method.Name}, OnExit().");
}

OnExit() 方法在我们的方法体执行完成后执行,并且我们的重写打印出已执行的方法的名称和它自己的名称。现在,我们将添加 OnSuccess() 方法:

public override void OnSuccess(MethodExecutionArgs args) {
    Console.WriteLine($"Method: {args.Method.Name}, OnSuccess().");
}

OnSuccess() 方法在应用于方法的主体完成并且没有异常返回后执行。当我们的重写执行时,它打印出已执行的方法的名称和它自己的名称。我们将要重写的最后一个方法是 OnException() 方法:

public override void OnException(MethodExecutionArgs args) {
    Console.WriteLine($"An exception was thrown in {args.Method.Name}. {args}");
}

OnException() 方法在遇到异常时执行,在我们的重写中,我们打印出方法的名称和参数对象的名称。要应用属性,请使用 [ConsoleLoggingAspect]。要添加文本文件日志记录方面,添加一个名为 TextFileLoggingAspect 的类。TextFileLoggingAspectConsoleLoggingAspect 相同,除了重写方法的内容。OnEntry()OnExit()OnSuccess() 方法调用 LogFile.AppendTextToFile() 方法,并将内容附加到 Log.txt 文件中。OnException() 方法也是一样,只是它将内容附加到 Exception.log 文件中。这是 OnEntry() 的示例:

public override void OnEntry(MethodExecutionArgs args) {
    LogFile.AppendTextToFile("Log.txt", $"\nMethod: {args.Method.Name}, OnEntry().");
}

这就是我们的日志记录处理完毕。现在,我们将继续添加我们的异常处理关注。

添加异常处理关注

在软件中,用户将不可避免地遇到异常。因此,需要一些方法来记录它们。记录异常的常规方式是将错误存储在用户系统上的文件中,例如 Exception.log。这就是我们将在本节中做的。我们将继承自 OnExceptionAspect 类,并将我们的异常数据写入 Exception.log 文件中,该文件将位于我们应用程序的 Logs 文件夹中。OnExceptionAspect 将标记的方法包装在 try/catch 块中。在类库中添加一个名为 Exceptions 的新文件夹,然后添加一个名为 ExceptionAspect 的文件,其中包含以下代码:

[PSerializable]
public class ExceptionAspect : OnExceptionAspect {
    public string Message { get; set; }
    public Type ExceptionType { get; set; }
    public FlowBehavior Behavior { get; set; }

    public override void OnException(MethodExecutionArgs args) {
        var message = args.Exception != null ? args.Exception.Message : "Unknown error occured.";
        LogFile.AppendTextToFile(
            "Exceptions.log", $"\n{DateTime.Now}: Method: {args.Method}, Exception: {message}"
        );
        args.FlowBehavior = FlowBehavior.Continue;
    }

    public override Type GetExceptionType(System.Reflection.MethodBase targetMethod) {
        return ExceptionType;
    }
}

ExceptionAspect 类被分配了 [PSerializable] 方面,并继承自 OnExceptionAspect。我们有三个属性:messageExceptionTypeFlowBehaviormessage 包含异常消息,ExceptionType 包含遇到的异常类型,FlowBehavior 决定异常处理后是否继续执行或者进程是否终止。GetExceptionType() 方法返回抛出的异常类型。OnException() 方法首先构造错误消息。然后通过调用 LogFile.AppendTextToFile() 将异常记录到文件中。最后,异常行为的流程被设置为继续。

要使用 [ExceptionAspect] 方面的唯一要做的就是将其作为属性添加到您的方法中。我们现在已经涵盖了异常处理。所以,我们将继续添加我们的安全性关注。

添加安全性关注

安全需求将针对正在开发的项目而具体。最常见的问题是用户是否经过身份验证并获得授权访问和使用系统的各个部分。在本节中,我们将使用装饰器模式实现具有基于角色的方法的安全组件。

安全本身是一个非常庞大的主题,超出了本书的范围。有许多优秀的 API,例如各种 Microsoft API。有关更多信息,请参阅docs.microsoft.com/en-us/dotnet/standard/security/,有关 OAuth 2.0,请参阅oauth.net/code/dotnet/。我们将让您选择并实现自己的安全方法。在本章中,我们只是使用装饰器模式添加了我们自己定义的安全性。您可以将其用作实现任何前述安全方法的基础。

新增一个名为Security的文件夹,并为其添加一个名为ISecureComponent的接口:

public interface ISecureComponent {
    void AddData(dynamic data);
    int EditData(dynamic data);
    int DeleteData(dynamic data);
    dynamic GetData(dynamic data);
}

我们的安全组件接口包含前面的四种方法,这些方法都是不言自明的。dynamic关键字意味着可以将任何类型的数据作为参数传递,并且可以从GetData()方法返回任何类型的数据。接下来,我们需要一个实现接口的抽象类。添加一个名为DecoratorBase的类,如下所示:

public abstract class DecoratorBase : ISecureComponent {
    private readonly ISecureComponent _secureComponent;

    public DecoratorBase(ISecureComponent secureComponent) {
        _secureComponent = secureComponent;
    }
}

DecoratorBase类实现了ISecureComponent。我们声明了一个ISecureComponent类型的成员变量,并在默认构造函数中设置它。我们需要添加ISecureComponent的缺失方法。添加AddData()方法:

public virtual void AddData(dynamic data) {
    _secureComponent.AddData(data);
}

此方法将接受任何类型的数据,然后将其传递给_secureComponentAddData()方法。为EditData()DeleteData()GetData()添加缺失的方法。现在,添加一个名为ConcreteSecureComponent的类,该类实现了ISecureComponent。对于每个方法,向控制台写入一条消息。对于DeleteData()EditData()方法,还返回一个值1。对于GetData(),返回"Hi!"ConcreteSecureComponent类是执行我们感兴趣的安全工作的类。

我们需要一种验证用户并获取其角色的方法。在执行任何方法之前,将检查角色。因此,添加以下结构:

public readonly struct Credentials {
    public static string Role { get; private set; }

    public Credentials(string username, string password) {
        switch (username)
        {
            case "System" when password == "Administrator":
                Role = "Administrator";
                break;
            case "End" when password == "User":
                Role = "Restricted";
                break;
            default:
                Role = "Imposter";
                break;
        }
    }
}

为了保持简单,该结构接受用户名和密码,并设置适当的角色。受限用户的权限比管理员少。我们安全问题的最终类是ConcreteDecorator类。添加如下类:

public class ConcreteDecorator : DecoratorBase {
    public ConcreteDecorator(ISecureComponent secureComponent) : base(secureComponent) { }
}

ConcreteDecorator类继承自DecoratorBase类。我们的构造函数接受ISecureComponent类型,并将其传递给基类。添加AddData()方法:

public override void AddData(dynamic data) {
    if (Credentials.Role.Contains("Administrator") || Credentials.Role.Contains("Restricted")) {
        base.AddData((object)data);
    } else {
        throw new UnauthorizedAccessException("Unauthorized");
    }
}

AddMethod()检查用户的角色是否与允许的AdministratorRestricted角色匹配。如果用户属于这些角色之一,则在基类中执行AddData()方法;否则,抛出UnauthorizedAccessException。其他方法遵循相同的模式。重写其他方法,但确保DeleteData()方法只能由管理员执行。

现在,让我们开始处理安全问题。在Program类的顶部添加以下行:

private static readonly ConcreteDecorator ConcreteDecorator = new ConcreteDecorator(
    new ConcreteSecureComponent()
);

我们声明并实例化一个具体的装饰器对象,并传入具体的安全对象。此对象将在我们的数据方法中引用。更新Main()方法,如下所示:

private static void Main(string[] _) {
    // ReSharper disable once ObjectCreationAsStatement
    new Credentials("End", "User");
    DoSecureWork();
    Console.WriteLine("Press any key to exit.");
    Console.ReadKey();
}

我们将用户名和密码分配给Credentials结构。这将导致设置Role。然后调用DoWork()方法。DoWork()方法将负责调用数据方法。然后暂停等待用户按任意键并退出。添加DoWork()方法:

private static void DoSecureWork() {
    AddData();
    EditData();
    DeleteData();
    GetData();
}

DoSecureWork()方法调用每个调用具体装饰器上的数据方法的数据方法。添加AddData()方法:

[ExceptionAspect(consoleOutput: true)]
private static void AddData() {
    ConcreteDecorator.AddData("Hello, world!");
}

[ExceptionAspect]应用于AddData()方法。这将确保任何错误都被记录到Exceptions.log文件中。参数设置为true,因此错误消息也将打印在控制台窗口中。方法本身调用ConcreteDecorator类的AddData()方法。按照相同的步骤添加其余的方法。然后运行你的代码。你应该看到以下输出:

现在我们有一个可以工作的基于角色的对象,包括异常处理。我们的下一步是实现验证关注点。

添加验证关注点

所有用户输入的数据都应该经过验证,因为它可能是恶意的、不完整的或格式错误的。您需要确保您的数据是干净的,不会造成伤害。对于我们的演示关注点,我们将实现空值验证。首先,在类库中添加一个名为Validation的文件夹。然后,添加一个名为AllowNullAttribute的新类:

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.Property)]
public class AllowNullAttribute : Attribute { }

该属性允许参数、返回值和属性上的空值。现在,将ValidationFlags枚举添加到同名的新文件中:

[Flags]
public enum ValidationFlags {
    Properties = 1,
    Methods = 2,
    Arguments = 4,
    OutValues = 8,
    ReturnValues = 16,
    NonPublic = 32,
    AllPublicArguments = Properties | Methods | Arguments,
    AllPublic = AllPublicArguments | OutValues | ReturnValues,
    All = AllPublic | NonPublic
}

这些标志用于确定方面可以应用于哪些项。接下来,我们将添加一个名为ReflectionExtensions的类:

public static class ReflectionExtensions {
    private static bool IsCustomAttributeDefined<T>(this ICustomAttributeProvider value) where T 
        : Attribute  {
        return value.IsDefined(typeof(T), false);
    }

    public static bool AllowsNull(this ICustomAttributeProvider value) {
        return value.IsCustomAttributeDefined<AllowNullAttribute>();
    }

    public static bool MayNotBeNull(this ParameterInfo arg) {
        return !arg.AllowsNull() && !arg.IsOptional && !arg.ParameterType.IsValueType;
    }
}

IsCustomAttributeDefined()方法在该成员上定义了该属性类型时返回true,否则返回falseAllowsNull()方法在已应用[AllowNull]属性时返回true,否则返回falseMayNotBeNull()方法检查是否允许空值,参数是否可选,以及参数的值类型。然后通过对这些值进行逻辑AND操作来返回一个布尔值。现在是时候添加DisallowNonNullAspect了:

[PSerializable]
public class DisallowNonNullAspect : OnMethodBoundaryAspect {
    private int[] _inputArgumentsToValidate;
    private int[] _outputArgumentsToValidate;
    private string[] _parameterNames;
    private bool _validateReturnValue;
    private string _memberName;
    private bool _isProperty;

    public DisallowNonNullAspect() : this(ValidationFlags.AllPublic) { }

    public DisallowNonNullAspect(ValidationFlags validationFlags) {
        ValidationFlags = validationFlags;
    }

    public ValidationFlags ValidationFlags { get; set; }
}

该类应用了[PSerializable]属性,以通知 PostSharp 为PortableFormatter生成序列化程序。它还继承了OnMethodBoundaryAspect类。然后,我们声明变量来保存经过验证的参数名称、返回值验证和成员名称,并检查被验证的项是否是属性。默认构造函数配置为允许验证器应用于所有公共成员。我们还有一个构造函数,它接受一个ValidationFlags值和一个ValidationFlags属性。现在,我们将重写CompileTimeValidate()方法:

public override bool CompileTimeValidate(MethodBase method) {
    var methodInformation = MethodInformation.GetMethodInformation(method);
    var parameters = method.GetParameters();

    if (!ValidationFlags.HasFlag(ValidationFlags.NonPublic) && !methodInformation.IsPublic) return false;
    if (!ValidationFlags.HasFlag(ValidationFlags.Properties) && methodInformation.IsProperty) 
        return false;
    if (!ValidationFlags.HasFlag(ValidationFlags.Methods) && !methodInformation.IsProperty) return false;

    _parameterNames = parameters.Select(p => p.Name).ToArray();
    _memberName = methodInformation.Name;
    _isProperty = methodInformation.IsProperty;

    var argumentsToValidate = parameters.Where(p => p.MayNotBeNull()).ToArray();

    _inputArgumentsToValidate = ValidationFlags.HasFlag(ValidationFlags.Arguments) ? argumentsToValidate.Where(p => !p.IsOut).Select(p => p.Position).ToArray() : new int[0];

    _outputArgumentsToValidate = ValidationFlags.HasFlag(ValidationFlags.OutValues) ? argumentsToValidate.Where(p => p.ParameterType.IsByRef).Select(p => p.Position).ToArray() : new int[0];

    if (!methodInformation.IsConstructor) {
        _validateReturnValue = ValidationFlags.HasFlag(ValidationFlags.ReturnValues) &&
                                            methodInformation.ReturnParameter.MayNotBeNull();
    }

    var validationRequired = _validateReturnValue || _inputArgumentsToValidate.Length > 0 || _outputArgumentsToValidate.Length > 0;

    return validationRequired;
}

该方法确保在编译时正确应用了该方面。如果该方面应用于错误类型的成员,则返回false。否则,返回true。现在我们将重写OnEntry()方法:

public override void OnEntry(MethodExecutionArgs args) {
    foreach (var argumentPosition in _inputArgumentsToValidate) {
        if (args.Arguments[argumentPosition] != null) continue;
        var parameterName = _parameterNames[argumentPosition];

        if (_isProperty) {
            throw new ArgumentNullException(parameterName, 
                $"Cannot set the value of property '{_memberName}' to null.");
        } else {
            throw new ArgumentNullException(parameterName);
        }
    }
}

该方法检查输入参数进行验证。如果任何参数为null,则会抛出ArgumentNullException;否则,该方法将在不抛出异常的情况下退出。现在让我们重写OnSuccess()方法:

public override void OnSuccess(MethodExecutionArgs args) {
    foreach (var argumentPosition in _outputArgumentsToValidate) {
        if (args.Arguments[argumentPosition] != null) continue;
        var parameterName = _parameterNames[argumentPosition];
        throw new InvalidOperationException($"Out parameter '{parameterName}' is null.");
    }

    if (!_validateReturnValue || args.ReturnValue != null) return;

    if (_isProperty) {
        throw new InvalidOperationException($"Return value of property '{_memberName}' is null.");
    }
    throw new InvalidOperationException($"Return value of method '{_memberName}' is null.");
}

OnSuccess()方法验证输出参数。如果任何参数为 null,则会抛出InvalidOperationException。接下来我们需要做的是添加一个用于提取方法信息的private class。在DisallowNonNullAspect类的结束大括号之前,添加以下类:

private class MethodInformation { }

将以下三个构造函数添加到MethodInformation类中:

 private MethodInformation(ConstructorInfo constructor) : this((MethodBase)constructor) {
     IsConstructor = true;
     Name = constructor.Name;
 }

 private MethodInformation(MethodInfo method) : this((MethodBase)method) {
     IsConstructor = false;
     Name = method.Name;
     if (method.IsSpecialName &&
     (Name.StartsWith("set_", StringComparison.Ordinal) ||
     Name.StartsWith("get_", StringComparison.Ordinal))) {
         Name = Name.Substring(4);
         IsProperty = true;
     }
     ReturnParameter = method.ReturnParameter;
 }

 private MethodInformation(MethodBase method)
 {
     IsPublic = method.IsPublic;
 }

这些构造函数区分构造函数和方法,并对方法进行必要的初始化。添加以下方法:

private static MethodInformation CreateInstance(MethodInfo method) {
    return new MethodInformation(method);
}

CreateInstance()方法根据传入的方法的MethodInfo数据创建MethodInformation类的新实例,并返回该实例。添加GetMethodInformation()方法:

public static MethodInformation GetMethodInformation(MethodBase methodBase) {
    var ctor = methodBase as ConstructorInfo;
    if (ctor != null) return new MethodInformation(ctor);
    var method = methodBase as MethodInfo;
    return method == null ? null : CreateInstance(method);
}

该方法将methodBase转换为ConstructorInfo并检查是否为null。如果ctor不为null,则基于构造函数生成一个新的MethodInformation类。但是,如果ctornull,则将methodBase转换为MethodInfo。如果方法不为null,则调用CreateInstance()方法,传入该方法。否则,返回null。最后,将以下属性添加到类中:

public string Name { get; private set; }
public bool IsProperty { get; private set; }
public bool IsPublic { get; private set; }
public bool IsConstructor { get; private set; }
public ParameterInfo ReturnParameter { get; private set; }

这些属性是应用了该方面的方法的属性。我们现在已经完成了编写验证方面。您现在可以使用验证器通过附加[AllowNull]属性来允许空值。您可以通过附加[DisallowNonNullAspect]来禁止空值。现在,我们将添加事务关注点。

添加事务关注点

事务是必须要完成或回滚的过程。在类库中添加一个名为Transactions的新文件夹,然后添加RequiresTransactionAspect类:

[PSerializable]
[AttributeUsage(AttributeTargets.Method)]
public sealed class RequiresTransactionAspect : OnMethodBoundaryAspect {
    public override void OnEntry(MethodExecutionArgs args) {
        var transactionScope = new TransactionScope(TransactionScopeOption.Required);
        args.MethodExecutionTag = transactionScope;
    }

    public override void OnSuccess(MethodExecutionArgs args) {
        var transactionScope = (TransactionScope)args.MethodExecutionTag;
        transactionScope.Complete();
    }

    public override void OnExit(MethodExecutionArgs args) {
        var transactionScope = (TransactionScope)args.MethodExecutionTag;
        transactionScope.Dispose();
    }
}

OnEntry()方法启动事务,OnSuccess()方法完成异常,OnExit()方法处理事务。要使用该方面,请在您的方法中添加[RequiresTransactionAspect]。要记录任何阻止事务完成的异常,还可以分配[ExceptionAspect(consoleOutput: false)]方面。接下来,我们将添加资源池关注点。

添加资源池关注点

资源池是在创建和销毁对象的多个实例昂贵时提高性能的好方法。我们将为我们的需求创建一个非常简单的资源池。添加一个名为ResourcePooling的文件夹,然后添加ResourcePool类:

public class ResourcePool<T> {
    private readonly ConcurrentBag<T> _resources;
    private readonly Func<T> _resourceGenerator;

    public ResourcePool(Func<T> resourceGenerator) {
        _resourceGenerator = resourceGenerator ??
                                 throw new ArgumentNullException(nameof(resourceGenerator));
        _resources = new ConcurrentBag<T>();
    }

    public T Get() => _resources.TryTake(out T item) ? item : _resourceGenerator();
    public void Return(T item) => _resources.Add(item);
}

该类创建一个新的资源生成器,并将资源存储在ConcurrentBag中。当请求项目时,它会从池中发出一个资源。如果不存在,则会创建一个并将其添加到池中,并发放给调用者:

var pool = new ResourcePool<Course>(() => new Course()); // Create a new pool of Course objects.
var course = pool.Get(); // Get course from pool.
pool.Return(course); // Return the course to the pool.

您刚刚看到的代码向您展示了如何使用ResourcePool类来创建资源池,获取资源并将其返回到资源池中。

添加配置设置关注点

配置设置应始终集中。由于桌面应用程序将其设置存储在app.config文件中,而 Web 应用程序将其设置存储在Web.config文件中,因此我们可以使用ConfigurationManager来访问应用程序设置。将System.Configuration.Configuration NuGet 库添加到您的类库中并测试测试工具。然后,添加一个名为Configuration的文件夹和以下Settings类:

public static class Settings {
    public static string GetAppSetting(string key) {
        return System.Configuration.ConfigurationManager.AppSettings[key];
    }

    public static void SetAppSettings(this string key, string value) {
        System.Configuration.ConfigurationManager.AppSettings[key] = value;
    }
}

该类将在Web.config文件和App.config文件中获取和设置应用程序设置。要在您的文件中包含该类,请添加以下using语句:

using static CrossCuttingConcerns.Configuration.Settings;

以下代码向您展示了如何使用这些方法:

Console.WriteLine(GetAppSetting("Greeting"));
"Greeting".SetAppSettings("Goodbye, my friends!");
Console.WriteLine(GetAppSetting("Greeting"));

使用静态导入,您无需包含class前缀。您可以扩展Settings类以获取连接字符串或在应用程序中执行所需的任何配置。

添加仪器化关注点

我们的最终横切关注点是仪器化。我们使用仪器化来分析我们的应用程序,并查看方法执行所需的时间。在类库中添加一个名为Instrumentation的文件夹,然后添加InstrumentationAspect类,如下所示:


[PSerializable]
[AttributeUsage(AttributeTargets.Method)]
public class InstrumentationAspect : OnMethodBoundaryAspect {
    public override void OnEntry(MethodExecutionArgs args) {
        LogFile.AppendTextToFile("Profile.log", 
            $"\nMethod: {args.Method.Name}, Start Time: {DateTime.Now}");
        args.MethodExecutionTag = Stopwatch.StartNew();
    }

    public override void OnException(MethodExecutionArgs args) {
        LogFile.AppendTextToFile("Exception.log", 
            $"\n{DateTime.Now}: {args.Exception.Source} - {args.Exception.Message}");
    }

    public override void OnExit(MethodExecutionArgs args) {
        var stopwatch = (Stopwatch)args.MethodExecutionTag;
        stopwatch.Stop();
        LogFile.AppendTextToFile("Profile.log", 
            $"\nMethod: {args.Method.Name}, Stop Time: {DateTime.Now}, Duration: {stopwatch.Elapsed}");
    }
}

正如您所看到的,仪器化方面仅适用于方法,记录方法的开始和结束时间,并将配置文件信息记录到Profile.log文件中。如果遇到异常,则将异常记录到Exception.log文件中。

我们现在拥有一个功能齐全且可重用的横切关注点库。让我们总结一下本章学到的内容。

总结

我们学到了一些宝贵的信息。我们首先看了装饰器模式,然后是代理模式。代理模式提供了作为客户端使用的真实服务对象的替代品。代理接收客户端请求,执行必要的工作,然后将请求传递给服务对象。由于代理与它们替代的服务共享相同的接口,它们是可互换的。

在介绍了代理模式之后,我们转向了使用 PostSharp 进行 AOP。我们看到了如何将切面和属性一起使用来装饰代码,以便在编译时注入代码来执行所需的操作,例如异常处理、日志记录、审计和安全性。我们通过开发自己的切面来扩展了切面框架,并研究了如何使用 PostSharp 和装饰器模式来解决配置管理、日志记录、审计、安全性、验证、异常处理、仪器化、事务、资源池、缓存、线程和并发的横切关注点。

在下一章中,我们将看看使用工具来帮助您提高代码质量。但在那之前,测试一下您的知识,然后继续阅读。

问题

  1. 什么是横切关注点,AOP 代表什么?

  2. 什么是切面,如何应用切面?

  3. 什么是属性,如何应用属性?

  4. 切面和属性如何一起工作?

  5. 切面如何与构建过程一起工作?

进一步阅读

第十二章:使用工具来提高代码质量

作为程序员,提高代码质量是您的主要关注点之一。提高代码质量需要利用各种工具。旨在改进代码并加快开发速度的工具包括代码度量衡、快速操作、JetBrains dotTrace 分析器、JetBrains ReSharper 和 Telerik JustDecompile。

这是本章的主要内容,包括以下主题:

  • 定义高质量的代码

  • 执行代码清理和计算代码度量衡

  • 执行代码分析

  • 使用快速操作

  • 使用 JetBrains dotTrace 分析器

  • 使用 JetBrains ReSharper

  • 使用 Telerik JustDecompile

通过本章结束时,您将掌握以下技能:

  • 使用代码度量衡来衡量软件复杂性和可维护性

  • 使用快速操作进行更改

  • 使用 JetBrains dotTrace 对代码进行分析和瓶颈分析

  • 使用 JetBrains ReSharper 重构代码

  • 使用 Telerik JustDecompile 对代码进行反编译和生成解决方案

技术要求

定义高质量的代码

良好的代码质量是一种重要的软件属性。低质量的代码可能导致财务损失、时间和精力浪费,甚至死亡。高标准的代码将具有性能、可用性、安全性、可扩展性、可维护性、可访问性、可部署性和可扩展性(PASSMADE)的特质。

高性能的代码体积小,只做必要的事情,并且非常快。高性能的代码不会导致系统崩溃。导致系统崩溃的因素包括文件输入/输出(I/O)操作、内存使用和中央处理单元(CPU)使用。性能低下的代码适合重构。

可用性指的是软件在所需性能水平上持续可用。可用性是软件功能时间(tsf)与预期功能总时间(ttef)之比,例如,tsf=700;ttef=744。700 / 744 = 0.9409 = 94.09%的可用性。

安全的代码是指正确验证输入以防止无效数据格式、无效范围数据和恶意攻击,并完全验证和授权其用户的代码。安全的代码也是容错的代码。例如,如果正在从一个账户转账到另一个账户,系统崩溃了,操作应确保数据保持完整,不会从相关账户中取走任何钱。

可扩展的代码是指能够安全处理系统用户数量呈指数增长,而不会导致系统崩溃的代码。因此,无论软件每小时处理一个请求还是一百万个请求,代码的性能都不会下降,也不会因过载而导致停机。

可维护性指的是修复错误和添加新功能的难易程度。可维护的代码应该组织良好,易于阅读。应该低耦合,高内聚,以便代码可以轻松维护和扩展。

可访问的代码是指残障人士可以轻松修改和根据自己的需求使用的代码。例如,具有高对比度的用户界面,为诵读困难和盲人提供的叙述者等。

可部署性关注软件的用户——用户是独立的、远程访问的还是本地网络用户?无论用户类型如何,软件都应该非常容易部署,没有任何问题。

可扩展性指的是通过向应用程序添加新功能来扩展应用程序的容易程度。意大利面代码和高度耦合的代码与低内聚度使这变得非常困难且容易出错。这样的代码很难阅读和维护,也不容易扩展。因此,可扩展的代码是易于阅读、易于维护的代码,因此也易于添加新功能。

从优质代码的 PASSMADE 要求中,您可以轻松推断出未能满足这些要求可能导致的问题。未能满足这些要求将导致性能不佳的代码变得令人沮丧和无法使用。客户会因增加的停机时间而感到恼火。黑客可以利用不安全的代码中的漏洞。随着更多用户加入系统,软件会呈指数级下降。代码将难以修复或扩展,在某些情况下甚至无法修复或扩展。能力有限的用户将无法修改其限制周围的软件,并且部署将成为配置噩梦。

代码度量来拯救。代码度量使开发人员能够衡量代码复杂性和可维护性,从而帮助我们识别需要重构的代码。

使用快速操作,您可以使用单个命令重构 C#代码,例如将代码提取到自己的方法中。JetBrains dotTrace 允许您分析代码并找到性能瓶颈。此外,JetBrains ReSharper 是 Visual Studio 的生产力扩展,使您能够分析代码质量、检测代码异味、强制执行编码标准并重构代码。而 Telerik JustDecompile 则帮助您反编译现有代码进行故障排除,并从中创建中间语言(IL)、C#和 VB.NET 项目。如果您不再拥有源代码并且需要维护或扩展已编译的代码,这将非常有用。您甚至可以为编译后的代码生成调试符号。

让我们深入了解一下提到的工具,首先是代码度量。

执行代码清理和计算代码度量

在我们看如何收集代码度量之前,我们首先需要知道它们是什么,以及它们对我们有何用处。代码度量主要涉及软件复杂性和可维护性。它们帮助我们看到如何改进源代码的可维护性并减少源代码的复杂性。

Visual Studio 2019 为您计算的代码度量包括以下内容:

  • 可维护性指数:代码可维护性是“应用生命周期管理”(ALM)的重要组成部分。在软件达到寿命终点之前,必须对其进行维护。代码基础越难以维护,源代码在完全替换之前的寿命就越短。与维护现有系统相比,编写新软件以替换不健康的系统需要更多的工作,也更昂贵。代码可维护性的度量称为可维护性指数。该值是 0 到 100 之间的整数值。以下是可维护性指数的评级、颜色和含义:

  • 20 及以上的任何值都具有良好可维护性的绿色评级。

  • 可维护性一般的代码在 10 到 19 之间,评级为黄色。

  • 任何低于 10 的值都具有红色评级,意味着它很难维护。

  • 圈复杂度:代码复杂度,也称为圈复杂度,指的是软件中的各种代码路径。路径越多,软件就越复杂。软件越复杂,测试和维护就越困难。复杂的代码可能导致更容易出错的软件发布,并且可能使软件的维护和扩展变得困难。因此,建议将代码复杂度保持在最低限度。

  • 继承深度:继承深度和类耦合度受到了一种流行的编程范式的影响,称为面向对象编程(OOP)。在 OOP 中,类能够从其他类继承。被继承的类称为基类。从基类继承的类称为子类。每个类相互继承的数量度量被称为继承深度。

继承层次越深,如果基类中的某些内容发生变化,派生类中出现错误的可能性就越大。理想的继承深度是 1。

  • 类耦合:面向对象编程允许类耦合。当一个类被参数、局部变量、返回类型、方法调用、泛型或模板实例化、基类、接口实现、在额外类型上定义的字段和属性装饰直接引用时,就会产生类耦合。

类耦合代码度量确定了类之间的耦合程度。为了使代码更易于维护和扩展,类耦合应该尽量减少。在面向对象编程中,实现这一点的一种方法是使用基于接口的编程。这样,您可以避免直接访问类。这种编程方法的好处是,只要它们实现相同的接口,您就可以随意替换类。质量低劣的代码具有高耦合和低内聚,而高质量的代码具有低耦合和高内聚。

理想情况下,软件应该具有高内聚性和低耦合性,因为这样可以使程序更容易测试、维护和扩展。

  • 源代码行数:源代码的完整行数,包括空行,由源代码行数度量。

  • 可执行代码行数:可执行代码中的操作数量由可执行代码行数度量。

现在,您已经了解了代码度量是什么,以及 Visual Studio 2019 版本 16.4 及更高版本中提供了哪些度量,现在是时候看到它们的实际效果了:

  1. 在 Visual Studio 中打开任何您喜欢的项目。

  2. 右键单击项目。

  3. 选择分析和代码清理|运行代码清理(Profile 1),如下截图所示:

  1. 现在,选择计算代码度量。

  2. 您应该看到代码度量结果窗口出现,如下截图所示:

如截图所示,我们所有的类、接口和方法都标有绿色指示器。这意味着所选的项目是可维护的。如果其中任何一行标记为黄色或红色,那么您需要解决它们并重构它们以使其变为绿色。好了,我们已经介绍了代码度量,因此自然而然地,我们继续介绍代码分析。

执行代码分析

为了帮助开发人员识别其源代码的潜在问题,微软提供了 Visual Studio 的代码分析工具。代码分析执行静态源代码分析。该工具将识别设计缺陷、全球化问题、安全问题、性能问题和互操作性问题。

打开书中的解决方案,并选择 CH11_AddressingCrossCuttingConcerns 项目。然后,从项目菜单中选择项目|CH11_AddressingCrossCuttingConcerns |属性。在项目的属性页面上,选择代码分析,如下截图所示:

如上面的截图所示,如果您发现推荐的分析器包未安装,请单击“安装”进行安装。安装后,版本号将显示在已安装版本框中。对我来说,它是版本 2.9.6。默认情况下,活动规则是 Microsoft 托管推荐规则。如描述中所示,此规则集的位置是 C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\Team Tools\Static Analysis Tools\Rule Sets\MinimumRecommendedRules.ruleset。打开文件。它将作为 Visual Studio 工具窗口打开,如下所示:

如上面的截图所示,您可以选择和取消选择规则。关闭窗口时,将提示您保存任何更改。要运行代码分析,转到分析和代码清理|代码分析。要查看结果,需要打开错误列表窗口。您可以从“视图”菜单中打开它。

一旦您运行了代码分析,您将看到错误、警告和消息的列表。您可以处理每一个,以提高软件的整体质量。以下截图显示了其中一些示例:

从上面的截图中,您可以看到CH10_AddressingCrossCuttingConcerns项目有32 个警告和 13 个消息。如果我们处理这些警告和消息,就可以将它们减少到 0 个消息和 0 个警告。因此,现在您已经知道如何使用代码度量来查看软件的可维护性,并对其进行分析以了解您可以做出哪些改进,现在是时候看看快速操作了。

使用快速操作

另一个我喜欢使用的方便工具是快速操作工具。在代码行上显示为螺丝刀,灯泡,或错误灯泡,快速操作使您能够使用单个命令生成代码,重构代码,抑制警告,执行代码修复,并添加using语句。

由于CH10_AddressingCrossCuttingConcerns项目有 32 个警告和 13 个消息,我们可以使用该项目来查看快速操作的效果。看看下面的截图:

看看上面的截图,我们看到第 10 行的灯泡。如果我们点击灯泡,将弹出以下菜单:

如果我们点击“添加 readonly 修饰符”,readonly访问修饰符将放置在私有访问修饰符之后。尝试使用快速操作修改代码。一旦掌握了,这是相当简单的。一旦您尝试了快速操作,就可以继续查看 JetBrains dotTrace 分析工具。

使用 JetBrains dotTrace 分析工具

JetBrains dotTrace 分析工具是 JetBrains ReSharper Ultimate 许可的一部分。因为我们将同时查看这两个工具,我建议您在继续之前下载并安装 JetBrains ReSharper Ultimate。

如果您还没有拥有副本,JetBrains 确实有试用版本可用。Windows、macOS 和 Linux 都有可用的版本。

JetBrains dotTrace 分析工具适用于 Mono、.NET Framework 和.NET Core。分析工具支持所有应用程序类型,您可以使用分析工具分析和跟踪代码库的性能问题。分析工具将帮助您解决导致 CPU 使用率达到 100%、磁盘 I/O 达到 100%、内存达到最大或遇到溢出异常等问题。

许多应用程序执行超文本传输协议(HTTP)请求。性能分析器将分析应用程序如何处理这些请求,并对数据库上的结构化查询语言(SQL)查询进行相同的分析。还可以对静态方法和单元测试进行性能分析,并可以在 Visual Studio 中查看结果。还有一个独立版本供您使用。

有四种基本的性能分析选项——Sampling、Tracing、Line-by-Line 和 Timeline。第一次开始查看应用程序的性能时,您可能决定使用 Sampling,它提供了准确的调用时间测量。Tracing 和 Line-by-Line 提供了更详细的性能分析,但会给被分析的程序增加更多开销(内存和 CPU 使用)。Timeline 类似于 Sampling,并会随时间收集应用程序事件。在它们之间,没有无法追踪和解决的问题。

高级性能分析选项包括实时性能计数器、线程时间、实时 CPU 指令和线程周期时间。实时性能计数器测量方法进入和退出之间的时间。线程时间测量线程运行时间。基于 CPU 寄存器,实时 CPU 指令提供了方法进入和退出的准确时间。

性能分析器可以附加到正在运行的.NET Framework 4.0(或更高版本)或.NET Core 3.0(或更高版本)应用程序和进程,对本地应用程序和远程应用程序进行性能分析。这些包括独立应用程序;.NET Core 应用程序;Internet 信息服务(IIS)托管的 Web 应用程序;IIS Express 托管的应用程序;.NET Windows 服务;Windows 通信基础(WCF)服务;Windows 商店和通用 Windows 平台(UWP)应用程序;任何.NET 进程(在运行性能分析会话后启动);基于 Mono 的桌面或控制台应用程序;以及 Unity 编辑器或独立的 Unity 应用程序。

要在 Visual Studio 2019 中从菜单中访问性能分析器,请选择 Extensions | ReSharper | Profile | Show Performance Profiler。在下面的截图中,您可以看到尚未进行性能分析。当前选择要进行性能分析的项目设置为 Basic CH3,并且性能分析类型设置为 Timeline。我们将使用 Sampling 对 CH3 进行性能分析,通过展开时间轴下拉功能并选择 Sampling,如下面的截图所示:

如果要对不同的项目进行采样,请展开项目下拉列表并选择要进行性能分析的项目。项目将被构建,并启动性能分析器。然后您的项目将运行并关闭。结果将显示在 dotTrace 性能分析应用程序中,如下面的截图所示:

从上面的截图中,您可以看到四个线程中的第一个线程。这是我们程序的线程。其他线程是支持进程的线程,这些支持进程使我们的程序能够运行,还有负责退出程序并清理系统资源的 finalizer 线程。

左侧的所有调用菜单项包括以下内容:

  • 线程树

  • 调用树

  • 普通列表

  • 热点

当前选项选择了线程树。让我们来看看下面截图中展开的调用树:

性能分析器为您的代码显示完整的调用树,包括系统代码和您自己的代码。您可以看到调用所花费的时间百分比。这使您能够识别任何运行时间较长的方法并加以解决。

现在,我们来看看普通列表。如下面截图中的普通列表视图所示,我们可以根据以下标准对其进行分组:

  • 命名空间

  • 程序集

您可以在下面的屏幕截图中看到前面的标准:

当您点击列表中的项目时,您可以查看包含该方法的类的源代码。这很有用,因为您可以看到问题所在的代码以及需要做什么。我们将看到的最后一个采样配置文件屏幕是热点视图,如下面的屏幕截图所示:

性能分析器显示,主线程(我们代码的起点)只占用了 4.59%的处理时间。如果您点击根,我们的用户代码占了 18%的代码,系统代码占了 72%的代码,如下面的屏幕截图所示:

我们只是用这个性能分析工具触及到了表面。还有更多内容,我鼓励您自己尝试一下。本章的主要目的是向您介绍可用的工具。

有关如何使用 JetBrains dotTrace 的更多信息,我建议您参考他们的在线学习材料,网址为www.jetbrains.com/profiler/documentation/documentation.html

接下来,我们来看看 JetBrains ReSharper。

使用 JetBrains ReSharper

在这一部分,我们将看看 JetBrains ReSharper 如何帮助您改进您的代码。 ReSharper 是一个非常广泛的工具,就像性能分析器一样,它是 ReSharper 的旗舰版的一部分,我们只会触及到表面,但您希望能够欣赏到这个工具是什么,以及它如何帮助您改进您的 Visual Studio 编码体验。以下是使用 ReSharper 的一些好处:

  • 使用 ReSharper,您可以对代码质量进行分析。

  • 它将提供改进代码、消除代码异味和修复编码问题的选项。

  • 通过导航系统,您可以完全遍历您的解决方案并跳转到任何感兴趣的项目。您有许多不同的辅助工具,包括扩展的智能感知、代码重组等。

  • ReSharper 的重构功能可以是局部的,也可以是整个解决方案的。

  • 您还可以使用 ReSharper 生成源代码,例如基类和超类,以及内联方法。

  • 在这里,可以根据公司的编码政策清理代码,以消除未使用的导入和其他未使用的代码。

您可以从 Visual Studio 2019 扩展菜单中访问 ReSharper 菜单。在代码编辑器中,右键单击代码片段将显示上下文菜单,其中包含适当的菜单项。上下文菜单中的 ReSharper 菜单项是 Refactor This...,如下面的屏幕截图所示:

现在,从 Visual Studio 2019 菜单中运行扩展 | ReSharper | 检查 | 解决方案中的代码问题。 ReSharper 将处理解决方案,然后显示检查结果窗口,如下面的屏幕截图所示:

如前面的屏幕截图所示,ReSharper 发现了我们代码中的 527 个问题,其中 436 个正在显示。这些问题包括常见做法和代码改进、编译器警告、约束违规、语言使用机会、潜在的代码质量问题、代码冗余、符号声明冗余、拼写问题和语法风格。

如果我们展开编译器警告,我们会看到有三个问题,如下所示:

  • _name字段从未被赋值。

  • nre本地变量从未被使用。

  • 这个async方法缺少await操作符,将以同步方式运行。使用await操作符等待非阻塞的应用程序编程接口API)调用,或者使用await TaskEx.Run(...)在后台线程上执行 CPU 绑定的工作。

这些问题是声明的变量没有被赋值或使用,以及一个缺少await运算符的async方法将以同步方式运行。如果单击第一个警告,它将带您到从未分配的代码行。查看类,您会发现字符串已声明并使用,但从未分配。由于我们检查字符串是否包含string.Empty,我们可以将该值分配给声明。因此,更新后的行将如下所示:

private string _name = string.Empty;

由于_name变量仍然突出显示,我们可以将鼠标悬停在上面,看看问题是什么。快速操作通知我们,_name变量可以标记为只读。让我们添加readonly修饰符。所以,现在这行变成了这样:

private readonly string _name = string.Empty;

如果单击刷新按钮,我们将发现发现的问题数量现在是 526。然而,我们解决了两个问题。所以,问题数量应该是 525 吗?好吧,不是。我们解决的第二个问题不是 ReSharper 检测到的问题,而是 Visual Studio 快速操作检测到的改进。因此,ReSharper 显示了它检测到的正确问题数量。

让我们看看LooseCouplingB类的潜在代码质量问题。ReSharper 报告了这个方法内可能的System.NullReferenceException。让我们先看看代码,如下所示:

public LooseCouplingB()
{
    LooseCouplingA lca = new LooseCouplingA();
   lca = null;
    Debug.WriteLine($"Name is {lca.Name}");
}

果然,我们面对着System.NullReferenceException。我们将查看LooseCouplingA类,以确认应将哪些成员设置为null。另外,要设置的成员是_name,如下面的代码片段所示:

public string Name
{
    get => _name.Equals(string.Empty) ? StringIsEmpty : _name;

    set
    {
        if (value.Equals(string.Empty))
            Debug.WriteLine("Exception: String length must be greater than zero.");
    }
}

然而,_name正在被检查是否为空。所以,实际上,代码应该将_name设置为string.Empty。因此,我们在LooseCouplingB中修复的构造函数如下:

public LooseCouplingB()
{
    var lca = new LooseCouplingA
    {
        Name = string.Empty
    };
    Debug.WriteLine($"Name is {lca.Name}");
}

现在,如果我们刷新 Inspection Results 窗口,我们的问题列表将减少五个,因为除了正确分配Name属性之外,我们利用了语言使用机会来简化我们的实例化和初始化,这是由 ReSharper 检测到的。玩一下这个工具,消除检查结果窗口中发现的问题。

ReSharper 还可以生成依赖关系图。要为我们的解决方案生成依赖关系图,请选择 Extensions | ReSharper | Architecture | Show Project Dependency Diagram。这将显示我们解决方案的项目依赖关系图。称为CH06的黑色容器框是命名空间,以CH06_为前缀的灰色/蓝色框是项目,如下面的屏幕截图所示:

CH06命名空间的项目依赖关系图中可以看出,CH06_SpecFlowCH06_SpecFlow.Implementation之间存在项目依赖关系。同样,您还可以使用 ReSharper 生成类型依赖关系图。选择 Extensions | ReSharper | Architecture | Type Dependencies Diagram。

如果我们为CH10_AddressingCrossCuttingConcerns项目中的ConcreteClass生成图表,那么图表将被生成,但只有ConcreteComponent类将被最初显示。右键单击图表上的ConcreteComponent框,然后选择 Add All Referenced Types。您将看到ExceptionAttribute类和IComponent接口的添加。右键单击ExceptionAttribute类,然后选择 Add All Referenced Types,您将得到以下结果:

这个工具真正美妙的地方在于你可以按命名空间对图表元素进行排序。对于有多个大型项目和深度嵌套命名空间的庞大解决方案来说,这真的非常有用。虽然我们可以右键单击代码并转到项目声明,但是以可视化的方式看到你正在工作的项目的情况是无可替代的,这就是为什么这个工具非常有用。以下是一个按命名空间组织的类型依赖关系图的示例:

在日常工作中,我真的经常需要这样的图表。这个图表是技术文档,将帮助开发人员了解复杂解决方案。他们将能够看到哪些命名空间是可用的,以及一切是如何相互关联的。这将使开发人员具备正确的知识,知道在进行新开发时应该把新类、枚举和接口放在哪里,但也知道在进行维护时应该在哪里找到对象。这个图表也很适合查找重复的命名空间、接口和对象名称。

现在让我们来看看覆盖率。操作如下:

  1. 选择扩展 | ReSharper | 覆盖 | 覆盖应用程序。

  2. 覆盖配置对话框将被显示,并且默认选择的选项将是独立运行。

  3. 选择你的可执行文件。

  4. 你可以从bin文件夹中选择一个.NET 应用程序。

  5. 以下截图显示了覆盖配置对话框:

  1. 点击运行按钮启动应用程序并收集分析数据。ReSharper 将显示以下对话框:

应用程序将会运行。当应用程序运行时,覆盖分析器将会收集数据。我们选择的可执行文件是一个控制台应用程序,显示如下数据:

  1. 点击控制台窗口,然后按任意键退出。覆盖对话框将消失,然后存储将被初始化。最后,覆盖结果浏览器窗口将显示,如下所示:

这个窗口包含了非常有用的信息。它提供了代码未被调用的视觉指示,用红色标记。执行的代码用绿色标记。使用这些信息,你可以看到代码是否是可以删除的死代码,或者由于系统路径而未被执行但仍然需要,或者由于测试目的而被注释掉,或者仅仅是因为开发人员忘记在正确的位置添加调用或者条件检查错误而未被调用。

要转到感兴趣的项目,你只需要双击该项目,然后你将被带到你感兴趣的具体代码。我们的Program类只覆盖了 33%的代码。所以,让我们双击Program,看看问题出在哪里。结果输出如下代码块所示:

static void Main(string[] args)
{
    LoggingServices.DefaultBackend = new ConsoleLoggingBackend();
    AuditServices.RecordPublished += AuditServices_RecordPublished;
    DecoratorPatternExample();
    //ProxyPatternExample();
    //SecurityExample();

    //ExceptionHandlingAttributeExample();

    //SuccessfulMethod();
    //FailedMethod();

    Console.ReadKey();
}

从代码中可以看出,我们的一些代码之所以没有被覆盖是因为调用代码的地方被注释掉了,用于测试目的。我们可以保留代码不变(在这种情况下我们会这样做)。然而,你也可以通过去掉注释来删除死代码或者恢复代码。现在,你知道代码为什么没有被覆盖了。

好了,现在你已经了解了 ReSharper 并且看了一下辅助你编写良好、干净的 C#代码的工具,是时候看看我们的下一个工具了,叫做 Telerik JustDecompile。

使用 Telerik JustDecompile

我曾多次使用 Telerik JustDecompile,比如追踪第三方库中的 bug,恢复丢失的项目源代码,检查程序集混淆的强度,以及学习目的。这是一个我强烈推荐的工具,多年来它已经证明了它的价值很多次。

反编译引擎是开源的,你可以从github.com/telerik/justdecompileengine获取源代码,因此你可以自由地为项目做出贡献并为其编写自己的扩展。你可以从 Telerik 网站下载 Windows 安装程序,网址是www.telerik.com/products/decompiler.aspx。所有源代码都可以完全导航。反编译器可作为独立应用程序或 Visual Studio 扩展使用。你可以从反编译的程序集创建 VB.NET 或 C#项目,并提取和保存反编译的程序集中的资源。

下载并安装 Telerik JustDecompile。然后我们将进行反编译过程,并从程序集生成一个 C#项目。在安装过程中可能会提示你安装其他工具,但你可以取消选择 Telerik 提供的其他产品。

运行 Telerik JustDecompile 独立应用程序。找到一个.NET 程序集,然后将其拖入 Telerik JustDecompile 的左窗格中。它将对代码进行反编译,并在左侧显示代码树。如果你在左侧选择一个项目,右侧将显示代码,就像屏幕截图中所示的那样:

你可以看到,反编译过程非常快速,并且在大多数情况下,它都能很好地完成反编译工作。按照以下步骤进行:

  1. 在“插件”菜单项右侧的下拉菜单中,选择 C#。

  2. 然后,点击“工具”|“创建项目”。

  3. 有时会提示你选择要针对的.NET 版本;有时则不会。

  4. 然后,你将被要求保存项目的位置。

  5. 项目将会被写入该位置。

然后你可以在 Visual Studio 中打开项目并对其进行操作。如果遇到任何问题,Telerik 会在你的代码中记录问题并提供电子邮件。你可以随时通过电子邮件联系他们。他们擅长回应和解决问题。

好了,我们已经完成了本章中工具的介绍,现在,让我们总结一下我们学到的东西。

总结

在本章中,你已经看到代码度量提供了代码质量的几个衡量标准,以及生成这些衡量标准有多么容易。代码度量包括行数(包括空行)与可执行代码行数的比例,圈复杂度,内聚性和耦合性水平,以及代码的可维护性。重构的颜色代码是绿色表示良好,黄色表示理想情况下需要重构,红色表示绝对需要重构。

然后你看到了提供项目的静态代码分析以及查看结果有多么容易。还涵盖了查看和修改规则集,规定了哪些内容会被分析,哪些不会被分析。然后,你体验了快速操作,并看到了如何通过单个命令进行错误修复,添加 using 语句,并重构代码。

然后,我们使用 JetBrains dotTrace 性能分析工具来测量我们应用程序的性能,找出瓶颈,并识别占用大部分处理时间的方法。接下来我们看了 JetBrains ReSharper,它使我们能够检查代码中的各种问题和潜在改进。我们确定了一些问题并进行了必要的更改,看到了使用这个工具改进代码有多么容易。然后,我们看了如何创建依赖关系和类型依赖的架构图。

最后,我们看了 Telerik JustDecompile,这是一个非常有用的工具,可以用来反编译程序集并从中生成 C#或 VB.NET 项目。当遇到错误或需要扩展程序,但无法访问现有源代码时,这将非常有用。

在接下来的章节中,我们将主要关注代码,以及我们如何重构它。但现在,用以下问题测试你的知识,并通过“进一步阅读”部分提供的链接进一步阅读。

问题

  1. 代码度量是什么,为什么我们应该使用它们?

  2. 列举六个代码度量测量。

  3. 什么是代码分析,为什么它有用?

  4. 什么是快速操作?

  5. JetBrains dotTrace 用于什么?

  6. JetBrains ReSharper 用于什么?

  7. 为什么要使用 Telerik JustDecompile 来反编译程序集?

进一步阅读

第十三章:重构 C# 代码 - 识别代码异味

在这一章中,我们将看看问题代码以及如何重构它。在行业中,问题代码通常被称为代码异味。它是编译、运行并完成其预期功能的代码。问题代码之所以成为问题是因为它变得难以阅读,具有复杂的性质,并使得代码库难以维护和进一步扩展。这样的代码应该在可行的情况下尽快重构。这是技术债务,在长期来看,如果你不处理它,它将使项目陷入困境。当这种情况发生时,你将面临昂贵的重新设计和从头开始编码应用程序。

那么什么是重构?重构是将现有的工作代码重写,使得代码变得干净的过程。正如你已经发现的那样,干净的代码易于阅读、易于维护和易于扩展。

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

  • 识别应用级别的代码异味以及我们如何解决它们

  • 识别类级别的代码异味以及我们如何解决它们

  • 识别方法级别的代码异味以及我们如何解决它们

通过本章的学习,您将获得以下技能:

  • 识别不同类型的代码异味

  • 理解为什么代码被归类为代码异味

  • 重构代码异味,使其成为干净的代码

我们将从应用级别的代码异味开始看重构代码异味。

技术要求

您需要本章的以下先决条件:

  • Visual Studio 2019

  • PostSharp

对于本章的代码文件,您可以使用以下链接:github.com/PacktPublishing/Clean-Code-in-C-/tree/master/CH13

应用级别的代码异味

应用级别的代码异味是散布在应用程序中的问题代码,影响每一层。无论您身处软件的哪一层,您都会看到相同的问题代码一遍又一遍地出现。如果您现在不解决这些问题,那么您将发现您的软件将开始缓慢而痛苦地死去。

在这一部分,我们将看看应用级别的代码异味以及我们如何去除它们。让我们从布尔盲目开始。

布尔盲目

布尔数据盲目指的是由处理布尔值的函数确定的信息丢失。使用更好的结构提供更好的接口和类来保存数据,使得在处理数据时更加愉快。

让我们通过这段代码示例来看看布尔盲目的问题:

public void BookConcert(string concert, bool standing)
{
    if (standing)
   {
        // Issue standing ticket.
    }
    else
    {
        // Issue sitting ticket.
    }
}

这个方法接受音乐会名称的字符串和一个布尔值,指示人是站立还是坐着。现在,我们将如下调用代码:

private void BooleanBlindnessConcertBooking()
{
    var booking = new ProblemCode.ConcertBooking();
    booking.BookConcert("Solitary Experiments", true);
}

如果一个新手看到BooleanBlindnessConcertBooking()方法,你认为他们会本能地知道true代表什么吗?我认为不会。他们对它的含义会一无所知。所以他们要么使用智能感知,要么找到被引用的方法来找到含义。他们是布尔盲目的。那么我们如何治愈他们的盲目呢?

嗯,一个简单的解决方案是用枚举替换布尔值。让我们首先添加我们的名为TicketType的枚举:

[Flags]
internal enum TicketType
{
    Seated,
    Standing
}

我们的枚举标识了两种类型的票。这些是SeatedStanding。现在让我们添加我们的ConcertBooking()方法:

internal void BookConcert(string concert, TicketType ticketType)
{
    if (ticketType == TicketType.Seated)
    {
        // Issue seated ticket.
    }
    else
    {
        // Issue standing ticket.
    }
}

以下代码显示了如何调用新重构的代码:

private void ClearSightedConcertBooking()
{
    var booking = new RefactoredCode.ConcertBooking();
    booking.BookConcert("Chrom", TicketType.Seated);
}

现在,如果有新人来看这段代码,他们会看到我们正在预订一场音乐会,看Chrom乐队,并且我们想要座位票。

组合爆炸

组合爆炸是同一段代码使用不同参数组合执行相同操作的副产品。让我们看一个添加数字的例子:

public int Add(int x, int y)
{
    return x + y;
}

public double Add(double x, double y)
{
    return x + y;
}

public float Add(float x, float y)
{
    return x + y;
}

这里,我们有三种方法都是对数字进行加法。返回类型和参数都不同。有更好的方法吗?有,通过使用泛型。通过使用泛型,你可以有一个单一的方法,能够处理不同类型的工作。因此,我们将使用泛型来解决我们的加法问题。这将允许我们有一个单一的加法方法,可以接受整数、双精度或浮点数。让我们来看看我们的新方法:

public T Add<T>(T x, T y)
{
    dynamic a = x;
    dynamic b = y;
    return a + b;
}

这个泛型方法被调用时,为T分配了特定类型。它执行加法并返回结果。只需要一个版本的方法来处理可以相加的不同.NET 类型。要调用intdoublefloat值的代码,我们将这样做:

var addition = new RefactoredCode.Maths();
addition.Add<int>(1, 2);
addition.Add<double>(1.2, 3.4);
addition.Add<float>(5.6f, 7.8f);

我们刚刚消除了三种方法,并用一个执行相同任务的单一方法替代了它们。

人为复杂

当你可以用简单的架构开发代码,但却实现了一个先进而相当复杂的架构时,这被称为人为复杂。不幸的是,我曾经不得不在这样的系统上工作,这是一种真正的痛苦和压力来源。你会发现这样的系统往往有很高的员工流动率。它们缺乏文档,似乎没有人知道系统或者有能力回答接受培训的人的问题——那些不得不学习系统来维护和扩展它的可怜人。

对所有超级智能软件架构师的建议是,当涉及软件时,保持简单,愚蠢KISS)。记住,永久就业和终身工作似乎已经成为过去的事情。通常情况下,程序员更多地追逐金钱,而不是对企业的终身忠诚。因此,由于企业依赖软件来获取收入,你需要一个易于理解、接纳新员工、维护和扩展的系统。问问自己这个问题:如果你负责的系统突然经历了你和所有分配给它们的员工离职并找到新机会,接管的新员工能立即上手吗?还是他们会感到压力重重,摸不着头脑?

还要记住,如果团队中只有一个人了解该系统,而他们去世、搬到新地方或退休了,那么你和团队的其他人会怎么样?甚至更重要的是,这对企业意味着什么?

我无法再强调你真的要简单了。创建复杂系统并不记录它们并分享架构知识的唯一原因是为了让企业束手就擒,让他们留住你并榨干他们。不要这样做。根据我的经验,系统越复杂,死亡速度越快,必须重写。

在第十二章中,使用工具提高代码质量,你学会了如何使用 Visual Studio 2019 工具来发现圈复杂度继承深度。你还学会了如何使用 ReSharper 生成依赖关系图。使用这些工具来发现代码中的问题区域,然后专注于这些区域。将圈复杂度降至 10 或更低。并将所有对象的继承深度降至不超过 1。

然后,确保所有类只执行它们的本职任务。力求使方法简短。一个很好的经验法则是每个方法不超过大约 10 行代码。对于方法参数,用参数对象替换长参数列表。在有很多out参数的地方,重构方法以返回元组或对象。识别任何多线程,并确保被访问的代码是线程安全的。你已经在第九章中看到了如何用不可变对象替换可变对象来提高线程安全性。

此外,寻找快速提示图标。它们通常会建议单击重构所突出显示的代码行。我建议你使用它们。这些在第十二章中提到过,使用工具提高代码质量

考虑的下一个代码异味是数据团。

数据团

数据团是指在不同的类和参数列表中看到相同字段一起出现。它们的名称通常遵循相同的模式。这通常是系统中缺少一个类的迹象。通过识别缺失的类并将其概括,可以减少系统复杂性。不要被这个类可能很小的事实吓到,也永远不要认为一个类不重要。如果需要一个类来简化代码,那就添加它。

除臭注释

当注释使用美好的词语来为糟糕的代码开脱时,这被称为除臭注释。如果代码糟糕,那就重构它使之变好,并删除注释。如果你不知道如何重构使之变好,那就寻求帮助。如果没有人可以帮助你,请在 Stack Overflow 上发布你的代码。那个网站上有一些非常优秀的程序员,他们可以真正帮助你。只要确保在发布时遵守规则!

重复代码

重复代码是指出现多次的代码。重复代码带来的问题包括每次重复增加的维护成本。当开发人员修复一段代码时,这会花费企业的时间和金钱。修复一个错误就是 技术债务(程序员的工资) x 1。但如果有 10 个代码重复,那就是 技术债务 x 10。因此,代码重复的次数越多,维护成本就越高。此外,还有在多个位置修复相同问题的无聊因素。还有重复可能被进行错误修复的程序员忽视的事实。

最好重构重复代码,使之只存在一份。通常,最简单的方法是将其添加到当前项目中的一个新的可重用类中,并将其放在一个类库中。将可重用代码放入类库的好处是其他项目可以使用相同的文件。

在当今,最好使用.NET 标准类库来构建可重用的代码。原因在于.NET 标准库可以在 Windows、Linux、macOS、iOS 和 Android 上的所有 C#项目类型中访问。

另一个消除样板代码的选择是使用面向方面的编程(AOP)。我们在上一章中看过 AOP。你可以将样板代码移入一个方面。然后,该方面装饰应用于的方法。当方法被编译时,样板代码就被编织到位。这使你只需在方法内编写满足业务需求的代码。应用于方法的方面隐藏了必要但不属于业务要求的代码。这种编码技术既美观又干净,而且效果非常好。

你也可以使用装饰者模式编写装饰器,就像你在上一章中看到的那样。装饰器以一种可以添加新代码而不影响代码预期操作的方式包装具体类操作。一个简单的例子是将操作包装在一个try/catch块中,就像你之前在第十一章中看到的那样,解决横切关注点

失去意图

如果你无法轻松理解源代码的意图,那它就失去了意图。

首先要做的是查看命名空间和类名。它们应该指示类的目的。然后,检查类的内容,寻找看起来不合适的代码。一旦你识别出这样的代码,就重构代码并将其放在正确的位置。

接下来要做的是看每个方法。它们只做一件事还是做多件事不太好?如果是的话,就重构它们。对于大型方法,寻找可以提取到方法中的代码。目标是使类的代码读起来像一本书。不断重构代码,直到意图清晰,类中只需要的东西才在类中。

不要忘记运用你在第十二章中学会的工具来提高代码质量。变量的变异是我们接下来要看的代码异味。

变量的变异

变量的变异意味着它们很难理解和推理。这使得它们很难重构。

可变变量是指被不同操作多次更改的变量。这使得理解值的原因更加困难。不仅如此,因为变量是从不同操作中变异的,这使得将代码片段提取到其他小而更易读的方法中变得困难。可变变量还可能需要更多的检查,增加了代码的复杂性。

试着重构代码的小部分,将它们提取到方法中。如果有很多分支和循环,请看看是否有更简单的方法来做事情,以消除复杂性。如果你使用多个out值,请考虑返回一个对象或元组。目标是消除变量的可变性,使其更容易理解,并知道它的值是什么,以及它是从哪里设置的。记住,持有变量的方法越小,确定变量设置位置和原因就越容易。

看下面的例子:

[InstrumentationAspect]
public class Mutant
{
    public int IntegerSquaredSum(List<int> integers)
    {
        var squaredSum = 0;
        foreach (var integer in integers)
        {
            squaredSum += integer * integer;
        }
        return squaredSum;
    }
}

该方法接受一个整数列表。然后它循环遍历整数,对它们进行平方,然后将它们添加到在方法退出时返回的squaredSum变量中。注意迭代次数,以及本地变量在每次迭代中的更新。我们可以使用 LINQ 来改进这一点。以下代码显示了改进后的重构版本:

[InstrumentationAspect]
public class Function
{
    public int IntegerSquaredSum(List<int> integers)
    {
            return integers.Sum(integer => integer * integer);
    }
}

在我们的新版本中,我们使用了 LINQ。正如你在前面的章节中所了解的,LINQ 采用了函数式编程。正如你在这里看到的,这里没有循环,也没有本地变量被变异。

编译并运行程序,你会看到以下内容:

代码的两个版本都产生了相同的输出。

你会注意到代码的两个版本都应用了[InstrumentationAspect]。我们在第十二章中将这个方面添加到了我们的可重用库中,解决横切关注点。当你运行代码时,你会在Debug文件夹中找到一个Logs文件夹。在记事本中打开Profile.log文件,你会看到以下输出:

Method: IntegerSquaredSum, Start Time: 01/07/2020 11:41:43
Method: IntegerSquaredSum, Stop Time: 01/07/2020 11:41:43, Duration: 00:00:00.0005489
Method: IntegerSquaredSum, Start Time: 01/07/2020 11:41:43
Method: IntegerSquaredSum, Stop Time: 01/07/2020 11:41:43, Duration: 00:00:00.0000027

输出显示ProblemCode.IntegerSquaredSum()方法是最慢的版本,运行时间为548.9纳秒。而RefactoredCode.IntegerSquaredSum()方法要快得多,只需要2.7纳秒。

通过重构循环使用 LINQ,我们避免了对本地变量的变异。我们还减少了处理计算所需的时间546.2纳秒。这样微小的改进对人眼来说并不明显。但如果你在大数据上执行这样的计算,那么你会体验到明显的差异。

现在我们来讨论奇异解决方案。

奇异解决方案

当你在源代码中看到以不同方式解决问题时,这被称为奇异解决方案。这可能是因为不同的程序员有他们自己的编程风格,没有制定标准。也可能是由于对系统的无知,即程序员没有意识到已经存在一个解决方案。

重构奇怪的解决方案的一种方法是编写一个新类,其中包含以不同方式重复的行为。以最高效的方式将行为添加到类中。然后,用新重构的行为替换奇怪的解决方案。

您还可以使用适配器模式来统一不同的系统接口:

Target类是由Client使用的特定于域的接口。需要适应的现有接口称为AdapteeAdapter类将Adaptee类适配到Target类。最后,Client类通信符合Target接口的对象。让我们实现适配器模式。添加一个名为Adaptee的新类:

public class Adaptee
{
    public void AdapteeOperation()
    {
        Console.WriteLine($"AdapteeOperation() has just executed.");
    }
}

Adaptee类非常简单。它包含一个名为AdapteeOperation()的方法,该方法将消息打印到控制台。现在添加Target类:

public class Target
{
    public virtual void Operation()
    {
        Console.WriteLine("Target.Operation() has executed.");
    }
}

Target类也非常简单,包含一个名为Operation()的虚方法,该方法将消息打印到控制台。现在我们将添加将TargetAdaptee连接在一起的Adapter类:

public class Adapter : Target
{
    private readonly Adaptee _adaptee = new Adaptee();

    public override void Operation()
    {
        _adaptee.AdapteeOperation();
    }
}

Adapter类继承了Target类。然后我们创建一个成员变量来保存我们的Adaptee对象并对其进行初始化。然后我们有一个单一方法,即Target类的重写Operation()方法。最后,我们将添加我们的Client类:

    public class Client
    {
        public void Operation()
        {
            Target target = new Adapter();
            target.Operation();
        }
    }

Client类有一个名为Operation()的方法。此方法创建一个新的Adapter对象并将其分配给Target变量。然后调用Target变量上的Operation()方法。如果调用new Client().Operation()方法并运行代码,您将看到以下输出:

您可以从屏幕截图中看到执行的方法是Adaptee.AdapteeOperation()方法。现在您已成功学会了如何实现适配器模式来解决奇怪的解决方案,我们将继续看散弹手术。

散弹手术

进行单个更改需要对多个类进行更改被称为散弹手术。这有时是由于代码过多重构导致遇到不同变化而产生的。这种代码异味增加了引入错误的倾向,例如由于错过机会而导致的错误。您还增加了合并冲突的可能性,因为代码需要在许多领域进行更改,程序员最终会互相干扰。代码如此复杂,以至于会导致程序员的认知负荷过重。新程序员由于软件的性质而面临陡峭的学习曲线。

版本控制历史将提供随时间对软件所做更改的历史记录。这可以帮助您识别每次添加新功能或遇到错误时所更改的所有区域。一旦确定了这些区域,那么您可以考虑将更改移动到代码库的更局部的区域。这样,当需要进行更改时,您只需专注于程序的一个区域,而不是许多区域。这使得项目的维护变得更加容易。

重复的代码是重构为一个适当命名的单个类的良好候选,并放置在正确的命名空间中。还要考虑应用程序的所有不同层。它们真的有必要吗?事情可以简化吗?在基于数据库的应用程序中,真的有必要拥有 DTO、DAO、领域对象等吗?数据库访问可以以任何方式简化吗?这些只是一些减少代码库大小的想法,从而减少必须修改以实现更改的区域数量。

其他要考虑的是耦合度和内聚度。耦合度需要保持在绝对最低限度。实现这一点的一种方法是通过构造函数、属性和方法注入依赖项。注入的依赖项将是特定接口类型。我们将编写一个简单的示例。添加一个名为IService的接口:

public interface IService
{
    void Operation();
}

接口包含一个名为Operation()的方法。现在,添加一个实现IService的类Dependency

public class Dependency : IService
{
    public void Operation()
    {
        Console.WriteLine("Dependency.Operation() has executed.");
    }
}

Dependency类实现了IService接口。在Operation()方法中,向控制台打印了一条消息。现在让我们添加LooselyCoupled类:

public class LooselyCoupled
{
    private readonly IService _service;

    public LooselyCoupled(IService service)
    {
        _service = service;
    }

    public void DoWork()
    {
        _service.Operation();
    }
}

如您所见,构造函数接受IService类型并将其存储在成员变量中。对DoWork()的调用调用IService类型内的Operation()方法。LooselyCoupled类就是松耦合的,很容易测试。

通过减少耦合度,使类更容易测试。通过删除不属于类的代码并将其放在应该属于的地方,可以提高应用程序的可读性、可维护性和可扩展性。您减少了任何新人上手的学习曲线,并且在进行维护或新开发时减少了引入错误的机会。

现在让我们来看一下解决方案扩散。

解决方案扩散

在不同方法、类甚至库中实现的单一责任会导致解决方案扩散。这会使代码变得非常难以阅读和理解。结果是代码变得更难维护和扩展。

为了解决问题,将单一责任的实现移入同一类中。这样,代码就只在一个位置,做它需要做的事情。这样做使得代码易于阅读和理解。结果是代码可以很容易地维护和扩展。

不受控制的副作用

不受控制的副作用是那些在生产中出现的问题,因为质量保证测试无法捕捉到它们。当遇到这些问题时,您唯一的选择就是重构代码,使其完全可测试,并且在调试期间可以查看变量,以确保它们被适当设置。

一个例子是通过引用传递值。想象两个线程通过引用将一个人的对象传递给修改人的对象的方法。一个副作用是,除非有适当的锁定机制,否则每个线程都可以修改另一个线程的人的对象,使数据无效。您在第八章中看到了可变对象的一个例子,线程和并发

这就结束了我们对应用级代码异味的讨论。现在,我们将继续看一下类级代码异味。

类级代码异味

类级代码异味是与所讨论的类有关的局部问题。可能困扰类的问题包括圈复杂度和继承深度、高耦合度和低内聚度。编写类时的目标是保持其小而功能齐全。类中的方法应该确实存在,并且应该很小。在类中只做需要做的事情 - 不多,也不少。努力消除类的依赖性,并使您的类可测试。将应该放在其他地方的代码移除到它应该属于的地方。在本节中,我们将解决类级代码异味以及如何重构它们,从圈复杂度开始。

圈复杂度

当一个类有大量的分支和循环时,它的圈复杂度会增加。 理想情况下,代码的圈复杂度值应该在1 到 10 之间。 这样的代码简单且没有风险。 圈复杂度为 11-20 的代码复杂但风险较低。 当代码的圈复杂度在 21-50 之间时,代码需要关注,因为它太复杂并对项目构成中等风险。 如果代码的圈复杂度超过 50,则这样的代码是高风险的,无法进行测试。 圈复杂度超过 50 的代码必须立即进行重构。

重构的目标是将圈复杂度值降低到 1-10 之间。 首先,通过替换switch语句后跟if表达式来开始。

用工厂模式替换switch语句

在本节中,您将看到如何用工厂模式替换switch语句。 首先,我们需要一个报告枚举:

[Flags]
public enum Report
{
    StaffShiftPattern,
    EndofMonthSalaryRun,
    HrStarters,
    HrLeavers,
    EndofMonthSalesFigures,
    YearToDateSalesFigures
}

[Flags]属性使我们能够提取枚举的名称。 Report枚举提供了报告列表。 现在让我们添加我们的switch语句:

public void RunReport(Report report)
{
    switch (report)
    {
        case Report.EndofMonthSalaryRun:
            Console.WriteLine("Running End of Month Salary Run Report.");
            break;
        case Report.EndofMonthSalesFigures:
            Console.WriteLine("Running End of Month Sales Figures Report.");
            break;
        case Report.HrLeavers:
            Console.WriteLine("Running HR Leavers Report.");
            break;
        case Report.HrStarters:
            Console.WriteLine("Running HR Starters Report.");
            break;
        case Report.StaffShiftPattern:
            Console.WriteLine("Running Staff Shift Pattern Report.");
            break;
        case Report.YearToDateSalesFigures:
            Console.WriteLine("Running Year to Date Sales Figures Report.");
            break;
        default:
            Console.WriteLine("Report unrecognized.");
            break;
    }
}

我们的方法接受一个报告,然后决定执行什么报告。 当我 1999 年作为初级 VB6 程序员开始时,我负责为 Thomas Cook,ANZ,BNZ,Vodafone 和其他一些大公司构建了一个报告生成器。 有很多报告,我负责编写一个庞大的 case 语句,使得这个 case 语句相形见绌。 但我的系统运行得非常好。 但是,按照今天的标准,有更好的方法来执行相同的代码,我会做一些非常不同的事情。

让我们使用工厂方法来运行我们的报告,而不使用switch语句。 添加一个名为IReportFactory的文件,如下所示:

public interface IReportFactory
{
    void Run();
}

IReportFactory接口只有一个名为Run()的方法。 实现类将使用此方法来运行其报告。 我们只添加一个名为StaffShiftPatternReport的报告类,它实现了IReportFactory

public class StaffShiftPatternReport : IReportFactory
{
    public void Run()
    {
        Console.WriteLine("Running Staff Shift Pattern Report.");
    }
}

StaffShiftPatternReport类实现了IReportFactory接口。 实现的Run()方法在屏幕上打印一条消息。 添加一个名为ReportRunner的报告:

public class ReportRunner
{
    public void RunReport(Report report)
    {
        var reportName = $"CH13_CodeRefactoring.RefactoredCode.{report}Report, CH13_CodeRefactoring";
        var factory = Activator.CreateInstance(
            Type.GetType(reportName) ?? throw new InvalidOperationException()
        ) as IReportFactory;
        factory?.Run();
    }
}

ReportRunner类有一个名为RunReport的方法。 它接受一个类型为Report的参数。 由于Report是带有[Flags]属性的枚举,我们可以获取report枚举的名称。 我们使用这个名称来构建报告的名称。 然后,我们使用Activator类来创建报告的实例。 如果在获取类型时reportName返回 null,则抛出InvalidOperationException。 工厂被转换为IReportFactory类型。 然后我们调用工厂上的Run方法来生成报告。

这段代码绝对比一个非常长的switch语句要好得多。 我们需要知道如何提高if语句中条件检查的可读性。 我们接下来会看一下。

提高if语句中条件检查的可读性

if语句可能会违反单一职责和开闭原则。 请参阅以下示例:

public string GetHrReport(string reportName)
{
    if (reportName.Equals("Staff Joiners Report"))
        return "Staff Joiners Report";
    else if (reportName.Equals("Staff Leavers Report"))
        return "Staff Leavers Report";
    else if (reportName.Equals("Balance Sheet Report"))
        return "Balance Sheet Report";
}

GetReport()类有三个职责:员工入职报告,员工离职报告和资产负债表报告。 这违反了 SRP,因为该方法应该只关心 HR 报告,但它返回 HR 和财务报告。 就开闭原则而言,每次需要新报告时,我们都必须扩展此方法。 让我们重构该方法,以便不再需要if语句。 添加一个名为ReportBase的新类:

public abstract class ReportBase
{
    public abstract void Print();
}

ReportBase类是一个带有抽象Print()方法的抽象类。 我们将添加NewStartersReport类,它继承了ReportBase类:

    internal class NewStartersReport : ReportBase
    {
        public override void Print()
        {
            Console.WriteLine("Printing New Starters Report.");
        }
    }

NewStartersReport类继承了ReportBase类并重写了Print()方法。 Print()方法在屏幕上打印一条消息。 现在,我们将添加LeaversReport类,它几乎相同:

    public class LeaversReport : ReportBase
    {
        public override void Print()
        {
            Console.WriteLine("Printing Leavers Report.");
        }
    }

LeaversReport继承了ReportBase类并重写了Print()方法。Print()方法向屏幕打印一条消息。现在我们可以这样调用报告:

ReportBase newStarters = new NewStartersReport();
newStarters.Print();

ReportBase leavers = new LeaversReport();
leavers.Print();

两个报告都继承了ReportBase类,因此可以被实例化并分配给ReportBase变量。然后可以在变量上调用Print()方法,并且将执行正确的Print()方法。现在的代码遵循了单一责任原则和开闭原则。

接下来,我们将看一看分歧变化代码异味。

分歧变化

当您需要在一个位置进行更改,并发现自己不得不更改许多不相关的方法时,这被称为分歧变化。分歧变化发生在单个类中,是糟糕的类结构的结果。复制和粘贴代码是导致此问题出现的另一个原因。

为了解决问题,将导致问题的代码移动到自己的类中。如果行为和状态在类之间共享,则考虑使用适当的基类和子类来实现继承。

修复分歧变化相关问题的好处包括更容易的维护,因为更改将位于单个位置。这使得支持应用程序变得更加容易。它还从系统中删除了重复的代码,这恰好是我们接下来将讨论的内容。

向下转型

当基类被转换为其子类之一时,这被称为向下转型。这显然是一种代码异味,因为基类不应该知道继承它的类。例如,考虑Animal基类。任何类型的动物都可以继承基类。但动物只能是一种类型。例如,猫科动物是猫科动物,犬科动物是犬科动物。将猫科动物转换为犬科动物,反之亦然,是荒谬的。

将动物向下转型为其子类型甚至更加荒谬。这就像说猴子和骆驼是一样的,非常擅长通过沙漠长距离运输人类和货物。这是毫无意义的。因此,您永远不应该进行向下转型。将各种动物(如猴子和骆驼)向上转型为类型Animal是有效的,因为猫科动物、犬科动物、猴子和骆驼都是动物的类型。

过度使用文字

在使用文字时,很容易引入编码错误。一个例子是字符串文字中的拼写错误。最好将文字文字分配给常量变量。字符串文字应放在资源文件中以进行本地化。特别是如果您计划将软件部署到世界各地的不同位置。

特征嫉妒

当一个方法在除了它自己所在的类之外的其他类中花费更多时间处理源代码时,这被称为特征嫉妒。我们将在我们的“授权”类中看到这样的例子。但在我们这样做之前,让我们来看看我们的“认证”类:

public class Authentication
{
    private bool _isAuthenticated = false;

    public void Login(ICredentials credentials)
    {
        _isAuthenticated = true;
    }

    public void Logout()
    {
        _isAuthenticated = false;
    }

    public bool IsAuthenticated()
    {
        return _isAuthenticated;
    }
}

我们的“认证”类负责登录和注销用户,以及确定他们是否经过身份验证。添加我们的“授权”类:

public class Authorization
{
    private Authentication _authentication;

    public Authorization(Authentication authentication)
    {
        _authentication = authentication;
    }

    public void Login(ICredentials credentials)
    {
        _authentication.Login(credentials);
    }

    public void Logout()
    {
        _authentication.Logout();
    }

    public bool IsAuthenticated()
    {
        return _authentication.IsAuthenticated();
    }

    public bool IsAuthorized(string role)
    {
        return IsAuthenticated && role.Contains("Administrator");
    }
}

正如您在我们的“授权”类中所看到的,它所做的事情远远超出了它应该做的范围。有一个方法用于验证用户是否被授权承担某个角色。传入的角色将被检查,以确定它是否是管理员角色。如果是,那么该人被授权。但如果角色不是管理员角色,那么该人就没有被授权。

然而,如果您看一下其他方法,它们所做的不过是调用“认证”类中的相同方法。因此,在这个类的上下文中,认证方法是特征嫉妒的一个例子。让我们从“授权”类中移除特征嫉妒:

public class Authorization
{
    private ProblemCode.Authentication _authentication;

    public Authorization(ProblemCode.Authentication authentication)
    {
        _authentication = authentication;
    }

    public bool IsAuthorized(string role)
    {
        return _authentication.IsAuthenticated() && role.Contains("Administrator");
    }
}

您会发现“授权”类现在要小得多,只做了它需要做的事情。不再有特征嫉妒。

接下来,我们将看一看不适当的亲密关系代码异味。

不适当的亲密关系

当一个类依赖于另一个类中保存的实现细节时,它就会参与不恰当的亲密关系。这种依赖的类真的需要存在吗?它能否与它所依赖的类合并?或者有没有共享功能最好被提取到自己的类中?

类不应该相互依赖,因为这会导致耦合,并且也会影响内聚性。一个类理想上应该是自包含的。类应该尽可能少地了解彼此。

不检点的暴露

当一个类暴露其内部细节时,这被称为不检点的暴露。这违反了面向对象编程的封装原则。只有应该是公共的才应该是公共的。所有不需要公开的实现都应该通过适当的访问修饰符进行隐藏。

数据值不应该是公共的。它们应该是私有的,只能通过构造函数、方法和属性进行修改。它们只能通过属性进行检索。

大类(又名上帝对象)

大类,也被称为“上帝”对象,对系统的所有部分都是一切。它是一个庞大而笨重的类,做了太多的事情。当你尝试阅读对象时,当你读到类名并看到它所在的命名空间时,代码的意图可能是清晰的,但当你来看代码时,代码的意图可能会变得模糊。

一个写得好的类应该有其意图的名称,并且应该放在适当的命名空间中。类的内容应该遵循公司的编码标准。方法应该尽可能保持小,方法参数应该尽可能保持绝对最少。只有属于类的方法应该在类中。不属于类的成员变量、属性和方法应该被移除,并放在正确的文件和正确的命名空间中。

为了保持类的小型和专注,如果没有必要,就不要继承类。如果有一个类有五个方法,而你只会使用其中一个,那么是否可能将该方法移出到自己可重用的类中?记住单一职责原则。一个类应该只有一个职责。例如,文件类应该只处理与文件相关的操作和行为。文件类不应该执行数据库操作。你明白了。

当编写一个类时,你的目标是使它尽可能小,干净和可读。

懒惰类(又名搭便车和懒惰对象)

一个搭便车的类几乎没有任何用处。当你遇到这样的类时,你可以将它们的内容与具有相同意图的其他类合并。

你也可以尝试折叠继承层次结构。记住,理想的继承深度是1。因此,如果你的类的继承深度较大,那么它们是将向上移动继承树的良好候选者。你可能还想考虑使用内联类来处理非常小的类。

中间人类

中间人类只是将功能委托给其他对象。在这种情况下,你可以摆脱中间人,直接处理负责的对象。

还要记住,你需要保持继承深度。所以如果你不能摆脱这个类,就要考虑将它与现有的类合并。看看代码区域的整体设计。是否可以以某种方式重构所有代码,以减少代码量和不同类的数量?

变量和常量的孤立类

拥有一个独立的类来保存应用程序多个不同部分的变量和常量并不是一个好的做法。当你遇到这种情况时,变量可能很难有任何真正的含义,它们的上下文可能会丢失。最好将常量和变量移动到使用它们的地方。如果常量和变量将被多个类使用,那么它们应该分配给命名空间根目录中的一个文件。

原始偏执

源代码使用原始值而不是对象来执行某些任务,比如范围值和格式化字符串,比如信用卡、邮政编码和电话号码,这就是原始偏执。其他迹象包括用于字段名称的常量,以及不适当存储在常量中的信息。

拒绝遗赠

当一个类继承自另一个类,但不使用其所有方法时,这被称为拒绝遗赠。发生这种情况的常见原因是子类与基类完全不同。例如,一个building基类被不同的建筑类型使用,但然后一个car对象继承building,因为它具有与窗户和门相关的属性和方法。这显然是错误的。

当你遇到这种情况时,考虑是否需要一个基类。如果需要,那么创建一个,然后从中继承。否则,将功能添加到从错误类型继承的类中。

投机泛化

一个类被编程为具有现在不需要但将来可能需要的功能,这就是投机泛化。这样的代码是死代码,会增加维护开销和代码膨胀。最好在发现这些类时将其删除。

告诉,不要问

告诉,不要问软件原则告诉我们作为程序员,我们应该将数据与将操作该数据的方法捆绑在一起。我们的对象不应该要求数据然后对其进行操作!它们必须告诉对象的逻辑在对象的数据上执行特定任务。

如果你发现包含逻辑并要求其他对象提供数据来执行其操作的对象,那么将逻辑和数据合并到一个类中。

临时字段

临时字段是不需要在对象的整个生命周期中的成员变量。

你可以通过将临时字段和操作它们的方法移除到它们自己的类中来进行重构。你最终会得到更清晰、更有组织的代码。

方法级别的异味

方法级别的代码异味是方法本身的问题。方法是使软件功能良好或糟糕的工作马。它们应该组织良好,只做它们预期要做的事情——不多也不少。了解由于构造不良的方法可能出现的问题和问题的种类是很重要的。我们将讨论在方法级别的代码异味方面要注意的问题,以及我们可以做些什么来解决它们。我们将首先从黑羊方法开始。

黑羊方法

在类中的所有方法中,黑羊方法将明显不同。当你遇到黑羊方法时,你必须客观地考虑这个方法。它的名字是什么?方法的意图是什么?当你回答了这些问题,然后你可以决定删除这个方法,并将它放在它真正属于的地方。

圈复杂度

当一个方法有太多的循环和分支时,这被称为圈复杂度。这种代码异味也是一个类级别的代码异味,我们已经看到了如何在替换switchif语句时可以减少分支的问题。至于循环,它们可以被替换为 LINQ 语句。LINQ 语句的额外好处是它是一个函数式代码,因为 LINQ 是一个函数式查询语言。

人为复杂

当一个方法不必要地复杂并且可以简化时,这种复杂性被称为人为复杂性。简化方法以确保其内容是人类可读和可理解的。然后,尝试重构方法并将其大小减小到实际可行的最小行数。

死代码

当存在但未被使用的方法时,这被称为死代码。构造函数、属性、参数和变量也是如此。它们应该被识别并移除。

过多的数据返回

当一个方法返回的数据比每个调用它的客户端所需的数据更多时,这种代码异味被称为过多的数据返回。应该只返回所需的数据。如果发现有不同要求的对象组,那么可能需要考虑编写不同的方法,以满足两组的需求,并且只返回对这些组有必要的数据。

特性嫉妒

特性嫉妒的方法花费更多时间访问其他对象中的数据,而不是在自己的对象中。当我们在类级别代码异味中看到特性嫉妒时,我们已经看到了这一点。

方法应该保持小巧,最重要的是,其主要功能应该局限于该方法。如果它在其他方法中做的事情比自己的方法还多,那么就有可能将一些代码从该方法中移出并放入自己的方法中。

标识符大小

标识符可能太短或太长。标识符应该具有描述性和简洁性。在命名变量时要考虑的主要因素是上下文和位置。在局部循环中,一个字母可能是合适的。但如果标识符在类级别,那么它将需要一个人能理解的名称来给它上下文。避免使用缺乏上下文、模糊或引起混淆的名称。

不恰当的亲密性

过于依赖其他方法或类中的实现细节的方法显示出不恰当的亲密性。这些方法需要被重构,甚至可能被移除。要牢记的主要事情是这些方法使用另一个类的内部字段和方法。

要进行重构,您可以将方法和字段移动到实际需要使用它们的地方。或者,您可以将字段和方法提取到它们自己的类中。当子类与超类亲密关联时,继承可以取代委托。

长行(又称上帝行)

长行代码很难阅读和解释。这使得程序员很难调试和重构这样的代码。在可能的情况下,可以格式化该行,使得任何句点和逗号后的代码出现在新行上。但这样的代码也应该被重构成更小的代码。

懒惰的方法

懒惰的方法是指做很少工作的方法。它可能将工作委托给其他方法,也可能只是调用另一个类的方法来完成它应该完成的工作。如果有任何这些情况,那么可能需要摆脱这些方法,并将代码放在需要的方法中。例如,您可以使用内联函数,比如 lambda。

长方法(又称上帝方法)

长方法是指已经超出自身范围的方法。这样的方法可能会失去其意图,并执行比预期更多的任务。您可以使用 IDE 选择方法的部分,然后选择提取方法或提取类,将方法的部分移动到自己的方法甚至自己的类中。方法应该只负责执行单一任务。

长参数列表(又称参数过多)

三个或更多参数被归类为长参数列表代码异味。您可以通过用方法调用替换参数来解决这个问题。另一种方法是用参数对象替换参数。

消息链

当一个方法调用一个对象,该对象调用另一个对象,依此类推时,就会出现消息链。之前,我们在看到迪米特法则时已经了解了如何处理消息链。消息链违反了这个法则,因为一个类只应该与其最近的邻居通信。重构类,将所需的状态和行为移动到需要它的地方。

中间人方法

当一个方法的全部工作只是委托给其他人完成时,它就是一个中间人方法,可以进行重构和删除。但如果有功能无法删除,那么将其合并到使用它的区域。

古怪解决方案

当看到多个方法做同样的事情但以不同的方式时,这就是一个古怪的解决方案。选择最好实现任务的方法,然后将对其他方法的调用替换为对最佳方法的调用。然后,删除其他方法。这将只留下一个方法和一种可以重复使用的实现任务的方法。

推测性泛化

一个在代码中没有被使用的方法被称为推测性泛化代码异味。它本质上是死代码,所有死代码都应该从系统中删除。这样的代码会增加维护成本,也会提供不必要的代码膨胀。

总结

在本章中,您已经了解了各种代码异味以及如何通过重构来消除它们。我们已经指出,有应用级别的代码异味渗透到应用程序的所有层,类级别的代码异味贯穿整个类,方法级别的代码异味影响个别方法。

首先,我们讨论了应用级别的代码异味,其中包括布尔盲目、组合爆炸、人为复杂、数据团、除臭剂注释、重复代码、意图丢失、变量突变、古怪解决方案、散弹手术、解决方案蔓延和不受控制的副作用。

然后,我们继续查看类级别的代码异味,包括圈复杂度、分歧变更、向下转型、过多的文字使用、特性嫉妒、不当亲密、不检狂露和大对象,也称为上帝对象。我们还涵盖了懒惰类,也称为吃白食者和懒惰对象;中间人;变量和常量的孤立类;原始偏执;拒绝继承;推测性泛化;告诉,不要问;和临时字段。

最后,我们转向了方法级别的代码异味。我们讨论了黑羊;圈复杂度;人为复杂;死代码;特性嫉妒;标识符大小;不当亲密;长行,也称为上帝行;懒惰方法;长方法,也称为上帝方法;长参数列表,也称为参数过多;消息链;中间人;古怪解决方案;和推测性泛化。

在下一章中,我们将继续使用 ReSharper 来查看代码重构。

问题

  1. 代码异味的三个主要类别是什么?

  2. 命名不同类型的应用级代码异味。

  3. 命名不同类型的类级别代码异味。

  4. 命名不同类型的方法级代码异味。

  5. 您可以执行哪些重构以清理各种代码异味?

  6. 什么是圈复杂度?

  7. 我们如何克服圈复杂度?

  8. 什么是人为复杂?

  9. 我们如何克服人为复杂?

  10. 什么是组合爆炸?

  11. 我们如何克服组合爆炸?

  12. 当发现除臭剂注释时,你应该怎么办?

  13. 如果你有糟糕的代码但不知道如何修复,你应该怎么办?

  14. 在处理编程问题时,哪里是提问和获取答案的好地方?

  15. 如何减少长参数列表?

  16. 如何重构一个大方法?

  17. 一个干净方法的最大长度是多少?

  18. 您的程序的圈复杂度应该在什么范围内?

  19. 继承深度的理想值是多少?

  20. 什么是投机泛化,以及你应该怎么做?

  21. 如果你遇到一个奇怪的解决方案,你应该采取什么行动?

  22. 如果你遇到一个临时字段,你会进行哪些重构?

  23. 什么是数据团,以及你应该怎么做?

  24. 解释拒绝遗赠的代码异味。

  25. 消息链违反了什么法则?

  26. 消息链应该如何重构?

  27. 什么是特征嫉妒?

  28. 你如何消除特征嫉妒?

  29. 你可以使用什么模式来替换返回对象的switch语句?

  30. 我们如何替换返回对象的if语句?

  31. 什么是解决方案蔓延,以及可以采取什么措施来解决它?

  32. 解释“告诉,不要问!”原则。

  33. “告诉,不要问!”原则是如何被打破的?

  34. 霰弹手术的症状是什么,应该如何解决?

  35. 解释失去意图以及可以采取的措施。

  36. 循环可以如何重构,重构会带来什么好处?

  37. 什么是分歧变化,你会如何重构它?

进一步阅读

第十四章:重构 C#代码——实现设计模式

编写清晰代码的一半战斗在于正确实现和使用设计模式。设计模式本身也可能成为代码异味。当用于过度设计某些相当简单的东西时,设计模式就会成为代码异味。

在本书的前几章中,你已经看到了设计模式在编写清晰代码和重构代码中的应用。具体来说,我们已经实现了适配器模式、装饰器模式和代理模式。这些模式都是以正确的方式实现以完成手头的任务。它们保持简单,绝对不会使代码复杂。因此,当用于其适当的目的时,设计模式在消除代码异味方面确实非常有用,从而使你的代码变得清晰、干净和新鲜。

在这一章中,我们将讨论四人帮(GoF)的创建、结构和行为设计模式。设计模式并非一成不变,你不必严格按照它们的实现方式。但是有代码示例可以帮助你从仅仅拥有理论知识过渡到具备正确实现和使用设计模式所需的实际技能。

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

  • 实现创建型设计模式

  • 实现结构设计模式

  • 行为设计模式的概述

在本章结束时,你将具备以下技能:

  • 理解、描述和编程不同的创建型设计模式的能力

  • 理解、描述和编程不同的结构设计模式的能力

  • 理解行为设计模式的概述

我们将通过讨论创建型设计模式来开始我们对 GoF 设计模式的概述。

技术要求

实现创建型设计模式

从程序员的角度来看,当我们执行对象创建时,我们使用创建型设计模式。模式是根据手头的任务选择的。有五种创建型设计模式:

  • 单例模式:单例模式确保应用程序级别只存在一个对象实例。

  • 工厂方法:工厂模式用于创建对象而不使用要使用的类。

  • 抽象工厂:在不指定其具体类的情况下,抽象工厂实例化相关或依赖的对象组。

  • 原型:指定要创建的原型的类型,然后创建原型的副本。

  • 建造者:将对象的构建与其表示分离。

我们现在将开始实现这些模式,从单例设计模式开始。

实现单例模式

单例设计模式只允许一个类的一个实例,并且可以全局访问。当系统内的所有操作必须由一个对象协调时,使用单例模式:

这个模式中的参与者是单例——一个负责管理自己实例的类,并确保在整个系统中只有一个实例在运行。

我们现在将实现单例设计模式:

  1. CreationalDesignPatterns文件夹中添加一个名为Singleton的文件夹。然后,添加一个名为Singleton的类:
public class Singleton {
    private static Singleton _instance;

    protected Singleton() { }

    public static Singleton Instance() {
        return _instance ?? (_instance = new Singleton());
    }
}
  1. Singleton类存储了自身实例的静态副本。您无法实例化该类,因为构造函数被标记为受保护。Instance()方法是静态的。它检查Singleton类的实例是否存在。如果存在,则返回该实例。如果不存在,则创建并返回该实例。现在,我们将添加调用它的代码:
var instance1 = Singleton.Instance();
var instance2 = Singleton.Instance();

if (instance1.Equals(instance2))
    Console.WriteLine("Instance 1 and instance 2 are the same instance of Singleton.");
  1. 我们声明了Singleton类的两个实例,然后将它们进行比较,以查看它们是否是同一个实例。您可以在以下截图中看到输出:

正如你所看到的,我们有一个实现了单例设计模式的工作类。接下来,我们将着手实现工厂方法设计模式。

实现工厂方法模式

工厂方法设计模式创建对象,让它们的子类实现自己的对象创建逻辑。当您想要将对象实例化保持在一个地方并且需要生成特定组相关对象时,请使用此设计模式:

该项目的参与者如下:

  • 产品 工厂方法创建的抽象产品

  • ConcreteProduct:继承抽象产品

  • 创建者:一个带有抽象工厂方法的抽象类

  • Concrete Creator 继承抽象创建者并重写工厂方法

我们现在将实现工厂方法:

  1. CreationalDesignPatterns文件夹中添加一个名为FactoryMethod的文件夹。然后,添加Product类:
public abstract class Product {}
  1. Product类定义了由工厂方法创建的对象。添加ConcreteProduct类:
public class ConcreteProduct : Product {}
  1. ConcreteProduct类继承了Product类。添加Creator类:
public abstract class Creator {
    public abstract Product FactoryMethod();
}
  1. Creator类将被ConcreteFactory类继承,后者将实现FactoryMethod()。添加ConcreteCreator类:
public class ConcreteCreator : Creator {
    public override Product FactoryMethod() {
        return new ConcreteProduct();
    }
}
  1. ConcreteCreator类继承了Creator类并重写了FactoryMethod()。该方法返回一个新的ConcreteProduct类。以下代码演示了工厂方法的使用:
var creator = new ConcreteCreator();
var product = creator.FactoryMethod();
Console.WriteLine($"Product Type: {product.GetType().Name}");

我们已经创建了ConcreteCreator类的一个新实例。然后,我们调用FactoryMethod()来创建一个新产品。由工厂方法创建的产品的名称随后输出到控制台窗口,如下所示:

现在我们知道如何实现工厂方法设计模式,我们将继续实现抽象工厂设计模式。

实现抽象工厂模式

在没有具体类的情况下,相关或依赖的对象组,称为家族,使用抽象工厂设计模式进行实例化:

该模式的参与者如下:

  • AbstractFactory:由具体工厂实现的抽象工厂

  • ConcreteFactory:创建具体产品

  • AbstractProduct:具体产品将继承的抽象产品

  • Product:继承AbstractProduct并由具体工厂创建

我们现在将开始实现该模式:

  1. 在项目中添加一个名为CreationalDesignPatterns的文件夹。

  2. CreationalDesignPatterns文件夹中添加一个名为AbstractFactory的文件夹。

  3. AbstractFactory文件夹中,添加AbstractFactory类:

public abstract class AbstractFactory {
    public abstract AbstractProductA CreateProductA();
    public abstract AbstractProductB CreateProductB();
}
  1. AbstractFactory包含两个创建抽象产品的抽象方法。添加AbstractProductA类:
public abstract class AbstractProductA {
    public abstract void Operation(AbstractProductB productB);
}
  1. AbstractProductA类有一个单一的抽象方法,该方法对AbstractProductB执行操作。现在,添加AbstractProductB类:
public abstract class AbstractProductB {
    public abstract void Operation(AbstractProductA productA);
}
  1. AbstractProductB类有一个单一的抽象方法,该方法对AbstractProductA执行操作。添加ProductA类:
public class ProductA : AbstractProductA {
    public override void Operation(AbstractProductB productB) {
        Console.WriteLine("ProductA.Operation(ProductB)");
    }
}
  1. ProductA继承了AbstractProductA并重写了Operation()方法,该方法与AbstractProductB进行交互。在这个例子中,Operation()方法打印出控制台消息。对ProductB类也做同样的操作:
public class ProductB : AbstractProductB {
    public override void Operation(AbstractProductA productA) {
        Console.WriteLine("ProductB.Operation(ProductA)");
    }
}
  1. ProductB继承了AbstractProductB并重写了Operation()方法,该方法与AbstractProductA进行交互。在这个例子中,Operation()方法打印出控制台消息。添加ConcreteFactory类:
public class ConcreteProduct : AbstractFactory {
    public override AbstractProductA CreateProductA() {
        return new ProductA();
    }

    public override AbstractProductB CreateProductB() {
        return new ProductB();
    }
}
  1. ConcreteFactory继承了AbstractFactory类,并重写了两个产品创建方法。每个方法返回一个具体类。添加Client类:
public class Client
{
    private readonly AbstractProductA _abstractProductA;
    private readonly AbstractProductB _abstractProductB;

    public Client(AbstractFactory factory) {
        _abstractProductA = factory.CreateProductA();
        _abstractProductB = factory.CreateProductB();
    }

    public void Run() {
        _abstractProductA.Operation(_abstractProductB);
        _abstractProductB.Operation(_abstractProductA);
    }
}
  1. Client类声明了两个抽象产品。它的构造函数接受一个AbstractFactory类。在构造函数内部,工厂为两个声明的抽象产品分配了它们各自的具体产品。Run()方法执行了两个产品上的Operation()。以下代码执行了我们的抽象工厂示例:
AbstractFactory factory = new ConcreteProduct();
Client client = new Client(factory);
client.Run();
  1. 运行代码,你会看到以下输出:

抽象工厂的一个很好的参考实现是 ADO.NET 2.0 的DbProviderFactory抽象类。一篇名为ADO.NET 2.0 中的抽象工厂设计模式的文章,作者是 Moses Soliman,发布在 C# Corner 上,对DbProviderFactory的抽象工厂设计模式的实现进行了很好的描述。这是链接:

www.c-sharpcorner.com/article/abstract-factory-design-pattern-in-ado-net-2-0/.

我们已成功实现了抽象工厂设计模式。现在,我们将实现原型模式。

实现原型模式

原型设计模式用于创建原型的实例,然后通过克隆原型来创建新对象。当直接创建对象的成本昂贵时,使用此模式。通过此模式,可以缓存对象,并在需要时返回克隆:

原型设计模式中的参与者如下:

  • Prototype:提供克隆自身的方法的抽象类

  • ConcretePrototype:继承原型并重写Clone()方法以返回原型的成员克隆

  • Client:请求原型的新克隆

我们现在将实现原型设计模式:

  1. CreationalDesignPatterns文件夹中添加一个名为Prototype的文件夹,然后添加Prototype类:
public abstract class Prototype {
    public string Id { get; private set; }

    public Prototype(string id) {
        Id = id;
    }

    public abstract Prototype Clone();
}
  1. 我们的Prototype类必须被继承。它的构造函数需要传入一个标识字符串,该字符串存储在类级别。提供了一个Clone()方法,子类将对其进行重写。现在,添加ConcretePrototype类:
public class ConcretePrototype : Prototype {
    public ConcretePrototype(string id) : base(id) { }

    public override Prototype Clone() {
        return (Prototype) this.MemberwiseClone();
    }
}
  1. ConcretePrototype类继承自Prototype类。它的构造函数接受一个标识字符串,并将该字符串传递给基类的构造函数。然后,它重写了克隆方法,通过调用MemberwiseClone()方法提供当前对象的浅拷贝,并返回转换为Prototype类型的克隆。现在,我们来演示原型设计模式的代码:
var prototype = new ConcretePrototype("Clone 1");
var clone = (ConcretePrototype)prototype.Clone();
Console.WriteLine($"Clone Id: {clone.Id}");

我们的代码创建了一个带有标识符"Clone 1"ConcretePrototype类的新实例。然后,我们克隆原型并将其转换为ConcretePrototype类型。然后,我们将克隆的标识符打印到控制台窗口,如下所示:

我们可以看到,克隆的标识符与其克隆自的原型相同。

对于一个真实世界示例的非常详细的文章,请参考一篇名为具有真实场景的原型设计模式的优秀文章,作者是 Akshay Patel,文章发布在 C# Corner 上。这是链接:www.c-sharpcorner.com/UploadFile/db2972/prototype-design-pattern-with-real-world-scenario624/

我们现在将实现我们的最终创建型设计模式,即建造者设计模式。

实现建造者模式

建造者设计模式将对象的构建与其表示分离。因此,您可以使用相同的构建方法来创建对象的不同表示。当您有一个需要逐步构建和连接的复杂对象时,请使用建造者设计模式:

建造者设计模式的参与者如下:

  • Director:一个类,通过其构造函数接收一个构建者,然后在构建者对象上调用每个构建方法

  • Builder:一个抽象类,提供抽象构建方法和一个用于返回构建对象的抽象方法

  • ConcreteBuilder:一个具体类,继承Builder类,重写构建方法以实际构建对象,并重写结果方法以返回完全构建的对象

让我们开始实现我们的最终创建型设计模式——建造者设计模式:

  1. 首先,在CreationalDesignPatterns文件夹中添加一个名为Builder的文件夹。然后,添加Product类:
public class Product {
    private List<string> _parts;

    public Product() {
        _parts = new List<string>();
    }

    public void Add(string part) {
        _parts.Add(part);
    }

    public void PrintPartsList() {
        var sb = new StringBuilder();
        sb.AppendLine("Parts Listing:");
        foreach (var part in _parts)
            sb.AppendLine($"- {part}");
        Console.WriteLine(sb.ToString());
    }
}
  1. 在我们的示例中,Product类保留了一个部件列表。这些部件是字符串。列表在构造函数中初始化。通过Add()方法添加部件,当对象完全构建时,我们可以调用PrintPartsList()方法将构成对象的部件列表打印到控制台窗口。现在,添加Builder类:
public abstract class Builder
{
    public abstract void BuildSection1();
    public abstract void BuildSection2();
    public abstract Product GetProduct();
}
  1. 我们的Builder类将被具体类继承,这些具体类将重写其抽象方法以构建对象并返回它。我们现在将添加ConcreteBuilder类:
public class ConcreteBuilder : Builder {
    private Product _product;

    public ConcreteBuilder() {
        _product = new Product();
    }

    public override void BuildSection1() {
        _product.Add("Section 1");
    }

    public override void BuildSection2() {
        _product.Add(("Section 2"));
    }

    public override Product GetProduct() {
        return _product;
    }
}
  1. 我们的ConcreteBuilder类继承了Builder类。该类存储要构建的对象的实例。构建方法被重写,并通过产品的Add()方法向产品添加部件。产品通过GetProduct()方法调用返回给客户端。添加Director类:
public class Director
{
    public void Build(Builder builder)
    {
        builder.BuildSection1();
        builder.BuildSection2();
    }
}
  1. Director类是一个具体类,通过其Build()方法接收一个Builder对象,并调用Builder对象上的构建方法来构建对象。现在我们需要的是演示建造者设计模式的代码:
var director = new Director();
var builder = new ConcreteBuilder();
director.Build(builder);
var product = builder.GetProduct();
product.PrintPartsList();
  1. 我们创建一个导演和一个构建者。然后,导演构建产品。然后分配产品,并将其部件列表打印到控制台窗口,如下所示:

一切都按预期运行。

在.NET Framework 中,System.Text.StringBuilder类是现实世界中建造者设计模式的一个例子。使用加号(+)运算符进行字符串连接比使用StringBuilder类在连接五行或更多行时要慢。当连接少于五行时,使用+运算符的字符串连接速度比StringBuilder快,但当连接超过五行时,速度比StringBuilder慢。原因是每次使用+运算符创建字符串时,都会重新创建字符串,因为字符串在堆上是不可变的。但StringBuilder在堆上分配缓冲区空间,然后将字符写入缓冲区空间。对于少量行,由于使用字符串构建器时创建缓冲区的开销,+运算符更快。但当超过五行时,使用StringBuilder时会有明显的差异。在大数据项目中,可能会进行数十万甚至数百万次字符串连接,您决定采用的字符串连接策略将决定其性能快慢。让我们创建一个简单的演示。创建一个名为StringConcatenation的新类,然后添加以下代码:

private static DateTime _startTime;
private static long _durationPlus;
private static long _durationSb;

_startTime 变量保存方法执行的当前开始时间。_durationPlus 变量保存使用 + 运算符进行连接时的方法执行持续时间的滴答声数量,_durationSb 保存使用 StringBuilder 连接的操作的持续时间作为滴答声数量。将 UsingThePlusOperator() 方法添加到类中:

public static void UsingThePlusOperator()
{
    _startTime = DateTime.Now;
    var text = string.Empty;
    for (var x = 1; x <= 10000; x++)
    {
        text += $"Line: {x}, I must not be a lazy programmer, and should continually develop myself!\n";
    }
    _durationPlus = (DateTime.Now - _startTime).Ticks;
    Console.WriteLine($"Duration (Ticks) Using Plus Operator: {_durationPlus}");
}

UsingThePlusOperator() 方法演示了使用 + 运算符连接 10,000 个字符串时所花费的时间。处理字符串连接所花费的时间以触发的滴答声数量存储。每毫秒有 10,000 个滴答声。现在,添加 UsingTheStringBuilder() 方法:

public static void UsingTheStringBuilder()
{
    _startTime = DateTime.Now;
    var sb = new StringBuilder();
    for (var x = 1; x <= 10000; x++)
    {
        sb.AppendLine(
            $"Line: {x}, I must not be a lazy programmer, and should continually develop myself!"
        );
    }
    _durationSb = (DateTime.Now - _startTime).Ticks;
    Console.WriteLine($"Duration (Ticks) Using StringBuilder: {_durationSb}");
}

这个方法与前一个方法相同,只是我们使用 StringBuilder 类执行字符串连接。现在我们将添加代码来打印时间差异,称为 PrintTimeDifference()

public static void PrintTimeDifference()
{
    var difference = _durationPlus - _durationSb;
    Console.WriteLine($"That's a time difference of {difference} ticks.");
    Console.WriteLine($"{difference} ticks = {TimeSpan.FromTicks(difference)} seconds.\n\n");
}

PrintTimeDifference() 方法通过从 StringBuilder 的滴答声中减去 + 的滴答声来计算时间差。然后将滴答声的差异打印到控制台,然后是将滴答声转换为秒的行。以下是用于测试我们的方法的代码,以便我们可以看到两种连接方法之间的时间差异:

StringConcatenation.UsingThePlusOperator();
StringConcatenation.UsingTheStringBuilder();
StringConcatenation.PrintTimeDifference();

当您运行代码时,您将在控制台窗口中看到时间和时间差异,如下所示:

从屏幕截图中可以看出,StringBuilder 要快得多。对于少量数据,肉眼几乎看不出差异。但是当处理的数据行数量大大增加时,肉眼可以看到差异。

另一个我想到的使用生成器模式的例子是报告构建。如果您考虑分段报告,那么各个段基本上是需要从各种来源构建起来的部分。因此,您可以有主要部分,然后每个子报告作为不同的部分。最终报告将是这些各种部分的融合。因此,您可以像以下代码一样构建报告:

var report = new Report();
report.AddHeader();
report.AddLastYearsSalesTotalsForAllRegions();
report.AddLastYearsSalesTotalsByRegion();
report.AddFooter();
report.GenerateOutput();

在这里,我们正在创建一个新的报告。我们首先添加标题。然后,我们添加去年所有地区的销售额,然后是去年按地区细分的销售额。然后我们为报告添加页脚,并通过生成报告输出完成整个过程。

所以,您已经从 UML 图表中看到了生成器模式的默认实现。然后,您使用 StringBuilder 类实现了字符串连接,这有助于以高性能的方式构建字符串。最后,您了解了生成器模式如何在构建报告的各个部分并生成其输出时有用。

好了,这就结束了我们对创建设计模式的实现。现在我们将继续实现一些结构设计模式。

实施结构设计模式

作为程序员,我们使用结构模式来改进代码的整体结构。因此,当遇到缺乏结构且不够清晰的代码时,我们可以使用本节中提到的模式来重构代码并使其变得清晰。有七种结构设计模式:

  • 适配器:使用此模式使具有不兼容接口的类能够干净地一起工作。

  • 桥接:使用此模式通过将抽象与其实现解耦来松散地耦合代码。

  • 组合:使用此模式聚合对象并提供一种统一的方式来处理单个和对象组合。

  • 装饰者:使用此模式保持接口相同,同时动态添加新功能到对象。

  • 外观:使用此模式简化更大更复杂的接口。

  • 享元:使用此模式节省内存并在对象之间传递共享数据。

  • 代理:在客户端和 API 之间使用此模式拦截客户端和 API 之间的调用。

我们已经在之前的章节中提到了适配器、装饰器和代理模式,所以本章不会再涉及它们。现在,我们将开始实现我们的结构设计模式,首先是桥接模式。

实现桥接模式

我们使用桥接模式来解耦抽象和实现,使它们在编译时不受限制。抽象和实现都可以在不影响客户端的情况下变化。

如果您需要在实现之间进行运行时绑定,或者在多个对象之间共享实现,如果一些类由于接口耦合和各种实现而存在,或者需要将正交类层次结构映射到一起,则使用桥接设计模式:

桥接设计模式的参与者如下:

  • Abstraction:包含抽象操作的抽象类

  • RefinedAbstraction:继承Abstraction类并重写Operation()方法

  • Implementor:一个带有抽象Operation()方法的抽象类

  • ConcreteImplementor:继承Implementor类并重写Operation()方法

现在我们将实现桥接设计模式:

  1. 首先将StructuralDesignPatterns文件夹添加到项目中,然后在该文件夹中添加Bridge文件夹。然后,添加Implementor类:
public abstract class Implementor {
    public abstract void Operation();
}
  1. Implementor类只有一个名为Operation()的抽象方法。添加Abstraction类:
public class Abstraction {
    protected Implementor implementor;

    public Implementor Implementor {
        set => implementor = value;
    }

    public virtual void Operation() {
        implementor.Operation();
    }
}
  1. Abstraction类有一个受保护的字段,保存着Implementor对象,该对象是通过Implementor属性设置的。一个名为Operation()的虚方法调用了实现者的Operation()方法。添加RefinedAbstraction类:
public class RefinedAbstraction : Abstraction {
    public override void Operation() {
        implementor.Operation();
    }
}
  1. RefinedAbstraction类继承了Abstraction类,并重写了Operation()方法以调用实现者的Operation()方法。现在,添加ConcreteImplementor类:
public class ConcreteImplementor : Implementor {
    public override void Operation() {
        Console.WriteLine("Concrete operation executed.");
    }
}
  1. ConcreteImplementor类继承了Implementor类,并重写了Operation()方法以在控制台打印消息。运行桥接设计模式示例的代码如下:
var abstraction = new RefinedAbstraction();
abstraction.Implementor = new ConcreteImplementor();
abstraction.Operation();

我们创建一个新的RefinedAbstraction实例,然后将其实现者设置为ConcreteImplementor的新实例。然后,我们调用Operation()方法。我们示例桥接实现的输出如下:

正如您所看到的,我们成功地在具体实现者类中执行了具体操作。我们接下来要看的模式是组合设计模式。

实现组合模式

使用组合设计模式,对象由树结构组成,以表示部分-整体的层次结构。这种模式使您能够以统一的方式处理单个对象和对象的组合。

当您需要忽略单个对象和对象组合之间的差异,需要树结构来表示层次结构,以及需要在整个结构中具有通用功能时,请使用此模式:

组合设计模式的参与者如下:

  • Component:组合对象接口

  • Leaf:组合中没有子节点的叶子

  • Composite:存储子组件并执行操作

  • Client:通过组件接口操纵组合和叶子

现在是时候实现组合模式了:

  1. StructuralDesignPatterns类中添加一个名为Composite的新文件夹。然后,添加IComponent接口:
public interface IComponent {
    void PrintName();
}
  1. IComponent接口有一个方法,将由叶子和组合实现。添加Leaf类:
public class Leaf : IComponent {
    private readonly string _name;

    public Leaf(string name) {
        _name = name;
    }

    public void PrintName() {
        Console.WriteLine($"Leaf Name: {_name}");
    }
}
  1. Leaf类实现了IComponent接口。它的构造函数接受一个名称并存储它,PrintName()方法将叶子的名称打印到控制台窗口。添加Composite类:
public class Composite : IComponent {
    private readonly string _name;
    private readonly List<IComponent> _components;

    public Composite(string name) {
        _name = name;
        _components = new List<IComponent>();
    }

    public void Add(IComponent component) {
        _components.Add(component);
    }

    public void PrintName() {
        Console.WriteLine($"Composite Name: {_name}");
        foreach (var component in _components) {
            component.PrintName();
        }
    }
}
  1. Composite类以与叶子相同的方式实现IComponent接口。此外,Composite通过Add()方法存储添加的组件列表。它的PrintName()方法打印出自己的名称,然后是列表中每个组件的名称。现在,我们将添加代码来测试我们的组合设计模式实现:
var root = new Composite("Classification of Animals");
var invertebrates = new Composite("+ Invertebrates");
var vertebrates = new Composite("+ Vertebrates");

var warmBlooded = new Leaf("-- Warm-Blooded");
var coldBlooded = new Leaf("-- Cold-Blooded");
var withJointedLegs = new Leaf("-- With Jointed-Legs");
var withoutLegs = new Leaf("-- Without Legs");

invertebrates.Add(withJointedLegs);
invertebrates.Add(withoutLegs);

vertebrates.Add(warmBlooded);
vertebrates.Add(coldBlooded);

root.Add(invertebrates);
root.Add(vertebrates);

root.PrintName();
  1. 如您所见,我们创建了我们的组合,然后创建了我们的叶子。然后,我们将叶子添加到适当的组合中。然后,我们将我们的组合添加到根组合中。最后,我们调用根组合的PrintName()方法,它将打印根的名称,以及层次结构中所有组件和叶子的名称。您可以看到输出如下:

我们的组合实现符合预期。我们将实现的下一个模式是外观设计模式。

实现外观模式

外观模式旨在使使用 API 子系统更容易。使用此模式将大型复杂系统隐藏在更简单的接口后,以供客户端使用。程序员实现此模式的主要原因是,他们必须使用或处理的系统过于复杂且非常难以理解。

采用此模式的其他原因包括如果太多类相互依赖,或者仅仅是因为程序员无法访问源代码:

外观模式中的参与者如下:

  • Facade:简单的接口,充当客户端和子系统更复杂系统之间的中间人

  • 子系统类:子系统类直接从客户端访问中移除,并且由外观直接访问

现在我们将实现外观设计模式:

  1. StructuralDesignPatterns文件夹中添加一个名为Facade的文件夹。然后,添加SubsystemOneSubsystemTwo类:
public class SubsystemOne {
    public void PrintName() {
        Console.WriteLine("SubsystemOne.PrintName()");
    }
}

public class SubsystemOne {
    public void PrintName() {
        Console.WriteLine("SubsystemOne.PrintName()");
    }
}
  1. 这些类有一个单一的方法,将类名和方法名打印到控制台窗口。现在,让我们添加Facade类:
public class Facade {
    private SubsystemOne _subsystemOne = new SubsystemOne();
    private SubsystemTwo _subsystemTwo = new SubsystemTwo();

    public void SubsystemOneDoWork() {
        _subsystemOne.PrintName();
    }

    public void SubsystemTwoDoWork() {
        _subsystemTwo.PrintName();
    }
}
  1. Facade类为其了解的每个系统创建成员变量。然后,它提供一系列方法,当请求时将访问各个子系统的各个部分。我们将添加代码来测试我们的实现:
var facade = new Facade();
facade.SubsystemOneDoWork();
facade.SubsystemTwoDoWork();
  1. 我们只需创建一个Facade变量,然后我们可以调用执行子系统中的方法调用的方法。您应该看到以下输出:

现在是时候看看我们最后的结构模式,即享元模式。

实现享元模式

享元设计模式用于通过减少总体对象数量来高效处理大量细粒度对象。使用此模式可以通过减少创建的对象数量来提高性能并减少内存占用:

享元设计模式中的参与者如下:

  • Flyweight:为享元提供接口,以便它们可以接收外在状态并对其进行操作

  • ConcreteFlyweight:可共享的对象,为内在状态添加存储

  • UnsharedConcreteFlyweight:当享元不需要共享时使用

  • FlyweightFactory:正确管理享元对象并适当共享它们

  • Client:维护享元引用并计算或存储享元的外在状态

外在状态意味着它不是对象的基本特性的一部分,它是外部产生的。内在状态意味着状态属于对象并且对对象是必不可少的。

让我们实现享元设计模式:

  1. 首先在StructuralDesignPatters文件夹中添加Flyweight文件夹。现在,添加Flyweight类:
public abstract class Flyweight {
    public abstract void Operation(string extrinsicState);
}
  1. 这个类是抽象的,并包含一个名为Operation()的抽象方法,该方法传入了享元的外部状态:
public class ConcreteFlyweight : Flyweight
{
    public override void Operation(string extrinsicState)
    {
        Console.WriteLine($"ConcreteFlyweight: {extrinsicState}");
    }
}
  1. ConcreteFlyweight类继承了Flyweight类并重写了Operation()方法。该方法输出方法名及其外部状态。现在,添加FlyweightFactory类:
public class FlyweightFactory {
    private readonly Hashtable _flyweights = new Hashtable();

    public FlyweightFactory()
    {
        _flyweights.Add("FlyweightOne", new ConcreteFlyweight());
        _flyweights.Add("FlyweightTwo", new ConcreteFlyweight());
        _flyweights.Add("FlyweightThree", new ConcreteFlyweight());
    }

    public Flyweight GetFlyweight(string key) {
        return ((Flyweight)_flyweights[key]);
    }
}
  1. 在我们特定的享元示例中,我们将享元对象存储在哈希表中。在我们的构造函数中创建了三个享元对象。我们的GetFlyweight()方法从哈希表中返回指定键的享元。现在,添加客户端:
public class Client
{
    private const string ExtrinsicState = "Arbitary state can be anything you require!";

    private readonly FlyweightFactory _flyweightFactory = new FlyweightFactory();

    public void ProcessFlyweights()
    {
        var flyweightOne = _flyweightFactory.GetFlyweight("FlyweightOne");
        flyweightOne.Operation(ExtrinsicState);

        var flyweightTwo = _flyweightFactory.GetFlyweight("FlyweightTwo");
        flyweightTwo.Operation(ExtrinsicState);

        var flyweightThree = _flyweightFactory.GetFlyweight("FlyweightThree");
        flyweightThree.Operation(ExtrinsicState);
    }
}
  1. 外部状态可以是任何你需要的东西。在我们的示例中,我们使用了一个字符串。我们声明了一个新的享元工厂,添加了三个享元,并对每个享元执行了操作。让我们添加代码来测试我们对享元设计模式的实现:
var flyweightClient = new StructuralDesignPatterns.Flyweight.Client();
flyweightClient.ProcessFlyweights();
  1. 该代码创建了一个新的Client实例,然后调用了ProcessFlyweights()方法。您应该会看到以下内容:

好了,结构模式就介绍到这里。现在是时候来看看如何实现行为设计模式了。

行为设计模式概述

作为程序员,您在团队中的行为受您的沟通和与其他团队成员的互动方式的影响。我们编程的对象也是如此。作为程序员,我们通过使用行为模式来确定对象的行为和与其他对象的通信方式。这些行为模式如下:

  • 责任链:一系列处理传入请求的对象管道。

  • 命令:封装了将在对象内部某个时间点用于调用方法的所有信息。

  • 解释器:提供对给定语法的解释。

  • 迭代器:使用此模式按顺序访问聚合对象的元素,而不暴露其底层表示。

  • 中介者:使用此模式让对象通过中介进行通信。

  • 备忘录:使用此模式来捕获和保存对象的状态。

  • 观察者:使用此模式来观察并被通知被观察对象状态的变化。

  • 状态:使用此模式在对象状态改变时改变对象的行为。

  • 策略:使用此模式来定义一系列可互换的封装算法。

  • 模板方法:使用此模式来定义一个算法和可以在子类中重写的步骤。

  • 访问者:使用此模式向现有对象添加新操作而无需修改它们。

由于本书的限制,我们没有足够的页面来涵盖行为设计模式。鉴于此,我将指导您阅读以下书籍,以进一步了解设计模式。第一本书名为《C#设计模式:实例指南》,作者是 Vaskaring Sarcar,由 Apress 出版。第二本书名为《.NET 设计模式:C#和 F#中的可重用方法》,作者是 Dmitri Nesteruk,也由 Apress 出版。第三本书名为《使用 C#和.NET Core 的设计模式实战》,作者是 Gaurav Aroraa 和 Jeffrey Chilberto,由 Packt 出版。

在这些书籍中,您不仅将了解所有的模式,还将获得真实世界示例的经验,这将帮助您从仅仅拥有理论知识转变为具有实际技能,能够在自己的项目中以可重用的方式使用设计模式。

这就是我们对设计模式实现的介绍。在总结我们所学到的知识之前,我将给您一些关于清晰代码和重构的最终思考。

最后的思考

软件开发有两种类型——brownfield 开发greenfield 开发。我们职业生涯中大部分时间都在进行 brownfield 开发,即维护和扩展现有软件,而 greenfield 开发则是新软件的开发、维护和扩展。在 greenfield 软件开发中,你有机会从一开始就编写清晰的代码,我鼓励你这样做。

确保在开始工作之前对项目进行适当规划。然后,利用可用的工具自信地开发清晰的代码。在进行 brownfield 开发时,最好花时间彻底了解系统,然后再进行维护或扩展。不幸的是,你可能并不总能有这样的时间。因此,有时你会开始编写你需要的代码,却没有意识到已经存在可以执行你正在实现的任务的代码。保持你编写的代码清晰和结构良好,将使项目后期的重构更加容易。

无论你正在进行的项目是 brownfield 还是 greenfield 项目,你都要确保遵循公司的程序。这些程序存在是有充分理由的,即开发团队之间的和谐以及清晰的代码库。当你在代码库中遇到不清晰的代码时,应立即考虑进行重构。

如果代码太复杂而无法立即更改,且需要跨层进行太多更改,那么这些更改必须被记录为项目中的技术债务,待适当规划后再进行处理。

在一天结束时,无论你自称自己是软件架构师、软件工程师、软件开发人员,或者其他任何称谓,你的编程技能才是你的生计。糟糕的编程可能对你目前的职位有害,甚至可能对你找到新职位产生负面影响。因此,尽一切资源确保你当前的代码给人留下持久的良好印象,展现你的能力水平。我曾听人说过以下话:

"你的最后一个编程任务决定了你的水平!"

在架构系统时,不要过于聪明,不要构建过于复杂的系统。将程序的继承深度控制在 1 以内,并尽力通过利用 LINQ 等函数式编程技术来减少循环。

你在第十三章中看到了,重构 C#代码——识别代码异味,LINQ 比foreach循环更高效。尽量减少软件的复杂性,限制计算机程序从开始到结束的路径数量。通过在编译时移除可以编织到代码中的样板代码,减少样板代码的数量。这样可以将方法中的行数减少到仅包含必要业务逻辑的行数。保持类小而专注于单一职责。同时,保持方法的代码行数不超过 10 行。类和方法必须只执行单一职责。

学会保持你编写的代码简单,以便易于阅读和理解。理解你所编写的代码。如果你能轻松理解自己的代码,那就没问题。现在,问问自己:在另一个项目上工作后回到这个项目,你是否仍能轻松理解代码?当代码难以理解时,就必须进行重构和简化。

不这样做可能会导致一个臃肿的系统,最终慢慢而痛苦地死去。使用文档注释来记录公开可访问的代码。对于隐藏的代码,只有在代码本身无法充分解释时才使用简洁而有意义的注释。对于经常重复的常见代码,使用模式以避免重复(DRY)。Visual Studio 2019 中的缩进是自动的,但默认的缩进在不同的文档类型中并不相同。因此,确保所有文档类型具有相同级别的缩进是一个好主意。使用微软建议的标准命名规范。

给自己一些编程挑战,不要复制粘贴他人的源代码。使用基准测试(性能分析)来重写相同的代码,以减少处理时间。经常测试你的代码,确保它表现正常并完成了它应该完成的任务。最后,练习,练习,然后再练习。

我们都会随着时间改变自己的编程风格。如果在一个采用了许多不良实践的团队中,一些程序员的代码会随着时间的推移而恶化。而另一些程序员的代码会随着时间的推移而改善,如果他们在一个采用了许多最佳实践的团队中。不要忘记,仅仅因为代码能编译并且能够完成其预期功能,并不一定意味着它是最清晰或者最高效的代码。

作为一名计算机程序员,你的目标是编写清晰高效的代码,易于阅读、理解、维护和扩展。练习实施 TDD 和 BDD,以及 KISS、SOLID、YAGNI 和 DRY 的软件范式。

考虑从 GitHub 上检出一些旧的代码,作为将旧的.NET 版本迁移到新的.NET 版本的培训机会,并重构代码以使其清晰高效,并添加文档注释以为开发团队生成 API 文档。这对磨练个人计算机编程技能是一个很好的实践。通过这样做,你经常会遇到一些相当聪明的代码,可以从中学习。有时,你可能会想知道程序员当时在想什么!但无论如何,利用每一个机会来提高你的清晰编码技能只会使你变得更强大、更优秀的程序员。

我相信编程领域的另一句话是:

“要成为真正的专业计算机程序员,你必须超越目前的能力。”

因此,无论你或你的同行认为你有多么专业,永远记住你可以做得更好。因此,不断前进,提高自己的水平。然后,当你退休时,你可以以一名计算机程序员的辉煌成就为荣,回顾你的职业生涯!

现在让我们总结一下我们在本章学到的内容。

总结

在本章中,我们涵盖了几种创建型、结构型和行为型设计模式。你利用本章学到的知识来查看遗留代码并理解其目标。然后,你使用本章学到的模式来重构现有代码,使其更易于阅读、理解、维护和扩展。通过使用本书中的模式以及其他可用的模式,你可以重构现有代码并从一开始编写清晰的代码。

你还使用了创建型设计模式来解决现实世界的问题,并提高了代码的效率。使用结构型设计模式来改善代码的整体结构和对象之间的关系。此外,使用行为设计模式来改善对象之间的通信,同时保持这些对象的解耦。

好吧,这是本章的结束,我感谢你抽出时间阅读这本书并通过代码示例进行学习。记住,软件应该是一种愉悦的工作。因此,我们不需要不洁净的代码给我们的业务、开发和支持团队以及软件的客户带来问题。因此,请考虑你正在编写的代码,并始终努力成为比今天更好的程序员——无论你在这个行业已经工作了多少年。有一句古话:无论你有多优秀,你总是可以做得更好

让我们测试一下你对本章内容的了解,然后我会给你一些进一步阅读的建议。祝你在 C#中编写干净的代码!

问题

  1. GoF 模式是什么,为什么我们要使用它们?

  2. 解释创建设计模式的用途并列举它们。

  3. 解释结构设计模式的用途并列举它们。

  4. 解释行为设计模式的用途并列举它们。

  5. 是否可能过度使用设计模式并称之为代码异味?

  6. 描述单例设计模式以及何时使用它。

  7. 为什么我们要使用工厂方法?

  8. 你会使用什么设计模式来隐藏一个庞大且难以使用的系统的复杂性?

  9. 如何最小化内存使用并在对象之间共享公共数据?

  10. 用于将抽象与其实现解耦的模式是什么?

  11. 如何构建同一复杂对象的多个表示?

  12. 如果你有一个需要经过多个阶段的操作才能将其转换为所需状态的项目,你会使用什么模式,为什么?

进一步阅读

  • 重构:改善现有代码的设计,作者:Martin Fowler

  • 规模化的重构,作者:Maude Lemaire

  • 软件开发、设计和编码:使用模式、调试、单元测试和重构,作者:John F. Dooley

  • 软件设计异味的重构,作者:Girish Suryanarayana, Ganesh Samarthyam 和 Tushar Sharma

  • 重构数据库:演进式数据库设计,作者:Scott W. Ambler 和 Pramod J. Sadalage

  • 重构到模式,作者:Joshua Kerievsky

  • C#7 和.NET Core 2.0 高性能,作者:Ovais Mehboob Ahmed Khan

  • 提高你的 C#技能,作者:Ovais Mehboob Ahmed Khan, John Callaway, Clayton Hunt 和 Rod Stephens

  • 企业应用架构模式,作者:Martin Fowler

  • 与遗留代码的有效工作,作者:Michael C. Feathers

  • www.dofactory.com/products/dofactory-net:dofactory 提供的用于 RAD 的 C#设计模式框架

  • 使用 C#和.NET Core 的设计模式实践,作者:Gaurav Aroraa 和 Jeffrey Chilberto

  • 使用 C#和.NET Core 的设计模式,作者:Dimitris Loukas

  • C#中的设计模式:实际示例指南,作者:Vaskaring Sarcar

第十五章:评估

第一章

  1. 糟糕代码的一个结果是,你可能最终得到一段非常糟糕的难以理解的代码。这往往会导致程序员压力和软件出现错误,难以维护、测试和扩展。

  2. 好代码的一个结果是它易于阅读和理解,因为你知道程序员的意图。这会减轻程序员在调试、测试和扩展代码时的压力。

  3. 当你将一个大项目分解成模块化的组件和库时,每个模块可以由不同的团队同时进行工作。小模块易于测试、编码、文档化、部署、扩展和维护。

  4. DRY代表Don't Repeat Yourself。寻找可重复的代码,并重构它,以便删除重复的代码。这样做的好处是程序更小,因为如果这样的代码包含错误,你只需要在一个地方进行更改。

  5. KISS 意味着简单的代码不会让程序员困惑,特别是如果你的团队中有初级程序员。KISS 代码易于阅读和编写测试。

  6. SSingle Responsibility PrincipleOOpen/Closed PrincipleLLiskov SubstitutionIInterface Segregation PrincipleDDependency Inversion Principle

  7. YAGNIYou Aren't Going to Need It的缩写。换句话说,不要添加不需要的代码。只添加绝对需要的代码,不要多余。

  8. 奥卡姆剃刀原则是指:实体不应该被无必要地增加。 只处理事实。只有在绝对必要的情况下才做假设

第二章

  1. 同行代码审查中的两个角色是审阅者和被审阅者。

  2. 项目经理同意参与同行代码审查的人员。

  3. 在请求同行代码审查之前,通过确保你的代码和测试都能正常工作,对项目进行代码分析并修复任何问题,以及确保你的代码符合公司的编码准则,可以节省审阅者的时间和精力。

  4. 在审查代码时,要注意命名、格式、编程风格、潜在错误、代码和测试的正确性、安全性和性能问题。

  5. 反馈的三个类别是积极的、可选的和关键的。

第三章

  1. 我们可以将我们的代码放在文件夹结构中的单独源文件中,并将类、接口、结构和枚举包装在映射到文件夹结构的命名空间中。

  2. 一个类应该只有一个职责。

  3. 你可以在代码中添加注释,用于文档生成器,放置在要记录的公共成员的正上方的 XML 注释。

  4. 内聚性是将处理相同职责的代码逻辑分组在一起。

  5. 耦合指的是类之间的依赖关系。

  6. 内聚性应该很高。

  7. 耦合应该很低。

  8. 你可以使用 DI 和 IoC 来设计变更。

  9. DI代表Dependency Injection

  10. IoC代表Inversion of Control

  11. 不可变对象是类型安全的,因此可以在线程之间安全地传递。

  12. 对象应该暴露方法和属性,并隐藏数据。

  13. 数据结构应该暴露数据,不应该有方法。

第四章

  1. 没有参数的方法称为 niladic 方法。

  2. 只有一个参数的方法称为单元方法。

  3. 具有两个参数的方法称为二元方法。

  4. 具有三个参数的方法称为三元方法。

  5. 具有三个以上参数的方法称为多元方法。

  6. 你应该避免重复的代码。这不是一种有效的编程方式,会使程序变得不必要地庞大,并有可能在整个代码库中扩散相同的异常。

  7. 函数式编程是一种将计算视为不修改状态的数学计算的软件编码方法。

  8. 函数式编程的优势包括在多线程应用中的安全代码和更小、更有意义的易于阅读和理解的方法。

  9. 输入和输出对于函数式程序可能会产生问题,因为它依赖于副作用。函数式编程不允许副作用。

  10. WET 代码是 DRY 的反义词,因为每次需要时都会编写代码。这会产生重复,并且相同的异常可能会在程序的多个位置发生,使得维护和支持更加困难。

  11. DRY 代码是 WET 的反义词,因为代码只会被写一次,并在需要时被重复使用。这减少了代码库和异常足迹,使得程序更易于阅读和维护。

  12. 通过重构来消除重复的代码,使 WET 代码变 DRY。

  13. 长方法笨重且容易出现异常。它们越小,阅读和维护就越容易。程序员引入逻辑错误的机会也更小。

  14. 为了避免使用 try/catch 块,你可以编写参数验证器。然后在方法的开头调用验证器。如果参数未通过验证,则会抛出适当的异常,并且方法不会被执行。

第五章

  1. 已检查的异常是在编译时检查的异常。

  2. 未经检查的异常是在编译时未经检查或简单忽略的异常。

  3. 当高阶位无法分配给目标类型时,会引发溢出异常。在检查模式下,会引发OverflowException。在未经检查的模式下,无法分配的高阶位会被简单忽略。

  4. 尝试访问空对象的属性或方法。

  5. 实现一个Validator类和一个Attribute类,检查参数是否为空,并抛出ArgumentNullException。你会在方法的开头使用Validator类,这样在方法执行到一半之前就会引发异常。

  6. 业务规则异常BRE)。

  7. BREs 是一种不好的做法,因为它们期望异常被引发以控制程序流程。

  8. 正确的编程不应该通过期望异常作为输出来控制计算机程序的流程。因此,鉴于 BRE 是不好的,因为它们期望异常输出并用它来控制程序流程,更好的解决方案是使用条件编程。在条件程序中,你使用布尔逻辑。布尔逻辑允许两种可能的执行路径,并且不会引发异常。条件检查是显式的,并且使程序更易于阅读和维护。你还可以轻松地扩展这样的代码,而对于 BRE,你无法这样做。

  9. 首先,从已知的异常类型开始进行错误捕获,比如使用 Microsoft .NET Framework 中已知的异常类型ArgumentNullExceptionsOverflowExceptions。但是当这些不够用,并且不能为特定情况提供足够的数据时,你会编写并使用自定义异常,并应用有意义的异常消息。

  10. 你的自定义异常必须继承自System.Exception,并实现三个构造函数:默认构造函数,接受文本消息的构造函数,以及接受文本消息和内部异常的构造函数。

第六章

  1. 一个好的单元测试必须是原子的、确定性的、可重复的和快速的。

  2. 一个好的单元测试不应该是不确定的。

  3. 测试驱动开发。

  4. 行为驱动开发。

  5. 一个小的代码单元,其唯一目的是测试只执行一件事的单个代码单元。

  6. 单元测试使用的虚假对象,用于测试真实对象的公共方法和属性,但不测试方法或属性的依赖关系。

  7. 一个虚假对象与一个模拟对象相同。

  8. MSTest、NUnit 和 xUnit。

  9. Rhino Mocks 和 Moq。

  10. SpecFlow。

  11. 不必要的注释、死代码和冗余测试。

第七章

  1. 从头到尾测试完整系统。这可以手动、自动或两种方法结合进行。

  2. 集成测试。

  3. 所有功能的手动测试,所有单元测试都应通过,并且我们应编写自动化测试来测试两个模块之间传递的命令和数据。

  4. 工厂是实现工厂方法模式的类,其意图是允许创建对象而不指定它们的类。我们会在以下情况下使用它们:

  5. 该类无法预测必须实例化的对象类型。

  6. 子类必须指定要实例化的对象类型。

  7. 类控制其对象的实例化。

  8. DI 是一种产生松散耦合代码的方法,易于维护和扩展。

  9. 使用容器可以轻松管理依赖对象。

第八章

  1. 线程是一个进程。

  2. 一个。

  3. 后台线程和前台线程。

  4. 后台线程。

  5. 前台线程。

  6. Thread.Sleep(500);

  7. var thread = new Thread(Method1);

  8. IsBackground设置为true

  9. 死锁是两个线程被阻塞并等待另一个线程释放资源的情况。

  10. Monitor.Exit(objectName);

  11. 使用相同资源的多个线程根据每个线程的时间产生不同的输出。

  12. 使用 TPL 与ContinueWith(),并使用Wait()在退出方法之前等待任务完成。

  13. 使用其他方法共享的成员变量,并传递引用变量。

  14. 是的。

  15. 线程池。

  16. 它是一种一旦构建就无法修改的对象。

  17. 它们允许您在线程之间安全共享数据。

第九章

  1. 应用程序编程接口。

  2. 表述性状态转移。

  3. 统一接口、客户端-服务器、无状态、可缓存、分层系统、可选可执行代码。

  4. 超媒体作为应用状态的引擎HATEOAS)。

  5. RapidApi.com。

  6. 授权和认证。

  7. 声明是实体对自身的陈述。然后根据数据存储对这些声明进行验证。它们在基于角色的安全性中特别有用,用于检查作出声明的实体是否对该声明有授权。

  8. 发出 API 请求并检查它们的响应。

  9. 因为您可以根据需求更改数据存储。

第十章

  1. 将软件正确分区为逻辑命名空间、接口和类,有助于测试软件。

  2. 通过了解 API,您可以通过不重复发明轮子并编写已经存在的代码来简化代码并使其保持干燥。这样可以节省时间、精力和金钱。

  3. 结构。

  4. 第三方 API 由软件开发人员编写,因此容易出现引入错误的人为错误。通过测试第三方 API,您可以确信它们按预期工作,如果不是,则可以修复代码或为其编写包装器。

  5. 您的 API 容易出错。通过根据规范及其验收标准测试它们,您可以确保以商业期望的质量水平交付准备进行公开发布。

  6. 规范和验收标准提供了正常程序流程。通过它们,您可以确定在执行的正常流程方面进行测试,并确定将遇到什么异常情况并对其进行测试。

  7. 命名空间、接口和类。

第十一章

  1. 横切关注点是不属于核心关注点的业务需求的关注点,但必须在代码的所有领域中进行处理。AOP代表面向切面编程

  2. 方面是应用于类、方法、属性或参数时,在编译时注入代码的属性。您在应用属性之前在方括号中应用属性。

  3. 属性为项目赋予语义含义。您在应用属性之前在方括号中应用属性。

  4. 属性赋予代码语义含义,而方面则消除样板代码,使其在编译时注入。

  5. 当代码正在构建时,编译器将插入切面隐藏程序员的样板代码。这个过程被称为代码编织。

第十二章

  1. 代码度量是几个源代码测量,使我们能够确定软件的复杂程度和可维护性。这些测量使我们能够确定可以通过重构使代码更简单和更易维护的代码区域。

  2. 圈复杂度,可维护性指数,继承深度,类耦合,源代码行数和可执行代码行数。

  3. 代码分析是对源代码的静态分析,目的是识别设计缺陷,全球化问题,安全问题,性能问题和互操作性问题。

  4. 快速操作是由螺丝刀或灯泡标识的单个命令,它将抑制警告,添加使用语句,导入缺少的库并添加使用语句,纠正错误,并实现旨在简化代码并减少方法中行数的语言使用改进。

  5. JetBrains 的 dotTrace 实用程序是用于对源代码和编译的程序集进行性能分析的工具,以识别软件的潜在问题。您可以执行采样,跟踪,逐行和时间线分析。您可以分析执行时间,线程时间,实时 CPU 指令和线程周期时间。

  6. JetBrains 的 ReSharper 实用程序是一个代码重构工具,它帮助开发人员识别和修复代码问题,并实现语言特性以改进和加快程序员的编程体验。

  7. 源代码的反编译可用于检索丢失的源代码,为调试生成 PDB,并用于学习。您还可以使用反编译器查看您的代码混淆得有多好,以使黑客和其他人难以窃取您的代码秘密。

第十三章

  1. 应用级别,类级别和方法级别。

  2. 布尔盲目,组合爆炸,人为复杂,数据团,除臭剂注释,重复代码,意图丢失,变量突变,古怪解决方案,散弹手术,解决方案蔓延和不受控制的副作用。

  3. 圈复杂度,分歧变更,向下转型,过多的文字使用,特征嫉妒,不当亲密,不雅曝光,大类(也称为上帝对象),懒惰类(也称为吃白食者和懒惰对象),中间人类,变量和常量的孤立类,原始偏执,拒绝遗赠,推测性泛化,告诉,不要问!和临时字段。

  4. 害群之马,圈复杂度,人为复杂,死代码,过多的数据返回,特征嫉妒,标识符大小,不当亲密,长行(也称为上帝行),懒惰方法,长方法(上帝方法),长参数列表(太多参数),消息链,中间人方法,古怪解决方案和推测性泛化。

  5. 使用 LINQ 而不是循环。使类只负责一件事。使方法只做一件事。用参数对象替换长参数列表。使用创建性设计模式来提高昂贵对象的创建和利用效率。保持方法不超过 10 行。使用 AOP 从方法中删除样板代码。解耦对象并使它们可测试。使代码高度内聚。

  6. 代表分支和循环数量的值。

  7. 减少分支和循环的数量,直到圈复杂度值变为 10 或更少。

  8. 使事情变得比必要的更复杂。

  9. 保持简单,愚蠢KISS)。

  10. 用不同的方法和不同的参数组合做同样的事情。

  11. 创建通用方法,可以对不同数据类型执行相同的任务,这样你只需要一个方法和一组参数。

  12. 修复糟糕的代码并删除注释。

  13. 寻求帮助。

  14. 堆栈溢出。

  15. 长参数列表可以用参数对象替换。

  16. 将其重构为只执行一件事的较小方法,并使用 AOP 将样板代码移入方面。

  17. 不超过 10 行。

  18. 0-10;超过这个范围,您就会自找麻烦。

  19. 一。

  20. 未使用的变量、类、属性和方法。摆脱它们。

  21. 选择最佳的实现方法,然后重构代码以只使用该实现方法。

  22. 将临时字段和操作它的方法重构为它们自己的类。

  23. 在不同类中使用相同的变量集。将变量重构为它们自己的类,然后引用该类。

  24. 一个类继承自另一个类,但不使用其所有方法。

  25. 迪米特法则。

  26. 只允许类与其直接邻居交谈。

  27. 一个类或方法在另一个类或方法中花费太多时间。

  28. 将依赖关系重构为它们自己的类或方法。

  29. 工厂方法。

  30. 从基类继承,然后创建从基类继承的新类。

  31. 单一责任在应用程序的不同层中的不同类的不同方法中实现。将责任重构为自己的类,以便它只存在于一个位置。

  32. 数据应该放在操作数据的同一个对象中。

  33. 当您创建一个对象,该对象请求另一个对象的数据,以便它可以对其执行操作。

  34. 单一更改需要在多个位置进行更改。消除重复,消除耦合,提高内聚性。

  35. 失去意图是因为类或方法的原因不清楚,因为有许多不相关的项目被聚集在一起。重构代码,使得所有方法都在正确的类中。这样,类和方法的意图就变得清晰了。

  36. 您可以使用 LINQ 查询重构循环。LINQ 是一种不改变位置变量并且比循环执行得快得多的函数语言。

第十四章

  1. GoFGang-of-Four模式的缩写。这些模式被分为创建、结构和行为设计模式。它们被认为是所有软件设计模式的基础。它们共同工作以产生清晰的面向对象的代码。

  2. 创建模式使抽象和继承能够以面向对象的方式消除代码重复,并在对象创建昂贵时提高性能。创建模式包括抽象工厂、工厂方法、单例、原型和生成器。

  3. 结构模式使对象之间的关系得到正确管理。我们可以使用结构模式使不兼容的接口一起工作,将抽象与其实现解耦,并提高性能。结构模式包括适配器、桥接、组合、装饰器、外观、享元和代理。

  4. 行为模式规定对象如何相互交互和通信。我们可以使用它们来生成管道,封装命令和将来执行的信息,调解对象之间的关系,观察对象的状态变化,等等。行为模式包括责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法和访问者。

  5. 是的。

  6. 单例只允许在应用程序的整个生命周期中存在一个对象实例。该对象对所有需要它的对象都是全局可访问的。当我们需要确保有一个集中的对象创建和对象访问点时,我们使用此模式。

  7. 当我们需要创建对象而不指定要实例化的确切类时,我们使用工厂方法。

  8. 外观。

  9. 使用享元设计模式。

  10. 桥接。

  11. 使用生成器模式。

  12. 您将使用责任链模式,因为您可以拥有一系列处理程序,每个处理程序执行一个任务。如果它们无法处理任务,处理程序将将任务传递给它们的后继者来处理。

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报