Skip to content

[L3] 路由匹配如何通过前缀树结构与正则预编译实现高性能查找?

一句话结论

路由器将静态路由存哈希表做 O(1) 查找,动态路由编译为分块合并正则,路由缓存序列化编译结果跳过注册阶段。

体系讲解

一、路由分类与数据结构

路由注册后被分为两类,分别走不同的匹配路径:

类型示例匹配方式复杂度
静态路由/about/api/users哈希表直接查找O(1)
动态路由/users/{id}/posts/{slug}/comments正则匹配O(n) → 优化后接近 O(1)

静态前缀分组优化

拥有共同前缀的路由被组织为前缀树节点。匹配时先比较固定前缀,命中后才递归匹配子节点,大幅减少正则引擎的调用次数:

/api/
  ├── users          → UsersController@index
  ├── users/{id}     → UsersController@show
  └── posts/{slug}   → PostsController@show

请求 /api/xxx 时,先匹配 /api/ 前缀,再在子节点中寻找,避免全量扫描。

二、FastRoute 的正则预编译策略

nikic/fast-route 是 PHP 路由性能优化的代表实现,Laravel、Slim 等框架均受其影响或直接使用。

核心思路:将多条动态路由合并为一个大正则,利用捕获组编号(非命名捕获)的位置来确定匹配的是哪条路由。

以三条路由为例:

路由1: GET /users/{id:\d+}       → 捕获组占位从 1 开始
路由2: GET /posts/{slug}/{page}  → 捕获组占位继续递增
路由3: GET /tags/{name}          → 继续递增

合并后形成类似:

~^(?:
  /users/(\d+)           # 路由1:捕获组1
| /posts/([^/]+)/([^/]+) # 路由2:捕获组2、3
| /tags/([^/]+)          # 路由3:捕获组4
)$~x

匹配后检查哪个捕获组非空,即可定位到对应路由并提取参数,而无需逐条尝试正则。

分块(Chunk)策略

PCRE 对单个正则的捕获组数量有上限(通常约 500 个),当路由数量很多时,FastRoute 将路由分组为多个 chunk,每个 chunk 单独编译为一个正则,匹配时顺序尝试各 chunk,直到命中。

三、Laravel 路由缓存序列化

每次请求默认都需要执行路由文件(routes/web.php 等),注册数千条路由会有明显的启动耗时。php artisan route:cache 的执行流程:

1. 加载所有路由文件,执行 RouteServiceProvider
2. 对 RouteCollection 调用编译(分类、排序、预处理正则)
3. 将编译结果通过 var_export / serialize 写入
   bootstrap/cache/routes-v7.php(文件名含版本后缀)
4. 应用启动时检测缓存文件存在 → 直接 require 反序列化,
   跳过整个路由注册阶段

路由缓存的限制

闭包路由(Route::get('/', function () { ... }))包含匿名函数,PHP 原生无法序列化 Closureroute:cache 会抛出 LogicException。生产环境所有路由必须改写为控制器形式。

考察意图

考察候选人是否理解"路由注册"与"路由匹配"是两个分离的阶段,以及为什么大型项目必须启用路由缓存。同时考察对正则预编译这类"以空间换时间"性能优化模式的原理认知,而不只是停留在"用 route:cache 命令"的操作层面。

追问链

Q1:为什么不把所有路由放在一个正则里?FastRoute 分块策略的代价是什么?

PCRE 捕获组数量有上限(编译选项决定,通常约 500),单一超大正则也会让正则引擎内部回溯开销急剧上升。分块策略以多次正则匹配为代价换取可扩展性;在最坏情况下(命中最后一个 chunk)需要顺序尝试所有 chunk,但实际场景中路由按频率排序后,高频路由命中较早的 chunk,整体表现良好。

Q2:Laravel 路由缓存文件名为何包含版本后缀(routes-v7.php)?

版本后缀用于区分不同 Laravel 版本序列化格式的缓存文件,防止升级框架后旧格式缓存被误加载导致反序列化错误。每次 route:cache 都会生成或覆盖对应版本的文件,旧版本文件不会被自动清理,可手动用 route:clear 删除。

Q3:路由参数的 where 约束(如 ->where('id', '\d+'))是在匹配前还是匹配后生效?

where 约束在编译阶段就被合并进该路由的正则片段中(替换默认的 [^/]+ 捕获模式),不是匹配后再做二次验证。因此约束条件越精确,正则引擎的回溯越少,性能越好;同时也意味着约束错误会直接导致路由永远不匹配,而不是返回验证失败。

易错点

  1. 误以为每次请求都会重新编译正则:正则编译(字符串拼接、参数替换)发生在路由注册阶段,PCRE 引擎本身也会对已执行过的正则做内部缓存(JIT)。启用路由缓存后,注册阶段完全跳过,编译成本归零。

  2. 误以为动态路由用前缀树递归匹配:前缀树在这里主要用于静态前缀的快速分组,动态部分仍然需要正则。真正的"前缀树路由器"(如 Go 的 httprouter)在 PHP 中并不普遍,PHP 路由器的主流优化是正则预编译而非纯前缀树。

代码示例

php
<?php
// 演示正则预编译合并策略的核心思路(简化版,非 FastRoute 源码)

/**
 * 将多条动态路由编译为单一合并正则
 * 返回 [合并正则字符串, 路由映射表(捕获组起始偏移 => 路由信息)]
 */
function compileRoutes(array $routes): array
{
    $regexParts = [];
    $routeMap   = [];   // [captureOffset => ['handler' => ..., 'paramNames' => [...]]]
    $offset     = 1;    // 捕获组编号从 1 开始

    foreach ($routes as $pattern => $handler) {
        // 提取参数名,将 {param} 替换为捕获组
        $paramNames = [];
        $regexPart  = preg_replace_callback(
            '/\{(\w+)\}/',
            static function (array $m) use (&$paramNames): string {
                $paramNames[] = $m[1];
                return '([^/]+)';
            },
            $pattern
        );

        $routeMap[$offset] = ['handler' => $handler, 'params' => $paramNames];
        $regexParts[]      = $regexPart;
        $offset           += count($paramNames) ?: 1; // 至少占一个捕获组位置
    }

    $combinedRegex = '~^(?:' . implode('|', $regexParts) . ')$~';
    return [$combinedRegex, $routeMap];
}

function matchRoute(string $uri, string $regex, array $routeMap): ?array
{
    if (!preg_match($regex, $uri, $matches)) {
        return null;
    }

    // 找到第一个非空捕获组,确定命中的路由
    foreach ($routeMap as $offset => $route) {
        if (!empty($matches[$offset])) {
            $params = array_combine(
                $route['params'],
                array_slice($matches, $offset, count($route['params'])) ?: []
            );
            return ['handler' => $route['handler'], 'params' => $params];
        }
    }

    return null;
}

// --- 使用示例 ---
$routes = [
    '/users/(\d+)'      => 'UsersController@show',  // 手写正则示例
    '/posts/([^/]+)'    => 'PostsController@show',
    '/tags/([^/]+)'     => 'TagsController@index',
];

// 实际 FastRoute 会自动处理 {param} 语法和捕获组对齐
[$regex, $map] = compileRoutes([
    '/users/{id}'   => 'UsersController@show',
    '/posts/{slug}' => 'PostsController@show',
    '/tags/{name}'  => 'TagsController@index',
]);

$result = matchRoute('/posts/hello-world', $regex, $map);
// $result = ['handler' => 'PostsController@show', 'params' => ['slug' => 'hello-world']]
var_dump($result);

基于 Apache License 2.0 开源