别再全是 200 了!—— 一套前后端都能无痛落地的 REST 接口规范
关键词:REST、HTTP 状态码、Optional、Null 安全、R 包装体、公司级规范
面向读者:后端、前端、测试、运维
目标:让「用户不存在」真正返回 404,让监控、缓存、Axios 拦截器一起省心
1. 为什么要“小题大做”?
现象 | 后果 |
---|---|
所有接口 HTTP 状态都是 200 | CDN 把 404 当成功缓存;前端必须写 if (res.data.code !== 200) |
返回 null / 空对象 {} |
前端无法区分“没查到”还是“字段全空”,页面白屏 |
多层 if (obj != null) |
代码臃肿,NPE 依旧横行 |
结论:我们需要
- 真 HTTP 状态码(4xx/5xx)
- Null 安全(Java8 Optional)
- 统一的 R 包装体(兼顾人和机器可读)
2. Java8 Optional —— 只当“返回值”,不当字段/参数
2.1 基本 API 速查表
// 创建
Optional.of(obj) // 非 null
Optional.ofNullable(obj) // 可 null
Optional.empty() // 空容器
// 消费
.isPresent() // boolean
.ifPresent(System.out::println)// 存在才消费
.orElse(other) // 兜底值
.orElseGet(Supplier) // 延迟兜底
.orElseThrow(() -> new XxxException("xxx", 404))
// 链式
.map(User::getName)
.filter(s -> s.startsWith("Bei"))
.flatMap(this::findCityByName) // 避免 Optional<Optional<City>>
2.2 典型 DAO → Controller 链路
@GetMapping("/api/v1/users/{id}")
public R<UserVo> getUser(@PathVariable Long id) {
return userMapper.selectById(id) // User
.map(UserConverter::toVo) // UserVo
.map(R::data) // R<UserVo>
.orElse(R.fail(404, "用户不存在")); // 404 + body
}
一行代码完成 null 判断 + 转换 + 真 404;前端 axios.get
直接进 then
/ catch
。
3. R 包装体设计(公司级统一)
@Data
@AllArgsConstructor
public final class R<T> implements Serializable {
private int code; // 业务码,0=success,其它=错误
private String msg; // 人类可读
private T data; // payload
// 快速工厂
public static <T> R<T> data(T data) {
return new R<>(0, "success", data);
}
public static <T> R<T> fail(int code, String msg) {
return new R<>(code, msg, null);
}
}
说明
code=0
与 HTTP 200 搭配,表示“业务成功”code≠0
可以与 4xx/5xx 搭配,仅作提示语,不被网关识别- 绝不出现
data:null
列表场景返回data:[]
,避免前端data.length
报错
4. 真 · HTTP 状态码使用矩阵
业务含义 | HTTP 状态 | R.code | R.msg 示例 | 前端 axios 处理 |
---|---|---|---|---|
查单条存在 | 200 | 0 | success | response.data.data |
查单条不存在 | 404 | 404 | 用户不存在 | 进 catch,弹 msg |
参数校验失败 | 400 | 400 | 手机号格式错误 | 进 catch,弹 msg |
未登录/Token 失效 | 401 | 401 | 请先登录 | 跳登录页 |
没权限 | 403 | 403 | 无权限 | 弹 Forbidden |
服务器未知异常 | 500 | 500 | 系统繁忙 | 统一弹“服务异常” |
代码落地
@ResponseStatus(HttpStatus.NOT_FOUND) // 关键!
public class BizException extends RuntimeException {
private final int code;
public BizException(int code, String msg) {
super(msg);
this.code = code;
}
}
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<R<Void>> handle(BizException e) {
R<Void> body = R.fail(e.getCode(), e.getMessage());
return ResponseEntity.status(e.getCode()).body(body);
}
}
5. 列表/分页 & 空数据返回约定
场景 | HTTP | R.data | 前端断言 |
---|---|---|---|
列表/分页 | 200 | [] 或 {list:[],total:0} |
list.length === 0 显示“暂无数据” |
Map/树 | 200 | {} |
Object.keys(data).length |
布尔/计数 | 200 | false / 0 |
直接取值 |
禁止返回
data:null
;前端不再写data && data.map
防御式代码
6. 日志 & 监控对接
- Nginx/AccessLog:只看 HTTP 状态 → 4xx/5xx 即错误
- Prometheus:相同规则,告警规则无需改造
- ELK/SLS:
status>=400
即异常采样,减少噪点 - Swagger:文档里能正确出现 200/404/400 多个 Response Example,Mock 更真实
7. 前端参考模板(axios 统一拦截)
import axios from 'axios';
import { Message } from 'element-ui';
import router from '@/router';
const http = axios.create({ baseURL: '/api' });
http.interceptors.response.use(
res => {
// 只有 HTTP 200 会进这里
const body = res.data;
if (body.code === 0) return body.data; // 直接返回业务数据
Message.error(body.msg); // 业务失败,弹提示
return Promise.reject(body);
},
err => {
// 所有 HTTP 4xx/5xx 进这里
const status = err.response?.status;
const msg = err.response?.data?.msg || '网络异常';
if (status === 401) router.push('/login');
else Message.error(msg);
return Promise.reject(err);
}
);
export default http;
前端再也不用写
if (res.data.code !== 200)
,真正的“关注业务,不关注 plumbing”
8. 常见坑 & QA
Q1. 404 会被公司网关重定向到友好页面?
→ 让运维把 /api/**
设为“纯接口域名”或关闭错误页重定向即可
Q2. 老项目全 200,改不动?
→ 新接口遵循即可;老接口加 @Deprecated
逐步切换,监控里把 code!=0
当错误先跑着
Q3. Optional 做字段/参数行不行?
→ 不行。序列化(Jackson)、Swagger 示例、可读性都会崩;只当返回值
Q4. 列表接口真的需要 404 吗?
→ 不需要。空列表是合法业务结果,HTTP 200 + data:[]
即可
9. 一句话总结(背下来)
“HTTP 状态码先认错,Optional 消灭 null,R 包装体给人看,监控缓存都省心。”
10. 附录:完整示例工程(SpringBoot + MyBatis)
# pom 关键依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.1</version>
</dependency>
分享完毕,欢迎 CR/提 Issue,一起把“200 全家桶”扔进历史!
本作品采用《CC 协议》,转载必须注明作者和本文链接