Fastjson的$ref在接口参数兼容上的隐患

oldclass_newclass.jpg
假设应用1给应用2提供了一个接口,需要更新参数,将Map变为List<Map>,很容易写出这样的兼容代码:

1
2
3
4
5
6
7
8
9
10
11
@Data
public static class OldClass {
private Map<String, String> bbbb;
}

@Data
public static class NewClass {
private List<Map<String,String>> aaaa;
@Deprecated
private Map<String, String> bbbb;
}

然而在部署后发现,应用2拿到的数据对象中的bbbb,没有任何数据。

直接原因

cause.jpg
可以很容易复现这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class JsonIssue {
public static void main(String[] args) {
issue();
}

private static void issue() {
Map<String,String> map = new HashMap<>();
map.put("key", "value");
List<Map<String,String>> aaaa = Collections.singletonList(map);

NewClass newClass = new NewClass();
newClass.setAaaa(aaaa);
newClass.setBbbb(aaaa.get(0));
String json1 = JSON.toJSONString(newClass);
System.out.println(json1);
// {"aaaa" : [{"key": "value"}],"bbbb": {"$ref":"$.aaaa[0]"}}

OldClass oldClass = JSON.parseObject(json1, OldClass.class);
String json2 = JSON.toJSONString(oldClass);
System.out.println(json2);
// {"bbbb":{"$ref";"$.aaaa[0]"}}

System.out.println(oldClass.getBbbb().get("key"));
// null
}

@Data
public static class OldClass {
private Map<String, String> bbbb;
}

@Data
public static class NewClass {
private List<Map<String,String>> aaaa;
@Deprecated
private Map<String, String> bbbb;
}
}

当兼容数据NewClass传递过去变成旧格式0ldClass后,bbbb原本期望的数据是:{"key": "value"},然而实际上却是{"$ref": "$.aaaa[0]"},而且此时旧数据并没有aaaa这个变量,数据就丢失了。

详细分析FastJson的引用$ref

fastjson默认对两类引用做了优化:

  • 循环引用:A参数里有B,B参数里有A,引用就用$ref替代,避免循环解析
  • 重复引用:一个相同对象出现多次,就用$ref进行替代,这样可以减少很多数据量

循环引用

circleReference.jpg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class JsonIssue {
public static void main(String[] args) {
circleReference();
}

private static void circleReference() {
ClassA a = new ClassA();
a.setName("aaaa");

ClassB b = new ClassB();
b.setName("bbbb");

b.setA(a);
a.setB(b);

String json1 = JSON.toJSONString(a);
System.out.println(json1);
// {"b":{"a": {"$ref":".."}, "name": "bbbb"}, "name": "aaaa"}

String json2 = JSON.toJSONString(a, SerializerFeature.DisableCircularReferenceDetect);
System.out.println(json2);
// Exception in thread "main" java.lang.StackOverflowError
// at com.alibaba.fastjson,serializer.SerializeWriter.writeFieldNameDirect(SerializeWriter,java:1609)
// at com.alibaba,fastjson,serializer,ASMSerializer_2_ClassB.writeDirectNonContext(Unknown Source)
// at com.alibaba,fastjson,serializer,ASMSerializer_1_ClassA.writeDirectNonContext(Unknown Source)
// at com.alibaba,fastjson.serializer,ASMSerializer_2_ClassB.writeDirectNonContext(Unknown Source)
}

@Data
public static class ClassA {
private String name;
private ClassB b;
}

@Data
public static class ClassB {
private String name;
private ClassA a;
}
}

ClassA引用了ClassB,ClassB引用了ClassA,我们打印A,可以看到其被FastJson处理为:

1
{"b":{"a": {"$ref":".."}, "name": "bbbb"}, "name": "aaaa"}

这里的..表是上一级,也就是A本身。更多地:

引用 描述
“$ref”:”..” 上一级
“$ref”:”@” 当前对象,也就是自引用
“$ref”:”$” 根对象
“$ref”:”$.children.0” 基于路径的引用,相当于root.getChildren().get(0)

如果我们用SerializerFeature.DisableCircularReferenceDetect去除检测,则会报错:

1
2
3
4
5
6
7
String json2 = JSON.toJSONString(a, SerializerFeature.DisableCircularReferenceDetect);
System.out.println(json2);
// Exception in thread "main" java.lang.StackOverflowError
// at com.alibaba.fastjson,serializer.SerializeWriter.writeFieldNameDirect(SerializeWriter,java:1609)
// at com.alibaba,fastjson,serializer,ASMSerializer_2_ClassB.writeDirectNonContext(Unknown Source)
// at com.alibaba,fastjson,serializer,ASMSerializer_1_ClassA.writeDirectNonContext(Unknown Source)
// at com.alibaba,fastjson.serializer,ASMSerializer_2_ClassB.writeDirectNonContext(Unknown Source)

重复引用

duplicatedData.jpg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class JsonIssue {
public static void main(String[] args) {
duplicatedData();
}

private static void duplicatedData() {
ClassC c1 = new ClassC();
c1.setName("cccc");

ClassC c2 = c1;

ClassC c3 = new ClassC();

String json1 = JSON.toJSONString(Arrays.asList(c1, c2, c3));
System.out.println(json1);
// [{"name": "cccc"}, {"$ref":"$[0]"},{"name":"cccc"}]

String json2 = JSON.toJSONString(Arrays.asList(c1,c2, c3),SerializerFeature.DisableCircularReferenceDetect);
System.out.println(json2);
// [{"name":"cccc"}, {"name":"cccc"}, {"name":"cccc"}]
}

@Data
public static class ClassC {
private String name;
}
}

c2直接引用了c1,c3和c1数据一样,可以看到c2被处理为了引用,c3却没有。所以FastJson处理重复引用条件之一是,Java中也要直接引用。

另外,对于issue的问题,假设我们把aaaa和bbbb换个名字,旧数据叫aaaa,新数据叫bbbb:

1
2
3
4
5
6
7
8
9
10
11
   @Data
public static class OldClass {
private Map<String, String> aaaa;
}

@Data
public static class NewClass {
private List<Map<String,String>> bbbb;
@Deprecated
private Map<String, String> aaaa;
}

则旧数据aaaa不会出现$ref,反而是新数据bbbb出现了$ref。

1
2
3
4
// 改名前,aaaa是新数据,bbbb是旧数据
{"aaaa":"[{"key": "value"}], "bbbb":{"$ref":"$.aaaa[0]"}}
// 改名后,aaaa是旧数据,bbbb是新数据
{"aaaa": {"key": "value"}, "bbbb":[{"$ref": "$.aaaa"}]}

说明如果是map数据,如果要产生被引用$ref的效果,成员属性的变量名需要在字典序之后。

如果我们用SerializerFeature.DisableCircularReferenceDetect去除检测,则会恢复正常数据:

1
2
3
String json2 = JSON.toJSONString(Arrays.asList(c1,c2,c3),SerializerFeature.DisableCircularReferenceDetect);
System.out.println(json2);
// [{"name":"cccc"}, {"name": "cccc"}, {"name":"cccc"}]

总结

  1. 使用FastJson时,考虑关闭循环引用检测功能(DisableCircularReferenceDetect),避免引发此类问题。虽然循环引用会报错,但是本来就不应该写出循环引用这样的接口DTO类。
  2. 如果不想关闭循环引用检测,那么在升级接口时,应尽量破坏重复引用出现的条件之一:
  • 复制对象时,采用深拷贝的方式,避免复制Java引用
  • 如果是Map,引用的变量名,字典序靠前(很难做到)

Fastjson的$ref在接口参数兼容上的隐患

https://www.bananaoven.com/posts/35291/

作者

香蕉微波炉

发布于

2023-05-20

更新于

2023-05-20

许可协议