返回

Spring Boot 统一异常处理:优雅解决API错误与404

java

Spring Boot REST API 统一异常处理:告别 500 错误和丑陋堆栈

开发 Spring Boot REST API 时,难免会遇到各种预料之外的异常,比如空指针 (NullPointerException)、数据库连接失败等等。如果不做处理,这些异常往往会导致服务器返回难看的 500 Internal Server Error 页面,甚至直接暴露堆栈信息,这既不友好也不安全。

咱们的目标是:优雅地捕获所有未处理的异常,给用户返回一个清晰、友好的提示信息,同时在后端记录下详细的错误日志,方便排查问题。还得能专门处理像 404 Not Found 这样的错误,看看是不是有人在瞎搞或者扫描我们的接口。

一、 问题在哪儿?

有人尝试通过继承 ResponseEntityExceptionHandler 并重写 handleExceptionInternal 方法来捕获全局异常。代码大概长这样:

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

// 假设的响应体类
class GenericResponse {
    private String message;
    // 省略 getter 和 setter
    public void setMessage(String message) { this.message = message; }
    public String getMessage() { return message; }
}


// 注意:这个实现方式并不能捕获所有控制器抛出的异常
@ControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        // 尝试构造简单的错误信息,包含了堆栈的第一行(非常不推荐)
        GenericResponse response = new GenericResponse();
        // 直接暴露部分堆栈信息给前端,这很危险!
        response.setMessage("Internal error occured, " + ex.getStackTrace()[0]);

        System.out.println("捕获到 Spring MVC 内部或特定异常,状态码:" + status);

        return new ResponseEntity<>(response, headers, status);
    }

    // 可能还有其他 handleXXX 方法的重写...
}

并且在 application.properties (或 application.yml) 文件里加了配置,让 Spring Boot 在找不到处理器时抛出异常,而不是默认处理成 404 页面:

# application.properties
spring.mvc.throw-exception-if-no-handler-found=true
# 也可以关闭 Spring Boot 默认的 Whitelabel Error Page
server.error.whitelabel.enabled=false
# 推荐:禁止在响应体中包含默认的错误属性(比如堆栈信息)
server.error.include-stacktrace=never

或者 YAML 格式:

# application.yml
spring:
  mvc:
    throw-exception-if-no-handler-found: true
server:
  error:
    whitelabel:
      enabled: false
    include-stacktrace: never # 同样推荐配置

奇怪的是,当访问一个不存在的路径(比如 /abc)时,上面这段代码能跑起来,返回了自定义的错误信息。但是,如果在 Controller 的某个方法里故意 throw new NullPointerException();,这个 handleExceptionInternal 方法却跟没看见一样,完全不执行!这到底是为啥?

二、 为啥 handleExceptionInternal 不给力?

简单来说,ResponseEntityExceptionHandler 这个类主要是 Spring MVC 框架自己用来处理 特定 异常的,比如请求方法不支持 (HttpRequestMethodNotSupportedException)、媒体类型不支持 (HttpMediaTypeNotSupportedException)、找不到处理器 (NoHandlerFoundException,前提是配置了 spring.mvc.throw-exception-if-no-handler-found=true) 等等。

handleExceptionInternal 是这些特定处理方法最终调用的一个公共方法,用来构建 ResponseEntity。它本身 不是一个万能的“捕捞网” ,设计上就没打算捕获那些从你的业务代码(比如 Controller、Service)里直接抛出来的任意异常,像是 NullPointerException、自定义业务异常等等。

所以,访问不存在的 /abc 路径时,Spring MVC 发现没有对应的 @RequestMapping,并且因为你配置了 spring.mvc.throw-exception-if-no-handler-found=true,它就会抛出 NoHandlerFoundExceptionResponseEntityExceptionHandler 里刚好有处理 NoHandlerFoundException 的逻辑 (可能需要显式重写 handleNoHandlerFound 或依赖默认实现流转到 handleExceptionInternal),于是你的代码就被调用了。

而当 Controller 方法内部抛出 NullPointerException 时,这个异常是在你的应用代码层面产生的,Spring MVC 默认的异常处理机制并不会把它路由到 ResponseEntityExceptionHandlerhandleExceptionInternal 方法里去。它会沿着调用栈往上抛,如果没有其他处理,最终会被 Servlet 容器捕获,然后可能就返回了那个充满堆栈信息的标准 500 错误页。

三、 好用的方子来了:全局异常处理实战

别灰心,Spring Boot 提供了更标准的、专门用来做全局异常处理的机制。下面介绍两种常用且有效的方法。

3.1 方案一: @ControllerAdvice + @ExceptionHandler (推荐)

这是最推荐也是最灵活的方式。创建一个类,给它加上 @ControllerAdvice 注解,然后在类里面定义处理不同异常类型的方法,并给这些方法加上 @ExceptionHandler 注解。

  • 原理:
    @ControllerAdvice 注解让这个类变成一个全局的“顾问”,Spring MVC 会自动扫描到它。当任何 Controller 抛出异常时,Spring 会检查这个“顾问”类里有没有 @ExceptionHandler 注解的方法能处理这种类型的异常(或者它的父类型异常)。如果有,就调用对应的方法来处理异常并返回响应。

  • 代码实操:

    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.ControllerAdvice;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.ResponseStatus;
    import org.springframework.web.context.request.WebRequest;
    import org.springframework.web.servlet.NoHandlerFoundException;
    
    // 定义一个标准的错误响应体结构
    class ErrorResponse {
        private int status;
        private String message;
        private String error; // 可以是异常类名或其他标识
        private long timestamp;
    
        // 构造函数、getter 和 setter 省略...
        public ErrorResponse(HttpStatus status, String message, String error) {
            this.status = status.value();
            this.message = message;
            this.error = error;
            this.timestamp = System.currentTimeMillis();
        }
         // Getter ...
        public int getStatus() { return status; }
        public String getMessage() { return message; }
        public String getError() { return error; }
        public long getTimestamp() { return timestamp; }
    }
    
    @ControllerAdvice // 声明这是一个全局异常处理类
    public class GlobalExceptionHandler {
    
        private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    
        // 处理 404 Not Found 错误 (需要配合 spring.mvc.throw-exception-if-no-handler-found=true)
        @ExceptionHandler(NoHandlerFoundException.class)
        @ResponseStatus(HttpStatus.NOT_FOUND) // 设置响应状态码为 404
        public ResponseEntity<ErrorResponse> handleNotFound(NoHandlerFoundException ex, WebRequest request) {
            log.warn("请求的资源未找到: {}, 请求路径: {}", ex.getMessage(), request.getDescription(false));
            ErrorResponse errorResponse = new ErrorResponse(
                    HttpStatus.NOT_FOUND,
                    "您访问的页面飞走了~",
                    "Resource Not Found"
            );
            // 这里可以加上日志记录,方便分析是否有人恶意扫描不存在的路径
            // log.warn(...)
            return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
        }
    
        // 处理其他所有未被捕获的异常 (Exception.class 是所有异常的父类)
        @ExceptionHandler(Exception.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 设置响应状态码为 500
        public ResponseEntity<ErrorResponse> handleAllExceptions(Exception ex, WebRequest request) {
            // !!! 重要:在生产环境中,绝对不要把原始异常信息暴露给用户 !!!
            // 这里应该记录详细的错误日志,供开发人员排查
            log.error("发生了未预期的内部错误: {}", request.getDescription(false), ex); // 记录完整堆栈
    
            // 给用户返回一个通用的、友好的错误提示
            ErrorResponse errorResponse = new ErrorResponse(
                    HttpStatus.INTERNAL_SERVER_ERROR,
                    "服务器开小差了,请稍后再试或联系管理员。",
                    "Internal Server Error"
            );
    
            // 你甚至可以在这里根据异常类型做一些判断,比如数据库连接异常可以发送告警邮件等
            // if (ex instanceof SQLException) { ... }
    
            return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    
        // 你还可以定义处理特定异常的方法,比如处理 NullPointerException
        @ExceptionHandler(NullPointerException.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ResponseEntity<ErrorResponse> handleNullPointerException(NullPointerException ex, WebRequest request) {
            log.error("发生了空指针异常: {}", request.getDescription(false), ex);
            ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "糟了,程序碰到空指针了,我们正在修复!",
                "Null Pointer Exception Occurred"
            );
            return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    
        // 处理其他自定义业务异常...
        // @ExceptionHandler(MyBusinessException.class)
        // public ResponseEntity<ErrorResponse> handleMyBusinessException(MyBusinessException ex, WebRequest request) { ... }
    
    }
    
  • 说明:

    • 使用 @ControllerAdvice 标记类。
    • @ExceptionHandler(Exception.class) 会捕获所有未被其他更具体的 ExceptionHandler 捕获的异常,相当于一个“兜底”的处理器。
    • @ExceptionHandler(NoHandlerFoundException.class) 专门处理 404 错误,记得要配置 spring.mvc.throw-exception-if-no-handler-found=true
    • @ResponseStatus 注解可以方便地设置响应的 HTTP 状态码。
    • 强烈建议使用 日志框架 (如 SLF4j + Logback/Log4j2) 记录详细的错误信息,包括完整的异常堆栈 (log.error("...", ex);)。这对于后续排查问题至关重要。
    • 返回给用户的 ErrorResponse 对象只包含状态码、友好的提示信息和错误类型(可选),绝对不能包含原始的异常堆栈或敏感的技术细节
    • 你可以根据需要添加更多处理特定异常的方法,Spring 会根据异常类型进行匹配,匹配最精确的那个 。例如,如果同时有处理 ExceptionNullPointerException 的方法,当抛出 NullPointerException 时,会优先调用处理 NullPointerException 的方法。
  • 安全提醒:

    • 永远不要在给客户端的响应中暴露原始的异常堆栈信息或任何可能泄露系统内部结构、数据库信息等的细节。上面代码里的 ex.getMessage()ex.getStackTrace() 都应避免直接返回给用户。内部日志记录足够详细就行。
  • 进阶玩法:

    • 按业务模块区分异常处理: 如果应用复杂,可以创建多个 @ControllerAdvice 类,并通过 @Order 注解控制它们的优先级,或者通过 @ControllerAdvice(assignableTypes = {MyController1.class, MyController2.class})@ControllerAdvice("com.myapp.controllers.package") 让它们只处理特定包或特定 Controller 的异常。
    • 统一处理校验异常: Spring Validation 抛出的 MethodArgumentNotValidException (通常发生在 @RequestBody 参数校验失败时) 或 ConstraintViolationException (通常发生在 @PathVariable@RequestParam 校验失败时) 也可以在这里统一处理,提取校验失败的字段和信息,返回更具体的错误提示。
    • 国际化错误信息: 可以结合 Spring 的 MessageSource 实现错误信息的国际化。

3.2 方案二: 继承 ResponseEntityExceptionHandler 的正确姿势

虽然方案一更直接,但如果你确实想利用 ResponseEntityExceptionHandler 提供的一些对 Spring MVC 特定异常的预处理逻辑(或者已经有基于它的代码),也可以继续使用它,但要明白怎么正确地添加对 自定义 异常的处理。

  • 原理:
    在继承 ResponseEntityExceptionHandler 的类上同样加上 @ControllerAdvice 注解,让它具备全局处理能力。然后,不要 指望 handleExceptionInternal 能捕获你的业务异常,而是 新增 @ExceptionHandler 方法来处理你关心的那些自定义异常或运行时异常,就像方案一那样。对于 Spring MVC 的特定异常(如 NoHandlerFoundException),你可以选择 重写 父类提供的 handleXXX 方法来自定义它们的响应。

  • 代码实操:

    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.ControllerAdvice;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.context.request.WebRequest;
    import org.springframework.web.servlet.NoHandlerFoundException;
    import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
    
    // ErrorResponse 类同上
    
    @ControllerAdvice // 仍然需要 @ControllerAdvice
    public class CustomRestExceptionHandler extends ResponseEntityExceptionHandler {
    
        private static final Logger log = LoggerFactory.getLogger(CustomRestExceptionHandler.class);
    
        // === 新增处理自定义或运行时异常的方法 ===
    
        // 处理 NullPointerException
        @ExceptionHandler(NullPointerException.class)
        public ResponseEntity<Object> handleNullPointer(NullPointerException ex, WebRequest request) {
            log.error("捕获到空指针异常: {}", request.getDescription(false), ex);
            ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "糟了,程序碰到空指针了,我们正在修复!",
                "Null Pointer Exception Occurred"
            );
            // 注意这里返回 ResponseEntity<Object> 以匹配父类的风格,或者可以改成 ResponseEntity<ErrorResponse>
            return new ResponseEntity<>(errorResponse, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
        }
    
        // 处理所有其他未明确处理的 Exception (兜底)
        @ExceptionHandler({ Exception.class })
        public ResponseEntity<Object> handleAllOtherExceptions(Exception ex, WebRequest request) {
            log.error("捕获到未预期异常: {}", request.getDescription(false), ex);
            ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "服务器内部错误,请联系管理员。",
                ex.getClass().getSimpleName() // 可以用异常类名作为标识
            );
            return new ResponseEntity<>(errorResponse, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
        }
    
    
        // === 选择性地重写父类方法来自定义对 Spring MVC 特定异常的处理 ===
    
        // 重写处理 404 Not Found (如果需要自定义的话)
        @Override
        protected ResponseEntity<Object> handleNoHandlerFoundException(
                NoHandlerFoundException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
    
            log.warn("资源未找到 (来自 handleNoHandlerFoundException): {}, 请求路径: {}", ex.getMessage(), request.getDescription(false));
            ErrorResponse errorResponse = new ErrorResponse(
                HttpStatus.NOT_FOUND,
                "页面迷路了,不如看看别的?",
                "Resource Not Found"
            );
            // 这里也可以记录 404 日志
            // log.warn(...)
            return new ResponseEntity<>(errorResponse, headers, status); // 使用父类传入的状态码
        }
    
        // 你可以重写其他 handleXXX 方法,比如 handleMethodArgumentNotValid 等
    
    
        // 这个方法现在通常不需要直接重写了,除非你有非常特殊的定制需求
        // 让上面的 @ExceptionHandler 和重写的 handleXXX 方法处理各自的异常即可
        // @Override
        // protected ResponseEntity<Object> handleExceptionInternal(
        //         Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        //     log.error("handleExceptionInternal 被调用,异常: {}, 状态码: {}", ex.getMessage(), status);
        //     // 如果走到这里,通常是父类处理流程中未被特定 handler 处理的内部异常
        //     // 或者你是故意调用 super.handleExceptionInternal(...)
        //     ErrorResponse errorResponse = new ErrorResponse(status, "服务器内部发生错误", "Internal Error");
        //     return new ResponseEntity<>(errorResponse, headers, status);
        // }
    }
    
  • 说明:

    • 核心在于,依然是用 @ExceptionHandler 来捕获来自 Controller 的 NullPointerException 或其他通用 Exception
    • 继承 ResponseEntityExceptionHandler 的好处是可以方便地重写针对特定 Spring MVC 异常(比如 MethodArgumentNotValidException, MissingServletRequestParameterException 等)的处理逻辑,并复用父类的一些基础设施。
    • 对于 404 (NoHandlerFoundException),可以直接重写 handleNoHandlerFoundException 方法,这样语义更清晰。
    • 除非有特殊目的,一般不再需要重写 handleExceptionInternal,让具体的 @ExceptionHandler 方法和重写的 handleXXX 方法各司其职就好。
  • 安全提醒:

    • 同样适用,绝不在响应中泄露敏感信息。
  • 进阶玩法:

    • 当需要精细控制 Spring MVC 自身的多种异常(参数绑定、请求方法错误、媒体类型等)的返回格式时,继承 ResponseEntityExceptionHandler 并重写相应的 handleXXX 方法会比较方便,能保持异常处理逻辑的集中。

四、 别忘了 application.properties 配置

无论采用哪种方案,为了让 404 错误能被 NoHandlerFoundException 处理器捕获,以及为了避免 Spring Boot 自动包含堆栈信息,以下配置通常是需要的:

# 让 Spring 在找不到 Handler 时抛出 NoHandlerFoundException
spring.mvc.throw-exception-if-no-handler-found=true

# (可选,但推荐) 禁用 Spring Boot 默认的错误页面
server.error.whitelabel.enabled=false

# (重要) 禁止在 Spring Boot 默认的错误响应中包含堆栈信息
# 即使我们自定义了处理,也最好关掉,以防万一有未捕获的错误走了默认流程
server.error.include-stacktrace=never

# (可选) 你可以决定是否要在 Spring Boot 默认错误响应中包含错误消息 (通常我们的自定义响应会覆盖这个)
# server.error.include-message=always

YAML 格式:

spring:
  mvc:
    throw-exception-if-no-handler-found: true
server:
  error:
    whitelabel:
      enabled: false
    include-stacktrace: never
    # include-message: always

五、 日志!日志!日志!重要的事情说三遍

再次强调,全局异常处理的核心价值不仅仅是给用户一个友好的界面,更重要的是在后端 完整地记录下错误的上下文信息 。这包括:

  • 时间戳
  • 请求的 URL、HTTP 方法
  • 请求参数 (注意脱敏,不要记录密码等敏感信息)
  • 用户信息(如果已登录)
  • 完整的异常堆栈 (Exception Stack Trace)

有了这些信息,开发和运维人员才能快速定位并修复问题。务必在你的 @ExceptionHandler 方法中使用日志框架(如 SLF4j + Logback/Log4j2)的 errorwarn 级别来记录这些日志。

// 示例日志记录
log.error("处理请求 [{}] 时发生异常.", request.getDescription(false), ex);
// 或者更详细,如果需要记录请求头、用户信息等
// log.error("异常详情 - URI: {}, Method: {}, User: {}, Exception: ", request.getDescription(false), httpRequest.getMethod(), principal.getName(), ex);

选择合适的日志级别,比如 WARN 用于 404 或预期的业务异常,ERROR 用于未预期的 NullPointerException 或其他严重错误。配置好日志输出目的地(文件、ELK、APM系统等)。

现在,你的 Spring Boot API 应该能够更优雅、更安全地处理各种突发状况了。用户看到的是友好的提示,而你拥有了排查问题所需的所有信息。