从 TP5.1 到 Next.js BFF:7 个让首页一直显示 Mock 的坑
作者:陈宁(万象部落) 关键词:Next.js、BFF、ThinkPHP、Docker、Nginx、pm2、gzip 分类:架构
背景
我有一个从 2014 年用 ThinkPHP5.1 写的老博客,跑在一台轻量云服务器上。去年想把它升级成个人品牌站,于是引入了 Next.js 16 作为前端,并顺手搭了一层 BFF(Backend For Frontend)。
架构目标很明确:
- 浏览器只跟 Next.js 说话,所有页面由 Next.js 服务端渲染(SSR);
- Next.js 在服务端内部去调后端接口,当时是 ThinkPHP(web1 容器),未来要换成 Go/Gin;
- 后端不需要通过公网暴露,只要能被 Next.js 所在的容器从内网访问到就行;
- 公网(SLB/Nginx)只需要开 Next.js 一个端口。
思路是好的,但落地过程踩了一路的坑。最典型的表现是:接口单独 curl 全通,可首页 / 和 /blog 永远渲染的是本地 mock 数据。
这篇文章把踩过的 7 个坑完整记录下来。如果你也在做「存量 PHP 博客 + Next.js BFF」,或者准备把后端从 TP 迁到 Gin,这份清单应该能帮你少折腾一整天。
架构与故障链路
最终部署形态:
| 层 | 组件 | 说明 |
|---|---|---|
| 入口 | slb Nginx | 只 proxy_pass 到 node:3000 |
| 前端 | Next.js standalone (node 容器, :3000) | 内含 BFF 透明代理 + /api/home 聚合 |
| 后端 | ThinkPHP (web1 容器, :80) | 按 Host 头路由到博客块 |
数据链路:
接下来按排障顺序讲这 7 个坑。
坑 1:Undici 的 fetch 会忽略自定义 Host 头
现象
Next.js BFF 里用 fetch(upstream, { headers: { host: "www.wxbuluo.com" } }) 去调 web1,返回结果是空 body,JSON.parse 直接抛 Unexpected end of JSON input。
定位
- 在 node 容器里
curl -H "Host: www.wxbuluo.com" http://web1/api/article/list是正常的; - 不带 Host 头时
curl http://web1/api/...返回空。
结论:web1 的 Nginx 按 Host 头选择 server 块。请求从内部发出时 Host=web1,落到默认 server(auth 块),自然没有内容。
我原本试图用 Node 的 fetch 手动盖 Host 头,但 Undici(Node 内置 fetch 的实现)会忽略手动设置的 Host 头——这是它和 curl 行为的关键差异。
根因
app/api/[...path]/route.ts:
const UPSTREAM = process.env.BFF_UPSTREAM ?? "http://web1";
const UPSTREAM_HOST = process.env.BFF_UPSTREAM_HOST ?? "www.wxbuluo.com";
// 下面这行 header 设置对 undici 无效!
headers.set("host", UPSTREAM_HOST);
修复(一):临时方案 —— docker 网络别名(后来废弃)
当时的临场解法是让 URL 的主机名同时承担解析与 Host 路由,而不是依赖 header:
- 给 web1 在 docker 内网加别名
www.wxbuluo.com(deploy.sh自愈):
docker network connect --alias www.wxbuluo.com --alias wxbuluo.com docker_default web1
- BFF 上游直接用别名主机名:
// pm2.app.json
{
"env": {
"BFF_UPSTREAM": "http://www.wxbuluo.com", // 解析到 web1,同时提供规范 Host
"BFF_UPSTREAM_HOST": "www.wxbuluo.com"
}
}
这个方案能跑,但很别扭:别名依赖 docker 网络配置、而且只对单域名有效——将来再加 auth.wxbuluo.com 等域名时,别名没法按请求区分,只能都指向同一个容器,绕回「靠 Host 头区分 server 块」的老问题。
修复(最终):换 axios,显式设置 Host 头
根因既然是 undici 不让你设 Host,那就换一个允许设 Host 的 HTTP 客户端——axios。axios 底层走 Node http 模块,headers.host 会原样传给 http.request:
// app/api/[...path]/route.ts
import axios from "axios";
const UPSTREAM = process.env.BFF_UPSTREAM ?? "http://web1"; // 直接内网服务名
const UPSTREAM_HOST = process.env.BFF_UPSTREAM_HOST ?? "www.wxbuluo.com";
const upstream = await axios.request({
method,
url: `${UPSTREAM}/api/${path.join("/")}${req.nextUrl.search}`,
headers: { ...clientHeaders(req), host: UPSTREAM_HOST }, // 显式 Host,axios 会透传
responseType: "arraybuffer",
validateStatus: () => true,
maxRedirects: 0,
});
pm2.app.json 也回归内网服务名直连:
{
"env": {
"BFF_UPSTREAM": "http://web1", // docker 内网服务名直连
"BFF_UPSTREAM_HOST": "www.wxbuluo.com" // Host 头单独控制
}
}
docker 网络别名从此删除。将来接 auth.wxbuluo.com 也不用再加别名——同一个 web1 上跑多个 server_name,按路径前缀换 Host 头即可;换 Gin 则直接 BFF_UPSTREAM=http://gin:8080。
Checklist
- 内部调后端,优先选能显式设 Host 头的 HTTP 客户端(axios),而非 Node 内置 fetch
- 避免为每个域名加 docker 网络别名(多域名时别名指向同一容器,无法按请求区分)
- 用 axios 后:
BFF_UPSTREAM直连内网服务名,Host 头单独控制,多域名按路径分发
坑 2:NEXT_PUBLIC_* 变量在构建期被内联
现象
首页 / 接口通了,但 SSR 拉取 /api/home 仍然失败,回退到 mock。
定位
- CI 构建时注入了
NEXT_PUBLIC_API_BASE=https://www.wxbuluo.com; lib/api.ts里写const API_BASE = NEXT_PUBLIC_API_BASE ?? API_BASE_URL ?? "";- 服务端在 node 容器里
fetch("https://www.wxbuluo.com/api/home")→ node 容器没有出公网能力 / hairpin NAT 不通 → 抛错 → 回退 mock。
根因
NEXT_PUBLIC_* 变量在 next build 时被 webpack 内联成字面量。即使我后来在 pm2 运行时注入 API_BASE_URL,?? 链也会因为 NEXT_PUBLIC_API_BASE 永远是真值而短路,运行时变量根本轮不到。
// 错误:构建期内联后的 NEXT_PUBLIC_API_BASE 永远是 https://www.wxbuluo.com(真值)
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? process.env.API_BASE_URL ?? "";
修复
按执行环境分流:服务端永远用内网地址(读运行时变量,兜底 localhost:3000),客户端才用公网地址。
const isServer = typeof window === "undefined";
const API_BASE = isServer
? (process.env.API_BASE_URL ?? "http://localhost:3000")
: (process.env.NEXT_PUBLIC_API_BASE ?? "");
Checklist
- 服务端组件取数绝不依赖
NEXT_PUBLIC_*(那是给浏览器用的) - 服务端用非
NEXT_PUBLIC_的运行时变量,且给一个内网兜底 - 确认 node 容器没有公网回环,别让 SSR 走公网 URL 打自己
坑 3:pm2 reload --update-env 不会刷新环境变量
现象
改了 pm2.app.json 里的 BFF_UPSTREAM,也跑了 pm2 reload --update-env,进程环境变量却没变,线上行为还是旧的。
根因
pm2 reload --update-env 只在某些场景生效,对已运行进程的环境变量刷新不可靠;最稳妥的是删掉再从配置文件启动。
修复
pm2 delete wxbuluo-web
pm2 start /www/node-apps/wxbuluo-web/pm2.app.json
并固化到 deploy.sh,注释里写明原因,防止后人又用 --update-env。
Checklist
- 改环境变量后,delete + start 而不是 reload
- 部署后用
pm2 env 0 | grep <KEY>核对实际生效值
坑 4:构建期静态预渲染把 mock 烤进产物
现象
首页加了 force-dynamic 后好了,但 /blog 还是 mock。检查页面代码发现它根本没声明动态渲染,next build 阶段在构建容器里跑 getArticleList(),此时 localhost:3000 还没起,fetch 失败 → 回退 mock → mock 被写死进 .next 静态产物,线上请求直接命中缓存。
根因
只有 app/page.tsx 声明了:
export const dynamic = "force-dynamic";
而 app/blog/page.tsx、app/category/[id]/page.tsx、app/tag/[id]/page.tsx、app/article/[id]/page.tsx、app/search/[keyword]/page.tsx 都没声明;且分类/标签/详情页还带 generateStaticParams(基于本地 mock 数组),构建期必然静态化。
修复
给所有依赖后端数据的内容页统一加:
export const dynamic = "force-dynamic";
Checklist
- 所有 SSR 取数页面显式声明
force-dynamic(或revalidate) - 别让
generateStaticParams里的 mock 数据参与构建期预渲染 - 新增页面时把「动态渲染 + 数据源是 BFF」当成默认约定
坑 5:BFF 透传流式响应导致 stream closed early
现象
/blog 页面的 getArticleList() 抛 TypeError: terminated,pm2 日志里是:
⨯ Error: The destination stream closed early.
根因
BFF 透明代理(用 Node fetch 时)把上游响应直接透传 ReadableStream:
return new NextResponse(upstream.body, { status: upstream.status, headers });
SSR 场景下同一页面会并发触发多个该代理请求,直传流在 Next.js 内部容易提前关闭。
修复
改用 axios 后,用 responseType: "arraybuffer" 把上游响应缓冲再返回,不再直传流:
const upstream = await axios.request({ ..., responseType: "arraybuffer" });
const body = Buffer.from(upstream.data ?? []);
return new NextResponse(body, { status: upstream.status, headers: headersOut });
Checklist
- 内部代理接口用
arraybuffer缓冲,避免流式直传的竞态 - 记下这个错误特征(
stream closed early)便于日后快速定位
坑 6:gzip 二次解压导致 Z_DATA_ERROR
现象
缓冲修复后 /blog 仍然 mock,日志里露出真正的错误:
[getArticleList] fetch failed, falling back to mock: TypeError: terminated
[cause]: Error: incorrect header check
errno: -3,
code: 'Z_DATA_ERROR'
根因
web1 的 Nginx 开了 gzip on。BFF 用 Node fetch 拉上游时,Undici 已经自动解压了响应体,但代理层还是把 content-encoding: gzip 这个头透传给了下游。下游(getArticleList 里的 fetch)收到「已解压的 body + 还写着 gzip 的头」,再解压一次就报 incorrect header check。
修复
代理返回时把 content-encoding(以及流式相关的 content-length、transfer-encoding 等)从响应头里剔除。用 axios 时同样在组装响应头前过滤(axios 默认也自动解压 gzip):
const DROP = ["connection", "content-length", "transfer-encoding", "keep-alive", "content-encoding"];
const headersOut = new Headers();
for (const [k, v] of Object.entries(upstream.headers ?? {})) {
const lk = k.toLowerCase();
if (DROP.includes(lk)) continue;
if (v !== undefined) headersOut.set(k, String(v));
}
const body = Buffer.from(upstream.data ?? []);
return new NextResponse(body, { status: upstream.status, headers: headersOut });
Checklist
- 代理层剥离
content-encoding(fetch/axios 都已自动解压) - 记住特征:
Z_DATA_ERROR / incorrect header check= 双重解压
坑 7:公网只暴露 Next.js,后端彻底内网化
现象(架构收尾)
这套 BFF 的初衷就是「浏览器只跟 Next.js 说话」。所以 slb Nginx 里只保留对 node:3000 的反代:
location / {
proxy_pass http://node:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
ThinkPHP 不需要通过 SLB 暴露到公网,公网完全不开放它的端口。后端只要能被 node 容器内网访问即可,未来换成 Gin 也一样。
好处
- 攻击面从「公网可达的 PHP 站点」收敛成「只暴露一个 Node 进程」;
- 后端 IP/端口/语言随便换,前端与公网入口零改动;
- 换 Gin 时只要改
BFF_UPSTREAM=http://gin:8080并把它也挂到 docker 内网,其余不动。
Checklist
- 公网 Nginx 只
proxy_pass到 Next.js - 后端容器只挂内网网络,不映射公网端口
- 换后端 = 改一个环境变量 + 内网可达性
总结
回头看,这 7 个坑其实可以归纳成三句话:
- 内网优先:服务端取数一律走内网(
localhost:3000/web1服务名),永远别让 SSR 请求公网 URL 打回环;后端从公网剥离开,只留给 Next.js 内网访问。 - 认清构建期与运行期的边界:
NEXT_PUBLIC_*构建期内联、非NEXT_PUBLIC_*运行时读取;页面动态性要显式声明,别让构建期把 mock 静态化;pm2 改 env 要delete + start。 - 代理层要有洁癖:流式响应要么缓冲要么处理生命周期;上游解过压的 body 就不要再带
content-encoding头。
最终的效果是:/、/blog、/category/:id、/tag/:id、/article/:id、/search/:kw 全部由 Next.js 在服务端实时拉取 ThinkPHP 数据渲染,公网只留一个 Node 端口。等 Gin 就位,改一行 BFF_UPSTREAM 即可完成后端切换。
希望这篇记录能帮你绕开这些坑。