php url路由入门实例

前言

「路由(routing)」这个词听起来像框架专属,其实它要做的事很朴素:把请求的 URL 和请求方法,映射到一段要执行的代码 。在没有框架的原生 PHP 里,这个过程通常被隐式地交给文件系统------一个 URL 对应一个 .php 文件,服务器按路径去找文件。文件一多,这套约定就会崩:user_detail.php、user_list.php、user_edit.php 散落一地,URL 的形态完全由目录结构决定。

路由要解决的正是这个耦合。它的核心思路是前端控制器(front controller) :所有请求先进入同一个入口脚本(通常是 index.php),由这张脚本里的路由表决定该调用哪个处理函数。URL 因此可以自由设计,/user/42 和 /u/42 对代码来说只是配置差异。

初学者最常见的两个误解:一是以为路由必须依赖框架或者 $_GET['r'] 这种参数;二是以为路由的「安全」来自 URL 长得不好猜。路由表本身就是一份白名单 ------用户能做的只是从你预先定义好的若干条路径里挑一条,他没有任何办法让程序去执行一个你没写进表里的文件。这一点才是路由在安全上的真正价值,也是它相对「include 用户传来的页面名」那种写法的根本区别。

本文用一个可以真实跑起来的例子(PHP 内置服务器 + 单入口脚本)把路由讲透,包含模式编译、方法匹配、参数提取和错误处理。示例以 PHP 8.0 及以上为基准,用到的箭头函数需要 PHP 7.4 及以上。

一、三种接入方式:从查询串到完全重写

URL 怎么把信息交给 PHP,有三种层次,复杂度递增:

方式 形如 是否需要服务器配置 说明

|-----|---------------------------------|-----|-----------------|
| 查询串 | index.php?r=user/detail&id=42 | 不需要 | 最省事,缺点是 URL 不好看 |

|-----------|----------------------------|---------|------------------------------------------------|
| PATH_INFO | index.php/user/detail/42 | 需要服务器允许 | Apache 要 AcceptPathInfo,Nginx 要传 PATH_INFO |

|------|------------------|--------|-------------------|
| 完全重写 | user/detail/42 | 需要重写规则 | URL 最干净,规则在服务器层配置 |

第三种方式依赖服务器把 /user/detail/42 重写进 index.php,这属于服务器配置,PHP 代码看不到重写过程------它只会发现 $_SERVER['REQUEST_URI'] 里是原始地址,而 $_SERVER['SCRIPT_NAME'] 是 index.php。

用 PHP 内置服务器(php -S)做开发时,不需要写重写规则:只要你用「路由脚本」的方式启动,内置服务器会把所有「找不到对应文件的请求」交给这个脚本处理。启动命令是:

bash 复制代码
php -S localhost:8000 router.php

这样访问 http://localhost:8000/user/42 时,因为磁盘上不存在 user/42 这个文件,请求就落到 router.php 里,由它来做路由匹配。这就是最简的「伪静态」开发环境。

二、让用户自己指定要执行的文件:一个必须避开的写法

在讲正面的例子里,先说清楚为什么需要路由表。下面这种写法在老教程里非常常见:

php 复制代码
<?php // ❌ 危险示范,不要这样写



// $page = $_GET['page'] ?? 'home';

// require __DIR__ . '/pages/' . $page . '.php';

问题在于 $page 完全由用户控制。即使加了一句 $page = basename($page); 来剥掉路径分隔符,攻击者仍然可以让你加载 pages/ 目录下的任意一个 PHP 文件------比如某个本该只由管理员触发的脚本。这类问题叫本地文件包含(Local File Inclusion,LFI),成因就是「把用户输入当成了代码路径」。

正确的做法是把「允许访问的页面」写成一张显式的白名单表:

php 复制代码
<?php // ✅ 白名单方式:用户只能从表里挑



$pages = [

    'home'    => __DIR__ . '/pages/home.php',

    'about'   => __DIR__ . '/pages/about.php',

    'contact' => __DIR__ . '/pages/contact.php',

];



$name = $_GET['page'] ?? 'home';



if (!array_key_exists($name, $pages)) {

    http_response_code(404);

    exit('页面不存在');

}



require $pages[$name];   // 这里的值来自代码,不来自用户

关键区别只有一句话:路径来自代码里的数组,而不是来自 $_GET 。这也是后面那个路由表例子的全部安全基础。另外,加载页面文件用 require 而不是 include------被包含的文件是程序的一部分,缺失就应该立刻中断,而不是继续执行下去。

三、实战:一个可运行的路由器

下面这份 router.php 可以直接跑。它把「方法 + 模式 + 处理函数」组成路由表,模式里的 {name} 会被编译成正则的具名捕获组。

php 复制代码
<?php // 适用于 PHP 8.0+(str_starts_with 为 8.0 新增)

// 文件:C:\proj\router.php

// 启动:php -S localhost:8000 router.php



$path   = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';



// 1) 内置服务器专用:磁盘上真实存在的静态文件直接交给服务器处理

if (PHP_SAPI === 'cli-server') {

    $full = realpath(__DIR__ . $path);

    if ($full !== false && is_file($full) && str_starts_with($full, __DIR__)) {

        return false;   // 返回 false 表示「我不处理,按静态文件返回」

    }

}



// 2) 路由表:顺序有意义,更具体的模式要写在前面

$routes = [

    ['GET',  '/',           fn(array $p): string => '首页'],

    ['GET',  '/user/list',  fn(array $p): string => '用户列表'],

    ['POST', '/user',       fn(array $p): string => '创建用户'],

    ['GET',  '/user/{id}',  fn(array $p): string => '用户详情:' . $p['id']],

];



/**

 * 把 /user/{id} 编译成 #^/user/(?P<id>[^/]+)$#

 */

function compile(string $pattern): string

{

    $regex = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $pattern);

    return '#^' . $regex . '$#';

}



$found          = false;

$methodMismatch = false;



// 3) 逐条匹配。用解构直接取出三个元素

foreach ($routes as [$verb, $pattern, $handler]) {

    if (preg_match(compile($pattern), $path, $m) !== 1) {

        continue;

    }



    if ($verb !== $method) {

        $methodMismatch = true;   // 路径对了但方法不对,继续找同路径的其他方法

        continue;

    }



    // 4) 只取具名捕获组,数字下标的整串匹配要丢掉

    $params = [];

    foreach ($m as $key => $value) {

        if (is_string($key)) {

            $params[$key] = $value;

        }

    }



    header('Content-Type: text/plain; charset=utf-8');

    echo $handler($params), PHP_EOL;

    $found = true;

    break;

}



// 5) 收尾:区分 404 和 405

if (!$found) {

    if ($methodMismatch) {

        http_response_code(405);

        header('Allow: GET, POST');

        echo '405 方法不允许', PHP_EOL;

    } else {

        http_response_code(404);

        echo '404 页面不存在', PHP_EOL;

    }

}

用 curl 或者浏览器挨个试一遍:

bash 复制代码
curl -i http://localhost:8000/

curl -i http://localhost:8000/user/42

curl -i http://localhost:8000/user/list

curl -i -X POST http://localhost:8000/user

curl -i -X POST http://localhost:8000/user/42

curl -i http://localhost:8000/not-exist

预期结果:

text 复制代码
GET  /              -> 200 首页

GET  /user/42       -> 200 用户详情:42

GET  /user/list     -> 200 用户列表

POST /user          -> 200 创建用户

POST /user/42       -> 405 方法不允许

GET  /not-exist     -> 404 页面不存在

有几个细节值得停下来看:

路由表的顺序就是匹配顺序。 /user/list 必须写在 /user/{id} 前面,否则 list 会被当成一个 id 匹配掉,得到「用户详情:list」。真实框架会用优先级或者完全匹配优先来处理,这里用「顺序即优先级」最简单也最容易理解。

具名捕获组和非具名捕获组要分开。 preg_match() 的结果数组里既有 0 这样的数字下标,也有 'id' 这样的字符串下标,混着用会在参数里多出一个无用的 0。只取 is_string($key) 的条目就干净了。

405 和 404 要分开。 路径存在但方法不对,按 HTTP 语义应该返回 405 并带上 Allow 响应头,而不是笼统地 404。这一点在提供 API 时尤其重要,客户端要靠它区分「地址写错了」和「该用 POST 却用了 GET」。

四、拿到参数之后:校验与命名

路由只负责「把 URL 拆成参数」,它不负责校验 。/user/{id} 匹配到的 id 是一个字符串 ,可能是 abc,可能是超长的一串,也可能带着奇怪的字符。处理函数里必须自己校验:

php 复制代码
<?php // 适用于 PHP 8.0+



// ❌ 错:$p['id'] 是字符串,直接拼进 SQL 就是注入点

// $sql = 'SELECT * FROM user WHERE id = ' . $p['id'];



// ✅ 先转成整数并校验范围

$id = filter_var($p['id'] ?? '', FILTER_VALIDATE_INT);



if ($id === false || $id < 1) {

    http_response_code(400);

    exit('参数不合法');

}



// ✅ 查询一律用参数化查询,占位符和值分开传

// $stmt = $pdo->prepare('SELECT id, name FROM user WHERE id = ?');

// $stmt->execute([$id]);

// $user = $stmt->fetch();

filter_var() 在格式不合法时返回 false,返回 false 时要做的是拒绝请求,而不是把 false 当 0 用------否则 ?id=abc 会变成查询 id 等于 0 的记录,得到一个「查到了但内容莫名其妙」的结果。

如果要做成优雅的 404(找不到用户时返回页面不存在而不是空页面),就在查询结果为空时统一走 404:

php 复制代码
<?php // 适用于 PHP 8.0+



// $user = $stmt->fetch();

// if ($user === false) {

//     http_response_code(404);

//     exit('用户不存在');

// }

需要更细的路由能力(可选参数、通配、正则约束、中间件)时,可以引入成熟的路由组件,例如 nikic/fast-route 或 Symfony 的 Routing 组件,它们的模型和上面这张表是一致的,只是把编译和匹配做得更完善。理解了这个最小实现,再看框架的路由文档会顺畅很多。

常见坑点

  1. 把用户输入当成要加载的文件名

❌ require __DIR__ . '/pages/' . $_GET['page'] . '.php'; ------ 典型的本地文件包含(LFI)入口 ✅ 用白名单数组把「页面名 → 绝对路径」固化在代码里,按名取路径

  1. 路由顺序颠倒导致固定路径被变量路径吃掉

❌ /user/{id} 写在 /user/list 前面,访问列表页时得到「用户详情:list」 ✅ 更具体的模式写在前面,或者给变量部分加正则约束

  1. 用 preg_match() 的结果不区分具名组与数字组

❌ 直接把 $m 当参数数组传给处理函数,参数里多出一个 0,值还是整条路径 ✅ 遍历时只收 is_string($key) 的条目

  1. 模式没有锚定首尾

❌ 编译成 #/user/(?P<id>[^/]+)#,于是 /aaa/user/42/bbb 也能匹配成功 ✅ 两边都加上 ^ 和 $:#^/user/(?P<id>[^/]+)$#

  1. 匹配到的参数不校验类型

❌ 把 $p['id'] 直接拼进 SQL ------ 路由参数同样是用户输入 ✅ filter_var($p['id'], FILTER_VALIDATE_INT) 校验,SQL 用参数化查询

  1. 方法不匹配时一律返回 404

❌ 所有异常情况都 http_response_code(404),客户端分不清「地址错」和「方法错」 ✅ 路径匹配但方法不匹配时返回 405,并带上 Allow 响应头

  1. 内置服务器下忘记处理静态文件

❌ 所有请求都进路由脚本,访问 /style.css 也返回 404 ✅ 路由脚本开头判断磁盘上是否存在该文件,存在就 return false; 让内置服务器自己处理

  1. 以为「URL 难看」就等于安全

❌ 把管理后台的 URL 起个猜不到的名字,就以为不需要权限校验 ✅ 路由只解决路径映射;每个处理函数里都必须独立做身份与权限校验

总结

事项 结论

|-------|---------------------------------------|
| 路由的本质 | 把「HTTP 方法 + URL 路径」映射到一段代码,通常由单入口脚本承担 |

|----------|----------------------------------------------------------|
| 与文件系统的关系 | 路由表是显式白名单,用户无法指定要执行哪个文件------这是它与 include($_GET) 的根本区别 |

|-------|-------------------------------------------|
| 参数从哪来 | 重写后仍是 $_GET;REQUEST_URI 始终是客户端发来的原始地址 |

|------|--------------------------------------------------------|
| 模式编译 | {name} 编译成具名捕获组 (?P<name>[^/]+),整条正则必须用 ^ $ 锚定 |

|------|---------------------|
| 匹配顺序 | 顺序即优先级,具体模式写在变量模式之前 |

|-----|------------------------------------------|
| 错误码 | 路径不存在返回 404;路径存在但方法不对返回 405 并给 Allow 头 |

|------|-------------------------------|
| 安全边界 | 路由不管校验,参数一律当外部输入处理;SQL 用参数化查询 |

路由的价值不在「URL 好看」,而在于它把「哪些入口是合法的」这件事从运行时的不确定状态,变成了代码里一张静态可审计的表。有了这张表,你既获得了 URL 设计上的自由,也顺手拿到了遏制文件包含类漏洞的结构性保障。

相关推荐
小羊没烦恼!1 小时前
关于大型asp.net应用系统的架构-架构的选择
java·服务器·开发语言·前端·c#
夏幻灵1 小时前
前端八股:JavaScript 深拷贝详解:实现方式、递归原理与循环引用
开发语言·前端·javascript
傲世仙尊1 小时前
C++起步-命名空间解决命名冲突与cin-cout缺省参数
开发语言·c++
fundoit2 小时前
资源服务器如何对 JWT 进行验签
java·运维·服务器·spring boot·php·oauth2
hljqwb2 小时前
java服务异常日志只打印异常类型,没有堆栈定位分析(十)
java·开发语言
ym hyd 1112 小时前
生鲜超市库存管理信息系统源码 Java+SpringBoot+Vue3 前后分离
java·开发语言·vue.js·spring boot·毕设
haerapi2 小时前
KingbaseES 全文检索实战之前:先建立中文搜索的可解释基线
开发语言·python·全文检索
徐小黑ACG2 小时前
Golang 基础02 控制语句
开发语言·后端·golang