← 学习路线

路由、状态码与兜底

访问一个不存在的地址,你的网站会怎么回答?为什么"永远成功"反而是个问题?

课型:概念·约 20 分钟·6 个术语

先看出事的那一刻

迁移之前,在这个网站后面随手敲一个不存在的地址,比如 /nope,页面照常打开:页眉页脚都在,中间一片空白,状态码是 200。对浏览器、搜索引擎和监控来说,这是一次成功的访问。

兄弟项目 E2E Review 有一个同类的问题,只是藏得更深:用来确认后端是否健康的 /healthz,经过正式域名访问,返回的也是一张网页和 200。

这节课要回答的是:访问一个不存在的地址,你的网站会怎么回答?为什么"永远成功"反而是个问题?

装备几个词

为什么要懂

AI 替你做了

AI 搭项目时会顺手配好路由和兜底:任何地址都能打开,页面永远不会“坏掉”,换页也顺滑。

留给你的

决定“没有”该怎么说出口:不存在的页面回 404,出了错回 5 开头的状态码,健康检查要真的问到后端。这些 AI 很少主动替你想。

不懂的代价

搜索引擎收录一堆重复的空页面,打错的链接永远不会暴露,监控在后端挂掉的时候,依然显示一切正常。

路由的最后一条规则

每个请求进来,都要先经过路由:从上往下对照规则,第一条对得上的规则负责处理。真正考验设计的是最后一条:前面都没对上的请求,交给谁?

常见的写法有三种:

  • 一律送回首页或应用外壳,状态码 200。 单页应用最常见的做法,因为前端路由需要每个地址都拿到那个 HTML 壳。代价是不存在的地址也"成功"了。
  • 返回一张"找不到"页面,状态码 404。 人看到提示,程序也知道这里没有东西。
  • 跳转到新地址,状态码 301。 适合"东西还在,只是搬了家"。

人看的是页面,程序看的是状态码。一张写着"页面不存在"的页面配上 200,对人是 404,对程序是成功:这就是软 404。

深潜为什么单页应用要把所有地址都指向首页+

单页应用的页面是浏览器里的 JavaScript 画出来的。访客直接打开 /blog/x 时,服务器上并没有这个文件;不把请求改写到那个唯一的 HTML,访客会先看到托管平台的 404,前端路由根本没机会运行。

所以改写本身不是错,错在改写之后,服务器再也说不出"没有":它不知道哪些地址真实存在,只能一律回答 200。这是单页应用的先天局限。

Cloudflare Pages 的默认规则也是这样:项目里没有 404 页面,它就把项目当成单页应用,所有找不到的地址都交给首页;放一个 404 页面,才会真的返回 404。本站迁移之前属于前一种。

本站现在怎么回答

迁到静态生成以后,每个存在的地址都有自己的文件,其余地址交给真正的 404 页面:

请求状态码说明
/blog/loop-engineering200页面存在
/nope404构建时生成的 404 页面
/blog/loop-engineering.html308带 .html 的旧式地址,永久跳到不带后缀的地址
/e2e/mainline301主线目录搬到了 /e2e
e2e.felixwithai.com301整个旧站并入 /e2e

/e2e/mainline 这一行,是写这节课时才改对的。之前用的是框架自带的跳转,在静态网站里,它只能生成一张"打开后自动跳走"的网页,状态码是 200。人感觉不出区别,搜索引擎和 curl 看到的却是一个真实存在的页面。现在改由 Cloudflare Pages 的跳转规则直接返回 301。

健康检查:体温计要夹在病人身上

E2E Review 的后端,健康检查写得很像样(节选并略作简化):

app.get("/healthz", async (c) => {
  try {
    await pool.query("SELECT 1");   // 真的去问一次数据库
    return c.json({ status: "ok", database: "ok" });
  } catch (e) {
    return c.json({ status: "degraded", database: "error" }, 503);
  }
});

连数据库都查了一遍,失败就返回 503。部署平台 Railway 也配置了用它来判断新版本有没有启动成功。

问题出在从哪里去量。Railway 直接敲后端的门,量得准;经过正式域名去量,请求要先过 Cloudflare 上的转发脚本,而脚本只转发 /api/ 开头的地址(第 3 课),/healthz 落进了前端的兜底规则,拿回来的是一张网页和 200。

这个问题其实修过一次,修复随即被一次重新部署覆盖了。那晚的验证只看了状态码,而兜底页面也是 200,所以验证无论如何都会通过。下面找茬的最后一行,就是那条验证命令。

找茬

下面是 E2E Review 里几处负责兜底的真实代码,以及一条上线验证命令(节选)。站长的要求是:不存在的地址要明确告诉访客和程序“没有”;出了错要看得出来;健康检查要反映后端真实的状态。

这段 JS / Shell 里埋了 5 处问题。点击你觉得有问题的行,至少找出 3 处再揭晓。

查看代码与答案(5 处问题)
 1  // server/index.mjs(后端路由的最后两条,节选)
 2  app.use("/assets/*", serveStatic({ root: "./web/dist" }));
 3  app.get("*", serveStatic({ path: "./web/dist/index.html" }));
 4  
 5  // web/public/_worker.js(前端转发脚本里的兜底,节选)
 6  const res = await env.ASSETS.fetch(request);
 7  if (res.status === 404) {
 8    return env.ASSETS.fetch(new Request(new URL("/index.html", url), request));
 9  }
10  
11  // web/src/api.js(前端的请求封装,节选并简化)
12  const data = await r.json().catch(() => ({}));
13  if (!r.ok) throw new Error(data.error ?? `请求失败(${r.status})`);
14  return data;
15  
16  // web/src/App.jsx(解析井号地址的最后两行)
17  if (["mainline", "papers"].includes(view)) return { view, id: null, topic: null, query: "" };
18  return DEFAULT_ROUTE;
19  
20  # 7 月 28 日晚:给转发规则补上 /healthz 之后的上线验证
21  curl -s -o /dev/null -w "healthz(经 worker 反代): %{http_code}\n" https://e2e.felixwithai.com/healthz
  • 第 3 行 · 高危 · 找不到的 GET 请求,一律回首页和 200:这一行接住了前面没对上的所有 GET 请求:拼错的接口地址 /api/nope、不存在的脚本文件 /assets/missing.js,都会得到首页的 HTML 和 200,实测确实如此。调用方以为请求成功,拿到的却是一张网页。 该问的话:请求一个拼错的接口地址,比如 /api/nope,返回什么状态码和内容?
  • 第 7 行 · 中危 · 把 404 改写成了首页:静态文件找不到时,这里把 404 换成首页和 200。访客看不到“页面不存在”,程序也收不到 404。 该问的话:一个不存在的地址经过这段脚本,最后返回什么状态码?
  • 第 12 行 · 高危 · 解析失败,被当成了空结果:如果接口返回的是一张网页,比如被第 3 行接住了,r.json() 会解析失败,这里悄悄换成一个空对象;状态码又是 200,不会抛错。调用方拿着空数据继续往下走,出错的地方离真正的原因很远。 该问的话:如果接口返回的不是 JSON 而是一张网页,调用方会看到什么?
  • 第 18 行 · 中危 · 认不出的地址,悄悄当成首页:井号地址里的页面名认不出来时,这里直接返回首页,没有任何提示。访客以为链接把他带到了首页,而不是“这个页面不存在”。 该问的话:打开一个不存在的井号地址,页面会提示找不到吗?
  • 第 21 行 · 高危 · 只看状态码的验证:兜底页面也返回 200,所以不管修复有没有生效,这条验证都会通过。当晚它被当成了修复成功的证据;一分钟后修复被覆盖(第 3 课),也就再没有人发现。 该问的话:这个 200 是后端给的吗?返回的内容是什么?

快测

1. curl -I https://你的网站/根本不存在的地址 返回 HTTP/2 200,页面上写着“页面不存在”。这是什么情况?

查看选项与答案
  • A. 正常的自定义错误页:访客能看到“页面不存在”就行——人看到了提示,就算处理好了。可程序看到的是 200,也就是成功:搜索引擎会把它当成一个真实页面收录,监控也认为一切正常。
  • B. 服务端错误:后端内部出了问题,应先去查后端日志——页面不对劲,就是服务器出错了。可服务器出错时会回 5 开头的状态码;这里回的是 200,说明它认为一切顺利,问题恰恰是它把“没有”说成了“成功”。
  • C. 软 404:对人是找不到,对程序是成功(正确)——人看的是页面,程序看的是状态码。页面写着“找不到”,状态码也得是 404,两边才对得上。

页面给人看,状态码给程序看,两个都要对。

2. 单页应用托管在静态平台上,为了让 /blog/x 这样的地址能直接打开,把所有路径都改写到 index.html。代价是什么?

查看选项与答案
  • A. 所有地址都会被 301 跳到首页,地址栏也会变——把所有地址都指向首页,就是跳转到首页。可改写后返回的是首页的 HTML 和 200,前端路由才能在原地址上运行;301 是“东西搬了家”时才用的跳转。
  • B. 基本没有代价,这是单页应用托管的标准做法——这是标准做法,就不会有代价。做法确实常见,但代价是实实在在的:不存在的地址也会返回首页和 200。
  • C. 所有地址都返回 200,连根本不存在的地址也不例外(正确)——改写让前端路由能运行,副作用是服务器再也说不出“没有”。

单页应用的服务器不知道哪些地址真实存在,这是软 404 的根源。

3. 你要给后端配一个外部监控。下面哪种最可靠?

查看选项与答案
  • A. 请求 /healthz,核对状态码和内容里的后端状态字段(正确)——状态码和内容都要核对:内容里有后端给的状态字段,才说明请求真的到了后端,而且后端自己说它没问题;兜底页面也能返回 200,只看状态码分不出来。
  • B. 请求网站首页,页面能打开、返回 200 就算后端健康——首页能打开,后端就没事。可首页可能由 CDN 或静态页面直接返回,后端挂了,它照样是 200。
  • C. 请求 /healthz,只要状态码是 200,就算后端健康——健康检查地址返回 200,就说明后端健康。E2E Review 就吃过这个亏:经过正式域名请求 /healthz,被前端的兜底页面接住,拿回一张网页和 200。只看状态码,无论后端死活都会通过。

健康检查要验证两件事:问到的是后端,后端说它好。

判断时刻

你的博客是单页应用,托管在静态平台上。你发现不存在的地址都返回首页和 200。你让 AI 修,它给了三个方案。

你会选哪一个?

三个选项各自的代价
  • A. 在前端加一个“页面不存在”组件,路由对不上时显示它——考察修给人看,还是修给程序看:访客终于能看到提示了,改动也小。但服务器返回的仍然是 200:对搜索引擎和监控来说,什么都没变。这正是软 404。
  • B. 给托管平台加一个 404 页面,再列出所有真实存在的地址,其余一律返回 404——考察单页应用的局限:思路是对的,难在那张清单:单页应用的页面是浏览器画的,服务器事先不知道哪些地址存在。每新增一篇文章就要改一次配置,很容易漏;漏掉的那篇,访客直接打开就是 404。
  • C. 改成构建时生成每一页,没生成的地址交给真正的 404 页面——考察一次性投入:工作量最大,要换一种渲染方式(第 4 课)。但从此“存在”就等于“有这个文件”,不存在的地址自然得到 404,搜索和分享卡片的问题也一起解决了。本站走的就是这条路。

软 404 的根源,是服务器不知道哪些地址存在。能让服务器知道的方案才算治本;只改页面文字的方案,只修好了给人看的那一半。

带走

下次让 AI 做这件事时,问它

  1. 列出这个网站的全部路由,以及一个不在列表里的地址会被怎么处理、返回什么状态码。
  2. 页面不存在、没登录、没权限、服务器出错,各自返回什么状态码?页面上显示什么?
  3. 健康检查地址是哪个?它检查了哪些依赖?部署平台和外部监控,分别是经过哪条链路去访问它的?
  4. 接口返回的不是 JSON(比如是一张网页)时,前端会怎么处理?

自己验证

  • curl -I https://你的网站/根本不存在的地址:第一行应该是 404。
  • curl -s https://你的网站/healthz:看返回的是不是后端给的那段状态,而不只看状态码。
  • curl -sI https://你的网站/某个旧地址:搬走的地址应该返回 301 或 308,并指向新地址。

“永远成功”的网站,等于什么都没告诉你。该说“没有”的时候,就回 404。