异常的统一处理
统一异常处理的必要性体现在保持代码的一致性、提供更清晰的错误信息、以及更容易排查问题。通过定义统一的异常处理方式,确保在整个应用中对异常的处理保持一致,减少了重复编写相似异常处理逻辑的工作,同时提供友好的错误信息帮助开发者和维护人员更快地定位和解决问题,最终提高了应用的可维护性和可读性。
Spring MVC 如何进行异常处理
当 Controller 里抛出异常时,请求会交给一串 HandlerExceptionResolver
依次处理:
ExceptionHandlerExceptionResolver
异常处理器会找@ExceptionHandler
标注(包含@RestControllerAdvice
)的方法处理。ResponseStatusExceptionResolver
会识别@ResponseStatus
或ResponseStatusException
异常类,按注解/参数设定状态码与原因。DefaultHandlerExceptionResolver
会处理框架异常,将其翻译成 4xx/5xx(如 405/415)。
如果抛出的异常没有相应的处理器处理,就走默认的 /error
(BasicErrorController
)。
接下来就详细讲一下上面描述的三种异常处理方式
RestControllerAdvice
和 @ExceptionHandler
@ControllerAdvice
或者 @RestControllerAdvice
注解的作用是创建一个全局的异常处理器,该类会拦截所有控制器抛出的异常
@ExceptionHandler
注解是用来定义异常处理类内部对应异常类型的处理逻辑。
定义一些状态码
/*** 统一业务码定义:同时绑定默认提示与 HTTP 状态*/ @Getter public enum ResponseCode { SUCCESS(0, "Success", HttpStatus.OK), INTERNAL_ERROR(1, "服务器内部错误", HttpStatus.INTERNAL_SERVER_ERROR), USER_INPUT_ERROR(2, "用户输入错误", HttpStatus.BAD_REQUEST), AUTHENTICATION_NEEDED(3, "Token过期或无效", HttpStatus.UNAUTHORIZED), FORBIDDEN(4, "禁止访问", HttpStatus.FORBIDDEN), TOO_FREQUENT_VISIT(5, "访问太频繁,请休息一会儿", HttpStatus.TOO_MANY_REQUESTS),; private final int code; // 业务码(给前端判断逻辑) private final String message; // 默认提示语 private final HttpStatus httpStatus; // http 状态 ResponseCode(int code, String message, HttpStatus httpStatus) {this.code = code;this.message = message;this.httpStatus = httpStatus;} }
定义一个业务异常
/*** 统一业务异常*/ @Getter public class AuroraRuntimeException extends RuntimeException { private final ResponseCode responseCode; public AuroraRuntimeException(ResponseCode responseCode) {super(responseCode.getMessage());this.responseCode = responseCode;} public AuroraRuntimeException(ResponseCode responseCode, String message) {super(message);this.responseCode = responseCode;} }
定义全局异常处理类
@RestControllerAdvice public class GlobalExceptionHandler { // 处理全局业务异常@ExceptionHandler(AuroraRuntimeException.class)public ResponseEntity<ErrorResponse> handleRuntimeException(AuroraRuntimeException e) {return buildErrorResponse(e.getResponseCode());} // 构建标准的错误响应private ResponseEntity<ErrorResponse> buildErrorResponse(ResponseCode responseCode) {ErrorResponse errorResponse = new ErrorResponse(LocalDateTime.now(),responseCode.getCode(),responseCode.getHttpStatus().value(),responseCode.getHttpStatus().getReasonPhrase(),responseCode.getMessage());return new ResponseEntity<>(errorResponse, responseCode.getHttpStatus());} // 定义错误响应的数据结构@Data@AllArgsConstructor@NoArgsConstructorpublic static class ErrorResponse {@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")private LocalDateTime timestamp;private int code; // 业务码private int status; // http 状态码private String error; // http 状态的简短描述private String message; // 详细的业务信息} }
该类也可以继承
ResponseEntityExceptionHandler
类,其中定义了一些 mvc 常处理的异常
@ExceptionHandler
注解可以用来处理局部异常。该注解在一个 @Controller
控制器类中使用只会在当前类中生效
@RestController public class TestController {@RequestMapping("/test")public ResultResponse test() {throw new ServiceException();}@ExceptionHandler(ServiceException.class)public ResultResponse handleServiceException(ServiceException e) {} }
@ResponseStatus
与 ResponseStatusException
可以自定义异常类
@ResponseStatus(HttpStatus.NOT_FOUND) // 直接 404 public class NotFoundException extends RuntimeException {public NotFoundException(String msg) { super(msg); } }
定义了异常类之后,在相应的位置直接抛出该类即可
也可以在代码中直接抛出该类
throw new org.springframework.web.server.ResponseStatusException(HttpStatus.BAD_REQUEST, "参数不正确");
这两种方式会被
ResponseStatusExceptionResolver
自动识别捕获
RestControllerAdvice
中定义的异常处理类优先级最高,当其中定义的异常类被抛出之后,ExceptionHandlerExceptionResolver
会识别处理。它没有定义的异常被抛出,才会由ResponseStatusExceptionResolver
处理@ResponseStatus
标注的类
集成了 Spring Security 框架
如果集成了 Spring Security 的框架,在他的 Filter Chain 中如果抛出了 401/403 的异常,这些异常是不会由 Spring MVC 处理的,因为这些异常 Web Filter 级的,发生在 Spring MVC 之前
这里我们主要讲一下在 Spring Security 中,未认证和已认证但权限不够时异常是如和处理的。
其中主要涉及三个核心类 AuthenticationEntryPoint
、AccessDeniedHandler
和 ExceptionTranslationFilter
AuthenticationEntryPoint:未认证 时怎么响应(通常返回 401 或跳登录页)。
AccessDeniedHandler:已认证,但权限不够 时怎么响应(通常返回 403)。
ExceptionTranslationFilter:Security 过滤器链里的 翻译器,捕获 鉴权阶段抛出的异常,然后 转给 上述两个处理器生成响应。
异常处理流程:
请求 →(自定义过滤器/JWT)→ … → FilterSecurityInterceptor(做鉴权)
如果当前没有有效的
Authentication
(匿名/未登录)且资源需要登录,鉴权会 抛AuthenticationException
,然后会被 ExceptionTranslationFilter 捕获,接着 调用AuthenticationEntryPoint.commence()
抛出 401/跳转登录如果当前已登录,但不满足访问所需权限/角色鉴权会 抛
AccessDeniedException
,然后被 ExceptionTranslationFilter 捕获 调用AccessDeniedHandler.handle()
抛出 403
在 security 加入相应的配置
http.csrf(csrf -> csrf.disable()).sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(reg -> reg.requestMatchers("/auth/**", "/public/**").permitAll().anyRequest().authenticated()).exceptionHandling(ex -> ex.authenticationEntryPoint(authenticationEntryPoint()) // 401.accessDeniedHandler(accessDeniedHandler()) // 403).addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
通过 Bean 注入 AuthenticationEntryPoint
,AccessDeniedHandler
类,并通过 lamba 实现处理方法
// 401 - 未认证 / Token 无效或过期 @Bean AuthenticationEntryPoint authenticationEntryPoint() {return (req, res, ex) -> {res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);res.setContentType("application/json;charset=UTF-8");res.getWriter().write("{\"code\":\"AUTHENTICATION_NEEDED\",\"message\":\"未登录或令牌无效\"}");}; } // 403 - 已认证但无权限 @Bean AccessDeniedHandler accessDeniedHandler() {return (req, res, ex) -> {res.setStatus(HttpServletResponse.SC_FORBIDDEN);res.setContentType("application/json;charset=UTF-8");res.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"没有访问权限\"}");}; }
这也可以通过继承实现
简单看一下 ExceptionTranslationFilter
内部的处理逻辑
try {chain.doFilter(request, response); // 继续往后交给 FilterSecurityInterceptor 等 } catch (AuthenticationException ae) {// 未认证:清上下文 + 可能保存原始请求 + 调用 EntryPointSecurityContextHolder.clearContext();// 如果用了 SavedRequest(表单站点),会保存请求以便登录后重定向回来authenticationEntryPoint.commence(request, response, ae); } catch (AccessDeniedException ade) {if (isAnonymousOrRememberMe()) {// 某些情况下把它当成未认证来走 EntryPoint(如 RememberMe 失效)authenticationEntryPoint.commence(request, response, new InsufficientAuthenticationException(..., ade));} else {// 已认证但权限不足 → 403accessDeniedHandler.handle(request, response, ade);} }
因此,在 security 中,一般不需要自己手动抛出处理登录逻辑的异常,MVC 层的业务异常由上述的业务处理方法