SpringBoot统一异常处理实践

SpringBoot统一异常处理实践

Spring Boot 简化了配置,但日志管理依然需要重视。日志配置、链路追踪、排查思路都是日常开发中会遇到的问题。本文讲实际项目中的日志管理经验。

异常体系设计

#

异常层次

Throwable
├── Error(系统级错误,不处理)
└── Exception
├── RuntimeException
│ ├── BusinessException(业务异常,已知)
│ │ ├── ValidationException(参数校验)
│ │ ├── NotFoundException(资源不存在)
│ │ └── UnauthorizedException(未授权)
│ └── SystemException(系统异常,未知)
└── Checked Exception(编译期异常)

#

基础异常类

@Getter
public class BusinessException extends RuntimeException {
private final Integer code;
private final Object[] args;

public BusinessException(ErrorCode errorCode, Object... args) {
super(MessageFormat.format(errorCode.getMessage(), args));
this.code = errorCode.getCode();
this.args = args;
}

public BusinessException(Integer code, String message) {
super(message);
this.code = code;
this.args = null;
}
}

#

错误码枚举

public enum ErrorCode {
// 系统级 1xxxx
SUCCESS(200, "操作成功"),
SYSTEM_ERROR(10001, "系统内部错误"),
PARAM_ERROR(10002, "参数错误"),

// 用户模块 2xxxx
USER_NOT_FOUND(20001, "用户不存在"),
USER_ALREADY_EXISTS(20002, "用户已存在"),
USER_PASSWORD_ERROR(20003, "密码错误"),
USER_UNAUTHORIZED(20004, "未授权"),
USER_FORBIDDEN(20005, "无权限访问"),

// 订单模块 3xxxx
ORDER_NOT_FOUND(30001, "订单不存在"),
ORDER_STATUS_ERROR(30002, "订单状态不正确"),
ORDER_STOCK_INSUFFICIENT(30003, "库存不足");

private final Integer code;
private final String message;

ErrorCode(Integer code, String message) {
this.code = code;
this.message = message;
}

public Integer getCode() { return code; }
public String getMessage() { return message; }
}

#

具体业务异常

public class NotFoundException extends BusinessException {
public NotFoundException(String resource, Object id) {
super(ErrorCode.USER_NOT_FOUND, resource, id);
}
}

public class ValidationException extends BusinessException {
public ValidationException(String field, String message) {
super(ErrorCode.PARAM_ERROR, field, message);
}
}

全局异常处理器

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 业务异常
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}

/**
* 参数校验异常 - 请求体
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> String.format("%s%s", error.getField(), error.getDefaultMessage()))
.collect(Collectors.joining(", "));

log.warn("参数校验失败: {}", message);
return Result.fail(ErrorCode.PARAM_ERROR.getCode(), message);
}

/**
* 参数校验异常 - URL 参数
*/
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleConstraintViolation(ConstraintViolationException e) {
String message = e.getConstraintViolations().stream()
.map(violation -> violation.getPropertyPath() + violation.getMessage())
.collect(Collectors.joining(", "));

log.warn("参数校验失败: {}", message);
return Result.fail(ErrorCode.PARAM_ERROR.getCode(), message);
}

/**
* 参数绑定异常
*/
@ExceptionHandler(BindException.class)
public Result<Void> handleBindException(BindException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + error.getDefaultMessage())
.collect(Collectors.joining(", "));

return Result.fail(ErrorCode.PARAM_ERROR.getCode(), message);
}

/**
* 请求方法不支持
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public Result<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
return Result.fail(405, "请求方法不支持: " + e.getMethod());
}

/**
* 资源不存在
*/
@ExceptionHandler(NoHandlerFoundException.class)
public Result<Void> handleNoHandlerFound(NoHandlerFoundException e) {
return Result.fail(404, "资源不存在: " + e.getRequestURL());
}

/**
* 缺少请求参数
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public Result<Void> handleMissingParam(MissingServletRequestParameterException e) {
return Result.fail(ErrorCode.PARAM_ERROR.getCode(),
"缺少参数: " + e.getParameterName());
}

/**
* 类型转换异常
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
return Result.fail(ErrorCode.PARAM_ERROR.getCode(),
String.format("参数%s类型错误,应为%s", e.getName(), e.getRequiredType().getSimpleName()));
}

/**
* 其他未知异常
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e, HttpServletRequest request) {
log.error("系统异常, URI: {}, 错误: {}", request.getRequestURI(), e.getMessage(), e);
return Result.fail(ErrorCode.SYSTEM_ERROR.getCode(), "系统繁忙,请稍后再试");
}
}

错误响应结构

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Result<T> {
private Integer code;
private String message;
private T data;
private String path;
private Long timestamp;
private String traceId;

public Result() {
this.timestamp = System.currentTimeMillis();
}

public static <T> Result<T> success(T data) {
return Result.<T>builder()
.code(ErrorCode.SUCCESS.getCode())
.message(ErrorCode.SUCCESS.getMessage())
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}

public static <T> Result<T> fail(Integer code, String message) {
return Result.<T>builder()
.code(code)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}

public static <T> Result<T> fail(ErrorCode errorCode) {
return fail(errorCode.getCode(), errorCode.getMessage());
}
}

添加请求路径和 TraceId

@Component
public class ResponseEnhanceFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

HttpServletRequest httpRequest = (HttpServletRequest) request;
String traceId = MDC.get("traceId");
if (traceId == null) {
traceId = UUID.randomUUID().toString().replace("-", "");
MDC.put("traceId", traceId);
}

RequestContextHolder.setPath(httpRequest.getRequestURI());

try {
chain.doFilter(request, response);
} finally {
MDC.remove("traceId");
RequestContextHolder.clear();
}
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
Result<Void> result = Result.fail(ErrorCode.SYSTEM_ERROR);
result.setPath(RequestContextHolder.getPath());
result.setTraceId(MDC.get("traceId"));
return result;
}
}

国际化支持

#

配置 MessageSource

@Configuration
public class I18nConfig {

@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource =
new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:i18n/messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setCacheSeconds(3600);
return messageSource;
}
}

#

错误码配置

# i18n/messages.properties
error.10001=System internal error
error.20001=User not found

# i18n/messages_zh_CN.properties
error.10001=系统内部错误
error.20001=用户不存在

#

使用

@Service
public class I18nService {
@Autowired
private MessageSource messageSource;

public String getMessage(ErrorCode errorCode, Object... args) {
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage("error." + errorCode.getCode(), args,
errorCode.getMessage(), locale);
}
}

测试验证

@SpringBootTest
@AutoConfigureMockMvc
public class ExceptionHandlerTest {

@Autowired
private MockMvc mockMvc;

@Test
public void testValidationException() throws Exception {
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(10002))
.andExpect(jsonPath("$.message").value(containsString("用户名不能为空")));
}

@Test
public void testBusinessException() throws Exception {
mockMvc.perform(get("/users/99999"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(20001))
.andExpect(jsonPath("$.message").value("用户不存在"));
}

@Test
public void testNotFound() throws Exception {
mockMvc.perform(get("/not-exist"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(404));
}
}

最佳实践

实践 说明
区分异常类型 业务异常 vs 系统异常
不暴露内部信息 系统异常返回通用消息
记录完整日志 包含 traceId、URI、参数
统一响应格式 所有接口返回相同结构
错误码规范 按模块划分,易于定位

总结

一套完整的异常处理体系包括:

  1. 异常层次:区分业务异常和系统异常
  2. 错误码规范:模块化、语义化
  3. 全局处理器:统一捕获、格式化响应
  4. 日志记录:包含上下文信息
  5. 国际化:支持多语言

良好的异常处理可以提升 API 的可用性和可维护性。

核心要点

  1. 日志级别设置:根据环境设置合适的级别

  2. 日志格式配置:添加 traceId 便于链路追踪

  3. 日志输出:控制台输出和文件输出的配置

  4. 日志归档:设置滚动策略和保留时间

总结

日志是排查问题的生命线,合理配置日志可以提升排查效率。在实际项目中,结合 ELK 等工具搭建日志系统,可以更好地管理和分析日志。


   转载规则


《SpringBoot统一异常处理实践》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录