12k
All articles

如何使用 Fastify 构建 REST API

使用 Fastify v5 和 Prisma 构建 REST API,涵盖 JSON Schema 验证、插件、装饰器、钩子与 TypeBox 类型化,实现 posts 的 CRUD。

OpenReplay Team
OpenReplay Team
如何使用 Fastify 构建 REST API

Fastify 是一个高性能、低开销的 Node.js Web 框架,其核心特性是基于 JSON Schema 的验证与序列化:你为每个请求和响应声明数据结构,Fastify 在启动时将这些 schema 编译为高效函数。据框架维护者所知,Fastify 是目前最快的 Web 框架之一,根据代码复杂度的不同,每秒可处理 76,000 次以上的请求。本教程将基于 Fastify v5Prisma 数据层,为 posts 资源构建一个完整的 CRUD REST API,并重点介绍将 Fastify 与 Express 区分开来的四个核心原语——封装插件、装饰器、基于 schema 的验证/序列化,以及生命周期钩子。

核心要点

  • Fastify v5 要求 Node.js v20 或更高版本;v4 已于 2025 年 6 月 30 日终止支持,v3 及更早版本已停止维护,因此新 API 应从 v5.x 版本线开始。
  • Fastify 的 response schema 具有双重作用——通过编译专用序列化器加快序列化速度,同时过滤掉所有未在 schema 中声明的属性,确保内部字段不会泄露到响应中。
  • 从 v5 起,schema 简写形式已被移除:每个 querystringparamsbodyresponse schema 必须是包含 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 对象上的全局中间件封装插件 + 装饰器
请求生命周期线性中间件链类型化钩子(onRequestpreHandleronError 等)

性能优势是机制层面的,而非营销噱头。尽管并非强制要求,Fastify 建议使用 JSON Schema 来验证路由和序列化输出;在内部,Fastify 会将 schema 编译为高性能函数。response schema 在启动时会被转换为专用序列化器,因此生成 JSON 时可绕过通用的 JSON.stringify 路径。官方性能数据应理解为其本来含义:这些基准测试使用合成的”Hello World”基准进行,旨在评估框架本身的开销——在引用具体数字之前,请先对你自己的应用进行基准测试。

项目配置与最小化 v5 服务器

从 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 即可验证传入的 bodyparamsquerystringheaders,并根据 response schema 序列化输出的响应体。这是框架的标志性特性,详见验证与序列化文档。

v5 中有一条适用于所有 schema 的规则:简写形式已被移除。从 v5 起,querystringparamsbodyresponse 的每个 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 导入 TypeTypeBoxTypeProvider,并调用 Fastify().withTypeProvider<TypeBoxTypeProvider>();请求体的字段类型随即会被自动推断。请使用这个作用域包——旧版指南中展示的 @sinclair/typebox 导入路径是 v5 之前的旧包名。有一个 v5 的细节需要注意:迁移指南指出类型提供器已被拆分为两个独立类型:ValidatorSchemaSerializerSchema,因此在升级 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 的中间件链。钩子参考文档按顺序列出了请求流程:onRequestpreParsingpreValidationpreHandler → 处理器 → preSerializationonSendonResponse,当发生异常时触发 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.elapsedTimev5 中已移除 reply.getResponseTime() 方法,应改用 reply.elapsedTime

从这里开始,生产环境检查清单就是安装插件,每个插件都是一个作用域为 @fastify/* 的包,注册方式与你的路由插件相同:

对于更深入的认证流程和部署方案,请参阅专项指南——这些属于独立的构建主题。如果你想了解这些端点在真实流量下的表现,可以将该 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 包名,并在每个需要它的插件中重新声明提供器。

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.