最佳实践
本页默认你已走完 教程:会写叶子路由、Schema、err、路由组和中间件。
这里整理 项目变大之后 的约定:目录、嵌套路由、中间件分层、启动配置、声明式元数据、withContext、SSE 与测试。
1. 目录怎么放
框架不强制结构。路由变多时,推荐:
src/
index.ts # Server + 全局中间件 + serve
routes/
index.ts # 汇总导出
notes.ts
users.ts
services/ # 与 HTTP 无关的业务函数(可选)
utils/- 路由文件:定义叶子,用
children挂到资源组 - 入口:只做组装与启动
- 也可以按功能模块拆(
modules/notes/{routes,service}),原则一样:路由薄、逻辑可测
2. 嵌套路由
叶子 = method + path + handler;组 = 只有 path + children(可再嵌套)。组负责前缀与共享中间件,叶子负责具体接口。
// routes/notes.ts
import { defineRoute, defineRoutes, Type, err } from 'vafast'
import { listNotes, getNote, createNote } from '../services/notes'
const NoteBody = Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String({ minLength: 1 }),
})
const listHandler = defineRoute({
method: 'GET',
path: '/',
handler: () => listNotes(),
})
const getOneHandler = defineRoute({
method: 'GET',
path: '/:id',
schema: { params: Type.Object({ id: Type.String() }) },
handler: ({ params }) => {
const note = getNote(params.id)
if (!note) throw err.notFound('笔记不存在')
return note
},
})
const createHandler = defineRoute({
method: 'POST',
path: '/',
schema: { body: NoteBody },
handler: ({ body }) => createNote(body),
})
export const notesRoutes = defineRoutes([
defineRoute({
path: '/notes',
name: '笔记',
description: '笔记 API',
children: [listHandler, getOneHandler, createHandler],
}),
])多级嵌套时,子路径写相对路径,最终 URL = 各级 path 拼接:
export const apiRoutes = defineRoutes([
defineRoute({
path: '/api',
children: [
defineRoute({
path: '/v1',
children: [
defineRoute({
path: '/notes',
children: [listHandler, getOneHandler, createHandler],
}),
],
}),
],
}),
])
// → GET /api/v1/notes 、 GET /api/v1/notes/:id 、 POST /api/v1/notes// routes/index.ts
import { notesRoutes } from './notes'
import { usersRoutes } from './users'
export const allRoutes = [...notesRoutes, ...usersRoutes]| 推荐 | 原因 |
|---|---|
叶子先定义为常量再放入 children | 文件可读,组级中间件好挂 |
子路径写相对路径(/、/:id) | 前缀由组统一提供 |
按资源拆文件,入口 ...allRoutes | 多模块组合清晰 |
| 鉴权 / 日志挂在组上 | 同一资源下的叶子自动继承 |
统一 API 前缀(可选)
也可以在入口统一拼前缀(不必改每个文件):
const BASE_PATH = '/api'
const routesWithBasePath = allRoutes.map((route) => ({
...route,
path: BASE_PATH + route.path,
}))
const server = new Server([
defineRoute({ method: 'GET', path: '/', handler: () => ({ ok: true }) }),
...routesWithBasePath,
])探活路由常放在无前缀的 /。更多规则见 路由指南。
3. 中间件怎么挂
中间件是洋葱模型,三层可叠加:
| 层级 | 写法 | 范围 | 典型用途 |
|---|---|---|---|
| 全局 | server.use(mw) | 全部路由(含 404) | CORS、请求 ID、访问日志 |
| 路由组 | defineRoute({ path, middleware, children }) | 该组及子孙 | 鉴权、租户、资源级日志 |
| 叶子 | defineRoute({ method, middleware, handler }) | 单接口 | 权限守卫、限流、上传校验 |
执行顺序(由外到内):全局 → 父组 → 子组 → 叶子 → handler,返回时反向。
import { Server, defineRoute, defineRoutes, defineMiddleware, serve, err } from 'vafast'
import { cors } from '@vafast/cors'
import { requestId } from '@vafast/request-id'
const log = defineMiddleware(async (req, next) => {
const start = Date.now()
const res = await next()
console.log(`${req.method} ${new URL(req.url).pathname} ${res.status} ${Date.now() - start}ms`)
return res
})
const auth = defineMiddleware(async (req, next) => {
const token = req.headers.get('authorization')
if (!token) throw err.unauthorized('请先登录')
return next({ userId: 'u_1' })
})
const adminOnly = defineMiddleware(async (req, next) => {
// 读上一层注入的上下文,或自行查库
return next()
})
const routes = defineRoutes([
// 公开
defineRoute({
method: 'GET',
path: '/health',
handler: () => ({ ok: true }),
}),
// 组级鉴权:/account/* 都要登录
defineRoute({
path: '/account',
middleware: [auth],
children: [
defineRoute({
method: 'GET',
path: '/profile',
handler: ({ userId }) => ({ userId }),
}),
// 叶子再加一层
defineRoute({
method: 'DELETE',
path: '/profile',
middleware: [adminOnly],
handler: ({ userId }) => {
console.log('delete', userId)
return null
},
}),
],
}),
])
const server = new Server(routes)
server.use(cors())
server.use(requestId())
server.use(log) // 全局:所有请求都打日志| 推荐 | 原因 |
|---|---|
| 横切能力用全局 | 与业务路径无关 |
| 同一资源的鉴权用组级 | 少在每个叶子重复挂 |
| 个别接口的额外约束用叶子 | 权限差异一眼能看出来 |
用 next({ ... }) 传上下文 | handler 直接拿字段;跨 children 见 §9 withContext |
官方包(按需安装):@vafast/cors、@vafast/jwt、@vafast/request-id、@vafast/request-logger 等。机制详见 中间件。
4. Schema 与类型同源
用 Type 做校验,用 Static 推断类型;不要用 class / interface 当请求模型。
import { Type, type Static } from 'vafast'
export const NoteBody = Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String({ minLength: 1 }),
})
export type NoteBody = Static<typeof NoteBody>相关 schema 可收拢:
export const NoteModel = {
create: NoteBody,
update: Type.Partial(NoteBody),
}校验失败时不进 handler,框架直接返回 HTTP 422:
{
"code": 422,
"message": "请求参数校验失败",
"details": [
{
"location": "body",
"path": "/title",
"field": "title",
"message": "Expected string length greater or equal to 1",
"value": ""
}
]
}| 字段 | 说明 |
|---|---|
details[].location | body / query / params 等 |
details[].field | 表单字段路径 |
details[].message | TypeBox 原始英文提示 |
details[].value | 触发错误的实际值(可选) |
业务错误(404、403 等)用下一节的 err.*,响应里一般没有 details。完整说明见 验证。
5. 错误用 err.*,服务不碰 HTTP
业务错误在路由 / handler 里 throw err.xxx(),框架转成 JSON;服务层只返回数据或 null,不构造 Response。
import { err } from 'vafast'
if (!id) throw err.badRequest('参数错误')
if (!row) throw err.notFound('资源不存在')
if (!allowed) throw err.forbidden('无权限')响应形状(无 details,与 Schema 422 区分):
{
"code": 404,
"message": "资源不存在"
}预定义错误一览
| 方法 | HTTP | 默认 message |
|---|---|---|
err.badRequest(msg?) | 400 | 请求参数错误 |
err.unauthorized(msg?) | 401 | 未授权 |
err.forbidden(msg?) | 403 | 禁止访问 |
err.notFound(msg?) | 404 | 资源不存在 |
err.conflict(msg?) | 409 | 资源冲突 |
err.unprocessable(msg?) | 422 | 无法处理的实体 |
err.tooMany(msg?) | 429 | 请求过于频繁 |
err.internal(msg?) | 500 | 服务器内部错误 |
第二个参数可传业务码(写入响应 code,HTTP 状态仍按上表):
throw err.notFound('用户不存在', 10001)
// → HTTP 404, { code: 10001, message: "用户不存在" }
throw err('自定义消息', 418, 20001) // 任意状态码// ❌ 服务里构造 Response
export function getNote(id: string) {
if (!id) return new Response('Bad Request', { status: 400 })
}
// ✅ 服务返回数据或 null;路由决定是否 throw err
export function getNote(id: string) {
return db.notes.findById(id)
}| 场景 | 做法 |
|---|---|
| 请求形状不对 | 靠 schema → 自动 422 + details |
| 业务规则失败 | throw err.* → 对应状态码,无 details |
| 服务层 | 不碰 HTTP;由路由 throw |
更多见 API 参考 · 错误处理。
6. 服务层:普通函数即可
与请求无关的逻辑抽成函数,便于单测:
// services/notes.ts
import type { NoteBody } from '../models/note'
const notes: Array<NoteBody & { id: string }> = []
export function listNotes() {
return notes
}
export function getNote(id: string) {
return notes.find((n) => n.id === id)
}
export function createNote(input: NoteBody) {
const note = { id: String(Date.now()), ...input }
notes.push(note)
return note
}路由负责:校验、鉴权上下文、调用服务、映射 err。不必强行 MVC。
7. 启动与 serve 配置
入口只做组装:挂全局中间件(见 §3),再 serve:
import { Server, serve, defineRoute } from 'vafast'
import { cors } from '@vafast/cors'
import { requestId } from '@vafast/request-id'
import { allRoutes } from './routes'
const server = new Server([
defineRoute({ method: 'GET', path: '/', handler: () => ({ ok: true }) }),
...allRoutes,
])
server.use(cors())
server.use(requestId())
serve({
fetch: server.fetch,
port: 3000,
hostname: '0.0.0.0',
bodyLimit: 1024 * 1024, // 默认 1MB;上传可调大,0 = 不限制
timeout: { requestTimeout: 30_000 },
gracefulShutdown: true,
trustProxy: true, // 反代后取真实 IP
})serve() 常用配置
| 选项 | 默认 | 作用 |
|---|---|---|
fetch | (必填) | 通常传 server.fetch |
port | 3000 | 监听端口 |
hostname | '0.0.0.0' | 绑定地址 |
bodyLimit | 1MB | 请求体上限(字节);超限 → 413;0 不限制 |
timeout.requestTimeout | 0(不限) | 单请求处理超时(毫秒);超限 → 504 |
timeout.headersTimeout | Node 默认 | 收齐请求头的超时 |
timeout.keepAliveTimeout | Node 默认 | Keep-Alive 空闲超时 |
gracefulShutdown | 关 | true 或对象:收 SIGTERM/SIGINT 后等请求结束再关 |
trustProxy | false | 信任反代,从 X-Forwarded-* 取 IP;request.ip / ips |
onError | — | Node 适配层未捕获错误时的兜底 |
| 场景 | 建议 |
|---|---|
| 纯 JSON API | bodyLimit 保持 1MB 或更小 |
| 文件上传 | bodyLimit: 10 * 1024 * 1024 等按业务调 |
| 前面有 Nginx / Ingress | 超时多交给代理;需要真实 IP 时开 trustProxy |
| 裸奔公网 | 设 timeout.requestTimeout(如 30–120s)防慢速 DoS |
| K8s / 容器 | gracefulShutdown: true(或设 timeout) |
完整字段与示例见 API 参考 · serve()。
8. 路由可以加参数:声明式元数据
理念
Vafast 的路由是配置对象,不只是 method + path + handler。
| 概念 | 含义 |
|---|---|
| 声明式 | 路由配置描述「这个端点能做什么、带什么能力」 |
| 单一数据源 | 元数据写在叶子上,和 handler 放一起;不另起 path→权限/计费 映射表 |
| 可查询 | 中间件、文档、Webhook 通过 RouteRegistry 读同一份配置 |
| 显式 | 行为开关写在路由上(如 sse: true),不靠隐式推断 |
路径上的 :id、schema 管的是请求怎么进来;路由上的参数管的是端点自身的能力与策略。
内置元信息
defineRoute({
method: 'POST',
path: '/notes',
name: 'create_note', // 机器可读(文档、工具)
description: '创建一条笔记', // 人可读
docs: { tags: ['notes'] }, // OpenAPI 等
sse: true, // 显式声明 SSE(如需要)
schema: { body: NoteBody },
handler: ({ body }) => createNote(body),
})业务扩展字段
任意自定义字段都会保留在扁平化后的路由上,常见用法:
defineRoute({
method: 'POST',
path: '/notes',
name: 'create_note',
description: '创建一条笔记',
webhook: true, // 写操作触发 Webhook
permission: 'notes.create', // 权限码
// billing: { price: 0.01 }, // 按需:计费、审计等
schema: { body: NoteBody },
handler: ({ body }) => createNote(body),
})中间件用 getRouteRegistry() 按当前请求查元数据,而不是硬编码路径:
import { defineMiddleware, getRouteRegistry, err } from 'vafast'
const requirePermission = defineMiddleware(async (req, next) => {
const route = getRouteRegistry().get(req.method, new URL(req.url).pathname)
if (route?.permission) {
const allowed = await checkPermission(req, route.permission)
if (!allowed) throw err.forbidden('无权限')
}
return next()
})| 推荐 | 原因 |
|---|---|
| 元数据写在叶子路由上 | 和 handler 同处,改接口时一起改 |
| 中间件读 Registry | 横切逻辑与具体 path 解耦 |
| 扩展字段命名稳定 | 便于 registry.filter('webhook') 批量收集 |
需要给扩展字段做 TypeScript 约束时,用下一节的 withContext 第二个泛参。完整说明见 路由 · 扩展字段。
9. 用 withContext 封装类型安全路由
withContext 用来创建带预设上下文的路由定义器:定义一次,多处复用。handler 自动获得中间件注入字段的类型;第二个泛参约束上一节的扩展字段(如 webhook、permission)。
常见场景:
| 场景 | 作用 |
|---|---|
| 中间件注入的上下文 | handler 里直接用 userId / role,有完整类型 |
| 路由拆到多个文件 | 导出 defineAuthedRoute,各模块共用同一套上下文约定 |
组级 middleware + children | 跨调用时 TS 推不出父级注入,用定义器显式接上 |
| 扩展字段类型 | withContext<Ctx, { webhook?: boolean; permission?: string }>() |
import {
defineRoute,
defineRoutes,
defineMiddleware,
withContext,
err,
} from 'vafast'
const auth = defineMiddleware(async (req, next) => {
const token = req.headers.get('authorization')
if (!token) throw err.unauthorized('请先登录')
return next({ userId: 'u_1', role: 'admin' as const })
})
// 定义一次:预设上下文 + 扩展字段类型
const defineAuthedRoute = withContext<
{ userId: string; role: 'admin' | 'user' },
{ webhook?: boolean; permission?: string }
>()
const profileHandler = defineAuthedRoute({
method: 'GET',
path: '/profile',
permission: 'account.read',
handler: ({ userId, role }) => ({ userId, role }),
})
const updateHandler = defineAuthedRoute({
method: 'PATCH',
path: '/profile',
webhook: true,
permission: 'account.write',
handler: ({ userId, body }) => ({ userId, ...body }),
})
export const accountRoutes = defineRoutes([
defineRoute({
path: '/account',
middleware: [auth],
children: [profileHandler, updateHandler],
}),
])| 对比 | 做法 |
|---|---|
| 叶子自己挂 middleware、无自定义扩展类型 | 普通 defineRoute 即可 |
| 要复用上下文 / 约束扩展字段 / 组级注入 | withContext 封装定义器 |
| 自建 JWT 签发 / 校验 | @vafast/jwt |
原理详见 中间件 · withContext。
10. SSE 流式响应
单向实时推送(AI 对话、进度、通知)用内置 SSE:路由上 显式 sse: true,handler 写 async function*,直接 yield 即可。
import { defineRoute, Type } from 'vafast'
defineRoute({
method: 'POST',
path: '/chat',
sse: true,
schema: {
body: Type.Object({
prompt: Type.String({ minLength: 1 }),
}),
},
handler: async function* ({ body }) {
yield { type: 'start' }
for await (const chunk of streamModel(body.prompt)) {
yield { type: 'delta', content: chunk }
}
yield { type: 'done' }
},
})| 要点 | 说明 |
|---|---|
sse: true | 必须显式声明,框架才走 text/event-stream |
async function* | yield 任意 JSON 可序列化数据 |
| Schema / 中间件 | 与普通路由一样,先校验再进 generator |
| WebSocket | 核心暂未内置;双向通道需自管或反代 |
需要事件名 / id 时用 sse() helper。完整用法见 SSE。
其它配套
| 能力 | 文档 |
|---|---|
| 写操作触发 Webhook | Webhook |
| 请求日志 / CORS | Request Logger、CORS |
| 静态文件 | Static |
| OpenAPI | OpenAPI |
| 前端类型化客户端 | API Client |
| Cookie | Cookie |
11. 测试
用 server.fetch,不必起端口:
import { describe, it, expect } from 'vitest'
import { Server, defineRoute, defineRoutes } from 'vafast'
const server = new Server(
defineRoutes([
defineRoute({
method: 'GET',
path: '/health',
handler: () => ({ ok: true }),
}),
]),
)
describe('health', () => {
it('returns ok', async () => {
const res = await server.fetch(new Request('http://localhost/health'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ ok: true })
})
})服务函数单独单测即可。更多见 单元测试。
小结
| 阶段 | 关注点 |
|---|---|
| 入门 | Schema、叶子路由、请求类型、简单中间件(快速入门) |
| 教程 | Schema、err、拆文件、路由组、中间件 |
| 本页 | 嵌套路由、中间件分层、serve 配置、声明式参数、withContext、SSE、测试 |