log
码中赤兔

Fastify 最佳实践 #3: 数据库访问

发布于 2025年6月22日
更新于 2025年6月26日
32 分钟阅读
TypeScript

在任何现代 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 查询问题,需要开发者有意识地使用 relationsQueryBuilder 进行优化。 。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)。这个定位完美地解决了上述所有问题:

  1. 无缝的日志追踪:它的日志输入并没有使用EventEmitter,因此可以兼容 AsyncLocalStorage
  2. 极致的性能与控制:它只是你和 SQL 之间的一层薄薄的、类型安全的胶水。你写的代码几乎就是最终执行的 SQL,没有任何隐藏的性能开销和魔法,性能完全由你的 SQL 功力决定。
  3. 真正的类型安全:它的类型推断能力令人惊叹。返回值的类型是根据你的 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.jsonscripts 中添加命令:

"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,你不仅选择了一个工具,更是选择了一种更严谨、更高效、更具掌控力的后端开发哲学。

关于

分享技术见解、经验和思考的个人博客

联系方式

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 码中赤兔. 版权所有