diff --git a/pom.xml b/pom.xml index 4bd510786b..0c1a061422 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-redis - 3.0.0-SNAPSHOT + 3.0.0-GH-2322-SNAPSHOT Spring Data Redis Spring Data module for Redis diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java index 8fca10fbd6..9b0aa53e54 100644 --- a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java @@ -16,8 +16,11 @@ package org.springframework.data.redis.serializer; import java.io.IOException; +import java.util.Collections; +import java.util.function.Supplier; import org.springframework.cache.support.NullValue; +import org.springframework.data.util.Lazy; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -25,18 +28,24 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; import com.fasterxml.jackson.databind.jsontype.TypeSerializer; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.node.TextNode; import com.fasterxml.jackson.databind.ser.SerializerFactory; import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.type.TypeFactory; /** * Generic Jackson 2-based {@link RedisSerializer} that maps {@link Object objects} to JSON using dynamic typing. + *

+ * JSON reading and writing can be customized by configuring {@link JacksonObjectReader} respective + * {@link JacksonObjectWriter}. * * @author Christoph Strobl * @author Mark Paluch @@ -47,6 +56,14 @@ public class GenericJackson2JsonRedisSerializer implements RedisSerializer defaultTypingEnabled; + + private final TypeResolver typeResolver; + /** * Creates {@link GenericJackson2JsonRedisSerializer} and configures {@link ObjectMapper} for default typing. */ @@ -59,13 +76,30 @@ public GenericJackson2JsonRedisSerializer() { * given {@literal name}. In case of an {@literal empty} or {@literal null} String the default * {@link JsonTypeInfo.Id#CLASS} will be used. * - * @param classPropertyTypeName Name of the JSON property holding type information. Can be {@literal null}. + * @param classPropertyTypeName name of the JSON property holding type information. Can be {@literal null}. * @see ObjectMapper#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) * @see ObjectMapper#activateDefaultTyping(PolymorphicTypeValidator, DefaultTyping, As) */ public GenericJackson2JsonRedisSerializer(@Nullable String classPropertyTypeName) { + this(classPropertyTypeName, JacksonObjectReader.create(), JacksonObjectWriter.create()); + } + + /** + * Creates {@link GenericJackson2JsonRedisSerializer} and configures {@link ObjectMapper} for default typing using the + * given {@literal name}. In case of an {@literal empty} or {@literal null} String the default + * {@link JsonTypeInfo.Id#CLASS} will be used. + * + * @param classPropertyTypeName name of the JSON property holding type information. Can be {@literal null}. + * @param reader the {@link JacksonObjectReader} function to read objects using {@link ObjectMapper}. + * @param writer the {@link JacksonObjectWriter} function to write objects using {@link ObjectMapper}. + * @see ObjectMapper#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) + * @see ObjectMapper#activateDefaultTyping(PolymorphicTypeValidator, DefaultTyping, As) + * @since 3.0 + */ + public GenericJackson2JsonRedisSerializer(@Nullable String classPropertyTypeName, JacksonObjectReader reader, + JacksonObjectWriter writer) { - this(new ObjectMapper()); + this(new ObjectMapper(), reader, writer, classPropertyTypeName); // simply setting {@code mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)} does not help here since we need // the type hint embedded for deserialization using the default typing feature. @@ -87,9 +121,57 @@ public GenericJackson2JsonRedisSerializer(@Nullable String classPropertyTypeName * @param mapper must not be {@literal null}. */ public GenericJackson2JsonRedisSerializer(ObjectMapper mapper) { + this(mapper, JacksonObjectReader.create(), JacksonObjectWriter.create()); + } + + /** + * Setting a custom-configured {@link ObjectMapper} is one way to take further control of the JSON serialization + * process. For example, an extended {@link SerializerFactory} can be configured that provides custom serializers for + * specific types. + * + * @param mapper must not be {@literal null}. + * @param reader the {@link JacksonObjectReader} function to read objects using {@link ObjectMapper}. + * @param writer the {@link JacksonObjectWriter} function to write objects using {@link ObjectMapper}. + * @since 3.0 + */ + public GenericJackson2JsonRedisSerializer(ObjectMapper mapper, JacksonObjectReader reader, + JacksonObjectWriter writer) { + this(mapper, reader, writer, null); + } + + private GenericJackson2JsonRedisSerializer(ObjectMapper mapper, JacksonObjectReader reader, + JacksonObjectWriter writer, @Nullable String typeHintPropertyName) { Assert.notNull(mapper, "ObjectMapper must not be null!"); + Assert.notNull(reader, "Reader must not be null!"); + Assert.notNull(writer, "Writer must not be null!"); + this.mapper = mapper; + this.reader = reader; + this.writer = writer; + + this.defaultTypingEnabled = Lazy.of(() -> mapper.getSerializationConfig().getDefaultTyper(null) != null); + + Supplier typeHintPropertyNameSupplier; + + if (typeHintPropertyName == null) { + + typeHintPropertyNameSupplier = Lazy.of(() -> { + if (defaultTypingEnabled.get()) { + return null; + } + + return mapper.getDeserializationConfig().getDefaultTyper(null) + .buildTypeDeserializer(mapper.getDeserializationConfig(), + mapper.getTypeFactory().constructType(Object.class), Collections.emptyList()) + .getPropertyName(); + + }).or("@class"); + } else { + typeHintPropertyNameSupplier = () -> typeHintPropertyName; + } + + this.typeResolver = new TypeResolver(Lazy.of(mapper::getTypeFactory), typeHintPropertyNameSupplier); } /** @@ -116,8 +198,8 @@ public byte[] serialize(@Nullable Object source) throws SerializationException { } try { - return mapper.writeValueAsBytes(source); - } catch (JsonProcessingException e) { + return writer.write(mapper, source); + } catch (IOException e) { throw new SerializationException("Could not write JSON: " + e.getMessage(), e); } } @@ -134,6 +216,7 @@ public Object deserialize(@Nullable byte[] source) throws SerializationException * @throws SerializationException */ @Nullable + @SuppressWarnings("unchecked") public T deserialize(@Nullable byte[] source, Class type) throws SerializationException { Assert.notNull(type, @@ -144,12 +227,53 @@ public T deserialize(@Nullable byte[] source, Class type) throws Serializ } try { - return mapper.readValue(source, type); + return (T) reader.read(mapper, source, resolveType(source, type)); } catch (Exception ex) { throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); } } + protected JavaType resolveType(byte[] source, Class type) throws IOException { + + if (!type.equals(Object.class) || !defaultTypingEnabled.get()) { + return typeResolver.constructType(type); + } + + return typeResolver.resolveType(source, type); + } + + static class TypeResolver { + + // need a separate instance to bypass class hint checks + private final ObjectMapper mapper = new ObjectMapper(); + + private final Supplier typeFactory; + private final Supplier hintName; + + public TypeResolver(Supplier typeFactory, Supplier hintName) { + + this.typeFactory = typeFactory; + this.hintName = hintName; + } + + protected JavaType constructType(Class type) { + return typeFactory.get().constructType(type); + } + + protected JavaType resolveType(byte[] source, Class type) throws IOException { + + JsonNode root = mapper.readTree(source); + JsonNode jsonNode = root.get(hintName.get()); + + if (jsonNode instanceof TextNode && jsonNode.asText() != null) { + return typeFactory.get().constructFromCanonical(jsonNode.asText()); + } + + return constructType(type); + } + + } + /** * {@link StdSerializer} adding class information required by default typing. This allows de-/serialization of * {@link NullValue}. @@ -172,8 +296,7 @@ private static class NullValueSerializer extends StdSerializer { } @Override - public void serialize(NullValue value, JsonGenerator jgen, SerializerProvider provider) - throws IOException { + public void serialize(NullValue value, JsonGenerator jgen, SerializerProvider provider) throws IOException { jgen.writeStartObject(); jgen.writeStringField(classIdentifier, NullValue.class.getName()); @@ -186,4 +309,5 @@ public void serializeWithType(NullValue value, JsonGenerator gen, SerializerProv serialize(value, gen, serializers); } } + } diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java index ed41ea1462..614b57d61e 100644 --- a/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java @@ -31,35 +31,98 @@ * Jackson's and * Jackson Databind {@link ObjectMapper}. *

- * This converter can be used to bind to typed beans, or untyped {@link java.util.HashMap HashMap} instances. + * This serializer can be used to bind to typed beans, or untyped {@link java.util.HashMap HashMap} instances. * Note:Null objects are serialized as empty arrays and vice versa. + *

+ * JSON reading and writing can be customized by configuring {@link JacksonObjectReader} respective + * {@link JacksonObjectWriter}. * * @author Thomas Darimont + * @author Mark Paluch * @since 1.2 */ public class Jackson2JsonRedisSerializer implements RedisSerializer { + /** + * @deprecated since 3.0 for removal. + */ + @Deprecated(since = "3.0", forRemoval = true) // public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; private final JavaType javaType; - private ObjectMapper objectMapper = new ObjectMapper(); + private ObjectMapper mapper; + + private final JacksonObjectReader reader; + + private final JacksonObjectWriter writer; /** * Creates a new {@link Jackson2JsonRedisSerializer} for the given target {@link Class}. * - * @param type + * @param type must not be {@literal null}. */ public Jackson2JsonRedisSerializer(Class type) { - this.javaType = getJavaType(type); + this(new ObjectMapper(), type); } /** * Creates a new {@link Jackson2JsonRedisSerializer} for the given target {@link JavaType}. * - * @param javaType + * @param javaType must not be {@literal null}. */ public Jackson2JsonRedisSerializer(JavaType javaType) { + this(new ObjectMapper(), javaType); + } + + /** + * Creates a new {@link Jackson2JsonRedisSerializer} for the given target {@link Class}. + * + * @param mapper must not be {@literal null}. + * @param type must not be {@literal null}. + * @since 3.0 + */ + public Jackson2JsonRedisSerializer(ObjectMapper mapper, Class type) { + + Assert.notNull(mapper, "ObjectMapper must not be null"); + Assert.notNull(type, "Java type must not be null"); + + this.javaType = getJavaType(type); + this.mapper = mapper; + this.reader = JacksonObjectReader.create(); + this.writer = JacksonObjectWriter.create(); + } + + /** + * Creates a new {@link Jackson2JsonRedisSerializer} for the given target {@link JavaType}. + * + * @param mapper must not be {@literal null}. + * @param javaType must not be {@literal null}. + * @since 3.0 + */ + public Jackson2JsonRedisSerializer(ObjectMapper mapper, JavaType javaType) { + this(mapper, javaType, JacksonObjectReader.create(), JacksonObjectWriter.create()); + } + + /** + * Creates a new {@link Jackson2JsonRedisSerializer} for the given target {@link JavaType}. + * + * @param mapper must not be {@literal null}. + * @param javaType must not be {@literal null}. + * @param reader the {@link JacksonObjectReader} function to read objects using {@link ObjectMapper}. + * @param writer the {@link JacksonObjectWriter} function to write objects using {@link ObjectMapper}. + * @since 3.0 + */ + public Jackson2JsonRedisSerializer(ObjectMapper mapper, JavaType javaType, JacksonObjectReader reader, + JacksonObjectWriter writer) { + + Assert.notNull(mapper, "ObjectMapper must not be null!"); + Assert.notNull(reader, "Reader must not be null!"); + Assert.notNull(writer, "Writer must not be null!"); + + this.mapper = mapper; + this.reader = reader; + this.writer = writer; this.javaType = javaType; } @@ -70,7 +133,7 @@ public T deserialize(@Nullable byte[] bytes) throws SerializationException { return null; } try { - return (T) this.objectMapper.readValue(bytes, 0, bytes.length, javaType); + return (T) this.reader.read(this.mapper, bytes, javaType); } catch (Exception ex) { throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); } @@ -83,7 +146,7 @@ public byte[] serialize(@Nullable Object t) throws SerializationException { return SerializationUtils.EMPTY_ARRAY; } try { - return this.objectMapper.writeValueAsBytes(t); + return this.writer.write(this.mapper, t); } catch (Exception ex) { throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex); } @@ -97,11 +160,15 @@ public byte[] serialize(@Nullable Object t) throws SerializationException { * process. For example, an extended {@link SerializerFactory} can be configured that provides custom serializers for * specific types. The other option for refining the serialization process is to use Jackson's provided annotations on * the types to be serialized, in which case a custom-configured ObjectMapper is unnecessary. + * + * @deprecated since 3.0, use {@link #Jackson2JsonRedisSerializer(ObjectMapper, Class) constructor creation} to + * configure the object mapper. */ - public void setObjectMapper(ObjectMapper objectMapper) { + @Deprecated(since = "3.0", forRemoval = true) + public void setObjectMapper(ObjectMapper mapper) { - Assert.notNull(objectMapper, "'objectMapper' must not be null"); - this.objectMapper = objectMapper; + Assert.notNull(mapper, "'objectMapper' must not be null"); + this.mapper = mapper; } /** diff --git a/src/main/java/org/springframework/data/redis/serializer/JacksonObjectReader.java b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectReader.java new file mode 100644 index 0000000000..46552db37d --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectReader.java @@ -0,0 +1,57 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import java.io.IOException; +import java.io.InputStream; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Defines the contract for Object Mapping readers. Implementations of this interface can deserialize a given byte array + * holding JSON to an Object considering the target type. + *

+ * Reader functions can customize how the actual JSON is being deserialized by e.g. obtaining a customized + * {@link com.fasterxml.jackson.databind.ObjectReader} applying serialization features, date formats, or views. + * + * @author Mark Paluch + * @since 3.0 + */ +@FunctionalInterface +public interface JacksonObjectReader { + + /** + * Read an object graph from the given root JSON into a Java object considering the {@link JavaType}. + * + * @param mapper the object mapper to use. + * @param source the JSON to deserialize. + * @param type the Java target type + * @return the deserialized Java object. + * @throws IOException if an I/O error or JSON deserialization error occurs. + */ + Object read(ObjectMapper mapper, byte[] source, JavaType type) throws IOException; + + /** + * Create a default {@link JacksonObjectReader} delegating to {@link ObjectMapper#readValue(InputStream, JavaType)}. + * + * @return the default {@link JacksonObjectReader}. + */ + static JacksonObjectReader create() { + return (mapper, source, type) -> mapper.readValue(source, 0, source.length, type); + } + +} diff --git a/src/main/java/org/springframework/data/redis/serializer/JacksonObjectWriter.java b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectWriter.java new file mode 100644 index 0000000000..64ecd44de9 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectWriter.java @@ -0,0 +1,54 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Defines the contract for Object Mapping writers. Implementations of this interface can serialize a given Object to a + * {@code byte[]} containing JSON. + *

+ * Writer functions can customize how the actual JSON is being written by e.g. obtaining a customized + * {@link com.fasterxml.jackson.databind.ObjectWriter} applying serialization features, date formats, or views. + * + * @author Mark Paluch + * @since 3.0 + */ +@FunctionalInterface +public interface JacksonObjectWriter { + + /** + * Write the object graph with the given root {@code source} as byte array. + * + * @param mapper the object mapper to use. + * @param source the root of the object graph to marshal. + * @return a byte array containing the serialized object graph. + * @throws IOException if an I/O error or JSON serialization error occurs. + */ + byte[] write(ObjectMapper mapper, Object source) throws IOException; + + /** + * Create a default {@link JacksonObjectWriter} delegating to {@link ObjectMapper#writeValueAsBytes(Object)}. + * + * @return the default {@link JacksonObjectWriter}. + */ + static JacksonObjectWriter create() { + return ObjectMapper::writeValueAsBytes; + } + +} diff --git a/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java index f13d7cf1a8..ae565f7eab 100644 --- a/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java +++ b/src/test/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializerUnitTests.java @@ -21,6 +21,7 @@ import static org.springframework.util.ObjectUtils.*; import lombok.Data; +import lombok.ToString; import java.io.IOException; @@ -31,6 +32,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.core.JsonGenerationException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; @@ -162,6 +164,52 @@ void deserializeShouldBeAbleToRestoreFinalObjectAfterSerialization() { assertThat(serializer.deserialize(serializer.serialize(source))).isEqualTo(source); } + @Test // GH-2322 + void shouldConsiderWriter() { + + User user = new User(); + user.email = "walter@heisenberg.com"; + user.id = 42; + user.name = "Walter White"; + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer((String) null, + JacksonObjectReader.create(), (mapper, source) -> { + return mapper.writerWithView(Views.Basic.class).writeValueAsBytes(source); + }); + + byte[] result = serializer.serialize(user); + + assertThat(new String(result)).contains("id").contains("name").doesNotContain("email"); + } + + @Test // GH-2322 + void shouldConsiderReader() { + + User user = new User(); + user.email = "walter@heisenberg.com"; + user.id = 42; + user.name = "Walter White"; + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer((String) null, + (mapper, source, type) -> { + if (type.getRawClass() == User.class) { + return mapper.readerWithView(Views.Basic.class).forType(type).readValue(source); + } + return mapper.readValue(source, type); + }, JacksonObjectWriter.create()); + + byte[] serializedValue = serializer.serialize(user); + + Object result = serializer.deserialize(serializedValue); + assertThat(result).isInstanceOf(User.class).satisfies(it -> { + User u = (User) it; + assertThat(u.id).isEqualTo(user.id); + assertThat(u.name).isEqualTo(user.name); + assertThat(u.email).isNull(); + assertThat(u.mobile).isNull(); + }); + } + private static void serializeAndDeserializeNullValue(GenericJackson2JsonRedisSerializer serializer) { NullValue nv = BeanUtils.instantiateClass(NullValue.class); @@ -252,4 +300,19 @@ public boolean equals(Object obj) { } } + @ToString + static class User { + @JsonView(Views.Basic.class) public int id; + @JsonView(Views.Basic.class) public String name; + @JsonView(Views.Detailed.class) public String email; + @JsonView(Views.Detailed.class) public String mobile; + } + + static class Views { + interface Basic {} + + interface Detailed {} + + } + } diff --git a/src/test/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializerTests.java b/src/test/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializerTests.java index f0d57830ce..1da6e10b8f 100644 --- a/src/test/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializerTests.java +++ b/src/test/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializerTests.java @@ -24,9 +24,15 @@ import org.springframework.data.redis.Person; import org.springframework.data.redis.PersonObjectFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.TypeFactory; + /** + * Unit tests for {@link Jackson2JsonRedisSerializer}. + * * @author Thomas Darimont * @author Christoph Strobl + * @author Mark Paluch */ class Jackson2JsonRedisSerializerTests { @@ -64,4 +70,14 @@ void testJackson2JsonSerilizerThrowsExceptionWhenSettingNullObjectMapper() { assertThatIllegalArgumentException().isThrownBy(() -> serializer.setObjectMapper(null)); } + @Test // GH-2322 + void shouldConsiderWriter() { + + serializer = new Jackson2JsonRedisSerializer<>(new ObjectMapper(), + TypeFactory.defaultInstance().constructType(Person.class), JacksonObjectReader.create(), + (mapper, source) -> "foo".getBytes()); + Person person = new PersonObjectFactory().instance(); + assertThat(serializer.serialize(person)).isEqualTo("foo".getBytes()); + } + }