本文面向:想学习 Fastify 框架的学生或初学者,以 ChatCrystal 的后端为实战案例。 预计阅读时间:10 分钟 前置知识:Node.js 基础、HTTP 概念
为什么学 Fastify
ChatCrystal 的后端用的是 Fastify,不是 Express。这不是偶然------Fastify 在很多方面比 Express 更适合现代 Node.js 开发:
| 对比 | Express | Fastify |
|---|---|---|
| 性能 | 基准线 | 快 2-3 倍 |
| 类型支持 | 需要 @types/express | 原生 TypeScript |
| Schema 验证 | 需要中间件 | 内建 JSON Schema |
| 插件系统 | 中间件链 | 封装式插件(encapsulation) |
| 日志 | 需要 morgan/winston | 内建 Pino |
| 学习曲线 | 简单但松散 | 稍陡但规范 |
Express 入门容易,但项目大了容易乱。Fastify 强制你用结构化的方式组织代码。
最小 Fastify 项目
typescript
// src/index.ts
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.get('/api/hello', async () => {
return { message: 'Hello Fastify' };
});
app.listen({ port: 3000 }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
});
运行:
bash
npx tsx src/index.ts
# 访问 http://localhost:3000/api/hello
和 Express 最大的区别:Fastify({ logger: true }) 自带日志,不需要额外装 morgan。
插件注册模式
Fastify 的核心设计是插件。路由、中间件、装饰器都通过插件注册:
typescript
import Fastify from 'fastify';
import cors from '@fastify/cors';
const app = Fastify({ logger: true });
// 注册插件
await app.register(cors, { origin: true });
// 注册路由插件
await app.register(statusRoutes);
await app.register(noteRoutes);
ChatCrystal 就是这样组织的------每个路由文件导出一个插件函数:
typescript
// routes/status.ts
import type { FastifyInstance } from 'fastify';
export async function statusRoutes(app: FastifyInstance) {
app.get('/api/status', async () => {
return { success: true, data: { server: true } };
});
}
插件函数接收 FastifyInstance,在里面注册路由。Fastify 会自动处理插件的加载顺序和依赖。
插件封装(Encapsulation)
Fastify 插件有封装机制:插件内部注册的装饰器、钩子、路由,默认对外不可见。
typescript
async function adminRoutes(app: FastifyInstance) {
// 这个装饰器只在 adminRoutes 内部可用
app.decorate('requireAdmin', async (req, reply) => {
if (!req.headers['x-admin-key']) {
reply.status(403).send({ error: 'Forbidden' });
}
});
app.get('/api/admin/stats', { preHandler: [app.requireAdmin] }, async () => {
return { users: 100 };
});
}
这和 Express 的中间件链完全不同。Express 里一个全局中间件会影响所有路由,容易出 bug。Fastify 的封装让插件之间互不干扰。
路由定义
Fastify 的路由支持 Schema 验证,这是它比 Express 强的一大优势。Schema 可以自动验证请求参数、自动序列化响应、还能生成高性能序列化代码。
不过 ChatCrystal 目前没有使用 Schema 验证,而是用 TypeScript 类型断言 + 手动校验的方式。下面看实际的对话查询路由:
typescript
// routes/conversations.ts --- 实际代码
app.get('/api/conversations', async (req) => {
const {
source, project, status, search,
offset = '0', limit = '50',
} = req.query as Record<string, string>;
const db = getDatabase();
const conditions: string[] = ["c.source != 'chatcrystal-memory'"];
const params: (string | number)[] = [];
if (source) {
conditions.push('c.source = ?');
params.push(source);
}
if (project) {
conditions.push('c.project_name LIKE ?');
params.push(`%${project}%`);
}
// ... 更多过滤条件
const countResult = db.exec(
`SELECT COUNT(*) FROM conversations c WHERE ${conditions.join(' AND ')}`, params,
);
const total = Number(countResult[0]?.values[0]?.[0] ?? 0);
const dataResult = db.exec(
`SELECT c.* FROM conversations c WHERE ${conditions.join(' AND ')}
ORDER BY c.last_message_at DESC LIMIT ? OFFSET ?`,
[...params, Number(limit), Number(offset)],
);
return {
success: true,
data: { items: resultToObjects(dataResult), total },
};
});
这个路由展示了 ChatCrystal 的典型模式:
- 用
req.query as Record<string, string>获取查询参数 - 动态拼接 SQL 条件(参数化查询防注入)
- 返回统一的
{ success, data }结构
更多路由示例
再看 status 路由,它展示了数据库聚合查询:
typescript
export async function statusRoutes(app: FastifyInstance) {
app.get('/api/status', async () => {
const db = getDatabase();
const convCount =
db.exec("SELECT COUNT(*) as c FROM conversations")[0]?.values[0]?.[0] ?? 0;
const noteCount =
db.exec("SELECT COUNT(*) as c FROM notes")[0]?.values[0]?.[0] ?? 0;
const tagCount =
db.exec("SELECT COUNT(*) as c FROM tags")[0]?.values[0]?.[0] ?? 0;
const realConvCount =
db.exec("SELECT COUNT(*) as c FROM conversations WHERE id NOT LIKE 'demo-%'")[0]?.values[0]?.[0] ?? 0;
const recentNotes = resultToObjects(
db.exec(
`SELECT n.id, n.title, n.conversation_id, c.project_name, n.created_at
FROM notes n JOIN conversations c ON c.id = n.conversation_id
ORDER BY n.created_at DESC LIMIT 5`
),
);
return {
success: true,
data: {
server: true,
database: true,
isSeeded: Number(convCount) > 0 && Number(realConvCount) === 0,
stats: { totalConversations: convCount, totalNotes: noteCount, totalTags: tagCount },
recentNotes,
},
};
});
}
注意几个要点:
- 路由是
async函数,直接 return 对象,Fastify 自动序列化为 JSON - 数据库查询直接在路由里做(小项目可以,大项目应该抽到 service 层)
resultToObjects是一个工具函数,把 sql.js 的行列格式转成对象数组
项目结构
ChatCrystal 的后端结构:
arduino
server/src/
├── index.ts ← 入口,创建 Fastify 实例,注册插件
├── config.ts ← 配置加载与持久化
├── db/
│ ├── schema.ts ← SQLite 表结构(SQL 字符串)
│ ├── index.ts ← 数据库初始化、自动保存
│ ├── utils.ts ← resultToObjects 等工具函数
│ ├── cleanup.ts ← 孤立标签清理
│ └── transaction.ts ← 事务支持
├── routes/
│ ├── status.ts ← GET /api/status、GET /api/config(只读)
│ ├── conversations.ts ← 对话查询与删除(无 Create/Update,对话通过导入创建)
│ ├── notes.ts ← 笔记查询、删除、摘要触发、语义搜索
│ ├── config.ts ← 配置更新、连接测试
│ ├── import.ts ← 手动导入触发
│ ├── relations.ts ← 笔记关联管理
│ └── memory.ts ← 记忆写入与召回
├── services/
│ ├── llm.ts ← LLM 调用
│ ├── providers.ts ← LLM/Embedding provider 管理
│ ├── embedding.ts ← Embedding 生成与向量搜索
│ ├── summarize.ts ← 摘要生成
│ ├── import.ts ← 数据导入
│ ├── relations.ts ← 笔记关联逻辑
│ ├── seed.ts ← 演示数据种子
│ ├── experience/ ← 经验评估与门控
│ └── memory/ ← 记忆写回、召回、质量评估
├── parser/
│ ├── adapter.ts ← SourceAdapter 接口定义
│ └── adapters/ ← 5 个数据源适配器(claude-code、codex、cursor、copilot、trae)
└── watcher/
└── index.ts ← 文件监听,自动触发导入
关键设计:
- routes/ --- 每个文件是一个 Fastify 插件,只负责 HTTP 层
- services/ --- 业务逻辑,不依赖 Fastify
- db/ --- 数据库层,原始 SQL
- parser/ --- 插件式架构,适配器实现统一接口
生产环境静态服务
Fastify 不只能做 API,还能直接服务前端静态文件:
typescript
import fastifyStatic from '@fastify/static';
import { resolve } from 'node:path';
// 注册静态文件服务
await app.register(fastifyStatic, {
root: resolve(import.meta.dirname, '../../client/dist'),
prefix: '/',
wildcard: false,
});
// SPA fallback:非 API 请求都返回 index.html
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/')) {
reply.status(404).send({ success: false, error: 'Not Found' });
} else {
reply.sendFile('index.html');
}
});
这实现了:
/api/*走 Fastify 路由- 其他请求返回对应的静态文件
- 找不到的静态文件返回
index.html(SPA 路由)
ChatCrystal 用这种方式让一个 Fastify 实例同时提供 API 和前端,不需要 Nginx。
CORS 处理
开发时前端(Vite,端口 13721)和后端(Fastify,端口 3721)不在同一个端口,需要 CORS:
typescript
import cors from '@fastify/cors';
await app.register(cors, { origin: true });
origin: true 表示允许所有来源。生产环境不需要 CORS,因为前后端在同一个端口。
生命周期管理
Fastify 的 createServer 模式让生命周期管理很干净:
typescript
export async function createServer(options?: { port?: number; host?: string }) {
const app = Fastify({ logger: true });
// 注册插件
await app.register(cors, { origin: true });
// 初始化数据库
await initDatabase();
startAutoSave();
seedDemoData();
// 注册路由插件
await app.register(statusRoutes);
await app.register(importRoutes);
await app.register(conversationRoutes);
await app.register(noteRoutes);
await app.register(configRoutes);
await app.register(relationRoutes);
await app.register(memoryRoutes);
// 生产环境:服务前端静态文件 + SPA fallback
const clientDist = resolve(import.meta.dirname, '../../client/dist');
if (existsSync(clientDist)) {
await app.register(fastifyStatic, { root: clientDist, prefix: '/' });
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/')) {
reply.status(404).send({ success: false, error: 'Not Found' });
} else {
reply.sendFile('index.html');
}
});
}
// 启动文件监听
const watcher = startWatcher();
// 启动服务器
const port = options?.port ?? appConfig.port;
const host = options?.host ?? '0.0.0.0';
await app.listen({ port, host });
// 优雅关闭
async function shutdown() {
await watcher.close(); // 停止文件监听
closeDatabase(); // 保存数据库
await app.close(); // 关闭 HTTP 服务
}
return { app, port, shutdown };
}
关闭时按顺序清理:先停 watcher,再存 DB,最后关 HTTP。这个顺序很重要------如果先关 HTTP,正在处理的请求可能丢失数据。
Electron 桌面应用和独立服务器共用同一个 createServer 函数,只是启动入口不同。
学习路径建议
如果你是 Fastify 新手,建议按这个顺序学:
- 最小项目 --- 一个 GET 路由,跑起来
- 路由定义 --- GET/POST/DELETE,参数获取与响应格式
- 插件系统 --- 把路由拆成独立插件
- 数据库集成 --- 用 sql.js 或 better-sqlite3
- 静态服务 --- 前后端一体化部署
- 生命周期 --- 优雅启动和关闭
ChatCrystal 的代码覆盖了上面大部分场景(Schema 验证除外),是一个很好的学习参考。