浅谈 Node.js 热更新

Posted Alibaba F2E

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈 Node.js 热更新相关的知识,希望对你有一定的参考价值。

记得在 15 16 年那会 Node.js 刚起步的时候,我在去前东家的入职面试也被问到了要如何实现 Node.js 服务的热更新。

其实早期从 php-fpm / Fast-cgi 转过来的 Noder,肯定非常喜欢这种更新业务逻辑代码无需重启服务器即可生效的部署方案,它的优势也非常明显:

  • 无需重启服务意味着用户连接不会中断,尤其对于大量长链接 hold 的应用
  • 文件更新加载缓存是一个非常快的过程,可以完成毫秒级别的应用更新
  • 热更新的副作用也非常多,比如常见的内存泄露(资源泄露),本文将以 clear-module 和 decache 这两个下载量比较高的热门热更辅助模块来探讨下热更究竟会给我们的应用带来哪些问题。

    热更实现原理

    在开始谈热更新的问题之前,我们首先要了解下 Node.js 的模块机制的概貌,这样对于后面它带来的问题将能有更加深刻的理解和认识。

    Node.js 自己实现的模块加载机制如下图所示:


    简单地说父模块 A 引入子模块 B 的步骤如下:

  • 判断子模块 B 缓存是否存在
  • 如果不存在则对 B 进行编译解析
  • 添加 B 模块缓存至require.cache(其中 key 为模块 B 的全路径)
  • 添加 B 模块引用至父模块 A 的children数组中
  • 如果存在,判断父模块 A 的children数组中是否存在 B,如不存在则添加 B 模块引用。
  • 其实到了这里,我们已经可以发现要实现没有内存泄露的热更新,需要断开待热更模块的以下引用链路:


    这样当我们再次去require子模块 B 的时候,就会重新从磁盘读取 B 模块的内容然后进行编译引入内存,据此实现了热更的能力。

    实际上,第一节中提到的clear-moduledecache两个包都是按照这个思路实现的模块热更,当然它们考虑的会更加完善一些,比如将子模块 B 本身的依赖也一并清除,以及对于循环引用场景的处理。

    那么,借助于这两个模块,Node.js 应用的热更新是不是就完美无缺了呢?我们接着看。

    问题一:内存泄露

    内存泄露是一个非常有意思的问题,凡是进入 Node.js 全栈开发深水区的同学基本或多或少都会遇到内存泄露的问题,那么从我个人的故障排查定位经验来说,开发者其实不需要畏惧内存泄露,因为相比其它摸不着头脑的问题,内存泄露是一个只要你熟悉代码并且肯花时间百分百可解的故障类型。

    这里我们来看看看似清除了所有旧模块引用的热更方案,又会以怎样的形式产生内存泄露现象。

    为例,继续修改我们的 uploda_mod.js:

    中去掉上面的 utils.js,保持只对 update_mod.js 进行重复热更:

    文件,可以看到这次又双叕泄露了,随着 update_mod.js 热更,堆内存迅速上升最后 OOM。

    在这个案例中,非幂等执行的子模块产生泄露的原因稍微复杂一些,涉及到 lodash 模块重复编译执行会造成闭包循环引用。

    其实会发现,引入模块对开发者是不可控的,换句话说开发者是无法确认自己是否引入了可以幂等执行的公共模块,那么对于像 lodash 这种无法幂等执行的库,热更就会造成其产生内存泄露。

    问题二:资源泄露

    讲完了热更可能引发的内存问题场景,我们来看看热更会导致的另一类相对更加无解一些资源泄露问题。

    我们依旧以简单的例子来进行说明,首先还是构造index.js

    \'use strict\';

    const cleanCache = require(\'clear-module\');

    let mod = require(\'./update_mod.js\');

    setInterval(() => 
      cleanCache(\'./update_mod.js\');
      mod = require(\'./update_mod.js\');
      console.log(\'-------- 热更新结束 --------\')
    1000);

    这次我们直接使用clear-module进行热更新操作,引入待热更模块update_mod.js如下:

    \'use strict\';

    const start = new Date().toLocaleString();

    setInterval(() => console.log(start), 1000);

    update_mod.js中我们创建了一个定时任务,以 1s 的间隔输出模块第一次被引入时的时间。

    最后执行node index.js可以看到如下结果:

    2022/1/21 上午9:37:29
    -------- 热更新结束 --------
    2022/1/21 上午9:37:29
    2022/1/21 上午9:37:30
    -------- 热更新结束 --------
    2022/1/21 上午9:37:29
    2022/1/21 上午9:37:30
    2022/1/21 上午9:37:31
    -------- 热更新结束 --------
    2022/1/21 上午9:37:29
    2022/1/21 上午9:37:30
    2022/1/21 上午9:37:31
    2022/1/21 上午9:37:32
    -------- 热更新结束 --------
    2022/1/21 上午9:37:29
    2022/1/21 上午9:37:30
    2022/1/21 上午9:37:31
    2022/1/21 上午9:37:32
    2022/1/21 上午9:37:33
    -------- 热更新结束 --------
    2022/1/21 上午9:37:29
    2022/1/21 上午9:37:30
    2022/1/21 上午9:37:31
    2022/1/21 上午9:37:32
    2022/1/21 上午9:37:33
    2022/1/21 上午9:37:34

    显然,clear-module虽然正确清除了热更模块旧引用,但是旧模块内部的定时任务并没有被一起回收进而产生了资源泄露。

    实际上,这里的定时任务只是资源中的一种而已,包括socketfd在内的各种系统资源操作,均无法在仅仅清除掉旧模块引用的场景下自动回收。

    问题三:ESM 喵喵喵?

    不管是decache还是clear-module,都是在 Node.js 实现的 CommonJS 模块机制的基础上进行的热更逻辑整合。

    但是整个前端发展到今天,原生 ECMA 规范定义的模块机制为 ESModule(简称 ESM),因为是规范定义的,所以其实现是在引擎层面,对应到 Node.js 这一层则是由 V8 实现的,因此目前的热更无法作用于 ESM 模块。

    不过在我看来,基于 CommonJS 的热更因为实现在更加上层,会暗藏各种坑所以非常不推荐在生产中使用,但是基于 ESM 的热更如果规范能定义完整的模块加载和卸载机制,反而是真正的热更新方案的未来。

    Node.js 在这一块也有对应的实验特性可以加以利用,详情参见:ESM Hooks。(https://nodejs.org/dist/latest/docs/api/esm.html#esm_hooks)不过目前其仅处于 Stability: 1 的状态,需要持续观望下。

    问题四:模块版本混乱

    Node.js 的热更新实际上并不是很多同学想象中的那种全局旧模块替换,因为缓存机制可能会导致内存中同时存在多个被热更模块的不同版本,从而造成一些难以定位的奇怪 Bug。

    我们继续构造一个小例子来进行说明,首先编写待热更模块update_mod.js

    \'use strict\';

    const version = \'v1\';

    module.exports = () => 
      return version;
    ;

    然后添加一个utils.js来正常使用此模块:

    \'use strict\';

    const mod = require(\'./update_mod.js\');

    setInterval(() => console.log(\'utils\', mod()), 1000);

    接着编写启动入口index.js进行热更新操作:

    \'use strict\';

    const cleanCache = require(\'clear-module\');

    let mod = require(\'./update_mod.js\');

    require(\'./utils.js\');

    setInterval(() => 
      cleanCache(\'./update_mod.js\');
      mod = require(\'./update_mod.js\');
      console.log(\'index\', mod())
    1000);

    此时当我们执行node index.js且不更改update_mod.js时可以看到:

    utils v1
    index v1
    utils v1
    index v1

    说明内存中的update_mod.js都是v1版本。

    无需重启刚才的服务,我们修改update_mod.js中的version

    // update_mod.js
    const version = \'v2\';

    接着观察到输出变成了:

    index v1
    utils v1
    index v2
    utils v1
    index v2
    utils v1

    index.js中进行了热更新操作,因此它重新require到的update_mod.js变成了最新的v2版本,而utils.js中并不会有任何变化。

    类似这种一个模块多个版本的状况,不仅会增加线上故障的问题定位难度,某种程度上,它也造成了内存泄露。

    适合热更新的场景

    抛开场景谈问题都是耍流氓,虽然写了这么多热更新存在的问题,但是确实也有非常模块热更新的使用场景,我们从线上和线下两个维度来探讨下。

    对于线下场景,轻微的内存和资源的泄露问题可以让位于开发效率,所以热更新非常适合于框架在 dev 模式下的单模块加载与卸载。

    而对于线上场景,热更新也并非一无用处,比如明确父子依赖一对一且不创建资源属性的内聚逻辑模块,可以通过合适的代码组织来进行热插拔,达到无缝发布更新的目的。

    最后总的来说,因为不熟悉而给应用下毒的风险与热更的收益,就目前我个人还是比较反对将热更新技术用户线上的生产环境中;而如果后面对 ESM 模块的加载与卸载机制能明确下沉至规范由引擎实现,可能才是热更新真正可以广泛和安全使用的恰当时机。

    一些总结

    前几年参与维护 AliNode 的过程中,处理了多起热更新引起的内存泄露问题,恰好借着编写本文的机会对以前的种种案例进行了回顾。

    目前实现热更新的模块其实都可以归结到 “黑魔法” 一类中,与 “黑科技” 相比,“黑魔法” 是一把双刃剑,使用之前还需要谨慎切勿伤到自己。


    关注「Alibaba F2E」微信公众号把握阿里巴巴前端新动向

    node web 应用热更新

    在每次更改完 node.js 项目后,我们都需要先将 node.js停止(快捷键: Ctrl+C),然后再通过命令再次运行,这样特别麻烦。这里我推荐使用 supervisor工具,

    npm 安装命令为:npm install -g supervisor

    这样我们启动 node.js 项目命令改为 supervisor app.js,更改项目后只需要保存,刷新浏览器页面就可以得到更改后的结果了。

    以上是关于浅谈 Node.js 热更新的主要内容,如果未能解决你的问题,请参考以下文章

    nodejs热更新

    Node.Js的热更新服务——supervisor

    node web 应用热更新

    使用Browsersync热更新热替换,解放F5

    浅谈 Node.js 单线程模型

    浅谈Node.js单线程模型