先查路由,还是先查构建产物,这个选择可以换成一个更有用的判断顺序:先确认请求有没有打到 Void 的服务进程。构建产物漏文件时,更常见的现象是进程起不来、入口报错、静态资源一起挂掉;而“服务在跑、页面能开、接口全 404”这类现象,通常落在路由注册与部署路径前缀这一层。建议按“网络面板拿事实 → 服务端日志判到达 → 构建产物查入口 → 前缀比对 → 固化为启动自检”的顺序走,每一步都有可验证的凭据,不要凭印象猜测。
部署后接口 404 不要一上来重打包。先在浏览器网络面板记录一条失败请求的完整 URL、方法和状态码,再对照服务端日志:没有对应日志说明请求没进进程,方向在网络层、反向代理或路径重写;有日志说明已进入路由匹配,方向在注册前缀与部署路径是否一致。构建产物只在服务起不来或入口缺失时才优先排查。
在网络面板里记录一条失败请求的完整路径、方法和状态码
先拿事实,不要拿感觉。打开浏览器开发者工具的 Network 面板,触发一次失败的接口调用,把这一条请求完整记下来:
- 请求 URL:是
https://example.com/api/user/list还是https://example.com/app/api/user/list,前缀有没有被部署路径带出来; - 请求方法:GET / POST,是否被 301、302 改写过方法;
- 状态码:404 是由哪个环节返回的,页面本身 200 而接口 404,说明静态资源与接口走了不同分支;
- 响应头摘要:看
server、content-type、x-powered-by之类字段,判断 404 页面是 Void 自己返回的,还是反向代理返回的默认页。
然后把这条 URL 与本地开发时的请求 URL 并排比较,差异点通常只有两类:前缀多了一段(部署在子路径下),或者端口与域名变了但路径没变。这一步的价值在于,很多“接口 404”其实是前端请求地址被拼成了 /api/api/xxx,或者少了统一前缀。
在服务端日志里确认请求是否到达进程
把 Void 服务的访问日志打开,再复现一次请求,观察有没有对应的日志行。两类 404 在这里可以分开:
- 无日志:请求根本没到进程。方向在反向代理的 location 规则、路径重写、容器端口映射、健康检查路径放行上。此时改路由注册是无效动作。
- 有日志:请求已经进到 Void 的路由匹配阶段,日志里能看到方法、路径和最终状态码。此时方向在注册前缀、路由分组、结尾斜杠是否严格匹配。
# 期望看到的大致形态(字段名以实际日志中间件为准)
2025-01-01T00:00:00Z INFO request method=GET path=/app/api/user/list status=404 duration=0.4ms
2025-01-01T00:00:00Z INFO request method=GET path=/api/user/list status=200 duration=1.2ms
判读方法很直接:path 字段与实际注册的路径逐字比对。日志里 path 带了 /app 前缀而注册时没有,说明前缀被反向代理保留了下来;日志里 path 前缀被剥掉而注册时带了,说明反向代理做了 rewrite。如果日志中间件本身写在路由之后,未匹配的请求不会打印,需要把访问日志挂在最外层,这一点要结合所用框架的中间件顺序确认。
核对构建产物里服务端入口是否真的被打包
当服务进程起不来、启动即退出,或日志里完全没有任何请求时,才把构建产物提到前面查。做法是把产物目录与源码目录做一次清单对比,重点看服务端入口、路由注册文件、外部依赖是否齐备。
# 产物目录清单与源码目录对比
tree -L 3 dist/ | head -50
find dist -name "*server*" -o -name "*entry*" -o -name "*route*" | head -20
常见漏项往往出在自定义配置的忽略规则上:服务端目录被整体排除进 ignore、把 server/ 当成只读源码不做打包、外部依赖被标记为 external 但运行环境没装、路径别名在构建后没有解析成相对路径。这些都能通过“产物里有没有这个文件”直接确认,不需要猜测。若产物齐全但服务仍无法启动,问题在启动命令与入口指向,而不在打包。
核对路由注册前缀与部署路径是否一致
这是部署后接口全 404 最高频的一层。把路由注册代码与部署路径配置并排放在一起看:
// 路由注册侧(伪代码,API 名称以所用版本为准)
app.group("/api", (r) => {
r.get("/user/list", handler)
})
# 部署侧配置
location /app/ {
proxy_pass http://127.0.0.1:8080/; # 结尾斜杠决定前缀是否被剥掉
}
两种错法要分清:反向代理保留 /app 前缀而路由只注册了 /api,请求会 404;反向代理剥掉了前缀而路由注册里又写了 /app/api,同样 404。验证方式不靠读配置,靠请求:用一个确定存在的健康检查路径分别请求带前缀和不带前缀两个版本,比对返回码与响应体。
curl -i http://127.0.0.1:8080/healthz
curl -i https://example.com/app/healthz
curl -i https://example.com/healthz
直连进程返回 200、经代理返回 404,问题就在代理与部署路径;两者都 404,问题在路由注册本身。这一步的边界是:如果应用同时提供前端页面与接口,页面能打开并不证明接口路由已注册,两者可能走不同的中间件分支。
把这次结论固化成启动时的一条自检日志
排查完不要只留在脑子里。在 Void 服务启动、路由注册完成之后打印一条自检日志,把已注册的路由数量或关键路径打出来,下次部署就能一眼判断路由有没有注册成功。
// 启动自检日志骨架
const routes = app.routes(); // 或等价的已注册路由列表接口
console.log(`[startup] routes_registered=${routes.length} prefix=${DEPLOY_BASE_PATH || ""} health=${routes.some(r => r.path === "/healthz")}`);
// 关键路径可单独打印
console.log(`[startup] sample_route=${routes[0]?.method} ${routes[0]?.path}`);
判断凭据有三条:routes_registered 为 0,说明注册代码没被执行到,回头查构建产物与入口;前缀字段与实际部署子路径不一致,回头查代理配置;健康检查路径不在列表里,说明注册条件分支被跳过或者挂载顺序有问题。日志级别建议保持 info,只在启动时打印一次,避免污染访问日志的判读。