
一、起因:前端同学的一句话
我的物联网设备接入平台(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 的处理器,篇幅原因略。)
几个设计点:
- 参数校验失败(400)返回具体哪个字段错了 。配合
@Valid和实体上的@NotBlank(message = "deviceName 不能为空"),调用方能直接看到改哪里。 - 未知异常返回 500 但不泄露堆栈 。
message是固定的"服务器内部错误",堆栈只进服务端日志------这是安全底线,面试常问。 - 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 遗嘱消息,这一篇回到后端基本功。欢迎关注,下期见。