C# 坏实践:通过糟糕代码示例学习如何写出好代码 - 第三部分






4.71/5 (79投票s)
什么时候不应该使用静态类?
本系列上一篇文章
这是我系列文章的第三篇:C# 坏习惯:通过反面例子学习如何写出好代码。
您可以通过以下链接找到前两篇文章
要理解本文,您不必阅读我之前的任何文章,但我鼓励您阅读。:)
文章目标
我想就一个非常有趣的话题表达我的观点
何时不应使用静态类?
在本文中,我将考虑将 `static` 类用作业务逻辑实现的一部分。
我对这个话题有许多不同的看法,因此我决定在此表达我的观点。
与本系列之前的文章一样,我将展示(在我看来)由于使用 `static` 类而导致一些问题的代码,然后我将提出解决这些问题的方案。
读者在开始阅读本文之前应该了解什么
- C# 语言基础知识
- 依赖注入设计模式基础知识
- IOC 容器工作原理基础知识
- 单元测试基础知识
- 模拟框架工作原理基础知识
C# 中的静态类是什么?
微软的说法静态类本质上与非静态类相同,但有一个区别:静态类不能实例化。换句话说,您不能使用 `new` 关键字来创建该类类型的变量。由于没有实例变量,您可以使用类名本身来访问静态类的成员。例如,如果您有一个名为 **UtilityClass** 的静态类,其中有一个名为 **MethodA** 的公共方法,您可以通过以下示例代码调用该方法
UtilityClass.MethodA();
这就是为什么许多开发人员经常使用它们。您无需创建类的实例即可使用它,只需输入一个点即可访问其成员。它快速而清晰,并且可以节省创建对象的时间。
关于 `static` 类有很多规则,但我不会详细阐述。
您可以从 MSDN 网站了解它们
对于本文,`static` 类的两条规则很重要
- `static` 类是 `sealed` 的——您不能继承它
- `static` 类不能实现接口
开始写代码
下面的示例仅用于具体展示问题。发送电子邮件与本文描述的问题无关,它只是展示了错误使用 `static` 类的方式(在我看来 :))。
这是我想作为示例的代码
public class EmailManager
{
public bool SendEmail(int emailId)
{
if (emailId <= 0)
{
Logger.Error("Incorrect emailId: " + emailId);
return false;
}
EmailData emailData = DatabaseManager.GetEmailData(emailId);
if (emailData == null)
{
Logger.Error("Email data is null (emailId: " + emailId + ")");
return false;
}
EmailMessage emailMessage = EmailMessageCreator.CreateEmailMessage(emailData);
if (!ValidateEmailMessage(emailMessage))
{
Logger.Error("Email message is not valid (emailId: " + emailId + ")");
return false;
}
try
{
EmailSender.SendEmail(emailMessage);
Logger.Info("Email was sent!");
}
catch(/* catch only exceptions, which you know that may occur and you can't
do anything to avid them, like: network related error, addressee server refused... etc.*/)
{
Logger.Exception(ex.ToString());
return false;
}
return true;
}
private bool ValidateEmailMessage(EmailMessage emailMessage)
{
if(emailMessage == null) return false;
if (string.IsNullOrEmpty(emailMessage.From) || string.IsNullOrEmpty(emailMessage.To) ||
string.IsNullOrEmpty(emailMessage.Subject) || string.IsNullOrEmpty(emailMessage.Body))
return false;
if (emailMessage.Subject.Length > 255) return false;
return true;
}
}
假设我们有一个发送电子邮件的组件。它从队列(无关紧要是什么实现,可能是 MSMQ、RabbitMQ 等)获取包含消息标识符的消息,创建一封电子邮件,然后将其发送给收件人。
我们有一个 `manager` 类,它以数据库中存储的消息的标识符(`messageId`)作为参数。它有自己的逻辑,例如
- 守卫子句
- 对 `EmailMessageCreator` 返回的消息进行验证——它被分解为一个 `private` 方法——我将其视为 `EmailManager` 类 `SendEmail` 方法的一部分
- 管理程序流程
- 错误处理,因为在发送电子邮件时,您可能会遇到许多已知错误,例如
- 与网络相关的错误、收件人服务器拒绝……等。
并且还会调用其他组件的逻辑,例如
- `DatabaseManager`
static
类——一个负责通过 `messageId` 从数据库获取消息信息的组件 - `EmailMessageCreator`
static
类——一个负责创建要发送的电子邮件消息的组件 - `EmailSender`
static
类——一个负责通过 SMTP 将消息发送给收件人的组件 - `Logger`
static
类——一个负责将信息记录到日志文件的组件
为简化情况,假设以上所有类都像服务一样工作,并且它们的成员没有竞态条件。
这看起来简单明了。它能工作,速度快,而且我们可以轻松快速地实现它。
那么这段代码有什么问题呢??!!
我看到了几个问题。
问题
单元测试
现在想象一下,您想为 `EmailManager` 类编写单元测试,所以您想测试 `SendEmail` 方法自身的逻辑:例如
- 检查返回值,如果
- 输入不正确
- `CreateEmailMessage` 返回无效数据
- 检查被测方法是否抛出异常,如果
- `CreateEmailMessage` 方法抛出异常
- `GetEmailData` 方法抛出异常
- `EmailSender` 类中的 `SendEmail` 方法抛出异常
- 检查 `Logger` 的正确方法是否以正确的参数调用,在情况下
- 输入不正确
- `EmailSender` 类中的 `SendEmail` 方法抛出了异常
- 电子邮件已成功发送
即使相关的类更改了它们的行为,这种行为也不应该改变。
您无法对该类进行单元测试,因为它硬依赖于其他类,例如
DatabaseManager
EmailMessageCreator
EmailSender
Logger
这有什么问题?
在测试 `SendEmail` 时,您还将测试它们的代码。这将是集成测试。简单来说,例如 `CreateEmailMessage` 中的一个 bug 不应该影响 `SendEmail` 方法的测试结果。
此外,在测试 `EmailSender` 类中的 `SendEmail` 方法的自身代码时,您不想调用数据库并发送真实的电子邮件,因为您没有测试它们,而且单元测试必须快速!
另一件事是,您无法在测试中检查 `Logger` 类是否被调用,因为您无法模拟 `Logger` 类。
重要提示
在 Wonde Tadesse 的评论之后,我同意您可以使用 Microsoft Fakes 框架来对(依赖于静态类的版本)`EmailSender` 类中的 `SendEmail` 方法进行单元测试。但是
- (在原始版本中依赖于静态类)您仍然违反了 SOLID 原则中的依赖倒置原则,所以这是一个坏习惯——`EmailManager` 决定采用哪个相关类的实现。
- 此功能仅在最高版本的 Visual Studio(Premium、Ultimate 或 Enterprise)中可用。您的公司现在依赖于特定版本的 Visual Studio。这算好习惯吗?
即使您的公司为每个开发人员都购买了最高版本的 VS(非常罕见且不太可能),如果公司决定将 Visual Studio 版本降级到 Professional 呢?您的测试将失败,您最终还是需要重构代码。这是个好习惯吗?
未来的更改
现在想象一下,一段时间后,`EmailSender` 类需要 `EmailMessageCreator` 的行为略有不同。所以您只想更改当前 `EmailMessageCreator` 代码的 10%,其余部分保持不变。
但同时,您不想破坏为此类编写的单元测试,并且您想遵循 SOLID 原则中的开放/封闭原则。
重要提示
维基百科关于开放/封闭原则的说法软件实体(类、模块、函数等)应该对扩展开放,对修改关闭
我们希望同时保留 `EmailMessageCreator` 和 `EmailMessageCreatorExtended` 类,因为还有另一个 `EmailMessageCreator` 的使用者希望继续使用其旧版本而不进行修改。
我们也不想更改调用者,在我们示例中是 `EmailManager` 类。
不幸的是,我们无法实现这一点,因为
- 如果我们想保留 `EmailMessageCreator` 的当前实现并仅对其进行扩展,最好的选择是创建一个 `EmailMessageCreatorExtended` 类(请原谅我这个名字,只是为了区分它们),它将继承自 `EmailMessageCreator` 类。我们不能这样做,因为静态类是 sealed 的,所以您不能继承它们。
- 您也不能替换 `EmailMessageCreator` 类的实现而不更改调用者,因为静态类不能实现接口。所以即使您通过 `EmailManager` 类的构造函数注入 `EmailMessageCreator`,要将其更改为使用另一个 `static` 类,如 `EmailMessageCreatorVersion2`(请原谅我这个名字,只是为了区分它们),您将不得不更改调用者的代码。
如何解决??!!
嗯,解决方案非常简单。
第一步
只需从以下类中删除 `static`
DatabaseManager
EmailMessageCreator
EmailSender
Logger
以及它们的方法!
第二步
为每个 `static` 类创建一个接口
IDatabaseManager
IEmailMessageCreator
IEmailSender
ILogger
并让它们实现这些接口。
第三步
像下面这样更改 `EmailManager` 类的实现
public class EmailManager
{
private readonly IDatabaseManager _databaseManager;
private readonly IEmailMessageCreator _emailMessageCreator;
private readonly IEmailSender _emailSender;
private readonly ILogger _logger;
public EmailManager(IDatabaseManager databaseManager,
IEmailMessageCreator emailMessageCreator, IEmailSender emailSender, ILogger logger)
{
_databaseManager = databaseManager;
_emailMessageCreator = emailMessageCreator;
_emailSender = emailSender;
_logger = logger;
}
public bool SendEmail(int emailId)
{
if (emailId <= 0)
{
_logger.Error("Incorrect emailId: " + emailId);
return false;
}
EmailData emailData = _databaseManager.GetEmailData(emailId);
if (emailData == null)
{
_logger.Error("Email data is null (emailId: " + emailId + ")");
return false;
}
EmailMessage emailMessage = _emailMessageCreator.CreateEmailMessage(emailData);
if (!ValidateEmailMessage(emailMessage))
{
_logger.Error("Email message is not valid (emailId: " + emailId + ")");
return false;
}
try
{
_emailSender.SendEmail(emailMessage);
_logger.Info("Email was sent!");
}
catch (/* catch only exceptions, which you know that may occur and you can't
do anything to avid them, like: network related error, addressee server refused... etc.*/)
{
_logger.Exception(ex.ToString());
return false;
}
return true;
}
private bool ValidateEmailMessage(EmailMessage emailMessage)
{
if(emailMessage == null) return false;
if (string.IsNullOrEmpty(emailMessage.From) || string.IsNullOrEmpty(emailMessage.To) ||
string.IsNullOrEmpty(emailMessage.Subject) || string.IsNullOrEmpty(emailMessage.Body))
return false;
if (emailMessage.Subject.Length > 255) return false;
return true;
}
}
第四步
现在您可以为您的项目设置一个IOC 容器,例如 Ninject 或 Autofac,并像这样将上述实现绑定到相应的接口,例如
`DatabaseManager` 到 `IDatabaseManager`
在您的IOC 容器配置中。
这里有一个维基页面,例如,描述了如何为 Ninject 创建配置
IOC 容器随后会将适当的实现注入到 `EmailManager` 类中。
您也可以手动将实现注入到 `EmailManager` 类中。
瞧,我们做到了!
这次更改我们获得了什么?
单元测试问题 - 已解决
那么,您还记得我们关于单元测试的第一个问题吗?现在我们可以毫无问题地对 `EmailManager` 类进行单元测试。我们可以注入任何我们想要的实现,模拟、存根等。
我们可以使用 Moq 这样的模拟框架来模拟类
- `DatabaseManager` ——然后,对于 `GetEmailData` 方法,我们可以
- 绕过数据库连接,
- 从内存中返回任何我们想要的值,
- 模拟抛出异常——以检查 `EmailManager` 类中的 `SendEmail` 方法将如何表现,
- `EmailMessageCreator` ——然后,对于 `CreateEmailMessage` 方法,我们可以
- 返回任何我们想要的值——以检查 `EmailManager` 类中的 `SendEmail` 方法将如何表现
- 模拟抛出异常——以检查 `EmailManager` 类中的 `SendEmail` 方法将如何表现
- `EmailSender` ——然后,对于它的 `SendEmail` 方法,我们可以
- 绕过发送电子邮件
- 返回任何我们想要的值——以检查 `EmailManager` 类中的 `SendEmail` 方法将如何表现
- 模拟抛出异常——以检查 `EmailManager` 类中的 `SendEmail` 方法将如何表现
- `Logger` ——然后,对于它的 `Info`、`Error` 和 `Exception` 方法,我们可以
- 检查它们是否被调用,如果调用了,则使用什么参数——这将使我们能够检查事件信息或异常是否已记录。
我不会介绍 Moq 库的 API,因为它不是本文的主要主题,并且可能是另一篇文章的主题。这会分散对本文主要主题——`static` 类的使用的注意力。您需要实现上述模拟在单元测试中所需要的一切都已在此提及
未来更改问题 - 已解决
我们遇到的下一个问题是无法扩展静态类,也无法在不更改使用者代码的情况下更改相关类的实现。
我们在新代码中仍然有这些问题吗?
不。
为什么?
因为,得益于面向抽象编程(引入接口)、依赖注入以及从相关类中移除静态,我们现在可以
- 通过继承来扩展相关类的功能
- 在IOC 容器配置中替换调用者(`EmailManager` 类的 `SendEmail` 方法)的相关类实现,而无需触及调用者本身
静态类会更快……
关于这两种解决方案在性能上的差异,我怎么能不提呢?
实现类似本文示例中 `static` 类的解决方案的人会说
“`Static` 类会更快,因为在使用它们时,您不必在每次调用其方法时都创建类的实例。”
但是……
在我提出的解决方案中,我们真的需要每次想要使用某个方法时都创建类的实例吗?
显然不。
我们可以在应用程序启动时创建一个对象并将其视为单例。这可以通过配置IOC 容器轻松实现。在 Ninject、Autofac 等所有实现中,您可以将对象生命周期设置为单例,仅此而已。每次我们想要访问接口的实现时,IOC 容器都会返回同一个对象。
在这里,您可以找到有关 Ninject 中对象生命周期的信息
这非常容易,因为您只需要为每个接口绑定添加一个方法调用
.InSingletonScope()
但是,如果我们不想使用任何现成的 IOC 容器实现,我们可以自己实现单例。
老实说,还有另一个因素,那就是我们还将 `static` 方法替换为实例方法。这有区别吗?让我们看看微软对此的说法
微软的说法对静态方法的调用会生成 Microsoft 中间语言 (MSIL) 中的调用指令,而对实例方法的调用会生成 `callvirt` 指令,该指令还会检查空对象引用。但是,大多数时候两者之间的性能差异并不显著。
在我看来,除非存在巨大的性能问题,否则我们不应该关心它。我们为很小的(非常可能看不见的)性能下降获得了很大的收益。
那么我们应该在哪里使用静态类呢?
那么我们应该完全放弃 `static` 类吗?当然不。
在我看来,我们可以使用 `static` 类来实现业务,如果
- 我们不关心单元测试——不要大声说出来 :),而且我们确信我们不会修改代码——这是最终版本,并且不会扩展
或者
- 这是一个极其简单的项目——只有几个类,而且我们想让它尽可能简单。
或者
- 您正在实现一个极其简单的工具,该工具没有理由被修改或扩展,也没有副作用,例如状态修改或异常抛出——事实上,在现实世界中很难找到这种情况。例如,几乎总是需要验证输入,并且如果输入无效,通常会抛出异常。重要提示:对于包含基本工具的内置框架静态类,例如 `System.Math` 等
我将它们视为例外的规则。我将它们视为内置语言指令。微软已对其进行了适当的测试,它们不会被修改,所以我认为使用它们是完全可以接受的。
我们还可以使用 `static` 类来实现参考项目的常量。例如
public class Point
{
public Point (int x, int y)
{
X = x;
Y = y;
}
public int X{get;private set;}
public int Y{get;private set;}
}
public static class Constants
{
public static Point StartPoint = new Point(0, 0);
}
结论
我一直试图创建尽可能灵活的代码。如果您实现了我建议的“新”解决方案而不是使用 `static` 类,您将不会损失任何东西(如果我们忽略非常微小的、可能看不见的性能下降),同时您可以获得很多,例如可单元测试的代码和未来的灵活性。这种能力对于大型、长期的项目将极其有益。
所以总而言之,我想说
“除非有充分的理由,否则避免在实现应用程序业务逻辑时使用 `static` 类!”
谢谢!
如果您对本文有任何疑问,请随时与我联系!
来源
文章中使用的信息
文章中使用的图片
历史
- 2016年12月12日:初始版本