背景
Node.js を使用して Web アプリケーションを開発した学生は、Node.js のプロセスを更新する前に、新しく変更したコードを再起動する必要があるという問題に悩まされたと思います。開発に PHP を使用することに慣れている学生にとっては、予想通り、PHP は世界で最高のプログラミング言語であると感じるでしょう。手動でプロセスを再起動することは、非常に面倒な作業の重複であるだけでなく、アプリケーションの規模が大きくなると、起動時間は徐々に無視できなくなります。
もちろん、プログラマーとして、どの言語を使用するとしても、そのようなことで苦しむことはありません。この種の問題を解決する最も直接的かつ普遍的な方法は、ファイルの変更を監視し、プロセスを再起動することです。この方法は、放棄されたノードスーパーバイザー、現在普及している PM2、または比較的軽量のノード開発など、多くの成熟したソリューションによっても提供されており、これらはすべてこのアイデアに基づいています。
この記事では、少し変更するだけで、真のゼロリスタート ホット アップデート コードを実現し、Node.js で Web アプリケーションを開発する際の煩わしいコード アップデートの問題を解決できる別のアイデアを提供します。
一般的な考え方
コードのホット アップデートと言えば、現時点で最も有名なのは Erlang 言語のホット アップデート機能です。この言語は、高い同時実行性と分散プログラミングを特徴としており、主なアプリケーション シナリオは証券取引やゲーム サーバーなどです。 。これらのシナリオでは多かれ少なかれ、サービスには運用中の運用とメンテナンスの手段が必要であり、コードのホット アップデートはその非常に重要な部分であるため、最初に Erlang のアプローチを簡単に見てみましょう。
私は Erlang を使用したことがないため、以下の内容はすべて伝聞です。Erlang のコード ホット アップデート実装について詳しく正確に理解したい場合は、公式ドキュメントを参照するのが最善です。
Erlang のコードのロードは code_server というモジュールによって管理されます。起動時に必要なコードを除いて、ほとんどのコードは code_server によってロードされます。
code_server は、モジュール コードが更新されたことを検出すると、モジュールを再ロードします。その後、新しいリクエストは新しいモジュールを使用して実行されますが、まだ実行中のリクエストは引き続き古いモジュールを使用して実行されます。
新しいモジュールがロードされると、古いモジュールには「old」というラベルが付けられ、新しいモジュールには「current」というラベルが付けられます。次回のホット アップデート中に、Erlang はまだ実行中の古いモジュールをスキャンして強制終了し、このロジックに従ってモジュールの更新を続けます。
Erlang のすべてのコードでホット アップデートが許可されるわけではありません。カーネル、stdlib、コンパイラ、その他の基本モジュールなどの基本モジュールは、デフォルトでは更新できません。
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;
それを検証するための小さなプログラムを書いてください
// 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';
モジュール マネージャーによるコード更新の問題は解決されました。次に、新しいモジュールを実際に Web アプリケーションで実行する方法を見てみましょう。
新しいモジュールを使用してリクエストを処理する方法
すべてのユーザーの使用習慣に合わせて、この問題を拡張する例として Express を直接使用します。実際、同様の考え方を使用して、ほとんどの Web アプリケーションに適用できます。まず第一に、私たちのサービスが Express のデモのようなもので、すべてのコードが同じモジュール内にある場合、モジュールをホットロードすることはできません
var express = require('express'); var app = express(); app.get('/', function(req, res){ res.send('hello world'); }); app.listen(3000);
// 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 も自動的に解放されます。
このシステムでは対処できない、不正な形式の EventEmitter アプリケーション シナリオが 1 つだけあります。つまり、code.js は実行されるたびにグローバル オブジェクト上のイベントをリッスンし、グローバル オブジェクト上で継続的にイベントをマウントすることになります。同時に、Node.js は、検出されたイベント バインディングが多すぎることをすぐに通知します。これはメモリ リークの可能性があります。
この時点で、require システム内の Node.js によって自動的に追加された参照が処理されている限り、Erlang のような次のホット アップデートは実現できませんが、古いモジュールのリソースのリサイクルは大きな問題ではないことがわかります。残りの古いモジュールはスキャンなどのきめ細かい制御の対象となりますが、古いモジュールのリソース解放の問題は合理的な回避方法で解決できます。
Web アプリケーションのもう 1 つの参照問題は、未リリースのモジュールまたはコア モジュールに、app.use などのホット アップデートが必要なモジュールへの参照があることです。その結果、古いモジュールのリソースが解放されず、新しいモジュールが解放されません。リクエストを正しく処理できません。処理には新しいモジュールを使用してください。この問題の解決策は、グローバル変数または参照の公開されたエントリを制御し、ホット アップデートの実行中にエントリを手動で更新することです。たとえば、「新しいモジュールを使用してリクエストを処理する方法」のルーターのカプセル化は、このエントリの制御を通じて、router.js で他のモジュールをどのように参照しても、エントリのリリースとともに解放される例です。 。
リソースの解放を引き起こす可能性のあるもう 1 つの問題は、オブジェクトのライフサイクルを解放しないようにする setInterval のような操作です。ただし、この種のテクノロジを Web アプリケーションで使用することはほとんどないため、Web アプリケーションでは注意を払いません。計画。
エピローグ
これまで、Web アプリケーションにおける Node.js コードのホット アップデートに関する 3 つの主要な問題を解決してきました。ただし、Node.js 自体には保持されたオブジェクトに対する効果的なスキャン メカニズムがないため、setInterval によって引き起こされる問題を 100% 排除することはできません。古いモジュールのリソースを解放できません。現在当社が提供しているYOG2フレームワークでは、ホットアップデートによる迅速な開発を実現するため、主に開発・デバッグ期にこの技術を利用しています。実稼働環境でのコード更新では、オンライン サービスの安定性を確保するために、引き続き再起動または PM2 のホット リロード機能が使用されます。
ホット アップデートは実際にはフレームワークやビジネス アーキテクチャと密接に関係しているため、この記事では一般的な解決策を提供しません。参考までに、YOG2 フレームワークでこのテクノロジーがどのように使用されているかを簡単に紹介しましょう。 YOG2 フレームワーク自体はフロントエンド サブシステムとバックエンド サブシステムの間でのアプリの分割をサポートしているため、更新戦略はアプリの粒度でコードを更新することです。同時に、fs.watch のような操作には互換性の問題があり、fs.watchFile などの代替操作にはより多くのパフォーマンスが消費されるため、YOG2 のテスト マシン デプロイメント機能を組み合わせて、更新する必要があることをフレームワークに通知しました。新しいコードのアップロードとデプロイ。アプリの粒度でモジュール キャッシュを更新すると、ルーティング キャッシュとテンプレート キャッシュが更新され、すべてのコードの更新が完了します。
Express や Koa などのフレームワークを使用している場合は、この記事の方法に従い、独自のビジネス ニーズとメイン ルートへのいくつかの変更を組み合わせるだけで、このテクノロジをうまく適用できます。