常见的 .NET Core 反模式以及如何避免它们

作者:微信公众号:【架构师老卢】
2-4 17:27
25

.NET Core 为构建健壮且可扩展的应用程序提供了一个强大的框架,但即使是经验丰富的开发者也可能陷入一些常见的反模式,这些反模式会阻碍代码的可维护性和性能。了解这些陷阱并采用最佳实践,能够确保代码简洁、高效且具有前瞻性。在本文中,我们将探讨.NET Core 中一些最为臭名昭著的反模式,分析它们为何存在问题,以及如何通过更好的编码实践来修正。

上帝对象 / 一团乱麻 —— 大杂烩式的混乱

上帝对象是指承担了过多职责的类,它试图在单个实体中管理多种不同的事务。这违反了单一职责原则(SRP),会导致代码紧密耦合,难以进行测试、扩展和维护。随着时间推移,这样的类会发展成无结构的、庞大的 “一团乱麻”,使得调试和重构变得越来越复杂。这种反模式会导致代码库脆弱,哪怕是微小的改动都可能引发意想不到的副作用。

❌反模式示例 此代码将多项职责 —— 验证、支付处理、客户通知和库存更新 —— 集中到了一个方法中。

public class OrderManager
{
    public void ProcessOrder(Order order)
    {
        // 验证订单
        // 处理支付
        // 通知客户
        // 更新库存
    }
}

❌违反单一职责原则(SRP) —— 该方法负责的事情太多,难以维护和测试。如果其中某一部分发生变化(例如支付处理),就可能需要修改这个方法,从而增加了破坏无关功能的风险。

❌紧密耦合的代码 —— 这个方法可能依赖多个服务(验证、支付、通知和库存),这使得修改或替换单个部分变得困难。

❌难以扩展 —— 如果你想支持新的支付方式或改变通知的工作方式,就必须修改这个类,而不是通过单独的组件来扩展功能。

❌难以进行单元测试 —— 每个单元测试都必须涵盖所有四项职责,这会导致测试用例复杂,存在多个依赖项。

✅反模式修正 将职责重构为更小、定义明确的服务。

public class OrderService
{
    private readonly IPaymentService _paymentService;
    private readonly INotificationService _notificationService;
    private readonly IInventoryService _inventoryService;

    public OrderService(IPaymentService paymentService, INotificationService notificationService, IInventoryService inventoryService)
    {
        _paymentService = paymentService;
        _notificationService = notificationService;
        _inventoryService = inventoryService;
    }

    public void ProcessOrder(Order order)
    {
        _paymentService.Charge(order);
        _notificationService.NotifyCustomer(order);
        _inventoryService.UpdateStock(order);
    }
}

✅解耦订单处理职责 —— 它不再处理多项任务,而是将这些任务委托给单独的服务:

  • _paymentService.Charge(order) → 处理支付流程。
  • _notificationService.NotifyCustomer(order) → 处理客户通知。
  • _inventoryService.UpdateStock(order) → 在订单处理后更新库存。 这使得代码更易于维护和修改,而不会影响无关的功能。

✅提高可测试性 —— 依赖项(IPaymentServiceINotificationServiceIInventoryService)通过构造函数注入。这允许在单元测试期间模拟这些依赖项,从而更易于进行隔离测试。

✅增强代码可复用性 —— 现在每个服务都可在应用程序的其他部分复用(例如,IPaymentService 可用于订阅、退款等)。你可以更换实现(例如不同的支付网关)而无需修改 OrderService

服务定位器 —— 隐藏依赖的陷阱

服务定位器模式是一种反模式,它将依赖解析集中化,但却模糊了类的真实依赖关系。对象不是通过构造函数或方法参数显式地注入依赖项,而是从全局服务定位器中检索它们,这使得很难明确一个类实际需要哪些依赖项。这会导致隐藏依赖、可测试性降低和紧密耦合,因为类变得依赖于服务定位器,而不是定义良好的抽象。调试和维护这样的代码极具挑战性,因为依赖项是在运行时解析的,而不是显式声明的,这使得系统更难重构。

❌反模式示例 这个类使用服务定位器检索 IPaymentService 的实例,然后调用 ProcessPayment()IPaymentService 不是通过构造函数注入的,而是在运行时使用 ServiceLocator.GetService<IPaymentService>() 动态获取的。

public class OrderProcessor
{
    public void Process()
    {
        var paymentService = ServiceLocator.GetService<IPaymentService>();
        paymentService.ProcessPayment();
    }
}

❌隐藏依赖 —— OrderProcessor 类没有显式声明其依赖项。任何阅读该类代码的人都无法立即得知它依赖哪些服务,这使得代码更难理解和维护。

❌违反依赖注入(DI)原则 —— 这种方法不是通过构造函数注入依赖项,而是从全局定位器中获取依赖项,这使得难以显式地管理依赖项。

❌难以进行单元测试 —— 由于依赖项是在运行时解析的,在测试中进行模拟变得很麻烦。你需要在测试框架中配置服务定位器,这增加了不必要的复杂性。

❌与服务定位器紧密耦合 —— 该类现在依赖于服务定位器本身,这降低了灵活性。如果你决定切换到构造函数注入,那么对 ServiceLocator.GetService<T>() 的每一处引用都必须重构。

✅反模式修正 这个 OrderProcessor 类负责通过调用 _paymentService.ProcessPayment() 来处理订单。与服务定位器反模式中动态检索依赖项不同,它通过构造函数注入接收 IPaymentService 的实例,使依赖关系变得明确。

public class OrderProcessor
{
    private readonly IPaymentService _paymentService;

    public OrderProcessor(IPaymentService paymentService)
    {
        _paymentService = paymentService;
    }

    public void Process()
    {
        _paymentService.ProcessPayment();
    }
}

✅消除隐藏依赖 —— 与服务定位器模式在运行时获取依赖项不同,这种方法在构造函数中使依赖关系变得明确。任何阅读该类代码的人都能立即明白 OrderProcessor 依赖于 IPaymentService

✅提高可测试性 —— 由于依赖项是注入的,在进行单元测试时可以轻松提供一个模拟的 IPaymentService

var mockPaymentService = new Mock<IPaymentService>();
var orderProcessor = new OrderProcessor(mockPaymentService.Object);

这允许进行隔离测试,而无需配置全局服务定位器。

✅减少紧密耦合 —— OrderProcessor 不再依赖静态的 ServiceLocator,使其更加模块化,更能适应变化。

✅支持依赖注入(DI)容器 —— 这种方法与现代 DI 框架(如 ASP.NET Core 内置的 DI)一致,使得在运行时管理依赖项更加容易。

过度使用静态类 —— 僵化依赖的陷阱

过度使用静态类是一种反模式,因为它会导致紧密耦合和僵化的代码。由于静态类是全局可访问的,它们隐藏了依赖关系,使得很难追踪服务在何处被使用。静态类也无法被模拟,这使得单元测试变得复杂,并且在运行时难以对其行为进行修改或扩展。随着系统的增长,过度使用静态类会导致代码库脆弱,难以重构、维护或演进,使得变更风险更高且更难管理。

❌反模式示例 这个 Logger 类是一个静态类,只有一个 Log() 方法,用于将日志消息输出到控制台。由于该方法是静态的,所以它是全局可访问的,应用程序的任何部分都可以调用 Logger.Log(message) 来记录消息。

public static class Logger
{
    public static void Log(string message)
    {
        Console.WriteLine(message);
    }
}

❌全局状态与紧密耦合 —— 静态类 Logger 创建了一个全局访问点。这种紧密耦合使得在不影响整个代码库的情况下替换或修改日志记录机制变得困难。日志记录器直接与控制台耦合,限制了灵活性。

❌难以测试 —— 由于该类是静态的,在单元测试中无法轻松地模拟或替换它。这使得为依赖日志记录的类编写单元测试变得困难,因为你无法轻松验证或控制输出。

❌缺乏灵活性 —— 静态类不可扩展。如果你之后想使用不同的日志记录策略(例如写入文件或远程日志服务),不重构所有使用 Logger 的代码就无法实现。

❌违反单一职责原则 —— Logger 类负责处理日志记录,但由于它是静态且全局可访问的,这可能会导致在应用程序的太多部分使用它,从而违反单一职责原则。

✅反模式修正 这段代码定义了一个接口 ILoggerService,其中包含一个 Log() 方法,以及一个具体实现类 LoggerService,它将日志消息写入控制台。LoggerService 类实现了 ILoggerService 接口,提供实际的日志记录功能。

public interface ILoggerService
{
    void Log(string message);
}

public class LoggerService : ILoggerService
{
    public void Log(string message) => Console.WriteLine(message);
}

✅通过依赖注入解耦 —— 通过使用接口(ILoggerService),代码将日志记录功能与应用程序逻辑解耦。这提供了灵活性,因为你可以轻松切换到不同的日志记录实现(例如文件日志记录、远程日志记录等),而无需修改依赖的类。

✅可测试性 —— 接口 ILoggerService 允许你在单元测试中轻松模拟日志记录器。例如,你可以模拟 Log() 方法,以验证日志记录是否正确发生,而无需实际写入控制台或文件系统。

✅可扩展性 —— 使用接口和实现类使得扩展或更改日志记录行为变得容易,而无需修改现有代码。现在你可以根据需要注入不同的日志记录实现,遵循开闭原则(对扩展开放,对修改关闭)。

✅单一职责原则(SRP) —— 通过将日志记录关注点分离到它自己的类(LoggerService)中,它遵循了单一职责原则,确保 LoggerService 只负责日志记录,而其他类可以专注于它们自己的职责。

忽视异步编程最佳实践 —— 性能陷阱

忽视异步编程最佳实践是一种反模式,因为它可能导致应用程序出现性能瓶颈和可扩展性差的问题。当异步操作处理不当(例如使用 .Result.Wait() 阻塞异步代码)时,可能会导致线程池耗尽、处理延迟和响应不及时,尤其是在高负载情况下。此外,错误处理不当以及未正确利用 async / await 可能会引入难以察觉的错误,使代码更难调试。忽视这些最佳实践,可能会使你的应用程序效率低下、难以维护,并且容易出现扩展性问题,最终影响其整体性能和可靠性。

❌反模式示例 此代码尝试通过异步调用从远程 API 获取数据,但它使用 .Result 阻塞了异步操作。Result 属性会阻塞调用线程,直到异步操作完成并返回结果。

public void GetData()
{
    var result = _httpClient.GetStringAsync("https://api.example.com").Result;
}

❌阻塞异步代码 —— 使用 .Result 阻塞异步调用是一种常见的反模式。这将异步操作变成了同步操作,违背了 async / await 的初衷,即在线程等待结果时释放线程以执行其他任务。这可能会导致线程池耗尽,线程被阻塞且无法重用,从而导致性能下降。

❌死锁风险 —— 如果此代码在 UI 线程或具有同步上下文的上下文中调用(如在 ASP.NET 或 Windows Forms 中),可能会导致死锁。.Result 调用等待异步操作完成,但该操作可能试图将控制权返回给 UI 线程(或原始调用上下文),而此时 UI 线程已经被阻塞。

❌资源使用效率低下 —— 阻塞异步代码会降低异步编程的优势,异步编程旨在允许线程在等待 I/O 绑定操作(如 HTTP 请求)时保持可用,以执行其他任务。这会导致资源使用效率降低,尤其是在高性能、可扩展的应用程序中。

❌更难维护 —— 阻塞异步代码会使代码的逻辑推理变得困难,并可能导致难以察觉的错误,尤其是在同步和异步逻辑混合时。由于代码执行流程不像正确使用 async / await 那样直观,所以维护和调试都很困难。

✅反模式修正 处理异步调用的正确方法是使用 asyncawait。这允许线程在等待异步任务完成时保持不阻塞,遵循异步编程的最佳实践。

public async Task GetDataAsync()
{
    var result = await _httpClient.GetStringAsync("https://api.example.com");
}

✅非阻塞代码 —— 通过使用 await,调用线程在等待 GetStringAsync 方法完成时可以自由执行其他任务,这正是异步编程的预期目的。

✅避免死锁 —— 使用 await 确保调用线程不会阻塞,避免了死锁的可能性,尤其是在具有同步上下文的环境中,如 UI 或 ASP.NET 应用程序。

✅提高性能 —— 由于线程在等待 HTTP 请求完成时不会被阻塞,资源可以得到更好的利用,从而带来更好的整体性能和可扩展性。

✅可读性和可维护性 —— 使用 asyncawait 使异步控制流变得明确,更易于理解和维护,尤其是随着应用程序复杂性的增加。

捕获并忽略异常 / 通用捕获块 —— 无声失败的陷阱

捕获并忽略异常或使用通用捕获块(例如 catch (Exception ex) { })是一种反模式,因为它隐藏了错误,阻止应用程序正确应对意外问题。捕获所有异常但不记录或重新抛出它们,会使应用程序无声地忽略潜在问题,使得在运行时诊断问题变得困难。这种做法还会掩盖可能被适当处理的错误、逻辑错误或外部故障。它会导致可维护性差和可靠性降低,因为开发人员无法追踪失败的根本原因,随着时间推移,未检测到的问题可能会升级为更大的问题。

❌反模式示例 此代码尝试在 try 块中执行一些逻辑。如果在执行过程中发生任何异常,catch 块会捕获它。然而,catch 块对捕获到的异常不做任何处理。它只是通过将 catch 块留空来忽略异常,这意味着没有进行日志记录、重新抛出或错误处理。

try
{
    // 一些逻辑
}
catch (Exception ex) { }

❌捕获并忽略异常 —— 通过捕获所有异常但不处理它们(例如记录或重新抛出),代码无声地忽略了潜在错误,使得检测和调试问题变得困难。这可能导致隐藏的失败,程序继续运行,但从不通知用户或开发人员关键问题。

❌缺乏可见性 —— 由于异常没有被记录或重新抛出,关于出错原因的信息无法获取。这使得故障排查几乎不可能,并使应用程序处于不可预测的状态。

❌掩盖错误的风险 —— 捕获并忽略异常可能会隐藏应用程序中的错误或逻辑错误。如果异常被忽略,潜在问题得不到解决,代码可能会使用有问题的数据或行为继续运行。

❌降低可维护性 —— 未来的开发人员(甚至原始开发人员)可能难以理解为什么忽略异常,这会导致代码质量差,增加维护应用程序的难度。此外,不处理异常可能会在系统执行后期导致更严重的问题。

✅反模式修正 此代码尝试在 try 块中执行一些逻辑。如果发生 SqlException(与 SQL Server 相关的特定类型异常),catch 块会捕获它。在 catch 块中,使用 _logger.LogError(ex, "Database error occurred") 记录异常,然后使用 throw 重新抛出异常。这允许对异常进行适当处理,同时确保不会无声地忽略它。

try
{
    // 一些逻辑
}
catch (SqlException ex)
{
    _logger.LogError(ex, "Database error occurred");
    throw;
}

✅记录异常 —— 通过记录异常(_logger.LogError(ex, "Database error occurred")),错误被捕获并记录下来。这为调试提供了有价值的信息,使开发人员能够了解出错原因并在日志中追踪问题。

✅重新抛出异常 —— 在记录异常后,使用 throw; 重新抛出异常。这确保异常不会被无声地忽略,而是允许应用程序的更高层根据需要处理它。这可能包括回滚事务、向用户显示错误消息或触发其他操作来处理该问题。

✅特定异常处理 —— 此代码不是捕获所有异常(Exception),而是捕获特定的异常(SqlException)。捕获特定异常有助于确保只有相关的错误由这个块处理,而其他异常可以继续传播或在其他地方处理。这更健壮,避免掩盖其他无关的错误。

✅提高可维护性 —— 通过记录异常并允许其传播,这种方法确保错误不会被隐藏,使系统更可靠且更易于维护。开发人员能够通过日志快速识别数据库问题,并且程序不会在无效状态下继续运行。

通过避免这些常见的反模式,.NET Core 开发人员可以构建可扩展、可维护且可测试的应用程序。遵循 SOLID 原则、正确使用依赖注入并遵循最佳实践,可确保代码简洁高效。牢记这些陷阱将带来更可靠和专业的开发实践。你还遇到过哪些其他反模式呢?

相关留言评论
昵称:
邮箱:
阅读排行