alibaba / alibaba/fastjson2

[BUG] ASM 路径忽略字段级 @JSONField(serializeFeatures),与 reflect 路径输出不一致(基本类型数组 / 基本类型 / 枚举)

Open
#7,773 0 comments 0 reactions 0 assignees View on GitHub
bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.