← 返回博客
架构2026-08-21 18:32:29 0

从 TP5.1 到 Next.js BFF:7 个让首页一直显示 Mock 的坑

在存量 ThinkPHP 博客上接入 Next.js BFF 时踩到的 7 个坑:undici 忽略 Host 头、NEXT_PUBLIC 构建期内联、pm2 reload 不刷新、静态预渲染固化 mock、流式透传、gzip 二次解压与后端内网化。

从 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 Nginxproxy_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,返回结果是空 bodyJSON.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:

  1. 给 web1 在 docker 内网加别名 www.wxbuluo.comdeploy.sh 自愈):
docker network connect --alias www.wxbuluo.com --alias wxbuluo.com docker_default web1
  1. 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.tsxapp/category/[id]/page.tsxapp/tag/[id]/page.tsxapp/article/[id]/page.tsxapp/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-lengthtransfer-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 个坑其实可以归纳成三句话:

  1. 内网优先:服务端取数一律走内网(localhost:3000 / web1 服务名),永远别让 SSR 请求公网 URL 打回环;后端从公网剥离开,只留给 Next.js 内网访问。
  2. 认清构建期与运行期的边界NEXT_PUBLIC_* 构建期内联、非 NEXT_PUBLIC_* 运行时读取;页面动态性要显式声明,别让构建期把 mock 静态化;pm2 改 env 要 delete + start
  3. 代理层要有洁癖:流式响应要么缓冲要么处理生命周期;上游解过压的 body 就不要再带 content-encoding 头。

最终的效果是://blog/category/:id/tag/:id/article/:id/search/:kw 全部由 Next.js 在服务端实时拉取 ThinkPHP 数据渲染,公网只留一个 Node 端口。等 Gin 就位,改一行 BFF_UPSTREAM 即可完成后端切换。

希望这篇记录能帮你绕开这些坑。

相关推荐