Pines-Cheng / blog

技术博客
https://pines-cheng.github.io/blog/
546 stars 42 forks source link

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

Open Pines-Cheng opened 4 years ago

Pines-Cheng commented 4 years ago

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


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

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

名词解释

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

参考

yuzai commented 3 years ago

求助,我照着这个方法写了一个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 commented 3 years ago

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

yuzai commented 3 years ago

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

yuzai commented 3 years ago

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

YangYongAn commented 1 year ago

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

Pines-Cheng commented 6 months ago

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

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