Spring应用抛出NoHandlerFoundException、全局异常处理、日志级别
本文记录在基于Spring(Boot)框架(使用Java语言)和Grails框架(使用Groovy语言)下,开发Controller接口,对不存在的URL请求,接口返回404 not found,而不是抛出NoHandlerFoundException异常的问题,以及排查过程。
对于Grails框架,请参考Grails。
Spring
代码省略,常规的Spring应用(Spring Boot基于Spring),/sso/logout
接口。在Postman里请求明显不存在的路径/sso/logout1
,服务响应如下图:
乍看之下,好像没什么问题,/sso/logout1
本来就不存在,请求一个不存在的路径,服务返回404报错等各种详情,一目了然。
深入思考下,真的没有问题吗?
在业务开发过程中,经常会有新接口的产生,同时还有旧接口的废弃。因此,接口的调用者请求某个接口时,势必会出现遇到404 Not Found异常报错。接口的请求者主要有两类:
- 微服务(或分布式)体系下的请求发起方:此时发起方会做好异常状态码处理,大概率不会出现问题;
- 大前端(包括Web,Android,iOS,小程序,H5等):前端页面会对404错误进行统一处理,然后给用户展示一个友好的页面。
好像也没啥问题。真的吗?
实际上,这里存在的问题是,应用里并没有记录任何有效信息,更谈何WARN或ERROR日志:
2025-01-11 17:50:12.465 INFO 24532 --- [nio-8880-exec-2] com.tesla.security.filter.LogFilter : ==== request url: GET /sso/logout1 ====
2025-01-11 17:50:12.485 INFO 24532 --- [nio-8880-exec-2] i.j.internal.reporters.LoggingReporter : Span reported - GET
2025-01-11 17:50:12.512 INFO 24532 --- [nio-8880-exec-2] i.j.internal.reporters.LoggingReporter : Span reported - error
日志分析:第一行日志是一个统一的:
@Slf4j
@Order(2)
@WebFilter(filterName = "logFilter", urlPatterns = "/*", asyncSupported = true)
public class LogFilter implements Filter {@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest) request;// 获取请求路径String path = CommonUtil.getRequestPath(request);if ("/health".equals(path)) {chain.doFilter(request, response);return;}String queryString = request.getQueryString();if (StringUtils.isNotBlank(queryString)) {queryString = "?" + queryString;} else {queryString = "";}log.info("==== request url: {} {} {} ====", request.getMethod(), path, queryString);chain.doFilter(request, response);}
}
第2~3行日志,是在应用接入到Jaeger后才会打印的日志。也就是说,应用接收到一个不存在的请求,没有记录任何有效日志。
系统应用或组件越来越多后,即变成所谓的复杂系统。复杂系统的可观测性,是一门学问,诞生(或催生)出可观测性工程。
404 Not Found,主要有两个场景:
- 前端(或其他后端通过RPC或HTTP方式)请求一个不存在的接口:接口确实不存在,或接口存在但调用方写错
- Spring Cloud Gateway等网关配置路由转发规则有误:接口存在,配置有误
NoHandlerFoundException
那如何配置才能让服务抛出异常呢?在Spring体系里,404对应的异常是NoHandlerFoundException。经过搜索,应用的application.yaml
文件做如下配置后:
spring:mvc:throw-exception-if-no-handler-found: trueweb:resources:add-mappings: false
应用会抛NoHandlerFoundException异常。
全局异常处理
非常经典的需求。此处直接给出实现代码片段。
NoHandlerFoundException也需要纳入全局统一异常处理:
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class AdviceConfiguration implements ResponseBodyAdvice<Object> {protected static final MediaType MEDIA_TYPE = new MediaType("application", "json", StandardCharsets.UTF_8);@ExceptionHandler(NoHandlerFoundException.class)@ResponseStatus(HttpStatus.NOT_FOUND)public ResponseEntity<R<?>> handleNoHandlerFound(HttpServletRequest request, NoHandlerFoundException e) {// 前端请求不存在的URI,或网关配置错误,日志级别为ERRORthis.logError(e, request, "方法不存在");return createResponseEntity(HttpStatus.NOT_FOUND, R.error(NOT_FOUND.value(), e.getMessage()));}private void logError(Exception e, HttpServletRequest request, String... msg) {String template = "[Web][有异常被抛出] >> 异常类=[%s], URI=[%s], 消息=[%s], 异常=[%s]";log.error(String.format(template, e.getClass().getName(), request.getRequestURI(),ArrayUtil.isEmpty(msg) ? e.getMessage() : ArrayUtil.join(msg, ","), ExceptionUtil.stacktraceToString(e)));}protected static ResponseEntity<R<?>> createResponseEntity(HttpStatus httpStatus, R<?> body) {return ResponseEntity.status(httpStatus.value()).contentType(MEDIA_TYPE).body(body);}
}
日志级别
对于404 Not Found异常,不建议使用WARN级别来记录日志,应使用ERROR级别。
几种适合使用WARN日志级别的异常:
- 自定义业务异常:业务代码里的校验,如用户ID非法,抛出BizException,然后交由全局统一异常处理类处理时,打印warn日志;
- HttpRequestMethodNotSupportedException:请求方法不支持,接口标记为POST请求,被PUT调用;
com.fasterxml.jackson.core.JsonParseException
:- HttpMessageNotReadableException:HTTP消息不可读异常;
- IllegalArgumentException:
- BindException:
- MethodArgumentNotValidException:
- MissingServletRequestParameterException:
- 还有更多
@ResponseStatus(OK)
@ExceptionHandler(BizException.class)
public ResponseEntity<R<?>> handleBaseException(HttpServletRequest request, BizException e) {this.logWarn(e, request, "自定义业务异常");return createResponseEntity(OK, R.error(e.getCode(), e.getMessage()));
}@ResponseStatus(BAD_REQUEST)
@ExceptionHandler({JsonParseException.class, HttpMessageNotReadableException.class, BindException.class,IllegalArgumentException.class, MethodArgumentNotValidException.class, MissingServletRequestParameterException.class})
public ResponseEntity<R<?>> handleJsonParseException(Exception e, HttpServletRequest request) {this.logWarn(e, request, "不合法的参数异常");this.countInc(request, e, CODE_400);return createResponseEntity(BAD_REQUEST, R.error(BAD_REQUEST.value(), e.getMessage()));
}@ResponseStatus(METHOD_NOT_ALLOWED)
@ExceptionHandler({HttpRequestMethodNotSupportedException.class})
public ResponseEntity<R<?>> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {this.logWarn(e, request, "不支持的请求方式");this.countInc(request, e, "405");return createResponseEntity(METHOD_NOT_ALLOWED, R.error(METHOD_NOT_ALLOWED.value(), "不支持的请求方式"));
}private void logWarn(Exception e, HttpServletRequest request, String... msg) {String template = "[Web][有Warn被抛出] >> Warn类=[%s], URI=[%s], 消息=[%s], Warn=[%s]";log.warn(String.format(template, e.getClass().getName(), request.getRequestURI(),ArrayUtil.isEmpty(msg) ? e.getMessage() : ArrayUtil.join(msg, ","), ExceptionUtil.stacktraceToString(e)));
}
HttpMessageNotReadableException
产生此异常的场景有很多:
- POST请求传入非法JSON;
IllegalArgumentException
产生此异常的场景:
- 1
BindException
MethodArgumentNotValidException
产生此异常的场景:
- POST请求里对实体类加@RequestBody、@Valid等注解,在实体类里对(部分)字段加Validation注解。请求接口时,实体类不满足校验条件,比如字段不存在,为空,长度超过20个字符等,就会抛此异常;
MissingServletRequestParameterException
产生此异常的场景:
- GET请求里有个
@RequestParam(value = "app_id") String appId
,但是请求时并没有带上此Query Params参数,就会抛此异常;