百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 热门文章 > 正文

vscode插件体系详解

bigegpt 2024-08-05 11:37 9 浏览

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.tsstart方法创建一个新的webworker作为插件的执行环境。

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

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

webWorkerExtensionHostStarter.tsstart方法在创建 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 的 onMessagepostMessage实现一个标准化的信道: IMessagePassingProtocolIMessagePassingProtocol接口分别定义了 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创建插件进程中可调用模块。

//插件进程中可调用的api
const 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中通过一个 ProxyIdentifier: MainContext.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) 插件进程模块的引入

我们定义了插件进程可调用模块,也已经通过 RPCProtocolProxyIdentifier将其与主进程相应模块一一对应起来了。插件进程该如何使用这些模块呢?一个显而易见的方案是全局变量,但这种方案对插件开发者非常不友好,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 主进程中往往通过 extensionServiceactivateByEvent来启动一个插件的执行。比如如下调用,会激活一个监听 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和require
const _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 源码,更正文中可能出现的错误。

作者:kellanzhang

来源:微信公众号:腾讯AlloyTeam

出处:https://mp.weixin.qq.com/s/ttNmGeTb8aukNAVsENfaiw

相关推荐

Go语言泛型-泛型约束与实践(go1.7泛型)

来源:械说在Go语言中,Go泛型-泛型约束与实践部分主要探讨如何定义和使用泛型约束(Constraints),以及如何在实际开发中利用泛型进行更灵活的编程。以下是详细内容:一、什么是泛型约束?**泛型...

golang总结(golang实战教程)

基础部分Go语言有哪些优势?1简单易学:语法简洁,减少了代码的冗余。高效并发:内置强大的goroutine和channel,使并发编程更加高效且易于管理。内存管理:拥有自动垃圾回收机制,减少内...

Go 官宣:新版 Protobuf API(go pro版本)

原文作者:JoeTsai,DamienNeil和HerbieOng原文链接:https://blog.golang.org/a-new-go-api-for-protocol-buffer...

Golang开发的一些注意事项(一)(golang入门项目)

1.channel关闭后读的问题当channel关闭之后再去读取它,虽然不会引发panic,但会直接得到零值,而且ok的值为false。packagemainimport"...

golang 托盘菜单应用及打开系统默认浏览器

之前看到一个应用,用go语言编写,说是某某程序的windows图形化客户端,体验一下发现只是一个托盘,然后托盘菜单的控制面板功能直接打开本地浏览器访问程序启动的webserver网页完成gui相关功...

golang标准库每日一库之 io/ioutil

一、核心函数概览函数作用描述替代方案(Go1.16+)ioutil.ReadFile(filename)一次性读取整个文件内容(返回[]byte)os.ReadFileioutil.WriteFi...

文件类型更改器——GoLang 中的 CLI 工具

我是如何为一项琐碎的工作任务创建一个简单的工具的,你也可以上周我开始玩GoLang,它是一种由Google制作的类C编译语言,非常轻量和快速,事实上它经常在Techempower的基准测...

Go (Golang) 中的 Channels 简介(golang channel长度和容量)

这篇文章重点介绍Channels(通道)在Go中的工作方式,以及如何在代码中使用它们。在Go中,Channels是一种编程结构,它允许我们在代码的不同部分之间移动数据,通常来自不同的goro...

Golang引入泛型:Go将Interface「」替换为“Any”

现在Go将拥有泛型:Go将Interface{}替换为“Any”,这是一个类型别名:typeany=interface{}这会引入了泛型作好准备,实际上,带有泛型的Go1.18Beta...

一文带你看懂Golang最新特性(golang2.0特性)

作者:腾讯PCG代码委员会经过十余年的迭代,Go语言逐渐成为云计算时代主流的编程语言。下到云计算基础设施,上到微服务,越来越多的流行产品使用Go语言编写。可见其影响力已经非常强大。一、Go语言发展历史...

Go 每日一库之 java 转 go 遇到 Apollo?让 agollo 来平滑迁移

以下文章来源于GoOfficialBlog,作者GoOfficialBlogIntroductionagollo是Apollo的Golang客户端Apollo(阿波罗)是携程框架部门研...

Golang使用grpc详解(golang gcc)

gRPC是Google开源的一种高性能、跨语言的远程过程调用(RPC)框架,它使用ProtocolBuffers作为序列化工具,支持多种编程语言,如C++,Java,Python,Go等。gR...

Etcd服务注册与发现封装实现--golang

服务注册register.gopackageregisterimport("fmt""time"etcd3"github.com/cor...

Golang:将日志以Json格式输出到Kafka

在上一篇文章中我实现了一个支持Debug、Info、Error等多个级别的日志库,并将日志写到了磁盘文件中,代码比较简单,适合练手。有兴趣的可以通过这个链接前往:https://github.com/...

如何从 PHP 过渡到 Golang?(php转golang)

我是PHP开发者,转Go两个月了吧,记录一下使用Golang怎么一步步开发新项目。本着有坑填坑,有错改错的宗旨,从零开始,开始学习。因为我司没有专门的Golang大牛,所以我也只能一步步自己去...