Spring Boot 统一异常处理,这样写才优雅!

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. 常见坑与优化

  1. 别吞异常:统一处理器里一定要 log.error 记录非预期异常(带堆栈)。

  2. 消息暴露:生产环境不要把数据库/堆栈信息直接返回。

  3. 字段校验:使用 @Valid / @Validated,并处理 MethodArgumentNotValidException 与 ConstraintViolationException(路径/Query 参数校验)。

  4. 多模块复用:将 ErrorCode/BaseException/ErrorResponse/GlobalExceptionHandler/I18nConfig 放入 common-web 模块,其他服务引入即可统一风格。

  5. OpenAPI/Swagger:把统一错误体登记到全局响应,便于前端查看(@ApiResponse/@ApiResponses)。

✅ 最佳实践清单

  1. 引入 spring-boot-starter-validation。\
  2. 建立
    ErrorResponseErrorCodeBaseExceptionBizException。\
  3. 添加 GlobalExceptionHandler。\
  4. (可选)国际化与 traceId。\
  5. 在业务中仅 throw new BizException(XXX) 即可。

🧩 总结
统一异常处理不是简单捕获,而是构建"可维护、可追踪、可演化"的错误体系。
优雅的开发者,不仅要让代码能跑,还要让错误能"说话"。 🚀

觉得有用的话,给个三连(点赞、收藏、关注)吧!你的三连是我写作的最大的动力

遇到问题?在评论区留言,我会及时回复!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值