写第一个业务接口之前,有五个基础设施问题需要先回答。它们不影响”能不能跑”,但决定了”跑起来之后会不会突然崩”。
一、环境变量:启动时校验,不延后
后端事故有一个共同特征:错误发生在启动十几秒之后,而不是启动那一刻。
DATABASE_URL 写错了,服务启动成功。第一个请求进来——数据库连接失败。这时候你已经离开终端了。问题不是”连接失败”本身,而是反馈延迟。如果在启动时就拒绝连接配置错误的服务,你能立刻发现问题。
所以环境变量应该在进入业务逻辑之前完成校验:
const envSchema = z.object({ DATABASE_URL: z.string().min(1), // 空字符串 = 没配置 → 直接拒绝启动 JWT_SECRET: z.string().min(32), // 太短的密钥 = 安全隐患 API_PORT: z.coerce.number().int().default(4000)});z.string().min(1) 比 z.string() 多拦截一种错误:Docker Compose 里写了 ${MYSQL_URL} 但这个变量没定义,值变成空字符串。z.string() 会说”没问题”,min(1) 会说”不行,你得给我一个非空字符串”。
z.coerce.number() 处理类型不一致:Docker 环境变量的值永远是字符串,但端口号在代码里应该是数字。与其到处 parseInt(process.env.API_PORT),不如在入口一次性转好。
z.enum() 防拼写错误:NODE_ENV=develop(少了个 p)会让整个应用的日志级别、错误返回策略全乱套。用 enum 会在启动时报错而不是运行时悄无声息地行为异常。
这一步的本质是把环境变量从”不可信的外部输入”变成”类型确定的内部配置”。校验完的 env 对象和 process.env 是两个世界——前者你可以信任,后者不行。
二、配置和启动为什么拆成两个文件
app.ts:创建并配置实例,但不监听端口。暴露一个函数返回完全配置好的实例——所有插件注册、路由挂载、错误处理都在这一步完成。
server.ts:调 app.ts 拿到实例,监听端口,处理操作系统信号。
这层分离解决三个实际问题:
测试不需要端口。 app.ts 返回的实例可以直接发送虚拟 HTTP 请求(不经过 TCP 层),不需要真的监听一个 Socket。集成测试可以并行运行,不会端口冲突。
快速失败和优雅退出不混在一起。 app.ts 里的错误(插件加载失败、环境变量不合法)阻止实例创建——服务根本不会启动。server.ts 里的错误(端口被占用)发生在启动之后,需要通知容器编排工具重试。两者的处理策略不同,放在两个文件里职责清晰。
部署方式可以切换。 今天用 app.listen(),明天想用 Serverless——改 server.ts 即可,app.ts 不动。如果你把 listen 写在 app.ts 里,这个切换会困难得多。
三、模块边界:不是靠约定,是靠机制
Express 项目最常见的问题是:/health 端点想跳过鉴权,你只能把它写在 app.use(auth) 的上面。模块的边界靠写代码的人的纪律来维持,而不是框架来保证。
Fastify 的插件系统把这个隐式约定变成了显式结构。每个插件有独立的作用域——它的 Hook、装饰器、路由不会泄漏到其他插件:
// auth 插件:preHandler 只对插件内的路由生效app.register(async (instance) => { instance.addHook("preHandler", verifyAuth); instance.get("/me", handler);});
// health 插件:完全独立,不受 auth 影响app.register(async (instance) => { instance.get("/health/live", handler);});一个插件的鉴权 Hook 不会影响另一个插件的公开端点。 这不是靠”记得把路由写在 auth 上面”来保证的——是框架在隔离它们。
Express 的中间件是全局线性的。你想让 /health 跳过 auth,必须把它放在 app.use(auth) 上方。这个”上方”是一个隐式约定——新人不会知道。Fastify 把它变成了显式的结构:需要鉴权的路由放在需要鉴权的插件里。
四、Graceful Shutdown:保护什么
HTTP 服务被 kill 的时候,不是所有请求都已经返回了。可能有一个耗时较长的查询刚执行到一半——直接 process.exit() 会把它扔在半路。
后端框架的 close() 方法应该做三件事:停止接收新连接、等待当前请求处理完毕、释放外部资源连接。
你需要在注册外部资源时把清理逻辑绑定到关闭钩子上:
// 数据库app.addHook("onClose", () => prisma.$disconnect());
// 缓存app.addHook("onClose", () => redis.quit());onClose 的执行顺序和注册顺序相反——后注册的先关闭。这意味着数据库连接在业务模块处理完最后一批请求之后才会断开。你不会遇到”连接已经关了但 handler 还想用它”的尴尬。
在 server.ts 里监听 SIGTERM 并调用 close():容器编排工具发送停止信号时,服务优雅退出而不是被强制 kill。
五、健康检查:liveness 和 readiness 的区别
一个 /health 返回 200 只能告诉负载均衡器”进程还没死”。但后端服务有一个特殊状态:进程活着,但没有能力处理请求。 最典型的是数据库连接断了。
Kubernetes 用两个探针对应这两种状态:
Liveness Probe(进程是否存活):返回 200 表示进程没卡死。失败时 K8s 重启容器。这个端点应该最轻量——不查数据库、不连外部服务,只返回 200。
Readiness Probe(服务是否可用):主动检查数据库连通性(SELECT 1)。失败时 K8s 摘除流量但不重启容器。数据库恢复后,下一次检查通过,容器自动回到负载均衡池。
把 liveness 和 readiness 混成一个端点是常见的坑。 如果 /health 也查数据库,数据库挂了→ health 失败→ K8s 认为容器死了→ 重启所有容器→ 数据库连接压力更大→ 更难恢复。正确做法是把数据库检查只放在 readiness 里——摘流量,不重启雪崩。
小结
这五个问题不依赖任何特定框架:
- 环境变量什么时候校验、在哪里校验
- 配置和启动逻辑能不能分开测试
- 模块之间的边界靠什么保证——约定还是机制
- 服务被 kill 时正在处理的请求怎么办
- 容器平台怎么区分”进程还活着”和”服务还能用”
下一篇:HTTP API 设计的三层决策(二)——URL 命名标准、幂等性为什么不是八股、DTO 隔离的真正价值。
Some information may be outdated