如何使用 Fastify 构建 REST API
使用 Fastify v5 和 Prisma 构建 REST API,涵盖 JSON Schema 验证、插件、装饰器、钩子与 TypeBox 类型化,实现 posts 的 CRUD。
Fastify 是一个高性能、低开销的 Node.js Web 框架,其核心特性是基于 JSON Schema 的验证与序列化:你为每个请求和响应声明数据结构,Fastify 在启动时将这些 schema 编译为高效函数。据框架维护者所知,Fastify 是目前最快的 Web 框架之一,根据代码复杂度的不同,每秒可处理 76,000 次以上的请求。本教程将基于 Fastify v5 和 Prisma 数据层,为 posts 资源构建一个完整的 CRUD REST API,并重点介绍将 Fastify 与 Express 区分开来的四个核心原语——封装插件、装饰器、基于 schema 的验证/序列化,以及生命周期钩子。
核心要点
- Fastify v5 要求 Node.js v20 或更高版本;v4 已于 2025 年 6 月 30 日终止支持,v3 及更早版本已停止维护,因此新 API 应从 v5.x 版本线开始。
- Fastify 的
responseschema 具有双重作用——通过编译专用序列化器加快序列化速度,同时过滤掉所有未在 schema 中声明的属性,确保内部字段不会泄露到响应中。 - 从 v5 起,schema 简写形式已被移除:每个
querystring、params、body和responseschema 必须是包含type属性的完整 JSON Schema。 - 通过将插件包裹在
fastify-plugin中来主动打破封装,再使用fastify.decorate('prisma', client)将客户端附加到实例上,即可在所有路由间共享数据库客户端。 - 使用 TypeBox 类型提供器时,每个 schema 只需声明一次,Fastify 即可从中推断请求和响应类型,从而消除验证规则与 TypeScript 类型之间的不一致问题。
为什么选择 Fastify:Express 所没有的四个核心原语
你当然可以用 Express 编写相同的 CRUD 端点。但 Express 难以复现的是 Fastify 的设计模型,它建立在四个核心原语之上:封装插件、装饰器、基于 schema 的验证与序列化,以及生命周期钩子。框架维护者指出,Fastify 通过其钩子、插件和装饰器实现了完全可扩展性。这些并非附加在路由器上的便利功能,而是框架的核心架构。
| 关注点 | Express(典型方式) | Fastify(惯用方式) |
|---|---|---|
| 输入验证 | 手动处理或中间件(express-validator) | 在路由上声明 JSON Schema |
| 响应序列化 | JSON.stringify | 从 response schema 编译的 fast-json-stringify |
| 资源共享 | 共享 req 对象上的全局中间件 | 封装插件 + 装饰器 |
| 请求生命周期 | 线性中间件链 | 类型化钩子(onRequest、preHandler、onError 等) |
性能优势是机制层面的,而非营销噱头。尽管并非强制要求,Fastify 建议使用 JSON Schema 来验证路由和序列化输出;在内部,Fastify 会将 schema 编译为高性能函数。response schema 在启动时会被转换为专用序列化器,因此生成 JSON 时可绕过通用的 JSON.stringify 路径。官方性能数据应理解为其本来含义:这些基准测试使用合成的”Hello World”基准进行,旨在评估框架本身的开销——在引用具体数字之前,请先对你自己的应用进行基准测试。
项目配置与最小化 v5 服务器
Discover how at OpenReplay.com.
从 ESM 开始,因为 Fastify 和 Prisma v7 都以 ESM 为优先。Fastify v5 仅支持 Node.js v20+;如果你使用的是旧版 Node.js,需要升级到更新版本才能使用 Fastify v5。
mkdir fastify-posts-api && cd fastify-posts-api
npm init -y
npm pkg set type="module"
npm i fastify@5
当前稳定版本为 5.9.0,发布于 2026 年 6 月 28 日。创建 server.js:
import Fastify from 'fastify'
const app = Fastify({ logger: true })
app.get('/health', async () => ({ status: 'ok' }))
try {
await app.listen({ port: 3000 })
} catch (err) {
app.log.error(err)
process.exit(1)
}
注意这里使用了对象形式的 listen 调用。v5 中已移除 .listen() 方法的可变参数签名,因此不能再以可变数量的参数调用 .listen()——始终传入类似 { port: 3000 } 的选项对象。使用 node --watch server.js 运行服务器,并访问 http://localhost:3000/health。
Fastify 的路由方式:插件与封装
在 Fastify 中,路由存在于插件内部,每个插件在其自身的封装上下文中运行。在插件内部注册的装饰器、钩子或路由,对该插件及其子插件可见,但对其兄弟插件不可见——这与 Express 中间件的机制相反,后者所有内容共享同一个全局请求对象。插件参考文档详细说明了这种作用域模型。
插件本质上是一个接收封装实例的异步函数:
// routes/posts.js
export default async function postsRoutes(app, opts) {
app.get('/', async (request, reply) => {
return [] // 列表处理器稍后实现
})
}
在 server.js 中为其注册前缀:
import postsRoutes from './routes/posts.js'
app.register(postsRoutes, { prefix: '/api/posts' })
保持插件函数始终使用 async 形式。v5 迁移指南明确指出,所有 v4 中已废弃的特性已被移除,升级后将不再可用,在同一个插件中混用回调和 Promise 风格——这在 v4 中是被允许的——在 v5 中已不再支持。请统一使用 async/await 风格。
Schema 验证与序列化
为任意路由附加 schema 对象,Fastify 即可验证传入的 body、params、querystring 和 headers,并根据 response schema 序列化输出的响应体。这是框架的标志性特性,详见验证与序列化文档。
v5 中有一条适用于所有 schema 的规则:简写形式已被移除。从 v5 起,querystring、params、body 和 response 的每个 schema 都必须是包含 type 属性的完整 JSON Schema,否则路由将无法通过验证——v5 迁移指南记录了 jsonShortHand 选项的移除。
为 posts 资源定义 schema:
// schemas/posts.js
export const postResponse = {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
published: { type: 'boolean' },
createdAt: { type: 'string' }
}
}
export const createPostBody = {
type: 'object',
required: ['title', 'content'],
properties: {
title: { type: 'string', minLength: 1 },
content: { type: 'string', minLength: 1 },
published: { type: 'boolean', default: false }
}
}
export const idParams = {
type: 'object',
required: ['id'],
properties: { id: { type: 'string' } }
}
response schema 是大多数教程所忽略的部分,但它的价值体现在两个方面。其一,它会被编译为专用序列化器,比通用字符串化更快;其二,它充当白名单:处理器返回的任何未在 schema 中声明的属性,都会在到达客户端之前被丢弃。查询结果中意外带入的 passwordHash 或内部 userId 永远不会离开服务器,因为序列化器根本不知道如何输出它们。
使用 TypeBox 的 TypeScript 方案
如果你使用 TypeScript,最大的摩擦点在于保持验证 schema 与类型定义的同步。TypeBox 类型提供器将两者合并为一个声明。安装 typebox 作为必需的对等依赖,以及 @fastify/type-provider-typebox:
npm i typebox @fastify/type-provider-typebox
注册类型提供器,并使用 Type 一次性声明每个 schema:
import Fastify from 'fastify'
import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()
app.post('/api/posts', {
schema: {
body: Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String({ minLength: 1 })
})
}
}, async (request) => {
// request.body 的类型为 { title: string; content: string }
const { title, content } = request.body
return { title, content }
})
从 @fastify/type-provider-typebox 导入 Type 和 TypeBoxTypeProvider,并调用 Fastify().withTypeProvider<TypeBoxTypeProvider>();请求体的字段类型随即会被自动推断。请使用这个作用域包——旧版指南中展示的 @sinclair/typebox 导入路径是 v5 之前的旧包名。有一个 v5 的细节需要注意:迁移指南指出类型提供器已被拆分为两个独立类型:ValidatorSchema 和 SerializerSchema,因此在升级 Fastify 的同时也需要升级提供器包。另请注意,提供器类型不会全局传播;在封装使用场景中,可以将上下文重新映射以使用一个或多个提供器,这意味着需要在每个用到它的插件中重新声明提供器。
以装饰器插件形式接入数据库层
通过装饰器将数据库连接挂载到 Fastify 实例上,并通过主动打破封装的插件对外暴露。要在所有路由间共享同一个资源(数据库客户端),需将插件包裹在 fastify-plugin 中,使装饰器能够逃出自身作用域,然后通过 fastify.decorate('prisma', client) 将其附加到实例上。装饰器参考文档详细介绍了这一模式。
本教程使用 Prisma(v7,当前为 7.8.x),它不依赖 Rust 且仅支持 ESM——与上述 ESM 配置完美契合。安装 Prisma,定义 schema,并生成客户端。Prisma 7 还需要驱动适配器来创建客户端,因此需要在安装核心包的同时安装 SQLite 适配器:
npm i @prisma/client@7 @prisma/adapter-better-sqlite3
npm i -D prisma@7
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id String @id @default(uuid())
title String
content String
published Boolean @default(false)
createdAt DateTime @default(now())
}
Prisma 7 通过 prisma.config.ts 文件配置其 CLI,并将客户端生成到你在上方设置的 output 路径;运行 npx prisma migrate dev 来创建数据库和客户端。然后将其作为装饰器暴露出来。注意在 Prisma 7 中,裸调用 new PrismaClient() 已被移除——必须使用驱动适配器来构造客户端:
// plugins/prisma.js
import fp from 'fastify-plugin'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from '../generated/prisma/client.js'
export default fp(async (app) => {
const adapter = new PrismaBetterSqlite3({ url: 'file:./dev.db' })
const prisma = new PrismaClient({ adapter })
await prisma.$connect()
app.decorate('prisma', prisma)
app.addHook('onClose', async (instance) => {
await instance.prisma.$disconnect()
})
})
由于插件被包裹在 fp 中,app.prisma 现在可以从应用中的每个路由访问。
如果你偏好使用 Postgres 配合 TypeORM,装饰器模式完全相同:连接 DataSource,然后执行 app.decorate('db', dataSource)。只有 schema 层有所不同。
具有正确状态码的 CRUD 处理器
在 posts 插件中将五个操作实现为路由,每个路由都携带其对应的 schema。为每个 HTTP 动词返回正确的状态码:
- POST 请求返回
201以及创建的资源; - 读取操作返回
200; - DELETE 请求返回
204且不带响应体; - 查找不到资源时返回
404。
Fastify 会自动设置 Content-Type 并根据你的 response schema 进行序列化——详见 Reply API。
// routes/posts.js
import { postResponse, createPostBody, idParams } from '../schemas/posts.js'
export default async function postsRoutes(app) {
// 创建
app.post('/', {
schema: { body: createPostBody, response: { 201: postResponse } }
}, async (request, reply) => {
const post = await app.prisma.post.create({ data: request.body })
return reply.code(201).send(post)
})
// 读取全部
app.get('/', {
schema: { response: { 200: { type: 'array', items: postResponse } } }
}, async () => {
return app.prisma.post.findMany({ orderBy: { createdAt: 'desc' } })
})
// 读取单条
app.get('/:id', {
schema: { params: idParams, response: { 200: postResponse } }
}, async (request, reply) => {
const post = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!post) return reply.code(404).send({ message: 'Post not found' })
return post
})
// 更新
app.put('/:id', {
schema: { params: idParams, body: createPostBody, response: { 200: postResponse } }
}, async (request, reply) => {
const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!exists) return reply.code(404).send({ message: 'Post not found' })
return app.prisma.post.update({ where: { id: request.params.id }, data: request.body })
})
// 删除
app.delete('/:id', {
schema: { params: idParams }
}, async (request, reply) => {
const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!exists) return reply.code(404).send({ message: 'Post not found' })
await app.prisma.post.delete({ where: { id: request.params.id } })
return reply.code(204).send()
})
}
关于 DELETE 操作有一个值得注意的 v5 变化:在 v4 中,Fastify 允许带有 Content-Type: application/json 请求头但请求体为空的 DELETE 请求;v5 中不再允许这种情况。当没有请求体时,不要发送 Content-Type 请求头。
错误处理、钩子与生产环境加固
使用 setErrorHandler 集中处理错误,并通过钩子拦截请求生命周期——这两个扩展点取代了 Express 的中间件链。钩子参考文档按顺序列出了请求流程:onRequest → preParsing → preValidation → preHandler → 处理器 → preSerialization → onSend → onResponse,当发生异常时触发 onError。
class NotFoundError extends Error {
constructor (message) { super(message); this.statusCode = 404 }
}
app.setErrorHandler((error, request, reply) => {
request.log.error(error)
const status = error.statusCode ?? 500
reply.code(status).send({
error: error.name,
message: status === 500 ? 'Internal Server Error' : error.message
})
})
// preHandler 钩子示例:在处理器运行前拦截路由
app.addHook('preHandler', async (request) => {
request.log.info({ url: request.url }, 'incoming request')
})
验证失败会在处理器运行之前短路,自动返回 400——这正是 schema 能够减少处理器代码量的原因之一。如需在钩子内计时,请使用 reply.elapsedTime;v5 中已移除 reply.getResponseTime() 方法,应改用 reply.elapsedTime。
从这里开始,生产环境检查清单就是安装插件,每个插件都是一个作用域为 @fastify/* 的包,注册方式与你的路由插件相同:
@fastify/jwt——用于身份验证;@fastify/swagger——直接从路由 schema 生成 OpenAPI 文档;@fastify/rate-limit——用于请求限流;@fastify/cors——用于跨域访问控制。
对于更深入的认证流程和部署方案,请参阅专项指南——这些属于独立的构建主题。如果你想了解这些端点在真实流量下的表现,可以将该 API 与消费它的前端的 Node.js 错误监控结合使用。
至此,你已拥有一个可运行的 Fastify v5 CRUD API:schema 负责验证输入、序列化输出并自我文档化,数据库通过装饰器共享,而非全局导入。下一个具体步骤:添加 @fastify/swagger,将其指向你已编写的 schema,然后观察 OpenAPI 规范自动生成——这正是 Fastify 中 schema 作为唯一真实来源而非事后补充的有力证明。
常见问题
fastify.register 和 fastify.decorate 有什么区别?
register 方法在其自身的封装上下文中挂载插件,因此在其内部添加的路由、钩子和装饰器的作用域仅限于该插件及其子插件。decorate 方法则直接将可复用的属性或方法附加到 Fastify 实例、request 或 reply 对象上。通常使用 decorate 来暴露共享资源(如数据库客户端),并将该插件包裹在 fastify-plugin 中,使装饰器能够逃出封装范围,在整个应用中可用。
Fastify 比 Express 快多少?
Fastify 自身的基准测试报告显示每秒可处理 76,000 次以上的请求,但维护者特别说明,这是一个合成的 "Hello World" 测试,用于衡量框架开销,而非真实场景下的吞吐量。机制层面的优势是实实在在的:Fastify 在启动时将 JSON Schema 编译为专用的验证和序列化函数,因此生成响应时可绕过通用的 JSON.stringify 开销。在引用任何速度倍数之前,请务必对你自己的应用进行基准测试。
为什么我的 Fastify v5 路由 schema 验证失败,而在 v4 中却能正常工作?
从 v5 起,schema 简写形式和 jsonShortHand 选项已被移除,因此每个 querystring、params、body 和 response schema 都必须是明确包含 type 属性的完整 JSON Schema。仅声明 properties 而没有顶层 type 的 schema 将不再通过验证。请为每个 schema 对象添加 type: 'object'。v5 迁移指南将此记录为相对于 v4 简写行为的有意破坏性变更。
使用 Fastify 和 TypeScript 时,能否避免重复编写类型?
可以。安装 typebox 作为对等依赖,以及作用域包 @fastify/type-provider-typebox,然后使用 TypeBoxTypeProvider 泛型调用 Fastify().withTypeProvider。使用 Type 一次性声明每个 schema,Fastify 即可从中推断请求和响应类型,消除验证规则与 TypeScript 接口之间的不一致问题。请从 @fastify/type-provider-typebox 导入 Type,而非旧版指南中展示的 @sinclair/typebox 包名,并在每个需要它的插件中重新声明提供器。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k