Eclipse Theia 框架技术架构揭秘

Theia 相关的文章会持续动态更新,更多文章可以关注专栏:《Eclipse Theia 揭秘》

Cloud IDE

随着前端开发的发展更迭,前端日常开发工作变得愈发复杂愈发深入,同时前端工程中从项目初始化、编译、构建到发布、运维也变得细化而成熟。本地开发环境存在开发机性能要求高、开发环境配置复杂、依赖特定设备、复杂工程管理难等问题,Cloud IDE 很好的解决了这些问题。最近几年,Cloud IDE 在开发全流程领域扮演者越来越重的角色,国内外很多厂商都在做 Cloud IDE。

Cloud IDE 基础设施

Cloud IDE 架构

Cloud IDE 主要包含 Client、Server、Container Pool 三部分。

  • Client: 客户端也是最重要的端,将代码编辑等本地功能移植到浏览器中。
  • Server: 服务端也是控制端,包括管理数据交互及资源调度。
  • Container Pool: 运行时,用户代码真正运行的容器环境。

三者之间最典型的架构如下图所示:

What is Theia?

Eclipse Theia 是一个可扩展的平台,基于现代 Web 技术(TypeScript, CSS 和 HTML)实现,用于开发成熟的、多语言的云计算和桌面类的理想产品。

Theia 为开发浏览器和桌面 IDE 提供了可扩展的平台,主要特性:

  • Web AND desktop-based:提供 Cloud IDE 和 Desktop IDE 两种模式
  • Do not reinvent the wheel:不重复造轮子,Theia 复用其他框架、标准和技术,例如通过语言服务协议(LSP,Language Server Protocol) 和调试适配器协议 (DAP,Debug Adapter Protocols) 提供对多种语言的支持,支持 VS Code 拓展。
  • For IDEs and domain-specific tools (not only code editors):Theia 的目标不是成为一个简单的代码编辑器,而是成为一个支持创建特定领域工具套件和集成开发环境的基础平台。
  • Extension first:Theia 所有东西都是扩展,甚至包括项目本身提供的核心特性。像 Eclipse rich-client 平台一样,你可以在 Theia 中定制几乎所有的东西,甚至替换核心功能。
  • Active community and vendor-neutrality:Theia 由 Eclipse 基金会托管,是一个与厂商无关的项目。

Theia 作为一个开发 IDE 和工具的平台,有三个目标:

  • 提供公共特性,可以重用这些公共特性尽可能高效地实现自定义工具,这些可重用特性大大降低了实现自定义工具或 IDE 所需的工作;
  • 提供将现有和新模块集成到定制产品的机制,允许您通过组合现有模块和自定义扩展来创建自定义产品;
  • 一个将工具构建为桌面应用程序和 Web 应用程序共享相同代码的平台。

为了实现前两个目标,Theia 提供了三个主要特性:

  • 提供基本的工作台框架,包括菜单、状态栏、视图概念、部件布局、工作空间抽象等,工作台可以通过自定义 UI 扩展进行扩展,比如菜单项、自定义视图和自定义编辑器;
  • 模块化扩展机制,允许您以模块化、可重用和可组合的方式实现功能,这些扩展可以针对前端及后端,该扩展机制还允许用户安装新特性;
  • 复用常用工具特性,如集成支持 LSP 的源代码编辑器、支持 Git、终端等工具。

目前 Theia 社区已经有一些产品基于 Theia 构建:

Theia vs. VS Code

根据 Stack Overflow 2019 年 Developer Survey Results 中流行的开发者工具排行榜,VS Code 无疑是最流行的开发者工具。

VS Code 提供了开箱即用的产品,借助强大的插件生态实现下载各种增强功能的 VS Code Extensions,例如对各种语言的支持;而 Theia 是一个构建 IDE 和工具的框架,当然 Theia 也提供了多种启动方法,下文将详细介绍。

对比指标 VS Code Product VS Code Project Eclipse Theia
可用性概念 ✔✔ ✔✔ ✔✔
技术栈和架构 ✔✔ ✔✔ ✔✔
可扩展性和适应性 ? ✔✔
可用的扩展和技术 ✔✔ ?
在线/离线功能 ✔✔
开源 ✔✔ ✔✔
生态系统和厂商无关性 ? ✔✔

可用性概念:VS Code 主要关注代码,在用户界面中占用的功能非常少,它的可用性概念是围绕使用键盘,而不是鼠标。许多特性只能通过CLI或命令面板获得。Theia 和 VS Code 使用相同的代码编辑器(Monaco)、相同的窗口管理和非常相似的命令面板,甚至大多数默认的快捷方式都是相同的。

技术栈和架构:VS Code 和 Eclipse Theia 都基于 Web 技术,包括 TypeScript,HTML5,CSS 以及 Node.js。实际上 Theia 复用了 VS Code 大量的技术,例如结合 Monaco code editor 和 language server protocol (LSP) 的方式,通过 LSP 允许以标准化的方式将代码编辑器的 UI 特性与底层逻辑解耦。

可扩展性和适应性:VS Code 通过用户可安装的 VS Code Extensions 提供了一种扩展机制,提供一套内置的 API 用于扩展 VS Code。Eclipse Theia 提供了一个类似的扩展机制,但称之为 Theia Plugins。Theia 实现了相同的API,所以许多 VS Code 扩展也可以在 Theia中 使用(参见《如何在 Theia 中安装 VS Code 扩展》的文章)。简而言之,VS Code Extensions 和 Theia Plugins 在概念上几乎是一样的。通过内置的 API,可以很容易开发一个VS Code 拓展,拓展运行在特定的进程中可以很好的保证产品稳定性,以及允许在运行时安装。这种扩展模式促进了 VS Code 生态,但是缺点是只能基于公开的 API 进行拓展。虽然涵盖了许多用例,例如添加命令,视图或扩展代码编辑,由于无法预见,因此很难进行其他一些修改。Theia 官方说插件克服了 VS Code 拓展的一些限制,提供了更强大的扩展机制。Theia 作为一个平台,完全由 Theia 扩展组成,这些扩展通过依赖注入进行连接,这使得 Theia 非常灵活,你可以删除任何你不喜欢的东西,调整或扩展平台本身所触及的一切。后面我们再专门探究一下 Theia 拓展的原理。

可用的扩展和技术:Eclipse Theia 支持 VS Code 扩展 API,也可以在 Theia 中使用VS代码扩展,不过有两个限制条件:Theia 还没有覆盖 VS Code 定义的全部 API,存在部分不可用的情况,API 覆盖率可以参考 vscode-theia-comparator;VS Code 插件市场的使用条款限制插件,推荐从项目主页下载。

在线与离线功能:最开始 VS Code 和 Theia 的区别就是在线和离线的区别,Theia 的核心架构明确允许将产品部署为桌面应用程序,并通过浏览器在云中访问。目前 VS Code 和 Theia 都支持在线和离线功能。

开源:VS Code Project 是开源的,但是 VS Code 产品不是开源的,VS Code Project 基于 MIT 协议;Theia 是基于 Eclipse Public License (EPL) 协议。最大的限制是只允许 VS Code 产品连接 VS Code 拓展市场,其他产品不能直接使用 VS Code 拓展市场的服务。

生态系统和三方库独立性:Theia 复用了 VS Code 生态的大量代码,所以整体代码数量上相对少一些。整体上讲相比 Theia,VS Code 生态更强大,Theia 的优势在于由 Eclipse 基金会托管,众多 Eclipse 成员公司参与 Eclipse Theia 的贡献。

整体上来说,如果我们想开发一个侧重代码的工具让更多的人在现有的 IDE 中使用,VS Code 拓展或许是更好的选择;如果是需要提供比编辑器更多功能的定制 IDE,使用 Theia 是更好的选择。

How to launch Theia?

上面我们说了 VS Code 和 Theia 的区别,相信大家及时没有看过 VS Code 源代码,也通过 VS Code 产品包体验过 VS Code 的功能,那么怎么安装并启动 Theia 呢?Theia 提供了多种启动方式:

1.基于自定义 package.json 构建包启动

由于 Theia 及其扩展是 Node.js 包,而 Theia 应用程序是包的集合,因此启动 Theia 及所选扩展的一个非常简单的方法就是创建一个 package.json。

参考文档:Build your own IDE

2.基于预配置的 Docker Image 构建

3.从源代码克隆、构建和运行

参考文档:How to build Theia and the example applications

4.基于 Eclipse Che(托管运行时和工作区) 构建

如果你不想下载、部署或编译任何东西,而只想以托管的方式尝试 Eclipse Theia,那么您可以使用 Eclipse Che 来实现这一点。Eclipse Che 提供了一个工作空间服务器,即服务器,它可以承载一个或多个开发人员的开发环境。从版本 Eclipse Che 7 开始,Eclipse Theia 是 Eclipse Che 的默认 IDE (在 Che 中称为编辑器)。

参考文档:Eclipse Che vs. Eclipse TheiaHow to install/run/try Eclipse Che

对于新手而言,推荐第一种方法方式去了解启动一个 Theia Application 工程,整体过程类似于一个普通的 Node 工程,本文不做赘述。

Theia Architecture

概述

Theia 被设计为一个可以在本地运行的桌面应用程序,也可以在浏览器和远程服务器之间工作。为了支持这两种工作方式,Theia 运行在两个独立的进程中,它们被称之为前端和后端,相互之间通过 WebSockets 上的 JSON-RPC 消息或 HTTP 上的 REST APIs 来通信。对于 Electron 而言,前端和后端都在本地运行,而在远程上下文中,后端运行在远程服务器上。前端和后端进行都有它们各自的依赖注入 (DI) 容器以方便开发者进行扩展。

前端

前端部分负责客户端的UI呈现。在浏览器中,它只是简单地在渲染循环中运行,而在 Electron 中,它运行在 Electron BrowserWindow 中,BrowserWindow 是包含 Electron 和 Node.js APIs 的浏览器窗口。因此,任何前端代码都可以把浏览器而不是 Node.js 作为一个运行平台。

启动前端进程将首先加载所有扩展包的 DI 模块,然后获取一个 FrontendApplication 的实例并在上面调用 start()。

const { Container } = require('inversify');

// 应用前端部分配置
FrontendApplicationConfigProvider.set({
    "applicationName": "Theia"
});

// IoC 容器
const container = new Container();
container.load(frontendApplicationModule);
container.load(messagingFrontendModule);
container.load(loggerFrontendModule);

function load(raw) {
    return Promise.resolve(raw.default).then(module =>
        container.load(module)
    )
}

function start() {
    (window['theia'] = window['theia'] || {}).container = container;

    const themeService = ThemeService.get();
    themeService.loadUserTheme();

    const application = container.get(FrontendApplication);
    return application.start();
}

module.exports = Promise.resolve()
    .then(function () { return Promise.resolve(require('@theia/core/lib/browser/menu/browser-menu-module')).then(load) })
    ...
    .then(start).catch(reason => {
        console.error('Failed to start the frontend application.');
        if (reason) {
            console.error(reason);
        }
    });

后端

后端进程运行在Node.js上。我们使用express作为HTTP服务器,它可以不使用任何需要浏览器平台的代码(DOM API)。

启动后端应用程序将首先加载所有扩展包的DI模块,然后获取一个 BackendApplication 的实例并在上面调用 start(portNumber)。 默认情况下后端的 Express 服务器也为前端提供代码。

require('reflect-metadata');
const path = require('path');
const express = require('express');
const { Container } = require('inversify');
const { BackendApplication, CliManager } = require('@theia/core/lib/node');
const { backendApplicationModule } = require('@theia/core/lib/node/backend-application-module');
const { messagingBackendModule } = require('@theia/core/lib/node/messaging/messaging-backend-module');
const { loggerBackendModule } = require('@theia/core/lib/node/logger-backend-module');

const container = new Container();
container.load(backendApplicationModule);
container.load(messagingBackendModule);
container.load(loggerBackendModule);

function load(raw) {
    return Promise.resolve(raw.default).then(module =>
        container.load(module)
    )
}

function start(port, host, argv) {
    if (argv === undefined) {
        argv = process.argv;
    }

    const cliManager = container.get(CliManager);
    return cliManager.initializeCli(argv).then(function () {
        const application = container.get(BackendApplication);
        application.use(express.static(path.join(__dirname, '../../lib')));
        application.use(express.static(path.join(__dirname, '../../lib/index.html')));
        return application.start(port, host);
    });
}

module.exports = (port, host, argv) => Promise.resolve()
    .then(function () { return Promise.resolve(require('@theia/process/lib/node/process-backend-module')).then(load) })
    ...
    .then(() => start(port, host, argv)).catch(reason => {
        console.error('Failed to start the backend application.');
        if (reason) {
            console.error(reason);
        }
        throw reason;
    });

按平台进行区分

在扩展包的根目录下,包含如下子目录层级,按不同的平台进行区分:

  • common:目录下包含的代码不依赖于任何运行时;
  • browser:目录下包含的代码需要运行在现代浏览器平台上(DOM API);
  • electron-browser:目录下包含了需要 DOM API 及 Electron 渲染进程特定的 APIs 的前端代码;
  • node:目录包含了需要运行在 Node.js 下的后端代码;
  • node-electron:目录包含了 Electron 特定的后端代码。

部署模式

Theia 支持三种部署模式。

1.Web Client, Remote Back-end (Cloud IDE)

前端是从远程服务器提供给本地浏览器的,并连接到远程后端。

2.Native Front-End, Local Back-end

基于Electron,IDE可以在本地运行前端和后端。

3.Native Front-End, Remote Back-end

基于Electron,只有前端会在本地运行,并连接到远程后端。

架构图

下图说明了主要组件及其连接方式:

依赖的组件:

模块名 版本 开源协议
electron 1.6.2 MIT
express 4.15.2 MIT
inversify 3.1.0 MIT
monaco-editor-core 0.8.2 MIT
monaco-editor 0.8.3 MIT
monaco-languageclient 0.0.1-alpha.2 MIT
ws 2.2.0 MIT
reconnecting-websocket 3.0.3 MIT
@phosphor/application 0.1.5 BSD-3-Clause
@phosphor/algorithm 0.1.1 BSD-3-Clause
@phosphor/domutils 0.1.2 BSD-3-Clause
@phosphor/messaging 0.1.2 BSD-3-Clause
@phosphor/signaling 0.1.2 BSD-3-Clause
@phosphor/virtualdom 0.1.1 BSD-3-Clause
@phosphor/widgets 0.1.7 BSD-3-Clause
reflect-metadata 0.1.10 Apache-2.0
vscode-ws-jsonrpc 0.0.1-alpha.1 MIT
vscode-languageserver 3.2.0 MIT

扩展包

Theia 由扩展包构成,前端应用程序和主后端应用程序均包含多个扩展。 一个 npm 软件包可以公开一个或多个扩展,这些扩展可以被前端和主后端应用程序使用。一个扩展包就是一个 npm 包,在这个 npm 包中公开了用于创建 DI 容器的多个 DI 模块 (ContainerModule) 。通过在应用程序的 package.json 中添加 npm 包的依赖项来使用扩展包,扩展包能够在运行时安装和卸载,这将触发重新编译和重启。通过 DI 模块,扩展包能提供从类型到具体实现的绑定,即提供服务和功能。

Services 和 Contributions

依赖注入(DI)

DI 在 Theia 中是一个非常重要的部分,Theia 使用DI框架 Inversify.js 来连接不同的组件。DI 在创建时注入组件(作为构造函数的参数),从而将组件从依赖项中彻底解耦出来。DI 容器根据你在启动时通过所谓的容器模块提供的配置项来进行创建。

例如,Navigator 小部件需要访问 FileSystem 用来在树形结构中显示文件夹和文件,但是 FileSystem 接口的实现对 Navigator 来说并不重要,它可以大胆地假设与 FileSystem 接口一致的对象已经准备好并可以使用了。在 Theia 中,FileSystem 的实现仅仅是一个发送 JSON-RPC 消息到后端的代理,它需要一个特殊的配置和处理程序。Navigator不需要关心这些细节,因为它将获取一个被注入的 FileSystem 的实例。此外,这种结构的解耦和使用,允许扩展包在需要时能提供非常具体的功能实现,例如这里提到的 FileSystem,而不需要接触到 FileSystem 接口的任何实现。

Services

Service 只是一个提供给其它组件使用的绑定。一个扩展包可以公开 SelectionService,这样其它扩展包就可以获得一个注入的实例并使用它。

Contribution-Points

如果一个扩展包想要提供 hook 由其它扩展包来实现其中的功能,那么它应该定义一个 contribution-point。一个 contribution-point 就是一个可以被其它扩展包实现的接口。扩展包可以在需要时将它委托给其它部分。例如,OpenerService 定义了一个 contribution-point,允许其它扩展包注册 OpenHandler,你可以查看 这里 的代码。Theia 已经提供了大量的 Contribution Points 列表,查看已存在的 Contribution Points 的一个好方法是查找 bindContributionProvider 的引用。

Contribution Providers

一个 Contribution Provider 基本上是 Contributions 的容器,其中的 Contributions 是绑定类型的实例,要将类型绑定到 Contribution Provider,你可以这样做:

// messageing-module.ts
export const messagingModule = new ContainerModule(bind => {
    bind<BackendApplicationContribution>(BackendApplicationContribution).to(MessagingContribution);
    bindContributionProvider(bind, ConnectionHandler)
});

最后一行将一个 ContributionProvider 绑定到一个包含所有 ConnectionHandler 绑定实例的对象上。像这样使用:

// messageing-module.ts
constructor(@inject(ContributionProvider) @named(ConnectionHandler) protected readonly handlers: ContributionProvider<ConnectionHandler>) {
}

这里我们注入了一个 ContributionProvider,它的 name 值是 ConnectionHandler,这个值之前是由 bindContributionProvider 绑定的。这使得任何人都可以绑定 ConnectionHandler,现在,当 messageingModule启动时,所有的 ConnectionHandlers 都将被初始化。

Theia Services 的依赖注入机制借鉴于 VS Code,Contributions 机制借鉴于 Eclipse。从整体设计上看 Theia 的可拓展性更强大,后面我们再详细分析 Theia 的拓展机制原理。

参考

写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!