Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

Jouons un peu avec XmlTool

XmlTool est une librairie opensource simplifiant la lecture de documents XML. Elle se positionne comme une surcouche légère à DOM et propose de nombreuses fonctions très utiles simplifiant la vie du développeur. Malgré tout, elle reste relativement bas niveau, et l'écriture de parsers spécialisés reste fastidueuse.

Dans cet article, nous allons nous amuser un peu avec la Réflexion et les Annotations pour développer un micro-framework de plus haut niveau - simple exercice de style, mais le résultat est tout à fait utilisable pour des fichiers de taille raisonnable.
Suivez le guide !

Développement du Parseur

Notre but est de développer un parseur générique - appelons-le AbstractParser -, dont pourront dériver des parseurs spécialisés.
Il proposera les fonctionnalités suivantes :

  • Gestion des attributs optionnels dans les balises (voir explication plus bas)
  • Conversion automatique des données textuelles brutes en objets Java
  • Découplage entre le parcours de l'arbre XML (effectué par le framework) et le traitement des balises (effectué par le parseur spécifique)
  • Souplesse de développement grâce aux annotations.

Premier pas

La première étape consiste naturellement à charger le fichier XML, à l'aide de la méthode statique XmlTool.from() :

  1. package net.thecodersbreakfast.xmltool.parser;
  2. public class AbstractParser<T> {
  3.  
  4. public final T parse(Reader reader) throws Exception {
  5. XMLTag tag = XMLDoc.from(reader, true);
  6. return ... //TODO
  7. }

La classe XmlTag renvoyée est la représentation directe d'un noeud de l'arbre DOM - en l'occurrence, son noeud racine.
Ses méthodes getText() et getAttribute(attributeName) permettent de récupérer respectivement le texte placé entre les balises ouvrante et fermante, et la valeur des attributs de la balise ouvrante.

Elles possèdent malheureusement des limitations gênantes :

  • getAttribute(attributeName) lève une exception si l'attribut demandé n'existe pas, forçant le développeur à entourer chaque appel d'un bloc "try/catch".
  • Les deux méthodes renvoient de simples chaînes de caractères, charge au développeur de les convertir en des objets métiers.

Voyons comment améliorer tout cela.

Première amélioration : conversion des données

Afin de transformer les chaînes de caractères brutes lues dans le fichier XML en véritables objets Java, nous emploierons un ensemble de convertisseurs, représentés par l'interface Converter :

  1. package net.thecodersbreakfast.xmltool.parser.converter;
  2. public interface Converter<T> {
  3. public T convert(String value) throws ConversionException;
  4. }

Par exemple, voici un convertisseur gérant les dates :

  1. package net.thecodersbreakfast.xmltool.parser.converter;
  2. public class DateConverter implements Converter<Date> {
  3.  
  4. String dateFormat;
  5.  
  6. public DateConverter(String dateFormat) {
  7. this.dateFormat = dateFormat;
  8. }
  9.  
  10. @Override
  11. public Date convert(String value) throws ConversionException {
  12. try {
  13. // Remember : (Simple)DateFormat is NOT thread-safe !
  14. return new SimpleDateFormat(dateFormat).parse(value);
  15. }
  16. catch (ParseException e) {
  17. throw new ConversionException(
  18. "Could not convert expression '"+value+"' to type 'java.util.Date'", e);
  19. }
  20. }
  21.  
  22. }

Notre AbstractParser maintient un registre des convertisseurs disponibles ; les parseurs spécifiques pourront enregistrer des convertisseurs personnalisés grâce à la méthode registerConverter().

  1. private Map<Class<?>, Converter<?>> converters = new HashMap<Class<?>, Converter<?>>();
  2.  
  3. public <T> void registerConverter(Class<T> type, Converter<T> converter) {
  4. converters.put(type, converter);
  5. }
  6.  
  7. protected <X> X convert(String rawValue, Class<X> type) throws ConversionException {
  8. Converter<X> converter = (Converter<X>) converters.get(type);
  9. if (converter == null) {
  10. throw new ConversionException("No converter registered for type "+type.getCanonicalName());
  11. }
  12. return converter.convert(rawValue);
  13. }

Note : la cohérence des types entre la clé et la valeur n'est pas garantie au niveau de la Map des convertisseurs, car il est impossible de "fixer" un type lors de la déclaration d'une propriété (contrairement aux méthodes) :

  1. // Impossible !
  2. <T> private Map<Class<T>, Converter<T> converters;

Fort heureusement, la méthode registerConverter(), point de passage obligatoire pour l'enregistrement de nouveaux convertisseurs, restaure cette contrainte.

Seconde amélioration : les fonctions de lecture

Voyons maintenant comment simplifier la lecture des attributs des balises.
Comme dit précédemment, la méthode getAttribute(attribureName) de l'objet XmlTag lève une exception si l'attribut demandé n'existe pas, forçant le développeur à entourer chaque lecture d'un bloc "try/catch".

Notre framework fait la distinction entre attributs requis et optionnels : en cas d'absence, les premiers lèveront une exception, tandis que les seconds renverront simplement null. De plus, les attributs présents seront convertis en objets grâce au système de convertisseurs vu plus haut.

  1. protected final <X> X getAttribute(XMLTag tag, String attributeName, Class<X> type, boolean optional)
  2. throws NoSuchAttributeException, ConversionException {
  3.  
  4. String rawValue = null;
  5. try {
  6. rawValue = tag.getAttribute(attributeName);
  7. }
  8. catch (XMLDocumentException e) {
  9. if (optional) {
  10. return null;
  11. }
  12. else {
  13. throw new NoSuchAttributeException(
  14. "Missing required value for attribute '"+attributeName+"' of tag '" +
  15. tag.getCurrentTagName() +"' at "+tag.getCurrentTagLocation(), e);
  16. }
  17. }
  18.  
  19. return convert(rawValue, type);
  20. }
  21. }

Par chance, la méthode de lecture du texte placé entre les balises ouvrante et fermante est plus simple car elle se contente de renvoyer une chaîne vide au lieu de lever une exception en cas d'absence de données.

  1. protected final <X> X getInnerText(XMLTag tag, Class<X> type) throws ConversionException {
  2. String rawValue = tag.getTextOrCDATA();
  3. return convert(rawValue, type);
  4. }

Parcours de l'arbre DOM et traitement des balises

Maintenant que toutes les fonctions de support sont prêtes, attaquons-nous au point central du parseur : le parcours du fichier XML et le traitement de ses balises.
Le premier point étant une opération générique, il sera géré par notre parseur abstrait ; le second point, spécifique à chaque type de fichier XML, sera au contraire pris en charge par les sous-classes spécialisées.

Plus spécifiquement, chaque balise XML sera traitée par une méthode dédiée, chargée de construire l'objet correspondant. L'association balise/méthode reposera sur l'utilisation de l'annotation @TagHandler déclarée comme suit :

  1. package net.thecodersbreakfast.xmltool.parser;
  2.  
  3. @Target(ElementType.METHOD)
  4. @Retention(RetentionPolicy.RUNTIME)
  5. public @interface TagHandler {
  6. String value();
  7. }

Note : Les méta-annotations spécifient que cette annotation peut être apposée uniquement sur des méthodes, et qu'elle doit être conservée au runtime

Les méthodes portant cette annotation sont détectées dynamiquement par réflexion lors de l'instanciation du parseur, et consignées dans un regitre pour future référence.
Il faut toutefois qu'elles respectent certaines conditions :

  • Accepter un unique paramètre de type XmlTag (dont elles extraieront les données)
  • Renvoyer un objet (qu'elles créeront)
  1. private Map<String, Method> handlerMethods = new HashMap<String, Method>();
  2.  
  3. private void initHandlers() {
  4.  
  5. // For each method of the parser (and its specific subclasses)...
  6. Method[] declaredMethods = this.getClass().getDeclaredMethods();
  7. for (Method method : declaredMethods) {
  8.  
  9. // Check if it has the @TagHandler annotation
  10. TagHandler annotation = method.getAnnotation(TagHandler.class);
  11. if (annotation != null) {
  12.  
  13. // Check if it has a compatible signature
  14. Class<?>[] parameterTypes = method.getParameterTypes();
  15. if (parameterTypes == null || parameterTypes.length != 1 || !XMLTag.class.equals(parameterTypes[0])) {
  16. throw new IllegalStateException("The method " + method.getName()
  17. + " must take one parameter of type com.mycila.xmltool.XMLTag.");
  18. }
  19. if (method.getReturnType() == null) {
  20. throw new IllegalStateException("The method " + method.getName() + " must return a value.");
  21. }
  22.  
  23. // If all is OK, register this method as a handler for the XML tag described in the annotation
  24. method.setAccessible(true);
  25. handlerMethods.put(annotation.value(), method);
  26. }
  27.  
  28. }
  29. }

Il reste tout de même une question en suspens : qui assemblera la hiérarchie des sous-objets créés lors du traitement des balises en une hiérarchie ?
Afin de simplifier au maximum le développement des parseurs spécifiques, c'est l'AbstractParser qui s'en chargera, utilisant pour cela le mécanisme de Réflexion et une simple convention : l'objet parent devra posséder soit une méthode addX(), soit une méthode setX(), où X est le type de l'objet enfant.

Voici le code correspondant, parcourant l'arbre DOM de manière récursive, appelant les méthodes associées aux balises rencontrées, et associant les objets entre eux pour produire l'objet final :

  1. private Object doParse(XMLTag tag) throws Exception {
  2.  
  3. // Look for the handler method for this tag
  4. String tagName = tag.getCurrentTagName();
  5. Method method = handlerMethods.get(tagName);
  6. if (method == null) {
  7. throw new IllegalStateException("No handler method for tag " + tagName);
  8. }
  9.  
  10. // Call the method
  11. final Object result = method.invoke(this, tag);
  12.  
  13. // Parse child nodes
  14. try {
  15. tag.forEachChild(new CallBack() {
  16. @Override
  17. public void execute(XMLTag childTag) {
  18. try {
  19. // Recursive call to handle this child node
  20. Object subResult = doParse(childTag);
  21. // Attach the child object to its parent
  22. setChild(result, subResult);
  23. }
  24. catch (Exception e) {
  25. throw new IllegalStateException("Error while parsing the XML file", e);
  26. }
  27. }
  28. });
  29. }
  30. catch (IllegalStateException e) {
  31. throw new Exception("Error while parsing the XML file", e.getCause());
  32. }
  33.  
  34. return result;
  35.  
  36. }
  37.  
  38. protected void setChild(Object parent, Object child) throws Exception {
  39. Class<?> childType = child.getClass();
  40.  
  41. // Look for an adder method (addX) or a setter method (setX) on the
  42. // parent object
  43. Method setter = null;
  44. String setterName = "set" + childType.getSimpleName();
  45. String adderName = "add" + childType.getSimpleName();
  46. setter = parent.getClass().getMethod(adderName, childType);
  47. if (setter == null) {
  48. setter = parent.getClass().getMethod(setterName, childType);
  49. if (setter == null) {
  50. String setterSignature = "(" + childType.getSimpleName() + ")";
  51. throw new IllegalStateException("Class " + parent.getClass().getCanonicalName()
  52. + " should have a method " + setterName + setterSignature + " or " + adderName
  53. + setterSignature);
  54. }
  55. }
  56.  
  57. // Set/Add the result to the current object
  58. setter.invoke(parent, child);
  59. }

Code complet de l'AbstractParser

Nous avons vu dans le détail les différentes étapes du développement de l'AbstractParser.
Avant d'en voir un exemple de mise en oeuvre, voici son code source complet :

  1. public class AbstractParser<T> {
  2.  
  3. private Map<String, Method> handlerMethods = new HashMap<String, Method>();
  4. private Map<Class<?>, Converter<?>> converters = new HashMap<Class<?>, Converter<?>>();
  5.  
  6. public AbstractParser() {
  7. initHandlers();
  8. }
  9.  
  10. private void initHandlers() {
  11. Method[] declaredMethods = this.getClass().getDeclaredMethods();
  12. for (Method method : declaredMethods) {
  13. TagHandler annotation = method.getAnnotation(TagHandler.class);
  14. if (annotation != null) {
  15. Class<?>[] parameterTypes = method.getParameterTypes();
  16. if (parameterTypes == null || parameterTypes.length != 1 || !XMLTag.class.equals(parameterTypes[0])) {
  17. throw new IllegalStateException("The method " + method.getName()
  18. + " must take one parameter of type com.mycila.xmltool.XMLTag.");
  19. }
  20. if (method.getReturnType() == null) {
  21. throw new IllegalStateException("The method " + method.getName() + " must return a value.");
  22. }
  23. method.setAccessible(true);
  24. handlerMethods.put(annotation.value(), method);
  25. }
  26. }
  27. }
  28.  
  29. // ================================================================================
  30. // Parsing methods
  31. // ================================================================================
  32.  
  33. public final T parse(Reader reader) throws Exception {
  34. XMLTag tag = XMLDoc.from(reader, true);
  35. return (T) doParse(tag);
  36. }
  37.  
  38. private Object doParse(XMLTag tag) throws Exception {
  39. // Look for the handler method for this tag
  40. String tagName = tag.getCurrentTagName();
  41. Method method = handlerMethods.get(tagName);
  42. if (method == null) {
  43. throw new IllegalStateException("No handler method for tag " + tagName);
  44. }
  45.  
  46. // Call the method
  47. final Object result = method.invoke(this, tag);
  48.  
  49. // Parse child nodes
  50. try {
  51. tag.forEachChild(new CallBack() {
  52. @Override
  53. public void execute(XMLTag childTag) {
  54. try {
  55. Object subResult = doParse(childTag);
  56. setChild(result, subResult);
  57. }
  58. catch (Exception e) {
  59. throw new IllegalStateException("Error while parsing the XML file", e);
  60. }
  61. }
  62. });
  63. }
  64. catch (IllegalStateException e) {
  65. throw new Exception("Error while parsing the XML file", e.getCause());
  66. }
  67.  
  68. return result;
  69.  
  70. }
  71.  
  72. protected void setChild(Object parent, Object child) throws Exception {
  73. Class<?> childType = child.getClass();
  74.  
  75. // Look for an adder method (addX) or a setter method (setX) on the
  76. // parent object
  77. Method setter = null;
  78. String setterName = "set" + childType.getSimpleName();
  79. String adderName = "add" + childType.getSimpleName();
  80. setter = parent.getClass().getMethod(adderName, childType);
  81. if (setter == null) {
  82. setter = parent.getClass().getMethod(setterName, childType);
  83. if (setter == null) {
  84. String setterSignature = "(" + childType.getSimpleName() + ")";
  85. throw new IllegalStateException("Class " + parent.getClass().getCanonicalName()
  86. + " should have a method " + setterName + setterSignature + " or " + adderName
  87. + setterSignature);
  88. }
  89. }
  90.  
  91. // Set/Add the result to the current object
  92. setter.invoke(parent, child);
  93. }
  94.  
  95. // ================================================================================
  96. // Data extraction methods
  97. // ================================================================================
  98.  
  99. protected final <X> X getAttribute(XMLTag tag, String attributeName, Class<X> type, boolean optional)
  100. throws NoSuchAttributeException, ConversionException {
  101. String rawValue = null;
  102. try {
  103. rawValue = tag.getAttribute(attributeName);
  104. }
  105. catch (XMLDocumentException e) {
  106. if (optional) {
  107. return null;
  108. }
  109. else {
  110. throw new NoSuchAttributeException("Missing required value for attribute '" + attributeName
  111. + "' of tag '" + tag.getCurrentTagName() + "' at " + tag.getCurrentTagLocation(), e);
  112. }
  113. }
  114.  
  115. return convert(rawValue, type);
  116. }
  117.  
  118. protected final <X> X getAttribute(XMLTag tag, String attributeName, Class<X> type)
  119. throws NoSuchAttributeException, ConversionException {
  120. return getAttribute(tag, attributeName, type, false);
  121. }
  122.  
  123. protected final <X> X getOptionalAttribute(XMLTag tag, String attributeName, Class<X> type)
  124. throws NoSuchAttributeException, ConversionException {
  125. return getAttribute(tag, attributeName, type, true);
  126. }
  127.  
  128. protected final <X> X getInnerText(XMLTag tag, Class<X> type) throws ConversionException {
  129. String rawValue = tag.getTextOrCDATA();
  130. return convert(rawValue, type);
  131. }
  132.  
  133. // ================================================================================
  134. // Conversion methods
  135. // ================================================================================
  136.  
  137. public <X> void registerConverter(Class<X> type, Converter<X> converter) {
  138. converters.put(type, converter);
  139. }
  140.  
  141. protected <X> X convert(String rawValue, Class<X> type) throws ConversionException {
  142. Converter<X> converter = (Converter<X>) converters.get(type);
  143. if (converter == null) {
  144. throw new ConversionException("No converter registered for type " + type.getCanonicalName());
  145. }
  146. return converter.convert(rawValue);
  147. }
  148.  
  149. }

Exemple de test : gestion d'une librairie

Pour finir, et parce qu'un article n'est jamais complet sans un exercice pratique, nous utiliserons l'exemple d'un fichier XML représentant une bibliothèque :

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <library>
  3. <author name="Isaac Asimov" birthDate="2/01/1920">
  4. <book name="Foundation" pages="500" type="SF">
  5. Foundation tells the story of (...)
  6. </book>
  7. <book name="The Caves of Steel" pages="250">
  8. Elijah Baley and R. Daneel Olivaw live roughly three millennia in Earth's future (...)
  9. </book>
  10. </author>
  11. <author name="Jack Vance" birthDate="28/08/1916">
  12. <book name="City of the Chasch" pages="450" type="SF">
  13. A human starship intercepts a mysterious signal (...)
  14. </book>
  15. <book name="The Anome" pages="180">
  16. It tells the story of a boy growing to manhood in the land of Shant(...)
  17. </book>
  18. </author>
  19. </library>

Le modèle objet correspondant est le suivant (les accesseurs sont volontairement omis, mais les méthodes addX() nécessaires au fonctionnement du framework sont présentes) :

  1. package net.thecodersbreakfast.xmltool.library.model;
  2.  
  3. public class Library {
  4. private List<Author> authors = new ArrayList<Author>();
  5. public void addAuthor(Author author) {
  6. authors.add(author);
  7. }
  8. }
  9.  
  10. public class Author {
  11. private String name;
  12. private Date birthDate;
  13. private List<Book> books = new ArrayList<Book>();
  14. public void addBook(Book book) {
  15. this.books.add(book);
  16. }
  17. }
  18.  
  19. public class Book {
  20. private String name;
  21. private int pages;
  22. private String summary;
  23. private BookType type;
  24. }
  25.  
  26. public enum BookType {
  27. UNKNOWN, SF, NOVEL, CLASSBOOK;
  28. }

Notez que le champ "type" des livres est optionnel, et qu'il est représenté par un Enum.
Nous devons donc développer un convertisseur adapté :

  1. package net.thecodersbreakfast.xmltool.library.converter;
  2. public class BookTypeConverter implements Converter<BookType> {
  3. @Override
  4. public BookType convert(String value) throws ConversionException {
  5. if (value==null) return null;
  6. try {
  7. return BookType.valueOf(value);
  8. }
  9. catch (IllegalArgumentException e) {
  10. return BookType.UNKNOWN;
  11. }
  12. }
  13. }

Le développement du parseur spécifique à ce modèle de données est maintenant très simple : il suffit de créer un ensemble de méthodes annotées, sans oublier bien sûr d'enregistrer le convertisseur développé précédemment :

  1. package net.thecodersbreakfast.xmltool.library;
  2. public class LibraryParser extends BasicParser<Library> {
  3.  
  4. public LibraryParser() {
  5. registerConverter(BookType.class, new BookTypeConverter());
  6. }
  7.  
  8. @TagHandler("library")
  9. private Library parseLibrary(XMLTag tag) throws NoSuchAttributeException, ConversionException {
  10. return new Library();
  11. }
  12.  
  13. @TagHandler("author")
  14. private Author parseAuthor(XMLTag tag) throws NoSuchAttributeException, ConversionException {
  15. Author author = new Author();
  16. author.setName(getStringAttribute(tag, "name"));
  17. author.setBirthDate(getDateAttribute(tag, "birthDate"));
  18. return author;
  19. }
  20.  
  21. @TagHandler("book")
  22. private Book parseBook(XMLTag tag) throws NoSuchAttributeException, ConversionException {
  23. Book book = new Book();
  24. book.setName(getStringAttribute(tag, "name"));
  25. book.setPages(getIntAttribute(tag, "pages"));
  26. book.setSummary(getStringInnerText(tag));
  27. book.setType(getOptionalAttribute(tag, "type", BookType.class));
  28. return book;
  29. }
  30.  
  31. @Override
  32. protected String getDateFormat() {
  33. return "yyyy/MM/dd";
  34. }
  35. }

Les lecteurs attentifs auront remarqué que ce parseur hérite de BasicParser et non directement d'AbstractParser, et que les méthodes getOptionalAttribute(), getFloatAttribute, etc. n'ont pas été détaillées dans cet article.
La classe BasicParser est en effet une classe de type Adapter simplifiant encore les opérations les plus courantes et effectuant certains réglages par défaut comme l'enregistrement des convertisseurs basiques (String, Int, Float, Date...). Son code source est bien entendu disponible en pièce jointe de ce billet.

Pour finir, voyons la classe de test :

  1. package net.thecodersbreakfast.xmltool.parser;
  2. public class ParserTest {
  3.  
  4. public static void main(String[] args) throws Exception {
  5.  
  6. // Locate and parse the "Library.xml" file
  7. InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream("Library.xml");
  8. Reader reader = new InputStreamReader(is);
  9. LibraryParser parser = new LibraryParser();
  10. Library library = parser.parse(reader);
  11.  
  12. // Navigate the object tree and display some data
  13. List<Author> authors = library.getAuthors();
  14. for (Author author : authors) {
  15. System.out.println(author.getName());
  16. List<Book> books = author.getBooks();
  17. for (Book book : books) {
  18. System.out.printf(" > %s (%s)%n", book.getName(), book.getType());
  19. }
  20. }
  21.  
  22. }
  23. }

Si tout va bien, vous devriez obtenir ceci :

Isaac Asimov
 > Foundation (SF)
 > The Caves of Steel (null)
Jack Vance
 > City of the Chasch (SF)
 > The Anome (null)

Conclusion

Cet exercice de style autour du framework XmlTool était une bonne occasion de découvrir cette librairie sympathique, de réfléchir à des problématiques d'architecture, et de s'amuser un peu avec des techniques un peu avancées comme la Réflexion et les annotations.

Le mini-framework développé ici est parfaitement utilisable dans le cadre d'un projet d'entreprise, même si sa conception (DOM et récursivité) montre ses limites sur des modèles de données à la hiérarchie très profonde.

Tout le code source est disponible en annexe de ce billet.

Annexes


Commentaires

1. Le dimanche 9 août 2009, 16:29 par Mathieu Carbou

Really interesting article !! It leverages XMLTool to another level ! We currently use it in production to handle web services and parse XML structures, but I've never thought yet about a deserialization API :) This article shows well how it is easy to write such parser, by using the power of XMLTool.

Another idea for the parsing would also be to annotate the fields of the object model with JAXB annotations in example, and use reflexion to determine which field to inject into.

Note: The getAttribute() method throws an exception if the node does not contain any attribute to retrieve a value from. You have also access to findAttribute() which does the same thing except that this method does not throw an exception, but returns null.

Also, it's true that getText() and getCDATA() returns a blank string when no text node is present. This is the Java DOM API which returns this blank string.

Thanks a lot for your good PoC !

Mat'

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.