来自jackson的灵魂一击:@ControllerAdvice就能保证万无一失吗?

添加图片注释,不超过 140 字(可选)

前几天写了篇关于fastjson的文章,[《fastjson很好,但不适合我》](fastjson很好,但不适合我 - 掘金 (juejin.cn))。里面探讨到关于对象循环引用的序列化问题。作为spring序列化的最大竞品,在讨论fastjson的时候肯定要对比一下jackson的。所以我也去测试了一下Jackson在对象循环引用的序列化的功用,然后有了一点意外的小发现,在这里跟大家讨论一下。 首先还得解释一下,jackson的序列化是怎么跟@ControllerAdvice关联上的呢? 前篇文章里说过,对于对象循环引用的序列化问题,fastjson和jackson分别采取了两种态度,fastjson是默认处理了,而jackson是默认抛出异常。后者把主动权交给了用户。 既然这里抛出了异常,就涉及到异常的全局处理,跟事务一样,我们不可能以硬编码的方式在每个方法里分别处理异常,而是通过统一全局异常处理。

@ControllerAdvice 全局异常捕获

这里简单的做一下介绍,嫌弃啰嗦的朋友可直接略过,跳到第2部分。

Spring家族中,通过注解@ControllerAdvice或者 @RestControllerAdvice 即可开启全局异常处理,使用该注解表示开启了全局异常的捕获,我们只需在自定义一个方法使用@ExceptionHandler注解然后定义捕获异常的类型即可对这些捕获的异常进行统一的处理。

只要异常最终能够到达controller层,且与@ExceptionHandler定义异常类型相匹配,就能被捕获。

 
 

@RestControllerAdvice public class GlobalExceptionHandler { Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(value = Exception.class) public Result exceptionHandler(Exception e){ logger.error(e.getMessage(), e); return Result.error(e.getMessage()); } @ExceptionHandler(value = RuntimeException.class) public Result exceptionHandlerRuntimeException(Exception e){ logger.error(e.getMessage(), e); return Result.error(e.getMessage()); } // 或者其它自定义异常 } 复制代码

再定义一个统一的接口返回对象:

点击查看代码

统一状态码:

点击查看代码

再定义一个测试对象:

 
 

@Getter @Setter //@ToString //@AllArgsConstructor //@NoArgsConstructor public class Person { private String name; private Integer age; private Person father; public Person(String name, Integer age) { this.name = name; this.age = age; } } 复制代码

写一个测试接口,模拟循环依赖的对象,使用fastjson进行序列化返回。

 
 

public Result test2 (){ List<Person> list = new ArrayList<>(); Person obj1 = new Person("张三", 48); Person obj2 = new Person("李四", 23); obj1.setFather(obj2); obj2.setFather(obj1); list.add(obj1); list.add(obj2); Person obj3 = new Person("王麻子", 17); list.add(obj3); List<Person> young = list.stream().filter(e -> e.getAge() <= 45).collect(Collectors.toList()); List<Person> children = list.stream().filter(e -> e.getAge()< 18).collect(Collectors.toList()); HashMap map = new HashMap(); map.put("young", young); map.put("children", children); return Result.success(map); } 复制代码

开启fastjson的 SerializerFeature.DisableCircularReferenceDetect禁用循环依赖检测,使其抛出异常。 访问测试接口,后台打印日志

 
 

ERROR 21360 [http-nio-8657-exec-1] [com.nyp.test.config.GlobalExceptionHandler] : Handler dispatch failed; nested exception is java.lang.StackOverflowError 复制代码

接口返回

 
 

{ "code":"500", "data":null, "msg":"Handler dispatch failed; nested exception is java.lang.StackOverflowError", "success":false } 复制代码

证明异常在全局异常捕获处被成功捕获。且返回了500状态码,证明服务端出现了异常。

jackson的问题

我们现在换掉fastjson,使用springboot自带的jackson进行序列化。同样还是上面的代码。 后台打印了日志:

 
 

[2023-04-01 15:27:42.230] ERROR 17156 [http-nio-8657-exec-2] [com.nyp.test.config.GlobalExceptionHandler] : Could not write JSON: Infinite recursion (StackOverflowError); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: com.nyp.test.model.Person["father"]->com.nyp.test.model.Person["father"].... 复制代码

日志信息略有不同,是两种不同的序列化框架的差异,总之全局异常捕获也成功了。

再来看返回的结果如下:

添加图片注释,不超过 140 字(可选)

这就很明显不对劲,后台已经抛出异常,并成功捕获了异常,前端怎么还接收到了200状态码呢?而且 data里面还有循环嵌套的数据!

返回的报文很长,仔细观察最后面,发现后面同时也返回了500状态码及异常信息。

添加图片注释,不超过 140 字(可选)

长话短说,相当使用jackson,在默认情况下,对于循环对象引用,在添加了全局异常处理情况下,接口同时返回了两段相反的报文:

 
 

{ "code":"200", "data":{"young":[{"name":"李四","age":23,"father":{"name":"张三","age":48}]}" "success":true } { "code":"500", "data":null, "msg":"Handler dispatch failed; nested exception is java.lang.StackOverflowError", "success":false } 复制代码

添加图片注释,不超过 140 字(可选)

小朋友你是否有很多问号??

这种现象是在return后面抛出异常引起?

这就有点意思了。 造成这种现象的原因,我初步怀疑是在方法return返回过后再抛出异常导致的。

我这怀疑也不是毫无理由,具体请看我的另一篇文章 [当transcational遇上synchronized ](当transcational遇上synchronized - 掘金 (juejin.cn)) ,里面提到过, spring使用动态代理加AOP实现事务管理。那么一个加了注解事务的方法实际上需要简化成至少3个步骤:

 
 

void begin(); @Transactional public synchronized void test(){ // } void commit(); // void rollback(); 复制代码

如果在读已提交及以上的事务隔离级别下,test方法执行完毕,更新了数据但这时候还没到commit事务,但已经释放了锁,另一个事务进来读到的还是旧数据。

类似地,这里的test方法实际上是一样的,jackson在做序列化操作在return之前,那么会不会return返回了一次200,在return过后再抛出异常后再返回了一次500状态码?

那就使用 TransactionSynchronization模拟一次在return后面的异常看返回给前端什么信息。

 
 

@Transactional @RequestMapping( "/clone") public Result test2 (){ List<Person> list = new ArrayList<>(); Person obj1 = new Person("张三", 48); Person obj2 = new Person("李四", 23); obj1.setFather(obj2); obj2.setFather(obj1); list.add(obj1); list.add(obj2); Person obj3 = new Person("王麻子", 17); list.add(obj3); List<Person> young = list.stream().filter(e -> e.getAge() <= 45).collect(Collectors.toList()); List<Person> children = list.stream().filter(e -> e.getAge()< 18).collect(Collectors.toList()); HashMap map = new HashMap(); map.put("young", young); map.put("children", children); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { if (1 == 1) { throw new HttpMessageNotWritableException("test exception after return"); } TransactionSynchronization.super.afterCommit(); } }); return Result.success(map); } 复制代码

重启调用测试接口,后台打印日志

 
 

[http-nio-8657-exec-1] [com.nyp.test.config.GlobalExceptionHandler] : test exception after return 复制代码

返回客户端信息:

 
 

{"code":"500","success":false,"data":null,"msg":"test exception after return"} 复制代码

测试表明,并不是这个原因造成的。

到这里,可能细心的朋友也发现了,对于前面的猜想,关于jackson在做序列化操作在return之前,那么会不会return返回了一次200,在return过后再抛出异常后再返回了一次500状态码?其实是不合理的。 我们在最开始接触java web开发的时候肯定是先学servlet,再学spring,springmvc,springboot这些框架,现在再回到最初的美好,想想servlet是怎样返回数据给客户端的?

通过HttpServletResponse获取一个输出流,不管是OutputStream还是PrintWriter,将我们手动序列化的json串输出到客户端。

 
 

@WebServlet(urlPatterns = "/testServlet") public class TestServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("text/html;charset=UTF-8"); // 通过PrintWriter 或者 OutputStream 创建一个输出流 // OutputStream outputStream = response.getOutputStream(); PrintWriter out = response.getWriter(); try { // 模拟获取一个返回对象 Person person = new Person("张三", 23); out.println("start!"); // 手动序列化,并输出到客户端 Gson gson = new Gson(); out.println(Result.success(gson.toJson(person))); // outputStream.write(); out.println("end"); } finally { out.println("成功!"); out.close(); } super.doGet(request, response); } } 复制代码

我没看过springmvc这块的源码,想来也是同样的逻辑处理对吧。 在dispatchServlet里面invoke完毕目标controller获得了返回对象以后,再调用序列化框架jackson或者fastjson得到一个json对象,再通过输出流输出前端,最后一步操作可能是在servlet里也可能直接在序列化框架里面直接操作。 总之不管是在哪步,都有点不合理,如果是在序列化的时候,序列化框架直接异常了,也不应该输出200和500两段报文。

不管怎样,这里也算是验证了@ControolerAdvice能不能捕获目标controller方法在Return以后抛出的异常,答案是可以。

现在我们可以再来看看Fastjson在return以后进行序列化发生异常的时候,为什么不会输出200和500两段报文。

fastjson为什么没有问题

通过前文我们知道,在同样的情况下,fastjson序列化是可以正常返回给客户端500异常的报文。

我们现在将springmvc的序列化框架切换到fastjson。通过断点走一遍源码。观察为什么fastjson可以正常抛出异常。

添加图片注释,不超过 140 字(可选)

通过调用栈信息,我们可以很明显的观察到我们很熟悉的distpatchServlet,再到handleReturnValue调用完成目标controller拿到返回对象,现到 AbstractMessageConverterMethodProcessor.writeWithMessageConverters,最终到达 GenericHttpMessageConverter.write()通过注释,哪怕是方法名和参数名,我们也知道这里就是开始调用具体的序列化框架重写这个方法输出返回报文到客户端了。

那么在这里开始打个断点,这是个接口方法,它有很多实现类,这里打断点会直接进入到具体实现类的方法。 最终来到了 FastJsonHttpMessageConverter.writeInternal()

添加图片注释,不超过 140 字(可选)

重点来了,如上图所示,执行到line 314行,也就是标记为1的地方就抛出异常,然后到了finally里面去了,跳过了line 337即2处真正执行write输出到客户端的操作。 我们不用去管line 314处所调用方法内部的序列化具体操作,我们只需要知道,它在序列化准备阶段直接异常了,并没有真正执行向客户端进行write的操作。

然后异常最终被@RestControllerAdvice所捕获,输出到客户端500。

这里可以稍微再延伸一下,为什么没有执行到第2步。 因为这里的expandCappacity方法,expand就是扩展,相当于它的write缓冲区理论上可以无限大。相比较jackson的8000缓冲长度,fastjson就能直到循环依赖出错都还没真正向客户端输出。

图片上传失败

​重试

jackson的输出流程

现在作为对比,再回过头来看看jackson是怎样完成上述的操作的。

打到与上小节fastjson一样的断点,最终进入了jackson的序列化方法,通过右边inline watches可以看到将要被序列化的value从对象的循环引用变成了具体的若干层嵌套循环了。

添加图片注释,不超过 140 字(可选)

再一路断点,来到UTF8JsonGenerator,可以观察到,jackson不是将整个返回值value一起进行序列化,而是一个对象一个field顺序进行序列化。

添加图片注释,不超过 140 字(可选)

这些值将临时进入了一个buffer缓冲区,在大于outputend=8000,就flush直接输出到客户端。

添加图片注释,不超过 140 字(可选)

这里的_outputstream就是java.io.OutputStream对象。

小结

这里可以做一个小结了。

jackson为什么会在对象循环引用的时候同时向客户端输出200和500两段报文?

勘误:因为使用objectWriter.writeValue 使用的是流输出,而缓冲区长度为8000,在循环依赖抛出异常之前,超过缓冲区长度,所以已经向前端write了200状态的数据。

fastjson为什么能正常的只输出500报文? 勘误: Fastjson使用JSONSerializer.write()同样先输出到缓冲区,但缓冲区可以扩展,理论上无限大,直到栈深度大于设置值抛出StackOverflowError之前,并没有真正向客户端输出任何报文。

@ControllerAdvice失效的场景

添加图片注释,不超过 140 字(可选)

通过注释,我们知道@ControllerAdvice默认作用于全部的controller类方法。也可以手动设置package.

 
 

@RestControllerAdvice("com.nyp.test.controller") 或者 @RestControllerAdvice(basePackages = "com.nyp.test.controller") 复制代码

那么让它失效的场景就是 1.异常到不了controller层,比如在service层里通过try-catch把异常吞了。又比如到达了controller层也抛出了,但在其它AOP切面通知里通过try-catch处理了。 2.或者不指向controller层或部份controller层,比如通过@RestControllerAdvice(basePackages = "com.nyp.test.else")

等等。

其它只要不触碰到以上情况,正确的配置了,即使是在return后面抛出异常也可以正确处理。 具体到本文jackson的这种情况,严格意义上来讲,@ControllerAdvice也是起了作用的。只不过是jackson在序列化的过程中本身出的问题。

总结

  1. @ControllerAdvice完全安全吗? 只要正确配置,它是完全安全的。本文属于jackson这种特殊情况,它造成的异常情况不是@ControllerAdvice的问题。

2.造成同时返回200和500报文的原因是什么?

勘误:因为jackson使用objectWriter.writeValue 使用的是流输出,而缓冲区长度为8000,在循环依赖抛出异常之前,超过缓冲区长度,所以已经向前端write了200状态的数据。

而 Fastjson使用JSONSerializer.write()先输出到缓冲区,但缓冲区可以扩展,理论上无限大,直到栈深度大于设置值抛出StackOverflowError之前,并没有真正向客户端输出任何报文。

  1. 怎么解决这种问题?

这本质上是一个jackson循环依赖的问题。通过注解 @JsonBackReference @JsonManagedReference @JsonIgnore @JsonIdentityInfo 可以部份解决。 比如:

 
 

@JsonIdentityInfo(generator= ObjectIdGenerators.IntSequenceGenerator.class, property="name") private Person father; 复制代码

返回:

 
 

{ "code": "200", "success": true, "data": { "young": [{ "name": "李四", "age": 23, "father": { "name": 1, "name": "张三", "age": 48, "father": { "name": 2, "name": "李四", "age": 23, "father": 1 } } }, { "name": "王麻子", "age": 17, "father": null }], "children": [{ "name": "王麻子", "age": 17, "father": null }] }, "msg": "success" } 复制代码

同时,对于对象循环引用这种情况,在代码中就应该尽量去避免。 就像spring处理依赖注入的情况,一开始使用@lazy注解解决,后面spring官方通过三层缓存来解决,再到后面springboot官方默认不支持依赖注入,如果有依赖注入默认启动就会报错。

一言以蔽之,本文说的是,关于spring mvc&spring boot使用jackson做序列化输出的时候,如果没有处理好循环依赖的问题,那么前端不能正确感知到服务器异常这个问题。

但因为循环依赖并不常见,遇到了也能有解决方案,所以看起来本文好像并没有什么卵用。

不过,没人规定必须要解决吧,当我还是一个新手的时候,我没解决循环依赖,而同时前端又没有接收到正确的服务端异常时,总是会有疑惑的。

而且如果引申开来的话,所有jackson在序列化中途导致的失败,都有可能发生这种情况。

从这个角度来说,算不算是jackson的一个问题呢?

不管怎样,希望本文对你能够有所启发。

                                                                                                           资源获取:
大家 点赞、收藏、关注、评论啦 、 查看👇🏻👇🏻👇🏻 微信公众号获取联系方式👇🏻👇🏻👇🏻
精彩专栏推荐订阅:下方专栏👇🏻👇🏻👇🏻👇🏻
每天学四小时:Java+Spring+JVM+分布式高并发,架构师指日可待

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值