ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PHP内置服务器URL重写:用router script实现本地单入口路由

PHP内置服务器URL重写:用router script实现本地单入口路由 本地开发用 PHP 内置服务器php -S起服务最常被吐槽的一点就是它既没有 Apache 的 .htaccess也没有 Nginx 的 location 和 rewrite 模块一访问 /user/1 这种不带 .php 后缀的地址直接甩你一个 404。很多人到这一步就放弃了转头装一套 Nginx或者把线上的重写规则复制过来靠猜结果本地能跑、线上打脸或者反过来。其实在 PHP 内置服务器上实现 URL 重写核心只有一个东西——router script一个普普通通的 PHP 文件写好了不超过三十行代码就能让 /user/1、/api/post/42 这类干净路径在本地跑起来而且这套路由逻辑和线上 Nginx 的 try_files 行为可以保持高度一致。这篇内容会从内置服务器的请求处理机制讲起把 router script 的判定规则拆开给出可以直接抄走的完整代码再把我自己踩过的 404、死循环、单线程卡顿这些坑逐个交代清楚。不管你是在写原生 PHP 小项目、调试 ThinkPHP/Laravel 这类框架还是给前端同学搭一个本地接口服务都能直接拿去用。1. 为什么内置服务器还要谈 URL 重写先说清楚一个前提URL 重写这个需求不是凭空造出来的它来自单入口这个架构习惯。几乎所有现代 PHP 项目都是 index.php 一个入口所有请求先进这个文件再由它根据路径分发到不同的控制器。这么做的理由很实在——权限控制、日志记录、初始化逻辑只需要写一遍不用在每个页面文件顶部复制粘贴。但单入口天然要求 URL 重写用户看到的是 /user/1服务器内部要把它交给 index.php 处理。Apache 时代用 .htaccess 里的 RewriteRuleNginx 时代用 location try_files这些规则都有一个共同特征写的是正则是配置是声明式的。而 PHP 内置服务器根本不读这些配置文件它走的是另一条路——你启动时多带一个参数把某个 PHP 文件指定为路由脚本之后每一个请求都先交给这个脚本由脚本自己用 PHP 代码决定是直接处理还是交回去当静态文件输出。换句话说URL 重写在内置服务器上不是配置问题是代码问题。1.1 内置服务器的真实定位开发调试工具不是生产服务器我见过不少人问php -S 能扛多少并发这个问题本身就问偏了。内置服务器从设计之初就定位在开发调试场景默认单进程单线程、没有进程管理器、没有访问日志轮转、不支持虚拟主机、不做 SSL。它的价值在于零配置启动——你 clone 一个项目下来不用装 Nginx、不用配 vhost、不用改 hosts敲一行命令就能跑改完代码刷新即生效。理解了这一定位很多决策就顺了。比如本地路由规则要不要跟线上一模一样我的做法是行为等价即可实现不必相同。线上可能是try_files $uri $uri/ /index.php?$query_string;本地就在 router script 里模拟这个顺序先看真实文件、再看目录、最后兜底进 index.php。这样两边对同一批 URL 的响应结果一致切换环境时就不会出现本地 200、线上 404这种玄学现象。注意内置服务器只在 PHP 5.4 及以上版本才有这个功能已经存在十几年了如果你的环境里没有 php -S先确认是不是装了个残缺的 PHP 包。1.2 单入口架构为什么离不开重写假设你的项目结构是 public/index.php 作为唯一入口控制器里定义了三类路由首页/、用户详情/user/{id}、文章接口/api/post/{id}。在没有重写的情况下访问首页必须写/index.php访问用户页必须写/index.php?cuserid1。这种 URL 除了难看还有几个实际麻烦一是前端同学联调时拼错参数名二是缓存和分享链接时不同写法指向同一资源三是路由参数和查询参数混在一起id到底是路径参数还是筛选条件说不清楚。重写之后/user/1和/index.php?cuserid1在语义上就是同一个资源只是前者对外后者是内部实现细节。这个内外分离的好处在使用 RESTful 风格 API 时尤其明显GET /api/post/42一看就知道是取 ID 为 42 的文章而?adetailid42需要你翻开代码才知道 adetail 是什么意思。1.3 从正则配置转到代码路由思维上要换个档写惯了 Nginx 的人第一次写 router script最容易犯的错误是把正则堆在配置文件里的思路带过来。Nginx 里你会写一堆 rewrite 规则按顺序匹配谁先命中谁生效而在路由脚本里我更推荐分层判断而不是线性正则判断层判断依据处理方式第一层静态资源扩展名在白名单内且文件真实存在return false交给服务器原样输出第二层真实 PHP 文件路径指向 docroot 下已存在的 .php视安全策略决定是否放行第三层应用路由其他所有情况引入 index.php由应用分发这个分层顺序的好处是可预测静态资源永远不会被应用逻辑拦截路由分发也不会因为某个临时文件的存在而意外短路。后面第三章的完整代码就是按这个顺序写的。2. router script 的判定机制return false 不是随便写的内置服务器的路由脚本有一个非常关键、也非常容易被忽略的约定判断路由脚本是否已经处理完请求的标准是它的返回值是否严格等于 false。这句话值得逐字读三遍因为大量莫名其妙的 bug 都出在这里。具体来说当你启动php -S 127.0.0.1:8000 router.php时每一个 HTTP 请求都会先被解释器丢给 router.php 执行。如果这个脚本执行完毕返回值是布尔 false内置服务器就认为你自己不处理那我来——它会回到 docroot 目录下按照请求路径去找真实文件找到静态文件就按对应 MIME 类型输出找到 .php 文件就交给解释器执行找不到就返回 404。反过来如果脚本正常执行结束没有return false那内置服务器就认定这个响应你已经自己输出完了它不会再做任何兜底动作哪怕你一个字都没输出浏览器也会收到一个空响应。2.1 一次请求在内置服务器内部的完整走向把这个过程拆细一点方便你在排查问题时对照解析请求行取出请求方法、原始 URI含查询串从 URI 中剥离查询串得到路径部分并对路径做一次百分号解码检查启动时是否指定了路由脚本如果有先执行它并把它返回的值记下来若返回 false则按 docroot 查找路径对应的资源是目录就先找 index.php 再找 index.html是静态文件就按 MIME 输出是 .php 就执行都没有就 404若不是 false直接结束不再做任何文件查找。这里有个细节值得注意步骤 3 里路由脚本的当前工作目录是启动命令执行的目录而不是脚本所在目录。我在一次多人协作的项目里就吃过这个亏——同事在项目根目录执行php -S 127.0.0.1:8000 tools/router.php我在 tools 目录里执行同样的命令结果一个能跑一个 404。原因是我在脚本里用相对路径public/index.php去 require工作目录一变就找不到了。解决办法很简单脚本里统一用__DIR__拼绝对路径。2.2 REQUEST_URI、PATH_INFO、SCRIPT_NAME 的真实取值这几个超全局变量在 Apache/Nginx 下和在内置服务器下取值差别不小我列个表对照一下省得你为了一个路径参数 debug 半小时。变量直接访问/user/1无路由脚本经 router script 接管后REQUEST_URI/user/1/user/1与前者一致SCRIPT_NAME/user/1/router.php会暴露路由脚本文件名PHP_SELF/user/1/router.phpPATH_INFO通常为空通常为空内置服务器不会自动填充DOCUMENT_ROOT-t指定的目录同左$_GET从查询串解析正常同左与路径参数无关表格里最扎眼的是SCRIPT_NAME。很多模板引擎和框架在生成链接时会用SCRIPT_NAME作为 base path结果本地调试时页面上所有链接变成了/router.php/user/1点一下就 404。我一般会在路由脚本的最开头手动覆写它$_SERVER[SCRIPT_NAME] /index.php; $_SERVER[PHP_SELF] /index.php;这样应用层拿到的基础路径就是干净的/index.php生成的链接也不会带出路由脚本。这个改动很小但能省掉一堆为什么页面上链接多了一截的困惑。另一个要注意的是PATH_INFO。Apache 在开启 AcceptPathInfo 时会把/index.php/user/1里的/user/1塞进 PATH_INFO很多人写路由习惯直接读它。内置服务器不做这件事PATH_INFO 在绝大多数情况下是空的。所以路由参数的解析必须自己动手用parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH)取路径再用explode或正则拆段。这是内置服务器和传统环境最实质的差异之一也是从 Apache 迁过来最容易翻车的地方。2.3 静态文件的短路边界在哪里return false看起来只是交回去但它实际触发的是内置服务器自己的文件查找逻辑这个逻辑有边界不清楚边界的后果是安全问题和诡异 404。第一它只认 docroot 之下的文件。如果你用-t public指定了文档根那么return false时服务器只会去 public 目录里找public 之外的代码目录天然不可访问。这是内置服务器比很多手写路由都安全的一点别浪费它。第二路径里出现..会被内置服务器自己处理掉它不会让你跳出 docroot。但你不能指望这个行为去兜底你自己拼出来的路径——比如你在代码里用用户输入的字符串去拼file_exists($root . $input)那是在路由脚本内部执行的文件系统调用内置服务器管不到。任何把外部输入拼进文件路径的写法都必须自己做规范化校验。第三以点开头的隐藏文件比如.env、.git/config虽然内置服务器对这类路径的处理有些版本差异但我的建议是彻底别赌运气。在路由脚本里做白名单而不是黑名单只放行你认识的扩展名其余一律走应用逻辑或直接 404。白名单思路虽然啰嗦一点但不会因为将来多了一个配置文件就突然变成公开可下载资源。3. 手写一个能长期用的 router.php前面讲了原理现在进入能直接落地的部分。我会从一个最小版本开始逐步补上静态资源处理、路径穿越防护、404 兜底最后给出一份我实际在用的完整文件。你可以按顺序读也可以直接跳到 3.4 抄走。3.1 十行代码跑通单入口先看最小可用版本理解结构比记住代码重要?php // router.php $root __DIR__ . /public; $uri parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH); // 只放行真实存在的静态文件其余全部交给单入口 if ($uri ! / is_file($root . $uri)) { return false; } require $root . /index.php;配套启动命令php -S 127.0.0.1:8000 -t public router.php这个版本能解决 80% 的场景访问/会落到 index.php访问/css/app.css会因为文件存在而返回 false 被原样输出访问/user/1会被 index.php 接管。但它有三个明显问题一是is_file判断只对文件生效如果路径指向一个目录就会走应用逻辑而应用逻辑可能把它当 404也可以接受二是没做路径规范化/../config.php这种输入会原样拼进$root虽然有内置服务器兜底但在脚本内部拼路径仍然是不好的习惯三是没区分扩展名docroot 下任何存在的文件都会被放行包括你不希望暴露的备份文件。3.2 静态资源白名单与 MIME 兜底第二个版本加上扩展名白名单。这个改动看起来保守实际上是让路由行为变得可预期$staticExt [ css, js, mjs, map, png, jpg, jpeg, gif, svg, webp, ico, avif, woff, woff2, ttf, eot, otf, txt, json, xml, pdf, mp3, mp4, webm, ogg, ]; $ext strtolower(pathinfo($uri, PATHINFO_EXTENSION)); if ($ext ! in_array($ext, $staticExt, true)) { $file realpath($root . $uri); if ($file ! false is_file($file) strncmp($file, realpath($root), strlen(realpath($root))) 0) { return false; } http_response_code(404); exit; }这段代码里有三个点值得说。第一先判断扩展名再判断文件是否存在。顺序反过来的话黑客可以用/config.php.bak这种路径去探测文件存在性——虽然暴露的信息很有限但没必要留这个口子。第二用 realpath 做二次校验。realpath会把路径里的..、软链接全部展开成绝对路径然后我用前缀比较确认它确实落在 docroot 之内。这里注意用strncmp而不是str_starts_with因为str_starts_with是 PHP 8 才有的函数很多项目还在 7.4 上跑。如果你确定是 PHP 8直接写str_starts_with($file, $rootReal)更直观。第三白名单命中的资源如果不存在直接返回 404不要回落到应用路由。原因是这样更符合直觉/css/app.css不存在就是 404不应该被前端路由接管然后返回一个 HTML 首页那会让浏览器报 MIME 类型错误排查起来更绕。3.3 查询串、路径参数与 URL 解码查询串这部分反而是内置服务器做得最省心的地方。parse_url只取 path 部分$_GET由解释器自动填充所以/user/1?tabpostspage2这种混合写法不需要额外处理路径部分你自己拆查询参数照常从$_GET读互不干扰。真正要小心的是解码时机。REQUEST_URI是原始编码的中文和空格都还是%E4%B8%AD和%20这种形式。如果你拿它跟路由正则匹配那就得用编码后的形式写正则非常难维护。所以第一步先取 path第二步做一次rawurldecode之后所有匹配和文件判断都在解码后的字符串上做。下面这个顺序不能颠倒$path parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH); // 原始编码 $path rawurldecode($path); // 解码后 $path preg_replace(#/#, /, $path); // 合并连续斜杠 $path rtrim($path, /) ?: /; // 统一末尾斜杠最后这行rtrim是个小技巧。同一个资源/user/1和/user/1/在搜索引擎眼里是两个 URL在缓存里也是两条记录。开发阶段统一去掉末尾斜杠可以减少很多这个页面为什么缓存没生效的问题。如果你更喜欢保留末尾斜杠把rtrim换成确保末尾有斜杠即可关键是全站策略统一别一半有一半没有。关于rawurldecode和urldecode的区别顺带提一句urldecode会把解码成空格这在处理查询串时是对的但用在路径上就错了——路径里的就是加号本身。用rawurldecode处理路径用parse_str或直接读$_GET处理查询串各司其职。3.4 一份可以直接抄走的完整 router.php把前面的点合起来这是我目前在用的版本稍微长一点但每个分支都有明确意图?php // router.php —— 放在项目根目录与 public/ 同级 $root __DIR__ . /public; $rootReal realpath($root); $staticExt [ css, js, mjs, map, png, jpg, jpeg, gif, svg, webp, ico, avif, woff, woff2, ttf, eot, otf, txt, json, xml, pdf, mp3, mp4, webm, ]; $path parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH); $path rawurldecode($path); $path preg_replace(#/#, /, $path); $path rtrim($path, /) ?: /; // 1. 静态资源白名单 存在性 目录内校验 $ext strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ($ext ! in_array($ext, $staticExt, true)) { $file realpath($root . $path); if ($file ! false is_file($file) strncmp($file, $rootReal, strlen($rootReal)) 0) { return false; } http_response_code(404); header(Content-Type: text/plain; charsetutf-8); echo 静态资源不存在: . $path; exit; } // 2. 统一 base path避免模板生成 /router.php/xxx 的链接 $_SERVER[SCRIPT_NAME] /index.php; $_SERVER[PHP_SELF] /index.php; // 3. 其余请求交给单入口 require $root . /index.php;配套的 public/index.php 里路由分发可以写得非常轻?php // public/index.php $path parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH); $path rtrim(rawurldecode($path), /) ?: /; $method $_SERVER[REQUEST_METHOD]; $routes [ GET [ #^/$# home, #^/user/(\d)$# user_show, #^/api/post/(\d)$# api_post_show, ], POST [ #^/api/post$# api_post_create, ], ]; $hit false; foreach ($routes[$method] ?? [] as $pattern $name) { if (preg_match($pattern, $path, $m)) { $params array_map(rawurldecode, array_slice($m, 1)); header(X-Route: . $name); // 调试利器见 5.4 dispatch($name, $params); $hit true; break; } } if (!$hit) { http_response_code(404); readfile(__DIR__ . /../views/404.html); }这段路由代码里header(X-Route: ...)是我强烈建议加的一行。联调的时候打开浏览器开发者工具的 Network 面板一眼就能看到当前请求命中了哪条规则不用再去翻日志或者在每个控制器里加断点。等上线前把这个头去掉或者只在APP_ENVdev时输出即可。4. 踩坑记录那些让人怀疑人生的 404 与死循环原理和代码讲完了接下来是本文最值钱的部分。下面这几个问题我都真实遇到过有的是自己写出来的有的是帮同事排查的共同特点是现象很吓人原因很简单找出原因的过程很有普适性。4.1 重定向死循环是怎么长出来的现象浏览器提示重定向次数过多Network 面板里一串 302 首尾相连地址栏里的路径越变越长比如/index.php/index.php/index.php/...。成因通常是这样一行代码if ($path ! /) { header(Location: /index.php . $path); exit; }写这段代码的人想的是把路径交给入口处理但他忘了/index.php本身也要经过路由脚本。于是一个/user/1被转成/index.php/user/1这个新地址又命中同一条件再转一次浏览器跟到第 20 跳就罢工了。排查这一类问题有个很高效的姿势用curl -I看响应头用curl -sIL -o /dev/null -w %{num_redirects} %{url_effective}\n看最终跳转次数和目标地址。比在浏览器里点来点去快得多。修法有两个方向。一是跳转前先判断目标是不是真实文件是就放行不是才继续二是干脆不做这种跳转单入口架构下路径本来就应该由 index.php 内部解析不需要用 302 绕一圈。我倾向后者少一次跳转也少一个出问题的地方。如果确实需要做规范化跳转比如统一去掉末尾斜杠一定确保跳转目标本身不会再命中跳转条件——最简单的方式是跳转前检查当前路径已经是目标形式就直接往下走。4.2 目录被当成文件、文件被当成入口第二个高频坑是判断顺序不对导致的诡异现象。我遇到过这样一次项目里有个public/uploads/目录存用户上传的图片路由脚本里写的是if (file_exists($root . $path)) return false;。结果访问/uploads不带末尾斜杠时file_exists对目录也返回 true于是return false内置服务器去找uploads这个目录发现有目录但没有 index 文件返回 404。看起来只是个小问题但前端上传完图片后拼接的预览地址恰好就是/uploads用户看到一片空白反馈是上传成功了但图不显示。排查这类问题关键是养成区分 is_file 和 file_exists的习惯。判断这是不是一个可以直接返回的静态资源用is_file判断这个路径存在不存在才用file_exists。前者排除了目录语义更准确。还有一个反向的坑docroot 下存在一个.php文件比如public/info.php路由脚本把请求原样交回去内置服务器发现是 PHP 文件就执行了。而info.php里是一个调试用的phpinfo()没有任何访问控制。本地无所谓但如果这个 docroot 直接同步到了测试环境那就是个不大不小的暴露面。我的做法是docroot 下只允许放真正需要对外暴露的入口文件调试脚本一律放到 docroot 之外用命令行跑。如果实在要放路由脚本里加一条显式拦截if (preg_match(#^/(info|phpinfo|test)\.php$#, $path)) { http_response_code(404); exit; }4.3 单线程排队为什么页面很慢但 CPU 很闲这个坑最容易被误判成性能问题。现象是本地打开首页慢打开开发者工具的 Network 面板发现所有请求的耗时都差不多而且是一批一批出结果的——先是第一个请求等了 800ms然后接下来五个请求同时开始。CPU 占用几乎为零数据库也没有慢查询。原因就在内置服务器的默认行为上它默认只用一个进程处理请求前一个请求没结束后面的请求就得排队。开发阶段这个问题被放大是因为我们经常在路由里插桩、打日志、断点调试一个请求卡住十几秒页面上的其他资源请求全部干等。前端 dev server 的模块加载动辄几十个请求串行处理下来页面白屏时间能拉到肉眼可见。解决办法是给内置服务器开多进程。PHP 7.4 起支持通过环境变量指定工作进程数PHP_CLI_SERVER_WORKERS4 php -S 127.0.0.1:8000 -t public router.php这个环境变量只在类 Unix 系统上生效Windows 上不支持。另外要注意多进程模式下每个请求可能落在不同进程里所以不要依赖进程内的全局状态比如静态变量缓存的配置、进程内计数器这类状态在多进程下是不共享的。跨请求要共享的数据老实用文件缓存、Redis 或者别的外部存储。如果你在 Windows 上做开发又确实被串行请求拖累了我的建议是要么把前端资源打包成少数几个文件减少并发请求数要么干脆换用一套本地的 Nginx PHP 把请求接起来把内置服务器只留给纯粹的接口调试。这不是内置服务器不好用而是它的设计目标里本来就没有扛并发这一项。4.4 中文路径、空格与百分号路径里带中文在内置服务器上要多做一步。因为REQUEST_URI是编码后的如果你直接拿它去拼文件路径得到的是/uploads/%E6%B5%8B%E8%AF%95.pdf文件系统当然找不到。前面 3.3 里讲过要rawurldecode但解码之后还有第二层坑解码后的路径里可能出现空格、#、?这些字符。比如文件名是产品 说明.pdf解码后路径里带空格。用这个路径做realpath一般没问题但如果你的应用层要把这个路径拼成 URL 再输出比如生成下载链接就必须重新编码用rawurlencode逐段编码而不是对整个路径整体编码——整体编码会把/也编码成%2F链接就废了$segments explode(/, $path); $encoded implode(/, array_map(rawurlencode, $segments));至于#它本来是 URL 里的片段标识符正常情况下浏览器不会把它发给服务器。但如果文件名里真的含#编码后再传输就没问题解码后拿到的也是原样字符。我吃过一次亏是文件名里带?前端拼接时没编码结果?之后的内容被当成查询串发走了服务器收到的路径少了半截查了半天才想到是文件名的问题。凡是用户可控的文件名进入路径之前一定做白名单字符过滤或者统一重命名这是比编码问题更根本的解法。5. 把本地环境推近线上多入口、联调与启动封装路由脚本写到能跑只是及格线。真正让本地开发舒服的是后面这些工程化上的小设计。这一章讲的是怎么让内置服务器在你手里更像一套环境而不是一个只能跑 Hello World 的玩具。5.1 多入口项目的降级处理有些项目天生不是单入口比如后台管理是一套独立入口/admin/index.php前台是/index.php还有一个/api/index.php。这种结构也能用内置服务器跑只要在路由脚本里把真实存在的 PHP 入口放到静态资源判断之后、应用分发之前。顺序上有个细节不要无脑放行所有 .php 文件而是维护一个入口文件的显式清单$entryPoints [/index.php, /admin/index.php, /api/index.php]; if (in_array($path, $entryPoints, true)) { $file realpath($root . $path); if ($file ! false is_file($file)) { return false; // 让内置服务器执行该 PHP 文件 } }写成清单的好处是将来有人往 docroot 里丢了一个debug.php它不会因为文件存在就自动变成可访问入口。这个习惯我在多个项目里坚持下来确实挡掉过几次手滑。5.2 前端联调代理、预检请求与统一响应头前后端分离的项目里前端通常跑在localhost:5173这类端口上后端接口在内置服务器的127.0.0.1:8000。这时候有两种联调姿势。第一种是前端 dev server 反向代理在 Vite 或 webpack 的配置里把/api前缀的请求转发到内置服务器。这是我最推荐的因为浏览器看到的还是同源请求完全没有跨域问题也不需要在后端加任何 CORS 头最省事。第二种是后端加 CORS 头。如果只能走这条路记得几件事Access-Control-Allow-Origin不要图省事写*又同时带上Access-Control-Allow-Credentials: true浏览器会直接拒绝要带上Vary: Origin否则中间的缓存层可能把给 A 站点的响应发给 B 站点预检请求是OPTIONS方法内置服务器不会特殊处理它直接交给路由脚本所以要在路由脚本最前面拦截if ($_SERVER[REQUEST_METHOD] OPTIONS) { header(Access-Control-Allow-Origin: http://localhost:5173); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization); header(Access-Control-Max-Age: 86400); header(Vary: Origin); http_response_code(204); exit; }把这段放在路由脚本的最顶部在所有业务逻辑之前。原因是预检请求不携带业务数据没有必要经过认证和路由分发提前返回还能省掉一次完整的应用初始化。5.3 用 composer script 或脚本文件封装启动命令每次手敲那串带环境变量的启动命令太累也容易记错。如果你的项目已经有 composer.json直接加一个 script 最省事{ scripts: { dev: php -S 127.0.0.1:8000 -t public router.php } }之后composer dev就能起服务。要注意 composer script 的工作目录是 composer.json 所在目录也就是项目根目录所以router.php和-t public都用相对根目录的写法就行。如果需要更复杂的启动逻辑——比如启动前检查.env是否存在、自动生成缓存目录、同时开一个队列消费进程——那就写一个 shell 脚本或者批处理#!/usr/bin/env bash set -e cd $(dirname $0)/.. [ -f .env ] || cp .env.example .env mkdir -p runtime/cache echo 服务已启动: http://127.0.0.1:8000 PHP_CLI_SERVER_WORKERS4 php -S 127.0.0.1:8000 -t public router.php脚本里第一行的cd很关键它保证了无论从哪个目录调用这个脚本工作目录都是项目根路径问题一次性解决。5.4 在响应头里留下路由痕迹前面 3.4 提到过X-Route响应头这里展开说说为什么我觉得它值回票价。开发阶段最耗时的往往不是写代码而是为什么这个请求没进我预期的那个方法。传统做法是打日志或者下断点但日志要翻文件、断点会打断前端并行请求尤其在单线程模式下断点一挂整个页面卡住。加一个X-Route头之后你只要打开浏览器 Network 面板点开任意一个请求看 Response Headers就能知道它命中了哪条规则、有没有命中任何规则。配合X-Route-Time记录路由匹配耗时还能顺带发现某条正则写得特别慢。这三个头在开发环境加上上线前用环境变量控制关掉成本几乎为零收益很实在。if (getenv(APP_DEBUG)) { header(X-Route: . $name); header(X-Route-Time: . round((microtime(true) - $start) * 1000, 2) . ms); }5.5 什么时候必须放弃内置服务器讲了这么多技巧也得把边界说清楚免得有人拿它去扛不该扛的活。以下这些场景内置服务器不是配置一下就能用而是能力上根本没有需要 HTTPS 本地调试证书做微信登录回调、OAuth 回调这类必须 https 的场景需要多虚拟主机一个端口对应多个站点需要做压力测试看真实的 QPS 和内存表现需要访问日志和错误日志分开落盘、按天切分需要自定义 404、限流、IP 白名单这类服务器层面的控制。遇到这些老老实实装一套 Nginx PHP 处理。但请注意换掉服务器不等于换掉路由逻辑。你在 router.php 里写的那套分层判断静态资源白名单 → 真实入口 → 应用分发完全可以一比一翻译成 Nginx 的try_files加几条location因为判断顺序是一致的。这也是我建议把路由规则用代码写在 router.php 里的原因之一——它比一堆正则更容易读懂也更容易在换环境的时候对照着翻译过去。最后分享两个我在实际使用中养成的小习惯。一是把 router.php 放到tools/目录而不是项目根目录启动命令写成php -S 127.0.0.1:8000 -t public tools/router.php这样项目根目录清爽路由脚本也不会被误当作可访问文件同时脚本内部所有路径都用__DIR__ . /../public这种形式拼绝对路径杜绝工作目录带来的差异。二是每次新建项目先在根目录跑一次php -S带着一个只做header(Content-Type: text/plain); echo $_SERVER[REQUEST_URI];的探针脚本把路由规则验证一遍再加业务逻辑——这个动作只花五分钟但能把后面几小时的环境排查时间省下来。踩过的坑告诉我本地环境的问题九成出在路径判断上先把路径捋顺剩下的都好办。
返回列表