拒绝 SQL 焦虑!手把手带你用 NestJS + Prisma + DTO 写出“防弹”级后端代码

在这个"全栈"都快卷成"全干"的年代,后端开发者的日常往往是:左手写接口,右手查 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 PrismaClientPrismaClient 是 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 让它们同时去数据库跑,速度直接翻倍。
  • includeselect 的区别: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 智能模块。想知道怎么让后端拥有"大脑"吗?点个关注,咱们下期见!

相关推荐
槑有老呆1 小时前
每次跟大模型聊天,都是一次「失忆」的 HTTP 请求
javascript
sarasuki1 小时前
彻底搞懂JS闭包:从作用域链、形成条件到优缺点
javascript
用户61541317281271 小时前
# 写接口自动化时,我在断言上栽过的两个跟头
后端
糖拌西瓜皮1 小时前
TypeScript 进阶:泛型、条件类型、类型守卫与装饰器
javascript·node.js
SamDeepThinking1 小时前
Java微服务练习方式
java·后端·微服务
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端
codedx2 小时前
LangChain 和 LangGraph 构建的 Agent 项目模版
后端·langchain·agent
葫芦和十三3 小时前
图解 MongoDB 08|ESR 原则:复合索引的字段顺序怎么定
后端·mongodb·agent
葫芦和十三10 小时前
图解 MongoDB 07|索引类型:七种索引,七种访问形状
后端·mongodb·agent