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

前一段时间写过一篇文章《实战,一个高扩展、可视化低代码前端,详实、完整》,得到了很多朋友的关注。
其中的逻辑编排部分过于简略,不少朋友希望能写一些关于逻辑编排的内容,本文就详细讲述一下逻辑编排的实现原理。
逻辑编排的目的,是用最少甚至不用代码来实现软件的业务逻辑,包括前端业务逻辑跟后端业务逻辑。本文前端代码基于typescript、react技术栈,后端基于golang。
涵盖内容:数据流驱动的逻辑编排原理,业务编排编辑器的实现,页面控件联动,前端业务逻辑与UI层的分离,子编排的复用、自定义循环等嵌入式子编排的处理、事务处理等
运行快照:
image.png
前端项目地址:https://github.com/codebdy/rxdrag
前端演示地址:https://rxdrag.vercel.app/
后端演示尚未提供,代码地址:https://github.com/codebdy/minions-go
注:为了便于理解,本文使用的代码做了简化处理,会跟实际代码有些细节上的出入。

整体架构

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

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

逻辑编排实现方式的选择

逻辑编排,实现方式很多,争议也很多。
一直以来,小编的思路也很局限。从流程图层面,以线性的思维去思考,认为逻辑编排的意义并不大。因为,经过这么多年发展,事实证明代码才是表达逻辑的最佳方式,没有之一。用流程图去表达代码,最终只能是老板、客户的丰满理想与程序员骨感现实的对决。
直到看到Mybricks项目交互部分的实现方式,才打开了思路。类似unreal蓝图数据流驱动的实现方式,其实大有可为。
这种方式的意义是,跳出循环、if等这些底层的代码细节,以数据流转的方式思考业务逻辑,从而把业务逻辑抽象为可复用的组件,每个组件对数据进行相应处理或者根据数据执行相应动作,从而达到复用业务逻辑的目的。并且,节点的粒度可大可小,非常灵活。
具体实现方式是,把每个逻辑组件看成一个黑盒,通过入端口流入数据,出端口流出变换后的数据:
image.png
举个例子,一个节点用来从数据库查询客户列表,会是这样的形式:
image.png
用户不需要关注这个元件节点的实现细节,只需要知道每个端口的功能就可以使用。这个元件节点的功能可以做的很简单,比如一个fetch,只有几十行代码。也可以做到很强大,比如类似useSwr,自带缓存跟状态管理,可以有几百甚至几千行代码。
我们希望这些元件节点是可以自行定义,方便插入的,并且我们做到了。
出端口跟入端口之间,可以用线连接,表示元件节点之间的调用关系,或者说是数据的流入关系。假如,数据读取成功,需要显示在列表中;失败,提示错误消息;查询时,显示等待的Spinning,那么就可以再加三个元件节点,变成:
image.png
如果用流程图,上面这个编排,会被显示成如下样子:
image.png
两个比较,就会发现,流程图的思考方式,会把人引入条件细节,其实就是试图用不擅长代码的图形来描述代码。是纯线性的,没有回调,也就无法实现类似js promise的异步。
而数据流驱动的逻辑编排,可以把人从细节中解放出来,用模块化的思考方式去设计业务逻辑,更方便把业务逻辑拆成一个个可复用的单元。
如果以程序员的角度来比喻,流程图相当于一段代码脚本,是面向过程的;数据流驱动的逻辑编排像是几个类交互完成一个功能,更有点面向对象的感觉。
朋友,如果是让你选,你喜欢哪种方式?欢迎留言讨论。
另外还有一种类似stratch的实现方式:
image.png
感觉这种纯粹为了可视化而可视化,只适合小孩子做玩具。会写代码的人不愿意用,太低效了。不会写代码的人,需要理解代码才会用。适合场景是用直观的方式介绍什么是代码逻辑,就是说只适合相对比较低智力水平的编程教学,比如幼儿园、小学等。商业应用,就免了。

数据流驱动的逻辑编排

一个简单的例子

从现在开始,放下流程图,忘记strach,我们从业务角度去思考也逻辑,然后设计元件节点去实现相应的逻辑。
选一个简单又典型的例子:学生成绩单。一个成绩单包含如下数据:
学生成绩单
假如数据已经从数据库取出来了,第一步处理,统计每个学生的总分数。设计这么几个元件节点来配合完成:
image.png
这个编排,输入成绩列表,循环输出每个学生的总成绩。为了完成这个编排,设计了四个元件节点:

  • 循环,入端口接收一个列表,遍历列表并循环输出,每一次遍历往“单次输出”端口发送一条数据,可以理解为一个学生对象(尽量从对象的角度思考,而不是数据记录),遍历结束后往“结束端口”发送循环的总数。如果按照上面的列表,“单次输出端口”会被调用4次,每次输出一个学生对象{姓名:xxx,语文:xxx,数学:xxx…},“结束”端口只被调用一次,输出结果是 4.
  • 拆分对象,这个元件节点的出端口是可以动态配置的,它的功能是把一个对象按照属性值按照名字分发到指定的出端口。本例中,就是把各科成绩拆分开来。
  • 收集数组,这个节点也可以叫收集到数组,作用是把串行接收到的数据组合到一个数组里。他有两个入端口:input端口,用来接收串行输入,并缓存到数组;finished端口,表示输入完成,把缓存到的数据组发送给输出端口。
  • 加和,把输入端口传来的数组进行加和计算,输出总数。

这是一种跟代码完全不同的思考方式,每一个元件节点,就是一小段业务逻辑,也就是所谓的业务逻辑组件化。我们的项目中,只提供给了有限的预定义元件节点,想要更多的节点,可以自行自定义并注入系统,具体设计什么样的节点,完全取决于用户的业务需求跟喜好。作者更希望设计元件的过程是一个创作的过程,或许具备一定的艺术性。
刚刚的例子,审视之。有人可能会换一个方式来实现,比如拆分对象跟收集数据这两个节点,合并成一个节点:对象转数组,可能更方便,适应能力也更强:
image.png
对象转换数组节点,对象属性与数组索引的对应关系,可以通过属性面板的配置来完成。
这两种实现方式,说不清哪种更好,选择自己喜欢的,或者两种都提供。

输入节点、输出节点

一段图形化的逻辑编排,通过解析引擎,会被转换成一段可执行的业务逻辑。这段业务逻辑需要跟外部对接,为了明确对接语义,再添加两个特殊的节点元件:输入节点(开始节点),输出节点(结束节点)。
image.png
输入节点用于标识逻辑编排的入口,输入节点可以有一个或者多个,输入节点用细线圆圈表示。
输出节点用于标识逻辑编排的出口,输出节点可以有一个或者多个,输出节点用粗线圆圈表示。
在后面的引擎部分,会详细描述输入跟输出节点如何跟外部的对接。

编排的复用:子编排

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

嵌入式编排节点

前文设计的循环组件非常简单,循环直接执行到底,不能被中断。但是,有的时候,在处理数据的时候,要根据每次遍历到的数据做判断,来决定继续循环还是终止循环。
就是说,需要一个循环节点,能够自定义它的处理流程。依据这个需求,设计了自定义循环元件,这是一种能够嵌入编排的节点,形式如下:
image.png
这种嵌入式编排节点,跟其它元件节点一样,事先定义好输入节点跟输出节点。只是它不完全是黑盒,其中一部分通过逻辑编排这种白盒方式来实现。
这种场景并不多见,除了循环,后端应用中,还有事务元件也需要类似实现方式:
image.png
嵌入式元件跟其它元件节点一样,可以被其它元件连接,嵌入式节点在整个编排中的表现形式:
image.png

基本概念

为了进一步深入逻辑编排引擎跟编辑器的实现原理,先梳理一些基本的名词、概念。
逻辑编排,本文特指数据流驱动的逻辑编排,是由图形表示的一段业务逻辑,由元件节点跟连线组成。
元件节点,简称元件、节点、编排元件、编排单元。逻辑编排中具体的业务逻辑处理单元,带副作用的,可以实现数据转换、页面组件操作、数据库数据存取等功能。一个节点包含零个或多个输入端口,包含零个或多个输出端口。在设计其中,以圆角方形表示:
image.png
端口,分为输入端口跟输出端口两种。是元件节点流入或流出数据的通道(或者接口)。在逻辑单元中,用小圆圈表示。
输入端口,简称入端口、入口。输入端口位于元件节点的左侧。
输出端口,简称出端口、出口。输出端口位于元件节点的右侧。
单入口元件,只有一个入端口的元件节点。
多入口元件,有多个入端口的元件节点。
单出口元件,只有一个出端口的元件节点。
多出口元件,有多个出端口的元件节点。
输入节点,一种特殊的元件节点,用于描述逻辑编排的起点(开始点)。转换成子编排后,会对应子编排相应的入端口。
输出节点,一种特殊的元件节点,用于描述逻辑编排的终点(结束点)。转换成子编排后,会对应子编排相应的出端口。
嵌入式编排,特殊的元件节点,内部实现由逻辑编排完成。示例:
image.png
子编排,特殊的逻辑编排,该编排可以转换成元件节点,供其它逻辑编排使用。
连接线,简称连线、线。用来连接各个元件节点,表示数据的流动关系。

定义DSL

逻辑编排编辑器生成一份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类的对象联动起来。这个实现是逻辑编排的核心,虽然实现代码只有几百行,但是很绕,需要静下心来好好研读接下来的部分。

编排引擎的设计

编排引擎类图

类图1
LogicFlow类,代表一个完整的逻辑编排。它解析一张逻辑编排图,并执行该图所代表的逻辑。
IActivity接口,一个元件节点的执行逻辑。不同的逻辑节点,实现不同的Activity类,这类都实现IActivity接口。比如循环元件,可以实现为

export class LoopActivity implements IActivity{
   
    id: string
    config: LoopActivityConfig
}

LogicFlow类解析逻辑编排图时,根据解析到的元件节点,创建相应的IActivity实例,比如解析到Loop节点的时候,就创建LoopActivity实例。
LogicFlow还有一个功能,就是根据连线,给构建的IActivity实例建立连接关系,让数据能在不同的IActivity实例之间流转。先明白引擎中的数据流,是理解上述类图的前提。

解析引擎中的Jointer

在解析引擎中,数据按照以下路径流动:
连接器间的数据流转
有三个节点:节点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。
连接器的包含关系
注意,这是实例的关系,如果对应到类图,就是这样的关系:
类图2
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的下游,后面会有进一步介绍。

Activity与Jointer的关系

一个元件节点包含多个(或零个)入端口和多个(或零个)出端口。那么意味着一个IActivity实例包含多个Jointer,这些Jointer也按照输入跟输出来分组:
类图3
TypeScript定义的代码如下:

export interface IActivityJointers {
   
  //入端口对应的连接器
  inputs: IJointer[];
  //处端口对应的连接器
  outputs: IJointer[];

  //通过端口名获取出连接器
  getOutput(name: string): IJointer | undefined
  
  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值