Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Generic Map for JAXB

Tags:

java

jaxb

Ok, so we all know that Maps are somewhat of a pain in JAXB. I here present an alternative to the current solutions. My main objective is to get feedback on any and all potential problems with this solution. Maybe it is not even a good solution for some reasons.

When I played around with the standard Generic Map Adapter it seemed like the adapters for the classes were not used. The classes are instead scanned, forcing me to mark my data model with JAXB annotations and adding default constructors where I don't want them (I'm talking about complex classes that I want to store in Maps, not simple data types). Above all, this makes my internal data model public thereby breaking encapsulation since the generated XML is a direct representation of the internal structures.

The "workaround" I did was to combine the adapter with the Marshall.Listener and Unmarshall.Listner thereby being able to extract additional annotation information. A field would then be

@XmlElement(name = "testMap")
@XmlJavaTypeAdapter(MapAdapter.class)
@MapKeyValueAdapters(key=SomeComplexClassAdapter.class)
private final HashMap<SomeComplexClass, String> testMap2 = new HashMap<SomeComplexClass, String>();

This additional annotation accepts both key and value as arguments. If omitted the functionality falls back on standard qualification for the omitted. The example above will use the given adapter for the key and standard handling for the value. Here the annotation.

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import javax.xml.bind.annotation.adapters.XmlAdapter;

/**
 * This annotation holds the adapters for the key and value used in the MapAdapter.
 */
@Retention(RUNTIME)
@Target({ FIELD })
public @interface MapKeyValueAdapters {

  /**
   * Points to the class that converts the value type to a bound type or vice versa. See {@link XmlAdapter} for more
   * details.
   */
  Class<? extends XmlAdapter<?, ?>> key() default UNDEFINED.class;

  /**
   * Points to the class that converts the value type to a bound type or vice versa. See {@link XmlAdapter} for more
   * details.
   */
  Class<? extends XmlAdapter<?, ?>> value() default UNDEFINED.class;

  static final class UNDEFINED extends XmlAdapter<String, String> {

    @Override
    public String unmarshal(String v) throws Exception {
      return null;
    }

    @Override
    public String marshal(String v) throws Exception {
      return null;
    }
  }
}

Here so the adapter

import java.io.StringReader;
import java.io.StringWriter;
import java.lang.annotation.Annotation;
import java.lang.annotation.IncompleteAnnotationException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBElement;
import javax.xml.bind.JAXBIntrospector;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlElementWrapper;
import javax.xml.bind.annotation.XmlType;
import javax.xml.bind.annotation.adapters.XmlAdapter;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import javax.xml.namespace.QName;

/**
 * This class represents a general purpose Map adapter. It is capable of handling any type of class implementing the Map
 * interface and has a no-args constructor.
 */
public class MapAdapter extends XmlAdapter<MapAdapter.Wrapper, Map<Object, Object>> {

  private static final String XSI_NS = "xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"";
  private static final String XSI_TYPE = "xsi:type";

  private static final String CDATA_START = "<![CDATA[";
  private static final String CDATA_END = "]]>";

  private final MarshallerListener marshallerListener = new MarshallerListener();
  private final UnmarshallerListener unmarshallerListener = new UnmarshallerListener();

  private final JAXBContext context;

  public MapAdapter(JAXBContext inContext) {
    context = inContext;
  }

  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Override
  public Map<Object, Object> unmarshal(Wrapper inWrapper) throws Exception {
    if (inWrapper == null) {
      return null;
    }
    Info info = null;
    for (Info element : unmarshallerListener.infoList) {
      if (element.field.equals(inWrapper.field)) {
        info = element;
      }
    }
    if (info != null) {
      Class<Map<Object, Object>> clazz = (Class<Map<Object, Object>>) Class.forName(inWrapper.mapClass);
      Map<Object, Object> outMap = clazz.newInstance();
      XmlAdapter<Object, Object> keyAdapter = null;
      XmlAdapter<Object, Object> valueAdapter = null;
      if (info.adapters.key() != MapKeyValueAdapters.UNDEFINED.class) {
        keyAdapter = (XmlAdapter<Object, Object>) info.adapters.key().getConstructor().newInstance();
      }
      if (info.adapters.value() != MapKeyValueAdapters.UNDEFINED.class) {
        valueAdapter = (XmlAdapter<Object, Object>) info.adapters.value().getConstructor().newInstance();
      }
      Unmarshaller um = context.createUnmarshaller();
      for (MapEntry entry : inWrapper.mapList) {
        Object key = ((JAXBElement) um.unmarshal(new StringReader(entry.key))).getValue();
        if (keyAdapter != null) {
          key = keyAdapter.unmarshal(key);
        }
        Object value = ((JAXBElement) um.unmarshal(new StringReader(entry.value))).getValue();
        if (valueAdapter != null) {
          value = valueAdapter.unmarshal(value);
        }
        outMap.put(key, value);
      }
      return outMap;
    } else {
      throw new IllegalStateException("Adapter info could not be found.");
    }
  }

  @SuppressWarnings("unchecked")
  @Override
  public Wrapper marshal(Map<Object, Object> inMap) throws Exception {
    if (inMap == null) {
      return null;
    }

    Info info = null;
    for (Info element : marshallerListener.infoList) {
      if (element.map == inMap) {
        info = element;
      }
    }
    if (info != null) {
      Wrapper outWrapper = new Wrapper();
      outWrapper.mapClass = inMap.getClass().getName();
      outWrapper.field = info.field;
      Marshaller m = context.createMarshaller();
      m.setProperty(Marshaller.JAXB_FRAGMENT, true);
      JAXBIntrospector introspector = context.createJAXBIntrospector();
      XmlAdapter<Object, Object> keyAdapter = null;
      XmlAdapter<Object, Object> valueAdapter = null;
      if (info.adapters.key() != MapKeyValueAdapters.UNDEFINED.class) {
        keyAdapter = (XmlAdapter<Object, Object>) info.adapters.key().getConstructor().newInstance();
      }
      if (info.adapters.value() != MapKeyValueAdapters.UNDEFINED.class) {
        valueAdapter = (XmlAdapter<Object, Object>) info.adapters.value().getConstructor().newInstance();
      }
      for (Map.Entry<?, ?> entry : inMap.entrySet()) {
        MapEntry jaxbEntry = new MapEntry();
        outWrapper.mapList.add(jaxbEntry);
        Object key = entry.getKey();
        if (key != null) {
          Class<Object> clazz = Object.class;
          if (keyAdapter != null) {
            key = keyAdapter.marshal(key);
            clazz = (Class<Object>) key.getClass();
          }
          if (introspector.getElementName(key) == null) {
            // The value of clazz determines if the qualification is written or not; Object.class generates the
            // qualification.
            key = new JAXBElement<Object>(new QName("key"), clazz, key);
          }
          StringWriter writer = new StringWriter();
          m.marshal(key, writer);
          jaxbEntry.key = format("key", writer.toString());
        }
        Object value = entry.getValue();
        if (value != null) {
          Class<Object> clazz = Object.class;
          if (valueAdapter != null) {
            value = valueAdapter.marshal(value);
            clazz = (Class<Object>) value.getClass();
          }
          if (introspector.getElementName(value) == null) {
            // The value of clazz determines if the qualification is written or not; Object.class generates the
            // qualification.
            value = new JAXBElement<Object>(new QName("value"), clazz, value);
          }
          StringWriter writer = new StringWriter();
          m.marshal(value, writer);
          jaxbEntry.value = format("value", writer.toString());
        }
      }
      return outWrapper;
    } else {
      throw new IllegalStateException("Adapter info could not be found.");
    }

  }

  private String format(String inTagName, String inXML) {
    String element = "<" + inTagName;
    // Remove unneeded namespaces, they are already declared in the top node.
    int beginIndex = inXML.indexOf(XSI_TYPE);
    if (beginIndex != -1) {
      int endIndex = inXML.indexOf(" ", beginIndex);
      element += " " + inXML.substring(beginIndex, endIndex) + " " + XSI_NS;
    }
    beginIndex = inXML.indexOf('>');
    element += inXML.substring(beginIndex);
    return CDATA_START + element + CDATA_END;
  }

  @XmlType(name = "map")
  static class Wrapper {

    @XmlElement(name = "mapClass")
    private String mapClass;

    @XmlElement(name = "field")
    private String field;

    @XmlElementWrapper(name = "map")
    @XmlElement(name = "entry")
    private final List<MapEntry> mapList = new ArrayList<MapEntry>();
  }

  @XmlType(name = "mapEntry")
  static class MapEntry {

    @XmlElement(name = "key")
    private String key;

    @XmlElement(name = "value")
    private String value;
  }

  public Marshaller.Listener getMarshallerListener() {
    return marshallerListener;
  }

  public Unmarshaller.Listener getUnmarshallerListener() {
    return unmarshallerListener;
  }

  private static class MarshallerListener extends Marshaller.Listener {

    private final List<Info> infoList = new ArrayList<Info>();

    @Override
    public void beforeMarshal(Object inSource) {
      extractInfo(infoList, inSource);
    }
  }

  private class UnmarshallerListener extends Unmarshaller.Listener {
    private final List<Info> infoList = new ArrayList<Info>();

    @Override
    public void beforeUnmarshal(Object inTarget, Object inParent) {
      extractInfo(infoList, inTarget);
    }
  }

  private static void extractInfo(List<Info> inList, Object inObject) {
    for (Field field : inObject.getClass().getDeclaredFields()) {
      for (Annotation a : field.getAnnotations()) {
        if (a.annotationType() == XmlJavaTypeAdapter.class) {
          if (((XmlJavaTypeAdapter) a).value() == MapAdapter.class) {
            MapKeyValueAdapters adapters = field.getAnnotation(MapKeyValueAdapters.class);
            if (adapters == null) {
              throw new IncompleteAnnotationException(XmlJavaTypeAdapter.class, "; XmlJavaTypeAdapter specifies "
                  + MapAdapter.class.getName() + " for field " + field.getName() + " in "
                  + inObject.getClass().getName() + ". This must be used in combination with annotation "
                  + MapKeyValueAdapters.class.getName());
            }
            try {
              field.setAccessible(true);
              Map<?, ?> value = (Map<?, ?>) field.get(inObject);
              if (value != null) {
                Info info = new Info();
                info.field = field.getName();
                info.map = value;
                info.adapters = adapters;
                inList.add(info);
              }
            } catch (Exception e) {
              throw new RuntimeException("Failed extracting annotation information from " + field.getName() + " in "
                  + inObject.getClass().getName(), e);
            }
          }
        }
      }
    }
  }

  private static class Info {
    private String field;
    private Map<?, ?> map;
    private MapKeyValueAdapters adapters;
  }
}

Note that the adapter is capable of handling all types of Maps as long as it has a default constructor.

Finally the code to set up the usage of the adapter.

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;

/**
 * Singleton that manages the JAXB functionality.
 */
public enum JAXBManager {

  INSTANCE;

  private JAXBContext context;

  private JAXBManager() {
    try {
      context = JAXBContext.newInstance(SomeComplexClass.class.getPackage().getName());
    } catch (JAXBException e) {
      throw new RuntimeException(e);
    }
  }
  public Marshaller createMarshaller() throws JAXBException {
    Marshaller m = context.createMarshaller();
    MapAdapter adapter = new MapAdapter(context);
    m.setAdapter(adapter);
    m.setListener(adapter.getMarshallerListener());
    m.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
    return m;
  }

  public Unmarshaller createUnmarshaller() throws JAXBException {
    Unmarshaller um = context.createUnmarshaller();
    MapAdapter adapter = new MapAdapter(context);
    um.setAdapter(adapter);
    um.setListener(adapter.getUnmarshallerListener());
    return um;
  }
}

This could generate an output of something like

<testMap2>
    <mapClass>java.util.HashMap</mapClass>
    <field>testMap2</field>
    <map>
        <entry>
            <key><![CDATA[<key><number>1357</number><type>Unspecified</type></key>]]></key>
            <value><![CDATA[<value xsi:type="xs:string" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">gn</value>]]></value>
        </entry>
    </map>
</testMap2>

As can be seen the qualification info is not needed for the key since we already know the adapter to use. Also note that I add CDATA to the output. I have implemented a simple character escape handler that respects this (not included in this code example).

Due to our release cycles I have a bit of time before the opportunity opens for implementing this functionality in our code so I therefore thought it would be wise to check with the community if there are any problems with this solution or if there are better ways already in the JAXB specification that I have overlooked. I also assume that there are sections of the code that can be done in better ways. Thanks for comments.

like image 785
Anders Persson Avatar asked Nov 02 '22 03:11

Anders Persson


1 Answers

Here is my proposal for a workaround:

  1. Make the map XmlTransient
  2. Use a wrapped List for the marshalling
  3. reinit the map from the list whenever it is needed
  4. if you need to keep the list and the map in sync use an add(order) function

Example Customer with a Map of Orders

  @XmlRootElement
  @XmlAccessorType(XmlAccessType.FIELD)
  public static class Order {
    @XmlID
    String orderId;
    String item;
    int count;
  }

  @XmlRootElement(name = "customer")
  @XmlAccessorType(XmlAccessType.FIELD)
  public static class Customer {
    String name;
    String firstname;
    @XmlElementWrapper(name = "orders")
    @XmlElement(name = "order")
    List<Order> orders = new ArrayList<Order>();

    @XmlTransient
    private Map<String, Order> ordermap = new LinkedHashMap<String, Order>();

    /**
     * reinitialize the order list
     */
    public void reinit() {
      for (Order order : orders) {
        ordermap.put(order.orderId, order);
      }
    }

    /**
     *  add the given order to the internal list and map
     *  @param order - the order to add
     */
    public void addOrder(Order order) {
      orders.add(order);
      ordermap.put(order.orderId,order);
    }
  }

Example XML

<customer>
   <name>Doe</name>
   <firstname>John</firstname>
   <orders>
      <order>
         <orderId>Id1</orderId>
         <item>Item 1</item>
         <count>1</count>
      </order>
      <order>
         <orderId>Id2</orderId>
         <item>Item 2</item>
         <count>2</count>
      </order>
   </orders>
</customer>

Mininimal complete and verifiable example

An example according to

  • https://stackoverflow.com/help/mcve

can be found at https://github.com/BITPlan/com.bitplan.simplerest/blob/master/src/test/java/com/bitplan/jaxb/TestJaxbFactory.java#L390

like image 106
Wolfgang Fahl Avatar answered Nov 13 '22 21:11

Wolfgang Fahl