背景
相信使用 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.js function 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.js module.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.js var 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.js var array = []; for (var i = 0; i < 10000; i++) { array.push('mem_leak_when_require_cache_clean_test_item_' + i); } module.exports = array; // app.js function 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.js function 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.js var 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 這類框架,只需要按照文中的方法結合自身業務需要,對主路由進行一些改造,就可以很好的應用這項技術。