[BUG] ASM 路径忽略字段级 @JSONField(serializeFeatures),与 reflect 路径输出不一致(基本类型数组 / 基本类型 / 枚举)
- Dominant language
- Java
- Stars
- 4.4k
- Forks
- 613
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 6
Description
### 问题描述
ASM 生成的 `ObjectWriter` 在判断三个 `JSONWriter.Feature` 时,只读取 **writer context** 上的标志位,而没有考虑 `FieldWriter.features` 中已合并的 **字段级** 特性。因此字段上的 `@JSONField(serializeFeatures = ...)` 被静默忽略。
`reflect` 路径处理正确,只有 ASM 路径(也就是默认路径,`JSONFactory.CREATOR == "asm"`)不正确。所以同一个类用 `-Dfastjson2.creator=reflect` 和默认配置跑出来的 JSON 不一样。
具体有三处,症状不同但成因相同:
| # | 生成方法 | 被忽略的特性 | 症状 |
|---|---|---|---|
| A | `gwFieldValueArray`、`gwFieldValueInt64VA`、`gwFieldValueIntVA` | `WriteNulls` | 一维基本类型数组(`int[] long[] boolean[] byte[] short[] double[] char[] float[]`)为 `null` 时,字段整个消失,不会输出 `"field":null` |
| B | `gwFieldValueInt64V`、`gwFieldValueInt32V`、`gwFieldValueBooleanV` | `NotWriteDefaultValue` | 基本类型(`byte short int long float char double`)取默认值时,字段仍然被写出 |
| C | `gwFieldValueEnum` | `WriteNulls` | 枚举类型字段为 `null` 时,字段整个消失 |
注意 A 和 B 都需要改 **三处**:`int[]` / `long[]` 有各自专用的生成方法,只改 `gwFieldValueArray` 是不够的,其它基本类型数组会好、`intArr` 和 `longArr` 仍然丢失。这一点很容易漏。
**请务必以行为为准来判断,而不是读 `FieldWriter*` 类的源码。** ASM 生成器独立于 `FieldWriterInt32Value` 等类实现了这些特性,`FieldWriter*` 里已经修好的逻辑可能根本不在实际执行的路径上——我们自己最初就是这样判断错的。
### 环境信息
- OS信息:Ubuntu 24.04 LTS(服务端)/ Windows 11(开发机),两边表现一致
- JDK信息:OpenJDK 26(`maven.compiler.release=26`)
- 版本信息:Fastjson2 2.0.64(源码内联编译)。同样的问题在 2.0.52 ~ 2.0.64 每一个我们检查过的版本上都存在
### 重现步骤
1. 定义一个带字段级 `serializeFeatures` 的类
2. 用默认(ASM)creator 序列化
3. 对比 `-Dfastjson2.creator=reflect` 的输出——两者不一致
```java
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONWriter;
import com.alibaba.fastjson2.annotation.JSONField;
public class Repro {
// ---- A / C:字段级 WriteNulls ----
public static class ForcedNulls {
@JSONField(serializeFeatures = JSONWriter.Feature.WriteNulls)
public int[] intArr; // 消失(应为 "intArr":null)
@JSONField(serializeFeatures = JSONWriter.Feature.WriteNulls)
public long[] longArr; // 消失
@JSONField(serializeFeatures = JSONWriter.Feature.WriteNulls)
public boolean[] boolArr; // 消失
@JSONField(serializeFeatures = JSONWriter.Feature.WriteNulls)
public short[] shortArr; // 消失
@JSONField(serializeFeatures = JSONWriter.Feature.WriteNulls)
public MyEnum enumField; // 消失
@JSONField(serializeFeatures = JSONWriter.Feature.WriteNulls)
public String strField; // 正常输出 null
}
public enum MyEnum { A, B }
// ---- B:字段级 NotWriteDefaultValue ----
public static class Defaults {
@JSONField(serializeFeatures = JSONWriter.Feature.NotWriteDefaultValue)
public short duration; // 被写成 "duration":0(应当省略)
@JSONField(serializeFeatures = JSONWriter.Feature.NotWriteDefaultValue)
public int count; // 被写成 "count":0
@JSONField(serializeFeatures = JSONWriter.Feature.NotWriteDefaultValue)
public long size; // 被写成 "size":0
public int control = 7; // 无注解,应当保留
}
public static void main(String[] args) {
// writer context 不带 WriteNulls —— 此时字段级注解应当仍然生效
System.out.println(JSON.toJSONString(new ForcedNulls()));
// writer context 不带 NotWriteDefaultValue
System.out.println(JSON.toJSONString(new Defaults()));
}
}
```
### 期待的正确结果
字段级 `serializeFeatures` 与 writer context 的标志位应当取并集,也就是 ASM 路径的输出应当与 `reflect` 路径一致:
```
{"boolArr":null,"enumField":null,"intArr":null,"longArr":null,"shortArr":null,"strField":null}
{"control":7}
```
实际(ASM,2.0.64):
```
{"strField":null}
{"control":7,"count":0,"duration":0,"size":0}
```
### 相关日志输出
不抛异常,也不打日志——这正是问题最麻烦的地方:字段安静地出现或消失,只有在对比线上报文时才会发现。我们是把生产环境抓到的真实报文在新旧引擎上重放、逐字节比对才定位到的。
在我们这边影响到的具体字段:
- A:`UserRecord.appGraphType` / `casinLocales` / `defaultNotificationType`、`DealRecord.backInStock` 从 API 响应里消失。
- B:`VideoObject.duration`(`short`)多出了 `"duration":0`。这个类通过 cloud 协议下发给已经安装在用户机器上的浏览器扩展,属于协议变更。
- C:我们枚举了 schema 类里声明的 18 种引用类型字段形状,枚举是唯一一种不遵守字段级请求的。
### 附加信息
我们在本地内联的这份 2.0.64 上一共打了 6 个补丁,上面三个(bug 编号 A = 补丁 1、B = 补丁 4、C = 补丁 5)我们认为是明确的缺陷。剩下三个是行为增强,一并列在这里,如果 maintainer 觉得合适我们可以另开 issue 或直接提 PR:
1. **`JSONWriter` 构造函数里 `maxArraySize` 的 64MB 上限**(`(context.features & LargeObject.mask) != 0 ? 1GB : 64MB`)。我们最大的序列化输出会超过 64MB,只能在这里改成 384MB。希望能做成可配置项,而不是只有 `LargeObject`(1GB)这一个二选一的档位。
2. **`ACCEPT_SINGLE_VALUE_AS_ARRAY` 的等价能力**。Jackson 有这个开关,fastjson2 没有。上游的 `ObjectReaderImplList.readObject` 已经对 `List` 做了这件事(`ch == '"'` 分支),但 Java 数组没有同等待遇,所以 `{"brand":"sony"}` 能解析进 `List`,解析进 `String[]` 就抛异常。我们在四处补齐了:`JSONReader.readStringArray` / `readInt64Array`、`ObjectReaderImplInt8Array`、`ObjectReaderImplBoolValueArray`、`ObjectArrayTypedReader`。纯放宽,今天能解析的输入行为不变。
`ObjectArrayTypedReader` 那一处需要注意:单值分支必须**只在 autoType 关闭时**才走,因为下面解 `{"@type":…,"@value":[…]}` 信封的代码是破坏性的(先吃掉 `{` 和第一个 field name 再判断),单值分支放前面会把信封吞掉。
3. **`readInt64Value` 对 `9223372036854776000` 抛异常**。这是 `Long.MAX_VALUE` 经过一次 JavaScript `Number` 往返之后的结果:`9223372036854775807` 超过 `Number.MAX_SAFE_INTEGER`,浏览器会四舍五入到最近的 double 再序列化。上游在 `readInt64ValueOverflow` 里直接抛,整个请求失败。我们把这个 19 位字面量映射回 `Long.MAX_VALUE`,其它溢出照抛。这个场景对任何有浏览器客户端的服务都会碰到,也许值得做成一个 `JSONReader.Feature`。
Contributor guide
Research direction
Start by reproducing the ASM-versus-reflect output with the provided ForcedNulls and Defaults examples. Then inspect the ASM generator methods gwFieldValueArray, gwFieldValueInt64VA, gwFieldValueIntVA, gwFieldValueInt64V, gwFieldValueInt32V, gwFieldValueBooleanV, and gwFieldValueEnum. Done means field-level serializeFeatures are honored and ASM output matches reflect for the listed null, default, and enum cases.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100