Melchior

バンクーバーで働くエンジニアの備忘録

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外で使用することができます。

サーバ概要

下図がざっくりとしたサーバ構成図です。

f:id:suguru03:20180713132219p:plain

クライアントがリクエストとともにクライアントバージョンを送信し、そのバージョンとマッチした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型を継承しているため、以下のようにinstanceoftrueになることが確認できます。

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.isArrayObject.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上で生じたTypeErrorinstnaceofでは判別することができません。

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使ったライブラリも公開してます、ぜひ試してみてください。

リンク