侧边栏壁纸
博主头像
洋洋的技术笔记

行动起来,活在当下

  • 累计撰写 16 篇文章
  • 累计创建 2 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

Spring Boot统一异常处理方案

洋洋的技术笔记
2026-11-09 / 0 评论 / 0 点赞 / 2 阅读 / 0 字

Spring Boot统一异常处理方案

异常处理的目标不是把所有异常都变成“成功响应”,而是在保留正确 HTTP 语义的前提下,为客户端提供稳定、可理解、便于排查的错误信息。

在 Web 项目中,参数错误、业务规则冲突、资源不存在和系统故障都会以异常的形式出现。如果每个 Controller 都自行拼接错误响应,不仅代码重复,还会导致错误结构、状态码和日志口径不一致。本文基于 Spring Boot 3.x,使用 @RestControllerAdvice 构建统一异常处理方案,并讨论生产环境中容易忽略的边界。

环境说明:示例使用 Spring Boot 3.x、Spring Framework 6 和 Java 17,Web 类型为 Servlet。Spring Boot 3 使用 jakarta.* 包名;如果项目仍是 Spring Boot 2.x,需要注意依赖与验证 API 的包名差异。


一、为什么需要统一异常处理

先看不使用统一处理时的代码:

@GetMapping("/users/{id}")
public Result<UserResponse> getUser(@PathVariable Long id) {
    try {
        UserResponse user = userService.getById(id);
        return Result.success(user);
    } catch (UserNotFoundException e) {
        return Result.error(404, "用户不存在");
    } catch (Exception e) {
        log.error("查询用户失败", e);
        return Result.error(500, "系统异常");
    }
}

这种方式很快会出现几个问题:

  1. 每个 Controller 都重复编写 try-catch。

  2. 有的接口返回 404,有的接口可能把“不存在”也返回成 200。

  3. 错误响应字段不统一,前端需要针对不同接口分别处理。

  4. 业务异常与系统异常混在一起,日志级别和排查方式不清晰。

  5. 直接在错误信息中返回异常堆栈,可能泄露内部实现。

统一异常处理将横切逻辑集中到全局处理器中,让 Controller 专注于正常业务流程:

@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
    return userService.getById(id);
}

用户不存在时,Service 直接抛出明确的业务异常,由全局处理器转换成一致的 HTTP 响应。


二、先统一错误响应结构

客户端处理错误时,通常需要稳定的错误码、可读消息、请求路径和追踪标识。可以定义如下结构:

import java.time.Instant;

public record ApiError(
        Instant timestamp,
        int status,
        String code,
        String message,
        String path,
        String traceId
) {
    public static ApiError of(
            int status,
            String code,
            String message,
            String path,
            String traceId
    ) {
        return new ApiError(
                Instant.now(), status, code, message, path, traceId
        );
    }
}

字段含义如下:

字段

作用

示例

timestamp

错误发生时间

2026-11-09T08:00:00Z

status

HTTP 状态码

404

code

稳定的业务错误码

USER_NOT_FOUND

message

给调用方阅读的提示

用户不存在

path

请求路径

/api/users/1001

traceId

串联本次请求日志

4f8c...

一次用户不存在的响应可以是:

{
  "timestamp": "2026-11-09T08:00:00Z",
  "status": 404,
  "code": "USER_NOT_FOUND",
  "message": "用户不存在",
  "path": "/api/users/1001",
  "traceId": "4f8c0a1d7f2c"
}

code 应该保持稳定,供客户端编写程序逻辑;message 面向人阅读,可以优化文案,但不应被前端当作程序分支判断依据。

如果项目已经存在统一返回包装,也可以复用现有结构,不必为了采用本文示例而同时维护两套响应模型。


三、定义业务异常与错误码

3.1 错误码枚举

将常用错误码集中管理,避免字符串散落在代码中:

import org.springframework.http.HttpStatus;

public enum ErrorCode {
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "用户不存在"),
    USER_DISABLED(HttpStatus.CONFLICT, "用户已被禁用"),
    INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "请求参数不合法"),
    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "系统暂时不可用");

    private final HttpStatus status;
    private final String defaultMessage;

    ErrorCode(HttpStatus status, String defaultMessage) {
        this.status = status;
        this.defaultMessage = defaultMessage;
    }

    public HttpStatus getStatus() {
        return status;
    }

    public String getDefaultMessage() {
        return defaultMessage;
    }
}

HTTP 状态码表达错误的通用语义,业务错误码表达更具体的原因。例如,多个业务异常都可以使用 409,但分别对应“用户已存在”“库存不足”或“状态冲突”。

3.2 业务异常基类

public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getDefaultMessage());
        this.errorCode = errorCode;
    }

    public BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

业务异常继承 RuntimeException,Service 不需要在每个方法签名中声明。是否使用受检异常应根据项目约定决定,但不要在 Service 中为了绕过异常处理而随意捕获并吞掉异常。

需要动态提示时,可以传入更具体的消息:

throw new BusinessException(
        ErrorCode.USER_DISABLED,
        "当前账号已被禁用,请联系管理员"
);

动态消息仍然应当是可以展示给客户端的内容,不要拼接 SQL、密钥或内部堆栈。


四、使用 @RestControllerAdvice 统一处理

@RestControllerAdvice 结合了 @ControllerAdvice 和 @ResponseBody,可以集中处理一组 Controller 抛出的异常,并将返回值写入响应体。

先实现业务异常和兜底异常两个处理器:

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiError> handleBusinessException(
            BusinessException ex,
            HttpServletRequest request
    ) {
        ErrorCode errorCode = ex.getErrorCode();
        int status = errorCode.getStatus().value();

        log.warn("业务异常:code={}, path={}, message={}",
                errorCode.name(), request.getRequestURI(), ex.getMessage());

        return ResponseEntity
                .status(errorCode.getStatus())
                .body(ApiError.of(
                        status,
                        errorCode.name(),
                        ex.getMessage(),
                        request.getRequestURI(),
                        TraceContext.getTraceId()
                ));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleException(
            Exception ex,
            HttpServletRequest request
    ) {
        log.error("未处理异常:path={}", request.getRequestURI(), ex);

        return ResponseEntity
                .internalServerError()
                .body(ApiError.of(
                        500,
                        ErrorCode.INTERNAL_ERROR.name(),
                        ErrorCode.INTERNAL_ERROR.getDefaultMessage(),
                        request.getRequestURI(),
                        TraceContext.getTraceId()
                ));
    }
}

关键点有三个:

  • 使用 ResponseEntity 设置真实 HTTP 状态码,不把 404、400 和 500 都包装成 200。

  • 业务异常记录简明日志,未知异常记录完整堆栈。

  • 500 响应对外使用通用文案,详细错误只留在受保护的日志系统中。

@ExceptionHandler(Exception.class) 是最后一道兜底,不应成为忽略具体异常的理由。客户端能够修正的问题,应尽量映射到准确的 4xx 状态码。


五、补齐常见 Web 异常

仅处理业务异常和 Exception 还不够。Spring MVC 在参数解析、请求体转换和路由匹配阶段也会抛出异常。

5.1 请求参数缺失与类型转换失败

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiError> handleMissingParameter(
        MissingServletRequestParameterException ex,
        HttpServletRequest request
) {
    String message = "缺少请求参数:" + ex.getParameterName();
    return badRequest(message, request);
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> handleTypeMismatch(
        MethodArgumentTypeMismatchException ex,
        HttpServletRequest request
) {
    String message = "请求参数格式错误:" + ex.getName();
    return badRequest(message, request);
}

private ResponseEntity<ApiError> badRequest(
        String message,
        HttpServletRequest request
) {
    return ResponseEntity.badRequest().body(ApiError.of(
            HttpStatus.BAD_REQUEST.value(),
            ErrorCode.INVALID_PARAMETER.name(),
            message,
            request.getRequestURI(),
            TraceContext.getTraceId()
    ));
}

例如,接口期望 page 为整数,客户端却传入 page=abc,就返回 400,而不是让类型转换异常落入 500。

5.2 JSON 请求体无法读取

import org.springframework.http.converter.HttpMessageNotReadableException;

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleNotReadable(
        HttpMessageNotReadableException ex,
        HttpServletRequest request
) {
    log.warn("请求体解析失败:path={}, reason={}",
            request.getRequestURI(), ex.getMessage());
    return badRequest("请求体不是合法的 JSON,或字段类型不匹配", request);
}

不要把 Jackson 的完整异常消息直接返回给客户端,其中可能包含类名、字段结构和内部实现信息。

5.3 请求方法不支持

import org.springframework.web.HttpRequestMethodNotSupportedException;

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiError> handleMethodNotSupported(
        HttpRequestMethodNotSupportedException ex,
        HttpServletRequest request
) {
    return ResponseEntity
            .status(HttpStatus.METHOD_NOT_ALLOWED)
            .body(ApiError.of(
                    405,
                    "METHOD_NOT_ALLOWED",
                    "请求方法不支持",
                    request.getRequestURI(),
                    TraceContext.getTraceId()
            ));
}

如果客户端向只支持 GET 的路径发送 DELETE,返回 405 比返回 500 更准确。

5.4 静态资源或路径不存在

Spring Framework 6.1 及以上版本中,未匹配到资源的请求可能抛出 NoResourceFoundException:

import org.springframework.web.servlet.resource.NoResourceFoundException;

@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiError> handleNotFound(
        NoResourceFoundException ex,
        HttpServletRequest request
) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiError.of(
            404,
            "RESOURCE_NOT_FOUND",
            "请求的资源不存在",
            request.getRequestURI(),
            TraceContext.getTraceId()
    ));
}

不同 Spring Boot 版本、静态资源配置和容器错误分发方式可能影响 404 的处理路径。应以实际项目版本为准,并用请求验证响应结构,而不是假设所有 404 都一定进入同一个处理器。


六、参数校验异常如何转换

下一篇会系统介绍参数校验,这里先统一其错误输出,避免校验失败时又回到默认响应。

6.1 @RequestBody 对象校验失败

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.MethodArgumentNotValidException;

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex,
        HttpServletRequest request
) {
    Map<String, String> fields = new LinkedHashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error ->
            fields.putIfAbsent(error.getField(), error.getDefaultMessage())
    );

    String message = fields.isEmpty()
            ? ErrorCode.INVALID_PARAMETER.getDefaultMessage()
            : fields.toString();

    log.warn("请求体校验失败:path={}, fields={}",
            request.getRequestURI(), fields);
    return badRequest(message, request);
}

直接返回 fields.toString() 能完成演示,但生产项目更适合为字段错误增加独立结构:

public record FieldViolation(
        String field,
        String message
) {}

然后让错误响应包含 List<FieldViolation> errors。这样前端不需要解析字符串,可以稳定地定位每个输入项。

6.2 方法参数校验失败

对 @RequestParam、@PathVariable 使用约束注解时,需要处理 ConstraintViolationException:

import jakarta.validation.ConstraintViolationException;

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleConstraintViolation(
        ConstraintViolationException ex,
        HttpServletRequest request
) {
    String message = ex.getConstraintViolations().stream()
            .map(violation -> violation.getPropertyPath()
                    + ": " + violation.getMessage())
            .distinct()
            .reduce((left, right) -> left + "; " + right)
            .orElse(ErrorCode.INVALID_PARAMETER.getDefaultMessage());

    return badRequest(message, request);
}

如果项目还使用 @ModelAttribute 或表单绑定,应根据实际情况处理 BindException。HandlerMethodValidationException 则可能出现在较新的 Spring Framework 方法校验流程中,具体异常类型应结合版本和配置确认。


七、完整调用链示例

7.1 Service 只表达业务规则

import org.springframework.stereotype.Service;

@Service
public class UserService {

    public UserResponse getById(Long id) {
        if (id == null || id <= 0) {
            throw new BusinessException(
                    ErrorCode.INVALID_PARAMETER,
                    "用户 ID 必须为正整数"
            );
        }

        if (!exists(id)) {
            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
        }

        return new UserResponse(id, "洋洋", true);
    }

    private boolean exists(Long id) {
        // 示例仅保留一个存在的数据,实际项目应查询数据库
        return id == 1L;
    }
}
public record UserResponse(
        Long id,
        String nickname,
        Boolean enabled
) {}

真实项目中,exists 和查询通常应合并为一次数据库操作,避免“先判断再查询”产生多余请求和并发间隙。这里拆开只是为了突出异常分支。

7.2 Controller 不再捕获业务异常

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        return userService.getById(id);
    }
}

请求 GET /api/users/1 返回正常数据;请求 GET /api/users/1001 返回 404;请求 GET /api/users/abc 在参数转换阶段返回 400。

可以使用 curl 验证:

curl -i http://localhost:8080/api/users/1001

应看到:

HTTP/1.1 404
Content-Type: application/json
{
  "timestamp": "2026-11-09T08:00:00Z",
  "status": 404,
  "code": "USER_NOT_FOUND",
  "message": "用户不存在",
  "path": "/api/users/1001",
  "traceId": "4f8c0a1d7f2c"
}

八、追踪 ID 与日志规范

统一异常响应中的 traceId 不能只是一个从未生成的字段。可以基于 SLF4J 的 MDC 保存当前请求的追踪标识。

8.1 通过过滤器设置 MDC

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class TraceIdFilter extends OncePerRequestFilter {

    public static final String TRACE_ID = "traceId";
    public static final String HEADER = "X-Trace-Id";

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String traceId = request.getHeader(HEADER);
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString()
                    .replace("-", "")
                    .substring(0, 16);
        }

        try {
            MDC.put(TRACE_ID, traceId);
            response.setHeader(HEADER, traceId);
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove(TRACE_ID);
        }
    }
}

生产环境若接收上游 traceId,还应限制长度和字符集,避免不受信任的请求头污染日志。已经接入 Micrometer Tracing、OpenTelemetry 或 SkyWalking 时,应优先复用现有追踪体系,而不是另建一套互不关联的 ID。

8.2 封装上下文读取

import org.slf4j.MDC;

public final class TraceContext {

    private TraceContext() {
    }

    public static String getTraceId() {
        return MDC.get(TraceIdFilter.TRACE_ID);
    }
}

在 application.yml 中把追踪 ID 加入日志格式:

logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId:-}] %logger{36} - %msg%n"

finally 中清理 MDC 非常重要。Servlet 容器会复用线程,不清理可能让后续请求错误地沿用旧追踪 ID。

8.3 日志级别建议

异常类型

建议级别

是否记录堆栈

参数错误、资源不存在

WARN 或按访问日志处理

通常不需要

可预期业务冲突

WARN

通常不需要

未知系统异常

ERROR

需要

外部服务超时

WARN 或 ERROR,结合影响判断

保留关键因果链

不要同时在 Service、Controller 和全局处理器重复记录同一异常堆栈,否则一次故障会产生多条相似 ERROR 日志。


九、局部异常处理与作用域控制

@RestControllerAdvice 默认作用于多个 Controller,也可以通过包、注解或指定类限定范围:

@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler {
    // 只处理 com.example.api 包下的 Controller
}

如果确实只需要处理当前 Controller 的少量异常,可以在 Controller 内使用 @ExceptionHandler。局部处理器优先级更高,全局处理器负责公共兜底。

还可以使用 @Order 调整多个 Advice 的顺序:

import org.springframework.core.annotation.Order;

@Order(1)
@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler {
}
@Order(2)
@RestControllerAdvice
public class GlobalExceptionHandler {
}

但更好的方式是减少职责重叠:API 异常处理器负责接口,视图异常处理器负责页面,全局处理器只处理真正公共的场景。避免多个 Advice 同时声明同一个异常类型,导致选择规则难以维护。


十、使用 ProblemDetail 表达标准问题详情

Spring Framework 6 支持 ProblemDetail,对应 RFC 9457 所更新的 Problem Details for HTTP APIs 规范(早期常称 RFC 7807)。如果项目没有历史响应格式约束,可以直接采用它,减少自定义模型。

import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ProblemDetailExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ProblemDetail> handleBusiness(
            BusinessException ex,
            HttpServletRequest request
    ) {
        HttpStatus status = ex.getErrorCode().getStatus();
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                status,
                ex.getMessage()
        );
        problem.setTitle("请求处理失败");
        problem.setType(URI.create("about:blank"));
        problem.setProperty("code", ex.getErrorCode().name());
        problem.setProperty("traceId", TraceContext.getTraceId());
        problem.setProperty("path", request.getRequestURI());

        return ResponseEntity.status(status).body(problem);
    }
}

典型响应包含:

{
  "type": "about:blank",
  "title": "请求处理失败",
  "status": 404,
  "detail": "用户不存在",
  "code": "USER_NOT_FOUND",
  "traceId": "4f8c0a1d7f2c",
  "path": "/api/users/1001"
}

自定义 ApiError 与 ProblemDetail 都能完成统一异常处理。选择时重点考虑团队规范、客户端兼容和现有接口契约,不要在同一套 API 中随意混用两种错误格式。


十一、几个容易踩坑的边界

11.1 不要捕获后返回 HTTP 200

以下做法会让监控、网关和客户端重试机制无法正确识别失败:

// 不推荐
return ResponseEntity.ok(ApiError.of(500, "INTERNAL_ERROR", ...));

如果业务确实失败,就应返回匹配语义的 HTTP 状态码。特殊渠道要求始终返回 200 时,也应单独定义协议,不要让整个 Web API 默认失去状态码语义。

11.2 不要向客户端返回堆栈和内部异常消息

// 不推荐
body.setMessage(ex.getMessage());
body.setStackTrace(ex.getStackTrace());

数据库异常可能包含 SQL、表名和连接信息,框架异常可能暴露类路径。对未知异常返回通用提示,详细信息写入受控日志。

11.3 注意 Spring Security 异常

@RestControllerAdvice 主要处理进入 Controller 及其调用链的异常。Spring Security 的认证入口、过滤器异常通常应由 AuthenticationEntryPoint、AccessDeniedHandler 等安全组件处理。

如果项目引入 Spring Security,不要让 Exception 兜底意外吞掉需要交由安全流程处理的异常。应通过实际请求验证 401、403 的响应格式,并统一安全层与业务层的错误模型。

11.4 异步请求需要单独验证

Callable、DeferredResult、WebFlux 或 Servlet 异步错误的处理路径可能与同步 MVC 请求不同。本文示例面向同步 Spring MVC;使用其他编程模型时,应针对对应异常处理机制补充配置和测试。

11.5 响应已经开始写出后无法可靠重写

如果异常发生在响应提交之后,例如流式输出中途失败,全局处理器可能无法再修改状态码和响应体。此时重点是记录日志、终止响应并监控连接中断,不能假设 Advice 可以修复所有输出异常。

11.6 文件上传、网关和容器错误不一定经过 Controller

请求大小超过网关限制、Tomcat 在到达应用前拒绝请求、负载均衡器超时等情况,都不会由 Controller Advice 处理。需要在网关、容器和基础设施层保持兼容的错误格式与追踪策略。


十二、用 MockMvc 验证异常契约

异常处理属于 API 契约,应该通过测试固定状态码和关键字段。以下是一个针对用户查询接口的切片测试思路:

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void shouldReturnNotFoundWhenUserDoesNotExist() throws Exception {
        when(userService.getById(1001L))
                .thenThrow(new BusinessException(ErrorCode.USER_NOT_FOUND));

        mockMvc.perform(get("/api/users/{id}", 1001L))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(404))
                .andExpect(jsonPath("$.code").value("USER_NOT_FOUND"))
                .andExpect(jsonPath("$.path").value("/api/users/1001"));
    }

    @Test
    void shouldReturnBadRequestWhenIdIsNotNumber() throws Exception {
        mockMvc.perform(get("/api/users/abc"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_PARAMETER"));
    }
}

@MockBean 在较新的 Spring Boot 版本中已被标记弃用,可迁移到 @MockitoBean,具体以项目使用的测试版本为准。若应用启用了 Spring Security,切片测试还需要配置测试用户或安全测试支持。

至少覆盖以下场景:

  • 正常响应不被全局处理器改变;

  • 业务异常映射到正确状态码和业务错误码;

  • 参数转换失败返回 400;

  • JSON 解析失败返回 400;

  • 未知异常返回 500,且响应不包含堆栈;

  • 错误响应包含可关联日志的追踪 ID。


十三、推荐落地顺序

在已有项目中改造时,可以按以下顺序逐步实施:

  1. 先统计现有错误响应,确定客户端依赖的字段和状态码。

  2. 建立统一错误模型,保留必要的兼容字段。

  3. 定义少量、稳定的业务错误码,避免为每句话创建一个错误码。

  4. 引入 @RestControllerAdvice,先处理业务异常与未知异常。

  5. 补齐参数解析、JSON 转换、方法不支持等常见 MVC 异常。

  6. 统一认证、权限、网关和容器层的错误边界。

  7. 接入请求追踪 ID,清理重复日志和敏感信息。

  8. 使用 MockMvc 与真实 HTTP 请求验证契约,再逐步删除 Controller 中重复的 try-catch。

不要一次性把每个异常都映射成大量细碎处理器。先覆盖公共异常,再根据实际调用链和监控发现补齐,结构更容易维护。


十四、总结

一套可维护的 Spring Boot 统一异常处理方案应做到:

  1. Controller 专注正常业务流程,Service 用明确异常表达业务失败。

  2. 错误响应包含稳定业务错误码、可读消息、请求路径和追踪 ID。

  3. 使用真实 HTTP 状态码区分客户端错误、业务冲突和系统故障。

  4. @RestControllerAdvice 集中处理公共异常,具体异常优先于通用兜底。

  5. 参数错误返回 400,未知异常返回 500,不把所有失败包装成 200。

  6. 业务异常日志简明,未知异常保留完整堆栈,客户端不接触内部细节。

  7. MDC 必须清理,已有分布式追踪体系时直接复用。

  8. 自定义 ApiError 与标准 ProblemDetail 按项目契约二选一。

  9. Security、异步请求、网关与响应提交后的异常需要单独考虑。

  10. 用测试固定异常响应,防止后续升级破坏客户端依赖的错误格式。

统一异常处理不是简单地“把异常转成 JSON”,而是建立一套稳定的 API 失败契约,并让客户端提示、HTTP 语义和日志排查形成闭环。

下一站预告

下一篇《Spring Boot参数校验与自定义校验》将介绍 Bean Validation、分组校验、嵌套对象校验和自定义约束注解,并与本文的统一异常处理组合成完整的请求校验流程。


作者:洋洋技术笔记 发布日期:2026-11-09 系列:Spring Boot实战 - 第7篇


原文链接:洋洋的技术笔记

0

评论区