🔌 AI 全栈学习第八天:后端数据库连接 Prisma —— 当 NestJS 遇上“最强翻译官”

182 阅读7分钟

哈喽,掘金的各位全栈练习生们!👋 欢迎回到 AI 全栈项目实战 的第八天!

昨天,我们在前端的世界里“自导自演”了一出大戏,用 Mock.js 伪造了数据,用 Axios 拦截器处理了登录鉴权。虽然页面跑得欢,但作为一名有追求的全栈开发者,我们心里清楚:没有真实后端的应用,就像没有灵魂的躯壳。

今天,我们要跨过前后端的分界线,进入深邃的后端领域。我们将使用 NestJS 这个企业级框架,搭配 Prisma 这个现代化的 ORM 神器,亲手搭建一个能连数据库、能校验参数、能跨域通信的真实后端服务!

准备好了吗?让我们把“假戏”做成“真做”!🚀


🏗️ 一、 起步:NestJS 后端项目搭建

关于 NestJS 的基础,我们在第四天已经详细讲过。它就像后端的“精装房”,模块化、依赖注入这些特性让我们写后端代码像搭积木一样爽。

首先,我们要创建一个新的后端项目来承载我们的博客数据:

# 如果你还没安装 CLI
npm i -g @nestjs/cli

# 创建名为 posts 的后端服务
nest new posts

项目建好后,先别急着写接口。对于后端来说,数据是核心。我们需要先设计好数据库,把地基打牢。


🤖 二、 ORM 与 Prisma:不懂 SQL 也能操作数据库?

2.1 什么是 ORM?

对于很多前端出身的全栈新手来说,SQL 语句(SELECT * FROM users WHERE ...)简直就是天书。这时候,ORM (Object-Relational Mapping,对象关系映射) 就闪亮登场了。

ORM 就像是一个翻译官

  • 你对它说:“嘿,帮我找一下 ID 为 1 的用户。”(调用 User.findUnique({ id: 1 })
  • 它对数据库说SELECT * FROM "users" WHERE "id" = 1 LIMIT 1;
  • 数据库返回:一行行枯燥的数据。
  • 它返回给你:一个漂亮的 JavaScript 对象 { id: 1, name: "admin" }

有了 ORM,我们就可以用操作对象的方式来操作数据库,再也不用手写复杂的 SQL 拼字符串了!

2.2 为什么选择 Prisma?

在 Node.js 领域,TypeORM 和 Sequelize 曾经是王者,但现在 Prisma 才是新宠。

  • 类型安全:它能根据你的数据库结构自动生成 TypeScript 类型定义。
  • 直观的 Schema:它的数据建模语言非常易读,就像写伪代码一样。
  • 强大的迁移工具:数据库表结构的变更管理非常方便。

2.3 初始化 Prisma

首先,在我们的 posts 项目中安装 Prisma:

# 安装 Prisma CLI (开发依赖)
pnpm i -D prisma

# 安装 Prisma Client (运行时依赖)
pnpm i @prisma/client

# 初始化 Prisma
npx prisma init

pnpm prisma generate

执行完 init 后,你会发现项目里多了一个 prisma 文件夹,里面有个 schema.prisma 文件。这就是我们的数据库设计图纸

同时,根目录下还会生成一个 .env 文件,用来存放数据库连接字符串。

# .env
# 格式:postgresql://用户名:密码@地址:端口/数据库名?schema=public
DATABASE_URL="postgresql://postgres:123456@localhost:5432/xue?schema=public"

⚠️ 注意:真实开发中,.env 包含敏感信息,绝对不能提交到 GitHub!


📝 三、 数据库建模:Schema 的艺术

打开 prisma/schema.prisma,这里是我们定义数据结构的地方。Prisma 的语法非常直观,我们来定义一下博客系统的核心模型:用户、文章、评论、标签等。

// prisma/schema.prisma

// 1. 生成器配置:告诉 Prisma 生成 JS 客户端
generator client {
  provider = "prisma-client-js"
}

// 2. 数据源配置:连接 PostgreSQL
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 3. 模型定义

// 用户模型
model User {
  id         Int         @id @default(autoincrement()) // 主键,自增
  name       String      @unique @db.VarChar(255)      // 唯一用户名
  password   String      @db.VarChar(255)              // 密码
  
  // 时间戳:创建时间和更新时间
  createdAt  DateTime?   @default(now()) @map("created_at") @db.Timestamptz(6)
  updatedAt  DateTime?   @default(now()) @map("updated_at") @db.Timestamptz(6)

  // 关系字段:一个用户可以有多篇文章、多条评论等
  posts      Post[]
  comments   Comment[]
  likes      UserLikePost[]
  avatars    Avatar[]
  files      File[]

  // 映射到数据库中的表名
  @@map("users")
}

// 文章模型
model Post {
  id         Int         @id @default(autoincrement())
  title      String      @db.VarChar(255)
  content    String?     @db.Text // 长文本
  
  // 外键关联:文章属于某个用户
  userId     Int?        
  user       User?       @relation(fields:[userId], references: [id], onDelete: SetNull)
  
  // 关系
  comments   Comment[]
  tags       PostTag[]      // 多对多关系
  likes      UserLikePost[] // 点赞
  files      File[]

  // 索引:加速查询
  @@index([userId])
  @@map("posts")
}

// 评论模型 (支持嵌套评论)
model Comment {
  id         Int         @id @default(autoincrement())
  content    String?     @db.Text
  postId     Int
  userId     Int
  parentId   Int? // 父评论 ID

  // 关联
  post       Post        @relation(fields:[postId], references:[id], onDelete:Cascade)
  user       User        @relation(fields:[userId], references:[id], onDelete:Cascade)
  
  // 自关联:评论回复评论
  parent     Comment?    @relation("CommentToComment", fields:[parentId], references:[id], onDelete: Cascade)   
  replies    Comment[]   @relation("CommentToComment")

  @@index([postId])
  @@index([userId])
  @@map("comments")
}

// 标签模型
model Tag {
  id         Int         @id @default(autoincrement())   
  name       String      @unique @db.VarChar(255)
  posts      PostTag[]
  @@map("tags") 
}

// 中间表:文章与标签的多对多关系
model PostTag {
  postId     Int
  tagId      Int
  post       Post        @relation(fields:[postId], references:[id], onDelete:Cascade)
  tag        Tag         @relation(fields:[tagId], references:[id], onDelete:Cascade)

  // 联合主键
  @@id([postId, tagId])
  @@index([tagId])
  @@map("post_tags")
}

// ... 其他模型如 File, Avatar, UserLikePost 省略,原理类似

🔍 关键点解析:

  • @id @default(autoincrement()):标准的 ID 自增主键配置。
  • @map("users"):Prisma 默认模型名是驼峰 (User),但数据库表名习惯用复数下划线 (users)。@map 帮我们做了这层映射。
  • @relation:定义表之间的关系。比如 Post 中的 user 字段,通过 fields: [userId] 关联到 User 模型的 references: [id]
    • onDelete: Cascade:级联删除。如果用户被删了,他的评论也一起删掉,非常方便!
  • ?:表示该字段可选(nullable)。

2.4 让设计变成现实:Migrate

写好了图纸,现在要让它变成真实的数据库表。

# 创建迁移并应用到数据库
npx prisma migrate dev --name init_db

这条命令会做两件事:

  1. prisma/migrations 下生成 SQL 迁移文件(留作日志和版本控制)。
  2. 在数据库里真正创建这些表。
  3. 后面的init_db就是给这次创建表操作的记录

在migrations文件夹在里面存储了所有生成的sql数据有什么好处? 来看看文件内容

image.png

Prisma 不仅让初学者能够快速上手数据库操作,避免直接编写复杂的原生 SQL,更重要的是,它通过自动生成的 Prisma Client 和迁移(Migration)机制,使得每一次数据库变更都有明确的记录文件(如 schema.prisma 和 migrations 目录下的 SQL 文件)。

在大型项目中,这种能力尤为重要:

  • 可追溯性:每次数据模型变更都会生成对应的迁移文件,团队成员可以清晰看到数据库结构是如何演进的;
  • 协作友好:代码审查(Code Review)时可以直接查看 schema 和 migration 文件,确保数据库变更符合预期;
  • 便于优化与调试:Prisma Client 会将数据库查询转化为类型安全的方法调用,配合日志或调试工具(如 prisma. $ queryRaw 或 DEBUG=prisma:*),能清楚看到实际执行的 SQL,方便性能分析和优化;
  • 版本控制友好:所有数据库结构变更都以文件形式纳入 Git 等版本控制系统,避免了“数据库状态漂移”的问题。

2.5 可视化神器:Prisma Studio

想看看数据表建得对不对?不用下专门的数据库软件,Prisma 自带了一个网页版管理工具:

npx prisma studio

运行后打开浏览器,你就可以看到一个超级好用的数据库管理界面,可以直接增删改查数据,用来造测试数据简直不要太爽!😍

image.png

🔗 四、 串联前后端:NestJS 实战配置

数据库准备好了,现在我们要配置 NestJS 来连接它,并处理前端的请求。

4.1 解决跨域与全局配置 (Main.ts)

前后端分离开发,第一件事就是跨域 (CORS)。 打开 src/main.ts

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
// 1️⃣ 引入 NestExpressApplication 以使用 Express 的特性
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common'

async function bootstrap() {
  // 2️⃣ 泛型指定为 NestExpressApplication,这样才有 cors 等提示
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    cors: true // ✅ 一键开启跨域!
  });

  // 3️⃣ 设置全局路由前缀
  // 这样所有的接口都会自动加上 /api,比如 localhost:3000/api/posts
  app.setGlobalPrefix('api'); 

  // 4️⃣ 启用全局验证管道 (DTO 的好搭档)
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true, // 🛡️ 自动剔除 DTO 中未定义的属性,防止恶意注入
    forbidNonWhitelisted: true, // 遇到未定义属性直接报错
    transform: true // ✨ 自动类型转换(把 query 中的字符串 '1' 转为数字 1)
  }))

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

4.2 模块化架构 (AppModule)

打开 src/app.module.ts,我们将看到应用的整体结构。

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PostsModule } from './posts/posts.module';
import { PrismaModule } from './prisma/prisma.module';

@Module({
  // 🧩 引入两个核心模块:
  // PostsModule: 处理文章相关的业务
  // PrismaModule: 负责数据库连接
  imports: [PostsModule, PrismaModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

🛡️ 五、 DTO:参数校验的守门员

在写业务逻辑之前,我们要先解决一个问题:怎么保证前端传来的参数是合法的? 比如分页参数 page 必须是数字且大于 1。这时候就需要 DTO (Data Transfer Object) 配合 class-validator 库了。

5.1 创建 Query DTO

新建 src/posts/dto/post-query.dto.ts

// src/posts/dto/post-query.dto.ts
import { IsOptional, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';

export class PostQueryDto {
    @IsOptional() // 参数可选
    @Type(() => Number) // ✨ 转换:前端传URL参数默认是字符串,这里转成数字
    @IsInt() // 校验:必须是整数
    @Min(1) // 校验:最小值为 1
    page?: number = 1; // 默认值 1

    @IsOptional()
    @Type(() => Number)
    @IsInt()
    @Min(1)
    limit?: number = 10; // 默认值 10
}

💡 为什么这很重要? 如果不加这个,你需要手动写 if (typeof page !== 'number') ...。现在,配合 main.ts 里的 ValidationPipe,NestJS 会自动帮我们完成类型转换数据校验。如果不符合规则,直接抛出 400 错误,逻辑代码完全不需要操心这些脏活累活。


🔌 六、 数据库服务:封装 PrismaService

我们不直接在每个 Service 里 new PrismaClient(),而是把它封装成一个可注入的 Service。

6.1 PrismaService

新建 src/prisma/prisma.service.ts

// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
    // PrismaClient 本身就是连接池,我们继承它
    
    // OnModuleInit 是 NestJS 生命周期钩子
    // 模块初始化时,自动连接数据库
    async onModuleInit() {
        await this.$connect(); 
    }
}

6.2 Global Module

为了让这个 Service 在全项目通用,我们在 prisma.module.ts 里把它设为全局模块。

// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global() // 🌍 标记为全局模块,其他模块使用时不需要再 imports: [PrismaModule]
@Module({
  providers: [PrismaService],
  exports: [PrismaService], // 导出 Service 供外部使用
})
export class PrismaModule {}

🚀 七、 业务逻辑:Controller 与 Service

最后,我们把所有东西串起来,实现一个简单的“获取文章列表”接口。

7.1 Controller (路由层)

src/posts/posts.controller.ts

import { Controller, Get, Query } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostQueryDto } from './dto/post-query.dto';

@Controller('posts') // 路由前缀:/api/posts
export class PostsController {
    constructor(private readonly postsService: PostsService) {}

    @Get()
    // @Query() 装饰器会自动提取 URL 参数
    // query: PostQueryDto 会自动触发 DTO 校验和转换
    async getPosts(@Query() query: PostQueryDto) {
        console.log(query); // 打印看看,此时 page 已经是 number 类型了
        return this.postsService.findAll(query);
    }
}

7.2 Service (业务层)

src/posts/posts.service.ts

import { Injectable } from '@nestjs/common';
import { PostQueryDto } from './dto/post-query.dto';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class PostsService {
    // 💉 依赖注入 PrismaService
    constructor(private prisma: PrismaService) {}

    async findAll(query: PostQueryDto) {
        // 直接使用 this.prisma 操作数据库,就像操作对象一样!
        // 这里演示简单统计文章总数
        const total = await this.prisma.post.count(); 
        console.log('Total posts:', total);
        
        // 之后我们会在这里实现真正的分页查询
        return {
            items: [], // 暂时留空
            total: total
        }
    }
}

🎬 总结

今天我们完成了一次从 0 到 1 的后端基建:

  1. NestJS 配置:解决了跨域,配置了全局前缀和管道。
  2. Prisma 建模:通过 schema.prisma 设计了复杂的关系型数据库表结构,并一键迁移。
  3. DTO 规范:利用 class-validator 实现了优雅的参数校验。
  4. 服务连接:封装了 PrismaService,成功打通了 NestJS 到 PostgreSQL 的“任督二脉”。

现在,当你访问 http://localhost:3000/api/posts?page=1&limit=5 时,后端已经能正确解析参数,并去数据库里“打个招呼”了。

明天,我们将利用这些基础设施,实现真正的增删改查 (CRUD),把我们的博客文章真正存进数据库里!

保持饥饿,保持愚蠢,我们明天见!👋