找回密码
 立即注册
首页 业界区 业界 前、后端通用的可视化逻辑编排

前、后端通用的可视化逻辑编排

单于易槐 昨天 21:02
前一段时间写过一篇文章《实战,一个高扩展、可视化低代码前端,详实、完整》,得到了很多朋友的关注。
其中的逻辑编排部分过于简略,不少朋友希望能写一些关于逻辑编排的内容,本文就详细讲述一下逻辑编排的实现原理。
逻辑编排的目的,是用最少甚至不用代码来实现软件的业务逻辑,包括前端业务逻辑跟后端业务逻辑。本文前端代码基于typescript、react技术栈,后端基于golang。
涵盖内容:数据流驱动的逻辑编排原理,业务编排编辑器的实现,页面控件联动,前端业务逻辑与UI层的分离,子编排的复用、自定义循环等嵌入式子编排的处理、事务处理等
运行快照:
1.png

前端项目地址:https://github.com/codebdy/rxdrag
前端演示地址:https://rxdrag.vercel.app/
后端演示尚未提供,代码地址:https://github.com/codebdy/minions-go
注:为了便于理解,本文使用的代码做了简化处理,会跟实际代码有些细节上的出入。
整体架构

2.png

整个逻辑编排,由以下几部分组成:

  • 节点物料,用于定义编辑器中的元件,包含在工具箱中的图标,端口以及属性面板中的组件schema。
  • 逻辑编排编辑器,顾名思义,可视化编辑器,根据物料提供的元件信息,编辑生成JSON格式的“编排描述数据”。
  • 编排描述数据,用户操作编辑器的生成物,供解析引擎消费
  • 前端解析引擎,Typescript 实现的解析引擎,直接解析“编排描述数据”并执行,从而实现的软件的业务逻辑。
  • 后端解析引擎,Golang 实现的解析引擎,直接解析“编排描述数据”并执行,从而实现的软件的业务逻辑。
逻辑编排实现方式的选择

逻辑编排,实现方式很多,争议也很多。
一直以来,小编的思路也很局限。从流程图层面,以线性的思维去思考,认为逻辑编排的意义并不大。因为,经过这么多年发展,事实证明代码才是表达逻辑的最佳方式,没有之一。用流程图去表达代码,最终只能是老板、客户的丰满理想与程序员骨感现实的对决。
直到看到Mybricks项目交互部分的实现方式,才打开了思路。类似unreal蓝图数据流驱动的实现方式,其实大有可为。
这种方式的意义是,跳出循环、if等这些底层的代码细节,以数据流转的方式思考业务逻辑,从而把业务逻辑抽象为可复用的组件,每个组件对数据进行相应处理或者根据数据执行相应动作,从而达到复用业务逻辑的目的。并且,节点的粒度可大可小,非常灵活。
具体实现方式是,把每个逻辑组件看成一个黑盒,通过入端口流入数据,出端口流出变换后的数据:
3.png

举个例子,一个节点用来从数据库查询客户列表,会是这样的形式:
4.png

用户不需要关注这个元件节点的实现细节,只需要知道每个端口的功能就可以使用。这个元件节点的功能可以做的很简单,比如一个fetch,只有几十行代码。也可以做到很强大,比如类似useSwr,自带缓存跟状态管理,可以有几百甚至几千行代码。
我们希望这些元件节点是可以自行定义,方便插入的,并且我们做到了。
出端口跟入端口之间,可以用线连接,表示元件节点之间的调用关系,或者说是数据的流入关系。假如,数据读取成功,需要显示在列表中;失败,提示错误消息;查询时,显示等待的Spinning,那么就可以再加三个元件节点,变成:
5.png

如果用流程图,上面这个编排,会被显示成如下样子:
6.png

两个比较,就会发现,流程图的思考方式,会把人引入条件细节,其实就是试图用不擅长代码的图形来描述代码。是纯线性的,没有回调,也就无法实现类似js promise的异步。
而数据流驱动的逻辑编排,可以把人从细节中解放出来,用模块化的思考方式去设计业务逻辑,更方便把业务逻辑拆成一个个可复用的单元。
如果以程序员的角度来比喻,流程图相当于一段代码脚本,是面向过程的;数据流驱动的逻辑编排像是几个类交互完成一个功能,更有点面向对象的感觉。
朋友,如果是让你选,你喜欢哪种方式?欢迎留言讨论。
另外还有一种类似stratch的实现方式:
7.png

感觉这种纯粹为了可视化而可视化,只适合小孩子做玩具。会写代码的人不愿意用,太低效了。不会写代码的人,需要理解代码才会用。适合场景是用直观的方式介绍什么是代码逻辑,就是说只适合相对比较低智力水平的编程教学,比如幼儿园、小学等。商业应用,就免了。
数据流驱动的逻辑编排

一个简单的例子

从现在开始,放下流程图,忘记strach,我们从业务角度去思考也逻辑,然后设计元件节点去实现相应的逻辑。
选一个简单又典型的例子:学生成绩单。一个成绩单包含如下数据:
8.png

假如数据已经从数据库取出来了,第一步处理,统计每个学生的总分数。设计这么几个元件节点来配合完成:
9.png

这个编排,输入成绩列表,循环输出每个学生的总成绩。为了完成这个编排,设计了四个元件节点:

  • 循环,入端口接收一个列表,遍历列表并循环输出,每一次遍历往“单次输出”端口发送一条数据,可以理解为一个学生对象(尽量从对象的角度思考,而不是数据记录),遍历结束后往“结束端口”发送循环的总数。如果按照上面的列表,“单次输出端口”会被调用4次,每次输出一个学生对象{姓名:xxx,语文:xxx,数学:xxx...},“结束”端口只被调用一次,输出结果是 4.
  • 拆分对象,这个元件节点的出端口是可以动态配置的,它的功能是把一个对象按照属性值按照名字分发到指定的出端口。本例中,就是把各科成绩拆分开来。
  • 收集数组,这个节点也可以叫收集到数组,作用是把串行接收到的数据组合到一个数组里。他有两个入端口:input端口,用来接收串行输入,并缓存到数组;finished端口,表示输入完成,把缓存到的数据组发送给输出端口。
  • 加和,把输入端口传来的数组进行加和计算,输出总数。
这是一种跟代码完全不同的思考方式,每一个元件节点,就是一小段业务逻辑,也就是所谓的业务逻辑组件化。我们的项目中,只提供给了有限的预定义元件节点,想要更多的节点,可以自行自定义并注入系统,具体设计什么样的节点,完全取决于用户的业务需求跟喜好。作者更希望设计元件的过程是一个创作的过程,或许具备一定的艺术性。
刚刚的例子,审视之。有人可能会换一个方式来实现,比如拆分对象跟收集数据这两个节点,合并成一个节点:对象转数组,可能更方便,适应能力也更强:
10.png

对象转换数组节点,对象属性与数组索引的对应关系,可以通过属性面板的配置来完成。
这两种实现方式,说不清哪种更好,选择自己喜欢的,或者两种都提供。
输入节点、输出节点

一段图形化的逻辑编排,通过解析引擎,会被转换成一段可执行的业务逻辑。这段业务逻辑需要跟外部对接,为了明确对接语义,再添加两个特殊的节点元件:输入节点(开始节点),输出节点(结束节点)。
11.png

输入节点用于标识逻辑编排的入口,输入节点可以有一个或者多个,输入节点用细线圆圈表示。
输出节点用于标识逻辑编排的出口,输出节点可以有一个或者多个,输出节点用粗线圆圈表示。
在后面的引擎部分,会详细描述输入跟输出节点如何跟外部的对接。
编排的复用:子编排

一般低代码中,提升效率的方式是复用,尽可能复用已有的东西,比如组件、业务逻辑,从而达到降本、增效的目的。
设计元件节点是一种创作,那么使用元件节点进行业务编排,更是一种基于领域的创作。辛辛苦苦创作的编排,如果能被复用,应该算是对创作本身的尊重吧。
如果编排能够像元件节点一样,被其它逻辑编排所引用,那么这样的复用方式无疑是最融洽的。也是最方便的实现方式。
把能够被其它编排引用的编排称为子编排,上面计算学生总成绩的编排,转换成子编排,被引入时的形态应该是这样的:
12.png

子编排元件的输入端口对应逻辑编排实现的输入节点,输出端口对应编排实现的输出节点。
嵌入式编排节点

前文设计的循环组件非常简单,循环直接执行到底,不能被中断。但是,有的时候,在处理数据的时候,要根据每次遍历到的数据做判断,来决定继续循环还是终止循环。
就是说,需要一个循环节点,能够自定义它的处理流程。依据这个需求,设计了自定义循环元件,这是一种能够嵌入编排的节点,形式如下:
13.png

这种嵌入式编排节点,跟其它元件节点一样,事先定义好输入节点跟输出节点。只是它不完全是黑盒,其中一部分通过逻辑编排这种白盒方式来实现。
这种场景并不多见,除了循环,后端应用中,还有事务元件也需要类似实现方式:
14.png

嵌入式元件跟其它元件节点一样,可以被其它元件连接,嵌入式节点在整个编排中的表现形式:
15.png

基本概念

为了进一步深入逻辑编排引擎跟编辑器的实现原理,先梳理一些基本的名词、概念。
逻辑编排,本文特指数据流驱动的逻辑编排,是由图形表示的一段业务逻辑,由元件节点跟连线组成。
元件节点,简称元件、节点、编排元件、编排单元。逻辑编排中具体的业务逻辑处理单元,带副作用的,可以实现数据转换、页面组件操作、数据库数据存取等功能。一个节点包含零个或多个输入端口,包含零个或多个输出端口。在设计其中,以圆角方形表示:
16.png

端口,分为输入端口跟输出端口两种。是元件节点流入或流出数据的通道(或者接口)。在逻辑单元中,用小圆圈表示。
输入端口,简称入端口、入口。输入端口位于元件节点的左侧。
输出端口,简称出端口、出口。输出端口位于元件节点的右侧。
单入口元件,只有一个入端口的元件节点。
多入口元件,有多个入端口的元件节点。
单出口元件,只有一个出端口的元件节点。
多出口元件,有多个出端口的元件节点。
输入节点,一种特殊的元件节点,用于描述逻辑编排的起点(开始点)。转换成子编排后,会对应子编排相应的入端口。
输出节点,一种特殊的元件节点,用于描述逻辑编排的终点(结束点)。转换成子编排后,会对应子编排相应的出端口。
嵌入式编排,特殊的元件节点,内部实现由逻辑编排完成。示例:
17.png

子编排,特殊的逻辑编排,该编排可以转换成元件节点,供其它逻辑编排使用。
连接线,简称连线、线。用来连接各个元件节点,表示数据的流动关系。
定义DSL

逻辑编排编辑器生成一份JSON,解析引擎解析这份JSON,把图形化的业务逻辑转化成可执行的逻辑,并执行。
编辑器跟解析引擎之间要有份约束协议,用来约定JSON的定义,这个协议就是这里定义的DSL。在typescript中,用interface、enum等元素来表示。
这些DSL仅仅是用来描述页面上的图形元素,通过activityName属性跟具体的实现代码逻辑关联起来。比如一个循环节点,它的actvityName是Loop,解析引擎会根据Loop这个名字找到该节点对应的实现类,并实例化为一个可执行对象。后面的解析引擎会详细展开描述这部分。
节点类型

元件节点类型叫NodeType,用来区分不同类型的节点,在TypeScript中是一个枚举类型。
  1. export enum NodeType {
  2.   //开始节点
  3.   Start = 'Start',
  4.   //结束节点
  5.   End = 'End',
  6.   //普通节点
  7.   Activity = 'Activity',
  8.   //子编排,对其它编排的引用
  9.   LogicFlowActivity = "LogicFlowActivity",
  10.   //嵌入式节点,比如自定义逻辑编排
  11.   EmbeddedFlow = "EmbeddedFlow"
  12. }
复制代码
端口
  1. export interface IPortDefine {
  2.   //唯一标识
  3.   id: string;
  4.   //端口名词
  5.   name: string;
  6.   //显示文本
  7.   label?: string;
  8. }
复制代码
元件节点
  1. //一段逻辑编排数据
  2. export interface ILogicFlowMetas {
  3.   //所有节点
  4.   nodes: INodeDefine<unknown>[];
  5.   //所有连线
  6.   lines: ILineDefine[];
  7. }
  8. export interface INodeDefine<ConfigMeta = unknown> {
  9.   //唯一标识
  10.   id: string;
  11.   //节点名称,一般用于开始结束、节点,转换后对应子编排的端口
  12.   name?: string;
  13.   //节点类型
  14.   type: NodeType;
  15.   //活动名称,解析引擎用,通过该名称,查找构造节点的具体运行实现
  16.   activityName: string;
  17.   //显示文本
  18.   label?: string;
  19.   //节点配置
  20.   config?: ConfigMeta;
  21.   //输入端口
  22.   inPorts?: IPortDefine[];
  23.   //输出端口
  24.   outPorts?: IPortDefine[];
  25.   //父节点,嵌入子编排用
  26.   parentId?: string;
  27.   // 子节点,嵌入编排用
  28.   children?: ILogicFlowMetas
  29. }
复制代码
连接线
  1. //连线接头
  2. export interface IPortRefDefine {
  3.   //节点Id
  4.   nodeId: string;
  5.   //端口Id
  6.   portId?: string;
  7. }
  8. //连线定义
  9. export interface ILineDefine {
  10.   //唯一标识
  11.   id: string;
  12.   //起点
  13.   source: IPortRefDefine;
  14.   //终点
  15.   target: IPortRefDefine;
  16. }
复制代码
逻辑编排
  1. //这个代码上面出现过,为了使extends更直观,再出现一次
  2. //一段逻辑编排数据
  3. export interface ILogicFlowMetas {
  4.   //所有节点
  5.   nodes: INodeDefine<unknown>[];
  6.   //所有连线
  7.   lines: ILineDefine[];
  8. }
  9. //逻辑编排
  10. export interface ILogicFlowDefine extends ILogicFlowMetas {
  11.   //唯一标识
  12.   id: string;
  13.   //名称
  14.   name?: string;
  15.   //显示文本
  16.   label?: string;
  17. }
复制代码
解析引擎的实现

解析引擎有两份实现:Typescript实现跟Golang实现。这里介绍基于原理,以Typescript实现为准,后面单独章节介绍Golang的实现方式。也有朋友根据这个dsl实现了C#版自用,欢迎朋友们实现不同的语言版本并开源。
DSL只是描述了节点跟节点之间的连接关系,业务逻辑的实现,一点都没有涉及。需要为每个元件节点制作一个单独的处理类,才能正常解析运行。比如上文中的循环节点,它的DSL应该是这样的:
  1. {
  2.   "id": "id-1",
  3.   "type": "Activity",
  4.   "activityName": "Loop",
  5.   "label": "循环",
  6.   "inPorts": [
  7.     {
  8.       "id":"port-id-1",
  9.       "name":"input",
  10.       "label":""
  11.     }
  12.   ],
  13.   "outPorts": [
  14.     {
  15.       "id":"port-id-2",
  16.       "name":"output",
  17.       "label":"单次输出"
  18.     },
  19.     {
  20.       "id":"port-id-3",
  21.       "name":"finished",
  22.       "label":"结束"
  23.     }
  24.   ]
  25. }
复制代码
开发人员制作一个处理类LoopActivity用来处理循环节点的业务逻辑,并将这个类注册入解析引擎,key为loop。这个类,我们叫做活动(Activity)。解析引擎,根据activityName查找类,并创建实例。LoopActivity的类实现应该是这样:
  1. export interface IActivity{
  2.   inputHandler (inputValue?: unknown, portName:string);
  3. }
  4. export class LoopActivity implements IActivity{
  5.   constructor(protected meta: INodeDefine<ILoopConfig>) {}
  6.   //输入处理
  7.   inputHandler (inputValue?: unknown, portName:string){
  8.     if(portName !== "input"){
  9.       console.error("输入端口名称不正确")
  10.       return      
  11.     }
  12.     let count = 0
  13.     if (!_.isArray(inputValue)) {
  14.       console.error("循环的输入值不是数组")
  15.     } else {
  16.       for (const one of inputValue) {
  17.         this.output(one)
  18.         count++
  19.       }
  20.     }
  21.     //输出循环次数
  22.     this.next(count, "finished")
  23.   }
  24.   //单次输出
  25.   output(value: unknown){
  26.     this.next(value, "output")
  27.   }
  28.   
  29.   next(value:unknown, portName:string){
  30.      //把数据输出到指定端口,这里需要解析器注入代码
  31.   }
  32. }
复制代码
解析引擎根据DSL,调用inputHanlder,把控制权交给LoopActivity的对象,LoopActivity处理完成后把数据通过next方法传递出去。它只需要关注自身的业务逻辑就可以了。
这里难点是,引擎如何让所有类似LoopActivity类的对象联动起来。这个实现是逻辑编排的核心,虽然实现代码只有几百行,但是很绕,需要静下心来好好研读接下来的部分。
编排引擎的设计

编排引擎类图

18.png

LogicFlow类,代表一个完整的逻辑编排。它解析一张逻辑编排图,并执行该图所代表的逻辑。
IActivity接口,一个元件节点的执行逻辑。不同的逻辑节点,实现不同的Activity类,这类都实现IActivity接口。比如循环元件,可以实现为
  1. export class LoopActivity implements IActivity{
  2.     id: string
  3.     config: LoopActivityConfig
  4. }
复制代码
LogicFlow类解析逻辑编排图时,根据解析到的元件节点,创建相应的IActivity实例,比如解析到Loop节点的时候,就创建LoopActivity实例。
LogicFlow还有一个功能,就是根据连线,给构建的IActivity实例建立连接关系,让数据能在不同的IActivity实例之间流转。先明白引擎中的数据流,是理解上述类图的前提。
解析引擎中的Jointer

在解析引擎中,数据按照以下路径流动:
19.png

有三个节点:节点A、节点B、节点C。数据从节点A的“a-in-1”端口流入,通过一些处理后,从节点A的“a-out-1”端口流出。在“a-out-1”端口,把数据分发到节点B的“b-in-1”端口跟节点C的“c-in-1”端口。在B、C节点以后,继续重复类似的流动。
端口“a-out-1”要把数据分发到端口“b-in-1”和端口“c-in-1”,那么端口“a-out-1”要保存端口“b-in-1”和端口“c-in-1”的引用。就是说在解析引擎中,端口要建模为一个类,端口“a-out-1”是这个类的对象。要想分发数据,端口类跟自身是一个聚合关系。这种关系,让解析引擎中的端口看起来像连接器,故取名Jointer。一个Joniter实例,对应一个元件节点的端口。
在逻辑编排图中,一个端口,可以连接多个其它端口。所以,一个Jointer也可以连接多个其它Jointer。
20.png

注意,这是实例的关系,如果对应到类图,就是这样的关系:
21.png

Jointer通过调用push方法把数据传递给其他Jointer实例。
connect方法用于给两个Joiner构建连接关系。
用TypeScript实现的话,代码是这样的:
  1. //数据推送接口
  2. export type InputHandler = (inputValue: unknown, context?:unknown) => void;
  3. export interface IJointer {
  4.   name: string;
  5.   //接收上一级Jointer推送来的数据
  6.   push: InputHandler;
  7.   //添加下游Jointer
  8.   connect: (jointerInput: InputHandler) => void;
  9. }
  10. export class Jointer implements IJointer {
  11.   //下游Jonter的数据接收函数
  12.   private outlets: IJointer[] = []
  13.   constructor(public id: string, public name: string) {
  14.   }
  15.   //接收上游数据,并分发到下游
  16.   push: InputHandler = (inputValue?: unknown, context?:unknown) => {
  17.     for (const jointer of this.outlets) {
  18.       //推送数据
  19.       jointer.push(inputValue, context)
  20.     }
  21.   }
  22.   //添加下游Joninter
  23.   connect = (jointer: IJointer) => {
  24.     //往数组加数据,跟上面的push不一样
  25.     this.outlets.push(jointer)
  26.   }
  27.   //删除下游Jointer
  28.   disconnect = (jointer: InputHandler) => {
  29.     this.outlets.splice(this.outlets.indexOf(jointer), 1)
  30.   }
  31. }
复制代码
在TypeScript跟Golang中,函数是一等公民。但是在类图里面,这个独立的一等公民是不好表述的。所以,上面的代码只是对类图的简单翻译。在实现时,Jointer的outlets可以不存IJointer的实例,只存Jointer的push方法,这样的实现更灵活,并且更容易把一个逻辑编排转成一个元件节点,优化后的代码:
  1. //数据推送接口
  2. export type InputHandler = (inputValue: unknown, context?:unknown) => void;
  3. export interface IJointer {
  4.   //当key使用,不参与业务逻辑
  5.   id: string;
  6.   name: string;
  7.   //接收上一级Jointer推送来的数据
  8.   push: InputHandler;
  9.   //添加下游Jointer
  10.   connect: (jointerInput: InputHandler) => void;
  11. }
  12. export class Jointer implements IJointer {
  13.   //下游Jonter的数据接收函数
  14.   private outlets: InputHandler[] = []
  15.   constructor(public id: string, public name: string) {
  16.   }
  17.   //接收上游数据,并分发到下游
  18.   push: InputHandler = (inputValue?: unknown, context?:unknown) => {
  19.     for (const jointerInput of this.outlets) {
  20.       jointerInput(inputValue, context)
  21.     }
  22.   }
  23.   //添加下游Joninter
  24.   connect = (inputHandler: InputHandler) => {
  25.     this.outlets.push(inputHandler)
  26.   }
  27.   //删除下游Jointer
  28.   disconnect = (jointer: InputHandler) => {
  29.     this.outlets.splice(this.outlets.indexOf(jointer), 1)
  30.   }
  31. }
复制代码
记住这里的优化:Jointer的下游已经不是Jointer了,是Jointer的push方法,也可以是独立的其它方法,只要参数跟返回值跟Jointer的push方法一样就行,都是InputHandler类型。这个优化,可以让把Activer的某个处理函数设置为入Jointer的下游,后面会有进一步介绍。
Activity与Jointer的关系

一个元件节点包含多个(或零个)入端口和多个(或零个)出端口。那么意味着一个IActivity实例包含多个Jointer,这些Jointer也按照输入跟输出来分组:
22.png

TypeScript定义的代码如下:
  1. export interface IActivityJointers {
  2.   //入端口对应的连接器
  3.   inputs: IJointer[];
  4.   //处端口对应的连接器
  5.   outputs: IJointer[];
  6.   //通过端口名获取出连接器
  7.   getOutput(name: string): IJointer | undefined
  8.   //通过端口名获取入连接器
  9.   getInput(name: string): IJointer | undefined
  10. }
  11. //活动接口,一个实例对应编排图一个元件节点,用于实现元件节点的业务逻辑
  12. export interface IActivity<ConfigMeta = unknown> {
  13.   id: string;
  14.   //连接器,跟元件节点的端口异议对应
  15.   jointers: IActivityJointers,
  16.   //元件节点配置,每个Activity的配置都不一样,故而用泛型
  17.   config?: ConfigMeta;
  18.   //销毁
  19.   destory(): void;
  20. }
复制代码
入端口挂接业务逻辑

入端口对应一个Jointer,这个Jointer的连接关系:
23.png

逻辑引擎在解析编排图元件时,会给每一个元件端口创建一个Jointer实例:
  1. //构造Jointers
  2. for (const out of activityMeta.outPorts || []) {
  3.    //出端口对应的Jointer
  4.    activity.jointers.outputs.push(new Jointer(out.id, out.name))
  5. }
  6. for (const input of activityMeta.inPorts || []) {
  7.    //入端口对应的Jointer
  8.    activity.jointers.inputs.push(new Jointer(input.id, input.name))
  9. }
复制代码
新创建的Jointer,它的下游是空的,就是说成员变量的outlets数组是空的,并没有挂接到真实的业务处理。要调用Jointer的connect方法,把Activity的处理函数作为下游连接过去。
最先想到的实现方式是Acitvity有一个inputHandler方法,根据端口名字分发数据到相应处理函数:
  1. export interface IActivity<ConfigMeta = unknown> {
  2.   id: string;
  3.   //连接器,跟元件节点的端口异议对应
  4.   jointers: IActivityJointers,
  5.   //元件节点配置,每个Activity的配置都不一样,故而用泛型
  6.   config?: ConfigMeta;
  7.   //入口处理函数
  8.   inputHandler(portName:string, inputValue: unknown, context?:unknown):void
  9.   //销毁
  10.   destory(): void;
  11. }
  12. export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  13.   id: string;
  14.   jointers: IActivityJointers;
  15.   config?: SomeConfigMeta;
  16.   constructor(public meta: INodeDefine<ConfigMeta>) {
  17.     this.id = meta.id
  18.     this.jointers = new ActivityJointers()
  19.     this.config = meta.config;
  20.   }
  21.   //入口处理函数
  22.   inputHandler(portName:string, inputValue: unknown, context?:unknown){
  23.     switch(portName){
  24.       case PORTNAME1:
  25.         port1Handler(inputValue, context)
  26.         break
  27.       case PORTNAME2:
  28.         ...
  29.         break
  30.       ...
  31.     }
  32.   }
  33.   //端口1处理函数
  34.   port1Handler = (inputValue: unknown, context?:unknown)=>{
  35.     ...
  36.   }
  37.   
  38.   destory = () => {
  39.     //销毁处理
  40.     ...
  41.   }
  42. }
复制代码
LogicFlow解析编排JSON,碰到SomeActivity对应的元件时,如下处理:
  1. //创建SomeActivity实例
  2. const someNode = new SomeActivity(meta)
  3. //构造Jointers
  4. for (const out of activityMeta.outPorts || []) {
  5.    //出端口对应的Jointer
  6.    activity.jointers.outputs.push(new Jointer(out.id, out.name))
  7. }
  8. for (const input of activityMeta.inPorts || []) {
  9.    //入端口对应的Jointer
  10.    const jointer = new Jointer(input.id, input.name)
  11.    activity.jointers.inputs.push(jointer)
  12.    //给入口对应的连接器,挂接输入处理函数
  13.    jointer.connect(someNode.inputHandler)
  14. }
复制代码
业务逻辑挂接到出端口

入口处理函数,处理完数据以后,需要调用出端口连接器的push方法,把数据分发出去:
24.png

具体实现代码:
  1. export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  2.   jointers: IActivityJointers;
  3.   ...
  4.   //入口处理函数
  5.   inputHandler(portName:string, inputValue: unknown, context?:unknown){
  6.     switch(portName){
  7.       case PORTNAME1:
  8.         port1Handler(inputValue, context)
  9.         break
  10.       case PORTNAME2:
  11.         ...
  12.         break
  13.       ...
  14.     }
  15.   }
  16.   //端口1处理函数
  17.   port1Handler = (inputValue: unknown, context?:unknown)=>{
  18.     ...
  19.     //处理后得到新的值:newInputValue 和新的context:newContext
  20.     //把数据分发到相应出口
  21.     this.jointers.getOutput(somePortName).push(newInputValue, newContext)
  22.   }
  23.   ...
  24. }
复制代码
入端口跟出端口,连贯起来,一个Activtity内部的流程就跑通了:
25.png

出端口挂接其它元件节点

入端口关联的是Activity的自身处理函数,出端口关联的是外部处理函数,这些外部处理函数有能是其它连接器(Jointer)的push方法,也可能来源于其它跟应用对接的部分。
如果是关联的是其他节点的Jointer,关联关系是通过逻辑编排图中的连线定义的。
26.png

解析器先构造完所有的节点,然后遍历一遍连线,调用连线源Jointer的conect方法,参数是目标Jointer的push,就把关联关系构建起来了:
  1.     for (const lineMeta of this.flowMeta.lines) {
  2.       //先找起始节点,这个后面会详细介绍,现在可以先忽略
  3.       let sourceJointer = this.jointers.inputs.find(jointer => jointer.id === lineMeta.source.nodeId)
  4.       if (!sourceJointer && lineMeta.source.portId) {
  5.         sourceJointer = this.activities.find(reaction => reaction.id === lineMeta.source.nodeId)?.jointers?.outputs.find(output => output.id === lineMeta.source.portId)
  6.       }
  7.       if (!sourceJointer) {
  8.         throw new Error("Can find source jointer")
  9.       }
  10.       //先找起终止点,这个后面会详细介绍,现在可以先忽略
  11.       let targetJointer = this.jointers.outputs.find(jointer => jointer.id === lineMeta.target.nodeId)
  12.       if (!targetJointer && lineMeta.target.portId) {
  13.         targetJointer = this.activities.find(reaction => reaction.id === lineMeta.target.nodeId)?.jointers?.inputs.find(input => input.id === lineMeta.target.portId)
  14.       }
  15.       if (!targetJointer) {
  16.         throw new Error("Can find target jointer")
  17.       }
  18.       //重点关注这里,把一条连线的首尾相连,构造起连接关系
  19.       sourceJointer.connect(targetJointer.push)
  20.     }
复制代码
特殊的元件节点:开始节点、结束节点

到目前为止,解析引擎部分,已经能够成功解析普通的元件并成功连线,但是一个编排的入口跟出口尚未处理,对应的是编排图的输入节点(开始节点)跟输出节点(结束节点)
27.png

这两个节点,没有任何业务逻辑,只是辅助把外部输入,连接到内部的元件;或者把内部的输出,发送给外部。所以,这两个节点,只是简单的Jointer就够了。
如果把一个逻辑编排看作一个元件节点:
28.png

输入元件节点对应的是输入端口,输出元件节点对应的是输出端口。既然逻辑编排也有自己端口,那么LogicFlow也要聚合ActivityJointers:
29.png

引擎解析的时候,要根据开始元件节点跟结束元件节点,构建LogicFlow的Jointer:
  1. export class LogicFlow {
  2.   id: string;
  3.   jointers: IActivityJointers = new ActivityJointers();
  4.   activities: IActivity[] = [];
  5.   constructor(private flowMeta: ILogicFlowDefine) {
  6.           ...
  7.     //第一步,解析节点
  8.     this.constructActivities()
  9.     ...
  10.   }
  11.   //构建一个图的所有节点
  12.   private constructActivities() {
  13.     for (const activityMeta of this.flowMeta.nodes) {
  14.       switch (activityMeta.type) {
  15.         case NodeType.Start:
  16.           //start只有一个端口,可能会变成其它流程的端口,所以name谨慎处理
  17.           this.jointers.inputs.push(new Jointer(activityMeta.id, activityMeta.name || "input"));
  18.           break;
  19.         case NodeType.End:
  20.           //end 只有一个端口,可能会变成其它流程的端口,所以name谨慎处理
  21.           this.jointers.outputs.push(new Jointer(activityMeta.id, activityMeta.name || "output"));
  22.           break;
  23.       }
  24.       ...
  25.     }
  26.   }
  27. }
复制代码
经过这样的处理,一个逻辑编排就可以变成一个元件节点,被其他逻辑编排所引用,具体实现细节,本文后面再展开叙述。
根据元件节点创建Activity实例

在逻辑编排图中,一种类型的元件节点,在解析引擎中会对应一个实现了IActivity接口的类。比如,循环节点,对应LoopActivity;条件节点,对应ConditionActivity;调试节点,对应DebugActivity;拆分对象节点,对应SplitObjectActivity。
这些Activity要跟具体的元件节点建立一一对应关系,在DSL中以activityName作为关联枢纽。这样解析引擎根据activityName查找相应的Activity类,并创建实例。
工厂方法

如何找到并创建节点单元对应的Activity实例呢?最简单的实现方法,是给每个Activity类实现一个工厂方法,建立一个activityName跟工厂方法的映射map,解析引擎根据这个map实例化相应的Activity。简易代码:
  1. //工厂方法的类型定义
  2. export type ActivityFactory = (meta:ILogiFlowDefine)=>IActivity
  3. //activityName跟工厂方法的映射map
  4. export const activitiesMap:{[activityName:string]:ActivityFactory} = {}
  5. export class LoopActivity implements IActivity{
  6.   ...
  7.   constructor(protected meta:ILogiFlowDefine){}
  8.   inputHandler=(portName:string, inputValue:unknown, context:unknown)=>{
  9.     if(portName === "input"){
  10.       //逻辑处理
  11.       ...
  12.     }
  13.   }
  14.   ...
  15. }
  16. //LoopActivity的工厂方法
  17. export const LoopActivityFactory:ActivityFactory = (meta:ILogiFlowDefine)=>{
  18.   return new LoopActivity(meta)
  19. }
  20. //把工厂方法注册进map,跟循环节点的activityName对应好
  21. activitiesMap["loop"] = LoopActivityFactory
  22. //LogicFlow的解析代码
  23. export class LogicFlow {
  24.   id: string;
  25.   jointers: IActivityJointers = new ActivityJointers();
  26.   activities: IActivity[] = [];
  27.   constructor(private flowMeta: ILogicFlowDefine) {
  28.           ...
  29.     //第一步,解析节点
  30.     this.constructActivities()
  31.     ...
  32.   }
  33.   //构建一个图的所有节点
  34.   private constructActivities() {
  35.     for (const activityMeta of this.flowMeta.nodes) {
  36.       switch (activityMeta.type) {
  37.               ...
  38.         case NodeType.Activity:
  39.           //查找元件节点对应的ActivityFactory
  40.           const activityFactory = activitiesMap[activityMeta.activityName]
  41.           if(activityFactory){
  42.             //创建Activity实例
  43.             this.activities.push(activityFactory(activityMeta))
  44.           }else{
  45.             //提示错误
  46.           }
  47.           break;
  48.       }
  49.       ...
  50.     }
  51.   }
  52. }
复制代码
引入反射

正常情况下,上面的实现方法,已经够用了。但是,作为一款开放软件,会有大量的自定义Activity的需求。上面的实现方式,会让Activity的实现代码略显繁琐,并且所有的输入端口都要通过switch判断转发到相应处理函数。
我们希望把这部分工作推到框架层做,让具体Activity的实现更简单。所以,引入了Typescipt的反射机制:注解。通过注解自动注册Activity类,通过注解直接关联端口与相应的处理函数,省去switch代码。
代码经过改造以后,就变成这样:
  1. //通过注解注册LoopActivity类
  2. @Activity("loop")
  3. export class LoopActivity implements IActivity{
  4.   ...
  5.   constructor(protected meta:ILogiFlowDefine){}
  6.   //通过注解把input端口跟该处理函数关联
  7.   @Input("input")
  8.   inputHandler=(inputValue:unknown, context:unknown)=>{
  9.     //逻辑处理
  10.     ...
  11.   }
  12.   ...
  13. }
  14. //LogicFlow的解析代码
  15. export class LogicFlow {
  16.   id: string;
  17.   jointers: IActivityJointers = new ActivityJointers();
  18.   activities: IActivity[] = [];
  19.   constructor(private flowMeta: ILogicFlowDefine) {
  20.           ...
  21.     //第一步,解析节点
  22.     this.constructActivities()
  23.     ...
  24.   }
  25.   //构建一个图的所有节点
  26.   private constructActivities() {
  27.     for (const activityMeta of this.flowMeta.nodes) {
  28.       switch (activityMeta.type) {
  29.               ...
  30.         case NodeType.Activity:
  31.           //根据反射拿到Activity的构造函数
  32.           const activityContructor = ...//此处是反射代码
  33.           if(activityContructor){
  34.             //创建Activity实例
  35.             this.activities.push(activityContructor(activityMeta))
  36.           }else{
  37.             //提示错误
  38.           }
  39.           break;
  40.       }
  41.       ...
  42.     }
  43.   }
  44. }
复制代码
LogicFlow是框架层代码,用户不需要关心具体的实现细节。LoopActivity的代码实现,明显简洁了不少。
Input注解接受一个参数作为端口名称,参数默认值是input。
还有一种节点,它的输入端口是不固定的,可以动态增加或者删除。比如:
30.png

合并节点就是动态入口的节点,它的功能是接收入口传来的数据,等所有数据到齐以后,合并成一个对象转发到输出端口。这个节点,有异步等待的功能。
为了处理这种节点,我们引入新的注解DynamicInput。实际项目中合并节点Activity的完整实现:
  1. import {
  2.   AbstractActivity,
  3.   Activity,
  4.   DynamicInput
  5. } from '@rxdrag/minions-runtime';
  6. import { INodeDefine } from '@rxdrag/minions-schema';
  7. @Activity(MergeActivity.NAME)
  8. export class MergeActivity extends AbstractActivity<unknown> {
  9.   public static NAME = 'system.merge';
  10.   private noPassInputs: string[] = [];
  11.   private values: { [key: string]: unknown } = {};
  12.   constructor(meta: INodeDefine<unknown>) {
  13.     super(meta);
  14.     this.resetNoPassInputs();
  15.   }
  16.   @DynamicInput
  17.   inputHandler = (inputName: string, inputValue: unknown) => {
  18.     this.values[inputName] = inputValue;、
  19.     //删掉已经收到数据的端口名
  20.     this.noPassInputs = this.noPassInputs.filter(name=>name !== inputName)
  21.     if (this.noPassInputs.length === 0) {
  22.       //next方法,把数据转发到指定出口,第二个参数是端口名,默认值input
  23.       this.next(this.values);
  24.       this.resetNoPassInputs();
  25.     }
  26.   };
  27.   resetNoPassInputs(){
  28.     for (const input of this.meta.inPorts || []) {
  29.       this.noPassInputs.push(input.name);
  30.     }
  31.   }
  32. }
复制代码
注解DynamicInput不需要绑定固定的端口,所以就不需要输入端口的名称。
子编排的解析

子编排就是一段完整的逻辑编排,跟普通的逻辑编排没有任何区别。只是它需要被其它编排引入,这个引入是通过附加一个Activity实现的。
  1. export interface ISubLogicFLowConfig {
  2.   logicFlowId?: string
  3. }
  4. export interface ISubMetasContext{
  5.   subMetas:ILogicFlowDefine[]
  6. }
  7. @Activity(SubLogicFlowActivity.NAME)
  8. export class SubLogicFlowActivity implements IActivity {
  9.   public static NAME = "system-react.subLogicFlow"
  10.   id: string;
  11.   jointers: IActivityJointers;
  12.   config?: ISubLogicFLowConfig;
  13.   logicFlow?: LogicFlow;
  14.   //context可以从引擎外部注入的,此处不必纠结它是怎么来的这个细节
  15.   constructor(meta: INodeDefine<ISubLogicFLowConfig>, context: ISubMetasContext) {
  16.     this.id = meta.id
  17.     //通过配置中的LogicFlowId,查找子编排对应的JSON数据
  18.     const defineMeta = context?.subMetas?.find(subMeta => subMeta.id === meta.config?.logicFlowId)
  19.     if (defineMeta) {
  20.       //解析逻辑编排,new LogicFlow 就是解析一段逻辑编排,也可以在别处被调用
  21.       this.logicFlow = new LogicFlow(defineMeta, context)
  22.       //把解析后的连接器对应到本Activity
  23.       this.jointers = this.logicFlow.jointers
  24.     } else {
  25.       throw new Error("No meta on sub logicflow")
  26.     }
  27.   }
  28.   
  29.   destory(): void {
  30.     this.logicFlow?.destory();
  31.     this.logicFlow = undefined;
  32.   }
  33. }
复制代码
因为不需要把端口绑定到相应的处理函数,故该Activity并没有使用Input相关注解。
嵌入式编排的解析

逻辑编排中,最复杂的部分,就是嵌入式编排的解析,希望小编能解释清楚。
再看一遍嵌入式编排的表现形式:
31.png

这是自定义循环节点。虽然它端口直接跟内部的编排节点相连,但是实际上这种情况是无法直接调用new LogicFlow 来解析内部逻辑编排的,需要进行转换。引擎解析的时候,把会把上面的子编排重组成如下形式:
32.png

首先,给子编排添加输入节点,名称跟ID分别对应自定义循环的入端口名称跟ID;添加输出节点,名称跟ID分别对应自定义循环的出端口名称跟ID。
然后,把一个图中的红色数字标注的连线,替换成第二个图中蓝色数字标注的连线。
容器节点的端口,并不会跟转换后的输入节点或者输出节点直接连接,而是在实现中根据业务逻辑适时调用,故用粗虚线表示。
自定义循环具体实现代码:
  1. import { AbstractActivity, Activity, Input, LogicFlow } from "@rxdrag/minions-runtime";
  2. import { INodeDefine } from "@rxdrag/minions-schema";
  3. import _ from "lodash"
  4. export interface IcustomizedLoopConifg {
  5.   fromInput?: boolean,
  6.   times?: number
  7. }
  8. @Activity(CustomizedLoop.NAME)
  9. export class CustomizedLoop extends AbstractActivity<IcustomizedLoopConifg> {
  10.   public static NAME = "system.customizedLoop"
  11.   public static PORT_INPUT = "input"
  12.   public static PORT_OUTPUT = "output"
  13.   public static PORT_FINISHED = "finished"
  14.   
  15.   finished = false
  16.   logicFlow?: LogicFlow;
  17.   constructor(meta: INodeDefine<IcustomizedLoopConifg>) {
  18.     super(meta)
  19.     if (meta.children) {
  20.       //通过portId关联子流程的开始跟结束节点,端口号对应节点号
  21.       //此处的children是被引擎转换过处理的
  22.       this.logicFlow = new LogicFlow({ ...meta.children, id: meta.id }, undefined)
  23.       //把子编排的出口,挂接到本地处理函数
  24.       const outputPortMeta = this.meta.outPorts?.find(
  25.         port=>port.name === CustomizedLoop.PORT_OUTPUT
  26.       )
  27.       if(outputPortMeta?.id){
  28.         this.logicFlow?.jointers?.getOutput(outputPortMeta?.name)?.connect(
  29.           this.oneOutputHandler
  30.         )
  31.       }else{
  32.         console.error("No output port in CustomizedLoop")
  33.       }
  34.       const finishedPortMeta = this.meta.outPorts?.find(
  35.         port=>port.name === CustomizedLoop.PORT_FINISHED
  36.       )
  37.       if(finishedPortMeta?.id){
  38.         this.logicFlow?.jointers?.getOutput(finishedPortMeta?.id)?.connect(
  39.           this.finisedHandler
  40.         )
  41.       }else{
  42.         console.error("No finished port in CustomizedLoop")
  43.       }
  44.       
  45.     } else {
  46.       throw new Error("No implement on CustomizedLoop meta")
  47.     }
  48.   }
  49.   @Input()
  50.   inputHandler = (inputValue?: unknown, context?:unknown) => {
  51.     let count = 0
  52.     if (this.meta.config?.fromInput) {
  53.       if (!_.isArray(inputValue)) {
  54.         console.error("Loop input is not array")
  55.       } else {
  56.         for (const one of inputValue) {
  57.           //转发输入到子编排
  58.           this.getInput()?.push(one, context)
  59.           count++
  60.           //如果子编排调用了结束
  61.           if(this.finished){
  62.             break
  63.           }
  64.         }
  65.       }
  66.     } else if (_.isNumber(this.meta.config?.times)) {
  67.       for (let i = 0; i < (this.meta.config?.times || 0); i++) {
  68.         //转发输入到子编排
  69.         this.getInput()?.push(, context)
  70.         count++
  71.         //如果子编排调用了结束
  72.         if(this.finished){
  73.           break
  74.         }
  75.       }
  76.     }
  77.     //如果子编排中还没有被调用过finished
  78.     if(!this.finished){
  79.       this.next(count, CustomizedLoop.PORT_FINISHED, context)
  80.     }
  81.   }
  82.   getInput(){
  83.     return this.logicFlow?.jointers?.getInput(CustomizedLoop.PORT_INPUT)
  84.   }
  85.   oneOutputHandler = (value: unknown, context?:unknown)=>{
  86.     //输出到响应端口
  87.     this.output(value, context)
  88.   }
  89.   finisedHandler = (value: unknown, context?:unknown)=>{
  90.     //标识已调用过finished
  91.     this.finished = true
  92.     //输出到响应端口
  93.     this.next(value, CustomizedLoop.PORT_FINISHED, context)
  94.   }
  95.   output = (value: unknown, context?:unknown) => {
  96.     this.next(value, CustomizedLoop.PORT_OUTPUT, context)
  97.   }
  98. }
复制代码
基础的逻辑编排引擎,基本全部介绍完了,清楚了节点之间的编排机制,是时候定义节点的连线规则了。
节点的连线规则

一个节点,是一个对象。有状态,有副作用。有状态的对象没有约束的互连,是非常危险的行为。
这种情况会面临一个诱惑,或者说用户自己也分不清楚。就是把节点当成无状态对象使用,或者直接认为节点就是无状态的,不加限制的把连线连到某个节点的入口上。
比如上面计算学生总分例子,可能会被糊涂的用户连成这样:
33.png

这种连接方式,直接造成收集数组节点无法正常工作。
逻辑编排之所以直观,在于它把每一个个数据流通的路径都展示出来了。在一个通路上的一个节点,最好只完成一个该通路的功能。另一个通路如果想完成同样的功能,最好再新建一个对象:
34.png

这样两个收集数组节点,就互不干扰了。
要实现这样的约束,只需要加一个连线规则:同一个入端口,只能连一条线
有了这条规则,节点对象状态带来的不利影响,基本消除了。
在这样的规则下,收集数组节点的入口不能连接多条连线,只需要把它重新设计成如下形式:
35.png

一个出端口,可以往外连接多条连线,用于表示并行执行。另一条规则就是:同一个出端口,可以有多条连线
数据是从左往右流动,所以再加上最后一条规则:入端口在节点左侧,出端口在节点右侧
所有的连线规则完成了,蛮简单的,编辑器层面可以直接做约束,防止用户输错。
编辑器的实现

编辑器布局

36.png

整个编辑器分为图中标注的四个区域。

  • ① 工具栏,编辑器常规操作,比如撤销、重做、删除等。
  • ② 工具箱(物料箱),存放可以被拖放的元件物料,这些物料是可以从外部注入到编辑器的。
  • ③ 画布区,绘制逻辑编排图的画布。每个节点都有自己的坐标,要基于这个对DSL进行扩展,给节点附加坐标信息。画布基于阿里antv X6实现。
  • ④ 属性面板,编辑元件节点的配置信息。物料是从编辑器外部注入的,物料对应节点的配置是变化的,所以属性面板内的组件也是变化的,使用RxDrag的低代码渲染引擎来实现,外部注入的物料要写到相应的Schema信息。低代码Schema相关内容,请参考另一篇文章《实战,一个高扩展、可视化低代码前端,详实、完整》
扩展DSL

前面定义的DSL用在逻辑编排解析引擎里,足够了。但是,在画布上展示,还缺少节点位置跟尺寸信息。设计器画布是基于X6实现的,要添加X6需要的信息,来扩展DSL:
  1. export interface IX6NodeDefine {
  2.   /** 节点x坐标 */
  3.   x: number;
  4.   /** 节点y坐标  */
  5.   y: number;
  6.   /** 节点宽度 */
  7.   width: number;
  8.   /** 节点高度 */
  9.   height: number;
  10. }
  11. // 扩展后节点
  12. export interface IActivityNode extends INodeDefine {
  13.   x6Node?: IX6NodeDefine
  14. }
复制代码
这些信息,足以在画布上展示一个完整的逻辑编排图了。
元件物料定义

工具箱区域②跟画布区域③显示节点时,使用了共同的元素:元件图标,元件标题,图标颜色,这些可以放在物料的定义里。
物料还需要:元件对应的Acitvity名字,属性面板④ 的配置Schema。具体定义:
  1. import { NodeType, IPortDefine } from "./dsl";
  2. //端口定义
  3. export interface IPorts {
  4.   //入端口
  5.   inPorts?: IPortDefine[];
  6.   //出端口
  7.   outPorts?: IPortDefine[];
  8. }
  9. //元件节点的物料定义
  10. export interface IActivityMaterial<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  11.   //标题
  12.   label: string;
  13.   //节点类型,NodeType在DLS中定义,这里根据activityType决定画上的图形样式
  14.   activityType: NodeType;
  15.   //图标代码,react的话,相当于React.ReactNode
  16.   icon?: ComponentNode;
  17.   //图标颜色
  18.   color?: string;
  19.   //属性面板配置,可以适配不同的低代码Schema,使用RxDrag的话,这可以是INodeSchema类型
  20.   schema?: NodeSchema;
  21.   //默认端口,元件节点的端口设置的默认值,大部分节点端口跟默认值是一样的,
  22.   //部分动态配置端口,会根据配置有所变化
  23.   defaultPorts?: IPorts;
  24.   //画布中元件节点显示的子标题
  25.   subTitle?: (config?: Config, context?: MaterialContext) => string | undefined;
  26.   //对应解析引擎里的Activity名称,根据这个名字实例化相应的节点业务逻辑对象
  27.   activityName: string;
  28. }
  29. //物料分类,用于在工具栏上,以手风琴风格分组物料
  30. export interface ActivityMaterialCategory<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  31.   //分类名
  32.   name: string;
  33.   //分类包含的物料
  34.   materials: IActivityMaterial<ComponentNode, NodeSchema, Config, MaterialContext>[];
  35. }
复制代码
只要符合这个定义的物料,都是可以被注入设计器的。
在前面定义DSL的时候, INodeDefine 也有一个一样的属性是 activityName。没错,这两个activityName指代的对象是一样的。画布渲染dsl的时候,会根据activityName查找相应的物料,根据物料携带的信息展示,入图标、颜色、属性配置组件等。
在做前端物料跟元件的时候,为了重构方便,会把activityName以存在Activity的static变量里,物料定义直接引用,端口名称也是类似的处理。看一个最简单的节点,Debug节点的代码。
Activity代码:
[code]import { Activity, Input, AbstractActivity } from "@rxdrag/minions-runtime"import { INodeDefine } from "@rxdrag/minions-schema"//调试节点配置export interface IDebugConfig {  //提示信息  tip?: string,  //是否已关闭  closed?: boolean}@Activity(DebugActivity.NAME)export class DebugActivity extends AbstractActivity {  //对应INodeDeifne 跟IActivityMaterial的 activityName  public static NAME = "system.debug"  constructor(meta: INodeDefine) {    super(meta)  }  //入口处理函数  @Input()  inputHandler(inputValue: unknown): void {    if (!this.config?.closed) {      console.log(`
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册