为什么要热更新?为什么热更新会带来问题?如何正确地实现热更新?
最近在探索一些 NodeJS 的模块热更新的内容。偶然见发现了热更新模块可能会导致内存泄漏的问题。而最近正在编写的一些项目势必要用到热更新。那么我们就必然需要回答开头的三个问题。
为什么要热更新
当我谈到热更新的时候,我的想法是:在不打断客户端与服务端通信且不重置上下文的情况下,替换业务逻辑。如果能做到这样的热更新,那么在开发过程中就可以把对源码的更改立刻反映到结果上、加快开发迭代的速度;在生产过程中就可以不打断客户端体验的情况下对系统进行更新升级。这在互联网开发和游戏开发当中可以说是不可或缺的功能。
热更新的问题和解决方案
上下文接管
热更新的首个问题就是上下文的接管问题,即被热更新的模块如何获取旧模块的上下文。
我们以一个例子说明:
const HotModule = function () {
this.name = "HotModule";
this.elements = [];
this.addElement = (e) => {
this.elements.push(e);
};
this.doThings = () => {
console.log(this.elements);
};
};
module.exports = HotModule;
这个模块中包含一个成员 elements
用于保存一些元素。如果这个模块在运行时被重载,那么 elements
成员也会随之丢失。
一个解决方案是把上下文作为构造函数的参数:
const HotModule = function (context) {
this.name = "HotModule";
this.context = context || {};
this.context.elements = this.context.elements || [];
this.addElement = (e) => {
this.context.elements.push(e);
};
this.doThings = () => {
console.log(this.context.elements);
};
};
module.exports = HotModule;
但这样做的缺点也很明显:现在我们需要手动检查上下文中是否存在我们需要的成员,否则要手动创建。如果我们约定模块的构造函数必须存在某些成员(比如用 TypeScript 的接口类型),那么模块系统就必须为每个模块提供初始上下文。
另一个思路是为模块引入生命周期,这样模块就可以将状态存储在模块之外。模块的实现保持为一个纯函数,或者至少让业务逻辑是纯函数:
const Stroage = require('./storage');
const HOT_MODULE_CONTEXT_KEY = "HotModuleContext";
const HotModule = function () {
this.name = "HotModule";
this.onLoad = () => {
this.context = Storage.loadContext(HOT_MODULE_CONTEXT_KEY, { elements: [] });
};
this.onUnload = () => {
Storage.saveContext(HOT_MODULE_CONTEXT_KEY, this.context);
};
this.addElement = (e) => {
this.context.elements.push(e);
};
this.doThings = () => {
console.log(this.context.elements);
};
};
module.exports = HotModule;
生命周期也可以使得模块有机会清理自己产生的副作用(比如钩子注册、数据库读写等)而无需引入过于复杂的机制。
模块依赖
如果脚本之间存在依赖,如图:
模块 B
依赖于模块 A
. 此时重载 A
, 则会变成:
模块 B
仍然持有旧的 A
的引用,从而导致 A
不会被释放,同时 A'
也不会成为新的模块。此时必须通知模块 B
更新依赖关系。
但是形如这样的代码是难以处理依赖关系更新的:
/*** A.js ***/
class A { }
module.exports = A();
/*** B.js ***/
const a = require('./A');
class B { }
因为 B!a
在 B
加载时就已经确定。如果我们决定在 A
重载后,重载所有依赖于 A
的模块,那么模块管理器需要维护模块之间的关系:
/*** B.js ***/
const a = require('./A');
const B = function () {
this.depends = [ 'A' ]; // read by ModuleManager
};
重载依赖还需要额外处理循环依赖等例外情况。如果我们绕过 NodeJS 提供的 require
而使用自制 require
处理呢?
/*** ModuleManager.js ***/
const ModuleManager = function () {
this.loadModules = function (dir) {
// populates this.modules
// name: instance
};
this.reloadModule = function (path) {
// replace this.modules[name] with new instance
};
this.modules = {};
this.getModule = function (name) {
return new Proxy({}, {
get: function (obj, prop) {
return this.modules[name][prop]; // prevents caching
};
});
};
};
/*** B.js ***/
const mm = require('./ModuleManager');
const a = mm.getModule('A');
const B = function () {
};
那么 B!a
作为代理对象,在获取内部方法时(如 a.doThings()
)将会触发对应的截获器 (trap), 并执行模块管理器中的 a
实例的 doThings
方法。使用代理方法对于用户而言是透明的,这可以使得下游开发更为简便。当然,使用代理对象可能会造成一定的性能影响,对于热门且不经常更新的模块,可以考虑整合到不可热更新的部分以避开代理对象。
更新间隙
对于一个多线程的程序,很有可能在替换模块实例时,有其他模块请求调用正在被替换的模块。当然,NodeJS 默认是事件驱动的单线程所以这种情况不太会发生。但是如果使用 C++ 配合 Lua 或者 Python, 那么就有可能产生更新间隙。
一个最常见的解决方案是为模块库加读写锁。当有模块更新时,任何读操作都需要等待更新操作(写操作)完成才能继续。
[…] 现在再来个热更新是不是就更爽了呢?当然,这个问题,我们将在之后再讨论。 […]