乐趣区

关于ide:Cloud-IDE-Theia-插件系统拓展探索

  • 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 等方面拓展插件零碎。

    • 用户无额定学习老本,领有 VS Code 插件开发教训就可能开发 Tide 插件。
    • 拓展 VS Code API,通过 tide namespace 裸露的办法和 interface 等。
    • 拓展 package.json 配置,其中次要是 Contribution Points
    • 拓展 Command,包含 built-in advanced commands api 和 keyboard shortcuts
    • 如有须要,拓展 task。
  3. 绝对于 theia/vscode Namespace,提供 tide 的 Namespace 拜访 Tide API。

    • 和 Theia Plugin 解耦

整体设计图示

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。

首先申明 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);

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

createAPIFactory

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 {}}

前端入口 initializeApi

前端入口脚本 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
    };

后端入口 provideApi

后端入口脚本 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;
    }
}

前后端 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
    }
}

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 模块。

参考

  • Theia – Authoring Theia Plug-ins
  • eclipse/che-theia
  • VS Code API
  • Contribution Points
  • Compare Theia vs VS Code API
  • This document describes how to add new plugin api namespace
  • Tree View API
  • Commands
  • KAITIAN IDE 是如何构建扩大能力极强的插件体系的?
退出移动版