1. 前后端分离:改造的核心思路与第一步规划
1.1 分离之前遇到的真实问题
“前后端分离”这四个字,很多刚接触全栈开发的同事理解成“我不用 JSP 了”或者“我把 JS 单独放一个文件”,这个理解太浅了。真正意义上的前后端分离,是让后端不再关心页面长什么样,前端也不再关心数据是怎么查出来的,双方只通过接口契约协作。我之前维护过一个用模板渲染的老项目,改一个按钮就要动后端页面、重启应用。最难受的是前后端没法并行开发,前端要等后端把页面模板写完才能套样式,一套迭代下来周期被拉得很长。
这次做“知识回顾”,其实就是把类似这样的老模块拆成 Vue 3 + Spring Boot 的前后端分离结构。核心目标很简单:第一,页面展示和数据处理彻底解耦;第二,让团队可以并行开工;第三,所有数据交互都收敛到接口层,方便统一加权限、加日志、做性能优化。拆完之后回头看,意义不只是代码更整洁,而是整个团队的开发流程变了,前端不用再等后端出页面,后端也不用被前端样式折腾,各干各的,联调时只需要对着接口文档对数据。
1.2 分层思路:Controller、Service、Mapper 各干各的
分离改造最容易犯的错,是只把接口拆出来,但后端代码还糊在一块。比如直接在 Controller 里写 SQL、做判断、拼返回结构,前端确实拿到接口了,但后端复用性和可维护性都很差。我这次的项目严格按三层来切:Controller 只负责接收参数、调用 Service、把结果封装成统一响应;Service 负责业务规则、事务边界;Mapper/Repository 只做数据访问。
举个例子,查询一篇文章详情的接口,Controller 只做三件事:接收 id、调 service、返回 ApiResponse。真正的查询逻辑放在 Service,再往下拼接查询条件、处理结果在 Mapper。代码结构大概是:
@RestController @RequestMapping("/api/articles") public class ArticleController { private final ArticleService articleService; public ArticleController(ArticleService articleService) { this.articleService = articleService; } @GetMapping("/{id}") public ApiResponse<ArticleVO> getArticle(@PathVariable Long id) { return ApiResponse.ok(articleService.getArticleById(id)); } }这样写的好处是接口升级时不用动前端,改动集中在服务层。比如后续要给文章加阅读量统计,前端接口路径和入参都不用变,只要在 Service 里补充逻辑,再把 VO 里加一个字段就行。
为了不让数据库实体直接暴露给接口,我还在 Controller 和 Service 之间加了一层 DTO/VO 转换。很多新人图省事直接把实体类返回给前端,这样做问题很大:表结构字段改了会影响接口返回,后端加了个敏感字段前端也能看到。我用了一个简单约定:Controller 层永远不返回数据库实体,只返回 VO;前端传入的数据也先转成 DTO 再交给 Service。这样多写几个转换方法,但长期维护成本低很多。
1.3 拆分过程最容易被忽视的四个点
第一是跨域。前后端分离之后,前端页面在 5173 端口,后端接口在 8080 端口,浏览器默认不允许跨域请求。我用 Spring Boot 的 CORS 配置解决,但注意“带 cookie 的请求不能把 Allow-Origin 设为 *”。我一开始图省事用了通配符,结果登录一直失败,查了很久才发现是允许的来源没写具体域名。
第二是会话状态。以前 Session 存在服务器,现在前端是独立部署,我改成 JWT 方案,后端只负责签发和校验,前端把 token 放在请求头里,这样后端接口天然支持多个前端(网页、小程序)共用。这里要提醒的是,JWT 的 secret 不要写死在代码里,放到配置中心或者环境变量里更安全。
第三是静态资源。后端不要再管 CSS/JS 的引用了,前端构建完的 dist 目录交给 Nginx 托管,接口请求统一走 /api 前缀转发到后端。Nginx 配置里要注意 proxy_pass 末尾的 /,多一个少一个 slash 会导致路径拼接错乱。
第四是环境配置。开发环境前端访问 http://localhost:8080 或者通过 Vite 代理转发,生产环境走 Nginx 反代,这个变化要在前后端各自的配置里分开维护。我习惯前端用 .env.development 和 .env.production 两个文件,后端用 application-dev.yml 和 application-prod.yml,各自维护好 baseURL 和接口地址,这样部署切换时不会手忙脚乱。这几点看起来琐碎,但每一条都会在联调时变成“时间黑洞”。
2. 接口编写:从 RESTful 设计和参数校验到统一响应
2.1 RESTful 设计:资源、动作、状态码三条规则
接口编写看似简单,实际规划和写代码是两回事。我在这次项目里统一采用 RESTful 风格,核心规则就三条:用名词表示资源,用 HTTP 方法表示动作,用状态码表达结果。文章相关的接口设计大概是这样的:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/articles | 文章列表,分页 + 筛选 |
| GET | /api/articles/{id} | 文章详情 |
| POST | /api/articles | 创建文章 |
| PUT | /api/articles/{id} | 更新文章 |
| DELETE | /api/articles/{id} | 删除文章 |
列表接口用 query 参数做分页、筛选、排序,比如GET /api/articles?page=1&size=10&categoryId=3&sort=createdAt,desc。一开始我们有的同事习惯用“/getArticleList”这种动词 URL,后来统一改成资源型 URL,前端对接起来发现非常直观,看 URL 就知道在操作什么资源。
状态码这块也要较真。很多项目不管成功失败都返回 200,把错误信息塞在 body 里,这种设计会让前端拦截特别费劲。我的约定是:成功用 200,创建用 201,删除成功返回 204;参数错误 400,未认证 401,没权限 403,资源不存在 404,服务器异常 500。前端 axios 拦截器可以区分“HTTP 层错误”和“业务层错误”,处理起来清晰很多。
2.2 统一响应体:前端不用猜的接口约定
接口响应如果每个人结构都不一样,前端写死三个字段就够头大了。这次项目的响应格式统一成这样:
public class ApiResponse<T> { private int code; // 0 成功,非 0 业务错误 private String message; private T data; public static <T> ApiResponse<T> ok(T data) { ApiResponse<T> response = new ApiResponse<>(); response.setCode(0); response.setMessage("success"); response.setData(data); return response; } public static <T> ApiResponse<T> error(int code, String message) { ApiResponse<T> response = new ApiResponse<>(); response.setCode(code); response.setMessage(message); return response; } }业务错误码我们分了几大类:1001 参数校验失败,2001 未登录或 token 过期,3001 资源不存在,5000 系统异常。data 放业务数据,列表接口固定返回{ total, list }结构,方便前端做分页组件。
后端实现写一个 ApiResponse 泛型类之后,用 @RestControllerAdvice 做全局异常处理,所有接口的响应格式都是套好模板的,不会出现“这个接口返回 { success: true },那个接口返回 { status: 1 }”的不一致。我在项目里还约定:Controller 里不能手动去 new ApiResponse,而是通过抛业务异常或者直接返回 ok() 来处理,让异常处理流程统一走全局 Advice。
2.3 参数校验和幂等性:防呆比实现功能更重要
接口能跑通只能算完成 30%,参数防呆才是后面省事的关键。我用 Validation 注解做第一道防线,比如创建文章接口的请求对象:
public class ArticleCreateRequest { @NotBlank(message = "标题不能为空") @Size(max = 100, message = "标题长度不能超过100") private String title; @NotBlank(message = "内容不能为空") private String content; @NotNull(message = "分类ID不能为空") private Long categoryId; }这样非法参数根本进不到 Service 层,直接返回统一的参数错误码。全局异常处理里加上 MethodArgumentNotValidException 的处理,前端拿到的还是同一个结构:code 1001,message 就是校验注解里的提示。
幂等性是我这次特别关注的点。以前遇到过用户快速点两次“创建订单”,生成了两条重复订单,后端也没拦住。这次的方案有两种:一种是前端生成幂等键放在 Header,后端在 Redis 里检查重复;另一种是业务字段加唯一索引,比如订单号。两者相比,唯一索引更可靠,幂等键能提前拦截。对于登录、支付这类高频敏感请求,我还在接口上简单做了次数限制,防止被脚本轰炸。防呆逻辑写在接口层,既保护了后端,也减少了前端排查问题的难度。
3. 其他优化:从接口性能到前端加载
3.1 数据库查询优化:慢SQL、索引和深分页
接口写完了,下一步就是优化。这次“知识回顾”里的其他优化,重点落在了数据库、缓存、并发和前端几个方向,其中数据库优化性价比最高。我先在 MySQL 开了慢查询日志,收集执行时间超过 1 秒的 SQL,结果发现列表接口的一条查询 join 了四张表,还带 order by,全表扫描跑了 3 秒多。用 explain 一查,type 是 ALL,rows 十万多,Extra 里还有 Using filesort。
解决办法不复杂:给 join 条件字段和 where 条件字段建合适的联合索引,把 select * 改成只查需要的字段,深分页从 offset 改为游标分页。比如文章列表的查询,核心条件往往包含 category_id 和 status,那就建这样的索引:
ALTER TABLE article ADD INDEX idx_category_id_status (category_id, status);改完这条 SQL 从 3 秒降到了 20 毫秒左右,效果非常直观。索引也不是越多越好,我这次总结的经验是:联合索引按“等值条件字段放前面、范围条件字段放后面、order by 字段尽量包含在索引里”的顺序建;优先用覆盖索引,让查询能直接在索引里拿到字段,避免回表。还有几个常见坑要记住:对索引字段做函数运算会导致索引失效,LIKE '%关键词%'也会失效,隐式类型转换同样会让索引失效。这些细节排查一次,比盲目加一百个索引管用得多。
3.2 缓存和并发控制:热点数据不要每次查库
数据库优化之后,还有一个更省力的优化点:把读多写少的热点接口加到缓存层。我在这套项目里用的是 Redis,主要缓存两类数据:一类是文章详情这类单个资源,另一类是热门分类列表这种聚合查询结果。实现上直接用 Spring Cache 的 @Cacheable 注解,key 用业务前缀加 id,缓存过期时间看业务来定,文章详情这种半实时数据我设了 60 分钟。
这里有一个值得专门强调的点:大促或热点活动时,一个热点 key 突然失效,如果大量请求同时去查库,就会出现缓存击穿。我的处理方案是热点 key 不设过期时间,依赖后台任务刷新;或者加互斥锁,只让一个请求去重建缓存。缓存穿透也要防范,有人恶意请求一个不存在的商品 ID,每次都打不到缓存直达数据库,我采用了两种方案:一种是把空结果也缓存几十秒,另一种是上线前加布隆过滤器拦截明显不存在的 ID。
对于并发问题,写操作我用数据库乐观锁,表上加 version 字段,更新时带上 version 条件,更新行数为 0 就重试。这个方案比直接用分布式锁简单很多,适合文章更新、评论点赞这类并发冲突不严重的场景。这样优化后,接口平均耗时有明显下降,数据库连接数也稳定了。
3.3 前端性能优化:打包体积、懒加载、请求合并
前端这边我也做了一些优化。构建工具用 Vite,第一个优化点是路由懒加载,把详情页、大页面都改成动态 import,首页首屏只加载必要的 chunk:
const ArticleDetail = () => import('@/views/ArticleDetail.vue')然后是依赖拆分,把体积较大的第三方库单独打包成 vendor 块,利用浏览器的长缓存。改完以后,打包出来的 js 体积比原来小了约三分之一,首屏加载时间明显下降。
请求层优化我主要做了三件事。第一是 axios 拦截器统一处理 code,业务错误直接弹出提示,未登录跳转逻辑不用每个页面重复写。第二是请求合并,列表页存在多个接口串行请求的情况,能并行的用 Promise.all 合并,减少网络往返。第三是重复请求拦截,用户快速切换筛选条件时,上一个请求未完成就不要发下一个,避免响应乱序覆盖。还顺手给图片加上了懒加载,大图走 CDN。前端优化不像后端优化有那么明显的数字直观,但用户体感差别很大,值得投入。
4. 常见问题与排障记录
4.1 接口调用报 404/405/500 的排查顺序
前后端联调最常遇见的三大问题无非是 404、405、500,排障顺序其实有套路可循。我一般先看网络请求的完整 URL 和响应状态码,然后用 curl 直接打后端接口,绕开前端代理,确认是后端的问题还是前端代理的问题。如果 curl 通而浏览器不通,优先查 Nginx 的 proxy_pass 配置和 context-path;如果 405,就去查前端用的 HTTP 方法跟后端映射是否一致;如果 500,看后端日志的堆栈信息,八成是空指针、SQL 报错、Redis 连接断开这三类。下面这个表格是我这段时间总结的排查速查表:
| 现象 | 检查项 | 常用排查方向 |
|---|---|---|
| 404 | 路由 path、Nginx proxy_pass、后端 context-path | curl 直连后端,逐层确认哪一层断了 |
| 405 | HTTP 方法、URL 映射 | 确认 POST/PUT/DELETE 是否用对 |
| 500 | 后端日志、异常堆栈 | 检查 SQL、NPE、Redis、连接池 |
| 400 | 参数格式、Validation 注解 | 看是否有必填字段缺失或类型不匹配 |
| 跨域报错 | CORS 头、Origin、Credentials | 带 cookie 时 Allow-Origin 不能为 * |
这表看起来简单,但每次联调时能省不少时间。强烈建议团队把“先 curl 直连后端”放进排查流程,很多人查了半天结果是 Nginx 少配了一个 slash。
4.2 跨域通了但 Cookie 带不上、业务 code 对不上
跨域问题是前后端分离项目的高频问题。我的经验是“看起来通了”和“真正能用”是两件事。比如前端请求能拿到数据,但登录状态总是丢,十有八九是跨域配置里没有开启 credentials。另外 OPTIONS 预检请求也要处理好,否则前端发出非简单请求时会直接失败。这里建议后端接口层统一处理 CORS,把允许来源、允许方法、允许头、是否允许凭证一次配好,别让每个 Controller 自己加注解。
还有一类问题是接口响应 body 里有 code 字段,前端拦截器却总是提示“接口异常”。我排查过几次,原因通常是后端某个异常没有走全局处理器,返回了默认的 Spring Boot 错误结构,前端拿到后没有 code 就误判了。解决办法是全局异常处理里加一个兜底方法,确保所有未知异常都被包装成统一响应格式。这样前端拦截逻辑就简单了:只看 code 是不是 0,其他都是失败。
4.3 优化前后的实测数据与复盘
这里放一组我自己项目里的实测记录,给想看到量化效果的同学一个参照。改造前,列表接口涉及多表 join,平均耗时约 3.2 秒,慢查询一天能抓到几十条;优化索引和查询字段后,平均耗时降到 24 毫秒,慢查询基本消失。缓存上线后,文章详情接口的 P95 耗时从 180 毫秒降到 25 毫秒左右,数据库连接池的活跃连接数直接降了一半。
前端侧,路由懒加载和依赖拆分让首次访问的静态资源总大小减少了约 35%,页面加载时间从 4.5 秒降到了 2.1 秒左右(本地模拟网络环境测试)。这些数据并不代表一定能复现,因为项目规模和数据量不同,但优化方向和收益比例是可以参考的。我给团队定的原则是:任何优化上线前都要记录优化前和优化后的数据,哪怕是一个字段长度的调整,也要有对比记录,这样才知道是不是在做无用功。
5. 复盘:这套改造方案落地后的真实体会
5.1 我的建议:按这个顺序推进,踩坑最少
如果想在现有项目里做前后端分离改造,我建议不要一次性推倒重来,而是按模块逐步迁移。第一个要定的是接口规范和错误码,这是前后端并行开发的地基;第二个是后端的全局异常处理和各种拦截器,这会减少联调时一半的沟通成本;第三个才是跨域、鉴权、环境配置这些支撑设施。最后再开始抽页面、改接口。这个顺序看起来慢,实际上最稳。团队如果小,一个人兼着前后端,也要坚持“先定契约再写代码”的习惯,不然后面改接口会改到怀疑人生。
还有一点就是要及时更新接口文档。我自己吃过亏,接口改了之后没有同步文档,前端拿着旧文档联调,来回对了好几轮才发现。后来约定接口变更必须在合并代码前更新文档或者附上响应示例,这一条坚持下来,线上问题少了很多。
5.2 后续可以继续优化的方向
这次“知识回顾”做到数据库、缓存和前端优化就告一段落了。如果项目规模再往上走,我会优先考虑几个方向:一是把部分实时统计接口改成异步任务预计算,查询时直接读结果表;二是引入消息队列削峰,比如订单创建、日志收集这类写操作;三是接口文档自动化,用 OpenAPI 或类似工具维护,保证接口变更能自动同步给前端。
另外近期有很多针对向量数据库集成与优化的讨论,如果业务里出现语义检索需求,可以把文章内容的向量索引也纳入改造范围。但前提是现有接口和数据结构已经足够稳定,不然一步到位的收益会被复杂度过快抵消。技术选型没有统一答案,关键是在合适的规模用合适的方案。
这次回过头来梳理,我个人最受益的一个习惯是:每次改完接口,都顺手在文档里更新一下响应体示例,并且把请求参数和返回字段的约束写清楚。一开始看着傻,实际联调阶段帮我少接了很多沟通电话。做“知识回顾”的过程中,我把这些踩过的坑又重新串了一遍,发现多数问题的根源不是技术本身有多难,而是开始之前没有把边界和责任讲清楚。前后端分离的真正价值,最后都体现在协作效率上,接口规范、错误码、环境配置这些基本功做到位,后面的优化才有稳定的地基可以站。