如何在 Avro 中序列化和反序列化日期
1. 簡介
在本教程中,我們將探索使用 Apache Avro 在 Java 中序列化和反序列化Date
物件的不同方法。該框架是一個資料序列化系統,提供緊湊、快速的二進位資料格式以及基於模式的資料定義。
在 Avro 中處理日期時,我們面臨挑戰,因為 Avro 在其類型結構中本身不支援 Java Date
類別。現在,讓我們更詳細地了解Date
序列化的挑戰。
2. 日期序列化的挑戰
在開始之前,讓我們將 Avro依賴項新增至我們的 Maven 專案:
<dependency>
<groupId>org.apache.avro</groupId>
<artifactId>avro</artifactId>
<version>1.12.0</version>
</dependency>
Avro 的型別系統由原始型別組成: null, boolean, int, long, float, double, bytes, and string.
此外,支援的複雜類型有: record, enum, array, map, union,
fixed.
現在,讓我們看一個例子來了解為什麼日期序列化在 Avro 中會出現問題:
public class DateContainer {
private Date date;
// Constructors, getters, and setters
}
當我們嘗試使用 Avro 的基於反射的序列化直接序列化此類時,預設行為會在內部將Date
物件轉換為long
值(自紀元以來的毫秒數)。
不幸的是,這個過程可能會導致精度問題。例如,反序列化的值可能與原始值相差幾毫秒。
3. 實現日期序列化
接下來,我們將使用兩種方法來實現Date
序列化和反序列化:使用帶有GenericRecord
邏輯類型和使用 Avro 的轉換 API。
3.1.使用邏輯類型和GenericRecord
自 Avro 1.8 起,該框架提供邏輯類型。這些為底層原始類型添加了必要且適當的含義。
因此,對於日期,我們有三種邏輯類型:
-
date:
表示沒有時間的日期。它以int
形式儲存(自紀元以來的天數)。 -
timestamp-millis:
表示具有毫秒精度的時間戳,儲存為long
整型 -
timestamp-micros:
表示具有微秒精度的時間戳,儲存為long
整型
現在,讓我們看看如何在 Avro 模式中使用這些邏輯類型:
public static Schema createDateSchema() {
String schemaJson =
"{"
+ "\"type\": \"record\","
+ "\"name\": \"DateRecord\","
+ "\"fields\": ["
+ " {\"name\": \"date\", \"type\": {\"type\": \"int\", \"logicalType\": \"date\"}},"
+ " {\"name\": \"timestamp\", \"type\": {\"type\": \"long\", \"logicalType\": \"timestamp-millis\"}}"
+ "]"
+ "}";
return new Schema.Parser().parse(schemaJson);
}
值得注意的是,我們將邏輯類型應用於底層原始類型,而不是直接應用於欄位。
現在,讓我們看看如何使用邏輯類型實現Date
序列化:
public static byte[] serializeDateWithLogicalType(LocalDate date, Instant timestamp) {
Schema schema = createDateSchema();
GenericRecord record = new GenericData.Record(schema);
record.put("date", (int) date.toEpochDay());
record.put("timestamp", timestamp.toEpochMilli());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(schema);
Encoder encoder = EncoderFactory.get().binaryEncoder(baos, null);
datumWriter.write(record, encoder);
encoder.flush();
return baos.toByteArray();
}
讓我們回顧一下上述邏輯。我們將LocalDate
轉換為自紀元以來的天數,將timestamp
為自紀元以來的毫秒數。這樣,我們就可以使用邏輯類型了。
現在,讓我們實作處理反序列化的方法:
public static Pair<LocalDate, Instant> deserializeDateWithLogicalType(byte[] bytes) {
Schema schema = createDateSchema();
DatumReader<GenericRecord> datumReader = new GenericDatumReader<>(schema);
Decoder decoder = DecoderFactory.get().binaryDecoder(bytes, null);
GenericRecord record = datumReader.read(null, decoder);
LocalDate date = LocalDate.ofEpochDay((int) record.get("date"));
Instant timestamp = Instant.ofEpochMilli((long) record.get("timestamp"));
return Pair.of(date, timestamp);
}
最後,讓我們測試一下我們的實作:
@Test
void whenSerializingDateWithLogicalType_thenDeserializesCorrectly() {
LocalDate expectedDate = LocalDate.now();
Instant expectedTimestamp = Instant.now();
byte[] serialized = serializeDateWithLogicalType(expectedDate, expectedTimestamp);
Pair<LocalDate, Instant> deserialized = deserializeDateWithLogicalType(serialized);
assertEquals(expectedDate, deserialized.getLeft());
assertEquals(expectedTimestamp.toEpochMilli(), deserialized.getRight().toEpochMilli(),
"Timestamps should match exactly at millisecond precision");
}
從測試中我們可以看到, timestamp-millis
邏輯類型保持精度,並且時間戳與預期相符。此外,使用邏輯類型使我們的資料格式在模式定義中明確,這對於模式開發和文件很有價值。
3.2.使用 Avro 的轉換 API
Avro 提供了可以自動處理邏輯類型的轉換 API。此 API 不是一種單獨的方法。事實上,它建立在邏輯類型之上,有助於加快轉換過程。
這樣,我們就無需在 Java 類型和 Avro 的內部表示之間手動轉換。此外,它還為轉換過程增加了類型安全性。
現在,讓我們實作自動處理邏輯類型的解決方案:
public static byte[] serializeWithConversionApi(LocalDate date, Instant timestamp) {
Schema schema = createDateSchema();
GenericRecord record = new GenericData.Record(schema);
Conversion<LocalDate> dateConversion = new org.apache.avro.data.TimeConversions.DateConversion();
LogicalTypes.date().addToSchema(schema.getField("date").schema());
Conversion<Instant> timestampConversion =
new org.apache.avro.data.TimeConversions.TimestampMillisConversion();
LogicalTypes.timestampMillis().addToSchema(schema.getField("timestamp").schema());
record.put("date", dateConversion.toInt(date,
schema.getField("date").schema(),
LogicalTypes.date()));
record.put("timestamp",
timestampConversion.toLong(timestamp, schema.getField("timestamp").schema(),
LogicalTypes.timestampMillis()));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DatumWriter<GenericRecord> datumWriter = new GenericDatumWriter<>(schema);
Encoder encoder = EncoderFactory.get().binaryEncoder(baos, null);
datumWriter.write(record, encoder);
encoder.flush();
return baos.toByteArray();
}
與之前的方法不同,這次我們使用LogicalTypes.date()
和LogicalTypes.timestampMillis()
來轉換。
接下來,讓我們實作處理反序列化的方法:
public static Pair<LocalDate, Instant> deserializeWithConversionApi(byte[] bytes) {
Schema schema = createDateSchema();
DatumReader<GenericRecord> datumReader = new GenericDatumReader<>(schema);
Decoder decoder = DecoderFactory.get().binaryDecoder(bytes, null);
GenericRecord record = datumReader.read(null, decoder);
Conversion<LocalDate> dateConversion = new DateConversion();
LogicalTypes.date().addToSchema(schema.getField("date").schema());
Conversion<Instant> timestampConversion = new TimestampMillisConversion();
LogicalTypes.timestampMillis().addToSchema(schema.getField("timestamp").schema());
int daysSinceEpoch = (int) record.get("date");
long millisSinceEpoch = (long) record.get("timestamp");
LocalDate date = dateConversion.fromInt(
daysSinceEpoch,
schema.getField("date").schema(),
LogicalTypes.date()
);
Instant timestamp = timestampConversion.fromLong(
millisSinceEpoch,
schema.getField("timestamp").schema(),
LogicalTypes.timestampMillis()
);
return Pair.of(date, timestamp);
}
最後我們來驗證一下實作:
@Test
void whenSerializingWithConversionApi_thenDeserializesCorrectly() {
LocalDate expectedDate = LocalDate.now();
Instant expectedTimestamp = Instant.now();
byte[] serialized = serializeWithConversionApi(expectedDate, expectedTimestamp);
Pair<LocalDate, Instant> deserialized = deserializeWithConversionApi(serialized);
assertEquals(expectedDate, deserialized.getLeft());
assertEquals(expectedTimestamp.toEpochMilli(), deserialized.getRight().toEpochMilli(),
"Timestamps should match at millisecond precision");
}
4.處理使用Date
的遺留程式碼
目前,許多現有的 Java 應用程式仍然使用遺留的java.util.Date
類別。對於這樣的程式碼庫,我們需要一個策略來在使用 Avro 序列化時處理這些物件。
一個好方法是在序列化資訊之前將舊日期轉換為現代 Java 時間 API:
public static byte[] serializeLegacyDateAsModern(Date legacyDate) {
Instant instant = legacyDate.toInstant();
LocalDate localDate = instant.atZone(ZoneId.systemDefault()).toLocalDate();
return serializeDateWithLogicalType(localDate, instant);
}
然後,我們可以使用前面的方法之一來序列化日期。這種方法使我們能夠利用 Avro 的邏輯類型,同時仍然可以使用傳統的Date
物件。
讓我們測試一下我們的實作:
@Test
void whenSerializingLegacyDate_thenConvertsCorrectly() {
Date legacyDate = new Date();
LocalDate expectedLocalDate = legacyDate.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate();
byte[] serialized = serializeLegacyDateAsModern(legacyDate);
LocalDate deserialized = deserializeDateWithLogicalType(serialized).getKey();
assertEquals(expectedLocalDate, deserialized);
}
5. 結論
在本文中,我們探討了使用 Avro 序列化Date
物件的不同方法。我們已經學習如何使用 Avro 的邏輯類型來正確表示日期和時間戳記值。
對於大多數現代應用程式來說,使用 Avro 的轉換 API 透過java.time
類別處理其邏輯類型是最佳方法。透過這種組合,我們獲得了類型安全性,保持了適當的語義,並與 Avro 的模式擴展功能相容。
與往常一樣,程式碼可在 GitHub 上取得。