下午两点,生产环境挂了。原因是某个服务的API接口在传入非法参数时,抛出了一个null指针异常,没有做任何处理,SpringBoot默认的“whitelabel error page”直接返回了一段丑陋的500 HTML。前端收到的不是JSON,而是堆栈信息,导致客户端解析失败,用户界面白屏。更糟的是,日志里只有一行“500 error”,连具体哪个参数出问题都查不到。优雅地处理异常,从来不是“顺便做做”的锦上添花,而是一个生产级应用的基本素养。
我们太容易把异常处理写成if-else的垃圾堆了。最常见的手法是在每个Controller方法里写try-catch,catch住一大坨Exception,然后返回一个Map。代码变得像一锅粥,每个接口都要重复写同样的错误响应逻辑,一旦响应格式变化,就得改遍所有方法。这种“防御性编程”实际上是在制造技术债,它让异常处理变成了代码的毒瘤,而不是盾牌。
从“到处抓”到“抓一次”——全局异常处理的降维打击
SpringBoot给了我们一把利器:@ControllerAdvice配合@ExceptionHandler。你只需要定义一个全局异常处理器,就能接管所有Controller抛出的异常。这就好比把散落在各个接口的灭火器集中到一个消防站,不仅管理方便,还统一了响应格式。
看一个基本实现:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public Result handleException(Exception e) { log.error("未预期异常", e); return Result.error(500, "服务器内部错误"); } }
注意,这里用了@RestControllerAdvice而非@ControllerAdvice,它直接帮我们干了@ResponseBody的活。一旦出现任何未被具体定义的异常,都会落入这个兜底方法。但要小心,这个方法不能随便写,如果所有异常都走这里,你就失去了对业务错误的精细控制。所以更好的做法是:把常见的系统异常先拦截掉,再把未知的推给兜底。
自定义异常体系——拒绝“万能异常”
很多团队用一个BusinessException打天下,里面塞一个errorCode和message。结果导致所有业务错误都抛出同一个类,前端只能根据message做判断,一旦message变了,前端逻辑就炸了。优雅的做法是为每种错误类型定义专属异常,比如参数校验异常、数据不存在异常、权限不足异常、业务规则冲突异常等。
我们可以构建一个异常基类:
public abstract class BaseException extends RuntimeException { private final int code; private final String message; public BaseException(int code, String message) { super(message); this.code = code; this.message = message; } // getters... }
然后派生出具体的异常:UserNotFoundException extends BaseException、InvalidParamException extends BaseException、ForbiddenException extends BaseException。这样做的好处是,在全局异常处理器中,你可以通过异常类型精确地设置HTTP状态码和错误响应,前端也能根据异常类型做不同的处理策略,而不是去解析message字符串。
统一响应体——给异常穿上标准制服
没有统一响应体的异常处理,就像军队没有统一的军装。你的接口可能返回{code: 200, data: ...},但异常时可能返回{"error":"bad request"}或者直接500页面。前端需要写两份解析逻辑,这是灾难源头。定义一个标准的API返回类,让成功和失败都穿同一件衣服。
@Data public class ApiResult<T> { private int code; private String message; private T data; private long timestamp; public static <T> ApiResult<T> success(T data) { return new ApiResult<>(200, "success", data); } public static <T> ApiResult<T> error(int code, String message) { return new ApiResult<>(code, message, null); } }
在全局异常处理器中,所有异常都被转化为ApiResult.error()。前端只需要判断code是否为200即可。注意,这里的code不是HTTP状态码,而是业务状态码。200表示业务成功,其他表示具体错误。HTTP状态码可以保持200(对于业务错误),但对于真正的系统错误(比如500、403)建议让前端通过HTTP状态码来感知,这就需要我们在@ExceptionHandler中设置response的status。
异常与HTTP状态码的精准映射
HTTP状态码是网络层的语义,业务状态码是应用层的语义,两者需要协同。例如用户请求了一个不存在的资源,HTTP状态码应该是404,而业务状态码可以是10001。在全局处理器中,我们可以根据异常类型来设置响应状态:
@ExceptionHandler(ResourceNotFoundException.class) public ApiResult handleNotFound(ResourceNotFoundException e, HttpServletResponse response) { response.setStatus(HttpStatus.NOT_FOUND.value()); return ApiResult.error(e.getCode(), e.getMessage()); }
切忌把所有异常都返回200,然后在body里放错误码。这违反了HTTP协议的语义,而且会让网关、负载均衡器的健康检查变得困难。但也要注意,对于同一种错误(比如参数错误),前端期望的往往是明确的业务错误码,而非HTTP 400,因为400比较笼统。所以更推荐的做法是:业务错误统一返回200,并在code中区分;系统错误则映射对应的HTTP状态码。这需要团队达成共识,但无论如何,一致性比固执己见更重要。
请求验证异常——别让@Valid抛出的异常裸奔
Spring Boot的@Valid或@Validated配合@NotBlank、@NotNull等注解,在校验失败时会抛出MethodArgumentNotValidException或ConstraintViolationException。如果不处理,默认返回的是400加上一个复杂的BindingResult结构,前端难以解析。优雅的做法是在全局异常处理器中捕获这些异常,并提取出友好的错误信息。
@ExceptionHandler(MethodArgumentNotValidException.class) public ApiResult handleValidation(MethodArgumentNotValidException e) { List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors(); String message = fieldErrors.stream() .map(f -> f.getField() + ":" + f.getDefaultMessage()) .collect(Collectors.joining("; ")); return ApiResult.error(40001, message); }
这里有个细节:建议仅返回第一个错误,而不是所有字段错误,因为处理多个错误反而让前端更迷惑。另外,对于ConstraintViolationException(参数在非Controller层校验),也要提前捕获,避免掉入兜底逻辑。
国际化与错误码——让你的API走出国门
如果你的服务需要支持多语言,那么异常消息就不能硬编码在代码里。Spring Boot天然支持i18n,我们可以把错误消息放到messages.properties中,通过语言locale来动态获取。优雅的做法是:异常类中只携带错误码,不携带具体消息,在全局处理器中根据错误码和用户语言来装配消息。
// 在GlobalExceptionHandler中 @Autowired private MessageSource messageSource; @ExceptionHandler(BusinessException.class) public ApiResult handleBusiness(BusinessException e, Locale locale) { String message = messageSource.getMessage("error." + e.getCode(), e.getArgs(), locale); return ApiResult.error(e.getCode(), message); }
错误码设计也需要讲究,建议按模块+场景分层,比如1001表示用户模块-账号不存在,2001表示订单模块-库存不足。这样即便message翻译变了,前端依然可以根据code做逻辑判断。同时,错误码最好在文档中有详细说明,形成团队的“错误码圣经”。
在Service层抛异常还是处理?——一个经典抉择
很多新手纠结于:Service层是直接抛异常让Controller统一处理,还是自己在Service里try-catch然后返回Result?我的建议是:Service层应该只专注于业务逻辑,遇到违反业务规则的情况直接抛出自定义的业务异常,让全局异常处理器来接管。这样Controller就变得极薄,只负责接收请求和返回响应,不需要再写try-catch。
但也有例外:如果你的Service需要处理非业务异常(比如远程调用超时),你可以捕获它并包装成业务异常再抛出,或者直接抛出一个系统级异常。关键是保持分层清晰:Controller中的try-catch除了全局处理器的兜底外,不应该存在。如果你发现某个Controller里写了catch块,那很可能全局处理器没有覆盖到该异常。
日志记录与监控——异常处理的最后防线
优雅地处理异常不只是返回好看的结构,还包括妥善的记录和监控。在全局异常处理器中,一定要区分日志级别:对于业务异常(例如用户输入错误、资源不存在),用warn或info级别记录,避免日志爆满;对于系统异常(空指针、数据库连接失败、第三方服务不可用),用error级别并打印完整堆栈。此外,引入一些监控指标(如Micrometer),对每种异常类型进行计数,当某种异常突然增多时,能及时报警。
@ExceptionHandler(DataAccessException.class) public ApiResult handleDB(DataAccessException e, HttpServletResponse response) { // 打印完整堆栈,并统计到监控系统 log.error("数据库异常", e); counterService.increment("exception.db"); response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); return ApiResult.error(50000, "数据服务异常,请稍后重试"); }
日志中要包含请求的唯一标识(traceId),便于快速关联上下游调用。如果你没有引入链路追踪,至少要在入口处添加UUID,并传递给异常处理器。这样运维人员就能根据traceId快速定位问题。
最佳实践清单——把套路变成规范
总结下来,优雅处理SpringBoot异常应该遵循以下原则:
全局统一拦截:所有异常都走@RestControllerAdvice,Controller不写try-catch。
精准抛出:使用自定义异常而不是RuntimeException,让异常类型说明意图。
统一响应体:成功和失败都返回相同的JSON结构,字段包括code、message、data、timestamp。
合理利用HTTP状态码:系统错误映射正确状态码,业务错误可以统一200+业务码。
参数验证统一处理:捕获MethodArgumentNotValidException,返回简洁的提示信息。
异常信息国际化:错误码对应资源文件,支持多语言。
分层日志:业务异常warn,系统异常error并带堆栈。
监控告警:对高频异常进行计数,设置阈值触发告警。
隔离第三方异常:将框架或中间件的异常(如Feign、Redis)包装成自己的异常,避免泄露内部实现。
不要吞异常:全局兜底方法不能只是return Result.error()而不记录,至少要把异常打印出来。
当心“优雅陷阱”——过度设计也是一种灾难
我见过一些团队,为了“优雅”搞出了几十种异常类,对应几十个错误码,全局处理器里写了十几个@ExceptionHandler方法。导致每次新增一个错误都要加类、加状态码、加处理器方法,反而拖累了开发效率。优雅不是越多越好,而是恰到好处。
我的建议是:先覆盖最常见的几类异常(参数校验、资源不存在、权限不足、业务冲突、系统错误),再根据业务复杂度逐步扩展。如果有非常多的相似错误,可以考虑使用枚举异常码+动态消息的方式,而非创建大量类。比如:
public class BusinessException extends BaseException { public BusinessException(ErrorCode errorCode, Object... args) { super(errorCode.getCode(), formatMessage(errorCode, args)); } }
其中ErrorCode是一个枚举,包含code和默认消息模板。这样既能类型化,又不需要为每个错误单独创建类。关键是要保持平衡,让代码可维护且不过度抽象。
回到开头那个线上事故。修复方案很简单:添加一个全局异常处理器,并把所有可能出现空指针的地方用明确的业务异常替代。但更重要的是,团队意识到异常处理不是面向debug的,而是面向用户体验的。前端不再看到白屏,运维不再靠猜,开发不用在每个接口加catch。这就是优雅的代价——一点点设计上的投入,换来持续的生产力回报。
真正优雅的SpringBoot异常处理,不是把异常藏起来,而是让异常变得可预期、可追溯、可管理。当你听到生产环境告警时,能根据错误码和traceId迅速定位,而不是面朝log大海发呆,你就成功了。