统一返回体不是"套一层"
几乎每个后端项目都会定义类似 Result<T> 的结构,但真正落地时问题不少:有的接口返回 {"code":0,"data":...},有的直接返回数组,有的出错时返回 HTTP 500 加一段 HTML;前端不得不为每个接口写一套判断,联调时间全耗在"这个接口成功时到底长什么样"上。
统一返回体的价值不只是好看,它让前端、网关、监控、自动化测试都有稳定的契约。这篇文章从协议设计讲到全局异常处理,重点说清楚 HTTP 状态码和业务码的关系,以及那些"看起来统一了、实际还有漏网之鱼"的情况。
先定协议
一个够用的返回体包含四部分:业务码、提示信息、数据、链路标识。
public record Result<T>(int code, String message, T data, String traceId) {
public static <T> Result<T> ok(T data) {
return new Result<>(0, "ok", data, MDC.get("traceId"));
}
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null, MDC.get("traceId"));
}
}
业务码建议分段管理,不要随手写数字:
| 区段 | 含义 | 示例 |
|---|---|---|
| 0 | 成功 | 0 |
| 400xx | 参数或请求错误 | 40001 参数校验失败 |
| 401xx | 未认证 | 40100 token 过期 |
| 403xx | 无权限 | 40300 无权访问该资源 |
| 404xx | 资源不存在 | 40400 订单不存在 |
| 500xx | 服务端错误 | 50000 系统异常 |
定义成枚举或常量类,和 HTTP 状态码分开维护。前端只认 code,运维和网关更关注 HTTP 状态,两套东西各司其职。
全局异常处理
参数校验失败、业务主动抛错、数据库异常,最终都要收敛到同一个出口:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result<Void>> handleBusiness(BusinessException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Result.fail(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result<Void>> handleValid(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + " " + err.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest().body(Result.fail(40001, message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<Void>> handleOther(Exception e) {
log.error("未处理异常", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Result.fail(50000, "系统繁忙,请稍后重试"));
}
}
BusinessException 继承 RuntimeException,带上业务码即可。参数校验的异常类型容易混:@RequestBody 加 @Valid 抛 MethodArgumentNotValidException,表单绑定抛 BindException,方法参数上的 @Validated 约束抛 ConstraintViolationException,请求体不是合法 JSON 则抛 HttpMessageNotReadableException。这几种最好分别处理,否则前端拿到的永远是"系统繁忙"。
HTTP 状态码和业务码怎么配合
常见的两种流派:
- 全部返回 200,靠
code区分成功失败。实现简单,前端统一处理方便,但网关、负载均衡、监控看到的状态永远是成功,错误率统计会失真。 - HTTP 状态码表达类别,业务码表达细节。参数错误 400,未登录 401,无权限 403,不存在 404,服务端异常 500,响应体里再带具体业务码。
更推荐第二种。它让浏览器、Nginx、Prometheus 这些不关心业务码的组件也能正确识别请求结果。需要注意的是,一旦选了第二种,就要保持一致:不能这个接口 400、那个接口 200 加错误码,否则前端还是要写两套逻辑。
常见坑
1. 全局异常处理器接不住所有异常
@RestControllerAdvice 只处理进入 DispatcherServlet 之后的异常。Filter 里抛的异常、Tomcat 层面的 404、连接超时,都不会走到这里。404 需要额外配置 spring.mvc.throw-exception-if-no-handler-found=true 并关闭静态资源映射,才能被 NoHandlerFoundException 捕获。
2. 返回 String 时包装失效或报错
如果项目用 ResponseBodyAdvice 做自动包装,Controller 返回 String 时会被 StringHttpMessageConverter 处理,强转成 Result 会抛 ClassCastException。要么统一返回 Result,要么在 advice 里判断转换器类型并跳过。
3. 把所有异常都返回 200
前面提过,最直接的后果是监控失真、重试机制失效。有些客户端 SDK 只对 5xx 重试,全 200 会让它认为请求成功,实际上数据根本没写进去。
4. 把异常信息原样返回前端
e.getMessage() 里可能包含 SQL、表名、文件路径,甚至内网地址。对外只返回统一文案,详细堆栈写日志,用 traceId 关联。
5. 捕获范围过大
为了"兜底"直接 catch (Throwable t),会把 OutOfMemoryError、StackOverflowError 也吞掉,应用状态已经不可信却还在继续处理请求。兜底捕获 Exception 就够了。
6. 校验异常提示不友好
直接把 BindingResult 的 toString() 返回,前端会收到一长串对象字符串。按字段拼成"用户名 不能为空"这种可读文案,体验会好很多。
7. 成功时 data 为 null 还是空对象
列表接口返回 null 还是 [],详情接口查不到是抛 404 还是返回 data: null,团队里要提前约定。比较稳妥的做法是:列表永远返回数组,详情查不到走业务异常,前端不用做双重判断。
小结
统一响应规范真正难的不是写 Result 类,而是把异常出口收干净、把状态码约定贯彻到底。落地时可以先从"所有 Controller 只返回 Result"和"所有异常必须经过 @RestControllerAdvice"两条硬规则开始,再逐步补齐参数校验、404、鉴权失败的场景。
评论