cover_image

vscode插件体系详解

kellanzhang 腾讯AlloyTeam
2020年09月09日 11:13

vscode 作为一款网红 IDE,其丰富的插件让人叹为观止,通过 vscode 提供的插件机制,我们几乎可以自定义 vscode 的所有细节。事实上很多 vscode 的核心功能也是通过插件实现的。

本文我们将从以下三个方面详述 vscode 的插件机制:

  • 插件的基本结构

  • 插件执行环境

  • 插件的运行流程

阅读本文后续内容,需要对 vscode 的插件开发有基本的了解。关于 vscode 的插件开发可参考 vscode 的官方教程 。

1. 插件基本结构

vscode 的官方教程 中有详细的插件开发文档;并且在 github 上提供了丰富插件案例, 从 UI 定制到代码自动补全都有可借鉴性很高的 demo。本文并不打算详诉插件开发的细节,我们更多的关注 vscode 是如何设计和实现这套插件架构的。

我们首先从一个插件项目的 package.json来了解其基本结构。 package.json中 main 指定了插件的入口函数,而 contributes 和 activationEvents 分别描述的插件的扩展点和触发事件。如下面代码所示:

"main": "./out/extension.js","contributes": {    "commands": [        {      "command": "extension.helloWorld",       "title": "Hello World"   }    ]}"activationEvents": [    "onCommand:extension.helloWorld"]

扩展点和触发事件是两个比较重要的概念,我们先简单解释下,后续讲到插件注册时再详细描述。

contributes(扩展点)用于定义插件要扩展 vscode 哪部分功能;vscode 暴露出多个扩展点,包括 commands (命令面板)、configuration (配置模板)等

activationEvents(触发事件)用于定义插件何时执行,当指定的事件发生时插件才会执行

有一类特殊的插件的 activationEvents为通配符 *,这类插件称为 EagerExtensions,它们会在插件环境初始化完成后自动执行,而不需要其他事件触发。

2. 插件执行环境

vscode 的第三方插件的质量往往难以保证,因此需要设计一套隔离机制,将核心功能和第三方插件的执行环境隔离,保证第三方插件挂了,vscode 的核心功能依然可用。由于不想增加理解成本,本文所讲的 vscode 插件机制是指纯 web 版 vscode,和基于 electron 的桌面版 vscode 原理类似。

插件环境初始化入口的在 vs/workbench/services/extensions/browser/extensionService.ts的构造函数中, _initialize函数中的第一步就是初始化插件执行环境。

protected async _initialize(): Promise<void> {    perf.mark('willLoadExtensions');    this._startExtensionHostProcess(true, []);  //启动插件worker进程,建立rpc通道,注入api    this.whenInstalledExtensionsRegistered().then(() => perf.mark('didLoadExtensions'));    await this._scanAndHandleExtensions();     this._releaseBarrier();}

2.1 环境隔离与通信

(1)worker 进程创建

插件环境初始化的第一步就是创建一个 webworker 用于运行插件逻辑,防止不可信的插件影响到 vscode 核心功能。通过调用 vs/workbench/services/extensions/browser/webWorkerExtensionHostStarter.ts的 start方法创建一个新的webworker作为插件的执行环境。

const url = getWorkerBootstrapUrl(require.toUrl('../worker/extensionHostWorkerMain.js'), 'WorkerExtensionHost');const worker = new Worker(url, { name: 'WorkerExtensionHost' });

(2)主进程与 worker 信道建立

webWorkerExtensionHostStarter.ts的 start方法在创建 webworker 后就封装一个 IMessagePassingProtocol接口返回。如下代码所示:

start(){   .....   const protocol: IMessagePassingProtocol = {        onMessage: emitter.event,  send: vsbuf => {      const data = vsbuf.buffer.buffer.slice(vsbuf.buffer.byteOffset, vsbuf.buffer.byteOffset + vsbuf.buffer.byteLength);       worker.postMessage(data, [data]);   }    }   return protocol;}

该方法通过 webworker 的 onMessage和 postMessage实现一个标准化的信道: IMessagePassingProtocol。 IMessagePassingProtocol接口分别定义了 send方法和 onMessage方法用于向 worker 发送消息和从 worker 接收消息。如下代码所示

export interface IMessagePassingProtocol {    send(buffer: VSBuffer): void;    onMessage: Event<VSBuffer>;}

(3)将信道封装成 RPC 通道

主进程与 worker 信道建立后就可以双向通信了,vscode 中通过对 IMessagePassingProtocol进一步封装,创建一个 RPCProtocol通道。主进程和 worker 进程之间可以通过 RPCProtocol进行模块调用。

比如主进程通过本地方法调用的语法,来完成远程方法调用,比如:

extensionProxy.$compute();

RPCProtocol封装了一切细节,它将方法的调用转换成消息,发送到 worker 进程,如下伪代码所示:

const protocol: IMessagePassingProtocol = ...protocol.send({rpcId, '$compute', ....});

RPCProtocol 的实现原理是利用 Proxy 代理将方法的调用,转换成远程消息的发送。如下代码所示:

export class RPCProtocol extends Disposable implements IRPCProtocol {  constructor((protocol: IMessagePassingProtocol, ...){     super();     this._protocol = protocol;  }  ....  //创建一个proxy,将对本地对象方法的调用转成一个远程调用  private _createProxy<T>(rpcId: number): T {   let handler = {     get: (target: any, name: PropertyKey) => {        //如果方法名以$开头,则转换成远程调用        if (typeof name === 'string' && !target[name] && name.charCodeAt(0) === CharCode.DollarSign) {           target[name] = (...myArgs: any[]) => {              //发送远程消息              return this._remoteCall(rpcId, name, myArgs);           };        }        return target[name];      }    };    return new Proxy(Object.create(null), handler);   }      //拼装远程消息,通过IMessagePassingProtocol发出   private _remoteCall(rpcId: number, methodName: string, args: any[]): Promise<any> {      const msg = MessageIO.serializeRequest(..., rpcId, methodName, ....);      this._protocol.send(msg);   }}

到这里为止,我们实现了:

执行环境的隔离:插件运行在 worker 进程中,保证安全性主进程和 worker 进程的 RPC 通道建立,保证进程间模块互相调用

以一张简单的图,总结本小节涉及到的内容:

图片

2.2 执行环境初始化

在创建 worker 进程后,需要对 worker 的执行环境进行初始化,注入worker进程中可调用的模块。

(1) 插件进程可调用模块的定义

vscode 在 src/vs/workbench/api/worker/extHostExtensionService.ts的 _beforeAlmostReadyToRunExtensions方法中对worker进程执行环境初始化。通过调用 createApiFactoryAndRegisterActors创建插件进程中可调用模块。

//插件进程中可调用的apiconst apiFactory = this._instaService.invokeFunction(createApiFactoryAndRegisterActors);

函数 createApiFactoryAndRegisterActors返回了一个对象大型 vscode 对象:

const workspace: typeof vscode.workspace = {    ....    window,    workspace,    Location,    tasks,    .....}

返回的 vscode 对象中提供了插件进程中全部的可用模块。插件进程中对 vscode 中模块的调用,最终通过 RPCProtocol进行远程调用,进而转换成对主进程对应模块的调用。

(2) 插件进程模块和主进程模块映射

插件进程中对 vscode 中模块的调用,最终通过 RPCProtocol进行远程调用;

接下来我们具体看下,如何将RPC调用映射到主进程特定的模块。以插件进程的模块 vs/workbench/api/common/extHostWindow为例:

export class ExtHostWindow implements ExtHostWindowShape {   constructor(mainContext: IMainContext) {       //通过mainContext获取proxy,这里的mainContext实际上是一个RPCProtocol       //MainContext.MainThreadWindow是一个ProxyIdentifier       this._proxy = mainContext.getProxy(MainContext.MainThreadWindow);   }    openUri(stringOrUri: string | URI, options: IOpenUriOptions): Promise<boolean> {        ...        //对openUri的调用,实际上转换成对相应的Proxy的$openUri方法调用,最后通过RPC调用发送消息到主进程        return this._proxy.$openUri(stringOrUri, uriAsString, options);    }}

上述代码 ExtHostWindow中通过一个 ProxyIdentifierMainContext.MainThreadWindow建立了与主进程对应模块的连接, ProxyIdentifier的主要作用是生成 RPC 调用的 rpcId,从而确保主进程能够找到对应的模块。

相应的主进程中通过装饰器 @extHostNamedCustomer和一个 ProxyIdentifier,将自身注册为 RPC 调用消息的消费者。如下代码所示:

//MainContext.MainThreadWindow是一个ProxyIdentifier@extHostNamedCustomer(MainContext.MainThreadWindow)export class MainThreadWindow implements MainThreadWindowShape {   async $openUri(...): Promise<boolean> {  return this.openerService.open(...);   }}

通过 ProxyIdentifier的一一对应,我们就能通过 worker 进程的 RPC 调用发送的消息,找到主进程中对应的模块。 ProxyIdentifier的定义如下,其 nid就是 RPC 调用的 rpcId, nid基于静态变量 count递增生成,因此也保证了 rpcId 的全局唯一性。

export class ProxyIdentifier<T> {  public static count = 0;  public readonly isMain: boolean;  public readonly sid: string;  public readonly nid: number;  constructor(isMain: boolean, sid: string) {    this.isMain = isMain;    this.sid = sid;    this.nid = (++ProxyIdentifier.count);  }}

(3) 插件进程模块的引入

我们定义了插件进程可调用模块,也已经通过 RPCProtocol和 ProxyIdentifier将其与主进程相应模块一一对应起来了。插件进程该如何使用这些模块呢?一个显而易见的方案是全局变量,但这种方案对插件开发者非常不友好,vscode 肯定不会这么做。这里 vscode 用了另外一种奇技淫巧:hook 模块加载 require 函数。

我们在开发插件时,往往直接引入了 vscode 模块,如下代码所示:

import * as vscode from 'vscode';export function activate(context: vscode.ExtensionContext) {   //插件逻辑}

这里的 vscode 实际上是 @types/vscode/index.d.ts中定义的一个模块接口,里面并没有具体的逻辑:

declare module 'vscode' {  //模块定义}

vscode 的插件进程通过引入一个 WorkerRequireInterceptor来拦截 require行为:

this._fakeModules = this._instaService.createInstance(WorkerRequireInterceptor, apiFactory, this._registry);await this._fakeModules.install();

这个 WorkerRequireInterceptor检测到 require加载模块语法时,就会从上文创建的 apiFactory查找对应模块。

class WorkerRequireInterceptor extends RequireInterceptor {  ...  getModule(request: string, parent: URI): undefined | any {    //如果apiFactory中有相应模块则直接返回    if (this._factories.has(request)) {      return this._factories.get(request)!.load(request, parent, () => { throw new Error('CANNOT LOAD MODULE from here.'); });    }    ...}

至此,我们已经完成了插件执行环境的初始化。包括:

插件进程执行环境模块定义主进程中的模块通过 @extHostNamedCustomer 注册为 RPC 调用的消费者插件进程 hook 了 require 模块加载机制,以一种优雅的方式将模块提供给开发者使用

以一张简单的图,总结本小节涉及的内容:

图片

3. 插件的运行流程

完成了插件执行环境的初始化后,就可以开始扫描扩展插件,并在合适的时机由事件触发执行。

3.1 contributes(扩展点)的定义

在上文中我们提到 contributes(扩展点)是一个比较重要的概念,这里我们详细描述这个概念的意义。 contributes(扩展点)实际上定义了 vscode 可通过插件修改的地方,比如:在命令面板上添加一条命令、在状态栏显示一个文案等。

一个扩展点的定义如下:

export const commandsExtensionPoint = ExtensionsRegistry.registerExtensionPoint({   extensionPoint: 'commands',   jsonSchema: schema.commandsContribution});

它定义了一个 extensionPoint 名称,并通过 JSON Schema 定义了扩展点所需要的数据格式。

我们可以将 contributes(扩展点)理解为一个可修改的数据结构,通过插件的 package.json 中配置的 contributes的值来修改这个扩展点的数据结构,vscode 再通过监听这个数据结构的变化进而触发UI和逻辑的执行

比如我们可以在插件的 package.json 中 contributes字段,修改扩展点 extensionPoint:'commands'的数据结构:

"contributes": {  "commands": [     {       "command": "extension.helloWorld",       "title": "Hello World"     }  ]}

而 vscode 会监听这个数据结构的变化,从而触发UI更新,在命令面板添加一条命令:

图片

3.2 插件扫描与 contributes(扩展点)

插件的扫描和扩展处理逻辑在 vs/workbench/services/extensions/browser/extensionService.ts文件的 _scanAndHandleExtensions方法中。该方法首先遍历插件目录找到所有插件,然后交由 _doHandleExtensionPoints处理插件的扩展逻辑:

首先找到插件影响的所有扩展点(contributes),如下代码所示:通过遍历插件列表,找到 contributes对象的所有key,记录在一个 affectedExtensionPoints结构中

const affectedExtensionPoints: { [extPointName: string]: boolean; } = Object.create(null);//遍历插件列表for (let extensionDescription of affectedExtensions) { //找到contributes if (extensionDescription.contributes) {     //把contributes的所有key定义为扩展点     for (let extPointName in extensionDescription.contributes) {       if (hasOwnProperty.call(extensionDescription.contributes, extPointName)) {         affectedExtensionPoints[extPointName] = true;       }     } }}

下面是一次插件扫描过程,搜集到的影响到扩展点列表 affectedExtensionPoints:

图片

找到插件影响的扩展点后,再拿到 vscode 中注册的扩展点,上文中提到 vscode 中有大量通过 ExtensionsRegistry.registerExtensionPoint注册的扩展点,通过如下调用可以拿到这些扩展点:

const extensionPoints = ExtensionsRegistry.getExtensionPoints();

最后将插件应用到 vscode 中注册的扩展点:

//遍历插件for (const extensionPoint of extensionPoints) {  //是否需要扩展  if (affectedExtensionPoints[extensionPoint.name]) {    //将当前插件应用扩展点    AbstractExtensionService._handleExtensionPoint(extensionPoint, availableExtensions, messageHandler);  }}

这里的 将当前插件应用扩展点往往指的是修改扩展点的数据结构。

3.3 插件的运行

在上文中我们提到 activationEvents是一个比较重要的概念,由它来触发插件的执行。vscode 主进程中往往通过 extensionService的 activateByEvent来启动一个插件的执行。比如如下调用,会激活一个监听 onCustomEditor命令的插件。

extensionService.activateByEvent(`onCustomEditor:${webview.viewType}`);

这个调用实际上也是通过 RPC 调用插件进行的响应函数:

public activateByEvent(activationEvent: string): Promise<void> {    ...    //主进程通过proxy,RPC调用插件进程    return proxy.value.$activateByEvent(activationEvent);}

插件进程再遍历插件列表,拿到 activationEvents满足条件的插件,最后逐一执行。

const activateExtensions = this._registry.getExtensionDescriptionsForActivationEvent(activationEvent);this._activateExtensions(activateExtensions.map(e => ({  id: e.identifier,  reason: { startup, extensionId: e.identifier, activationEvent }}))

接下来就是根据插件路径 extensionDescription.extensionLocation加载插件JS代码:

this._loadCommonJSModule<IExtensionModule>(joinPath(extensionDescription.extensionLocation, extensionDescription.main), activationTimesBuilder)

加载完 JS 代码后,创建一个插件的执行环境(修改 commonJs 默认的 exports 和 require)并执行:

//创建插件执行函数const initFn = new Function('module', 'exports', 'require', 'window', jsSourceText);//修改commonjs模块加载的export和requireconst _exports = {};const _module = { exports: _exports };//require从_fakeModules中寻找模块,_fakeModules是由上文讲到的WorkerRequireInterceptor提供的const _require = (request: string) => {  const result = this._fakeModules!.getModule(request, module);  if (result === undefined) {    throw new Error(`Cannot load module '${request}'`);  }   return result;};//使用自定义的require和export,执行插件initFn(_module, _exports, _require, self);

如下图所述,执行插件代码中 require('vscode')实际上返回的是 _fakeModules 中 vscode 全局对象。

图片

本文,我们从一个插件的结构开始,详述了插件的环境的创建以及插件的执行流程,希望能帮助大家对 vscode 的插件体系有清晰的理解。由于 vscode 的代码库过于庞大,难免理解有误,我会持续学习 vscode 源码,更正文中可能出现的错误。



继续滑动看下一个
腾讯AlloyTeam
向上滑动看下一个