Nugine 的个人博客关于

URL Router 背后的数据结构与算法

URL Router 将不同的URL映射到不同的处理函数,不管在前端后端都是一个重要的组件。本文将简要介绍用来实现 Router 的几种数据结构。

工作模式

Router 的工作模式通常分为两个阶段。

第一阶段:注册

输入一组确定的路径模式和处理函数,Router 解析路径模式,保存映射关系。

第二阶段:查找

输入一个路径,Router 查找对应的处理函数,输出结果。

举个例子:

1 <= "/posts/:post_id/comments/:id"
2 <= "/posts/:post_id/comments"    
3 <= "/posts/:post_id"             
4 <= "/posts"                      
5 <= "/comments"                   
6 <= "/comments/:id"               
7 <= "/file/*file_path"

查找 "/posts/100/comments/200",应该返回 1, 并且附带参数捕获 [("post_id", "100"), ("id", "200")].

查找 "/file/usr/bin/sudo", 应该返回 7,附带 [("file_path", "/usr/bin/sudo")].

查找 "/asdqwe", 应该返回 None.

常见功能

静态路径匹配:基本功能。

命名参数捕获:路径中可以包含动态部分,模式将动态部分命名,查找时要返回对应的捕获,也属于基本功能。

通配符:捕获某段以及某段之后的所有部分,这一功能对静态文件服务很重要。

嵌套路由:将包含某个前缀的所有路径转发到子路由器,例如 "/api/v1" 对应 v1 路由,"/api/v2" 对应 v2 路由,有利于模块化。

正则表达式:用正则作为路径模式,大幅提高自由度。

末尾斜杠修正:自动修正 "/home" 和 "/home/" 这类路径。

数据结构与算法

第一种:遍历

保存一个路径模式的数组,查询时挨个测试,返回第一个匹配。

毋庸置疑,这是速度最慢的,但也是扩展性最高的,能够轻易实现复杂的匹配。很多框架的 Router 实现为遍历测试正则表达式。

第二种:正则表达式集合

RegexSet 能够同时测试多个正则表达式,并返回哪些表达式匹配了字符串。

这种方法本质上是将多个正则表达式转换为更复杂的状态机,兼顾了效率和自由度。

第三种: 非确定有限状态自动机 (NFA)

URL 路径是分段的,如果有效利用模式中的信息,可以自动产生一个状态机来匹配,这也是正则表达式的实现原理。

状态机的实现方式和正则表达式集合有异曲同工之妙。理论效率相差不大,砍掉复杂的模式特性后能更加高效。

第四种:基数树

以下内容翻译整理自 julienschmidt/httprouter

URL 路径通常具有良好的层次结构,即大量的公共前缀,符合基数树的使用场合。

这是一棵基数树的示意图。

Priority   Path             Handle
9          \                *<1>
3          ├s               nil
2          |├earch\         *<2>
1          |└upport\        *<3>
2          ├blog\           *<4>
1          |    └:post      nil
1          |         └\     *<5>
2          ├about-us\       *<6>
1          |        └team\  *<7>
1          └contact\        *<8>

树在多个路径的不同字符处分叉,从树根一直匹配到叶子结点,即可得到匹配结果。结点可以是静态字符串,也可以是动态占位符。

子结点的匹配顺序是有讲究的,将子结点按权值排序,其中权值是路径长度,更具体的模式具有更高的优先级。

基数树的查找时间取决于树的深度和选择子结点的时间。通常来说,结点深度越深,子结点数越小,选择越快。因此这种实现会非常高效。

这种方式的弊端在于无法有效支持正则表达式,要想支持,只能将其他方式拼凑过来。

第五种:多叉树

\
|- posts                => 4
    |- :post_id         => 3
        |- comments     => 2
            |- :id      => 1
|- comments             => 5
    |- :id              => 6
|- file                 => None
    |- *file_path       => 7

将 URL 路径模式分为多段,插入多叉树,树在相同段的不同静态串处分叉,动态占位符也可分出一叉。

以下代码摘自 http-rs/path-table

多叉树非常符合直觉,反应快的人已经在脑海中实现整个算法了。

多叉树的查找速度取决于树的深度和选择分叉的速度。基于分段的树的深度通常非常小,大约在十几以下,开发者不会没事写几十上百段的 URL 路径模式。哈希的查找速度也非常快。因此这种方式甚至是最高效的。

同样,速度的代价是灵活性,多叉树无法支持正则表达式,也不能支持非整段的捕获,

第六种:位集 (BitSet)

同样基于分段,对于一个输入的路径,只有每段都匹配某个模式,才能最终获得处理函数。这个匹配过程可以转换为布尔表达式求值,用位集来同时求每个模式的值。

1 <= "/posts/:post_id/comments/:id"
2 <= "/posts/:post_id/comments"    
3 <= "/posts/:post_id"             
4 <= "/posts"                      
5 <= "/comments"                   
6 <= "/comments/:id"               
7 <= "/file/*file_path"

查找 "/posts/100/comments/200"

首先有一个位集 11111111,表示所有模式都启用。

第一段存储三个位集,表示有哪些模式在第一段的这个字符串有静态注册。

00001111 <= "posts"       
00110000 <= "comments"
01000000 <= "file"

选择 00001111,和 11111111 做与运算,得 00001111

第二段存储有一个位集,表示有哪些模式在第二段有动态注册。

01100111 <= dynamic

选择 01100111,和 00001111 做与运算,得 00000111

第三段

00000011 <= "comments"

求与得 00000011

第四段

00000001 <= dynamic

求与得 00000001

所有段匹配完毕,位集中有一个 1,位置是最右边,即第 1 位,表示第 1 个模式完全匹配,匹配成功。

为了支持其他功能,还要仔细添加算法细节,才能做到正确。

位集方式的查找速度取决于段数和每段中查找特定字符串的速度,还与位集的运算速度有关。如果仅支持不多于128个的模式,就可以用 u128 实现位集,用一条 SSE 指令实现运算。最终速度能比多叉树还快。

为了更快的速度,位集方式加上了数量限制。

总结

选择 URL Router 算法,无非是用自由换性能,用性能换自由,符合需要的就是好的。

Rust crates

遍历+正则 actix-router

正则表达式集合 reset-router

NFA route-recognizer

基数树 path-tree

多叉树 path-table

位集 nuclear-router

发布于 2020-02-13地址: GitHub, 知乎