基于压缩字典树RadixTree实现API路径匹配

本文介绍了API网关重构中针对RESTful风格API模板的路由匹配问题,包括常量匹配优先、前缀匹配优化和歧义消除。通过引入压缩字典树数据结构,实现了高效匹配并支持线性遍历。文章详细阐述了数据结构设计、匹配逻辑和实现方法,包括插入、查找和删除API的操作,并分析了复杂度和性能测试结果。
摘要由CSDN通过智能技术生成

很久没写更新了,非常惭愧,一点一点补吧。这次介绍一下年初写的一个API匹配功能

1.问题的引入

年初网关这块出于多方面的考虑,希望重构API网关的路由匹配实现,我们的API采用restful风格,在路径中存在参数,称之为API模板。在网关层,后端服务注册开放出去的API,要遵守API模板的约束。网关的一个主要职责,就是将请求path匹配到指定的API(模板)上,并将请求转发给注册了该API(模板)的后端服务。如果没有匹配到,则说明是一个无法识别的请求,并予以拒绝。

先来看下api模板长什么样子

/a/b/c
/a/b/{p1}/d
/a/b/{p1}/{p2}

RFC6570中约定了多种level的模板规范,这里我们的API规范采用了LEVEL1的模板规范,即只有大括号表达的模板参数,且大括号前后没有额外的常量串,前面只能是/,后面可以是/或结尾,所以上面三个示例都是合法的API模板。

以上面的模板为例,下面用一些请求示例,来说明API的匹配结果

/a/b - 不匹配
/a/b/c - 匹配第一条规则
/a/b/d - 不匹配
/a/b/d/d - 匹配第二条规则
/a/b/f/d - 匹配第二条规则
/a/b/f/e - 匹配第三条规则
/a/b/d/e/ - 不匹配

1.1.最多常量匹配

如果所有的API都是不带模板参数的常量API,那么基于字符串比较的匹配复杂度应该是O(nm),其中n为API数量,m为API平均字符串长度。但是麻烦在于这里有带有模板参数,比如下面的情况。

/a/b/{p}
/a/b/c

当请求/a/b/c到达时,从规则上来讲,两个API都是能匹配成功的,这种情况我们希望选中第二个API,即当请求匹配多个API时,选择常量部分匹配最多的API。这意味着,除非完整匹配到了全常量定义的API时,匹配可以立即结束,否则只要匹配到的API中带有参数,我们就需要继续进行匹配。

1.2.API歧义

这也引入了歧义的问题,我们再看下面的两个API

/a/b/{p}/d
/a/b/c/{p}

当请求/a/b/c/d到达时,这两个API都是能匹配成功的,这种情况下,我们期望匹配到第二个API,其语义是,尽可能多的在前缀方向匹配常量内容。

1.3.无法消除的歧义

有一种歧义是无法消除的,比如下面的示例

/user/{uid}
/user/{name}

我们期望在API向网关注册时解决此类问题,即从源头上避免将此类歧义问题,带入匹配运行时。

除了对单个API请求的匹配的需求外,还有一种场景,要求我们可以对所有的API进行遍历,我们期望在O(n)时间内完成所有API的遍历。

虽然需求略显啰嗦,但这基本是每一个API网关都可能面临的问题,总结一下我们的需求

  • 实现带模板参数的API匹配
  • 匹配多个API时选择常量吻合度最高的API
  • 在前缀方向选择常量匹配尽可能多的API
  • 避免无法消除的歧义进入运行时的匹配阶段
  • 提供线性的访问方式

2.解决思路

为了解决上述的API匹配问题,我引入了字典树结构

2.1.压缩字典树数据结构

字典树是一种加速查找的方法,网上和算法书籍有很多介绍,不再赘述。
由于内部制定了一些API的命名规范,大部分API都会具有类似的前缀,所以传统字典树按字符推进查找的方式空间利用率较低,这里采用了路由实现中常用的压缩字典树。
举例来说

/a/b/c
/a/b/d

传统字典树的结构如下:

而压缩字典树结构如下:

可见压缩字典树可以降低树的高度,提高空间利用率和查找效率。

2.2.匹配逻辑设计

那么如何解决参数部分的匹配呢?
由于模板参数不是可直接比较的内容,它是一个占位符,对应实际请求path中的一个可变长部分。所以在字典树中,不能对模板参数进行分割,它需要作为一个独立的树节点加入到RadixTree,比如下面的例子

同一个位置可能会有多个带参的API,比如下面的情况

这个树表达了四个API > /a/b/c > /a/b/{p1} > /a/b/{p2}/d > /a/b/{p2}/{p3}

为了能优先匹配到尽可能多的常量部分,我们对树的子节点进行分组,一组是常量部分,一组是变量部分,比如节点/a/b/的两个子节点集合{c}、{{p1},{p2}},当匹配进行时,我们需要优先去常量集合进行匹配,当常量集合没有找到匹配时,再去变量集合匹配。这里任意一个变量子集都可能实现匹配,所以需要对变量集合所有子节点进行回溯,这也是带模板参数压缩字典树对普通常量字典树性能低的主要原因,子节点方向上计算量可能成倍的增长。

跨过了当前模板参数层的后续匹配规则跟之前是一样的,这里剩余的部分是/d和/{p3},还是尽量匹配常量/d,匹配不上再选择{p3}。

需要注意的一点是,API的结尾并不一定在叶子结点上,所以你需要在每个节点上标明当前节点是否是某个API的结尾,比如下面的例子

/a/b
/a/b/c
/a/b/d

这个树看起来是这样的

但/a/b也是一个API,所以这里需要在/a/b节点上标明

最后,为了满足线性访问的需要,我们还需要在每个API的结束顶点增加一个双向链表指针。

至此,这个数据结构已经满足我们的需求了,下面我们来设计实现它的方法

3.设计实现

3.1.基础数据结构

type node_type int

const (
	NT_Root     = iota + 1 // 根节点
	NT_Static              // 实例节点
	NT_Variable            // 参数节点
)

type tagdata struct {
	fullpath string     // API的完整路径
	next     *tagdata   // 双向链表后向指针
	prev     *tagdata   // 双向链表前向指针
	custom   interface{}// 只会出现在API末端节点的自定义数据
}

// trie树节点
type node struct {
	tp       node_type // 当前节点类型
	instance []*node   // 实例节点
	variable []*node   // 参数节点
	path     string    // 当前节点路径
	indices  string    // 子节点首字母表,只存储非参数子节点(instance)的首字母
	tag      *tagdata  // 外挂数据
}

3.2.辅助方法

func min(a, b int) int {
	if a <= b {
		return a
	}
	return b
}

// 参数节点不计入公共前缀
func longestCommonPrefix(a, b string) int {
	i := 0
	max := min(len(a), len(b))
	for i < max && a[i] == b[i] && a[i] != '{' && b[i] != '{' {
		i++
	}
	return i
}

// 查找参数节点,获得参数节点值和之前、之后的节点路径
// 如果path中包含参数,比如/A/{param}/B
// variable - {param}
// i - 3
func findVariable(path string) (variable string, i int) {
	// Find start
	for start, c := range []byte(path) {
		if c != '{' {
			continue
		}

		for end, c := range []byte(path[start+1:]) {
			if c == '}' {
				return path[start : start+1+end+1], start
			}
		}
		break
	}
	return "", -1
}

3.3.插入API

func (n *node) addRoute(path string, tag *tagdata) {
	fullPath := path

	// Empty tree
	if n.path == "" && n.indices == "" {
		n.insertChild(path, fullPath, tag)
		n.tp = NT_Root
		return
	}

walk:
	for {
		// 找到待插入path与当前节点path的公共前缀,遇到参数节点会提前返回
		i := longestCommonPrefix(path, n.path)

		// 公共前缀长度小于当前节点n.path,裂变当前节点为父子节点
		if i < len(n.path) {
			child := node{
				path:     n.path[i:],
				tp:       NT_Static,
				indices:  n.indices,
				instance: n.instance,
				variable: n.variable,
				tag:      n.tag,
			}

			n.instance = []*node{&child}
			n.variable = nil
			n.indices = string([]byte{n.path[i]})
			n.path = path[:i]
			n.tag = nil
		}

		// 公共前缀长度小于剩余path,剩余部分
		if i < len(path) {
			path = path[i:]
			idxc := path[0]

			if idxc != '{' {
				// 尝试查找同前缀n.instance子节点
				for i, c := range []byte(n.indices) {
					if c == idxc {
						n = n.instance[i]
						continue walk
					}
				}

				// 没有同前缀instance子节点,创建新的子节点,并执行insertChild
				n.indices += string([]byte{idxc})
				child := &node{tp: NT_Static}
				n.instance = append(n.instance, child)
				n = child
			} else {
				// 尝试查找同名n.variable子节点
				variable, _ := findVariable(path)

				for _, vchild := range n.variable {
					if variable == vchild.path {
						path = path[len(variable):]
						// 此时path要么是空串,要么是"/"开头的内容
						if len(path) > 0 {
							idxc = path[0]
							for i, k := range []byte(vchild.indices) {
								if k == idxc {
									// 继续向下查找
									n = vchild.instance[i]
									continue walk
								}
							}
							vchild.indices += string([]byte{idxc})
							child := &node{tp: NT_Static}
							vchild.instance = append(vchild.instance, child)
							n = child
						} else {
							// 空串,到达末端
							n = vchild
							goto finish
						}

						continue walk
					}
				}
			}
			n.insertChild(path, fullPath, tag)
			return
		}
	finish:
		// n.path与path相等,当前n就是输入path的末端节点
		td := &tagdata{
			fullpath: fullPath,
			next:     tag.next,
		}
		if td.next != nil {
			td.next.prev = td
		}
		tag.next, td.prev = td, tag
		n.tag = td
		return
	}
}

// 在当前node下插入path
func (n *node) insertChild(path, fullPath string, tag *tagdata) {
	for {
		// 查找路径中的参数
		wildcard, i := findVariable(path)
		if i < 0 { // 路径中没有参数
			break
		}

		// 解析参数
		if i > 0 {
			n.path = path[:i] // 保存前缀常量串到当前node
			path = path[i:]   // 取得path的其他部分
		}

		// 将参数部分构造一个新的子节点
		child := &node{
			tp:   NT_Variable,
			path: wildcard,
		}

		n.variable = append(n.variable, child)
		n.tp = NT_Static
		n = child

		// 参数不是末尾,将当前节点修改为参数节点,并继续迭代
		if len(wildcard) < len(path) {
			path = path[len(wildcard):]
			child := &node{
				tp:   NT_Static,
				path: path,
			}
			// 之所以赋值给instance子节点slice,是因为规则中假定不会出现{PARAM1}{PARAM2}的情形
			// 连续参数之间至少会有斜线分割,即{PARAM1}/{PARAM2},所以{PARAM1}下一个节点必然是instance
			n.instance = append(n.instance, child)
			n.indices += string([]byte{path[0]})
			n = child
			continue
		}

		// 到达path的末尾,结束
		break
	}

	// 将路径与全路径赋值给末端n节点
	n.path = path

	td := &tagdata{
		fullpath: fullPath,
		next:     tag.next,
	}
	if td.next != nil {
		td.next.prev = td
	}
	tag.next, td.prev = td, tag
	n.tag = td
}

3.4.查找API

3.4.1.自定义栈

为了避免递归函数调用,这里自定义了一个栈数据结构,用于实现非递归回溯

type stackNode struct {
	n *node  // 当前节点
	i int    // 子节点数组索引
	p string // path,用于跟n.variable匹配
	c bool   // 是否instance匹配失败
}

// 获取stackNode.n.variable中的下一个node
func (sn *stackNode) next() *node {
	if sn.i+1 < len(sn.n.variable) {
		sn.i++
		return sn.n.variable[sn.i]
	}
	return nil
}

type vstack []*stackNode

// 压栈
func (v *vstack) push(sn *stackNode) {
	*v = append(*v, sn)
}

// 弹栈
func (v *vstack) pop() *stackNode {
	sn := (*v)[len(*v)-1]
	*v = (*v)[:len(*v)-1]
	return sn
}

// 获取栈顶
func (v vstack) top() *stackNode {
	if len(v) == 0 {
		return nil
	}
	return v[len(v)-1]
}
3.4.2.匹配实现
func (n *node) match(path string, d debug) []string {
	// 栈长度设置为20,大部分路径深度不会达到这个数量
	var result []string
	stack := vstack(make([]*stackNode, 0, 20))

	fn := func(op int, path string) {
		if d != nil {
			d(op, path)
		}
	}

walk:
	for {
		prefix := n.path
		var idxc byte
		if n.tp == NT_Variable {
			i := 0
			// 当前节点是参数节点,跳过参数部分
			for i < len(path) && path[i] != '/' {
				i++
			}

			// 到达结尾且末端节点为合法路径,当前分支匹配结束
			if i == len(path) && n.tag != nil {
				result = append(result, n.tag.fullpath)
				fn(OP_Backword, n.path)
				goto loopback
			}

			// 截断path
			path = path[i:]
			if len(path) == 0 {
				// path匹配在非末端参数节点提前结束,当前分支匹配结束
				fn(OP_Backword, n.path)
				goto loopback
			}
			idxc = path[0]
		} else if len(path) > len(prefix) {
			if path[:len(prefix)] == prefix {
				// 前缀吻合当前节点
				path = path[len(prefix):]
				idxc = path[0]
			} else {
				// 当前路径匹配失败,需要回退
				fn(OP_Backword, n.path)
				goto loopback
			}
		} else if path == prefix {
			// 剩余路径完全匹配,当前分支匹配结束
			if n.tag != nil {
				result = append(result, n.tag.fullpath)
				fn(OP_Backword, n.path)
				goto loopback
			}
		}

		// 执行到这里,说明已经完成了当前节点的匹配,还需要在当前节点的子节点中继续匹配
		// 尝试在instance列表中匹配
		for i, c := range []byte(n.indices) {
			if c == idxc {
				// go deep
				stack.push(&stackNode{
					n: n,
					i: 0,
					p: path,
					c: true,
				})
				fn(OP_Forward, n.path)
				n = n.instance[i]
				continue walk
			}
		}

		// 尝试在variable列表中匹配
		if len(n.variable) > 0 {
			// 保存现场
			stack.push(&stackNode{
				n: n,
				i: 0,
				p: path,
			})
			fn(OP_Forward, n.path)
			n = n.variable[0]
			continue walk
		}

		// 当前路径匹配结束,回退
		fn(OP_Backword, n.path)
	loopback:
		top := stack.top()
		for top != nil {
			// instance分支匹配失败,尝试variable分支
			if top.c && len(top.n.variable) > 0 {
				top.i = 0
				top.c = false
				n = top.n.variable[0]
				continue walk
			}
			// variable下top.i分支匹配失败,尝试迭代top.i+1
			if next := top.next(); next != nil {
				n = next
				path = top.p
				continue walk
			}

			// top节点的variable已经完成遍历,出栈
			stack.pop()
			top = stack.top()
		}
		// stack为空,结束匹配
		break
	}
	return result
}

3.5.删除API

由于我这里的实践中,采用的是异步定时全量更新策略,具体来说,完成新树构造后,对树根对象采用RCU策略实现运行时更新,所以对于运行时是无锁的。
如果要实现删除,需要注意以下几点:

  • 当被删除API为非叶子节点时,不要真实删除节点,只更新其属性值即可
  • 被删除的节点如果是叶子节点,需要在其父节点上移除该节点的索引
  • 被删除的节点需要在双链表上脱链
  • 并发访问的状态下,需要加锁

4.复杂度及性能

4.1.复杂度

压缩字典树的复杂度主要取决于其中API的路径长度和API的差异度,这决定了树的深度。当路径差异度较大时,它可能退化为传统的字典树。另外一个约束条件是模板参数,由于需要对带有模板参数的节点进行回溯,这个过程也会显著影响匹配的性能。所以有如下复杂度分析

  • 对于传统字典树,每个节点在基于前缀进行查找,复杂度为常数1
  • 我们假设在同一个树节点位置上平均有m个不同的模板参数,则每个节点上有m次回溯。
  • 单个节点的匹配复杂度为1+m(单个子树匹配),匹配的节点数量在n次,其中n为API的长度或树的平均深度

实际复杂度为n+(m^n),即当不存在模板参数时,匹配的复杂度为n,即全常量压缩字典树。可见差异模板参数的数据起决定作用,约定良好的API规范可以有效提高匹配的性能

4.2.性能测试结果

测试思路是使用静态的API原始数据构造Radix树,然后使用线上环境的请求数据循环进行匹配查找,测试环境和数据如下

  • API条目:4w+
  • 线上请求数:30w+
  • 测试运行环境:2.3GHz、L2 256KB、L3 4M、Mem 8G,单线程运行

测试结果:每个API匹配耗时0.5-5μm

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值