参考资料
https://www.testrail.com/blog/highly-testable-code/
低耦合
泛泛而谈的低耦合
想成为百万富翁吗?好吧,请按照以下两个简单步骤操作:
- 做一个人
- 获得一百万美元
我们知道这只是一个笑话,但这正是一些人谈论“低耦合”的方式。他们说:“想编写可维护的代码吗?好吧,保持低耦合。”
有趣的是,他们似乎没有意识到:
- 初学者对“低耦合”的含义一无所知
- 即使他们知道,这也不足以让他们编写实践中的低耦合代码
耦合与低耦合
首先,我们来定义耦合。在软件开发中,这意味着一个给定的软件工件(一个方法、一个类,甚至一个模块)对另一个软件工件的依赖程度。 考虑到这一点,“低耦合”意味着代码的每个部分都应该尽可能少地了解代码的其他部分。
高耦合的问题
当您的代码是高耦合的时,其维护就会变得昂贵。 例如:您编辑一些被广泛使用的方法签名,这会产生连锁反应。 您很快就会意识到,您几乎已经接触了整个应用程序的代码。 让我们看一个简单的例子。假设您正在编写一个包含 ProductService 类的应用程序:
public class ProductService
{
// fields, properties, etc
public void SaveProduct(ProductToAddDTO productDTO)
{
Product product = MapToEntity(productDTO);
productRepository.Add(product);
productRepository.SaveChanges();
}
// more methods
}
上面的代码只是一个玩具。请允许我在这里发挥你的想象力来填写缺少的相关代码。 是的,代码工作正常,但现在有人决定它需要日志记录。你说,这很公平,然后你将代码更改为如下所示:
productRepository.Add(product);
productRepository.SaveChanges();
var logger = new FileLogger(@"logsapp-demo.log");
logger.Info($"The product with Id {product.Id} was saved.");
上面的代码有错吗?嗯,它可能有效,但有问题。上面的代码与 FileLogger 类紧密耦合。它需要了解得太多了。首先,它知道FileLogger类确实存在。 未来需求完全有可能(甚至很可能)再次发生变化,就像刚刚发生变化一样。两个月后,有人可能会决定代码应该记录到数据库而不是文件。 或者更糟糕的是,除了记录到文件之外,还记录到数据库。上面的代码还知道 FileLogger 的构造函数需要路径。 如果将来它也开始需要一个布尔标志来指示如果文件不存在是否应该创建该文件怎么办? 文件路径。路径、数据库字符串连接、XML 配置文件等都属于我所说的基础设施级别;业务代码逻辑执行中不需要知道。 哦,当然,上面的代码只是一个简单的例子。在一个规模很大的应用程序中,您可能有数百行引用 FileLogger 的代码。 每次开发人员对其进行更改时,都可能需要花费数小时的开发工作。
如何低耦合
有一个已知的解决这个问题的方法,它被称为依赖注入(DI)。 DI 包括通过其构造函数以接口的形式传递类所需的依赖项。 让我们重写示例,使用 DI 创建低耦合代码。首先,我们需要一个ILogger接口:
public interface ILogger
{
void Debug(string entry);
void Trace(string entry);
void Info(string entry);
void Warning(string entry);
void Error(string entry);
}
接口声明就位后,我们现在准备根据需要编写接口的 n 个实现。这里就不具体描述代码了。 最后,让我们编辑 ProductService 类:
public class ProductService
{
// fields, properties, etc
public ProductService(IProductRepository repo, ILogger logger)
{
this.repo = repo ?? throw new ArgumentNullException(nameof(repo));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void SaveProduct(ProductToAddDTO productDTO)
{
Product product = MapToEntity(productDTO);
this.repo.Add(product);
this.repo.SaveChanges();
this.logger.Info($"The product with Id {product.Id} was saved.");
}
// more methods
}
您肯定已经注意到,我不仅通过 ProductService 的构造函数注入记录器,而且还注入存储库 IProductRepository。 同样的推理也适用:只要实体被持久化,您的业务规则就不应该关心它们被持久化到哪里。 这样,ProductService 类现在完全可以进行单元测试,无需接触数据库或文件系统。
纯代码和非纯代码之间的明确区分
纯代码
这个概念是函数式编程范式不可或缺的一部分。面向对象的程序员也应该理解并利用它。 当我们谈论纯代码时,我们实际上是在谈论函数的纯粹性。 简而言之,纯函数是一种既不消耗也不产生副作用的函数。通过一个例子就会变得更清楚。考虑下面的代码:
public int Sum(int a, int b)
{
return a + b;
}
将两个数字相加感觉像是我能想到的最懒的例子,但它是纯函数的完美例证。
- 它可以访问的唯一数据是作为参数获取的值
- 它不会引起任何外部变化。它不会导致数据库、文件系统或任何外部现实发生变化。屏幕上没有显示任何内容,也没有在纸上打印任何内容。它不会在任何地方改变任何变量
非纯代码
一个不纯函数的示例:
public string GetGreeting()
{
var now = DateTime.Now;
var hour = now.Hour;
var greeting = string.Empty;
if (hour >= 6 && hour < 12)
{
greeting = "Good morning!";
}
else if (hour >= 12 && hour < 18)
{
greeting = "Good afternoon!";
}
else
{
greeting = "Good evening!";
}
greeting += " Today is " + now.ToString("D");
}
GetGreeting 方法绝对是不纯粹的。为什么?它从参数以外的源访问数据(参数甚至不存在)。 换句话说,这些差异可能看起来不多,但这些差异的后果可能是巨大的。
纯代码和非纯代码的差异
纯函数是确定性的。对于给定的输入,它将始终返回相同的输出。这使它安全。你可以放心地调用它一百万次,任何地方的任何事情都不会因此而改变。 纯函数本质上是可测试的。 相反,考虑一下您将如何测试非纯代码的 GetGreeting 函数。 Assert.AreEquals(“这里怎么描述期望输出呢???”, obj.GetGreeting()); 这绝对是可能的,甚至有多种方法可用,但这需要一些思考。
逻辑与表示的分离
对于任何一个已经从事该行业多年的值得信赖的软件开发人员来说,这一点确实不应该感到惊讶。 业务逻辑和表示之间的明确分离是一个值得追求的目标,即使您没有考虑可测试性。 保持这两个问题之间的严格分离允许您将用户界面层视为插件,根据需要将一种类型的界面替换为另一种类型的界面。 或者您可以拥有多个 UI,所有 UI 都使用相同的底层 API。 但分离逻辑和表示的真正好处在于测试。当您将所有业务逻辑包含在不关心 UI 等脆弱问题的库中时,您将拥有尽可能快速、健壮和可靠的单元测试。
保持简单
最后,我们来谈谈简单性。我所说的简单并不是一种模糊的、虚假的深刻的方式,这听起来好像在你阅读时有意义,但随后会让你摸不着头脑,想知道到底如何才能以实际的方式应用它。 不,我的意思是非常实用且可衡量的。简单代码是具有低圈复杂度的代码。 简而言之,圈复杂度是给定函数或方法中所有可能的执行分支的数量。具有高圈复杂度的方法将需要大量的测试用例才能正确测试。 如果降低代码的复杂性,代码不仅会变得更易于测试,而且会变得更干净、更可读、更容易维护。