1. 简介

Apache Avro 是一个数据序列化框架,提供丰富的数据结构和紧凑、快速的二进制数据格式。在 Java 应用中使用 Avro 时,我们经常需要序列化枚举值。如果处理不当,这可能会变得棘手。

本文将探讨如何使用 Avro 正确序列化 Java 枚举值,并解决在 Avro 中处理枚举时常见的挑战。

2. 理解 Avro 枚举序列化

在 Avro 中,枚举通过名称和一组符号定义。序列化 Java 枚举时,必须确保 schema 中的枚举定义与 Java 枚举定义匹配,因为 Avro 会在序列化过程中验证枚举值。

Avro 采用基于 schema 的方法,schema 定义了数据的结构,包括字段名称、类型以及枚举的允许符号值。schema 作为序列化器和反序列化器之间的契约,确保数据一致性。

首先添加必要的 Avro Maven 依赖:

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

3. 在 Avro Schema 中定义枚举

下面演示如何在 Avro schema 中正确定义枚举:

Schema colorEnum = SchemaBuilder.enumeration("Color")
  .namespace("com.baeldung.apache.avro")
  .symbols("UNKNOWN", "GREEN", "RED", "BLUE");

这创建了一个包含四个可用值的枚举 schema。namespace 有助于避免命名冲突,symbols 定义了有效的枚举值

现在将此枚举用于记录 schema:

Schema recordSchema = SchemaBuilder.record("ColorRecord")
  .namespace("com.baeldung.apache.avro")
  .fields()
  .name("color")
  .type(colorEnum)
  .noDefault()
  .endRecord();

这创建了一个名为 ColorRecord 的记录 schema,包含一个名为 color 的字段,类型为前面定义的枚举。

4. 序列化枚举值

定义好枚举 schema 后,我们探讨如何序列化枚举值。

本节将讨论基本枚举序列化的标准方法,并处理联合类型中枚举的常见问题——这通常是混淆的根源。

4.1 基本枚举序列化的正确方法

要正确序列化枚举值,需要创建 EnumSymbol 对象,使用适当的枚举 schema(colorEnum):

public void serializeEnumValue() throws IOException {
    GenericRecord record = new GenericData.Record(recordSchema);
    GenericData.EnumSymbol colorSymbol = new GenericData.EnumSymbol(colorEnum, "RED");
    record.put("color", colorSymbol);
    
    DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(recordSchema);
    try (DataFileWriter<GenericRecord> dataFileWriter = new DataFileWriter<>(datumWriter)) {
        dataFileWriter.create(recordSchema, new File("color.avro"));
        dataFileWriter.append(record);
    }
}

首先基于 recordSchema 创建 GenericRecord,然后使用枚举 schema 和值 "RED" 创建 EnumSymbol,最后将其添加到记录中并序列化到文件。

测试实现:

@Test
void whenSerializingEnum_thenSuccess() throws IOException {
    File file = tempDir.resolve("color.avro").toFile();

    serializeEnumValue();

    DatumReader<GenericRecord> datumReader = new GenericDatumReader<>(recordSchema);
    try (DataFileReader<GenericRecord> dataFileReader = new DataFileReader<>(file, datumReader)) {
        GenericRecord result = dataFileReader.next();
        assertEquals("RED", result.get("color").toString());
    }
}

此测试验证了枚举值的成功序列化和反序列化。

4.2 处理联合类型中的枚举

下面处理常见问题——序列化联合类型中的枚举:

Schema colorEnum = SchemaBuilder.enumeration("Color")
  .namespace("com.baeldung.apache.avro")
  .symbols("UNKNOWN", "GREEN", "RED", "BLUE");
    
Schema unionSchema = SchemaBuilder.unionOf()
  .type(colorEnum)
  .and()
  .nullType()
  .endUnion();
    
Schema recordWithUnionSchema = SchemaBuilder.record("ColorRecordWithUnion")
  .namespace("com.baeldung.apache.avro")
  .fields()
  .name("color")
  .type(unionSchema)
  .noDefault()
  .endRecord();

分析定义的 schema:我们创建了一个联合 schema,可以是枚举类型或 null(常用于可选字段)。然后创建使用此联合类型的记录 schema。

序列化联合类型中的枚举时,仍使用 EnumSymbol,但需使用正确的 schema 引用:

GenericRecord record = new GenericData.Record(recordWithUnionSchema);
GenericData.EnumSymbol colorSymbol = new GenericData.EnumSymbol(colorEnum, "RED");
record.put("color", colorSymbol);

关键点:创建 EnumSymbol 时使用枚举 schema 而非联合 schema——这是导致序列化错误的常见陷阱。

测试联合类型处理:

@Test
void whenSerializingEnumInUnion_thenSuccess() throws IOException {
    File file = tempDir.resolve("colorUnion.avro").toFile();

    GenericRecord record = new GenericData.Record(recordWithUnionSchema);
    GenericData.EnumSymbol colorSymbol = new GenericData.EnumSymbol(colorEnum, "GREEN");
    record.put("color", colorSymbol);

    DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(recordWithUnionSchema);
    try (DataFileWriter<GenericRecord> dataFileWriter = new DataFileWriter<>(datumWriter)) {
        dataFileWriter.create(recordWithUnionSchema, file);
        dataFileWriter.append(record);
    }

    DatumReader<GenericRecord> datumReader = new GenericDatumReader<>(recordWithUnionSchema);
    try (DataFileReader<GenericRecord> dataFileReader = new DataFileReader<>(file, datumReader)) {
        GenericRecord result = dataFileReader.next();
        assertEquals("GREEN", result.get("color").toString());
    }
}

测试联合类型中的 null 值处理:

@Test
void whenSerializingNullInUnion_thenSuccess() throws IOException {
    File file = tempDir.resolve("colorNull.avro").toFile();

    GenericRecord record = new GenericData.Record(recordWithUnionSchema);
    record.put("color", null);

    DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(recordWithUnionSchema);
    assertDoesNotThrow(() -> {
        try (DataFileWriter<GenericRecord> dataFileWriter = new DataFileWriter<>(datumWriter)) {
            dataFileWriter.create(recordWithUnionSchema, file);
            dataFileWriter.append(record);
        }
    });
}

5. 枚举的 Schema 演进

处理枚举时,schema 演进是敏感领域,添加或删除枚举值可能导致兼容性问题。本节探讨如何更新数据结构,重点是通过正确配置默认值保持向后兼容性。

5.1 添加新枚举值

扩展 schema 时需谨慎考虑新枚举值的添加。为保持向后兼容性,添加默认值至关重要

@Test
void whenSchemaEvolution_thenDefaultValueUsed() throws IOException {
    String evolvedSchemaJson = "{\"type\":\"record\",
                                 \"name\":\"ColorRecord\",
                                 \"namespace\":\"com.baeldung.apache.avro\",
                                 \"fields\":
                                   [{\"name\":\"color\",
                                     \"type\":
                                        {\"type\":\"enum\",
                                         \"name\":\"Color\",
                                     \"symbols\":[\"UNKNOWN\",\"GREEN\",\"RED\",\"BLUE\",\"YELLOW\"],
                                         \"default\":\"UNKNOWN\"
                                   }}]
                                 }";
    
    Schema evolvedRecordSchema = new Schema.Parser().parse(evolvedSchemaJson);
    Schema evolvedEnum = evolvedRecordSchema.getField("color").schema();
    
    File file = tempDir.resolve("colorEvolved.avro").toFile();

    GenericRecord record = new GenericData.Record(evolvedRecordSchema);
    GenericData.EnumSymbol colorSymbol = new GenericData.EnumSymbol(evolvedEnum, "YELLOW");
    record.put("color", colorSymbol);

    DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(evolvedRecordSchema);
    try (DataFileWriter<GenericRecord> dataFileWriter = new DataFileWriter<>(datumWriter)) {
        dataFileWriter.create(evolvedRecordSchema, file);
        dataFileWriter.append(record);
    }
    
    String originalSchemaJson = "{\"type\":\"record\",
                                  \"name\":\"ColorRecord\",
                                  \"namespace\":\"com.baeldung.apache.avro\",
                                  \"fields\":[{
                                     \"name\":\"color\",
                                     \"type\":
                                         {\"type\":\"enum\",
                                          \"name\":\"Color\",
                                          \"symbols\":[\"UNKNOWN\",\"GREEN\",\"RED\",\"BLUE\"],
                                          \"default\":\"UNKNOWN\"}}]
                                 }";
    
    Schema originalRecordSchema = new Schema.Parser().parse(originalSchemaJson);
    
    DatumReader<GenericRecord> datumReader = 
                    new GenericDatumReader<>(evolvedRecordSchema, originalRecordSchema);
    try (DataFileReader<GenericRecord> dataFileReader = new DataFileReader<>(file, datumReader)) {
        GenericRecord result = dataFileReader.next();
        assertEquals("UNKNOWN", result.get("color").toString());
    }
}

代码分析:

  1. 演进 schema(evolvedSchemaJson)添加新符号 "YELLOW"
  2. 创建包含 "YELLOW" 枚举值的记录并写入文件
  3. 创建具有相同默认值的原始 schema(originalSchemaJson
  4. 使用原始 schema 读取数据时,验证默认值 "UNKNOWN" 被使用而非 "YELLOW"

正确进行枚举 schema 演进时,需在枚举类型级别(而非字段级别)指定默认值。示例中使用 JSON 字符串定义 schema 以便直接控制结构。

6. 总结

本文探讨了使用 Apache Avro 正确序列化枚举值的方法,包括基本枚举序列化、联合类型中的枚举处理以及 schema 演进挑战。

在 Avro 中处理枚举时需牢记关键点:

  • ✅ 使用正确的 namespace 和 symbols 定义枚举 schema
  • ✅ 使用 GenericData.EnumSymbol 并传入适当的枚举 schema 引用
  • ✅ 对于联合类型,使用枚举 schema(而非联合 schema)创建枚举符号
  • ✅ schema 演进时,在枚举类型级别设置默认值以确保兼容性

完整代码可在 GitHub 获取。