本节目标:看清 Node 原生
http模块在工程化场景下的类型缺口;掌握 Fastify 的 schema 驱动路由与 Hono 的 Web 标准路由;学会用插件与分组组织大型服务的路由树;并能按部署形态在两者之间做出有依据的选型。
5.1 HTTP 服务与路由(Fastify / Hono)
前四章我们解决了「项目怎么搭、配置怎么读、错误怎么抛、日志怎么打、测试怎么写」。从这一章开始,服务要真正对外提供能力了,而最外层的一层就是 HTTP 协议接入与路由分发。
在 TypeScript 里写 HTTP 服务,框架的选择会直接决定类型信息能渗透到多深:一个只有 (req, res) => void 签名的框架,会把所有类型工作都推回给你手写断言;而一个把「路由声明、请求校验、处理函数入参」串成同一条推导链的框架,能让 tsc 替你守住接口边界。
本节同时覆盖 Fastify 与 Hono,不是为了罗列 API,而是因为它们代表了两条清晰的技术路线:自研抽象(Node 长驻进程) 与 Web 标准(边缘运行时)。理解差异比记住语法重要。
5.1.1 原生 http 模块:能力够,类型不够
Node 内置的 http 模块零依赖、启动最快,但它的类型签名是面向「流」而不是面向「业务」的。
import { createServer } from 'node:http'
const server = createServer((req, res) => {
// req.url 的类型是 string | undefined
// req.method 的类型是 string | undefined
const url = new URL(req.url ?? '/', 'http://localhost')
if (req.method === 'GET' && url.pathname === '/healthz') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
}
res.writeHead(404)
res.end()
})
server.listen(3000, () => {
console.log('listening on http://localhost:3000')
})
这段代码能跑,但三处类型信息是丢失的:
req.method是宽泛的string | undefined,编译器无法帮你穷举GET/POST分支;- 查询串与请求体没有任何校验,
url.searchParams.get('page')的返回类型是string | null,转数字全靠手写; - 处理逻辑与路由匹配混在同一个函数里,路由一多就会退化成手写
switch。
真正的问题不是「原生能不能用」,而是类型信息在框架边界上断裂。Fastify 与 Hono 各自用不同方式把这条链接了回去。
5.1.2 Fastify:schema 驱动的一体化推导
Fastify 的核心设计是「JSON Schema 先行」。你为路由声明 schema,框架在运行期用它校验并序列化,同时在编译期把 schema 推导成处理函数的参数类型。
import Fastify from 'fastify'
import { Type, type Static } from '@fastify/type-provider-typebox'
const app = Fastify({ logger: true })
const CreateUser = Type.Object({
name: Type.String({ minLength: 1, maxLength: 32 }),
email: Type.String({ format: 'email' }),
age: Type.Optional(Type.Integer({ minimum: 0, maximum: 150 })),
})
type CreateUser = Static<typeof CreateUser>
app.post(
'/users',
{ schema: { body: CreateUser } },
async (req, reply) => {
// req.body 的类型自动是 CreateUser,不需要任何断言
const { name, email, age } = req.body
reply.code(201)
return { id: crypto.randomUUID(), name, email, age }
},
)
await app.listen({ port: 3000, host: '0.0.0.0' })
这里的关键是 Type.Object 一次声明、三处复用:运行期校验、编译期类型(Static<typeof CreateUser>)、以及 OpenAPI 文档生成。校验失败时 Fastify 会自动返回 400 并附带 message 字段,你不需要写任何 if (!body.name)。
若项目已统一用 Zod,可以换成 fastify-type-provider-zod,把 Type.Object 替换成 z.object,推导机制完全一致:
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
schema 不只约束入参,也约束出参。声明 response 后,Fastify 会用 fast-json-stringify 按 schema 生成序列化器,既提速又顺手做了「字段白名单」——schema 里没写的字段会被静默丢弃:
const User = Type.Object({
id: Type.String(),
name: Type.String(),
email: Type.String(),
passwordHash: Type.String(), // 内部字段
})
const PublicUser = Type.Omit(User, ['passwordHash'])
app.get(
'/users/:id',
{
schema: {
params: Type.Object({ id: Type.String() }),
response: { 200: PublicUser },
},
},
async (req) => {
const user = await repo.findById(req.params.id)
// 返回值里即使带上 passwordHash,也会被序列化器剔除
return user
},
)
这一点在安全上价值很大:「忘记删敏感字段」这类事故可以被 schema 结构性防住,而不是靠每个 handler 自觉。代价是返回值多出的字段不会报类型错误,只会被悄悄丢掉,调试时容易困惑,建议在开发环境打开 logger.level = 'debug' 观察序列化行为。
req.body 若被写成 any,说明类型推导链断了。最常见的原因是忘了装 type provider,此时 Fastify 会退回默认的 FastifyRequest 泛型,你写的 req.body.name 不会报错也不会被校验——这是最危险的静默失败。
5.1.3 用插件与前缀组织路由树
单文件里堆二十个 app.get 是不可维护的。Fastify 的插件模型(register + prefix)提供了真正的封装边界:插件内注册的 hook、装饰器默认不外泄。
import type { FastifyPluginAsync } from 'fastify'
const usersRoutes: FastifyPluginAsync = async (app) => {
app.get('/', async () => [{ id: '1', name: 'Ada' }])
app.get('/:id', async (req, reply) => {
const { id } = req.params as { id: string }
if (!/^\d+$/.test(id)) {
return reply.code(400).send({ message: 'id 必须是数字' })
}
return { id }
})
}
export default async function routes(app: import('fastify').FastifyInstance) {
await app.register(usersRoutes, { prefix: '/users' })
// 实际路径:GET /users、GET /users/:id
}
prefix 不只是拼字符串,它同时影响路由表、日志上下文与 OpenAPI 分组。若你希望插件内的装饰器向上「泄漏」(比如全局注册一个 app.db),需要显式用 fastify-plugin 包一层:
import fp from 'fastify-plugin'
export default fp(async (app) => {
app.decorate('db', createPool()) // 加 fp 后外部 app.db 才可见
})
忘记 fp() 时,插件内 decorate 的东西在外部看不见,报错形如 app.db is not a function——这是初学者最容易踩的坑。
5.1.4 Hono:Web 标准与边缘运行时
Hono 走的是另一条路:完全基于 Web 标准 Request / Response,因此同一份代码可以跑在 Node、Bun、Deno、Cloudflare Workers 上。
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().min(1).max(100).default(20),
})
app.get('/users', zValidator('query', querySchema), (c) => {
const { page, size } = c.req.valid('query')
// page / size 的类型是 number,不是 string
return c.json({ page, size, items: [] })
})
export default app
z.coerce.number() 解决了查询串永远是字符串的问题;c.req.valid('query') 返回的是 Zod 推导出的类型,而不是 Record<string, string>。若换成 c.req.query(),你拿到的仍然是字符串,这层差异是 Hono 新手最容易忽略的。
Hono 还能把服务端路由类型直接导出给前端,用 hc 客户端获得端到端类型安全:
// server.ts
export type AppType = typeof app
// client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('http://localhost:3000')
const res = await client.users.$get({ query: { page: '2' } })
const data = await res.json() // 类型由服务端路由推导
这与后文契约章节的思路一致,可参考 16.1 tRPC 端到端类型安全 。
5.1.5 用 inject 给路由写测试
Fastify 内置 app.inject,无需真的监听端口就能发起请求,这对单元测试非常友好;涉及真实数据库的场景则参考 4.2 集成测试与 Testcontainers
。
import { test, expect } from 'vitest'
import { buildApp } from './app'
test('POST /users 校验失败返回 400', async () => {
const app = buildApp()
const res = await app.inject({
method: 'POST',
url: '/users',
payload: { name: '', email: 'not-an-email' },
})
expect(res.statusCode).toBe(400)
await app.close()
})
把 buildApp() 与 app.listen() 拆开是关键:前者返回实例供测试注入,后者只在进程入口调用。Hono 同理,用 app.request('/users?page=2') 即可测试:
import { test, expect } from 'vitest'
import app from './app'
test('GET /users 查询串被强制转型', async () => {
const res = await app.request('/users?page=3&size=50')
expect(res.status).toBe(200)
await expect(res.json()).resolves.toMatchObject({ page: 3, size: 50 })
})
test('size 超过上限返回 400', async () => {
const res = await app.request('/users?size=999')
expect(res.status).toBe(400)
})
注意 app.request 接收的是路径字符串,底层会构造 Web 标准 Request,因此在 Workers 环境里同一份测试代码也能跑。这也是 Hono 在 CI 里「零成本可测」的原因之一。
5.1.6 选型对照
| 维度 | Fastify | Hono |
|---|---|---|
| 运行时 | Node(长驻进程) | Node / Bun / Deno / Workers / 边缘 |
| 请求抽象 | 自研 Request/Reply | Web 标准 Request/Response |
| 校验集成 | JSON Schema(type provider) | 任意,Zod 最常用 |
| 插件/中间件 | 封装式插件 + hooks | 洋葱式 app.use |
| 生态成熟度 | 高(DB、缓存、队列插件齐全) | 中,边缘场景领先 |
| 测试方式 | app.inject | app.request |
| 典型场景 | 传统后端 API、微服务 | BFF、边缘函数、轻量 API |
经验判断:需要长驻进程、连接池、后台任务的服务选 Fastify;部署到边缘或需要极致冷启动的选 Hono。两者都不是「过渡方案」,同时用也很常见——边缘层用 Hono 做鉴权与聚合,内网核心服务用 Fastify。
5.1.7 三个高频坑
第一个坑是异步处理函数忘记 return。Fastify 依赖返回值序列化响应,写成 async (req, reply) => { reply.send(data) } 又忘记 return,会导致响应体为空;要么统一 return,要么统一 reply.send 并 return reply。
第二个坑是路由参数永远是字符串。/users/:id 的 req.params.id 是 string,直接参与数据库查询前必须校验或转换,否则会拿到 "abc" 这类脏值。schema 里的 params 声明同样不会自动转换类型,只做校验。
第三个坑是 404 与 405 语义混淆。Fastify 默认对未知路径返回 404,对路径存在但方法不匹配也返回 404;如果你的前端依赖 405 做提示,需要用 app.setNotFoundHandler 自行区分。
路由能通了,但请求从进入到离开要经过鉴权、日志、限流、事务等一系列横切逻辑——这正是下一节 5.2 中间件与请求上下文 的主题。若你尚未搭好日志与错误处理基线,建议先回看 3.3 结构化日志与脱敏 与 3.2 全局错误边界与未捕获异常 。
小结
本节的核心结论是:框架的价值在于把「声明—校验—类型」串成一条链。
- 原生
http模块能用,但路由匹配、参数校验、类型推导都要手写,只适合极简场景; - Fastify 用 JSON Schema / Zod 作为单一事实来源,一次声明同时产出运行期校验、编译期类型与接口文档;
- 插件与
prefix是 Fastify 组织大型路由树的手段,fastify-plugin决定装饰器是否外泄; - Hono 基于 Web 标准,天然适配边缘运行时,
zValidator配合z.coerce能干净地解决查询串类型问题,hc客户端可把类型一路带到前端; - 选型依据是部署形态与生态需求,而非性能数字;
- 路由可测性来自「构建实例」与「监听端口」的分离,
inject/request让 HTTP 层也能进单元测试。
下一步我们把横切关注点从路由里抽出来:请求 ID 怎么贯穿全链路、认证结果怎么类型安全地挂到请求上、错误怎么统一收口。延伸阅读可参考 Node.js 服务端实践 与 Hono 边缘框架 。
阅读导航:上一节:4.3 类型测试与覆盖率门禁 · 下一节:5.2 中间件与请求上下文 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。