前缀树是一种多叉树的数据结构,可以用来实现快速查找,比如模糊词搜索中,可以根据用户输入的信息作为前缀,然后反馈模糊匹配的内容。
比如我在搜索栏输入「a」时,会反馈出apple/apkpure/aws等搜索提示,使用前缀树如图:
前缀树每个节点代表一个字符,用户的输入作为前缀遍历子节点到终点,获取到完整的模糊匹配提示。
前缀树不仅可以应用在模糊搜索中,我们还可以使用前缀树来实现服务端「路由」的插入与查询。
使用前缀树实现路由的契机:
路由由/组成,例如/about、/article/detail/:id等,我们可以将斜杠和内容作为前缀树的一个节点,如图:
图中对应以下路由路径:
- /user/:id
- /user/info
- /about
- /article/detail/:id
- /article/list
使用前缀树实现的好处有:
- 效率高,性能好,凡是涉及到高效的算法都是往树数据结构靠拢(对比服务端框架
express或koa基于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");