Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cloud IDE Theia 插件系统拓展探索 #83

Open
Pines-Cheng opened this issue Nov 12, 2020 · 6 comments
Open

Cloud IDE Theia 插件系统拓展探索 #83

Pines-Cheng opened this issue Nov 12, 2020 · 6 comments
Labels

Comments

@Pines-Cheng
Copy link
Owner

Pines-Cheng commented Nov 12, 2020

科技工作者复工科技风公众号首图 (3)


2021.8.25 日补充:推荐文章 大型 Web 应用插件化架构探索


  • Theia 插件系统功能非常强大,这么大的工程,依然能保持高质量的代码和清晰的架构,值得思考和学习
  • VSCode Extension API 和配置的定义规范且抽象

Eclipse Theia 是一个可扩展的平台,可以利用最先进的 Web 技术开发多语言的 Cloud & Desktop IDE。

名词解释

  • Theia:可拓展的 Cloud & Desktop IDE 平台。
  • Theia Extension:Theia 是由一系列 Extension 组成,Extension 提供了处理 widgets, commands, handlers 等的能力,在编译时加载
  • Theia Plugin:概念上类似于 VSCode Extension,由 Theia 的其中一个 Extension :theia/packages/plugin-ext 定义了 加载机制、运行环境及 API 等。兼容 VSCode Extension ,功能更为强大,运行时加载
  • VSCode Extension:VSCode 运行时加载的 extensions,基于 VSCode Extension API 实现,概念上类似于 Theia 的 Plugin,运行时加载

VSCode Extension 可以看成是 Theia Plugin 的子集。

Theia Extension 与 Plugin 的界线:核心的、抽象的、编译时加载的采用 Extension;业务的、具体的、运行时加载的采用 Plugin。

Theia Plugin 类型分为前端和后端(VSCode 只有后端),其中后端运行在独立的插件进程;前端运行在 Web Worker 上,通过 postMessage 和 Browser 主进程通信。这里的 Web Worker 有点像微信小程序架构里面的 App Service。

概述

拓展 Theia Plugin 能力,让业务方简单、灵活地深度定制 IDE 的功能和界面。

动机

Theia Plugin 拓展方式和 能力VSCode Extension 类似,并不满足我们的需求:

  1. UI 定制能力非常薄弱:主界面仅提供了少量的按钮与菜单支持自定义。但很多实际场景都有非常强烈的 UI 需求以满足不同的业务能力。如:例如 Taro IDE 主界面需要大量的按钮菜单注入以及模拟器、调试器等预览面板。
  2. 配置化的 UI 定制方式无法满足定制需求:Theia Plugin 基于 Phosphor.js 实现布局系统,将定制能力限定在了 配置化 这一层,随着 IDE Core 不同场景的业务方越来越多,容易形成「配置地狱」,因此在保留配置化的同时,最好提供布局相关 API ,让业务方使用 JSX 自定义布局。(参考开天)
  3. 与内部业务、场景对接:如 ERP 登录认证、gitlab 仓库对接、团队协作与工作空间、监控/运营系统集成等。(参考 开天 + Eclipse Che

因此,需要拓展 Theia 的插件系统。

原则

  1. 屏蔽 IoC/布局系统/Widgt 等复杂概念,让用户只需要拥有 VS Code 插件开发经验就能够开发 Tide 插件。
  2. 尽可能复用 VS Code Extension 相关的设计和 API ,尽可能参照 VS Code Extension API 现有的接口或规范进行拓展。
  3. 用户只需要拥有 React 开发经验就可以定制布局系统。

设计概览

设计总结

  1. 通过独立的 Extension 包拓展插件系统。
    • 参考 eclipse-che-theia-plugin-ext,提供 tide-theia-plugin-ext 。
    • 用户只需要加载 tide-theia-plugin-ext,就可以使用拓展的 API 及配置等。
  2. 提案参考 VS Code Extension 的拓展接口及规范,从配置、Command 、VS Code API 等方面拓展插件系统。
  3. 相对于 theia/vscode Namespace,提供 tide 的 Namespace 访问 Tide API。
    • 和 Theia Plugin 解耦

整体设计图示

image

tide 项目结构

IDE Core 和 Taro IDE 暂时放在同一个项目 tide 里,建议参考:che-theia

./
├── configs
├── examples
│   ├── browser-app
│   └── electron-app
├── extensions
│   ├── tide-theia-about
│   ├── tide-theia-plugin // Tide API 接口规范定义
│   ├── tide-theia-plugin-ext // 插件系统拓展实现
│   ├── tide-theia-user-preferences // 用户信息相关
│   ├──  ...
└──  plugins
    ├── dashboard-plugin
    ├── test-plugin
    ├── deploy-plugin
    ├── setting-plugin
    └── ...

npm 包发布在 @tide Scope 下。

VS Code Extension(概念上等同于 Theia 的 Plugin)能力是以下三种方式拓展:

详细设计

项目将参考 VS Code Extension 的拓展接口及规范,从配置、Command 、VS Code API 等方面拓展插件系统,其中,VSCode 最具代表性的拓展例子应该是 Tree View API,兼具以上三者方式。

Contribution Points 配置拓展

Contribution Pointspackage.jsoncontributes 字段的一系列 JSON 声明,插件通过注册 Contribution Points 来拓展 VSCode 的功能。

contributes 配置的处理可以分为配置扫描(scanner)和配置处理(handler)。主要在 plugin-ext 里实现。

scanner

plugin-ext/src/hosted/node/scanners/scanner-theia.ts 里的 TheiaPluginScanner 类实现了所有 package.json 配置的读取方法,包括 contribution 配置、activationEvents 配置等。

我们应该是不需要添加新的配置读取,所以不需要修改这里。

handler

contribution 最终配置的 handle 都是在 PluginContributionHandler 里注入实际 handler 类统筹处理的。

// packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts
export class PluginContributionHandler {
    @inject(MenusContributionPointHandler) // 注入 Menu 相关 handler 
    private readonly menusContributionHandler: MenusContributionPointHandler;
    @inject(KeybindingsContributionPointHandler) // 注入 Keybindings 相关 handler 
    private readonly keybindingsContributionHandler: KeybindingsContributionPointHandler;
    // ...

    handleContributions(clientId: string, plugin: DeployedPlugin): Disposable {
     // ...
        pushContribution('commands', () => this.registerCommands(contributions));
        pushContribution('menus', () => this.menusContributionHandler.handle(plugin));
        pushContribution('keybindings', () => this.keybindingsContributionHandler.handle(contributions));

        if (contributions.views) {
            for (const location in contributions.views) {
                for (const view of contributions.views[location]) {
                    pushContribution(`views.${view.id}`,
                        () => this.viewRegistry.registerView(location, view) // 注册页面配置
                    );
                }
            }
        }
     // ...
    }
    registerCommandHandler(id: string, execute: CommandHandler['execute']): Disposable {}
    registerCommand(command: Command): Disposable {}
     // ...
}

拓展

和 API 拓展不同专门预留了拓展注入点 ExtPluginApiProvider 不同,Theia 代码里类似的并没有预留专门的接口,暂时采用以下步骤拓展:

  1. 定义 TidePluginContributionHandler 继承 PluginContributionHandler
  2. 重写 handleContributions 方法
  3. 在 ContainerModule 里 rebind(TidePluginContributionHandler).to(PluginContributionHandler).inSingletonScope();

如果有更好的方式,请指正。

Command 拓展

Commands 触发 Theia/VSCode 的 actions。VSCode 代码里包含大量 built-in commands,你可以使用这些命令与编辑器交互、控制用户界面或执行后台操作。

Command 拓展可以参考:packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts

首先定义 XXXCommandsContribution 类实现 CommandContribution,并注入对应的服务,如然后在 XXXCommandsContribution 中通过 commands.registerCommand 进行 Command 拓展,如:

// packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts
export class PluginVscodeCommandsContribution implements CommandContribution {
    @inject(ContextKeyService)
    protected readonly contextKeyService: ContextKeyService;
    @inject(WorkspaceService)
    protected readonly workspaceService: WorkspaceService;
    
    registerCommands(commands: CommandRegistry): void {
            commands.registerCommand({ id: 'openInTerminal' }, { // 注册命令
            execute: (resource: URI) => this.terminalContribution.openInTerminal(new TheiaURI(resource.toString()))
        });
    }
}

然后 bind 到 container 即可:

bind(XXXCommandsContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(XXXCommandsContribution);

XXXCommandsContribution 会被注入到对应的 ContributionProvider,然后进行处理:

constructor(
        @inject(ContributionProvider) @named(CommandContribution)
        protected readonly contributionProvider: ContributionProvider<CommandContribution>
) { }

Command 可以传入对象作为参数,无法暴露接口和组件。

API 拓展

相对上面两种拓展方式,API 的拓展方式比较复杂。

方式一:plugin-ext-vscode 的方式

这种方式是 VSCode 采用的方式,通过修改 PluginLifecycle 里面的 backendInitPathfrontendInitPath,这两个脚本类似于 preload 脚本,在插件加载前进行预加载,初始化插件环境。

具体是 VsCodePluginScanner 类里的 getLifecycle() 方法的 backendInitPath。在这里 backendInitPath 被初始化为: backendInitPath: __dirname + '/plugin-vscode-init.js'

/**
 * This interface describes a plugin lifecycle object.
 */
export interface PluginLifecycle {
    startMethod: string;
    stopMethod: string;
    /**
     * Frontend module name, frontend plugin should expose this name.
     */
    frontendModuleName?: string; 
    /**
     * Path to the script which should do some initialization before frontend plugin is loaded.
     */
    frontendInitPath?: string;   // 插件前端 preload
    /**
     * Path to the script which should do some initialization before backend plugin is loaded.
     */
    backendInitPath?: string;  // 插件后端 preload
}

然后在 PluginHostRPC 类里 new PluginManagerExtImpl() 实例时,在传入的 init 钩子中调用的 initContext 中通过 require() 方法加载。

注意:initContext 里面的 backendInitPath 来自于 PluginLifecycle,并不是 ExtPluginApiProvider

// packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts
/**
 * Handle the RPC calls.
 */
export class PluginHostRPC {
    private apiFactory: PluginAPIFactory;

    private pluginManager: PluginManagerExtImpl;

    initialize(): void {
        this.pluginManager = this.createPluginManager(envExt, storageProxy, preferenceRegistryExt, webviewExt, this.rpc);
    }

    initContext(contextPath: string, plugin: Plugin): any {
        const { name, version } = plugin.rawModel;
        console.log('PLUGIN_HOST(' + process.pid + '): initializing(' + name + '@' + version + ' with ' + contextPath + ')');
        const backendInit = require(contextPath);  // 加载 PluginLifecycle 的 backendInitPath
        backendInit.doInitialization(this.apiFactory, plugin);  // 调用 backendInitPath 脚本暴露的 doInitialization 方法
    }

    createPluginManager(){
        const pluginManager = new PluginManagerExtImpl({
            loadPlugin(plugin: Plugin): any {},
            async init(raw: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> {
                            let backendInitPath = pluginLifecycle.backendInitPath;
                            // if no init path, try to init as regular Theia plugin
                            if (!backendInitPath) {
                                backendInitPath = __dirname + '/scanners/backend-init-theia.js';
                            }
                            self.initContext(backendInitPath, plugin);  // backendInitPath 来自于 pluginLifecycle
            },
            initExtApi(extApi: ExtPluginApi[]): void {
                            const extApiInit = require(api.backendInitPath); // 加载 ExtPluginApiProvider 注入的 backendInitPath
                            extApiInit.provideApi(rpc, pluginManager);
            },
            loadTests: extensionTestsPath ? async () => {}
        })
    }
}

而 backendInitPath 配置的 plugin-vscode-init.ts 文件提供了 doInitialization 方法,在 doInitialization 方法中通过 Object.assign 合并 Theia API 到 vscode namespace,添加简单的 API 和字段。

// packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts
export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIFactory, plugin: Plugin) => {
    const vscode = Object.assign(apiFactory(plugin), { ExtensionKind });  // 合并 API 

    // use Theia plugin api instead vscode extensions
    (<any>vscode).extensions = {
        get all(): any[] {
            return vscode.plugins.all.map(p => asExtension(p));
        },
        getExtension(pluginId: string): any | undefined {
            return asExtension(vscode.plugins.getPlugin(pluginId));
        },
        get onDidChange(): theia.Event<void> {
            return vscode.plugins.onDidChange;
        }
    };
}

这种方法本质是在插件加载前运行脚本,不涉及到 RPC,通过 Object.assign 合并简单的 API。

这种方式不如 ExtPluginApiProvider 的方式优雅,社区有人提里 PR 将其改成 ExtPluginApiProvider 的形式:Make "theia" and "vscode" contributed API's #8142,目前为止依然还没有被合并。

方式二:ExtPluginApiProvider 的方式

eclipse/che-theia 就是采用了这种方式,功能非常强大。具体可见:ChePluginApiProvider

Theia 官方文档没有提到这种方式,不过在 plugin-ext/doc 下倒是有一片简单的介绍文档:This document describes how to add new plugin api namespace

Che-Theia plug-in API 提供了 che 的 namespace。

image

主要步骤

主要步骤如下:

  1. 实现 ExtPluginApiProvider 接口,定义 API 拓展的前端入口 frontendExtApi 和后端入口 backendInitPath
  2. 定义 Client API 的接口 createAPIFactory(rpc),然后分别挂载到前端及后端插件运行时。
  3. 前端入口脚本 che-api-worker-provider.js ,实现并 export initializeApi 方法。在 initializeApi 中,通过 createAPIFactory(rpc)定义接口。
  4. 后端入口脚本 che-api-node-provider.js,暴露 export provideApi(),然后在 overrideInternalLoad() 方法中改写 module._load,通过 createAPIFactory(rpc)定义接口。
  5. 注入 Server API,Server API 可以看成是接口的实现,通过 MainPluginApiProvider 注入到 browser,监听 Client API 的 RPC 消息并触发对应处理方法。

1. ExtPluginApiProvider 提供前后端入口

首先声明 ExtPluginApiProvider 实现:

// extensions/eclipse-che-theia-plugin-ext/src/node/che-plugin-api-provider.ts
export class ChePluginApiProvider implements ExtPluginApiProvider {
    provideApi(): ExtPluginApi {
        return {
            frontendExtApi: {
                initPath: '/che/api/che-api-worker-provider.js',
                initFunction: 'initializeApi',
                initVariable: 'che_api_provider'
            },
            backendInitPath: path.join('@eclipse-che/theia-plugin-ext/lib/plugin/node/che-api-node-provider.js')
        };
    }
}

然后注入到 backend moudule:

    // extensions/eclipse-che-theia-plugin-ext/src/node/che-backend-module.ts
    bind(ChePluginApiProvider).toSelf().inSingletonScope();
    bind(Symbol.for(ExtPluginApiProvider)).toService(ChePluginApiProvider);

这样,前端及后台都有了插件拓展的入口。

2. Client API 的接口 createAPIFactory(rpc)

createAPIFactory 用于定义 Client API 接口,然后分别挂载到前端及后端插件运行时的 namespace。

createAPIFactory 方法的实现,和 Theia 源码中 packages/plugin-ext/src/plugin/plugin-context.ts 里 createAPIFactory 的实现一致:

export function createAPIFactory(rpc: RPCProtocol): CheApiFactory {
    return function (plugin: Plugin): typeof che {}
}

前后端 Client API 注入

Client API 可以看成是接口的定义,暴露到前后端运行时中,供插件调用。

new PluginManagerExtImpl() 传入的第一个参数 host 是 PluginHost 类型,其中的 initExtApi 等方法前端后台分别实现:

export interface PluginHost {

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    loadPlugin(plugin: Plugin): any;

    init(data: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> | [Plugin[], Plugin[]]; // 初始化插件

    initExtApi(extApi: ExtPluginApi[]): void;  // 初始化从外部引入的前后端 API,ExtPluginApi 包含 frontendExtApi 或 backendInitPath

    loadTests?(): Promise<void>;
}

initExtApi 前端,挂在 window 下。

initExtApi(extApi: ExtPluginApi[]): void {
    if (api.frontendExtApi) {
        ctx.importScripts(api.frontendExtApi.initPath);
        ctx[api.frontendExtApi.initVariable][api.frontendExtApi.initFunction](rpc, pluginsModulesNames);
    }
}

其中 const pluginsModulesNames = new Map<string, Plugin>(); 插件的集合。

initExtApi 后端,直接 require,并运行 provideApi()

initExtApi(extApi: ExtPluginApi[]): void {
    if (api.backendInitPath) {
        const extApiInit = require(api.backendInitPath);
        extApiInit.provideApi(rpc, pluginManager);  // 调用 provideAp
    }
}

3. 前端入口 initializeApi,挂载 API 到 namespace

前端入口脚本 che-api-worker-provider.js ,实现并 export initializeApi 方法。在 initializeApi 中,传入 RPC,挂载到 che namespace。

// extensions/eclipse-che-theia-plugin-ext/src/plugin/webworker/che-api-worker-provider.ts
export const initializeApi: ExtPluginApiFrontendInitializationFn = (rpc: RPCProtocol, plugins: Map<string, Plugin>) => {
    const cheApiFactory = createAPIFactory(rpc);  // 核心在于 createAPIFactory
    const handler = {
        get: (target: any, name: string) => {
            const plugin = plugins.get(name);
            if (plugin) {
                let apiImpl = pluginsApiImpl.get(plugin.model.id);
                if (!apiImpl) {
                    apiImpl = cheApiFactory(plugin);
                    pluginsApiImpl.set(plugin.model.id, apiImpl);
                }
                return apiImpl;
            };MainPluginApiProvider
        }
        ctx['che'] = new Proxy(Object.create(null), handler); // 直接挂载到 che namespace
    };

4. 后端入口 provideApi,load 时代理 API

后端入口脚本 che-api-node-provider.js,代码里需要暴露 export provideApi()

后端也是通过 createAPIFactory 定义 Client API 接口。

export const provideApi: ExtPluginApiBackendInitializationFn = (rpc: RPCProtocol, pluginManager: PluginManager) => {
    cheApiFactory = createAPIFactory(rpc);
    plugins = pluginManager;

    if (!isLoadOverride) {
        overrideInternalLoad();
        isLoadOverride = true;
    }
};

然后在 overrideInternalLoad() 方法中改写 module._load,使 require('@eclipse-che/plugin') 返回定义的 Client API。

function overrideInternalLoad(): void {
    const module = require('module');
    // save original load method
    const internalLoad = module._load;

    // if we try to resolve che module, return the filename entry to use cache.
    module._load = function (request: string, parent: any, isMain: {}): any {
        if (request !== '@eclipse-che/plugin') {
            return internalLoad.apply(this, arguments);
        }

        apiImpl = cheApiFactory(plugin);
        return apiImpl;
    }
}

5. Server API 的注入

Server API 可以看成是接口的实现,通过 MainPluginApiProvider 注入到 browser,监听 Client API 的 RPC 消息并触发对应处理方法。

MainPluginApiProvider 的实现应该包含新命名空间的 Plugin API 的 main(接口实现)部分。

/**
 * Implementation should contains main(Theia) part of new namespace in Plugin API.
 * [initialize](#initialize) will be called once per plugin runtime
 */
export interface MainPluginApiProvider {
    initialize(rpc: RPCProtocol, container: interfaces.Container): void;
}

注入到浏览器的 HostedPluginSupport 中,然后在 initRpc方法一次调用注入 MainPluginApiProvider 的 initialize 方法进行初始化。

// packages/plugin-ext/src/hosted/browser/hosted-plugin.ts
@injectable()
export class HostedPluginSupport {

    @inject(ContributionProvider)
    @named(MainPluginApiProvider)
    protected readonly mainPluginApiProviders: ContributionProvider<MainPluginApiProvider>;
    
    protected initRpc(host: PluginHost, pluginId: string): RPCProtocol {
        const rpc = host === 'frontend' ? new PluginWorker().rpc : this.createServerRpc(pluginId, host); 
        setUpPluginApi(rpc, this.container); // 初始化 VScode API 的 Server 端实现
        this.mainPluginApiProviders.getContributions().forEach(p => p.initialize(rpc, this.container)); // 初始化外部注入的接口实现
        return rpc;
    }

}

简化的 API 通信架构图大致如下:

ExtPluginApiProvider 的拓展方式非常成熟优雅且功能强大,建议采用这一种。

Demo

见 Tide 项目 master 分支 extension/tide-theia-plugin-ext 模块。

参考

@Pines-Cheng Pines-Cheng changed the title Cloud IDE Theia 插件系统的拓展的探索 Cloud IDE Theia 插件系统拓展探索 Nov 12, 2020
@yuzai
Copy link

yuzai commented Dec 23, 2020

求助,我照着这个方法写了一个theia自定义的插件api,

import { injectable } from 'inversify';
import { ExtPluginApiProvider, ExtPluginApi  } from '@theia/plugin-ext/lib/common/plugin-ext-api-contribution';
import * as path from 'path';

@injectable()
export class MusicPluginApiProvider implements ExtPluginApiProvider {
    provideApi(): ExtPluginApi {
        return {
            frontendExtApi: {
                initPath: '../../lib/plugin/webworker/music-api-worker-provider.js',
                initFunction: 'initializeApi',
                initVariable: 'music_api_provider'
            },
            backendInitPath: path.join('@music/theia-plugin-ext/lib/plugin/node/music-api-node-provider.js')
        };
    }
}

插件api后端使用没有问题,可以正常加载,可以正常使用。
但是前端在插件引入的时候,会在控制台报这样的错:
image
看起来关键点在于我的webworker的文件没有找到,问题是这个路径,该怎么写,才能找到这个文件去执行呢?

@yuzai
Copy link

yuzai commented Dec 24, 2020

总算是看到了,che-plugin还添加了一个backend的扩展,来处理前端对api-provider的请求,把文件返回给webworker。
感觉离实现距离还很远啊。。先给大哥点个赞!

@yuzai
Copy link

yuzai commented Dec 30, 2020

有个问题,我基本上按照che-plugin的在写了
通过在MainPluginApiProvider中通过rpc.set(xxx, new xxx)来实现api.
但是在createAPIFactory使用rpc.getProxy(xxx)的时候,拿到该Api实现的对应的属性是undefined,这里不知道为什么。应该不需要我再在BackendApplication中自己起一套rpc服务了吧,感觉都没问题,但是拿到的属性就是undefined,可以提供点排查这个问题的思路吗?

@yuzai
Copy link

yuzai commented Dec 30, 2020

不好意思,,我找了下,找到了。。在plugin-ext/src/common/rpc-protocol里面,设定了属性中$开头的才会进行转发,
虽然原理我还是不太懂,不过目前整体的扩展插件api算是走通了,打扰了。
image

@YangYongAn
Copy link

我有点混乱了,这几个究竟是什么关系啊?
打开看好像是 VSCode 的网页版。但是又是 eclipse 的?

@Pines-Cheng
Copy link
Owner Author

我有点混乱了,这几个究竟是什么关系啊? 打开看好像是 VSCode 的网页版。但是又是 eclipse 的?

你可以看成是 Eclipse 官方重写了一个 VS Code,名字叫 Theia,很多能力、架构包括接口设计等都是和 VS Code 相通的。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants