为什么研究 VSCode
几周前的某一天,发现 GitHub 支持一键使用 web 版 VSCode 打开仓库,只需要按下“.”按钮便可一键打开,同时域名 .com
后缀会变成 .dev
。
如果你的 VSCode 配置了 Settings Sync, VSCode 还会自动同步你的配置和插件,这样在线编程的体验和本地基本一致(如果想要在线运行还可以打开 Codespaces 开发环境)。
看到 VSCode 这么优秀,不仅免费开源、跨平台、还有非常优秀的插件生态,我突然产生了兴趣想去搞懂它底层的运行机制,尤其对它强大的可扩展性特别好奇。
希望从中学到什么
• 大型开源项目如何组织代码
• 插件系统的架构设计
• 插件如何注册
• 何时调起插件
• 如何在打造插件化开放生态的同时保证软件质量与性能
VSCode 的产品定位
VSCode 作为时下最为流行的代码编辑器,自2015年推出以来逐渐蚕食了 Sublime Text、Atom 等编辑器的市场份额,占领了编辑器领域的半壁江山,截至目前(2021年9月)其 GitHub 仓库的 star 数已经达到了 121k+。
VSCode 的领导者 Erich Gamma 在 2017 SpringOne Platform 上有一个 关于 VSCode 的分享,讲解了在他开发 Eclipse 的过往经验基础上,对 VSCode 进行顶层设计时的诸多思路与决策,其中提到过对于 VSCode 的产品定位:
从图中可以看出 VSCode 定位是处于编辑器和 IDE 的中间并且偏向轻量编辑器一侧的。
VSCode 的核心是“编辑器 + 代码理解 + 调试”,围绕这个关键路径做深做透,其他东西非常克制,产品保持轻量与高性能。
较小的团队也使得团队成员做到了行为层面的整齐划一,这点在社区互动上体现得尤为明显,大家可以去 GitHub 上看他们的 Issues,超出产品定位范畴的请求和反馈基本都被婉拒或者转交到第三方插件项目,可以说是拿捏得死死的👌。
扩展阅读:Basecamp 的产品方法论: Shape Up
VSCode 代码结构
VSCode 在它的 wiki 中这样介绍:
Visual Studio Code 由一个多层级、模块化的可扩展内核构成。每一个扩展都是一个独立进程称之为扩展宿主(extension host)
。扩展都是通过 (extension API)[https://code.visualstudio.com/docs/extensions/overview] 实现。
1 | 一级目录 |
内核分层模块化
隔离内核 (src) 与插件 (extensions)
- /src/vs:分层和模块化的 core
base
: 通用的公共方法和公共视图组件code
: VSCode 应用主入口platform
:可被依赖注入的各种基础服务editor
: “Monaco” 文本编辑器workbench
:整体视图框架languages
: 历史版本中的语言插件目录,现在都迁移至/extentions
- /src/typings: 公共基础类型
- /extensions:内置插件
2. 按环境分层
由于 VSCode 依赖 Electron,而在上述我们提到了 Electron 存在着主进程和渲染进程,而它们能使用的 API 有所不到,所以** VSCode Core 中每个目录的组织也按照它们能使用的 API 来组织安排**。在 Core 下的每个子目录下,按照代码所运行的目标环境分为以下几类::
common
: 公共的 js 方法,只使用 JavaScript API 的源代码,可能运行在任何环境browser
: 只使用浏览器 API 的代码,可以调用 commonnode
: 只使用 NodeJS API 的代码,可以调用 commonelectron-browser
: 使用 electron 渲染线程和浏览器 API 的代码,可以调用 common,browser,nodeelectron-main
: 使用 electron 主线程和 NodeJS API 的代码,可以调用 common, nodetest
: 测试代码
3. Editor 的源码结构
vs/editor
文件夹不能有任何对node
和electon-browser
的依赖vs/editor/contrib
编辑器内部的扩展文件夹,依赖browser
,可打包进 VSCode 或者独立编辑器vs/editor/standalone
独立编辑器代码,任何代码不得依赖该目录vs/workbench/contrib/codeEditor
打包进 VSCode 的目录
4. Workbench 工作台
VSCode 工作台(vs/workbench)包含有很多特性以提供丰富的开发体验。比如全文搜索,git 和 debug。工作台核心不得直接依赖扩展包,VSCode 使用一种内部机制将这些扩展注册到工作台。
所有的工作台扩展(Contrib)都必须包含在vs/workbench/contrib
,该目录有以下约定:
vs/workbench/contrib
目录下的代码不得依赖任何文件夹外部代码- 每一个 Contrib 如果要对外暴露,将API 在一个出口文件里面导出 (e.g. vs/workbench/contrib/search/common/search.ts)
- 一个 Contrib 可以依赖其他 Contrib 的API (e.g. the git contribution may depend on vs/workbench/contrib/search/common/search.ts)
- 一个 Contrib 不得依赖其他 Contrib 的非API内部文件
- 即使 Contrib 可以调用另一个 Contrib 的出口 API,也要审慎的考虑,应尽量避免两个 Contrib 互相依赖
小结
VSCode 中对各个目录都有不同的依赖限制规范,这一点超出了我对于编程规范的认知,以往我所认知中的编程规范更多的是 coding 层面的,比如命名风格、代码风格、注释规范,对于依赖关系的规范也是第一次见,这可能是大型开源项目的不同之处吧。
VSCode 的代码结构中有非常多的地方都是采用相似的模式去横向扩展,如果这些功能代码直接采用原始的模块引用的方式在 core 里面硬编码聚合拼装起来,是一个自顶向下的架构,对维护性的挑战比较大。
而采用暴露扩展点的方式,可以将依赖关系反转,依附于扩展点协议,独立的小功能的代码实现可以单独聚合,核心模块无需硬编码和集成所有判断,整体是一个松散式的架构,降低了代码信息密度与提升维护性,也更好扩展。
VSCode的层级结构有点像这样:
重新学习依赖注入
依赖注入基本概念
控制反转(Inversion of Control)是一种面向对象编程中的设计思想,用来减低计算机代码之间的耦合度。其基本思想是:借助于“第三方”实现具有依赖关系的对象之间的解耦。
依赖注入(Dependency Injection)是控制反转的一种实现,就是将实例变量传入到一个对象中去。
在软件工程中,依赖注入是一种为一类对象提供依赖的对象的设计模式。被依赖的对象称为 Service,注入则是指将被依赖的对象 Service 传递给使用服务的对象(称为 Client),从而客户 Client 不需要主动去建立(new)依赖的服务 Service,也不需要通过工厂模式去获取依赖的服务 Service。
扩展阅读:控制反转(IoC)与依赖注入(DI)
举个🌰
如果在 Class Client 中,有 Class Service 的实例,则称 Class Client 对 Class Service 有一个依赖。例如下面类 Client 中用到一个 Service 对象,我们就说类 Client 对类 Service 有一个依赖。
1 | public class Client { |
仔细看这段代码我们会发现存在一些问题:
- 如果现在要改变 service 生成方式,如需要用 new Service(String name) 初始化 service,需要修改 Client 代码;
- 如果想测试不同 Service 对象对 Client 的影响很困难,因为 service 的初始化被写死在了 Client 的构造函数中;
上面将依赖在构造函数中直接初始化是一种 Hard init 方式,弊端在于两个类不够独立,不方便测试。我们还有另外一种 Init 方式,如下:
1 | public class Client { |
上面代码中,我们将 service 对象作为构造函数的一个参数传入。在调用 Client 的构造方法之前外部就已经初始化好了 Service 对象。像这种非自己主动初始化依赖,而通过外部来传入依赖的方式,我们就称为依赖注入。
依赖注入的基本原理是由外部的容器来保存对象之间的依赖关系,同时这些对象的实例化也由容器来实现,这个容器被称为依赖注入容器。实际应用中我们的系统可能存在大量的对象或者服务,对象之间的依赖关系非常复杂,比如 B 依赖 A,则 A 就要先于 B 被实例化,这就要求外部的容器能够分析出这些对象的依赖关系。许多依赖注入框架还支持多种注入方式,比如构造函数注入、属性注入、方法参数注入等。
依赖注入是软件工程中广泛实践的一种设计模式,依赖注入模式有效的解耦了程序耦合性,实现控制反转思想。
VSCode 便是使用这种方式来降低程序的耦合性的。
VSCode 中的依赖注入
VSCode 的代码是围绕着各式各样的 service 组织起来的,这些 service 基本都定义在 platform
那一层,通过构造函数注入器(constructor injection)
注入到 client 中。
一个 service 需要两部分定义:1. service 接口定义 2. service 标志符。service 标志符是一个装饰器(Decorator是ES7的提案)并且必须和 service 接口同名。
声明一个服务依赖需要在构造函数参数中加入装饰器。
1 | class Client { |
这里,申明的客户对象Client,所依赖的 service 有 IModelService 和 IEditorService ,其中装饰器 @IModelService 是 ModelService 的标识,后面的 IModelService 只是 TypeScript 中的接口定义;@optional(IEditorService)是 EditorService 的标识,同时通过 optional 的装饰申明为可选的依赖。
在代码是实际使用Client对象时,需要通过注入器提供的instantiationService来实例化的到 Client 的实例:
1 | const client = instantiationService.createInstance(Client) |
VS Code 中依赖注入的实现主要在 vs/platform/instantiation/common
文件夹下,如下:
1 | ├── descriptors.ts # 服务实例包装类 |
声明一个装饰器:
思考
- 引入
DI
除了让项目代码比较解耦之外,会带来什么负面影响吗? - 什么规模的项目适合引入
DI
?
实践快速开发插件
我们利用一个简单的🌰来了解一下 VSCode 的插件开发,以及插件的注册和激活,看看 VSCode 的插件能做什么
HelloWorld
首先需要安装 Node.js 环境 和 Git, 然后安装 Yeoman 和 VSCode Extension Generator:
1 | npm install -g yo generator-code |
进入到编辑器后按F5
,便会编译插件然后在新的扩展开发宿主
窗口中启动。
在命令选项板(⇧⌘P
)中触发命令
修改插件
我们将 helloworld 改成一个简单的番茄钟🍅,实现每搬砖🧱25分钟提醒休息🧘♂️5分钟,休息5分钟再提醒工作的功能。
在 src/extension.ts 的 activate 函数中添加如下代码:
1 | var timerID: NodeJS.Timer; |
修改 package.json :
1 | "activationEvents": [ |
这样便实现了一个乞丐版番茄钟插件。
在插件开发宿主窗口中按下Ctrl+R
(Mac Cmd+R
)即会加载新的修改。
插件有哪些扩展点
- contributes.commands:向 vscode 的命令系统注册一些可供用户调用的命令
- contributes.menus:扩展菜单
- contributes.languages:扩展语言
- contributes.themes:扩展主题
- contributes.iconThemes:扩展图标主题
更多扩展点请查看官方文档 https://code.visualstudio.com/api/references/contribution-points
Activation Events 激活时机
- onLanguage:包含该语言类型的文件被打开
- onLanguage:json
- onCommand:某个命令
- onCommand:extension.sayHello
- onDebug:开始调试
- workspaceContains:有匹配规则的文件被打开
- workspaceContains:**/.editorconfig
- onFileSystem:打开某个特殊协议的文件
- onView:某个 id 的视图被显示
- onUri:向操作系统注册的 schema
- vscode://vscode.git/init
- onWebviewPanel:某种 viewType 的 webview 打开时
- *:启动就立即打开
多进程架构
VSCode 基于 Electron,Electron App 一般都有 1 个 Main Process 和多个 Renderer Process:
- main process:主进程环境下可以访问 Node 及 Native API;
- renderer process:渲染器进程环境下可以访问 Browser API 和 Node API 及一部分 Native API。
VSCode 采用多进程架构,VSCode 启动后主要有下面的几个进程:
- 主进程:VSCode 的入口进程,负责一些类似窗口管理、进程间通信、自动更新等全局任务
- 渲染进程:负责一个 Web 页面的渲染
- 插件宿主进程:每个插件的代码都会运行在一个独属于自己的 NodeJS 环境的宿主进程中,插件不允许访问 UI
- Debug 进程:Debugger 相比普通插件做了特殊化
- Search 进程:搜索是一类计算密集型的任务,单开进程保证软件整体体验与性能
进程隔离的好处
这样做首先解决的问题就是稳定性,这个问题对于 VSCode 来说尤为重要。都知道 VSCode 基于 Electron ,实质上是个 node.js 环境,单线程,任何代码崩了都是灾难性后果。所以 VSCode 干脆不信任任何人,把插件们放到单独的进程里,任你折腾,主程序妥妥的。
与之对比的是 Eclipse 的架构:
插件与主进程隔离 VSCode 团队的这一决策不是没有原因的,因为团队里很多人其实是 Eclipse 的旧部,自然对 Eclipse 的插件模型有深入的思考。Eclipse 的设计目标之一就是把组件化推向极致,所以很多核心功能都是用插件的形式来实现的。遗憾的是,Eclipse 的插件运行在主进程中,任何插件性能不佳或者不稳定,都直接影响到 Eclipse,最终结果是大家抱怨 Eclipse 臃肿、慢、不稳定。VSCode 基于进程做到了物理级别的隔离,成功解决了该问题。
LSP 语言服务协议 (Language Server Protocol)
LSP定义了一套编辑器或IDE与语言服务器之间的通信协议,用于实现诸如自动补全、代码跳转、查找引用等功能。通过这样的标准化协议,一个语言服务可以服用在多种开发工具之中,同时开发工具也只需要付出较小的代价就能实现多语言支持。
How it works
语言服务器跑在一个单独的进程中,和开发工具基于照语言协议通过 JSON-RPC 进行通信,下面看一个常规的编辑会话中,开发工具和语言服务器是如何通信的:
看一个 C++ 文件的 “Go to Definition” 请求和响应参数:
request:
1 |
|
response:
1 | { |
当用户使用多语言变成时,开发工具通常为每一种语言启动一个语言服务器,就像下面的会话中用户同时打开 Java 和 SASS 文件。
小结
VSCode 在实现多语言支持时没有选择做一个涵盖所有语言特性的超集,而是把小巧作为设计目标之一,选择了做最小子集。
它关心的是用户在编辑代码时最经常处理的物理实体(比如文件、目录)和状态(光标位置)。
它根本没有试图去理解语言的特性,编译也不是它所关心的问题,所以自然不会涉及语法树一类的复杂概念。
它也不是一步到位的,而是随着 VSCode 功能的迭代而逐步发展的。
所以它自诞生至今依然保持着小巧的身材,易懂,实现门槛也很低,迅速在社区得到了广泛的支持,各种语言的 Language Server 遍地开花。
Code Everywhere – 远程开发
VSCode 在 2019 年发布了 Remote Development,我们可以在远程环境(比如虚拟机、容器、WSL)里开一个 VSCode 工作区,然后用本地的 VSCode 连上去工作,基本上就像你在本地开发一样的体验。
远程开发的意义
- 面向集群编程(特别适合 AI 同学需要依赖大量硬件资源的场景)
- 开发环境分离,避免污染本地环境
- 团队一致性的开发环境,新员工一秒钟点亮
- 在 WSL 下开发 Linux 程序
- 在不同的环境下调试
- 任何地点,任何机器,即可开发(卷起来呀朋友们🤠)
进程隔离的好处(2)
- 进程级别隔离的插件模型
Extension Host(也就是图中的 VSCode Server)与主程序做到了物理级别的分离,那么把 Extension Host 在远程或者本地跑没有本质的区别 - UI渲染与插件逻辑隔离,整齐划一的插件行为
所有的插件的UI都由 VSCode 统一渲染,所以插件里面只有纯业务逻辑,行为高度统一,跑在哪里都没区别 - 高效的协议 LSP
VSCode 的两大协议 LSP、DAP(Debug Adapter Protocol) 都非常精简,天然适合网络延迟高的情况,用在远程开发上再适合不过
关于远程开发的更多介绍也可参考 Github Codespaces 的介绍
总结
我们挑以上几个维度来分析学习 VSCode 的产品实现,还有很多精彩的地方没有涉及。总结下来,VSCode 之所以能在几年的时间内取得空前成功主要有以下几点:
- 坚定不移的产品定位
- 健壮的架构设计
- 免费、开源、开放
能在当下躁动的商业环境下,这么多年保持初心实属不易。VSCode 团队在很多方面还是值得我们花时间去深入挖掘推敲,比如其中的产品哲学和团队文化,还有架构思想等。
感谢大家的耐心,Happy coding…
参考
https://codeteenager.github.io/vscode-analysis/learn/
https://www.yuque.com/paranoidjk/blog/vuuz30#YT3Xh
http://www.godbasin.com/front-end-basic/deep-learning/vscode-event.html
https://zhaomenghuan.js.org/blog/vscode-workbench-source-code-interpretation.html
https://zhuanlan.zhihu.com/p/35303567
https://github.com/microsoft/vscode/wiki/
https://code.visualstudio.com/api/language-extensions/overview