在这个"全栈"都快卷成"全干"的年代,后端开发者的日常往往是:左手写接口,右手查 Bug,中间还得防着前端传来的参数把你的数据库搞崩。
很多刚入门 NestJS 的兄弟,看着满屏的 @Injectable() 和 class-validator 装饰器,脑子里全是问号。今天,咱们不聊那些枯燥的底层原理,直接拿一个实战项目的核心代码,逐行拆解 ,带你看看如何用 NestJS + Prisma + DTO 这套"黄金三角",写出既优雅又安全,还能让同事直呼"内行"的后端代码。
一、 Prisma:当后端开始写"类",SQL 老炮儿可能会流泪
以前写后端,最头疼的是什么?是 SELECT * FROM users WHERE id = ? 写到手软,还是多表关联时 JOIN 到怀疑人生?
Prisma 的出现,就像给后端开发者发了一副"透视眼镜"。它不再让你去拼凑字符串 SQL,而是让你用 TypeScript 的对象思维去操作数据库。
1. 把数据库变成你的"私人定制"
咱们先来看看项目里的 schema.prisma,这哪里是配置文件,这简直就是数据库的"设计蓝图":
kotlin
model Post {
id Int @id @default(autoincrement())
title String @db.VarChar(255)
content String? @db.Text
userId Int?
// 一句话搞定关联,Prisma 自动帮你处理外键
user User? @relation(fields:[userId], references: [id], onDelete: SetNull)
comments Comment[]
tags PostTag[]
likes UserLikePost[]
@@index([userId]) // 索引?一个注解就搞定
@@map("posts")
}
逐行拆解:
id Int @id @default(autoincrement()):这行定义了主键。@id告诉数据库这是唯一标识,autoincrement()意味着你不需要自己操心 ID 是多少,每新增一条,数据库自动 +1。content String?:注意那个问号?。在 Prisma 里,加问号代表这个字段在数据库里是"可选的"(允许为 NULL)。如果不加问号,你插入数据时如果不传content,数据库直接给你报错。user User? @relation(...):这是 Prisma 最强大的地方!它直接在这里定义了"这篇文章属于哪个用户"。fields: [userId]指定了当前表的外键,references: [id]指定了它要去关联 User 表的哪个字段。以后你想查某篇文章的作者,再也不用手写JOIN语句了。
在 NestJS 中,我们只需要创建一个全局的 PrismaService,并在模块初始化时自动连接数据库:
typescript
@Injectable() // 1. 告诉 NestJS:这个类可以被"注入"到其他模块里使用
export class PrismaService
extends PrismaClient // 2. 继承 PrismaClient,让 PrismaService 具备所有操作数据库的能力
implements OnModuleInit // 3. 实现 NestJS 的模块初始化生命周期接口
{
async onModuleInit() {
await this.$connect(); // 4. 当整个 NestJS 应用启动时,自动建立数据库连接
}
}
逐行拆解:
@Injectable():这是 NestJS 的灵魂。加上它,你才能在别的 Service 里通过constructor(private prisma: PrismaService)直接拿来用。extends PrismaClient:PrismaClient是 Prisma 自动生成的、类型安全的数据库查询客户端。我们继承它,相当于给 NestJS 的依赖注入系统套了一层 Prisma 的外壳。onModuleInit():这是一个生命周期钩子。应用一启动,NestJS 就会自动调用这个方法,执行$connect()连上数据库,省去了手动初始化的麻烦。
2. 复杂查询?Prisma 的"套娃"艺术
很多新手怕关联查询,但在 Prisma 面前,多表查询就像剥洋葱一样简单。来看看咱们项目里获取文章列表的 PostsService:
less
async findAll(query: PostQueryDto) {
const { page, limit } = query;
// 1. 计算跳过多少条数据(比如第2页,每页10条,就要跳过前10条)
const skip = (((page || 1 ) - 1) * (limit || 10) );
// 2. 使用 Promise.all 并行执行两个数据库操作,提升性能
const [total, posts] = await Promise.all([
// 操作A:统计总共有多少篇文章(用于前端做分页器)
this.prisma.post.count(),
// 操作B:查询具体的文章列表
this.prisma.post.findMany({
skip, // 跳过前面的数据
take: limit, // 拿多少条
orderBy: { id: 'desc' }, // 按 ID 倒序排列,最新的文章在最前面
include: { // 【核心重点】include 用于关联查询其他表的数据
user: {
select: { // select 用于"裁剪"字段,只拿我们需要的,保护隐私
id: true,
name: true,
// 还能继续往下套娃!直接查出用户的头像文件名
avatars: { select: { filename: true } }
}
},
tags: {
select: { tag: { select: { name: true } } }
},
// _count 是 Prisma 的神技,直接统计关联数据的数量,不用自己再 count 一遍
_count: { select: { likes: true, comments: true } },
// 可以在关联查询里加过滤条件,比如只查图片类型的附件
files: { where: { mimetype: { startsWith: "image/" } } }
}
})
])
// ... 后续的数据格式化
}
逐行拆解:
Promise.all([...]):为什么要用它?因为"统计总数"和"查询列表"是两个独立的操作,如果串行执行(先查总数再查列表),用户就要等两倍的时间。用Promise.all让它们同时去数据库跑,速度直接翻倍。include与select的区别:include是把关联的表"拉进来"一起查;而select是"挑挑拣拣"。比如用户表里有密码字段,我们绝对不想返回给前端,所以用select: { id: true, name: true }只提取安全的字段。_count:以前你想查一篇文章有多少个赞,得先查出文章,再查点赞表。现在 Prisma 直接帮你算好放在_count.likes里,简直是懒人福音。
二、 DTO:给接口穿上"防弹衣",把危险挡在门外
后端开发有一条铁律:永远不要相信前端传来的任何数据!
如果不加校验,前端传个 page: "abc" 或者 password: "123",你的服务可能当场罢工。这时候,DTO(Data Transfer Object,数据传输对象)和 class-validator 就是你的"防弹衣"。
1. 基础校验:简单粗暴但有效
在用户注册时,我们定义一个 CreateUserDto:
less
export class CreateUserDto {
@IsNotEmpty() // 1. 规定这个字段不能为空
@IsString() // 2. 规定这个字段必须是字符串类型
name: string;
@IsNotEmpty()
@IsString()
@MinLength(6) // 3. 规定密码长度至少为 6 位,少于 6 位直接打回!
password: string;
}
逐行拆解:
这些 @ 开头的叫"装饰器"。当请求到达 Controller 时,NestJS 的全局管道(ValidationPipe)会先拦截这个请求,拿着这些规则去检查前端传来的 JSON。如果 name 是空的,或者 password 只有 3 位,NestJS 根本不会让你的代码进入 Service 层,直接给前端返回一个 400 Bad Request 错误。这就叫"把危险挡在门外"。
2. 进阶校验:处理复杂嵌套结构
当你的接口越来越复杂,比如 AI 模块需要接收一组对话历史时,DTO 的威力就显现了:
less
export class Message {
@IsString()
@IsNotEmpty()
role: string; // 规定角色必须是 user 或 assistant
@IsString()
@IsNotEmpty()
content: string; // 规定聊天内容不能为空
}
export class ChatDto {
@IsString()
@IsNotEmpty()
id: string; // 对话的唯一 ID
@IsArray() // 1. 规定 messages 必须是一个数组
@ValidateNested({ each: true }) // 2. 【核心】约定数组里的"每一个"元素都要符合 Message 类的规则
@Type(() => Message) // 3. 【必写】必须加这个,否则 class-validator 不知道数组里装的是什么类型,校验会失效
messages: Message[];
}
逐行拆解:
@ValidateNested({ each: true }):这是处理嵌套对象的神器。它告诉校验器:"别光看messages是不是数组,还要钻进数组里,把里面的每一条消息都拿出来,用Message类的规则再校验一遍!"@Type(() => Message):因为前端传来的 JSON 只是纯文本对象,NestJS 需要这个装饰器把它"转换"成真正的Message类实例,否则嵌套校验根本跑不起来。
只要在 main.ts 里全局启用 ValidationPipe 并开启 transform: true,这些校验规则就会自动生效。不合规的数据,连 Controller 的门都进不去!
三、 NestJS 基础写法:企业级开发的"强迫症"美学
为什么大厂都爱用 NestJS?因为它把"模块化"和"依赖注入"刻进了 DNA 里。
1. 路由守卫:把权限控制做成"安检门"
在发布文章时,只有登录用户才能操作。我们不需要在每个接口里写 if (!user) return 401,只需要一个守卫:
less
@Post()
@UseGuards(JwtAuthGuard) // 1. 加上这一行,没带 Token 或 Token 过期的统统拦下
createPost(
@Body('title') title: string, // 2. 从请求体里只提取 title 字段
@Body('content') content: string, // 3. 从请求体里只提取 content 字段
@Req() req // 4. 拿到原始的 HTTP 请求对象
) {
const { user } = req; // 5. 守卫通过后,JWT 策略解析出的用户信息会自动挂载到 req 上
return this.postsService.createPost({
title,
content,
userId: user.id // 6. 把当前登录用户的 ID 塞进数据库
})
}
逐行拆解:
@UseGuards(JwtAuthGuard):这就像机场的安检门。请求一来,先过安检(校验 Token)。如果安检不通过,直接返回 401,你的createPost函数压根不会被执行。@Body('title'):这是一种"参数解构"的写法。前端可能传了 10 个字段,但你只关心title,这样写既清晰又安全,避免了拿到一堆无用的垃圾数据。
2. 异常处理:优雅地"甩锅"
当用户名已存在时,不要返回一堆看不懂的报错堆栈。NestJS 提供了标准的异常类:
csharp
const existingUser = await this.prisma.user.findUnique({ where: { name } })
if (existingUser) {
// 抛出 NestJS 内置的标准异常,框架会自动捕获并返回漂亮的 JSON 错误信息
throw new BadRequestException("用户名已存在")
}
逐行拆解:
throw new BadRequestException(...):如果你直接throw new Error(...),前端收到的可能是一堆服务器内部报错代码。但如果你抛出 NestJS 提供的BadRequestException(对应 HTTP 400 状态码),NestJS 的全局异常过滤器会帮你把它格式化成一个标准的 JSON 对象返回给前端。这叫"优雅地甩锅",让前端能看懂到底哪里错了。
结语
从 Prisma 的类型安全查询,到 DTO 的严密参数校验,再到 NestJS 模块化的优雅架构,这套技术栈的核心逻辑其实就一句话:用代码的规范,去对抗现实世界的混乱。
当你用 Prisma 写出 post.user.avatars.filename 这种链式查询时,SQL 老炮儿可能会流泪------但你的产品经理会笑,因为你的 Bug 变少了,交付变快了。
下一期,我们将深入挖掘这个项目中的 JWT 双 Token 认证机制 以及 LangChain 驱动的 AI 智能模块。想知道怎么让后端拥有"大脑"吗?点个关注,咱们下期见!