实践课-规则引擎 | 青训营笔记

100 阅读5分钟

这是我参与「第五届青训营」伴学笔记创作活动的第8天。青训营的第六次课程中展示了一个简易版规则引擎的实现,包含编译的词法分析、语法分析功能及执行功能,并且在课后作业中,要求使用Hertz框架实现一个HTTP服务的规则引擎系统。下面是我对规则引擎功能实现的回顾,以及个人HTTP规则引擎服务器的实现及效果展示。

规则引擎原理

编译过程

在规则引擎中,编译过程可以分为词法分析及语法分析两个子部分。在词法分析中,编译器将代码文件解析成若干个token的顺序组合,这里的token包括标识符、数值常量、字符常量、运算符等符号。在语法分析中,编译器将顺序排好的token数组解析成可用于执行的语法树,在这里使用了递归下降算法完成语法树的构建。

func Compiler(exp string) (*executor.Node, error) {
	tokenScanner := compiler.NewScanner(exp)
	tokens, err := tokenScanner.Lexer()
	if err != nil {
		return nil, err
	}

	parser := compiler.NewParser(tokens)
	err = parser.ParseSyntax()
	if err != nil {
		return nil, err
	}

	astBuilder := compiler.NewBuilder(parser)
	ast, err := astBuilder.Build()
	if err != nil {
		return nil, err
	}

	return ast, nil
}

上述代码展示了规则引擎的具体编译过程,其中:

  • 通过Lexer函数进行词法分析,将输入的字符序列解析成token序列;
  • 通过ParseSyntax函数对token序列进行大致的正确性检查;
  • 通过Build函数将token序列解析成用于执行的语法树,若执行成功则返回语法树根节点;

下面我们将分别介绍词法分析、语法分析的具体实现:

词法分析

func NewScanner(source string) *Scanner {
	runes := []rune(source)

	if len(runes) == 0 {
		runes = append(runes, rune(eofRune))
	}

	return &Scanner{
		source: runes,
		length: len(runes),
		ch:     runes[0],
	}
}

在词法分析中,将输入的字符序列初始化为Scanner类型,其中包含了字符序列本身、当前遍历位置及当前遍历字符。

func (scanner *Scanner) Lexer() ([]token.Token, error) {
	tokens := make([]token.Token, 0)

	var err error
	var tok token.Token
	for {
		tok, err = scanner.Scan()
		tokens = append(tokens, tok)
		if err != nil || tok.Kind == token.Eof {
			break
		}
	}

	return tokens, err
}

Lexer函数中,通过循环调用Scan函数每次解析字符序列中的一个token,并将其存储于tok数组,直到遍历完成Scan函数返回token.Eof

func (scanner *Scanner) Scan() (token.Token, error) {
	var tok token.Token
	var err error

	scanner.skipWhitespace()
	tok.Position = scanner.position

	switch ch := scanner.cur(); {
	case isEof(ch):
		tok.Kind = token.Eof
	case isLetter(ch):
		// if the first character is letter, this token must be an Identifier or BoolLiteral or otherwise
		literal := scanner.scanIdentifier()
		tok.Kind = token.Lookup(literal)
		tok.Value = literal
		// boolean?
		if tok.Kind == token.BoolLiteral {
			tok.Value = parseBool(literal)
		}
        ...
        return tok, err
}

Scan函数的流程即为遍历字符序列,其首先通过skipWhitespace跳过当前位置及之后存在的空白字符,并通过检查当前字符的类型判断:字符串是否遍历完成、是否为字母(标识符、布尔常量)、是否为数字(常量)等判断类型,在这里不一一阐述。

语法分析

func (p *Parser) ParseSyntax() error {
	// '(a + (b > c)' is illegal
	err := p.checkBalance()
	if err != nil {
		return err
	}

	// 'param1 + 100 param2' is illegal
	var lastTok token.Token
	state, err := lastTok.Kind.GetLexerState()
	for p.hasNext() {
		tok := p.next()
		if !state.CanTransitionTo(tok.Kind) {
			return fmt.Errorf("cannot transition token types from %s [%v] to %s [%v]",
				lastTok.Kind.String(), lastTok.Value, tok.Kind.String(), tok.Value)
		}

		state, err = tok.Kind.GetLexerState()
		if err != nil {
			return err
		}

		lastTok = tok
	}

	// 'a + b +' is illegal
	if !state.IsEOF() {
		return errors.New("unexpected end of expression")
	}
	p.Reset()
	return nil
}

在执行语法分析之前,需要执行PaseSyntax来检查token序列的大致的正确性,在这里包括了:

  • 括号是否是封闭的;
  • 相邻两个token之间的承接关系是否合理;、
  • token序列是否以Eof作为结尾;
func (b *Builder) Build() (*executor.Node, error) {
	if b.parser == nil {
		return nil, errors.New("parse is nil")
	}

	if b.rootPlanner != nil {
		// TODO 树优化
		return b.rootPlanner.plan(b)
	}

	return nil, errors.New("build failed")
}

对于语法分析而言,该规则引擎使用plan函数开启递归下降算法,并得到完整的语法树。

func (p *precedence) plan(builder *Builder) (*executor.Node, error) {
	var err error
	var leftNode, rightNode *executor.Node

	if p.nextPrecedence != nil {
		leftNode, err = p.nextPrecedence.plan(builder)
		if err != nil {
			return nil, err
		}
	} else if p.planner != nil {
		leftNode, err = p.planner(builder, p)
		if err != nil {
			return nil, err
		}
	}

	for builder.parser.hasNext() {
		tok := builder.parser.next()

		if tok.Kind.IsEof() {
			break
		}

		symbol, exist := p.validKindsToSymbols[tok.Kind]
		if !exist {
			break
		}

		rightNode, err = p.plan(builder)
		if err != nil {
			return nil, err
		}

		node := executor.NewNode(leftNode, rightNode, symbol, nil)
		return node, nil
	}
	builder.parser.rewind()
	return leftNode, nil
}

在该函数中,使用递归下降算法解析语法树,其沿着token优先级从低到高,贪心的构造语法树的左节点、运算符和右节点。通过该种方法,使得运算符优先级功能得以实现。具体流程如下图所示

image.png

执行过程

func (n *Node) Eval(parameters map[string]interface{}) error {
	if n == nil {
		return nil
	}

	if n.leftNode != nil {
		err := n.leftNode.Eval(parameters)
		if err != nil {
			return err
		}
	}

	if n.rightNode != nil {
		err := n.rightNode.Eval(parameters)
		if err != nil {
			return err
		}
	}

	if n.typeChecker != nil {
		if !n.typeChecker(n.leftNode, n.rightNode) {
			return n.symbol.formatTypeError(n.leftNode, n.rightNode)
		}
	}

	ret, tp, err := n.operator(n, n.leftNode, n.rightNode, MapParameters(parameters))
	if err != nil {
		return err
	}
	n.value = ret
	n.tp = tp

	return nil
}

Eval函数用于将具体的标识符赋值(通过parameters参数),并执行相应的语法树。执行的过程也通过递归实现,其对将其左右子语法树的运算结果通过运算符对应的函数进行计算,并向上传递,直至整颗语法树被求解。

HTTP规则引擎服务器

添加表达式

type AddExpressionRequest struct {
	Exp string `json:"exp"`
}

func HandleAddExpression(ctx context.Context, c *app.RequestContext) {
	var req AddExpressionRequest
	if err := c.Bind(&req); err != nil {
		BindResp(c, ParamErrCode, err.Error(), nil)
		return
	}
	res, err := dal.AddExpression(req.Exp)
	if err != nil {
		BindResp(c, ServiceErrCode, err.Error(), nil)
		return
	}

	BindResp(c, SuccessCode, "Add success, expression id is return", res.ID)
}

/api/engine/exp/new/提供了保存表达式的功能,其将表达式通过dal.AddExpression方法存入数据库,并返回数据库条目所对应的ID。调用结果如下:

image.png

运行表达式

type RunExpressionRequest struct {
	Id uint `json:"id"`
}
func HandleRunExpression(ctx context.Context, c *app.RequestContext) {
	var req RunExpressionRequest
	if err := c.Bind(&req); err != nil {
		BindResp(c, ParamErrCode, err.Error(), nil)
		return
	}
	exp, err := dal.GetExpressionByID(req.Id)
	if err != nil {
		BindResp(c, RuleNotExistCode, err.Error(), nil)
		return
	}
	evaluatedExp, err := Compiler(exp.Exp)
	if err != nil {
		BindResp(c, CompileErrCode, err.Error(), nil)
		return
	}
	params := make(map[string]interface{})
	err = evaluatedExp.Eval(params)
	if err != nil {
		BindResp(c, RuleExecErrCode, err.Error(), nil)
		return
	}

	resp, _ := evaluatedExp.GetVal()

	BindResp(c, SuccessCode, SuccessMsg, resp)
}

/api/engine/exp/run/提供了执行对应ID表达式的接口,其由HandleRunExpression处理函数实现,其从数据库中读取对应ID的表达式,并通过词法分析、语法分析进行编译,并最后运行并返回结果。调用结果如下:

image.png

表达式列表

func HandleGetAllExpression(ctx context.Context, c *app.RequestContext) {
	exps, err := dal.GetAllExpression()
	if err != nil {
		BindResp(c, ServiceErrCode, err.Error(), nil)
		return
	}
	BindResp(c, SuccessCode, "success", exps)
}

/api/engine/exp/list/提供了列出所有表达式的接口,其由HandleGetAllExpression处理函数实现,其从数据库中获得所有表达式条目,并返回,执行结果如下。 image.png

删除表达式

func HandleDeleteExpression(ctx context.Context, c *app.RequestContext) {
	idStr := c.Param("id")
	id, err := strconv.ParseUint(idStr, 10, 32)
	if err != nil {
		BindResp(c, ParamErrCode, err.Error(), nil)
		return
	}
	err = dal.DeleteExpressionByID(uint(id))
	if err != nil {
		BindResp(c, ServiceErrCode, err.Error(), nil)
		return
	}

	BindResp(c, SuccessCode, SuccessMsg, nil)
}

/api/engine/exp/:id提供了删除对应ID表达式的接口,其从数据库中删除了对应ID的表达式条目。