速读#
这篇讲统一返回体和全局异常,不是因为「最佳实践」四个字,而是因为当过前端,知道不统一会把协作成本甩给对方。成功失败都用同一结构,是给前端稳定契约;异常集中处理,是给自己未来排查留完整堆栈。
为什么没犹豫#
成功时返回数组、失败时返回字符串、有的接口直接返回对象——前端就得对每个接口单独解析。这件事我痛过,不是当后端时痛的,是用 Element-Plus 做前端时痛的:结构五花八门,出错还分不清是前端没接对还是后端没返对。
所以自己写后端时,统一成 {code, message, data} 没有犹豫。理由不是「最佳实践」,是当前端时被不统一坑过。
契约:前端只认一个结构#
public record ApiResult<T>(int code, String message, T data) { public static <T> ApiResult<T> ok(T data) { return new ApiResult<>(0, "success", data); } public static <T> ApiResult<T> fail(int code, String msg) { return new ApiResult<>(code, msg, null); }}无论成功失败,前端永远同一结构。axios 拦截器里判断 code !== 0 就弹错误,所有接口通用。
这不是让接口好看,是给前端一份契约。 契约稳定,对方能写通用逻辑;契约随意,对方要为你的随意写适配层。
业务 code 和 HTTP 状态码不是二选一,是分层:HTTP 状态码给网络层和通用工具看(拦截器、监控、缓存都认它),业务 code 给前端业务逻辑看(同样是 400,「参数缺失」和「余额不足」前端要走不同分支)。两层各说各的话,别用 200 包一切,也别指望 HTTP 状态码分清所有业务错误。
全局异常#
@Slf4j@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(NoSuchElementException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ApiResult<Void> handleNotFound(Exception e) { return ApiResult.fail(404, e.getMessage()); }
@ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResult<Void> handleInvalid(MethodArgumentNotValidException e) { var msg = e.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + ": " + f.getDefaultMessage()) .findFirst().orElse("参数不合法"); return ApiResult.fail(400, msg); }
@ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResult<Void> handleOther(Exception e) { log.error("unexpected", e); // 堆栈打全 return ApiResult.fail(500, "服务器开小差了"); }}每个处理器上那行 @ResponseStatus 不是装饰。不加它,处理器正常返回,HTTP 状态就是 200——body 里写着 code: 500,状态行却说 OK,正好踩翻上面「两层各说各的话」:监控和拦截器只认状态行,全 200 它们就瞎了。业务 code 归 body,HTTP 状态归状态行,两边都要如实。
集中处理的好处是分工清楚:业务代码只管抛,怎么翻译成响应由这一个类统一说了算。校验失败(@Valid 拦下的)翻译成「哪个字段错了」,能预期的业务异常翻译成对应 code,兜底的 Exception 才是「开小差」。
最在意的是 log.error("unexpected", e)——异常对象要带进去。
| 给谁看 | 要什么 |
|---|---|
| 用户 | 友好可读(「服务器开小差了」) |
| 自己 / 运维 | 完整堆栈(log.error 带异常对象) |
两件事不冲突,是分层的。打不全堆栈,等于把将来排查的路堵了——报错信息是写给未来的自己看的,深夜排查时你只有日志,没有现场。
反过来,「开小差了」这句模糊话也不只是嘴甜。兜底异常的真实 message 里可能带着表名、SQL 片段、内部路径,原样抛给客户端等于把内部结构漏出去。对用户模糊、对日志详尽——这一句话同时是体验的边界,也是安全的边界。
统一是默认,例外要标注#
文件下载、SSE 流等场景,天生不适合塞进 {code, message, data}。
统一是默认,例外明确标注。 别为了「统一」把不该统一的也硬塞。
回看:统一返回体固定「前端怎么读响应」(对外稳定),全局异常固定「错误打到多详细」(对内可查)。一个防护协作损耗,一个撑起可观测——两件都做,不互换。
