
你见过这样的代码吗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提供的ControllerAdviceExceptionHandler就是那个“收网”的工具。定义自己的异常“身份证”在统一处理前必须先让异常具备明确的语义。直接抛出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 ResultT { private Integer code; // 业务状态码0表示成功非0表示失败 private String message; // 对code的描述 private T data; // 返回数据 private Long timestamp; // 时间戳便于排查 }成功时code0失败时使用错误码。注意不要用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 MapClass? 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-stacktracenever来禁止或者在全局处理中根据不同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返回结果。推荐统一配置AsyncConfigurerConfiguration 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时请直接用全局异常处理重构它——你会感激自己做出的这个选择。