避免过早的软件抽象 - Jonas


让我们看一些在实践中经常发生的过早抽象的具体案例。这些都是基于在我们自己的代码库中找到的真实示例。

  1. 职责抽象得太细了
  2. 使用设计模式没有真正的好处
  3. 性能过早优化
  4. 低耦合无处不在

让我们分别仔细看看其中的每一个。
 
1.职责抽象得太细了
复杂代码库的一个根本原因是职责在过于精细的级别上划分。这可能是将数据库查询抽象到专用存储库类中,将 HTTP 调用抽象到服务类中,或者将某些完全内部的逻辑移到单独的组件中。
这样做通常是为了满足高度流行的单一职责的 SOLID 原则——每个类应该只有一个改变的理由,或者,他们应该只有一份工作。如果我们把每一小块逻辑拆分成一个单独的类,那么一切都有非常明确的职责,只做一个工作,因此只有一个改变的理由。很棒吧?问题是所有这些小部分通常仍然紧密耦合并高度依赖于彼此。如果各个部分之间的任何通信发生变化,通常会产生级联效应,需要对其中的许多部分进行更改。所以他们每个人可能只有一个改变的理由,但是如果一个单一的改变经常需要对许多部分进行改变,那么改变代码就很痛苦,那也没有好处。
此外,仅仅因为一个原因而改变类通常没有真正的实际优势。事实上,在做不止一件事的类中进行更改通常为开发人员提供了更多的上下文,这使得理解更改及其对周围代码的影响变得更加容易。
那么我们什么时候应该分担责任呢?一种常见且非常有效的情况是需要在多个地方使用逻辑。如果在代码中的多个位置需要完全相同的 HTTP 调用或数据库查询,重复逻辑通常会降低可维护性。在这种情况下,将其移至共享且可重用的组件可能是一个好主意。关键是在需要之前不要这样做。另一个有效的情况是当逻辑非常复杂并对周围代码的可读性产生负面影响时。如果一段逻辑占用 300 行代码,这可能是这种情况,而仅用几行代码可能最终只会损害可读性并使代码导航变得更加困难。请记住,拆分职责总是会给代码增加更多的结构复杂性。
下面你会看到改变我们对类职责的看法如何影响帖子顶部显示的原始架构。在左侧,我们将来自服务类的逻辑直接放入需要服务逻辑的命令处理程序中。在右侧,我们将存储库类中的数据库查询直接移动到需要它的事件处理程序中。

在左侧,我们将来自 Service 类的逻辑直接放入需要它的命令处理程序中。在右侧,我们将一个数据库查询移动到一个 Repository,直接进入需要它的 Event Handler。
 
2. 使用的设计模式没有真正的好处
在真正需要它们的好处之前引入各种编程设计模式是另一个常见的陷阱。设计模式非常擅长解决代码库中的特定问题,并且在某些情况下可以降低整体复杂性。也就是说,几乎所有这些都带有增加结构复杂性和降低代码一致性的缺点。
一个很好的例子是装饰模式。装饰器模式通常用于在现有组件之上添加附加功能。这可能是一个发出 HTTP 请求的组件,我们希望向其添加重试机制。在不改变原有组件的情况下,我们可以添加一个新的组件来包裹原有的组件,并在上面添加重试逻辑。通过实现相同的接口,它可以直接在代码中或通过依赖注入替换原始组件。
乍一看,这似乎是个好主意。我们不必更改任何现有代码,我们可以单独测试它们中的每一个,并且在单独查看时每个部分都很容易理解。巨大的不利之处在于我们再次失去连贯性。当开发人员稍后查看原始组件或使用该组件的代码时,不会立即清楚执行代码时会发生什么,因为在“幕后”顶部添加了更多逻辑。我见过将重试直接添加到类的真实案例,但后来发现它已经用重试逻辑装饰,最终在部署时重试多次。像这样的情况只有在不清楚代码的行为方式时才会真正发生。
另一种广泛使用的模式是命令发布-订阅模式. 这里的一个类,不是直接处理请求,而是抽象成一个命令在别处处理。一个示例可能是 API 控制器将 HTTP 请求映射到命令,并将它们发布以由订阅此特定命令的适当处理程序处理。这在接收和解释请求的代码部分与知道如何处理请求的部分之间提供了松散耦合和清晰的隔离。这种模式存在合法的用例,但在实践中需要质疑其是否只是一个无用的映射层,映射层进一步使跟踪程序的执行路径变得更加困难,因为发布者不知道命令最终在哪里被处理。
这些只是经常过早使用的设计模式的几个例子。几乎所有的模式都可以这样说。它们都有缺点,所以只有在需要好处并超过缺点时才使用模式。
下面您将再次看到删除不必要的设计模式对我们原始架构的影响。在左边,我们删除了装饰器模式,在右边,包括发布/订阅机制在内的整个命令流都被删除了。

删除过早引入的设计模式。左侧的装饰模式已移除。删除了右侧的命令和发布/订阅模式。
 
3.性能优化过早
构建性能良好的软件至关重要,通常最有效的问题解决方案是最干净、最简单的。但在其他时候,情况并非如此。在这里,优化的成本必须与我们期望获得的实际实际收益相对。需要考虑的成本包括分析、实施和维护优化所花费的时间,以及使用更复杂的方法来提高效率可能会降低代码可读性。不要为了不必要的效率而牺牲代码可读性,并记住开发人员的时间成本通常远远超过通过微优化代码节省计算资源的潜在收益。
过早的优化是万恶之源——Donald Knuth
也可以在架构级别进行优化。一个示例是命令查询职责分离 (CQRS)模式。CQRS 本质上意味着您有两个独立的数据模型,一个用于更新数据,另一个用于读取数据,将您的应用程序分为读写端。这允许优化一侧以实现高效读取,并优化另一侧以实现高效写入,并且在您的应用程序特别重读或重写的情况下,可以将一侧比另一侧扩展更多。
这种模式的巨大缺点是需要构建和维护一个完整的独立数据模型,从而导致大量的开发开销。如果需要性能,这种权衡可能很好,但即使对于数百万人使用的应用程序,我也很少看到增加的读取或写入效率提供任何可衡量的好处。更明智的方法是对读取和写入使用单一模型,并且仅针对已知简单方法无法充分执行的少数个别情况创建优化的读取模型。
下面您会看到从我们的示例流程中删除读取端的插图。后续查询不会从专用的读取模型表中读取,而是直接从事件源中读取,在我们的示例中,事件源是数据最初写入的位置。

删除应用程序的整个专用读取端,转而使用相同的模型进行读取和写入。
 
4. 低耦合接口太多
低耦合的代码库是每个部分都尽可能独立于其他部分的代码库。低耦合使得对一个部分的更改对其他部分的影响很小,并且可以更轻松地更改一部分代码,因为它们仅以最少的方式相互依赖。
实现低耦合的典型方法是遵循依赖倒置开放/封闭SOLID 原则,即实体应该依赖抽象而不是具体实现,同时对扩展开放,对修改关闭。在实践中,这通常是通过抽象接口后面的类并让其他类依赖于这些接口而不是具体类来完成的。
当对这些原则的解释导致低耦合被引入到任何地方时,问题就出现了:
低耦合,特别是接口,使代码不那么连贯,更难导航,因为您不直接知道将执行哪些具体代码。
您首先需要检查接口存在哪些实现,然后找出在运行时实际使用的是哪个实现。此外,接口是另一个需要添加到项目中的文件,并且在具体实现的签名发生变化时保持最新。
接口解决了大量问题,在需要解决实际实际问题时引入它们,而不是为了实现不必要的低耦合而提前引入。通常,这将是当您需要能够更换实现时,或者在创建其他人使用的外部库而无法修改库代码库时。此外,如果您只是使用接口来允许在测试中进行模拟,请认真考虑切换到允许模拟具体类的模拟库以避免开销。
下面通过删除两个接口进行可视化,让事件处理程序和命令处理程序分别直接引用存储库和服务类的具体实现。

删除不需要的接口。左侧是存储库的接口,右侧是服务类的接口。