欢迎光临
我们一直在努力

API 传输数据 小心 fastjson 循环引用检测($ref)会作怪

API 传输数据时 fastjson 循环引用检测($ref)会作怪

一、背景

在 HistoryPriceHandler 中,需要根据一批 crmId 构造出多个 HistoryPriceQuery,再调用 wso2Utils.getHistoryPrice(…) 发送给 WSO2 历史价格接口。

构造逻辑大致是:

List<HistoryPriceQuery> priceQuery = HistoryPriceQuery.build(crmIdList, query);
List<ApimHistoryPriceResponse> priceList = wso2Utils.getHistoryPrice(priceQuery);

每个 HistoryPriceQuery 内部都持有一份 mtm(List<CpmMtm>)。

二、优化引发的问题

出于「节约内存」的想法,我把 build 改成了共享同一份 mtm 列表:所有 HistoryPriceQuery 的 mtm 字段都指向同一个 List 对象(因为每个 query 的 mtm 内容本来就完全一样)。

// 共享版:mtm 只克隆一次,所有 query 共用同一个引用
List<CpmMtm> sharedMtmList = buildMtmList(query.getMtm());
for (...) {
metaQuery.setMtm(sharedMtmList); // 同一个对象!
}

结果出现了诡异现象:

  • 原来的独立克隆版本:getHistoryPrice 调用成功。
  • 共享 mtm 的新版本:getHistoryPrice 调用失败。

而更迷惑的是——两个版本用 System.out.println(JSON.toJSON(…)) 打印出来的 JSON 看起来一模一样。

三、根因:fastjson 的循环引用检测($ref)

3.1 什么是 $ref

fastjson 在序列化时会记录已经序列化过的对象。如果同一个对象引用在结构里出现第二次,它不会再展开,而是写成一个引用标记:

{"$ref":"$[0].mtm"}

$ref 路径语法:

  • $:根对象
  • $[0]:根数组第 0 个元素
  • $[0].mtm:第 0 个元素的 mtm 字段

它的本意是防止循环引用导致的无限递归(StackOverflowError),例如父子对象互相持有的场景。

3.2 判定依据是「引用相同」而非「内容相同」

关键点:fastjson 用的是**对象身份(==,同一个内存地址)**来判重,类似 IdentityHashMap,不是用 equals() 比内容。

情况是否同一个引用会不会 $ref
同一个 List 被多个字段引用(共享 mtm) 是(== 成立)
两个不同对象,内容一模一样(独立克隆) 否(== 不成立) 不会

推论:

  • 同一个对象引用 → 内容一定相同(成立,因为就是同一个对象)。
  • 内容相同 → 是同一个对象(不成立,可能是内容巧合相同的独立对象)。

所以「内容相同但不是同一个实例」不会触发 $ref,不会出问题。

3.3 为什么这次会踩坑

共享 mtm 后,多个 HistoryPriceQuery 的 mtm 指向同一个 List 实例。fastjson 序列化时:

[
{ "key":"0", "mtm":[{"material":"82H803GJJP","key":"0"}] }, // 第一次,完整展开
{ "key":"1", "mtm":{"$ref":"$[0].mtm"} }, // 第二次,变成 $ref
{ "key":"2", "mtm":{"$ref":"$[0].mtm"} } // 第三次,变成 $ref
]

WSO2 接口不认识 $ref(它是 fastjson 私有语法,不是标准 JSON 语义),解析畸形报文失败 → 请求报错。

独立克隆版本里每个 mtm 都是不同对象,没有重复引用,不会出现 $ref,报文干净 → 成功。

四、为什么「打印一样、发送却失败」

这是最迷惑的一点,原因在于两个 API 的差异:

  • JSON.toJSON(obj):先把对象转成一棵全新的 JSONObject/JSONArray 树,转换过程把共享引用摊平成各自独立的节点,再 toString。此时已经没有「同一对象」了,所以打印结果正常、看不出 $ref。
  • JSON.toJSONString(obj):直接序列化原始对象,共享引用仍然存在 → 触发 $ref。

getHistoryPrice 内部发请求时用的是 toJSONString(或等价的直接序列化),所以才会带 $ref。

结论:判断有没有 $ref,一定要看 toJSONString 的结果,不能用 toJSON。

五、如何检测是否触发了 $ref

方法 1:直接打印 toJSONString

System.out.println(JSON.toJSONString(priceQuery));

输出里出现 {"$ref":"…"} 即触发。

方法 2:字符串搜 $ref

String json = JSON.toJSONString(priceQuery);
boolean hasRef = json.contains("$ref");
System.out.println("包含 $ref = " + hasRef);

方法 3:对比开/关检测的结果

import com.alibaba.fastjson.serializer.SerializerFeature;

String withDetect = JSON.toJSONString(priceQuery); // 默认开检测,可能有 $ref
String withoutDetect = JSON.toJSONString(priceQuery,
SerializerFeature.DisableCircularReferenceDetect); // 关检测,全展开

System.out.println("两者是否不同 = " + !withDetect.equals(withoutDetect));

两者不同即说明默认序列化里存在 $ref。

六、解决方案

方案说明建议
独立克隆,不共享引用 每个对象各自持有独立实例,从源头避免重复引用 ✅ 推荐
关闭引用检测 JSON.toJSONString(obj, SerializerFeature.DisableCircularReferenceDetect),需改到每个序列化点,易漏 ⚠️ 谨慎
全局关闭 影响面大 ❌ 不建议

本次采用独立克隆:虽然多占一点内存,但对外传输数据正确、稳定,不用碰序列化配置,是最省心的选择。

七、经验总结

  • 对外传输(尤其发给第三方接口)的对象,避免多个字段/元素共享同一个可序列化对象实例,否则 fastjson 会用 $ref 表示重复引用,导致对端解析失败。
  • $ref 只认 ==(同一实例),不认 equals(内容相同)。内容相同但不同实例是安全的。
  • 排查时用 toJSONString 看真实报文,别用 toJSON,后者会把引用摊平,掩盖问题。
  • 「省内存的共享」和「对外序列化的安全」有时是冲突的,涉及序列化传输的场景优先保证隔离性(独立实例)。
  • 赞(0)
    未经允许不得转载:171主机测评 » API 传输数据 小心 fastjson 循环引用检测($ref)会作怪
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址