Spring Boot 统一异常处理,这样写才优雅!
💡 优雅的异常处理 = 高可读 + 高一致 + 易追踪 + 可维护
1️⃣ 目标与原则
- 一致:所有错误返回统一格式,前端/调用方零成本消费。
- 可读:错误码可定位、可检索,信息可国际化。
- 可观测:错误都有日志与 traceId,方便排查。
- 低侵入:业务层只需
throw new BizException(...)即可。
2️⃣ 响应体格式约定
{
"code": "USR_001",
"message": "用户不存在",
"details": null,
"traceId": "9f7a9b1e0d8c4f8a",
"timestamp": "2025-10-29T10:12:34.567+08:00"
}
字段说明: | 字段 | 含义 | |------|------| | code |
业务或系统错误码 | | message | 可读的错误提示 | | details |
可选字段,用于放校验错误等 | | traceId | 链路追踪ID | | timestamp
| 时间戳 |
统一响应对象
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
private Map<String, Object> details;
private String traceId;
private OffsetDateTime timestamp;
}
3️⃣ 错误码与异常分层设计
错误码接口
public interface ErrorCode {
String code();
String defaultMessage();
}
通用错误码枚举
public enum CommonErrorCodes implements ErrorCode {
OK("OK","success"),
BAD_REQUEST("COMMON_400","请求参数错误"),
UNAUTHORIZED("COMMON_401","未认证"),
FORBIDDEN("COMMON_403","无权限"),
NOT_FOUND("COMMON_404","资源不存在"),
SERVER_ERROR("COMMON_500","服务器异常");
private final String code;
private final String msg;
CommonErrorCodes(String code,String msg){this.code=code;this.msg=msg;}
public String code(){return code;}
public String defaultMessage(){return msg;}
}
业务异常基类
@Getter
public class BaseException extends RuntimeException {
private final ErrorCode errorCode;
public BaseException(ErrorCode code) { super(code.defaultMessage()); this.errorCode = code; }
public BaseException(ErrorCode code, String message) { super(message); this.errorCode = code; }
}
示例:用户模块异常
public enum UserErrorCodes implements ErrorCode {
USER_NOT_FOUND("USR_001","用户不存在"),
DUPLICATE_USERNAME("USR_002","用户名已存在");
private final String code;
private final String msg;
UserErrorCodes(String c,String m){this.code=c;this.msg=m;}
public String code(){return code;}
public String defaultMessage(){return msg;}
}
public class BizException extends BaseException {
public BizException(ErrorCode code){super(code);}
public BizException(ErrorCode code, String msg){super(code, msg);}
}
4️⃣ 参数校验与异常处理
在 pom.xml 添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
DTO 示例:
@Data
public class UserCreateReq {
@NotBlank(message = "{user.name.notBlank}")
@Size(max = 20, message = "{user.name.size}")
private String name;
@Email(message = "{user.email.invalid}")
private String email;
}
5️⃣ 全局异常处理器(核心)
// common/web/GlobalExceptionHandler.java
package com.example.common.web;
import com.example.common.error.CommonErrorCodes;
import com.example.common.model.ErrorResponse;
import com.example.common.exception.BaseException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSource;
import org.springframework.http.*;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
private final MessageSource messageSource;
public GlobalExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(BaseException.class)
public ResponseEntity<ErrorResponse> handleBiz(BaseException ex, HttpServletRequest req) {
log.warn("Biz error: code={}, msg={}, uri={}", ex.getErrorCode().code(), ex.getMessage(), req.getRequestURI());
return build(CommonErrorCodes.BAD_REQUEST.equals(ex.getErrorCode()) ? HttpStatus.BAD_REQUEST : HttpStatus.OK,
ex.getErrorCode().code(),
resolveMessage(ex.getMessage()),
null);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleInvalid(MethodArgumentNotValidException ex) {
List<Map<String, String>> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(this::toFieldError).collect(Collectors.toList());
return build(HttpStatus.BAD_REQUEST,
CommonErrorCodes.BAD_REQUEST.code(),
CommonErrorCodes.BAD_REQUEST.defaultMessage(),
Map.of("fieldErrors", fieldErrors));
}
@ExceptionHandler({
MissingServletRequestParameterException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<ErrorResponse> handleBadRequest(Exception ex) {
return build(HttpStatus.BAD_REQUEST,
CommonErrorCodes.BAD_REQUEST.code(),
ex.getMessage(),
null);
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NoSuchElementException ex) {
return build(HttpStatus.NOT_FOUND,
CommonErrorCodes.NOT_FOUND.code(),
"资源不存在",
null);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleOthers(Exception ex, HttpServletRequest req) {
// 生产环境避免把堆栈暴露给客户端
log.error("Unexpected error at {}: {}", req.getRequestURI(), ex.getMessage(), ex);
return build(HttpStatus.INTERNAL_SERVER_ERROR,
CommonErrorCodes.SERVER_ERROR.code(),
CommonErrorCodes.SERVER_ERROR.defaultMessage(),
null);
}
// —— 工具方法 ——
private ResponseEntity<ErrorResponse> build(HttpStatus status, String code, String message, Map<String,Object> details) {
ErrorResponse body = ErrorResponse.builder()
.code(code)
.message(message)
.details(details)
.traceId(currentTraceId())
.timestamp(OffsetDateTime.now())
.build();
return ResponseEntity.status(status).contentType(MediaType.APPLICATION_JSON).body(body);
}
private Map<String,String> toFieldError(FieldError fe) {
String msg = fe.getDefaultMessage();
return Map.of("field", fe.getField(), "msg", resolveMessage(msg));
}
private String resolveMessage(String msgOrKey) {
// 如果是国际化 key,尝试解析;解析失败就直接返回原文本
try {
return messageSource.getMessage(msgOrKey, null, Locale.getDefault());
} catch (Exception ignored) { return msgOrKey; }
}
private String currentTraceId() {
// 如果接入了 Sleuth/Observability,可从 MDC 获取:MDC.get("traceId")
return UUID.randomUUID().toString().replace("-", "").substring(0,16);
}
}
说明
@RestControllerAdvice:保证返回 JSON。
对业务异常(继承 BaseException)用 warn 级别日志;对非预期异常打 error 且不把堆栈暴露给客户端。
details.fieldErrors 专用于字段校验错误,前端可直接高亮。
6️⃣ Controller 示例
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public String create(@Valid @RequestBody UserCreateReq req){
if ("taken".equalsIgnoreCase(req.getName())){
throw new BizException(UserErrorCodes.DUPLICATE_USERNAME);
}
return "OK";
}
@GetMapping("/{id}")
public String find(@PathVariable Long id){
if (id == 404L) {
throw new BizException(UserErrorCodes.USER_NOT_FOUND);
}
return "user-" + id;
}
}
7️⃣ 国际化支持
src/main/resources/messages.properties
user.name.notBlank=用户名不能为空
user.name.size=用户名长度不能超过20
user.email.invalid=邮箱格式不正确
配置类:
@Configuration
public class I18nConfig {
@Bean
public ResourceBundleMessageSource messageSource(){
ResourceBundleMessageSource ms = new ResourceBundleMessageSource();
ms.setBasenames("messages");
ms.setDefaultEncoding("UTF-8");
ms.setUseCodeAsDefaultMessage(true);
return ms;
}
}
8️⃣ 日志与链路追踪
过滤器注入 traceId:
@Slf4j
public class TraceFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
String traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);
((HttpServletResponse)res).setHeader("X-Trace-Id", traceId);
try { chain.doFilter(req, res); } finally { MDC.remove("traceId"); }
}
}
9️⃣ 错误码与 HTTP 状态约定
场景 HTTP 状态 错误码示例
参数错误 400 COMMON_400
未登录 401 COMMON_401
无权限 403 COMMON_403
资源不存在 404 COMMON_404
业务校验失败 200 / 409 USR_002
系统异常 500 COMMON_500
🔟 单元测试(MockMvc)
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper om;
@Test
void shouldReturnFieldErrorsWhenInvalid() throws Exception {
var req = new UserCreateReq();
mvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(om.writeValueAsString(req)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("COMMON_400"))
.andExpect(jsonPath("$.details.fieldErrors").isArray());
}
}
11. 常见坑与优化
-
别吞异常:统一处理器里一定要 log.error 记录非预期异常(带堆栈)。
-
消息暴露:生产环境不要把数据库/堆栈信息直接返回。
-
字段校验:使用 @Valid / @Validated,并处理 MethodArgumentNotValidException 与 ConstraintViolationException(路径/Query 参数校验)。
-
多模块复用:将 ErrorCode/BaseException/ErrorResponse/GlobalExceptionHandler/I18nConfig 放入 common-web 模块,其他服务引入即可统一风格。
-
OpenAPI/Swagger:把统一错误体登记到全局响应,便于前端查看(@ApiResponse/@ApiResponses)。
✅ 最佳实践清单
- 引入
spring-boot-starter-validation。\ - 建立
ErrorResponse、ErrorCode、BaseException、BizException。\ - 添加
GlobalExceptionHandler。\ - (可选)国际化与 traceId。\
- 在业务中仅
throw new BizException(XXX)即可。
🧩 总结
统一异常处理不是简单捕获,而是构建"可维护、可追踪、可演化"的错误体系。
优雅的开发者,不仅要让代码能跑,还要让错误能"说话"。 🚀
239

被折叠的 条评论
为什么被折叠?



