Fastify 最佳实践 #3: 数据库访问
在任何现代 Web 应用中,数据库层都是名副其实的“引擎室”。它的性能、稳定性和可维护性直接决定了整个应用的上限。在 Node.js 与 TypeScript 大行其道的今天,我们拥有了众多优秀的数据库工具,如老牌的 TypeORM、新锐的 Prisma,以及追求极致类型安全的 Kysely。
选择哪个工具不仅影响开发效率,更深远地关系到应用在生产环境中的表现,尤其是在问题排查和性能优化方面。本文将对这三者进行深度剖析,并基于一个常被忽视却至关重要的标准——可观测性,阐述为何我最终推荐 Kysely 作为 Fastify 项目的首选。
三大主流工具横向对比
首先,我们通过一个表格来直观地了解 TypeORM, Prisma 和 Kysely 的核心特点与区别。
| 维度 | TypeORM | Prisma | Kysely |
|---|---|---|---|
| 数据模型定义 | 代码优先 (Code-First):使用 TypeScript 的类和装饰器 (@Entity, @Column) 定义数据模型,代码是事实的唯一来源。 |
模型优先 (Schema-First):在专属的 .prisma 文件中用其自有的 SDL (Schema Definition Language) 定义模型,该文件是事实的唯一来源。 |
无模型管理:不直接管理数据模型。它通过你提供的 TypeScript interface 来感知数据库结构,从而提供完美的类型提示。 |
| 类型安全 | 良好但有漏洞。基础 CRUD 操作类型安全,但在处理复杂关系、原生查询或 QueryBuilder 的某些链式调用时,类型推断可能丢失,容易返回 any。 |
极致。其客户端根据你的 .prisma 模型自动生成,确保你写的每一个查询,包括字段选择、关系嵌套,都拥有精确的、端到端的类型安全。 |
极致。类型安全直接源于查询结构本身。你 select 了什么字段,join 了什么表,返回结果的 TypeScript 类型就会被精确地推断出来,毫不含糊。 |
| 数据库迁移 | 内置。能根据实体 (Entity) 变化自动生成迁移文件 (migration:generate)。但在复杂项目中,自动生成的迁移常需手动审查和调整,有时会出错。 |
内置且极其强大。prisma migrate dev 命令提供了丝滑的开发体验,能可靠地根据 .prisma 文件变化生成和应用 SQL 迁移,是其公认的核心优势。 |
不内置,需官方工具。需要额外使用官方提供的 kysely-ctl 工具来管理迁移,功能完善,但需要额外配置。 |
| 学习曲线 | 中等。需要理解 ORM 的多种概念(实体、仓库、数据映射器、活动记录模式、N+1问题等),API 体系庞大。 | 低。API 非常直观且设计精良。Prisma Studio (一个 GUI 工具) 让数据浏览和操作变得异常简单,极大地降低了上手门槛。 |
低 (若你懂 SQL) / 高 (若你不懂 SQL)。其 API 与 SQL 语句几乎一一对应。如果你熟悉 SQL,会感到无比自然和强大。反之,则需要先学习 SQL。 |
| 查询控制与性能 | 中等。高度抽象有时会生成非最优的 SQL 查询。容易出现 N+1 查询问题,需要开发者有意识地使用 relations 或 QueryBuilder 进行优化。 |
高。API 设计巧妙地规避了许多 ORM 常见陷阱。例如,.include 和 .select 让你能精确控制查询范围。生成的 SQL 通常是可预测且高效的。 |
最高。因为它就是 SQL 的一层类型化外衣。你可以编写任何复杂的 JOIN、子查询、CTE、窗口函数等。性能的上限和下限完全取决于你的 SQL 水平。 |
一个关键考量:为何我最终放弃了 Prisma?
看到上面的对比,很多人会倾向于 Prisma。它学习曲线低,迁移工具强大,类型安全也做到极致,堪称“现代ORM”的典范。
然而,在生产环境中,一个看似微小的设计选择可能会带来巨大的维护成本。Prisma (截至 6.9.0 版本) 就存在这样一个问题:日志与异步上下文的割裂。
在构建可维护的后端服务时,可观测性 (Observability) 是一个核心概念。我们期望每一条进入系统的 HTTP 请求都能被赋予一个唯一的追踪ID (Trace ID)。在此请求的整个生命周期中,从进入控制器到调用服务,再到数据库查询,所有产生的日志都应包含这个ID。这使得我们能在海量的日志中,精准地串联起一次请求的所有足迹,是快速定位和解决线上问题的生命线。
在 Node.js 中,我们通常使用 AsyncLocalStorage 来在异步调用链中传递这类上下文信息。然而,Prisma 的日志系统却在这里遇到了麻烦。
// Prisma 的日志订阅方式
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
});
prisma.$on('query', (e) => {
// 这里的回调函数是基于 Node.js 的 Event Emitter 触发的
// 它运行在一个独立的异步上下文中
// 因此,无法访问到当前 HTTP 请求的 AsyncLocalStorage
const traceId = getTraceIdFromContext(); // 这将返回 undefined
console.log(`[TraceID: ${traceId}] Query: ${e.query}`); // TraceID 会是 undefined
console.log('Params: ' + e.params);
console.log('Duration: ' + e.duration + 'ms');
});
$on 方法基于 Node.js 的 EventEmitter,它的事件回调是独立于请求的异步流程的。这意味着,当 Prisma 执行完一条 SQL 并发出 query 事件时,我们已经丢失了原始请求的上下文。无法在 SQL 日志中注入追踪ID,对于构建严肃的、可观测的生产系统而言,这是一个难以接受的妥协。
为此,我情愿放弃 Prisma 带来的种种便利。
Kysely:为 TypeScript 和 SQL 匠人打造的终极工具
这自然引出了我们的主角:Kysely。
Kysely 不像 TypeORM 或 Prisma 那样是一个完整的 ORM,它是一个 类型安全的 SQL 查询构建器 (Query Builder)。这个定位完美地解决了上述所有问题:
- 无缝的日志追踪:它的日志输入并没有使用
EventEmitter,因此可以兼容AsyncLocalStorage。 - 极致的性能与控制:它只是你和 SQL 之间的一层薄薄的、类型安全的胶水。你写的代码几乎就是最终执行的 SQL,没有任何隐藏的性能开销和魔法,性能完全由你的 SQL 功力决定。
- 真正的类型安全:它的类型推断能力令人惊叹。返回值的类型是根据你的
select,join等操作动态计算出来的,而不是固定的实体模型。
因此,我坚信,对于追求代码质量、应用性能和长期可维护性的团队来说,Kysely 是更优的选择。
实战指南:在 Fastify 中集成 Kysely
接下来,让我们一步步将 Kysely 集成到项目中。
1. 安装与类型定义
首先,安装 Kysely 及对应的数据库驱动(以 PostgreSQL 为例):
npm i kysely pg
然后,创建数据库的类型定义文件。这是 Kysely 类型魔法的核心。
// src/database/types/user.type.ts
import { CamelCasePlugin, Kysely, ParseJSONResultsPlugin, ColumnType, PostgresDialect, Pool } from 'kysely';
// 为 `user_tbl` 表定义接口
export interface UserTbl {
id: ColumnType<string, string, never>;
username: string;
address: string;
email: string;
birthday: ColumnType<Date, Date | string, Date | string>;
// 在数据库中是 timestamptz 类型,但在代码中我们希望它是 Date 类型
createdAt: ColumnType<Date, Date | string | undefined, never>;
updatedAt: ColumnType<Date | null, Date | string | null, Date>;
}
解释一下 ColumnType<Select, Insert, Update> 的作用:
- Select: 从数据库读取时,该列的类型 (e.g., createdAt 是 Date)
- Insert: 插入数据时,可以接受的类型 (e.g., birthday 可以是 Date 对象或 ISO 字符串)
- Update: 更新数据时,可以接受的类型 (e.g., id 和 createdAt 在更新时通常不由用户提供,所以是 never)
// src/database/types/index.ts
import { UserTbl } from './user.type';
// 整个数据库的结构
export interface Database {
userTbl: UserTbl;
}
// src/database/index.ts
const dialect = new PostgresDialect({
pool: new Pool({
database: process.env.DB_NAME,
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
port: Number(process.env.DB_PORT),
max: 10,
}),
});
// 创建 Kysely 实例
export const db = new Kysely<Database>({
dialect,
plugins: [
new CamelCasePlugin(), // 将蛇形命名(snake_case)的列名自动转为驼峰命名(camelCase)
new ParseJSONResultsPlugin(), // 自动解析 JSON 字符串
],
});
一个查询示例:
const result = await db.selectFrom('userTbl').selectAll().where('id', '=', id).executeTakeFirstOrThrow();
2. 使用 kysely-ctl 进行数据库迁移
为了规范化地管理数据库结构变更,我们需要一个迁移工具。
npm i -D kysely-ctl dotenv-cli
在 package.json 的 scripts 中添加命令:
"scripts": {
"db:init": "kysely-ctl init",
"db:migrate:make": "kysely-ctl migrate make",
"db:migrate:latest": "dotenv -e .env -- kysely-ctl migrate latest",
"db:migrate:down": "dotenv -e .env -- kysely-ctl migrate down",
"db:seed:make": "kysely-ctl seed make",
"db:seed:run": "dotenv -e .env -- kysely-ctl seed run"
}
注意:这里我使用了 dotenv-cli 来加载环境变量,并简化了命令名,使其更符合通用习惯。
A. 初始化
执行 npm run db:init,它会生成一个 .config/kysely.config.ts 文件。我们对其进行配置:
// kysely.config.ts
import { PostgresDialect } from 'kysely';
import { defineConfig } from 'kysely-ctl';
import { Pool } from 'pg';
export default defineConfig({
dialect: new PostgresDialect({
pool: new Pool({
database: process.env.DB_NAME,
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
port: Number(process.env.DB_PORT),
max: 10,
}),
}),
migrations: {
migrationFolder: '.config/migrations',
},
seeds: {
seedFolder: '.config/seeds',
},
});
B. 创建迁移文件
执行 npm run migrate:make -x=ts init_tbl,这会在 .config/migrations 目录下生成一个带时间戳的迁移文件。
提示:Kysely<any> 是必须的,因为迁移文件是数据库在某个时间点的快照,它不应该依赖于未来可能改变的 Database 接口。
// <timestamp>_init_tables.ts
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_tbl') // 推荐使用 snake_case 作为表名和列名
.addColumn('id', 'uuid', (col) => col.primaryKey())
.addColumn('username', 'varchar(100)', (col) => col.notNull())
.addColumn('address', 'varchar(255)', (col) => col.notNull())
.addColumn('email', 'varchar(100)', (col) => col.notNull().unique())
.addColumn('birthday', 'timestamptz', (col) => col.notNull())
.addColumn('created_at', 'timestamptz', (col) => col.defaultTo(sql`now()`).notNull())
.addColumn('updated_at', 'timestamptz')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_tbl').execute();
}
C. 执行迁移
执行 npm run db:migrate:latest,Kysely 就会在数据库中创建这张表。
3. 使用 kysely-ctl 填充种子数据
A. 创建 Seed 文件
执行 npm run seed:make,生成种子文件。
B. 编写 Seed 逻辑
// <timestamp>_init_user.ts
import type { Database } from '@/database/types';
import type { Kysely } from 'kysely';
// 引入你的 DB 类型
export async function seed(db: Kysely<Database>): Promise<void> {
const user1Id = '123e4567-e89b-12d3-a456-426614174000';
await db
.insertInto('userTbl')
.values({
id: user1Id,
username: 'Shukang',
email: 'shukang@example.com',
address: '123 Main St',
birthday: new Date('1990-01-01T00:00:00Z'),
})
.execute();
}
C. 运行 Seed
执行 npm run db:seed:run,初始数据便成功插入数据库。
CRUD 示例
关于更完整的增删改查(CRUD)操作,我已经将其整理在一个开源项目中,欢迎参考和交流:
fastify-best-practice on GitHub
结论
选择数据库工具是一项带有战略意义的技术决策。Prisma 提供了无与伦比的开发者体验,非常适合快速启动新项目或内部系统。然而,当项目迈向成熟,面临生产环境复杂性的挑战时,可观测性和底层控制力就显得尤为重要。
Kysely 正是为此类场景而生。它信任并赋能开发者,让你在享受完全类型安全的同时,能够编写出最高效、最可控的 SQL 查询。它要求你更懂 SQL,但这本身就是一项宝贵的投资。通过拥抱 Kysely,你不仅选择了一个工具,更是选择了一种更严谨、更高效、更具掌控力的后端开发哲学。