1. 引言

本文将探讨在 Java 中使用 Apache Avro 时处理和写入 null 值的两种方法。通过这些方法,我们也将讨论处理可空字段的最佳实践

2. Avro 中 Null 值的问题

Apache Avro 是一个数据序列化框架,提供丰富的数据结构和紧凑、快速的二进制数据格式。然而,在 Avro 中使用 null 值需要特别注意。

来看一个常见的问题场景:

GenericRecord record = new GenericData.Record(schema);
record.put("email", null);
// 写入文件时可能抛出 NullPointerException

*默认情况下,Avro 字段不可为空。尝试存储 null 值会在序列化时导致 NullPointerException*

在开始第一个解决方案前,先添加正确的 依赖

<dependency>
    <groupId>org.apache.avro</groupId>
    <artifactId>avro</artifactId>
    <version>1.12.0</version>
</dependency>

3. 处理 Null 值的解决方案

本节将探讨处理 Avro 中 null 值的两种主要方法:Schema 定义和基于注解的方式。

3.1. 定义 Schema 的三种方式

我们可以通过三种方式定义支持 null 值的 Avro Schema。首先看 JSON 字符串方式

private static final String SCHEMA_JSON = """
    {
        "type": "record",
        "name": "User",
        "namespace": "com.baeldung.apache.avro.storingnullvaluesinavrofile",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "name", "type": "string"},
            {"name": "active", "type": "boolean"},
            {"name": "lastUpdatedBy", "type": ["null", "string"], "default": null},
            {"name": "email", "type": "string"}
        ]
    }""";
public static Schema createSchemaFromJson() {
    return new Schema.Parser().parse(SCHEMA_JSON);
}

这里使用 union 类型语法 ["null", "string"] 定义可空字段。

接下来使用 SchemaBuilder 的编程式方式定义 Schema:

public static Schema createSchemaWithOptionalFields1() {
    return SchemaBuilder
      .record("User")
      .namespace("com.baeldung.apache.avro.storingnullvaluesinavrofile")
      .fields()
      .requiredLong("id")
      .requiredString("name")
      .requiredBoolean("active")
      .name("lastUpdatedBy")
      .type() // 配置开始
      .unionOf()
      .nullType()
      .and()
      .stringType()
      .endUnion()
      .nullDefault() // 配置结束
      .requiredString("email")
      .endRecord();
}

此例中,我们使用 SchemaBuilder 创建 Schema,其中 lastUpdatedBy 字段可以是 nullstring 值。

最后创建类似但方式不同的 Schema

public static Schema createSchemaWithOptionalFields2() {
    return SchemaBuilder
      .record("User")
      .namespace("com.baeldung.apache.avro.storingnullvaluesinavrofile")
      .fields()
      .requiredLong("id")
      .requiredString("name")
      .requiredBoolean("active")
      .requiredString("lastUpdatedBy")
      .optionalString("email")  // 使用可选字段
      .endRecord();
}

这里用 optionalString() 替代了冗长的 type().unionOf().nullType().andStringType().endUnion().nullDefault() 链式调用。

快速对比后两种方式:

  • 长版本:提供更精细的 null 值控制
  • 短版本SchemaBuilder 提供的语法糖
  • ⚠️ 本质上两者功能相同

3.2. 使用 @Nullable 注解

下一种方式使用 Avro 内置的 @Nullable 注解:

public class AvroUser {
    private long id;
    private String name;
    @Nullable
    private Boolean active;  
    private String lastUpdatedBy;  
    private String email; 

    // 其他代码
}

此注解告诉 Avro 的反射式代码生成机制:该字段可接受 null

4. 写入文件的实现

现在看如何序列化包含 null 值的 Record

public static void writeToAvroFile(Schema schema, GenericRecord record, String filePath) throws IOException {
    DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(schema);
    try (DataFileWriter<GenericRecord> dataFileWriter = new DataFileWriter<>(datumWriter)) {
        dataFileWriter.create(schema, new File(filePath));
        dataFileWriter.append(record);
    }
}

关键步骤解析:

  1. 初始化 GenericDatumWriter 处理 GenericRecord 对象
  2. 将 Schema 作为构造参数,告知序列化规则
  3. 初始化 DataFileWriter 处理实际数据写入和元数据管理
  4. 通过 create() 方法创建带指定 Schema 的 Avro 文件
  5. *最后写入 record。若字段标记为 @Nullableunion 类型,其 null 值将被正确序列化*

5. 测试解决方案

现在验证实现是否正确

@Test
void whenSerializingUserWithNullPropFromStringSchema_thenSuccess(@TempDir Path tempDir) {
    user.setLastUpdatedBy(null);
    schema = AvroUser.createSchemaWithOptionalFields1();

    String filePath = tempDir.resolve("test.avro").toString();
    GenericRecord record = AvroUser.createRecord(AvroUser.createSchemaFromJson(), user);

    assertDoesNotThrow(() -> AvroUser.writeToAvroFile(schema, record, filePath));

    File avroFile = new File(filePath);
    assertTrue(avroFile.exists());
    assertTrue(avroFile.length() > 0);
}

测试中先将 lastUpdatedBy 设为 null,然后用字符串 Schema 创建 Schema 对象。测试表明记录可成功序列化 null 值。

第二个测试使用长配置的 SchemaBuilder

@Test
void givenSchemaBuilderWithOptionalFields1_whenCreatingSchema_thenSupportsNull(@TempDir Path tempDir) {
    user.setLastUpdatedBy(null);
    String filePath = tempDir.resolve("test.avro").toString();

    schema = AvroUser.createSchemaWithOptionalFields1();
    GenericRecord record = AvroUser.createRecord(schema, user);

    assertTrue(schema.getField("lastUpdatedBy").schema().isNullable(),
        "Union type field should be nullable");
    assertDoesNotThrow(() -> AvroUser.writeToAvroFile(schema, record, filePath));

    File avroFile = new File(filePath);
    assertTrue(avroFile.exists());
    assertTrue(avroFile.length() > 0);
}

最后测试短配置的 SchemaBuilder

@Test
void givenSchemaBuilderWithOptionalFields2_whenCreatingSchema_thenSupportsNull(@TempDir Path tempDir) {
    user.setEmail(null);
    String filePath = tempDir.resolve("test.avro").toString();

    schema = AvroUser.createSchemaWithOptionalFields2();
    GenericRecord record = AvroUser.createRecord(schema, user);

    assertTrue(schema.getField("email").schema().isNullable(),
        "Union type field should be nullable");
    assertDoesNotThrow(() -> AvroUser.writeToAvroFile(schema, record, filePath));

    File avroFile = new File(filePath);
    assertTrue(avroFile.exists());
    assertTrue(avroFile.length() > 0);
}

6. 结论

本文探讨了处理 Apache Avro 中 null 值的两种主要方法:

  1. 通过三种方式定义 Schema(JSON 字符串、SchemaBuilder 长配置、SchemaBuilder 短配置)
  2. 直接在类属性上使用 @Nullable 注解

两种方法都有效,但 Schema 方式提供更细粒度的控制,通常是生产环境的首选


原始标题:Storing Null Values in Avro Files | Baeldung