001/* 002 * Copyright 2002-2020 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.jms.support.converter; 018 019import java.io.ByteArrayOutputStream; 020import java.io.IOException; 021import java.io.OutputStreamWriter; 022import java.io.StringWriter; 023import java.io.UnsupportedEncodingException; 024import java.util.HashMap; 025import java.util.Map; 026 027import javax.jms.BytesMessage; 028import javax.jms.JMSException; 029import javax.jms.Message; 030import javax.jms.Session; 031import javax.jms.TextMessage; 032 033import com.fasterxml.jackson.annotation.JsonView; 034import com.fasterxml.jackson.databind.DeserializationFeature; 035import com.fasterxml.jackson.databind.JavaType; 036import com.fasterxml.jackson.databind.MapperFeature; 037import com.fasterxml.jackson.databind.ObjectMapper; 038import com.fasterxml.jackson.databind.ObjectWriter; 039 040import org.springframework.beans.factory.BeanClassLoaderAware; 041import org.springframework.core.MethodParameter; 042import org.springframework.lang.Nullable; 043import org.springframework.util.Assert; 044import org.springframework.util.ClassUtils; 045 046/** 047 * Message converter that uses Jackson 2.x to convert messages to and from JSON. 048 * Maps an object to a {@link BytesMessage}, or to a {@link TextMessage} if the 049 * {@link #setTargetType targetType} is set to {@link MessageType#TEXT}. 050 * Converts from a {@link TextMessage} or {@link BytesMessage} to an object. 051 * 052 * <p>It customizes Jackson's default properties with the following ones: 053 * <ul> 054 * <li>{@link MapperFeature#DEFAULT_VIEW_INCLUSION} is disabled</li> 055 * <li>{@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled</li> 056 * </ul> 057 * 058 * <p>Compatible with Jackson 2.6 and higher, as of Spring 4.3. 059 * 060 * @author Mark Pollack 061 * @author Dave Syer 062 * @author Juergen Hoeller 063 * @author Stephane Nicoll 064 * @since 3.1.4 065 */ 066public class MappingJackson2MessageConverter implements SmartMessageConverter, BeanClassLoaderAware { 067 068 /** 069 * The default encoding used for writing to text messages: UTF-8. 070 */ 071 public static final String DEFAULT_ENCODING = "UTF-8"; 072 073 074 private ObjectMapper objectMapper; 075 076 private MessageType targetType = MessageType.BYTES; 077 078 @Nullable 079 private String encoding; 080 081 @Nullable 082 private String encodingPropertyName; 083 084 @Nullable 085 private String typeIdPropertyName; 086 087 private Map<String, Class<?>> idClassMappings = new HashMap<>(); 088 089 private Map<Class<?>, String> classIdMappings = new HashMap<>(); 090 091 @Nullable 092 private ClassLoader beanClassLoader; 093 094 095 public MappingJackson2MessageConverter() { 096 this.objectMapper = new ObjectMapper(); 097 this.objectMapper.configure(MapperFeature.DEFAULT_VIEW_INCLUSION, false); 098 this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); 099 } 100 101 /** 102 * Specify the {@link ObjectMapper} to use instead of using the default. 103 */ 104 public void setObjectMapper(ObjectMapper objectMapper) { 105 Assert.notNull(objectMapper, "ObjectMapper must not be null"); 106 this.objectMapper = objectMapper; 107 } 108 109 /** 110 * Specify whether {@link #toMessage(Object, Session)} should marshal to a 111 * {@link BytesMessage} or a {@link TextMessage}. 112 * <p>The default is {@link MessageType#BYTES}, i.e. this converter marshals to 113 * a {@link BytesMessage}. Note that the default version of this converter 114 * supports {@link MessageType#BYTES} and {@link MessageType#TEXT} only. 115 * @see MessageType#BYTES 116 * @see MessageType#TEXT 117 */ 118 public void setTargetType(MessageType targetType) { 119 Assert.notNull(targetType, "MessageType must not be null"); 120 this.targetType = targetType; 121 } 122 123 /** 124 * Specify the encoding to use when converting to and from text-based 125 * message body content. The default encoding will be "UTF-8". 126 * <p>When reading from a text-based message, an encoding may have been 127 * suggested through a special JMS property which will then be preferred 128 * over the encoding set on this MessageConverter instance. 129 * @see #setEncodingPropertyName 130 */ 131 public void setEncoding(String encoding) { 132 this.encoding = encoding; 133 } 134 135 /** 136 * Specify the name of the JMS message property that carries the encoding from 137 * bytes to String and back is BytesMessage is used during the conversion process. 138 * <p>Default is none. Setting this property is optional; if not set, UTF-8 will 139 * be used for decoding any incoming bytes message. 140 * @see #setEncoding 141 */ 142 public void setEncodingPropertyName(String encodingPropertyName) { 143 this.encodingPropertyName = encodingPropertyName; 144 } 145 146 /** 147 * Specify the name of the JMS message property that carries the type id for the 148 * contained object: either a mapped id value or a raw Java class name. 149 * <p>Default is none. <b>NOTE: This property needs to be set in order to allow 150 * for converting from an incoming message to a Java object.</b> 151 * @see #setTypeIdMappings 152 */ 153 public void setTypeIdPropertyName(String typeIdPropertyName) { 154 this.typeIdPropertyName = typeIdPropertyName; 155 } 156 157 /** 158 * Specify mappings from type ids to Java classes, if desired. 159 * This allows for synthetic ids in the type id message property, 160 * instead of transferring Java class names. 161 * <p>Default is no custom mappings, i.e. transferring raw Java class names. 162 * @param typeIdMappings a Map with type id values as keys and Java classes as values 163 */ 164 public void setTypeIdMappings(Map<String, Class<?>> typeIdMappings) { 165 this.idClassMappings = new HashMap<>(); 166 typeIdMappings.forEach((id, clazz) -> { 167 this.idClassMappings.put(id, clazz); 168 this.classIdMappings.put(clazz, id); 169 }); 170 } 171 172 @Override 173 public void setBeanClassLoader(ClassLoader classLoader) { 174 this.beanClassLoader = classLoader; 175 } 176 177 178 @Override 179 public Message toMessage(Object object, Session session) throws JMSException, MessageConversionException { 180 Message message; 181 try { 182 switch (this.targetType) { 183 case TEXT: 184 message = mapToTextMessage(object, session, this.objectMapper.writer()); 185 break; 186 case BYTES: 187 message = mapToBytesMessage(object, session, this.objectMapper.writer()); 188 break; 189 default: 190 message = mapToMessage(object, session, this.objectMapper.writer(), this.targetType); 191 } 192 } 193 catch (IOException ex) { 194 throw new MessageConversionException("Could not map JSON object [" + object + "]", ex); 195 } 196 setTypeIdOnMessage(object, message); 197 return message; 198 } 199 200 @Override 201 public Message toMessage(Object object, Session session, @Nullable Object conversionHint) 202 throws JMSException, MessageConversionException { 203 204 return toMessage(object, session, getSerializationView(conversionHint)); 205 } 206 207 /** 208 * Convert a Java object to a JMS Message using the specified json view 209 * and the supplied session to create the message object. 210 * @param object the object to convert 211 * @param session the Session to use for creating a JMS Message 212 * @param jsonView the view to use to filter the content 213 * @return the JMS Message 214 * @throws javax.jms.JMSException if thrown by JMS API methods 215 * @throws MessageConversionException in case of conversion failure 216 * @since 4.3 217 */ 218 public Message toMessage(Object object, Session session, @Nullable Class<?> jsonView) 219 throws JMSException, MessageConversionException { 220 221 if (jsonView != null) { 222 return toMessage(object, session, this.objectMapper.writerWithView(jsonView)); 223 } 224 else { 225 return toMessage(object, session, this.objectMapper.writer()); 226 } 227 } 228 229 @Override 230 public Object fromMessage(Message message) throws JMSException, MessageConversionException { 231 try { 232 JavaType targetJavaType = getJavaTypeForMessage(message); 233 return convertToObject(message, targetJavaType); 234 } 235 catch (IOException ex) { 236 throw new MessageConversionException("Failed to convert JSON message content", ex); 237 } 238 } 239 240 protected Message toMessage(Object object, Session session, ObjectWriter objectWriter) 241 throws JMSException, MessageConversionException { 242 243 Message message; 244 try { 245 switch (this.targetType) { 246 case TEXT: 247 message = mapToTextMessage(object, session, objectWriter); 248 break; 249 case BYTES: 250 message = mapToBytesMessage(object, session, objectWriter); 251 break; 252 default: 253 message = mapToMessage(object, session, objectWriter, this.targetType); 254 } 255 } 256 catch (IOException ex) { 257 throw new MessageConversionException("Could not map JSON object [" + object + "]", ex); 258 } 259 setTypeIdOnMessage(object, message); 260 return message; 261 } 262 263 264 /** 265 * Map the given object to a {@link TextMessage}. 266 * @param object the object to be mapped 267 * @param session current JMS session 268 * @param objectWriter the writer to use 269 * @return the resulting message 270 * @throws JMSException if thrown by JMS methods 271 * @throws IOException in case of I/O errors 272 * @since 4.3 273 * @see Session#createBytesMessage 274 */ 275 protected TextMessage mapToTextMessage(Object object, Session session, ObjectWriter objectWriter) 276 throws JMSException, IOException { 277 278 StringWriter writer = new StringWriter(1024); 279 objectWriter.writeValue(writer, object); 280 return session.createTextMessage(writer.toString()); 281 } 282 283 /** 284 * Map the given object to a {@link BytesMessage}. 285 * @param object the object to be mapped 286 * @param session current JMS session 287 * @param objectWriter the writer to use 288 * @return the resulting message 289 * @throws JMSException if thrown by JMS methods 290 * @throws IOException in case of I/O errors 291 * @since 4.3 292 * @see Session#createBytesMessage 293 */ 294 protected BytesMessage mapToBytesMessage(Object object, Session session, ObjectWriter objectWriter) 295 throws JMSException, IOException { 296 297 ByteArrayOutputStream bos = new ByteArrayOutputStream(1024); 298 if (this.encoding != null) { 299 OutputStreamWriter writer = new OutputStreamWriter(bos, this.encoding); 300 objectWriter.writeValue(writer, object); 301 } 302 else { 303 // Jackson usually defaults to UTF-8 but can also go straight to bytes, e.g. for Smile. 304 // We use a direct byte array argument for the latter case to work as well. 305 objectWriter.writeValue(bos, object); 306 } 307 308 BytesMessage message = session.createBytesMessage(); 309 message.writeBytes(bos.toByteArray()); 310 if (this.encodingPropertyName != null) { 311 message.setStringProperty(this.encodingPropertyName, 312 (this.encoding != null ? this.encoding : DEFAULT_ENCODING)); 313 } 314 return message; 315 } 316 317 /** 318 * Template method that allows for custom message mapping. 319 * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or 320 * {@link MessageType#BYTES}. 321 * <p>The default implementation throws an {@link IllegalArgumentException}. 322 * @param object the object to marshal 323 * @param session the JMS Session 324 * @param objectWriter the writer to use 325 * @param targetType the target message type (other than TEXT or BYTES) 326 * @return the resulting message 327 * @throws JMSException if thrown by JMS methods 328 * @throws IOException in case of I/O errors 329 */ 330 protected Message mapToMessage(Object object, Session session, ObjectWriter objectWriter, MessageType targetType) 331 throws JMSException, IOException { 332 333 throw new IllegalArgumentException("Unsupported message type [" + targetType + 334 "]. MappingJackson2MessageConverter by default only supports TextMessages and BytesMessages."); 335 } 336 337 /** 338 * Set a type id for the given payload object on the given JMS Message. 339 * <p>The default implementation consults the configured type id mapping and 340 * sets the resulting value (either a mapped id or the raw Java class name) 341 * into the configured type id message property. 342 * @param object the payload object to set a type id for 343 * @param message the JMS Message on which to set the type id property 344 * @throws JMSException if thrown by JMS methods 345 * @see #getJavaTypeForMessage(javax.jms.Message) 346 * @see #setTypeIdPropertyName(String) 347 * @see #setTypeIdMappings(java.util.Map) 348 */ 349 protected void setTypeIdOnMessage(Object object, Message message) throws JMSException { 350 if (this.typeIdPropertyName != null) { 351 String typeId = this.classIdMappings.get(object.getClass()); 352 if (typeId == null) { 353 typeId = object.getClass().getName(); 354 } 355 message.setStringProperty(this.typeIdPropertyName, typeId); 356 } 357 } 358 359 /** 360 * Convenience method to dispatch to converters for individual message types. 361 */ 362 private Object convertToObject(Message message, JavaType targetJavaType) throws JMSException, IOException { 363 if (message instanceof TextMessage) { 364 return convertFromTextMessage((TextMessage) message, targetJavaType); 365 } 366 else if (message instanceof BytesMessage) { 367 return convertFromBytesMessage((BytesMessage) message, targetJavaType); 368 } 369 else { 370 return convertFromMessage(message, targetJavaType); 371 } 372 } 373 374 /** 375 * Convert a TextMessage to a Java Object with the specified type. 376 * @param message the input message 377 * @param targetJavaType the target type 378 * @return the message converted to an object 379 * @throws JMSException if thrown by JMS 380 * @throws IOException in case of I/O errors 381 */ 382 protected Object convertFromTextMessage(TextMessage message, JavaType targetJavaType) 383 throws JMSException, IOException { 384 385 String body = message.getText(); 386 return this.objectMapper.readValue(body, targetJavaType); 387 } 388 389 /** 390 * Convert a BytesMessage to a Java Object with the specified type. 391 * @param message the input message 392 * @param targetJavaType the target type 393 * @return the message converted to an object 394 * @throws JMSException if thrown by JMS 395 * @throws IOException in case of I/O errors 396 */ 397 protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJavaType) 398 throws JMSException, IOException { 399 400 String encoding = this.encoding; 401 if (this.encodingPropertyName != null && message.propertyExists(this.encodingPropertyName)) { 402 encoding = message.getStringProperty(this.encodingPropertyName); 403 } 404 byte[] bytes = new byte[(int) message.getBodyLength()]; 405 message.readBytes(bytes); 406 if (encoding != null) { 407 try { 408 String body = new String(bytes, encoding); 409 return this.objectMapper.readValue(body, targetJavaType); 410 } 411 catch (UnsupportedEncodingException ex) { 412 throw new MessageConversionException("Cannot convert bytes to String", ex); 413 } 414 } 415 else { 416 // Jackson internally performs encoding detection, falling back to UTF-8. 417 return this.objectMapper.readValue(bytes, targetJavaType); 418 } 419 } 420 421 /** 422 * Template method that allows for custom message mapping. 423 * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or 424 * {@link MessageType#BYTES}. 425 * <p>The default implementation throws an {@link IllegalArgumentException}. 426 * @param message the input message 427 * @param targetJavaType the target type 428 * @return the message converted to an object 429 * @throws JMSException if thrown by JMS 430 * @throws IOException in case of I/O errors 431 */ 432 protected Object convertFromMessage(Message message, JavaType targetJavaType) 433 throws JMSException, IOException { 434 435 throw new IllegalArgumentException("Unsupported message type [" + message.getClass() + 436 "]. MappingJacksonMessageConverter by default only supports TextMessages and BytesMessages."); 437 } 438 439 /** 440 * Determine a Jackson JavaType for the given JMS Message, 441 * typically parsing a type id message property. 442 * <p>The default implementation parses the configured type id property name 443 * and consults the configured type id mapping. This can be overridden with 444 * a different strategy, e.g. doing some heuristics based on message origin. 445 * @param message the JMS Message from which to get the type id property 446 * @throws JMSException if thrown by JMS methods 447 * @see #setTypeIdOnMessage(Object, javax.jms.Message) 448 * @see #setTypeIdPropertyName(String) 449 * @see #setTypeIdMappings(java.util.Map) 450 */ 451 protected JavaType getJavaTypeForMessage(Message message) throws JMSException { 452 String typeId = message.getStringProperty(this.typeIdPropertyName); 453 if (typeId == null) { 454 throw new MessageConversionException( 455 "Could not find type id property [" + this.typeIdPropertyName + "] on message [" + 456 message.getJMSMessageID() + "] from destination [" + message.getJMSDestination() + "]"); 457 } 458 Class<?> mappedClass = this.idClassMappings.get(typeId); 459 if (mappedClass != null) { 460 return this.objectMapper.constructType(mappedClass); 461 } 462 try { 463 Class<?> typeClass = ClassUtils.forName(typeId, this.beanClassLoader); 464 return this.objectMapper.constructType(typeClass); 465 } 466 catch (Throwable ex) { 467 throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex); 468 } 469 } 470 471 /** 472 * Determine a Jackson serialization view based on the given conversion hint. 473 * @param conversionHint the conversion hint Object as passed into the 474 * converter for the current conversion attempt 475 * @return the serialization view class, or {@code null} if none 476 */ 477 @Nullable 478 protected Class<?> getSerializationView(@Nullable Object conversionHint) { 479 if (conversionHint instanceof MethodParameter) { 480 MethodParameter methodParam = (MethodParameter) conversionHint; 481 JsonView annotation = methodParam.getParameterAnnotation(JsonView.class); 482 if (annotation == null) { 483 annotation = methodParam.getMethodAnnotation(JsonView.class); 484 if (annotation == null) { 485 return null; 486 } 487 } 488 return extractViewClass(annotation, conversionHint); 489 } 490 else if (conversionHint instanceof JsonView) { 491 return extractViewClass((JsonView) conversionHint, conversionHint); 492 } 493 else if (conversionHint instanceof Class) { 494 return (Class<?>) conversionHint; 495 } 496 else { 497 return null; 498 } 499 } 500 501 private Class<?> extractViewClass(JsonView annotation, Object conversionHint) { 502 Class<?>[] classes = annotation.value(); 503 if (classes.length != 1) { 504 throw new IllegalArgumentException( 505 "@JsonView only supported for handler methods with exactly 1 class argument: " + conversionHint); 506 } 507 return classes[0]; 508 } 509 510}