你真以为“全栈”就是前端多写两个接口就完事了?

举报
bug菌 发表于 2026/01/13 16:07:41 2026/01/13
【摘要】 🏆本文收录于《滚雪球学SpringBoot 3》:https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。  本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。...

🏆本文收录于《滚雪球学SpringBoot 3》:https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
  
本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】https://blog.csdn.net/weixin_43970743/article/details/151115907,你想学习的都被收集在内,快速投入学习!!两不误。
  
若还想学习更多,可直接前往《滚雪球学SpringBoot(全版本合集)》:https://blog.csdn.net/weixin_43970743/category_11599389.html,涵盖SpringBoot所有版本教学文章。

演示环境说明:

  • 开发工具:IDEA 2021.3
  • JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
  • Spring Boot版本:3.5.4(于25年7月24日发布)
  • Maven版本:3.8.2 (或更高)
  • Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
  • 操作系统:Windows 11

前言:我为什么还在写“全栈入门”?

说出来你可能不信:我并不爱写入门文。入门文太容易写得轻飘飘——贴几段代码、跑起来、截图、完结撒花。读者是爽了,但一遇到真实业务的泥潭:分页、鉴权、并发、迁移、缓存、环境变量、容器化、线上日志、冷启动……立刻就开始“老师我报错了”。

我更想写的是那种:
你读的时候会忍不住点头:“对对对,我之前就是栽在这儿。
你写的时候会稍微皱眉:“原来这里应该这样设计才不挨打。
你上线的时候能松口气:“至少这玩意儿不是纸糊的。

我们用的技术栈如下:

  • Next.js(App Router + Route Handlers):路由、服务端渲染、接口一体化。官方文档明确 Route Handlers 基于 Web Request/Response API,且位于 app 目录中。
  • Prisma ORM + Postgres:类型安全的数据访问、迁移与建模;Prisma 官方有 Next.js + Prisma Postgres 的完整指南。
  • PostgreSQL 认证与访问控制:别把“连上库”当胜利,认证与访问策略是底线。
  • Docker(Dockerfile & 镜像分层思维):把“我电脑能跑”升级成“到哪儿都能跑”。

接下来,我们做一个“认真但不沉闷”的小项目:任务清单(Task Board)。它不伟大,但足够覆盖全栈最常见的坑。

1. 需求别急着写代码:先把“边界”讲明白

任务清单听起来就四个字:增删改查。
但你要是把它当成纯 CRUD,那我只能说:你很勇。🙂

我们先定一个最小可行但“像样”的范围:

  1. 任务模型:标题、描述、状态(TODO/DOING/DONE)、创建时间、更新时间
  2. 列表接口:支持分页、按状态筛选、按时间排序
  3. 写接口:创建、更新状态、删除(软删可选)
  4. 防呆:字段校验(长度、空值)、统一错误返回
  5. 安全边界:先不做复杂登录,但至少要避免“把数据库密码写进前端包里”这种社死行为
  6. 部署形态:Docker 一键起(含数据库),别让读者装环境装到怀疑人生

你看,光是这么一列,工程味儿就出来了——这才是“全栈”的起点:不是功能多,而是思路完整。

2. 项目骨架:Next.js App Router 为什么舒服(也为什么容易踩坑)

Next.js 的 App Router 把页面与服务端能力融合得很自然:Server Components、Layouts、Route Handlers……让你写全栈像在写一棵树。官方也明确它基于 React Server Components,并围绕 app 目录工作。

2.1 初始化项目

npx create-next-app@latest task-board
cd task-board

目录建议(关键点):

task-board/
  app/
    page.tsx
    api/
      tasks/
        route.ts
  lib/
    db.ts
  prisma/
    schema.prisma
  • app/api/**/route.ts:Route Handlers 的标准位置与写法(支持 GET/POST/PUT/PATCH/DELETE 等)。
  • lib/db.ts:数据库 client 的统一出口,避免到处 new client(不然热更新时你会看到连接像野草一样长)

3. 数据建模:Prisma + Postgres,别让“表结构”靠灵感

我见过最离谱的事之一:有人说“字段先随便写,后面再改”。
我当时差点把咖啡喷出来:你以为数据库迁移是改 Word 文档吗?

Prisma 的好处是:模型清晰、迁移可追踪,且官方对 Next.js 的整合路径写得很细。

3.1 安装 Prisma

npm i prisma @prisma/client
npx prisma init

3.2 Prisma Schema(prisma/schema.prisma

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

enum TaskStatus {
  TODO
  DOING
  DONE
}

model Task {
  id        String     @id @default(cuid())
  title     String
  detail    String?
  status    TaskStatus @default(TODO)
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt
}

这段建模其实在“克制”上很重要:

  • idcuid():前端友好、分布式友好
  • updatedAt @updatedAt:别手写更新时间,写一次错一次
  • enum:把状态限定住,避免未来出现 DoingDOINdo_ing 这种“人类迷惑行为”

3.3 迁移与生成 Client

npx prisma migrate dev --name init
npx prisma generate

4. 数据库连接:别把连接池当摆设(也别把密码当装饰)

PostgreSQL 的认证与访问控制不是“高级话题”,它是底裤。
官方文档把认证与 pg_hba.conf 讲得很清楚:你允许谁从哪儿连、以什么身份连、用什么认证方式连,都能配置。

我们在开发环境用 Docker 起 Postgres,并把连接串放进 .env

.env

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/taskboard?schema=public"

注意:

  • 连接串只在服务端用。Next.js 里 Route Handlers 在服务端执行,别把它 import 到 Client Component 里。

5. 统一 Prisma Client:不然热更新会把你逼疯

lib/db.ts

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ["error", "warn"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

这段的意义就一句话:
开发环境热更新别重复创建连接。
你要是见过日志里连接数飙升,你会明白这不是“优化”,这是“救命”。

6. Route Handlers:接口不是“写出来就行”,要“写得像能维护”

Next.js 官方说 Route Handlers 允许你为特定路由写自定义请求处理,并使用 Web Request/Response API。

我们写两个端点:

  • GET /api/tasks:分页 + 筛选
  • POST /api/tasks:创建任务

app/api/tasks/route.ts

import { prisma } from "@/lib/db";

function badRequest(message: string) {
  return Response.json({ ok: false, message }, { status: 400 });
}

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const status = searchParams.get("status"); // TODO/DOING/DONE
  const page = Number(searchParams.get("page") ?? "1");
  const pageSize = Math.min(Number(searchParams.get("pageSize") ?? "10"), 50);

  if (!Number.isFinite(page) || page < 1) return badRequest("page 参数不合法");
  if (!Number.isFinite(pageSize) || pageSize < 1) return badRequest("pageSize 参数不合法");

  const where =
    status && ["TODO", "DOING", "DONE"].includes(status)
      ? { status: status as any }
      : {};

  const [total, items] = await Promise.all([
    prisma.task.count({ where }),
    prisma.task.findMany({
      where,
      orderBy: { createdAt: "desc" },
      skip: (page - 1) * pageSize,
      take: pageSize,
    }),
  ]);

  return Response.json({
    ok: true,
    data: { total, page, pageSize, items },
  });
}

export async function POST(req: Request) {
  const body = await req.json().catch(() => null);
  if (!body) return badRequest("请求体不是合法 JSON");

  const title = String(body.title ?? "").trim();
  const detail = body.detail ? String(body.detail).trim() : null;

  if (!title) return badRequest("title 不能为空");
  if (title.length > 80) return badRequest("title 太长了(<=80)");
  if (detail && detail.length > 500) return badRequest("detail 太长了(<=500)");

  const task = await prisma.task.create({
    data: { title, detail: detail ?? undefined },
  });

  return Response.json({ ok: true, data: task }, { status: 201 });
}

你会发现我写了不少“啰嗦”的校验。
是的,我就是故意的。因为线上最常见的事故之一就是:
“前端传了个奇怪的东西,后端没校验,数据库里塞进了更奇怪的东西,最后报错还找不到是谁干的。”


7. 前端页面:别把数据请求写得像在掷骰子

app/page.tsx(为了简单,这里用客户端请求;如果你想更进一步,可以改为 Server Component 直取数据)

"use client";

import { useEffect, useMemo, useState } from "react";

type Task = {
  id: string;
  title: string;
  detail: string | null;
  status: "TODO" | "DOING" | "DONE";
  createdAt: string;
};

export default function Page() {
  const [status, setStatus] = useState<"" | Task["status"]>("");
  const [title, setTitle] = useState("");
  const [items, setItems] = useState<Task[]>([]);
  const [loading, setLoading] = useState(false);

  const qs = useMemo(() => {
    const p = new URLSearchParams();
    if (status) p.set("status", status);
    p.set("page", "1");
    p.set("pageSize", "10");
    return p.toString();
  }, [status]);

  async function load() {
    setLoading(true);
    try {
      const res = await fetch(`/api/tasks?${qs}`);
      const json = await res.json();
      setItems(json?.data?.items ?? []);
    } finally {
      setLoading(false);
    }
  }

  async function create() {
    const t = title.trim();
    if (!t) return alert("标题别空着呀(我会尴尬)");
    const res = await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: t }),
    });
    if (!res.ok) {
      const json = await res.json().catch(() => ({}));
      return alert(json?.message ?? "创建失败");
    }
    setTitle("");
    await load();
  }

  useEffect(() => {
    load();
  }, [qs]);

  return (
    <main style={{ maxWidth: 720, margin: "40px auto", padding: 16 }}>
      <h1 style={{ fontSize: 28, marginBottom: 12 }}>Task Board</h1>

      <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="写点什么任务…(别写“摸鱼”,我怕你老板看到)"
          style={{ flex: 1, padding: 10 }}
        />
        <button onClick={create} style={{ padding: "10px 14px" }}>
          添加
        </button>
      </div>

      <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
        <button onClick={() => setStatus("")}>全部</button>
        <button onClick={() => setStatus("TODO")}>TODO</button>
        <button onClick={() => setStatus("DOING")}>DOING</button>
        <button onClick={() => setStatus("DONE")}>DONE</button>
      </div>

      {loading ? <p>加载中…我在努力了😮‍💨</p> : null}

      <ul style={{ paddingLeft: 18 }}>
        {items.map((t) => (
          <li key={t.id} style={{ marginBottom: 10 }}>
            <b>[{t.status}]</b> {t.title}
            <div style={{ fontSize: 12, opacity: 0.7 }}>
              {new Date(t.createdAt).toLocaleString()}
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

这页面不花哨,但有三个好处:

  1. 接口写得对不对,一眼就能验证
  2. 前后端联通后,再做复杂 UI 才有意义
  3. 你不会在 CSS 上浪费一下午(真的,CSS 会吃人)

8. 容器化:把“能跑”变成“到哪儿都能跑”

Docker 官方文档把 Dockerfile 的指令、镜像分层、构建概念讲得很系统。

8.1 Dockerfile(生产构建思路:依赖层缓存 + 构建层 + 运行层)

Dockerfile

# 1) deps
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2) build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 3) runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["npm", "run", "start"]

这三段式写法的“人话解释”就是:

  • 依赖装一次,能缓存就缓存(不然每次构建都慢得想砸键盘)
  • 构建阶段做编译
  • 运行阶段尽量干净(不把无关文件塞进去)

8.2 docker-compose:把数据库也带上

docker-compose.yml

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: taskboard
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  web:
    build: .
    environment:
      DATABASE_URL: postgresql://postgres:postgres@db:5432/taskboard?schema=public
    ports:
      - "3000:3000"
    depends_on:
      - db

volumes:
  pgdata:

启动:

docker compose up --build

9. 别急着庆祝:全栈“能上线”才算数(至少把这些坑提前填了)

到这里,项目基本跑起来了。你可能会有点兴奋,我也理解。
但我还是得泼一小瓢冷水:跑起来 ≠ 可靠。

下面这些点,你哪怕只做一半,项目的“抗揍性”都会明显提升:

  1. 统一错误结构:现在我们 badRequest 还算统一,但进一步可以扩展错误码、追踪 ID
  2. 输入校验库:例如 zod(这里我没引入,是为了让读者先看清“该校验什么”)
  3. 数据库索引:当你开始按 statuscreatedAt 查询时,索引就是性能的空气
  4. 并发与事务:多个操作组合时,用事务保证一致性
  5. 鉴权:哪怕是最简 token,也别让“谁都能删库”成为你的项目特色
  6. 配置与密钥管理:不要把 .env 提交到仓库;更不要在客户端代码里读 DATABASE_URL

PostgreSQL 在认证和访问控制上给了非常多选择,包括多种认证方法与 pg_hba.conf 的配置方式。你越早理解它,越不容易把安全当成“上线后再说”。

10. 结语:你要的“全栈”,到底想解决什么问题?

写到这儿我有点感慨。

“全栈”从来不是一个炫技标签,它更像一种态度:

我不只关心界面好不好看,也关心数据怎么存、接口怎么扛、部署怎么稳、出事怎么查。

🧧福利赠与你🧧

  无论你是计算机专业的学生,还是对编程有兴趣的小伙伴,都建议直接毫无顾忌的学习此专栏「滚雪球学SpringBoot」,bug菌郑重承诺,凡是学习此专栏的同学,均能获取到所需的知识和技能,全网最快速入门SpringBoot,就像滚雪球一样,越滚越大, 无边无际,指数级提升。

最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。

同时欢迎大家关注公众号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。

ps:本文涉及所有源代码,均已上传至Gitee:https://gitee.com/bugjun01/SpringBoot-demo 开源,供同学们一对一参考 Gitee传送门https://gitee.com/bugjun01/SpringBoot-demo,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗

🫵 Who am I?

我是 bug菌:

  • 热活跃于 CSDN:https://blog.csdn.net/weixin_43970743 | 掘金:https://juejin.cn/user/695333581765240 | InfoQ:https://www.infoq.cn/profile/4F581734D60B28/publish | 51CTO:https://blog.51cto.com/u_15700751 | 华为云:https://bbs.huaweicloud.com/community/usersnew/id_1582617489455371 | 阿里云:https://developer.aliyun.com/profile/uolxikq5k3gke | 腾讯云:https://cloud.tencent.com/developer/user/10216480/articles 等技术社区;
  • CSDN 博客之星 Top30、华为云多年度十佳博主&卓越贡献奖、掘金多年度人气作者 Top40;
  • 掘金、InfoQ、51CTO 等平台签约及优质作者;
  • 全网粉丝累计 30w+

更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看:https://bbs.csdn.net/topics/612438251 👈️
硬核技术公众号 「猿圈奇妙屋」https://bbs.csdn.net/topics/612438251 期待你的加入,一起进阶、一起打怪升级。

- End -

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。