spring MVC异常处理
一般我们在开发web项目的时候如果用到了 SpringMVC ,那就会省很多事儿,因为spring都帮我们默默地做了很多事。特别是SpringBoot,连配置都尽量帮开发人员简化了。比如,如果我们需要返回给前端一个Json形式的响应值而不是一个页面,那我们只需要在接口上标注 @ResponseBody (或者说有类似功能的注解,比如类上标注 @ Rest Controller )。这样在业务代码执行完逻辑后,Spring会帮我们把返回值转换成相应格式然后传送给客户端。
如果考虑到业务代码可能抛出的异常呢?最直接的就是直接每个方法来一个 try-catch ,或者手动写个切面拦截所有controller的方法。本着重复劳动对程序员就是可耻的思想, Spring 又周到地提供了 @RestControllerAdvice 和 @ Exception Handler ,类上标注 @RestControllerAdvice ,然后里面的方法标注 @ExceptionHandler ,这样 @ResponseBody 标注的方法抛出的异常就会被这边处理,下面是一个示例,针对某些特殊的异常会有相应的处理,最后是一个异常兜底,返回的是默认的错误信息。
@RestControllerAdvice
public class GlobalExceptionResolver {
@ExceptionHandler(value = Throwable.class)
@ResponseStatus(code = HttpStatus.OK)
public Response exceptionHandler(Throwable e) {
if (e instanceof MethodArgumentNotValidException) {
return exceptionHandler((MethodArgumentNotValidException) e);
} else if (e instanceof BindException) {
return exceptionHandler((BindException) e);
} else if (e instanceof MissingServletRequestParameterException) {
return exceptionHandler((MissingServletRequestParameterException) e);
} else if (e instanceof MissingPathVariableException) {
return exceptionHandler((MissingPathVariableException) e);
} else {
log.warn("发生异常-被拦截器拦截:{}", Throwables.getStackTraceAsString(e));
return Response.failOfMessage("网络错误,请刷新后重试");
}
}
}
很明显,在这个异常处理逻辑返回后,Spring肯定还帮我们干了其他的事,既然数据是通过网络发送给客户端的,那 序列化 是一定少不了的,Spring默认的json格式序列化框架是jackson。说了半天终于要进入本文的主题了,如果Spring在序列化的时候抛了异常会出现什么情况呢?也会被上面的异常处理拦截住吗?也许有人会说,序列化怎么会有异常,不就是一些普通的get、set方法的使用嘛。正常来讲确实是这样,但是方法是可以重写的,有时候我们可能需要返回给客户端一个经过计算得到的值,这样可以把计算逻辑写在get方法中,jackson就会调用我们重写的get方法来获取字段值,既然方法可以重写,那就有可能不小心写出bug,然后抛出异常。巧合的是,生产环境还真的碰到了这么一例事儿,且听我慢慢道来。
问题复现
异常日志如下,从打印的堆栈来看就是序列化的时候抛了空指针,而打印日志的地方就是上面举例的全局异常处理代码,看来用户得到了网络错误的回复,还好。
image
随手查了一下 nginx 的日志,发现了一个惊人的现象,responsebody有数据!
可以看到success还是true,看上去是正常返回了。
image
反反复复看了几遍的代码,然后查看了Spring注解的 java doc,还是觉得不可思议,毕竟异常日志已经打印,所以异常处理方法返回的肯定是网络错误。报空指针的代码就是我前面说的,重写了get方法,但是里面因为bug而报了空指针。后来经同事提醒,测试环境可以复现这个问题,跟着debug断点,一步步终于发现了原因。
调试&问题定位
其实从错误日志中是可以看到方法调用栈的,重要的几个方法如下,调用顺序是从下往上:
org.springframework.http.converter. Json .AbstractJackson2HttpMessageConverter.writeInternal(AbstractJackson2HttpMessageConverter.java:296)
org.springframework.http.converter.AbstractGenericHttpMessageConverter.write(AbstractGenericHttpMessageConverter.java:)
org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:)
org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:)
org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:)
org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:)
- 在ServletInvocableHandlerMethod的invokeAndHandle方法中,将断点打在如下的119行,可以看到此时还没有对返回值进行序列化,至于为啥debug显示returnValue显示一个空指针的异常,是因为 idea 在debug时会调用变量的toString方法,tostring方法同样调了对象的getter方法,所以这里的空指针跟程序运行没有关系,继续往下走。
- 这里注意一下RequestResponseBodyMethodProcessor的handleReturnValue方法,创建了一个Response往下 透传 了。
- 直接看一下抛出异常的方法,到这里其实还是不是很明白为啥抛了异常客户端还是能拿到正常的返回值,只好跟着idea的debug一步一步往下走
protected void writeInternal(Object object, @Nullable Type type, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
MediaType contentType = outputMessage.getHeaders().getContentType();
JsonEncoding encoding = get json Encoding(contentType);
// 创建一个json的生成器
JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding);
try {
writePrefix(generator, object);
Class<?> serialization View = null;
Filter Provider filters = null;
// object就是controller的返回值
Object value = object;
JavaType javaType = null;
if (object instanceof MappingJacksonValue) {
MappingJacksonValue container = (MappingJacksonValue) object;
value = container.getValue();
serializationView = container.getSerializationView();
filters = container.getFilters();
}
if (type != null && TypeUtils.isAssignable(type, value.getClass())) {
javaType = getJavaType(type, null);
}
ObjectWriter objectWriter;
if (serializationView != null) {
objectWriter = this.objectMapper.writerWithView(serializationView);
}
else if (filters != null) {
objectWriter = this.objectMapper.writer(filters);
}
else {
objectWriter = this.objectMapper.writer();
}
if (javaType != null && javaType.isContainerType()) {
objectWriter = objectWriter.forType(javaType);
}
SerializationConfig config = objectWriter.getConfig();
if (contentType != null && contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) &&
config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
objectWriter = objectWriter.with(this.ssePrettyPrinter);
}
// 处理controller的返回值
objectWriter.writeValue(generator, value);
writeSuffix(generator, object);
generator.flush();
}
catch (InvalidDefinitionException ex) {
throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex);
}
catch (JsonProcessingException ex) {
// 日志中打印异常的地方
throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getOriginalMessage(), ex);
}
}
- 跟着断点进去的各个方法图
其实从最后一个断点图已经能看出一点端倪了,虽然框架内部对于异常都是往外抛出的,但是细心的同学应该已经发现了,跟着controller的返回值一直往下透传的还有一个关键的对象,那就是 JsonGenerator ,这里 JsonGenerator 是对前面的response的一个包装。所以继续往下走会发现对于bean的属性值会循环写到输出流里面去,而遇到某个解析不了的属性抛出异常时,前面已经序列化完成的字段是已经写到输出流里的。异常之后会被我们自己写的处理逻辑拦截,返回值会被追加到response的输出中去。
其实一开始让我们迷惑的原因有一点是因为这样response返回的不是一个标准的json格式输出(因为解析到一半抛异常,所以json格式没有闭合),再加上nginx的日志在解析的时候进行了截断,导致我们没有看到“网络错误”这个追加值,所以错误不是那么的直观。
总结
Spring使用的json序列化框架是jackson,而对于rest接口,json格式的输出其实是循环进行的,这样有可能导致的一个结果就是:响应中只有一半的json,就好像被人截掉了尾部。这样如果前端不能好好处理的话,会出现奇怪的错误信息。