SpringBoot4系列23 - 使用Jackson 3与空安全

前段时间我帮订单服务做过一次对账排查:财务那边的解析脚本按「字段有值才算有效记录」的口径读订单 JSON。游客单没留电话、没用优惠券、没填备注,响应体里这些字段带着 null 原样输出,对账脚本把整批订单判成无效数据,夜里重跑了三次,我第二天早上才看到告警。另一件事更隐蔽:金额字段 discountCents 在下单链路里偶尔是 null,对账方拿到 null 按数值 0 参与汇总,月初的折扣报表少算了两万块,排查的时候我才搞明白,null 与 0 在他们的口径里是两个东西,这么混着用迟早闹笑话。

这两件事指向同一个机制:JSON 序列化时对空值的处理策略。空字段省略还是输出 null、请求体里的 null 落成什么值、null 打进 primitive 怎么办,这些决策都写死在框架默认行为里,我不显式声明,框架就替业务做主。所以我把 Jackson 3 的空值处理在 Spring Boot 4.1.1 上完整实测了一遍:默认行为取证、三种空值策略同场对比、自定义序列化器做金额分转元、反序列化容错逐项验证,11 个端到端用例全绿,全部数字来自同一次运行,原始记录我都留了底。

包名与坐标事实清单

动笔前我把相关制品全部解包核对了一遍:tools.jackson.core:jackson-databind 3.1.5、jackson-core 3.1.5、jackson-annotations 2.21、spring-boot-jackson 4.1.1,下面这些事实都来自制品与我自己的真实运行。

第一个事实是坐标。Jackson 3 的 Maven groupId 换成了 tools.jackson.core,Java 包名前缀跟着换成 tools.jackson,Boot 4.1.1 的 BOM 管理的小版本是 3.1.5,pom 里一个版本号都不用写,我一开始还想手动指定,后来发现纯属多余。注解是个例外:jackson-annotations 的 groupId 还是 com.fasterxml.jackson.core,版本线还是 2.21,包名也还是 com.fasterxml.jackson.annotation。这不是残留,databind 3 的代码大量引用这个包里的注解,它是两个包名世界之间的一座桥。

第二个事实是类名。构建 mapper 的推荐入口在 tools.jackson.databind.json 包:

1
2
3
4
5
public class JsonMapper extends ObjectMapper {
    public static JsonMapper.Builder builder();
    public static JsonMapper shared();
    public static JsonMapper.Builder builderWithJackson2Defaults();
}

ObjectMapper 本身仍是可实例化的类,JsonMapper 继承它并补上 JSON 专属的工厂方法。异常体系在 Jackson 3 里全部改成了非受检:JacksonException 继承 RuntimeException,DatabindException 继承 JacksonException,序列化与反序列化方法签名里不再出现 throws。我第一次写自定义序列化器时顺手在方法上写了 throws,编译器当场给我上了一课。自定义序列化器的基类也换了名字,叫做 ValueSerializer 与 ValueDeserializer,方法参数里的上下文类型是 SerializationContext 与 DeserializationContext。

第三个事实是注解的归属,我把它们整理成一张当前事实清单:

1
2
3
4
5
com.fasterxml.jackson.annotation 包(不变)
  @JsonInclude / @JsonSetter / @Nulls
  @JsonIgnore / @JsonProperty / @JsonAlias / @JsonFormat / @JsonCreator
tools.jackson.databind.annotation 包(databind 注解的新家)
  @JsonSerialize / @JsonDeserialize / @JsonNaming / @JsonPOJOBuilder

常用的 null 处理注解全在 com.fasterxml.jackson.annotation 包里,写法与记忆一致;需要指定自定义序列化器时,@JsonSerialize 与 @JsonDeserialize 在 tools.jackson.databind.annotation 包下,import 的时候认准路径。

空安全主题里两个注解的取值集合同样值得列成清单,签名我都在 javap 里核对过:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@JsonInclude.Include 六个值
  ALWAYS        默认值:null 照常输出
  NON_NULL      null 不输出(本篇主角)
  NON_ABSENT    null 与「缺席值」(Optional.empty)都不输出
  NON_EMPTY     在 NON_ABSENT 基础上再省空字符串、空集合、空 Map
  CUSTOM        按 valueFilter/contentFilter 指定的过滤类判定
  USE_DEFAULTS  回退到类级或全局配置,解决字段与类注解冲突时的显式表态

@JsonSetter 的 Nulls 五个值(反序列化方向)
  SET          null 照常赋给字段
  SKIP         跳过这条 JSON 键,字段保持原值
  FAIL         抛 InvalidNullException,异常携带字段名(PropertyName)
  AS_EMPTY     落成类型的空值:String 为空串、数值为 0、集合为空集合
  DEFAULT      回退到全局默认行为

序列化侧的 NON_NULL 与反序列化侧的 AS_EMPTY 是一对常被混用的取值:前者管「出去时省略」,后者管「进来时补空值」,方向相反,语义也不同,我后文的实测会把这层差异摆出来。

https://static.xiongneng.me/jackson3-package-fact-tree-20260922055525.png

依赖与配置

依赖零新增,这是我这次实测里最省心的一环。starter-webmvc 自带 Jackson 3,传递链是 spring-boot-starter-webmvc → spring-boot-starter-jackson → spring-boot-jackson → tools.jackson.core:jackson-databind 3.1.5,我跑了一遍 dependency:tree,没有任何可选件缺席:

1
2
3
4
5
6
7
8
9
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>

单模块只想用 JSON 不起 Web 时,坐标是 spring-boot-starter-jackson。没有 starter-json 这个名字,我在官方文档里翻了半天才确认,九成的人第一次都会在这里扑空。

application.yml 里与 Jackson 相关的配置只有端口和日志,全局空值策略我故意留成注释,后面的对比实测会把它打开:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
server:
  port: ${SERVER_PORT:18230}

spring:
  application:
    name: springboot4-jackson3
  jackson:
    # default-property-inclusion: non_null

logging:
  level:
    com.xncoding.jackson3: DEBUG

spring.jackson 命名空间在 Boot 4.1.1 共有 34 条属性,这是我从 configuration metadata 里一条条数出来的。与空值直接相关的一条是 default-property-inclusion,绑定的类型是 com.fasterxml.jackson.annotation.JsonInclude$Include 枚举,yml 里写 non_null。serialization 与 deserialization 两条 Map 属性还在,键的类型已经换成 tools.jackson 的枚举,比如给 deserialization 配 FAIL_ON_UNKNOWN_PROPERTIES: true 就能打开未知字段报错。

默认行为:先看 Jackson 3 怎么处理 null

空值策略的第一步是搞清楚默认值,不摸清基准,后面的对比都没有参照。工程里我放了一个探针端点,注入 Boot 容器里的 ObjectMapper,回显三个 Feature 的开关状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@GetMapping("/defaults")
public Map<String, Object> defaults() {
    Map<String, Object> out = new LinkedHashMap<>();
    out.put("mapperClass", mapper.getClass().getName());
    out.put("failOnUnknownProperties",
            mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES));
    out.put("failOnNullForPrimitives",
            mapper.isEnabled(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES));
    out.put("failOnEmptyBeans",
            mapper.isEnabled(SerializationFeature.FAIL_ON_EMPTY_BEANS));
    return out;
}

实测输出一行顶三段解释:

三个值里有一个值得停下来看:FAIL_ON_NULL_FOR_PRIMITIVES 默认是开着的。JSON null 打进 int 字段会直接抛异常,金额字段出过事的项目对这个默认值会有体感,我第一次看到它默认开启时还挺意外。FAIL_ON_UNKNOWN_PROPERTIES 默认关闭,请求体里多出来的字段被静默忽略。FAIL_ON_EMPTY_BEANS 默认关闭,没有可输出属性的对象序列化成空对象,前端拿到 {} 的场景多半与它有关。

三个开关都能经 spring.jackson 属性调整。deserialization 与 serialization 是 Map 类型,键写 Feature 枚举名,比如把 primitive 的严格模式关掉:

1
2
3
4
spring:
  jackson:
    deserialization:
      FAIL_ON_NULL_FOR_PRIMITIVES: false

键名与探针端点回显的枚举名一一对应,宽松绑定不改动枚举常量本身。这条属性与探针端点读的是同一组 Feature 开关,我后续的实测全部保持在默认值状态。

序列化方向的默认值靠一个不贴任何注解的 DTO 实测。订单 2 是一张游客单,customerPhone、couponCode、remark、discountCents 四个字段为 null:

null 字段原样输出,10 个字段一个不少,字段名按字母序排列。Jackson 3 的默认 inclusion 仍是 ALWAYS,想省掉空字段必须显式声明,框架不会替业务做这个决定,我觉得这个设计立场挺有意思:它宁可让响应冗余,也不悄悄丢字段。

三种空值策略同场对比

策略一,类级 @JsonInclude(NON_NULL)。注解贴在 DTO 类上,整类所有 null 字段都不输出:

1
2
3
4
5
6
7
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OrderNonNullResponse {
    private Long id;
    private String orderNo;
    private String customerName;
    // 其余字段同默认 DTO
}

订单 2 经过这个 DTO 输出,4 个 null 字段全部消失,10 个字段剩 6 个:

策略二,字段级 @JsonInclude(NON_NULL)。注解只贴在 couponCode 一个字段上,其余字段维持默认行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderFieldIncludeResponse {
    private Long id;
    private String orderNo;
    private String customerName;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String couponCode;

    private String remark;
    // 其余字段同默认 DTO
}

实测输出里 couponCode 整个键消失了,remark 与 discountCents 仍是 null。字段级注解只作用于贴了它的字段,类里其他 null 字段不受影响。这个粒度适合「个别字段有契约要求、其余保持完整结构」的场景,比如对账方要求某个可空字段缺席时代表未启用,而不是 null。

策略三,全局属性。yml 里打开我留的那行注释,或者验证脚本里用命令行参数起服务:

1
2
3
spring:
  jackson:
    default-property-inclusion: non_null

全局生效后,订单 2 的默认 DTO 输出与类级注解的效果一致,4 个 null 字段省略;record DTO 的输出也同步变化。三种策略同场对比如下:

策略写法位置作用域订单 2 实测
默认行为无整个应用10 字段,4 个 null 输出
字段级 NON_NULL字段上贴注解的那个字段仅 couponCode 消失
类级 NON_NULLDTO 类上当前 DTO4 个 null 全省,剩 6 字段
全局 non_nullspring.jackson 属性整个应用与类级一致,record 同步受控

作用域从小到大,选型的依据是契约的归属:字段契约写在字段上,接口契约写在 DTO 上,应用级口径才走全局属性。三者叠加时的求值顺序由 USE_DEFAULTS 串起来,字段注解最先表态,没表态再看类注解,然后落到全局属性,没有一层声明时才是默认的 ALWAYS。我把这条判定链画成了一张图:

https://static.xiongneng.me/jackson3-null-decision-flow-20260922055529.png

自定义序列化器:金额分转元

金额在数据库里存分(long),对外接口输出元(两位小数),我把换算逻辑收在序列化器里,DTO 字段不出现重复换算代码:

1
2
3
4
5
6
7
public class CentsToYuanSerializer extends ValueSerializer<Long> {

    @Override
    public void serialize(Long cents, JsonGenerator gen, SerializationContext ctxt) {
        gen.writeNumber(OrderService.centsToYuan(cents));
    }
}

基类 ValueSerializer 在 tools.jackson.databind 包,第二个参数是 JsonGenerator,第三个是 SerializationContext。DTO 字段上用 @JsonSerialize 指定(注解在 tools.jackson.databind.annotation 包):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class OrderAmountResponse {
    private Long id;
    private String orderNo;

    @JsonSerialize(using = CentsToYuanSerializer.class)
    private long amountYuan;

    @JsonSerialize(using = CentsToYuanSerializer.class)
    private Long discountYuan;
}

订单 1 的实测输出,24182 分变成 241.82 元,2000 分变成 20.00 元,尾零保留(BigDecimal 按 scale 2 构造):

订单 2 的 discountYuan 是 null,输出仍是 “discountYuan”:null。null 值不进自定义序列化器,走框架的 NullSerializer,这点我是踩过才记住的:「字段可能为空的金额」场景要单独想清楚,要么接受 null 出现在响应里,要么给 DTO 补 NON_NULL,要么像下一节那样在反序列化方向处理空值。

反方向是请求体里的元转分。ValueDeserializer 的子类把 241.82 解析成 24182:

1
2
3
4
5
6
7
public class YuanToCentsDeserializer extends ValueDeserializer<Long> {

    @Override
    public Long deserialize(JsonParser p, DeserializationContext ctxt) {
        return OrderService.yuanToCents(p.getDecimalValue());
    }
}

取数用的是 JsonParser 的 getDecimalValue()。请求体里写 “amount”: 241.82,实测解析落库 amountCents 为 24182。这两个自定义类与 Boot 侧的装配关系,我画成了一张类图:

https://static.xiongneng.me/jackson3-serializer-class-diagram-20260922055640.png

反序列化容错

反序列化方向的空值由 @JsonSetter 控制,它管「JSON 里显式写了 null 时字段落什么值」:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class OrderCreateRequest {

    private String customerName;

    @JsonAlias("orderNo")
    private String merchantOrderNo;

    @JsonSetter(nulls = Nulls.AS_EMPTY)
    private String couponCode;

    private int quantity;

    @JsonSetter(nulls = Nulls.AS_EMPTY)
    private Long discountCents;

    @JsonProperty("amount")
    @JsonDeserialize(using = YuanToCentsDeserializer.class)
    private Long amountCents;
}

Nulls 枚举共五个值:SET、SKIP、FAIL、AS_EMPTY、DEFAULT。AS_EMPTY 把 null 落成类型的空值,String 落空字符串、Long 落 0。我构造了一个请求体,带一个未知字段 extraField,加上 null 值与元金额一起发过去:

1
2
3
4
5
请求:{"customerName":"王五","orderNo":"TM-2026-777","extraField":"x",
      "couponCode":null,"quantity":1,"discountCents":null,"amount":241.82}
响应:200
     {"customerName":"王五","merchantOrderNo":"TM-2026-777",
      "couponCode":"","quantity":1,"discountCents":0,"amountCents":24182}

四件事同一次请求里验证完:未知字段 extraField 被静默忽略(FAIL_ON_UNKNOWN_PROPERTIES 默认关闭);@JsonAlias 让别名 orderNo 命中 merchantOrderNo;couponCode 的 null 落成空字符串;discountCents 的 null 落成 0;241.82 换算成 24182 分。

AS_EMPTY 的语义边界要清楚:它给的是「空值」,空字符串与 0 在对账口径里依然是有效值,与「字段缺席」不同。null 想被跳过保留字段原值用 SKIP,想直接报错用 FAIL,FAIL 会抛出携带字段名的 InvalidNullException。我把三个取值的实测差异钉在同一行字段上:

1
2
3
4
5
JSON: "couponCode": null

Nulls.AS_EMPTY → couponCode 落 ""(空字符串,仍是有效值)
Nulls.SKIP     → setter 不被调用,字段保持 Java 侧的初始值
Nulls.FAIL     → 请求失败,InvalidNullException 携带PropertyName(couponCode)

AS_EMPTY 适合「把 null 规整成可计算的缺省值」的入口,比如优惠券字段 null 落空串后,下游代码不再需要判 null;FAIL 适合契约严格的内部接口,null 一出现立刻暴露调用方问题;SKIP 的用途最窄,字段有 Java 侧默认值时才用得上。

primitive 的行为我单独实测。quantity 是 int,请求体显式给 “quantity”: null:

1
2
3
4
HTTP 400 | content-type: application/problem+json
{"detail":"请求体解析失败: Cannot map `null` into type `int`
  (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)",
 "instance":"/api/orders/parse","status":400,"title":"Invalid request body"}

返回 400,没有静默落 0,错误文本里直接给出了关闭开关的名字,这是本次实测里我最欣赏的一个默认值:null 与 0 的区分在 primitive 字段上也被守住,代价是客户端必须保证不该为 null 的字段别发 null。捕获侧的代码同样有 Jackson 3 的痕迹:DatabindException 是 RuntimeException 的子类,@RestControllerAdvice 的处理方法不需要 throws 声明:

1
2
3
4
5
6
7
@ExceptionHandler(DatabindException.class)
public ProblemDetail handleDatabind(DatabindException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "请求体解析失败: " + ex.getOriginalMessage());
    pd.setTitle("Invalid request body");
    return pd;
}

一个请求从进来到落地,中间要过未知字段、别名、空值语义、自定义换算、primitive 严格模式五道关口,出口只有 200 与 400 两种,我把整条容错链路画了出来:

https://static.xiongneng.me/jackson3-deserialize-tolerance-flow-20260922055532.png

完整案例:一次运行的全部输出

验证脚本起两次服务。A 轮默认配置发 11 个请求,B 轮开全局 non_null 再发 2 个,输出我逐条实录。三种策略对同一张游客单(订单 2)的输出对比:

1
2
3
4
默认行为        → couponCode/customerPhone/remark/discountCents 均带 null,10 字段
类级 NON_NULL   → 4 个 null 字段省略,6 字段
字段级 NON_NULL → 仅 couponCode 省略,其余 null 照常
全局 non_null   → 与类级注解效果一致,record 同步受控

record 的输出单独看一眼。组件按声明序排列,与普通 DTO 的字母序不同:

LocalDateTime 组件零配置输出 ISO-8601 文本,Jackson 3 的 java.time 支持已内置在 databind 里,不再需要单独注册模块。全局 non_null 打开后,这张 record 输出里的 4 个 null 同样消失,customerPhone 与 couponCode 整行不见:

record 的空值策略与普通 DTO 共用同一套注解与全局属性,@JsonInclude 贴在 record 声明上、@JsonProperty 定名、@JsonSetter 控制组件的反序列化空值,写法完全一致。区别只剩两处:输出顺序是声明序,组件不可变所以没有 setter,反序列化走构造器参数,@JsonSetter 贴在组件上同样生效。

13 个场景的完整行为汇总成一张表:

#请求实测结果
1GET /api/jackson/defaultsJsonMapper + 三 Feature 默认值实证
2GET /api/orders/2(默认 DTO)10 字段,4 个 null 原样输出
3GET /api/orders/1(默认 DTO)全字段非空,输出与字段值一致
4GET /api/orders/2/non-null4 个 null 省略,剩 6 字段
5GET /api/orders/2/field-include仅 couponCode 消失
6GET /api/orders/2/record声明序输出,null 照常
7GET /api/orders/1/amount24182→241.82,2000→20.00
8GET /api/orders/2/amountdiscountYuan 为 null,输出 null
9POST /api/orders/parse(混合容错)未知字段忽略,AS_EMPTY 生效,241.82→24182
10POST /api/orders/parse(quantity:null)400,提示 FAIL_ON_NULL_FOR_PRIMITIVES
11POST /api/orders(下单)201,amount 99.90 落库 9990 分
12B 轮 GET /api/orders/2全局 non_null 生效,null 全省
13B 轮 GET /api/orders/2/recordrecord 同步受控,null 全省

下单入口把容错规则串成完整链路,请求体 “amount”: 99.90 落库 9990 分,HTTP 201 返回建好的订单:

1
2
3
HTTP 201 | content-type: application/json
{"amountCents":9990,"couponCode":"VIP30","customerName":"赵六","id":3,
 "orderNo":"SO-2026-0003","quantity":3,"status":"CREATED"}

响应 DTO 用的是类级 NON_NULL 的那个,没填的 customerPhone 不出现在响应里。对账脚本按「字段缺席即未启用」的口径解析,null 判定的分支整体消失。说实话,开头那两桩对账事故,在接口契约这一层就都能拦下来。

测试怎么写

11 个用例起真实 Tomcat 跑 HTTP 断言,客户端是 RestTestClient,RANDOM_PORT 下手工绑定地址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class JacksonNullStrategyTest {

    @LocalServerPort
    private int port;

    private RestTestClient rest;

    @BeforeEach
    void setUp() {
        rest = RestTestClient.bindToServer()
                .baseUrl("http://localhost:" + port)
                .build();
    }

    @Test
    void classLevelNonNullOmitsAllNulls() {
        String body = rest.get().uri("/api/orders/2/non-null")
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class).returnResult().getResponseBody();
        assertThat(body)
                .doesNotContain(":null")
                .contains("\"customerName\":\"李四\"");
    }
}

断言直接对响应字符串做 contains 与 doesNotContain,JSON 结构变化(字段消失)比数值比较更贴空值策略的本意,这是我写完才体会到的。反序列化容错用一个用例覆盖四个点,断言每个容错点落进字段的值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Test
void deserializationTolerances() {
    String payload = """
            {"customerName":"王五","orderNo":"TM-2026-777","extraField":"x",
             "couponCode":null,"quantity":1,"discountCents":null,"amount":241.82}""";
    String body = rest.post().uri("/api/orders/parse")
            .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
            .body(payload)
            .exchange()
            .expectStatus().isOk()
            .expectBody(String.class).returnResult().getResponseBody();
    assertThat(body)
            .contains("\"merchantOrderNo\":\"TM-2026-777\"")
            .contains("\"couponCode\":\"\"")
            .contains("\"discountCents\":0")
            .contains("\"amountCents\":24182");
}

payload 里的 extraField 我故意不删,它就是「未知字段被忽略」这条断言的前提,删掉后用例还能过,但容错覆盖少了一半。primitive 的 400 与元转分的 201 各占一个用例,九个用例合起来把探针、序列化、反序列化三个方向全部钉住。全局策略轮用测试属性单独起一个上下文,验证 default-property-inclusion 对普通 DTO 与 record 同时生效:

1
2
3
4
5
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = "spring.jackson.default-property-inclusion=non_null")
class GlobalInclusionPropertyTest {
    // 两个用例:普通 DTO 与 record 的 null 字段同时消失
}

mvn test 一次跑完的对账:

1
2
3
GlobalInclusionPropertyTest    2    3.933s    全局 non_null 对 DTO 与 record 生效
JacksonNullStrategyTest        9    0.621s    默认行为 / 三种策略 / 序列化器 / 容错
合计                          11    BUILD SUCCESS

应用日志 ERROR 0 行。全部数字来自同一次运行,原始记录我都留了底。

避坑指南

坑一,FAIL_ON_NULL_FOR_PRIMITIVES 默认是开的,我第一次实测就撞上了。 JSON null 打进 int、long 字段直接 400,错误文本自带修复提示。想让 null 落 0,显式关掉这个 Feature,或者把字段类型换成包装类,业务代码里对包装类自己判空。

坑二,自定义反序列化器取小数要用 getDecimalValue()。 getNumberValue() 对 241.82 这类小数返回的是 Double,直接强转 BigDecimal 会抛 ClassCastException,我就这么栽过一次。取 BigDecimal 走 getDecimalValue(),取整型再按需转换。

坑三,null 不进自定义序列化器。 字段值为 null 时 Jackson 走 NullSerializer,@JsonSerialize 指定的序列化器不会被调用。空字段想输出缺省值(比如金额输出 0.00),要么在序列化器里用 Nulls.AS_EMPTY 的空值语义,要么接受 null 出现并交给客户端判断。

坑四,字段级 @JsonInclude 只管贴了注解的字段。 想整类省略 null 用类级注解,只想动一个字段用字段级,两者混用时字段级优先。我排查时就以为贴一个字段全类生效,盯着其余 null 字段还在输出看了半天,方向从一开始就错了。

坑五,默认 inclusion 是 ALWAYS,空字段省略必须显式声明。 Jackson 3 在这一点上取的是保守默认值,没配策略的接口照常输出 null 字段。对账、开放平台这类按字段解析的外部消费方,上线前先对一遍口径:null、缺字段、空字符串是三种不同的值。

坑六,record 的输出顺序是声明序。 普通 POJO 的字段按字母序输出,record 组件按声明序。契约方如果按字节序或字段位置对账,两种 DTO 的输出顺序不同,换 DTO 形态前先确认对方的解析方式。

坑七,未知字段默认静默忽略。 FAIL_ON_UNKNOWN_PROPERTIES 在 Jackson 3 默认 false,请求体多打一个字段名不会有任何提示。契约收敛严格的接口要显式打开这个 Feature,让多出来的字段在测试期就报错。

坑八,定制 mapper 的入口是 JsonMapperBuilderCustomizer。 Boot 侧想调 Feature、注册 Module,实现 org.springframework.boot.jackson.autoconfigure 包下的 JsonMapperBuilderCustomizer 接口,方法签名 customize(JsonMapper.Builder)。注解风格的序列化器注册用 @JacksonComponent(org.springframework.boot.jackson 包)。

小结

空值策略怎么选,看契约的归属:单个字段有约定就贴字段级注解,整个 DTO 有统一口径就贴类级注解,应用级要对齐一种口径才动 spring.jackson 的全局属性,三者叠加时字段级最先表态。反序列化入口的 null 优先考虑 AS_EMPTY,把 null 规整成下游可计算的缺省值;契约严格的内部接口用 FAIL,让调用方的问题当场暴露。金额这类有换算口径的字段,把换算收进自定义序列化器,DTO 里别出现重复代码。

没解决的事也直说:null 打进对象类型字段时走的是框架的 NullSerializer,自定义 NullSerializer 能接管到什么程度、「null 字段输出占位符」这类需求怎么落,我这次没测,等遇到真实场景再补一轮。

参考链接