软件再造(从意大利面到干净的设计)

本文概述

  • 起点-重新设计的准备
  • 关键建筑目标
  • 重组代码
  • 记录中
  • 组态
  • 代码流
  • 关于Node.js性能
  • 总结
你能看看我们的系统吗?编写该软件的人不在了, 我们遇到了许多问题。我们需要有人仔细检查并为我们清理。
任何在软件工程领域工作了一段时间的人都知道, 这个看似无辜的请求通常是” 整个过程都备有灾难” 的项目的开始。继承别人的代码可能是一场噩梦, 尤其是在代码设计不良且缺乏文档的情况下。
因此, 当我最近收到一位客户的要求来检查他现有的socket.io聊天服务器应用程序(用Node.js编写)并进行改进时, 我非常警惕。但是在奔跑之前, 我决定至少同意看一下代码。
不幸的是, 查看代码只能再次确认我的担忧。该聊天服务器已实现为单个大型JavaScript文件。将这个单一的整体文件重新设计成一个结构清晰, 易于维护的软件确实是一个挑战。但是我喜欢挑战, 所以我同意了。
软件再造(从意大利面到干净的设计)

文章图片
起点-重新设计的准备 现有软件由一个包含1200行未记录代码的文件组成。 kes此外, 已知其中包含一些错误并存在一些性能问题。
此外, 检查日志文件(在继承别人的代码时总是一个很好的起点)发现潜在的内存泄漏问题。在某个时候, 据报道该进程正在使用超过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文件夹将保存与business和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);

关于事件处理, 值得一提的另一点是:在原始文件中, 我们有一个setInterval函数调用位于socket.io连接事件的事件处理程序内, 并且我们已将此函数识别为问题。
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变量, 这意味着所有连接都相同。因此, 绝对不需要每个连接使用单独的计时器。因此, 我们已将其从连接事件处理程序中删除, 并将其包含在我们的常规初始化代码中, 在本例中为初始化函数。
最后, 在响应处理中, 在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), 以使在线套接字可用于不同的进程。
总结 完成所有这些操作后, 我们已经完成了对原始代码的大量清理工作。这不是要使代码完美, 而是要对其进行重新设计以创建一个干净的体系结构基础, 该基础将更易于支持和维护, 并且将促进并简化调试。
遵循先前列举的关键软件设计原则-可维护性, 可扩展性, 模块化和可伸缩性-我们创建了模块和代码结构, 以清楚, 明确地确定不同的模块职责。我们还发现了原始实现中的一些问题, 这些问题会导致大量内存消耗, 从而降低性能。
希望你喜欢这篇文章, 如果你有其他意见或问题, 请告诉我。

    推荐阅读