惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
C
Cisco Blogs
Simon Willison's Weblog
Simon Willison's Weblog
Latest news
Latest news
P
Palo Alto Networks Blog
T
The Exploit Database - CXSecurity.com
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
AI
AI
阮一峰的网络日志
阮一峰的网络日志
量子位
Project Zero
Project Zero
Know Your Adversary
Know Your Adversary
Google Online Security Blog
Google Online Security Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
Schneier on Security
大猫的无限游戏
大猫的无限游戏
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
K
Kaspersky official blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
V
Visual Studio Blog
Spread Privacy
Spread Privacy
C
CERT Recently Published Vulnerability Notes
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
S
SegmentFault 最新的问题
Hugging Face - Blog
Hugging Face - Blog
W
WeLiveSecurity
博客园 - Franky
Last Week in AI
Last Week in AI
博客园 - 聂微东
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
IT之家
IT之家
I
Intezer
The Cloudflare Blog
月光博客
月光博客
N
News | PayPal Newsroom
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
V
V2EX
博客园_首页
罗磊的独立博客
NISL@THU
NISL@THU
爱范儿
爱范儿
Jina AI
Jina AI
有赞技术团队
有赞技术团队
The Hacker News
The Hacker News
A
Arctic Wolf
Scott Helme
Scott Helme
Engineering at Meta
Engineering at Meta
P
Proofpoint News Feed

Lynx

Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方 Lynx | 小熊写字的地方
Lynx | 小熊写字的地方
2022-03-15 · via Lynx

前言

对于一个 Node.js web 框架来说,路由系统的设计是重中之重。所谓路由,干的事情就是根据 HTTP method 和 URL 路径匹配需要执行的处理函数,并在处理函数中提供有关请求和回应的上下文。借助原生的 HTTP 模块,我们可以写出这样的代码,来实现一个简易的路由功能:

const http = require("http");

const host = 'localhost';
const port = 8000;

const requestListener = function (req, res) {
res.setHeader("Content-Type", "application/json");
switch (req.url) {
case "/hello":
res.writeHead(200);
res.end(JSON.stringify({"message": "world"}));
break;
default:
res.writeHead(404);
res.end(JSON.stringify({error:"Resource not found"}));
}
};

const server = http.createServer(requestListener);
server.listen(port, host, () => {
console.log(`Server is running on http://${host}:${port}`);
});

上面的这段代码有几个可优化点:

  1. 我们知道 HTTP 是一个文本传输协议,请求和响应内容实际上是一段字符串。因此我们在这里用了 JSON.stringify 来序列化响应 JSON,而响应内容的数据结构通常是固定的,这就给了我们优化的空间。比如已知会返回 {"message": msg},那直接做字符串拼接 '{"message":' + msg + '}',它的执行速度就会比 JSON.stringify 快。关于这块的性能优化,我会放到下一篇文章中单独介绍。
  2. 从可维护性的角度来看,它的请求分发逻辑和处理逻辑是耦合在一起的。在大型项目中,我们可能有成百上千个路由,这样的代码会变得难以维护。
  3. 路由查询性能。在这里例子中还不太能体现出来,因为它一共就1条路由规则。但假设项目中有 1000 条路由规则,怎么样根据请求方法和 path,快速匹配到对应的处理函数,这就需要一个高效的路由算法。

集中式路由 vs 分散式路由

对于第 2 点,我们很容易可以想到的一个优化方案是:将所有路由注册到一个映射表上。这个映射表大概是这样的:

get / => home#index
get /name => user#index
post /user => user#create

箭头左边是请求方法和URL路径,右边是对应的处理函数。这样需要新增路由的时候,只需要更新映射表,而不用调整原有代码逻辑。这也是一种很常见的代码优化技巧。这种我将其称之为集中式路由,因为所有路由规则都是集中管理的。基于这种设计,写出来的 Controller 大概长这样:

export default class AppController {
index(){
return {
status: 'ok'
});
}
}

当请求过来时,框架要找到对应的处理函数,首先要去逐行读取这个映射表文件,对每一行做正则匹配,拿到它的 Controller 名和处理函数名。其次框架要对 Controller 的命名和位置做约束,这样才能根据 Controller 名和处理函数名定位到具体的处理函数。

还有一种思路是,将路由规则的定义放到对应的Controller内部,这样路由的注册就分散到各个Controller了。以 hoth(百度内部基于 Fastify 自研的 Node 框架)为例,比如:

import {Controller, GET} from '@hoth/decorators';
import type {FastifyReply, FastifyRequest} from 'fastify';

@Controller('/index')
export default class AppController {
@GET()
getApp(req: FastifyRequest, reply: FastifyReply) {
return {
status: 'ok'
});
}
}

它使用了 Controller 和 GET 两个装饰器,实现了路由规则和处理函数的解耦,提升了代码的可读性和可维护性。装饰器只是一个语法糖,最终它还是走的 fastify 内部的路由注册逻辑:

fastify.get('/index', (req, reply) => {})

这里的 fastify.get 方法会在 Node.js 应用启动时执行,将路由规则加载到内存中。

对于小型项目,采用集中式路由或是分散式路由,对性能的影响微乎其微。这两种方案只有在大型项目中才能体现出性能优劣。集中式路由的问题在于,新增了一个(或多个)文件来单独存储这些路由规则,注册路由时框架需要解析文件,这就涉及到文件 IO;查询路由时可能还需要用到正则匹配,这对性能也是一种损耗。而像 fastify 采用的这种分散式路由,路由规则提前被注册到内存中,没有文件 IO,也不用正则来解析规则。

而且集中式注册路由也不利于应用的横向扩展,试想下有多个子应用的场景,框架就需要知道所有子应用的路由规则。

更高效的路由算法

除了上面讲到的注册路由的方式,存储路由的数据结构,对性能也有着不小的影响。最简单的可以想到用数组存储路由,它的查询性能是 O(n),随着路由数量增加,性能会越来越差。而在实际场景中,许多 HTTP 请求的路径有着相同的前缀。某些框架,比如 go 的 echogin,Node.js 的 fastify 会引入另一种数据结构:Radix-Trie,来存储路由。

计算机科学中,基数树Radix Trie,也叫基数特里树压缩前缀树)是一种数据结构,是一种更节省空间的Trie(前缀树),其中作为唯一子节点的每个节点都与其父节点合并,边既可以表示为元素序列又可以表示为单个元素。 因此每个内部节点的子节点数最多为基数树的基数r ,其中r为正整数,x为2的幂,x≥1,这使得基数树更适用于对于较小的集合(尤其是字符串很长的情况下)和有很长相同前缀的字符串集合。

ds-radix-tree

Radix-Trie 的插入、删除、查询的时间复杂度均为 O(k)(k为字符串集合中最长的字符串长度),因此无论路由规则增长多少,它都可以保证查询性能始终是常量级的。

fastify 的核心路由逻辑是另一个库 find-my-way 实现的,fastify 提供了 get、post、all 等方法用来注册路由,它们的内部调用了 find-my-way 上的 router.on 方法。当应用启动时,它会将所有路由构建成多个 Radix-Trie,比如:

fastify.get('/index', (req, reply) => {})

它其实就是在 Radix-Trie 上插入了一个新节点,tree 的数据结构大体是这样的:

{
prefix: '/',
label: '/',
method: 'GET',
children: {
i: {
prefix: 'index',
label: 'i',
method: 'GET',
children: [Object],
numberOfChildren: 1,
kind: 0,
handler: [Object],
},
u: {
prefix: 'user',
label: 'u',
method: 'GET',
children: [Object],
numberOfChildren: 1,
kind: 0,
handler: [Object],
},
numberOfChildren: 2,
kind: 0,
handler: [Object],
params: []
}
}

fastify 根据不同 method,将路由划分到多个树中。prefix 标记路由前缀,如果某个节点只有一个子节点,那么会与其父节点合并。kind 用于标记路由的类型,find-my-way 支持这五种路由类型:

{
STATIC: 0, // 静态路由,/info/version
PARAM: 1, // 带参数路由 /aaa/:id
MATCH_ALL: 2, // 通配路由 /aaa/*
REGEX: 3, // 正则路由 /at/(^\\d+)
MULTI_PARAM: 4 // 多参数路由 /foo/:param1-:param2
}

现在有这样几条路由(省略了handler):

GET /user
GET /user/list
GET /user/info
GET /admin/group
GET /admin/delete

它们构成的 tree 长这样:

radix-tree1.png

现在要新增一条路由 GET /usage,假定它是节点 a,插入节点 a 到 tree 的步骤如下:

  1. 遍历 tree,找到节点在树中的最长公共前缀长度 len,这里 usage 在树中的最长公共前缀是us,长度是2。

  2. 如果最长公共前缀长度 maxCommonPrefixLen 等于当前节点的前缀长度 prefixLen,将(a - 公共前缀)插入到这个节点的子节点。显然节点 a 的 maxCommonPrefixLen 是小于 prefixLen 的。

  3. 如果 maxCommonPrefixLen 小于 prefixLen,将节点 a 拆分为公共前缀 + 剩余b,将公共前缀作为父节点,插入树中,然后对剩余部分 b 重复上述步骤。这里会将 us 作为父节点,插入到树中,然后将剩余部分 age 作为子节点插入。

最终,新的 tree 长这样:

radix-tree2.png

讲完了路由节点的插入,再来看路由的查询。这里直接上代码:

Router.prototype.find = function find(method, path) {
var currentNode = this.trees[method];
if (!currentNode) return null;

var originalPath = path;
var originalPathLength = path.length;
var i = 0;
var idxInOriginalPath = 0;

while (true) {
var pathLen = path.length;
var prefix = currentNode.prefix;
var prefixLen = prefix.length;
var len = 0;
var previousPath = path;


if (pathLen === 0 || path === prefix) {
var handle = currentNode.handler;
if (handle !== null && handle !== undefined) {
return {
handler: handle.handler
};
}
}


i = pathLen < prefixLen ? pathLen : prefixLen;
while (len < i && path.charCodeAt(len) === prefix.charCodeAt(len)) len++;



if (len === prefixLen) {
path = path.slice(len);
pathLen = path.length;
idxInOriginalPath += len;
}

var node = currentNode.findChild(path);


if (node === null) {
var goBack = previousPath.charCodeAt(0) === 47 ? previousPath : '/' + previousPath;
if (originalPath.indexOf(goBack) === -1) {
var pathDiff = originalPath.slice(0, originalPathLength - pathLen);
previousPath = pathDiff.slice(pathDiff.lastIndexOf('/') + 1, pathDiff.length) + path;
}
idxInOriginalPath = idxInOriginalPath - (previousPath.length - path.length);
path = previousPath;
pathLen = previousPath.length;
len = prefixLen;
}


if (node.kind === NODE_TYPES.STATIC) {
currentNode = node;
continue;
}

}
};

总结

web 框架中路由的性能主要取决于路由注册和路由匹配两个环节,采用何种介质、何种数据结构存储路由规则,很大程度影响了路由库的性能。本文介绍了一种数据结构 Radix-Trie,及其它在 fastify 中的应用。

除了路由之外,数据序列化也是一个性能优化点,在进程通信、请求响应等环节都会涉及到序列化。下一篇文章将介绍 fastify 中如何实现的高性能 JSON 序列化。


欢迎加微信 xxr0314 聊一聊技术、人生,本人微信公众号:小熊写字的地方

qrcode_for_gh_a2d61e1595a5_258.jpg