路由匹配
Web 框架的作用,主要就是封装 Web 服务,整合网络相关的通用逻辑,一般来说也就是帮助 HTTP 服务建立网络连接、解析 HTTP 头、错误恢复等等;另外,大部分框架可能也会提供一些拦截器或者 middleware,帮助处理一些每个请求可能都需要进行的操作,比如鉴权、获取用户信息。
但是所有 Web 框架,无论设计得多么不同,必不可少的能力肯定就是路由匹配。
因为Web 服务通常会对外暴露许多不同的 API,而区分这些 API 的标识,主要就是用户请求 API 的 URL。所以,一个好用的 Web 框架,要能尽可能快地解析请求 URL 并映射到不同 API 的处理逻辑,也就是我们常说的“路由匹配”。
以 Golang 中的 Web 框架 Gin 为例,如果用户想注册一套遵循 RESTful 风格的接口,只需要像这样:
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,用于处理与用户相关的请求。在这个路由组中,每个HTTP请求方法(如POST、DELETE、GET、PUT)对应着不同的用户操作,例如创建用户、删除用户、获取用户信息、获取用户列表、更新用户信息和启用用户。
userRouter := router.Group("/users")
: 创建一个新的路由组,所有在这个组中的路由都会有一个共同的基础路径 /users
userRouter.POST("", user.CreateUser)
: 当收到一个指向/users
的 POST 请求时,将调用user.CreateUser
函数。userRouter.DELETE("/:userID", user.DeleteUserByUserID)
: 当收到一个指向/users/:userID
的 DELETE 请求时(:userID
是一个动态参数),将调用user.DeleteUserByUserID
函数。用于根据提供的用户ID删除特定用户的信息userRouter.GET("/:userID", user.GetUserInfoByUserID)
: 当收到一个指向/users/:userID
的 GET 请求时,将调用user.GetUserInfoByUserID
函数。这允许客户端通过指定的用户ID获取单个用户的信息userRouter.GET("", user.GetUserList)
: 当收到一个指向/users
的 GET 请求时,将调用user.GetUserList
函数。userRouter.PUT("/:userID", user.UpdateUser)
: 当收到一个指向/users/:userID
的 PUT 请求时,将调用user.UpdateUser
函数。用来更新特定用户的信息
那么这样的路由功能是如何实现的呢?
动态路由
每个 HTTP 请求都会带上需要访问的 URL,Web 框架其实也就是根据这个信息,再通过用户写出的代码中注册的路由和 handler 的关系,从而找到每个请求应该调用的处理逻辑。
那么,如何保存路由和处理方法的对应关系呢?
一个非常直接的想法,采用 HashMap 来存储路由表,这样索引起来非常高效。
但是事实上主流的 Web 框架都不会这样做,因为利用哈希表存储的路由和处理逻辑的关系,只能用来索引静态路由,也就是路由中没有动态参数的路由,比如/user/enable/vip ,这样的路由,路径是明确的,一个路由只有一种可能性。
但是在 Web 开发中,我们经常需要在路由中带上参数
最常见的动态参数就是各种 ID,虽然参数不同,但所对应的处理逻辑实际上是一致的,在很多 Web 框架中,这种路由的注册方式一般是写成 /column/article/:id ,其中 id 的参数就是10010或者其他不同的值,在框架的处理方法里,一般可以通过 context 之类的变量拿到。
这样的路由,就不再是单一的静态路由,而是可以对应某一类型的许多不同的路由,我们也称这种带有参数的路由为“动态路由”。
在这种需要支持动态路由的场景下,就不太能继续用 HashMap 记录路由和方法的绑定关系了。那动态路由如何实现呢?方式有很多种,可以用正则表达式匹配来实现,另一种更常用的方式就是 Trie 树。
1. 正则表达式匹配
实现方式
- 匹配路径: 将每条路由定义为正则表达式。例如,路由
/users/:userID
可以转换为正则表达式/users/([^/]+)
。 - 提取参数: 使用正则表达式的匹配功能来提取路径中的动态部分。
优点
- 灵活性: 可以处理复杂的路径模式,包括可选参数和多层嵌套。
- 统一性: 所有路由都可以通过正则表达式来表示,逻辑一致。
缺点
- 性能: 正则表达式的解析和匹配可能相对较慢,尤其是在大规模路由时。
- 可读性: 正则表达式可能不够直观,维护性较差。
Dart代码实现
import 'dart:core';
typedef HandlerFunc = void Function(Map<String, String> params);
// 路由类
class Route {
final RegExp pattern; // 正则表达式模式
final List<String> paramNames; // 参数名称列表
final HandlerFunc handler; // 处理函数
Route(this.pattern, this.paramNames, this.handler);
}
// 自定义路由器类
class MyRouter {
final List<Route> _routes = []; // 路由列表
// 添加路由
void addRoute(String path, HandlerFunc handler) {
final result = _pathToRegex(path); // 将路径转换为正则表达式
_routes.add(Route(result['regex']!, result['paramNames']!, handler)); // 将路由添加到列表
}
// 处理请求
void handleRequest(String path) {
for (var route in _routes) { // 遍历所有路由
final match = route.pattern.firstMatch(path); // 匹配请求路径
if (match != null) { // 如果匹配成功
final params = <String, String>{}; // 存储参数的映射
// 提取所有命名参数
for (var name in route.paramNames) {
params[name] = match.namedGroup(name)!; // 从匹配中获取参数值
}
route.handler(params); // 调用处理函数
return;
}
}
print('Route not found'); // 如果没有匹配的路由,打印未找到路由
}
// 将路径转换为正则表达式
Map<String, dynamic> _pathToRegex(String path) {
final List<String> paramNames = []; // 存储参数名称
var regexPath = path; // 初始化正则路径
// 第一步:替换动态参数 :param → (?<param>[^/]+)
regexPath = regexPath.replaceAllMapped(
RegExp(r':(\w+)'),
(m) {
final paramName = m.group(1)!; // 获取参数名称
paramNames.add(paramName); // 添加到参数名称列表
return '(?<$paramName>[^/]+)'; // 替换为正则表达式
},
);
// 第二步:处理可选路径段 [xxx],替换为 (?:/xxx)?
regexPath = regexPath.replaceAllMapped(
RegExp(r'\[([^]]+)\]'), // 严格匹配方括号内的内容
(m) {
final content = m.group(1)!; // 获取内容
// 递归替换内容中的动态参数
final replacedContent = content.replaceAllMapped(
RegExp(r':(\w+)'),
(innerMatch) {
final paramName = innerMatch.group(1)!; // 获取参数名称
paramNames.add(paramName); // 添加到参数名称列表
return '(?<$paramName>[^/]+)'; // 替换为正则表达式
},
);
// 转换为非捕获组并设为可选(注意移除方括号)
return '(?:/$replacedContent)?';
},
);
// 第三步:清除残留的非法符号(如 ^ 或 $)
regexPath = regexPath.replaceAll(RegExp(r'/\^/'), '/'); // 修复错误引入的 ^
// 生成最终正则表达式
return {
'regex': RegExp('^$regexPath\$'), // 返回正则表达式
'paramNames': paramNames, // 返回参数名称列表
};
}
}
测试用例:
void testRouter() {
final router = MyRouter();
// 添加带命名参数的路由
router.addRoute('/users/:userID/posts/:postID', (params) {
print('User ID: ${params['userID']}, Post ID: ${params['postID']}');
});
// 添加静态路由
router.addRoute('/about', (params) {
print('About Page');
});
// 添加带命名参数的路由
router.addRoute('/users/:userID', (params) {
print('users Page User ID: ${params['userID']}');
});
// 测试请求
router.handleRequest('/users/123/posts/456');
router.handleRequest('/about');
router.handleRequest('/users/345');
}
输出:
User ID: 123, Post ID: 456
About Page
users Page User ID: 345
2. Trie 树
实现方式
- 树结构: 使用 Trie 树(前缀树)来存储路由。每个节点代表路径的一部分,叶子节点存储对应的处理函数。
- 动态部分: 对于动态路由(如
:userID
),可以在树中使用特殊标记(如*
或:
)来表示变量部分。
优点
- 高效性: 路由查找时间复杂度为 O(m),其中 m 是请求路径的长度,适合大规模路由。
- 易于管理: 结构清晰,易于添加和删除路由。
缺点
- 复杂性: 实现相对复杂,需要管理树的插入和查找操作。
Dart代码实现
typedef HandlerFunc = void Function(Map<String, String> params);
// Trie 树节点
class TrieNode {
// 存储子节点的映射,键为路径段,值为对应的 TrieNode
Map<String, TrieNode> children = {};
// 如果是参数节点,则记录参数名,例如路径中的 :userID,paramName 为 userID
String? paramName;
// 是否为通配符节点,通配符节点用于匹配任意路径段
bool isWildcard = false;
// 处理函数,当路径匹配到该节点时,调用此函数处理请求
HandlerFunc? handler;
// 构造函数,可传入处理函数和参数名
TrieNode({this.handler, this.paramName});
}
// 基于 Trie 的路由器
class TrieRouter {
// Trie 树的根节点
final TrieNode root = TrieNode();
// 添加路由
void addRoute(String path, HandlerFunc handler) {
// 将传入的路径字符串按照 '/' 进行分割,并去除分割后得到的空字符串段
// 例如,路径 "/users/:userID/posts" 会被分割为 ["users", ":userID", "posts"]
final parts = _splitPath(path);
// 从 Trie 树的根节点开始,后续会在树中遍历节点
var node = root;
// 遍历路径分割后的每一个部分
for (var i = 0; i < parts.length; i++) {
// 获取当前正在处理的路径段
final part = parts[i];
// 判断当前路径段是否以 ':' 开头,如果是,则表示这是一个参数节点
if (part.startsWith(':')) {
// 参数节点 :param
// 提取参数名称,去掉开头的 ':',例如 ":userID" 提取出 "userID"
final paramName = part.substring(1);
// 检查当前节点的子节点中是否已经存在参数节点(用 ':' 作为标识)
if (!node.children.containsKey(':')) {
// 如果不存在,则创建一个新的 Trie 树节点,并设置其参数名称
node.children[':'] = TrieNode(paramName: paramName);
}
// 将当前节点移动到参数节点,以便后续处理
node = node.children[':']!;
} else if (part == '*') {
// 通配符 *
// 检查当前节点的子节点中是否已经存在通配符节点(用 '*' 作为标识)
if (!node.children.containsKey('*')) {
// 如果不存在,则创建一个新的 Trie 树节点
node.children['*'] = TrieNode();
// 将当前节点标记为通配符节点
node.isWildcard = true;
}
// 将当前节点移动到通配符节点,以便后续处理
node = node.children['*']!;
// 因为通配符必须在路径的最后,所以匹配到通配符后就不再继续遍历路径段了
break;
} else {
// 普通节点
// 检查当前节点的子节点中是否已经存在与当前路径段名称相同的节点
if (!node.children.containsKey(part)) {
// 如果不存在,则创建一个新的 Trie 树节点
node.children[part] = TrieNode();
}
// 将当前节点移动到普通节点,以便后续处理
node = node.children[part]!;
}
}
// 当遍历完路径的所有部分后,将传入的处理函数 handler 绑定到最终的节点上
// 这样,当匹配到该路径时,就可以调用这个处理函数来处理请求
node.handler = handler;
}
// 匹配路由
HandlerFunc? matchRoute(String path, Map<String, String> params) {
// 将传入的路径字符串按照 '/' 进行分割,并去除分割后得到的空字符串段
// 例如,路径 "/users/123/posts" 会被分割为 ["users", "123", "posts"]
final parts = _splitPath(path);
// 从 Trie 树的根节点开始,后续会在树中遍历节点进行匹配
var node = root;
// 遍历路径分割后的每一个部分
for (var i = 0; i < parts.length; i++) {
// 获取当前正在处理的路径段
final part = parts[i];
// 检查当前节点的子节点中是否存在与当前路径段名称相同的普通节点
if (node.children.containsKey(part)) {
// 匹配普通节点
// 如果存在,则将当前节点移动到该普通节点,继续下一个路径段的匹配
node = node.children[part]!;
} else if (node.children.containsKey(':')) {
// 匹配参数节点
// 如果当前节点存在参数节点(用 ':' 作为标识),则移动到参数节点
node = node.children[':']!;
// 获取参数节点的参数名称
final paramName = node.paramName!;
// 将当前路径段的值作为参数值,保存到传入的 params 映射中
// 例如,路径 "/users/123" 中,"123" 会被保存为参数 "userID" 的值
params[paramName] = part;
} else if (node.children.containsKey('*')) {
// 匹配通配符
// 如果当前节点存在通配符节点(用 '*' 作为标识),则获取通配符节点
final wildcardNode = node.children['*']!;
// 检查通配符节点是否有参数名称(有些情况下通配符可能关联了参数名)
if (wildcardNode.paramName != null) {
// 将从当前位置开始到路径末尾的所有路径段合并成一个字符串
// 作为通配符参数的值,保存到 params 映射中
params[wildcardNode.paramName!] =
parts.sublist(i).join('/');
}
// 一旦匹配到通配符,就找到了对应的路由,返回通配符节点的处理函数
return wildcardNode.handler;
} else {
// 如果以上情况都不满足,说明当前路径段无法在 Trie 树中找到匹配的节点
// 即未找到匹配的路由,返回 null
return null;
}
}
// 当遍历完路径的所有部分后,说明路径已经完整匹配到了 Trie 树中的一个节点
// 返回该节点绑定的处理函数,如果节点没有绑定处理函数,则返回 null
return node.handler;
}
// 辅助函数:将路径分割成段
List<String> _splitPath(String path) {
// 按 / 分割路径,并去除空段
return path.split('/').where((p) => p.isNotEmpty).toList();
}
// 处理请求
void handleRequest(String path) {
// 用于存储路径参数的映射
final params = <String, String>{};
// 调用 matchRoute 方法匹配路由
final handler = matchRoute(path, params);
if (handler != null) {
// 找到匹配的路由,调用处理函数
handler(params);
} else {
// 未找到匹配的路由,打印提示信息
print('Route not found');
}
}
}
测试用例:
void testTrieRouter() {
final router = TrieRouter();
// 添加路由
router.addRoute('/about', (params) {
print('About Page');
});
router.addRoute('/users/:id', (params) {
print('User ID: ${params['id']}');
});
router.addRoute('/users/:id/posts/:postId', (params) {
print('User ID: ${params['id']}, Post ID: ${params['postId']}');
});
// 测试请求
router.handleRequest('/about'); // About Page
router.handleRequest('/users/123'); // User ID: 123
router.handleRequest('/users/123/posts/456'); // User ID: 123, Post ID: 456
router.handleRequest('/notfound'); // Route not found
}
输出:
About Page
User ID: 123
User ID: 123, Post ID: 456
Route not found