65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.71/5 (79投票s)

2016年12月12日

CPOL

13分钟阅读

viewsIcon

133532

什么时候不应该使用静态类?

本系列上一篇文章

这是我系列文章的第三篇:C# 坏习惯:通过反面例子学习如何写出好代码。

您可以通过以下链接找到前两篇文章

要理解本文,您不必阅读我之前的任何文章,但我鼓励您阅读。:)

文章目标

我想就一个非常有趣的话题表达我的观点

何时不应使用静态类?

在本文中,我将考虑将 `static` 类用作业务逻辑实现的一部分。

我对这个话题有许多不同的看法,因此我决定在此表达我的观点。

与本系列之前的文章一样,我将展示(在我看来)由于使用 `static` 类而导致一些问题的代码,然后我将提出解决这些问题的方案。

读者在开始阅读本文之前应该了解什么

  • C# 语言基础知识
  • 依赖注入设计模式基础知识
  • IOC 容器工作原理基础知识
  • 单元测试基础知识
  • 模拟框架工作原理基础知识

C# 中的静态类是什么?

微软的说法

静态类本质上与非静态类相同,但有一个区别:静态类不能实例化。换句话说,您不能使用 `new` 关键字来创建该类类型的变量。由于没有实例变量,您可以使用类名本身来访问静态类的成员。例如,如果您有一个名为 **UtilityClass** 的静态类,其中有一个名为 **MethodA** 的公共方法,您可以通过以下示例代码调用该方法

UtilityClass.MethodA();

这就是为什么许多开发人员经常使用它们。您无需创建类的实例即可使用它,只需输入一个点即可访问其成员。它快速而清晰,并且可以节省创建对象的​​时间。

关于 `static` 类有很多规则,但我不会详细阐述。

您可以从 MSDN 网站了解它们

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` 方法的测试结果。

重要提示:我并不是说我们只需要单元测试。集成测试也很重要,我们应该同时拥有它们,但我们应该清楚地区分它们。为什么?因为所有单元测试都应该始终通过!集成测试通常需要一些额外的配置,例如假的数据库、假的 SMTP 服务器等。

此外,在测试 `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 容器,例如 NinjectAutofac,并像这样将上述实现绑定到相应的接口,例如

`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 容器轻松实现。在 NinjectAutofac 等所有实现中,您可以将对象生命周期设置为单例,仅此而已。每次我们想要访问接口的实现时,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日:初始版本
© . All rights reserved.