目录
这篇文章的目标
什么是c#中的静态类?
我们去看看代码
问题
单元测试
未来的变化
如何修复?? !!
第一步
第二步
第三步
第四步
这次改变后我们获得了什么?
单元测试问题——已解决
未来的变化问题——已解决
静态类会更快......
那么我们应该在哪里使用静态类?
结论
C#坏习惯:通过不好的例子学习如何制作好的代码——第1部分
C#坏习惯:通过不好的例子学习如何制作好的代码——第2部分
要理解这篇文章,你不必阅读我之前的任何文章,但我鼓励你这样做:)
这篇文章的目标我想就一个非常有趣的话题表达我的观点:你什么时候不应该使用静态类?
在本文中,我正在考虑使用静态类作为业务逻辑实现的一部分。
我在这个主题上遇到了很多不同的意见,所以我决定就此提出我的观点。
与本系列前几篇文章中的相同,我将介绍一些问题(在我看来)——因为使用静态类,然后我将提出解决这些问题的解决方案。
在开始阅读本文之前,读者应该知道什么:
- C#语言的基础知识
- 依赖注入设计模式的基础知识
- 有关IOC容器如何工作的基本知识
- 单元测试的基本知识
- 模拟框架如何工作的基本知识
微软说:
静态类与非静态类基本相同,但有一个区别:静态类无法实例化。换句话说,您不能使用new关键字来创建类类型的变量。因为没有实例变量,所以可以使用类名本身访问静态类的成员。例如,如果您有一个名为UtilityClass 的静态类, 它具有名为MethodA的公共方法 ,则可以调用该方法,如以下示例所示:
UtilityClass.MethodA();
这就是为什么许多开发人员经常使用它们的原因。你不需要创建一个类的实例来使用它,你只需要一个点,瞧,你可以访问它的成员。它快速而清晰,您可以节省创建对象的时间。
静态类有很多规则,但我不会详细说明它们。
您可以从msdn站点了解它们:msdn
对于本文,静态类的2个规则很重要:
- 静态类是密封的——你不能继承它,
- 静态类无法实现接口。
创建以下示例仅用于在具体代码上显示问题。发送电子邮件与本文描述的问题无关,它只是以错误的方式显示静态类的使用(以我个人的观点 :))。
这是我想要设置的代码示例:
public class EmailManager
{
public bool SendEmail(int emailId)
{
if (emailId 255) return false;
return true;
}
}
让我们假设我们有一个发送电子邮件的组件。它从队列中获取包含消息标识符的消息(无论哪个实现,可能是MSMQ,RabbitMQ等),创建电子邮件消息并将其发送到Addressee。
我们这里有一个manager类,它作为存储在数据库message(messageId)中的参数标识符。它有自己的逻辑:
- 保护条款
- 验证返回的消息EmailMessageCreator——它分为私有方法——我将它视为EmailManager类SendEmail 方法的一部分
- 管理程序流程
- 错误处理,因为在发送电子邮件时,您可能会遇到许多已知错误,例如:
- 网络相关错误,收件人服务器拒绝...等
并且还从另一个组件调用逻辑,例如:
- DatabaseManager 静态类——负责从DB messageId通过获取消息信息的组件
- EmailMessageCreator 静态类——负责创建要发送的电子邮件的组件
- EmailSender 静态类——负责通过SMTP向Addressee发送消息的组件
- Logger 静态类——负责在日志文件中记录信息的组件
为了简化情况,假设所有上述类都像服务一样工作,并且这里的成员没有竞争条件。
它看起来简单明了。它工作正常,速度快,我们可以轻松快速地实施它。
那么这段代码有什么问题?? !!
我看到它有一些问题..
问题 单元测试想象一下,你现在想要为EmailManager 类编写单元测试,所以你想测试自己的SendEmail 方法逻辑:像:
- 在以下情况下检查返回的值:
- 输入不正确
- CreateEmailMessage 将返回无效数据
- 检查测试方法是否抛出异常,如果:
- CreateEmailMessage 方法抛出异常
- GetEmailData 方法抛出异常
- EmailSender类中的SendEmail方法抛出异常
- 检查是否使用适当的参数调用了正确的Logger方法,以防万一:
- 输入不正确
- 来自EmailSender 类的SendEmail方法抛出了异常
- 电子邮件已正确发送
即使相关类将更改其行为,此行为也不应更改。
重要提示:请记住,单元测试不是集成测试。它应该只测试一段孤立的代码。如果与其他代码有关系,那么它们应该被模拟,存根等替换。
您无法对此类进行单元测试,因为存在与其他类的硬依赖关系,例如:
- DatabaseManager
- EmailMessageCreator
- EmailSender
- Logger
有什么问题?
在测试SendEmail时,您也将测试他们的代码。这将是集成测试。简单来说,例如,CreateEmailMessage中的错误不应该影响SendEmail 方法的测试结果。
重要提示:我不想说我们只需要进行单元测试。集成测试也很重要,我们应该同时使用它们,但我们应该清楚地将它们分开。为什么?因为所有单元测试都应该通过!集成测试通常需要一些额外的配置,如假数据库,假smtp服务器等。
此外,从EmailSender 类中测试自己的SendEmail 方法代码时,您不想调用数据库并发送真实的电子邮件,因为您没有测试它们,并且单元测试必须快速!
另一件事是你无法检查你的测试,Logger类是否被调用,因为你无法模拟这个Logger类。重要提示:在Wonde Tadesse评论之后,我同意您可以使用Microsoft Fakes框架,对来自EmailSender 类(依赖于静态类的版本)的SendEmail方法进行单元测试。但:
- (在原始版本中依赖于静态类)你仍然违反了 SOLID 原则中的依赖倒置原则,所以这是一个不好的做法——EmailManager决定要采取哪些相关类的实现。
- 此功能仅适用于最高版本的Visual Studio(Premium,Ultimate或Enterprise)。您的公司现在依赖于Visual Studio的具体版本。这是一个好习惯吗?即使您的公司拥有为每个开发人员购买的VS的最高版本(非常罕见且不太可能),如果公司决定将Visual Studio的版本降级为专业版,该怎么办?您的测试将失败,无论如何您都必须重构代码。好的做法?
想象一下,经过一段时间后,EmailSender类需要EmailMessageCreator的不同的行为,但只需要一点点。因此,您只想更改当前EmailMessageCreator代码的10%,其余的可以保持原样。但在同时,你不想打破这个类编写的单元测试,并要遵循SOLID原则中的开放/封闭原则。
重要:
维基百科说开放/封闭原则:
软件实体(类,模块,函数等)应该扩展开放,但是对修改关闭
我们希望保留EmailMessageCreator和EmailMessageCreatorExtended类,也因为有另一个EmailMessageCreator的消费者,其希望继续使用其旧版本而不进行修改。
在我们的示例中,我们也不想接触调用者——EmailManager类。
不幸的是我们无法做到这一点,因为:
- 如果我们想保持当前的EmailMessageCreator实现并且只是扩展它,那么最好的选择是创建一个类EmailMessageCreatorExtended (原谅这个名字,它只是为了区分它们),它们将继承自EmailMessageCreator类。我们不能这样做,因为静态类是密封的,所以你不能从它们继承。
- 您也无法在不更改调用者的情况下替换EmailMessageCreator类的实现,因为静态类无法实现接口。因此,即使您通过EmailManager类的构造函数注入EmailMessageCreator,要将其更改为使用另一个静态类,如EmailMessageCreatorVersion2 (请原谅这个名称,它只是为了区分它们),您将不得不更改调用者的代码。
嗯,解决方案非常简单。
只需从以下类中删除静态:
- DatabaseManager
- EmailMessageCreator
- EmailSender
- Logger
从他们的方法中!
第二步为每个静态类创建一个接口:
- 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 255) return false;
return true;
}
}
第四步
现在,您可以为项目设置一个IOC容器,如Ninject或Autofac,并将上面的实现绑定到适当的接口,如:
DatabaseManager至 IDatabaseManager
在您的IOC容器配置中。
这是一个wiki页面,它描述了如何为Ninject创建配置:
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,因为它不是本文的主要主题,它可能是单独文章的主题。它会分散文章的主题,即静态类的使用。在此处提到了在单元测试中实现上述模拟所需的所有内容:
Moq快速入门
未来的变化问题——已解决我们遇到的另一个问题是缺乏扩展静态类的能力以及缺乏在不改变消费者代码的情况下改变相关类的实现的能力。我们的新代码中是否还有这些问题?
没有。
为什么?
因为,多亏了抽象编程(引入接口)、依赖注入和从相关类中删除静态,我们现在可以:
- 使用继承扩展相关类的功能,
- 在IOC容器配置中替换调用者(EmailManager类的SendEmail方法)中的相关类的实现,而根本不接触调用者。
在性能方面,我怎么能不说这两种解决方案之间的区别呢。
正在实现与文章中给出的静态类相似的解决方案的人会说:
“静态类将更快,因为在使用它们时,您不必在每次调用其方法时创建类的实例”。
但是...... 在我自己提出的解决方案中,每次我们想要使用它的方法时,我们真的需要创建一个类的实例吗?
显然没有。
我们可以在应用程序启动时创建一个对象,并将其视为单例。通过配置IOC容器可以非常容易地实现。在像Ninject,Autofac等所有实现中,您可以将对象生命周期设置为单例,就是这样。每次,我们都希望访问接口的实现,IOC容器将返回相同的对象。
在这里,您可以在Ninject中找到有关对象生命周期的信息:
Ninject对象生命周期
这非常简单,因为您只需要为每个接口绑定添加一个方法调用:
.InSingletonScope()
但是如果我们不想使用任何现成的IOC容器实现,我们可以自己实现单例。
为了100%诚实,还有另一个因素,即我们将静态方法替换为实例方法。它有什么改变吗?让我们来看看微软对它的看法:
微软说:
对静态方法的调用以Microsoft中间语言(MSIL)生成调用指令,而对实例方法的调用生成callvirt指令,该指令还检查空对象引用。但是,大多数时候两者之间的性能差异并不显着。
在我看来,除非我们遇到巨大的性能问题,否则我们不应该关心它。我们在很少(很可能看不见)的性能下降方面获益匪浅。
那么我们应该在哪里使用静态类?
那么我们应该放弃静态类吗?当然不是。在我看来,我们可以使用静态类来实现业务,如果:
- 我们不关心单元测试——不要大声说:)我们确信我们不会修改代码——它是最终版本而且不会扩展
要么
- 这是一个非常简单的项目——只有几个类,我们希望尽可能简单。
要么
- 你正在实现一个非常简单的工具,它没有理由被修改或扩展,没有副作用,比如修改状态或异常抛出——实际上,在现实世界中找到这种情况非常困难。例如,您几乎总是必须验证输入,并且如果它无效则经常抛出异常。
重要提示:对于包含一些基本工具的内置框架静态类,例如System.Math:我将它们视为规则的一个例外。我在考虑将它们作为语言指令的内置。它们由Microsoft正确测试,不会被修改,所以在我看来使用它们是完全可以接受的。
我们还可以使用静态类来实现引用项目的常量。例如:
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);
}
结论
我总是试图尽可能灵活地创建代码。如果你将实现我自己建议的“新”解决方案,而不是使用静态类,你不会失去任何东西(如果我们跳过很少,甚至可以忽略的性能下降),同时你可以获得很多,比如单元可测试代码和未来变化的灵活性。这种能力对于巨大的长期项目非常有益。
总而言之,我会说:“在实现应用程序的业务逻辑时避免使用静态类,除非有充分理由使用它们!”。
下一篇:C#坏习惯:通过不好的例子学习如何制作好的代码——第4部分
原文地址:https://www.codeproject.com/Articles/1160717/Csharp-BAD-PRACTICES-Learn-how-to-make-a-good-co