Node.js巧妙实现Web应用代码热更新
背景
相信使用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,稍微精简一下代码贴出来。
//Checkthecachefortherequestedfile. //1.Ifamodulealreadyexistsinthecache:returnitsexportsobject. //2.Ifthemoduleisnative:call`NativeModule.require()`withthe //filenameandreturntheresult. //3.Otherwise,createanewmoduleforthefileandsaveittothecache. //Thenhaveitloadthefilecontentsbeforereturningitsexports //object. Module._load=function(request,parent,isMain){ varfilename=Module._resolveFilename(request,parent); varcachedModule=Module._cache[filename]; if(cachedModule){ returncachedModule.exports; } varmodule=newModule(filename,parent); Module._cache[filename]=module; module.load(filename); returnmodule.exports; }; require.cache=Module._cache;
可以发现其中的核心就是Module._cache,只要清除了这个模块缓存,下一次require的时候,模块管理器就会重新加载最新的代码了。
写一个小程序验证一下
//main.js functioncleanCache(module){ varpath=require.resolve(module); require.cache[path]=null; } setInterval(function(){ cleanCache('./code.js'); varcode=require('./code.js'); console.log(code); },5000); //code.js module.exports='helloworld';
我们执行一下main.js,同时取修改code.js的内容,就可以发现控制台中,我们代码成功的更新为了最新的代码。
那么模块管理器更新代码的问题已经解决了,接下来再看看在Web应用中,我们如何让新的模块可以被实际执行。
如何使用新模块处理请求
为了更符合大家的使用习惯,我们就直接以Express为例来展开这个问题,实际上使用类似的思路,绝大部分Web应用均可适用。
首先,如果我们的服务是像Express的DEMO一样所有的代码均在同一模块内的话,我们是无法针对模块进行热加载的
varexpress=require('express'); varapp=express(); app.get('/',function(req,res){ res.send('helloworld'); }); app.listen(3000);
要实现热加载,和Erlang中不允许的基础库一样,我们需要一些无法进行热更新的基础代码控制更新流程。而且类似app.listen这类操作如果重新执行了,那么和重启Node.js进程也没太大的区别了。因此我们需要一些巧妙的代码将频繁更新的业务代码与不频繁更新的基础代码隔离开。
//app.js基础代码 varexpress=require('express'); varapp=express(); varrouter=require('./router.js'); app.use(router); app.listen(3000); //router.js业务代码 varexpress=require('express'); varrouter=express.Router(); //此处加载的中间件也可以自动更新 router.use(express.static('public')); router.get('/',function(req,res){ res.send('helloworld'); }); module.exports=router;
然而很遗憾,经过这样处理之后,虽然成功的分离了核心代码,router.js依然无法进行热更新。首先,由于缺乏对更新的触发机制,服务无法知道应该何时去更新模块。其次,app.use操作会一直保存老的router.js模块,因此即使模块被更新了,请求依然会使用老模块处理而非新模块。
那么继续改进一下,我们需要对app.js稍作调整,启动文件监听作为触发机制,并且通过闭包来解决app.use的缓存问题
//app.js varexpress=require('express'); varfs=require('fs'); varapp=express(); varrouter=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('moduleupdatefailed'); } }); functioncleanCache(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.js vararray=[]; for(vari=0;i<10000;i++){ array.push('mem_leak_when_require_cache_clean_test_item_'+i); } module.exports=array; //app.js functioncleanCache(module){ varpath=require.resolve(module); require.cache[path]=null; } setInterval(function(){ varcode=require('./code.js'); cleanCache('./code.js'); },10);
好~我们用了一个非常笨拙但是有效的方法,提高了router.js模块的内存占用,那么再次启动main.js后,就会发现内存出现显著的飙升,不到一会Node.js就提示processoutofmemory。然而实际上从app.js与router.js的代码中观察的话,我们并没发现哪里保存了旧模块的引用。
我们借助一些profile工具如node-heapdump就可以很快的定位到问题所在,在module.js中我们发现Node.js会自动为所有模块添加一个引用
functionModule(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.js functioncleanCache(modulePath){ varmodule=require.cache[modulePath]; //removereferenceinmodule.parent if(module.parent){ module.parent.children.splice(module.parent.children.indexOf(module),1); } require.cache[modulePath]=null; } setInterval(function(){ varcode=require('./code.js'); cleanCache(require.resolve('./code.js')); },10);
再执行一下,这次好多了,内存只会有轻微的增长,说明老模块占用的资源已经正确的释放掉了。
使用了新的cleanCache函数后,常规的使用就没有问题,然而并非就可以高枕无忧了。在Node.js中,除了require系统会添加引用外,通过EventEmitter进行事件监听也是大家常用的功能,并且EventEmitter有非常大的嫌疑会出现模块间的互相引用。那么EventEmitter能否正确的释放资源呢?答案是肯定的。
//code.js varmoduleA=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的hotreload功能来保证线上服务的稳定性。
由于热更新实际上与框架和业务架构紧密相关,因此本文并未给出一个通用的解决方案。作为参考,简单的介绍一下在YOG2框架中我们是如何使用这项技术的。由于YOG2框架本身就支持前后端子系统App拆分,因此我们的更新策略是以App为粒度更新代码。同时由于类似fs.watch这类操作会有兼容性问题,一些替代方案如fs.watchFile则会比较消耗性能,因此我们结合了YOG2的测试机部署功能,通过上传部署新代码的形式告知框架需要更新App代码。在以App为粒度更新模块缓存的同时,会更新路由缓存与模板缓存,来完成所有代码的更新工作。
如果你使用的是类似Express或者Koa这类框架,只需要按照文中的方法结合自身业务需要,对主路由进行一些改造,就可以很好的应用这项技术。