你见过这样的代码吗?Controller里密密麻麻的try-catch,每个业务方法都要包裹一层,catch之后写一堆重复的响应组装逻辑,还时不时忘记处理某个异常导致500飘红。这种“异常痛感”几乎是每个Spring Boot项目从原型走向生产的必经之路。今天我们不谈理论,只讲实战——如何用Spring Boot的全局异常处理机制,把散落在各个Controller里的异常“收网”,让代码回归优雅。
从“散装try-catch”到“集中处理”
先看一段真实的“痼疾”代码:
@GetMapping("/user/{id}") public Result getUser(@PathVariable Long id) { try { User user = userService.findById(id); return Result.success(user); } catch (UserNotFoundException e) { return Result.error(1001, e.getMessage()); } catch (DatabaseException e) { return Result.error(2001, "数据库异常"); } catch (Exception e) { return Result.error(500, "系统错误"); } }
每个接口都这样写,代码量翻倍,可读性极差。更可怕的是,一旦业务异常类型增加,每个Controller都要同步修改。真正的优雅,是把异常处理的关注点从“怎么处理”转移到“怎么定义”上。而Spring Boot提供的@ControllerAdvice+@ExceptionHandler,就是那个“收网”的工具。
定义自己的异常“身份证”
在统一处理前,必须先让异常具备明确的语义。直接抛出RuntimeException是懒惰的做法,优秀代码中的异常应该像身份证一样,告诉你它属于哪个错误分类、错误码是什么、适合在哪一层被捕获。建议至少定义三个级别的异常:
BizException(业务异常):用户操作导致的错误,如用户名重复、订单状态不符。错误码4xx系列。
SystemException(系统异常):基础设施故障,如数据库连接失败、缓存超时。错误码5xx系列。
ThirdPartyException(第三方异常):远程调用失败,如支付接口超时。可根据是否自动重试决定处理方式。
每个异常类只需持有两个字段:errorCode和errorMessage。让异常携带足够的上下文,是全局处理发挥价值的前提。例如:
public class BizException extends RuntimeException { private final Integer errorCode; public BizException(Integer errorCode, String message) { super(message); this.errorCode = errorCode; } }
这样,抛出时只需一行:throw new BizException(1001, "用户不存在");
统一响应体:所有接口“说同一种语言”
异常处理的最终输出是HTTP响应。如果每个接口返回的JSON结构不一致,前端写if-else就会写到崩溃。统一响应体是API契约的基石,建议使用泛型Result 。一个典型的Result包括:
public class Result<T> { private Integer code; // 业务状态码,0表示成功,非0表示失败 private String message; // 对code的描述 private T data; // 返回数据 private Long timestamp; // 时间戳,便于排查 }
成功时code=0,失败时使用错误码。注意不要用HTTP状态码直接作为业务码,例如404可能对应多种业务语义。Result类提供静态方法success()和fail(),全局异常处理器只需调用Result.fail(errorCode, message),即可保证所有异常响应格式完全一致。
核心:搭建全局异常处理中枢
在Spring Boot中,@ControllerAdvice可以拦截所有Controller的异常。一个简洁且完备的处理类如下:
@RestControllerAdvice public class GlobalExceptionHandler { // 处理业务异常 @ExceptionHandler(BizException.class) public Result<?> handleBizException(BizException e) { log.warn("业务异常:{}", e.getMessage()); return Result.fail(e.getErrorCode(), e.getMessage()); } // 处理参数校验异常 @ExceptionHandler(MethodArgumentNotValidException.class) public Result<?> handleValidation(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining("; ")); return Result.fail(400, msg); } // 处理404 - 没访问到接口 @ExceptionHandler(NoHandlerFoundException.class) public Result<?> handleNotFound(NoHandlerFoundException e) { return Result.fail(404, "请求资源不存在"); } // 处理兜底异常 - 所有未处理的RuntimeException @ExceptionHandler(Exception.class) public Result<?> handleException(Exception e) { log.error("系统异常:", e); return Result.fail(500, "服务器内部错误"); } }
关键点有两个:一是顺序,更具体的异常(如BizException)要放在更通用的异常(Exception)之前,Spring会根据异常类型匹配最近的处理器;二是在兜底方法中要打印完整的堆栈日志,以便排查问题,而返回给前端的消息则要模糊化,避免泄露敏感信息。
处理“看起来不一样”的异常:参数校验
参数校验异常是全局异常中最高频的一种。当我们在Controller参数上使用@Valid或@Validated时,Spring会自动校验并抛出MethodArgumentNotValidException或ConstraintViolationException。如果不专门处理,框架会直接返回400和一段难看的JSON,完全不遵从我们的统一响应格式。
上面的代码已经处理了MethodArgumentNotValidException,提取了所有校验失败字段的错误消息拼接成字符串。但这里有个陷阱:如果校验失败的是单个参数(比如@RequestParam),会抛出ConstraintViolationException。所以还需要一个额外的方法:
@ExceptionHandler(ConstraintViolationException.class) public Result<?> handleConstraintViolation(ConstraintViolationException e) { String msg = e.getConstraintViolations().stream() .map(v -> v.getMessage()) .collect(Collectors.joining("; ")); return Result.fail(400, msg); }
优雅的本质不是代码少,而是所有分支都有清晰的归属。只要没有遗漏,客户端收到的永远是{code, message, data, timestamp}结构,前端解析逻辑就能做到“一劳永逸”。
再处理几种“隐形成员”:请求方法错误与类型转换
除了业务和校验,常见的还有:
HttpRequestMethodNotSupportedException:使用了不支持的HTTP方法(GET调POST接口)。
MissingServletRequestParameterException:缺少必填的请求参数。
TypeMismatchException:路径变量类型不匹配(期待Long却传了字符串)。
HttpMessageNotReadableException:请求体JSON解析失败。
这些异常框架会自动抛出,默认返回的响应格式五花八门。如果你不做处理,它们就会成为全局异常处理的“漏网之鱼”。建议在GlobalExceptionHandler中为每个异常写一个简短的方法,或者用统一的RuntimeException类型捕获它们。更优雅的做法是使用一个Map注册常见异常类型:
private static final Map<Class<? extends Throwable>, Integer> EXCEPTION_CODE_MAP = new HashMap<>(); static { EXCEPTION_CODE_MAP.put(MissingServletRequestParameterException.class, 400); EXCEPTION_CODE_MAP.put(TypeMismatchException.class, 400); EXCEPTION_CODE_MAP.put(HttpRequestMethodNotSupportedException.class, 405); } @ExceptionHandler(Throwable.class) public Result<?> handleCommon(Throwable e) { Integer code = EXCEPTION_CODE_MAP.get(e.getClass()); if (code != null) { return Result.fail(code, e.getMessage()); } // 兜底 log.error("未归类异常:", e); return Result.fail(500, "服务器内部错误"); }
这种“分类器”模式比逐个写方法更简洁,但要注意Throwable会覆盖之前更具体的方法,所以必须保证前面的@ExceptionHandler优先级更高(即更具体的异常先匹配)。混合使用按类和方法两种方式,让代码既灵动又有序。
日志:异常处理的“眼睛”与“口罩”
全局异常处理器不仅要返回统一的JSON,还要承担日志记录的职责。日志必须区分级别:业务异常用warn、系统异常用error、参数校验用info。同时要警惕在日志里打印用户敏感信息(如手机号、密码),否则可能埋下安全隐患。
一个简单的实践:在自定义异常中增加shouldLog()标记,让异常自己决定是否记录堆栈。或者通过错误码区间来判断:4xx的异常通常只要记录消息,5xx的异常需要记录堆栈。把日志策略嵌入异常处理层,能让运维人员快速定位问题,而又不淹没关键信息。
此外,生产环境请务必关闭异常堆栈的响应。可以通过配置或application.yml中的server.error.include-stacktrace=never来禁止,或者在全局处理中根据不同profile判断:
@Value("${server.error.include-stacktrace:never}") private String stacktraceMode; @ExceptionHandler(Exception.class) public Result<?> handle(Exception e) { if ("always".equals(stacktraceMode)) { return Result.fail(500, ExceptionUtils.getStackTrace(e)); } return Result.fail(500, "服务器繁忙"); }
开发环境开启堆栈便于调试,生产环境关闭堆栈保障安全。
跨越边界:Filter和Interceptor中的异常
@ControllerAdvice只能处理DispatcherServlet内Controller层的异常,但过滤器(Filter)和拦截器(Interceptor)中抛出的异常它管不了。Filter在Servlet容器中跑,异常如果不在Filter内部捕获,就会直接跑到Tomcat的默认错误处理器,返回一个HTML页面,彻底打破你的统一JSON规范。
解决方案有两个:
在Filter中自行捕获异常并写入响应:这是最直接的方式,每个Filter做一个try-catch,调用HttpServletResponse.getWriter().write(JSON.toJSONString(Result.fail(...)))。
使用Spring Boot的ErrorController:实现ErrorController,重写getErrorPath(),这样所有未被捕获的异常(包括404、Filter中的异常)都会走到这个Controller,再由其转发到/error,你可以在这个Controller里将其转化为JSON。
个人推荐第二种,因为只需写一次。例如:
@RestController public class CustomErrorController implements ErrorController { @RequestMapping("/error") public Result<?> handleError(HttpServletRequest request) { Integer status = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); String message = (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE); return Result.fail(status, message); } }
加上@ControllerAdvice和这个ErrorController,你就能覆盖Controller层、Interceptor层、Filter层以及Tomcat层所有异常。至此,整个应用程序的异常终于形成了“一张网”。
异步方法异常:别让它悄悄溜走
Spring的@Async异步方法中的异常默认不会被主线程捕获,因为异步调用是另一个线程执行的。不加处理,异常就会丢失,业务逻辑中断但日志毫无记录。你需要实现AsyncUncaughtExceptionHandler或使用CompletableFuture返回结果。
推荐统一配置AsyncConfigurer:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> { log.error("异步方法[{}]执行异常:", method.getName(), ex); // 可发送告警、写入失败表等 }; } }
如果异步方法有返回值,使用CompletableFuture,在调用处通过exceptionally或whenComplete处理异常。两种方式二选一,但必须选一个,否则异步异常就是全局异常处理的“死角”。
写在最后:优雅的核心是“一致性”与“可预期”
回到开头的问题:究竟什么是“优雅地处理全局异常”?我认为不是代码多酷炫、用了多少设计模式,而是让团队中每一个开发者都能在3秒内理解异常处理的流向,让客户端收到的每一次错误响应格式都一模一样的信息。从定义异常类,到统一响应体,再到全局处理器、Filter捕获、异步异常处理,每一步都是在消除“意外”。每一次恰到好处的加粗,都是为了让读者记住那个能立即落地的技巧。下一次,当你再看到Controller里连片的try-catch时,请直接用全局异常处理重构它——你会感激自己做出的这个选择。