メインコンテンツへ移動

API 注入メカニズム及びプラグイン起動フロー_VSCode プラグイン開発ノート 2

無料2018-03-09#Node#vscode#vscode extension host#vscode extension activationEvents#vscode插件启动流程

内側から外側へ VS Code の強力なプラグインメカニズムを理解する

はじめに

プラグイン Helloworld には 1 つのサンプル用法があります:

// The module 'vscode' contains the VS Code extensibility API
import * as vscode from 'vscode';

var disposable = vscode.commands.registerCommand('extension.sayHello', () => {
    // Display a message box to the user
    vscode.window.showInformationMessage('Hello World!');
});

プラグインプロセス環境では、vscode モジュールを導入してプラグインが利用可能な API にアクセスできます。少し好奇心があれば、node_modules 下に vscode モジュールが存在せず、しかも vscode モジュール名も define() されていないことに気づくでしょう。どうやら存在しないモジュールを require しているように見えます。では、これはどこから来たのか?

P.S.define() に関するより多くの情報は、[VS Code ソースコード簡析 | Renderer Process 初期化](/articles/vs-code ソースコード簡析/#articleHeader9) を参照

一.require

手掛かりを辿って、まず Node モジュールを導入する時に何が起こるかを見てみましょう?

Node は require(name) 関数を通じてモジュールをロードし、モジュール名 name を传入し、Module インスタンスを返します。大まかなプロセスは以下の通り:

  1. name パラメータが Module._resolveFilename() メソッドを通じて完全なファイルパスにマッピング

  2. cache[fullName] が存在すれば、cache[fullName].exports を返す(キャッシュを優先)。モジュールは 1 回のみロードされ、モジュールロード速度を向上。キャッシュを回避したい場合、require(name) 前に cache[fullName]delete できます。例えば delete require.cache[require.resolve('./my-module.js')]

  3. そうでなければ、対応するファイルのソースコードをロードし、前処理(モジュールレベル変数注入)を行います。Module.prototype.load を参照

  4. 最後に、変換済みのソースコードをコンパイル(実行)し、module.exports の値を返します。Module.prototype._compile を参照

P.S. モジュールキャッシュに関するより多くの情報は、node.js require() cache - possible to invalidate? を参照

シンプルなシーンを見てみましょう。2 つのソースコードファイルがあると仮定:

  // my-modue.js
module.exports = 'my-modue';

// index.js
const m = require('./my-module.js');

エントリーファイルの最初の行 require('./my-modue.js') を実行する大まかなプロセスは:

// module.js
function require(path) {
  return mod.require(path);
}
Module.prototype.require = function(path) {
  return Module._load(path, this, /* isMain */ false);
}
Module._load = function(request, parent, isMain) {
  var filename = Module._resolveFilename(request, parent, isMain);
  var module = new Module(filename, parent);
  Module._cache[filename] = module;
  tryModuleLoad(module, filename);
  return module.exports;
}

その中で tryModuleLoad() は具体的に:

function tryModuleLoad(module, filename) {
  module.load(filename);
}
Module.prototype.load = function(filename) {
  // 上にアクセス可能なすべての node_modules ディレクトリを検索
  this.paths = Module._nodeModulePaths(path.dirname(filename));
  // ファイル拡張子に従ってモジュールをロード
  Module._extensions[extension](this, filename);
}
Module._extensions['.js'] = function(module, filename) {
  // ソースコードを読む
  var content = fs.readFileSync(filename, 'utf8');
  // コンパイル(実行)
  module._compile(internalModule.stripBOM(content), filename);
}
Module.prototype._compile = function(content, filename) {
  // IIFE でモジュールソースコードを包み、モジュールレベル変数を注入。NativeModule.wrap() を参照
  var wrapper = Module.wrap(content);
  // より安全な eval() に相当し、包まれた function ソースコードをコンパイルし、実行可能な Function インスタンスを取得
  var compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
  });
  var dirname = path.dirname(filename);
  // 注入するモジュールレベル require() メソッド
  var require = internalModule.makeRequireFunction(this);
  // モジュールパラメータを注入し、実行
  result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
  // この戻り値は破棄され、役に立たない。モジュール内容は this.exports で持ち出される
  return result;
}

モジュールソースコードの外側に包まれた IIFE はこう:

NativeModule.wrap = function(script) {
  // NativeModule.wrapper[0] = "(function (exports, require, module, __filename, __dirname) { "
  // NativeModule.wrapper[1] = "\n});"
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

簡単に整理すると、実際このプロセスの核心的な作業は:

// 1.ファイルを読む
const moduleScript = fs.readFileSync(fullFilename, 'utf8');
// 2.モジュールを構築(モジュール作用域を隔離し、モジュールレベル変数を宣言)
const wrapped = `(function (exports, require, module, __filename, __dirname) {
  ${moduleScript}
});`;
// 2.5.コンパイルして実行可能モジュールを取得
const moduleFunction = eval(wrapped);
// 3.実行(モジュールレベル変数値を注入)
let exportsHost = {};
moduleFunction.call(exportsHost, exportsHost);
const m = exportsHost;

では、require は(モジュールレベルの)局所変数なので、手を加える(ハイジャック/改ざん)のが不便です。では、必ず Module に何かを施して、存在しない仮想モジュールのロードをサポートしているはずです

P.S.require('internal/module').makeRequireFunction ファクトリーメソッドをハイジャックして require を改ざんしようと考えてはいけません。internal module へのアクセスは許可されていません:

NativeModule.nonInternalExists = function(id) {
  return NativeModule.exists(id) && !NativeModule.isInternal(id);
};
NativeModule.isInternal = function(id) {
  return id.startsWith('internal/');
};

Module._resolveFilename 時に外部者とみなされ、外部から探し、私たちが欲しいあのインスタンスにアクセスできません

二.extension API 注入

require('vscode') のプロセスを debug すると、手を加えられた場所を簡単に見つけられます:

// ref: src/vs/workbench/api/node/extHost.api.impl.ts
function defineAPI(factory: IExtensionApiFactory, extensionPaths: TernarySearchTree<IExtensionDescription>): void {

  // 各拡張機能は独自の api 実装を取得する
  const extApiImpl = new Map<string, typeof vscode>();
  let defaultApiImpl: typeof vscode;

  const node_module = <any>require.__$__nodeRequire('module');
  const original = node_module._load;
  node_module._load = function load(request, parent, isMain) {
    if (request !== 'vscode') {
      return original.apply(this, arguments);
    }

    // ファイル名から拡張機能 id と拡張機能用 api を取得
    const ext = extensionPaths.findSubstr(parent.filename);
    if (ext) {
      let apiImpl = extApiImpl.get(ext.id);
      if (!apiImpl) {
        apiImpl = factory(ext);
        extApiImpl.set(ext.id, apiImpl);
      }
      return apiImpl;
    }

    // デフォルト実装にフォールバック
    if (!defaultApiImpl) {
      defaultApiImpl = factory(nullExtensionDescription);
    }
    return defaultApiImpl;
  };
}

Module._load() メソッドがハイジャックされ、vscode に出会うと apiImpl と呼ばれる仮想モジュールを返します。注意、各プラグインが取得する API は独立しています(おそらくプラグイン安全隔離の考慮で、API をハイジャックして他のプラグインに影響を与えるのを避けるため)

P.S. 注意、require.__$__nodeRequire('module') が必要な理由は、global.require がすでにハイジャックされているからです([VS Code ソースコード簡析 | Renderer Process 初期化](/articles/vs-code ソースコード簡析/#articleHeader9) の loader 部分を参照)。。。VS Code チームのやり方は非常に野性的です

三.プラグインメカニズム初期化フロー

以前 [VS Code 起動フロー](/articles/vs-code ソースコード簡析/#articleHeader9) の UI レイアウト部分で言及:

UI エントリー
src/vs/workbench/electron-browser/bootstrap/index.html
  src/vs/workbench/electron-browser/bootstrap/index.js
    src/vs/workbench/workbench.main js index ファイル
      src/vs/workbench/electron-browser/main.ts
        src/vs/workbench/electron-browser/shell.ts 界面と機能サービスの接入点
          src/vs/workbench/electron-browser/workbench.ts 界面を作成
            src/vs/workbench/browser/layout.ts レイアウト計算,絶対定位

WorkbenchShell の作成から正式に機能エリア UI レイアウトに入り、UI は Shell と呼ばれ、機能を承载する容器(「殻」)として用いられます

つまり src/vs/workbench/electron-browser/shell.ts から界面の作成、及び界面と機能サービスの对接に着手します。前回は主起動フロー関連の部分のみ关注しましたが、今回はプラグインメカニズムの初期化フローを見てみます

プラグインメカニズム初期化関連ファイルの遞進関係:

src/vs/workbench/electron-browser/shell.ts 界面と機能サービスの接入点
  src/vs/workbench/services/extensions/electron-browser/extensionService.ts
    src/vs/workbench/services/extensions/electron-browser/extensionHost.ts
      src/vs/workbench/node/extensionHostProcess.ts
        src/vs/workbench/node/extensionHostMain.ts

ExtensionService の作成

src/vs/workbench/electron-browser/shell.tscreateContents() メソッドは ExtensionService に関係し、主要内容は以下の通り:

private createContents(parent: Builder): Builder {
  // Instantiation service with services
  const [instantiationService, serviceCollection] = this.initServiceCollection(parent.getHTMLElement());
}
private initServiceCollection(container: HTMLElement): [IInstantiationService, ServiceCollection] {
  this.extensionService = instantiationService.createInstance(ExtensionService);
  serviceCollection.set(IExtensionService, this.extensionService);
}

ExtensionServicesrc/vs/workbench/services/extensions/electron-browser/extensionService.ts から来て、关键部分は以下の通り:

lifecycleService.when(LifecyclePhase.Running).then(() => {
  // extensionHost の作成と拡張機能スキャンを遅延
  // workbench 実行後まで
  // 1.extensionHost を初期化
  this._startExtensionHostProcess([]);
  // 2.インストール済みのプラグインをスキャン
  this._scanAndHandleExtensions();
});

private _startExtensionHostProcess(initialActivationEvents: string[]): void {
  // すでに存在する ExtensionHost プロセスを停止
	  this._stopExtensionHostProcess();
  // ExtensionHostProcessWorker を作成し起動
  this._extensionHostProcessWorker = this._instantiationService.createInstance(ExtensionHostProcessWorker, this);
  this._extensionHostProcessProxy = this._extensionHostProcessWorker.start().then(
    //...
  );
  // シーン別にトリガー激活するイベントを登録(例えば特定ファイルを開いた時にのみプラグインを激活)
  this._extensionHostProcessProxy.then(() => {
    initialActivationEvents.forEach((activationEvent) => this.activateByEvent(activationEvent));
  });
}

まず ExtensionHostProcessWorker を通じて extensionHost プロセスを起動し、同時にインストール済みのプラグインをスキャンします。extensionHost プロセス作成完毕后、按需激活するプラグインを登録します(activationEvents["*"] でないプラグイン)

extensionHost プロセスの起動

ExtensionHostProcessWorkersrc/vs/workbench/services/extensions/electron-browser/extensionHost.ts から来て、关键部分は以下の通り:

public start(): TPromise<IMessagePassingProtocol> {
  const opts = {
    env: objects.mixin(objects.deepClone(process.env), {
      AMD_ENTRYPOINT: 'vs/workbench/node/extensionHostProcess'
    })
  };

  // 当前プロセスのフォークとして Extension Host を実行
  this._extensionHostProcess = fork(URI.parse(require.toUrl('bootstrap')).fsPath, ['--type=extensionHost'], opts);
}

この fork() は一見 AMD_ENTRYPOINT と関係ないように見えますが、実際、fork で得られた子プロセスのエントリーは:

// URI.parse(require.toUrl('bootstrap')).fsPath
// toUrl 変換後に対応
// out/bootstrap

つまり src/bootstrap.js で、关键部分は以下の通り:

require('./bootstrap-amd').bootstrap(process.env['AMD_ENTRYPOINT']);

遠回りして戻ってきて、loader を通じてエントリーファイルを実行するため:

var loader = require('./vs/loader');
exports.bootstrap = function (entrypoint) {
  loader([entrypoint], function () { }, function (err) { console.error(err); });
};

では今、エントリー src/vs/workbench/node/extensionHostProcess.ts に足を踏み入れます:

// 物事をセットアップ
const extensionHostMain = new ExtensionHostMain(renderer.rpcProtocol, renderer.initData);
onTerminate = () => extensionHostMain.terminate();
return extensionHostMain.start();

また ExtensionHostMain に転じ、対応ソースコードファイルは src/vs/workbench/node/extensionHostMain.ts

public start(): TPromise<void> {
  return this._extensionService.onExtensionAPIReady()
    // 最も急ぐ一批のプラグインを起動
    .then(() => this.handleEagerExtensions())
    .then(() => this.handleExtensionTests())
    .then(() => {
      this._logService.info(`eager extensions activated`);
    });
}
// "eager" 激活拡張機能を処理
private handleEagerExtensions(): TPromise<void> {
  this._extensionService.activateByEvent('*', true).then(null, (err) => {
    console.error(err);
  });
  return this.handleWorkspaceContainsEagerExtensions();
}

ここまでで、無条件で起動するプラグインも激活され、プラグインメカニズム初期化が完了します

プラグインの激活

具体的なプラグイン激活プロセスは非常に煩雑で、Extension Pack 型プラグイン(プラグインが他のプラグインに依存することを許可)をサポートしているため、プラグインを激活するにはプラグイン依存ツリーも処理する必要があり、依存するすべてのプラグインが成功裡に激活された後でなければ、当前のプラグインを激活できません

P.S. 具体的なプロセスを理解したい場合、この 2 つのファイルを見ることができます:

src/vs/workbench/api/node/extHostExtensionService.ts
src/vs/workbench/api/node/extHostExtensionActivator.ts

篇幅の制限により、煩雑な依存処理環節をスキップして、直接プラグイン pkg.main エントリーファイルをロードする部分を見ます:

private _doActivateExtension() {
  // require でプラグインエントリーファイルをロード
  loadCommonJSModule(this._logService, extensionDescription.main, activationTimesBuilder),
		this._loadExtensionContext(extensionDescription).then(values => {
    // その activate() メソッドを実行
    return ExtHostExtensionService._callActivate(this._logService, extensionDescription.id, <IExtensionModule>values[0], <IExtensionContext>values[1], activationTimesBuilder);
  });
}

// エントリーファイルをロード
function loadCommonJSModule() {
  r = require.__$__nodeRequire<T>(modulePath);
  return TPromise.as(r);
}
// 約定の activate() メソッドを実行
private static _callActivateOptional() {
  if (typeof extensionModule.activate === 'function') {
    const activateResult: TPromise<IExtensionAPI> = extensionModule.activate.apply(global, [context]);
  }
}

直接 node require でプラグインエントリーファイルを実行してモジュールインスタンスを取得し、その後 apply でその activate メソッドを呼び出し、プラグインが走り出します

四.プロセスモデル

至此、VS Code 内に少なくとも 3 つのプロセスがあることを理解しました:

  • Electron Main Process:App 主プロセス

  • Electron Renderer Process:UI プロセス

  • Extension Host Process:プラグインホストプロセス、プラグインに実行環境を提供

その中で Extension Host Process は(各 VS Code 窗体)1 つのみ存在し、すべてのプラグインが該プロセスで実行されます。各プラグインが独立したプロセスというわけではありません

注意、プラグインホストプロセスは普通の Node プロセスchildProcess.fork() で生成)で、Electron プロセスではなく、しかもelectron の使用が制限されています

// 環境変数
ELECTRON_RUN_AS_NODE: '1'

したがってプラグイン実行環境で require('electron').BrowserWindow.getAllWindows() を使用して UI を曲線的に変更することはできません

P.S. プラグイン UI カスタマイズ��力に関する議論は、access electron API from vscode extension を参照

プロセス間通信方式

      <Electron IPC>
Main ---------------- Renderer
 |
 |
 | <Child Process IPC>
 |
 |
Extension Host

その中で、Extension Host と Main 間の通信は fork() 内蔵の IPC を通じて完了され、具体的に:

// extension host からのログをサポート
this._extensionHostProcess.on('message', msg => {
  if (msg && (<IRemoteConsoleLog>msg).type === '__$console') {
    this._logExtensionHostMessage(<IRemoteConsoleLog>msg);
  }
});

これは単方向通信(プラグイン -> Main)のみで、実際には this._extensionHostProcess.send({msg}) を通じて另一半(Main -> プラグイン)を完了できます

P.S. プロセス間通信に関するより多くの情報は、[Nodejs プロセス間通信](/articles/nodejs プロセス間通信/) を参照

参考資料

コメント

コメントはまだありません

コメントを書く