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

vscode插件体系详解

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

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

相关推荐

程序员请收好:10个非常有用的 Visual Studio Code 插件

一个插件列表,可以让你的程序员生活变得轻松许多。作者|Daan译者|Elle出品|CSDN(ID:CSDNnews)以下为译文:无论你是经验丰富的开发人员还是刚刚开始第一份工作的初级开发人...

PADS在WIN10系统中菜单显示不全的解决方法

决定由AD转PADS,打开发现菜单显示不正常,如下图所示:这个是由于系统的默认字体不合适导致,修改一下系统默认字体即可,修改方法如下:打开开始菜单-->所有程序-->Windows系统--...

一文讲解Web前端开发基础环境配置

先从基本的HTML语言开始学习。一个网页的所有内容都是基于HTML,为了学好HTML,不使用任何集成工具,而用一个文本编辑器,直接从最简单的HTML开始编写HTML。先在网上下载notepad++文...

TCP/IP协议栈在Linux内核中的运行时序分析

本文主要是讲解TCP/IP协议栈在Linux内核中的运行时序,文章较长,里面有配套的视频讲解,建议收藏观看。1Linux概述  1.1Linux操作系统架构简介Linux操作系统总体上由Linux...

从 Angular Route 中提前获取数据

#头条创作挑战赛#介绍提前获取意味着在数据呈现在屏幕之前获取到数据。本文中,你将学到,在路由更改前怎么获取到数据。通过本文,你将学会使用resolver,在AngularApp中应用re...

边做游戏边划水: 基于浅水方程的水面交互、河道交互模拟方法

以下文章来源于腾讯游戏学堂,作者Byreave篇一:基于浅水方程的水面交互本文主要介绍一种基于浅水方程的水体交互算法,在基本保持水体交互效果的前提下,实现了一种极简的水面模拟和物体交互方法。真实感的...

Nacos介绍及使用

一、Nacos介绍Nacos是SpringCloudAlibaba架构中最重要的组件。Nacos是一个更易于帮助构建云原生应用的动态服务发现、配置和服务管理平台,提供注册中心、配置中心和动态DNS...

Spring 中@Autowired,@Resource,@Inject 注解实现原理

使用案例前置条件:现在有一个Vehicle接口,它有两个实现类Bus和Car,现在还有一个类VehicleService需要注入一个Vehicle类型的Bean:publicinte...

一文带你搞懂Vue3 底层源码

作者:妹红大大转发链接:https://mp.weixin.qq.com/s/D_PRIMAD6i225Pn-a_lzPA前言vue3出来有一段时间了。今天正式开始记录一下梗vue3.0.0-be...

一线开发大牛带你深度解析探讨模板解释器,解释器的生成

解释器生成解释器的机器代码片段都是在TemplateInterpreterGenerator::generate_all()中生成的,下面将分小节详细展示该函数的具体细节,以及解释器某个组件的机器代码...

Nacos源码—9.Nacos升级gRPC分析五

大纲10.gRPC客户端初始化分析11.gRPC客户端的心跳机制(健康检查)12.gRPC服务端如何处理客户端的建立连接请求13.gRPC服务端如何映射各种请求与对应的Handler处理类14.gRP...

聊聊Spring AI的Tool Calling

序本文主要研究一下SpringAI的ToolCallingToolCallbackorg/springframework/ai/tool/ToolCallback.javapublicinter...

「云原生」Containerd ctr,crictl 和 nerdctl 命令介绍与实战操作

一、概述作为接替Docker运行时的Containerd在早在Kubernetes1.7时就能直接与Kubelet集成使用,只是大部分时候我们因熟悉Docker,在部署集群时采用了默认的dockers...

在MySQL登录时出现Access denied for user ~~ (using password: YES)

Windows~~~在MySQL登录时出现Accessdeniedforuser‘root‘@‘localhost‘(usingpassword:YES),并修改MySQL密码目录适用...

mysql 8.0多实例批量部署script

背景最近一个项目上,客户需要把阿里云的rdsformysql数据库同步至线下,用作数据的灾备,需要在线下的服务器上部署mysql8.0多实例,为了加快部署的速度,写了一个脚本。解决方案#!/bi...