做前后端分离项目时,最烦的不是写业务逻辑,而是本地联调。前端跑在 3000,后端接口跑在 8080,浏览器随手一个 fetch 就是跨域报错;后端同事说“我本地起了个服务,你帮我连一下”,你又不想改一堆环境变量。后来我干脆写了这个 PHP 单文件代理服务器,塞进任意一个 PHP 环境,用内置 Web 服务器一跑,就能把请求按前缀转发到不同后端,顺带解决跨域、记录日志、附加请求头。整个过程就一个文件,没有框架依赖,没有外部服务,PHP 7.4 到 8.3 都能跑。这篇文章会把设计思路、完整代码、启动步骤和调试中踩过的坑一次讲清楚,适合做前端联调、后端排接口、需要快速搭建临时转发网关的同学。
我之前在好几家公司见过同类型需求,大家第一反应都是“配个 Nginx 反代”或者“上 Node 中间层”。Nginx 当然稳定,但为了联调改一堆 server 块,改完还要 reload,最后还不一定带得走。Node 中间层要装依赖、写中间件,也是一个工程。于是我把方案收敛成一个 PHP 文件,几行 cURL 转发,一台机器有一份 PHP 就能马上跑起来。下面从场景到原理、代码、排坑,一层层说清楚。
1. 单文件代理服务器到底在解决什么场景问题
1.1 本地跨域调试:为什么需要中转
浏览器同源策略大家都懂,前端开发时最容易踩的就是跨域。同一个页面里,页面运行在http://127.0.0.1:5173,后端接口在http://127.0.0.1:8000,两个端口不同,浏览器就会拦截响应。虽然后端可以通过 CORS 头放行,但有些老项目、内部系统根本没加 CORS 支持,你去改后端代码又不现实。
代理解决这个问题的方式很直接:让浏览器只访问代理这一个源,例如http://127.0.0.1:8080,由代理服务器去请求真实的多个后端地址,再把结果带回来。因为代理方向后端的请求是服务端发起的,不涉及浏览器同源策略;代理作为返回源,只要它在响应头里附上Access-Control-Allow-Origin: *,前端收到的请求就完全没有跨域问题。
这个场景是最刚需的。我见过很多前端同事为了跨域,在 vite 里配 proxy,在 webpack 里配 proxy,项目一多配置就散落到各个仓库里。单文件代理是一个脱离项目的独立工具,任何项目需要联调时都能临时指过去,用完就关,不污染项目配置。
1.2 多路由转发:一个端口接入多个后端服务
很多联调场景不止一个后端。我这边最常见的情况是:页面、API 网关、SSO 登录三个服务同时开着,前端希望用自己的域名访问,又不希望每次登录跳转都改域名。单文件代理可以用前缀规则做分流:
/api/开头的请求转发到 API 服务http://127.0.0.1:3000/static/开头的请求转发到静态资源服务http://127.0.0.1:8081/sso/开头的请求转发到统一登录服务http://127.0.0.1:9000
前端代码里只需要写一个基础地址http://127.0.0.1:8080,后面接路径即可。后端服务换端口了,改一下代理文件里的路由表,刷新页面就生效,比逐个改前端配置要省事得多。
在一个文件里维护路由表还有一个隐性好处:这份配置可以跟着你走。同一份 proxy.php 拷贝到另一台电脑,配置不用重新写,非常适合同事之间互相对接。
1.3 和其他反代方案比,单文件PHP的取舍
我对比过几种常见方案,各有适用场景。Nginx 反代最稳定,适合正式环境或长期驻留的本地网关,但配置较重,临时起一个还需要写 conf 文件、改端口。Node 的http-proxy中间件灵活,但要初始化 npm 项目、装依赖。Python 写转发脚本也不长,但同样的脚本在不同机器上要处理虚拟环境。
PHP 单文件代理的优势主要在“轻”和“到处能跑”。只要机器上有 PHP CLI 环境,一条php -S命令就启动,不需要额外的服务进程,不需要装依赖。很多公司的开发机即使没装 Node,也大概率有 PHP,因为历史项目里总有 PHP 的影子。
限制也很明显:它是同步阻塞的,一个请求没结束,同一个 worker 不会处理下一个并发请求。当然 PHP 内置服务器本身是单进程模型,所以它更适合“联调、排接口、临时网关”这种低并发场景,不适合承担生产流量。它的定位就是开发工具箱里的一把螺丝刀,不是重型扳手。
下面是几个方案的粗略对比:
| 方案 | 依赖 | 启动成本 | 路由灵活性 | 日志能力 | 适用场景 |
|---|---|---|---|---|---|
| Nginx 反代 | Nginx | 改配置+reload | 强 | 强 | 正式/长期网关 |
| Node http-proxy | npm 依赖 | 初始化项目 | 强 | 可写 | 工程化中间层 |
| Python 转发脚本 | Python | 需解释器 | 中 | 可写 | 临时脚本 |
| PHP 单文件 | PHP + curl 扩展 | 一条命令 | 中 | 内置日志 | 快速联调/临时转发 |
我在实际项目里两者都用:正式环境走 Nginx,本地开发调试基本都用单文件代理。切换成本低,唯一要记住的是别把它当生产工具用。
2. 核心实现原理与关键细节拆解
2.1 一次请求从进入到返回的完整链路
整个代理的处理流程并不复杂,我用一句话先概括:收到请求、拼目标 URL、带原请求头和后端通信、把后端响应原样回给客户端。
拆开看有六个环节:
- 解析当前请求的 URL 和 HTTP 方法,拿到
$_SERVER['REQUEST_URI']与$_SERVER['REQUEST_METHOD']。 - 根据路由表做前缀匹配,确定本次要转发的目标地址,拼接出完整的转发 URL。
- 把当前请求的请求头转成 cURL 能用的 header 数组,并补上
X-Forwarded-For这类代理标记。 - 读取请求体
php://input,按方法判断是否需要作为 POSTFIELDS 传给后端。 - 用 cURL 执行转发,拿到后端返回的状态码、Content-Type 和响应 body。
- 用
http_response_code()回写状态码,透传 Content-Type,输出 body,再补上 CORS 头,最后写日志。
这里最容易被忽略的是请求头处理。浏览器发给代理的 Header 里有Host、Connection、Accept-Encoding等,这些如果原封不动转发给后端,轻则语义不对,重则把连接管理的头也带过去,导致后端响应异常。我们后面单独讲这一点。
2.2 转发引擎选型:cURL为什么比 stream context 更顺手
实现 HTTP 转发在 PHP 里有两条路:cURL 扩展,或者file_get_contents()+stream_context_create()。两条路都能做,但我强烈建议用 cURL,因为要拿的信息太全了。
cURL 里有一个curl_getinfo()方法,转发结束后能拿到 HTTP 状态码、Content-Type、重定向次数、总耗时、上传/下载字节数,甚至远程 IP。而file_get_contents()虽然可以通过$http_response_header拿到响应头,但要解析状态码、Content-Type、Set-Cookie 这些字段,全得自己手写正则或者字符串截取。
再看请求方法的支持。file_get_contents()配合 stream context 虽然也能设置method为 POST、PUT、PATCH,但对于 DELETE、OPTIONS 这类方法的兼容性不如 cURL 直接。cURL 一行CURLOPT_CUSTOMREQUEST就能发任意方法。超时控制也一样,cURL 有专门的CURLOPT_TIMEOUT,stream context 需要设置timeout和http->ignore_errors等参数,细节更多。
还有一个实用功能:cURL 对 HTTPS 的控制非常灵活。本地调试经常遇到自签名证书,cURL 可以临时关闭证书校验;stream context 虽然也能配置,但参数比较绕。总结下来,cURL 天生就是为发送 HTTP 请求设计的,单文件代理里选它是最省心的。唯一需要注意的是 curl 扩展必须有,好在这几乎是 PHP 的标配扩展,php -m里看到curl就能用。
2.3 请求头与响应头的处理细节
请求头处理是代理服务器里最容易出问题的地方,这里列几个关键细节。
第一个是 Host 头。浏览器发给代理的 Host 是127.0.0.1:8080,如果你把这个 Host 原样转发给后端,后端虚拟主机可能就找不到对应站点,或者日志里记录的全是代理地址。默认做法是去掉原始 Host 头,让 cURL 使用目标 URL 自带的 Host。比如目标地址是http://127.0.0.1:3000,cURL 发出去时 Host 就是127.0.0.1:3000,后端看到的就是请求真实指向的地址,符合直觉。
但有一种情况需要保留原始 Host:后端在同一个 IP 上用 Nginx 按域名做虚拟主机分发,比如http://127.0.0.1:80上有dev-api.example.com和dev-admin.example.com两个站点,此时代理的目标地址写成 IP,实际要访问哪个站就得靠 Host 决定。这时把preserve_host打开,把浏览器的 Host 原样透传过去,后端虚拟主机才能正确路由。这段逻辑在代码里做成了一个配置开关,默认关闭,需要时打开。
第二个是 hop-by-hop 头。HTTP 协议里有一组头是连接级别的,比如Connection、Keep-Alive、Transfer-Encoding、TE、Trailer、Upgrade、Proxy-Connection,它们描述的是当前这一段连接的信息,不应该由代理转给下一个节点。我把它们过滤掉,让 cURL 自己管理连接层,后端收到的头更干净。
第三个是X-Forwarded-For。代理转发时最好补上这个头,后端拿到后可以知道最原始的真实客户端 IP。这个头本身是追加式的,如果后端链路上已经有多层代理,通常的做法是把新 IP 追加到末尾,而不是直接覆盖。单文件代理里我直接取了REMOTE_ADDR,逻辑简单,足够开发场景使用。
第四个是Accept-Encoding。我在调试中踩过坑:如果前端带了Accept-Encoding: gzip,后端返回的 body 是压缩后的二进制,你在代理日志里看到的就是一坨乱码,想要基于 body 做点分析和统计都没法做。单文件代理里我把这个请求头过滤掉,让后端返回默认的未压缩内容,调试时一眼就能看清数据。生产环境不能这么省事,开发工具可以。
响应头的处理相对简单,主要是透传Content-Type,以及状态码回写。如果你增加 CORS 头,就自己补上那几行。注意不要重复输出同名的响应头,cURL 拿到的Content-Type如果为空,就不要强制设置。
2.4 安全边界:把代理锁在本地开发环境里
单文件代理本质是一个请求转发器,它天然具备“开放代理”的能力:只要谁能访问它,它就能替谁去访问配好的目标地址。所以如果不做限制,一旦这个文件被暴露到公网,就变成了一个别人可以利用的跳板。我在代码里加了两层防护。
第一层是allowed_clients白名单,只允许特定来源 IP 访问代理。默认只允许本机127.0.0.1,这样部署在开发机时,只有本机能使用。如果你需要局域网内的同事访问,可以把自己的局域网 IP 加进去,但不要图省事直接写*。
第二层是allowed_targets白名单,代理只能转发到名单里的目标主机。即使有人偷偷拼接路径,只要目标主机不在白名单里,就直接返回 403。这个设计很简单,但对于避免代理被滥用很有价值。我把目标地址安全校验放在路由匹配之后,避免单纯依赖路由前缀做防护。
需要明确的是,这套东西只是开发工具,不应该被部署成面向公网的正式服务。我给它的定位是“本地联调助手”,而不是“互联网网关”。在这种定位下,即使安全措施做得还不算深度,风险也是可控的。
3. 完整可运行的PHP代码与启动步骤
3.1 完整代码
下面是完整的单文件代理服务器代码。代码里把配置项集中在最上方,方便修改;核心逻辑在路由匹配、请求头组装、cURL 转发和日志输出上。
<?php /** * proxy.php * 单文件 HTTP 转发代理,面向本地开发调试场景 * * 用法: * php -S 127.0.0.1:8080 proxy.php * * 配置项在下方 $config 数组里,修改后重启即可。 */ // ---------- 配置区 ---------- $config = [ // 默认转发目标,没有匹配到任何路由前缀时使用 'default_target' => 'http://127.0.0.1:3000', // 按 URL 前缀转发到不同后端 'routes' => [ '/api/' => 'http://127.0.0.1:3000', '/static/' => 'http://127.0.0.1:8081', '/sso/' => 'http://127.0.0.1:9000', ], // 目标主机白名单,防止代理变成开放代理 'allowed_targets' => [ '127.0.0.1', 'localhost', ], // 允许访问代理的客户端来源,留空表示不限制 'allowed_clients' => [ '127.0.0.1', ], // 转发超时时间,单位秒 'timeout' => 30, // 日志文件路径,留空表示不写日志 'log_file' => __DIR__ . '/proxy.log', // 是否保留原始 Host 头,按需开启 'preserve_host' => false, // 是否自动附加 CORS 头,解决前端跨域调试 'add_cors_headers' => true, ]; // ---------- 兼容非 Apache 环境的 getallheaders ---------- if (!function_exists('getallheaders')) { function getallheaders(): array { $headers = []; foreach ($_SERVER as $key => $value) { if (strpos($key, 'HTTP_') === 0) { $name = str_replace('_', ' ', strtolower(substr($key, 5))); $name = str_replace(' ', '-', ucwords($name)); $headers[$name] = $value; } } if (isset($_SERVER['CONTENT_TYPE'])) { $headers['Content-Type'] = $_SERVER['CONTENT_TYPE']; } if (isset($_SERVER['CONTENT_LENGTH'])) { $headers['Content-Length'] = $_SERVER['CONTENT_LENGTH']; } return $headers; } } // ---------- 客户端来源检查 ---------- if (!empty($config['allowed_clients'])) { $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; if (!in_array($clientIp, $config['allowed_clients'], true)) { http_response_code(403); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => 'client not allowed']); exit; } } // ---------- 解析请求 ---------- $requestUri = $_SERVER['REQUEST_URI'] ?? '/'; $requestMethod = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); // 去掉协议和域名干扰,只保留路径和查询参数 $requestPath = parse_url($requestUri, PHP_URL_PATH) ?: '/'; $requestQuery = parse_url($requestUri, PHP_URL_QUERY); $normalizedUri = $requestPath . ($requestQuery ? '?' . $requestQuery : ''); // ---------- 路由匹配 ---------- $target = $config['default_target']; foreach ($config['routes'] as $prefix => $baseUrl) { if ($prefix !== '' && strpos($normalizedUri, $prefix) === 0) { $target = rtrim($baseUrl, '/'); break; } } $remoteUrl = $target . $normalizedUri; // ---------- 目标地址安全校验 ---------- $targetHost = parse_url($remoteUrl, PHP_URL_HOST); if (!in_array($targetHost, $config['allowed_targets'], true)) { http_response_code(403); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => 'target host not allowed']); exit; } // ---------- 组装转发请求头 ---------- $forwardHeaders = []; foreach (getallheaders() as $name => $value) { $lowerName = strtolower($name); if (in_array($lowerName, [ 'host', 'connection', 'keep-alive', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'proxy-connection', 'accept-encoding', ], true)) { continue; } $forwardHeaders[] = $name . ': ' . $value; } if (!empty($config['preserve_host']) && isset($_SERVER['HTTP_HOST'])) { $forwardHeaders[] = 'Host: ' . $_SERVER['HTTP_HOST']; } $forwardHeaders[] = 'X-Forwarded-For: ' . ($_SERVER['REMOTE_ADDR'] ?? ''); $forwardHeaders[] = 'X-Forwarded-Proto: ' . (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http'); // ---------- 读取请求体 ---------- $requestBody = file_get_contents('php://input'); // ---------- 执行转发 ---------- $startTime = microtime(true); $ch = curl_init($remoteUrl); if ($requestMethod === 'GET') { curl_setopt($ch, CURLOPT_HTTPGET, true); } elseif ($requestMethod === 'POST') { curl_setopt($ch, CURLOPT_POST, true); if ($requestBody !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $requestBody); } } else { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $requestMethod); if ($requestBody !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $requestBody); } } curl_setopt($ch, CURLOPT_HTTPHEADER, $forwardHeaders); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_MAXREDIRS, 5); curl_setopt($ch, CURLOPT_TIMEOUT, $config['timeout']); // 本地开发调试时关闭证书校验,生产环境绝不能照抄 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); $responseBody = curl_exec($ch); $statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $curlError = curl_error($ch); $elapsedMs = round((microtime(true) - $startTime) * 1000, 1); curl_close($ch); // ---------- 回写响应 ---------- http_response_code($statusCode > 0 ? $statusCode : 502); if ($contentType) { header('Content-Type: ' . $contentType); } if (!empty($config['add_cors_headers'])) { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); } echo $responseBody; // ---------- 写日志 ---------- if (!empty($config['log_file'])) { $logLine = sprintf( "[%s] %s %s -> %d %dB %dms target=%s%s\n", date('Y-m-d H:i:s'), $requestMethod, $normalizedUri, $statusCode, strlen((string) $responseBody), $elapsedMs, $targetHost, $curlError ? ' [' . $curlError . ']' : '' ); @file_put_contents($config['log_file'], $logLine, FILE_APPEND); }这段代码我实际用过,配合自定义路由和 CORS 头,能覆盖绝大多数本地联调场景。要注意的是,代码默认假设请求体不需要流式读取,因此file_get_contents('php://input')对于普通 JSON 接口完全够用,大文件上传场景后面会讲替代方案。
3.2 环境准备和启动命令
运行这份代码的前提非常简单:一台装有 PHP 的机器,PHP 7.4 以上即可,8.0、8.1、8.3 我都验证过。PHP 内置开发服务器和 cURL 扩展属于标配,一般不需要额外装东西。可以先跑两条命令确认环境:
php -v php -m | grep curl如果输出里能看到curl,环境就绪。把上面的代码保存为proxy.php,然后在终端里执行:
php -S 127.0.0.1:8080 proxy.php这里php -S是 PHP 内置开发服务器,127.0.0.1:8080表示监听本机 8080 端口,proxy.php是路由脚本。在 PHP 内置服务器模式下,所有请求都会交给这个路由文件处理,所以它天然成为整个代理的入口。
启动后终端会打印类似这样的信息:
Listening on http://127.0.0.1:8080 Document root is /path/to/current/directory Press Ctrl-C to quit.这时代理已经跑起来了。前端代码里的baseURL直接指到http://127.0.0.1:8080,后面正常请求/api/users之类的路径即可。
如果你用的是 Windows,也一样,php -S命令通用,只不过终端窗口不要关。还可以往命令后面加-t指定网站根目录,但单文件代理不需要。
3.3 关键配置项逐条说明
配置区里最常改的是路由表。routes数组的匹配规则是:拿当前请求路径的前缀去和路由 key 比较,谁先命中谁先赢。例如路由/api/会匹配/api/users、/api/order/123,但不会匹配/apiv2/test。如果你的接口路径不是明确前缀,需要调整前缀规则。
allowed_targets这个白名单要特别注意。我把默认值设置成127.0.0.1和localhost,意思是代理只能转发到本机的端口。同事把他的电脑 IP 告诉你,你想转发到他的机器,需要把他的 IP 加进这个数组,同时确认你们在同一局域网。不要把目标地址写成域名但忘了把域名加白名单,否则请求会被 403 拦下来,排查时会有一点懵。
preserve_host前面说过,是给“同 IP 不同虚拟主机”场景用的。本地联调一般用不上,保持false就行。如果你代理的目标地址是一个域名,而不是 IP,保留不保留 Host 差异不大,cURL 本身会以目标域名为 Host。
add_cors_headers在前端跨域调试时建议保持true。它会让代理替所有响应附加Access-Control-Allow-Origin: *,前端页面就能直接拿到数据。如果你要带 Cookie 跨域,就不能用*,而是要把这行改为具体的前端源域名,并开启Access-Control-Allow-Credentials,下面排坑部分会展开。
timeout默认 30 秒。如果你在联调一个慢查询接口,30 秒不够,就把它调大。日志字段里的耗时也会实时变化,方便你判断瓶颈在哪台机器。
3.4 用 curl 实测验证
启动后,先用命令行验证一下转发是否正常。最简单的是 GET:
curl -i "http://127.0.0.1:8080/api/users"-i会打印响应头。如果代理正常,你会看到 200 状态码、透传回来的Content-Type,然后是后端返回的 body。如果后端没起,代理会拿到一个连接失败错误,CURLINFO_RESPONSE_CODE为 0,最后回写 502。
再测一个 POST JSON:
curl -X POST "http://127.0.0.1:8080/api/user" \ -H "Content-Type: application/json" \ -d '{"name":"dev","age":18}'这时看代理日志,应该有一行记录该请求和耗时。日志里能看到转发的目标主机,方便确认路由有没有命中。
如果想确认 CORS 头是否生效,上面的命令加-H "Origin: http://127.0.0.1:5173",再-i看响应头里是否出现Access-Control-Allow-Origin: *。浏览器跨域调试时这个头是关键。
4. 实战里的坑与排查技巧
4.1 重定向和302状态被吞的问题
我在做登录流程联调的时候踩过一个大坑:后端登录接口返回 302,期望浏览器自己跳转到首页。结果用了代理之后,前端拿到的不是 302,而是跳转后的首页 HTML。原因是代码里开了CURLOPT_FOLLOWLOCATION,让 cURL 自动跟随了重定向。
自动跟随重定向在日常接口转发时很方便,少处理一跳;但如果你需要保留 302 状态给前端,或者需要观察每一次跳转的 Location,这个选项反而碍事。我的处理策略是:默认跟随,但把这个行为抽成一个配置项。如果你也写自己的版本,可以让follow_redirect在true和false之间切换,false时不设FOLLOWLOCATION,直接把后端 302 和Location头原样回给前端。
另外还要注意,跟随重定向时 cURL 默认会把后续响应的内容作为最终结果,中间跳转过程不会出现在日志里。如果你怀疑跳转链路过长或有循环,可以把MAXREDIRS调小,重定向次数太多时会返回错误,日志里能看到报错信息。
4.2 会话保持:Cookie和跨域Credentials的配合
本地联调最常见的会话问题是:登录成功之后,后续请求没有携带 Cookie,导致一直 401。首先看代理层,代理把浏览器发来的Cookie请求头原样转发给后端,后端下发的Set-Cookie也会原样回给浏览器,这两个环节默认是通的。问题多半出在浏览器跨域策略上。
如果前端域名是http://127.0.0.1:5173,代理是http://127.0.0.1:8080,前端要携带 Cookie,必须在 fetch 里设置credentials: 'include',同时代理响应的 CORS 头不能是Access-Control-Allow-Origin: *,必须是具体的源地址,并且要加上Access-Control-Allow-Credentials: true。浏览器的规则很严格,*和credentials同时出现会直接拒绝请求。
我实际使用时是手动把Access-Control-Allow-Origin改成前端地址。你如果经常需要带 Cookie 联调,可以在配置区加一个cors_origin字段,动态填充这个头,而不是写死*。
4.3 SSL证书、压缩编码和中文乱码
本地开发遇到 HTTPS 后端很常见。比如前端联调一个测试环境的域名,证书是公司内部签发的自签名证书,cURL 默认校验会报错,返回的错误信息通常是SSL certificate problem: self-signed certificate。代码里已经默认关闭了证书校验,但我要提醒:这个开关只适合本地开发。如果把这份代码部署到正式环境,必须把CURLOPT_SSL_VERIFYPEER改回true,否则就无法确认通信对端身份,安全边界等于没有。
再说压缩编码。如果你测试时发现代理日志里的响应体是一片乱码,先检查请求头里是否带了Accept-Encoding: gzip。我在代码里过滤了这个请求头,后端通常不会返回压缩内容。但有些后端框架会强制开启 Gzip 中间件,这时候响应头里会出现Content-Encoding: gzip,body 是二进制。临时处理方式是给 cURL 设置CURLOPT_ENCODING => '',让 cURL 自动解压,然后记得把响应头里的Content-Encoding移除,否则浏览器会再做一次解压导致错误。
中文乱码一般不是代理的问题,更多是 Content-Type 里的 charset 设置没透传好。后端如果返回application/json; charset=utf-8,我的代码会把整个Content-Type原样透传,浏览器按 UTF-8 解析就不会乱码。如果后端漏了 charset,前端可以自己在response.text()之后用TextDecoder兜底,代理不需要转码。
4.4 日志轮转和大请求体的资源控制
日志默认写到一个文件里,长年累月会越来越大。一个折中方案是在写日志时把文件名按天分隔开:
$logFile = $config['log_file'] . '.' . date('Y-m-d');这样每天一个文件,观察问题的时候按日期去找也方便。不要忘了给日志目录写权限,否则file_put_contents失败并只是被@静默吞掉,你还在奇怪为什么没日志。
大请求体的坑更隐蔽。file_get_contents('php://input')会把整个请求体载入 PHP 进程内存。如果只是传几百 KB 的 JSON 完全没事,但你要代理一个几十 MB 的文件上传,内存占用就可能顶不住。PHP 内置服务器本身是单进程,内存一爆炸整个代理就挂了。针对文件上传场景,更合理的做法是用 cURL 的CURLOPT_INFILE从文件流读取,或者干脆不要用单文件代理,直接用 Nginx 反代。这也是为什么我一直强调它只适合开发场景。
4.5 常见问题速查表
最后把实际使用中碰到的问题整理成一张速查表,方便你遇到现象时直接对着查。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 转发后返回 403 | 目标主机不在allowed_targets白名单 | 把目标 IP 或域名加进白名单 |
| 浏览器报跨域错误 | 响应头缺少 CORS 头 | 打开add_cors_headers或将源加入白名单 |
| 请求超时 | 后端响应慢或timeout太小 | 调大 timeout,确认后端可达 |
| 后端收到请求但路由 404 | 代理路由前缀和后端实际路径不匹配 | 检查routes前缀是否覆盖请求路径 |
| 登录后一直 401 | Cookie 未跨域携带 | 设置credentials: 'include'并调整 CORS 头 |
| 响应体是乱码/压缩块 | 后端强制 Gzip | 过滤 Accept-Encoding 或设置自动解压 |
| 状态码变成 302 后的页面 | cURL 自动跟随重定向 | 关闭FOLLOWLOCATION或改配置项 |
| 日志里没有请求记录 | 日志目录无写权限 | 检查 proxy.log 所在目录权限并去掉@调试 |
| 启动命令报端口占用 | 8080 被其他进程占用 | 换一个端口再启动 |
这张表是我实际踩坑的记录汇总,大部分问题都是配置和转发语义理解上没对齐导致的。对照着排查,基本几分钟就能定位。
我自己现在把这份 proxy.php 固定放在~/tools/proxy.php,命令行里配了一条 alias:alias dev-proxy='php -S 127.0.0.1:8080 ~/tools/proxy.php'。需要联调时,一行命令启动,用完 Ctrl-C 关掉。它帮我解决过太多“前端配跨域、后端改接口、临时连同事服务”的琐碎问题。如果你也在做前后端分离开发,或者经常要在一个入口里接好几个后端服务,不妨把这份脚本留下来,大概率某一天能派上用场。