From 778693f9f84fc55589199b6d89ffdc3d484377e4 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 19 Dec 2012 13:57:19 +0100 Subject: [PATCH] Added MappingJackson2MessageConverter for JMS Issue: SPR-10099 --- org.springframework.jms/.classpath | 3 + org.springframework.jms/ivy.xml | 2 + org.springframework.jms/jms.iml | 33 ++ .../MappingJackson2MessageConverter.java | 370 ++++++++++++++++++ .../MappingJackson2MessageConverterTests.java | 175 +++++++++ org.springframework.jms/template.mf | 1 + org.springframework.web/template.mf | 2 +- org.springframework.web/web.iml | 8 +- 8 files changed, 589 insertions(+), 5 deletions(-) create mode 100644 org.springframework.jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java create mode 100644 org.springframework.jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java diff --git a/org.springframework.jms/.classpath b/org.springframework.jms/.classpath index 3c9ebe937a..9114b3fe70 100644 --- a/org.springframework.jms/.classpath +++ b/org.springframework.jms/.classpath @@ -20,6 +20,9 @@ + + + diff --git a/org.springframework.jms/ivy.xml b/org.springframework.jms/ivy.xml index 4a9c8b0eb3..6cb0b591f8 100644 --- a/org.springframework.jms/ivy.xml +++ b/org.springframework.jms/ivy.xml @@ -38,6 +38,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/org.springframework.jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java b/org.springframework.jms/src/main/java/org/springframework/jms/support/converter/MappingJackson2MessageConverter.java new file mode 100644 index 0000000000..880ae9c75c --- /dev/null +++ b/org.springframework.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/org.springframework.jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java b/org.springframework.jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java new file mode 100644 index 0000000000..9416822af0 --- /dev/null +++ b/org.springframework.jms/src/test/java/org/springframework/jms/support/converter/MappingJackson2MessageConverterTests.java @@ -0,0 +1,175 @@ +/* + * 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/org.springframework.jms/template.mf b/org.springframework.jms/template.mf index 09cc73f63b..1a48550b36 100644 --- a/org.springframework.jms/template.mf +++ b/org.springframework.jms/template.mf @@ -3,6 +3,7 @@ Bundle-Name: Spring JMS Bundle-Vendor: SpringSource Bundle-ManifestVersion: 2 Import-Template: + com.fasterxml.jackson.*;version="[2.0.0, 3.0.0)";resolution:=optional, javax.jms.*;version="[1.1.0, 2.0.0)", javax.naming.*;version="0";resolution:=optional, javax.resource.*;version="[1.5.0, 2.0.0)";resolution:=optional, diff --git a/org.springframework.web/template.mf b/org.springframework.web/template.mf index 16770ab71b..247ac193dd 100644 --- a/org.springframework.web/template.mf +++ b/org.springframework.web/template.mf @@ -5,7 +5,6 @@ Bundle-ManifestVersion: 2 Import-Template: com.caucho.*;version="[3.2.0, 5.0.0)";resolution:=optional, com.sun.syndication.*;version="[1.0.0, 2.0.0)";resolution:=optional, - org.codehaus.jackson.*;version="[1.3.0, 2.0.0)";resolution:=optional, com.fasterxml.jackson.*;version="[2.0.0, 3.0.0)";resolution:=optional, com.sun.net.*;version="0";resolution:=optional, javax.activation.*;version="0";resolution:=optional, @@ -25,6 +24,7 @@ Import-Template: org.apache.commons.logging.*;version="[1.1.1, 2.0.0)", org.apache.http.*;version="[4.1.0, 5.0.0)";resolution:=optional, org.apache.log4j.*;version="[1.2.15, 2.0.0)";resolution:=optional, + org.codehaus.jackson.*;version="[1.3.0, 2.0.0)";resolution:=optional, org.springframework.aop.*;version=${spring.osgi.range}, org.springframework.beans.*;version=${spring.osgi.range}, org.springframework.context.*;version=${spring.osgi.range}, diff --git a/org.springframework.web/web.iml b/org.springframework.web/web.iml index 0e05b53ab5..a8f3dee2a1 100644 --- a/org.springframework.web/web.iml +++ b/org.springframework.web/web.iml @@ -155,22 +155,22 @@ - + - + - + - +