《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono)

本节从 Node 原生 http 模块的类型缺口讲起,对比 Fastify 与 Hono 两套框架的路由声明、请求校验与类型推导方式。你将学会用 JSON Schema 与 Zod 让请求参数自动获得类型,用插件与分组路由组织大型服务的路由树,并理解 Web 标准 Request/Response 在边缘运行时上的取舍。读完后你能按部署形态选出合适框架,写出端到端类型安全的接口层。

本节目标:看清 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')
})

这段代码能跑,但三处类型信息是丢失的:

  1. req.method 是宽泛的 string | undefined,编译器无法帮你穷举 GET / POST 分支;
  2. 查询串与请求体没有任何校验,url.searchParams.get('page') 的返回类型是 string | null,转数字全靠手写;
  3. 处理逻辑与路由匹配混在同一个函数里,路由一多就会退化成手写 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 选型对照

维度FastifyHono
运行时Node(长驻进程)Node / Bun / Deno / Workers / 边缘
请求抽象自研 Request/ReplyWeb 标准 Request/Response
校验集成JSON Schema(type provider)任意,Zod 最常用
插件/中间件封装式插件 + hooks洋葱式 app.use
生态成熟度高(DB、缓存、队列插件齐全)中,边缘场景领先
测试方式app.injectapp.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 中间件与请求上下文 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes