「WIDE核心引擎CodeE 」低代码架构设计之IOC实践

时光飞逝,「WIDE核心能力升级」系列终于迎来最终章。这篇文章让我们来了解一下WIDE核心引擎CodeE是如何诞生的?

1. 背景

随着业务发展,雷尔团队出现了多个低代码编辑器(大屏、3D编辑器等)的研发需求,为了解决各编辑器之间底层框架不统一、模块复用性低、物料无法流转等问题,CodeE应运而生。

CodeE是一个基于IOC设计原则实现的低代码框架,其优势有:

  • ioc设计模式:解耦各模块之间依赖、增强模块复用率、提升应用维护能力。
  • 自定义schema:支持用户自定义项目schema,适配不同的业务场景。
  • 骨架:提供开箱即用的布局骨架。
  • 与vue版本无关

提示:与低代码相关的概念可以提前阅读阿里低代码引擎(感谢大佬)。

2. 设计与实现

2.1. 基于IOC设计实现低代码框架

(1)目前的问题

  • 各个编辑器用户怎么自定义Project(项目schema)? 首先,不同编辑器的业务场景不同、编辑的对象也不一样。例如:3D编辑器需要存储相机、交互控制等配置信息,大屏编辑器只需要存储全局样式、组件绝对定位等信息;其次,部分老的编辑器还有历史债务,无法转换schema的问题。所以我们无法在框架内部限制项目schema,需要让用户自定义。
  • Project、插件之间如何通信? 首先,如果Project由用户在框架外部定义,我们需要考虑Project如何合理调用框架内部功能;其次,用户自定义的插件经常有互相调用的需求,但加载顺序可能导致调用失败,需要设计机制确保多个插件既能实现自身逻辑的内聚性,又能与其他插件高效、稳定地协作,这一点至关重要。

以上两点是我们在设计低代码框架之初重点考虑的问题,至于其他提升团队协作、合理分离模块等通用研发需求在此不再赘述。所以,我们该如何解决上述问题?

直接说结论:由于团队有nestjs开发经验,并且参考过阿里低代码引擎,经过团队内部讨论我们最终敲定了用IOC设计模式。

(2)IOC是什么?

IOC(Inversion of Control)控制反转,是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度,其中最常见的方式就是依赖注入。随着spring框架的推广,IOC设计模式被使用于更多的框架上,例如:nestjs、angular等。其优势有:

  • 对象之间不再直接依赖具体的实现,而是通过接口或抽象类进行交互。
  • 依赖的管理由容器负责,通过注入机制可以灵活替换实现(如实现类的动态更改),从而方便扩展功能。
  • 减少对象直接在代码中实例化的情况,代码更加优雅,减少重复。
  • 通过依赖解析、延迟加载、生命周期管理和代理机制,能自动处理对象的加载顺序,开发者无需显式关注各个对象的初始化时机。只需正确声明依赖关系,容器会自动完成依赖注入和实例化。

可以看出,IOC设计模式能够完美贴合我们的需求。首先,能够帮助我们解耦项目schema模块和其他模块之间的强关联关系;其次,IOC容器能够自行管理对象之间的加载关系;最后,IOC非常适合复杂项目的解构,通过接口和抽象约定交互规范,可以让开发人员专注于自己模块的实现,减少冲突。

(3)实现

如下图所示,整个低代码框架以IOC容器为基底,实现了低代码核心类和项目协议基础类两大功能模块:

  • 低代码核心类 :用于注册及管理 Project(项目schema管理类)、插件、物料等资源,然后提供渲染组件;
  • 项目基础协议基础类 :主要提供了一些基础功能类,用户可以通过这些类创建 Project(项目schema管理类),减少开发冗余的功能。

2.2. 低代码核心类

借助inversify我们创建了一个IOC容器,该容器默认采用单例模式:

javascript 复制代码
import { Container } from 'inversify';
// ioc 容器, 默认单例模式
const container = new Container({
  defaultScope: 'Singleton',
  autoBindInjectable: true,
});

在此基础上我们分别实现了以下几个重要的功能类,基本都是对相关资源的管理类,实现了常见的crud等API,具体内容不再赘述:

  • 物料(Materials) :用于管理页面搭建所需组件及其样式、属性和事件等配置;
  • 骨架(Skeleton) :提供了引擎视图的管理和扩展能力,底层采用Grid布局实现,用户可以通过配置随意定义区块渲染视图;
  • 插件(Plugins) :插件是扩展低代码框架的最小单元,通过Plugin我们可以实现添加物料,向骨架添加渲染视图等功能;
  • 快捷键(Hotkey) :用于注册和管理快捷键功能;

CodeEFactory 是一个入口类,注入了上述所有功能类。最终,我们只向用户提供了由该类new的应用实例codeEFactory,通过setProjectSchemaAndPlugin方法用户可以注册Project(项目schema管理类)和插件;最后通过init方法用户可以启动项目:

kotlin 复制代码
// 使用 由inversify 提供的装饰器
import { injectable, inject } from 'inversify';

// CodeEFactory 
@injectable()
  class CodeEFactory {
    private _app: any = null;

    private _project = null;

    @inject(Libs) public readonly libs: Libs;
    @inject(Materials) public readonly materials: Materials;
    @inject(Plugins) public readonly plugins: Plugins;
    @inject(Skeleton) public readonly skeleton: Skeleton;
    @inject(Hotkey) public readonly hotkey: Hotkey;

    ...

    /**
   * 设置项目类和插件类
   * @param options - 引擎初始化配置
   * @returns void
   */
    setProjectSchemaAndPlugin(options: EngineInitOptions) {
      const { project, plugins = [] } = options;

      const projectInstance = container.get(project);
      this._project = projectInstance;

      plugins.forEach(plugin => {
        this.plugins.register(plugin);
      });
      return {
        project: projectInstance,
      };
    }
    /**
   * 引擎初始化方法-创建vue实例等。
   * @param el - dom
   */
    init(el: string) {
      const app = new Vue2!({
        render: h => {
          return h(Workbench, {
            props: { skeleton: this.skeleton },
          });
        },
      });
      app.$mount(el || '#app');
      this._app = app;
      ...
    }

  }

/**
 * 低代码应用实例
 */
export const codeEFactory = container.get(CodeEFactory);

2.2.1. 自定义Project

在低代码框架CodeE的基础上,用户的Project(项目schema管理类)如何自定义和使用呢?

首先,用户需要使用装饰器@provideProject()创建Project类,其中属性可以通过框架内部提供的BaseTreeBaseArray等项目协议基本类实现;也可以借助vue的响应式方法RefReactive等实现,有助于组件模版渲染。

typescript 复制代码
import { provideProject, BaseProject, BaseArray, ExtensionConfig, schemaKey } from '@vislab/codee-core';
import { Ref } from 'vue';

@provideProject()
  export class Project extends BaseProject {
    // 项目id
    readonly id = Ref<string>('');

    readonly config = new ProjectConfig();

    // 项目页面
    readonly pages = new BaseTree({ nodeKey: 'uuid' });

    readonly assets = new BaseArray<Asset>();

    readonly options = new BaseObject();

    readonly globalStyle = new BaseObject(); // todo: new BaseStyle()

    constructor() {
      super();
    }
  }

然后,用户可以将Project注册到codeEFactory上使用:

arduino 复制代码
codeEFactory.setProjectSchemaAndPlugin({
  // 项目
  project: Project,
  plugins: [
    // 插件
    ...
  ],
});

2.2.2. 定义插件

使用装饰器@providePlugin()创建,并且需要实现CodeEPlugin接口。在插件和Project中均可以通过注入使用Skeleton、Materials等类:

typescript 复制代码
import { providePlugin, inject, CodeEPlugin } from '@vislab/codee-core';
import Project from './project.ts';

@providePlugin({ pluginName: 'headMenu' })
  export class HeadMenuPlugin implements CodeEPlugin {
    constructor(
      @inject(Skeleton) private readonly skeleton: Skeleton,
      @inject(Project) private readonly project: Project,
    ) {
    }

    init(): void {
      // 该插件实现了向骨架注册视图组件的功能
      const { project, skeleton } = this;
      skeleton.getAreaByName('topArea').add({
        name: 'topArea',
        content: HeadMenu,
        contentProps: {
          project
        }
      })
    }

    destroy(): void {
      throw new Error('Method not implemented.');
    }
  }

通过skeleton.getAreaByName方法可以获取注册的相关视图区域,然后通过add方法可以向该区域设置渲染组件和属性。

插件之间也可以通过注入互相调用,而且不用担心插件的初始化顺序:

less 复制代码
// 插件A
@providePlugin({ pluginName: "Aplugin" })
export class Aplugin implements CodeEPlugin {

  readonly items = shallowRef<ToolItem[]>([]);

  constructor(
    @inject(Skeleton) private readonly skeleton: Skeleton,
  ) {}

  extend(items: ToolItem | ToolItem[]) {
    if (Array.isArray(items)) {
      this.items.value = [...this.items.value, ...items];
    } else {
      this.items.value = [...this.items.value, items];
    }
  }
}

// 插件B
@providePlugin({ pluginName: "Bplugin" })
export class Bplugin implements CodeEPlugin {

  constructor(
    @inject(Project) private readonly project: Project,
    // 通过装饰器inject注入插件A
    @inject(Aplugin) private readonly aPlugin: Aplugin
  ) {}

  init(): void {
    const { project, aPlugin } = this;
    // 调用插件A的方法
    aPlugin.extend({
      content: Adapt,
      contentProps: {
        project,
      }
    })
  }
}

2.3. 项目协议基本类

项目协议基本类主要方便用户创建Project,在这里我们挑其中常用的BaseArray-基础数组类进行说明。

该类可以用于管理以数组类型存储的数据,比如,大屏编辑器中的图片、json等静态资源数据:

scala 复制代码
import { provideProject, BaseProject, BaseArray } from '@vislab/codee-core';

@provideProject()
export class Project extends BaseProject {
  readonly assets = new BaseArray<Asset>();
}

该类里面提供了和数组操作相关的大部分操作,而且相关操作默认返回了对应的redo和undo方法,方便用户做历史记录:

ini 复制代码
export class BaseArray<T> implements IBase {
  _produce(fn: (draft: T[]) => void) {
    const oldValue = [...this[writableSymbol].value];
    const newValue = produce(oldValue, fn);
    this[writableSymbol].value = newValue;
    return {
      redo: () => (this[writableSymbol].value = newValue),
      undo: () => (this[writableSymbol].value = oldValue),
    };
  }

  splice(startIndex: number, deleteCount: number, ...items: T[]) {
    const action = (value: T[]) => {
      value.splice(startIndex, deleteCount, ...items);
    };
    return this._produce(action);
  }
}

2.4. 其他

2.4.1. 如何自定义装饰器

如2.2.2所示,自定义插件需要使用的装饰器@providePlugin(),用户可以通过该装饰器设置是否自动初始化、是否覆盖等与业务逻辑无关的数据:

php 复制代码
@providePlugin({ 
  pluginName: "Aplugin",
  autoInit: false,
  override: false;
})

其实,inversify中提供了两个装饰器@injectable() @inject() 供我们注册和注入类,但是如果想要装饰器携带参数并成功获取到参数,我们需要自定义装饰器。如下所示是插件装饰器的源码实现,其中有两个点需要关注:

  1. 使用Reflect.defineMetadata将参数和自定义插件关联
  2. 必须使用decorate(injectable(), target)将自定义插件注册到IOC容器上
typescript 复制代码
import { decorate, injectable } from 'inversify';

import { PluginDecoratorOptions } from './type';
import { PLUGIN_METADATA_KEY } from './constants';

export function providePlugin(options: PluginDecoratorOptions) {
  return function <T extends abstract new (...args: never) => unknown>(target: T): T {
    if (Reflect.hasOwnMetadata(PLUGIN_METADATA_KEY, target)) {
      throw new Error('plugin decorator can not be used twice');
    }

    Reflect.defineMetadata(PLUGIN_METADATA_KEY, options, target);

    decorate(injectable(), target);
    return target;
  };
}

如此一来,我们就可以在插件注册的时候获取相关插件的参数:

kotlin 复制代码
/**
 * # 插件类
 * 统一管理编辑器注册插件的类
 */
@injectable()
export class Plugins {
  
  /**
   * 插件注册
   * @param pluginClass - 插件类
   * @returns
   */
  async register(pluginClass: CodeEPluginClass) {
    const plugin = container.get(pluginClass);
    const metaData = this.getPluginMeta(pluginClass);
    const { pluginName, override = false } = metaData;
    ...
  }
  
  private getPluginMeta(pluginClass: CodeEPluginClass): PluginDecoratorOptions {
    const data = Reflect.getMetadata(PLUGIN_METADATA_KEY, pluginClass);
    return data;
  }
}

2.4.2. 注册到容器上的类如何在组件里面使用

从上述内容可以看出,在IOC容器的基础上,用户只需要定义相关类即可,但是我们如何在vue组件中获取到数据呢?目前有两种方式:

(1)通过contentProps传递

skeleton上通过add方法添加视图组件的时候可以通过contentProps传递注入类的实例

less 复制代码
@providePlugin({
  pluginName: 'PluginToolbar',
})
export class PluginToolbar implements CodeEPlugin {
  constructor(
    @inject(Project) private readonly project: Project,
    @inject(Skeleton) private readonly skeleton: Skeleton,
  ) {}

  async init() {
    const { project, skeleton } = this;

    skeleton.getAreaByName(TOP_AREA)?.add({
      name: 'toolbar',
      content: ToolBar,
      defaultActive: true,
      contentProps: {
        project,
      }
    });
  }
  
  destroy(): void {
    throw new Error('Method not implemented.');
  }
}

(2)通过容器获取相关类的实例

框架暴露了IOC容器,通过get方法用户可以更方便的获取相关类的实例:

javascript 复制代码
import { container } from '@vislab/codee-core';
import { Project } from './Project';

const project = container.get(Project);

3. 结语

历时数月,「WIDE核心能力升级」系列文章将以今天这篇画上句点。

从技术架构的革新到产品能力的重构再到用户体验的蜕变,我们尝试用文字记录的不只是代码与功能,更是产品团队如何用技术与用户思维重塑产品的历程。

这个系列虽终,但WIDE的进化不会停步。

敬请期待吧!

参考文献

juejin.cn/post/684490...

juejin.cn/post/684490...

juejin.cn/post/722133...

相关推荐
OpenTiny社区9 小时前
强烈推荐|新手从搭建到二开TinyEngine低代码引擎
前端·低代码·开源
低代码布道师2 天前
加油站小程序实战教程01首页搭建
低代码·小程序
_xaboy2 天前
基于Vue的低代码可视化表单设计器 FcDesigner 3.2.11更新说明
前端·vue.js·低代码·开源·表单设计器
jonyleek2 天前
【JVS更新日志】低代码、规则引擎、智能BI、逻辑引擎3.26更新说明!
java·低代码·数据分析·团队开发·软件需求
2501_906800763 天前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·数学建模·web
#六脉神剑3 天前
【入门初级篇】布局类组件的使用(2)
低代码·产品运营·mybuilder
NocoBase6 天前
替代 Airtable / 飞书表格?用零代码构建多对多关系的任务管理系统
低代码·开源·资讯
砺能6 天前
阿里巴巴低代码引擎————自定义物料并发布
低代码