ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

前后端分离项目实战:从接口契约到跨域联调全流程解析

前后端分离项目实战:从接口契约到跨域联调全流程解析 1. 阶段3到底在做什么把页面和接口真正接上头前后端分离项目做到第3个阶段最典型的体验是后端接口在Postman里怎么测怎么通前端页面在浏览器里怎么跑怎么顺但只要把两者放到一起立刻就各种幺蛾子。我在接手这个阶段的时候第一感觉就是终于到了整个项目承上启下的节点——阶段1和阶段2分别把SpringBoot后端的基础骨架、Vue前端的基础骨架搭好了但前端还在用Mock数据、后端还在跟着Postman走双方各跑各的实际上并没有真正发生过一次完整的请求往返。阶段3的核心任务就是把这条断掉的路接起来。说得更直白一点这个阶段要交付的成果只有一个标准在页面里点一个按钮能把数据库里的数据查出来并展示在表格里填一个表单点提交能把数据写进数据库并刷新出最新结果。到这一步前后端分离才不是概念而是一条真实的链路。这件事对于很多新手来说特别容易低估觉得不过就是发个请求而已但实际走下来你会发现连接这件事牵扯到接口契约设计、跨域策略、请求封装、环境配置、异常处理、数据格式对齐等多个环节任何一个点没踩实整个链路就跑不通。这篇文章的内容基于我在这个项目阶段3里的实操总结。整个过程我踩了不少坑尤其是跨域和字段格式这两个问题几乎占了联调排错时间的一大半。后面我会按实际推进顺序把整个连接过程拆开讲每个环节都会给出可复现的配置和代码也会把常见报错的完整排查链路写清楚。如果你是第一次从前后端分离项目实战的角度走向联调这一步这篇文章可以帮你少走很多弯路。2. 连接之前先搞懂一件事前后端到底是怎么约定的2.1 一次请求的完整旅程前后端分离之后前端和后端就是两个完全独立的进程前端跑在Vue的开发服务器上默认端口5173后端跑在SpringBoot的内嵌服务器上默认端口8080。两个进程各管各的互不干扰那它们之间怎么通信靠的就是HTTP协议。用户在页面上点击一个按钮Vue组件里的事件函数通过axios向后端发出一条HTTP请求比如GET http://localhost:8080/api/users。这个请求会先经过浏览器网络层穿越到后端SpringBoot的控制器层由GetMapping修饰的方法接收并处理然后返回一段JSON数据。浏览器拿到这段JSON之后axios的Promise被resolve前端代码把数据渲染到页面上。整个链路差不多是页面事件 → axios → HTTP请求 → SpringBoot控制器 → Service层 → Mapper层 → 数据库 → 返回值逐层返回 → 浏览器接收 → 页面渲染。这里有一个很容易被忽略的点前端发请求、后端接请求双方各自运行在自己的端口上。端口不同就涉及跨域问题这也是整个阶段3排错频率最高的拦路虎后面我会专门用一节来讲。另一个容易忽略的点是整个链路中传输的数据格式是JSON后端Java对象和前端JavaScript对象长得很像但绝不是同一套东西字段名的映射规则如果不提前对齐就会发生前端取不到数据这种让人抓狂的问题。2.2 阶段3的最小闭环标准我给阶段3定了一个最小闭环标准当作这块骨头啃没啃下来的验收线后端提供一个获取用户列表的接口数据库里有三条测试数据前端页面进入后自动调用该接口并在表格中渲染出三条数据前端提供新增用户表单提交后调用后端新增用户接口新增成功后前端自动刷新列表表格中出现第四条数据。这个闭环看似简单但它成功跑通意味着以下能力全部就位SpringBoot接口定义正确、Vue路由配置正确、axios请求封装可用、跨域方案生效、JSON数据映射正确、错误处理有兜底。这六个能力恰好就是前后端连接这个主题的全部内涵。阶段3如果能把这条链路稳健地跑通后面再去做登录鉴权、文件上传、复杂查询都只是在这个地基上继续盖楼。3. 连接前先把接口契约写死联调效率翻倍3.1 接口契约的四要素在前后端开始对接之前有一件事必须先做完否则联调过程会变成一场灾难——那就是把接口契约定下来。接口契约说白了就是一份双方都认账的口头协议内容包括四个要素请求路径、请求方法、请求参数、返回结构。我在这个项目里是这样设计的以用户管理为例功能请求方法请求路径请求参数返回结构获取用户列表GET/api/usersquery: page, size{ code, message, data: { list, total } }新增用户POST/api/usersbody: { username, email }{ code, message, data: null }更新用户PUT/api/users/{id}body: { username, email }{ code, message, data: null }删除用户DELETE/api/users/{id}path: id{ code, message, data: null }这套设计放在任何规模的项目里都不算负担过重但它把联调阶段90%的扯皮空间提前消灭了。前端不用猜删除用户到底传id还是传整个对象后端也不用抱怨前端参数名跟文档不一致。3.2 统一响应结构真的值得较真先说一个很多同学最开始都会踩的坑直接把数据库的实体类完整返回到前端。比如后端User实体类里有密码字段password如果像下面这样直接返回用户的密码哈希就赤裸裸地暴露在了前端这在真实项目里属于严重的低级错误。虽然这个阶段还没有做完整的权限体系但从项目一开始就让返回结构和数据库实体解耦是一个值得养成的习惯。为了解决这个问题我定义了全局统一响应结构Result类让所有接口的返回数据都包一层同时用VOView Object对象隔离数据库实体public class ResultT { private int code; // 业务状态码0表示成功 private String message; // 提示信息 private T data; // 真正的业务数据 public static T ResultT success(T data) { ResultT result new Result(); result.code 0; result.message success; result.data data; return result; } public static T ResultT error(int code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }前端拿到任何接口的返回数据统一先从code字段判断业务是否成功成功再取data失败直接展示message给用户提示。这个约定看起来多包了一层数据却让前后的错误处理逻辑变得高度统一。我在实际写Vue代码的时候axios的响应拦截器里就可以根据code统一弹出错误提示业务代码到页面里就只管成功分支清爽太多。还要强调一点HTTP状态码和业务码是两回事。200 200 200只代表HTTP传输层成功但业务逻辑可能依然失败比如用户名已存在。所以在设计接口契约的时候不要把code字段和HTTP状态码混为一谈。我见过不少项目为了让前端逻辑简单把传输出错和业务出错全压到HTTP状态码上最后后端抛出异常时返回的HTTP 500会让浏览器控制台一片飘红排查问题特别费劲。我这个项目的约定是HTTP状态码永远返回200业务结果靠code字段区分。虽然有争议但对中小型项目来说这套约定简单直接、前端处理成本最低。3.3 前端响应拦截与后端异常处理的配套设计契约定好了前后端就要分别在各自侧落实。后端要在全局异常处理器里把所有异常统一转换成Result结构构建GlobalExceptionHandler全局异常处理类在SpringBoot的Controller层里使用RestControllerAdvice或者ControllerAdvice注解配合ExceptionHandler来兜住所有异常不能出现异常直接抛给Tomcat返回一堆默认错误页的情况。前端则要在axios封装里做响应拦截统一读取code字段做分支处理。这两件事是一体两面的一定要结对设计。如果后端只返回Result结构但前端不解析等于白设计如果前端解析Result但后端某些接口没有包Result也会导致前端拦截器逻辑异常。所以联调之前前后端要各自确认已按同一个契约完成落实再开始真正调通。4. SpringBoot后端这一步把端口和跨域提前收拾明白4.1 先统一端口规划我正在用的项目里后端端口规划为8080前端默认5173。这两个端口必然不一样所以启动后前后端天然跨域。开发阶段的跨域解决方案我建议优先采用后端CORS配置理由很简单无侵入、零学习成本、一个类搞定。在SpringBoot里写一个配置类实现WebMvcConfigurer接口并重写addCorsMappings方法是最常见也最稳妥的方式。示例配置如下Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里要注意几个关键参数。allowedOriginPatterns(*)表示允许所有来源访问这个后端服务接口开发阶段用没问题生产环境如果后端接口要开放给特定前端域名使用建议换成具体的域名白名单安全系数更高。allowedMethods要显式声明OPTIONS因为浏览器在跨域请求时对于非简单请求会先发一个OPTIONS预检请求如果后端没有放行OPTIONS后续的正式请求根本发不出去。有一个特别容易踩的坑allowCredentials和allowedOrigins不能同时用通配符。早期版本的SpringBoot如果allowCredentials(true)同时又设置allowedOrigins(*)启动不会报错但浏览器会在控制台输出Access-Control-Allow-Origin相关的错误导致跨域请求失败。所以allowedOriginPatterns和allowCredentials(true)的组合是安全可用的不要用allowedOrigins(*)配allowCredentials(true)。4.2 一个探路接口先证明链路通了一半在还没有正式开始写业务接口之前我建议后端先写一个最简单的/api/health接口返回Result.success(server is running)。这个接口有两个作用一是验证SpringBoot服务已经正常启动二是给前端联调的时候提供一个最简单的目标先把链路通起来再谈业务复杂度。RestController RequestMapping(/api) public class HealthController { GetMapping(/health) public ResultString health() { return Result.success(server is running); } }SPringBoot启动后直接用浏览器访问http://localhost:8080/api/health能看到JSON返回就说明后端基本就绪。这一步不要跳过因为后面前端联调过程中如果发生任何异常你要能判断问题出在前端还是后端最简单的排查方式就是用浏览器直接访问一次后端接口能通说明后端没问题问题大概率在前端侧的配置或代码上。4.3 后端自测清单后端接口全部写完之后在正式跟前端联调前建议先按这个清单自测一遍每个接口在浏览器或Postman中直接访问返回结构是否符合{ code, message, data }每个接口的请求方式和路径是否与文档完全一致传参类型是否匹配比如路径参数用了PathVariable查询参数用了RequestParamJSON body用了RequestBody全局异常处理器是否对非法参数的异常做了统一兜底跨域配置是否正确生效。这套自测做完后端侧的问题就基本清零了后面联调阶段遇到的所有报错聚焦到前端去排查排查范围会小很多。5. Vue前端这一步把axios封装好联调才谈得上效率5.1 为什么前后端连接几乎都会选axiosVue前端发起HTTP请求可选方案有fetch原生API、axios、VueUse里封装的useFetch、还有比较冷门的request库等。但这个项目里我仍然选了axios原因有三个。第一是拦截器机制非常成熟请求发出前统一注入token、响应回来统一处理业务码这些逻辑在项目变大之后必不可少第二是错误处理能力比较细可以区分超时、网络异常、HTTP错误、业务失败等不同级别第三是生态足够通用团队里其他成员接手时不需要额外学习成本。axios本身不是Vue专用库它可以在任何JavaScript环境使用。在Vue项目里用axios只是把它当作一个纯HTTP客户端来用不涉及任何Vue响应式逻辑所以放在src/utils/request.js独立封装即可。5.2 封装request.js的基础形态我在项目里新建了src/utils/request.js基础封装如下import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // 请求拦截器自动携带token request.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) ) // 响应拦截器统一处理业务码 request.interceptors.response.use( (response) { const res response.data if (res.code 0) { return res.data } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message || 请求失败)) }, (error) { ElMessage.error(error.message || 网络异常请稍后重试) return Promise.reject(error) } ) export default request有几个细节值得展开说明。baseURL这里我用的是import.meta.env.VITE_API_BASE_URL这是Vite的环境变量方式。在项目根目录的.env.development文件里配置开发环境的地址在.env.production文件里配置生产环境的地址。这样做的好处是代码里不用到处写死后端地址换环境只要改配置文件就行。我采用的是开发环境打包时直接使用代理转发方案这在后面第7节里会详细展开这里先表明一点baseURL在开发环境直接写http://localhost:8080是可以的但在生产环境如果前后端部署在不同域名下就会出现跨域请求失败所以生产环境的baseURL一般要写成相对路径/api再由Nginx反向代理到后端服务。响应拦截器里直接返回res.data这个设计的意图是让页面代码里拿到的就是后端真正的业务数据不需要在组件里再写一遍response.data.data这种地狱级嵌套。但要注意这会让请求方法返回的Promise在业务失败时被reject所以页面代码里一定要写try...catch或者用.catch捕获否则会有未捕获Promise异常。5.3 再包一层API函数业务组件保持清爽有了request.js基础封装之后我不建议在组件里直接调用request.get(/api/users)这种裸方法。更好的做法是按业务模块再封装一层。比如src/api/user.jsimport request from /utils/request export function getUserList(params) { return request({ url: /api/users, method: get, params }) } export function createUser(data) { return request({ url: /api/users, method: post, data }) } export function updateUser(id, data) { return request({ url: /api/users/${id}, method: put, data }) } export function deleteUser(id) { return request({ url: /api/users/${id}, method: delete }) }这样做的好处有两个层面。第一是把所有接口URL集中在同一个文件里接口一变改一处就行不会出现前端有十个页面里都写了/api/users后端路径一改前端就要全局搜索替换的惨剧。第二是组件的逻辑更聚焦于交互和状态管理调用接口就是调用一个返回Promise的普通函数组件内部可读性会好很多。组件里的调用形态大概是这样import { getUserList } from /api/user const loading ref(false) const tableData ref([]) async function loadUsers() { loading.value true try { tableData.value await getUserList({ page: 1, size: 10 }) } finally { loading.value false } } onMounted(() { loadUsers() })在这个调用形态里接口函数后面是否加await、是否用try...finally释放loading状态这些细节决定了一个页面的健壮程度。我的经验是所有接口调用都必须确保finally里释放loading否则接口挂了loading永远转圈用户会以为页面卡死了。5.4 页面联调前的自测先用Mock模式验证前端自身真正开始联调之前前端自身还有一项自测要做——用Mock数据把所有页面的渲染链路先验证一遍。我这里的Mock不一定是装一个Mock服务器也可以是暂时把API函数改成返回静态数据甚至直接用后端给到的一份示例JSON先静态渲染到页面上看展示效果。这一步的目标是确保页面代码本身没有渲染问题比如字段名拼写错误、Vue模板绑定错误、分页逻辑写错等。如果这些错误没有擦干净就进入联调到时候前后端两边同时出问题排查起来会非常痛苦。先自证前端清白再引入真实接口排查范围就清晰很多。6. 联调阶段最常见的四个翻车现场附完整排查链路联动调开始之后报错会变得非常高频。我把这个项目里实际遇到过的四类问题按出现频率整理出来每一类都给出完整的排查思路方便你对照复现。6.1 翻车现场一跨域报错刷屏现象浏览器控制台出现大量红色报错核心信息是Access to XMLHttpRequest at http://localhost:8080/api/users from origin http://localhost:5173 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.排查链路第一步先在浏览器里直接访问后端接口如果能通说明接口和SpringBoot本身没有问题第二步看请求头F12打开Network面板找到这条跨域请求看它的Method是什么如果是OPTIONS说明浏览器发的是预检请求那问题大概率出在后端CORS配置没有放行OPTIONS方法第三步检查后端CORS配置类有没有生效有没有被某个拦截器或过滤器拦掉或者Spring Security还没放行该路径。解决方案我项目里最终选用的是SpringBoot的CorsConfig配置类方案配置代码在前面第4节里已经给出了。如果你项目里用了Spring Security还要在Security配置里显式放行OPTIONS预检请求和/api/**路径否则Security会先把预检请求挡在外面CORS配置再好也没用。6.2 翻车现场二前端能拿到数据但是表格里一片空白现象Network面板里能看到请求成功返回了200Response里也有完整的JSON数据但页面上表格就是没有渲染出任何内容。排查链路先看Network里的响应JSON到底是什么结构。我遇到的情况是后端返回了{ code: 0, message: success, data: { records: [...], total: 10 } }而我前端API函数返回的却是整个data字段页面代码里直接去遍历res.records。但Element Plus的el-table默认要求data属性接收一个数组如果直接传了个对象给表格的:data那表格能渲染才是怪事。解决方案统一后端返回结构分页数据里的list和total字段必须前后端约定一致。我后来把分页返回结构统一为{ list: [...], total: 100 }后端写一个PageResult类前端解析data.list一次解决此问题。如果你不想反复改后端代码也可以在前端API函数里把data解构后返回data.list两种方式都可以但必须在契约里写明不能说改就改。6.3 翻车现场三后端收到了字段值但是是null现象前端调用新增用户接口请求体里明明传了username: zhangsan和email: zhangsanexample.com但后端接收到的对象里这两个字段是null数据库里也写入了null。排查链路先看请求头的Content-Typeaxios里如果直接传对象默认会变成application/jsonSpringBoot用RequestBody接收没问题。但如果你在axios里加了transformRequest做了自定义格式化或者后端用了ModelAttribute接收请求体就会导致字段映射不上。还有一个非常隐蔽的点是字段命名风格如果前端用的是userName后端实体字段是username全小写在JSON序列化时如果后端开启了驼峰转下划线配置spring.jackson.property-naming-strategy: SNAKE_CASE就会造成字段对不上。这个坑我排查了整整一个小时最后发现是application.yml里一行全局配置导致的。解决方案检查后端是否配置了额外的Jackson命名策略如果没有就不要乱开检查请求体的Content-Type是不是application/json后端字段命名统一用小驼峰前端传参也统一用小驼峰双方保持完全一致。前后端联调不是比谁更灵活而是比谁更严格。6.4 翻车现场四请求成功但状态码被错误处理现象接口能返回200业务也成功执行了但页面上弹了一个错误提示隐约记得跟el-message有关。排查链路这个问题主要出在响应拦截器的设计上。我前面提到过axios响应拦截器里我直接返回了res.data这样页面里拿到的就是业务数据。但如果你后端某个接口没有统一使用Result.success()包装而是直接返回了String或者Map那响应拦截器在解析res.code的时候就会报错因为undefined 0永远为假前端会误判成业务失败。解决方案要么坚持所有接口统一返回Result结构要么响应拦截器里加一个容错判断——if (res.code 0 || res.code undefined)都视为成功。我建议选前者因为接口统一性在长期维护里价值巨大一个后端接口裸奔就会破坏前端的统一错误处理机制。7. 从本地联调到前后端部署开发代理和生产代理怎么配7.1 开发环境方案Vite代理一劳永逸虽然后端CORS配置可以解决开发环境的跨域但更优雅的方案是在Vite开发服务器上配置代理。原理是浏览器的请求发到Vite开发服务器端口5173的同源地址Vite再把请求转发到后端服务器端口8080浏览器全程看到的是同源请求自然不存在跨域问题。我项目里的vite.config.js配置长这样import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { host: 0.0.0.0, port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })配置好代理之后前端代码里的baseURL就可以改成相对路径/api比如请求/api/usersVite会把请求转发到http://localhost:8080/api/users。这个方案的好处是前端代码里不会再出现http://localhost:8080这种硬编码地址后面换后端地址只改Vite配置文件即可且开发环境的请求形态和生产环境完全一致都是同源相对路径。使用代理方案时后端不配置CORS也没关系因为浏览器看到的请求是同源的。但从兼容性角度我还是建议后端把CORS留着因为我们偶尔会用Postman或Apifox直连后端测试那也可避免不必要的困扰。7.2 生产环境方案Nginx反向代理生产环境前后端分离部署最常见的形态是前端打包成静态文件由Nginx托管后端跑在一个独立服务上Nginx里配置反向代理把/api路径的请求转发到后端服务器地址。Nginx的关键配置片段如下server { listen 80; server_name your-domain.com; root /var/www/vue-dist; index index.html; location / { try_files $uri $uri/ /index.html; } 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 $uri $uri/ /index.html这一行。Vue是单页应用如果用户直接访问/users/edit/1这个路由刷新页面时Nginx会去/var/www/vue-dist/users/edit/1找文件找不到就404。加了try_files之后所有不存在的文件路径都会回退到index.html由Vue路由接管页面渲染。proxy_pass的路径拼接也是常见坑位。如果你在Nginx里写了location /api/proxy_pass http://127.0.0.1:8080;末尾没有路径则请求/api/users会原样转发给后端如果proxy_pass http://127.0.0.1:8080/;末尾带斜杠则请求/api/users会变成/users。两种写法语义完全不同一旦配错后端的路由就全乱了且浏览器里报的是404而不是跨域错误光看报错信息很容易误判成后端服务没起来。7.3 CORS配置和代理方案怎么取舍很多人在开发环境遇到跨域问题第一反应都是加CORS但生产环境如果要开放接口给多个不同域名的前端使用CORS确实是必选项如果前后端部署在同一个域名下通过Nginx路径区分代理就够用。我的策略是开发阶段Vite代理为主后端CORS为辅生产阶段Nginx反向代理后端CORS按需开启开放给第三方使用时才需要安全要求高时后端CORS白名单配置精确到域名不要用*。这套策略走下来跨域这一个环节基本不会再有反复。8. 阶段3走完之后的收尾经验8.1 三层自检确认法阶段3的收尾不应该是页面能跑起来就算完。我给自己定了一个三层自检确认法来验收整个链路的质量第一层功能层面最小闭环的全部操作——查列表、增、改、删——都能正常走通数据在数据库里真实发生变化。第二层异常层面接口失败时页面上是否出现了友好的提示网络断开时是否有超时提示后端返回业务错误时前端能否正确展示后端给出的message。第三层体验层面请求中的loading状态是否正常显示和消失接口返回后页面是否自动刷新数据重复点击提交按钮时是否会重复发请求造成重复数据表格数据为空时的页面展示是否友好。这三个层面都过了阶段3才算真正验收完成而不只是能连通。8.2 联调工具选型的参考整个阶段3的联调过程中我用了一款API调试工具来辅助排查可以快速验证后端接口的返回结构和参数情况不用等前端代码改完再测。目前主流的选择有Postman、Apifox等我用的是Apifox因为它支持直接导入SpringBoot的Swagger文档能自动生成接口列表和Mock数据联调效率提升明显。如果你在项目里集成了SpringDoc或Knife4j可以在后端pom.xml里加一个依赖启动后访问/doc.html就可以看到所有接口文档和调试界面。这种方式比单独维护一份接口文档更不容易过期因为文档是直接从代码注解中生成的只要代码改了文档自动同步。我在这个项目里使用了Knife4j联调阶段打开它的调试界面直接验证接口契约比传统的两边对文档高效太多。8.3 阶段3给整个项目打下的地基阶段3走完之后最直观的收获是前后端项目终于从两个孤岛变成了一条管道。但这篇文章真正想强调的是阶段3为项目后续打下的一些不太容易看见的地基接口统一返回结构让所有模块的前端错误处理逻辑变得一致axios封装和API函数分层让新增一个接口的工作量降到最低CORS和代理方案的双通道让开发和生产环境都能稳定运行。基于这些经验后续你做登录认证的时候只需要在axios拦截器和后端过滤器里加上token校验这两个位置在阶段3的地基里都已经预留做文件上传的时候只需要在全局异常处理里加一个类型异常的分支做权限管理的时候只需要在接口契约里扩展角色字段。地基没打好后期补是很痛苦的地基打好了后面盖楼就是顺理成章的事情。我个人在走完阶段3之后最大的一个体会是前后端连接这个阶段表面上拼的是配置和代码实际上拼的是契约意识。没有事先约定清楚就闷头写代码联调时一定会互相甩锅先把接口契约写死、把统一响应结构定好、把异常处理逻辑商量透联调就会变成一件非常安静的工作。希望这篇经验能帮你把阶段3平稳落地少熬几个排查跨域的夜。
返回列表