Map avec JAX-B

JAXB - Marshalling d'une Map


Tout d'abord, je dois préciser que le titre n'est pas tout à fait exact : je ne vais pas mapper une Map, mais une HashMap. La technique présentée ici peut s'appliquer à n'importe quel type de Map, mais je n'ai pas réussi à la faire fonctionner en déclarant mon champ avec l'interface Map.

Présentation des classes

Les lecteurs qui auront déjà suivi une de mes formations reconnaîtront mon sujet de prédilection... Je vais donc utiliser une classe Cours, qui est un JavaBean avec trois propriétés (champ + get + set).

 package fr.sewatech.formation.universite.xml.data;
 
 public class Cours {
   private String code;
   private String nom;
   private int duree;
   
   public Cours() {
   }
   public Cours(String code, String nom, int duree) {
     this.code = code;
     this.nom = nom;
     this.duree = duree;
   }
   // Méthodes get + set, non présentées ici pour gagner de la place
   @Override
   public String toString() {
     return code + " - " + nom + (duree == 0 ? "" : " - " + duree);
   }
 }

Je vais ensuite utiliser une classe Etudiant qui va avoir un champ de type HashMap<String, Cours>.

 package fr.sewatech.formation.universite.xml.data;
 
 import java.util.HashMap;
 
 public class Etudiant {
 
   private HashMap<String, Cours> allCours;
 
   public Etudiant() {
   }
 
   public Etudiant(int capacite) {
     allCours = new HashMap<String, Cours>();
   }
 
   public String toString() {
     StringBuilder resultat = new StringBuilder("Liste des cours de l'étudiant :");
     for (Object unCours : allCours.values()) {
       resultat.append("\n\t").append(unCours);
     }
     return resultat.toString();
   }
 }

On remarquera que les deux classes ont un constructeur vide, ce qui est imposé par JAX-B, que tous les champs de Cours ont des méthodes get et set, mais pas le champ allCours de la classe Etudiant.

Marshalling simple

Commençons tout d'abord à faire le mapping de la classe Cours. Ce sera très rapide, puisque cette classe respecte la convention JavaBean ; on peut donc l'utiliser pour le marshall et le unmarshall sans aucun ajout.

 package fr.sewatech.formation.universite.xml;
 
 import java.io.File;
 import javax.xml.bind.JAXB;
 import fr.sewatech.formation.universite.xml.data.Cours;
 
 public class CoursXML {
 
   public void save(Cours cours) {
     JAXB.marshal(cours, getXmlFile(cours.getCode()));
   }
   
   public Cours load(String code) {
     return JAXB.unmarshal(getXmlFile(code), Cours.class);
   }
 
   private File getXmlFile(String code) {
     File directory = new File("xml");
     if (! directory.exists()) {
       directory.mkdir();
     }  
     return new File(String.format("xml/cours-%s.xml", code));    
   }
 }

Le marshalling donnera un fichier de ce type :

 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <cours>
   AA
   <duree>1</duree>
   <nom>Aaaa</nom>
 </cours>

Les annotations permettent d'affiner le fichier généré, en terme de noms et de structure.

Marshalling simple de Map

Si on essaie de faire la même opération avec la classe Etudiant, l'élément racine etudiant généré est vide, car il n'y a ni getter, ni setter. La solution la plus rapide pour faire générer du contenu est de changer le XmlAccessorType, pour que le marshalling s'appuie sur les champs au lieu des propriétés. Dans ce cas, il n'y a plus besoin de get et set.

 @XmlRootElement
 @XmlAccessorType(XmlAccessType.FIELD)
 public class Etudiant {
   private HashMap<String, Cours> allCours;
   //...
 }

Cette façon de faire fonctionne, et fonctionnerait même si le champ était de type Map au lieu de HasMap, mais sans possibilité d'affiner le résultat.

Si j'ajoute l'annotation @XMLElement sur un champ de type Map, une exception est générée (IllegalAnnotationsException), et si j'ajoute l'annotation @XMLElement sur un champ de type HashMap, l'élément généré est vide.

 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <etudiant>
   <allCours/>
 </etudiant>

Marshalling complexe de Map

Pour résoudre le problème j'ai utilisé la technique des adaptateurs, avec la balise @XmlJavaTypeAdapter. Le principe est de confier à un adaptateur la transformation entre un objet que JAX-B n'est pas capable de gérer et un objet gérable. Pour une map, on peut envisager une transformation vers une liste de Entry. Là encore, une classe comme AbstractMap.SimpleEntry ne peut pas être utilisée car il lui manque le constructeur vide.

J'ai donc développer une classe SwMap qui contient une liste de SwMapEntry. Cette classe est conçue pour être compatible avec JAX-B, et réutilisable dans d'autres contextes grâce aux generics. SwMap implémente Iterable pour des raisons pratiques.

 @XmlAccessorType(XmlAccessType.FIELD)
 @XmlRootElement
 public class SwMap<K, V> implements Iterable<SwMapEntry<K, V>> {
   @XmlElement(name = "entry", required = true)
   private final ArrayList<SwMapEntry<K, V>> list = new ArrayList<SwMapEntry<K, V>>();
   
   public ArrayList<SwMapEntry<K, V>> getList() {
     return this.list;
   }
   public void put(K key, V value) {
     list.add(new SwMapEntry<K, V>(key, value));
   }
   public int size() {
     return list.size();
   }
   @Override
   public Iterator<SwMapEntry<K, V>> iterator() {
     return list.iterator();
   }
 }
 @XmlAccessorType(XmlAccessType.FIELD)
 @XmlRootElement
 public class SwMapEntry<K, V> {
   @XmlElement(name="key")
   private K key;
   @XmlElement
   private V value;
   
   public SwMapEntry() {
   }
   public SwMapEntry(K key, V value) {
       this.key = key;
       this.value = value;
   }
   
   public K getKey() {
       return key;
   }
   public V getValue() {
       return value;
   }
 }

Enfin, j'ai pu développer la classe d'adaptation entre Map et SwMap, là aussi avec les generics.

 public class SwMapAdapter<K, V> extends XmlAdapter<SwMap<K, V>, Map<K, V>> {
   
   @Override
   public SwMap<K, V> marshal(Map<K, V> value) throws Exception {
     SwMap<K, V> set = new SwMap<K, V>();
     for (Entry<K, V> entry : value.entrySet()) {
       set.put(entry.getKey(), entry.getValue());
     }
     return set;
   }
   
   @Override
   public Map<K, V> unmarshal(SwMap<K, V> value) throws Exception {
     HashMap<K, V> map = new HashMap<K, V>(value.size());
     for (SwMapEntry<K, V> entry : value) {
       map.put(entry.getKey(), entry.getValue());
     }
     return map;
   }
 }

Grâce à ces classes totalement génériques, je peux paramétrer le marshalling de mon champs de type HashMap.

 @XmlRootElement(name="student")
 @XmlAccessorType(XmlAccessType.FIELD)
 public class Etudiant {
   
   @XmlElement(name="courses")
   @XmlJavaTypeAdapter(SwMapAdapter.class)
   private HashMap<String, Cours> allCours;
   
   //...
 }

Conclusion

En attendant que JAX-B supporte correctement les classes qui implémentent Map, cette technique s'adaptera aux différents types de Map.

Peut-être existe-t-il une technique s'appuyant uniquement sur les annotations et peut-être existe-t-il une façon de faire avec l'interface Map, mais je n'ai pas trouvé... Dans ce cas, le première chose à améliorer serait la documentation de JAX-B !


Les exemples ci-dessus ont été développés avec JAX-B 2.1, intégré à JavaSE 6.