lombok + jackson = bug

作者:微信小助手

发布时间:2022-01-25T13:26:03

一个真实案例,我把业务细节去掉,只给大家分享一下技术的部分。

先直接说复现方式。
 
引入 lombok 和 jackson 的 maven 配置。
<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.16.18</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.9.3</version>
    </dependency>
</dependencies>
随便写一个类 Student,上面标注 @AllArgsConstructor 注解。
package com.flash;
import lombok.AllArgsConstructor;

@ToString
@AllArgsConstructor
public class Student {
    private int age;
}
尝试用 jackson 解析一个 json 字符串,并反序列化为 Student 类。
package com.flash;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;

public class TestLombokJackson {
    public static void main(String[] args) throws IOException {
        String json = "{\"age\":1}";
        Student s = new ObjectMapper().readValue(json, Student.class);
        System.out.println(s);
    }
}
结果没有任何问题。
Student(age=1)
升级一下 lombok 版本,从 1.16.18 升级到 1.16.20,其他什么都不动。
  
<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.16.20</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.9.3</version>
    </dependency>
</dependencies>
再次运行程序,结果报错。
Exception in thread "main" com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `com.flash.Student` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (String)"{"age":1}"; line: 1, column: 2]
 at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:63)
 at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1342)
 at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1031)
 at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1275)
 at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:325)
 at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:159)
 at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4001)
 at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2992)
 at com.flash.TestLombokJackson.main(TestLombokJackson.java:10)

Process finished with exit code 1



猜测一下为什么报错
 

先不看具体错误信息,我们仅仅升级了一下 lombok 版本,就导致了错误出现,说明大概率是 lombok 有了什么变化导致的。
 
这里 lombok 的作用就是编译期为这个 Student 类添加一个有参构造方法,免去了手写的麻烦。
 
所以,第一想到的是由 lombok 在编译期改造的这个 Student 类有什么不同。
 
反编译由 1.16.18 版本生成的 Student.class 如下
package com.flash;
import java.beans.ConstructorProperties;

public class Student {
    private int age;
    public String toString() {
        return "Student(age=" + this.age + ")";
    }
    @ConstructorProperties({"age"})
    public Student(int age) {
        this.age = age;
    }
}
反编译由 1.16.20 版本生成的 Student.class 如下
package com.flash;

public class Student {
    private int age;
    public String toString() {
        return "Student(age=" + this.age + ")";
    }
    public Student(int age) {
        this.age = age;
    }
}
很明显,20 版本去掉了 18 版本在有参构造方法上的一个注解。
@ConstructorProperties({"age"})

去掉了它,导致了 Jackson 报错,也就是说,Jackson 在反序列化时,用到了这个注解去寻找有参构造方法。
 
当然,目前仅仅是猜测。

 

Jackson 是不是用到了这个注解?

 

整个反序列化过程细节很多,我们只看两个关键节点。
 
第一,这个方法里获取了 ConstructorProperties 注解,如果没有这个注解,将会返回 null。
@Override
public Boolean hasCreatorAnnotation(Annotated a) {
    ConstructorProperties props = 
        a.getAnnotation(ConstructorProperties.class);
    if (props != null) {
        return Boolean.TRUE;
    }
    return null;
}
假如上一个方法返回了 null,就会导致下面这个方法的 if 判断失效,走到最后一行。
protected Object deserializeFromObjectUsingNonDefault(JsonParser p,
        DeserializationContext ctxt)
 throws IOException 
{
    ...
    if (_propertyBasedCreator != null) {
        return _deserializeUsingPropertyBased(p, ctxt);
    }
    ...
    return ctxt.handleMissingInstantiator(raw, getValueInstantiator(), p,
            "cannot deserialize from Object value (no delegate- or property-based Creator)");
}
看最后一行的报错信息,和我们实验的报错信息,就完全一样了。
cannot deserialize from Object value
(no delegate- or property-based Creator)
 
jackson 反序列化的过程大概是这样的:
 

1. 先尝试找空参构造方法和 get\set 方法,如果有,直接利用这两个反序列化。

2. 如果没有,尝试找有参构造方法,但需要通过 @ConstructorProperties 来寻找。

 
所以,正是因为:
 
1 . 我们代码中又没有空参构造方法(默认的空参构造由于写了 @AllArgsConstructor 被有参构造给覆盖了)。
2 . 新版的 lombok 所生成的有参构造函数,没有生成一个叫 ConstructorProperties 的注解。
 
导致上述两个条件均没有命中,进而导致 jackson 反序列化失败。
 
所以解决办法也很简单,加一个空参构造或者用 @NoArgsConstructor 注解即可,要么就降级 lombok 或在新版 lombok 修改默认配置项,要么就换一个不以这个参数寻找有参构造方法的 json 框架。
 

新版 lombok 为什么不生成这个注解了?

 

此时我们自然想到一个问题,既然 jackson 这么大名鼎鼎的框架都用到了这个注解,那 lombok 新版本为什么要做这么一件多余的事情,把它去掉呢?
 
一般开源软件的新版本发布,都会有 changelog,来表示它修改了什么内容,又是为什么修改。
 
我们看 lombok 的 changelog。
 
 
看到 20 版本的改变一共有 9 个,我们只关心第三个,是一个 BREAKING CHANGE,表示突破性的改变。
 
BREAKING CHANGE: _lombok config_ key `lombok.anyConstructor.suppressConstructorProperties` is now deprecated and defaults to `true`, that is, by default lombok no longer automatically generates `@ConstructorProperties` annotations. New config key `lombok.anyConstructor.addConstructorProperties` now exists; set it to `true` if you want the old behavior. Oracle more or less broke this annotation with the release of JDK9, necessitating this breaking change.
 
这里可以看出,从 20 版本开始,lombok 果然不再默认添加 @ConstructorProperties 注解了,原因是最后一句话:JDK9 或多或少破坏了这个注解,从而需要进行这一突破性的更改。
 
具体看 18 ~ 20 版本的一大堆 commit 信息,也的确有一个 commit 是修改这个地方的。
 
commit:d7c019c07c3851fa0c89b3080da6c08d021fd272
for lombok v2, make generation of ConstructorProperties an optional extra, instead of default on. Reinier Zwitserloot 2017/12/5 4:35
 
那看来,就是 lombok 新版本的这个改动,再加上 jackson 反序列化时又非要用到这个注解,导致的这个问题发生。
 

fastjson 也需要有这个注解么?

 

既然 jackson 是这么玩的,那 fastjson 会不会也是这样呢?我印象中用 fastjson 好像从来没有因为 lombok 发生过什么错误。
 
我先尝试了低版本的 fastjson,代码还是原来的代码。
 
Student 类
package com.flash;
import lombok.AllArgsConstructor;

@ToString
@AllArgsConstructor
public class Student {
    private int age;
}
测试类
package com.flash;
import com.alibaba.fastjson.JSONObject;
import java.io.IOException;

public class TestLombokJackson {
    public static void main(String[] args) throws IOException {
        String json = "{\"age\":1}";
        Student s = JSONObject.parseObject(json, Student.class);
        System.out.println(s);
    }
}
结果报出了这个错。
Exception in thread "main" com.alibaba.fastjson.JSONException: default constructor not found. class com.flash.Student
 at com.alibaba.fastjson.util.JavaBeanInfo.build(JavaBeanInfo.java:224)
 at com.alibaba.fastjson.parser.ParserConfig.createJavaBeanDeserializer(ParserConfig.java:574)
 at com.alibaba.fastjson.parser.ParserConfig.getDeserializer(ParserConfig.java:491)
 at com.alibaba.fastjson.parser.ParserConfig.getDeserializer(ParserConfig.java:348)
 at com.alibaba.fastjson.parser.DefaultJSONParser.parseObject(DefaultJSONParser.java:639)
 at com.alibaba.fastjson.JSON.parseObject(JSON.java:350)
 at com.alibaba.fastjson.JSON.parseObject(JSON.java:254)
 at com.alibaba.fastjson.JSON.parseObject(JSON.java:467)
 at com.flash.TestLombokJackson.main(TestLombokJackson.java:12)

关键信息是这个
default constructor not found
 
看来,低版本 fastjson 在尝试寻找默认空参构造失败后,并不会去寻找那个 @ConstructorProperties 注解,所以直接在空参构造这里就把错误报出来了。
 
我又试了一下高版本的 fastjson,解析成功了!
  
Student(age=1)
也就是说,高版本的 fastjson 可以不必非要有空参构造了,而且,也不需要有 @ConstructorProperties 注解,也能反序列化成功。
 

那 fastjson 是怎么做到的呢?

 

这是怎么做到的呢?为啥比 jackson 厉害。
 
发生这一改变,肯定是在某个版本之间,我努力寻找到了这两个版本,1.2.35 版本的会报错,而 1.2.36 版本的不会报错,那我们看看区别吧。
 
 
我只看了关键节点,注意到原来直接报错的那个地方,前面多了好多行代码,那看来就是这些代码,导致情况有些不同。
 
我截取关键片段,并做了一定程度的简化,比如省略了通过 JSONCreator 来寻找构造方法的过程。
public static JavaBeanInfo build(...) {
    ...
    // 直接获取默认空参构造
    Constructor[] constructors = clazz.getDeclaredConstructors();
    defaultConstructor = getDefaultConstructor(clazz, constructors);
    ...
    // 默认空参不存在的话
    if (defaultConstructor == null){
        ...
        for (Constructor constructor : constructors) {
            ...
            // 通过 asm 方式寻找参数名
            paramNames = ASMUtils.lookupParameterNames(constructor);
            ...
        }
        ...
        // 找到了就可以正常返回了
        if (paramNames != null && ...) {
            ...
            return new JavaBeanInfo(...);
        }
        // 原来是直接到下面这句,不经过上面 asm 寻找名的过程
        throw new JSONException("default constructor not found. " + clazz);
    }
    ...
}
注释已经很明确了,就是通过一个 ASM 工具类,在字节码层面把参数名找出来,这样就不用开发者手动把参数名写在注解上了。
 
总结起来就是:
 

1. 老版本的 fastjson 是不尝试寻找合适的有参构造,除非写了 JSONCreator 注解。

2. jackson 也是不尝试自己寻找,但可以借助 lombok 或用户自己写的 ConstructorProperties 注解。

3. 新版本的 fastjson 比较卷,还会自己尝试通过 asm 的方式强行帮你找到。

 
所以,新版的 fastjson 用在这里,就不会报错。
 
但是新版本的 fastjson 仍然会先找空参构造,没找到,又去找加了 JSONCreator 注解的方法,又没找到,最后尝试用 ASMUtil 强行读取 class 文件把参数名揪出来,这过程一听就很麻烦。
 
找到这行关键代码 ASMUtils.lookupParameterNamescommit 信息,提交者也明确说明了它的作用,就是解决无默认空参构造的问题。
 
improved non default constructor bean deserialize support. based on asm bytecode class reader lookup parameters.
 
从这个角度看,人家考虑的真全面。但换个角度,哪个开发者能管你这么细节的东西啊,假如我用 fastjson 好好的,结果换成了 jackson 报错了,谁能立刻想到居然是 lombok 生成注解以及 fastjson “超强大”的 ASM 取参数名这段代码导致的连锁 反应。
 
或者,看似 lombok 为了 JDK9 的一个隐患而突破性地升级了自己的代码,但谁又能想到这东西配合 jackson 居然能产生这种“化学反应”。
 
所有的框架,都在卷,增加功能,适配老版本,适应新时代,对开发者友好。
 
但也正是这些过度设计,导致现在排查一个问题十分头疼,一个简简单单的 json 解析工具,搞得如此复杂,而且升级速度还特别快,根本不知道过几天又加了哪些“友好”的设计。

不过,咱作为开发者,就老老实实给实体类加上空参构造方法get\set 方法吧,标配就完事了。这么搞无论什么版本的 jackson 和 fastjson,就都不会报错了。