VM moduleを利用してLambdaのような機能を提供する
概要
Express + VMモジュールを使用し、Lambdaのような機能を提供するサーバの構築方法、またVMの注意点についてまとめます。
vmとは
VMとは同プロセス上で別のNodeスクリプトの実行をサポートするモジュールです。eval
に似ていますが、Contextの値を外から代入できる大きな違いがあります。
また同プロセス上で動くので値の共有が可能です。以下が実行例です。
const vm = require('vm'); const code = `() => num + 1`; const func = vm.runInNewContext(code, { num: 1 }); func(); // 2
このようにVM上で生成された関数をVM外で使用することができます。
サーバ概要
下図がざっくりとしたサーバ構成図です。
クライアントがリクエストとともにクライアントバージョンを送信し、そのバージョンとマッチしたVMスクリプトをDBから取得します。また一度取得したスクリプトはパフォーマンス向上のためオンメモリに保持しています。
そのスクリプトをVM上で起動し、サーバのAPIをContext経由で渡すことでスクリプト上からサーバへのリクエストを可能にします。 以下が実行例です。
// script1.js handlers.getData = async () => ({ a: await apis.getDataA(), b: await apis.getDataB(), c: await apis.getDataC(), });
// vm.js const vm = require('vm'); const fs = require('fs'); const path = require('path'); const script = fs.readFileSync(path.join(__dirname, 'script1.js')); // 実際はDBから取得 const apis = { async getDataA() { return 'a'; }, async getDataB() { return 'b'; }, async getDataC() { return 'c'; }, }; const handlers = {}; const context = { apis, handlers, }; vm.runInNewContext(script, context); (async () => { const res = await handlers.getData(); console.log(res); // { a: 'a', b: 'b', c: 'c' } })();
$ node vm.js
このようにVM上で作成されたhandlersをVM外で実行することにより、一つのクラスタで複数のバージョンの異なるクライアントをサポートしています。
使用上の注意
Objectの扱い
JavaScriptでは全てのオブジェクトはObject型を継承します。例えばArrayはObject型を継承しているため、以下のようにinstanceof
がtrue
になることが確認できます。
const array = []; console.log(array instanceof Array); // true console.log(array instanceof Object); // true
しかしVM上ではVM外とは異なるObject型が生成され、VM内の全てのObject型はVM外とは異なるObject型を継承します。
const vm = require('vm'); const code = `() => []`; const func = vm.runInNewContext(code, {}); const res = func(); console.log(res instanceof Array); // false console.log(res instanceof Object); // false
逆も同様な問題があります。
const vm = require('vm'); const code = `() => array instanceof Array`; const func = vm.runInNewContext(code, { array: [] }); func(); // false
Arrayの場合はArray.isArray
やObject.prototype.toString
を使用することで、Arrayであるかどうかの判別が可能です。
console.log(Array.isArray(res)); // true console.log(Object.prototype.toString.call(res)); // [object Array]
しかしVM上で生成された値をサードパーティのライブラリに代入する際、内部でinstandeof
等を使用している可能性があるためこの方法が安全とは言えません。
エラーハンドリング
エラーハンドリングでも同様な問題があります。
const vm = require('vm'); const code = `() => { throw new TypeError() }`; const func = vm.runInNewContext(code, {}); try { func(); } catch (e) { if (e instanceof TypeError) { console.log('type error!'); } else if (e instanceof Error) { console.log('common error!'); } else { console.log('unkown error!'); } } // unknown error!
つまりVM上で生じたTypeError
はinstnaceof
では判別することができません。
util.inheritsを使った解決方法
util.inherits
とは、Node上でprototype関数を継承するために使われる関数です。
現在はextendsシンタックスを使用してclassを継承することが主流ですが、classの実態はprototype関数であり、util.inherits
を使用して拡張することができます。
基本的な使い方は以下の通りです。
const util = require('util'); // prototype function function MyError1() {} new MyError1() instanceof Error; // false util.inherits(MyError1, Error); new MyError1() instanceof Error; // true // class class MyError2 {} new MyError2() instanceof Error; // false util.inherits(MyError2, Error); new MyError2() instanceof Error; // true
これを踏まえVM上のclassの継承関係を解決していきます。以下のように呼び出し元のclassを渡し、そのclassをVM内で継承することにより解決することができます。
const util = require('util'); const inherit = 'util.inherits(Array, classMap.Array);'; const code = `() => []`; const func = vm.runInNewContext(`${inherit}${code}`, { util, classMap: { Array } }); const res = func(); console.log(res instanceof Array); // true console.log(res instanceof Object); // true
他のclassも同様な問題があるため、こちらから適宜追加してください。
まとめ
NodeでLambdaのような機能を提供するサーバの構築方法を紹介しました。 750KBくらいのスクリプトを使用していますが、VMによるオーバーヘッドは5ms程度で無視できるレベルかと思います。
VMは様々な用途に使用でき、Nodeの幅が広がる面白いモジュールです。 VM使ったライブラリも公開してます、ぜひ試してみてください。