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 2019Windows 10, macOS
AtomWindows 10, macOS, Linux: atom.io/
Azure 资源Azure 订阅:azure.microsoft.com/en-gb/
Azure Key VaultAzure 订阅:azure.microsoft.com/en-gb/
Morningstar APIrapidapi.com/integraatio/api/morningstar1获取您自己的 API 密钥
PostmanWindows 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. 结构应该隐藏和显示什么?

进一步阅读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值