JSON 日志里的 `{“$ref“:“$.xxx“}`:从 fastjson 兼容包到原生 fastjson2 的迁移实录

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 消失。

六、延伸:toLogStringisDebugEnabled

收口完成、$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 序列化)时,后者才是真正的保护开关。

七、总结与启示

  1. fastjson 兼容包 ≠ fastjson2com.alibaba:fastjson:2.x 只是为存量代码平滑过渡的桥:旧包名、旧 API 都在,但默认行为也对齐了 fastjson1------包括引用检测。看到依赖树里 com.alibaba:fastjson 配 2.x 版本号,要意识到它底层跑的是 fastjson2,但行为开关仍是 fastjson1 那套 。要根治 $ref,切原生 com.alibaba.fastjson2

  2. 迁移序列化框架,行为验证比 API 验证重要javap 能确认方法存在,确认不了语义变化。fastjson2 的 toJSON(String) 不再解析字符串,这种差异 IDE 和编译器完全不会报错,只有运行时实测能暴露。迁移前写一个 20 行的行为探针测试,性价比极高。

  3. 日志序列化先收口再迁移 。把散落在 12 个文件里的 25 处 JSON.toJSONString 收口到 JsonUtils.toLogString() 一个入口,后面切 fastjson2 时改动量从 25 处降到 4 个文件------基础设施收口的红利在下一步立刻兑现。

  4. $ref 不只是日志可读性问题 。引用检测在"打印"场景是噪音,但如果哪天把带 $ref 的字符串落库或回传给不识别该语法的消费方,就是实打实的数据 bug。日志用 toLogString,业务序列化用各自明确的序列化器,两条路径不要混。

  5. 重量级日志参数要用 isDebugEnabled() 保护{} 占位符挡不住参数求值:log.debug("{}", toLogString(menu)) 在 INFO 环境下依然全量序列化。凡是参数里带 toLogStringtoJson、深度 toString 之类重计算的日志,一律套 isDebugEnabled();本工程 SLF4J 1.7 没有 Supplier 重载,"Lambda 惰性求值"那一套暂时用不了。

相关推荐
geovindu35 分钟前
python:HandWriting Recognition using paddlepaddle
开发语言·后端·python·paddlepaddle
wuyk5551 小时前
Python 零基础入门第九章:用户输入与 While 循环
开发语言·python
兮动人1 小时前
Python变量与常量
开发语言·python·机器学习·python变量与常量
风筝在晴天搁浅1 小时前
解题代码清爽版
java·开发语言
2601_962300811 小时前
机器学习的理想基石
人工智能·python·机器学习·编程语言·数据处理
Java后端的Ai之路1 小时前
21、Python - 命令模式
开发语言·人工智能·python·命令模式·外观模式
astronautyi2 小时前
Go 运行时内存分配与 GC 位图深度剖析
开发语言·后端·golang
2601_962077982 小时前
机器学习及其Python实践
pytorch·python·机器学习·tensorflow·scikit-learn
techdashen2 小时前
Go 中如何处理错误
开发语言·后端·golang