Golang实现逻辑编排解释引擎

作者:井卓

文章简介:

逻辑编排提供一站式集成平台,简化了在集成接口、应用和服务时,所涉及的业务逻辑和流程。本文会介绍如何通过ChatGPT学习Golang、以及表达式解释器的实现Golang解析引擎的基本设计架构。

Golang实现逻辑编排解释引擎

  1. 背景:

逻辑编排提供一站式集成平台,简化了在集成接口、应用和服务时,所涉及的业务逻辑和流程。逻辑编排自研流程中缺少后端的解析引擎,于是我通过ChatGPT高效的学习了Golang,并完成了开发。本文会介绍如何通过ChatGPT对Golang学习教程的提炼和依赖资料的获取路径、表达式解释器的实现和Golang解析引擎的基本设计架构等。

2.如何使用ChatGPT学习Golang

2.1 跑起来 "Hello World!"

问ChatGPT如何用go跑起来一个"Hello World!" ,尽量精简提问,这样就能更精确的拿到自己想要的内容

通过这个提问可以提炼出两个要点

  1. 一个最简单的go文件是什么样子
  1. package main  
  2. import "fmt"  
  3. func main() {  
  4.     fmt.Println("Hello, World!")  
  5. }  
  1. 如何运行这个go文件,chat可以精确的告诉go的安装地址,以及安装流程,可以很快搞定

2.2 用例子来学习

比起官网上冗长的学习教程,我更想看到 go和我们前端常用的ts有什么区别,这样的内容可以直接问ChatGPT,并且让他给出具体的例子,会得到例子。

2.2.1 变量定义

变量定义在两种语言的中的区别是不同的,除了语法上的不同。在参数的类型上对于数字类型Golang有 int, float32, float64等多种类型。这是因为Golang作为编译型静态类型语言,在变量定义的时候根据不同的类型所占据的字节数是有差别的,所以对语言的变量类型定义更加严格。但这些数字类型这在TypeScript中都统一称做number。

  1. // Golang  
  2. var a int = 1  
  3. // TypeScript  
  4. var a: number = 1;  

2.2.2 面向对象的区别

面向对象编程是可以代码 高内聚,低耦合的方案。可以通过ChatGPT方便的知道语言的特性,在Golang中没有传统的类(class)的概念,也就是常说的oo,它采用了一种不同的面向对象编程模式,称为"结构体(struct)"和"方法(method)",与高级语言中类定义的方式有所区别。

  1. // Golang  
  2. type FlowNode struct {  
  3.     id         string  
  4.     nodeName   string  
  5. }  
  6.   
  7. func NewFlowNode(params map[string]interface{}) (*FlowNode, error) {  
  8.     nodeType, err := node.ToNodeEnum(params["type"].(string))  
  9.     if err != nil {  
  10.         fmt.Println(err)  
  11.         return nil, nil  
  12.     }  
  13.     flowNode := &FlowNode{  
  14.         id: params["id"].(string),  
  15.         nodeName:   params["displayName"].(string),  
  16.     }  
  17.     return flowNode, nil  
  18. }  
  19.   
  20. func (f *FlowNode) GetId() string {  
  21.     return f.id  
  22. }  
  23.   
  24. // TypeScript  
  25. class FlowNode {  
  26.     private id: string;  
  27.     private nodeName: string;  
  28.   
  29.     constructor(params: any) {  
  30.         this.id = params.id  
  31.         this.nodeName = params.nodeName  
  32.     }  
  33.     public GetId(): string {  
  34.         return this.id  
  35.     }  
  36. }  

2.3 进阶学习更为复杂的用例

学习go还是为了写后端接口,在了解了基础的语法后,进阶的问如何用go起后端服务,跑起来一个基础的接口用例

  1. package main  
  2.   
  3. import (  
  4.     "fmt"  
  5.     "net/http"  
  6. )  
  7.   
  8. func main() {  
  9.     // 定义一个HTTP请求处理函数  
  10.     http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {  
  11.         fmt.Fprintf(w, "Hello, World!"// 向客户端发送 "Hello, World!" 响应  
  12.     })  
  13.   
  14.     // 启动HTTP服务器,监听在端口8080  
  15.     err := http.ListenAndServe(":8080", nil)  
  16.     if err != nil {  
  17.         fmt.Println("Server error:", err)  
  18.     }  
  19. }  

在这这个的基础上询问如何实现get请求,post请求,以及如果连接数据库,拿到具体的学习用例,来满足自己的目的。

3.表达式解释器的实现

在逻辑编排中需要配置不同的分支进入的逻辑,需要用Go实现一套规则的解释引擎,也就是表达式解释器。规则引擎的配置交互如图所示 。

Golang实现逻辑编排解释引擎_第1张图片

3.1 什么是表达式解释器

需要先了解如何实现一个解释器。解释器(Interpreter)是一个基于用户输入执行源码的工具并拿到结果的工具,解释器会解析源码,生成 AST(Abstract Syntax Tree)抽象语法树,逐个迭代并执行 AST 节点。解释器有三个阶段:词法分析(Lexer),语法分析(Syntax ),执行(Eval)

其中 Lexer 负责将原始文本转换为标记流。解析器将标记流转换为抽象语法树(AST)。 AST 基本上是表示为树数据结构的源代码。访问者通过递归“访问”AST 中的每个节点来进行实际的评估/执行。稍后我们将仔细研究它们中的每一个。

Golang实现逻辑编排解释引擎_第2张图片

3.2 词法分析

词法分析的过程是解析字符串生成有具体含义的每个单词,这个过程我们需要借助有穷自动机来完成,在此之前我们需要重新了解一下正则表达式,这是所有的步骤的基石。

3.2.1 正则表达式

日常编程大家应该都接触过正达式,用它来匹配字符串等,也可能已经很熟悉其语法了。但我这次想从正则表达式的最基本概念来重新介绍一次,主要想让大家更深地理解它。首先我们要重新定义一下“语言”这个概念。“语言”就是指字符串的集合,其中的字符来自于一个有限的字符集合。也就是说,语言总要定义在一个有限的字符集上,但是语言本身可以既可以是有穷集合,也可以是无穷集合。比如“JavaScripe语言”就是指满足JavaScripe语法的全体字符串的集合,它显然是个无穷集合。当然也可以定义一些简单的语言,比如这个语言{ a }就只有一个成员,那就是一个字母a。后面我们都用大括号{}来表示字符串的集合。所谓正则表达式呢,就是描述一类语言的一种特殊表达式,正则表达式共有2种基本要素:

  1. 表达式ε表示一个语言,仅包含一个长度为零的字符串,可以理解为{ String.Empty },我们通常把String.Empty记作ε,读作epsilon。
  2. 对字符集中任意字符a,表达式a表示仅有一个字符a的语言,即{ a }。

同时正则表达式定义了3种基本运算规则:

  1. 两个正则表达式的并,记作X|Y,表示的语言是正则表达式X所表示的语言与正则表达式Y所表示语言的并集。比如a|b所得的语言就是{a, b}。类似于加法
  2. 两个正则表达式的连接,记作XY,表示的语言是将X的语言中每个字符串后面连接上Y语言中的每一种字符串,再把所有这种连接的结果组成一种新的语言。比如令X = a|b,Y = c|d,那么XY所表示的语言就是{ac, bc, ad, bd}。因为X表示是{a, b},而Y表示的是{ c, d},连接运算取X语言的每一个字符串接上Y语言的每一个字符串,最后得到了4种连接结果。这类似于乘法
  3. 一个正则表达式的克林闭包,记作X,表示分别将零个,一个,两个……无穷个X与自己连接,然后再把所有这些求并。也就是说X* = ε | X | XX | XXX | XXX | ……。比如a这个正则表达式,就表示的是个无穷语言{ ε, a, aa, aaa, aaaa, …. }。这相当于任意次重复一个语言。

比如我们常见的变量定义改写成正则就是 ^[a-zA-Z_][a-zA-Z0-9_]*$,数字对应的正则是^-?\d+(\.\d+)?$, 我们大家平时熟悉的正则表达式是写成上文这样的字符串形式。但这次我们要自己处理正则表达式,写成字符串显然增加了处理的难度(要解析正则表达式字符串)。所以词法分析库中,需要通过面相对象思想来抽象正则表达式,每一种正则表达式要素或运算编写了一个子类, 具体要如何转换需要借助NFADFA来进行。

3.2.2 有穷自动机NFA

有穷自动机首先包含一个有限状态的集合,还包含了从一个状态到另外一个状态的转换。有穷自动机看上去就像是一个有向图,其中状态是图的节点,而状态转换则是图的边。此外这些状态中还必须有一个初始状态和至少一个接受状态。下面的图展示了一个有穷自动机,有根从外边来的箭头指向的状态表示初始状态,有个黑圈的状态是接受状态:

Golang实现逻辑编排解释引擎_第3张图片

有穷自动机怎么处理输入的字符串"ab*a"的流程如下:

  1. 一开始,自动机处于初始状态
  2. 输入字符串的第一个字符,这时自动机会查询当前状态上与输入字符相匹配的边,并沿这条边转换到下一个状态。
  3. 继续输入下一个字符,重复第二步,查询当前状态上的边并进行状态转换
  4. 当字符串全部输入后,如果自动机正好处于接受状态上,就说该自动机接受了这一字符串

3.2.3 非确定性有穷自动机DFA

非确定性有穷自动机(NFA),它允许从一个状态发出多条具有相同符号的边,甚至允许发出标有ε(表示空)符号的边,也就是说,NFA可以不输入任何字符就自动沿ε边转换到下一个状态,他会比NFA接受的状态更多,也是我常用的词法分析方案,也就是说在NFA中从开始状态到结束的状态的路径并不是固定的,甚至有可能是空字符串!

Golang实现逻辑编排解释引擎_第4张图片

下图展示了一个非确定性有穷自动机:


Golang实现逻辑编排解释引擎_第5张图片

不同的自动机对应不同的正则表达式,例如正则表达式-?[0-9]+就是对应上述的非确定性有穷自动机,借助这样的有穷自动机我们可以实现把正则拆分到不同的自动机上,来实现词法分析中对字符串的解析, 我们上边的更多正则的规则可以用下图进行描述。

Golang实现逻辑编排解释引擎_第6张图片

通过NFA我们可以把所有的正则进行解析和完善,并列出相应的对应表关系。把不同的正则抽象成不同的类,用来语法分析,也就是表达式中诸如变量、数字、算数运算符号、逻辑运算符号这样有特定意义的单词,来进入下一步的解析。

3.3 语法分析

词法分析把字符串解析成一个个具有具体意义的单词,一种完整的编程语言,必须在此基础上定义出各种声明、语句和表达式的语法规则。观察我们所熟悉的编程语言,其语法大都有某种递归的性质。例如四则运算与括号的表达式,其每个运算符的两边,都可以是任意的表达式。比如1+a是表达式,(1+a)*(2 – c)也是表达式,((a+b) + c) * (d – e)也是表达式。再比如if语句,其if的块和else的块中还可以再嵌套if语句。我们在词法分析中引入的正则表达式和正则语言无法描述这种结构,如果用DFA来解释,DFA只有有限个状态,它没有办法追溯这种无限递归。所以,编程语言的表达式,并不是正则语言。要引入一种表现能力更强的语言——上下文无关语言。

要介绍上下文无关语言,我们先来了解一下定义上下文无关文法的工具——产生式的写法。我们还是使用编程语言的表达式作为例子,但这次我们假设表达式只有三种——单个表示变量名标识符、括号括起来的表达式和两个表达式相加。比如a是一个变量表达式,a+b是两个变量表达式相加的表达式,(a+b)是一个括号表达式。我们用符号E来表示一个表达式,那么这三种表达式分别可以定义为:

E → E + S | E

E → number | ( S )

这种形式的定义就叫做产生式。出现在→左侧符号E称作非终结符(nonterminal symbol),代表可以继续产生新符号的“文法变量”。 符号→表示非终结符可以“产生”的东西。而上述产生式中的蓝色id、+、(等符号,是具有固定意义的单词,它们不再会产生新的东西,称作终结符(terminal symbol)。注意,非终结符可以出现在产生式的右侧,这就是具有递归性质文法的来源。产生式经过一系列的推导,就能够生成各种完全由终结符组成的句子。比如,我们演示一下表达式(1+2+(3+4))+5的推导过程:

Golang实现逻辑编排解释引擎_第7张图片

推导树与Ast


Golang实现逻辑编排解释引擎_第8张图片

上边我们说到语法分析使用的上下文无关语言,以及描述上下文无关文法的产生式、产生式推导和语法分析树等概念。今天我们就来讨论实际编写语法分析器的方法。今天介绍的这种方法叫做递归下降(recursive descent)法,这是一种适合手写语法编译器的方法,且非常简单。递归下降法对语言所用的文法有一些限制,但递归下降是现阶段主流的语法分析方法,因为它可以由开发人员高度控制,在提供错误信息方面也很有优势,使用递归下降法编写语法分析器无需任何类库,编写简单的分析器时甚至连前面学习的词法分析库都无需使用。我们来看一个例子:现在有一种表示二叉树的字符串表达式,它的文法是:

  1. N → a ( N, N )
  2. N → ε

其中终结符a表示任意一个英文字母,ε表示空。这个文法的含义是,二叉树的节点要么是空,要么是一个字母开头,并带有一对括号,括号中逗号左边是这个节点的左儿子,逗号右边是这个节点的右儿子。例如字符串 A(B(,C(,)),D(,))就表示这样一棵二叉树:

                                Golang实现逻辑编排解释引擎_第9张图片 

把这样的推导过程用代码表达出来,我们就能构成真正的Ast数,进行下一步的的解释执行。

下边介绍我们用LL文法对一个二元表达式(1 + 2 + (3 + 4)) + 5的解析。

Golang实现逻辑编排解释引擎_第10张图片

4.解释执行

通过语法分析拿到真正的Ast之后,可以做的事情有两种,解释器(compiler) 和 编译器(Interpreter)和,如果有个翻译器把源程序翻译成机器语言,那它就是 编译器。如果一个翻译器可以处理并执行源程序,却不用把它翻译器机器语言,那它就是 解释器。比如我们用的Taro其实本质上就是编译器的一种,解析我们用React成Ast,并翻译成原生的小程序语法。而我们这里的表达式解释器主要是写的是解释器,通过对表达式Ast的解释执行拿到表达式的结果。

4.1 模式匹配

下面就是这个计算器的代码。它接受一个表达式,输出一个数字作为结果,正如上一节所示。

  1. (define calc  
  2.  (lambda (exp)  
  3.   ; 匹配表达式的两种情况  
  4.   (match exp   
  5.    ; 是数字,直接返回  
  6.    [(? number? x) x]  
  7.    ; 匹配并且提取出操作符 op 和两个操作数 e1, e2  
  8.    [`(,op ,e1 ,e2)    
  9.       ; 递归调用 calc 自己,得到 e1 的值             
  10.     (let ([v1 (calc e1)]     
  11.        ; 递归调用 calc 自己,得到 e2 的值  
  12.        [v2 (calc e2)])        
  13.       ; 分支:处理操作符 op 的 4 种情况  
  14.      (match op        
  15.        ; 如果是加号,输出结果为 (+ v1 v2)         
  16.       ['+ (+ v1 v2)]  
  17.        ; 如果是减号,乘号,除号,相似的处理            
  18.       ['- (- v1 v2)]           
  19.       ['* (* v1 v2)]  
  20.       ['/ (/ v1 v2)]))])))  
  21.       ; 是变量 在全局的作用域中查找  
  22.     [(? variable? x) x]     

这里的 match 语句是一个模式匹配。它的形式是这样:

  1. (match exp  
  2.  [模式 结果]  
  3.  [模式 结果]  
  4.   ...  ...  
  5. )  

它根据表达式 exp 的“结构”来进行“分支”操作。每一个分支由两部分组成,左边的是一个“模式”,右边的是一个结果。左边的模式在匹配之后可能会绑定一些变量,它们可以在右边的表达式里面使用。

一般说来,数据的“定义”有多少种情况,用来处理它的“模式”就有多少情况。比如算术表达式有两种情况,数字或者 (op e1 e2)。所以用来处理它的 match 语句就有两种模式。“你所有的情况,我都能处理”,这就是“穷举法”。穷举的思想非常重要,你漏掉的任何一种情况,都非常有可能带来麻烦。所谓的“数学归纳法”,就是这种穷举法在自然数的递归定义上面的表现。因为你穷举了所有的自然数可能被构造的两种形式,所以你能确保定理对“任意自然数”成立。

6.总结

我们在了解表达式解释器的理论基础上,需要应用于实战,下边是我们一个表达式节点配置的schema,分别包含解释器的上下文以及需要解析的逻辑语句信息。其中 schema 会被翻译成 ( Key1 == 1) && ( Key2 == 2) && ( (Key1 > 1) ||(Key3 >= 2)) 然后放入我们的表达式解释器中拿到真正的执行结果,可以疑问这里的Key是什么,其实这是我们的上下文中存储的变量,我们在最终解释器的模式匹配中,会设置如果遇到变量就去相应的作用域中查找, 由于我们这里实现的表达式解释的上下文的没有嵌套的情况,所以这里的的表达式只有一层。通过以上步骤,我们就实现了表达式解释器,可以当作逻辑编排中的规则引擎进行使用,利用动态表达式来实时修改配置,我们保险产品的显示规则等,下文将继续介绍表达式解释器如何应用到逻辑编排中。

4. 用Golang实现逻辑编排引擎设计

4.1. 什么是逻辑编排

逻辑编排提供一站式集成平台,简化了在集成接口、应用和服务时,所涉及的业务逻辑和流程。提供简便易用的设计方式,快速生成服务接口以及定时任务, 流程设计如下。

Golang实现逻辑编排解释引擎_第11张图片

在具体交互流程如下,用户可以可视化的配置自己的条件分支,以及不同的分支需要执行的流程结果。


Golang实现逻辑编排解释引擎_第12张图片

4.2. 逻辑编排的架构设计

4.2.1 节点架构设计

规则解析引擎的的代码实现比较灵活,没有固定的模板。应用设计模式主要是应对代码的复杂性,实际上,解释器模式也不例外。它的代码实现的核心思想,就是将语法解析的工作拆分到为不同的节点类型,以此来避免大而全的解析类。一般的做法是,将语法规则拆分成一些小的独立的单元,然后对每个单元进行解析,最终合并为对整个语法规则的解析。

在架构设计上的逻辑编排引擎的节点类型有开始节点,结束节点, 条件节点, 代码节点, 合并节点,分支节点。所有的节点信息都会被记录在一维的数组中,每个节点的都会有两个字段preIdnextId 记录自己上下节点的ID信息,这是一个类似于双向链表的数据结构。之前我看过内容生产平台的出码部分的源码,数据结构也是类似的。拿到节点数组后,会按照不同的节点type进行分发,从而实例化不同的节点。每个实例节点都继承自相同的父类,需要统一实现 Run从而来处理不同的节点特定的逻辑内容

开始节点

标注逻辑解析引擎开始的节点。

结束节点

作为整个解析引擎的结束,nextId 是空标注程序的结束,需要接口的返回参数, 作为整个接口的输出。

条件节点

条件配置节点就是我们上边实现的表达式解释器它通过执⾏规则并进⾏推理,能够实现规则匹配、前向推理、后向推理等功能。拿到当前逻辑判断节点的结果 true 或者 false 。决定当前分⽀是否可以继续向下执⾏,这里的表达式解析引擎实现的具体步骤会在下文介绍。

接口调用节点

接口节点可以支持配置请求头请求链接请求参数,通过go发送接口,并拿到接口的执行结果,并将结果请求传递到后边的节点。

代码块节点

代码节点的解释执行是通过goja进行的,gojaGolang的一个包用来可以执行js的代码,同时支持定制上下文拦截器 context,来对整个逻辑编排数据的读取进行读取, 通过 getArg 可以读取到上文中传入当前节点的参数,setReturn 可以设置代码中需要返回的数据。

  1. const phone_number = context.getArg('phone_number');  
  2. const p1 = phone_number.substring(0,3)  
  3. const p2 = phone_number.substring(7,11)  
  4. const result = `${p1}****${p2}`;  
  5. context.setReturn({'result_phone': result}) ;  

分支节点

记录逻辑编排中会进行分支的地方,记录叉的节点信息,并发的判断不同分叉节点的逻辑是否通过,从而判断具体是哪个分支才能向后执行。

合并节点

记录逻辑编排中合并节点信息的地方,用于向下传递参数信息。

4.2.2 作用域Scope架构设计

任何一种编程中,作用域是程序中定义的变量所存在的区域,超过该区域变量就不能被访问。所以每个节点支持配置自己的输出和输入的参数,用来暴露出给全局的参数信息,前边节点的输出,可以当作后边节点的输入。

每节点都会有 inputArgs 参数,用来存储输入的参数信息。输入的参数类型分为以下几种globalData 全局参数 , flowData 节点参数, immediate 立即数,具体结构如下。其中 immediate可以直接的读取数据,globalData 和 flowData会被从作用域从进行存取。

每节点都会有 outputArgs 参数,用来在作用域中记录,当前节点暴露出的字段信息有哪些,用于向下边节点传递信息。

5.总结

通过本文的阅读,会了解如何通过ChatGPT对教程的提炼和依赖资料的获取,以及Golang解析引擎的基本设计架构:

  1. 如何通过ChatGPT对学习一门新语言的流程进行高效的拆解。
  2. 表达式解释器的基本构成,词法分析,语法分析,解释器分别作用的流程与原理。
  3. 逻辑编排的架构设计,需要对流程的过程进行抽象,用面向对象的思想编程,抽出逻辑流程的节点之间的共性与区别。

你可能感兴趣的:(golang,开发语言,后端)