创建没有依赖关系的真正模块化代码

已发表: 2022-03-11

开发软件很棒,但是……我想我们都同意这可能有点像过山车。 一开始,一切都很好。 您可以在几天甚至几小时内一个接一个地添加新功能。 你在滚!

快进几个月,你的开发速度就会下降。 是因为没有以前那么努力了吗? 并不真地。 让我们再快进几个月,你的开发速度进一步下降。 在这个项目上工作不再有趣,已经成为一种拖累。

它变得更糟。 您开始在应用程序中发现多个错误。 通常,解决一个错误会产生两个新错误。 此时,您可以开始唱歌:

代码中的 99 个小错误。 99 个小虫子。 取下一个,修补它,

…代码中有 127 个小错误。

你现在对这个项目的工作感觉如何? 如果你像我一样,你可能会开始失去动力。 开发此应用程序只是一种痛苦,因为对现有代码的每次更改都可能产生不可预知的后果。

这种经历在软件世界中很常见,可以解释为什么这么多程序员想要扔掉他们的源代码并重写一切。

软件开发随着时间的推移而放缓的原因

那么这个问题的原因是什么?

主要原因是复杂性上升。 根据我的经验,造成整体复杂性的最大因素是,在绝大多数软件项目中,一切都是相互关联的。 由于每个类都有依赖关系,如果您更改发送电子邮件的类中的某些代码,您的用户突然无法注册。 这是为什么? 因为您的注册码取决于发送电子邮件的代码。 现在你不能在不引入错误的情况下改变任何东西。 根本不可能跟踪所有依赖项。

所以你有它; 我们问题的真正原因是来自我们代码所具有的所有依赖项的复杂性。

大泥球及其减少方法

有趣的是,这个问题已经知道多年了。 这是一种常见的反模式,称为“大泥球”。 我多年来在多家不同公司从事的几乎所有项目中都看到了这种类型的架构。

那么这个反模式到底是什么? 简单地说,当每个元素都与其他元素有依赖关系时,你会得到一个大泥球。 下面,您可以看到著名开源项目 Apache Hadoop 的依赖关系图。 为了可视化大泥球(或者更确切地说,大毛线球),您绘制一个圆圈并将项目中的类均匀地放置在其上。 只需在每对相互依赖的类之间画一条线。 现在您可以看到问题的根源。

Apache Hadoop 的“大泥球”的可视化,有几十个节点和数百条将它们相互连接的线。

Apache Hadoop 的“大泥球”

模块化代码的解决方案

于是我问了自己一个问题:有没有可能在降低复杂度的同时还能像项目开始时一样享受乐趣? 说实话,你无法消除所有的复杂性。 如果你想添加新特性,你总是不得不提高代码的复杂性。 然而,复杂性可以被移动和分离。

其他行业如何解决这个问题

想想机械行业。 一些小型机械厂在制造机器时,他们会购买一套标准元件,制造一些定制的,然后将它们组合在一起。 他们可以完全独立地制造这些组件并在最后组装所有东西,只需进行一些调整。 这怎么可能? 他们通过设定的行业标准(如螺栓尺寸)和预先决定(如安装孔的尺寸和它们之间的距离)了解每个元素如何组合在一起。

物理机制的技术图以及它的各个部分如何组合在一起。这些部分按接下来要附加的顺序编号,但从左到右的顺序是 5、3、4、1、2。

上述组件中的每个元素都可以由对最终产品或其其他部件一无所知的独立公司提供。 只要每个模块化元件都按照规格制造,您就可以按计划创建最终设备。

我们可以在软件行业复制吗?

我们当然可以! 通过使用接口和反转控制原理; 最好的部分是这种方法可以在任何面向对象的语言中使用:Java、C#、Swift、TypeScript、JavaScript、PHP——这样的例子不胜枚举。 您不需要任何花哨的框架来应用此方法。 您只需要遵守一些简单的规则并保持自律。

控制反转是你的朋友

当我第一次听说控制反转时,我立即意识到我找到了解决方案。 这是一个通过使用接口来获取现有依赖项并反转它们的概念。 接口是方法的简单声明。 他们没有提供任何具体的实现。 因此,它们可以用作两个元素之间关于如何连接它们的协议。 如果您愿意,它们可以用作模块化连接器。 只要一个元素提供接口,另一个元素为其提供实现,它们就可以在不知道彼此的情况下一起工作。 这个棒极了。

让我们看一个简单的例子,我们如何解耦我们的系统以创建模块化代码。 下图已实现为简单的 Java 应用程序。 您可以在此 GitHub 存储库中找到它们。

问题

假设我们有一个非常简单的应用程序,它只包含一个Main类、三个服务和一个Util类。 这些元素以多种方式相互依赖。 下面,您可以看到使用“大泥球”方法的实现。 类只是相互调用。 它们是紧密耦合的,你不能简单地取出一个元素而不触及其他元素。 使用这种风格创建的应用程序可以让您最初快速成长。 我相信这种风格适用于概念验证项目,因为您可以轻松地玩弄事物。 然而,它不适合生产就绪的解决方案,因为即使是维护也可能是危险的,任何单一的更改都可能产生不可预知的错误。 下图显示了这个大泥球结构。

Main 使用服务 A、B 和 C,它们各自使用 Util。服务 C 也使用服务 A。

为什么依赖注入全错了

为了寻找更好的方法,我们可以使用一种称为依赖注入的技术。 此方法假定所有组件都应通过接口使用。 我读过声称它可以将元素解耦,但它真的如此吗? 不,请看下图。

以前的架构,但有依赖注入。现在 Main 使用接口服务 A、B 和 C,由它们对应的服务实现。服务 A 和 C 都使用接口 Service B 和接口 Util,由 Util 实现。服务 C 也使用接口服务 A。每个服务及其接口都被视为一个元素。

目前的情况和一团糟的唯一区别是,现在我们不是直接调用类,而是通过它们的接口调用它们。 它略微改善了彼此分离的元素。 例如,如果您想在不同的项目中重用Service A ,您可以通过取出Service A本身以及Interface A以及Interface BInterface Util来实现。 如您所见, Service A仍然依赖于其他元素。 结果,我们仍然会遇到在一个地方更改代码和在另一个地方搞乱行为的问题。 它仍然会产生一个问题,即如果您修改Service BInterface B ,您将需要更改所有依赖它的元素。 这种方法不能解决任何问题。 在我看来,它只是在元素之上添加了一层界面。 您永远不应该注入任何依赖项,而应该一劳永逸地摆脱它们。 为独立万岁!

模块化代码的解决方案

我相信解决依赖关系的所有主要问题的方法是完全不使用依赖关系。 您创建一个组件及其侦听器。 监听器是一个简单的接口。 每当您需要从当前元素外部调用方法时,您只需向侦听器添加一个方法并调用它即可。 该元素只允许使用文件,调用其包中的方法,以及使用主框架或其他使用的库提供的类。 下面,您可以看到修改为使用元素架构的应用程序图。

修改为使用元素架构的应用程序图。主要使用 Util 和所有三个服务。 Main 还为每个服务实现了一个侦听器,该侦听器由该服务使用。侦听器和服务一起被视为一个元素。

请注意,在此架构中,只有Main类具有多个依赖项。 它将所有元素连接在一起并封装应用程序的业务逻辑。

另一方面,服务是完全独立的元素。 现在,您可以从该应用程序中取出每个服务并在其他地方重用它们。 他们不依赖其他任何东西。 但是等等,它变得更好了:你不需要再修改这些服务,只要你不改变它们的行为。 只要这些服务做他们应该做的事情,它们就可以保持不变,直到时间结束。 它们可以由专业的软件工程师创建,也可以是第一次使用混合了goto语句制作的最糟糕的意大利面条代码的程序员。没关系,因为它们的逻辑是封装的。 尽管它可能很可怕,但它永远不会溢出到其他班级。 这也使您能够在多个开发人员之间拆分项目中的工作,每个开发人员都可以独立处理自己的组件,而无需中断另一个开发人员,甚至不需要知道其他开发人员的存在。

最后,您可以再次开始编写独立代码,就像在上一个项目开始时一样。

元素模式

让我们定义结构元素模式,以便我们能够以可重复的方式创建它。

元素的最简单版本由两部分组成:主元素类和侦听器。 如果要使用元素,则需要实现侦听器并调用主类。 这是最简单的配置图:

应用程序中单个元素及其侦听器的图表。和以前一样,App 使用元素,该元素使用其侦听器,该侦听器由 App 实现。

显然,您最终需要在元素中添加更多复杂性,但您可以轻松地做到这一点。 只要确保你的逻辑类都不依赖于项目中的其他文件。 他们只能使用该元素中的主框架、导入的库和其他文件。 当涉及到像图像、视图、声音等资产文件时,它们也应该被封装在元素中,以便将来它们易于重用。 您可以简单地将整个文件夹复制到另一个项目中,就可以了!

下面,您可以看到一个显示更高级元素的示例图。 请注意,它由正在使用的视图组成,并且不依赖于任何其他应用程序文件。 如果您想知道检查依赖项的简单方法,只需查看导入部分。 是否有来自当前元素之外的文件? 如果是这样,那么您需要通过将这些依赖项移动到元素中或添加对侦听器的适当调用来删除这些依赖项。

更复杂元素的简单图表。在这里,“元素”这个词的更大意义由六个部分组成:视图;逻辑 A、B 和 C;元素;和元素监听器。后两者与App的关系和之前一样,但是内部Element也使用了Logic A和C。Logic C使用了Logic A和Logic B。Logic A使用了Logic B和View。

让我们看一下用 Java 创建的简单“Hello World”示例。

 public class Main { interface ElementListener { void printOutput(String message); } static class Element { private ElementListener listener; public Element(ElementListener listener) { this.listener = listener; } public void sayHello() { String message = "Hello World of Elements!"; this.listener.printOutput(message); } } static class App { public App() { } public void start() { // Build listener ElementListener elementListener = message -> System.out.println(message); // Assemble element Element element = new Element(elementListener); element.sayHello(); } } public static void main(String[] args) { App app = new App(); app.start(); } }

最初,我们定义ElementListener来指定打印输出的方法。 元素本身定义如下。 在元素上调用sayHello时,它只是使用ElementListener打印一条消息。 请注意,该元素完全独立于printOutput方法的实现。 它可以打印到控制台、物理打印机或精美的 UI 中。 该元素不依赖于该实现。 由于这种抽象,这个元素可以很容易地在不同的应用程序中重用。

现在看看主App类。 它实现了监听器并将元素与具体实现组装在一起。 现在我们可以开始使用它了。

您也可以在此处使用 JavaScript 运行此示例

元素架构

让我们看看在大规模应用中使用元素模式。 在一个小项目中展示它是一回事——将它应用到现实世界是另一回事。

我喜欢使用的全栈 Web 应用程序的结构如下所示:

 src ├── client │ ├── app │ └── elements │ └── server ├── app └── elements

在源代码文件夹中,我们最初拆分了客户端和服务器文件。 这样做是合理的,因为它们在两种不同的环境中运行:浏览器和后端服务器。

然后我们将每一层的代码拆分到名为 app 和 elements 的文件夹中。 Elements 由具有独立组件的文件夹组成,而 app 文件夹将所有元素连接在一起并存储所有业务逻辑。

这样,元素可以在不同的项目之间重用,而所有特定于应用程序的复杂性都封装在一个文件夹中,并且通常简化为对元素的简单调用。

动手示例

相信实践总是胜过理论,让我们看一个用 Node.js 和 TypeScript 创建的真实示例。

现实生活中的例子

这是一个非常简单的 Web 应用程序,可以用作更高级解决方案的起点。 它确实遵循元素架构,并且使用了广泛的结构元素模式。

从高亮可以看出,主页已经被区分为一个元素。 此页面包含其自己的视图。 因此,例如,当您想重用它时,您可以简单地复制整个文件夹并将其放入不同的项目中。 只需将所有东西连接在一起就可以了。

这是一个基本示例,说明您现在可以开始在自己的应用程序中引入元素。 您可以开始区分独立的组件并分离它们的逻辑。 您当前正在处理的代码有多混乱并不重要。

开发更快,更频繁地重用!

我希望,使用这套新工具,您将能够更轻松地开发更易于维护的代码。 在开始在实践中使用元素模式之前,让我们快速回顾一下所有要点:

  • 由于多个组件之间的依赖关系,软件中会出现很多问题。

  • 通过在一个地方进行更改,您可以在其他地方引入不可预测的行为。

三种常见的架构方法是:

  • 大泥球。 它非常适合快速开发,但不适合稳定的生产目的。

  • 依赖注入。 这是您应该避免的半生不熟的解决方案。

  • 元素架构。 该解决方案允许您创建独立的组件并在其他项目中重用它们。 对于稳定的生产版本,它是可维护且出色的。

基本元素模式由一个包含所有主要方法的主类和一个侦听器组成,该侦听器是一个允许与外部世界通信的简单接口。

为了实现全栈元素架构,首先将前端与后端代码分开。 然后,您在每个文件夹中为应用程序和元素创建一个文件夹。 elements 文件夹包含所有独立的元素,而 app 文件夹将所有内容连接在一起。

现在您可以开始创建和分享您自己的元素了。 从长远来看,它将帮助您创建易于维护的产品。 祝你好运,让我知道你创造了什么!

此外,如果您发现自己过早地优化代码,请阅读 Toptaler Kevin Bloch的如何避免过早优化的诅咒

相关: JS 最佳实践:使用 TypeScript 和依赖注入构建 Discord Bot