Spring Boot 4.0 全局异常处理与错误码体系设计
前言
项目只有两个异常处理器时,通常还能正常工作。接口一多,很快就会出现同一个“订单不存在”在不同 Controller 中分别返回 200、404 和 500 的情况。
调用方真正需要的是稳定契约:HTTP 状态码说明请求结果,业务错误码说明失败类型,字段明细告诉用户该修改什么。
本章会在订单接口上建立统一异常出口,覆盖四类失败:
- 订单不存在。
- 请求体或查询参数不合法。
- 请求方法、媒体类型或 JSON 格式错误。
- 未预料到的服务端异常。
配套源码:dvsusan/spring-boot4-demo。
最终结果
请求一笔不存在的订单:
curl -i "http://localhost:8080/api/v1/orders/SO-NOT-FOUND"返回:
HTTP/1.1 404
Content-Type: application/json
{
"code": "ORDER_NOT_FOUND",
"message": "订单不存在: SO-NOT-FOUND",
"data": null,
"timestamp": "2026-11-06T05:00:00Z"
}提交空客户编号和非法金额时,返回 400 和字段级明细:
{
"code": "INVALID_ARGUMENT",
"message": "请求参数不合法",
"data": [
{
"field": "customerId",
"message": "客户编号不能为空"
},
{
"field": "amount",
"message": "订单金额必须在 0.01 到 1000000.00 之间"
}
]
}
图 1:不同异常先分类,再映射到稳定错误码;只有未知异常输出完整服务端日志。
第 1 步:定义错误码的唯一来源
创建:
demo-web/src/main/java/com/susan/training/web/error/ApiErrorCode.java/**
* API 错误码的唯一来源。
*/
public enum ApiErrorCode {
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "订单不存在"),
INVALID_ARGUMENT(HttpStatus.BAD_REQUEST, "请求参数不合法"),
INVALID_JSON(HttpStatus.BAD_REQUEST, "请求体不是合法 JSON"),
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "请求媒体类型不受支持"),
NOT_ACCEPTABLE(HttpStatus.NOT_ACCEPTABLE, "无法生成调用方接受的响应格式"),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "请求方法不受支持"),
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部错误");
private final HttpStatus status;
private final String defaultMessage;
ApiErrorCode(HttpStatus status, String defaultMessage) {
this.status = status;
this.defaultMessage = defaultMessage;
}
public HttpStatus status() {
return status;
}
public String defaultMessage() {
return defaultMessage;
}
}枚举把状态码和业务码绑定在一起。新增错误时只改这里,不在每个 Controller 中手写字符串。
业务码使用大写下划线格式。它描述机器可判断的失败类型,message 则用于人阅读,两者不能互相替代。
第 2 步:定义字段错误明细
创建:
demo-web/src/main/java/com/susan/training/web/error/FieldViolation.java/**
* 单个字段的校验失败说明,客户端可以直接映射到表单项。
*/
public record FieldViolation(String field, String message) {
}字段错误放进 ApiResponse.data:
{
"code": "INVALID_ARGUMENT",
"message": "请求参数不合法",
"data": [
{"field": "customerId", "message": "客户编号不能为空"}
]
}这样不需要扩展公共响应外壳,也不会因为一个接口新增错误格式而破坏其他接口。
如果团队采用 RFC 9457 ProblemDetail,也可以使用标准 type/title/detail/instance 字段。关键是全项目只保留一种错误协议。
第 3 步:把业务异常映射为 404
订单业务模块只定义自己的异常:
/**
* 业务模块只表达“订单不存在”,不依赖 HTTP 状态码。
*/
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(String orderNo) {
super("订单不存在: " + orderNo);
}
}业务异常不导入 HttpStatus。业务模块不知道接口是 HTTP、消息还是批处理,Web 层负责翻译:
// 业务异常由 Web 层翻译成 404 和稳定业务码。
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(
OrderNotFoundException exception
) {
return response(ApiErrorCode.ORDER_NOT_FOUND, exception.getMessage());
}ResponseEntity.status(...) 同时设置 HTTP 状态和响应体。不要只把 code 写成 404,却让 HTTP 状态仍然是 200。
第 4 步:处理请求体字段错误
完整处理器位于:
demo-web/src/main/java/com/susan/training/web/GlobalExceptionHandler.java处理请求体校验:
// 请求体校验失败时收集全部字段错误。
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<List<FieldViolation>>> handleBodyValidation(
MethodArgumentNotValidException exception
) {
List<FieldViolation> violations = exception.getBindingResult()
.getFieldErrors()
.stream()
.map(this::toViolation)
.toList();
return errorResponse(
ApiErrorCode.INVALID_ARGUMENT,
ApiErrorCode.INVALID_ARGUMENT.defaultMessage(),
violations
);
}
private FieldViolation toViolation(FieldError error) {
return new FieldViolation(error.getField(), error.getDefaultMessage());
}一次返回全部字段错误,前端可以同时标红多个输入框。只返回第一条会让用户反复提交、反复发现新问题。
Spring 7 对路径变量和查询参数触发的校验使用 HandlerMethodValidationException。项目也一并处理:
// 路径变量和查询参数校验失败时同样返回字段明细。
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ApiResponse<List<FieldViolation>>> handleParameterValidation(
HandlerMethodValidationException exception
) {
List<FieldViolation> violations = exception.getParameterValidationResults()
.stream()
.flatMap(result -> result.getResolvableErrors().stream()
.map(error -> new FieldViolation(
result.getMethodParameter().getParameterName(),
error.getDefaultMessage()
)))
.toList();
return errorResponse(
ApiErrorCode.INVALID_ARGUMENT,
ApiErrorCode.INVALID_ARGUMENT.defaultMessage(),
violations
);
}这里使用的 API 是 Spring Framework 7 的 getParameterValidationResults()。旧教程里的部分方法名在版本升级后可能变化,最终以当前依赖的编译结果为准。
第 5 步:区分协议错误和解析错误
JSON 无法解析和缺少查询参数都属于客户端错误,但业务码不同:
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleMalformedJson(
HttpMessageNotReadableException exception
) {
// 不返回底层 Jackson 异常内容,避免暴露字段类型和包名。
return response(ApiErrorCode.INVALID_JSON, ApiErrorCode.INVALID_JSON.defaultMessage());
}
@ExceptionHandler({
MissingServletRequestParameterException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<ApiResponse<Void>> handleInvalidArgument(Exception exception) {
return response(
ApiErrorCode.INVALID_ARGUMENT,
ApiErrorCode.INVALID_ARGUMENT.defaultMessage()
);
}请求方法错误返回 405:
// HTTP 方法不匹配属于 405,不是业务错误。
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotAllowed(
HttpRequestMethodNotSupportedException exception
) {
return response(ApiErrorCode.METHOD_NOT_ALLOWED, exception.getMessage());
}媒体类型不匹配时分别返回 415 和 406:
// 服务端不支持请求体媒体类型时返回 415。
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleUnsupportedMediaType(
HttpMediaTypeNotSupportedException exception
) {
return response(
ApiErrorCode.UNSUPPORTED_MEDIA_TYPE,
ApiErrorCode.UNSUPPORTED_MEDIA_TYPE.defaultMessage()
);
}
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public ResponseEntity<ApiResponse<Void>> handleNotAcceptable(
HttpMediaTypeNotAcceptableException exception
) {
return response(
ApiErrorCode.NOT_ACCEPTABLE,
ApiErrorCode.NOT_ACCEPTABLE.defaultMessage()
);
}415 表示服务端不支持请求体格式,406 表示服务端无法生成调用方要求的响应格式。两者不要都写成 400。
第 6 步:兜底异常只暴露安全信息
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception) {
// 完整堆栈只写服务端日志,响应不暴露数据库、文件路径或密钥。
log.error("unhandled request failure", exception);
return response(
ApiErrorCode.INTERNAL_ERROR,
ApiErrorCode.INTERNAL_ERROR.defaultMessage()
);
}业务异常可以返回明确原因,未知异常不能把原始堆栈直接交给调用方。日志已经通过上一章的 MDC traceId 带上请求标识,排查时可以从响应头找回日志。
第 7 步:验证错误响应
启动项目后先创建订单:
curl -i -X POST "http://localhost:8080/api/v1/orders" \
-H "Content-Type: application/json" \
-d '{"customerId":"C1001","amount":99.00}'验证 404:
curl -i "http://localhost:8080/api/v1/orders/SO-NOT-FOUND"验证字段错误:
curl -i -X POST "http://localhost:8080/api/v1/orders" \
-H "Accept-Language: zh-CN" \
-H "Content-Type: application/json" \
-d '{"customerId":"","amount":0}'验证 405:
curl -i -X PUT "http://localhost:8080/api/v1/orders"自动化验证:
mvn testOrderApiIntegrationTest 会断言错误码、HTTP 状态和字段错误内容。
常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 异常仍返回 Spring 默认错误页 | @RestControllerAdvice 不在扫描范围内 | 放在启动类子包或显式导入 |
| 业务错误返回 200 | 只写了响应体,没有设置状态 | 使用 ResponseEntity.status(...) |
| 字段错误只有一条 | 只取了第一个 FieldError | 收集全部字段错误 |
| JSON 报错泄露内部类名 | 直接返回底层异常 | 转换成 INVALID_JSON |
| SSE 断开时又打印 JSON 错误 | 异常处理器尝试写已完成响应 | 单独处理异步断开和超时 |
本章小结
一个稳定的错误体系至少包含三层:
- HTTP 状态码表达协议结果。
- 业务错误码表达失败分类。
- 字段明细表达调用方如何修正。
业务模块只抛业务异常,Web 层统一翻译。未知异常保留完整服务端日志,但只向调用方返回安全信息。
练习题
增加 ORDER_AMOUNT_LIMIT_EXCEEDED:
- 订单金额超过 100 万时抛出业务异常。
- 映射为 HTTP 422。
- 返回稳定业务码和当前限额。
- 为正常、边界值和超限值分别写测试。
上一节:REST API 版本管理
下一节:参数校验、分组与国际化
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
