JSON 日志里的 {"$ref":"$.xxx"}:从 fastjson 兼容包到原生 fastjson2 的迁移实录
联调时日志里冒出
{"$ref":"$.xxx"}------菜单项的优惠券数据变成了一串 JSONPath 天书。查下去发现工程用的com.alibaba:fastjson其实是 fastjson2 的兼容包,为了对齐 fastjson1 行为默认开着引用检测。本文记录从定位根因、过渡止血,到彻底迁移原生 fastjson2 的全过程。
一、现象:日志里的数据变成了引用指针
联调时查看响应体日志,菜单项的 coupon 部分长这样:
json
"tenderName": "指定小食 (3选1) 同款买一送一优惠券",
"linkIds": {
"$ref": "$.100012808$100089523.\\,100087662.28514$100106028.100012810$100089523..."
},
"couponCode": "Q-44212956107937024514-SZKFC2603008-14961",
linkIds 本该是数组,输出却是一个 {"$ref": "$.jsonpath"} 对象。排障时看到的不是数据,是一串指向序列化树其他位置的路径------日志基本废了。
二、根因:兼容包默认开启循环引用检测
{"$ref": "$.jsonpath"} 是 fastjson 的循环引用检测(ReferenceDetection)在起作用:同一对象实例在序列化树中出现第二次时,不再重复输出内容,而是输出一个 JSONPath 指针。菜单数据里大量共享对象引用(同一个 coupon 对象挂在多个菜单项下),于是日志里到处是指针。
关键在 pom 依赖树:
com.alibaba:fastjson:jar:2.0.45:compile ← 注意:groupId 还是 com.alibaba,版本却是 2.x
\- com.alibaba.fastjson2:fastjson2-extension:jar:2.0.45:compile
\- com.alibaba.fastjson2:fastjson2:jar:2.0.45:compile
工程引的 com.alibaba:fastjson 实际是 fastjson2 的兼容包 :保留 com.alibaba.fastjson.* 旧包名和旧 API,底层委托给 fastjson2 核心。为了让存量代码行为不变,兼容包默认开启 ReferenceDetection ------这正是 fastjson1 的默认行为,也正是 $ref 的来源。
三、过渡止血:日志序列化先收口
最快的止血方式是序列化时显式关闭引用检测。但直接改有 25 处调用点,散落在 12 个文件里------先收口再改,而不是复制粘贴 25 遍。
在 JsonUtils 增加日志专用序列化入口:
java
// JsonUtils.java
public static String toLogString(Object object) {
return JSON.toJSONString(object, SerializerFeature.DisableCircularReferenceDetect);
}
然后全局替换各文件的日志调用:
| 文件 | 处数 |
|---|---|
ApiLoggingResponseBodyAdvice(截图日志的来源) |
1 |
IntentORServiceImpl |
5 |
CrowTagController / RestTemplateUtils |
4 / 4 |
ObjectLogicMethodLocal / ScopeTransformer |
2 / 2 |
DataToolMethod / GlobalExceptionHandler / IntentServiceImpl / RankCandidateFilter / RankSatisfactionEvaluator / Rule |
各 1 |
注意 AppController 用的本来就是 com.alibaba.fastjson2.JSON(原生包默认无引用检测),不用动。
能跑,但代码里同时存在 com.alibaba.fastjson(兼容包)和 com.alibaba.fastjson2(原生)两套 API,长期必须收敛------顺势升级。
四、迁移原生 fastjson2
4.1 pom:去掉兼容包,直连原生
xml
<!-- fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.45</version>
</dependency>
4.2 javap 摸清 API 面
工程用到的 fastjson API 只有四个:toJSONString / parseObject / parseArray / toJSON。对 fastjson2 核心包 javap 确认签名全部存在:
bash
javap -cp fastjson2-2.0.45.jar com.alibaba.fastjson2.JSON | grep -E "toJSON|parseArray|parseObject"
# public static java.lang.String toJSONString(java.lang.Object);
# public static <T> T parseObject(java.lang.String, java.lang.Class<T>);
# public static <T> java.util.List<T> parseArray(java.lang.String, java.lang.Class<T>);
# public static java.lang.Object toJSON(java.lang.Object);
API 层面无痛。
4.3 用真实运行验证行为差异(关键步骤)
API 存在 ≠ 行为一致。写了个 20 行的测试类直接跑 fastjson2,抓到两个关键差异:
java
Object r = JSON.toJSON("{\"a\":1}");
System.out.println(r.getClass()); // fastjson1: JSONObject;fastjson2: java.lang.String !
实测结论:
| 行为 | fastjson1(兼容包) | fastjson2 原生 |
|---|---|---|
toJSONString 共享引用 |
输出 {"$ref":"$.xxx"} |
输出完整 JSON(默认无引用检测) |
toJSON(String) |
解析成 JSONObject/JSONArray | 原样返回 String,不解析 |
parseObject/parseArray(String, Class) |
--- | 行为一致 |
toJSONString(null) |
--- | 一致 |
第一个差异正是我们要的:迁移后 toLogString 里的 SerializerFeature 参数直接删掉即可,$ref 天然消失。
第二个差异如果没有实测、直接迁移,isJSONString() 会被静默破坏:
java
// 迁移前(fastjson1)
return toJSON(content) != null; // toJSON 会解析字符串
// 迁移后若不改:toJSON 对 String 原样返回(永远非 null)------任何字符串都被判定为合法 JSON
修正为显式解析:
java
// JsonUtils.java
public static boolean isJSONString(String content) {
if (content == null) {
return false;
}
if (!isJSONStringByMap(content) && !isJSONStringByArray(content)) {
return false;
}
try {
return JSON.parse(content) != null;
} catch (Exception e) {
return false;
}
}
4.4 迁移落地
得益于第三步的收口,全部 fastjson1 引用只剩 4 个文件:
| 文件 | 改动 |
|---|---|
JsonUtils |
import 换 fastjson2;toLogString 简化(不再需要 SerializerFeature);isJSONString 如上修正 |
DataToolMethod |
import 换 fastjson2(parseObject(String, Class) 签名相同) |
Menu |
删除未使用的 import static ... toJSON |
VectorCondition |
删除未使用的 fastjson1 import(文件里 gson 的 JsonObject 不动) |
4.5 迁移后的依赖树
+- com.yumchina.architecture.framework:yum-common-utils:jar:4.0.0-SNAPSHOT:compile
| +- com.alibaba:fastjson:jar:2.0.45:compile ← 公司工具包还在传递引入兼容包
| | \- com.alibaba.fastjson2:fastjson2-extension:jar:2.0.45:compile
+- com.alibaba.fastjson2:fastjson2:jar:2.0.45:compile ← 本工程显式直连原生
yum-common-utils 仍传递依赖兼容包,但与显式 fastjson2 同版本(2.0.45),底层是同一个 fastjson2 核心,无冲突;且显式声明保证了即使将来 yum-common-utils 变化,本工程的 fastjson2 依赖不受影响。
五、验证
bash
mvn clean test
# Tests run: 46, Failures: 0, Errors: 0, Skipped: 0
# BUILD SUCCESS
编译通过、46 个单测全绿;重启服务后日志中共享引用输出完整 JSON,$ref 消失。
六、延伸:toLogString 与 isDebugEnabled
收口完成、$ref 消失之后,还有一个隐患值得单独拿出来说:toLogString 本身的执行时机。
25 处调用点里,绝大多数是这样的 log.debug:
java
log.debug("sessionId:{}, menuData:{}", ApiLogContext.getSessionId(), JsonUtils.toLogString(menuData));
SLF4J 的 {} 占位符只能省掉字符串拼接 ,省不掉参数求值 ------调用 log.debug() 之前,toLogString(menuData) 已经把整个菜单对象树全量 JSON 序列化完了。而生产环境 root 级别是 INFO(见 log4j2.xml),这条 debug 日志最终会被丢弃,但序列化的开销(遍历菜单、反射取值、分配大字符串、立刻变成垃圾)一点没少。菜单数据带 round/subItem 嵌套,单个菜单序列化出几十 KB 是常态,高频请求下这笔浪费很可观。
这和 log.isDebugEnabled() 的经典场景是同一个问题:在日志真正打印之前,先判断级别,避免为注定被丢弃的日志做重计算。
java
// 反例:DEBUG 关闭时也会全量序列化菜单
log.debug("sessionId:{}, menuData:{}", ApiLogContext.getSessionId(), JsonUtils.toLogString(menuData));
// 正例:DEBUG 关闭时连 toLogString 都不会执行
if (log.isDebugEnabled()) {
log.debug("sessionId:{}, menuData:{}", ApiLogContext.getSessionId(), JsonUtils.toLogString(menuData));
}
本工程能不能用 Lambda 写法?不能
网上常见这样的"现代替代方案":
java
// SLF4J 2.0+ 的 Supplier 重载(本项目不可用)
log.debug("sessionId:{}, menuData:{}", sessionId, () -> JsonUtils.toLogString(menuData));
要泼一盆冷水:Supplier 重载是 SLF4J 2.0 才引入的 。本工程依赖树是 slf4j-api:1.7.36(Spring Boot 2.7.18 管理)+ Log4j2 2.22.1 桥接,Lombok @Slf4j 生成的是 SLF4J 1.7 的 Logger------log.debug(String, Supplier...) 这个重载根本不存在。所以在当前依赖下,isDebugEnabled() 是唯一正确的防御姿势;等将来升级 SLF4J 2.x 再考虑换 Lambda。
| 写法 | 参数求值时机 | 本工程可用 |
|---|---|---|
log.debug("{}", toLogString(obj)) |
调用前必然执行,DEBUG 关闭也全量序列化 | ✅(但有性能隐患) |
if (log.isDebugEnabled()) { ... } |
关闭时整块跳过,零开销 | ✅ 推荐 |
log.debug("{}", () -> toLogString(obj)) |
级别开启时才求值 | ❌ 需 SLF4J 2.0+,当前 1.7.36 不可用 |
一句话总结:{} 占位符解决的是字符串拼接的开销,isDebugEnabled() 解决的是参数构造的开销------当参数本身是一次重量级操作(如全量 JSON 序列化)时,后者才是真正的保护开关。
七、总结与启示
-
fastjson 兼容包 ≠ fastjson2 。
com.alibaba:fastjson:2.x只是为存量代码平滑过渡的桥:旧包名、旧 API 都在,但默认行为也对齐了 fastjson1------包括引用检测。看到依赖树里com.alibaba:fastjson配 2.x 版本号,要意识到它底层跑的是 fastjson2,但行为开关仍是 fastjson1 那套 。要根治$ref,切原生com.alibaba.fastjson2。 -
迁移序列化框架,行为验证比 API 验证重要 。
javap能确认方法存在,确认不了语义变化。fastjson2 的toJSON(String)不再解析字符串,这种差异 IDE 和编译器完全不会报错,只有运行时实测能暴露。迁移前写一个 20 行的行为探针测试,性价比极高。 -
日志序列化先收口再迁移 。把散落在 12 个文件里的 25 处
JSON.toJSONString收口到JsonUtils.toLogString()一个入口,后面切 fastjson2 时改动量从 25 处降到 4 个文件------基础设施收口的红利在下一步立刻兑现。 -
$ref不只是日志可读性问题 。引用检测在"打印"场景是噪音,但如果哪天把带$ref的字符串落库或回传给不识别该语法的消费方,就是实打实的数据 bug。日志用toLogString,业务序列化用各自明确的序列化器,两条路径不要混。 -
重量级日志参数要用
isDebugEnabled()保护 。{}占位符挡不住参数求值:log.debug("{}", toLogString(menu))在 INFO 环境下依然全量序列化。凡是参数里带toLogString、toJson、深度toString之类重计算的日志,一律套isDebugEnabled();本工程 SLF4J 1.7 没有 Supplier 重载,"Lambda 惰性求值"那一套暂时用不了。