SPR-7404: Added JsonMessageConverter, borrowing from Spring AMQP

This commit is contained in:
David Syer 2011-12-09 11:03:40 +00:00
parent 3beef9a92e
commit 7ec9292e54
6 changed files with 747 additions and 0 deletions

View File

@ -13,6 +13,7 @@
<conf name="commons-pool" extends="runtime" description="JARs needed to run with Commons Pool"/>
<conf name="jca" extends="runtime" description="JARs needed to develop JCA beans"/>
<conf name="oxm" extends="runtime" description="JARs needed to use the MarshallingMessageConverter"/>
<conf name="jackson" extends="runtime" description="JARs needed to use the Jackson JSON View"/>
</configurations>
<publications>
@ -35,6 +36,8 @@
conf="optional, commons-pool->compile"/>
<dependency org="org.easymock" name="com.springsource.org.easymock" rev="2.5.1" conf="test->compile"/>
<dependency org="org.junit" name="com.springsource.org.junit" rev="${junit.version}" conf="test->compile"/>
<dependency org="org.codehaus.jackson" name="com.springsource.org.codehaus.jackson.mapper" rev="1.4.2"
conf="optional, jackson->compile"/>
<dependency org="org.springframework" name="org.springframework.aop" rev="latest.integration"
conf="compile->compile"/>
<dependency org="org.springframework" name="org.springframework.beans" rev="latest.integration"

View File

@ -90,6 +90,12 @@
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-mapper-asl</artifactId>
<version>1.4.2</version>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<pluginManagement>

View File

@ -0,0 +1,143 @@
/*
* Copyright 2002-2010 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 static org.codehaus.jackson.map.type.TypeFactory.collectionType;
import static org.codehaus.jackson.map.type.TypeFactory.mapType;
import static org.codehaus.jackson.map.type.TypeFactory.type;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import javax.jms.JMSException;
import javax.jms.Message;
import org.codehaus.jackson.type.JavaType;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.ClassUtils;
/**
* Default implementation of {@link JavaTypeMapper} using hard coded message
* properties to store and retrieve the content type information.
*
* @author Mark Pollack
* @author Sam Nelson
* @author Dave Syer
**/
public class DefaultJavaTypeMapper implements JavaTypeMapper, InitializingBean {
public static final String CLASSID_PROPERTY_NAME = "__TypeId__";
public static final String CONTENT_CLASSID_PROPERTY_NAME = "__ContentTypeId__";
public static final String KEY_CLASSID_PROPERTY_NAME = "__KeyTypeId__";
private Map<String, Class<?>> idClassMapping = new HashMap<String, Class<?>>();
private Map<Class<?>, String> classIdMapping = new HashMap<Class<?>, String>();
@SuppressWarnings({ "unchecked", "rawtypes" })
public JavaType toJavaType(Message message) throws JMSException {
JavaType classType = getClassIdType(retrieveHeader(message,
CLASSID_PROPERTY_NAME));
if (!classType.isContainerType()) {
return classType;
}
JavaType contentClassType = getClassIdType(retrieveHeader(message,
CONTENT_CLASSID_PROPERTY_NAME));
if (classType.getKeyType() == null) {
return collectionType(
(Class<? extends Collection>) classType.getRawClass(),
contentClassType);
}
JavaType keyClassType = getClassIdType(retrieveHeader(message,
KEY_CLASSID_PROPERTY_NAME));
JavaType mapType = mapType(
(Class<? extends Map>) classType.getRawClass(), keyClassType,
contentClassType);
return mapType;
}
private JavaType getClassIdType(String classId) {
if (this.idClassMapping.containsKey(classId)) {
return type(idClassMapping.get(classId));
}
try {
return type(ClassUtils
.forName(classId, getClass().getClassLoader()));
} catch (ClassNotFoundException e) {
throw new MessageConversionException(
"failed to resolve class name. Class not found [" + classId
+ "]", e);
} catch (LinkageError e) {
throw new MessageConversionException(
"failed to resolve class name. Linkage error [" + classId
+ "]", e);
}
}
private String retrieveHeader(Message message, String headerName)
throws JMSException {
String classId = message.getStringProperty(headerName);
if (classId == null) {
throw new MessageConversionException(
"failed to convert Message content. Could not resolve "
+ headerName + " in header");
}
return classId;
}
public void setIdClassMapping(Map<String, Class<?>> idClassMapping) {
this.idClassMapping = idClassMapping;
}
public void fromJavaType(JavaType javaType, Message message)
throws JMSException {
addHeader(message, CLASSID_PROPERTY_NAME,
(Class<?>) javaType.getRawClass());
if (javaType.isContainerType()) {
addHeader(message, CONTENT_CLASSID_PROPERTY_NAME, javaType
.getContentType().getRawClass());
}
if (javaType.getKeyType() != null) {
addHeader(message, KEY_CLASSID_PROPERTY_NAME, javaType.getKeyType()
.getRawClass());
}
}
public void afterPropertiesSet() throws Exception {
validateIdTypeMapping();
}
private void addHeader(Message message, String headerName, Class<?> clazz)
throws JMSException {
if (classIdMapping.containsKey(clazz)) {
message.setStringProperty(headerName, classIdMapping.get(clazz));
} else {
message.setStringProperty(headerName, clazz.getName());
}
}
private void validateIdTypeMapping() {
Map<String, Class<?>> finalIdClassMapping = new HashMap<String, Class<?>>();
for (Entry<String, Class<?>> entry : idClassMapping.entrySet()) {
String id = entry.getKey();
Class<?> clazz = entry.getValue();
finalIdClassMapping.put(id, clazz);
classIdMapping.put(clazz, id);
}
this.idClassMapping = finalIdClassMapping;
}
}

View File

@ -0,0 +1,30 @@
/*
* Copyright 2002-2010 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 javax.jms.JMSException;
import javax.jms.Message;
import org.codehaus.jackson.type.JavaType;
/**
* Strategy for setting metadata on messages such that one can create the class that needs to be instantiated when
* receiving a message.
*
* @author Dave Syer
* @author Sam Nelson
*/
public interface JavaTypeMapper {
void fromJavaType(JavaType javaType, Message message) throws JMSException;
JavaType toJavaType(Message message) throws JMSException;
}

View File

@ -0,0 +1,359 @@
/*
* Copyright 2002-2010 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 javax.jms.BytesMessage;
import javax.jms.JMSException;
import javax.jms.Message;
import javax.jms.Session;
import javax.jms.TextMessage;
import javax.xml.transform.Result;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.map.DeserializationConfig;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.map.type.TypeFactory;
import org.codehaus.jackson.type.JavaType;
import org.springframework.oxm.Marshaller;
/**
* Message converter that uses the Jackson library to convert messages to and
* from JSON. Maps an object to a {@link BytesMessage}, or to a
* {@link TextMessage} if the {@link #setTargetType marshalTo} is set to
* {@link MessageType#TEXT}. Converts from a {@link TextMessage} or
* {@link BytesMessage} to an object.
*
* @author Mark Pollack
* @author James Carr
* @author Dave Syer
*/
public class JsonMessageConverter implements MessageConverter {
public static final String DEFAULT_CHARSET = "UTF-8";
public static final String DEFAULT_ENCODING_PROPERTY_NAME = "__Encoding__";
private volatile String defaultCharset = DEFAULT_CHARSET;
private MessageType targetType = MessageType.BYTES;
private ObjectMapper jsonObjectMapper = new ObjectMapper();
private JavaTypeMapper javaTypeMapper = new DefaultJavaTypeMapper();
private String encodingPropertyName = DEFAULT_ENCODING_PROPERTY_NAME;
public JsonMessageConverter() {
super();
initializeJsonObjectMapper();
}
/**
* Specify whether {@link #toMessage(Object, Session)} should marshal to a
* {@link BytesMessage} or a {@link TextMessage}.
* <p>
* The default is {@link MessageType#BYTES}, i.e. this converter marshals to
* a {@link BytesMessage}. Note that the default version of this converter
* supports {@link MessageType#BYTES} and {@link MessageType#TEXT} only.
*
* @see MessageType#BYTES
* @see MessageType#TEXT
*/
public void setTargetType(MessageType targetType) {
this.targetType = targetType;
}
/**
* A mapper to extract a Jackson {@link JavaType} from a message.
*
* @param javaTypeMapper
* the javaTypeMapper to set
*/
public void setJavaTypeMapper(JavaTypeMapper javaTypeMapper) {
this.javaTypeMapper = javaTypeMapper;
}
/**
* Specify the default charset to use when converting from and from
* text-based Message body content. If not specified, the charset will be
* "UTF-8".
*/
public void setDefaultCharset(String defaultCharset) {
this.defaultCharset = (defaultCharset != null) ? defaultCharset
: DEFAULT_CHARSET;
}
/**
* 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.
*
* @param encodingPropertyName
* the name of the message property
*/
public void setEncodingPropertyName(String encodingPropertyName) {
this.encodingPropertyName = encodingPropertyName;
}
/**
* The {@link ObjectMapper} to use instead of using the default. An
* alternative to injecting a mapper is to extend this class and override
* {@link #initializeJsonObjectMapper()}.
*
* @param jsonObjectMapper
* the object mapper to set
*/
public void setJsonObjectMapper(ObjectMapper jsonObjectMapper) {
this.jsonObjectMapper = jsonObjectMapper;
}
/**
* Subclass and override to customize the mapper.
*/
protected void initializeJsonObjectMapper() {
jsonObjectMapper
.configure(
DeserializationConfig.Feature.FAIL_ON_UNKNOWN_PROPERTIES,
false);
}
public Message toMessage(Object object, Session session)
throws JMSException, MessageConversionException {
Message message;
try {
switch (this.targetType) {
case TEXT:
message = mapToTextMessage(object, session,
this.jsonObjectMapper);
break;
case BYTES:
message = mapToBytesMessage(object, session,
this.jsonObjectMapper);
break;
default:
message = mapToMessage(object, session, this.jsonObjectMapper,
this.targetType);
}
} catch (JsonMappingException ex) {
throw new MessageConversionException("Could not map [" + object
+ "]", ex);
} catch (IOException ex) {
throw new MessageConversionException("Could not map [" + object
+ "]", ex);
}
javaTypeMapper.fromJavaType(TypeFactory.type(object.getClass()),
message);
return message;
}
/**
* Map the given object to a {@link TextMessage}.
* @param object the object to be mapped
* @param session current JMS session
* @param jsonObjectMapper the mapper to use
* @return the resulting message
* @throws JMSException if thrown by JMS methods
* @throws IOException in case of I/O errors
* @throws JsonMappingException in case of Jackson mapping errors
* @see Session#createBytesMessage
* @see Marshaller#marshal(Object, Result)
*/
protected Message mapToTextMessage(Object object, Session session,
ObjectMapper jsonObjectMapper) throws JsonMappingException,
IOException, JMSException {
StringWriter writer = new StringWriter();
jsonObjectMapper.writeValue(writer, object);
return session.createTextMessage(writer.toString());
}
/**
* Map the given object to a {@link BytesMessage}.
* @param object the object to be mapped
* @param session current JMS session
* @param jsonObjectMapper the mapper to use
* @return the resulting message
* @throws JMSException if thrown by JMS methods
* @throws IOException in case of I/O errors
* @throws JsonMappingException in case of Jackson mapping errors
* @see Session#createBytesMessage
* @see Marshaller#marshal(Object, Result)
*/
protected Message mapToBytesMessage(Object object, Session session,
ObjectMapper jsonObjectMapper) throws JsonMappingException,
IOException, JMSException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(bos, defaultCharset);
jsonObjectMapper.writeValue(writer, object);
BytesMessage message = session.createBytesMessage();
message.writeBytes(bos.toByteArray());
message.setStringProperty(encodingPropertyName, defaultCharset);
return message;
}
/**
* Template method that allows for custom message mapping. Invoked when
* {@link #setTargetType} is not {@link MessageType#TEXT} or
* {@link MessageType#BYTES}.
* <p>
* The default implementation throws an {@link IllegalArgumentException}.
*
* @param object
* the object to marshal
* @param session
* the JMS session
* @param jsonObjectMapper
* the mapper to use
* @param targetType
* the target message type (other than TEXT or BYTES)
* @return the resulting message
* @throws JMSException
* if thrown by JMS methods
* @throws IOException
* in case of I/O errors
* @throws JsonMappingException
* in case of Jackson mapping errors
*/
protected Message mapToMessage(Object object, Session session,
ObjectMapper jsonObjectMapper, MessageType targetType)
throws JsonMappingException, IOException, JMSException {
throw new IllegalArgumentException("Unsupported message type ["
+ targetType + "]. Cannot map to the specified message type.");
}
public Object fromMessage(Message message) throws JMSException,
MessageConversionException {
Object content = null;
try {
JavaType targetJavaType = javaTypeMapper.toJavaType(message);
content = convertToObject(message, targetJavaType);
} catch (JsonParseException e) {
throw new MessageConversionException(
"Failed to convert Message content", e);
} catch (JsonMappingException e) {
throw new MessageConversionException(
"Failed to convert Message content", e);
} catch (IOException e) {
throw new MessageConversionException(
"Failed to convert Message content", e);
}
return content;
}
/**
* Convenience method to dispatch to converters for individual message
* types.
*/
private Object convertToObject(Message message, JavaType targetJavaType)
throws JMSException, JsonParseException, JsonMappingException,
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 generic Message to a Java Object with the specified type.
* Default implementation throws IllegalArgumentException.
*
* @param message
* the input message
* @param targetJavaType
* the target type
* @return the message converted to an object
* @throws JsonParseException
* if thrown by Jackson
* @throws JsonMappingException
* if thrown by Jackson
* @throws IOException
* in case of I/O errors
* @throws JMSException
* if thrown by JMS
*/
protected Object convertFromMessage(Message message, JavaType targetJavaType) {
throw new IllegalArgumentException(
"JsonMessageConverter only supports TextMessages and BytesMessages");
}
/**
* 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 JsonParseException
* if thrown by Jackson
* @throws JsonMappingException
* if thrown by Jackson
* @throws IOException
* in case of I/O errors
* @throws JMSException
* if thrown by JMS
*/
protected Object convertFromTextMessage(TextMessage message,
JavaType targetJavaType) throws JsonParseException,
JsonMappingException, IOException, JMSException {
String body = message.getText();
return jsonObjectMapper.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 JsonParseException
* if thrown by Jackson
* @throws JsonMappingException
* if thrown by Jackson
* @throws IOException
* in case of I/O errors
* @throws JMSException
* if thrown by JMS
*/
protected Object convertFromBytesMessage(BytesMessage message,
JavaType targetJavaType) throws JsonParseException,
JsonMappingException, IOException, JMSException {
String encoding = defaultCharset;
if (message.propertyExists(encodingPropertyName)) {
encoding = message.getStringProperty(encodingPropertyName);
}
byte[] bytes = new byte[(int) message.getBodyLength()];
message.readBytes(bytes);
try {
String body = new String(bytes, encoding);
return jsonObjectMapper.readValue(body, targetJavaType);
} catch (UnsupportedEncodingException e) {
throw new MessageConversionException(
"Cannot convert bytes to String", e);
}
}
}

View File

@ -0,0 +1,206 @@
/*
* Copyright 2002-2009 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 static org.easymock.EasyMock.createMock;
import static org.easymock.EasyMock.expect;
import static org.easymock.EasyMock.isA;
import static org.easymock.EasyMock.replay;
import static org.easymock.EasyMock.verify;
import static org.junit.Assert.assertEquals;
import java.util.Collections;
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;
/**
* @author Arjen Poutsma
* @author Dave Syer
*/
public class JsonMessageConverterTests {
private JsonMessageConverter converter;
private Session sessionMock;
@Before
public void setUp() throws Exception {
sessionMock = createMock(Session.class);
converter = new JsonMessageConverter();
}
@Test
public void toBytesMessage() throws Exception {
BytesMessage bytesMessageMock = createMock(BytesMessage.class);
Object toBeMarshalled = new Object();
expect(sessionMock.createBytesMessage()).andReturn(bytesMessageMock);
bytesMessageMock.setStringProperty(
JsonMessageConverter.DEFAULT_ENCODING_PROPERTY_NAME, "UTF-8");
bytesMessageMock.setStringProperty(
DefaultJavaTypeMapper.CLASSID_PROPERTY_NAME,
Object.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<String, String> unmarshalled = Collections.singletonMap("foo",
"bar");
final byte[] bytes = "{\"foo\":\"bar\"}".getBytes();
Capture<byte[]> captured = new Capture<byte[]>() {
@Override
public void setValue(byte[] value) {
super.setValue(value);
System.arraycopy(bytes, 0, value, 0, bytes.length);
}
};
expect(
bytesMessageMock
.getStringProperty(DefaultJavaTypeMapper.CLASSID_PROPERTY_NAME))
.andReturn(Object.class.getName());
expect(
bytesMessageMock
.propertyExists(JsonMessageConverter.DEFAULT_ENCODING_PROPERTY_NAME))
.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);
Object toBeMarshalled = new Object();
textMessageMock.setStringProperty(
DefaultJavaTypeMapper.CLASSID_PROPERTY_NAME,
Object.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<String, String> toBeMarshalled = new HashMap<String, String>();
toBeMarshalled.put("foo", "bar");
textMessageMock.setStringProperty(
DefaultJavaTypeMapper.CLASSID_PROPERTY_NAME,
HashMap.class.getName());
textMessageMock.setStringProperty(
DefaultJavaTypeMapper.CONTENT_CLASSID_PROPERTY_NAME,
Object.class.getName());
textMessageMock.setStringProperty(
DefaultJavaTypeMapper.KEY_CLASSID_PROPERTY_NAME,
Object.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<String, String> unmarshalled = Collections.singletonMap("foo",
"bar");
String text = "{\"foo\":\"bar\"}";
expect(
textMessageMock
.getStringProperty(DefaultJavaTypeMapper.CLASSID_PROPERTY_NAME))
.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<String, String> unmarshalled = Collections.singletonMap("foo",
"bar");
String text = "{\"foo\":\"bar\"}";
expect(
textMessageMock
.getStringProperty(DefaultJavaTypeMapper.CLASSID_PROPERTY_NAME))
.andReturn(HashMap.class.getName());
expect(
textMessageMock
.getStringProperty(DefaultJavaTypeMapper.CONTENT_CLASSID_PROPERTY_NAME))
.andReturn(Object.class.getName());
expect(
textMessageMock
.getStringProperty(DefaultJavaTypeMapper.KEY_CLASSID_PROPERTY_NAME))
.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);
}
}