Eclipse Theia学习(一):如何设计一款借鉴Theia的插件扩展功能的Electron桌面应用程序?

2,158 阅读13分钟

欢迎关注我的博客:小瓜看世界 | crownhuang.cn/

Electron里为什么有必要进行插件扩展功能的设计?

对于大型软件来说,能够将庞杂地业务处理逻辑通过插件的方式进行管理往往需要更清晰的架构分层,一般都会采用npm包的方式进行管理。但是如何能够借鉴npm包的管理方式进一步优化插件扩展功能呢?Theia-Plugin即是一个非常优秀地实现方式。

Theia官方文档中对于插件的介绍相对较少,不过其对于插件扩展(Extention)的比较分析得十分清晰。

综合以上分析,由于公司所涉及的业务领域相对较庞大,为了更好地将Electron主程序内的各个插件模块清晰分层、彼此隔离、相对好地进行拓展,因此借鉴了Theia地插件扩展功能,并将其插件扩展模块清晰地进行了摘解,封装成了独立模块供Electron主进程进行使用。由此,插件扩展功能将会有以下的优势:

  1. 清晰分层:可以更清晰地将Electron运行时与平台核心功能进行分层。
  2. 独立开发:插件的独立开发、灵活部署特性,可以更高效地快速增加平台的运行时能力。
  3. 热加载:插件可在运行时加载,不用再次全量编译平台代码,从而减少平台编译时间。
  4. 独立运行:插件运行在独立进程中,插件调用造成进程级崩溃不会干扰主进程,更安全。
  5. 插件自治:插件可以打包到单个文件中,然后直接加载。无需从NPM库等获取依赖项。

为什么无法直接使用Theia Plugin,还需要进行哪些具体工作?

Theia 应用程序由一个内核组成,该内核提供了一组用于特定功能的小部件,命令,处理程序等。Theia 定义了一个运行时 API,允许插件自定义 IDE 并将其行为添加到应用程序的各个方面。在 Theia 中,插件可以通过名为theia 的对象访问 API,该对象在所有插件中都可用。Theia 可用的 API 使用文档:@theia/plugin,Theia API 兼容 VS Code API,API 覆盖率文档:Compare Theia vs VS Code API

Theia 在技术架构上分成前端和后端两大部分,对于插件体系也是类似的,分为 Frontend plug-in 和 Backend plug-in。前端插件是工作在 Browser 的 UI 线程,因此无法直接打开或写入文件;后端插件的代码在服务器端以专用进程运行,后端插件调用 API 后,将在用户的浏览器 UI 上发送消息注册操作命令等,后端插件和 VS Code 的 Extensions 类似。

Theia在前端框架选型中也采用了基于 PhosphorJS 框架。 PhosphorJS 提供了一组丰富的小部件,布局,事件和数据结构。

通过上述分析Theia Plugin的架构体系以及结合自身平台要求,我们发现完全采用Theia-Plugin的一套代码,不是特别符合需求。首先,自身平台只需要执行特定的后台服务逻辑,有点类似Theia-Plugin中的backend-plugin,因此其前端插件功能,则可以摒弃掉。第二,Theia-Plugin中结合了较多PhosphorJS的控件用于在加载插件过程中调度视图UI层,此类功能也需要被摒弃掉。所以最终考虑,只迁移Theia-Plugin中关于插件子进程与主进程调度、插件扫描、插件管理等核心功能。让我们总结下最后我到底都干了啥:

  1. 重新基于咱们自身需求进行架构分层,并对各层之间的调度时机进行调整
  2. 只摘取了Theia关于backend的插件调度机制,frontend由于我们不需要,所以扔掉先
  3. 摒弃了Theia在插件调度过程中与PhosphorJS(有点类似于Java中的swt)的强关联性
  4. 重新template模板工程,后续可以基于cli来搞。
  5. 重新规划plugin中对于实体类的绑定功能。

插件扩展整体架构

整体架构.png 我们对整体架构进行下分析,首先,Electron主进程里专门需要提供一个特定主进程插件管理器,我们姑且称之为hosted-plugin-manager,用于提供对于插件子进程的创建和销毁功能,并且接收来自渲染进程发送过来的消息,并且建立与插件子进程的消息通道,基于rpc通讯功能来发送和接受消息。同时,hsoted-plugin-manager里也应该提供一套对于报文组织和转发的功能。 当启动Electron客户端时,hosted-plugin-manager会fork出插件子进程。并且初始化对应的子进程管控、插件调度、插件扫描部署和插件管理层。那么让我们看看这几层分别都有哪些功能。

插件子进程管控层(process)

process.png 插件子进程管控层主要对于主进程fork出来的整个插件子进程进行消息通道的建立/销毁,同时转发和接收来自主进程发送过来的消息。每当主进程发送一条消息时,需要对发送过来的消息进行处理转发。这里的转发则交由一个RPCProtocol的具体实现类来实现。消息的转发和RPCProtocol对于消息的订阅,两者通过一种EventEmitter的事件订阅发布工具来进行绑定。 Theia Plugin中的EventEmitter本身设计的十分合理,其实现了Iterable的迭代器功能。定义Symbol.iterator的构造函数,每当主进程发送一则消息需要分发时,都会触发迭代器来遍历具体的订阅函数,触发对应的订阅方法。EventEmitter的具体代码实现,我会专门写一篇文章来详细学习介绍一下。 RPCProtocol实际上是对于某一类事件进行监听。彼时,在插件加载之后,RPCProtocol则会传递给插件内部,其提供的set方法用于插件内部来注册某个实例的API。 class RPCProtocolImplset方法代码如下:

set<T, R extends T>(identifier: any, instance: R): R {
        if (this.isDisposed) {
            throw ConnectionClosedError.create();
        }
        //TODO:id的变化 this.locals则用来维护所有插件的实例
        this.locals.set(identifier, instance);
        if (Disposable.is(instance)) {
            this.toDispose.push(instance);
        }
        this.toDispose.push(Disposable.create(() => this.locals.delete(identifier.id)));
        return instance;
    }

插件中调用set方法代码如下:

export function start(context: any) {
    console.log("ab-electron-plugin-codec is active!")
    context.rpc.set("codec-plugin", new CodecHandler());
}

从而每当主进程发来一个事件请求时,其doInvokeHandler方法都会通过其维护的注册插件实例来apply调用某个插件的某个方法实现真正插件内部逻辑的调用。

private doInvokeHandler(proxyId: string, methodName: string, args: any[]): any {
        const actor = this.locals.get(proxyId);
        if (!actor) {
            throw new Error('Unknown actor ' + proxyId);
        }
        const method = actor[methodName];
        if (typeof method !== 'function') {
            throw new Error('Unknown method ' + methodName + ' on actor ' + proxyId);
        }

        return method.apply(actor, args);
    }

说完RPCProtocol,我们把堆栈收一收,回到插件子进程管理层还干了啥。在整个插件子进程管理实现的最后,它还初始化了插件调度层new PluginHostRPC(rpc),并且出发了插件调度层的initialize()start()方法。然后插件子进程管理的工作结束。

const pluginHostRPC = new PluginHostRPC(rpc);
pluginHostRPC.initialize();
pluginHostRPC.start();

插件调度层(dispatcher)

dispatcher.png

插件调度层其最主要的工作是对插件扫描部署层、插件管理层进行调用和组织,initialize()start()scannerPlugin()三个关键function实现了对插件管理层的初始化、插件部署扫描层的初始化和插件管理层的启动操作。class PluginHostRPC代码如下

initialize() {
        const storageProxy = new KeyValueStorageProxy(this.rpc);
        this.pluginManager = this.createPluginManager(storageProxy, this.rpc);
        this.pluginManager.$init();
    }

    async start() {
        const plugins = await this.scannerPlugin();
        const activationEvents = ["*"];
        this.pluginManager.$start({ plugins, activationEvents });
    }
    async scannerPlugin() {
        const pluginDeployer = new PluginDeployer();
        await pluginDeployer.dostart();
        return pluginDeployer.pluginsMetadata;
    }

插件扫描部署层(deploy)

deploy.png

由于Theia-plugin中对于插件包的分类分为:vscode-plugintheia-plugin,前者是对于vscode插件的继承,可以允许vscode插件在Theia中运行。后者则是Theia允许用户自定义拓展一些功能。Theia 定义了一个运行时 API,允许插件自定义 IDE 并将其行为添加到应用程序的各个方面。在 Theia 中,插件可以通过名为theia 的对象访问 API,该对象在所有插件中都可用。Theia 可用的 API 使用文档:@theia/plugin,Theia API 兼容 VS Code API,API 覆盖率文档:Compare Theia vs VS Code API。因此对于插件包的基础信息,两者也有差异。(比如生命周期函数不一样。) theia的插件扫描方法提供了一套统一标准接口,vscode-plugintheia-plugin分别对接口进行了实现,所以在扫描插件过程中,Theia会根据engine的类型来选择是调用vscode-pluginscanner还是theia-pluginscannerPluginScanner接口具体为:

/**
 * This scanner process package.json object and returns plugin metadata objects.
 */
export interface PluginScanner {
    /**
     * The type of plugin's API (engine name)
     */
    apiType: PluginEngine;

    /**
     * Creates plugin's model.
     *
     * @param {PluginPackage} plugin
     * @returns {PluginModel}
     */
    getModel(plugin: PluginPackage): PluginModel;

    /**
     * Creates plugin's lifecycle.
     *
     * @returns {PluginLifecycle}
     */
    getLifecycle(plugin: PluginPackage): PluginLifecycle;

    getContribution(plugin: PluginPackage): PluginContribution | undefined;

    /**
     * A mapping between a dependency as its defined in package.json
     * and its deployable form, e.g. `publisher.name` -> `vscode:extension/publisher.name`
     */
    getDependencies(plugin: PluginPackage): Map<string, string> | undefined;
}

其中包括了插件package.json中的元数据信息(getMOdel)、插件生命周期获取,插件依赖,插件的contribution等。因此,这部分接口的定义,我们也同步迁移了过来,并且基于接口提供了一个父类:BasePluginScanner,用来提供基本的插件扫描功能,而针对平台AB插件,我们也做了一部分改造,主要改造点为:

  • 指定了插件目录
  • 扩展封装了对于PluginScanner的实现——ABPluginScanner,指定了读取package.json中的engineABPlugin~定义了ABPlugin的生命周期函数方法:start()以及stop()
  • ABPluginScanner后续还可以根据插件package.json中定义的其他客户化要求进行定制扩展。 将上述插件的基础信息封装成了一个”bean“,即pluginMetadata其中包裹着全部插件包package.json中的元数据信息和相关生命周期函数的定义。从而在插件调度层中可以被获取到。
async deployPlugins(entry: PluginDeployerEntry) {
        const pluginPath = entry.path();
        const entryPoint = 'backend'
        try {
            const manifest = await this.reader.readPackage(pluginPath);
            if (!manifest) {
                return;
            }

            const metadata = this.reader.readMetadata(manifest);

            const deployedLocations = this.deployedLocations.get(metadata.model.id) || new Set<string>();
            deployedLocations.add(entry.rootPath);
            this.deployedLocations.set(metadata.model.id, deployedLocations);

            const deployedPlugins = this.deployedBackendPlugins;
            if (deployedPlugins.has(metadata.model.id)) {
                return;
            }
            const { type } = entry;
            const deployed: DeployedPlugin = { metadata, type };
            deployedPlugins.set(metadata.model.id, deployed);
            this.pluginMetadata.push(deployed.metadata);
            console.log(`Deploying ${entryPoint} plugin "${metadata.model.name}@${metadata.model.version}" from "${metadata.model.entryPoint[entryPoint] || pluginPath}"`);
        } catch (e) {
            console.error(`Failed to deploy ${entryPoint} plugin from '${pluginPath}' path`, e);
        }
    }
	get pluginsMetadata(): Array<any> {
        return this.pluginMetadata
    }

插件管理层(managerment)

management.png

插件管理层主要提供了一个类:PluginManagerExtImpl,其提供了加载插件、初始化插件、启动插件、destroy插件的相应方法实现。由于Theia本身分为前端插件和后端插件,分别在worker-main.ts以及plugin-host-rpc.ts中进行调度,因此对于Theia来说,前端和后端插件在插件管理的实现有所区别,因此PluginManagerExtImpl等同于是一个泛泛的插件加载的流程调度,前后端插件加载的差异性需要在调度过程中区别对待:如PluginMangagerExtImpl中的$start方法中,有一条语句是this.host.init(),则是调度了前后端插件加载的自己的init方法。 PluginmanagerExtImpl.ts

async $start(params: PluginManagerStartParams): Promise<void> {
        const [plugins, foreignPlugins] = await this.host.init(params.plugins);
        // add foreign plugins
        for (const plugin of foreignPlugins) {
            this.registerPlugin(plugin);
        }
        // add own plugins, before initialization
        for (const plugin of plugins) {
            this.registerPlugin(plugin);
        }

        // run eager plugins
        await this.$activateByEvent('*');
        for (const activationEvent of params.activationEvents) {
            await this.$activateByEvent(activationEvent);
        }
    }

plugin-host-rpc.ts

const pluginManager = new PluginManagerExtImpl({
......
async init(raw: PluginMetadata[]): Promise<[Plugin[], Plugin[]]> {
                const result: Plugin[] = [];
                const foreign: Plugin[] = [];
                for (const plg of raw) {
                    try {
                        const pluginModel = plg.model;
                        const pluginLifecycle = plg.lifecycle;

                        const rawModel = await loadManifest(pluginModel.packagePath);
                        rawModel.packagePath = pluginModel.packagePath;
                        if (pluginModel.entryPoint!.frontend) {
                            foreign.push({
                                pluginPath: pluginModel.entryPoint.frontend!,
                                pluginFolder: pluginModel.packagePath,
                                pluginUri: pluginModel.packageUri,
                                model: pluginModel,
                                lifecycle: pluginLifecycle,
                                rawModel
                            });
                        } else {
                            let backendInitPath = pluginLifecycle.backendInitPath;
                            // if no init path, try to init as regular Theia plugin
                            if (!backendInitPath) {
                                backendInitPath = __dirname + '/scanners/backend-init-theia.js';
                            }

                            const plugin: Plugin = {
                                pluginPath: pluginModel.entryPoint.backend!,
                                pluginFolder: pluginModel.packagePath,
                                pluginUri: pluginModel.packageUri,
                                model: pluginModel,
                                lifecycle: pluginLifecycle,
                                rawModel
                            };
                            result.push(plugin);
                        }
                    } catch (e) {
                        console.error(`Failed to initialize ${plg.model.id} plugin.`, e);
                    }
                }
                return [result, foreign];
            }
        }
......
})

因此,在我们平台当中,这部分功能,仅迁移了后端插件的加载过程。后端插件加载过程主要分两步骤:第一,是进行了插件的require,会根据上述插件部署扫描层获取到的插件元数据信息,拿到具体每一个插件的入口文件,通过require的函数进行加载。

const pluginManager = new PluginManagerExtImpl({
	loadPlugin(plugin: Plugin): any {
	......
	if (plugin.pluginPath) {
                    return require(plugin.pluginPath);
                }
	......
	}
	......
})

第二,则是进行了插件生命周期函数中生命周期启动方法(ABPlugin为start()方法)的调用。在调用过程中,可以将相关上下文传递给插件内部,也就是在这个关键点,我们将RPCProtocol作为上下文的一部分传递了进去。

private async startPlugin(plugin: Plugin, pluginMain: any) {
const pluginContext: ab.PluginContext = {
            rpc: this.rpc,
            extensionPath: plugin.pluginFolder,
            extensionUri: Uri.file(plugin.pluginFolder),
            globalState: new Memento(plugin.model.id, true, this.storageProxy),
            workspaceState: new Memento(plugin.model.id, false, this.storageProxy),
            subscriptions: subscriptions,
            asAbsolutePath: asAbsolutePath
        };
        this.pluginContextsMap.set(plugin.model.id, pluginContext);
......
const pluginExport = await pluginMain[plugin.lifecycle.startMethod].apply(getGlobal(), [pluginContext]);
            this.activatedPlugins.set(plugin.model.id, new ActivatedPlugin(pluginContext, pluginExport, stopFn));
......
}

以上即为插件扩展的整体架构以及每一个具体功能的实现。

插件工程长什么样子?我该如何开发一个插件?插件的调用流程是什么呢?

我们先来观察一下Theia构建插件的流程。Theia对于插件的开发,官方文档 中写的较为详细。其提供了一套插件CLI,用来拉取前/后端插件模板工程,该工程整体基于TypeScript编写,直接通过tsc进行编译:

theia-plugin-cli.png 工程目录结构如下,其中有tsfmt(还未详细看是啥玩意儿。。),插件src以及package.json

theia-plugin-project.png

package.json中对于一个插件的engine和入口做了特殊定义:

theia-plugin-package.png

如上图所示,该插件是一个自定义拓展的theia-plugin,其入口时lib/test-plugin-backend.jstest-plugin-backend.ts源码如下:

theia-plugin-start.png 这里面综合考虑,结合Theia-plugin的插件工程,平台插件工程我们对engines和入口做了一些改动。指定了enginesABPlugin,直接指定main属性作为插件入口。

ab-plugin-package.png

下一步,我们就可以直接在源码中书写插件的生命周期函数了。

ab-plugin-start.png

在这里,我们通过context中传递过来的rpc方法,new一个CodecHandler实例。实例的具体实现如下:

ab-plugin-codec.png

那么,插件内的方法是如何调用的呢?

plugin调用.png插件子进程管控层 接收到消息分发给RPCProtocol时,其内部会根据实例名查找到对应的实例,并根据方法名去进行apply的调用,从而实现了插件内方法的调度。最终会通过process.send()再次返回给Electron的主进程。 至此,插件内方法的调度完成。

总结

本文主要研究了Theia的插件扩展机制,并结合实际情况将Theia的插件扩展功能进行了改造,以符合Electron业务系统插件拓展体系架构下的要求。并对整体的代码结构进行了分层设计和调整,目前只是一个初版,接下来还有很多详细的工作:

  • hosted-plugin-manager的进一步封装,应该对Electron主进程提供一整套相对完善的API(包括自身对于进程的管理,如killProcess等)。
  • event-emitter其较好的设计是否可以迁移出来供agree-sdk使用或者供Electron主进程使用。
  • 子进程内部模块间调用应优化为基于DI的方式,前期进行了一些学习探索,后续会详细写一篇文章
  • id的生成和管理??思考CEF对于onQueryid生成规则和管理方式
  • 插件卸载尚未测试且只部分实现,包括子进程的销毁,插件的销毁,内存中对实例对象的销毁
  • 日志传递
  • 代码优化,比如插件工程应该对context进行类型声明,需要引入d.ts

参考文章:

Theia官方文档

Eclipse Theia揭秘

欢迎大家评论指导。