Hintergrund
Ich glaube, dass Studenten, die Webanwendungen mit Node.js entwickelt haben, mit dem Problem zu kämpfen haben müssen, dass neu geänderter Code neu gestartet werden muss, bevor der Node.js-Prozess aktualisiert werden kann. Studenten, die es gewohnt sind, PHP für die Entwicklung zu verwenden, werden es als sehr ungeeignet empfinden. Wie erwartet ist PHP die beste Programmiersprache der Welt. Das manuelle Neustarten des Prozesses ist nicht nur eine sehr lästige Doppelarbeit, sondern wenn der Umfang der Anwendung größer wird, wird die Startzeit allmählich nicht mehr zu vernachlässigen.
Natürlich werden Sie sich als Programmierer, egal welche Sprache Sie verwenden, von solchen Dingen nicht quälen lassen. Der direkteste und universellste Weg, diese Art von Problem zu lösen, besteht darin, Dateiänderungen zu überwachen und den Prozess neu zu starten. Diese Methode wurde auch von vielen ausgereiften Lösungen bereitgestellt, wie etwa dem aufgegebenen Node-Supervisor, dem mittlerweile beliebten PM2 oder dem relativ leichten Node-Dev usw., die alle auf dieser Idee basieren.
Dieser Artikel bietet eine weitere Idee, mit der Sie mit nur einer kleinen Änderung einen echten Null-Neustart-Hot-Update-Code erreichen und das lästige Code-Update-Problem bei der Entwicklung von Webanwendungen mit Node.js lösen können.
Allgemeine Idee
Apropos Code-Hot-Update: Das bekannteste ist derzeit die Hot-Update-Funktion der Erlang-Sprache. Diese Sprache zeichnet sich durch hohe Parallelität und verteilte Programmierung aus. Ihre Hauptanwendungsszenarien sind Bereiche wie Wertpapierhandel und Spieleserver . . Diese Szenarien erfordern mehr oder weniger, dass Dienste über Mittel zum Betrieb und zur Wartung während des Betriebs verfügen, und die Hot-Aktualisierung von Code ist ein sehr wichtiger Teil davon, daher können wir zunächst einen kurzen Blick auf den Ansatz von Erlang werfen.
Da ich Erlang noch nie verwendet habe, sind die folgenden Inhalte allesamt Hörensagen. Wenn Sie ein detailliertes und genaues Verständnis der Code-Hot-Update-Implementierung erhalten möchten, konsultieren Sie am besten die offizielle Dokumentation.
Das Laden des Codes von Erlang wird von einem Modul namens code_server verwaltet. Mit Ausnahme einiger notwendiger Codes beim Start wird der größte Teil des Codes von code_server geladen.
Wenn code_server feststellt, dass der Modulcode aktualisiert wurde, lädt er das Modul neu. Anschließend werden neue Anforderungen mit dem neuen Modul ausgeführt, während noch ausgeführte Anforderungen weiterhin mit dem alten Modul ausgeführt werden.
Das alte Modul wird nach dem Laden des neuen Moduls als „alt“ gekennzeichnet, und das neue Modul wird als „aktuell“ gekennzeichnet. Beim nächsten Hot-Update scannt und beendet Erlang die alten Module, die noch ausgeführt werden, und aktualisiert die Module dann weiterhin gemäß dieser Logik.
Nicht alle Codes in Erlang erlauben Hot-Updates. Basismodule wie Kernel, Stdlib, Compiler und andere Basismodule dürfen standardmäßig nicht aktualisiert werden
Wir können feststellen, dass Node.js auch ein Modul hat, das code_server ähnelt, nämlich das Anforderungssystem, daher sollte Erlangs Ansatz auch auf Node.js ausprobiert werden. Wenn wir den Ansatz von Erlang verstehen, können wir die Schlüsselprobleme bei der Lösung von Code-Hot-Updates in Node.js grob zusammenfassen
So aktualisieren Sie den Modulcode
So bearbeiten Sie Anfragen mit dem neuen Modul
So geben Sie Ressourcen alter Module frei
Dann analysieren wir diese Problempunkte einzeln.
So aktualisieren Sie den Modulcode
Um das Problem der Modulcode-Aktualisierung zu lösen, müssen wir die Modulmanager-Implementierung von Node.js lesen und direkt auf module.js verlinken. Durch einfaches Lesen können wir feststellen, dass der Kerncode in Module._load liegt. Vereinfachen wir den Code und veröffentlichen ihn.
// 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;
Sie können feststellen, dass der Kern davon Module._cache ist. Solange dieser Modulcache geleert ist, lädt der Modulmanager den neuesten Code neu, wenn er das nächste Mal benötigt wird.
Schreiben Sie ein kleines Programm, um es zu überprüfen
// 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';
Führen wir main.js aus und ändern gleichzeitig den Inhalt von code.js. Wir können feststellen, dass unser Code in der Konsole erfolgreich auf den neuesten Code aktualisiert wurde.
Das Problem, dass der Modulmanager den Code aktualisiert, ist also gelöst. Schauen wir uns als Nächstes an, wie wir das neue Modul tatsächlich in der Webanwendung ausführen können.
So nutzen Sie das neue Modul zur Bearbeitung von Anfragen
Um den Nutzungsgewohnheiten aller besser gerecht zu werden, verwenden wir Express direkt als Beispiel, um dieses Problem zu erweitern. Tatsächlich können die meisten Webanwendungen mit einer ähnlichen Idee angewendet werden.
Erstens: Wenn unser Service wie die DEMO von Express ist und sich der gesamte Code im selben Modul befindet, können wir das Modul nicht im laufenden Betrieb laden
var express = require('express'); var app = express(); app.get('/', function(req, res){ res.send('hello world'); }); app.listen(3000);
Um ein Hot-Loading zu erreichen, benötigen wir, genau wie die in Erlang nicht erlaubten Basisbibliotheken, einen Basiscode, der nicht im Hot-Update aktualisiert werden kann, um den Aktualisierungsprozess zu steuern. Und wenn ein Vorgang wie app.listen erneut ausgeführt wird, unterscheidet sich das nicht wesentlich vom Neustart des Node.js-Prozesses. Daher benötigen wir cleveren Code, um häufig aktualisierten Geschäftscode von selten aktualisiertem Basiscode zu isolieren.
// 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 () { });
Wenn das code.js-Modul aktualisiert und alle Referenzen entfernt werden, wird auch ModulA automatisch freigegeben, solange es nicht von anderen unveröffentlichten Modulen, einschließlich unserer internen Ereignis-Listener, referenziert wird.
Es gibt nur ein fehlerhaftes EventEmitter-Anwendungsszenario, das unter diesem System nicht behandelt werden kann, nämlich dass code.js bei jeder Ausführung auf Ereignisse für ein globales Objekt wartet, was zu ständigen Montageereignissen für das globale Objekt führt Gleichzeitig meldet Node.js schnell, dass zu viele Ereignisbindungen erkannt wurden, was auf einen Speicherverlust hindeuten kann.
An diesem Punkt können Sie sehen, dass das Ressourcenrecycling alter Module kein großes Problem darstellt, solange die von Node.js im Anforderungssystem automatisch hinzugefügten Referenzen verarbeitet werden, obwohl wir das nächste Hot-Update nicht wie Erlang erreichen können Die verbleibenden alten Module unterliegen einer feinkörnigen Kontrolle wie dem Scannen, aber wir können das Problem der Ressourcenfreigabe alter Module durch angemessene Vermeidungsmethoden lösen.
In Webanwendungen besteht ein weiteres Referenzproblem darin, dass unveröffentlichte Module oder Kernmodule Verweise auf Module haben, die im laufenden Betrieb aktualisiert werden müssen, z. B. app.use. Infolgedessen können die Ressourcen des alten Moduls nicht freigegeben werden, und neue Anfragen können nicht korrekt bearbeitet werden. Verwenden Sie neue Module zur Bearbeitung. Die Lösung für dieses Problem besteht darin, die offengelegten Einträge globaler Variablen oder Referenzen zu kontrollieren und die Einträge während der Ausführung von Hot-Updates manuell zu aktualisieren. Ein Beispiel ist beispielsweise die Kapselung des Routers in „So verwenden Sie ein neues Modul zur Verarbeitung von Anforderungen“. Unabhängig davon, wie wir auf andere Module in router.js verweisen, werden diese mit der Veröffentlichung des Eintrags veröffentlicht .
Ein weiteres Problem, das zur Ressourcenfreigabe führen kann, sind Vorgänge wie setInterval, die verhindern, dass der Lebenszyklus des Objekts freigegeben wird. Wir verwenden diese Art von Technologie jedoch selten in Webanwendungen und achten daher nicht darauf der Plan.
Epilog
Bisher haben wir die drei Hauptprobleme des Node.js-Code-Hot-Updates in Webanwendungen gelöst. Da Node.js selbst jedoch keinen effektiven Scanmechanismus für beibehaltene Objekte hat, kann es die durch setInterval verursachten Probleme nicht zu 100 % beseitigen. Die Ressource des alten Moduls kann nicht freigegeben werden. Aufgrund dieser Einschränkungen wird diese Technologie im derzeit von uns bereitgestellten YOG2-Framework hauptsächlich in der Entwicklungs- und Debugging-Phase verwendet, um eine schnelle Entwicklung durch Hot-Updates zu erreichen. Codeaktualisierungen in der Produktionsumgebung verwenden weiterhin einen Neustart oder die Hot-Reload-Funktion von PM2, um die Stabilität der Online-Dienste sicherzustellen.
Da Hot-Updates tatsächlich eng mit dem Framework und der Geschäftsarchitektur zusammenhängen, bietet dieser Artikel keine allgemeine Lösung. Lassen Sie uns als Referenz kurz vorstellen, wie wir diese Technologie im YOG2-Framework verwenden. Da das YOG2-Framework selbst die App-Aufteilung zwischen Front-End- und Back-End-Subsystemen unterstützt, besteht unsere Update-Strategie darin, den Code auf der App-Granularität zu aktualisieren. Da Vorgänge wie fs.watch Kompatibilitätsprobleme aufweisen und einige Alternativen wie fs.watchFile mehr Leistung verbrauchen, haben wir gleichzeitig die Testmaschinenbereitstellungsfunktion von YOG2 kombiniert, um das Framework darüber zu informieren, dass es aktualisiert werden muss Hochladen und Bereitstellen von neuem Code. Während der Modul-Cache mit der App-Granularität aktualisiert wird, werden der Routing-Cache und der Vorlagen-Cache aktualisiert, um alle Code-Updates abzuschließen.
Wenn Sie ein Framework wie Express oder Koa verwenden, müssen Sie nur die Methoden im Artikel befolgen und Ihre eigenen Geschäftsanforderungen mit einigen Änderungen am Hauptweg kombinieren, und Sie können diese Technologie gut anwenden.