前言
通过前面的负载均衡算法系列文章我们分析完了各种算法的优缺点和适用场景,在流量进入BFE选择后端服务之前, 除了通过VIP对应的租户确定对应的集群外, 还可以配置针对HTTP协议的路由转发规则确认后端实例, 在bfe_route/host_table.go:LookupCluster()
方法中通过rule.Cond.Match()
匹配对应的集群。我们本期分析一下路由匹配规则的实现。
前期回顾: BFE负载均衡源码--最小连接数算法及实现
路由匹配
我们常用的nginx路由匹配规则是基于正则表达式构成的, 路由语法规则为:
location [=|~|~*|^~] /uri/ { … }
多个匹配规则可能这个样子:
location ~ /images/.*\.(gif|jpg|png)$ {...}
location ~* /test_a {}
配置复杂比较复杂, 可读性也很差, 验证也比较困难, 后续的维护风险也比较大。在使用golang重构之前, BFE使用正则表达式来描述转发的条件,存在以下两个严重的问题:
-
配置难以维护: 正则表达式存在严重的可读性问题。用正则表达式编写的转发条件很难看懂,且易存在二义性。也经常会发现一个人编写的分流条件,其他人很难接手继续维护。
-
性能存在隐患: 对于编写不当的正则表达式,可能在特定的流量特征下出现严重的性能退化。在线上曾经发生过这样的情况:原本每秒可以处理几千请求的服务,由于增加了一个正则表达式描述,性能下降到每秒只能处理几十个请求。
BFE使用条件表达式描述路由转发的规则, 其中条件表达是支持的操作符即优先级如下:
优先级 | 操作符 | 含义 | 结合律 |
---|---|---|---|
1 | () | 括号 | 从左至右 |
2 | ! | 逻辑非 | 从右至左 |
3 | && | 逻辑与 | 从左至右 |
4 | || | 逻辑或 | 从左至右 |
目前在BFE开源项目中,已经包括40多种条件原语。条件原语的名称会遵循一定的规范,以便于分类和阅读。BFE开源项目所支持条件原语的列表,可以查看BFE条件原语。
golang中的AST
golang标准库中有相关的pkg帮我们更加有效的分析AST:
- go/scanner:词法解析,将源代码分割成一个个token
- go/token:token类型及相关结构体定义
- go/ast:ast的结构定义
- go/parser:语法分析,读取token流生成ast
假如我们有一个条件表达式: host = "cooper.com" && ip = "127.0.0.1"
, 生成的token如下:
// demo.go
package main
import (
"go/ast"
"go/parser"
"go/token"
)
func main() {
srcCode := `host = "cooper.com" && ip = "127.0.0.1"`
exprAst, _ := parser.ParseExpr(srcCode)
fset := token.NewFileSet()
ast.Print(fset, exprAst)
}
生成的token片段如下:
0 *ast.BinaryExpr {
1 . X: *ast.BinaryExpr {
2 . . X: *ast.Ident {
3 . . . NamePos: -
4 . . . Name: "host"
5 . . }
6 . . OpPos: -
7 . . Op: ==
8 . . Y: *ast.BasicLit {
9 . . . ValuePos: -
10 . . . Kind: STRING
11 . . . Value: "\"cooper.com\""
12 . . }
13 . }
14 . OpPos: -
15 . Op: &&
16 . Y: *ast.BinaryExpr {
17 . . X: *ast.Ident {
18 . . . NamePos: -
19 . . . Name: "ip"
20 . . }
21 . . OpPos: -
22 . . Op: ==
23 . . Y: *ast.BasicLit {
24 . . . ValuePos: -
25 . . . Kind: STRING
26 . . . Value: "\"127.0.0.1\""
27 . . }
28 . }
29 }
基于token片段构成的语法树图示如下:
这是AST的基本解析结构, 基于此原理, BFE实现了自定义的规则解析器。
源码实现
构建
当我们定义了一条件原语: res_code_in("200|500")
, 首先要经过构建解析bfe_basic/condition/build.go
func Build(condStr string) (Condition, error) {
node, identList, err := parser.Parse(condStr)
/*** 省略相关代码 ***/
return build(node)
}
func build(node parser.Node) (Condition, error) {
switch n := node.(type) {
case *parser.CallExpr: // 函数描述
return buildPrimitive(n)
case *parser.UnaryExpr: // 一元操作描述
return buildUnary(n)
case *parser.BinaryExpr: // 二元操作描述
return buildBinary(n)
case *parser.ParenExpr: // 父节点, 则调用解析
return build(n.X)
default:
return nil, fmt.Errorf("unsupported node %s", node)
}
}
- 使用parse包解析条件表达式结构, 生成node节点树
- 当节点是
自定义方法
,一元操作
,二元操作
分别构建做针对性构建, 如果是父节点, 则递归调用build()
方法, 遍历语法树解析,生成实现Condition
接口的解析类。
res_code_in
对应的是方法解析, 会执行buildPrimitive()
方法构造解析,生成NewInMatcher
对象。
匹配
通过构建生成的NewInMatcher
实现了Match(v interface{}) bool
方法,
func (im *InMatcher) Match(v interface{}) bool {
/*** 省略相关代码 ***/
// vs 待匹配的数据
// im.patterns 表达式中描述的数据
return in(vs, im.patterns)
}
// 调用 in 方法
func in(v string, patterns []string) bool {
// 查询string是否在[]string中存在
i := sort.SearchStrings(patterns, v)
return i < len(patterns) && patterns[i] == v
}
还有很多函数的自定义实现, 原理大同小异, 我们就不过多做解析说明。
思考
条件表达式相对正则匹配, 在可读性和性能方面确实有非常大的优势, 回想起线上nginx配置繁多的路由转发规则, 到最后都都很难维护。条件表达式在日常工作中也有非常广泛的使用, 比如运营配置一些活动, 弹窗的规则展示等, 都可以通过配置化解决开发效率问题。在社区方面, 条件表达式从来都是不缺少通用化的轮子, 但是在大型项目中个性化依赖比较强, 所以很有必要实现一个独立的解析库。
总结
关于条件原语的定义理解和使用都比较简单, 可以参考官网中的描述, 我们更感兴趣的是其实现原理。下期我们一起实现一个基本的条件表达式解析器。