接口报错全返回 500?Spring Boot 全局异常处理与统一响应体实战(面试常问)

一、起因:前端同学的一句话

我的物联网设备接入平台(Spring Boot 3.5 + EMQX)有一个看板前端,数据全靠 REST 接口喂。有天自己扮演前端联调,故意乱发请求,结果发现一个尴尬的现象:

  • 用 GET 去访问一个只支持 POST 的接口 → 返回 500 服务器内部错误;
  • POST 的 body 写成非法 JSON → 也是 500 服务器内部错误。

这两种明明都是调用方的错,却报成了"服务器内部错误"。调用方看到 500 会以为我的服务挂了,实际上是他的请求姿势不对。更糟的是,服务端日志还会记一整条 ERROR 堆栈------客户端的错,把真正的服务端故障日志淹掉了。

这篇文章就讲我这套接口的错误处理是怎么搭的(统一响应体 + 全局异常处理器),以及这次实测揪出来的两个兜底坑。所有代码都来自项目真实源码,所有响应都是实测结果。

二、先立规矩:统一响应体

第一件事是让所有接口返回同一种结构。项目里的 ApiResponse:

java 复制代码
// 来源:src/main/java/com/iothub/common/ApiResponse.java
/**
 * 统一响应体。所有接口都返回这个结构,前端只需要判断 code。
 *
 * 面试考点:为什么要统一响应体?
 * 答散装 JSON 每个接口结构不同,前端没法统一处理;约定 code/message/data
 * 三段式后,拦截器、错误处理、前端封装都能按同一套逻辑走。
 */
public record ApiResponse<T>(int code, String message, T data) {

    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(0, "success", data);
    }

    public static <T> ApiResponse<T> error(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }
}

三段式:code 为 0 表示成功,非 0 就是错误码;data 只装业务数据。浏览器里直接访问概览接口,看到的真实返回:

json 复制代码
{"code":0,"message":"success","data":[{"id":1,"deviceName":"temp-sensor-01","productKey":"productA","status":"offline","lastReceivedAt":null,"metrics":{}}]}

一个 record 三行就写完了------Java 17 之后这种"纯数据载体"用 record 比 class 省事得多,这也是个面试小加分点。

三、业务代码只管抛:BusinessException

有了统一结构,业务层的错误怎么"变成"这个结构?答案是自定义异常:

java 复制代码
// 来源:src/main/java/com/iothub/common/BusinessException.java
/**
 * 业务异常:service 层抛出,携带 HTTP 状态码,由全局异常处理器统一转成响应体。
 * 好处:业务代码只管抛,controller 不用到处 try-catch。
 */
@Getter
public class BusinessException extends RuntimeException {

    private final int status;

    public BusinessException(int status, String message) {
        super(message);
        this.status = status;
    }
}

service 层用起来很干净,比如设备注册时的重名检查(真实代码):

java 复制代码
// 来源:src/main/java/com/iothub/device/DeviceService.java(节选)
public RegisterResult register(RegisterDeviceRequest request) {
    if (deviceRepository.existsByProductKeyAndDeviceName(request.getProductKey(), request.getDeviceName())) {
        throw new BusinessException(409, "同名设备已存在");
    }
    // ... 正常注册流程
}

注意两点:RuntimeException 而不是受检异常,业务代码不用被 try-catch 包住;异常自带 HTTP 状态码(409 冲突),谁抛谁定语义。

四、@RestControllerAdvice:把所有异常收进一张网

最后一块拼图是全局异常处理器,负责把 controller 抛出来的所有异常统一转成 ApiResponse:

java 复制代码
// 来源:src/main/java/com/iothub/common/GlobalExceptionHandler.java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ": " + f.getDefaultMessage())
                .findFirst()
                .orElse("参数错误");
        return ResponseEntity.badRequest().body(ApiResponse.error(400, msg));
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
        return ResponseEntity.status(e.getStatus())
                .body(ApiResponse.error(e.getStatus(), e.getMessage()));
    }

    @ExceptionHandler(NoResourceFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
        log.debug("静态资源不存在: {}", e.getResourcePath());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.error(404, "资源不存在"));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception e) {
        log.error("未处理异常", e);
        return ResponseEntity.internalServerError()
                .body(ApiResponse.error(500, "服务器内部错误"));
    }
}

(源码里还有一个 IllegalArgumentException 的处理器,篇幅原因略。)

几个设计点:

  1. 参数校验失败(400)返回具体哪个字段错了 。配合 @Valid 和实体上的 @NotBlank(message = "deviceName 不能为空"),调用方能直接看到改哪里。
  2. 未知异常返回 500 但不泄露堆栈 。message 是固定的"服务器内部错误",堆栈只进服务端日志------这是安全底线,面试常问。
  3. 404 单独处理而不是落进兜底 。源码注释里写了原因:浏览器每次来要 favicon.ico 都会触发一次 NoResourceFoundException,如果落进 Exception 兜底,正常请求会打一整条 ERROR 堆栈,把真故障淹掉。

五、实测:正确的错误各回各家

服务起在 8080,用 curl 把三条错误路径都打了一遍(全部真实结果):

text 复制代码
# 1) 参数校验失败 → 400
POST /api/devices  {"productKey":"productA"}
{"code":400,"message":"deviceName: deviceName 不能为空","data":null}

# 2) 同名设备冲突 → 409(BusinessException)
POST /api/devices  {"deviceName":"temp-sensor-01","productKey":"productA"}
{"code":409,"message":"同名设备已存在","data":null}

# 3) 未知路径 → 404(NoResourceFoundException)
GET /api/not-exist
{"code":404,"message":"资源不存在","data":null}

结构统一、状态码准确、message 可读------到这里一切都对。

六、翻车现场:两种调用方错误被兜成了 500

然后就是开头说的两连翻车:

text 复制代码
# 4) 用 GET 访问只支持 POST 的接口 → 预期 405,实际 500
GET /api/devices/1/reset-secret
{"code":500,"message":"服务器内部错误","data":null}

# 5) POST body 写成非法 JSON → 预期 400,实际 500
POST /api/devices  '{bad json'
{"code":500,"message":"服务器内部错误","data":null}

根因是同一个:@ExceptionHandler(Exception.class) 是无条件兜底 。Spring 对"请求方法不支持"(HttpRequestMethodNotSupportedException)和"JSON 解析失败"(HttpMessageNotReadableException)本来有各自的标准映射(405/400),但这两类异常没有专属处理器时,会直接落进最宽的 Exception 兜底------被当成未知异常,记 ERROR 堆栈、返回 500。

这类问题的危害不只是状态码难看:客户端的重试策略会跟着错(500 让人以为重试有用,405 重试毫无意义),服务端日志被无效堆栈刷屏,真正的故障反而看不见。这其实是第一章"404 单独处理"的教训换了个马甲再犯一次------"调用方的错"永远不该按"服务端故障"记账。

七、修复:兜底之前先铺两张网

给处理器补上两个专属方法,状态码和语义就归位了:

java 复制代码
// 修复代码:GlobalExceptionHandler 新增(方法不支持 → 405)
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupported(
        HttpRequestMethodNotSupportedException e) {
    return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
            .body(ApiResponse.error(405, "请求方法不支持: " + e.getMethod()));
}

// 修复代码:GlobalExceptionHandler 新增(请求体解析失败 → 400)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleNotReadable(HttpMessageNotReadableException e) {
    return ResponseEntity.badRequest()
            .body(ApiResponse.error(400, "请求体不是合法 JSON"));
}

Spring 的异常匹配规则是先精确后兜底 :抛出的异常能命中更具体的 @ExceptionHandler,就绝不落到 Exception.class。所以只要补上网,405/400 自动归位,兜底继续只接真正的意外。

面试聊到这里的加分句:兜底处理器解决的是"别把堆栈泄露给客户端",而不是"什么错都往里塞";每发现一类"正常错误"被 500 接走,就给它单独加一层。

八、小结与面试考点

这套组合拳一句话总结:前端永远收到同一种结构,服务端永远知道哪个错误是谁的。

面试可以主动讲的点:

  • 为什么统一响应体:结构一致后,前端封装、错误处理、拦截器都按同一套逻辑走;代价是放弃了 HTTP 状态码的细粒度表达,所以用 HTTP status + 业务 code 双轨补齐。
  • @RestControllerAdvice 的匹配优先级 :精确类型 > 父类,Exception.class 是最后一张网;不要让客户端错误(4xx)落进它。
  • 全局异常处理器的安全意义:未知异常不能把堆栈、SQL、内部路径透给客户端,message 固定、堆栈进日志。
  • record 当响应体:不可变、天然是数据载体,Java 17+ 项目里的惯用法。

作者:软件工程在读(专升本),正在从零搭一套物联网设备接入平台(Spring Boot + EMQX + MySQL),踩过的坑都写成文章。上一篇写了 MQTT 遗嘱消息,这一篇回到后端基本功。欢迎关注,下期见。

相关推荐
律宏阔1 小时前
Dart FFI 回调无法使用局部变量?使用 NativeCallable.isolateLocal
前端·flutter
hai_android1 小时前
一个公式看懂动态跨表查找:P8 + VLOOKUP
前端·javascript·vue.js
鬼手点金2 小时前
opencode-全能开发者配置
java·linux·服务器·前端·javascript·学习·前向传播
溪语流沙2 小时前
【Web全栈进阶】Alembic数据库迁移:改表结构不再删库
前端·数据库·python·elasticsearch·postgresql
AI你一生一世2 小时前
当构建工具成为基础设施:从 Tailwind 被收购谈起,前端工具链的“归属”难题
前端·构建工具·tailwind css·前端工具链·开源软件收购·技术中立性·前端基础设施
_zxd2 小时前
TypeScript 类型树
前端·typescript
炸鸡叔2 小时前
我做了 BotBus:从手机续聊本地 Agent,查看文件和终端
前端·后端
骑着蜗牛撵大象3272 小时前
多 Agent 串行流水线:把一个任务拆成可重试、可续跑的 Pipeline 节点链
前端·langchain