概述
交友项目的前端是 Flutter 写的 App,后端是一堆 Java 服务,两边从第一天起就是两拨人在写代码。分离本身不是难点,难的是"约定"------接口返回长什么样、字段叫什么、跨域怎么办、上线怎么部署。这篇只讲工程落地这一层。
纲要
- 接口契约怎么约定
- 统一响应结构:
ErrorResult/ HTTP 状态码口径 - URL 资源化命名与 HTTP 方法表达动作
- 分页结构:
PageResult的固定字段 - 全局异常处理器如何把异常翻译成统一响应
- 统一响应结构:
- 接口文档与协作流程
- 串行协作(先文档后开发)与并行协作(Mock 先行)
- 文档与实现漂移的风险与三种应对
- 跨域问题与解决
- 跨域从哪来:端口不同即跨域
- 前端代理 / 后端 CORS / 网关统一处理 三条路径对比
- 开发期用代理、生产期用网关的常见组合
- 联调与调试手段
- Postman / Apifox 自测、浏览器 Network 面板
- 联调问题的四步定位顺序、跨域与业务错误的区分
- 部署方式的变化
- 前端
npm run build产出静态资源、Nginx 托管 - Nginx 配置示例:静态目录 +
/api/反向代理
- 前端
- 端到端数据流(Mermaid 时序图)
- 实践坑清单
- 登录页原型与接口对照
- API 速览
- 常见误解澄清
接口契约怎么约定
前后端分离的核心不是"你写页面我写接口",而是接口契约驱动。契约不定死,两边各写各的,联调时就是互相甩锅现场。契约里最容易被忽略、也最影响协作效率的是返回结构------前端要能用一套代码处理所有接口的成败。
统一响应结构
后端所有接口的响应体应当是同一层封装。交友项目在 mf-model 的 vo 包里放了一份错误响应体,errCode 给机器判断,errMessage 给人看:
java
package com.mf.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一错误响应体
* errCode ------ 业务错误码,前端按码分支处理
* errMessage ------ 面向用户的提示文案
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResult {
private String errCode;
private String errMessage;
public static ErrorResult error() {
return ErrorResult.builder().errCode("999999").errMessage("系统异常稍后再试").build();
}
public static ErrorResult fail() {
return ErrorResult.builder().errCode("000001").errMessage("发送验证码失败").build();
}
public static ErrorResult loginError() {
return ErrorResult.builder().errCode("000002").errMessage("验证码失效").build();
}
public static ErrorResult mobileError() {
return ErrorResult.builder().errCode("000004").errMessage("手机号码已注册").build();
}
}
错误码用静态工厂方法集中定义,好处是错误码和文案只有一处来源,改文案不用满项目搜字符串。
HTTP 状态码的口径要和响应体配套。交友项目的做法是:成功走 ResponseEntity.ok(...)(200),错误走 ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)(500),body 统一是 ErrorResult。第 01 章 LoginController 里就是这么写的:
java
package com.mf.server.controller;
import com.mf.server.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/user")
public class LoginController {
@Autowired
private UserService userService;
/**
* 获取登录验证码
* 请求参数:phone
*/
@PostMapping("/login")
public ResponseEntity login(@RequestBody Map map) {
String phone = (String) map.get("phone");
userService.sendMsg(phone);
return ResponseEntity.ok(null); // 正常返回状态码 200
}
/**
* 校验登录
* 请求参数:phone、verificationCode
* 响应:{ token, isNew }
*/
@PostMapping("/loginVerification")
public ResponseEntity loginVerification(@RequestBody Map map) {
String phone = (String) map.get("phone");
String code = (String) map.get("verificationCode");
Map retMap = userService.loginVerification(phone, code);
return ResponseEntity.ok(retMap);
}
}
另一种常见口径是HTTP 状态码永远是 200,业务成败全靠 body 里的 code 区分。两种都行,但必须全项目统一------最怕的是 A 接口用状态码、B 接口用 code,前端拦截器只能疯狂写特判。
全局异常处理器
契约要有"异常出口"。业务代码只管抛异常,由 @ControllerAdvice 统一翻译成响应体,前端永远只面对一种错误格式:
java
package com.mf.server.exception;
import com.mf.model.vo.ErrorResult;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class ExceptionAdvice {
@ExceptionHandler(BusinessException.class)
public ResponseEntity handlerException(BusinessException be) {
be.printStackTrace();
ErrorResult errorResult = be.getErrorResult();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResult);
}
@ExceptionHandler(Exception.class)
public ResponseEntity handlerException1(Exception be) {
be.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResult.error());
}
}
配套的 BusinessException 只需要携带一个 ErrorResult:
java
package com.mf.server.exception;
import com.mf.model.vo.ErrorResult;
import lombok.Data;
@Data
public class BusinessException extends RuntimeException {
private ErrorResult errorResult;
public BusinessException(ErrorResult errorResult) {
super(errorResult.getErrMessage());
this.errorResult = errorResult;
}
}
这样 Service 层写 throw new BusinessException(ErrorResult.loginError()),前端收到的永远是 {"errCode":"000002","errMessage":"验证码失效"}。
URL 命名规范
资源化路径,动作交给 HTTP 方法。交友项目 app-server 用 @RequestMapping("/user") 做模块前缀,方法级再细化:
| 约定项 | 示例 | 为什么重要 |
|---|---|---|
| 模块前缀 | /user、/movements、/messages |
前端按模块聚合请求,代理和网关可按前缀分流 |
| 资源用名词复数 | /movements、/comments |
避免 /getMovements、/doLike 这类动词路径 |
| 动作交给 HTTP 方法 | POST /user/login、GET /user/{id} |
语义清晰,缓存/重试策略可按方法区分 |
路径参数用 {} |
/user/{id} |
与 Spring @PathVariable 天然对应 |
| 查询参数用 kebab/小驼峰统一 | page、pagesize |
分页参数名跨接口一致,前端不用记两套 |
版本前缀(/api/v1/...)在交友项目这种内部 App 场景里没有强上,因为 App 是强制升级的,不存在老客户端长时间存活的问题。如果是对外开放 API,版本前缀几乎是必选项。
分页结构约定
分页是最高频的返回形态,字段名必须一次定死。交友项目用 PageResult,五个字段包打天下:
java
package com.mf.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {
private Long counts = 0L; // 总记录数
private Integer pagesize = 10; // 页大小
private Long pages = 0L; // 总页数
private Integer page = 1; // 当前页码
private List<?> items = Collections.emptyList(); // 列表数据
public PageResult(Integer page, Integer pagesize, Long counts, List list) {
this.page = page;
this.pagesize = pagesize;
this.items = list;
this.counts = counts;
this.pages = counts % pagesize == 0 ? counts / pagesize : counts / pagesize + 1;
}
}
注意 pages 是构造器里算出来的,不是让前端自己除。前端只管 items 渲染列表、counts 显示"共 N 条",落一屏加载更多就 page++。字段名 counts、pagesize、items 这种非标准写法一旦定了就别改------改一次前端全量返工。
接口文档与协作流程
分离之后,接口文档就是契约本身。讲义里明确点出来:"两端之间通过接口文档定义规范,前端和后端基于文档进行数据请求和格式的响应。" 团队协作通常两种走法:
串行:后端先把文档写出来(接口路径、请求参数、响应示例),前端照着文档先把页面和请求逻辑写完,后端再实现。风险是前端干等文档。
并行:后端先出文档 + Mock 数据(YApi、Apifox 都能一键生成 Mock 服务),前端直接连 Mock 地址开发,后端同时写实现。切换真实环境时只改一个 baseURL。
交友项目的实际做法偏并行------App 端用原型图(03-资料/交友项目移动端原型V1.2)先定页面和交互,接口文档定请求/响应格式,后端在 mf-app-server 里实现。第 3.1.3 节把 Postman 列为标配工具,就是为了后端在交付前能自测接口。
文档与实现漂移是分离模式最大的协作风险。 文档写的是 username,代码里返回的是 user_name,联调时前端只会看到 undefined。三种应对,从轻到重:
| 手段 | 做法 | 成本 |
|---|---|---|
| 契约评审 | 接口定稿前前后端一起过一遍,双方签字 | 低,但靠人 |
| 自动化校验 | 用 OpenAPI/Swagger 注解生成文档,与代码同源 | 中,需改造 |
| 接口测试兜底 | 后端每个接口配 Postman/Swagger 用例,回归时跑一遍 | 中,能提前发现 |
| 前端契约测试 | Mock 层按文档写死结构,后端不符合就联调失败 | 高,但最拦得住 |
一句话:能把文档从代码里自动生成出来,就别手写文档。
跨域问题与解决
前端跑在一个端口、后端跑在另一个端口,浏览器就认为这是"跨域"。交友项目后端 mf-app-server 监听 18080,如果前端本地开发服务跑在 8080,端口不同 → 协议 + 域名 + 端口任一不同即为跨域 → 浏览器会在发请求前先做同源检查,不通过就把响应拦下来。
需要先纠正一个认知:跨域是浏览器的安全策略,不是后端的 bug。 后端其实收到请求并处理了,只是响应里缺了允许跨域的响应头,浏览器拒绝把结果交给 JS。所以 Network 面板里经常看到请求有状态码、却报了 CORS------请求成功了,是浏览器不给你用。
三条解决路径:
| 路径 | 做法 | 适用场景 | 代价 |
|---|---|---|---|
| 前端代理 | 开发服务器(Vite/webpack devServer)把 /api 代理到后端 |
开发期首选 | 只在本机生效,上线无效 |
| 后端 CORS | 后端加 @CrossOrigin 或全局 CORS 配置 |
前后端同域不同端口的小项目 | 开发配置可能被带上生产 |
| 网关统一处理 | 网关(如 Spring Cloud Gateway)统一加 CORS 响应头 | 生产环境推荐 | 需要网关层支持 |
实际项目里最省心的组合是:开发期用前端代理,生产期用网关。
开发期用代理的原因很直接------前端配置里改一行 target 就完事,不用动后端代码,也避免了把"允许 localhost:8080 跨域"这种调试配置带进生产环境。生产期的跨域处理应该收敛到网关一处,而不是散在每个业务服务里各配各的。
如果要在后端配 CORS,注意区分"前端代理能不能解决"和"必须后端解决"两种场景:代理走的是服务器到服务器,压根不经过浏览器的同源检查,所以开发期代理能覆盖绝大多数情况。CORS 的响应头细节(Access-Control-Allow-Origin 等)属于网关配置范畴,本系列另有《网关的 cors 跨域配置》一篇专门展开,这里不重复。
联调与调试手段
联调出问题时,别急着怀疑代码,按固定顺序排查能省一大半时间。
第一步:请求发出去了吗? 打开浏览器开发者工具的 Network 面板,前端点了按钮但列表里一条记录都没有,说明问题在前端------请求根本没构造出来,或者被某个 return 提前跳过了。这一步能直接砍掉一半的排查范围。
第二步:状态码是什么? 4xx 是请求方的问题(路径写错 404、参数缺失 400、鉴权失败 401/403),5xx 是服务端的问题(业务异常 500、服务没起来连不上)。看到 500 就去后端看堆栈,别在前端瞎改。
第三步:响应体内容对不对? 状态码 200 也不代表对。展开响应体,字段名和文档对得上吗?data 里是不是空的?这一层最常见的问题是字段名拼写或大小写不一致。
第四步:是跨域还是业务错误? 区分方法很明确:跨域时浏览器控制台会显式打印 CORS 或 "blocked by CORS policy",且 Network 里请求往往显示成功却被拦;业务错误则是一个正常返回的响应体,里面 errCode/errMessage 有值。前者去改代理或网关配置,后者去查业务代码。
工具层面,后端用 Postman 或 Apifox 单独跑接口,能快速回答"是前端请求的问题还是后端逻辑的问题"。交友项目讲义把 Postman 作为环境搭建的一部分,就是这个用途。
部署方式的变化
分离部署带来的是两边可以各自独立上线。
前端:Flutter 项目打包成 APK 交给客户端团队(交友项目是 App,不是浏览器页面),如果换成 Web 前端则是 npm run build 产出 dist 静态资源目录,扔给 Nginx 托管。静态资源不经过 Java 进程,天然可以上 CDN。
后端:只提供 API,mf-app-server 按接口压力独立扩容,和前端发布互不影响。
典型的 Nginx 配置长这样------静态资源和后端接口在同一个域名下,前端请求 /api/ 时反向代理到后端服务,同源问题自然消失:
nginx
server {
listen 80;
server_name localhost;
# 前端静态资源(Vue/React 打包产物)
location / {
root /usr/share/nginx/html;
index index.html;
# 单页应用刷新 404 兜底
try_files $uri $uri/ /index.html;
}
# 后端接口反向代理
location /api/ {
proxy_pass http://127.0.0.1:18080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
注意 proxy_pass 结尾有没有 / 会导致路径拼接结果不同:http://127.0.0.1:18080/ 会把 /api/ 剥掉,http://127.0.0.1:18080 则会把 /api/ 原样带上。这一条坑过很多人。
工程目录(本章快照)大致是这样组织的:
tree
mf
├── mf-autoconfig # 自动装配组件(短信等)
├── mf-model # 实体类 + VO(含 ErrorResult、PageResult)
├── mf-commons # 工具类(JwtUtils 等)
├── mf-app-server # 与手机端交互的入口模块(18080)
│ └── src/main/java/com/mf/server
│ ├── controller # 对外 HTTP 接口
│ ├── service # 业务编排
│ └── exception # 全局异常处理
└── mf-dubbo # Dubbo 子模块
├── mf-dubbo-interface # 接口定义
├── mf-dubbo-db # 数据库服务(18081)
└── mf-dubbo-mongo # MongoDB 服务
端到端数据流
把一次业务请求从浏览器到后端再回来的完整链路画清楚,分离模式的数据流就一目了然了:
Dubbo 服务/DB 后端应用(mf-app-server) Nginx 浏览器/App Dubbo 服务/DB 后端应用(mf-app-server) Nginx 浏览器/App #mermaid-svg-JWTAIbzi4BQf9mhL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JWTAIbzi4BQf9mhL .error-icon{fill:#552222;}#mermaid-svg-JWTAIbzi4BQf9mhL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JWTAIbzi4BQf9mhL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JWTAIbzi4BQf9mhL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JWTAIbzi4BQf9mhL .marker.cross{stroke:#333333;}#mermaid-svg-JWTAIbzi4BQf9mhL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JWTAIbzi4BQf9mhL p{margin:0;}#mermaid-svg-JWTAIbzi4BQf9mhL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JWTAIbzi4BQf9mhL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-JWTAIbzi4BQf9mhL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JWTAIbzi4BQf9mhL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-JWTAIbzi4BQf9mhL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-JWTAIbzi4BQf9mhL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-JWTAIbzi4BQf9mhL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-JWTAIbzi4BQf9mhL .sequenceNumber{fill:white;}#mermaid-svg-JWTAIbzi4BQf9mhL #sequencenumber{fill:#333;}#mermaid-svg-JWTAIbzi4BQf9mhL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-JWTAIbzi4BQf9mhL .messageText{fill:#333;stroke:none;}#mermaid-svg-JWTAIbzi4BQf9mhL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JWTAIbzi4BQf9mhL .labelText,#mermaid-svg-JWTAIbzi4BQf9mhL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-JWTAIbzi4BQf9mhL .loopText,#mermaid-svg-JWTAIbzi4BQf9mhL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-JWTAIbzi4BQf9mhL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JWTAIbzi4BQf9mhL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-JWTAIbzi4BQf9mhL .noteText,#mermaid-svg-JWTAIbzi4BQf9mhL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-JWTAIbzi4BQf9mhL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JWTAIbzi4BQf9mhL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JWTAIbzi4BQf9mhL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JWTAIbzi4BQf9mhL .actorPopupMenu{position:absolute;}#mermaid-svg-JWTAIbzi4BQf9mhL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-JWTAIbzi4BQf9mhL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JWTAIbzi4BQf9mhL .actor-man circle,#mermaid-svg-JWTAIbzi4BQf9mhL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-JWTAIbzi4BQf9mhL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /api/user/1 (静态页已加载) 反向代理 /user/1 (剥掉 /api) 查询用户数据 领域对象 JSON 响应体 { code, data } 原样转发 JSON 前端渲染页面
前端拿到 JSON 后只管渲染,后端只管返回数据,中间靠 Nginx(或网关)把两边拼在一个域名下。
实践坑清单
| 坑 | 表现 | 处理方案 |
|---|---|---|
| 返回结构不统一 | 有的接口 {code,data},有的直接返回数组 |
全项目统一封装,异常也走同一结构 |
| 字段命名风格不一致 | 后端 user_name,前端读 userName 拿到 undefined |
服务端序列化统一用驼峰,或前端拦截器做转换 |
| 日期格式不统一 | 一会儿时间戳、一会儿 yyyy-MM-dd 字符串 |
全局配置 Jackson 日期格式,契约里写死 |
| 分页参数名不一致 | 这个接口 page、那个接口 pageNum |
分页字段进契约,统一用 page/pagesize |
| 跨域配置开发/生产不一致 | 本地好使,一上线全报 CORS | 开发用代理、生产用网关,配置分离 |
| 接口文档过期 | 照着文档写,联调时字段全对不上 | 文档从代码生成,或契约评审 + 接口测试 |
| 错误码含义不明 | 前端只知道 500,不知道具体原因 | errCode 分段管理,前端按码分支 |
proxy_pass 尾斜杠 |
代理后路径多了一层 /api |
明确剥前缀:proxy_pass http://host:port/; |
登录页原型与接口对照
03-资料/交友项目移动端原型V1.2/登录页.html 是 App 端登录页的原型,正好可以拿来对照接口契约怎么落地:
doc
┌──────────────────────────────┐
│ │
│ 项目 · 交友 │ ← 品牌区
│ │
│ ┌────────────────────────┐ │
│ │ 请输入手机号 │ │ ← 手机号输入框
│ └────────────────────────┘ │
│ ┌──────────────┬─────────┐ │
│ │ 请输入验证码 │ 获取验证码│ │ ← 验证码输入 + 获取按钮
│ └──────────────┴─────────┘ │
│ │
│ ┌────────────────────────┐ │
│ │ 登 录 │ │ ← 主按钮
│ └────────────────────────┘ │
│ │
│ ☐ 已阅读并同意《用户协议》 │ ← 协议勾选
└──────────────────────────────┘
要点:
- 手机号输入框:11 位数字校验,未填或格式错时"获取验证码"按钮应置灰。
- "获取验证码"按钮:点击调
POST /user/login,参数{ "phone": "138xxxx" },成功后按钮进入 60 秒倒计时。 - 登录按钮:点击调
POST /user/loginVerification,参数{ "phone": "...", "verificationCode": "123456" }。 - 登录响应:
{ "token": "xxx", "isNew": false }。token是后续所有请求的鉴权凭证,isNew为true时前端跳"首次登录信息完善"页。 - 协议勾选:前端校验,未勾选不发请求------这类校验能放到前端的就别占后端资源。
API 速览
| 接口 | 方法 | 路径 | 请求参数 | 响应 |
|---|---|---|---|---|
| 获取登录验证码 | POST | /user/login |
{ phone } |
空 body,200 |
| 校验登录 | POST | /user/loginVerification |
{ phone, verificationCode } |
{ token, isNew } |
| 错误响应(统一) | --- | 任意 | --- | { errCode, errMessage } |
| 分页响应(统一) | --- | 列表类接口 | page、pagesize |
{ counts, pagesize, pages, page, items } |
官方文档
常见误解澄清
"前后端分离就是前端写页面、后端写接口。" 这只是分工描述。真正决定成败的是接口契约驱动 和独立部署------没有统一契约,分工再清楚也是联调灾难。
"跨域是后端的 bug。" 跨域是浏览器的同源策略在起作用,后端请求其实处理了。排查时先看是不是 CORS 提示,别上来就改后端业务代码。
"前端不需要了解后端。" 不了解契约前端根本没法联调。至少要知道接口返回结构、错误码含义、分页字段名。
"分离之后就不用管 SEO 了。" 对 Web 端的 C 端页面,分离(尤其是纯前后端分离 + JS 渲染)确实会削弱 SEO,需要 SSR/预渲染补救。但交友项目是 App,走的是原生客户端,不涉及这个问题------这也是它敢全量分离的原因之一。
总结
- 分离模式的工程重心在约定 :统一响应结构(
ErrorResult)、分页结构(PageResult)、URL 命名规范,一次定死、全项目一致。 - 错误处理要收敛到全局异常处理器,业务代码只抛异常,前端只面对一种错误格式。
- 接口文档就是契约,能自动生成就别手写;文档漂移是分离模式头号协作风险。
- 跨域开发期用前端代理、生产期用网关,别把调试用的 CORS 配置带上生产。
- 部署上前端交给 Nginx 托管静态资源并反向代理
/api/,后端只管出 API、独立扩容。 - 联调按"请求发了吗 → 状态码 → 响应体 → 跨域还是业务错"四步走,比盲目改代码快得多。
下一篇按讲义顺序进入「开发工具与环境搭建」,讲 Linux 虚拟机、Android 模拟器、Postman 的安装,以及 Maven 聚合工程从零搭起来的过程。