赞
踩
前一段时间写过一篇文章《实战,一个高扩展、可视化低代码前端,详实、完整》,得到了很多朋友的关注。
其中的逻辑编排部分过于简略,不少朋友希望能写一些关于逻辑编排的内容,本文就详细讲述一下逻辑编排的实现原理。
逻辑编排的目的,是用最少甚至不用代码来实现软件的业务逻辑,包括前端业务逻辑跟后端业务逻辑。本文前端代码基于typescript、react技术栈,后端基于golang。
涵盖内容:数据流驱动的逻辑编排原理,业务编排编辑器的实现,页面控件联动,前端业务逻辑与UI层的分离,子编排的复用、自定义循环等嵌入式子编排的处理、事务处理等
运行快照:
前端项目地址:https://github.com/codebdy/rxdrag
前端演示地址:https://rxdrag.vercel.app/
后端演示尚未提供,代码地址:https://github.com/codebdy/minions-go
注:为了便于理解,本文使用的代码做了简化处理,会跟实际代码有些细节上的出入。
整个逻辑编排,由以下几部分组成:
逻辑编排,实现方式很多,争议也很多。
一直以来,小编的思路也很局限。从流程图层面,以线性的思维去思考,认为逻辑编排的意义并不大。因为,经过这么多年发展,事实证明代码才是表达逻辑的最佳方式,没有之一。用流程图去表达代码,最终只能是老板、客户的丰满理想与程序员骨感现实的对决。
直到看到Mybricks项目交互部分的实现方式,才打开了思路。类似unreal蓝图数据流驱动的实现方式,其实大有可为。
这种方式的意义是,跳出循环、if等这些底层的代码细节,以数据流转的方式思考业务逻辑,从而把业务逻辑抽象为可复用的组件,每个组件对数据进行相应处理或者根据数据执行相应动作,从而达到复用业务逻辑的目的。并且,节点的粒度可大可小,非常灵活。
具体实现方式是,把每个逻辑组件看成一个黑盒,通过入端口流入数据,出端口流出变换后的数据:
举个例子,一个节点用来从数据库查询客户列表,会是这样的形式:
用户不需要关注这个元件节点的实现细节,只需要知道每个端口的功能就可以使用。这个元件节点的功能可以做的很简单,比如一个fetch,只有几十行代码。也可以做到很强大,比如类似useSwr,自带缓存跟状态管理,可以有几百甚至几千行代码。
我们希望这些元件节点是可以自行定义,方便插入的,并且我们做到了。
出端口跟入端口之间,可以用线连接,表示元件节点之间的调用关系,或者说是数据的流入关系。假如,数据读取成功,需要显示在列表中;失败,提示错误消息;查询时,显示等待的Spinning,那么就可以再加三个元件节点,变成:
如果用流程图,上面这个编排,会被显示成如下样子:
两个比较,就会发现,流程图的思考方式,会把人引入条件细节,其实就是试图用不擅长代码的图形来描述代码。是纯线性的,没有回调,也就无法实现类似js promise的异步。
而数据流驱动的逻辑编排,可以把人从细节中解放出来,用模块化的思考方式去设计业务逻辑,更方便把业务逻辑拆成一个个可复用的单元。
如果以程序员的角度来比喻,流程图相当于一段代码脚本,是面向过程的;数据流驱动的逻辑编排像是几个类交互完成一个功能,更有点面向对象的感觉。
朋友,如果是让你选,你喜欢哪种方式?欢迎留言讨论。
另外还有一种类似stratch的实现方式:
感觉这种纯粹为了可视化而可视化,只适合小孩子做玩具。会写代码的人不愿意用,太低效了。不会写代码的人,需要理解代码才会用。适合场景是用直观的方式介绍什么是代码逻辑,就是说只适合相对比较低智力水平的编程教学,比如幼儿园、小学等。商业应用,就免了。
从现在开始,放下流程图,忘记strach,我们从业务角度去思考也逻辑,然后设计元件节点去实现相应的逻辑。
选一个简单又典型的例子:学生成绩单。一个成绩单包含如下数据:
假如数据已经从数据库取出来了,第一步处理,统计每个学生的总分数。设计这么几个元件节点来配合完成:
这个编排,输入成绩列表,循环输出每个学生的总成绩。为了完成这个编排,设计了四个元件节点:
这是一种跟代码完全不同的思考方式,每一个元件节点,就是一小段业务逻辑,也就是所谓的业务逻辑组件化。我们的项目中,只提供给了有限的预定义元件节点,想要更多的节点,可以自行自定义并注入系统,具体设计什么样的节点,完全取决于用户的业务需求跟喜好。作者更希望设计元件的过程是一个创作的过程,或许具备一定的艺术性。
刚刚的例子,审视之。有人可能会换一个方式来实现,比如拆分对象跟收集数据这两个节点,合并成一个节点:对象转数组,可能更方便,适应能力也更强:
对象转换数组节点,对象属性与数组索引的对应关系,可以通过属性面板的配置来完成。
这两种实现方式,说不清哪种更好,选择自己喜欢的,或者两种都提供。
一段图形化的逻辑编排,通过解析引擎,会被转换成一段可执行的业务逻辑。这段业务逻辑需要跟外部对接,为了明确对接语义,再添加两个特殊的节点元件:输入节点(开始节点),输出节点(结束节点)。
输入节点用于标识逻辑编排的入口,输入节点可以有一个或者多个,输入节点用细线圆圈表示。
输出节点用于标识逻辑编排的出口,输出节点可以有一个或者多个,输出节点用粗线圆圈表示。
在后面的引擎部分,会详细描述输入跟输出节点如何跟外部的对接。
一般低代码中,提升效率的方式是复用,尽可能复用已有的东西,比如组件、业务逻辑,从而达到降本、增效的目的。
设计元件节点是一种创作,那么使用元件节点进行业务编排,更是一种基于领域的创作。辛辛苦苦创作的编排,如果能被复用,应该算是对创作本身的尊重吧。
如果编排能够像元件节点一样,被其它逻辑编排所引用,那么这样的复用方式无疑是最融洽的。也是最方便的实现方式。
把能够被其它编排引用的编排称为子编排,上面计算学生总成绩的编排,转换成子编排,被引入时的形态应该是这样的:
子编排元件的输入端口对应逻辑编排实现的输入节点,输出端口对应编排实现的输出节点。
前文设计的循环组件非常简单,循环直接执行到底,不能被中断。但是,有的时候,在处理数据的时候,要根据每次遍历到的数据做判断,来决定继续循环还是终止循环。
就是说,需要一个循环节点,能够自定义它的处理流程。依据这个需求,设计了自定义循环元件,这是一种能够嵌入编排的节点,形式如下:
这种嵌入式编排节点,跟其它元件节点一样,事先定义好输入节点跟输出节点。只是它不完全是黑盒,其中一部分通过逻辑编排这种白盒方式来实现。
这种场景并不多见,除了循环,后端应用中,还有事务元件也需要类似实现方式:
嵌入式元件跟其它元件节点一样,可以被其它元件连接,嵌入式节点在整个编排中的表现形式:
为了进一步深入逻辑编排引擎跟编辑器的实现原理,先梳理一些基本的名词、概念。
逻辑编排,本文特指数据流驱动的逻辑编排,是由图形表示的一段业务逻辑,由元件节点跟连线组成。
元件节点,简称元件、节点、编排元件、编排单元。逻辑编排中具体的业务逻辑处理单元,带副作用的,可以实现数据转换、页面组件操作、数据库数据存取等功能。一个节点包含零个或多个输入端口,包含零个或多个输出端口。在设计其中,以圆角方形表示:
端口,分为输入端口跟输出端口两种。是元件节点流入或流出数据的通道(或者接口)。在逻辑单元中,用小圆圈表示。
输入端口,简称入端口、入口。输入端口位于元件节点的左侧。
输出端口,简称出端口、出口。输出端口位于元件节点的右侧。
单入口元件,只有一个入端口的元件节点。
多入口元件,有多个入端口的元件节点。
单出口元件,只有一个出端口的元件节点。
多出口元件,有多个出端口的元件节点。
输入节点,一种特殊的元件节点,用于描述逻辑编排的起点(开始点)。转换成子编排后,会对应子编排相应的入端口。
输出节点,一种特殊的元件节点,用于描述逻辑编排的终点(结束点)。转换成子编排后,会对应子编排相应的出端口。
嵌入式编排,特殊的元件节点,内部实现由逻辑编排完成。示例:
子编排,特殊的逻辑编排,该编排可以转换成元件节点,供其它逻辑编排使用。
连接线,简称连线、线。用来连接各个元件节点,表示数据的流动关系。
逻辑编排编辑器生成一份JSON,解析引擎解析这份JSON,把图形化的业务逻辑转化成可执行的逻辑,并执行。
编辑器跟解析引擎之间要有份约束协议,用来约定JSON的定义,这个协议就是这里定义的DSL。在typescript中,用interface、enum等元素来表示。
这些DSL仅仅是用来描述页面上的图形元素,通过activityName属性跟具体的实现代码逻辑关联起来。比如一个循环节点,它的actvityName是Loop,解析引擎会根据Loop这个名字找到该节点对应的实现类,并实例化为一个可执行对象。后面的解析引擎会详细展开描述这部分。
元件节点类型叫NodeType,用来区分不同类型的节点,在TypeScript中是一个枚举类型。
export enum NodeType {
//开始节点
Start = 'Start',
//结束节点
End = 'End',
//普通节点
Activity = 'Activity',
//子编排,对其它编排的引用
LogicFlowActivity = "LogicFlowActivity",
//嵌入式节点,比如自定义逻辑编排
EmbeddedFlow = "EmbeddedFlow"
}
export interface IPortDefine {
//唯一标识
id: string;
//端口名词
name: string;
//显示文本
label?: string;
}
//一段逻辑编排数据 export interface ILogicFlowMetas { //所有节点 nodes: INodeDefine<unknown>[]; //所有连线 lines: ILineDefine[]; } export interface INodeDefine<ConfigMeta = unknown> { //唯一标识 id: string; //节点名称,一般用于开始结束、节点,转换后对应子编排的端口 name?: string; //节点类型 type: NodeType; //活动名称,解析引擎用,通过该名称,查找构造节点的具体运行实现 activityName: string; //显示文本 label?: string; //节点配置 config?: ConfigMeta; //输入端口 inPorts?: IPortDefine[]; //输出端口 outPorts?: IPortDefine[]; //父节点,嵌入子编排用 parentId?: string; // 子节点,嵌入编排用 children?: ILogicFlowMetas }
//连线接头 export interface IPortRefDefine { //节点Id nodeId: string; //端口Id portId?: string; } //连线定义 export interface ILineDefine { //唯一标识 id: string; //起点 source: IPortRefDefine; //终点 target: IPortRefDefine; }
//这个代码上面出现过,为了使extends更直观,再出现一次 //一段逻辑编排数据 export interface ILogicFlowMetas { //所有节点 nodes: INodeDefine<unknown>[]; //所有连线 lines: ILineDefine[]; } //逻辑编排 export interface ILogicFlowDefine extends ILogicFlowMetas { //唯一标识 id: string; //名称 name?: string; //显示文本 label?: string; }
解析引擎有两份实现:Typescript实现跟Golang实现。这里介绍基于原理,以Typescript实现为准,后面单独章节介绍Golang的实现方式。也有朋友根据这个dsl实现了C#版自用,欢迎朋友们实现不同的语言版本并开源。
DSL只是描述了节点跟节点之间的连接关系,业务逻辑的实现,一点都没有涉及。需要为每个元件节点制作一个单独的处理类,才能正常解析运行。比如上文中的循环节点,它的DSL应该是这样的:
{ "id": "id-1", "type": "Activity", "activityName": "Loop", "label": "循环", "inPorts": [ { "id":"port-id-1", "name":"input", "label":"" } ], "outPorts": [ { "id":"port-id-2", "name":"output", "label":"单次输出" }, { "id":"port-id-3", "name":"finished", "label":"结束" } ] }
开发人员制作一个处理类LoopActivity用来处理循环节点的业务逻辑,并将这个类注册入解析引擎,key为loop。这个类,我们叫做活动(Activity)。解析引擎,根据activityName查找类,并创建实例。LoopActivity的类实现应该是这样:
export interface IActivity{ inputHandler (inputValue?: unknown, portName:string); } export class LoopActivity implements IActivity{ constructor(protected meta: INodeDefine<ILoopConfig>) { } //输入处理 inputHandler (inputValue?: unknown, portName:string){ if(portName !== "input"){ console.error("输入端口名称不正确") return } let count = 0 if (!_.isArray(inputValue)) { console.error("循环的输入值不是数组") } else { for (const one of inputValue) { this.output(one) count++ } } //输出循环次数 this.next(count, "finished") } //单次输出 output(value: unknown){ this.next(value, "output") } next(value:unknown, portName:string){ //把数据输出到指定端口,这里需要解析器注入代码 } }
解析引擎根据DSL,调用inputHanlder,把控制权交给LoopActivity的对象,LoopActivity处理完成后把数据通过next方法传递出去。它只需要关注自身的业务逻辑就可以了。
这里难点是,引擎如何让所有类似LoopActivity类的对象联动起来。这个实现是逻辑编排的核心,虽然实现代码只有几百行,但是很绕,需要静下心来好好研读接下来的部分。
LogicFlow类,代表一个完整的逻辑编排。它解析一张逻辑编排图,并执行该图所代表的逻辑。
IActivity接口,一个元件节点的执行逻辑。不同的逻辑节点,实现不同的Activity类,这类都实现IActivity接口。比如循环元件,可以实现为
export class LoopActivity implements IActivity{
id: string
config: LoopActivityConfig
}
LogicFlow类解析逻辑编排图时,根据解析到的元件节点,创建相应的IActivity实例,比如解析到Loop节点的时候,就创建LoopActivity实例。
LogicFlow还有一个功能,就是根据连线,给构建的IActivity实例建立连接关系,让数据能在不同的IActivity实例之间流转。先明白引擎中的数据流,是理解上述类图的前提。
在解析引擎中,数据按照以下路径流动:
有三个节点:节点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。
注意,这是实例的关系,如果对应到类图,就是这样的关系:
Jointer通过调用push方法把数据传递给其他Jointer实例。
connect方法用于给两个Joiner构建连接关系。
用TypeScript实现的话,代码是这样的:
//数据推送接口 export type InputHandler = (inputValue: unknown, context?:unknown) => void; export interface IJointer { name: string; //接收上一级Jointer推送来的数据 push: InputHandler; //添加下游Jointer connect: (jointerInput: InputHandler) => void; } export class Jointer implements IJointer { //下游Jonter的数据接收函数 private outlets: IJointer[] = [] constructor(public id: string, public name: string) { } //接收上游数据,并分发到下游 push: InputHandler = (inputValue?: unknown, context?:unknown) => { for (const jointer of this.outlets) { //推送数据 jointer.push(inputValue, context) } } //添加下游Joninter connect = (jointer: IJointer) => { //往数组加数据,跟上面的push不一样 this.outlets.push(jointer) } //删除下游Jointer disconnect = (jointer: InputHandler) => { this.outlets.splice(this.outlets.indexOf(jointer), 1) } }
在TypeScript跟Golang中,函数是一等公民。但是在类图里面,这个独立的一等公民是不好表述的。所以,上面的代码只是对类图的简单翻译。在实现时,Jointer的outlets可以不存IJointer的实例,只存Jointer的push方法,这样的实现更灵活,并且更容易把一个逻辑编排转成一个元件节点,优化后的代码:
//数据推送接口 export type InputHandler = (inputValue: unknown, context?:unknown) => void; export interface IJointer { //当key使用,不参与业务逻辑 id: string; name: string; //接收上一级Jointer推送来的数据 push: InputHandler; //添加下游Jointer connect: (jointerInput: InputHandler) => void; } export class Jointer implements IJointer { //下游Jonter的数据接收函数 private outlets: InputHandler[] = [] constructor(public id: string, public name: string) { } //接收上游数据,并分发到下游 push: InputHandler = (inputValue?: unknown, context?:unknown) => { for (const jointerInput of this.outlets) { jointerInput(inputValue, context) } } //添加下游Joninter connect = (inputHandler: InputHandler) => { this.outlets.push(inputHandler) } //删除下游Jointer disconnect = (jointer: InputHandler) => { this.outlets.splice(this.outlets.indexOf(jointer), 1) } }
记住这里的优化:Jointer的下游已经不是Jointer了,是Jointer的push方法,也可以是独立的其它方法,只要参数跟返回值跟Jointer的push方法一样就行,都是InputHandler类型。这个优化,可以让把Activer的某个处理函数设置为入Jointer的下游,后面会有进一步介绍。
一个元件节点包含多个(或零个)入端口和多个(或零个)出端口。那么意味着一个IActivity实例包含多个Jointer,这些Jointer也按照输入跟输出来分组:
TypeScript定义的代码如下:
export interface IActivityJointers {
//入端口对应的连接器
inputs: IJointer[];
//处端口对应的连接器
outputs: IJointer[];
//通过端口名获取出连接器
getOutput(name: string): IJointer | undefined
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。