diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonRequestSerializer.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonRequestSerializer.java index da61fbfe6..83d6499df 100644 --- a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonRequestSerializer.java +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonRequestSerializer.java @@ -16,6 +16,7 @@ import io.arex.foundation.serializer.gson.adapter.OffsetDateTimeAdapter; import io.arex.foundation.serializer.gson.adapter.TimeZoneAdapter; import io.arex.foundation.serializer.gson.adapter.XMLGregorianCalendarAdapter; +import io.arex.foundation.serializer.gson.adapter.ZonedDateTimeAdapter; import io.arex.inst.runtime.serializer.StringSerializable; import javax.xml.datatype.XMLGregorianCalendar; @@ -27,6 +28,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -54,6 +56,7 @@ public GsonRequestSerializer() { .registerTypeAdapter(LocalTime.class, new LocalTimeAdapter.RequestSerializer()) .registerTypeAdapter(Class.class, new ClassAdapter.Serializer()) .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeAdapter.RequestSerializer()) + .registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter.RequestSerializer()) .registerTypeHierarchyAdapter(TimeZone.class, new TimeZoneAdapter.Serializer()) .registerTypeAdapter(String.class, new StringAdapter.Serializer()) .registerTypeAdapterFactory(new CustomTypeAdapterFactory.RequestSerializerFactory()) diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonSerializer.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonSerializer.java index de0fb0be6..e4b7d6d8d 100644 --- a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonSerializer.java +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/GsonSerializer.java @@ -13,6 +13,7 @@ import java.sql.Time; import java.time.Instant; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import com.google.gson.*; import javax.xml.datatype.XMLGregorianCalendar; @@ -72,6 +73,8 @@ public GsonSerializer() { .registerTypeAdapter(Class.class, new ClassAdapter.Deserializer()) .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeAdapter.Serializer()) .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeAdapter.Deserializer()) + .registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter.Serializer()) + .registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter.Deserializer()) .registerTypeHierarchyAdapter(TimeZone.class, new TimeZoneAdapter.Serializer()) .registerTypeAdapter(TimeZone.class, new TimeZoneAdapter.Deserializer()) .registerTypeAdapterFactory(new FastUtilAdapterFactory()) diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/adapter/ZonedDateTimeAdapter.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/adapter/ZonedDateTimeAdapter.java new file mode 100644 index 000000000..9a6540163 --- /dev/null +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/gson/adapter/ZonedDateTimeAdapter.java @@ -0,0 +1,43 @@ +package io.arex.foundation.serializer.gson.adapter; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import io.arex.agent.thirdparty.util.time.DateFormatUtils; +import io.arex.foundation.serializer.util.DateFormatParser; +import io.arex.foundation.serializer.util.DenoisingUtil; +import io.arex.foundation.serializer.util.TimePatternConstants; + +import java.lang.reflect.Type; +import java.time.ZonedDateTime; + +public class ZonedDateTimeAdapter { + private ZonedDateTimeAdapter() { + } + + public static class RequestSerializer implements JsonSerializer { + @Override + public JsonElement serialize(ZonedDateTime src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(DenoisingUtil.zeroSecondTime(src)); + } + } + + public static class Serializer implements JsonSerializer { + @Override + public JsonElement serialize(ZonedDateTime src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(DateFormatUtils.format(src, TimePatternConstants.zonedDateTimeFormat)); + } + } + + public static class Deserializer implements JsonDeserializer { + @Override + public ZonedDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + return ZonedDateTime.parse(json.getAsString(), + DateFormatParser.INSTANCE.getFormatter(TimePatternConstants.zonedDateTimeFormat)); + } + } +} diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonRequestSerializer.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonRequestSerializer.java index 39a2e4099..fb4e20e7e 100644 --- a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonRequestSerializer.java +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonRequestSerializer.java @@ -16,6 +16,7 @@ import io.arex.foundation.serializer.jackson.adapter.LocalTimeAdapter; import io.arex.foundation.serializer.jackson.adapter.OffsetDateTimeAdapter; import io.arex.foundation.serializer.jackson.adapter.XMLGregorianCalendarAdapter; +import io.arex.foundation.serializer.jackson.adapter.ZonedDateTimeAdapter; import io.arex.inst.runtime.serializer.StringSerializable; import javax.xml.datatype.XMLGregorianCalendar; @@ -26,6 +27,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -57,6 +59,7 @@ private void customTimeFormatSerializer(SimpleModule module) { module.addSerializer(Date.class, dateRequestSerializer); module.addSerializer(Instant.class, new InstantAdapter.RequestSerializer()); module.addSerializer(OffsetDateTime.class, new OffsetDateTimeAdapter.RequestSerializer()); + module.addSerializer(ZonedDateTime.class, new ZonedDateTimeAdapter.RequestSerializer()); module.addSerializer(String.class, new StringAdapter.Serializer()); } diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializer.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializer.java index 06f0cc810..301dc92c8 100644 --- a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializer.java +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializer.java @@ -29,6 +29,7 @@ import io.arex.foundation.serializer.jackson.adapter.SqlTimeAdapter; import io.arex.foundation.serializer.jackson.adapter.TimestampAdapter; import io.arex.foundation.serializer.jackson.adapter.XMLGregorianCalendarAdapter; +import io.arex.foundation.serializer.jackson.adapter.ZonedDateTimeAdapter; import io.arex.inst.runtime.log.LogManager; import io.arex.inst.runtime.serializer.StringSerializable; @@ -41,6 +42,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -151,6 +153,7 @@ private void customTimeFormatSerializer(SimpleModule module) { module.addSerializer(Date.class, dateSerializer); module.addSerializer(Instant.class, new InstantAdapter.Serializer()); module.addSerializer(OffsetDateTime.class, new OffsetDateTimeAdapter.Serializer()); + module.addSerializer(ZonedDateTime.class, new ZonedDateTimeAdapter.Serializer()); } private void customTimeFormatDeserializer(SimpleModule module) { @@ -164,6 +167,7 @@ private void customTimeFormatDeserializer(SimpleModule module) { module.addDeserializer(Date.class, new DateAdapter.Deserializer()); module.addDeserializer(Instant.class, new InstantAdapter.Deserializer()); module.addDeserializer(OffsetDateTime.class, new OffsetDateTimeAdapter.Deserializer()); + module.addDeserializer(ZonedDateTime.class, new ZonedDateTimeAdapter.Deserializer()); module.addDeserializer(java.sql.Date.class, new SqlDateAdapter.Deserializer()); module.addDeserializer(Time.class, new SqlTimeAdapter.Deserializer()); } diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithType.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithType.java index a5eb609ef..322ba1c8f 100644 --- a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithType.java +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithType.java @@ -23,6 +23,7 @@ import io.arex.foundation.serializer.jackson.adapter.SqlTimeAdapter; import io.arex.foundation.serializer.jackson.adapter.TimestampAdapter; import io.arex.foundation.serializer.jackson.adapter.XMLGregorianCalendarAdapter; +import io.arex.foundation.serializer.jackson.adapter.ZonedDateTimeAdapter; import io.arex.inst.runtime.log.LogManager; import io.arex.inst.runtime.serializer.StringSerializable; @@ -35,6 +36,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -89,6 +91,7 @@ private void customTimeFormatSerializer(SimpleModule module) { module.addSerializer(Date.class, dateSerializer); module.addSerializer(Instant.class, new InstantAdapter.Serializer()); module.addSerializer(OffsetDateTime.class, new OffsetDateTimeAdapter.Serializer()); + module.addSerializer(ZonedDateTime.class, new ZonedDateTimeAdapter.Serializer()); } private void customTimeFormatDeserializer(SimpleModule module) { @@ -102,6 +105,7 @@ private void customTimeFormatDeserializer(SimpleModule module) { module.addDeserializer(Date.class, new DateAdapter.Deserializer()); module.addDeserializer(Instant.class, new InstantAdapter.Deserializer()); module.addDeserializer(OffsetDateTime.class, new OffsetDateTimeAdapter.Deserializer()); + module.addDeserializer(ZonedDateTime.class, new ZonedDateTimeAdapter.Deserializer()); module.addDeserializer(java.sql.Date.class, new SqlDateAdapter.Deserializer()); module.addDeserializer(Time.class, new SqlTimeAdapter.Deserializer()); } diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/adapter/ZonedDateTimeAdapter.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/adapter/ZonedDateTimeAdapter.java new file mode 100644 index 000000000..79c557644 --- /dev/null +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/jackson/adapter/ZonedDateTimeAdapter.java @@ -0,0 +1,54 @@ +package io.arex.foundation.serializer.jackson.adapter; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.type.WritableTypeId; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import io.arex.foundation.serializer.util.DateFormatParser; +import io.arex.foundation.serializer.util.DenoisingUtil; +import io.arex.foundation.serializer.util.TimePatternConstants; + +import java.io.IOException; +import java.time.ZonedDateTime; + +public class ZonedDateTimeAdapter { + private ZonedDateTimeAdapter() { + } + + public static class RequestSerializer extends JsonSerializer { + @Override + public void serialize(ZonedDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(DenoisingUtil.zeroSecondTime(value)); + } + } + + public static class Serializer extends JsonSerializer { + @Override + public void serialize(ZonedDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeString(value.format(DateFormatParser.INSTANCE.getFormatter(TimePatternConstants.zonedDateTimeFormat))); + } + + @Override + public void serializeWithType(ZonedDateTime value, JsonGenerator gen, SerializerProvider serializers, + TypeSerializer typeSer) throws IOException { + WritableTypeId writableTypeId = typeSer.writeTypePrefix(gen, typeSer.typeId(value, JsonToken.VALUE_STRING)); + serialize(value, gen, serializers); + typeSer.writeTypeSuffix(gen, writableTypeId); + } + } + + public static class Deserializer extends JsonDeserializer { + @Override + public ZonedDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + return ZonedDateTime.parse(node.asText(), + DateFormatParser.INSTANCE.getFormatter(TimePatternConstants.zonedDateTimeFormat)); + } + } +} diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/DenoisingUtil.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/DenoisingUtil.java index d99df7779..ac55df430 100644 --- a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/DenoisingUtil.java +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/DenoisingUtil.java @@ -9,6 +9,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Calendar; import java.util.Date; @@ -100,6 +101,11 @@ public static String zeroSecondTime(OffsetDateTime offsetDateTime) { return time + TimePatternConstants.ZERO_SECOND_TIME_REQUEST + timeZone; } + public static String zeroSecondTime(ZonedDateTime zonedDateTime) { + // the zone id is placed at the end of the pattern, format the zeroed value instead of concat + return DateFormatUtils.format(zonedDateTime.withSecond(0).withNano(0), TimePatternConstants.zonedDateTimeFormat); + } + public static String zeroSecondTime(Instant instant) { return DateTimeFormatter.ofPattern(TimePatternConstants.SIMPLE_DATE_FORMAT_MILLIS_REQUEST). withZone(ZoneId.systemDefault()).format(instant) + diff --git a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/TimePatternConstants.java b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/TimePatternConstants.java index 0ce8d7e02..ea34caa50 100644 --- a/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/TimePatternConstants.java +++ b/arex-instrumentation-foundation/src/main/java/io/arex/foundation/serializer/util/TimePatternConstants.java @@ -25,6 +25,18 @@ private TimePatternConstants() { */ public static final String SIMPLE_DATE_FORMAT_WITH_TIMEZONE = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZ"; public static final String SIMPLE_DATE_FORMAT_WITH_TIMEZONE_DATETIME = "yyyy-MM-dd'T'HH:mm:ss.SSSZZ"; + /** + * 2020-06-09T09:00:00.000+08:00[Asia/Shanghai], keep the zone id to avoid losing it after deserialize + * + *

The offset uses ZZZZZ and the year uses uuuu, unlike the patterns above: + * ZZZ drops the seconds of the offset, and on deserialize the offset overrides the zone when the + * instant is computed, so any zone that still had a sub-minute offset comes back shifted + * (Africa/Monrovia was -00:44:30 until 1972, Instant.EPOCH drifts 30s there) and the shift + * accumulates over record-replay-record. yyyy is year-of-era, it turns a year before 1 AD into a + * positive one and can not write LocalDateTime.MIN back. + */ + public static final String SIMPLE_DATE_FORMAT_MILLIS_WITH_ZONE_ID = "uuuu-MM-dd'T'HH:mm:ss.SSSZZZZZ'['VV']'"; + public static final String SIMPLE_DATE_FORMAT_NANOSECOND_WITH_ZONE_ID = "uuuu-MM-dd'T'HH:mm:ss.SSSSSSSSSZZZZZ'['VV']'"; /** * yyyy-MM-dd */ @@ -40,11 +52,14 @@ private TimePatternConstants() { public static String localTimeFormat = SHORT_TIME_FORMAT_MILLISECOND; + public static String zonedDateTimeFormat = SIMPLE_DATE_FORMAT_MILLIS_WITH_ZONE_ID; + static { if (JdkUtils.isJdk11OrHigher()) { localDateTimeFormat = SIMPLE_DATE_FORMAT_NANOSECOND; localTimeFormat = SHORT_TIME_FORMAT_NANOSECOND; localTimeZeroSecondTimeRequest = "00.000000000"; + zonedDateTimeFormat = SIMPLE_DATE_FORMAT_NANOSECOND_WITH_ZONE_ID; } } } diff --git a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonRequestSerializerTest.java b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonRequestSerializerTest.java index 5e0721530..8b546e75b 100644 --- a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonRequestSerializerTest.java +++ b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonRequestSerializerTest.java @@ -37,6 +37,7 @@ void serialize() throws Throwable { assertEquals(testInfo.getLocalDate(), zeroSecondTimeTestInfo.getLocalDate()); assertEquals(testInfo.getLocalTime(), zeroSecondTimeTestInfo.getLocalTime()); assertEquals(testInfo.getOffsetDateTime(), zeroSecondTimeTestInfo.getOffsetDateTime()); + assertEquals(testInfo.getZonedDateTime(), zeroSecondTimeTestInfo.getZonedDateTime()); assertEquals(testInfo.getTimeZone1(), zeroSecondTimeTestInfo.getTimeZone1()); } diff --git a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonSerializerTest.java b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonSerializerTest.java index d87f47f77..50439272a 100644 --- a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonSerializerTest.java +++ b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/GsonSerializerTest.java @@ -7,17 +7,26 @@ import com.google.gson.internal.LinkedTreeMap; import io.arex.agent.bootstrap.internal.Pair; import io.arex.foundation.serializer.gson.GsonSerializer; +import io.arex.foundation.serializer.jackson.JacksonSerializer; import io.arex.inst.runtime.util.TypeUtil; import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.ints.IntSet; import java.lang.reflect.Type; import java.sql.Time; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class GsonSerializerTest { @@ -43,6 +52,50 @@ public void testSqlTime() throws InterruptedException { assertEquals(expectedJson, actualJson); } + /** + * https://github.com/arextest/arex-agent-java/issues/588 + */ + @ParameterizedTest + @MethodSource("zonedDateTimeCase") + public void testZonedDateTime(ZonedDateTime expected) { + String json = GsonSerializer.INSTANCE.serialize(expected); + ZonedDateTime actualResult = GsonSerializer.INSTANCE.deserialize(json, ZonedDateTime.class); + assertEquals(expected, actualResult); + // the zone id should not be replaced by the zone offset + assertEquals(expected.getZone(), actualResult.getZone()); + // the offset keeps the local time repeated in a dst overlap distinguishable + assertEquals(expected.getOffset(), actualResult.getOffset()); + assertEquals(json, GsonSerializer.INSTANCE.serialize(actualResult)); + } + + static Stream zonedDateTimeCase() { + // 2025-11-02T01:30 of America/New_York happens twice, once with -04:00 and once with -05:00 + ZonedDateTime dstOverlap = ZonedDateTime.of(2025, 11, 2, 1, 30, 0, 0, ZoneId.of("America/New_York")); + return Stream.of( + Arguments.arguments(ZonedDateTime.of(2020, 6, 9, 9, 0, 0, 123456789, ZoneId.of("Asia/Shanghai"))), + Arguments.arguments(ZonedDateTime.now(ZoneId.of("Asia/Shanghai"))), + Arguments.arguments(ZonedDateTime.now(ZoneOffset.UTC)), + Arguments.arguments(ZonedDateTime.now(ZoneId.of("GMT-01:00"))), + Arguments.arguments(dstOverlap.withEarlierOffsetAtOverlap()), + Arguments.arguments(dstOverlap.withLaterOffsetAtOverlap()), + // zones that still had an offset with seconds, dropping them shifts the instant + Arguments.arguments(ZonedDateTime.of(1900, 1, 1, 12, 0, 0, 0, ZoneId.of("Asia/Shanghai"))), + Arguments.arguments(Instant.EPOCH.atZone(ZoneId.of("Africa/Monrovia"))), + Arguments.arguments(ZonedDateTime.of(2026, 1, 1, 9, 0, 0, 0, ZoneOffset.ofTotalSeconds(8 * 3600 + 30))), + // year of era would turn this into the year 100 AD + Arguments.arguments(ZonedDateTime.of(-100, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC))); + } + + /** + * both serializers must emit the same bytes, a case can be recorded with one and replayed with + * the other + */ + @Test + public void testZonedDateTimeSameFormatAsJackson() throws Throwable { + ZonedDateTime expected = ZonedDateTime.of(2020, 6, 9, 9, 0, 0, 123456789, ZoneId.of("Asia/Shanghai")); + assertEquals(JacksonSerializer.INSTANCE.serialize(expected), GsonSerializer.INSTANCE.serialize(expected)); + } + @Test public void testTimeSerializeAndDeserialize() throws Exception { TimeTestInfo expectedTimeTest = new TimeTestInfo(LocalDateTime.now()); @@ -71,6 +124,7 @@ public void testTimeSerializeAndDeserialize() throws Exception { assert expectedTimeTest.getDate().equals(deserializedTimeTest.getDate()); assert expectedTimeTest.getInstant().equals(deserializedTimeTest.getInstant()); + assert expectedTimeTest.getZonedDateTime().equals(deserializedTimeTest.getZonedDateTime()); assert expectedTimeTest.getJodaLocalDate().equals(deserializedTimeTest.getJodaLocalDate()); assert expectedTimeTest.getJodaLocalTime().equals(deserializedTimeTest.getJodaLocalTime()); diff --git a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonRequestSerializerTest.java b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonRequestSerializerTest.java index 5b5b1f858..b12b29868 100644 --- a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonRequestSerializerTest.java +++ b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonRequestSerializerTest.java @@ -37,6 +37,7 @@ void serialize() throws Throwable { assertEquals(testInfo.getLocalDate(), zeroSecondTimeTestInfo.getLocalDate()); assertEquals(testInfo.getLocalTime(), zeroSecondTimeTestInfo.getLocalTime()); assertEquals(testInfo.getOffsetDateTime(), zeroSecondTimeTestInfo.getOffsetDateTime()); + assertEquals(testInfo.getZonedDateTime(), zeroSecondTimeTestInfo.getZonedDateTime()); assertEquals(testInfo.getTimeZone1(), zeroSecondTimeTestInfo.getTimeZone1()); } diff --git a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonSerializerTest.java b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonSerializerTest.java index 84ea8a4cb..76a0eec6f 100644 --- a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonSerializerTest.java +++ b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/JacksonSerializerTest.java @@ -4,17 +4,28 @@ import static org.junit.jupiter.api.Assertions.*; +import io.arex.agent.bootstrap.util.JdkUtils; import io.arex.foundation.serializer.jackson.JacksonSerializer; +import io.arex.foundation.serializer.util.DateFormatParser; +import io.arex.foundation.serializer.util.TimePatternConstants; import io.arex.inst.runtime.util.TypeUtil; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.sql.Time; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint; import org.springframework.aop.framework.ReflectiveMethodInvocation; import org.springframework.lang.Nullable; @@ -36,6 +47,61 @@ void testLocalTime() throws Throwable { assertEquals(now, actualResult); } + /** + * https://github.com/arextest/arex-agent-java/issues/588 + */ + @ParameterizedTest + @MethodSource("zonedDateTimeCase") + void testZonedDateTime(ZonedDateTime expected) throws Throwable { + String json = JacksonSerializer.INSTANCE.serialize(expected); + ZonedDateTime actualResult = JacksonSerializer.INSTANCE.deserialize(json, ZonedDateTime.class); + assertEquals(expected, actualResult); + // the zone id should not be replaced by the zone offset + assertEquals(expected.getZone(), actualResult.getZone()); + // the offset keeps the local time repeated in a dst overlap distinguishable + assertEquals(expected.getOffset(), actualResult.getOffset()); + assertEquals(json, JacksonSerializer.INSTANCE.serialize(actualResult)); + } + + static Stream zonedDateTimeCase() { + // 2025-11-02T01:30 of America/New_York happens twice, once with -04:00 and once with -05:00 + ZonedDateTime dstOverlap = ZonedDateTime.of(2025, 11, 2, 1, 30, 0, 0, ZoneId.of("America/New_York")); + return Stream.of( + Arguments.arguments(ZonedDateTime.of(2020, 6, 9, 9, 0, 0, 123456789, ZoneId.of("Asia/Shanghai"))), + Arguments.arguments(ZonedDateTime.now(ZoneId.of("Asia/Shanghai"))), + Arguments.arguments(ZonedDateTime.now(ZoneOffset.UTC)), + Arguments.arguments(ZonedDateTime.now(ZoneId.of("GMT-01:00"))), + Arguments.arguments(dstOverlap.withEarlierOffsetAtOverlap()), + Arguments.arguments(dstOverlap.withLaterOffsetAtOverlap()), + // zones that still had an offset with seconds, dropping them shifts the instant + Arguments.arguments(ZonedDateTime.of(1900, 1, 1, 12, 0, 0, 0, ZoneId.of("Asia/Shanghai"))), + Arguments.arguments(Instant.EPOCH.atZone(ZoneId.of("Africa/Monrovia"))), + Arguments.arguments(ZonedDateTime.of(2026, 1, 1, 9, 0, 0, 0, ZoneOffset.ofTotalSeconds(8 * 3600 + 30))), + // year of era would turn this into the year 100 AD + Arguments.arguments(ZonedDateTime.of(-100, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC))); + } + + /** + * the serialized form is the contract between record and replay, a silent change to it makes the + * mock data recorded by another agent unreadable, so pin the bytes instead of only round tripping + */ + @Test + void testZonedDateTimeSerializedFormat() throws Throwable { + ZonedDateTime expected = ZonedDateTime.of(2020, 6, 9, 9, 0, 0, 123456789, ZoneId.of("Asia/Shanghai")); + assertEquals("2020-06-09T09:00:00.123+08:00[Asia/Shanghai]", expected.format( + DateFormatParser.INSTANCE.getFormatter(TimePatternConstants.SIMPLE_DATE_FORMAT_MILLIS_WITH_ZONE_ID))); + assertEquals("2020-06-09T09:00:00.123456789+08:00[Asia/Shanghai]", expected.format( + DateFormatParser.INSTANCE.getFormatter(TimePatternConstants.SIMPLE_DATE_FORMAT_NANOSECOND_WITH_ZONE_ID))); + + // jdk8 only has millisecond precision, and the unit test does not run there + String expectedPattern = JdkUtils.isJdk11OrHigher() + ? TimePatternConstants.SIMPLE_DATE_FORMAT_NANOSECOND_WITH_ZONE_ID + : TimePatternConstants.SIMPLE_DATE_FORMAT_MILLIS_WITH_ZONE_ID; + assertEquals(expectedPattern, TimePatternConstants.zonedDateTimeFormat); + assertEquals("\"" + expected.format(DateFormatParser.INSTANCE.getFormatter(expectedPattern)) + "\"", + JacksonSerializer.INSTANCE.serialize(expected)); + } + @Test void testNullList() throws Throwable { final List list = new ArrayList<>(); @@ -100,6 +166,7 @@ public void testTimeSerializeAndDeserialize() throws Throwable { assert expectedTimeTest.getDate().equals(deserializedTimeTest.getDate()); assert expectedTimeTest.getInstant().equals(deserializedTimeTest.getInstant()); + assert expectedTimeTest.getZonedDateTime().equals(deserializedTimeTest.getZonedDateTime()); assert expectedTimeTest.getJodaLocalDate().equals(deserializedTimeTest.getJodaLocalDate()); assert expectedTimeTest.getJodaLocalTime().equals(deserializedTimeTest.getJodaLocalTime()); diff --git a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/TimeTestInfo.java b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/TimeTestInfo.java index 4fd2ccdfd..6bb921c47 100644 --- a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/TimeTestInfo.java +++ b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/TimeTestInfo.java @@ -11,6 +11,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -33,6 +34,7 @@ public class TimeTestInfo { private Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT-01:00")); private XMLGregorianCalendar xmlGregorianCalendar; private OffsetDateTime offsetDateTime = OffsetDateTime.now(ZoneId.of("GMT-01:00")); + private ZonedDateTime zonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai")); private TimeZone timeZone1 = TimeZone.getDefault(); public OffsetDateTime getOffsetDateTime() { @@ -43,6 +45,14 @@ public void setOffsetDateTime(OffsetDateTime offsetDateTime) { this.offsetDateTime = offsetDateTime; } + public ZonedDateTime getZonedDateTime() { + return zonedDateTime; + } + + public void setZonedDateTime(ZonedDateTime zonedDateTime) { + this.zonedDateTime = zonedDateTime; + } + { try { xmlGregorianCalendar = DatatypeFactory.newInstance().newXMLGregorianCalendar(gregorianCalendar); diff --git a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/ZeroSecondTimeTestInfo.java b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/ZeroSecondTimeTestInfo.java index 8a9b8dbbc..435b0e7fe 100644 --- a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/ZeroSecondTimeTestInfo.java +++ b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/ZeroSecondTimeTestInfo.java @@ -14,6 +14,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -33,6 +34,7 @@ public class ZeroSecondTimeTestInfo { private Calendar calendar; private XMLGregorianCalendar xmlGregorianCalendar; private OffsetDateTime offsetDateTime; + private ZonedDateTime zonedDateTime; private TimeZone timeZone1; public OffsetDateTime getOffsetDateTime() { @@ -43,6 +45,14 @@ public void setOffsetDateTime(OffsetDateTime offsetDateTime) { this.offsetDateTime = offsetDateTime; } + public ZonedDateTime getZonedDateTime() { + return zonedDateTime; + } + + public void setZonedDateTime(ZonedDateTime zonedDateTime) { + this.zonedDateTime = zonedDateTime; + } + // { // try { // xmlGregorianCalendar = DatatypeFactory.newInstance().newXMLGregorianCalendar(gregorianCalendar); @@ -102,6 +112,7 @@ public ZeroSecondTimeTestInfo(TimeTestInfo timeTestInfo) { this.offsetDateTime = timeTestInfo.getOffsetDateTime().withSecond(0).withNano(0); + this.zonedDateTime = timeTestInfo.getZonedDateTime().withSecond(0).withNano(0); this.timeZone1 = timeTestInfo.getTimeZone1(); // zero second timestamp diff --git a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithTypeTest.java b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithTypeTest.java index 5db42b1f1..9ec6b8e93 100644 --- a/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithTypeTest.java +++ b/arex-instrumentation-foundation/src/test/java/io/arex/foundation/serializer/jackson/JacksonSerializerWithTypeTest.java @@ -57,6 +57,7 @@ void testTimeSerializerWithType() throws Throwable { map.put("jodaLocalDateTime", timeTestInfo.getJodaLocalDateTime()); map.put("dateTime", timeTestInfo.getDateTime()); map.put("offsetDateTime", timeTestInfo.getOffsetDateTime()); + map.put("zonedDateTime", timeTestInfo.getZonedDateTime()); String json = JacksonSerializerWithType.INSTANCE.serialize(map); Map actualResult = (Map) JacksonSerializerWithType.INSTANCE.deserialize(json, Object.class); assertEquals(actualResult.get("localDateTime"), map.get("localDateTime")); @@ -68,6 +69,7 @@ void testTimeSerializerWithType() throws Throwable { assertEquals(actualResult.get("gregorianCalendar"), map.get("gregorianCalendar")); assertEquals(actualResult.get("xmlGregorianCalendar"), map.get("xmlGregorianCalendar")); assertEquals(actualResult.get("instant"), map.get("instant")); + assertEquals(actualResult.get("zonedDateTime"), map.get("zonedDateTime")); assertEquals(actualResult.get("jodaLocalDate"), map.get("jodaLocalDate")); assertEquals(actualResult.get("jodaLocalTime"), map.get("jodaLocalTime")); assertEquals(actualResult.get("jodaLocalDateTime"), map.get("jodaLocalDateTime")); diff --git a/arex-third-party/src/main/java/io/arex/agent/thirdparty/util/time/DateFormatUtils.java b/arex-third-party/src/main/java/io/arex/agent/thirdparty/util/time/DateFormatUtils.java index 6f462dfa4..b556f379e 100644 --- a/arex-third-party/src/main/java/io/arex/agent/thirdparty/util/time/DateFormatUtils.java +++ b/arex-third-party/src/main/java/io/arex/agent/thirdparty/util/time/DateFormatUtils.java @@ -5,6 +5,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Calendar; import java.util.Date; @@ -42,6 +43,10 @@ public static String format(final OffsetDateTime offsetDateTime, final String pa return offsetDateTime.format(DateTimeFormatter.ofPattern(pattern)); } + public static String format(final ZonedDateTime zonedDateTime, final String pattern) { + return zonedDateTime.format(DateTimeFormatter.ofPattern(pattern)); + } + public static String format(final Instant instant, final String pattern) { return DateTimeFormatter.ofPattern(pattern).withZone(ZoneId.systemDefault()).format(instant); }