From 3bb5ed1ae4fb40f6160c422df51c0a6f5c0e9146 Mon Sep 17 00:00:00 2001 From: junhyeong9812 Date: Fri, 21 Aug 2026 00:04:19 +0900 Subject: [PATCH] Make ResolvableType.forClassWithGenerics results serializable ResolvableType declares Serializable, and forClassWithGenerics carries no documented serialization caveat, but any ResolvableType created through it failed to serialize with NotSerializableException. Two carriers were responsible: TypeVariablesVariableResolver holds the raw TypeVariable array of the supplied class, and SyntheticParameterizedType re-injects raw TypeVariables as type arguments when generics are null or unresolved. This also broke the public TypeDescriptor.collection() and TypeDescriptor.map() factories as well as serializable carriers such as NoSuchBeanDefinitionException and PayloadApplicationEvent. Both inner classes now use serialization proxies: writeReplace() encodes type variables by their declaring class (and type parameter index), and readResolve() restores the identical JDK instances via Class#getTypeParameters, which returns canonical objects. Restoring identical instances preserves equals, hashCode, and the precomputed hash of the enclosing ResolvableType. Arguments that cannot be encoded fall back to default serialization, retaining the existing failure mode. The non-serialization runtime paths are unchanged, and derived types (getSuperType, getInterfaces, as) intentionally remain non-serializable per SPR-17070. Signed-off-by: junhyeong9812 --- .../springframework/core/ResolvableType.java | 137 +++++++++++++ .../core/ResolvableTypeTests.java | 192 ++++++++++++++++++ .../core/convert/TypeDescriptorTests.java | 43 ++++ 3 files changed, 372 insertions(+) diff --git a/spring-core/src/main/java/org/springframework/core/ResolvableType.java b/spring-core/src/main/java/org/springframework/core/ResolvableType.java index 3d69e9fd92c2..79661d741245 100644 --- a/spring-core/src/main/java/org/springframework/core/ResolvableType.java +++ b/spring-core/src/main/java/org/springframework/core/ResolvableType.java @@ -16,6 +16,8 @@ package org.springframework.core; +import java.io.InvalidObjectException; +import java.io.ObjectStreamException; import java.io.Serializable; import java.lang.reflect.Array; import java.lang.reflect.Constructor; @@ -1634,6 +1636,53 @@ public TypeVariablesVariableResolver(TypeVariable[] variables, @Nullable Reso public Object getSource() { return this.generics; } + + private Object writeReplace() throws ObjectStreamException { + if (this.variables.length == 0) { + return this; + } + // Arrays.equals invokes equals on this.variables elements: JDK TypeVariable + // implementations only accept their own class, so the receiver side must be + // this.variables for wrapped variables to match their unwrapped counterparts. + if (!(this.variables[0].getGenericDeclaration() instanceof Class declaringClass) || + !Arrays.equals(this.variables, declaringClass.getTypeParameters())) { + // Not the type parameters of a single declaring class -> retain default serialization. + return this; + } + return new SerializedTypeVariablesVariableResolver(declaringClass, this.generics); + } + } + + + /** + * Serialization proxy for {@link TypeVariablesVariableResolver}, restoring the + * non-serializable {@link TypeVariable} array from its declaring class. + */ + @SuppressWarnings("serial") + private static final class SerializedTypeVariablesVariableResolver implements Serializable { + + private final Class declaringClass; + + private final @Nullable ResolvableType[] generics; + + SerializedTypeVariablesVariableResolver(Class declaringClass, @Nullable ResolvableType[] generics) { + this.declaringClass = declaringClass; + this.generics = generics; + } + + private Object readResolve() throws ObjectStreamException { + // The stream is not trusted to be self-consistent: incomplete or mismatched + // proxy state fails with InvalidObjectException rather than downstream errors. + if (this.declaringClass == null || this.generics == null) { + throw new InvalidObjectException("Incomplete serialization proxy for TypeVariablesVariableResolver"); + } + TypeVariable[] variables = this.declaringClass.getTypeParameters(); + if (variables.length != this.generics.length) { + throw new InvalidObjectException( + "Mismatched type variables for " + this.declaringClass.getName()); + } + return new TypeVariablesVariableResolver(variables, this.generics); + } } @@ -1692,6 +1741,94 @@ public int hashCode() { public String toString() { return getTypeName(); } + + private Object writeReplace() throws ObjectStreamException { + if (!(this.rawType instanceof Class rawClass)) { + return this; + } + TypeVariable[] variables = rawClass.getTypeParameters(); + Object[] encodedArguments = new Object[this.typeArguments.length]; + boolean encodedVariable = false; + for (int i = 0; i < this.typeArguments.length; i++) { + Type argument = this.typeArguments[i]; + int variableIndex = indexOf(variables, argument); + if (variableIndex != -1) { + // Re-derivable from the raw class -> encode as a type parameter index. + encodedArguments[i] = variableIndex; + encodedVariable = true; + } + else if (argument instanceof Serializable) { + encodedArguments[i] = argument; + } + else { + // Not encodable -> retain default serialization (and its failure mode). + return this; + } + } + // Only use the proxy for instances that would otherwise fail to serialize. + return (encodedVariable ? new SerializedSyntheticParameterizedType(rawClass, encodedArguments) : this); + } + + // Identity comparison, relying on the JDK returning canonical TypeVariable + // instances; a miss simply falls back to default serialization. + private static int indexOf(TypeVariable[] variables, Type argument) { + for (int i = 0; i < variables.length; i++) { + if (variables[i] == argument) { + return i; + } + } + return -1; + } + } + + + /** + * Serialization proxy for {@link SyntheticParameterizedType}, encoding type + * arguments that are non-serializable {@link TypeVariable TypeVariables} as + * indexes into the type parameters of the raw class. + */ + @SuppressWarnings("serial") + private static final class SerializedSyntheticParameterizedType implements Serializable { + + private final Class rawType; + + private final Object[] encodedArguments; + + SerializedSyntheticParameterizedType(Class rawType, Object[] encodedArguments) { + this.rawType = rawType; + this.encodedArguments = encodedArguments; + } + + private Object readResolve() throws ObjectStreamException { + // The stream is not trusted to be self-consistent: incomplete or mismatched + // proxy state fails with InvalidObjectException rather than downstream errors. + if (this.rawType == null || this.encodedArguments == null) { + throw new InvalidObjectException("Incomplete serialization proxy for SyntheticParameterizedType"); + } + TypeVariable[] variables = this.rawType.getTypeParameters(); + if (this.encodedArguments.length != variables.length) { + throw new InvalidObjectException("Mismatched type arguments for " + this.rawType.getName()); + } + Type[] typeArguments = new Type[this.encodedArguments.length]; + for (int i = 0; i < this.encodedArguments.length; i++) { + Object encoded = this.encodedArguments[i]; + if (encoded instanceof Integer index) { + if (index < 0 || index >= variables.length) { + throw new InvalidObjectException( + "Invalid type variable index " + index + " for " + this.rawType.getName()); + } + typeArguments[i] = variables[index]; + } + else if (encoded instanceof Type type) { + typeArguments[i] = type; + } + else { + throw new InvalidObjectException( + "Invalid type argument encoding for " + this.rawType.getName()); + } + } + return new SyntheticParameterizedType(this.rawType, typeArguments); + } } diff --git a/spring-core/src/test/java/org/springframework/core/ResolvableTypeTests.java b/spring-core/src/test/java/org/springframework/core/ResolvableTypeTests.java index f6e66a2c0b57..cf34755ada6d 100644 --- a/spring-core/src/test/java/org/springframework/core/ResolvableTypeTests.java +++ b/spring-core/src/test/java/org/springframework/core/ResolvableTypeTests.java @@ -18,6 +18,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.NotSerializableException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; @@ -54,6 +55,7 @@ import org.springframework.util.MultiValueMap; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -1403,6 +1405,180 @@ void serializeWithCachedState() throws Exception { testSerialization(type); } + @Test + void serializeClassWithGenerics() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(Map.class, String.class, Integer.class); + ResolvableType read = testSerialization(type); + + assertThat(read).hasSameHashCodeAs(type); + assertThat(read.toString()).isEqualTo("java.util.Map"); + assertThat(read.getGenerics()).hasSize(2); + assertThat(read.getGeneric(0).resolve()).isEqualTo(String.class); + assertThat(read.getGeneric(1).resolve()).isEqualTo(Integer.class); + assertThat(read.resolveGeneric(0)).isEqualTo(String.class); + + ParameterizedType readType = (ParameterizedType) read.getType(); + assertThat(readType.getRawType()).isSameAs(Map.class); + assertThat(readType.getActualTypeArguments()[0]).isSameAs(String.class); + assertThat(readType.getActualTypeArguments()[1]).isSameAs(Integer.class); + } + + @Test + void serializeClassWithNestedGenerics() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(Map.class, + ResolvableType.forClass(String.class), + ResolvableType.forClassWithGenerics(List.class, Integer.class)); + ResolvableType read = testSerialization(type); + + assertThat(read).hasSameHashCodeAs(type); + assertThat(read.toString()).isEqualTo("java.util.Map>"); + assertThat(read.getGeneric(1).resolve()).isEqualTo(List.class); + assertThat(read.resolveGeneric(1, 0)).isEqualTo(Integer.class); + + ParameterizedType readType = (ParameterizedType) read.getType(); + assertThat(readType.getRawType()).isSameAs(Map.class); + assertThat(readType.getActualTypeArguments()[0]).isSameAs(String.class); + Type nested = readType.getActualTypeArguments()[1]; + assertThat(nested).isInstanceOf(ParameterizedType.class); + assertThat(((ParameterizedType) nested).getRawType()).isSameAs(List.class); + assertThat(((ParameterizedType) nested).getActualTypeArguments()[0]).isSameAs(Integer.class); + } + + @Test + void serializeClassWithGenericsResolvesTypeVariables() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(ArrayList.class, String.class); + ResolvableType read = testSerialization(type); + + assertThat(read.resolveGeneric()).isEqualTo(String.class); + assertThat(read.as(List.class).getGeneric(0).resolve()).isEqualTo(String.class); + assertThat(read.asCollection().resolveGeneric()).isEqualTo(String.class); + } + + @Test + void serializeClassWithNullGenerics() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(List.class, (ResolvableType[]) null); + ResolvableType read = testSerialization(type); + + assertThat(read).hasSameHashCodeAs(type); + assertThat(read.toString()).isEqualTo("java.util.List"); + assertThat(read.resolve()).isEqualTo(List.class); + assertThat(read.getGenerics()).hasSize(1); + assertThat(read.getGeneric(0).resolve()).isNull(); + assertThat(read).isNotEqualTo(ResolvableType.forClass(List.class)); + + ParameterizedType readType = (ParameterizedType) read.getType(); + assertThat(readType.getActualTypeArguments()[0]).isSameAs(List.class.getTypeParameters()[0]); + } + + @Test + void serializeClassWithNullGenericElement() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(Map.class, (ResolvableType) null, null); + ResolvableType read = testSerialization(type); + + assertThat(read).hasSameHashCodeAs(type); + assertThat(read.toString()).isEqualTo("java.util.Map"); + assertThat(read.getGeneric(0).resolve()).isNull(); + assertThat(read.getGeneric(1).resolve()).isNull(); + + ParameterizedType readType = (ParameterizedType) read.getType(); + assertThat(readType.getActualTypeArguments()[0]).isSameAs(Map.class.getTypeParameters()[0]); + assertThat(readType.getActualTypeArguments()[1]).isSameAs(Map.class.getTypeParameters()[1]); + } + + @Test + void serializeClassWithoutTypeParameters() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(String.class, new ResolvableType[0]); + ResolvableType read = testSerialization(type); + + assertThat(read).hasSameHashCodeAs(type); + assertThat(read.toString()).isEqualTo("java.lang.String"); + assertThat(read.resolve()).isEqualTo(String.class); + assertThat(read.getGenerics()).isEmpty(); + assertThat(read.getType()).isInstanceOf(ParameterizedType.class); + assertThat(((ParameterizedType) read.getType()).getRawType()).isSameAs(String.class); + assertThat(read).isNotEqualTo(ResolvableType.forClass(String.class)); + } + + @Test + void serializeClassWithGenericsHashCodeIsConsistent() throws Exception { + ResolvableType stringList = ResolvableType.forClassWithGenerics(List.class, String.class); + ResolvableType integerList = ResolvableType.forClassWithGenerics(List.class, Integer.class); + + ResolvableType readStringList = testSerialization(stringList); + ResolvableType readIntegerList = testSerialization(integerList); + + assertThat(readStringList).hasSameHashCodeAs(stringList).isNotEqualTo(readIntegerList); + assertThat(readStringList.hashCode()).isNotEqualTo(readIntegerList.hashCode()); + + ResolvableType rebuilt = ResolvableType.forClassWithGenerics(List.class, String.class); + assertThat(readStringList).isEqualTo(rebuilt).hasSameHashCodeAs(rebuilt); + + Map map = new HashMap<>(); + map.put(stringList, "string"); + map.put(integerList, "integer"); + assertThat(map.get(readStringList)).isEqualTo("string"); + assertThat(map.get(readIntegerList)).isEqualTo("integer"); + } + + @Test + void serializeClassWithGenericsInsideSerializableHolder() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(List.class, String.class); + SerializableHolder holder = new SerializableHolder(type); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(bos)) { + oos.writeObject(holder); + } + SerializableHolder read = (SerializableHolder) new ObjectInputStream( + new ByteArrayInputStream(bos.toByteArray())).readObject(); + + assertThat(read.type).isEqualTo(type).hasSameHashCodeAs(type); + assertThat(read.type.resolveGeneric()).isEqualTo(String.class); + } + + @Test // gh-36346: transient cached state populated by derivation must not affect serializability + void serializeClassWithGenericsAfterDerivation() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(ArrayList.class, String.class); + type.as(Collection.class); + type.getSuperType(); + type.getInterfaces(); + type.getGenerics(); + type.hasUnresolvableGenerics(); + ResolvableType read = testSerialization(type); + + assertThat(read).hasSameHashCodeAs(type); + assertThat(read.resolveGeneric()).isEqualTo(String.class); + } + + @Test // SPR-17070: derived types are intentionally not serializable + void serializeSuperTypeIsNotSupported() { + ResolvableType superType = ResolvableType.forClass(ArrayList.class).getSuperType(); + ResolvableType interfaceType = ResolvableType.forClass(ArrayList.class).getInterfaces()[0]; + ResolvableType asType = ResolvableType.forClassWithGenerics(ArrayList.class, String.class).as(List.class); + + assertThatExceptionOfType(NotSerializableException.class).isThrownBy(() -> serialize(superType)); + assertThatExceptionOfType(NotSerializableException.class).isThrownBy(() -> serialize(interfaceType)); + assertThatExceptionOfType(NotSerializableException.class).isThrownBy(() -> serialize(asType)); + } + + @Test + void forClassWithGenericsUsesJdkTypesDirectly() { + ResolvableType type = ResolvableType.forClassWithGenerics(List.class, String.class); + ParameterizedType parameterizedType = (ParameterizedType) type.getType(); + + assertThat(parameterizedType.getRawType()).isSameAs(List.class); + assertThat(parameterizedType.getActualTypeArguments()[0]).isSameAs(String.class); + assertThat(parameterizedType.getOwnerType()).isNull(); + + ResolvableType raw = ResolvableType.forClassWithGenerics(List.class, (ResolvableType[]) null); + assertThat(((ParameterizedType) raw.getType()).getActualTypeArguments()[0]) + .isSameAs(List.class.getTypeParameters()[0]); + + assertThat(type.toString()).isEqualTo("java.util.List"); + assertThat(type.getType().getTypeName()).isEqualTo("java.util.List"); + assertThat(type.resolveGeneric()).isEqualTo(String.class); + } + @Test void canResolveVoid() { ResolvableType type = ResolvableType.forClass(void.class); @@ -1615,6 +1791,12 @@ private ResolvableType testSerialization(ResolvableType type) throws Exception { return read; } + private void serialize(ResolvableType type) throws Exception { + try (ObjectOutputStream oos = new ObjectOutputStream(new ByteArrayOutputStream())) { + oos.writeObject(type); + } + } + private ResolvableType forField(String field) throws NoSuchFieldException { return ResolvableType.forField(Fields.class.getField(field)); } @@ -1632,6 +1814,16 @@ private static ResolvableTypeAssert assertThatResolvableType(ResolvableType type private HashMap> myMap; + @SuppressWarnings("serial") + static class SerializableHolder implements Serializable { + + final ResolvableType type; + + SerializableHolder(ResolvableType type) { + this.type = type; + } + } + @SuppressWarnings("serial") static class ExtendsList extends ArrayList { } diff --git a/spring-core/src/test/java/org/springframework/core/convert/TypeDescriptorTests.java b/spring-core/src/test/java/org/springframework/core/convert/TypeDescriptorTests.java index 18b70b7eecef..6ed9a1de1f2f 100644 --- a/spring-core/src/test/java/org/springframework/core/convert/TypeDescriptorTests.java +++ b/spring-core/src/test/java/org/springframework/core/convert/TypeDescriptorTests.java @@ -737,6 +737,49 @@ void serializable() throws Exception { assertThat(readObject).isEqualTo(typeDescriptor); } + @Test + void serializableCollection() throws Exception { + TypeDescriptor typeDescriptor = TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(String.class)); + TypeDescriptor readObject = serializeAndDeserialize(typeDescriptor); + + assertThat(readObject).isEqualTo(typeDescriptor).hasSameHashCodeAs(typeDescriptor); + assertThat(readObject.getType()).isEqualTo(List.class); + assertThat(readObject.getElementTypeDescriptor().getType()).isEqualTo(String.class); + assertThat(readObject.toString()).isEqualTo("java.util.List"); + } + + @Test + void serializableMap() throws Exception { + TypeDescriptor typeDescriptor = TypeDescriptor.map(Map.class, + TypeDescriptor.valueOf(String.class), TypeDescriptor.valueOf(Integer.class)); + TypeDescriptor readObject = serializeAndDeserialize(typeDescriptor); + + assertThat(readObject).isEqualTo(typeDescriptor).hasSameHashCodeAs(typeDescriptor); + assertThat(readObject.getMapKeyTypeDescriptor().getType()).isEqualTo(String.class); + assertThat(readObject.getMapValueTypeDescriptor().getType()).isEqualTo(Integer.class); + assertThat(readObject.toString()).isEqualTo("java.util.Map"); + } + + @Test + void serializableCollectionWithNullElementType() throws Exception { + TypeDescriptor typeDescriptor = TypeDescriptor.collection(List.class, null); + TypeDescriptor readObject = serializeAndDeserialize(typeDescriptor); + + assertThat(readObject).isEqualTo(typeDescriptor).hasSameHashCodeAs(typeDescriptor); + assertThat(readObject.getElementTypeDescriptor()).isNull(); + assertThat(readObject.toString()).isEqualTo("java.util.List"); + } + + @SuppressWarnings("unchecked") + private static T serializeAndDeserialize(T object) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ObjectOutputStream outputStream = new ObjectOutputStream(out)) { + outputStream.writeObject(object); + } + ObjectInputStream inputStream = new ObjectInputStream(new ByteArrayInputStream(out.toByteArray())); + return (T) inputStream.readObject(); + } + @Test void createCollectionWithNullElement() { TypeDescriptor typeDescriptor = TypeDescriptor.collection(List.class, null);