跳到主要內容
黯羽輕揚每天積累一點點

API 注入機制及插件啟動流程_VSCode 插件開發筆記 2

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

由內而外地了解 VS Code 強大的插件機制

寫在前面

插件 Helloworld 有一種示例用法:

// 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(優先走緩存),一個模塊只加載一次,從而提高模塊加載速度。不想走緩存的話,可以在 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?

看一個簡單場景,假設有兩個源碼文件:

  // 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 {

  // each extension is meant to get its own api implementation
  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);
    }

    // get extension id from filename and api for extension
    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;
    }

    // fall back to a default implementation
    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);
}

ExtensionService 來自 src/vs/workbench/services/extensions/electron-browser/extensionService.ts,關鍵部分如下:

lifecycleService.when(LifecyclePhase.Running).then(() => {
  // delay extension host creation and extension scanning
  // until after workbench is running
  // 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 進程

ExtensionHostProcessWorker 來自 src/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'
    })
  };

  // Run Extension Host as fork of current process
  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

// setup things
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`);
    });
}
// Handle "eager" activation extensions
private handleEagerExtensions(): TPromise<void> {
  this._extensionService.activateByEvent('*', true).then(null, (err) => {
    console.error(err);
  });
  return this.handleWorkspaceContainsEagerExtensions();
}

到這裡,無條件啟動的插件也激活了,插件機制初始化完成

激活插件

具體的插件激活過程相當繁瑣,因為支持 Extension Pack 型插件(允許插件依賴其它插件),所以激活插件還要處理插件依賴樹,等依賴的所有插件成功激活之後,才激活當前插件

P.S. 想要了解具體過程的話,可以看這兩個文件:

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 窗體)只存在一個,所有插件都在該進程執行,而不是每個插件一個獨立進程

注意,插件宿主進程是個普通的 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 來完成的,具體如下:

// Support logging from 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 進程間通信/)

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論