问题
spring boot中使用全局异常捕捉器捕捉异常返回友好数据, 准确地说不应该叫做全局异常捕捉器, 因为@RestControllerAdvice定义的异常捕捉只能捕捉经过controller层的异常, 而进入controller层之前的异常, 比如进入controller层之前的过滤器中的异常, 无法被捕捉
那么如何捕捉进入controller层之前的异常?
场景
spring security + jwt安全权限框架
用户发起一个请求, 首先经过过滤器检验是否带token或token是否合法, 不合法就不从数据中加载用户数据, 合法就加载用户数据和权限到上下文中
// AuthenticationFilter
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.getTokenFromRequest(request); // 从request中解析token
// 没token的直接玩完
if (StrUtil.isNotEmpty(token)) {
// token过期的 或伪造 或多设备登录的 直接玩完
String username = jwtUtil.getUsernameFromToken(token); // 从token中解析username
if (StrUtil.isNotEmpty(username) && ObjectUtil.isNull(SecurityUtil.getCurrentAuthentication())) {
// 合法, 用户加载信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, null);
SecurityUtil.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
// jwtUtil
/**
* 将token解析为Claims
* @param token token
* @throws TokenExpiredException token过期
* @throws IllegalTokenException 不合法的token
* @throws OtherClientsLoggedInException 已在其它客户端登录
*/
public Claims parseToken(String token) {
Claims claims;
String username;
try {
claims = Jwts.parser()
.setSigningKey(this.secret)
.parseClaimsJws(token) // 解析token 抛出ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException捕捉后再抛出自定义的异常
.getBody();
username = claims.getSubject();
} catch (ExpiredJwtException e) {
log.error("JwtUtil - Token 已过期");
throw new TokenExpiredException("token已过期");
} catch (UnsupportedJwtException e) {
log.error("JwtUtil - 不支持的token");
throw new IllegalTokenException("不支持的token");
} catch (MalformedJwtException e) {
log.error("JwtUtil - token无效");
throw new IllegalTokenException("token无效");
} catch (SignatureException e) {
log.error("JwtUtil - 无效的token签名");
throw new IllegalTokenException("无效的token签名");
} catch (IllegalArgumentException e) {
log.error("JwtUtil - token参数不存在");
throw new IllegalTokenException("JwtUtil - token参数不存在");
}
String redisKey = this.redisTokenPrefix + username;
if (redisService.isExpired(redisKey)) {
log.error("JwtUtil - redis中的token已过期");
throw new TokenExpiredException("token已过期");
}
// 检验redis中的token是否与当前一致, 不一致则代表用户已注销/用户在不同设备登录,均代表JWT已过期
String redisToken = redisService.get(redisKey);
if (!StrUtil.equals(token, redisToken)) {
log.error("JwtUtil - redis中的token不一致");
throw new OtherClientsLoggedInException("已在其它客户端登录, 请重新登录");
}
return claims;
}
/**
* 从token中解析username
*/
public String getUsernameFromToken(String token) {
Claims claims = parseToken(token);
return claims.getSubject();
}
// GlobalExceptionHandler 全局异常处理
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalTokenException.class)
public Dict IllegalTokenException(HttpServletRequest request, HttpServletResponse response, IllegalTokenException e) {
log.error("GlobalExceptionHandler - IllegalTokenException - {}", e.getMessage());
return Result.illegalToken(e.getMessage());
}
@ExceptionHandler(TokenExpiredException.class)
public Dict tokenExpiredException(HttpServletRequest request, HttpServletResponse response,TokenExpiredException e) {
log.error("GlobalExceptionHandler - TokenExpiredException - {}", e.getMessage());
return Result.tokenExpired(e.getMessage());
}
@ExceptionHandler(OtherClientsLoggedInException.class)
public Dict otherClientsLoggedInException(HttpServletRequest request, HttpServletResponse response,OtherClientsLoggedInException e) {
log.error("GlobalExceptionHandler - OtherClientsLoggedInException - {}", e.getMessage());
return Result.otherClientsLoggedIn(e.getMessage());
}
全局异常处理返回友好数据, 其中Result是封装HuTool的Dict字典类作为统一返回对象
使用不合法的token发起一次请求

根据图1可以看到, 抛出了异常, 但GlobalExceptionHandler未能正确捕捉异常返回友好数据
解决
可行的做法一[1]
(推荐)在过滤器中捕捉异常并直接利用response返回结果
// AuthenticationFilter
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.getTokenFromRequest(request);
// 没token的直接玩完
if (StrUtil.isNotEmpty(token)) {
// token过期的 或伪造 或多设备登录的 直接玩完
String username;
try {
// filter中的异常, GlobalExceptionHandler无法捕捉, 转发到ExceptionController中进行统一返回
username = jwtUtil.getUsernameFromToken(token);
} catch (Exception e) {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "*");
response.setContentType("application/json;charset=UTF-8");
response.setStatus(200);
// 将Dict对象转化为JSON字符串写入response
if (e instanceof TokenExpiredException) {
// 调用统一封装对象的tokenExpired()
response.getWriter()
.write(JSONUtil.toJsonStr(Result.tokenExpired(e.getMessage())));
} else if (e instanceof IllegalTokenException) {
// 调用统一封装对象的illegalToken()
response.getWriter()
.write(JSONUtil.toJsonStr(Result.illegalToken(e.getMessage())));
} else if (e instanceof OtherClientsLoggedInException) {
// 调用统一封装对象的otherClientsLoggedIn()
response.getWriter()
.write(JSONUtil.toJsonStr(Result.otherClientsLoggedIn(e.getMessage())));
}else{
response.getWriter()
.write(JSONUtil.toJsonStr(Result.forbidden(e.getMessage())));
}
return;
}
if (StrUtil.isNotEmpty(username) && ObjectUtil.isNull(SecurityUtil.getCurrentAuthentication())) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, null);
SecurityUtil.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}

可以看到, 这种方法行得通, 返回了自定义的封装结果
可行的做法二[2]
在过滤器中捕捉异常, 利用request的转发, 转发到特定的controller进行异常的返回
创建专门返回异常的controller
// ExceptionController
@RestController
@RequestMapping("/exception")
public class ExceptionController {
@RequestMapping("/token-expired-exception")
public Dict tokenExpiredException(HttpServletRequest request) {
String msg = (String) request.getAttribute("msg");
return Result.tokenExpired(msg);
}
@RequestMapping("/illegal-token-exception")
public Dict illegalTokenException(HttpServletRequest request) {
String msg = (String) request.getAttribute("msg");
return Result.illegalToken(msg);
}
@RequestMapping("/other-clients-logged-in-exception")
public Dict otherClientsLoggedInException(HttpServletRequest request) {
String msg = (String) request.getAttribute("msg");
return Result.otherClientsLoggedIn(msg);
}
@RequestMapping("/access-denied-exception")
public Dict accessDeniedException(HttpServletRequest request) {
String msg = (String) request.getAttribute("msg");
return Result.forbidden(msg);
}
@RequestMapping("/authentication-entry-point-exception")
public Dict authenticationEntryPoint(HttpServletRequest request) {
String msg = (String) request.getAttribute("msg");
return Result.forbidden(msg);
}
@RequestMapping("/error")
public Dict error(HttpServletRequest request) {
String msg = (String) request.getAttribute("msg");
return Result.otherClientsLoggedIn(msg);
}
}
// Authentication
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.getTokenFromRequest(request);
// 没token的直接玩完
if (StrUtil.isNotEmpty(token)) {
// token过期的 或伪造 或多设备登录的 直接玩完
String username = null;
try {
// filter中的异常, GlobalExceptionHandler无法捕捉, 转发到ExceptionController中进行统一返回
username = jwtUtil.getUsernameFromToken(token);
} catch (Exception e) {
request.setAttribute("msg", e.getMessage()); // 设置异常信息
String url;
if (e instanceof TokenExpiredException) {
url = "/exception/token-expired-exception";
} else if (e instanceof IllegalTokenException) {
url = "/exception/illegal-token-exception";
} else if (e instanceof OtherClientsLoggedInException) {
url = "/exception/other-clients-logged-in-exception";
} else {
url = "/exception/error";
}
request.getRequestDispatcher(url).forward(request, response);
return;
}
if (StrUtil.isNotEmpty(username) && ObjectUtil.isNull(SecurityUtil.getCurrentAuthentication())) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, null);
SecurityUtil.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}

这种方法虽然可以catch到异常后转发到专门的controller中方便管理, 但有时候会直接跳过request.getRequestDispatcher(url).forward(request, response);
并不会进行转发, 但有时也能正常转发, 很让人头疼
方法一可以在catch到异常后准确地将异常信息发送到浏览器
参考
[1]. SpringBoot统一异常拦截处理(filter中的异常无法被拦截处理)
[2]. SpringBoot全局异常处理捕获Filter内部异常