哈喽,掘金的各位全栈练习生们!👋 欢迎回到 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
这条命令会做两件事:
- 在
prisma/migrations下生成 SQL 迁移文件(留作日志和版本控制)。 - 在数据库里真正创建这些表。
- 后面的init_db就是给这次创建表操作的记录
在migrations文件夹在里面存储了所有生成的sql数据有什么好处? 来看看文件内容
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
运行后打开浏览器,你就可以看到一个超级好用的数据库管理界面,可以直接增删改查数据,用来造测试数据简直不要太爽!😍
🔗 四、 串联前后端: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 的后端基建:
- NestJS 配置:解决了跨域,配置了全局前缀和管道。
- Prisma 建模:通过
schema.prisma设计了复杂的关系型数据库表结构,并一键迁移。 - DTO 规范:利用
class-validator实现了优雅的参数校验。 - 服务连接:封装了
PrismaService,成功打通了 NestJS 到 PostgreSQL 的“任督二脉”。
现在,当你访问 http://localhost:3000/api/posts?page=1&limit=5 时,后端已经能正确解析参数,并去数据库里“打个招呼”了。
明天,我们将利用这些基础设施,实现真正的增删改查 (CRUD),把我们的博客文章真正存进数据库里!
保持饥饿,保持愚蠢,我们明天见!👋