利用前缀树实现一个路由

883 阅读4分钟

前缀树是一种多叉树的数据结构,可以用来实现快速查找,比如模糊词搜索中,可以根据用户输入的信息作为前缀,然后反馈模糊匹配的内容。 搜索.png

比如我在搜索栏输入「a」时,会反馈出apple/apkpure/aws等搜索提示,使用前缀树如图:

前缀树.png

前缀树每个节点代表一个字符,用户的输入作为前缀遍历子节点到终点,获取到完整的模糊匹配提示。

前缀树不仅可以应用在模糊搜索中,我们还可以使用前缀树来实现服务端「路由」的插入与查询。

使用前缀树实现路由的契机:

路由由/组成,例如/about/article/detail/:id等,我们可以将斜杠和内容作为前缀树的一个节点,如图:

路由.png

图中对应以下路由路径:

  • /user/:id
  • /user/info
  • /about
  • /article/detail/:id
  • /article/list

使用前缀树实现的好处有:

  • 效率高,性能好,凡是涉及到高效的算法都是往树数据结构靠拢(对比服务端框架expresskoa基于path-to-regexp实现的正则路由,在大量路由的条件下,前缀树具有一定的性能优势。当然实际中node常被当做中间层,所以性能基本可以忽略)
  • 可以实现动态路由,例如user/:id

实现前缀树路由

目标:

  • 动态参数,如路径/user/:id,可以匹配/user/1/user/2
  • 通配符*,例如/assets/*filepath,可以匹配/assets/picture.png,同样可以匹配/assets/js/jQuery.js

首先定义树节点结构

TreeNode {
  part: string       		 // 路由路径各个部分,如路径为`/user/:id`,则part为`/user`或`/:id`
  pattern: string		 // 路由全路径,只有在叶子节点保存,用于查询时判断是否叶子节点
  isWild: boolean		 // 我们要实现动态路由,isWild表示当前part为动态部分,当路径以`:`或`*`开头为true
  children: TreeNode[]	         // 子节点
}

实现树节点两个查询方法

class TreeNode {
  pattern: string = "";
  children: TreeNode[] = [];
  part: string = "";
  isWild: boolean = false;

  constructor(
    part: string = "",
    isWild: boolean = false,
    pattern: string = "",
    children: TreeNode[] = []
  ) {
    this.pattern = pattern;
    this.part = part;
    this.children = children;
    this.isWild = isWild;
  }

  // 查询子节点, 返回所有part相同或者为isWild动态节点的子节点
  matchChildren(part: string) {
    const nodes: TreeNode[] = [];
    for (let i = 0; i < this.children.length; i++) {
      const child = this.children[i];
      if (child.isWild || child.part === part) {
        // 保存子节点
        nodes.push(child);
      }
    }

    return nodes;
  }

  // 查询子节点, 返回第一个part相同或者为isWild动态节点的子节点
  matchChild(part: string) {
    for (let i = 0; i < this.children.length; i++) {
      const child = this.children[i];
      if (child.isWild || child.part === part) {
        return child;
      }
    }

    return null;
  }
}

接下来实现「插入」和「匹配」节点方法

class TreeNode {
  pattern: string = "";
  children: TreeNode[] = [];
  part: string = "";
  isWild: boolean = false;

  constructor(
    part: string = "",
    isWild: boolean = false,
    pattern: string = "",
    children: TreeNode[] = []
  ) {
    this.pattern = pattern;
    this.part = part;
    this.children = children;
    this.isWild = isWild;
  }

  // ...
  
  // 插入节点
  insertNode(pattern: string, parts: string[], height: number) {
    // 当路径被spilt('/')拆分成数据parts,其长度和height相等时,表明为叶子节点,完成pattern赋值操作,并返回
    if (parts.length === height) {
      this.pattern = pattern;

      return;
    }

    // 获取到当前路径部分
    const part = parts[height];
    // 插入节点只要查找到匹配的一个子节点即可
    let child = this.matchChild(part);

    // 子节点不存在,则new一个子节点,推入当前子节点中
    if (!child) {
      child = new TreeNode(part, part[0] === ":" || part[0] === "*");
      this.children.push(child);
    }

    // 子节点执行插入操作
    child.insertNode(pattern, parts, height + 1);
  }
  
  // 匹配节点,匹配失败时返回null
  matchNode(parts: string[], height: number): null | TreeNode {
    // 当遍历的高度和parts长度相等 或 part以*开始时(这个主要匹配/*filepath的路径)
    if (parts.length === height || this.part.startsWith("*")) {
    // 即使遍历的高度和parts长度相等,还需要判断当前节点是否为叶子节点,pattern为''不是叶子节点 例如插入了`/a/b/c`路由,匹配`/a/b`时,此时遍历的高度和parts长度相等,但对于`/a/b/c`路由的`b`部分不是叶子节点,因此匹配不成功
      if (this.pattern === "") {
        return null;
      }
      return this;
    }
    const part = parts[height];
    const nodes = this.matchChildren(part);

    // 遍历查询到的所有子节点,进行子节点的查询
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i];
      const matchedNode = node.matchNode(parts, height + 1);

      // 返回第一个匹配到的节点
      if (matchedNode) {
        return matchedNode;
      }
    }

    return null;
  }
}

关于前缀树节点的插入、查询操作已经完成,还需要定义路由类,来管理节点

class Router {
  // 根节点
  root: {
    [method: string]: TreeNode;
  } = {};

  // 添加路由
  addRoute(method: string, pattern: string) {
    // 解析路径成数组
    const parts = parsePattern(pattern);
    // 根据请求方法判断是否需要初始化根节点
    if (!this.root[method]) {
      this.root[method] = new TreeNode("/", true, "/");
    }

    // 通过根节点插入
    this.root[method].insertNode(pattern, parts, 0);
  }

  // 获取路由
  getRoute(method: string, path: string) {
    // 获取并解析路径
    const searchParts = parsePattern(path);
    const params: { [param: string]: string } = {};
    if (!this.root[method]) {
      return null;
    }

    // 匹配节点
    const node = this.root[method].matchNode(searchParts, 0);
    if (!node) {
      return [null, null];
    } else {
      // 根据实际请求路径和节点上的part解析出动态参数,如路由`user/:id`,请求路径为`user/1`,则params为{ id: 1 }
      const pathParts = parsePattern(node.pattern);

      for (let i = 0; i < pathParts.length; i++) {
        const part = pathParts[i];

        if (part.startsWith(":")) {
          params[part.slice(1)] = searchParts[i];
        }

        if (part.startsWith("*")) {
          params[part.slice(1)] = searchParts.slice(i).join("/");
        }
      }
      return [node, params];
    }
  }
}

// 分割路径,如路径`/a/b/c`,返回['a', 'b', 'c']
function parsePattern(pattern: string) {
  const pa = pattern.split("/");
  const parts = [];
  for (let i = 0; i < pa.length; i++) {
    const item = pa[i];
    if (item !== "") {
      parts.push(item);

      // 如果当前部分以`*`开始,则结束循环
      if (item[0] === "*") {
        break;
      }
    }
  }

  return parts;
}

现在我们可以操作上层的路由插入和查询路由节点

const router = new Router();

router.addRoute("GET", "/article/detail/:id");
router.addRoute("POST", "/static/*filepath");

// [
// 	TreeNode {
//     pattern: '/article/detail/:id',
//     children: [],
//     part: ':id',
//     isWild: true
//   },
//   { id: '1' }
// ]
router.getRoute("POST", "/article/detail/1");

// [
//   TreeNode {
//     pattern: '/static/*filepath',
//     children: [],
//     part: '*filepath',
//     isWild: true
//   },
//   { filepath: 'js/jquery' }
// ]
router.getRoute("POST", "/static/js/jquery");