From b73a7a84104576d2ea471aead19d5adcd7049faf Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 19 Dec 2012 13:55:22 +0100 Subject: [PATCH] Added MappingJackson2MessageConverter for JMS Issue: SPR-10099 --- build.gradle | 3 +- .../MappingJackson2MessageConverter.java | 370 ++++++++++++++++++ .../MappingJackson2MessageConverterTests.java | 178 +++++++++ .../MappingJackson2HttpMessageConverter.java | 3 +- .../MappingJacksonHttpMessageConverter.java | 5 +- 5 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java create mode 100644 spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java diff --git a/build.gradle b/build.gradle index fd4a12d057..746a290e88 100644 --- a/build.gradle +++ b/build.gradle @@ -335,10 +335,11 @@ project("spring-jms") { compile(project(":spring-tx")) optional(project(":spring-oxm")) compile("aopalliance:aopalliance:1.0") - optional("org.codehaus.jackson:jackson-mapper-asl:1.4.2") provided("org.apache.geronimo.specs:geronimo-jms_1.1_spec:1.1") optional("org.apache.geronimo.specs:geronimo-jta_1.1_spec:1.1") optional("javax.resource:connector-api:1.5") + optional("org.codehaus.jackson:jackson-mapper-asl:1.4.2") + optional("com.fasterxml.jackson.core:jackson-databind:2.0.1") } } diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java new file mode 100644 index 0000000000..880ae9c75c --- /dev/null +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java @@ -0,0 +1,370 @@ +/* + * Copyright 2002-2012 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 + * + * http://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.jms.support.converter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.Map; +import javax.jms.BytesMessage; +import javax.jms.JMSException; +import javax.jms.Message; +import javax.jms.Session; +import javax.jms.TextMessage; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Message converter that uses the Jackson 2 library to convert messages to and from JSON. + * Maps an object to a {@link javax.jms.BytesMessage}, or to a {@link javax.jms.TextMessage} if the + * {@link #setTargetType targetType} is set to {@link org.springframework.jms.support.converter.MessageType#TEXT}. + * Converts from a {@link javax.jms.TextMessage} or {@link javax.jms.BytesMessage} to an object. + * + * @author Mark Pollack + * @author Dave Syer + * @author Juergen Hoeller + * @since 3.1.4 + */ +public class MappingJackson2MessageConverter implements MessageConverter { + + /** + * The default encoding used for writing to text messages: UTF-8. + */ + public static final String DEFAULT_ENCODING = "UTF-8"; + + + private ObjectMapper objectMapper = new ObjectMapper(); + + private MessageType targetType = MessageType.BYTES; + + private String encoding = DEFAULT_ENCODING; + + private String encodingPropertyName; + + private String typeIdPropertyName; + + private Map> idClassMappings = new HashMap>(); + + private Map, String> classIdMappings = new HashMap, String>(); + + + /** + * Specify the {@link org.codehaus.jackson.map.ObjectMapper} to use instead of using the default. + */ + public void setObjectMapper(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + this.objectMapper = objectMapper; + } + + /** + * Specify whether {@link #toMessage(Object, javax.jms.Session)} should marshal to a + * {@link javax.jms.BytesMessage} or a {@link javax.jms.TextMessage}. + *

The default is {@link org.springframework.jms.support.converter.MessageType#BYTES}, i.e. this converter marshals to + * a {@link javax.jms.BytesMessage}. Note that the default version of this converter + * supports {@link org.springframework.jms.support.converter.MessageType#BYTES} and {@link org.springframework.jms.support.converter.MessageType#TEXT} only. + * @see org.springframework.jms.support.converter.MessageType#BYTES + * @see org.springframework.jms.support.converter.MessageType#TEXT + */ + public void setTargetType(MessageType targetType) { + Assert.notNull(targetType, "MessageType must not be null"); + this.targetType = targetType; + } + + /** + * Specify the encoding to use when converting to and from text-based + * message body content. The default encoding will be "UTF-8". + *

When reading from a a text-based message, an encoding may have been + * suggested through a special JMS property which will then be preferred + * over the encoding set on this MessageConverter instance. + * @see #setEncodingPropertyName + */ + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + /** + * Specify the name of the JMS message property that carries the encoding from + * bytes to String and back is BytesMessage is used during the conversion process. + *

Default is none. Setting this property is optional; if not set, UTF-8 will + * be used for decoding any incoming bytes message. + * @see #setEncoding + */ + public void setEncodingPropertyName(String encodingPropertyName) { + this.encodingPropertyName = encodingPropertyName; + } + + /** + * Specify the name of the JMS message property that carries the type id for the + * contained object: either a mapped id value or a raw Java class name. + *

Default is none. NOTE: This property needs to be set in order to allow + * for converting from an incoming message to a Java object. + * @see #setTypeIdMappings + */ + public void setTypeIdPropertyName(String typeIdPropertyName) { + this.typeIdPropertyName = typeIdPropertyName; + } + + /** + * Specify mappings from type ids to Java classes, if desired. + * This allows for synthetic ids in the type id message property, + * instead of transferring Java class names. + *

Default is no custom mappings, i.e. transferring raw Java class names. + * @param typeIdMappings a Map with type id values as keys and Java classes as values + */ + public void setTypeIdMappings(Map> typeIdMappings) { + this.idClassMappings = new HashMap>(); + for (Map.Entry> entry : typeIdMappings.entrySet()) { + String id = entry.getKey(); + Class clazz = entry.getValue(); + this.idClassMappings.put(id, clazz); + this.classIdMappings.put(clazz, id); + } + } + + + public Message toMessage(Object object, Session session) throws JMSException, MessageConversionException { + Message message; + try { + switch (this.targetType) { + case TEXT: + message = mapToTextMessage(object, session, this.objectMapper); + break; + case BYTES: + message = mapToBytesMessage(object, session, this.objectMapper); + break; + default: + message = mapToMessage(object, session, this.objectMapper, this.targetType); + } + } + catch (IOException ex) { + throw new MessageConversionException("Could not map JSON object [" + object + "]", ex); + } + setTypeIdOnMessage(object, message); + return message; + } + + public Object fromMessage(Message message) throws JMSException, MessageConversionException { + try { + JavaType targetJavaType = getJavaTypeForMessage(message); + return convertToObject(message, targetJavaType); + } + catch (IOException ex) { + throw new MessageConversionException("Failed to convert JSON message content", ex); + } + } + + + /** + * Map the given object to a {@link javax.jms.TextMessage}. + * @param object the object to be mapped + * @param session current JMS session + * @param objectMapper the mapper to use + * @return the resulting message + * @throws javax.jms.JMSException if thrown by JMS methods + * @throws java.io.IOException in case of I/O errors + * @see javax.jms.Session#createBytesMessage + * @see org.springframework.oxm.Marshaller#marshal(Object, javax.xml.transform.Result) + */ + protected TextMessage mapToTextMessage(Object object, Session session, ObjectMapper objectMapper) + throws JMSException, IOException { + + StringWriter writer = new StringWriter(); + objectMapper.writeValue(writer, object); + return session.createTextMessage(writer.toString()); + } + + /** + * Map the given object to a {@link javax.jms.BytesMessage}. + * @param object the object to be mapped + * @param session current JMS session + * @param objectMapper the mapper to use + * @return the resulting message + * @throws javax.jms.JMSException if thrown by JMS methods + * @throws java.io.IOException in case of I/O errors + * @see javax.jms.Session#createBytesMessage + * @see org.springframework.oxm.Marshaller#marshal(Object, javax.xml.transform.Result) + */ + protected BytesMessage mapToBytesMessage(Object object, Session session, ObjectMapper objectMapper) + throws JMSException, IOException { + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + OutputStreamWriter writer = new OutputStreamWriter(bos, this.encoding); + objectMapper.writeValue(writer, object); + + BytesMessage message = session.createBytesMessage(); + message.writeBytes(bos.toByteArray()); + if (this.encodingPropertyName != null) { + message.setStringProperty(this.encodingPropertyName, this.encoding); + } + return message; + } + + /** + * Template method that allows for custom message mapping. + * Invoked when {@link #setTargetType} is not {@link org.springframework.jms.support.converter.MessageType#TEXT} or + * {@link org.springframework.jms.support.converter.MessageType#BYTES}. + *

The default implementation throws an {@link IllegalArgumentException}. + * @param object the object to marshal + * @param session the JMS Session + * @param objectMapper the mapper to use + * @param targetType the target message type (other than TEXT or BYTES) + * @return the resulting message + * @throws javax.jms.JMSException if thrown by JMS methods + * @throws java.io.IOException in case of I/O errors + */ + protected Message mapToMessage(Object object, Session session, ObjectMapper objectMapper, MessageType targetType) + throws JMSException, IOException { + + throw new IllegalArgumentException("Unsupported message type [" + targetType + + "]. MappingJacksonMessageConverter by default only supports TextMessages and BytesMessages."); + } + + /** + * Set a type id for the given payload object on the given JMS Message. + *

The default implementation consults the configured type id mapping and + * sets the resulting value (either a mapped id or the raw Java class name) + * into the configured type id message property. + * @param object the payload object to set a type id for + * @param message the JMS Message to set the type id on + * @throws javax.jms.JMSException if thrown by JMS methods + * @see #getJavaTypeForMessage(javax.jms.Message) + * @see #setTypeIdPropertyName(String) + * @see #setTypeIdMappings(java.util.Map) + */ + protected void setTypeIdOnMessage(Object object, Message message) throws JMSException { + if (this.typeIdPropertyName != null) { + String typeId = this.classIdMappings.get(object.getClass()); + if (typeId == null) { + typeId = object.getClass().getName(); + } + message.setStringProperty(this.typeIdPropertyName, typeId); + } + } + + + /** + * Convenience method to dispatch to converters for individual message types. + */ + private Object convertToObject(Message message, JavaType targetJavaType) throws JMSException, IOException { + if (message instanceof TextMessage) { + return convertFromTextMessage((TextMessage) message, targetJavaType); + } + else if (message instanceof BytesMessage) { + return convertFromBytesMessage((BytesMessage) message, targetJavaType); + } + else { + return convertFromMessage(message, targetJavaType); + } + } + + /** + * Convert a TextMessage to a Java Object with the specified type. + * @param message the input message + * @param targetJavaType the target type + * @return the message converted to an object + * @throws javax.jms.JMSException if thrown by JMS + * @throws java.io.IOException in case of I/O errors + */ + protected Object convertFromTextMessage(TextMessage message, JavaType targetJavaType) + throws JMSException, IOException { + + String body = message.getText(); + return this.objectMapper.readValue(body, targetJavaType); + } + + /** + * Convert a BytesMessage to a Java Object with the specified type. + * @param message the input message + * @param targetJavaType the target type + * @return the message converted to an object + * @throws javax.jms.JMSException if thrown by JMS + * @throws java.io.IOException in case of I/O errors + */ + protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJavaType) + throws JMSException, IOException { + + String encoding = this.encoding; + if (this.encodingPropertyName != null && message.propertyExists(this.encodingPropertyName)) { + encoding = message.getStringProperty(this.encodingPropertyName); + } + byte[] bytes = new byte[(int) message.getBodyLength()]; + message.readBytes(bytes); + try { + String body = new String(bytes, encoding); + return this.objectMapper.readValue(body, targetJavaType); + } + catch (UnsupportedEncodingException ex) { + throw new MessageConversionException("Cannot convert bytes to String", ex); + } + } + + /** + * Template method that allows for custom message mapping. + * Invoked when {@link #setTargetType} is not {@link org.springframework.jms.support.converter.MessageType#TEXT} or + * {@link org.springframework.jms.support.converter.MessageType#BYTES}. + *

The default implementation throws an {@link IllegalArgumentException}. + * @param message the input message + * @param targetJavaType the target type + * @return the message converted to an object + * @throws javax.jms.JMSException if thrown by JMS + * @throws java.io.IOException in case of I/O errors + */ + protected Object convertFromMessage(Message message, JavaType targetJavaType) + throws JMSException, IOException { + + throw new IllegalArgumentException("Unsupported message type [" + message.getClass() + + "]. MappingJacksonMessageConverter by default only supports TextMessages and BytesMessages."); + } + + /** + * Determine a Jackson JavaType for the given JMS Message, + * typically parsing a type id message property. + *

The default implementation parses the configured type id property name + * and consults the configured type id mapping. This can be overridden with + * a different strategy, e.g. doing some heuristics based on message origin. + * @param message the JMS Message to set the type id on + * @throws javax.jms.JMSException if thrown by JMS methods + * @see #setTypeIdOnMessage(Object, javax.jms.Message) + * @see #setTypeIdPropertyName(String) + * @see #setTypeIdMappings(java.util.Map) + */ + protected JavaType getJavaTypeForMessage(Message message) throws JMSException { + String typeId = message.getStringProperty(this.typeIdPropertyName); + if (typeId == null) { + throw new MessageConversionException("Could not find type id property [" + this.typeIdPropertyName + "]"); + } + Class mappedClass = this.idClassMappings.get(typeId); + if (mappedClass != null) { + return this.objectMapper.getTypeFactory().constructType(mappedClass); + } + try { + return this.objectMapper.getTypeFactory().constructType( + ClassUtils.forName(typeId, getClass().getClassLoader())); + } + catch (Throwable ex) { + throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex); + } + } + +} diff --git a/spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java b/spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java new file mode 100644 index 0000000000..48bc55913d --- /dev/null +++ b/spring-jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2002-2012 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 + * + * http://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.jms.support.converter; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import javax.jms.BytesMessage; +import javax.jms.Session; +import javax.jms.TextMessage; + +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +/** + * @author Arjen Poutsma + * @author Dave Syer + */ +public class MappingJackson2MessageConverterTests { + + private MappingJackson2MessageConverter converter; + + private Session sessionMock; + + @Before + public void setUp() throws Exception { + sessionMock = createMock(Session.class); + converter = new MappingJackson2MessageConverter(); + converter.setEncodingPropertyName("__encoding__"); + converter.setTypeIdPropertyName("__typeid__"); + } + + @Test + public void toBytesMessage() throws Exception { + BytesMessage bytesMessageMock = createMock(BytesMessage.class); + Date toBeMarshalled = new Date(); + + expect(sessionMock.createBytesMessage()).andReturn(bytesMessageMock); + bytesMessageMock.setStringProperty("__encoding__", "UTF-8"); + bytesMessageMock.setStringProperty("__typeid__", Date.class.getName()); + bytesMessageMock.writeBytes(isA(byte[].class)); + + replay(sessionMock, bytesMessageMock); + + converter.toMessage(toBeMarshalled, sessionMock); + + verify(sessionMock, bytesMessageMock); + } + + @Test + public void fromBytesMessage() throws Exception { + BytesMessage bytesMessageMock = createMock(BytesMessage.class); + Map unmarshalled = Collections.singletonMap("foo", + "bar"); + + final byte[] bytes = "{\"foo\":\"bar\"}".getBytes(); + Capture captured = new Capture() { + @Override + public void setValue(byte[] value) { + super.setValue(value); + System.arraycopy(bytes, 0, value, 0, bytes.length); + } + }; + + expect( + bytesMessageMock.getStringProperty("__typeid__")) + .andReturn(Object.class.getName()); + expect( + bytesMessageMock.propertyExists("__encoding__")) + .andReturn(false); + expect(bytesMessageMock.getBodyLength()).andReturn( + new Long(bytes.length)); + expect(bytesMessageMock.readBytes(EasyMock.capture(captured))) + .andReturn(bytes.length); + + replay(sessionMock, bytesMessageMock); + + Object result = converter.fromMessage(bytesMessageMock); + assertEquals("Invalid result", result, unmarshalled); + + verify(sessionMock, bytesMessageMock); + } + + @Test + public void toTextMessageWithObject() throws Exception { + converter.setTargetType(MessageType.TEXT); + TextMessage textMessageMock = createMock(TextMessage.class); + Date toBeMarshalled = new Date(); + + textMessageMock.setStringProperty("__typeid__", Date.class.getName()); + expect(sessionMock.createTextMessage(isA(String.class))).andReturn( textMessageMock); + + replay(sessionMock, textMessageMock); + + converter.toMessage(toBeMarshalled, sessionMock); + + verify(sessionMock, textMessageMock); + } + + @Test + public void toTextMessageWithMap() throws Exception { + converter.setTargetType(MessageType.TEXT); + TextMessage textMessageMock = createMock(TextMessage.class); + Map toBeMarshalled = new HashMap(); + toBeMarshalled.put("foo", "bar"); + + textMessageMock.setStringProperty("__typeid__", HashMap.class.getName()); + expect(sessionMock.createTextMessage(isA(String.class))).andReturn( + textMessageMock); + + replay(sessionMock, textMessageMock); + + converter.toMessage(toBeMarshalled, sessionMock); + + verify(sessionMock, textMessageMock); + } + + @Test + public void fromTextMessageAsObject() throws Exception { + TextMessage textMessageMock = createMock(TextMessage.class); + Map unmarshalled = Collections.singletonMap("foo", + "bar"); + + String text = "{\"foo\":\"bar\"}"; + expect( + textMessageMock.getStringProperty("__typeid__")) + .andReturn(Object.class.getName()); + expect(textMessageMock.getText()).andReturn(text); + + replay(sessionMock, textMessageMock); + + Object result = converter.fromMessage(textMessageMock); + assertEquals("Invalid result", result, unmarshalled); + + verify(sessionMock, textMessageMock); + } + + @Test + public void fromTextMessageAsMap() throws Exception { + TextMessage textMessageMock = createMock(TextMessage.class); + Map unmarshalled = Collections.singletonMap("foo", + "bar"); + + String text = "{\"foo\":\"bar\"}"; + expect( + textMessageMock.getStringProperty("__typeid__")) + .andReturn(HashMap.class.getName()); + expect(textMessageMock.getText()).andReturn(text); + + replay(sessionMock, textMessageMock); + + Object result = converter.fromMessage(textMessageMock); + assertEquals("Invalid result", result, unmarshalled); + + verify(sessionMock, textMessageMock); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java index f2fe753839..c36c8c1958 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java @@ -49,11 +49,12 @@ import org.springframework.util.Assert; * * @author Arjen Poutsma * @author Keith Donald + * @author Rossen Stoyanchev * @since 3.1.2 * @see org.springframework.web.servlet.view.json.MappingJackson2JsonView */ public class MappingJackson2HttpMessageConverter extends AbstractHttpMessageConverter - implements GenericHttpMessageConverter { + implements GenericHttpMessageConverter { public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonHttpMessageConverter.java index 81fae185c6..3b8566e337 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonHttpMessageConverter.java @@ -21,7 +21,6 @@ import java.lang.reflect.Type; import java.nio.charset.Charset; import java.util.List; -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; import org.codehaus.jackson.JsonEncoding; import org.codehaus.jackson.JsonGenerator; import org.codehaus.jackson.JsonProcessingException; @@ -53,7 +52,7 @@ import org.springframework.util.Assert; * @see org.springframework.web.servlet.view.json.MappingJacksonJsonView */ public class MappingJacksonHttpMessageConverter extends AbstractHttpMessageConverter - implements GenericHttpMessageConverter { + implements GenericHttpMessageConverter { public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); @@ -112,7 +111,7 @@ public class MappingJacksonHttpMessageConverter extends AbstractHttpMessageConve } /** - * Whether to use the {@link DefaultPrettyPrinter} when writing JSON. + * Whether to use the {@link org.codehaus.jackson.impl.DefaultPrettyPrinter} when writing JSON. * This is a shortcut for setting up an {@code ObjectMapper} as follows: *
 	 * ObjectMapper mapper = new ObjectMapper();