1. 从技术栈选型到项目落地:为什么是Spring Boot这套组合
1.1 后端开发的"标准答案"为什么偏偏是它们
我刚学完后端基础准备找项目练手的时候,最大的困扰不是不会写代码,而是不知道"一套正经的后端项目到底该有哪些零件"。SSM框架学了,MySQL也会了,Redis知道是缓存,但这些东西怎么咬合到一起,心里完全没底。做苍穹外卖之前,我以为项目开发就是把Controller、Service、Mapper这三层糊出来,写完才发现,真正的复杂度根本不在三层架构里。
这套项目最值得说的,就是它的技术栈长得很"标准",标准到它在面试里几乎等于后端新人的通用语言。
- Spring Boot负责把项目"跑起来"。它内置Tomcat,自带自动配置,省掉了SSM时代一大堆XML配置。我当时最大的感受是:以前配一个Spring MVC要小心类扫描路径、视图解析器、JSON转换器,Spring Boot里全给你安排好了,你只需要专注于业务。
- MyBatis负责数据库操作。选它而不是JPA,核心原因是SQL可控。外卖系统的订单查询、套餐关联查询、分类统计,很多SQL带着动态条件和多表关联,MyBatis的
<if>、<foreach>写起来比JPA的派生查询直观得多,出问题也容易定位。 - MySQL存业务数据。分类、菜品、套餐、订单这些数据高度结构化,用关系型数据库天然合适,事务也好处理——一个下单操作要扣库存、生成订单、清购物车,这些必须在一个事务里。
你可能觉得这套组合太"烂大街"了,但烂大街恰恰说明它是经过大量生产环境验证的。对于后端学习者来说,先用一套有大量资料、大量踩坑记录的技术栈把一个完整项目跑通,比追逐冷门技术重要得多。
1.2 双端业务形态:一套后端同时服务管理端和用户端
苍穹外卖这个项目有一半的价值在于它的业务形态。它不是那种只有一个后台管理界面的练习项目,而是一个真正意义上"双端并行"的系统:管理端是Web页面,给餐厅员工用,负责菜品管理、分类管理、套餐管理、订单接单;用户端是微信小程序,给顾客用,负责浏览菜品、加购物车、下单支付。
这个模型对后端接口设计的影响非常直接。
管理端和用户端虽然共用一个Spring Boot服务,但接口风格和鉴权逻辑完全不同。管理端走的是账号密码登录,登录后发一个JWT,后续所有接口都在请求头里带token;用户端走的是微信登录,用微信的code换openid,服务端识别用户身份后同样签发JWT。两个端的接口都集中在Controller层,但按模块划分得清清楚楚,避免混在一起。
我做完这一层之后才理解一个词——"面向接口设计"。你先想清楚给谁用、什么场景用、返回什么结构,然后才动手写实现。不是先把表建好、把CRUD写完,再回头想接口怎么发。
1.3 Redis、Nginx和JWT为什么成了"隐形刚需"
这个项目让我真正理解了三样东西在实际业务里是怎么用的:
- Redis。一开始我以为Redis就是"把查到的数据放一份到内存里,下次快一点"。实际项目里它的用法是那个范围:首页菜品缓存(高频读)、用户购物车(状态存储)、全平台在线用户状态标记。同一个中间件,在不同业务场景下的数据结构设计完全不一样。
- Nginx。没有Nginx之前,我以为前端打包出来的dist目录随便扔到Tomcat就能跑。实际操作中,Nginx负责托管前端静态页面、把
/api开头的请求反向代理到Spring Boot服务、处理前端页面刷新时的路由问题。它是一个"入口层",也是前后端分离部署的关键。 - JWT。以前做登录都是在Session里存值,换成JWT之后,最大的变化是服务端不用存登录状态了,token自己携带用户信息,验签通过就可以信任。这也就意味着你必须清楚地知道token的过期策略、拦截器放行规则,以及哪些接口是不需要登录就能访问的。
所以这套项目练完,你不只是会写几个CRUD接口,而是把"一个真实后端服务从开发到上线"所需要的零件都摸了一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭建后端骨架:一段被很多人跳过的工程化基础
2.1 包结构和分层:先想清楚再动手
很多新手做项目,上来就从Controller开始写,写到哪算哪,等我第一次拿到苍穹外卖的源码时,才意识到好的包结构本身就在"说话"。
我当时搭的项目包结构大概是这样的:
code复制com.sky
├── controller # 接收请求、参数校验、结果返回
│ ├── admin # 管理端接口
│ └── user # 用户端接口
├── service # 业务逻辑层
├── mapper # 数据库访问层,对应MyBatis接口
├── entity # 数据库表对应的实体类
├── dto # 接收前端参数的传输对象
├── vo # 返回给前端的数据对象
├── config # 配置类(Redis配置、WebMvc配置、拦截器配置)
├── interceptor # JWT拦截器
├── constant # 常量定义
├── common # 公共类:统一返回结果、异常处理、工具类
├── handler # 全局异常处理器
└── task # 定时任务
这个分层的核心思想是:每一层只做自己该做的事。Controller不写SQL,Service不直接操作HttpServletRequest,Mapper方法只关心数据库交互。这样做的直接好处是排查问题非常快:接口返回不对,先看Controller层参数收没收到;业务结果不对,去Service层看逻辑;数据查得不对,去Mapper看SQL。
如果你也打算拿这个项目练手,我建议动手前先花半天把包结构搭好、把各层之间的调用约定想清楚。磨刀不误砍柴工,这句话在后端项目里是100%成立的。
2.2 多环境配置:dev和prod切换到底在切什么
第一次接触多环境配置时,我觉得不就是几个配置文件来回改嘛。真正做部署时才意识到,如果你手动改配置,迟早会因为"哦这里忘了改"而出事故。
Spring Boot的application.yml配合application-dev.yml和application-prod.yml,通过spring.profiles.active激活对应环境,算是标准姿势。我在项目里是这样拆的:
yaml复制# application.yml
spring:
profiles:
active: dev # 切换环境时只改这里
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
dev环境连本地MySQL、本地Redis,日志级别是DEBUG,可以看到SQL参数和调试信息。prod环境连服务器上的数据库、Redis,日志级别是INFO,接口地址是线上IP或域名。
这里最容易踩的坑有两个:一是数据库连接串写错,dev和prod的密码用户名不同,切换环境没同步改,导致服务起来后连不上库;二是时区问题。MySQL连接串我建议加serverTimezone=Asia/Shanghai,不然本地时间可能差8小时,订单表的创建时间全错位。这种问题不会让你的项目启动失败,但会让数据看上去全是乱的。
2.3 统一返回结果和全局异常:被低估的"地基代码"
我以前写代码,Controller返回什么全看心情:有的返回Map,有的直接返回Entity,有的返回String。这样的接口对接起来非常痛苦,前端每接一个接口都要问"返回结构是什么"。
后来我把统一返回结构定成这样的格式:
java复制public class Result<T> {
private Integer code; // 1成功,0失败
private String msg; // 提示信息
private T data; // 业务数据
private Result() {}
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.code = 1;
result.msg = "成功";
return result;
}
public static <T> Result<T> success(T object) {
Result<T> result = new Result<>();
result.code = 1;
result.msg = "成功";
result.data = object;
return result;
}
public static <T> Result<T> error(String msg) {
Result<T> result = new Result<>();
result.code = 0;
result.msg = msg;
return result;
}
}
与之配套的是全局异常处理器。我在项目里定义了业务异常BusinessException,凡是业务上可以预见的错误,比如"菜品分类下存在菜品,无法删除""订单状态不允许接单",都直接抛出这个异常。全局异常处理器统一捕获,转换成Result.error()返回。这样Controller就变得非常干净,不需要到处写try-catch。
你没看错,这两块代码不复杂,但它们决定了你后续几十个接口的风格是否一致。地基没打好,后面每写一个接口都等于在加固一栋歪楼。
3. 核心业务模块拆解:从菜品上架到订单履约的全链路
3.1 管理端的"增删改查"为什么也需要认真设计
很多人提到苍穹外卖的管理端,第一反应是"不就一堆增删改查吗"。如果只做单表CRUD,那确实没什么含量。但当你开始处理分类、菜品、套餐之间的关联关系时,CRUD的复杂度瞬间就上来了。
我拿"菜品管理"这个模块举例。菜品有一个status字段,表示起售或停售。如果你只是把它当一个普通字段去更新,那就错过了一个关键业务点:菜品在套餐里被引用了怎么办?如果一个套餐包含多个菜品,你停售了其中一个菜,那这个套餐还能正常售卖吗?
苍穹外卖的做法是:起售套餐时,需要判断套餐内所有菜品是否都在起售状态;如果有停售菜品,整个套餐不能起售。这就倒逼你在Service层写一套关联校验逻辑,而不是简单地把status字段改成1。类似的还有删除分类时检查该分类下是否还有菜品,有的话就不能删。
这种设计意识很宝贵。它教会我一个道理:数据库表之间的一对多、多对多关系,最终会体现为业务规则,而业务规则必须在接口层强制执行,不能指望前端"别点那个按钮"。
管理端还有一块容易被忽略的部分是图片上传。菜品图片上传到OSS或本地存储,返回一个可访问的URL,然后随菜品信息一起提交。我第一次做的时候没考虑到底层存储的切换,直接把图片路径写死在本地,后面部署到服务器上才发现图片全裂了。建议把上传接口抽象出一个"存储服务"的概念,本地存储和OSS存储可以互相替换,这样后期维护会顺很多。
3.2 用户端:微信登录、购物车和下单的事务边界
用户端的第一个核心链路是微信登录。流程是:小程序端调用wx.login()拿到临时code,后端拿着code调用微信接口换取openid,如果这是第一次登录,就自动注册一个新用户,然后生成JWT返回给小程序。
我在这里学到的一个细节是:换到openid之后,不能拿它直接当数据库主键或用户编号。openid属于敏感信息,应该把用户ID作为主键,openid单独存一列。JWT里面放的是用户ID,而不是openid。这样做既安全,又能在后续业务里统一用userId关联所有数据。
购物车模块是Redis用得最实践的地方。购物车数据不需要持久化到MySQL里,用Redis存就对了。我用的是Hash结构:key = cart:用户ID,field = 菜品ID/套餐ID,value = 数量。加购、修改数量、清空购物车,都通过Redis的Hash操作完成,效率非常高,也不需要写一堆Mapper方法。
下单接口是整个项目里事务最重的接口之一。一个完整的下单流程涉及:
- 查询地址簿校验配送地址
- 查询购物车数据
- 校验菜品是否还有库存
- 计算订单金额(必须用BigDecimal,不能用double算钱)
- 插入订单主表和订单明细表
- 清空购物车
这串操作必须放在一个事务里,任何一个步骤失败,前面的操作都要回滚。我在做这个接口时,特意在Service方法上加了@Transactional,因为订单数据一旦脏了,后续退款、对账全是麻烦。
3.3 Redis、WebSocket和定时任务:让项目"活"起来的三个配角
这个项目对后端学习者最友好的一点,就是它提供了大量"中间件在真实业务中的标准用法",而不是让你为了用而用。我总结了三个配角:
- Redis缓存首页菜品。用户端首页需要展示分类和对应菜品,这个数据几乎所有用户都会访问,属于典型的高频读、低频写。查询逻辑是"先查缓存,缓存没有则查数据库,然后回填缓存",同时设置过期时间。我手动模拟并发访问验证了一下,效果显著。
- WebSocket来单提醒。用户下单后,管理端界面能够实时弹出新订单提示,这里用的是WebSocket。后端在订单状态变更时,通过WebSocket向管理端推送消息。以前我以为WebSocket只能用来做聊天室,做完这功能发现它做业务消息推送也很顺手。
- 定时任务处理超时订单。用户下单15分钟未支付,订单状态要自动变为"已取消"。这个用的是Spring Task的定时任务,每隔固定时间去扫描超时未支付订单并更新状态。
这三个功能单拎出来都不难,但它们组合在一起,就把整个项目的"实时感"和"业务闭环感"拉起来了。这对我理解后端项目的思维方式影响很大:一个接口不只是返回数据,它可能还要触发通知、更新缓存、启动定时逻辑。
4. 上线部署与排错:项目从IDEA跑到Linux服务器
4.1 环境安装:MySQL、Redis、Nginx一次装齐
做完开发不等于做完项目,真正把项目放到Linux服务器上跑起来,才是后端实战的"成人礼"。我第一次部署时用的是阿里云服务器,CentOS 7,系统自带的是yum包管理器。部署前要装三样东西:MySQL、Redis、Nginx。
MySQL装完必须先改字符集。我在/etc/my.cnf里加了:
ini复制[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci
不然后端插入中文会变成一串问号。Redis装完要设置开机自启,然后改动一下requirepass设置密码,因为服务器上的Redis默认没有密码,容易被人扫描爆破。
Nginx装起来很简单,关键在配置。默认的/etc/nginx/nginx.conf已经提供了一个server块,你只需要在server里替换成自己的项目配置就行。这三样装完之后,还要检查防火墙端口:MySQL的3306、Redis的6379、Nginx的80。生产环境MySQL和Redis的端口建议不对外开放,只允许内网访问;对外只需要开放80端口。
4.2 前后端分离下的Nginx配置:静态资源与接口转发
部署的关键配置如下:
nginx复制server {
listen 80;
server_name your-domain.com;
# 前端页面(Vue打包后的dist目录)
location / {
root /usr/local/app/dist;
index index.html;
try_files $uri $uri/ /index.html; # 解决前端路由刷新404
}
# 后端接口反向代理
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
这段配置让我第一次理解了try_files的巧妙之处。前端Vue项目使用的是history路由,如果用户直接访问/order这样的地址,Nginx默认会去找/usr/local/app/dist/order这个文件,结果当然404。加了try_files $uri $uri/ /index.html之后,所有找不到的路径都回退到index.html,前端路由就能正确接管。
/api/开头的请求全部转发到后端的8080端口,这是前后端分离部署的标准姿势。但这里有个大坑:dev环境里前端可以直接请求http://localhost:8080,不需要走Nginx;到了prod环境,前端请求的是/api路径,走Nginx转发。所以我在前端项目里把所有请求前缀统一配成了/api,然后靠Nginx做了一层"去前缀转发"到后端,否则后端接口路径对不上,会真的404。
4.3 部署后容易遇到的三类问题:跨域、时区、序列化
部署完成后才是问题高发期,我复盘自己踩过的坑,最典型的有三类。
一是跨域。前后端分离项目里,前端域名是http://your-domain.com,小程序和H5请求/api接口时,如果后端没有开启CORS,浏览器会直接拦截响应。我在项目里写了CorsConfig,创建了一个CorsFilter,允许指定来源和请求头。开发阶段可以宽松点允许所有来源,但上线后建议只允许自己的域名和微信小程序域名,不然别人随便一个页面就能调你的接口。
二是时区。这个前面提过,但部署时体会更明显。服务器端和数据库端的时区如果不一样,订单创建时间就会比实际时间慢8小时。我的解决方式是:数据库连接串带serverTimezone=Asia/Shanghai,Jackson配置里加time-zone: GMT+8,同时Linux服务器的系统时区也要确认是Asia/Shanghai。
三是LocalDateTime的序列化。Spring Boot默认用Jackson序列化时间,如果后端返回的LocalDateTime字段没有被格式化,前端拿到的是一个大数组,非常不友好。我在application.yml里配置了全局日期格式,又在实体类字段上加@JsonFormat做了双重保险。
5. 项目复盘:做完苍穹外卖,我对后端开发的理解变了什么
5.1 从"会写方法"到"会设计接口":Entity、DTO、VO的边界
在写这个项目之前,我对Entity、DTO、VO的理解就是"三张看起来差不多的类"。项目做了一半,我才开始体会到它们各自存在的意义。
- Entity对应数据库表,字段和表结构保持一致,它只属于持久层。
- DTO是接收前端参数的载体。前端传过来的参数,往往跟Entity字段不一一对应,比如修改菜品时既要传菜品基本信息,又要传关联的分类ID,直接拿Entity接收很容易把不该暴露的字段暴露出去。
- VO是返回给前端的数据对象。接口要返回菜品列表并带上分类名称,但菜品的实体类里只有分类ID,这时候用VO拼装前端需要的数据结构,是最清晰的做法。
我在项目里认真贯彻了这个原则之后,最大的感受是:Controller层变得"很有自尊"。它不关心数据库表长什么样,不关心前端最终要什么,它只负责调度Service和进行参数与返回结构的转换。
5.2 并发、缓存一致性、事务:第一次被业务逼着考虑"边界"
这个项目让我第一次直面"并发"这个问题,不是因为系统真有多大流量,而是因为业务逻辑天然就涉及并发。
最典型的是秒杀式场景:两个用户同时下单最后一个菜品。我只写了"查询库存 > 库存减一 > 更新库存",但两个请求并发执行时,它们可能同时读到库存为1,然后各自减一,结果库存变成0却卖出了两单。解决方式是在更新库存的SQL里加上条件判断:
sql复制UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0;
用WHERE stock > 0保证超卖不会发生。这种"乐观锁SQL"的思路比Java代码里加锁更简单也更可靠。
另一个教学时刻是缓存一致性。我把首页菜品缓存到Redis,但管理端更新了菜品信息之后,用户端看到的还是旧数据。最简单的处理是"更新数据库时同时删除对应缓存",下次查的时候再回填。虽然极端情况下依然有窗口期,但在课程项目的量级下,这个方案足够稳定。这个思考过程非常关键,因为它让你意识到:缓存不是银弹,它的每一次读取,背后都有一个"什么时候失效"的决策。
5.3 项目在简历和面试里应该怎么讲
项目做完之后,紧接着就是面试。苍穹外卖出现在简历上没有什么问题,关键在于你怎么讲。
我的建议是不要讲功能列表,而是讲"决策点"。比如面试官问"这个项目里遇到过什么困难",你不要回答"CORS配置了好久",而要回答"下单接口需要在一个事务里完成多表写入,同时还要考虑并发超卖的边界条件,我最终通过数据库乐观锁和事务控制解决"。这种回答体现的是你在做决策时的思考链,而不是踩坑流水账。
如果面试官问"Redis在你这个项目里用来做什么",不要只说"缓存菜品",可以往深了说:购物车为什么用Hash而不是String?缓存更新为什么用删除缓存而不是更新缓存?超时订单删除是怎么用定时任务实现的?这些问题我都能在项目里找到对应的实践依据。
总结下来,这个项目真正的价值,不在于它让你掌握了一个外卖系统,而在于它让你以最小的成本,体验了一遍"从零到一"的过程——从需求分析,到技术选型,到分层编码,到部署上线,再到复盘迭代。这个过程走了一遍,后面再看别的复杂系统,你至少知道它是从哪块地上盖起来的了。
