接口返回结构一天被问好几遍,根子不在前端,而在后端各写各的。更头疼的是异常处理,早期某个接口把堆栈直接抛给了前端,页面上冒出一串英文报错,用户根本看不懂。当时我对比过两种做法:一种是在每个 Controller 里各自 try-catch 然后统一返回,试了几天发现重复代码满天飞,异常处理还越写越乱;另一种是从架构上定一套全局规约,定义好”成功长什么样、失败长什么样”,让框架统一接管。我选了后者——BaseResponse<T> + ResultUtils + @RestControllerAdvice 三件套,从项目一开始就强制统一,前端只需要写一个响应拦截器就能吃下所有接口。
一、统一响应对象
先定义响应外壳,解决”所有接口返回格式不统一”的问题:
public class BaseResponse<T> {
private int code;
private T data;
private String message;
public BaseResponse(int code, T data, String message) {
this.code = code; this.data = data; this.message = message;
}
}
三段式:业务码、数据、消息。这里有个细节,code 用的是自定义业务码而不是 HTTP 状态码——HTTP 状态码语义太粗,200 既能表示成功也能表示”HTTP 正常但业务失败”,业务码能精确到”参数错误-密码为空”这种粒度。前端约定只看 code === 0 就当成功,其他都按业务错误处理,拦截器里一个判断就够。
这套外壳还顺带承担了”活文档”的作用。接口文档不用再为每个接口描述返回结构,前端看到 BaseResponse 就知道外层固定三字段,真正变化的只有 data 内部结构;平台后来接上接口文档自动生成后,前端连 data 里的字段都能自动拿到,对接效率又提了一截。
外壳定下来之后,我又在响应头里加了 traceId。早期排查问题时,一个请求从网关到数据库要穿四五层,日志分散在多处,没有关联标识只能靠时间戳猜。现在全局处理器打日志时带上 traceId,前端把请求失败时的 traceId 贴回来,顺着它就能把整条链路串起来。成本极低,排障效率却提升了一大截。
二、ResultUtils 工具类
BaseResponse 构造函数参数多,每个接口都 new 一遍很啰嗦,所以包一层静态工厂,解决”构造响应对象太繁琐”的问题:
public class ResultUtils {
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
}
public static <T> BaseResponse<T> error(ErrorCode ec, String msg) {
return new BaseResponse<>(ec.getCode(), null, msg);
}
public static BaseResponse<?> error(ErrorCode ec) {
return new BaseResponse<>(ec.getCode(), null, ec.getMessage());
}
}
Controller 写 return ResultUtils.success(data),比直接 new BaseResponse 干净一截。建议 success 和 error 的重载保持对称,团队用起来才不容易记混;也避免有人在业务代码里手滑把 code 和 data 传反,这类低级 bug 我们早期真的出过一次。后来我们还约定 error 的重载都必须传 ErrorCode 而不是裸 code,强制业务方在枚举里选错误码,新错误先登记再使用,错误码文档基本不会和实现脱节。
三、BusinessException 与 ErrorCode
业务异常用一个统一类,避免每个业务模块自定义异常子类导致异常类型爆炸。先定义异常本身:
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(ErrorCode ec) { super(ec.getMessage()); this.code = ec.getCode(); }
public BusinessException(ErrorCode ec, String msg) { super(msg); this.code = ec.getCode(); }
public int getCode() { return code; }
}
ErrorCode 用枚举集中所有错误码,平台约 20 个:
public enum ErrorCode {
SUCCESS(0, "ok"),
PARAMS_ERROR(40000, "请求参数错误"),
NOT_LOGIN_ERROR(40100, "未登录"),
NO_AUTH_ERROR(40101, "无权限"),
NOT_FOUND_ERROR(40400, "请求数据不存在"),
FORBIDDEN_ERROR(40300, "禁止访问"),
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败");
// ... 业务码
}
这样设计的好处是错误码全平台统一、可搜索,排障时看日志里的 code 就能定位到具体模块。业务代码里想抛异常就 throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码不能为空"),不需要关心异常后续怎么被翻译成响应,职责边界很清楚。
四、全局异常处理器
这是规约落地的关键一环。所有 Controller 不写 try-catch,异常都抛到这里统一翻译,解决”异常散落各处、响应格式不统一”的问题:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public BaseResponse<?> business(BusinessException e) {
log.warn("业务异常 code={} msg={}", e.getCode(), e.getMessage());
return ResultUtils.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseResponse<?> valid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldError().getDefaultMessage();
return ResultUtils.error(ErrorCode.PARAMS_ERROR, msg);
}
@ExceptionHandler(Exception.class)
public BaseResponse<?> all(Exception e) {
log.error("系统异常", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统繁忙");
}
}
三个分支覆盖三种典型场景:业务异常走 business 分支,参数校验失败走 valid 分支拿到具体字段错误,未知异常走 all 分支兜底。@Slf4j 把完整堆栈打进日志,前端拿到的是脱敏后的”系统繁忙”。需要留意的是,valid 分支让 @Valid 注解真正可用起来——以前参数校验错误信息是乱糟糟的默认格式,现在能精准返回”哪个字段错了、为什么错”。
这套处理器上线后,前端同学的体验变化很明显。以前参数校验报错,他们拿到的是 Spring 默认的一长串英文,现在能直接看到具体字段和原因,定位问题从翻代码变成看提示。接口联调的速度肉眼可见地变快,后端也不用再被追着问”这个报错是什么意思”。
五、为什么一定要做这件事
这套规约刚推行时,有同事觉得多此一举,直到项目进入联调期,对比才变得明显。把”各接口自己管”和”统一规约”两条路放在一起看,差别一目了然:
| 维度 | 各 Controller 自己管 | BaseResponse + 全局异常 |
|---|---|---|
| 响应结构 | 每个接口自定,data/result 混用 | 统一三字段,前端拦截器一把接 |
| 异常出口 | 散落各处,有的堆栈直接抛给前端 | 全局处理器统一翻译、统一打日志 |
| 新增接口成本 | 每个都要写 try-catch 和响应组装 | 直接 return,框架兜底 |
| 排障效率 | 翻接口源码逐段找 | 看全局日志流和错误码分布 |
结论很直接:规约的收益随接口数量线性放大,接口越多越省。不统一响应会出现的问题:
- 前端要写一堆适配:有的接口返回
data,有的返回result,有的直接返回对象。 - 错误码语义丢失:异常堆栈直接吐到前端,既不安全也不友好。
- 国际化困难:错误消息要本地化时,没有
message字段就只能改源码。 - 日志难关联:异常散落在各处,全局处理器能在这一层打统一日志,把 traceId 串起来。
这些问题我们早期几乎全踩过一遍,所以强烈建议任何新项目从第一个接口就定好规约——后面再补的代价远高于一开始就做。等接口数量上到几百个,再回头统一格式就是一场伤筋动骨的改造。还有一个容易被忽略的收益:统一响应让监控报警变得好做。全局处理器是异常的统一出口,告警系统只要盯住这一个点,就能看到全平台哪里在报错、错误码分布长什么样,排障路径从”翻遍每个接口”收敛成”看一条日志流”。
六、踩过的坑
规约跑顺之后,坑主要集中在边界场景。下面四条是平台实际踩过的,每一条都对应一个线上小事故:
- static 工具类被 Spring 接管:早期写过
ResultUtils.success(null),前端拿到data: null报错,改为统一返回空对象new HashMap<>()。 - 鉴权失败要返回 401 而不是 200:因为全局处理器返回的是 BaseResponse,默认 HTTP 200,业务码 40100 表示未登录,前端按业务码处理。如果前端要 HTTP 状态码判断,需要在处理器加
@ResponseStatus(HttpStatus.UNAUTHORIZED)。 - 超大异常不要进日志:调用大模型超时堆栈很深,5MB 起步,要 catch 后只记摘要。
- 404 与 500 风格不一:NoHandlerFoundException 默认走 Spring 默认页,要在
application.yml配spring.mvc.throw-exception-if-no-handler-found=true让它也进全局处理器。
这套规约在平台运行期间一直很稳定,几乎没有为响应格式返过工。真要找还能改进的地方,就是错误码目前二十多个,随着业务增长会越来越多,后续打算按模块分段规划,比如 41xxx 留给用户中心、42xxx 留给评测任务,让码段本身就带归属信息,查日志时连模块都不用猜。
常见问题(FAQ)
Q1:BaseResponse 的 code 用 HTTP 状态码还是自定义?
自定义。HTTP 状态码语义粗(200/401/500),业务码(0/40000/40100)能精确到”参数错误-密码为空”这种细粒度,前端可以基于业务码做不同提示。
Q2:全局异常会不会吞掉重要信息?
不会。在 all 分支先 log.error("系统异常", e) 把堆栈打全,再返回脱敏后的”系统繁忙”给前端。
Q3:要不要每个业务都自定义异常子类?
不用。BusinessException + ErrorCode 枚举足够覆盖 95% 场景,强行继承只会让异常类爆炸。