1. 引言

在Java开发中,我们经常需要处理JSON数据。Jackson作为主流的JSON处理库,如何配置其ObjectMapper来区分字段缺失(absent)和显式设置为null的字段,是一个常见需求。本文将探讨Jackson的序列化和反序列化配置,并通过一个实际案例(更新记录时区分null和缺失字段)来演示。

2. JSON中缺失字段与Null字段的区别

在JSON处理中,区分字段缺失和显式null至关重要。虽然看起来相似,但它们在数据处理和API设计中含义不同。我们以一个包含基本类型、列表和对象类型的POJO为例:

public class Sample {

    private Long id;
    private String name;
    private int amount;
    private List<String> keys;
    private List<Integer> values;

    // 标准getter和setter
}

字段缺失:指JSON载荷中完全没有该字段。例如,以下JSON中,除了name字段外,其他字段都缺失:

{
    "name": null
}

反序列化时,缺失字段会采用其类型的默认值(例如,对象为null,基本类型为0)。这种区别在以下场景中很关键:

  1. 部分更新:在支持部分更新的API(如PATCH请求)中,缺失字段可能表示“不修改该值”,而null字段可能表示“清除该值”。
  2. 默认值:当字段缺失时,应用可能使用默认值;而显式设置为null则表示要清除该值。
  3. 验证:根据业务需求,缺失字段和null字段的验证规则可能不同。

在示例中,我们将创建方法来更新现有对象,针对非缺失字段采用不同策略。理解这些细微差别有助于确保应用行为可预测,并符合JSON语义。此外,我们还会为基本类型设置自定义默认值,并添加简单的JSON验证。

2.1. Jackson的默认行为

假设amount为0是无效的,我们可以在Sample类中为amount字段设置默认值:

private int amount = 1;

当序列化一个未调用任何setter的新Sample实例时,结果JSON中amount为1,其他字段为null:

@Test
void whenSerializingWithDefaults_thenNullValuesIncluded() {
    Sample zeroArg = new Sample();
    Map<String, Object> map = new ObjectMapper()
      .convertValue(zeroArg, Map.class);

    assertEquals(1, map.get("amount"));
    assertTrue(map.containsKey("id"));
    assertNull(map.get("id"));
    // 其他字段...
}

如果JSON载荷显式将amount设为null,Jackson会使用基本类型的默认值(0)而非我们的自定义默认值:

@Test
void whenDeserializingToMapWithDefaults_thenNullPrimitiveIsDefaulted() {
    String json = """
      {
        "amount": null
      }
      """;
    Sample sample = new ObjectMapper().readValue(json, Sample.class);

    assertEquals(0, sample.getAmount());
}

3. 自定义Jackson反序列化

为确保null值不会被静默转换为默认值,我们可以启用FAIL_ON_NULL_FOR_PRIMITIVES反序列化特性。启用后,为基本类型设置null会抛出MismatchedInputException

@Test
void whenValidatingNullPrimitives_thenFailOnNullAmount() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES);
    String json = """
      {
        "amount": null
      }
      """;

    assertThrows(MismatchedInputException.class,
      () -> mapper.readValue(json, Sample.class));
}

4. 自定义Jackson序列化

对于我们的补丁方法,我们希望排除null、缺失或设置为Java默认值的字段。在Jackson中,“缺失”指空的Optional 使用Include.NON_DEFAULT配置即可实现。此设置通过省略不必要字段来减小载荷大小。

让我们将一个空的Sample实例转换为Map,验证只有amount字段会出现(因为设置了自定义默认值):

@Test
void whenSerializingNonDefault_thenOnlyNonJavaDefaultsIncluded() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.setSerializationInclusion(Include.NON_DEFAULT);

    Sample zeroArg = new Sample();
    Map<String, Serializable> map = mapper.convertValue(
      zeroArg, Map.class);

    assertEquals(zeroArg.getAmount(), map.get("amount"));
    assertEquals(1, map.keySet().size());
}

精简的序列化使得在补丁对象时更容易决定更新哪些字段。

5. 补丁方法

现在,我们将Jackson对缺失和null值的处理应用到实际场景:部分更新。

简而言之,处理部分更新有多种方式。我们看两种:

  • 仅更新非null值:因为null值表示“该值未改变”
  • 更新所有非缺失字段:因为null和非缺失值表示“该值应设为null”

让我们看实现这两种方式的具体代码,摒弃常见的“复制所有属性”方法,利用我们的Jackson配置。

5.1. 仅更新非null值

第一种方法在反序列化后忽略所有null值。 这样,发送补丁时只需关注要修改的值:

void updateIgnoringNulls(String json, Sample current) 
  throws JsonProcessingException {
    Sample update = MAPPER.readValue(json, Sample.class);

    if (update.getId() != null)
        current.setId(update.getId());

    if (update.getName() != null)
        current.setName(update.getName());

    current.setAmount(update.getAmount());

    if (update.getKeys() != null)
        current.setKeys(update.getKeys());

    if (update.getValues() != null)
        current.setValues(update.getValues());
}

如果不需要删除现有值,这种方案很有效。

5.2. 测试非null字段更新策略

添加一些测试设置,首先在Sample类中设置一些默认值:

public static Sample basic() {
    Sample defaults = new Sample();

    List keys = List.of("foo", "bar");
    List values = List.of(1, 2);

    defaults.setId(1l);
    defaults.setKeys(keys);
    defaults.setValues(values);

    return defaults;
}

然后测试:JSON输入中只包含values字段,检查该字段是否更新,以及一个缺失字段是否保留原值:

@Test
void whenPatchingNonNulls_thenNullsIgnored() {
    List<Integer> values = List.of(3);

    Sample defaults = Sample.basic();

    String json = """
      {
        "values": %s
      }
      """.formatted(values);

    updateIgnoringNulls(json, defaults);

    assertEquals(values, defaults.getValues());
    assertNotNull(defaults.getKeys());
}

5.3. 更新所有非缺失字段

下一种方案更新JSON输入中包含的所有字段,即使是null:

void updateNonAbsent(String json, Sample current) 
  throws JsonProcessingException {
    Map<String, Serializable> update = MAPPER.readValue(json, Map.class);

    if (update.containsKey("id"))
        current.setId((Long) update.get("id"));

    if (update.containsKey("name"))
        current.setName((String) update.get("name"));

    if (update.containsKey("amount"))
        current.setAmount((int) update.get("amount"));

    if (update.containsKey("keys"))
        current.setKeys((List<String>) update.get("keys"));

    if (update.containsKey("values"))
        current.setValues((List<Integer>) update.get("values"));
}

使用此方案时,显式包含null字段表示更新现有对象时要清除该字段。

5.4. 测试非缺失字段更新策略

测试时,显式将keys字段设为null,并修改values字段。我们预期只有这两个字段受影响,同时检查一个缺失字段是否保持不变:

@Test
void whenPatchingNonAbsent_thenNullsConsidered() {
    List<Integer> values = List.of(3);

    Sample defaults = Sample.basic();

    String json = """
      {
        "values": %s,
        "keys": null
      }
      """.formatted(values);

    updateNonAbsent(json, defaults);

    assertEquals(values, defaults.getValues());
    assertNull(defaults.getKeys());
    assertNotNull(defaults.getId());
}

6. 结论

本文探讨了根据应用需求灵活处理null和缺失值的方法。无论是忽略null还是将其视为有效值,自定义Jackson行为都能让我们在遵循JSON语义的同时实现所需功能。关键点总结:

区分缺失和null:在部分更新中,缺失表示“不修改”,null表示“清除”。 ⚠️ 基本类型默认值:Jackson会将null转为0,踩坑时启用FAIL_ON_NULL_FOR_PRIMITIVES。 ❌ 避免静默转换:确保null值不会意外被默认值覆盖。

通过合理配置Jackson,我们可以更精确地控制JSON数据处理,避免常见陷阱。


原始标题:How to Distinguish Between Field Absent vs. Null in Jackson | Baeldung