☰
PHP单文件代理服务器:轻松搞定跨域与本地联调
2026/10/5 2:57:27 网站建设 项目流程

做前后端分离项目时,最烦的不是写业务逻辑,而是本地联调。前端跑在 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-proxynpm 依赖初始化项目强可写工程化中间层
Python 转发脚本Python需解释器中可写临时脚本
PHP 单文件PHP + curl 扩展一条命令中内置日志快速联调/临时转发

我在实际项目里两者都用:正式环境走 Nginx,本地开发调试基本都用单文件代理。切换成本低,唯一要记住的是别把它当生产工具用。

2. 核心实现原理与关键细节拆解

2.1 一次请求从进入到返回的完整链路

整个代理的处理流程并不复杂,我用一句话先概括:收到请求、拼目标 URL、带原请求头和后端通信、把后端响应原样回给客户端。

拆开看有六个环节:

  1. 解析当前请求的 URL 和 HTTP 方法,拿到$_SERVER['REQUEST_URI']与$_SERVER['REQUEST_METHOD']。
  2. 根据路由表做前缀匹配,确定本次要转发的目标地址,拼接出完整的转发 URL。
  3. 把当前请求的请求头转成 cURL 能用的 header 数组,并补上X-Forwarded-For这类代理标记。
  4. 读取请求体php://input,按方法判断是否需要作为 POSTFIELDS 传给后端。
  5. 用 cURL 执行转发,拿到后端返回的状态码、Content-Type 和响应 body。
  6. 用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前缀是否覆盖请求路径
登录后一直 401Cookie 未跨域携带设置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 关掉。它帮我解决过太多“前端配跨域、后端改接口、临时连同事服务”的琐碎问题。如果你也在做前后端分离开发,或者经常要在一个入口里接好几个后端服务,不妨把这份脚本留下来,大概率某一天能派上用场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询