软件再造:从意大利面条到清洁设计

已发表: 2022-03-11

你能看看我们的系统吗? 编写软件的人已经不在了,我们遇到了很多问题。 我们需要有人为我们检查并清理它。

任何从事软件工程工作相当长的时间的人都知道,这个看似天真的请求往往是一个“满是灾难”的项目的开始。 继承别人的代码可能是一场噩梦,尤其是当代码设计不佳且缺乏文档时。

因此,当我最近收到一位客户的请求,要求查看他现有的 socket.io 聊天服务器应用程序(用 Node.js 编写)并改进它时,我非常谨慎。 但在奔向山丘之前,我决定至少同意看一下代码。

不幸的是,查看代码只会再次确认我的担忧。 该聊天服务器已实现为单个大型 JavaScript 文件。 将这个单一的整体文件重新设计成一个架构清晰且易于维护的软件确实是一个挑战。 但我喜欢挑战,所以我同意了。

软件再造

起点 - 为再造做准备

现有软件由一个包含 1,200 行未记录代码的文件组成。 哎呀。 此外,已知它包含一些错误并存在一些性能问题。

此外,检查日志文件(在继承别人的代码时总是一个很好的起点)揭示了潜在的内存泄漏问题。 在某些时候,据报道该进程使用了​​超过 1GB 的 RAM。

鉴于这些问题,很明显,在尝试调试或增强业务逻辑之前,代码需要重新组织和模块化。 为此,需要解决的一些初步问题包括:

  • 代码结构。 代码根本没有真正的结构,因此很难区分配置、基础设施和业务逻辑。 基本上没有模块化或关注点分离。
  • 冗余代码。 代码的某些部分(例如每个事件处理程序的错误处理代码、发出 Web 请求的代码等)被多次复制。 复制代码从来都不是一件好事,这使得代码更难维护并且更容易出错(当冗余代码在一个地方得到修复或更新但在另一个地方没有得到修复或更新时)。
  • 硬编码值。 该代码包含许多硬编码值(很少是好事)。 能够通过配置参数修改这些值(而不是要求更改代码中的硬编码值)将增加灵活性,还有助于促进测试和调试。
  • 记录。 日志系统非常基础。 它将生成一个巨大的日志文件,难以分析或解析。

主要架构目标

在开始重构代码的过程中,除了解决上面确定的具体问题之外,我还想开始解决一些对于任何软件系统的设计来说都是(或至少应该是)共同的关键架构目标. 这些包括:

  • 可维护性。 永远不要期望自己是唯一需要维护它的人来编写软件。 始终考虑您的代码对其他人的理解程度,以及他们修改或调试的容易程度。
  • 可扩展性。 永远不要假设您今天实现的功能就是您所需要的全部。 以易于扩展的方式构建您的软件。
  • 模块化。 将功能分成逻辑和不同的模块,每个模块都有自己明确的目的和功能。
  • 可扩展性。 今天的用户越来越不耐烦,期望立即(或至少接近立即)响应时间。 糟糕的性能和高延迟甚至会导致市场上最有用的应用程序失败。 随着并发用户数量和带宽需求的增加,您的软件将如何运行? 尽管负载和资源需求增加,但并行化、数据库优化和异步处理等技术有助于提高系统保持响应的能力。

重构代码

我们的目标是从一个单一的单体 mongo 源代码文件转变为一组模块化的、架构清晰的组件。 生成的代码应该更容易维护、增强和调试。

对于这个应用程序,我决定将代码组织成以下不同的架构组件:

  • app.js - 这是我们的入口点,我们的代码将从这里运行
  • config - 这是我们的配置设置所在的位置
  • ioW - 包含所有 IO(和业务)逻辑的“IO 包装器”
  • logging - 所有与日志相关的代码(请注意,目录结构还将包括一个新的logs文件夹,该文件夹将包含所有日志文件)
  • package.json - Node.js 的包依赖项列表
  • node_modules - Node.js 所需的所有模块

这种特定方法没有什么神奇之处。 可能有许多不同的方法来重构代码。 我只是个人觉得这个组织足够干净,组织得很好,没有过于复杂。

生成的目录和文件组织如下所示。

重构代码

日志记录

已经为当今大多数开发环境和语言开发了日志包,因此现在很少需要“推出自己的”日志功能。

因为我们使用的是 Node.js,所以我选择了 log4js-node,它基本上是用于 Node.js 的 log4js 库的一个版本。 这个库有一些很酷的特性,比如能够记录多个级别的消息(警告、错误等),我们可以有一个可以分割的滚动文件,例如,每天,所以我们不必处理需要大量时间才能打开且难以分析和解析的大型文件。

出于我们的目的,我围绕 log4js-node 创建了一个小型包装器,以添加一些特定的额外所需功能。 请注意,我选择在 log4js-node 周围创建一个包装器,然后我将在整个代码中使用它。 这将这些扩展日志记录功能的实现本地化在一个位置,从而在我调用日志记录时避免整个代码中的冗余和不必要的复杂性。

由于我们正在使用 I/O,并且我们将有几个客户端(用户)将产生多个连接(套接字),我希望能够在日志文件中跟踪特定用户的活动,并且还想知道每个日志条目的来源。 因此,我希望有一些关于应用程序状态的日志条目,以及一些特定于用户活动的日志条目。

在我的日志包装代码中,我能够映射用户 ID 和套接字,这将允许我跟踪在 ERROR 事件之前和之后执行的操作。 日志包装器还将允许我创建具有不同上下文信息的不同记录器,我可以将这些信息传递给事件处理程序,以便我知道日志条目的来源。

日志包装器的代码可在此处获得。

配置

通常需要支持系统的不同配置。 这些差异可能是开发环境和生产环境的差异,甚至是基于需要展示不同的客户环境和使用场景。

通常的做法是通过配置参数来控制这些行为差异,而不是要求更改代码来支持这一点。 就我而言,我需要能够拥有不同的执行环境(登台和生产),这些环境可能有不同的设置。 我还想确保测试代码在登台和生产中都能正常工作,如果我需要为此更改代码,那会使测试过程无效。

使用 Node.js 环境变量,我可以指定要用于特定执行的配置文件。 因此,我将所有以前硬编码的配置参数都移到了配置文件中,并创建了一个简单的配置模块,该模块可以加载具有所需设置的正确配置文件。 我还对所有设置进行了分类,以在配置文件上强制执行某种程度的组织并使其更易于导航。

这是生成的配置文件的示例:

 { "app": { "port": 8889, "invRepeatInterval":1000, "invTimeOut":300000, "chatLogInterval":60000, "updateUsersInterval":600000, "dbgCurrentStatusInterval":3600000, "roomDelimiter":"_", "roomPrefix":"/" }, "webSite":{ "host": "mysite.com", "port": 80, "friendListHandler":"/MyMethods.aspx/FriendsList", "userCanChatHandler":"/MyMethods.aspx/UserCanChat", "chatLogsHandler":"/MyMethods.aspx/SaveLogs" }, "logging": { "appenders": [ { "type": "dateFile", "filename": "logs/chat-server", "pattern": "-yyyy-MM-dd", "alwaysIncludePattern": false } ], "level": "DEBUG" } }

代码流

到目前为止,我们已经创建了一个文件夹结构来托管不同的模块,我们已经设置了一种加载环境特定信息的方法,并创建了一个日志系统,所以让我们看看如何在不更改业务特定代码的情况下将所有部分联系在一起。

由于我们新的代码模块化结构,我们的入口点app.js非常简单,仅包含初始化代码:

 var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);

当我们定义我们的代码结构时,我们说ioW文件夹将包含业务和 socket.io 相关的代码。 具体来说,它将包含以下文件(请注意,您可以单击列出的任何文件名以查看相应的源代码):

  • index.js – 处理 socket.io 初始化和连接以及事件订阅,以及事件的集中错误处理程序
  • eventManager.js – 托管所有与业务相关的逻辑(事件处理程序)
  • webHelper.js – 用于执行 Web 请求的辅助方法。
  • linkedList.js – 一个链表实用程序类

我们重构了发出 Web 请求的代码并将其移动到一个单独的文件中,并且我们设法将我们的业务逻辑保持在同一个位置并且未修改。

一个重要的注意事项:在这个阶段, eventManager.js仍然包含一些真正应该提取到单独模块中的帮助函数。 然而,由于我们在第一阶段的目标是重组代码,同时最大限度地减少对业务逻辑的影响,而且这些辅助函数与业务逻辑的联系过于复杂,因此我们选择将其推迟到后续阶段,以改进代码。

由于 Node.js 在定义上是异步的,我们经常会遇到一些“回调地狱”的老鼠窝,这使得代码特别难以导航和调试。 为了避免这个陷阱,在我的新实现中,我使用了 Promise 模式,并专门利用了 bluebird,它是一个非常好的和快速的 Promise 库。 Promise 将使我们能够像同步代码一样跟踪代码,还提供错误管理和一种标准化调用之间响应的干净方式。 我们的代码中有一个隐含的约定,即每个事件处理程序都必须返回一个 Promise,以便我们可以管理集中的错误处理和日志记录。

所有事件处理程序都将返回一个承诺(无论它们是否进行异步调用)。 有了这个,我们可以集中错误处理和日志记录,并且我们确保,如果我们在事件处理程序中有未处理的错误,则该错误被捕获。

 function execEventHandler(socket, eventName, eventHandler, data){ var sLogger = logging.createLogger(socket.id + ' - ' + eventName); sLogger.info(''); eventHandler(socket, data, sLogger).then(null, function(err){ sLogger.error(err.stack); }); };

在我们对日志的讨论中,我们提到每个连接都有自己的记录器,其中包含上下文信息。 具体来说,我们在创建记录器时将套接字 id 和事件名称绑定到记录器,因此当我们将该记录器传递给事件处理程序时,每个日志行都会包含该信息:

 var sLogger = logging.createLogger(socket.id + ' - ' + eventName);

关于事件处理还有一点值得一提:在原始文件中,我们在 socket.io 连接事件的事件处理程序中调用了一个setInterval函数,我们发现这个函数存在问题。

 io.on('connection', function (socket) { ... Several event handlers .... setInterval(function() { try { var date = Date.now(); var tmp = []; while (0 < messageHub.count() && messageHub.head().date < date) { var item = messageHub.remove(); tmp.push(item); } ... Post Data to an external web service... } catch (e) { log('ERROR: ex: ' + e); } }, CHAT_LOGS_INTERVAL); });

这段代码为我们获得的每个连接请求创建一个具有指定间隔(在我们的例子中是 1 分钟)的计时器。 因此,例如,如果在任何给定时间我们有 300 个在线套接字,那么我们将每分钟执行 300 个计时器。 正如您在上面的代码中看到的那样,问题在于没有使用套接字,也没有在事件处理程序的范围内定义任何变量。 唯一使用的变量是在模块级别声明的messageHub变量,这意味着它对于所有连接都是相同的。 因此,每个连接绝对不需要单独的计时器。 所以我们已经从连接事件处理程序中删除了它,并将它包含在我们的通用初始化代码中,在本例中是initialize函数。

最后,在我们处理响应的过程中,在webHelper.js中,我们添加了对任何无法识别的响应的处理,这些响应将记录有助于调试过程的信息:

 if (!res || !res.d || !res.d.IsValid){ logger.debug(sendData); logger.debug(data); reject(new Error('Request failed. Path ' + params.path + ' . Invalid return data.')); return; }

最后一步是为 Node.js 的标准错误设置日志文件。 该文件将包含我们可能遗漏的未处理错误。 为了将 Windows 中的节点进程(不理想,但您知道……)设置为服务,我们使用了一个名为 nssm 的工具,该工具具有可视化 UI,允许您定义标准输出文件、标准错误文件和环境变量。

关于 Node.js 性能

Node.js 是一种单线程编程语言。 为了提高可扩展性,我们可以采用几种替代方案。 有节点集群模块,或者只是添加更多节点进程并在它们之上放置一个 nginx 来进行转发和负载平衡。

但是,在我们的例子中,鉴于每个节点集群子进程或节点进程都有自己的内存空间,我们将无法轻松地在这些进程之间共享信息。 因此,对于这种特殊情况,我们将需要使用外部数据存储(例如 redis)来保持在线套接字对不同进程可用。

结论

有了这一切,我们已经对最初交给我们的代码进行了重大清理。 这不是要使代码完美,而是要重新设计它以创建一个更易于支持和维护的干净的架构基础,这将有助于和简化调试。

遵循前面列举的关键软件设计原则——可维护性、可扩展性、模块化和可扩展性——我们创建了模块和代码结构,清楚地识别了不同的模块职责。 我们还发现了原始实现中的一些问题,这些问题会导致高内存消耗,从而降低性能。

希望您喜欢这篇文章,如果您有进一步的意见或问题,请告诉我。