14 — 数据库接入指南

1 阅读5分钟

14.2 常见的 Node.js 数据库方案

关系型数据库(SQL)

方案说明适合场景
mysql2 + 手写 SQL原生驱动,最灵活追求性能、SQL 能力强
Knex.jsSQL 查询构建器不想写裸 SQL,但也不要太重的 ORM
Sequelize老牌 ORM功能全,生态成熟
TypeORMTypeScript 友好的 ORMTS 项目,喜欢装饰器风格
Prisma新一代 ORM,Schema 驱动喜欢类型安全、自动生成客户端

NoSQL 数据库

方案说明
MongooseMongoDB 的 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 性能优化建议

  1. 加索引:常用的查询条件字段(name、status、created_at 等)加索引
  2. 分页用数据库做:不要查全部数据再在内存里 slice,数据库的 LIMIT/OFFSET 高效得多
  3. N+1 查询:列表页展示关联数据时注意 N+1 问题,用 include 或 join 一次查出来
  4. 连接池:配置合适的数据库连接池大小
  5. 缓存:热点数据可以加 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 入侵业务代码