前阵子整理项目仓库的时候,翻出了当时做的这个校园快递代取系统,Java + Vue + Spring Boot这套组合,前前后后折腾了差不多三个月,从需求梳理、数据库设计到前后端联调,踩坑无数但也收获很大。当时做这个项目的初衷特别朴素:学校驿站离宿舍太远,高峰期排队老半天,很多同学愿意出几块钱找人帮忙代取快递,但一直缺一个能把“发单”和“接单”两边匹配起来的小平台。所以我就决定自己动手写一个——后端用Spring Boot提供接口,前端用Vue做页面,做成一套能真正跑通的前后端分离项目。
写这篇文章的目的,不只是给自己的项目做个复盘,更想给准备做类似毕设或者打算练全栈的朋友一条可以直接照抄的路线:核心功能怎么拆、订单状态机怎么设计、JWT鉴权怎么做、Vue页面怎么组织、前后端联调遇到问题怎么排查。我会把我在开发过程中遇到的那些奇奇怪怪的坑也一并写出来,这些都是常规文档里看不到的实操经验。
1. 项目整体设计与技术选型思路
1.1 为什么选Spring Boot + Vue?聊聊选型背后的思考
做这个项目之前,我其实犹豫过要不要用传统方案,比如JSP加Servlet,或者Spring MVC配合Thymeleaf模板引擎渲染页面。但后来想了想,现在的开发环境里前后端分离已经是大趋势,工作以后接触的项目也基本都是这种模式。再加上“java+vue+springboot”这个组合的生态太成熟了,遇到问题随便一搜就有解决方案,对新手来说容错率很高。所以我最后敲定的方案是:后端用Spring Boot 2.x加MyBatis Plus操作MySQL,前端用Vue 2加Element UI组件库,通过RESTful接口完成数据交互。
这个选型背后其实有一个很重要的考虑:项目复杂度要匹配技术方案的复杂度。校园快递代取这种场景,并发量不高,业务逻辑也不算复杂,核心就是解决信息撮合和订单流转两个问题。如果这时候硬上一套微服务、消息队列、分布式事务,反而是自己给自己挖坑。技术栈的选择永远要服务于业务本身,能用简单方案解决的事情就不要人为复杂化,这是我在这个项目里体会最深的一点。
另外说一下为什么选JWT而不是Shiro或者Session。因为前后端分离之后,后端不能依赖Cookie自动携带Session,如果还沿用传统的Session鉴权方式,跨域场景下处理起来会比较麻烦。JWT的思路是把用户信息加密放进Token里,前端每次请求把Token放在请求头里带上,后端统一校验。这样后端服务天然就是无状态的,以后想横向扩展也方便。虽然JWT有token无法主动失效的问题,但在这个项目规模下完全够用。
1.2 系统角色与订单核心流程梳理
这个系统涉及的账号角色主要有三类:发单人、接单人和系统管理员。实际业务中,同一个学生既可以是发单人也可以是接单人,这个设计很关键,因为如果系统一开始就把用户角色写死成“要么发单、要么接单”,那整个平台的供需流动性会差很多。
我梳理的核心业务流程是这样的:学生登录后可以发布一条代取订单,填写取件码、快递点位置、送达宿舍楼、期望送达时间还有感谢费金额之类的信息。订单发布后进入订单大厅,其他学生可以看到所有“待接单”状态的订单,觉得合适的就直接抢单。接单后订单变成“配送中”,接单人需要去驿站取件然后送到指定位置,最终发单人确认收货,系统把感谢费结算到接单人的账户余额里。最后双方还可以互相评价,评价数据沉淀下来用于展示用户信用。
这个流程看起来简单,但做起来有两个难点:一是订单状态变化的约束条件特别多,不能出现两个人同时接同一单,也不能出现订单还在配送中就被人取消了;二是“抢单”这个动作天然是并发操作,如果不做控制,同一时间有很多人点击接单,就很可能导致数据错乱。这两个问题我在后面会单独展开讲。
1.3 数据库设计方案与核心表结构
数据库设计我前后改了好几版,一开始把用户表、订单表、余额表都揉在一张表里,后来发现查询逻辑越来越乱,才又重新拆分。最终的核心表大概是这样的:用户表user保存账号密码、昵称、手机号、学号、用户角色和信用分;订单表orders保存发单人ID、接单人ID、快递点、取件码、送达地址、状态、感谢费金额、发布时间和各个状态变更的时间点;钱包表wallet保存余额和累计收入;评价表comment保存订单ID、评价人ID、被评价人ID、评分和内容。
关于订单表,我想重点说一个经验:一定要把“取件码”和“快递点”这些信息设计成冗余字段存在订单表里,而不是通过外键关联一张快递单表。因为订单生成之后,这些信息应该被固定下来,如果关联到另一张表,一旦原数据被修改,历史订单就会出问题。另外,订单状态这个字段我建议用整数类型存储,用状态枚举类在代码层面对应,这样查询可以用索引,可读性也不差。
索引设计方面,orders表上我加了联合索引(status, create_time),因为订单大厅的核心查询就是按照状态过滤再按时间排序。user表的手机号字段加唯一索引,登录的时候查询会很快。还有一个细节是金额字段千万不要用float或者double,精度问题会在结算的时候坑死你,我用的Decimal,精确到小数点后两位。
2. 后端核心功能开发与实现细节
2.1 Spring Boot项目结构搭建与自动装配机制
Spring Boot项目我是在Idea里通过Spring Initializr直接生成的,这里有个坑:创建时选择的Spring Boot版本不要太激进。我当时图新鲜选了3.0以上的版本,后来发现很多第三方依赖还没有同步适配,MyBatis Plus和某些工具库的兼容版本对不上,折腾了很久。后来换回2.7.x版本,所有问题都消失了。所以如果你做的是一个需要稳定运行的业务系统,而不是技术调研项目,建议选择相对成熟稳定的版本系列。
项目结构我采用的是经典的分层架构:controller、service、mapper、entity、config、common这几个包。Controller层只负责参数接收和结果返回,不写业务逻辑;Service层处理具体的业务规则;Mapper层通过MyBatis Plus操作数据库。另外单独抽了一个common包,用来存放统一返回结果类、异常处理器、JWT工具类和常量定义,这样代码结构很清晰,后面排查问题也方便。
第一次用Spring Boot的人可能会好奇:为什么我没有写一堆XML配置,项目就能跑起来?这里就要提到自动装配机制了。简单说,Spring Boot通过@SpringBootApplication注解开启了自动配置,它会在项目启动时扫描classpath下的依赖,根据依赖jar包自动创建对应的Bean。比如你引入了spring-boot-starter-web,它就自动帮你配置好内嵌的Tomcat;引入了spring-boot-starter-data-redis,就自动帮你创建RedisTemplate。这种“约定大于配置”的设计,大幅降低了项目搭建的成本。当然,自动装配的背后是通过@EnableAutoConfiguration结合spring.factories文件里的配置类实现的,这个在面试里属于高频考点,后面我会列一个问题自查表。
2.2 登录鉴权模块:JWT生成与拦截器校验
登录功能是所有模块的基础。密码的存储我用了BCrypt加密,没用MD5,因为MD5彩虹表太容易破解了。登录成功后,后端会生成一个JWT Token并返回给前端,浏览器端把Token存在本地存储里,之后每次请求都放到请求头的Authorization字段中。
JWT的生成代码不算复杂,我用的jjwt这个库,核心逻辑就是用用户的ID和角色信息生成一个带过期时间的Token。这里面有一个设计细节:不要在Token里放入太多敏感信息,只要放用户ID和角色就够了,手机号之类的信息应该等后端拿到用户ID后再查数据库获取。
拦截器的实现思路就是写一个HandlerInterceptor,在preHandle方法里从请求头取出Token,然后解析校验,如果解析失败就返回401状态码,提示用户重新登录。解析成功之后,把用户ID放进ThreadLocal里边,后续的Service层就能直接获取当前登录用户信息,而不需要每个接口都传一个userId参数。这里要注意的是,一定要在afterCompletion方法里调用ThreadLocal的remove方法,不然线程池复用线程时会串数据。
2.3 订单状态机设计:一个订单从发布到完成的状态流转
订单模块是系统的核心,状态机的设计决定了整个业务逻辑是否清晰。我把订单状态设定为:0待接单、1配送中、2已完成、3已取消、4异常中。状态流转规则如下:
- 发单人发布订单后,状态为待接单;
- 接单人抢单成功后,状态变为配送中;
- 接单人点击“确认送达”,状态保持不变,等待发单人确认;
- 发单人确认收货,状态变为已完成,同时触发余额结算;
- 发布后超过一定时间没人接单,发单人可主动取消,状态变为已取消;
- 配送过程中如果出现取件码错误、联系不上发单人等情况,任何一方可发起申诉,状态变为异常中,由管理员介入处理。
这个状态机看起来不复杂,但我刚开始实现的时候还是踩了坑。比如我一开始在Service里到处执行“判断当前状态、然后直接更新为下一状态”的逻辑,结果代码写得很分散,经常出现某个接口漏了状态检查,导致状态乱跳。后来我改用了一个统一的订单状态变更方法,先通过乐观锁更新数据库,根据更新行数决定是否成功,这样既保证了状态校验逻辑集中在同一个地方,也解决了并发问题。
接单这个操作的并发控制,我前后试过两种方案。第一种是用数据库的乐观锁,在orders表加一个version字段,执行更新时带上where version = #{oldVersion},如果更新行数为0说明冲突,就让用户重新试一次。第二种是用Redis的分布式锁,抢单前先尝试获取锁,拿到锁才执行订单更新。最终我同时用了这两种方案:先走Redis锁挡住大部分并发请求,再用乐观锁兜底保证数据一致性。实测下来效果很稳。
2.4 Redis集成:缓存热点数据和使用场景
这个项目里Redis主要承担了两个作用:第一个是缓存订单大厅的数据,第二个是处理分布式锁。
订单大厅是访问频率最高的接口,用户一进来就要看到最新的待接单列表。但这个列表的查询涉及订单表分页、用户昵称关联等操作,每次都查MySQL的话压力不小。我的做法是把首页第一页的数据缓存到Redis里,key设计成order:list:page:1,过期时间设为30秒。用户请求时先查缓存,缓存没有再查数据库并回填。由于订单发布频率并不高,30秒的延迟对用户感知几乎为零,但数据库的压力明显降下来了。这里有一个小细节:缓存数据一定要做序列化,建议直接用字符串或者JSON格式存储,不要用JDK序列化,否则后面数据迁移和排查都不方便。
分布式锁在抢单业务里的用法,我简单说一下。抢单接口里,我先通过StringRedisTemplate的setIfAbsent方法往Redis里写一个临时key,key的命名比如order:lock:订单ID,value写当前用户ID,同时设置过期时间3秒。只有写入成功的用户才继续执行订单状态更新逻辑,其他用户直接提示“手慢了,订单已被抢走”。可能有人会问万一并发量真有那么大,锁过期了怎么办,这个场景在校园代取这种低频业务里基本不会出现,真到了那种并发级别,这个项目本身的设计就要推倒重来了。
3. 前端Vue页面实现与前后端联调经验
3.1 Vue项目搭建与环境配置注意事项
前端我用的是Vue 2.6加Element UI,因为网上资料最丰富,遇到编译报错基本都能找到现成的答案。环境准备上,Node.js的版本很有讲究,Vue 2项目建议使用Node 14到16之间的版本,太新的Node版本在安装依赖的时候经常会有兼容问题。我记得有一次同事用Node 18装了依赖之后,项目直接起不来,报了一堆openssl相关的错误,最后是把Node切换到16才解决的。
创建项目的命令是vue create school-express,在交互式配置里选择了Router、Vuex和axios相关的选项。依赖安装的时候也踩过坑,npm install经常跑到一半卡住,后来我直接用淘宝镜像源,这个问题就消失了。
这里顺便提一下“vue安装依赖”这个搜索热词。很多新手装依赖失败,第一反应是再来一次,但我建议先看一下报错信息。常见的错误无非三类:一是网络问题,换镜像源;二是Node版本不兼容,切换Node版本;三是某些依赖包已经被废弃导致安装失败,那就需要检查package.json里的依赖版本,手动指定一个稳定版本。搞清楚原因再动手,比盲目重试要高效得多。
3.2 前端核心页面拆解与路由设计
前端页面我按功能拆分成了几个大块:登录注册页、订单大厅首页、发布订单页、个人中心页和后台管理页。其中订单大厅是用户最常访问的页面,展示所有待接单的订单卡片,支持按照快递点或宿舍楼筛选,还接了分页组件。
路由设计上,我用了vue-router的懒加载模式,通过动态import的方式导入组件,这样前端打包出来的代码会按页面拆分成多个小块,首屏加载速度明显更快。路由守卫是另一个重要的点,我在全局前置守卫里判断用户是否已登录,如果没有登录且访问的是需要登录的页面,就重定向到登录页。
订单大厅里有一个“抢单”按钮,点击后调用后端接口。这里有一个体验细节:因为抢单的结果是异步返回的,用户点击之后前端要进入“提交中”状态,禁用按钮防止重复提交,拿到后端返回结果后再恢复。不然用户连续点好几次,虽然后端有幂等控制不会出问题,但前端看起来会非常不专业。
3.3 Vuex状态管理与axios请求封装
项目里的用户信息和登录状态,我放在Vuex中管理。Vuex的state里保存一个user对象和一个token,登录成功后dispatch一个action把这两项写进去,同时持久化到localStorage。页面刷新的时候,在应用初始化阶段检查localStorage里有没有token,有的话就恢复登录状态。
axios封装上,我新建了一个request.js文件,创建了一个axios实例,设置baseURL为“/api”,并配置了请求拦截器和响应拦截器。请求拦截器里做了一件事:从Vuex或者localStorage拿到token,添加到请求头的Authorization字段。响应拦截器里做的事情比较关键:如果后端返回的状态码是401,说明token过期或者未登录,这时候要清除本地登录状态并跳转到登录页;如果业务状态码不是200,就弹出一个错误提示消息。
统一处理的好处是,业务代码里不需要每个接口都写try-catch,也不用在每次请求时手动加token。项目里的请求代码会变得非常简洁,比如“获取订单列表”这个接口,我只需要写一行api调用,然后处理返回的数据就可以了。
3.4 前后端跨域问题处理与联调方案
前后端分离的项目,在本地联调的时候几乎一定会遇到跨域问题。我当时的开发环境是后端跑在8080端口,前端devServer跑在8081端口,浏览器的同源策略直接拦截了跨域请求。
解决跨域我当时试了三种方案。第一种是在后端写一个CorsConfig配置类,实现WebMvcConfigurer接口,配置允许跨域的来源、请求头和请求方法。第二种是在前端的vue.config.js里配置devServer.proxy,把“/api”前缀的请求都代理到后端地址,这样浏览器认为请求是同源的,跨域问题自然就没了。第三种是后面前端打包后部署到Nginx时,在Nginx配置里做反向代理,同样是以“/api”开头转发到后端端口。
我最终采用的是前端proxy方案和Nginx代理方案,因为这两种方式可以避免在后端代码里写死允许的跨域来源,更加灵活安全。需要注意的一点是,如果同时开了后端跨域配置和前端proxy,反而可能导致请求被代理转发后又经过一层跨域处理,逻辑变得混乱,所以建议只选一种方式,不要混用。
4. 常见问题与排查技巧实录
4.1 Spring Boot版本太高引发的依赖兼容问题
这个坑我必须放在第一位说。我第一次创建项目的时候选了当时最新的Spring Boot版本,结果引入MyBatis Plus之后,项目启动就报错,提示找不到某个类的方法。排查了很久发现是MyBatis Plus的某个老版本和Spring Boot新版本在自动配置类上有冲突。
解决方案很简单:把Spring Boot的版本降级到2.7.x,同时把MyBatis Plus的版本升级到3.5.x以上,问题就消失了。我建议在做这种常规业务项目的时候,不要追求新版本,而是选择已经经过大规模验证的稳定组合。在Maven的pom.xml里锁定好版本之后,后续所有依赖的引入都以这个版本为基础来搜索兼容版本,这样整个依赖树会比较稳定。
4.2 Vue打包后布局异常与上线部署问题
前端在开发模式跑得好好的,执行npm run build打包,部署到Nginx之后,页面出现了两个让人头疼的问题。第一个是刷新页面后出现404,这是因为前端路由用了history模式,刷新时Nginx找不到对应的真实文件路径。解决办法是在Nginx配置里加一个try_files配置,把所有请求都重写到index.html。
第二个问题是打包后图片和静态资源的路径不对。开发模式下资源路径是相对于根路径的,但部署到服务器子目录时就会出问题。解决办法是在vue.config.js里设置publicPath为“./”相对路径,这样打包出来的资源路径就会以相对路径加载。还有一个容易被忽略的点是,如果用的图片路径是写在CSS里的,也要检查一下编译后的路径是否正确。这类问题排查的时候,打开浏览器开发者工具看控制台里的资源加载报错信息,比盲目猜测要快得多。
4.3 多线程等待与异步任务处理经验
项目中有个导出功能需要从一个接口同时查询多个维度的统计数据,如果串行查询的话,每个维度都要访问一次数据库,耗时叠加就很明显。我当时用CompletableFuture对几个查询任务做了并行处理,最后用allOf等待所有任务完成后再统一返回结果。这个其实就是热词“java线程等待都完成”里经常提到的那种场景。
使用CompletableFuture有一个关键点:一定要自己指定线程池,不要用默认的ForkJoinPool.commonPool。因为默认线程池的并行数和CPU核数相关,而且整个应用共用一套,一旦某个任务阻塞了,其他所有并行任务都会被拖累。我当时定义了一个核心线程数为10的线程池,专门给这种异步任务使用,配合Future超时控制,整体性能提升非常明显。
代码大致是这样的模式:先用thenApplyAsync定义每个子任务的逻辑,再用CompletableFuture.allOf(...).get(5, TimeUnit.SECONDS)等待全部完成,最后从每个future里取出结果组装。注意get一定要传超时时间,否则如果某个子任务异常卡住了,接口会一直不返回,给用户的体验就是页面死掉了。
4.4 面试高频考点自查:从项目里提炼技术点
做完这个项目之后,我发现自己对很多知识点的理解从“背概念”变成了“真明白”。这里我把相关的面试高频考点整理成一个自查清单,如果你之后要去面试Java开发岗,可以照着这个清单过一遍:
| 考点 | 项目中的对应点 | 回答思路 |
|---|---|---|
| Spring Boot自动装配原理 | 项目能自动运行的原因 | 从@SpringBootApplication说起,讲@EnableAutoConfiguration加载自动配置类的过程 |
| 动态代理 | MyBatis的Mapper代理实现 | 讲JDK动态代理和CGLIB的区别,说清楚MyBatis是通过代理生成Mapper实现类的 |
| MySQL索引优化 | 订单表联合索引设计 | 以status+create_time为例说明最左前缀原则 |
| 并发控制 | 抢单接口的Redis锁和乐观锁 | 讲清楚为什么不能只靠代码加锁,分布式场景下怎么办 |
| Redis缓存穿透与击穿 | 订单列表缓存设计 | 结合实际场景讲缓存策略和过期时间设置 |
| 冒泡排序 | 面试常考手写题 | 虽然业务没用上,但手写冒泡排序要闭眼能写出来 |
这里想多说一句:做项目最大的价值不是让你在简历上多一行字,而是让你在每个技术点上有真实的使用场景。比如动态代理,如果你只是背概念,面试官一问“你项目里哪里用了”,你答不上来等于白背。而我在做MyBatis Plus集成的时候,就因为好奇Mapper接口为什么不用写实现类就能直接用,去翻了源码研究了一遍动态代理,这才真正搞懂了。
5. 个人实操体会与扩展建议
5.1 做完这个项目我最想分享的几个心得
第一个心得是:数据库设计一定要先想清楚再动手,不然后面全是坑。我第一版数据库设计特别草率,订单表和用户表之间没有clear的索引策略,写到查询接口的时候才发现很多SQL没法走索引,又回头改表结构。做项目要养成一个习惯:写代码之前先在纸上画出核心表结构和字段关系,互相评审一轮,再开始建表。
第二个心得是:前后端联调阶段要有耐心,遇到问题先定位是哪一端的问题,不要盲目改代码。我总结了一个排查顺序:先看后端接口返回的数据对不对,用Postman直接调接口验证;再看前端请求有没有发出去,打开浏览器Network面板看请求参数和响应;最后才去看代码逻辑。很多新手一遇到问题就两边一起改,结果反而把问题改没了也不知道为什么。
第三个心得是:不要把项目做得太“完美主义”。我当时一度纠结于要不要加上消息推送、实时聊天这些功能,后来冷静想了想,这些功能对核心业务流程没有本质提升,反而会增加大量排查时间。先完成核心闭环,再考虑锦上添花,这个顺序一定不要搞反。
5.2 这个项目后续还可以怎么扩展
做完这个系统之后,我其实又想了一些可以继续扩展的方向。比如可以接入微信小程序,因为学生群体用小程序比用网页更方便,前端通过uni-app复用现有Vue代码,成本相对可控。还可以在订单状态变化时增加通知机制,比如当接单人点击“确认送达”时,给发单人发送一条微信订阅消息提醒,这样用户不必一直盯着页面看。再往后如果订单量上来了,可以考虑引入配送路线规划的简单策略,相同宿舍楼的订单可以合并分配,降低接单人的跑腿成本。
另外在技术层面也有不少可以打磨的地方:比如把文件上传功能加上,让用户发布订单时可以上传物品照片;比如把权限管理升级一点,增加更细粒度的角色权限控制;再比如把日志链路跟踪做好,接一个简单的操作日志模块,方便管理员后台查看关键操作记录。这些都是很好的练手方向,也都能写进项目介绍里。
最后分享一个小技巧,也是我做这个项目踩了很多次坑之后总结出来的:所有涉及订单状态变更的接口,务必要加一个统一的审计字段,记录操作人和操作时间。前期你可能觉得多余,但一旦系统上线后发现某个订单状态异常,这个字段就是你排查问题的最重要线索。这个习惯我一直保留到了现在,后面做的所有项目都受益于此。