Node.js巧妙实现Web应用代码热更新

 更新时间:2015年10月23日 11:37  点击:2279

背景

相信使用 Node.js 开发过 Web 应用的同学一定苦恼过新修改的代码必须要重启 Node.js 进程后才能更新的问题。习惯使用 PHP 开发的同学更会非常的不适用,大呼果然还是我大PHP才是世界上最好的编程语言。手动重启进程不仅仅是非常恼人的重复劳动,当应用规模稍大以后,启动时间也逐渐开始不容忽视。

当然作为程序猿,无论使用哪种语言,都不会让这样的事情折磨自己。解决这类问题最直接和普适的手段就是监听文件修改并重启进程。这个方法也已经有很多成熟的解决方案提供了,比如已经被弃坑的 node-supervisor,以及现在比较火的 PM2 ,或者比较轻量级的 node-dev 等等均是这样的思路。

本文则提供了另外一种思路,只需要很小的改造,就可以实现真正的0重启热更新代码,解决 Node.js 开发 Web 应用时恼人的代码更新问题。

总体思路

说起代码热更新,当下最有名的当属 Erlang 语言的热更新功能,这门语言的特色在于高并发和分布式编程,主要的应用场景则是类似证券交易、游戏服务端等领域。这些场景都或多或少要求服务拥有在运行中运维的手段,而代码热更新就是其中非常重要的一环,因此我们可以先简单的了解一下 Erlang 的做法。

由于我也没有使用过 Erlang ,以下内容均为道听途说,如果希望深入和准确的了解 Erlang 的代码热更新实现,最好还是查阅官方文档。

Erlang 的代码加载由一个名为code_server的模块管理,除了启动时的一些必要代码外,大部分的代码均是由code_server加载。
当code_server发现模块代码被更新后,会重新加载模块,此后的新请求会使用新模块执行,而原有还在执行的请求则继续使用老模块执行。
老模块会在新模块加载后,被打上old标签,新模块则是current标签。当下一次热更新的时候,Erlang 会扫描还在执行老模块的进行并杀掉,再继续按照这个逻辑更新模块。
Erlang 中并非所有代码均允许热更新,如 kernel, stdlib, compiler 等基础模块默认是不允许更新的
我们可以发现 Node.js 中也有与code_server类似的模块,即 require 体系,因此 Erlang 的做法应该也可以在 Node.js 上做一些尝试。通过了解 Erlang 的做法,我们可以大概的总结出在 Node.js 中解决代码热更新的关键问题点

如何更新模块代码
如何使用新模块处理请求
如何释放老模块的资源

那么接下来我们就逐个的解析这些问题点。

如何更新模块代码

要解决模块代码更新的问题,我们就需要去阅读 Node.js 的模块管理器实现,直接上链接 module.js。通过简单的阅读,我们可以发现核心的代码就在于 Module._load ,稍微精简一下代码贴出来。

// Check the cache for the requested file.// 1. If a module already exists in the cache: return its exports object.// 2. If the module is native: call `NativeModule.require()` with the// filename and return the result.// 3. Otherwise, create a new module for the file and save it to the cache.// Then have it load the file contents before returning its exports// object.Module._load = function(request, parent, isMain) { var filename = Module._resolveFilename(request, parent); var cachedModule = Module._cache[filename]; if (cachedModule) { return cachedModule.exports; } var module = new Module(filename, parent); Module._cache[filename] = module; module.load(filename); return module.exports;};require.cache = Module._cache;

可以发现其中的核心就是 Module._cache ,只要清除了这个模块缓存,下一次 require 的时候,模块管理器就会重新加载最新的代码了。

写一个小程序验证一下

// main.jsfunction cleanCache (module) { var path = require.resolve(module); require.cache[path] = null;}setInterval(function () { cleanCache('./code.js'); var code = require('./code.js'); console.log(code);}, 5000);// code.jsmodule.exports = 'hello world';

我们执行一下 main.js ,同时取修改 code.js 的内容,就可以发现控制台中,我们代码成功的更新为了最新的代码。

图片

那么模块管理器更新代码的问题已经解决了,接下来再看看在 Web 应用中,我们如何让新的模块可以被实际执行。

如何使用新模块处理请求

为了更符合大家的使用习惯,我们就直接以 Express 为例来展开这个问题,实际上使用类似的思路,绝大部分 Web应用 均可适用。

首先,如果我们的服务是像 Express 的 DEMO 一样所有的代码均在同一模块内的话,我们是无法针对模块进行热加载的

var express = require('express');var app = express();app.get('/', function(req, res){ res.send('hello world');});app.listen(3000);

要实现热加载,和 Erlang 中不允许的基础库一样,我们需要一些无法进行热更新的基础代码控制更新流程。而且类似 app.listen 这类操作如果重新执行了,那么和重启 Node.js 进程也没太大的区别了。因此我们需要一些巧妙的代码将频繁更新的业务代码与不频繁更新的基础代码隔离开。

// app.js 基础代码var express = require('express');var app = express();var router = require('./router.js');app.use(router);app.listen(3000);// router.js 业务代码var express = require('express');var router = express .Router();// 此处加载的中间件也可以自动更新router.use(express.static('public'));router.get('/', function(req, res){ res.send('hello world');});module.exports = router;

然而很遗憾,经过这样处理之后,虽然成功的分离了核心代码, router.js 依然无法进行热更新。首先,由于缺乏对更新的触发机制,服务无法知道应该何时去更新模块。其次, app.use 操作会一直保存老的 router.js 模块,因此即使模块被更新了,请求依然会使用老模块处理而非新模块。

那么继续改进一下,我们需要对 app.js 稍作调整,启动文件监听作为触发机制,并且通过闭包来解决 app.use 的缓存问题

// app.jsvar express = require('express');var fs = require('fs');var app = express();var router = require('./router.js');app.use(function (req, res, next) { // 利用闭包的特性获取最新的router对象,避免app.use缓存router对象 router(req, res, next);});app.listen(3000);// 监听文件修改重新加载代码fs.watch(require.resolve('./router.js'), function () { cleanCache(require.resolve('./router.js')); try {  router = require('./router.js'); } catch (ex) {  console.error('module update failed'); }});function cleanCache(modulePath) { require.cache[modulePath] = null;}

再试着修改一下 router.js 就会发现我们的代码热更新已经初具雏形了,新的请求会使用最新的 router.js 代码。除了修改 router.js 的返回内容外,还可以试试看修改路由功能,也会如预期一样进行更新。

当然,要实现一个完善的热更新方案需要更多结合自身方案做一些改进。首先,在中间件的使用上,我们可以在 app.use 处声明一些不需要热更新或者说每次更新不希望重复执行的中间件,而在 router.use 处则可以声明一些希望可以灵活修改的中间件。其次,文件监听不能仅监听路由文件,而是要监听所有需要热更新的文件。除了文件监听这种手段外,还可以结合编辑器的扩展功能,在保存时向 Node.js 进程发送信号或者访问一个特定的 URL 等方式来触发更新。

如何释放老模块的资源

要解释清楚老模块的资源如何释放的问题,实际上需要先了解 Node.js 的内存回收机制,本文中并不准备详加描述,解释 Node.js 的内存回收机制的文章和书籍很多,感兴趣的同学可以自行扩展阅读。简单的总结一下就是当一个对象没有被任何对象引用的时候,这个对象就会被标记为可回收,并会在下一次GC处理的时候释放内存。

那么我们的课题就是,如何让老模块的代码更新后,确保没有对象保持了模块的引用。首先我们以 如何更新模块代码 一节中的代码为例,看看老模块资源不回收会出现什么问题。为了让结果更显著,我们修改一下 code.js

// code.jsvar array = [];for (var i = 0; i < 10000; i++) { array.push('mem_leak_when_require_cache_clean_test_item_' + i);}module.exports = array;// app.jsfunction cleanCache (module) { var path = require.resolve(module); require.cache[path] = null;}setInterval(function () { var code = require('./code.js'); cleanCache('./code.js');}, 10);

好~我们用了一个非常笨拙但是有效的方法,提高了 router.js 模块的内存占用,那么再次启动 main.js 后,就会发现内存出现显著的飙升,不到一会 Node.js 就提示 process out of memory。然而实际上从 app.js 与 router.js 的代码中观察的话,我们并没发现哪里保存了旧模块的引用。

我们借助一些 profile 工具如 node-heapdump 就可以很快的定位到问题所在,在 module.js 中我们发现 Node.js 会自动为所有模块添加一个引用

function Module(id, parent) { this.id = id; this.exports = {}; this.parent = parent; if (parent && parent.children) { parent.children.push(this); } this.filename = null; this.loaded = false; this.children = [];}

因此相应的,我们可以调整一下cleanCache函数,将这个引用在模块更新的时候一并去除。

// app.jsfunction cleanCache(modulePath) { var module = require.cache[modulePath]; // remove reference in module.parent if (module.parent) {  module.parent.children.splice(module.parent.children.indexOf(module), 1); } require.cache[modulePath] = null;}setInterval(function () { var code = require('./code.js'); cleanCache(require.resolve('./code.js'));}, 10); 

再执行一下,这次好多了,内存只会有轻微的增长,说明老模块占用的资源已经正确的释放掉了。

使用了新的 cleanCache 函数后,常规的使用就没有问题,然而并非就可以高枕无忧了。在 Node.js 中,除了 require 系统会添加引用外,通过 EventEmitter 进行事件监听也是大家常用的功能,并且 EventEmitter 有非常大的嫌疑会出现模块间的互相引用。那么 EventEmitter 能否正确的释放资源呢?答案是肯定的。

// code.jsvar moduleA = require('events').EventEmitter();moduleA.on('whatever', function () {});

当 code.js 模块被更新,并且所有引用被移出后,只要 moduleA 没有被其他未释放的模块引用, moduleA 也会被自动释放,包括我们在其内部的事件监听。

只有一种畸形的 EventEmitter 应用场景在这套体系下无法应对,即 code.js 每次执行的时候都会去监听一个全局对象的事件,这样会造成全局对象上不停的挂载事件,同时 Node.js 会很快的提示检测到过多的事件绑定,疑似内存泄露。

至此,可以看到只要处理好了 require 系统中 Node.js 为我们自动添加的引用,老模块的资源回收并不是大问题,虽然我们无法做到像 Erlang 一样实现下一次热更新对还留存的老模块进行扫描这样细粒度的控制,但是我们可以通过合理的规避手段,解决老模块资源释放的问题。

在 Web 应用下,还有一个引用问题就是未释放的模块或者核心模块对需要热更新的模块有引用,如 app.use,导致老模块的资源无法释放,并且新的请求无法正确的使用新模块进行处理。解决这个问题的手段就是控制全局变量或者引用的暴露的入口,在热更新执行的过程中手动更新入口。如 如何使用新模块处理请求 中对 router 的封装就是一个例子,通过这一个入口的控制,我们在 router.js 中无论如何引用其他模块,都会随着入口的释放而释放。

另一个会引起资源释放问题的就是类似 setInterval 这类操作,会保持对象的生命周期无法释放,不过在 Web 应用中我们极少会使用这类技术,因此方案中并未关注。

尾声

至此,我们就解决了 Node.js 在 Web 应用下代码热更新的三大问题,不过由于 Node.js 本身缺乏对有效的留存对象的扫描机制,因此并不能100%的消除类似 setInterval 导致的老模块的资源无法释放的问题。也是由于这样的局限性,目前我们提供的 YOG2 框架中,主要还是将此技术应用于开发调试期,通过热更新实现快速开发。而生产环境的代码更新依然使用重启或者 PM2 的 hot reload 功能来保证线上服务的稳定性。

由于热更新实际上与框架和业务架构紧密相关,因此本文并未给出一个通用的解决方案。作为参考,简单的介绍一下在 YOG2 框架中我们是如何使用这项技术的。由于 YOG2 框架本身就支持前后端子系统 App 拆分,因此我们的更新策略是以 App 为粒度更新代码。同时由于类似 fs.watch 这类操作会有兼容性问题,一些替代方案如 fs.watchFile 则会比较消耗性能,因此我们结合了 YOG2 的测试机部署功能,通过上传部署新代码的形式告知框架需要更新 App 代码。在以 App 为粒度更新模块缓存的同时,会更新路由缓存与模板缓存,来完成所有代码的更新工作。

如果你使用的是类似 Express 或者 Koa 这类框架,只需要按照文中的方法结合自身业务需要,对主路由进行一些改造,就可以很好的应用这项技术。

[!--infotagslink--]

相关文章

  • 不打开网页直接查看网站的源代码

      有一种方法,可以不打开网站而直接查看到这个网站的源代码..   这样可以有效地防止误入恶意网站...   在浏览器地址栏输入:   view-source:http://...2016-09-20
  • php 调用goolge地图代码

    <?php require('path.inc.php'); header('content-Type: text/html; charset=utf-8'); $borough_id = intval($_GET['id']); if(!$borough_id){ echo ' ...2016-11-25
  • JS基于Mootools实现的个性菜单效果代码

    本文实例讲述了JS基于Mootools实现的个性菜单效果代码。分享给大家供大家参考,具体如下:这里演示基于Mootools做的带动画的垂直型菜单,是一个初学者写的,用来学习Mootools的使用有帮助,下载时请注意要将外部引用的mootools...2015-10-23
  • JS+CSS实现分类动态选择及移动功能效果代码

    本文实例讲述了JS+CSS实现分类动态选择及移动功能效果代码。分享给大家供大家参考,具体如下:这是一个类似选项卡功能的选择插件,与普通的TAb区别是加入了动画效果,多用于商品类网站,用作商品分类功能,不过其它网站也可以用,...2015-10-21
  • JS实现自定义简单网页软键盘效果代码

    本文实例讲述了JS实现自定义简单网页软键盘效果。分享给大家供大家参考,具体如下:这是一款自定义的简单点的网页软键盘,没有使用任何控件,仅是为了练习JavaScript编写水平,安全性方面没有过多考虑,有顾虑的可以不用,目的是学...2015-11-08
  • php 取除连续空格与换行代码

    php 取除连续空格与换行代码,这些我们都用到str_replace与正则函数 第一种: $content=str_replace("n","",$content); echo $content; 第二种: $content=preg_replac...2016-11-25
  • php简单用户登陆程序代码

    php简单用户登陆程序代码 这些教程很对初学者来讲是很有用的哦,这款就下面这一点点代码了哦。 <center> <p>&nbsp;</p> <p>&nbsp;</p> <form name="form1...2016-11-25
  • PHP实现清除wordpress里恶意代码

    公司一些wordpress网站由于下载的插件存在恶意代码,导致整个服务器所有网站PHP文件都存在恶意代码,就写了个简单的脚本清除。恶意代码示例...2015-10-23
  • JS实现双击屏幕滚动效果代码

    本文实例讲述了JS实现双击屏幕滚动效果代码。分享给大家供大家参考,具体如下:这里演示双击滚屏效果代码的实现方法,不知道有觉得有用处的没,现在网上还有很多还在用这个特效的呢,代码分享给大家吧。运行效果截图如下:在线演...2015-10-30
  • js识别uc浏览器的代码

    其实挺简单的就是if(navigator.userAgent.indexOf('UCBrowser') > -1) {alert("uc浏览器");}else{//不是uc浏览器执行的操作}如果想测试某个浏览器的特征可以通过如下方法获取JS获取浏览器信息 浏览器代码名称:navigator...2015-11-08
  • JS日期加减,日期运算代码

    一、日期减去天数等于第二个日期function cc(dd,dadd){//可以加上错误处理var a = new Date(dd)a = a.valueOf()a = a - dadd * 24 * 60 * 60 * 1000a = new Date(a)alert(a.getFullYear() + "年" + (a.getMonth() +...2015-11-08
  • PHP开发微信支付的代码分享

    微信支付,即便交了保证金,你还是处理测试阶段,不能正式发布。必须到你通过程序测试提交订单、发货通知等数据到微信的系统中,才能申请发布。然后,因为在微信中是通过JS方式调用API,必须在微信后台设置支付授权目录,而且要到...2014-05-31
  • PHP常用的小程序代码段

    本文实例讲述了PHP常用的小程序代码段。分享给大家供大家参考,具体如下:1.计算两个时间的相差几天$startdate=strtotime("2009-12-09");$enddate=strtotime("2009-12-05");上面的php时间日期函数strtotime已经把字符串...2015-11-24
  • node.JS md5加密中文与php结果不一致怎么办

    这次文章要给大家介绍的是node.JS md5加密中文与php结果不一致怎么办,不知道具体解决办法的下面跟小编一起来看看。 因项目需要,需要Node.js与PHP做接口调用,发现nod...2017-07-06
  • php怎么用拼音 简单的php中文转拼音的实现代码

    小编分享了一段简单的php中文转拼音的实现代码,代码简单易懂,适合初学php的同学参考学习。 代码如下 复制代码 <?phpfunction Pinyin($_String...2017-07-06
  • 浅谈node.js中async异步编程

    1.什么是异步编程? 异步编程是指由于异步I/O等因素,无法同步获得执行结果时, 在回调函数中进行下一步操作的代码编写风格,常见的如setTimeout函数、ajax请求等等。示例: for (var i = 1; i <= 3; i++) {setTimeout(functi...2015-10-23
  • php导出csv格式数据并将数字转换成文本的思路以及代码分享

    php导出csv格式数据实现:先定义一个字符串 存储内容,例如 $exportdata = '规则111,规则222,审222,规222,服2222,规则1,规则2,规则3,匹配字符,设置时间,有效期'."/n";然后对需要保存csv的数组进行foreach循环,例如复制代...2014-06-07
  • node.js+express留言板功能实现示例

    本文介绍基于nodejs+express+art-template的留言板功能。包含列表界面、添加界面和发送留言功能,具有一定的参考价值,感兴趣的小伙伴们可以参考一下...2021-09-21
  • ecshop商品无限级分类代码

    ecshop商品无限级分类代码 function cat_options($spec_cat_id, $arr) { static $cat_options = array(); if (isset($cat_options[$spec_cat_id]))...2016-11-25
  • NODE.JS加密模块CRYPTO常用方法介绍

    使用require('crypto')调用加密模块。加密模块需要底层系统提供OpenSSL的支持。它提供了一种安全凭证的封装方式,可以用于HTTPS安全网络以及普通HTTP连接。该模块还提供了一套针对OpenSSL的hash(哈希),hmac(密钥哈希),cipher...2014-06-07