1. 引言
Fastify 是一款高性能的 Node.js Web 框架,以极快的请求处理速度和极低的资源开销著称。它依托 Schema 校验、插件化架构与原生异步支持,非常适合构建 RESTful API、微服务以及需要高吞吐量的后端应用。本教程以电商平台后台服务系统为实战目标,从环境准备开始,循序渐进地带你搭建一个完整的 Fastify 实战项目,内容涵盖工程构建、数据库集成、鉴权、缓存、定时任务等核心能力,并配套使用 Vite + React + Less 搭建前端管理后台,最终交付一个可运行、可复制的电商项目。
目录
- [1. 引言](#1. 引言)
- [2. 环境准备](#2. 环境准备)
- [3. 创建项目并安装 Fastify](#3. 创建项目并安装 Fastify)
- [4. 编写第一个 Fastify 服务器](#4. 编写第一个 Fastify 服务器)
- [5. 路由与请求参数](#5. 路由与请求参数)
- [6. 使用 Schema 校验请求](#6. 使用 Schema 校验请求)
- [7. 插件化架构](#7. 插件化架构)
- [8. 日志与错误处理](#8. 日志与错误处理)
- [9. 环境变量与配置管理:区分开发、测试与生产环境](#9. 环境变量与配置管理:区分开发、测试与生产环境)
- [10. 使用 MongoDB 实现数据表的增删改查](#10. 使用 MongoDB 实现数据表的增删改查)
- [11. 登录注册鉴权与开放接口配置](#11. 登录注册鉴权与开放接口配置)
- [12. 模块化项目架构:Model、Service 与 Controller](#12. 模块化项目架构:Model、Service 与 Controller)
- [13. 集成 Redis 缓存](#13. 集成 Redis 缓存)
- [14. 定时任务配置](#14. 定时任务配置)
- [15. WebSocket 支持](#15. WebSocket 支持)
- [17. 总结](#17. 总结)
2. 环境准备
在开始之前,请确保你的开发环境满足以下要求:
- Node.js:版本 18 或更高(推荐使用 LTS 版本)。
- npm 或 yarn:用于安装依赖包。
- MongoDB:本地安装或使用 Docker 启动,用于数据存储。
- Redis:本地安装或使用 Docker 启动,用于缓存与会话。
- 代码编辑器:推荐使用 VS Code,并安装 ESLint 插件。
检查 Node.js 版本:
bash
node -v
npm -v
使用 Docker 快速启动 MongoDB 和 Redis(推荐):
bash
docker run -d --name mongo -p 27017:27017 mongo:6
docker run -d --name redis -p 6379:6379 redis:7
3. 创建项目并安装 Fastify
首先创建一个新的项目目录并初始化 package.json:
bash
mkdir fastify-demo
cd fastify-demo
npm init -y
然后安装 Fastify:
bash
npm install fastify
4. 编写第一个 Fastify 服务器
在项目根目录创建 server.js 文件,写入以下代码:
javascript
const Fastify = require('fastify');
const app = Fastify({
logger: true
});
app.get('/', async (request, reply) => {
return { hello: 'world' };
});
const start = async () => {
try {
await app.listen({ port: 3000 });
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
启动服务器:
bash
node server.js
打开浏览器访问 http://localhost:3000,你将看到返回的 JSON 数据 {"hello":"world"}。
5. 路由与请求参数
Fastify 支持丰富的路由定义方式,包括路径参数、查询参数和请求体。下面是一个带路径参数的示例:
javascript
app.get('/user/:id', async (request, reply) => {
const { id } = request.params;
return { userId: id };
});
查询参数通过 request.query 获取:
javascript
app.get('/search', async (request, reply) => {
const { keyword } = request.query;
return { keyword };
});
6. 使用 Schema 校验请求
Fastify 的一大特色是内置基于 JSON Schema 的请求校验和序列化。通过定义 schema,可以自动校验请求参数并加速响应序列化:
javascript
app.post('/user', {
schema: {
body: {
type: 'object',
required: ['name', 'age'],
properties: {
name: { type: 'string' },
age: { type: 'integer', minimum: 0 }
}
}
}
}, async (request, reply) => {
const { name, age } = request.body;
return { status: 'ok', name, age };
});
当请求体不符合 schema 时,Fastify 会自动返回 400 错误,无需手动编写校验逻辑。
7. 插件化架构
Fastify 采用插件化设计,所有功能都可以封装为插件。插件可以注册路由、装饰器、钩子等。下面创建一个简单的插件:
javascript
// plugins/hello-plugin.js
module.exports = async function (app, options) {
app.get('/plugin-hello', async (request, reply) => {
return { message: 'Hello from plugin!' };
});
};
在主文件中注册插件:
javascript
const helloPlugin = require('./plugins/hello-plugin');
app.register(helloPlugin);
8. 日志与错误处理
Fastify 内置了基于 pino 的高性能日志系统。在创建实例时开启 logger: true 即可。自定义错误处理可以通过 setErrorHandler 实现:
javascript
app.setErrorHandler((error, request, reply) => {
app.log.error(error);
reply.status(error.statusCode || 500).send({
error: '服务器内部错误'
});
});
9. 环境变量与配置管理:区分开发、测试与生产环境
在实际项目中,开发、测试和生产环境往往需要不同的配置。下面介绍如何通过环境变量和配置文件,实现开发、测试与生产环境的自动切换。
首先安装 dotenv 和 dotenv-cli:
bash
npm install dotenv dotenv-cli
创建三个环境配置文件,分别存放开发、测试和生产环境的变量:
bash
# .env.development
PORT=3000
DB_HOST=localhost
DB_NAME=fastify_dev
LOG_LEVEL=debug
bash
# .env.test
PORT=4000
DB_HOST=localhost
DB_NAME=fastify_test
LOG_LEVEL=info
bash
# .env.production
PORT=8080
DB_HOST=db.production.example.com
DB_NAME=fastify_prod
LOG_LEVEL=info
在 package.json 中配置启动脚本,通过 NODE_ENV 区分环境:
json
{
"scripts": {
"dev": "dotenv -e .env.development node server.js",
"test": "dotenv -e .env.test node server.js",
"start": "dotenv -e .env.production node server.js"
}
}
在代码中根据 NODE_ENV 加载对应配置:
javascript
const envFile = {
development: '.env.development',
test: '.env.test',
production: '.env.production'
}[process.env.NODE_ENV] || '.env.development';
require('dotenv').config({ path: envFile });
const port = process.env.PORT || 3000;
const logLevel = process.env.LOG_LEVEL || 'info';
const app = Fastify({
logger: {
level: logLevel
}
});
这样,运行 npm run dev 时使用开发环境配置,运行 npm test 时切换到测试环境配置,运行 npm start 时自动切换到生产环境配置,无需手动修改代码。
10. 使用 MongoDB 实现数据表的增删改查
在开始编写 CRUD 接口之前,我们先设计好数据表(集合)的结构。MongoDB 是文档型数据库,集合中的文档结构通过应用层约定,这里以电商平台的用户表为例,设计如下字段:
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| _id | ObjectId | 是 | 主键,由 MongoDB 自动生成 |
| name | string | 是 | 用户姓名,长度至少 1 个字符 |
| string | 是 | 邮箱地址,需符合 email 格式 | |
| age | integer | 是 | 年龄,最小值为 0 |
| createdAt | string | 否 | 创建时间,ISO 格式字符串,由服务端自动写入 |
在 MongoDB 中,集合(Collection)对应关系型数据库中的表,文档(Document)对应表中的一行记录。上面的用户表结构通过 JSON Schema 定义后,可以在接口层自动校验请求参数,确保写入的数据符合预期。
Fastify 可以很方便地与 MongoDB 集成,实现数据表的定义以及增删改查操作。下面以用户表为例,演示完整的实现过程。
首先安装 MongoDB 驱动:
bash
npm install @fastify/mongodb
在 server.js 中注册 MongoDB 插件并连接数据库:
javascript
const Fastify = require('fastify');
const fastifyMongo = require('@fastify/mongodb');
const app = Fastify({ logger: true });
app.register(fastifyMongo, {
url: 'mongodb://localhost:27017/fastify_demo'
});
定义用户表(集合)的 Schema 结构。MongoDB 是文档型数据库,集合中的文档结构通过应用层约定,这里使用 JSON Schema 定义字段约束:
javascript
const userSchema = {
type: 'object',
required: ['name', 'email', 'age'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0 },
createdAt: { type: 'string' }
}
};
创建用户(增):
javascript
app.post('/users', { schema: { body: userSchema } }, async (request, reply) => {
const users = app.mongo.db.collection('users');
const user = {
...request.body,
createdAt: new Date().toISOString()
};
const result = await users.insertOne(user);
reply.code(201).send({ id: result.insertedId, ...user });
});
查询用户列表(查):
javascript
app.get('/users', async (request, reply) => {
const users = app.mongo.db.collection('users');
const list = await users.find({}).toArray();
return { total: list.length, data: list };
});
查询单个用户(查):
javascript
app.get('/users/:id', async (request, reply) => {
const users = app.mongo.db.collection('users');
const { ObjectId } = app.mongo;
const user = await users.findOne({ _id: new ObjectId(request.params.id) });
if (!user) {
reply.code(404).send({ error: '用户不存在' });
return;
}
return user;
});
更新用户(改):
javascript
app.put('/users/:id', { schema: { body: userSchema } }, async (request, reply) => {
const users = app.mongo.db.collection('users');
const { ObjectId } = app.mongo;
const result = await users.updateOne(
{ _id: new ObjectId(request.params.id) },
{ $set: request.body }
);
if (result.matchedCount === 0) {
reply.code(404).send({ error: '用户不存在' });
return;
}
return { status: 'ok' };
});
删除用户(删):
javascript
app.delete('/users/:id', async (request, reply) => {
const users = app.mongo.db.collection('users');
const { ObjectId } = app.mongo;
const result = await users.deleteOne({ _id: new ObjectId(request.params.id) });
if (result.deletedCount === 0) {
reply.code(404).send({ error: '用户不存在' });
return;
}
return { status: 'ok' };
});
这样,我们就完成了基于 MongoDB 的用户表定义以及增删改查接口。启动服务器后,可以通过 RESTful 接口对用户数据进行完整的 CRUD 操作。
11. 登录注册鉴权与开放接口配置
在实际项目中,大部分接口需要登录后才能访问,而部分接口(如注册、登录、健康检查)应当对外开放。下面介绍如何基于 JWT 实现登录注册鉴权,并通过配置管理开放接口白名单。
首先安装 JWT 相关依赖:
bash
npm install @fastify/jwt @fastify/cookie
在 server.js 中注册 JWT 插件,并配置密钥和 Cookie 支持:
javascript
const Fastify = require('fastify');
const fastifyJwt = require('@fastify/jwt');
const fastifyCookie = require('@fastify/cookie');
const app = Fastify({ logger: true });
app.register(fastifyCookie);
app.register(fastifyJwt, {
secret: process.env.JWT_SECRET || 'your-secret-key',
cookie: {
cookieName: 'token',
signed: false
}
});
接下来实现注册接口。注册时对用户密码进行哈希处理,并将用户信息写入 MongoDB:
javascript
const crypto = require('crypto');
// 密码哈希工具
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto
.pbkdf2Sync(password, salt, 10000, 64, 'sha512')
.toString('hex');
return { salt, hash };
}
function verifyPassword(password, salt, hash) {
const testHash = crypto
.pbkdf2Sync(password, salt, 10000, 64, 'sha512')
.toString('hex');
return testHash === hash;
}
// 注册接口
app.post('/auth/register', {
schema: {
body: {
type: 'object',
required: ['name', 'email', 'password'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 6 }
}
}
}
}, async (request, reply) => {
const users = app.mongo.db.collection('users');
const { name, email, password } = request.body;
// 检查邮箱是否已注册
const existing = await users.findOne({ email });
if (existing) {
reply.code(409).send({ error: '邮箱已被注册' });
return;
}
const { salt, hash } = hashPassword(password);
const user = {
name,
email,
salt,
passwordHash: hash,
createdAt: new Date().toISOString()
};
const result = await users.insertOne(user);
reply.code(201).send({
id: result.insertedId,
name: user.name,
email: user.email
});
});
登录接口:校验用户密码,验证通过后签发 JWT Token:
javascript
// 登录接口
app.post('/auth/login', {
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 6 }
}
}
}
}, async (request, reply) => {
const users = app.mongo.db.collection('users');
const { email, password } = request.body;
const user = await users.findOne({ email });
if (!user) {
reply.code(401).send({ error: '邮箱或密码错误' });
return;
}
const isValid = verifyPassword(password, user.salt, user.passwordHash);
if (!isValid) {
reply.code(401).send({ error: '邮箱或密码错误' });
return;
}
// 签发 JWT Token
const token = app.jwt.sign(
{ id: user._id.toString(), email: user.email, name: user.name },
{ expiresIn: '7d' }
);
// 写入 Cookie
reply.setCookie('token', token, {
httpOnly: true,
path: '/',
maxAge: 7 * 24 * 60 * 60, // 7 天
sameSite: 'strict'
});
return { token, user: { id: user._id, name: user.name, email: user.email } };
});
通过 preHandler 钩子实现受保护路由的鉴权逻辑。定义一个鉴权装饰器,在需要登录的接口上复用:
javascript
// 鉴权装饰器:校验 JWT 并注入用户信息
app.decorate('authenticate', async (request, reply) => {
try {
// 优先从 Authorization 头读取,其次从 Cookie 读取
const token = request.headers.authorization?.replace('Bearer ', '') ||
request.cookies?.token;
if (!token) {
reply.code(401).send({ error: '未登录' });
return;
}
const decoded = app.jwt.verify(token);
request.user = decoded;
} catch (err) {
reply.code(401).send({ error: 'Token 无效或已过期' });
}
});
// 受保护路由示例:获取当前登录用户信息
app.get('/auth/me', {
preHandler: [app.authenticate]
}, async (request, reply) => {
const users = app.mongo.db.collection('users');
const user = await users.findOne(
{ _id: new app.mongo.ObjectId(request.user.id) },
{ projection: { salt: 0, passwordHash: 0 } }
);
if (!user) {
reply.code(404).send({ error: '用户不存在' });
return;
}
return user;
});
// 受保护路由示例:更新用户资料
app.put('/auth/profile', {
preHandler: [app.authenticate],
schema: {
body: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 }
}
}
}
}, async (request, reply) => {
const users = app.mongo.db.collection('users');
const result = await users.updateOne(
{ _id: new app.mongo.ObjectId(request.user.id) },
{ $set: { name: request.body.name } }
);
if (result.matchedCount === 0) {
reply.code(404).send({ error: '用户不存在' });
return;
}
return { status: 'ok' };
});
开放接口白名单配置。通过一个数组维护无需鉴权的公开路径,在全局 preHandler 中统一判断,实现灵活的接口开放策略:
javascript
// 开放接口白名单
const publicPaths = [
'/auth/register',
'/auth/login',
'/health',
'/'
];
// 全局 preHandler:白名单内的接口直接放行,其余接口校验 JWT
app.addHook('preHandler', async (request, reply) => {
const { url } = request;
const isPublic = publicPaths.some((path) => {
if (path === '/') return url === '/';
return url.startsWith(path);
});
if (isPublic) return;
try {
const token = request.headers.authorization?.replace('Bearer ', '') ||
request.cookies?.token;
if (!token) {
reply.code(401).send({ error: '未登录' });
return;
}
const decoded = app.jwt.verify(token);
request.user = decoded;
} catch (err) {
reply.code(401).send({ error: 'Token 无效或已过期' });
}
});
// 健康检查接口(开放)
app.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
这样,我们就完成了基于 JWT 的登录注册鉴权实现。注册接口负责创建用户并安全存储密码,登录接口校验身份后签发 Token,受保护路由通过 preHandler 钩子统一校验 Token,而开放接口白名单则让注册、登录、健康检查等公开接口无需鉴权即可访问。
放接口白名单则让注册、登录、健康检查等公开接口无需鉴权即可访问。
12. 模块化项目架构:Model、Service 与 Controller
随着项目规模的增长,如果把所有路由、业务逻辑和数据访问都堆在 server.js 中,代码会迅速变得臃肿且难以维护。模块化架构的核心价值在于:让每一层只关注自己的职责,降低模块之间的耦合,提升代码的可读性、可测试性和可复用性。下面我们按照 Model、Service、Controller 三层架构来重构电商后台项目。
首先看一下推荐的项目目录结构:
bash
fastify-demo/
├── server.js # 入口文件,注册所有插件与路由
├── config/
│ └── index.js # 全局配置(端口、数据库、JWT 密钥等)
├── plugins/
│ ├── mongodb.js # MongoDB 插件封装
│ ├── jwt.js # JWT 鉴权插件封装
│ └── redis.js # Redis 缓存插件封装
├── models/
│ ├── user.model.js # 用户表结构定义
│ └── product.model.js # 商品表结构定义
├── services/
│ ├── user.service.js # 用户业务逻辑
│ └── product.service.js # 商品业务逻辑
├── controllers/
│ ├── user.controller.js # 用户接口处理
│ └── product.controller.js
├── routes/
│ ├── user.routes.js # 用户路由定义
│ └── product.routes.js # 商品路由定义
└── utils/
└── response.js # 统一响应封装
各目录职责说明:
- config:集中管理环境变量和全局配置,供所有模块引用。
- plugins:封装第三方插件(MongoDB、JWT、Redis),统一注册。
- models:定义数据表(集合)的 Schema 结构,约定字段约束。
- services:实现具体业务逻辑,如密码校验、数据组装、缓存读写。
- controllers:接收请求参数,调用 service 处理,返回响应。
- routes:定义路由与 Controller 的映射关系,配置鉴权钩子。
下面以用户模块为例,演示完整的模块化实现。首先是 Model 层,定义用户表结构:
javascript
// models/user.model.js
const userSchema = {
type: 'object',
required: ['name', 'email', 'password'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 6 },
role: { type: 'string', enum: ['user', 'admin'], default: 'user' },
createdAt: { type: 'string' }
}
};
module.exports = { userSchema };
接着是 Service 层,封装用户相关的业务逻辑,包括密码哈希、用户创建、查询等:
javascript
// services/user.service.js
const crypto = require('crypto');
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto
.pbkdf2Sync(password, salt, 10000, 64, 'sha512')
.toString('hex');
return { salt, hash };
}
function verifyPassword(password, salt, hash) {
const testHash = crypto
.pbkdf2Sync(password, salt, 10000, 64, 'sha512')
.toString('hex');
return testHash === hash;
}
async function createUser(db, data) {
const users = db.collection('users');
const existing = await users.findOne({ email: data.email });
if (existing) {
const err = new Error('邮箱已被注册');
err.statusCode = 409;
throw err;
}
const { salt, hash } = hashPassword(data.password);
const user = {
name: data.name,
email: data.email,
salt,
passwordHash: hash,
role: data.role || 'user',
createdAt: new Date().toISOString()
};
const result = await users.insertOne(user);
return { id: result.insertedId, name: user.name, email: user.email, role: user.role };
}
async function findUserByEmail(db, email) {
return db.collection('users').findOne({ email });
}
async function findUserById(db, id) {
const { ObjectId } = require('mongodb');
return db.collection('users').findOne(
{ _id: new ObjectId(id) },
{ projection: { salt: 0, passwordHash: 0 } }
);
}
module.exports = { createUser, findUserByEmail, findUserById, verifyPassword };
然后是 Controller 层,负责接收请求、调用 Service 并返回响应:
javascript
// controllers/user.controller.js
const userService = require('../services/user.service');
async function register(request, reply) {
try {
const user = await userService.createUser(request.server.mongo.db, request.body);
reply.code(201).send(user);
} catch (err) {
reply.code(err.statusCode || 500).send({ error: err.message });
}
}
async function login(request, reply) {
const { email, password } = request.body;
const user = await userService.findUserByEmail(request.server.mongo.db, email);
if (!user) {
reply.code(401).send({ error: '邮箱或密码错误' });
return;
}
const isValid = userService.verifyPassword(password, user.salt, user.passwordHash);
if (!isValid) {
reply.code(401).send({ error: '邮箱或密码错误' });
return;
}
const token = request.server.jwt.sign(
{ id: user._id.toString(), email: user.email, name: user.name, role: user.role },
{ expiresIn: '7d' }
);
reply.setCookie('token', token, {
httpOnly: true,
path: '/',
maxAge: 7 * 24 * 60 * 60,
sameSite: 'strict'
});
return { token, user: { id: user._id, name: user.name, email: user.email, role: user.role } };
}
async function me(request, reply) {
const user = await userService.findUserById(request.server.mongo.db, request.user.id);
if (!user) {
reply.code(404).send({ error: '用户不存在' });
return;
}
return user;
}
module.exports = { register, login, me };
最后是 Routes 层,定义路由并挂载到 Fastify 实例上:
javascript
// routes/user.routes.js
const userController = require('../controllers/user.controller');
async function userRoutes(app, options) {
app.post('/auth/register', {
schema: {
body: {
type: 'object',
required: ['name', 'email', 'password'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 6 }
}
}
}
}, userController.register);
app.post('/auth/login', {
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 6 }
}
}
}
}, userController.login);
app.get('/auth/me', {
preHandler: [app.authenticate]
}, userController.me);
}
module.exports = userRoutes;
在 server.js 中注册路由插件:
javascript
const userRoutes = require('./routes/user.routes');
app.register(userRoutes);
通过 Model、Service、Controller 三层拆分,路由只负责参数校验和分发,业务逻辑集中在 Service 层,数据表结构由 Model 层统一约定。这样每个模块职责清晰、易于测试和复用,后续新增商品、订单等模块时只需按同样的模式扩展即可。
13. 集成 Redis 缓存
在高并发场景下,频繁查询数据库会带来较大的 IO 压力。Redis 作为高性能的内存数据库,非常适合用来做接口缓存,显著降低数据库负载、提升响应速度。下面我们以用户列表接口为例,演示如何在 Fastify 中集成 Redis 缓存。
首先安装 @fastify/redis 依赖:
bash
npm install @fastify/redis
在 server.js 中注册 Redis 插件并连接 Redis 服务:
javascript
const fastifyRedis = require('@fastify/redis');
app.register(fastifyRedis, {
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379
});
封装一个简单的缓存工具函数,统一处理缓存的读取、写入与删除:
javascript
// utils/cache.js
async function getCache(app, key) {
const data = await app.redis.get(key);
return data ? JSON.parse(data) : null;
}
async function setCache(app, key, value, ttl = 60) {
await app.redis.set(key, JSON.stringify(value), 'EX', ttl);
}
async function delCache(app, key) {
await app.redis.del(key);
}
module.exports = { getCache, setCache, delCache };
改造用户列表接口,优先读取缓存,未命中时再查询数据库并写入缓存(设置 60 秒过期):
javascript
const { getCache, setCache, delCache } = require('./utils/cache');
// 查询用户列表(带缓存)
app.get('/users', async (request, reply) => {
const cacheKey = 'users:list';
// 1. 先读缓存
const cached = await getCache(app, cacheKey);
if (cached) {
return { source: 'cache', ...cached };
}
// 2. 缓存未命中,查询数据库
const users = app.mongo.db.collection('users');
const list = await users.find({}).toArray();
const result = { total: list.length, data: list };
// 3. 写入缓存,60 秒过期
await setCache(app, cacheKey, result, 60);
return { source: 'db', ...result };
});
缓存失效策略:在用户新增或更新时,主动删除对应的缓存,避免读到脏数据:
javascript
// 创建用户后删除缓存
app.post('/users', { schema: { body: userSchema } }, async (request, reply) => {
const users = app.mongo.db.collection('users');
const user = {
...request.body,
createdAt: new Date().toISOString()
};
const result = await users.insertOne(user);
await delCache(app, 'users:list'); // 清除列表缓存
reply.code(201).send({ id: result.insertedId, ...user });
});
// 更新用户后删除缓存
app.put('/users/:id', { schema: { body: userSchema } }, async (request, reply) => {
const users = app.mongo.db.collection('users');
const { ObjectId } = app.mongo;
const result = await users.updateOne(
{ _id: new ObjectId(request.params.id) },
{ $set: request.body }
);
if (result.matchedCount === 0) {
reply.code(404).send({ error: '用户不存在' });
return;
}
await delCache(app, 'users:list'); // 清除列表缓存
return { status: 'ok' };
});
验证缓存是否生效。第一次请求会命中数据库,第二次请求会命中缓存:
bash
# 第一次请求:返回 source 为 db
curl http://localhost:3000/users
第二次请求:返回 source 为 cache
curl http://localhost:3000/users
通过以上方式,我们为查询接口加上了 Redis 缓存,既提升了接口响应速度,又减轻了数据库压力。在实际项目中,还可以根据业务需要为不同接口设置不同的过期时间,并在数据变更时精准清除对应缓存。
除了用户列表接口,商品查询接口同样适合使用 Redis 缓存。下面以商品详情接口为例,演示如何为商品查询添加缓存读写。首先定义商品表结构:
javascript
// models/product.model.js
const productSchema = {
type: 'object',
required: ['name', 'price', 'stock'],
properties: {
name: { type: 'string', minLength: 1 },
price: { type: 'number', minimum: 0 },
stock: { type: 'integer', minimum: 0 },
description: { type: 'string' },
createdAt: { type: 'string' }
}
};
module.exports = { productSchema };
在 server.js 中注册商品路由,并为商品详情接口添加缓存读写逻辑。查询时优先读取缓存,未命中时再查询数据库并写入缓存(设置 120 秒过期):
javascript
const { getCache, setCache, delCache } = require('./utils/cache');
const { productSchema } = require('./models/product.model');
// 创建商品(增)
app.post('/products', { schema: { body: productSchema } }, async (request, reply) => {
const products = app.mongo.db.collection('products');
const product = {
...request.body,
createdAt: new Date().toISOString()
};
const result = await products.insertOne(product);
// 清除商品列表缓存
await delCache(app, 'products:list');
reply.code(201).send({ id: result.insertedId, ...product });
});
// 查询商品列表(带缓存)
app.get('/products', async (request, reply) => {
const cacheKey = 'products:list';
// 1. 先读缓存
const cached = await getCache(app, cacheKey);
if (cached) {
return { source: 'cache', ...cached };
}
// 2. 缓存未命中,查询数据库
const products = app.mongo.db.collection('products');
const list = await products.find({}).toArray();
const result = { total: list.length, data: list };
// 3. 写入缓存,120 秒过期
await setCache(app, cacheKey, result, 120);
return { source: 'db', ...result };
});
// 查询商品详情(带缓存)
app.get('/products/:id', async (request, reply) => {
const { ObjectId } = app.mongo;
const cacheKey = `product:${request.params.id}`;
// 1. 先读缓存
const cached = await getCache(app, cacheKey);
if (cached) {
return { source: 'cache', ...cached };
}
// 2. 缓存未命中,查询数据库
const products = app.mongo.db.collection('products');
const product = await products.findOne({ _id: new ObjectId(request.params.id) });
if (!product) {
reply.code(404).send({ error: '商品不存在' });
return;
}
// 3. 写入缓存,120 秒过期
await setCache(app, cacheKey, product, 120);
return { source: 'db', ...product };
});
// 更新商品(改)
app.put('/products/:id', { schema: { body: productSchema } }, async (request, reply) => {
const products = app.mongo.db.collection('products');
const { ObjectId } = app.mongo;
const result = await products.updateOne(
{ _id: new ObjectId(request.params.id) },
{ $set: request.body }
);
if (result.matchedCount === 0) {
reply.code(404).send({ error: '商品不存在' });
return;
}
// 清除商品详情缓存和列表缓存
await delCache(app, `product:${request.params.id}`);
await delCache(app, 'products:list');
return { status: 'ok' };
});
// 删除商品(删)
app.delete('/products/:id', async (request, reply) => {
const products = app.mongo.db.collection('products');
const { ObjectId } = app.mongo;
const result = await products.deleteOne({ _id: new ObjectId(request.params.id) });
if (result.deletedCount === 0) {
reply.code(404).send({ error: '商品不存在' });
return;
}
// 清除商品详情缓存和列表缓存
await delCache(app, `product:${request.params.id}`);
await delCache(app, 'products:list');
return { status: 'ok' };
});
验证商品缓存是否生效。第一次请求会命中数据库,第二次请求会命中缓存:
bash
# 第一次请求:返回 source 为 db
curl http://localhost:3000/products/64b8f1a2c3d4e5f6a7b8c9d0
第二次请求:返回 source 为 cache
curl http://localhost:3000/products/64b8f1a2c3d4e5f6a7b8c9d0
通过以上方式,我们为商品查询接口加上了 Redis 缓存。商品详情使用单条缓存键(product:<id>),商品列表使用列表缓存键(products:list),并在商品新增、更新或删除时精准清除对应缓存,避免读到脏数据。这样既提升了商品查询的响应速度,又减轻了数据库压力。
14. 定时任务配置
在电商后台系统中,很多操作需要定时执行,例如每天凌晨清理过期的会话、定时同步商品库存、定期生成报表等。下面我们基于 node-cron 实现定时任务,演示如何在 Fastify 中注册定时任务插件,并给出一个具体的定时任务示例。
首先安装 node-cron 依赖:
bash
npm install node-cron
在 server.js 中注册定时任务插件。这里我们封装一个 plugins/cron.js 插件,统一管理所有定时任务:
javascript
// plugins/cron.js
const cron = require('node-cron');
module.exports = async function (app, options) {
// 每天凌晨 2 点执行:清理过期会话
cron.schedule('0 2 * * *', async () => {
app.log.info('开始清理过期会话...');
try {
const sessions = app.mongo.db.collection('sessions');
const now = new Date();
const result = await sessions.deleteMany({ expiresAt: { $lt: now } });
app.log.info(`清理过期会话完成,共删除 ${result.deletedCount} 条`);
} catch (err) {
app.log.error('清理过期会话失败:', err);
}
});
// 每天凌晨 3 点执行:同步商品库存
cron.schedule('0 3 * * *', async () => {
app.log.info('开始同步商品库存...');
try {
const products = app.mongo.db.collection('products');
// 这里可以对接第三方库存系统,此处以模拟数据为例
const result = await products.updateMany(
{ stock: { $lt: 10 } },
{ $set: { stockWarning: true } }
);
app.log.info(`库存同步完成,标记 ${result.modifiedCount} 个低库存商品`);
} catch (err) {
app.log.error('同步商品库存失败:', err);
}
});
app.log.info('定时任务插件注册完成');
};
在 server.js 中注册定时任务插件:
javascript
const cronPlugin = require('./plugins/cron');
app.register(cronPlugin);
除了在插件中直接定义任务,也可以把定时任务封装成独立的 Service,便于复用和测试。下面以「清理过期会话」为例,演示更规范的写法。首先创建 services/task.service.js:
javascript
// services/task.service.js
async function cleanExpiredSessions(db) {
const sessions = db.collection('sessions');
const now = new Date();
const result = await sessions.deleteMany({ expiresAt: { $lt: now } });
return result.deletedCount;
}
async function syncProductStock(db) {
const products = db.collection('products');
const result = await products.updateMany(
{ stock: { $lt: 10 } },
{ $set: { stockWarning: true } }
);
return result.modifiedCount;
}
module.exports = { cleanExpiredSessions, syncProductStock };
然后在 plugins/cron.js 中调用 Service 层的方法,并加入任务日志输出和错误处理:
javascript
// plugins/cron.js
const cron = require('node-cron');
const taskService = require('../services/task.service');
module.exports = async function (app, options) {
// 每天凌晨 2 点执行:清理过期会话
cron.schedule('0 2 * * *', async () => {
app.log.info('定时任务启动:清理过期会话');
try {
const deletedCount = await taskService.cleanExpiredSessions(app.mongo.db);
app.log.info(`清理过期会话完成,共删除 ${deletedCount} 条`);
} catch (err) {
app.log.error({ err }, '清理过期会话任务执行失败');
}
});
// 每天凌晨 3 点执行:同步商品库存
cron.schedule('0 3 * * *', async () => {
app.log.info('定时任务启动:同步商品库存');
try {
const modifiedCount = await taskService.syncProductStock(app.mongo.db);
app.log.info(`库存同步完成,标记 ${modifiedCount} 个低库存商品`);
} catch (err) {
app.log.error({ err }, '同步商品库存任务执行失败');
}
});
app.log.info('定时任务插件注册完成');
};
启动服务器后,定时任务会在指定时间自动执行。为了便于本地验证,可以临时把 cron 表达式改为每分钟执行一次,观察日志输出:
bash
# 临时改为每分钟执行一次,便于验证
cron.schedule('* * * * *', async () => {
app.log.info('测试定时任务执行中...');
});
启动服务器后,你将在控制台看到类似下面的日志输出:
bash
[info] 定时任务插件注册完成
[info] 定时任务启动:清理过期会话
[info] 清理过期会话完成,共删除 3 条
[info] 定时任务启动:同步商品库存
[info] 库存同步完成,标记 2 个低库存商品
通过以上方式,我们基于 node-cron 为 Fastify 项目配置了定时任务。定时任务统一封装在插件中,业务逻辑下沉到 Service 层,既保持了代码整洁,又便于复用和测试。在实际项目中,还可以结合 Redis 实现分布式锁,避免多实例部署时同一任务被重复执行,以及通过配置中心动态调整任务执行时间。
15. WebSocket 支持
WebSocket 提供了全双工的实时通信能力,非常适合聊天室、消息推送、在线协作等场景。下面我们基于 @fastify/websocket 实现一个简单的聊天室,演示如何在 Fastify 中接入 WebSocket 并实现消息广播。
首先安装 @fastify/websocket 依赖:
bash
npm install @fastify/websocket
在 server.js 中注册 WebSocket 插件:
javascript
const fastifyWebsocket = require('@fastify/websocket');
app.register(fastifyWebsocket);
接下来实现聊天室路由。当客户端连接到 /chat 时,服务端会维护一个连接集合,收到消息后广播给所有在线客户端:
javascript
// 维护所有在线连接
const clients = new Set();
app.register(async function (app) {
app.get('/chat', { websocket: true }, (connection, request) => {
// 新客户端加入
clients.add(connection.socket);
app.log.info('新客户端加入聊天室');
// 广播消息给所有在线客户端
connection.socket.on('message', (message) => {
const data = message.toString();
const payload = {
user: request.query.username || '匿名用户',
message: data,
time: new Date().toISOString()
};
for (const client of clients) {
if (client.readyState === 1) {
client.send(JSON.stringify(payload));
}
}
});
// 客户端断开时移除连接
connection.socket.on('close', () => {
clients.delete(connection.socket);
app.log.info('客户端离开聊天室');
});
});
});
启动服务器后,可以通过 WebSocket 客户端工具进行测试。这里推荐使用 wscat 命令行工具:
bash
# 安装 wscat
npm install -g wscat
打开两个终端,分别连接聊天室
wscat -c "ws://localhost:3000/chat?username=张三"
wscat -c "ws://localhost:3000/chat?username=李四"
在第一个终端发送消息,第二个终端会实时收到广播:
bash
# 终端一(张三)发送
> 大家好,我是张三
终端二(李四)实时收到
< {"user":"张三","message":"大家好,我是张三","time":"2026-10-09T03:00:00.000Z"}
通过以上方式,我们基于 @fastify/websocket 实现了一个简单的聊天室。服务端维护在线连接集合,收到消息后广播给所有客户端,实现了实时双向通信。在实际项目中,还可以结合 JWT 鉴权校验连接身份、结合 Redis 实现多实例间的消息同步,以及增加消息持久化等能力。
Redis 实现多实例间的消息同步,以及增加消息持久化等能力。
16. 常见问题与排查指南
在实战开发中,环境配置、依赖版本、网络连接等问题常常会打断开发节奏。下面针对 MongoDB 连接失败、JWT 过期、Redis 连接超时、端口冲突等高频问题,给出具体的错误现象、排查命令和解决方案,并附上对应的代码修复示例。
16.1 MongoDB 连接失败
错误现象 :启动服务时控制台报错 MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017,或接口请求时返回 500 错误,日志中出现 MongoTimeoutError。
排查命令:
bash
# 检查 MongoDB 进程是否在运行
ps aux | grep mongod
检查端口是否被监听
lsof -i :27017
尝试本地连接
mongosh --host 127.0.0.1 --port 27017
解决方案:
- 如果 MongoDB 未启动,使用 Docker 启动:
docker start mongo,或重新运行docker run -d --name mongo -p 27017:27017 mongo:6。 - 如果端口被占用,检查是否有多个 MongoDB 实例,或修改连接端口。
- 如果使用远程数据库,确认防火墙和安全组已放行 27017 端口。
代码修复示例:在注册 MongoDB 插件时增加连接超时和错误日志,便于快速定位问题:
javascript
app.register(fastifyMongo, {
url: 'mongodb://localhost:27017/fastify_demo',
connectTimeoutMS: 5000,
serverSelectionTimeoutMS: 5000
}).after((err) => {
if (err) {
app.log.error({ err }, 'MongoDB 连接失败');
process.exit(1);
}
app.log.info('MongoDB 连接成功');
});
16.2 JWT 过期
错误现象 :访问受保护接口时返回 401,响应体为 { "error": "Token 无效或已过期" },或前端跳转到登录页。
排查命令:
bash
# 解码 JWT,查看 exp 字段
echo "<你的token>" | cut -d '.' -f 2 | base64 -d
使用 curl 携带 Token 请求受保护接口
curl -H "Authorization: Bearer <你的token>" http://localhost:3000/auth/me
解决方案:
- 检查签发 Token 时的
expiresIn配置,确认过期时间是否符合业务需求。 - 确认服务器时间与客户端时间是否一致,时间偏差会导致 Token 提前过期。
- 前端应在 Token 过期前主动刷新,或使用刷新 Token 机制。
代码修复示例:在鉴权装饰器中区分「未登录」和「Token 过期」,并给出更明确的提示:
javascript
app.decorate('authenticate', async (request, reply) => {
const token = request.headers.authorization?.replace('Bearer ', '') ||
request.cookies?.token;
if (!token) {
reply.code(401).send({ error: '未登录' });
return;
}
try {
const decoded = app.jwt.verify(token);
request.user = decoded;
} catch (err) {
if (err.code === 'FAST_JWT_EXPIRED') {
reply.code(401).send({ error: 'Token 已过期,请重新登录' });
} else {
reply.code(401).send({ error: 'Token 无效' });
}
}
});
16.3 Redis 连接超时
错误现象 :接口首次请求时响应缓慢,随后报错 Redis connection to 127.0.0.1:6379 failed,或日志中出现 ECONNREFUSED。
排查命令:
bash
# 检查 Redis 进程
ps aux | grep redis-server
检查端口
lsof -i :6379
使用 redis-cli 测试连接
redis-cli -h 127.0.0.1 -p 6379 ping
解决方案:
- 如果 Redis 未启动,使用 Docker 启动:
docker start redis,或重新运行docker run -d --name redis -p 6379:6379 redis:7。 - 检查
REDIS_HOST和REDIS_PORT环境变量是否正确。 - 如果 Redis 设置了密码,需要在连接配置中增加
password字段。
代码修复示例:为 Redis 连接增加重试和超时配置,避免服务启动时因 Redis 未就绪而崩溃:
javascript
app.register(fastifyRedis, {
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
connectTimeout: 5000,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 2000);
}
});
16.4 端口冲突
错误现象 :启动服务时报错 Error: listen EADDRINUSE: address already in use :::3000,或访问页面时连接被拒绝。
排查命令:
bash
# 查看 3000 端口被哪个进程占用
lsof -i :3000
查看占用进程的 PID 和名称
ps aux | grep node
结束占用进程(谨慎操作)
kill -9 <PID>
解决方案:
- 结束占用端口的进程,或修改
PORT环境变量使用其他端口。 - 检查是否有多个服务实例同时启动,避免重复运行
node server.js。 - 在开发环境中,可以使用
nodemon或fastify-cli的自动重启功能,避免手动重复启动。
代码修复示例:在监听端口时增加冲突检测,并给出友好提示:
javascript
const start = async () => {
try {
await app.listen({ port: process.env.PORT || 3000 });
} catch (err) {
if (err.code === 'EADDRINUSE') {
app.log.error(`端口 ${process.env.PORT || 3000} 已被占用,请更换端口或结束占用进程`);
} else {
app.log.error(err);
}
process.exit(1);
}
};
以上是实战中最常见的四类问题。遇到报错时,建议先查看日志定位错误码,再结合排查命令逐步缩小范围。将这些问题和修复方案沉淀为团队文档,可以显著提升后续开发和联调效率。
17. 总结
至此,我们从零到一完成了一个基于 Fastify 的电商平台后台服务系统。回顾全文,我们系统性地掌握了 Fastify 的多项核心能力:
- 插件化架构 :通过
app.register将 MongoDB、JWT、Redis、WebSocket 等能力封装为插件,保持入口文件简洁、功能边界清晰。 - Schema 校验:利用 JSON Schema 自动校验请求参数并加速序列化,减少手写校验逻辑,提升接口健壮性。
- MongoDB CRUD :基于
@fastify/mongodb完成用户、商品等集合的定义与增删改查,并配合 JSON Schema 约束字段。 - JWT 鉴权 :通过注册、登录、Token 签发与
preHandler钩子实现受保护路由,并支持开放接口白名单配置。 - 模块化架构:按照 Model、Service、Controller、Routes 四层拆分业务,让代码更易维护、测试与复用。
- Redis 缓存:为高频查询接口接入缓存读写与失效策略,显著降低数据库压力、提升响应速度。
- 定时任务 :基于
node-cron实现清理过期会话、同步库存等周期性任务,并下沉到 Service 层便于复用。 - WebSocket 支持 :通过
@fastify/websocket实现聊天室等实时双向通信场景。
在完成本教程后,你可以沿着以下方向继续深入:
- 部署上线:学习使用 Docker 容器化打包 Fastify 应用,配合 Nginx 反向代理与 PM2 进程守护,实现生产环境的高可用部署。
- 自动化测试 :使用
fastify.inject()编写接口单元测试,结合tap或jest覆盖路由、Service 与鉴权逻辑,保障代码质量。 - 性能优化:进一步研究 Fastify 的序列化优化、连接池调优、Redis 分布式锁,以及通过压测工具(如 autocannon)评估接口吞吐量。
- 前端联调:将 Vite + React + Less 管理后台与后端接口对接,完善登录、用户管理、商品管理等完整业务闭环。
Fastify 以高性能和插件化著称,掌握这些核心能力后,你已具备构建生产级 Node.js 服务的基础。继续在实践中打磨,逐步向微服务、消息队列、监控告警等更复杂的架构演进。