14.2 常见的 Node.js 数据库方案
关系型数据库(SQL)
| 方案 | 说明 | 适合场景 |
|---|---|---|
| mysql2 + 手写 SQL | 原生驱动,最灵活 | 追求性能、SQL 能力强 |
| Knex.js | SQL 查询构建器 | 不想写裸 SQL,但也不要太重的 ORM |
| Sequelize | 老牌 ORM | 功能全,生态成熟 |
| TypeORM | TypeScript 友好的 ORM | TS 项目,喜欢装饰器风格 |
| Prisma | 新一代 ORM,Schema 驱动 | 喜欢类型安全、自动生成客户端 |
NoSQL 数据库
| 方案 | 说明 |
|---|---|
| Mongoose | MongoDB 的 ODM,最常用 |
| 原生 MongoDB 驱动 | 灵活,但写起来麻烦 |
本教程以 Prisma + MySQL 为例,因为它 TypeScript 支持最好,开发体验佳。
14.3 为什么只改 Model 层
分层架构的好处在这里体现:接入数据库只需要改 Model 层,Service 和 Controller 完全不用动。
原来的内存 Model:
export class ProjectModel {
#projects: Project[];
list(): Project[] { /* 操作数组 */ }
findById(id: number): Project | undefined { /* 查找数组 */ }
create(input): Project { /* push 到数组 */ }
// ...
}
换成数据库后的 Model:
export class ProjectModel {
constructor(private readonly db: PrismaClient) {}
async list(): Promise<Project[]> {
return this.db.project.findMany();
}
async findById(id: number): Promise<Project | null> {
return this.db.project.findUnique({ where: { id } });
}
async create(input): Promise<Project> {
return this.db.project.create({ data: input });
}
// ...
}
方法签名几乎一样(只是变成 async 了),Service 层调用方式不变。
14.4 Prisma 快速上手
安装和初始化
npm install prisma -D
npm install @prisma/client
npx prisma init
会生成 prisma/schema.prisma 和 .env 文件。
定义数据模型
prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Project {
id Int @id @default(autoincrement())
name String
description String @default("")
status String @default("active")
created_at Int
updated_at Int
@@map("projects")
}
生成数据库表
# 创建迁移
npx prisma migrate dev --name init
# 生成 Prisma Client(类型安全的数据库客户端)
npx prisma generate
14.5 重写 Model 层
把内存数组换成 Prisma 查询。
// src/models/project/project-model.ts
import type { PrismaClient } from '@prisma/client';
import type { Project } from './project.js';
export class ProjectModel {
constructor(private readonly db: PrismaClient) {}
async list(): Promise<Project[]> {
const rows = await this.db.project.findMany();
return rows.map((row) => this.#toDomain(row));
}
async findById(id: number): Promise<Project | undefined> {
const row = await this.db.project.findUnique({ where: { id } });
return row ? this.#toDomain(row) : undefined;
}
async findByName(name: string): Promise<Project | undefined> {
const row = await this.db.project.findFirst({ where: { name } });
return row ? this.#toDomain(row) : undefined;
}
async create(input: Pick<Project, 'name' | 'description' | 'status'>): Promise<Project> {
const now = Math.floor(Date.now() / 1000);
const row = await this.db.project.create({
data: { ...input, created_at: now, updated_at: now },
});
return this.#toDomain(row);
}
async update(
id: number,
input: Partial<Pick<Project, 'name' | 'description' | 'status'>>,
): Promise<Project | undefined> {
try {
const row = await this.db.project.update({
where: { id },
data: { ...input, updated_at: Math.floor(Date.now() / 1000) },
});
return this.#toDomain(row);
} catch {
return undefined;
}
}
async delete(id: number): Promise<boolean> {
try {
await this.db.project.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// 数据库行 → 领域对象
#toDomain(row: {
id: number;
name: string;
description: string;
status: string;
created_at: number;
updated_at: number;
}): Project {
return {
id: row.id,
name: row.name,
description: row.description,
status: row.status as 'active' | 'inactive',
created_at: row.created_at,
updated_at: row.updated_at,
};
}
}
为什么要有 toDomain
Prisma 返回的类型和你定义的业务类型可能不完全一致:
- 枚举类型可能要转换
- 字段名可能不一样(数据库用 snake_case,代码用 camelCase)
- 有些字段不想暴露给上层
加一层转换,上层就不用关心数据库细节了。
14.6 Service 层的改动
因为 Model 方法变成 async 了,Service 也得加 await:
// src/services/project/project-service.ts
export class ProjectService {
constructor(private readonly projects: ProjectModel) {}
async list(name = ''): Promise<Project[]> {
const all = await this.projects.list();
if (!name) return all;
const lower = name.toLowerCase();
return all.filter((p) => p.name.toLowerCase().includes(lower));
}
async getById(id: number): Promise<Project> {
const project = await this.projects.findById(id);
if (!project) {
throw new AppError('项目不存在', { status: 2 });
}
return project;
}
// ... 其他方法都加 async/await
}
Controller 层同理,把方法改成 async:
list = async (req: Request, res: Response) => {
const offset = parseOffset(req.query.offset);
const size = parseSize(req.query.size);
const name = parseText(req.query.name);
const all = await this.service.list(name);
return sendList(res, all.slice(offset, offset + size), {
count: all.length,
offset,
size,
});
};
14.7 bootstrap 里初始化数据库
// src/bootstrap.ts
import { PrismaClient } from '@prisma/client';
import { ProjectModel } from './models/project/project-model.js';
import { ProjectService } from './services/project/project-service.js';
import { ProjectController } from './controllers/project/project-controller.js';
export function bootstrap() {
const db = new PrismaClient();
const projectModel = new ProjectModel(db);
const projectService = new ProjectService(projectModel);
const projectController = new ProjectController(projectService);
return {
db,
controllers: { project: projectController },
};
}
14.8 事务处理
涉及多个写操作时,用事务保证要么都成功要么都失败。
// Service 层
async deleteProjectWithTasks(projectId: number): Promise<void> {
await this.db.$transaction(async (tx) => {
// 删除项目下的任务
await tx.task.deleteMany({ where: { projectId } });
// 删除项目
await tx.project.delete({ where: { id: projectId } });
});
}
事务应该在 Service 层管理,因为它是业务层面的概念(“这几个操作要一起成功”)。
14.9 性能优化建议
- 加索引:常用的查询条件字段(name、status、created_at 等)加索引
- 分页用数据库做:不要查全部数据再在内存里 slice,数据库的 LIMIT/OFFSET 高效得多
- N+1 查询:列表页展示关联数据时注意 N+1 问题,用 include 或 join 一次查出来
- 连接池:配置合适的数据库连接池大小
- 缓存:热点数据可以加 Redis 缓存
14.10 为什么不用 ORM 的 model 直接当业务 model
很多人用了 Prisma/Sequelize 后就直接把数据库模型当业务模型用,Controller 直接调 Prisma Client。
短期看省事,长期有问题:
- 数据库表结构一变,所有用到的地方都可能受影响
- 没法轻松换数据库(比如从 MySQL 换到 PostgreSQL)
- 业务逻辑和数据库耦合,不好测试
保留自己的 Model 层,用 ORM 只是实现细节。哪天不想用 Prisma 了,换个实现就行。
14.11 小结
- 接入数据库只需要改 Model 层,上层代码改动很小
- 推荐用 Prisma,TypeScript 支持最好,开发体验佳
- Model 层做数据库行到业务对象的转换,上层不感知数据库
- 方法变成 async 后,Service 和 Controller 加 await 就行
- 事务在 Service 层管理
- 保留自己的 Model 层,不要让 ORM 入侵业务代码