作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
胡安·巴勃罗·西达的头像

胡安·巴勃罗·西达

Juan是一名拥有10多年经验的软件架构师. 他是一名合格的 .. NET和Java开发人员,并且热爱Node.js和Erlang.

专业知识

以前在

Globant
Share

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

去过的人 软件工程 在一段合理的时间内,他们知道这个看似无辜的请求往往是一个“灾难已经写满了”的项目的开始。. 继承别人的代码可能是一场噩梦, 特别是当代码设计得很差且缺乏文档时.

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

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

软件再工程

起点——为再造做好准备

现有的软件由一个包含1200行未记录代码的文件组成. Yikes. 此外,它还包含一些bug和一些性能问题.

除了, 对日志文件的检查(在继承别人的代码时总是一个很好的开始)揭示了潜在的内存泄漏问题. 在某些时候,据报告该进程使用了超过1GB的RAM.

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

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

主要架构目标

在开始重构代码的过程中, 除了解决上述具体问题之外, 我想开始解决一些关键的体系结构目标,这些目标是(或者至少是, 对于任何软件系统的设计都应该是通用的吗. 这些包括:

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

重组守则

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

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

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

There is nothing magic about this specific approach; there could be many different ways to restructure the code. 我只是个人觉得这个组织足够干净,组织得很好,而不是过于复杂.

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

调整代码

Logging

Logging包是为当今大多数开发环境和语言开发的, 因此,现在很少需要“推出自己的”Logging功能.

因为我们正在使用Node.js,我选择了 log4j-node,这基本上是一个版本的 log4j 用于Node的库.js. 这个库有一些很酷的特性,比如能够记录多个级别的消息(警告), ERROR, etc.),我们可以有一个滚动的文件,可以分割, 例如, 在日常生活中, 因此,我们不必处理需要花费大量时间才能打开且难以分析和解析的大型文件.

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

因为我们正在处理I/O, 我们会有几个客户端(用户)产生几个连接(套接字), 我希望能够在日志文件中跟踪特定用户的活动, 还想知道每条日志的来源. 因此,我希望有一些关于应用程序状态的日志条目, 还有一些是针对用户活动的.

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

日志包装器的代码是可用的 here.

配置

通常需要支持系统的不同配置. 这些差异可以是开发环境和生产环境之间的差异, 甚至可以根据需要显示不同的客户环境和使用场景.

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

使用节点.Js环境变量, 我可以指定要为特定的执行使用哪个配置文件. 因此,我将所有以前硬编码的配置参数移到了配置文件中, 并创建了一个简单的配置模块,该模块可以加载带有所需设置的适当配置文件. 我还对所有设置进行了分类,以便在配置文件上执行某种程度的组织,并使其更易于导航.

下面是一个配置文件的示例:

{
    "应用程序":{
        “端口”:8889年,
        “invRepeatInterval”:1000年,
        “invTimeOut”:300000年,
        “chatLogInterval”:60000年,
        “updateUsersInterval”:600000年,
        “dbgCurrentStatusInterval”:3600000,
        “roomDelimiter”:“_”,
        “roomPrefix”:“/”
    },
    "网站":{
        “主机”:“mysite.com",
        “端口”:80年,
        :“friendListHandler / MyMethods.aspx / FriendsList”,
        :“userCanChatHandler / MyMethods.aspx / UserCanChat”,
        :“chatLogsHandler / MyMethods.aspx / SaveLogs”
    },
    "日志":{
        “输出源”:[
            {
                “类型”:“dateFile”,
                “文件名”:“日志/聊天服务器”,
                “模式”:“-yyyy-MM-dd”,
                “alwaysIncludePattern”:假的
            }
        ],
        “级别”:“调试”
    }
}

代码流

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

由于我们新的模块化结构的代码,我们的切入点 app.js 足够简单,只包含初始化代码:

Var config = require('./配置);
Var Logging = require('./Logging的);
var ioW = require('./低”);

Var obj = config.getCurrent ();
Logging.初始化(obj.Logging);

ioW.初始化(配置);

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

  • index.js -手柄插座.IO初始化和连接以及事件订阅, 再加上一个集中的事件错误处理程序
  • eventManager.js 承载所有与业务相关的逻辑(事件处理程序)
  • webHelper.js -处理web请求的helper方法.
  • linkedList.js -一个链表工具类

我们重构了发出web请求的代码,并将其移动到一个单独的文件中, 我们设法保持我们的业务逻辑在相同的地方和未修改.

重要提示: 在这个阶段, eventManager.js 仍然包含一些应该提取到单独模块中的辅助函数. 然而, 因为我们在第一阶段的目标是重新组织代码,同时尽量减少对业务逻辑的影响, 而且这些辅助功能与业务逻辑捆绑得太过复杂, 我们选择将其推迟到改进代码组织的后续阶段.

由于节点.Js的定义是异步的, 我们经常遇到一些“回调地狱”的老鼠窝,这使得代码特别难以导航和调试. 为了避免这个陷阱,在我的新实现中,我使用了 承诺的模式 并且特别地利用 蓝知更鸟 哪个是一个非常好和快速的承诺库. 承诺将使我们能够跟踪代码,就像它是同步的一样,还提供错误管理和一种标准化调用之间响应的干净方式. 在我们的代码中有一个隐含的约定,每个事件处理程序必须返回一个承诺,这样我们就可以集中管理错误处理和Logging.

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

execEventHandler(socket, eventName, eventHandler, data){
    var 勤劳的工作者 =日志.createLogger(套接字.id + ' - ' + eventName);
    勤劳的工作者.信息(");
    eventHandler(socket, data, slologger).然后(null,函数(err) {
        勤劳的工作者.错误(犯错.堆栈);
    });
};

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

var 勤劳的工作者 =日志.createLogger(套接字.id + ' - ' + eventName);

关于事件处理,还有一点值得一提:在原始文件中,我们有一个 setInterval 在套接字的事件处理程序内部的函数调用.IO连接事件,我们已经确定这个函数是一个问题.

io.On ('connection', 函数 (socket) {

    ... 几个事件处理程序 ....	

    setInterval(函数(){
        try {
            var date =日期.现在();
            Var TMP = [];
            while (0 < messageHub.count () && messageHub.head().date < date) {
                var item = messageHub.remove ();
                tmp.推动(项);
            }

            ... 将数据发布到外部web服务...
        } catch (e) {
            log('ERROR: ex: ' + e);
        }
    }, CHAT_LOGS_INTERVAL);
});

这段代码创建了一个具有指定间隔(在我们的例子中是1分钟)的计时器 每一个连接请求 我们得到. So, 例如, 如果任何时候我们有300个在线套接字, 然后每分钟执行300个计时器. 问题是, 正如您在上面的代码中看到的那样, 是否没有使用套接字或在事件处理程序范围内定义的任何变量. 唯一使用的变量是a messageHub 在模块级声明的变量,这意味着它对所有连接都是相同的. 因此,绝对不需要为每个连接设置单独的计时器. 因此,我们已将其从连接事件处理程序中删除,并将其包含在一般初始化代码中, 在这种情况下,哪个是 初始化 函数.

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

if (!res || !res.d || !res.d.IsValid) { 
  logger.调试(sendData); 
  logger.调试(数据); 
  请求失败. 路径+参数.路径+ ' . 无效的返回数据.'));
  返回; 
}

最后一步是为Node的标准错误设置一个日志文件.js. 该文件将包含我们可能错过的未处理的错误. 为了将Windows中的节点进程设置为服务(不是很理想,但是你知道…),我们使用了一个叫做 nssm 它有一个可视化的用户界面,允许你定义一个标准的输出文件, 标准错误文件, 环境变量.

关于节点.js性能

Node.Js是一种单线程编程语言. 为了提高可伸缩性,我们可以采用几种替代方案. 有一个节点集群模块,或者只是简单地添加更多的节点进程,并在这些节点进程之上放一个nginx来做转发和负载平衡.

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

结论

这些都准备好了, 我们已经完成了对最初交给我们的代码的重大清理. 这并不是要使代码完美, 而是重新设计它,以创建一个更容易支持和维护的干净的体系结构基础, 这将方便和简化调试.

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

希望你喜欢这篇文章,如果你有进一步的评论或问题,请告诉我.

聘请Toptal这方面的专家.
现在雇佣
胡安·巴勃罗·西达的头像
胡安·巴勃罗·西达

位于 阿根廷科尔多瓦

成员自 2014年8月7日

作者简介

Juan是一名拥有10多年经验的软件架构师. 他是一名合格的 .. NET和Java开发人员,并且热爱Node.js和Erlang.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

以前在

Globant

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.