前一段时刻写过一篇文章《实战,一个高扩展、可视化低代码前端,详实、完好》,得到了许多朋友的重视。

其间的逻辑编列部分过于简略,不少朋友期望能写一些关于逻辑编列的内容,本文就详细讲述一下逻辑编列的完结原理。

逻辑编列的意图,是用最少乃至不必代码来完结软件的业务逻辑,包括前端业务逻辑跟后端业务逻辑。本文前端代码依据typescript、react技术栈,后端依据golang。

包括内容:数据流驱动的逻辑编列原理,业务编列修正器的完结,页面控件联动,前端业务逻辑与UI层的别离,子编列的复用、自界说循环等嵌入式子编列的处理、业务处理等

运转快照:

挑战零代码:可视化逻辑编排

前端项目地址:github.com/codebdy/rxd…

前端演示地址:rxdrag.vercel.app/

后端演示没有供给,代码地址:github.com/codebdy/min…

注:为了便于了解,本文运用的代码做了简化处理,会跟实践代码有些细节上的收支。

全体架构

挑战零代码:可视化逻辑编排

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

  • 节点物料,用于界说修正器中的元件,包括在工具箱中的图标,端口以及特点面板中的组件schema。
  • 逻辑编列修正器,望文生义,可视化修正器,依据物料供给的元件信息,修正生成JSON格局的“编列描绘数据”。
  • 编列描绘数据,用户操作修正器的生成物,供解析引擎消费
  • 前端解析引擎,Typescript 完结的解析引擎,直接解析“编列描绘数据”并履行,然后完结的软件的业务逻辑。
  • 后端解析引擎,Golang 完结的解析引擎,直接解析“编列描绘数据”并履行,然后完结的软件的业务逻辑。

逻辑编列完结方法的挑选

逻辑编列,完结方法许多,争议也许多。

一直以来,小编的思路也很局限。从流程图层面,以线性的思想去思考,以为逻辑编列的含义并不大。由于,经过这么多年发展,事实证明代码才是表达逻辑的最佳方法,没有之一。用流程图去表达代码,最终只能是老板、客户的饱满抱负与程序员骨感现实的对决。

直到看到Mybricks项目交互部分的完结方法,才打开了思路。相似unreal蓝图数据流驱动的完结方法,其实大有可为。

这种方法的含义是,跳出循环、if等这些底层的代码细节,以数据流通的方法思考业务逻辑,然后把业务逻辑笼统为可复用的组件,每个组件对数据进行相应处理或许依据数据履行相应动作,然后达到复用业务逻辑的意图。而且,节点的粒度可大可小,十分灵敏。

详细完结方法是,把每个逻辑组件看成一个黑盒,经过入端口流入数据,出端口流出改换后的数据:

挑战零代码:可视化逻辑编排

举个比方,一个节点用来从数据库查询客户列表,会是这样的方法:

挑战零代码:可视化逻辑编排

用户不需求重视这个元件节点的完结细节,只需求知道每个端口的功用就能够运用。这个元件节点的功用能够做的很简略,比方一个fetch,只要几十行代码。也能够做到很强壮,比方相似useSwr,自带缓存跟状况办理,能够有几百乃至几千行代码。

咱们期望这些元件节点是能够自行界说,便利刺进的,而且咱们做到了。

出端口跟入端口之间,能够用线衔接,表明元件节点之间的调用联络,或许说是数据的流入联络。假设,数据读取成功,需求显现在列表中;失利,提示过错消息;查询时,显现等待的Spinning,那么就能够再加三个元件节点,变成:

挑战零代码:可视化逻辑编排

假设用流程图,上面这个编列,会被显现成如下姿态:

挑战零代码:可视化逻辑编排

两个比较,就会发现,流程图的思考方法,会把人引进条件细节,其实就是企图用不拿手代码的图形来描绘代码。是纯线性的,没有回调,也就无法完结相似js promise的异步。

而数据流驱动的逻辑编列,能够把人从细节中解放出来,用模块化的思考方法去规划业务逻辑,更便利把业务逻辑拆成一个个可复用的单元。

假设以程序员的视点来比方,流程图相当于一段代码脚本,是面向进程的;数据流驱动的逻辑编列像是几个类交互完结一个功用,更有点面向方针的感觉。

朋友,假设是让你选,你喜爱哪种方法?欢迎留言评论。

别的还有一种相似stratch的完结方法:

挑战零代码:可视化逻辑编排

感觉这种朴实为了可视化而可视化,只合适小孩子做玩具。会写代码的人不愿意用,太低效了。不会写代码的人,需求了解代码才会用。合适场景是用直观的方法介绍什么是代码逻辑,就是说只合适相对比较低智力水平的编程教育,比方幼儿园、小学等。商业运用,就免了。

数据流驱动的逻辑编列

一个简略的比方

从现在开端,放下流程图,忘记strach,咱们从业务视点去思考也逻辑,然后规划元件节点去完结相应的逻辑。

选一个简略又典型的比方:学生成果单。一个成果单包括如下数据:

挑战零代码:可视化逻辑编排

假设数据现已从数据库取出来了,第一步处理,核算每个学生的总分数。规划这么几个元件节点来配合完结:

挑战零代码:可视化逻辑编排

这个编列,输入成果列表,循环输出每个学生的总成果。为了完结这个编列,规划了四个元件节点:

  • 循环,入端口接纳一个列表,遍历列表并循环输出,每一次遍历往“单次输出”端口发送一条数据,能够了解为一个学生方针(尽量从方针的视点思考,而不是数据记载),遍历完毕后往“完毕端口”发送循环的总数。假设依照上面的列表,“单次输出端口”会被调用4次,每次输出一个学生方针{姓名:xxx,语文:xxx,数学:xxx…},“完毕”端口只被调用一次,输出结果是 4.
  • 拆分方针,这个元件节点的出端口是能够动态装备的,它的功用是把一个方针依照特点值依照姓名分发到指定的出端口。本例中,就是把各科成果拆分开来。
  • 搜集数组,这个节点也能够叫搜集到数组,作用是把串行接纳到的数据组合到一个数组里。他有两个入端口:input端口,用来接纳串行输入,并缓存到数组;finished端口,表明输入完结,把缓存到的数据组发送给输出端口。
  • 加和,把输入端口传来的数组进行加和核算,输出总数。

这是一种跟代码彻底不同的思考方法,每一个元件节点,就是一小段业务逻辑,也就是所谓的业务逻辑组件化。咱们的项目中,只供给给了有限的预界说元件节点,想要更多的节点,能够自行自界说并注入体系,详细规划什么样的节点,彻底取决于用户的业务需求跟喜爱。作者更期望规划元件的进程是一个创造的进程,或许具有一定的艺术性。

刚刚的比方,审视之。有人或许会换一个方法来完结,比方拆分方针跟搜集数据这两个节点,合并成一个节点:方针转数组,或许更便利,适应能力也更强:

挑战零代码:可视化逻辑编排

方针转化数组节点,方针特点与数组索引的对应联络,能够经过特点面板的装备来完结。

这两种完结方法,说不清哪种更好,挑选自己喜爱的,或许两种都供给。

输入节点、输出节点

一段图形化的逻辑编列,经过解析引擎,会被转化成一段可履行的业务逻辑。这段业务逻辑需求跟外部对接,为了明确对接语义,再增加两个特别的节点元件:输入节点(开端节点),输出节点(完毕节点)。

挑战零代码:可视化逻辑编排

输入节点用于标识逻辑编列的进口,输入节点能够有一个或许多个,输入节点用细线圆圈表明。

输出节点用于标识逻辑编列的出口,输出节点能够有一个或许多个,输出节点用粗线圆圈表明。

在后边的引擎部分,会详细描绘输入跟输出节点怎样跟外部的对接。

编列的复用:子编列

一般低代码中,提升功率的方法是复用,尽或许复用已有的东西,比方组件、业务逻辑,然后达到降本、增效的意图。

规划元件节点是一种创造,那么运用元件节点进行业务编列,更是一种依据范畴的创造。辛辛苦苦创造的编列,假设能被复用,应该算是对创造自身的尊重吧。

假设编列能够像元件节点相同,被其它逻辑编列所引证,那么这样的复用方法无疑是最融洽的。也是最便利的完结方法。

把能够被其它编列引证的编列称为子编列,上面核算学生总成果的编列,转化成子编列,被引进时的形态应该是这样的:

挑战零代码:可视化逻辑编排

子编列元件的输入端口对应逻辑编列完结的输入节点,输出端口对应编列完结的输出节点。

嵌入式编列节点

前文规划的循环组件十分简略,循环直接履行究竟,不能被中止。可是,有的时分,在处理数据的时分,要依据每次遍历到的数据做判别,来决议持续循环仍是停止循环。

就是说,需求一个循环节点,能够自界说它的处理流程。依据这个需求,规划了自界说循环元件,这是一种能够嵌入编列的节点,方法如下:

挑战零代码:可视化逻辑编排

这种嵌入式编列节点,跟其它元件节点相同,事前界说好输入节点跟输出节点。仅仅它不彻底是黑盒,其间一部分经过逻辑编列这种白盒方法来完结。

这种场景并不多见,除了循环,后端运用中,还有业务元件也需求相似完结方法:

挑战零代码:可视化逻辑编排

嵌入式元件跟其它元件节点相同,能够被其它元件衔接,嵌入式节点在整个编列中的体现方法:

挑战零代码:可视化逻辑编排

底子概念

为了进一步深化逻辑编列引擎跟修正器的完结原理,先梳理一些底子的名词、概念。

逻辑编列,本文特指数据流驱动的逻辑编列,是由图形表明的一段业务逻辑,由元件节点跟连线组成。

元件节点,简称元件、节点、编列元件、编列单元。逻辑编列中详细的业务逻辑处理单元,带副作用的,能够完结数据转化、页面组件操作、数据库数据存取等功用。一个节点包括零个或多个输入端口,包括零个或多个输出端口。在规划其间,以圆角方形表明:

挑战零代码:可视化逻辑编排

端口,分为输入端口跟输出端口两种。是元件节点流入或流出数据的通道(或许接口)。在逻辑单元中,用小圆圈表明。

输入端口,简称入端口、进口。输入端口坐落元件节点的左侧。

输出端口,简称出端口、出口。输出端口坐落元件节点的右侧。

单进口元件,只要一个入端口的元件节点。

多进口元件,有多个入端口的元件节点。

单出口元件,只要一个出端口的元件节点。

多出口元件,有多个出端口的元件节点。

输入节点,一种特别的元件节点,用于描绘逻辑编列的起点(开端点)。转化成子编列后,会对应子编列相应的入端口。

输出节点,一种特别的元件节点,用于描绘逻辑编列的终点(完毕点)。转化成子编列后,会对应子编列相应的出端口。

嵌入式编列,特别的元件节点,内部完结由逻辑编列完结。示例:

挑战零代码:可视化逻辑编排

子编列,特别的逻辑编列,该编列能够转化成元件节点,供其它逻辑编列运用。

衔接线,简称连线、线。用来衔接各个元件节点,表明数据的流动联络。

界说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类的方针联动起来。这个完结是逻辑编列的中心,尽管完结代码只要几百行,可是很绕,需求静下心来好好研读接下来的部分。

编列引擎的规划

编列引擎类图

挑战零代码:可视化逻辑编排

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。

挑战零代码:可视化逻辑编排

留意,这是实例的联络,假设对应到类图,就是这样的联络:

挑战零代码:可视化逻辑编排

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也依照输入跟输出来分组:

挑战零代码:可视化逻辑编排

TypeScript界说的代码如下:

export interface IActivityJointers {
  //入端口对应的衔接器
  inputs: IJointer[];
  //处端口对应的衔接器
  outputs: IJointer[];
  //经过端口名获取出衔接器
  getOutput(name: string): IJointer | undefined
  //经过端口名获取入衔接器
  getInput(name: string): IJointer | undefined
}
//活动接口,一个实例对应编列图一个元件节点,用于完结元件节点的业务逻辑
export interface IActivity<ConfigMeta = unknown> {
  id: string;
  //衔接器,跟元件节点的端口异议对应
  jointers: IActivityJointers,
  //元件节点装备,每个Activity的装备都不相同,故而用泛型
  config?: ConfigMeta;
  //毁掉
  destory(): void;
}

入端口挂接业务逻辑

入端口对应一个Jointer,这个Jointer的衔接联络:

挑战零代码:可视化逻辑编排

逻辑引擎在解析编列图元件时,会给每一个元件端口创立一个Jointer实例:

 //结构Jointers
 for (const out of activityMeta.outPorts || []) {
   //出端口对应的Jointer
   activity.jointers.outputs.push(new Jointer(out.id, out.name))
 }
 for (const input of activityMeta.inPorts || []) {
   //入端口对应的Jointer
   activity.jointers.inputs.push(new Jointer(input.id, input.name))
 }

新创立的Jointer,它的下流是空的,就是说成员变量的outlets数组是空的,并没有挂接到实在的业务处理。要调用Jointer的connect方法,把Activity的处理函数作为下流衔接曩昔。

最早想到的完结方法是Acitvity有一个inputHandler方法,依据端口姓名分发数据到相应处理函数:

export interface IActivity<ConfigMeta = unknown> {
  id: string;
  //衔接器,跟元件节点的端口异议对应
  jointers: IActivityJointers,
  //元件节点装备,每个Activity的装备都不相同,故而用泛型
  config?: ConfigMeta;
  //进口处理函数
  inputHandler(portName:string, inputValue: unknown, context?:unknown):void
  //毁掉
  destory(): void;
}
export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  id: string;
  jointers: IActivityJointers;
  config?: SomeConfigMeta;
  constructor(public meta: INodeDefine<ConfigMeta>) {
    this.id = meta.id
    this.jointers = new ActivityJointers()
    this.config = meta.config;
  }
  //进口处理函数
  inputHandler(portName:string, inputValue: unknown, context?:unknown){
    switch(portName){
      case PORTNAME1:
        port1Handler(inputValue, context)
        break
      case PORTNAME2:
        ...
        break
      ...
    }
  }
  //端口1处理函数
  port1Handler = (inputValue: unknown, context?:unknown)=>{
    ...
  }
  destory = () => {
    //毁掉处理
    ...
  }
}

LogicFlow解析编列JSON,碰到SomeActivity对应的元件时,如下处理:

//创立SomeActivity实例
const someNode = new SomeActivity(meta)
 //结构Jointers
 for (const out of activityMeta.outPorts || []) {
   //出端口对应的Jointer
   activity.jointers.outputs.push(new Jointer(out.id, out.name))
 }
 for (const input of activityMeta.inPorts || []) {
   //入端口对应的Jointer
   const jointer = new Jointer(input.id, input.name)
   activity.jointers.inputs.push(jointer)
   //给进口对应的衔接器,挂接输入处理函数
   jointer.connect(someNode.inputHandler)
 }

业务逻辑挂接到出端口

进口处理函数,处理完数据今后,需求调用出端口衔接器的push方法,把数据分发出去:

挑战零代码:可视化逻辑编排

详细完结代码:

export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  jointers: IActivityJointers;
  ...
  //进口处理函数
  inputHandler(portName:string, inputValue: unknown, context?:unknown){
    switch(portName){
      case PORTNAME1:
        port1Handler(inputValue, context)
        break
      case PORTNAME2:
        ...
        break
      ...
    }
  }
  //端口1处理函数
  port1Handler = (inputValue: unknown, context?:unknown)=>{
    ...
    //处理后得到新的值:newInputValue 和新的context:newContext
    //把数据分发到相应出口
    this.jointers.getOutput(somePortName).push(newInputValue, newContext)
  }
  ...
}

入端口跟出端口,连贯起来,一个Activtity内部的流程就跑通了:

挑战零代码:可视化逻辑编排

出端口挂接其它元件节点

入端口相关的是Activity的自身处理函数,出端口相关的是外部处理函数,这些外部处理函数有能是其它衔接器(Jointer)的push方法,也或许来源于其它跟运用对接的部分。

假设是相关的是其他节点的Jointer,相相联络是经过逻辑编列图中的连线界说的。

挑战零代码:可视化逻辑编排

解析器先结构完一切的节点,然后遍历一遍连线,调用连线源Jointer的conect方法,参数是方针Jointer的push,就把相相联络构建起来了:

    for (const lineMeta of this.flowMeta.lines) {
      //先找起始节点,这个后边会详细介绍,现在能够先疏忽
      let sourceJointer = this.jointers.inputs.find(jointer => jointer.id === lineMeta.source.nodeId)
      if (!sourceJointer && lineMeta.source.portId) {
        sourceJointer = this.activities.find(reaction => reaction.id === lineMeta.source.nodeId)?.jointers?.outputs.find(output => output.id === lineMeta.source.portId)
      }
      if (!sourceJointer) {
        throw new Error("Can find source jointer")
      }
      //先找起停止点,这个后边会详细介绍,现在能够先疏忽
      let targetJointer = this.jointers.outputs.find(jointer => jointer.id === lineMeta.target.nodeId)
      if (!targetJointer && lineMeta.target.portId) {
        targetJointer = this.activities.find(reaction => reaction.id === lineMeta.target.nodeId)?.jointers?.inputs.find(input => input.id === lineMeta.target.portId)
      }
      if (!targetJointer) {
        throw new Error("Can find target jointer")
      }
      //重点重视这儿,把一条连线的首尾相连,结构起衔接联络
      sourceJointer.connect(targetJointer.push)
    }

特别的元件节点:开端节点、完毕节点

到现在为止,解析引擎部分,现已能够成功解析普通的元件并成功连线,可是一个编列的进口跟出口没有处理,对应的是编列图的输入节点(开端节点)跟输出节点(完毕节点)

挑战零代码:可视化逻辑编排

这两个节点,没有任何业务逻辑,仅仅辅助把外部输入,衔接到内部的元件;或许把内部的输出,发送给外部。所以,这两个节点,仅仅简略的Jointer就够了。

假设把一个逻辑编列看作一个元件节点:

挑战零代码:可视化逻辑编排

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

挑战零代码:可视化逻辑编排

引擎解析的时分,要依据开端元件节点跟完毕元件节点,构建LogicFlow的Jointer:

export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];
  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析节点
    this.constructActivities()
    ...
  }
  //构建一个图的一切节点
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
        case NodeType.Start:
          //start只要一个端口,或许会变成其它流程的端口,所以name慎重处理
          this.jointers.inputs.push(new Jointer(activityMeta.id, activityMeta.name || "input"));
          break;
        case NodeType.End:
          //end 只要一个端口,或许会变成其它流程的端口,所以name慎重处理
          this.jointers.outputs.push(new Jointer(activityMeta.id, activityMeta.name || "output"));
          break;
      }
      ...
    }
  }
}

经过这样的处理,一个逻辑编列就能够变成一个元件节点,被其他逻辑编列所引证,详细完结细节,本文后边再展开叙述。

依据元件节点创立Activity实例

在逻辑编列图中,一种类型的元件节点,在解析引擎中会对应一个完结了IActivity接口的类。比方,循环节点,对应LoopActivity;条件节点,对应ConditionActivity;调试节点,对应DebugActivity;拆分方针节点,对应SplitObjectActivity。

这些Activity要跟详细的元件节点建立一一对应联络,在DSL中以activityName作为相关纽带。这样解析引擎依据activityName查找相应的Activity类,并创立实例。

工厂方法

怎样找到并创立节点单元对应的Activity实例呢?最简略的完结方法,是给每个Activity类完结一个工厂方法,建立一个activityName跟工厂方法的映射map,解析引擎依据这个map实例化相应的Activity。简易代码:

//工厂方法的类型界说
export type ActivityFactory = (meta:ILogiFlowDefine)=>IActivity
//activityName跟工厂方法的映射map
export const activitiesMap:{[activityName:string]:ActivityFactory} = {}
export class LoopActivity implements IActivity{
  ...
  constructor(protected meta:ILogiFlowDefine){}
  inputHandler=(portName:string, inputValue:unknown, context:unknown)=>{
    if(portName === "input"){
      //逻辑处理
      ...
    }
  }
  ...
}
//LoopActivity的工厂方法
export const LoopActivityFactory:ActivityFactory = (meta:ILogiFlowDefine)=>{
  return new LoopActivity(meta)
}
//把工厂方法注册进map,跟循环节点的activityName对应好
activitiesMap["loop"] = LoopActivityFactory
//LogicFlow的解析代码
export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];
  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析节点
    this.constructActivities()
    ...
  }
  //构建一个图的一切节点
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
      	...
        case NodeType.Activity:
          //查找元件节点对应的ActivityFactory
          const activityFactory = activitiesMap[activityMeta.activityName]
          if(activityFactory){
            //创立Activity实例
            this.activities.push(activityFactory(activityMeta))
          }else{
            //提示过错
          }
          break;
      }
      ...
    }
  }
}

引进反射

正常状况下,上面的完结方法,现已够用了。可是,作为一款开放软件,会有大量的自界说Activity的需求。上面的完结方法,会让Activity的完结代码略显繁琐,而且一切的输入端口都要经过switch判别转发到相应处理函数。

咱们期望把这部分工作推到结构层做,让详细Activity的完结更简略。所以,引进了Typescipt的反射机制:注解。经过注解主动注册Activity类,经过注解直接相关端口与相应的处理函数,省去switch代码。

代码经过改造今后,就变成这样:

//经过注解注册LoopActivity类
@Activity("loop")
export class LoopActivity implements IActivity{
  ...
  constructor(protected meta:ILogiFlowDefine){}
  //经过注解把input端口跟该处理函数相关
  @Input("input")
  inputHandler=(inputValue:unknown, context:unknown)=>{
    //逻辑处理
    ...
  }
  ...
}
//LogicFlow的解析代码
export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];
  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析节点
    this.constructActivities()
    ...
  }
  //构建一个图的一切节点
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
      	...
        case NodeType.Activity:
          //依据反射拿到Activity的结构函数
          const activityContructor = ...//此处是反射代码
          if(activityContructor){
            //创立Activity实例
            this.activities.push(activityContructor(activityMeta))
          }else{
            //提示过错
          }
          break;
      }
      ...
    }
  }
}

LogicFlow是结构层代码,用户不需求关怀详细的完结细节。LoopActivity的代码完结,明显简练了不少。

Input注解承受一个参数作为端口称号,参数默许值是input。

还有一种节点,它的输入端口是不固定的,能够动态增加或许删去。比方:

挑战零代码:可视化逻辑编排

合并节点就是动态进口的节点,它的功用是接纳进口传来的数据,等一切数据到齐今后,合并成一个方针转发到输出端口。这个节点,有异步等待的功用。

为了处理这种节点,咱们引进新的注解DynamicInput。实践项目中合并节点Activity的完好完结:


import {
  AbstractActivity,
  Activity,
  DynamicInput
} from '@rxdrag/minions-runtime';
import { INodeDefine } from '@rxdrag/minions-schema';
@Activity(MergeActivity.NAME)
export class MergeActivity extends AbstractActivity<unknown> {
  public static NAME = 'system.merge';
  private noPassInputs: string[] = [];
  private values: { [key: string]: unknown } = {};
  constructor(meta: INodeDefine<unknown>) {
    super(meta);
    this.resetNoPassInputs();
  }
  @DynamicInput
  inputHandler = (inputName: string, inputValue: unknown) => {
    this.values[inputName] = inputValue;、
    //删掉现已收到数据的端口名
    this.noPassInputs = this.noPassInputs.filter(name=>name !== inputName)
    if (this.noPassInputs.length === 0) {
      //next方法,把数据转发到指定出口,第二个参数是端口名,默许值input
      this.next(this.values);
      this.resetNoPassInputs();
    }
  };
  resetNoPassInputs(){
    for (const input of this.meta.inPorts || []) {
      this.noPassInputs.push(input.name);
    }
  }
}

注解DynamicInput不需求绑定固定的端口,所以就不需求输入端口的称号。

子编列的解析

子编列就是一段完好的逻辑编列,跟普通的逻辑编列没有任何差异。仅仅它需求被其它编列引进,这个引进是经过附加一个Activity完结的。

export interface ISubLogicFLowConfig {
  logicFlowId?: string
}
export interface ISubMetasContext{
  subMetas:ILogicFlowDefine[]
}
@Activity(SubLogicFlowActivity.NAME)
export class SubLogicFlowActivity implements IActivity {
  public static NAME = "system-react.subLogicFlow"
  id: string;
  jointers: IActivityJointers;
  config?: ISubLogicFLowConfig;
  logicFlow?: LogicFlow;
  //context能够从引擎外部注入的,此处不必纠结它是怎样来的这个细节
  constructor(meta: INodeDefine<ISubLogicFLowConfig>, context: ISubMetasContext) {
    this.id = meta.id
    //经过装备中的LogicFlowId,查找子编列对应的JSON数据
    const defineMeta = context?.subMetas?.find(subMeta => subMeta.id === meta.config?.logicFlowId)
    if (defineMeta) {
      //解析逻辑编列,new LogicFlow 就是解析一段逻辑编列,也能够在别处被调用
      this.logicFlow = new LogicFlow(defineMeta, context)
      //把解析后的衔接器对应到本Activity
      this.jointers = this.logicFlow.jointers
    } else {
      throw new Error("No meta on sub logicflow")
    }
  }
  destory(): void {
    this.logicFlow?.destory();
    this.logicFlow = undefined;
  }
}

由于不需求把端口绑定到相应的处理函数,故该Activity并没有运用Input相重视解。

嵌入式编列的解析

逻辑编列中,最杂乱的部分,就是嵌入式编列的解析,期望小编能解说清楚。

再看一遍嵌入式编列的体现方法:

挑战零代码:可视化逻辑编排

这是自界说循环节点。尽管它端口直接跟内部的编列节点相连,可是实践上这种状况是无法直接调用new LogicFlow 来解析内部逻辑编列的,需求进行转化。引擎解析的时分,把会把上面的子编列重组成如下方法:

挑战零代码:可视化逻辑编排

首要,给子编列增加输入节点,称号跟ID别离对应自界说循环的入端口称号跟ID;增加输出节点,称号跟ID别离对应自界说循环的出端口称号跟ID。

然后,把一个图中的赤色数字标示的连线,替换成第二个图中蓝色数字标示的连线。

容器节点的端口,并不会跟转化后的输入节点或许输出节点直接衔接,而是在完结中依据业务逻辑当令调用,故用粗虚线表明。

自界说循环详细完结代码:

import { AbstractActivity, Activity, Input, LogicFlow } from "@rxdrag/minions-runtime";
import { INodeDefine } from "@rxdrag/minions-schema";
import _ from "lodash"
export interface IcustomizedLoopConifg {
  fromInput?: boolean,
  times?: number
}
@Activity(CustomizedLoop.NAME)
export class CustomizedLoop extends AbstractActivity<IcustomizedLoopConifg> {
  public static NAME = "system.customizedLoop"
  public static PORT_INPUT = "input"
  public static PORT_OUTPUT = "output"
  public static PORT_FINISHED = "finished"
  finished = false
  logicFlow?: LogicFlow;
  constructor(meta: INodeDefine<IcustomizedLoopConifg>) {
    super(meta)
    if (meta.children) {
      //经过portId相关子流程的开端跟完毕节点,端口号对应节点号
      //此处的children是被引擎转化过处理的
      this.logicFlow = new LogicFlow({ ...meta.children, id: meta.id }, undefined)
      //把子编列的出口,挂接到本地处理函数
      const outputPortMeta = this.meta.outPorts?.find(
        port=>port.name === CustomizedLoop.PORT_OUTPUT
      )
      if(outputPortMeta?.id){
        this.logicFlow?.jointers?.getOutput(outputPortMeta?.name)?.connect(
          this.oneOutputHandler
        )
      }else{
        console.error("No output port in CustomizedLoop")
      }
      const finishedPortMeta = this.meta.outPorts?.find(
        port=>port.name === CustomizedLoop.PORT_FINISHED
      )
      if(finishedPortMeta?.id){
        this.logicFlow?.jointers?.getOutput(finishedPortMeta?.id)?.connect(
          this.finisedHandler
        )
      }else{
        console.error("No finished port in CustomizedLoop")
      }
    } else {
      throw new Error("No implement on CustomizedLoop meta")
    }
  }
  @Input()
  inputHandler = (inputValue?: unknown, context?:unknown) => {
    let count = 0
    if (this.meta.config?.fromInput) {
      if (!_.isArray(inputValue)) {
        console.error("Loop input is not array")
      } else {
        for (const one of inputValue) {
          //转发输入到子编列
          this.getInput()?.push(one, context)
          count++
          //假设子编列调用了完毕
          if(this.finished){
            break
          }
        }
      }
    } else if (_.isNumber(this.meta.config?.times)) {
      for (let i = 0; i < (this.meta.config?.times || 0); i++) {
        //转发输入到子编列
        this.getInput()?.push(, context)
        count++
        //假设子编列调用了完毕
        if(this.finished){
          break
        }
      }
    }
    //假设子编列中还没有被调用过finished
    if(!this.finished){
      this.next(count, CustomizedLoop.PORT_FINISHED, context)
    }
  }
  getInput(){
    return this.logicFlow?.jointers?.getInput(CustomizedLoop.PORT_INPUT)
  }
  oneOutputHandler = (value: unknown, context?:unknown)=>{
    //输出到呼应端口
    this.output(value, context)
  }
  finisedHandler = (value: unknown, context?:unknown)=>{
    //标识已调用过finished
    this.finished = true
    //输出到呼应端口
    this.next(value, CustomizedLoop.PORT_FINISHED, context)
  }
  output = (value: unknown, context?:unknown) => {
    this.next(value, CustomizedLoop.PORT_OUTPUT, context)
  }
}

根底的逻辑编列引擎,底子悉数介绍完了,清楚了节点之间的编列机制,是时分界说节点的连线规矩了。

节点的连线规矩

一个节点,是一个方针。有状况,有副作用。有状况的方针没有束缚的互连,是十分风险的行为。

这种状况会面对一个诱惑,或许说用户自己也分不清楚。就是把节点当成无状况方针运用,或许直接以为节点就是无状况的,不加束缚的把连线连到某个节点的进口上。

比方上面核算学生总分比方,或许会被糊涂的用户连成这样:

挑战零代码:可视化逻辑编排

这种衔接方法,直接形成搜集数组节点无法正常工作。

逻辑编列之所以直观,在于它把每一个个数据流通的途径都展现出来了。在一个通路上的一个节点,最好只完结一个该通路的功用。另一个通路假设想完结相同的功用,最好再新建一个方针:

挑战零代码:可视化逻辑编排

这样两个搜集数组节点,就互不干扰了。

要完结这样的束缚,只需求加一个连线规矩:同一个入端口,只能连一条线

有了这条规矩,节点方针状况带来的晦气影响,底子消除了。

在这样的规矩下,搜集数组节点的进口不能衔接多条连线,只需求把它从头规划成如下方法:

挑战零代码:可视化逻辑编排

一个出端口,能够往外衔接多条连线,用于表明并行履行。另一条规矩就是:同一个出端口,能够有多条连线

数据是从左往右流动,所以再加上最终一条规矩:入端口在节点左侧,出端口在节点右侧

一切的连线规矩完结了,蛮简略的,修正器层面能够直接做束缚,防止用户输错。

修正器的完结

修正器布局

挑战零代码:可视化逻辑编排

整个修正器分为图中标示的四个区域。

  • ① 工具栏,修正器惯例操作,比方撤销、重做、删去等。
  • ② 工具箱(物料箱),寄存能够被拖放的元件物料,这些物料是能够从外部注入到修正器的。
  • ③ 画布区,制作逻辑编列图的画布。每个节点都有自己的坐标,要依据这个对DSL进行扩展,给节点附加坐标信息。画布依据阿里antv X6完结。
  • ④ 特点面板,修正元件节点的装备信息。物料是从修正器外部注入的,物料对应节点的装备是改变的,所以特点面板内的组件也是改变的,运用RxDrag的低代码烘托引擎来完结,外部注入的物料要写到相应的Schema信息。低代码Schema相关内容,请参阅另一篇文章《实战,一个高扩展、可视化低代码前端,详实、完好》

扩展DSL

前面界说的DSL用在逻辑编列解析引擎里,足够了。可是,在画布上展现,还缺少节点位置跟尺寸信息。规划器画布是依据X6完结的,要增加X6需求的信息,来扩展DSL:

export interface IX6NodeDefine {
  /** 节点x坐标 */
  x: number;
  /** 节点y坐标  */
  y: number;
  /** 节点宽度 */
  width: number;
  /** 节点高度 */
  height: number;
}
// 扩展后节点
export interface IActivityNode extends INodeDefine {
  x6Node?: IX6NodeDefine
}

这些信息,足以在画布上展现一个完好的逻辑编列图了。

元件物料界说

工具箱区域②跟画布区域③显现节点时,运用了共同的元素:元件图标,元件标题,图标色彩,这些能够放在物料的界说里。

物料还需求:元件对应的Acitvity姓名,特点面板④ 的装备Schema。详细界说:

import { NodeType, IPortDefine } from "./dsl";
//端口界说
export interface IPorts {
  //入端口
  inPorts?: IPortDefine[];
  //出端口
  outPorts?: IPortDefine[];
}
//元件节点的物料界说
export interface IActivityMaterial<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  //标题
  label: string;
  //节点类型,NodeType在DLS中界说,这儿依据activityType决议画上的图形款式
  activityType: NodeType;
  //图标代码,react的话,相当于React.ReactNode
  icon?: ComponentNode;
  //图标色彩
  color?: string;
  //特点面板装备,能够适配不同的低代码Schema,运用RxDrag的话,这能够是INodeSchema类型
  schema?: NodeSchema;
  //默许端口,元件节点的端口设置的默许值,大部分节点端口跟默许值是相同的,
  //部分动态装备端口,会依据装备有所改变
  defaultPorts?: IPorts;
  //画布中元件节点显现的子标题 
  subTitle?: (config?: Config, context?: MaterialContext) => string | undefined;
  //对应解析引擎里的Activity称号,依据这个姓名实例化相应的节点业务逻辑方针
  activityName: string;
}
//物料分类,用于在工具栏上,以手风琴风格分组物料
export interface ActivityMaterialCategory<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  //分类名
  name: string;
  //分类包括的物料
  materials: IActivityMaterial<ComponentNode, NodeSchema, Config, MaterialContext>[];
}

只要符合这个界说的物料,都是能够被注入规划器的。

在前面界说DSL的时分, INodeDefine 也有一个相同的特点是 activityName。没错,这两个activityName指代的方针是相同的。画布烘托dsl的时分,会依据activityName查找相应的物料,依据物料带着的信息展现,入图标、色彩、特点装备组件等。

在做前端物料跟元件的时分,为了重构便利,会把activityName以存在Activity的static变量里,物料界说直接引证,端口称号也是相似的处理。看一个最简略的节点,Debug节点的代码。

Activity代码:

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<IDebugConfig> {
  //对应INodeDeifne 跟IActivityMaterial的 activityName
  public static NAME = "system.debug"
  constructor(meta: INodeDefine<IDebugConfig>) {
    super(meta)
  }
  //进口处理函数
  @Input()
  inputHandler(inputValue: unknown): void {
    if (!this.config?.closed) {
      console.log(`${this.config?.tip || "Debug"}:`, inputValue)
    }
  }
}

物料代码:

import { createUuid } from "@rxdrag/shared";
import { debugSchema } from "./schema";
import { NodeType } from "@rxdrag/minions-schema";
import { Debug, IDebugConfig } from "@rxdrag/minions-activities"
import { debugIcon } from "../../icons";
import { DEFAULT_INPUT_NAME } from "@rxdrag/minions-runtime";
import { IRxDragActivityMaterial } from "../../interfaces";
//debug节点物料
export const debugMaterial: IRxDragActivityMaterial<IDebugConfig> = {
  //对应Activity的Name
  activityName: Debug.NAME,
  //Svg格局的表
  icon: debugIcon,
  //显现标题,工具栏直接多言语化后显现,画布上节点Title的初值是这个,能够经过
  //特点面板修正
  label: "$debug",
  //节点类型,普通的Activity
  activityType: NodeType.Activity,
  //图标色彩
  color: "orange",
  //默许端口
  defaultPorts: {
    inPorts: [
      {
        id: createUuid(),
        name: DEFAULT_INPUT_NAME,
        label: "",
      },
    ],
  },
  //子标题,显现装备用的tip
  subTitle: (config?: IDebugConfig) => {
    return config?.tip
  },
  //特点面板Schema
  schema: debugSchema,
}

特点面板schema的装备就不展开了,感兴趣的朋友请参阅Rxdrag的相关文章。该节点在修正器中的体现:

挑战零代码:可视化逻辑编排

修正器的状况办理

假设纯React运用的话,Recoil是不错的状况办理计划,梦想有一天或许会适配其它UI结构,就运用了对结构依靠较少的Redux作为状况办理工具。

修正器一切状况:

import { INodeDefine, ILineDefine } from "@rxdrag/minions-schema";
//操作快照
export interface ISnapshot {
  //悉数节点
  nodes: INodeDefine<unknown>[];
  //悉数连线
  lines: ILineDefine[];
  //当时选中元素
  selected?: string,
}
//修正器状况
export interface IState {
  //是否被修正,该标识用于提示是否需求保存
  changeFlag: number,
  //撤销快照列表
  undoList: ISnapshot[],
  //重做快照列表
  redoList: ISnapshot[],
  //悉数节点
  nodes: INodeDefine<unknown>[];
  //悉数连线
  lines: ILineDefine[];
  //当时选中元素
  selected?: string,
  //画布缩放数值
  zoom: number,
  //是否显现小地图
  showMap: boolean,
}

修正器就是环绕这份状况数据做功。建一个独自的类用来操作redux store:

//一个用来操作Redux状况数据的类,能够修正数据,也能够订阅数据的改变
export class EditorStore {
  store: Store<IState>
  constructor(debugMode?: boolean,) {
    this.store = makeStoreInstance(debugMode || false)
  }
  dispatch = (action: Action) => {
    this.store.dispatch(action)
  }
  //节省篇幅,本类不展开了,详细代码青岛rxdrag代码库检查
  //地址:https://github.com/codebdy/rxdrag/blob/master/packages/minions/editor/logicflow-editor/src/classes/EditorStore.ts
  ...
}

修正器接口

修正器被规划成一个React Library,便利其它项意图引证。其接口界说如下:

//主题色彩接口
export interface IThemeToken {
  colorBorder?: string;
  colorBgContainer?: string;
  colorText?: string;
  colorTextSecondary?: string;
  colorBgBase?: string;
  colorPrimary?: string;
}
//逻辑编列修正器特点
export type LogicFlowEditorProps = {
  value: ILogicMetas,
  onChange?: (value: ILogicMetas) => void,
  //修正器支撑的一切物料
  materialCategories: ActivityMaterialCategory<ReactNode>[],
  //特点面板用的的空间
  setters?: IComponents,
  logicFlowContext?: unknown,
  //能够被引证的子编列
  canBeReferencedLogflowMetas?: ILogicFlowDefine[],
  //工具栏,false表明躲藏
  toolbar?: false | React.ReactNode,
}
//逻辑编列修正器对应的React组件
export const LogicMetaEditor = memo((
  props: LogicFlowEditorAntd5rProps&{
    token: IThemeToken,
  }
) => {
  ...
}}

深度集成的考量

修正器有时分要跟其它修正器,比方后端的范畴模型修正器深度集成,共享一套撤销、重做、删去按钮。比方:

挑战零代码:可视化逻辑编排

这种状况要经过给toolbar特点传入false,把修正器原本的工具条躲藏掉。别的,要跟范畴模型修正器共用一个工具栏区域,需求在修正器能在外部操控Redux store里面的内容。

为了完结这样的集成,增加一个上下文,用于下发一个大局的EditorStore。界说一个标签,叫LogicFlowEditorScope,用于限制逻辑编列修正器的规模,只要是在标签内部,store数据能够随意修正。

import { memo, useMemo } from "react"
import { LogicFlowEditorStoreContext } from "../contexts";
import { EditorStore } from "../classes";
import { useEditorStore } from "../hooks";
//用于创立大局EditorStore,并经过Context下发
const ScopeInner = memo((props: {
  children?: React.ReactNode
}) => {
  const { children } = props;
  const store: EditorStore = useMemo(() => {
    return new EditorStore()
  }, [])
  return (
    <LogicFlowEditorStoreContext.Provider value={store}>
        {children}
    </LogicFlowEditorStoreContext.Provider>
  )
})
//修正器Scope界说
export const LogicFlowEditorScope = memo((
  props: {
    children?: React.ReactNode
  }
) => {
  const { children } = props;
  //去外层Store
  const parentStore = useEditorStore()
  return (
    //假设外层现已创立Scope,那么直接用外层的,反之新建一个
    parentStore ?
      <>{children}</>
      :
      <ScopeInner>
        {children}
      </ScopeInner>
  )
})

这个LogicFlowEditorScope会在逻辑修正器的根部放置一个,假设修正器外部没有界说,就是用这个默许的。假设外部现已界说了,那么就用外部的。

范畴模型修正器集成的时分,只要在工具栏外层放置一个LogicFlowEditorScope,就能够便利的操作修正器里的内容了:

import { memo } from "react"
import { UmlEditorInner, UmlEditorProps } from "./UmlEditorInner"
import { RecoilRoot } from "recoil"
import { LogicFlowEditorScope } from "@rxdrag/minions-logicflow-editor"
//范畴模型UML修正器部分
export const UmlEditor = memo((props: UmlEditorProps) => {
  return <RecoilRoot>
    //外层放置逻辑编列的Scope
    <LogicFlowEditorScope>
      <UmlEditorInner {...props} />
    </LogicFlowEditorScope>
  </RecoilRoot>
})
...
//创立逻辑编列修正起的时分,toolbar赋值false
   <LogicFlowEditorAntd5
     materialCategories={activityMaterialCategories}
     locales={activityMaterialLocales}
     token={token}
     value={value?.logicMetas || EmpertyLogic}
     logicFlowContext={logicFlowContext}
     onChange={handleChange}
     setters={{
       SubLogicFlowSelect,
     }}
     canBeReferencedLogflowMetas={canBeReferencedLogflowMetas}
     //躲藏默许工具栏
     toolbar={false}
   />
  ...

前端逻辑编列

前端逻辑编列首要编列的内容是组件的联动,组件数据的填充以及服务端数据的获取及存储等内容。这些内容,就是一般被称作业务逻辑的内容。

通常的完结方法中,这些业务逻辑会与ui组件紧密结合,乃至许多被写在了组件内部。想让逻辑编列的适应能力更强,最好能够把这些内容从组件中剥离出来,形成独立的业务层,尽或许压缩UI层的厚度,UI层就像美人,瘦的总是比胖的好看些,假设不认同的话欢迎留言评论。

像React这样的结构,组件有自己的生命周期办理,在低代码项目中,假设业务逻辑跟组件搅在一同,业务逻辑就跟组件的生命周期也搅在一同了,处理这样的代码,是十分痛苦的进程。

所以,有人喜爱mobx,能够不必过度重视React的生命周期。

mobx是把双刃剑,有有点也有缺陷。假设用mobx做低代码渠道,底子不好兼容现有的组件库,一切组件都要从头封装一层,就像formily做的那样。这样的方法不能说不好,仅仅小编不喜爱。就像姑娘,自己不喜爱的姑娘照样许多人抢,自己朝思暮想的姑娘,别人或许敬而远之。或许,这就是生活吧。百花齐放的国际里,能够肆意挑选的感觉挺好。

组件操控器

想要ui层变瘦,就不要在组件内部加太多的业务逻辑,仅仅经过组件自身的props操控组件的行为,只需在组件外层加一个操控器,来操控组件的props就好。

挑战零代码:可视化逻辑编排

参加操控器今后,组件之间的交互联动,就变成了操控器之间的交互了,而且能够经过逻辑编列来编列这些操控器。

不同的完结方法,有不同的优点跟缺陷。逻辑编列也相同,他或许并不合适一切的业务逻辑,对于CRUD这样简略的业务,逻辑编列反而显得粗笨了。

不需求逻辑编列的场景,或许需求给组件装备一个其它的操控器。所以,组件操控器能够遵从相同的接口,而且能够有不同的完结:

挑战零代码:可视化逻辑编排

用了不少承继,这儿也没必要争论该用承继仍是该用组合,代码量不大,习惯了这样写。看不惯的朋友,能够用组合再完结一遍。

操控器IController接口完结了两个接口:特点操控器(IPropsController)跟变量操控器(IVariableController)。特点操控器用来办理组件的特点,这儿的特点仅仅数值类型的特点,不包括函数类型的特点(也就事情),事情需求独自处理。变量操控器用来办理操控器的自界说变量,有点相似类的成员变量。也是不同操控器之间沟通数据的重要手段。

不管是特点操控器仍是变量操控器,都完结了相应的订阅(subscribe)方法,用于监听其改变,有点mobx之类的Proxy感觉,操控器监听到了Props的改变,会经过自身的subscribeToPropsChange方法发布出去,用于更新组件。要留意的是,特点操控器(IPropsController)的订阅方法订阅的是单个特点的改变,而这儿订阅的是悉数特点的改变,这儿完结的内部运用了特点操控器(IPropsController)的subscribeToPropChange方法。

AbstractController是一切Controller的基类,封装了操控器的通用逻辑。

下面的三个操控器别离是:

  • 逻辑编列操控器(LogicFlowController),望文生义,用逻辑编列完结操控器的业务逻辑。这是结构内置操控器,
  • 脚本操控器(ScriptController),用JS脚本完结操控器的业务逻辑。这也是结构内置操控器。
  • 简略操控器(SimpleController),本操控器不是结构内置的,属于自界说操控器,放在了代码的Expamle部分(代码中或许叫ShortcutController)。由于有些简略的CRUD操作,几不需求编列操控器,也不需求脚本操控器,就界说这些简易操控器。还能够依据需求界说其它操控器,并把这些操控器注入到修正器跟解析引擎。

操控器在Page Schema中的装备

前端逻辑编列的运用场景是低代码,低代码渠道中,用DSL(通常是JSON Schema)来描绘一个页面。这儿以RxDrag的Schema界说为例,介绍操控器的装备,逻辑编列部分能够独立于Rxdrag运转,您能够用相似的方法整合到其它低码渠道的Schema中。

RxDrag中组件元数据的界说:

export interface INodeMeta<
  IField = unknown,
  INodeController = unknown
> {
  componentName: string;
  props?: {
    [key: string]: unknown;
  };
  'x-field'?: IField;
  //节点操控器,逻辑编列用
  'x-controller'?: INodeController;
  //锁定子控件
  locked?: boolean;
  //自己烘托,引擎不烘托
  selfRender?: boolean;
}

泛型特点x-controller是操控器的DSL,在咱们这儿给它这样一个界说:

import { ILogicFlowDefine } from "@rxdrag/minions-schema";
//操控器变量界说
export interface IVariableDefineMeta {
  //变量标识
  id: string;
  //变量称号
  name: string;
  //变量默许值
  defaultValue?: unknown;
}
//操控器元数据界说,相当于操控器装备的DSL
export interface IControllerMeta {
  //操控器标识
  id: string;
  //操控器类型,由于操控器能够注入许多种,类型不固定,这儿不能用枚举,只能用字符串
  controllerType?: string;
  //是否大局,装备操控器的可见规模
  global?: boolean;
  //操控器称号
  name?: string;
}
//逻辑编列操控器
export interface ILogicFlowControllerMeta extends IControllerMeta {
  //组件事情对应的逻辑编列,经过name与组件的事情建立联络
  events?: ILogicFlowDefine[];
  //操控器的交互,相当于子编列,能够被其他编列调用
  reactions?: ILogicFlowDefine[];
  //操控器的变量
  variables?: IVariableDefineMeta[];
}
//脚本操控器
export interface IScriptControllerMeta extends IControllerMeta {
  //脚本代码
  script?: string
}

只给出了逻辑编列操控器跟脚本操控器DSL的界说,简易操控器跟结构完结联络不大,属于自界说操控器,这儿就不深化展开了。

操控器与组件的绑定

低代码一般两种方法烘托页面:1、有一个烘托引擎,烘托页面DSL(通常是JSON);2、生成前端代码。

作者自己的低代码渠道还没做出码,这儿只评论第一种状况。

低代码烘托引擎以组件节点为单位,递归烘托页面Schema:依据componentName拿到组件完结函数,并烘托。当烘托引擎解析到字段x-controller时,就在外面套一个高阶组件withController,在这个高阶组件里完结操控器与方针组件的绑定。

烘托引擎相关代码:

export const ComponentView = memo((
  props: ComponentViewProps
) => {
  const { node, ...other } = props
  //拿到组件界说函数
  const com = usePreviewComponent(node.componentName)
  //依据需求包装组件
  const Component = useMemo(() => {
    return com && 
      withController(//经过高阶组件,绑定操控器与方针组件
        com,
        node["x-controller"] as ILogicFlowControllerMeta,
        node.id,
      )
  }, [com, node]);
  ...
  return (
    Component &&
    (
      node.children?.length ?
        <Component {...node.props} {...other}>
          {
            !node.selfRender && node.children?.map(child => {
              return (<ComponentView key={child.id} node={child} />)
            })
          }
        </Component>
        : <Component {...node.props}  {...other} />
    )
  )
})

高阶组件内部的绑定代码:

export function withController(WrappedComponent: ReactComponent, meta: IControllerMeta | undefined, schemaId: string): ReactComponent {
  if (!meta?.id || !meta?.controllerType) {
    return WrappedComponent
  }
  return memo((props: any) => {
    const [changedProps, setChangeProps] = useState<any>()
    const [controller, setController] = useState<IController>()
    //运转时引擎,经过它来创立操控器
    const runtimeEngine = useRuntimeEngine();
    //拿到controller对应的仅有标识
    const controllerKey = useControllerKey(meta, schemaId)
    const handlePropsChange = useCallback((name: string, value: any) => {
      setChangeProps((changedProps: any) => {
        return ({ ...changedProps, [name]: value })
      })
    }, [])
    useEffect(() => {
      if (meta?.controllerType && runtimeEngine && controllerKey) {
        //创立操控器
        const ctrl = runtimeEngine.getOrCreateController(meta, controllerKey)
        //初始化
        ctrl.init(controllers, logicFlowContext);
        //订阅特点改变
        const unlistener = ctrl?.subscribeToPropsChange(handlePropsChange)
        setController(ctrl)
        return () => {
          ctrl?.destory()
          unlistener?.()
        }
      }
    }, [controllerKey, handlePropsChange, runtimeEngine])
    const newProps = useMemo(() => {
      //组装最新的Props,留意,组件的事情也在这儿完结的绑定
      return { ...props, ...controller?.events, ...changedProps }
    }, [changedProps, controller?.events, props])
    return (
      controller
        //经过上下文下发Controller,这样能够在组件内拿到Controller
        //仅仅应对特例,由于只要极少状况需求在组件内部调用Controller
        ? <ControllerContext.Provider value={controller}>
            //最新的Props传入方针组件
            <WrappedComponent {...newProps} />
          </ControllerContext.Provider>
        : <>Can not creat controller </>
    )
  })

操控器的可见规模

上面只完结了一个操控器,而且这个操控器是孤立的,并没有跟其他操控器发生联络。想要发生联络,操控器就不能是孤立的,需求相互认识(一方知道另一方的引证,或许一方知道另一方的查找方法)。

让操控器相互知道的方法有:

1、悉数操控器注册到一个大局变量里面,一切操控器都能拜访这个变量;

挑战零代码:可视化逻辑编排

2、经过Context下发操控器,子组件的操控器能拜访一切的父组件的操控器,父组件的操控器拜访不了子控件的操控器。

挑战零代码:可视化逻辑编排

3、前两种方法的组合,默许经过第二种方法传递操控器,假设操控器在规划其间被装备为大局,则依照第一种方法传递。

第一种方法现已很直观了,为什么还要考虑第二种跟第三种?或许说压根遗忘第二种,直接用第一种。

答案是低代码渠道的组件规划是否支撑,就是跟低代码渠道的组件规划理念相关。作者本人是业余选手,项目经历不多,不知道哪种计划更合理。就把或许的状况罗列一下,有观念朋友欢迎留言给点定见,不胜感激。

原子化组件规划

不同的低代码渠道,组件的规划粒度是不相同的。作者自己的的低码渠道,发起的是原子化的组件规划,许多组件的规划粒度很细。这种状况下,不或许每个组件都附加一个操控器,许多组件仅仅用来调整页面布局,底子不需求操控器。

所以,在前端装备页面,加了操控器挑选组件,用于指示给组件启用哪种类型的操控器:

挑战零代码:可视化逻辑编排

这个单选按钮组,是能够不选的,不选时意味着不需求给组件装备操控器。

原子化组件规划,组件的粒度很细,细到列表也是有各种组件组合而成,列表内的组件是能够随意拖入的,比方下面这个列表行中的“修正”、“删去”按钮:

挑战零代码:可视化逻辑编排

这些行组件是依据数据行记载而动态创立的,原子化的规划对这些列表没有太多的封装,所以无法从大局拿到这些动态创立组件的操控器。

在这种状况下,经过context下发操控器是个不错的挑选,一切子组件的操控都能够拜访父组件操控器。部分需求大局共享的操控器,就在界面中装备为大局。这两种方法结合,底子能够满足操控器之间的交互需求。

高阶组件withController的完结代码变成:

import { ReactComponent } from "@rxdrag/react-shared"
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { ControllerContext, ControllersContext } from "../contexts"
import { useControllers } from "../hooks/useControllers"
import { Controllers, IController, ILogicFlowControllerMeta as IControllerMeta } from "@rxdrag/minions-runtime-react"
import { useLogicFlowContext } from "../hooks/useLogicFlowContext"
import { useRuntimeEngine } from "../hooks/useRuntimeEngine"
import { useControllerKey } from "../hooks/useControllerKey"
export function withController(WrappedComponent: ReactComponent, meta: IControllerMeta | undefined, schemaId: string): ReactComponent {
  if (!meta?.id || !meta?.controllerType) {
    return WrappedComponent
  }
  return memo((props: any) => {
    //发生改变的props
    const [changedProps, setChangeProps] = useState<any>()
    //组件自身的操控器
    const [controller, setController] = useState<IController>()
    //一切上级操控器+大局操控器
    const controllers = useControllers()
    //办理操控器的运转时引擎
    const runtimeEngine = useRuntimeEngine();
    //操控器仅有标识,依据层级联络跟操控器自身ID组合产生,用于仅有标识一个操控器
    const controllerKey = useControllerKey(meta, schemaId)
    //处理操控器中的props改变
    const handlePropsChange = useCallback((name: string, value: any) => {
      setChangeProps((changedProps: any) => {
        return ({ ...changedProps, [name]: value })
      })
    }, [])
    useEffect(() => {
      if (meta?.controllerType && runtimeEngine && controllerKey) {
        //给组件创立操控器
        const ctrl = runtimeEngine.getOrCreateController(meta, controllerKey)
        //初始化操控器
        ctrl.init(controllers);
        //监听props改变
        const unlistener = ctrl?.subscribeToPropsChange(handlePropsChange)
        setController(ctrl)
        return () => {
          ctrl?.destory()
          unlistener?.()
        }
      }
    }, [controllerKey, controllers, handlePropsChange, runtimeEngine])
    //把该组件可见的操控器打包成一个数组
    const newControllers: Controllers = useMemo(() => {
      return controller ? { ...controllers, [controller.id]: controller } : controllers
    }, [controller, controllers])
    //最新的props
    const newProps = useMemo(() => {
      return { ...props, ...controller?.events, ...changedProps }
    }, [changedProps, controller?.events, props])
    return (
      controller
        ? <ControllersContext.Provider value={newControllers}>
          <ControllerContext.Provider value={controller}>
            <WrappedComponent {...newProps} />
          </ControllerContext.Provider>
        </ControllersContext.Provider>
        : <>Can not creat controller </>
    )
  })
}

这种完结方法其完结已足够灵敏了,能够应对几乎常见的需求,可是,比起后边的粗粒度组件规划,用户体会确实要差一些,用户需求了解的概念有点多,需求装备的东西也有点多。

当然,实践的项目中,从列表外面拜访列表行内组件的场景几乎没有,所以只要把外围的组件操控器放入大局,供列表内组件运用,也是能够的。这样,就不需求context了,可是这样应对不了列表套列表的状况。或许列表套列表的状况不多吧。

小编也想用户体会更好些,可是做起来就不由自主的统筹了更多的灵敏性,或许把最终用户当成自己了吧。后边或许需求更多的在项目中锻炼自己,有跟作者方向共同的朋友,欢迎联络作者,咱们能够一同做一些项目相互学习、共同成长。

粗粒度组件规划

粗粒度组件的规划,会带来十分杰出的用户体会。

这是跟原子化组件彻底不同的规划理念,每个组件的功用比较多,一个页面仅需求十分少的组件就能完结。

已然组件较少,每个组件装备一个操控器(或许相似操控器的东西),把这些操控器设置为大局,逻辑编列的时分,这些大局操控器能够相互调用,直观便利利。

粗粒度的组件,相同会有列表,列表要怎样处理呢?

能够把列表做成功用全面的组件,行内组件以卡槽的方法刺进列表。这种状况下,天然的会假设没有编列列表套列表这样的变态需求。

逻辑编列修正器规划的时分,能够直接从物料箱(工具箱)挑选组件操控器,进行编列。

在上文中,咱们只界说了根底的IController接口,完结了根底的笼统类AbstractController。AbstractController下面派生出来的子类,不管是逻辑编列操控器仍是脚本操控器,都没有完结太多的业务逻辑,而是把业务逻辑留给了逻辑编列或许脚原本完结。

已然组件的粒度变粗了,必定是包括了某些业务逻辑。能够把这些逻辑从头还给组件,让每个组件或许组件的操控器包括自己的业务逻辑。

粗粒度组件更友爱的编列方法

操控器,是软件规划完结层面的东西,最终用户或许不需求关怀这样的完结细节。操控器这个概念的增加,无疑会增加用户的了解成本。对用户来讲,最直观的了解方针是组件。

在逻辑编列中,假设以组件而不是操控器为编列方针,会愈加有利于用户的了解,比方:

挑战零代码:可视化逻辑编排

图中对话框的这个元件节点,是不是更直观,更简略了解?

在咱们现已规划的架构体系里,能完结这样的效果?

当然能够,而且能够持续用操控器(通用操控器或许特别操控器都行),参加有个通用操控器叫DefaultController,它是AbstractConroller的一个子类,完结了常用的操控器功用。能够为对话框组件,定制一个独自的元件节点,节点保有组件操控器DefaultController的一个引证就能够:

挑战零代码:可视化逻辑编排

给DialgoActivity装备上相应物料,就能够以组件的面貌参加逻辑编列了,尽管内部依据操控器IController完结,但用户是感知不到IController存在的。元件物料界说:

//上下文中的操控器参数
export interface IControllerEditorContextParam {
  //一切能拜访的操控器
  controllers?: ILogicFlowControllerMeta[],
  //当时组件操控器
  controller?: ILogicFlowControllerMeta,
}
export const dialogMaterial: IRxDragActivityMaterial<IPropConfig, IControllerEditorContextParam> = {
  icon: dialogIcon,
  label: "对话框",
  activityType: NodeType.Activity,
  defaultPorts: {
    inPorts: [
      {
        id: createUuid(),
        name: DialogAtivity.PORT_OPEN,
        label: "打开",
      },
    ],
    outPorts: [
      {
        id: createUuid(),
        name: DialogAtivity.PORT_CLOSE,
        label: "封闭",
      },
    ],
  },
  //特点面板Schema
  schema: dialogSchema,
  //副标题显现详细哪个对话框
  subTitle: (config?: IPropConfig, context?: IControllerEditorContextParam) => {
    const controllerName = context?.controllers?.find(controler => controler.id === config?.param?.controllerId)?.name
    return controllerName ? (controllerName + "/" + (config?.param?.prop || "")) : ""
  },
  activityName: DialogAtivity.NAME,
}

DialogActivity的界说大致如下(作者没做粗粒度组件,故以下代码不来自实在代码,仅仅示意):

export interface IControllerContext {
  controllers: Controllers,
}
export interface IControllerParam {
  controllerId?: string
}
export interface IDialogConfig {
  param?: IControllerParam
}
@Activity(DialogActivity.NAME)
export class DialogActivity extends AbstractActivity<IDialogConfig> {
  public static NAME = "dialog"
  public static PORT_OPEN = "open"
  public static PORT_CLOSE = "close"
  //组件操控器
  controller: IController
  constructor(meta: INodeDefine<IDialogConfig>, context?: IControllerContext) {
    super(meta, context)
    if (!meta.config?.param?.controllerId) {
      throw new Error("ReadProp not set controller id")
    }
    const controller = context?.controllers?.[meta.config?.param?.controllerId]
    if (!controller) {
      throw new Error("Can not find controller")
    }
    this.controller = controller
  }
  @Input(DialogActivity.PORT_OPEN)
  openHandler = () => {
    this.controller.setProp("open", true)
  }
  @Input(DialogActivity.PORT_CLOSE)
  closeHandler = () => {
    this.controller.setProp("close", true)
  }
}

朋友,读到这儿,业务逻辑跟ui层解耦的魅力,您体会到了吗?

前端逻辑编列修正器

Rxdrag项目中,前端修正器的项目构成:

挑战零代码:可视化逻辑编排

整个逻辑编列功用分为两个顶层包:

  • minions,包括逻辑编列运转时、规划器跟DSL界说(schema),本包不依靠antd,就是说假设你的项目不想引进antd或许说antd的版本跟作者不相同,能够运用这个包,但不能运用下面另一个包。
  • minions-antd5,跟antd5相关的部分,悉数都在这个包里,这个包大部分都是规划器相关的东西,运转时相关的东西仅包括Activity的界说。

详细每个包的详细解说:

minions
  -editor 修正器相关,或许会依靠runtime包。
      -controller-editor 操控器编列修正器。包括了对组件操控器的编列,首要用于前端,依据下面的logicflow-editor完结。
      -logicflow-editor 最根底的逻辑编列修正器,不包括前端操控器编列相关内容。后端编列修正器能够依据这个完结。
  -runtime 运转时,逻辑编列的解析引擎相关,不依靠于editor包的任何东西。
      -activities 预界说的Activities,也就是元件节点的完结逻辑。比方循环、条件等
      -runtime-core 逻辑编列解析引擎中心包,react无关。
      -runtime-react 逻辑编列解析引擎react相关部分,包括操控器相关内容。
minions-antd5
  -controller-editor-antd5 操控器编列修正器,依靠antd5,依靠logicflow-editor-antd5
  -logicflow-editor-antd5 普通编列修正器,依靠antd5
  -minions-react-antd5-activites antd5相关的Activities
  -minions-react-materials 物料界说,这些物料的完结依靠antd5和rxdrag低代码引擎部分。

详细代码完结内容不少,感兴趣的话自行翻代码库看看吧,篇幅所限,无法进一步展开了。

操控器的注入

本节内容仅仅针对原子化组件低代码渠道的,并未考虑粗粒度组件,粗粒度组件不需求注入操控器,只需求运用DefaultController就能够。

低代码渠道中,逻辑编列的装备是以特点装备组件的方法呈现的:

挑战零代码:可视化逻辑编排

这些操控器是能够注入的,这个跟详细低代码渠道的完结机制有关,这儿仅仅提一下思路,底子文主题联络不大,就不详细展开了。

后端逻辑编列

后端逻辑编列的规划器运用的是logicflow-editor-antd5这个包,修正器部分的完结跟前端逻辑编列底子共同。本节首要评论后端解析引擎的完结。

挑战零代码:可视化逻辑编排

前端界说元件节点物料,把物料注入逻辑编列修正器。后端界说元件完结逻辑(Activity),把Activity注入后端编列解析引擎。节点物料依靠Activity,经过activityName相关。

逻辑编列修正器生成产物是JSON格局的编列描绘数据,后端引擎消费这个JSON,转化成详细的履行逻辑。

逻辑编列解析引擎的代码量并不大,相当于把前面评论的Typescript完结转译为一份golang的完结。项目代码结构:

挑战零代码:可视化逻辑编排

这是一个golang library,能够在其他golang项目中被引证,作者在自己的低代码渠道中,运用了这个库。代码结构关键部分:

  • activities,预界说元件的完结逻辑。
  • dsl,就是上面Typescript界说的那份DSL,这儿转译成Golang。
  • example,该库运用比方,后边会完结,现在还没有。
  • runtime,逻辑编列解析引擎。

DSL转译

//端口界说
type PortDefine struct {
	//ID
	Id    string `json:"id"`
	//称号
	Name  string `json:"name"`
	//标题
	Label string `json:"label"`
}
//线的衔接点
type PortRef struct {
	//节点ID
	NodeId string `json:"nodeId"`
	//端口ID
	PortId string `json:"portId"`
}
//连线界说
type LineDefine struct {
	Id string `json:"id"`
	//源节点
	Source PortRef `json:"source"`
	//方针节点
	Target PortRef `json:"target"`
}
//节点界说
type NodeDefine struct {
	Id           string                 `json:"id"`
	//称号
	Name         string                 `json:"name"` //嵌入编列,端口转化成子节点时运用
	//节点类型,对应Typescript的枚举
	Type         string                 `json:"type"`
	//元件对应Activity称号
	ActivityName string                 `json:"activityName"`
	//标题
	Label        string                 `json:"label"`
	//装备
	Config       map[string]interface{} `json:"config"`
	//入端口
	InPorts      []PortDefine           `json:"inPorts"`
	//出端口
	OutPorts     []PortDefine           `json:"outPorts"`
	//子节点,嵌入式节点用,比方自界说循环节点、业务节点
	Children     LogicFlowMeta          `json:"children"`
}
//一段逻辑编列
type LogicFlowMeta struct {
	//一切节点
	Nodes []NodeDefine `json:"nodes"`
	//一切连线
	Lines []LineDefine `json:"lines"`
}
//子编列,能够被其它编列调用
type SubLogicFlowMeta struct {
    //组合一段编列数据
	LogicFlowMeta
    //用于调用寻址
	Id string
}

就是简略界说,没有什么需求特别解说的。

Activity的完结

在前端编列引擎的完结中,咱们用了放射中的注解来搜集Ativity跟它的端口处理函数。golang中并没有注解,而且也没有方法经过Struct的姓名拿到Struct。这部分,就不得不做一些变通的处理。

第一次尝试,充分利用泛型,运用工厂方法,在runtime模块界说一个注册工厂方法的函数:

//注册工厂方法,用于创立Activity实例
func RegisterActivity(name string, factory interface{}) {
	activitiesMap.Store(name, factory)
}
//工厂方法的泛型
func NewActivity[Config any, T Activity[Config]](meta *dsl.ActivityDefine) *T {
	var activity T
	activity.GetBaseActivity().Init(meta)
	return &activity
}

Activity相应的完结方法:

type DebugConfig struct {
	Tip    string `json:"tip"`
	Closed bool   `json:"closed"`
}
type DeubugActivity struct {
	BaseActivity runtime.BaseActivity[DebugConfig]
}
func init() {
    //注册工厂函数
	runtime.RegisterActivity(
		"debug",
        //把泛型界说的工厂方法实例化为详细方法
		runtime.NewActivity[DebugConfig, DeubugActivity],
	)
}
func (d* DeubugActivity) GetBaseActivity() *runtime.BaseActivity[DebugConfig] {
	return &d.BaseActivity
}

这个完结方法能够正常运转,而且引擎代码相对简略。可是,完结一个Activity的代码看起来有些杂乱,增加了用户的心智担负,仍是期望Activity的界说能够更简略些。

最终,放弃了泛型,详细类型由引擎经过反射来识别,注册时只传入一个Activity实例,Activity的杂乱度,转移到了引擎内部:

type DebugConfig struct {
	Tip    string `json:"tip"`
	Closed bool   `json:"closed"`
}
type DebugActivity struct {
	Activity runtime.Activity[DebugConfig]
}
func init() {
	runtime.RegisterActivity(
		"debug",
		DebugActivity{},
	)
}

是不是看起来简略多了?

端口与端口处理函数的绑定

golang没有注解,不能像Typescript那样,经过注解把端口跟端口处理函数相关起来。可是golang有反射,经过一个变量,能够拿到变量的类型,而且创立另一个同类型的实例。经过方法称号,能够拿到并调用这个实例上的方法。所以,不需求注解,能够直接经过端口姓名调用对应端口处理函数,只要端口姓名跟端口处理函数的姓名共同(首字母大小写不灵敏),这些都是在结构内完结的。

上面Debug对应的input端口处理函数,不需求任何额外处理,直接这么写,结构能主动完结绑定,下面这个函数会被主动绑定到input入端口:

func (d *DebugActivity) Input(inputValue any, ctx context.Context) {
	config := d.Activity.GetConfig()
    ...
}

编列引擎的详细完结原理这儿就不展开了,感兴趣的话请参阅上面的前段编列引擎部分吧。

对接函数参数的处理

在前端,一个编列对应的是组件的一个事情,事情是一个函数,事情的参数会以数组的方法传给进口的inputValue,运用的时分用数组拆分元,把参数拆出来运用:

挑战零代码:可视化逻辑编排

组件函数的参数,没有姓名信息不能转成map:

//这种方法运用函数,参数没有姓名信息,只要次序信息
(...args: unknown[]) => inputOne.push(args)

在后端,作者把一个逻辑编列对应一个graphql接口(field),它的参数是map,没有次序信息,不能转成数组,只能用方针拆分元件来拆分参数:

挑战零代码:可视化逻辑编排

是的,你没看错,相同是参数分化,前后端不共同了,或许会给用户形成困扰。

现在想了一个折中的方法,不管是前端编列仍是后端编列,都建一个姓名是“参数分化”的元件,仅仅后端是依据方针拆分完结,前端是依据数组拆分完结。

挑战零代码:可视化逻辑编排

后端的逻辑编列的集成

在低代码渠道中,后端的逻辑编列要跟后端其它服务集成在一同,有两种完结方法:

  • 逻辑编列是独自的服务

挑战零代码:可视化逻辑编排

  • 逻辑编列跟模型服务集成在一同

挑战零代码:可视化逻辑编排

这两种完结方法,小编倾向于后者。理由有两个:

  1. 逻辑编列独立成一个微服务,愈加了业务编列的难度
  2. 逻辑编列独立成微服务,不便利给编列增加后边说的类型体系。

展望未来

逻辑编列的介绍接近结尾了,尽管洋洋洒洒2w字,感觉仍是浅尝辄止,许多内容没有深化下去,实践代码相比文章仍是要杂乱些,感兴趣的朋友欢迎翻阅代码并沟通。

文中并没有触及逻辑编列中的一个重要内容,类型束缚。有了类型束缚,用户编列的功率会提高许多,出错的概率会降低许多。类型能够修正器增加智能提示,输入束缚,给解析引擎的类型转化供给支撑。

接下来,会以UML类图的方法,给自己的低代码渠道供给范畴模型。这个范畴模型,贯穿模型前后端,并衍生出一套类型体系,附加到低代码的各个部分,天然也会附加到逻辑编列这部分。类型体系的思路,来自大佬徐飞的一篇文章。

今年写了两篇长文,一篇是《实战,一个高扩展、可视化低代码前端,详实、完好》还有一篇就是本文。上一篇重点在可视化修正部分,本篇重点是逻辑编列。还短缺页面的数据模型部分,会在不远的将来补上。

总结

本文首要写了数据流驱动的逻辑编列运转原理、修正器、解析引擎等内容,包括前端编列跟后端编列。尽管质量不怎样样,可是却是用心在写,前后花了一个星期的时刻。期望能给需求的朋友一点协助或许启发,价值就是我的动力地点。

有不同定见的朋友欢迎留言评论,没事找事的朋友,欢迎来战。

感谢

感谢板砖团队的MyBricks产品,它供给了名贵的思路,让Rxdrag的逻辑编列部分得以完结。

感谢网友陌路及其团队成员供给的支撑,每次苍茫的时分,跟他们评论一下,总会有一种豁然开朗的感觉。

感谢网友青铜供给支撑,正是由于他的鼎力相助,才干让我这个环境小白成功的做了一个Monorepo项目。

感谢共同以来重视跟支撑我的朋友,朋友们的支撑给了我极大的鼓舞,期望在未来的日子里,咱们还能相互陪伴一同走的更远。

最终,感谢CCTV,尽管不知道它做了什么,可是总觉仍是要提前感谢一下,说不定它某一天真能做些什么。

期望在今后的日子里,帮到的人越来越多。也期望今后文章的感谢列表越来越长。