从特定Java类生成Avro Schema

2025/04/21

1. 简介

在本教程中,我们将讨论从现有Java类生成Avro模式的不同选项,虽然这不是标准的工作流程,但这种转换方式也可能发生,并且最好以最简单的方式了解现有库的使用方法。

2. 什么是Avro?

在深入研究将现有类转换回模式的细微差别之前,让我们先回顾一下什么是Avro。

根据文档,它是一个数据序列化系统,能够按照预定义的模式对数据进行序列化和反序列化,这也是该系统的核心,模式本身以JSON格式表示。更多关于Avro的信息,请参阅已发布的指南

3. 从现有Java类生成Avro Schema的动机

使用Avro的标准工作流程包括定义模式,然后用所选语言生成类,虽然这是最常用的方式,但也可以倒推,从项目中现有的类生成Avro Schema

假设我们正在使用一个遗留系统,并希望通过消息代理发送数据,我们决定使用Avro作为序列化/反序列化解决方案。在仔细研究代码后,我们发现通过发送现有类表示的数据,可以快速满足新规则的要求。

手动将Java代码转换为Avro JSON模式会非常繁琐,相反,我们可以使用现有的库来帮我们完成这项工作,从而节省时间。

4. 使用Avro反射API生成Avro Schema

第一个允许我们将现有Java类快速转换为Avro模式的选项是使用Avro Reflection API,要使用此API,我们需要确保我们的项目依赖Avro库

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

4.1 简单记录

假设我们想使用反射API来获取一个简单的Java记录:

record SimpleBankAccount(String bankAccountNumber) {
}

我们可以使用ReflectData的单例实例为任何给定的Java类生成一个org.apache.avro.Schema对象,然后,我们可以调用Schema实例的toString()方法,将Avro模式转换为JSON字符串。

为了验证生成的字符串是否符合我们的期望,我们可以使用JsonUnit

@Test
void whenConvertingSimpleRecord_thenAvroSchemaIsCorrect() {
    Schema schema = ReflectData.get()
            .getSchema(SimpleBankAccount.class);
    String jsonSchema = schema.toString();

    assertThatJson(jsonSchema).isEqualTo("""
            {
                "type" : "record",
                "name" : "SimpleBankAccount",
                "namespace" : "cn.tuyucheng.taketoday.apache.avro.model",
                "fields" : [ {
                    "name" : "bankAccountNumber",
                    "type" : "string"
                } ]
            }
            """);
}

尽管我们为了简单起见使用了Java记录,但这对于普通的Java对象也同样有效

4.2 可空字段

让我们在Java记录中添加另一个String字段,我们可以使用@org.apache.avro.reflect.Nullable注解将其标记为可选:

record BankAccountWithNullableField(
        String bankAccountNumber,
        @Nullable String reference
) {
}

如果我们重复测试,我们可以预期reference的可空性将会得到体现:

@Test
void whenConvertingRecordWithNullableField_thenAvroSchemaIsCorrect() {
    Schema schema = ReflectData.get()
            .getSchema(BankAccountWithNullableField.class);
    String jsonSchema = schema.toString(true);

    assertThatJson(jsonSchema).isEqualTo("""
            {
                "type" : "record",
                "name" : "BankAccountWithNullableField",
                "namespace" : "cn.tuyucheng.taketoday.apache.avro.model",
                "fields" : [ {
                    "name" : "bankAccountNumber",
                    "type" : "string"
                }, {
                    "name" : "reference",
                    "type" : [ "null", "string" ],
                    "default" : null
                } ]
            }
            """);
}

我们可以看到,在新字段上应用@Nullable注解使得生成的模式联合中的reference字段为null

4.3 忽略字段

Avro库还允许我们在生成schema时忽略某些字段,例如,我们不想通过网络传输敏感信息。为此,只需在特定字段上使用@AvroIgnore注解即可

record BankAccountWithIgnoredField(
        String bankAccountNumber,
        @AvroIgnore String reference
) {
}

因此,生成的模式将与我们第一个示例中的模式相匹配。

4.4 覆盖字段名称

默认情况下,生成的模式中的字段名称直接来自Java字段名,虽然这是默认行为,但可以进行调整:

record BankAccountWithOverriddenField(
        String bankAccountNumber,
        @AvroName("bankAccountReference") String reference
) {
}

从我们的记录的此版本生成的模式使用bankAccountReference而不是reference

{
    "type" : "record",
    "name" : "BankAccountWithOverriddenField",
    "namespace" : "cn.tuyucheng.taketoday.apache.avro.model",
    "fields" : [ {
        "name" : "bankAccountNumber",
        "type" : "string"
    }, {
        "name" : "bankAccountReference",
        "type" : "string"
    } ]
}

4.5 具有多种实现的字段

有时,我们的类可能包含一个类型为子类型的字段。

我们假设AccountReference是一个具有两个实现的接口-为了简洁起见,我们可以坚持使用Java记录:

interface AccountReference {
    String reference();
}

record PersonalBankAccountReference(
        String reference,
        String holderName
) implements AccountReference {
}

record BusinessBankAccountReference(
        String reference,
        String businessEntityId
) implements AccountReference {
}

在我们的BankAccountWithAbstractField中,我们使用@org.apache.avro.reflect.Union注解指示AccountReference字段支持的实现:

record BankAccountWithAbstractField(
        String bankAccountNumber,
        @Union({ PersonalBankAccountReference.class, BusinessBankAccountReference.class })
        AccountReference reference
) {
}

因此,生成的Avro模式将包含一个联合,允许分配这两个类中的任意一个,而不是限制我们只分配一个

{
    "type" : "record",
    "name" : "BankAccountWithAbstractField",
    "namespace" : "cn.tuyucheng.taketoday.apache.avro.model",
    "fields" : [ {
        "name" : "bankAccountNumber",
        "type" : "string"
    }, {
        "name" : "reference",
        "type" : [ {
            "type" : "record",
            "name" : "PersonalBankAccountReference",
            "namespace" : "cn.tuyucheng.taketoday.apache.avro.model.BankAccountWithAbstractField",
            "fields" : [ {
                "name" : "holderName",
                "type" : "string"
            }, {
                "name" : "reference",
                "type" : "string"
            } ]
        }, {
            "type" : "record",
            "name" : "BusinessBankAccountReference",
            "namespace" : "cn.tuyucheng.taketoday.apache.avro.model.BankAccountWithAbstractField",
            "fields" : [ {
                "name" : "businessEntityId",
                "type" : "string"
            }, {
                "name" : "reference",
                "type" : "string"
            } ]
        } ]
    } ]
}

4.6 逻辑类型

Avro支持逻辑类型,这些是模式级别的原始类型,但包含额外的提示,用于告诉代码生成器应该使用哪个类来表示特定字段

例如,如果我们的模型使用时间字段或UUID,我们可以利用逻辑类型功能:

record BankAccountWithLogicalTypes(
        String bankAccountNumber,
        UUID reference,
        LocalDateTime expiryDate
) {
}

此外,我们将配置ReflectData实例,添加所需的Conversion对象。我们可以创建自己的Conversion对象,也可以使用系统自带的Conversion对象:

@Test
void whenConvertingRecordWithLogicalTypes_thenAvroSchemaIsCorrect() {
    ReflectData reflectData = ReflectData.get();
    reflectData.addLogicalTypeConversion(new Conversions.UUIDConversion());
    reflectData.addLogicalTypeConversion(new TimeConversions.LocalTimestampMillisConversion());

    String jsonSchema = reflectData.getSchema(BankAccountWithLogicalTypes.class).toString();
  
    // verify schema
}

因此,当我们生成并验证模式时,我们会注意到新字段将包含一个logicalType字段:

{
    "type" : "record",
    "name" : "BankAccountWithLogicalTypes",
    "namespace" : "cn.tuyucheng.taketoday.apache.avro.model",
    "fields" : [ {
        "name" : "bankAccountNumber",
        "type" : "string"
    }, {
        "name" : "expiryDate",
        "type" : {
            "type" : "long",
            "logicalType" : "local-timestamp-millis"
        }
    }, {
        "name" : "reference",
        "type" : {
            "type" : "string",
            "logicalType" : "uuid"
        }
    } ]
}

5. 使用Jackson生成Avro Schema

尽管Avro反射API很有用,并且应该能够满足不同的甚至复杂的需求,但了解替代方案总是值得的。

在我们的例子中,我们刚刚试验过的库的替代方案是Jackson Dataformats Binary库,特别是其与Avro相关的子模块

首先,让我们将jackson-corejackson-dataformat-avro依赖添加到pom.xml中:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.17.2</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-avro</artifactId>
    <version>2.17.2</version>
</dependency>

5.1 简单转换

让我们通过编写一个简单的转换器来探索Jackson的功能,此实现的优势在于它使用了众所周知的Java API。事实上,Jackson是最广泛使用的库之一,而直接使用的Avro API则相对较少。

我们将创建AvroMapper和AvroSchemaGenerator实例并使用它们来检索org.apache.avro.Schema实例。

从那里,我们只需调用toString()方法,就像前面的例子一样:

@Test
void whenConvertingRecord_thenAvroSchemaIsCorrect() throws JsonMappingException {
    AvroMapper avroMapper = new AvroMapper();
    AvroSchemaGenerator avroSchemaGenerator = new AvroSchemaGenerator();

    avroMapper.acceptJsonFormatVisitor(SimpleBankAccount.class, avroSchemaGenerator);
    Schema schema = avroSchemaGenerator.getGeneratedSchema().getAvroSchema();
    String jsonSchema = schema.toString();

    assertThatJson(jsonSchema).isEqualTo("""
            {
                "type" : "record",
                "name" : "SimpleBankAccount",
                "namespace" : "cn.tuyucheng.taketoday.apache.avro.model",
                "fields" : [ {
                    "name" : "bankAccountNumber",
                    "type" : [ "null", "string" ]
                } ]
            }
            """);
}

5.2 Jackson注解

如果我们比较为SimpleBankAccount生成的两个模式,我们会注意到一个关键的区别:使用Jackson生成的模式将bankAccountNumber字段标记为可空,这是因为Jackson的工作方式与Avro反射不同。

Jackson不太依赖反射,为了能够识别要迁移到模式的字段,它要求类具有访问器。此外,需要注意的是,默认行为假定该字段可空。如果我们不希望该字段在模式中可空,则需要使用@JsonProperty(required = true)注解

让我们创建该类的不同变体并利用此注解:

record JacksonBankAccountWithRequiredField(
        @JsonProperty(required = true) String bankAccountNumber
) {
}

由于应用于原始Java类的所有Jackson注解仍然有效,因此我们需要仔细检查转换的结果。

5.3 逻辑类型感知转换器

Jackson与Avro反射类似,默认情况下不考虑逻辑类型,因此,我们需要显式启用此功能。让我们通过对AvroMapper和AvroSchemaGenerator对象进行一些小的调整来实现这一点:

@Test
void whenConvertingRecordWithRequiredField_thenAvroSchemaIsCorrect() throws JsonMappingException {
    AvroMapper avroMapper = AvroMapper.builder()
            .addModule(new AvroJavaTimeModule())
            .build();

    AvroSchemaGenerator avroSchemaGenerator = new AvroSchemaGenerator()
            .enableLogicalTypes();

    avroMapper.acceptJsonFormatVisitor(BankAccountWithLogicalTypes.class, avroSchemaGenerator);
    Schema schema = avroSchemaGenerator.getGeneratedSchema()
            .getAvroSchema();
    String jsonSchema = schema.toString();

    // verify schema
}

通过这些修改,我们将能够观察在为Temporal对象生成的Avro模式中使用的逻辑类型特征。

6. 总结

在本文中,我们展示了几种从现有Java类生成Avro模式的方法,你可以使用标准的Avro反射API,也可以使用Jackson及其二进制Avro模块。

尽管Avro的方式及其API不太为大众所知,但它似乎是一种比使用Jackson更可预测的解决方案,如果将其纳入我们正在进行的主要项目中,很容易导致错误

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章