You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

34前缀树Web框架中如何实现路由匹配

你好,我是微扰君。

不知不觉已经到工程实战篇的最后一讲了在这个章节中我们一起学习了很多工程中常用的算法如果你从事后端开发应该或多或少有些接触比如在Redis、Kafka、ZooKeeper等常用中间件里就经常出现理解它们的核心思想能给你的工作带来很大的帮助。

今天我们最后来聊一聊大部分Web开发工程师都会用到的后端Web框架中的算法。

路由匹配

Web框架的作用我们都知道主要就是封装Web服务整合网络相关的通用逻辑一般来说也就是帮助HTTP服务建立网络连接、解析HTTP头、错误恢复等等另外大部分框架可能也会提供一些拦截器或者middleware帮助我们处理一些每个请求可能都需要进行的操作比如鉴权、获取用户信息。

但是所有Web框架无论设计得多么不同必不可少的能力就是路由匹配。

因为我们的Web服务通常会对外暴露许多不同的API而区分这些API的标识主要就是用户请求 API的URL。所以一个好用的Web框架要能尽可能快地解析请求URL并映射到不同API 的处理逻辑,也就是我们常说的“路由匹配”

以Golang中常用的Web框架Gin为例如果用户想注册一套遵循RESTful风格的接口只需要像这样写一下注册每个路由所对应的handler方法就完成了

    userRouter := router.Group("/users")
    {
        userRouter.POST("", user.CreateUser)
        userRouter.DELETE("/:userID", user.DeleteUserByUserID)
        userRouter.GET("/:userID", user.GetUserInfoByUserID)
        userRouter.GET("", user.GetUserList)
        userRouter.PUT("/:userID", user.UpdateUser)
        userRouter.POST("/:userID/enable", user.EnableUser)
    }

例子中userRouter就代表着和用户相关的接口POST、DELETE、GET等方法标识着HTTP请求的method方法里第一个参数就是路由具体的值也就是URL的值而第二个参数是一个方法可以用来实现不同接口的处理逻辑。

这样的路由功能我们是如何实现的呢?

动态路由

每个 HTTP请求都会带上需要访问的URLWeb框架其实也就是根据这个信息再通过在用户写出的代码中注册的路由和handler的关系找到每个请求应该调用的处理逻辑。

所以,如何保存路由和处理方法的对应关系呢?

初看这个问题估计你一定会有一个非常直接的想法采用HashMap来存储路由表吧这样索引起来非常高效。

但是事实上主流的Web框架都不会这样做因为利用哈希表存储的路由和处理逻辑的关系,只能用来索引静态路由,也就是路由中没有动态参数的路由,比如/user/enable time.geekbang.org/hybrid/pvip ,这样的路由,路径是明确的,一个路由只有一种可能性。

但是在Web开发中我们经常需要在路由中带上参数这也是RESTful风格的接口所要求的。

最常见的动态参数就是各种ID比如极客时间的专栏URL路由中就带了专栏ID的参数 column/intro/100100901 ,这里的100100901就是一个特定的参数。虽然参数不同但所对应的处理逻辑实际上是一致的在很多Web框架中这种路由的注册方式一般是写成 /column/article/:id 其中id的参数就是100100901或者其他不同的值,在框架的处理方法里,一般可以通过 context 之类的变量拿到。

这样的路由,就不再是单一的静态路由,而是可以对应某一类型的许多不同的路由,我们也称这种带有参数的路由为“动态路由”。

显然在这种需要支持动态路由的场景下我们就不太能继续用HashMap记录路由和方法的绑定关系了。那动态路由如何实现呢方式有很多种可以用正则表达式匹配来实现另一种更常用的方式就是我们要重点学习的Trie树。

Trie树

我们先学习一下Trie树这个数据结构。

Trie树也称为前缀树或者字典树是一种常用的维护字符串集合的数据结构能用来做排序、保存、查询字符串常用场景比如搜索引擎关键词匹配、路由匹配、词频统计和字符串排序等等。

相比于HashMap和Map这样的数据结构Trie树有一些特别的优势尤其是上面说的可以适应类似于动态路由匹配的场景有着不可替代的作用。我们公司的开源产品EMQ X也有用到相关的数据结构来实现MQTT协议路由表。

先简单剧透一下,Trie树主要的特点和优势都建立基于前缀的树状存储方式上。具体是什么样的呢,我们看例子理解。

比如现在想要存储,geekgeektimegeekbanggetgo这样几个单词在trie树上我们是怎么存储的呢

图片

看trie树的示意图。你可以注意到在trie树中每个节点都代表着一个字符且有若干个子节点对于整个树状图来说从根节点出发到任意其他节点构成的路径一定构成我们要存储的字符串集中某一个的前缀或者就是其本身。

所以不同于同样是树状结构的二叉查找树实现的treemap在trie树中我们存储的字符串并不是直接存储在二叉树的节点中而是通过节点在树中的位置表示的。我们会为trie树中的结点标记颜色。如果标记为绿色表示根节点到当前节点的路径是一个集合中的字符串反之代表这个节点仅仅是某个字符串的前缀。

显然相比于treemap来说trie树存储的开销要小得多并且因为它天然的前缀匹配和排序的特性在很多时候也能帮助我们更快检索数据。最常见的比如在搜索引擎的网站中,我们有时候输入一部分内容,搜索框可能就会自动补全一些可能的选项,很多时候这个小功能的实现,用的就是前缀树前缀匹配的特性。

图片

前缀树具体如何用代码来实现呢?

前缀树实现

首先,我们还是要先用代码定义一下前缀树的结构体。

为了方便讲解我们就假设前缀树只存储英文单词所以我们的字符集只包括26个小写字母。那在这样的情况下用来表示每个节点的子节点也不用开动态数组了直接开一个26维的静态数组就可以下标025正好可以对应az这26个字母。

同时每个节点还需要像前面说的那样标记一下自身的颜色我们用isKey来表示当前节点是集合中的单词还是只是某些单词的前缀。

写成C++代码如下:

    struct trie_node
    {
        bool isKey;   // 标记该节点是否代表一个关键字
        trie_node *children[26]; // 各个子节点 
    };


现在有了基本的数据结构定义,我们自然也需要进行初始化、插入、查询等操作。先来看初始化和插入的过程。

一开始我们只有一个代表空串的空节点所以初始化的过程很简单就是创造一个空的根节点其子节点也都是空指针同时因为空节点不代表任何串isKey必然也是false。

class Trie {
    trie_node* root;
public:
    Trie() {
        root = new trie_node();
        root->isKey = false;
        for (int i = 0; i < 26; i++) {
            root->children[i] = NULL;
        }
    }
    
    /** Inserts a word into the trie. */
    void insert(string word) {
        trie_node* node = root;
        // 循环判断单词的每个字母是否被当前节点node的子节点所包含
        for (auto ch: word) {
            // 不包含则需要创建
            if (node->children[ch-'a'] == NULL) {
                node->children[ch-'a'] = new trie_node();
                node->children[ch-'a']->isKey = false;
            }
            // 否则将当前节点指向下一个节点继续这个过程
            node = node->children[ch-'a'];
        }
        // 遍历完成时当前节点的位置就是一个被包含于字符集的串需要将标记置true
        node->isKey = true;
    }
}

具体的插入过程其实就是要沿着根节点根据插入单词每一位的字母一路往下遍历选择合适的分支判断每个字母是否被trie树所包含如果遇到尚未被包含的字母我们需要在对应字母的位置创建节点并循环这个过程直到整个单词的每个字母都被添加到trie树中。

最后记得把单词的最后一个字母节点的isKey标记置为true表示这个单词被成功加入集合。

有了插入过程的基础搜索的过程理解起来就很简单了沿着trie树一路搜索单词的每个字母直到遇到空的子节点或者最后遍历完成发现当前节点的isKey为false这代表所有字母虽然都在trie树中存在但这只是某个单词的前缀而不是全部如果最后一个字母在trie树中且isKey为true说明单词存在于集合中我们找到了它。

    /** Returns if the word is in the trie. */
    bool search(string word) {
        trie_node* node = root;
        for (auto ch: word) {
            // 字母不存在
            if (node->children[ch-'a'] == NULL) return false;
            node = node->children[ch-'a'];
        }
        // 仅仅是前缀
        return node->isKey;
    }    

我们来简单分析一下前缀树的复杂度。

假设查询的单词平均长度为n字符集大小为t。对于查询和插入来说我们所做的就是遍历一遍整个单词并在树上创建节点或者移动时间复杂度和插入单词的平均长度一致为O(n)。

空间复杂度相对差一些上面的实现方式里由于我们为每个节点都开了字符集大小的数组所以空间复杂度是O(t*N)其中N是节点的数量最差是所有单词的长度和。

Trie树在路由匹配中的应用

好了相信你现在已经理解了trie树是如何工作的了那它是如何用于动态路由匹配的呢

首先对于静态路由,相信你应该已经猜到了,我们只需要稍作调整,从每个节点表示一个字符,变成每个节点表示路由被 / 分割的一节,比如图片里,我们就存储了两条静态路由 /users/users/register

图片

而动态路由表示起来其实是一样的只不过我们在匹配下一层节点的时候会优先匹配有静态路由规则的值如果没有匹配上同一层又有其他动态路由的占位符号我们才会去认为对应的URL匹配的是动态路由中的动态参数。

比如/users/register肯定会匹配/users/register,但是/users/regissss/enable会匹配到/users/:uid/enable,并把regisss当成/:uid传入对应URL的handler中。

除此之外插入和查询的过程和前面讲的的trie树实现是一致的感兴趣的话你可以自己动手写一个Web框架感受一下或者去查阅一些经典路由框架的源码一定会有很多收获的。

总结

前缀树采用了独特的树状存储结构是一种高效的有序集合的实现通常集合元素存储的是字符串。但是不同于treemap直接在节点中存储键前缀树在节点中存储的是某个串的一个组成单元对于字符串来说通常就是一个字符集合中的每个元素由节点在树中的位置来标记根结点到每个标记为key的节点的路径构成了集合中的所有元素。

也正是因为这样的特性前缀树天然就做到了对集合的字典序的维护特别适合各种前缀匹配的场景在字符串检索、敏感词过滤、搜索推荐、词频统计等场景中多有应用。我们Web框架动态路由的功能也多是基于trie树实现的。另外力扣上就有一道实现前缀树的题目,你可以试着做一做。

课后思考

今天留给你两个课后思考题。

  1. 可以尝试自己实现一下trie树和基于trie树的路由匹配逻辑看看在路由匹配的场景下我们是否需要做一些什么不同的改造。
  2. 文中给出了一种trie树的C++实现,其中提到这种实现的空间效率不是很好,主要原因就在于我们为每个节点都开了等同于字符集大小的数组,但其中显然很大一部分都存的是空指针。你有没有什么办法优化呢,优化后会出现什么新的问题吗?

欢迎你在评论区留下你的思考,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习~