写在前面
项目早期最被低估的技术债,不是数据库设计,也不是架构分层,而是报错方式。
每个 Controller 自己拼返回结构:有的返回 {code: 200},有的返回 {success: true},有的直接把实体丢出去;报错更随意——有的抛 RuntimeException 变成前端眼里的 500,有的返回 200 但 body 里塞一句"操作失败"。前端同学每次对接新接口都要先问一句:“你这个接口报错的时候长什么样?”
一个所有接口都回答不一致的问题,就是系统性问题。这篇记录我后来怎么把响应、异常、错误码、requestId 这四件事一次性统一掉的。总共不到两百行代码,但它是全项目被引用最多的两百行。
一、先定形状:统一响应包
所有接口返回同一个外壳:
@Data
public class R<T> implements Serializable {
private int code;
private String message;
private T data;
private String requestId;
public static <T> R<T> ok(T data) {
R<T> r = new R<>();
r.setCode(0);
r.setMessage("ok");
r.setData(data);
r.setRequestId(resolveRequestId());
return r;
}
public static <T> R<T> fail(int code, String message) { ... }
}
形状本身不稀奇,稀奇的是几个必须一开始就想清楚的决策:
code = 0 表示成功,不是 200。 0 在大多数语言里都是"无错误"的传统值,而且它和 HTTP 状态码 200 天然区分开——HTTP 状态码描述传输层,code 描述业务层,两套编码系统混在一起迟早打架。
data 泛型,成功才可能有数据。 失败响应的 data 永远是 null,错误信息只放 message。不要发明 {code: 500, data: {reason: ...}} 这种嵌套——错误详情属于 message 或者日志,不属于 data。
requestId 自动注入。 这是整个外壳里我最满意的部分:R.ok() 里面调 resolveRequestId(),从 RequestContextHolder 拿当前请求的关联 ID。业务代码完全不用管 requestId,它自己就在。
// 非 Web 环境(单测、异步线程)拿不到 request,返回 null 不报错
private static String resolveRequestId() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
...
}
注意这个判空:统一响应包会被单测和异步线程复用,那里没有请求上下文。工具类对环境的宽容度,决定了它会不会变成"只在 Web 层能用"的半残品。
二、错误码:前缀编码,不用发明新体系
错误码我见过两种失败方案:一种是全靠乱编(30001、50002……半年后没人记得 30004 是什么),一种是过度设计(错误码分五段,每段有含义,配一张 Excel 对照表)。
我的方案是让业务码"长得像"它对应的 HTTP 状态:
public enum BusinessErrorCode {
GENERAL(100000, HttpStatus.BAD_REQUEST),
BAD_REQUEST(100400, HttpStatus.BAD_REQUEST),
UNAUTHORIZED(100401, HttpStatus.UNAUTHORIZED),
FORBIDDEN(100403, HttpStatus.FORBIDDEN),
NOT_FOUND(100404, HttpStatus.NOT_FOUND),
CONFLICT(100409, HttpStatus.CONFLICT),
LOCKED(100423, HttpStatus.LOCKED),
TOO_MANY_REQUESTS(100429, HttpStatus.TOO_MANY_REQUESTS),
INTERNAL_ERROR(100500, HttpStatus.INTERNAL_SERVER_ERROR),
...
}
1 开头表示业务错误,后五位就是 HTTP 状态码。看到 100429 不用查表也知道是"请求过多",看到 100403 就是"没权限"。错误码的第一受众是人,不是程序——能让同事一眼读懂的编码,才不用维护对照表。
每个枚举值同时绑定 HttpStatus,业务抛错时只说"我是 CONFLICT",状态码由枚举统一决定,Controller 里从此不再出现 ResponseEntity.status(409) 这种手写数字。
三、业务异常:只带两个信息
@Getter
public class BizException extends RuntimeException {
private final int code;
private final HttpStatus httpStatus;
public BizException(int httpStatus, String message) {
this(BusinessErrorCode.fromHttpStatus(httpStatus), message);
}
public BizException(BusinessErrorCode errorCode, String message) {
super(message);
this.code = errorCode.code();
this.httpStatus = errorCode.httpStatus();
}
}
业务异常只携带"业务码 + 人话"两样东西。它是 RuntimeException——业务错误不是调用方"必须当场处理"的受检异常,让它一路向上飘到统一出口即可。Service 层的写法因此变成一行:
throw new BizException(409, "该部门下仍有在职员工,无法删除");
不写 try、不写返回值判空、不用 Result<T> 包着 errorCode 往上传。异常的错误传播路径,比返回值干净一个数量级——前提是有下面的统一出口接住它。
四、统一出口:一层兜底,各管一段
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<R<Void>> handleBizException(BizException e) {
return ResponseEntity.status(e.getHttpStatus()).body(R.fail(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public R<Void> handleValidationException(MethodArgumentNotValidException e) {
String message = ...; // 取字段校验信息
return R.fail(BusinessErrorCode.BAD_REQUEST.code(), message);
}
// 兜底:未预期的异常统一返回 500,详细信息只记日志不暴露给前端
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public R<Void> handleException(Exception e) {
log.error("系统异常", e);
return R.fail(BusinessErrorCode.INTERNAL_ERROR.code(), "系统内部错误");
}
}
结构是"从具体到兜底"的漏斗:业务异常按自己的状态码返回;参数校验、认证、权限、上传超限这些"可预期错误"各有专属文案;最后 Exception.class 兜底——记完整堆栈到日志,给前端一句毫无信息量的"系统内部错误"。
这句"毫无信息量"是故意的。数据库连接串、SQL 片段、内部路径,这些堆栈里的东西对用户毫无价值,对攻击者价值连城。用户要的从来不是异常详情,是"出错了,requestId 是 abc123,找客服能查到"。
五、一个真实的坑:multipart 的两面性
统一异常里有个比较绕的处理,值得单独讲:
@ExceptionHandler(MultipartException.class)
public ResponseEntity<R<Void>> handleMultipartException(MultipartException e) {
if (isMalformedMultipart(e)) {
return ResponseEntity.badRequest()
.body(R.fail(BusinessErrorCode.BAD_REQUEST.code(), "上传请求格式错误"));
}
log.error("解析 multipart 请求失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(R.fail(BusinessErrorCode.INTERNAL_ERROR.code(), "系统内部错误"));
}
同样是"解析上传请求失败",可能是用户的错(请求体被截断、boundary 错乱、用 curl 传坏了文件),也可能是服务端的错(临时目录不可写、IO 异常)。前者该 400 让用户检查请求,后者该 500 让运维看日志。Spring 把这两种都包成同一个 MultipartException,所以只能顺着 cause 链往下挖,判断根因是不是 FileUploadException 家族。
这个小函数背后是个大原则:异常的 HTTP 语义由"谁造成了这个错误"决定,而不是由"抛了什么类"决定。 4xx 意味着"你重试也一样,请改你的请求",5xx 意味着"我这边出问题了"——分错了,监控系统会报警报向错误的方向。
六、小结
三点体会:
- 统一响应的价值在"一致性",不在结构本身。
{code, message, data, requestId}没有任何高明之处,高明的是全项目两千个接口都长这样——前端可以写一个通用的请求封装,错误提示、跳转登录、requestId 上报全部只写一次。 - 错误码要能被人类直接读懂。 编码方案的第一受众是凌晨排查问题的同事。
100429不需要查表,这就赢了。 - 兜底 handler 是安全边界,不是垃圾桶。 它负责两件事:不让内部细节泄露给外界,不让任何异常裸奔成 Tomcat 默认错误页。做到这两件事,其余的都可以"具体错误具体处理"。
统一之后,前端的请求封装长这样:code !== 0 就弹 message,401 跳登录,其他一律带上 requestId 上报到监控。对接新接口的问题从"你报错长什么样"变成了"你的业务码是哪个"——进步不大,但每一次对接都省下了那五分钟。
默认评论
Halo系统提供的评论