数据库接入入门
这一课你将能做到
- 画出 Controller → Service → Repository/ORM → 数据库的完整数据流
- 读懂 TypeORM 的 Entity + Repository 写法,以及 Prisma schema + Client 写法
- 在模块层正确登记连接与实体,并坚持「Controller 不写 SQL、synchronize 不开」两条纪律
为什么要学
内存数组只适合学框架:重启数据就没了,也无法支撑真实用户。真实的刷题平台必须把题库、作答记录持久化。
Nest 生态最常见两条持久化路线:TypeORM 用装饰器定义 Entity、注入 Repository,风格接近 Spring Data JPA;Prisma 用独立 schema 文件描述模型、生成类型安全的客户端,迁移工作流自成一体。
本课的边界先说清楚:本课程不要求你安装数据库,也不会执行任何同步或迁移操作。 下面的 ORM 代码以「读懂形状」为目标,你的 quiz-api 继续运行内存版本即可;将来环境就绪,再按注释的替换点切换。
先建立图像
QuestionsController
→ QuestionsService
→ Repository<Question>(TypeORM)或 prisma.question(Prisma)
→ MySQL / PostgreSQL / SQLite
AppModule: TypeOrmModule.forRoot(...) 或 PrismaService(全局)
QuestionsModule: TypeOrmModule.forFeature([Question])
三端概念对照:
| Spring Data JPA | Nest + TypeORM | Nest + Prisma |
|---|
@Entity 类 | @Entity() 装饰的类 | schema.prisma 中的 model |
JpaRepository<T,ID> | Repository<T> | prisma.question |
@Transactional | DataSource 事务 / QueryRunner | prisma.$transaction |
| Flyway / Liquibase | TypeORM migrations | prisma migrate |
跟着做
步骤 1:先画数据流(不装任何东西)
在笔记里画出「提交作答」的四格流转:
POST /api/submissions,JwtAuthGuard 解析出登录用户。
- ValidationPipe 校验 questionId、selectedIndex。
- SubmissionsService 调用 QuestionsService 查标准答案、判分。
- 持久化层执行
INSERT INTO submission ...。
重点结论:SQL 只允许出现在第四层。如果你曾在 Express 路由里直接 connection.query(...),这一课就是把这个习惯纠正过来。
步骤 2:读懂 TypeORM 实体
将来需要持久化时,安装命令是(本课先不执行):
npm install @nestjs/typeorm typeorm mysql2
新建 src/questions/question.entity.ts:
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from "typeorm";
@Entity("question")
export class QuestionEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 500 })
stem: string;
@Column("simple-json") // 数组存为 JSON 字符串
options: string[];
@Column({ name: "answer_index" })
answerIndex: number;
@Column({ type: "varchar", length: 10, default: "easy" })
difficulty: "easy" | "medium" | "hard";
@Column("simple-json", { nullable: true })
tags: string[];
@CreateDateColumn({ name: "created_at" })
createdAt: Date;
}
对比内存版 Question 接口:字段一一对应,只多了表列映射元数据。
步骤 3:读懂 Repository 注入版 Service
下面是将来的持久化版 QuestionsService(只读懂,不替换当前文件):
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { QuestionEntity } from "./question.entity";
import { CreateQuestionDto } from "./dto/create-question.dto";
@Injectable()
export class QuestionsService {
constructor(
@InjectRepository(QuestionEntity)
private readonly repo: Repository<QuestionEntity>,
) {}
findAll() {
// 对应 SELECT * FROM question
return this.repo.find();
}
async findOne(id: number): Promise<QuestionEntity> {
const row = await this.repo.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`题目 ${id} 不存在`);
}
return row;
}
create(dto: CreateQuestionDto) {
const row = this.repo.create(dto); // 先构造实体实例
return this.repo.save(row); // 再 INSERT
}
}
Service 依赖的是 Repository<QuestionEntity> 这个抽象,单元测试中只需 mock 它的 find/findOne/save,不需要真实数据库。
步骤 4:读懂模块登记(连接全局一次)
AppModule 中配置连接,全部值来自 ConfigService(不要照抄进项目运行):
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: "mysql",
host: config.get<string>("DB_HOST", "localhost"),
port: config.get<number>("DB_PORT", 3306),
username: config.get<string>("DB_USER", "root"),
password: config.get<string>("DB_PASSWORD", ""),
database: config.get<string>("DB_NAME", "quiz"),
entities: [QuestionEntity],
synchronize: false, // 红线:任何环境都不靠框架改表结构
autoLoadEntities: true,
}),
});
QuestionsModule 中登记本模块使用的实体:
@Module({
imports: [TypeOrmModule.forFeature([QuestionEntity])],
controllers: [QuestionsController],
providers: [QuestionsService],
})
export class QuestionsModule {}
结构记忆:连接 forRoot 全局一次,实体 forFeature 按业务模块登记,类似 Spring 的数据源 Bean 加各模块的 Repository。
步骤 5:对照 Prisma 的写法
prisma/schema.prisma(概念示例):
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Question {
id Int @id @default(autoincrement())
stem String @db.VarChar(500)
options Json
answerIndex Int @map("answer_index")
difficulty String @default("easy") @db.VarChar(10)
tags Json?
createdAt DateTime @default(now()) @map("created_at")
submissions Submission[]
@@map("question")
}
model Submission {
id Int @id @default(autoincrement())
questionId Int @map("question_id")
userId Int @map("user_id")
selectedIndex Int @map("selected_index")
correct Boolean
question Question @relation(fields: [questionId], references: [id])
@@map("submission")
}
import { Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
// TypeORM
this.repo.find({ where: { difficulty: "hard" } });
// Prisma
this.prisma.question.findMany({ where: { difficulty: "hard" } });
步骤 6:记住迁移工作流与安全红线
- 表结构变更只走迁移文件:TypeORM 用
migration:generate,Prisma 用 migrate dev,并纳入代码评审。
synchronize 永远关闭:它可能在生产直接 DROP 列、丢数据。
- 连接串与密码只走 ConfigService / 环境变量;数据库账号最小授权。
- 唯一约束建在数据库层(如登录名 unique),应用层查重只是体验优化,不能替代约束。
讲清楚要点
- ORM 藏在 Service 之下:将来切换持久化实现,Controller 和接口契约都不需要改动。
- 连接是基础设施:forRoot 放根模块、forFeature 放业务模块,职责清晰。
- synchronize 是开发期糖、生产期刀:本课程直接保持 false,从一开始养成迁移习惯。
- Repository 抽象带来可测性:mock 仓库比搭建测试数据库更轻量。
- 关联查询注意 N+1:入门先单表 CRUD;进阶再学 relations、include 的预加载策略。
- 两套方案二选一:TypeORM 与 Prisma 不要在同一项目双写同一批表,否则真相来源分裂。
你可能会懵的地方
-
不接数据库,学这课有什么用?
初学者先建立分层和术语直觉,将来接入时你知道每样东西该放哪、配置长什么样;这比边连库边猜结构高效得多。
-
TypeORM 和 Prisma 到底选哪个?
团队熟悉 JPA、喜欢装饰器实体选 TypeORM;想要 schema 即文档、生成类型安全客户端选 Prisma。选定后把 CRUD 加迁移做完整,胜过两个都只点到为止。
-
Repository 层和 Service 层会不会重复?
小项目 Service 直接注入 Repository 很常见;只有查询逻辑复杂、需要复用数据装配时,再单独包一层 Repository 类。
-
JSON 字段和关联表怎么选?
options 这类固定随题目读取、不参与查询的结构适合 JSON 列;Submission 与 Question 这种有独立生命周期、需要按关系检索的,必须建关联表。
-
集成测试时数据库怎么办?
单测一律 mock 仓库;需要验证 SQL 与约束时,使用独立测试库并在测试后清理,绝不能连生产库。
错法对照
| 错法 | 问题 | 正解 |
|---|
Controller 里写 repo.query 或拼接 SQL | 难测试、注入风险 | Service 调用 ORM 参数化 API |
| 生产开启 synchronize | 可能自动改表、丢数据 | 永远 false + 迁移文件 |
| 数据库密码写进 forRoot 源码 | 凭据泄露 | ConfigService + 环境变量 |
| TypeORM 与 Prisma 同时操作同一表 | 两套真相互相覆盖 | 项目内二选一 |
| 只靠应用层查重不建唯一索引 | 并发下仍产生脏数据 | 数据库层加唯一约束 |
卡点急救
- 将来连接报
ECONNREFUSED:数据库未启动或主机端口不对;先用环境变量核对,再用本地客户端验证连通性。
- 报 EntityMetadataNotFound:实体没有放进 forFeature 或 forRoot 的 entities;使用自动加载时确认实体被对应模块引用。
- Prisma 客户端无类型提示:改完 schema 后执行
prisma generate 重新生成客户端,IDE 再重启 TS 服务。
面试常问
-
NestJS 接入数据库的标准分层是什么?
Controller 处理 HTTP,Service 承载业务并通过 Repository(TypeORM)或 PrismaClient(Prisma)访问数据库;连接在根模块 forRoot 配置,实体按模块 forFeature 登记。
-
为什么生产环境不能开 TypeORM 的 synchronize?
它根据实体内省直接执行 DDL,字段改名会被当作删除加新增,可能清掉整列数据;表结构必须通过可评审、可回滚的迁移文件演进。
-
TypeORM 的 Repository 怎么注入?
在模块中 TypeOrmModule.forFeature([Entity]),然后在 Service 构造器中用 @InjectRepository(Entity) 注入 Repository<Entity>,测试时可替换为 mock。
自测三问
- forRoot 与 forFeature 各自解决什么问题、分别放在哪一层?
- 为什么本课程坚持「不同步数据库」、synchronize 保持关闭?
- TypeORM 与 Prisma 如果只能选一个,你的依据是什么?
小结一句话
ORM 挂在 Service 之下,连接与实体按模块登记,表结构只靠迁移演进、绝不同步。
延伸