用 ChatCrystal 学 Fastify:从零搭建 REST API

本文面向:想学习 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');
  }
});

这实现了:

  1. /api/* 走 Fastify 路由
  2. 其他请求返回对应的静态文件
  3. 找不到的静态文件返回 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 新手,建议按这个顺序学:

  1. 最小项目 --- 一个 GET 路由,跑起来
  2. 路由定义 --- GET/POST/DELETE,参数获取与响应格式
  3. 插件系统 --- 把路由拆成独立插件
  4. 数据库集成 --- 用 sql.js 或 better-sqlite3
  5. 静态服务 --- 前后端一体化部署
  6. 生命周期 --- 优雅启动和关闭

ChatCrystal 的代码覆盖了上面大部分场景(Schema 验证除外),是一个很好的学习参考。


项目地址:github.com/ZengLiangYi...

相关推荐
taocarts_bidfans11 小时前
Taoify开放接口全解析:RESTful架构下,跨境开发者多系统对接实操指南
大数据·架构·restful·跨境电商·独立站
清水白石0081 天前
在 RESTful、RPC 与事件驱动之间做选择:高频内部调用与审计回放场景下的架构取舍
rpc·架构·restful
weixin199701080163 天前
[特殊字符] RESTful API 接口规范详解:构建高效、可扩展的 Web 服务(附 Python 源码)
前端·python·restful
zyl837213 天前
Express快速上手
https·node.js·express
vim怎么退出4 天前
排查 WebSocket "Invalid frame header" 的一次复盘
websocket·node.js·express
码以致用6 天前
FastAPI 从入门到实践:构建规范的 RESTful API 服务
后端·restful·fastapi
若阳安好6 天前
【备忘录】正则表达式
后端·正则表达式·restful
学习使我快乐016 天前
Express 学习
学习·node.js·express
AIFQuant8 天前
贵金属 API 避坑:黄金/白银行情接口常见陷阱(数据漂移、断点、延迟)
开发语言·python·websocket·金融·restful·贵金属