速读#
这篇讲统一返回体和全局异常,不是因为“最佳实践”四个字,而是因为当过前端,知道随意变化会把协作成本甩给对方。项目既然选择 {code, message, data},就让普通 JSON 接口稳定遵守;HTTP 状态仍如实返回,异常翻译集中处理,日志则给未来排查留下完整证据。
为什么没犹豫#
成功时返回数组、失败时返回字符串、有的接口直接返回对象——前端就得对每个接口单独解析。这件事我痛过,不是当后端时痛的,是用 Element-Plus 做前端时痛的:结构五花八门,出错还分不清是前端没接对还是后端没返对。
所以自己写后端时,统一成 {code, message, data} 没有犹豫。理由不是「最佳实践」,是当前端时被不统一坑过。
契约:前端只认一个结构#
public record ApiResult<T>(String code, String message, T data) { public static <T> ApiResult<T> ok(T data) { return new ApiResult<>("OK", "success", data); } public static <T> ApiResult<T> fail(String code, String msg) { return new ApiResult<>(code, msg, null); } public static <T> ApiResult<T> fail(String code, String msg, T details) { return new ApiResult<>(code, msg, details); }}普通 JSON 成功与失败都保持同一外壳。Axios 遇到非 2xx 会直接进入 rejected 分支,所以成功拦截器读取 response.data,错误拦截器再从 error.response?.data 读取同一结构;不能只在成功分支判断业务码,否则 4xx、5xx 根本走不到那里。
这不是让接口好看,是给前端一份契约。 契约稳定,对方能写通用逻辑;契约随意,对方要为你的随意写适配层。
业务 code 和 HTTP 状态码不是二选一,而是分层:HTTP 状态码给代理、监控、缓存和通用客户端看,业务码给界面逻辑和埋点看。同样是 HTTP 400,VALIDATION_FAILED 与 INSUFFICIENT_BALANCE 可以走不同分支;业务码使用稳定、可搜索的名字,也避免 body 再写一个 404 让人误以为它只是状态码复制。别用 200 包一切,也别指望 HTTP 状态码表达全部业务语义。
全局异常#
@Slf4j@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(NoteNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ApiResult<Void> handleNotFound(NoteNotFoundException e) { return ApiResult.fail("NOTE_NOT_FOUND", "笔记不存在"); }
@ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResult<List<FieldViolation>> handleInvalid(MethodArgumentNotValidException e) { var details = e.getBindingResult().getFieldErrors().stream() .map(f -> new FieldViolation(f.getField(), f.getDefaultMessage())) .toList(); return ApiResult.fail("VALIDATION_FAILED", "请求参数不合法", details); }
@ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResult<Void> handleOther(Exception e) { log.error("unexpected error", e); // 保留完整堆栈 return ApiResult.fail("INTERNAL_ERROR", "服务器开小差了,请稍后重试"); }}
record FieldViolation(String field, String message) {}每个处理器上的 @ResponseStatus 不是装饰。不加它,处理器正常返回时 HTTP 状态就是 200,body 写着 INTERNAL_ERROR,状态行却说 OK,监控和网关会被误导。状态需要按异常动态决定时可以直接返回 ResponseEntity<ApiResult<?>>,但固定映射没必要多包一层。业务 code 归 body,HTTP 状态归状态行,两边都要如实。
集中处理的好处是分工清楚:业务代码抛有语义的领域异常,Advice 负责翻译成外部契约。不能把 NoSuchElementException 这类通用异常一律映射为 404,因为它也可能来自代码缺陷;NoteNotFoundException 才明确表示客户端请求的资源不存在。校验失败返回字段和安全的提示,不回显用户提交值;能预期的冲突、权限或状态错误各有业务码,最后才由 Exception 兜底。
最在意的是 log.error("unexpected error", e)——异常对象要带进去。生产日志还应由入口过滤器或链路追踪补上 request ID、路径和服务版本,让这一条堆栈能和具体请求对上;密码、令牌、请求正文和个人数据不能因为“排查方便”就整包写入日志。
| 给谁看 | 要什么 |
|---|---|
| 用户 | 友好可读(「服务器开小差了」) |
| 自己 / 运维 | 完整堆栈(log.error 带异常对象) |
两件事不冲突,是分层的。打不全堆栈,等于把将来排查的路堵了——报错信息是写给未来的自己看的,深夜排查时你只有日志,没有现场。
反过来,“开小差了”这句模糊话也不只是嘴甜。兜底异常的真实 message 里可能带着表名、SQL 片段和内部路径,原样返回会泄露实现细节。对客户端稳定且克制、对受控日志保留堆栈——这是体验边界,也是安全边界。
统一是默认,例外要标注#
文件下载、SSE、图片、204 No Content 等响应天生不适合塞进 {code, message, data}。认证和授权异常通常发生在 Spring Security 过滤器链里,也不会进入 @RestControllerAdvice,要由 AuthenticationEntryPoint 与 AccessDeniedHandler 返回同一错误契约;不要以为写了一个 Advice 就覆盖了整条 HTTP 链。
统一是默认,例外明确标注。 别为了「统一」把不该统一的也硬塞。
如果是面向外部的新 API,我会先评估 Spring 6 自带的 ProblemDetail(RFC 9457),它已经提供标准错误结构;只有现有客户端确实依赖 {code, message, data} 时才维护自定义协议。统一的价值在契约,不在重复发明一个长得不同但表达相同的错误壳。
回看:统一返回体固定「前端怎么读响应」(对外稳定),全局异常固定「错误打到多详细」(对内可查)。一个防护协作损耗,一个撑起可观测——两件都做,不互换。
