koa-router-tree 源码解析

Posted 小平果118

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了koa-router-tree 源码解析相关的知识,希望对你有一定的参考价值。

npm包:https://www.npmjs.com/package/koa-tree-router

1.基数树

这次学习的是koa-router-tree中的路由,在学习源码一种我们看到了koa-router-tree的路由是它的特色。然而基础数据使用了基数树也提供了性能的保障。因为路由这部分比较独立而且逻辑相对复杂,所以需要单独学习。首先我们需要了解的是基数树,百度百科中的解释

其中有一个图可以让我们更加直观的看到数据是如何存储的。

基数树,相当于是一种前缀树。对于基数树的每个节点,如果该节点是确定的子树的话,就和父节点合并。基数树可用来构建关联数组。在上面的图里也可以看到,基数树数据结构会把所有相同前缀都提取,剩余的都作为子节点。

2. 基数树在koa-router-tree中的应用

从上面可以看到基数树是一个前缀树,图中也可以看到数据结构。那基数树在koa-router-tree中是如何应用的呢?举一个例子其实就能看得出来

2.1 koa-router-tree使用分析

const Router = require("koa-tree-router");
const router = new Router();

router.GET("/support", handler1)
router.GET("/search", handler2)
router.GET("/contact", handler3)
router.GET("/group/user/", handler4)
router.GET("/group/user/test", handler5)

最终的内存结构为:

可以看到 router使用get方法添加了5个路由,实际存储结果就是上面显示的。我特地在后面加上了每个节点中的handler和indices。

indices是有序保存所有子节点的第一个字符形成的字符串。为什么要特意突出这个字段,因为在查找子节点下面是否包含path的时候不需要循环子节点,只需要循环这个字段就可以知道是否包含。这样的操作也可以提升一些效率。

2.2 再举个栗子分析下

从根节点遍历到叶子节点我们就能得到完整的路由表,如果项目实现了以下路由:

GET("/", func1)
GET("/search/", func2)
GET("/support/", func3)
GET("/blog/", func4)
GET("/blog/:blogid/", func5)
GET("/about-us/", func6)
GET("/about-us/team/", func7)
GET("/contact/", func8)

对应的路由树的结构:

Priority   Path             Handle
9          /                func1
3          ├s               null
2          |├earch/         func2
1          |└upport/        func3
2          ├blog/           func4
1          |:blogid      null
1          |/     func5
2          ├about-us/       func6
1          |        └team/  func7
1          └contact/        func8

:blogid是一个占位符(就是一个参数)。

这里体现了radix tree相较于hash-map的一个优点,树结构允许我们的路径中存在动态的部分(参数),因为我们匹配的是路由的模式而不是hash值

为了更具扩展性,每一层的节点按照priority排序,priority是节点的子节点(儿子节点,孙子节点等)注册的handler的数量,这样做有两个好处:

被最多路径包含的节点会被最先评估。这样可以让尽量多的路由快速被定位。

有点像成本补偿。最长的路径可以被最先评估,补偿体现在最长的路径需要花费更长的时间来定位,如果最长路径的节点能被优先评估(即每次拿子节点都命中),那么所花时间不一定比短路径的路由长。下面展示了节点(每个-可以看做一个节点)评估的路径:从左到右,从上到下

├------------  
├---------
├-----
├----
├--
├--
└-
  1. 源码查看

在的路由koa-router-tree中,每一个Http Method(GET, PUT, POST…)都对应了一棵 radix tree

class Router 
  constructor(opts = ) 
    this.trees = ;
    this.opts = opts;
  
  // 注册路由,生成 radix tree
  on(method, path, ...handle) 
    // 获取method对应的树,如果没有就创建,只有根节点
    if (!this.trees[method]) 
      this.trees[method] = new Node();
    
    if (this.opts.prefix) 
      path = this.opts.prefix + path;
      if (path[path.length - 1] === '/') 
        path = path.slice(0, path.length - 1);
      
    
    // 增加树的路由节点
    this.trees[method].addRoute(path, handle);
    return this;
  

trees 是个数组,数组里会有不同请求方法的路由树。


先看一下节点Node的对象的定义和如何调用的,需要注意的是indices这个字段 上面已经提到了它的作用

3.1 数据结构

const STATIC = 0; // 普通节点,默认
const ROOT = 1; // 根节点
const PARAM = 2; // 参数路由,比如 /user/:id
const CATCH_ALL = 3; // 匹配所有内容的路由,比如 /article/*key

/**
 * 根据path计算 params
 * @param string path 
 */
function countParams(path) 
  let n = 0;
  for (let i = 0; i < path.length; i++) 
    if (path[i] !== ":" && path[i] !== "*") 
      continue;
    
    n++;
  

  return n;



class Node 
  /**
   * 保存这个节点上的URL路径, 例如上图中的search和support, 共同的parent节点的path="s",后面两个节点的path分别是"earch"和"upport"
   * @param string path
   * 
   * 判断当前节点路径是不是参数节点, 例如path中包含,:shopId部分就是wildChild节点
   * @param boolean wildChild
   * 
   * 节点类型包括static, root, param, catchAll
   * static: 静态节点, 例如上面分裂出来作为parent的s
   * root: 如果插入的节点是第一个, 那么是root节点
   * catchAll: 有*匹配的节点
   * param: 除上面外的节点
   * @param number type
   * 
   * 和children[]对应, 保存的是分裂的分支的第一个字符
   * 例如search和support, 那么s节点的indices对应的"eu"
   * 代表有两个分支, 分支的首字母分别是e和u
   * @param string indices
   * 
   * 保存孩子节点
   * @param Node[] children
   * 
   * hadle的actions
   * @param function[]= handle
   * 
   * 优先级,子节点注册的handler数量
   * @param number priority
   */
  constructor(
    path = "",
    wildChild = false,
    type = STATIC,
    indices = "",
    children = [],
    handle = null,
    priority = 0
  ) 
    this.path = path;
    this.wildChild = wildChild;
    this.type = type;
    this.indices = indices;
    this.children = children;
    this.handle = handle;
    this.priority = priority;
  
  /**
   *
   * @param number pos
   */
  addPriority(pos) 
    const children = this.children;
    children[pos].priority++;
    const prio = children[pos].priority;

    // Adjust position (move to fron)
    let newPos = pos;
    while (newPos > 0 && children[newPos - 1].priority < prio) 
      const temp = children[newPos];
      children[newPos] = children[newPos - 1];
      children[newPos - 1] = temp;
      newPos--;
    

    // Build new index char string
    if (newPos !== pos) 
      this.indices =
        this.indices.slice(0, newPos) +
        this.indices[pos] +
        this.indices.slice(newPos, pos) +
        this.indices.slice(pos + 1);
    

    return newPos;
  

type定义了四种节点类型:

param 与 catchAll 使用的区别就是:*的区别。*会把路由后面的所有内容赋值给参数key;但 :可以多次使用。比如:/user/:id/:no是合法的,但 /user/*id/:no是非法的,因为 *后面所有内容会赋值给参数 id

wildChild

如果孩子节点是通配符(*或者:),则该字段为 true。

3.2 路由注册

接下来我们需要看的是addRoute这个方法了,方法体比较长。其实大多的逻辑都在处理带参数的节点,真正核心的逻辑其实并不多。我把主要的逻辑都写上了注释应该还是比较容易理解的。如果看不懂其实一步步debug几次也能帮助理解。

  /**
   * 将path对应的handler添加到路由中
   * @param string path
   * @param function[] handle
   */
  addRoute(path, handle) 
    let n = this;
    //记录原始path
    let fullPath = path;
    n.priority++;
    //统计path中包含多少参数 就是判断`:`,`*`的数量 最多255个
    let numParams = countParams(path);

    // 判断节点是否为空
    if (n.path.length > 0 || n.children.length > 0) 
      walk: while (true) 
        // Find the longest common prefix
        // This also implies that the common prefix contains no ':' or '*'
        // since the existing key can't contain those chars.
        let i = 0;
        const max = Math.min(path.length, n.path.length); // 找到相同前缀 循环次数 是取 path 和 n.path 长度的小那个长度
        while (i < max && path[i] === n.path[i])  //循环判断是否字符相同,相同则i++ 直到最后
          i++;
        

        // Split edge
        //判断是否有前缀相同,如果有相同的则把目前这个节点提取出来作为子节点
        //再把相同前缀的path部分作为 父节点
        //比如n的path = romaned 现在新增路由的path = romanus 相同前缀为 roman
        //步骤为:
        //1. 提取ed 新建一个child节点 把原来n的属性都复制过去
        //2. 把原来的n的path改为相同前缀:roman 为indices添加 子节点的第一个字符:e
        if (i < n.path.length) 
          const child = new Node(
            n.path.slice(i), // 不匹配的部分作为child节点
            n.wildChild,
            STATIC,
            n.indices,
            n.children,
            n.handle,
            n.priority - 1 // 降级成子节点,priority减1
          );

          // 当前节点的子节点变成刚刚分裂的出来的节点
          n.children = [child];
          n.indices = n.path[i];
          n.path = path.slice(0, i);
          n.handle = null;
          n.wildChild = false;
        

          //原先的节点n现在已经分成2个节点了 结构为:
          //roman 父节点
          //    ed    子节点[0]
          //那么现在需要把传入的路由添加到这个父节点中
          //最终结构为
          //roman 父节点
          //    ed 子节点[0]
          //    us 子节点[1]
          // 其中还有一些情况需要自调用 相当于递归 举例说明:
          //roman
          //    ed
          //    uie
          //当判断父节点n 本来就有一个uie子节点 这时候uie和us 又有相同前缀u 这个时候需要把这个u再次提取出来作为父节点 所以需要递归调用walk
          //最终结果为 三层结构
          //roman
          //    ed
          //    u
          //        ie
          //        s
          //还有一种情况是如果是带有参数的路由 则也会再次调用walk
        // Make new node a child of this node
        if (i < path.length) 
          path = path.slice(i);

          if (n.wildChild) // 如果是参数节点(包含:或*)
            n = n.children[0];
            n.priority++;

            numParams--;

            // Check if the wildcard matches, // 例如:/blog/:pp 和 /blog/:ppp,需要检查更长的通配符
            if ( path.length >= n.path.length && n.path === path.slice(0, n.path.length) && (n.path.length >= path.length || path[n.path.length] === "/")) 
              continue walk;
             else 
              // Wildcard conflict
              let pathSeg = "";
              if (n.type === CATCH_ALL) 
                pathSeg = path;
               else 
                pathSeg = path.split("/")[0];
              
              const prefix = fullPath.slice(0, fullPath.indexOf(pathSeg)) + n.path;
              throw new Error(
                `'$pathSeg' in new path '$fullPath' conflicts with existing wildcard '$
                  n.path
                ' in existing prefix '$prefix'`
              );
            
          

          const c = path[0];// 首字母,用来与indices做比较

          // Slash after param
          if (n.type === PARAM && c === "/" && n.children.length === 1) 
            n = n.children[0];
            n.priority++;
            continue walk;
          

          // Check if a child with the next path char exists
          // 判断子节点中是否有和当前path有匹配的,只需要查看子节点path的第一个字母即可,即indices
           // 比如s的子节点现在是earch和upport,indices为eu
           // 如果新来的路由为super,那么就是和upport有匹配的部分u,将继续分类现在的upport节点
          for (let j = 0; j < n.indices.length; j++) 
            if (c === n.indices[j]) 
              j = n.addPriority(j);
              n = n.children[j];
              continue walk;
            
          

          // Otherwise insert it
          if (c !== ":" && c !== "*") 
             // 记录第一个字符,放在indices中
            n.indices += c;
            const child = new Node(
              "",
              false,
              STATIC
            );
            // 增加子节点
            n.children.push(child);
            n.addPriority(n.indices.length - 1);
            n = child;
          
          n.insertChild(numParams, path, fullPath, handle);
          return;
         else if (i === path.length) 
          // Make node a (in-path leaf)
          // 路径相同,如果已有handler就报错,没有就赋值
          if (n.handle !== null) 
            throw new Error(
              "A handle is already registered for path '" + fullPath + "'"
            );
          
          n.handle = handle;
        
        return;
      
     else 
      // 节点为空,直接添加直接添加路由,节点种类是root
      n.insertChild(numParams, path, fullPath, handle);
      n.type = ROOT;
    
  
  

3.3 插入子节点

insertChild函数是根据path本身进行分割, 将/分开的部分分别作为节点保存, 形成一棵树结构. 注意参数匹配中的:*的区别, 前者是匹配一个字段, 后者是匹配后面所有的路径

/**
   * 添加节点函数 主要处理包含参数节点
   * @param number numParams 参数个数
   * @param string path 路径
   * @param string fullPath 完整路径
   * @param function[] handle 处理函数
   */
  insertChild(numParams, path, fullPath, handle) 
    let n = this;
    let offset = 0; // Already handled chars of the path

    // 循环查找前缀为':' 或者 '*' 通配符,只要匹配到wildcard
    for (let i = 0, max = path.length; numParams > 0; i++) 
      const c = path[i];
      if (c !== ":" && c !== "*") 
        continue;
      

      // 判断在*参数之后不能再有*或者: 否则则报错 除非到了下一个/
      let end = i + 1;
      while (end < max && path[end] !== "/") 
        if (path[end] === ":" || path[end] === "*") 
          throw new Error(
            "only one wildcard per path segment is allowed, has: '" +
              path.slice(i) +
              "' in path '" +
              fullPath +
              "'"
          );
         else 
          end++;
        
      

      // 检查这个节点是否存在子节点,如果我们在这里插入通配符,子节点将是不可访问的
      if (n.children.length > 0) 
        throw new Error(
          "wildcard route '" +
            path.slice(i, end) +
            "' conflicts with existing children in path '" +
            fullPath +
            "'"
        );
      

      // check if the wildcard has a name
      if (end - i < 2) 
        throw new Error(
          "wildcards must be named with a non-empty name in path '" +
            fullPath +
            "'"
        );
      
      
      // 参数类型 相当于注册路由时候带有:
      if (c === ":") 
        // Split path at the beginning of the wildcard
        if (i > 0) 
          n.path = path.slice(offset, i);
          offset = i;
        

        const child = new Node("", false, PARAM);
        n.children = [child];
        n.wildChild = true;
        n = child;
        n.priority++;
        numParams--;
        if (end < max) 
          n.path = path.slice(offset, end);
          offset = end;

          const staticChild = new Node(
            "",
            false,
            STATIC,
            "",
            [],
            null,
            1
          );
          n.children = [staticChild];
          n = staticChild; // 下次循环这个新的child节点
        
       else 
         // 如果是通配符*
        if (end !== max || numParams > 1) 
          throw new Error(
 

以上是关于koa-router-tree 源码解析的主要内容,如果未能解决你的问题,请参考以下文章

复用tcmalloc源码中的基数树进行优化

复用tcmalloc源码中的基数树进行优化

Redis源码剖析--基数统计hyperloglog

Redis源码剖析--基数统计hyperloglog

mysql选错娱乐平台源码搭建索引的原因与处理

parseInt() 函数的奇怪行为