Désérialiser un XML complexe

Serializer le barbare

Dernière je me suis retrouvé confronté à l’un des pires problèmes de ma vie de dev : la désérialisation. Derrière ce mot barbare, que le dictionnaire de Firefox ne semble pas reconnaître (d’ailleurs il n’apprécie pas plus son équivalent anglais « deserialization ») se cache un processus pourtant fort utile : la transformation d’un fichier contenant des données en entités objets.

En réalité la désérialisation va de paire avec la sérialisation, l’opération inverse (transformation d’entités en un fichier de données). On peut également passer par une étape intermédiaire, celle d’un tableau de données, ce qui ajoute alors les opérations d’encodage, de décodage, de normalisation et de dénormalisation.

Ceux qui ont déjà eu l’occasion de se frotter au composant « Serializer » de Symfony2 ont certainement eu l’occasion de lire ce superbe schéma récapitulatif du workflow que je viens d’évoquer :

Source : documentation Symfony2

Source : documentation Symfony2

Du XML trop complexe pour Symfony et JMS

Ce que je voulais faire était simple : un de nos logiciels métiers me fournit un fichier unique XML contenant plusieurs entités (jusqu’à plusieurs milliers, en fait) nommées « insertions ». Chaque insertion possède plusieurs relation ManyToOne comme, par exemple, des factures, des règlements, etc… Au final c’est un peu ce genre de schéma que je recevais :

<insertions>
    <insertion>
        <nom>Mon nom</nom>
        <factures>
            <facture date="2014-01-01" numero="12"/>
            <facture date="2015-01-02" numero=""/>
        </factures>
    </insertion>
    <insertion>
        <nom>Mon nom 2</nom>
        <factures>
            <facture date="2014-01-02" numero="12"/>
            <facture date="2015-02-02" numero=""/>
        </factures>
    </insertion>
</insertions>

Ce XML au format relativement simple pose pourtant de sérieux problèmes au serializer de Sf2. Notamment car :

  • Certaines données ne sont pas directement dans les nœuds, mais dans les attributs,
  • Il y a plusieurs « insertions » dans un seul fichier XML.

Or le serializer est intransigeant : il attend des nœuds simples, et une seul entités par fichier. Pratique ! J’ai donc tenté de me tourner vers le fameux JMSSerializerBundle, sans succès. Celui-ci me promettait pourtant le paradis : gestion des données en attributs et possibilité d’avoir une liste d’entités. Mais j’ai vite déchanté : mon cas était encore trop complexe.

En effet, nativement le JMS permet de gérer les données en attribut direct d’une entité, mais pas dans les attributs d’un composant d’une entité… Si je vous ai perdu, voici un petit exemple pour vous faire comprendre la lacune. Imaginons un codeur qui porte un pantalon dont on veut stocker la couleur. JMS va gérer le cas où dans le XML l’attribut « couleurPantalon » est directement au sein du nœud « codeur » :

<codeur couleurPantalon="bleue">
    <nom>Pete Gore</nom>
    <pantalon/>
</codeur>

Mais pas le cas où on a un nœud « pantalon » avec l’attribut « couleur » :

<codeur>
    <nom>Pete Gore</nom>
    <pantalon couleur="bleue"/>
</codeur>

La solution résiderait alors dans la création d’une entité « pantalon » lié par un OneToOne à mon entité « codeur », ce qui ne nous arrange absolument pas avouez-le. Si l’utilisation d’un unique attribut dans l’entité « codeur » est justifiée, la construction du XML avec un  nœud dédié « pantalon » l’est aussi. Alors que faire ?

Le denormalizer maison

Template du fichier XML

Je me suis finalement tourné vers une solution proposée sur ma question StackOverflow, à savoir créer mon propre « dénormalisateur ». Mais bien plus enrichi que ce qui est proposé en solution sur le site de SO. L’idée est simple : laisser le sérializer gérer le décodage en un tableau, et ensuite parcourir moi-même ce tableau afin de générer mes entités. Ça peut sembler une vraie usine à gaz, et c’est normal car ç’en est une, mais aux grands maux les grands remèdes !

Voilà mon idée : je finis un modèle de mon fichier XML dans le format que je veux. Personnellement j’ai choisis le YAML que j’affectionne pour sa simplicité de lecture comme d’écriture. Dans le cas de notre insertion, il ressemblerait à ceci :

codeur:
    nom:
        &type: string
        &setter: setName
    pantalon:
        @couleur:
            &type: string
            &setter: setCouleurPantalon

Vous avez compris le principe : je définis l’arborescence du XML et, dès que j’arrive à un noeud contenant des données, je lui attribue deux paramètres :

  • &type : le format des données (string, datetime, etc…) qui me servira plus tard à les traiter,
  • &setter : le nom de la methode setter de l’entité qui servira à insérer la donnée dans celle-ci.

Vous remarquez également le @couleur du noeud « pantalon » : cela signifie que la donnée se trouve dans l’attribut « couleur » de son nœud parent.

Arobase comme dans @ttribut

Maintenant que le template est prêt, voyons le reste. On crée notre propre dénormalizer en lui passant en paramètre ce qu’on souhaite, mais au moins une instance de Doctrine afin qu’il puisse faire ses propres insertions en base.

use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

class FacturationDenormalizer implements DenormalizerInterface {
    private $xmlMap;
    private $rootName       = "insertion";
    private $setterNodeName = "&setter";
    private $typeNodeName   = "&type";
    private $em;
    
    public function __construct(DenormalizerInterface $delegate, 
                                \Doctrine\Bundle\DoctrineBundle\Registry $doctrine
    ){
        $this->delegate = $delegate;
        $this->em       = $doctrine->getManager();
    }

    private function setXmlMap($xmlMap){
        $this->xmlMap = $xmlMap;
    }
    // le reste viendra ici
}

À partir de là, l’idée est la suivante : on réécrit la méthode denormalize() (à laquelle on injecte en paramètre le YAML schéma) au sein de laquelle on parcourt le tableau issu du XML décodé :

    public function denormalize($data, $class, $format = null, array $context = array()){
        // on utilise la clé "xml_map" pour passer le schéma XML
        if (!is_array($data) 
            || !isset($context['xml_map']) 
            || !is_array($context['xml_map'])
        ){
            return null;
        }
        $xmlMap = $context['xml_map'];
        $this->setXmlMap($xmlMap);
        $counter = 0;

        // On va gérer les cas particuliers comme les attributs et les entités liées
        foreach ($data as $key => $value) {
            if ($key === $this->rootName){
                // on distingue le cas où la première clé du tableau est : 
                // 0 (zéro) : cela signifie qu'on a plusieurs entités enfants
                // (autre)  : cela signifie qu'on aura qu'une entité à gérer
                $keys   = array_keys($value);
                if ($keys[0] === 0){
                    // Entités multiples dans le XML
                    $counter = 0;
                    foreach ($value as $codeur){
                        $entity = $this->treatEntity($codeur);
                        $this->em->persist($entity);
                        $counter++;
                    }
                    $this->em->flush();
                    $this->em->clear();
                } else {
                    // Entité unique dans le XML
                    $entity = $this->treatEntity($value);
                    $this->em->persist($entity);
                    $this->em->flush();
                    $counter = 1;
                }
            }
        }
        return $counter;
    }

Comme vous le voyez, cette méthode appelle en fait une méthode « treatEntity() » donc le but va être de traiter chaque noeud représentant une de nos entités en base (ici un codeur). Cette méthode va alors faire deux choses  : elle va commencer par traiter les attributs directs de l’entité si elle en a, puis elle appellera une méthode itérative qui se chargera de traiter le reste de l’arborescence.

    private function treatEntity($entity){
        $codeur = new Codeur();
        foreach($entity as $key => $value){
            if (0 === strpos($key, '@')){
                // gestion des ATTRIBUTS du noeud <codeur>
                $this->updateFacturation(
                    $codeur,
                    $value,
                    $this->xmlMap[$this->rootName][$key]
                );
            } else {
                // gestion des NOEUDS ENFANTS du noeud <codeur>
                $map = $this->xmlMap[$this->rootName];
                $this->treatNode($map, $entity, $codeur, $key, $value);
            }
        }
        return $codeur;
    }

Vous avez immédiatement noté l’appel à la méthode itérative treatNode() qui va ensuite traiter le tableau étage après étage. Par contre, deux choses ont du vous interpeler : tout d’abord, on cherche le caractère ‘@’ dans le nom des noeuds. En effet, c’est la subtilité du serializer : les noms des attributs sont précédés d’un arobase afin de les reconnaître. Le second point, c’est l’utilisation d’une nouvelle variable $map ; en fait, l’efficacité du processus est basée sur le fait qu’on avance en parallèle dans le tableau de données et dans le schéma YAML. Cela nous permet de diminuer, à chaque étape du tableau, la quantité de données à évaluer via des boucles.

Dès lors, le chemin de la méthode treatNode() est tout tracé car c’est quasiment la même que la précédente :

  • soit le nœud courant est sans enfants, commence par un @ (attribut) ou par un # (donnée du nœud ) auquel cas on envoie le nœud à une méthode chargée de l’évaluer et de le transformer en donnée de l’entité
  • soit le nœud contient des enfants, auquel cas la méthode se relance elle-même en passant au niveau enfant du nœud de base.
    private function treatNode($xmlMap, $baseTable, Codeur $codeur, $nodeName, $nodeValues){
        if (is_array($xmlMap)){
            $isEntity = false;
            if (array_key_exists($nodeName, $xmlMap) 
                && array_key_exists($this->typeNodeName, $xmlMap[$nodeName]) 
                && $xmlMap[$nodeName][$this->typeNodeName] === "entity"
            ){
                $isEntity = true;
            }
            
            if ($isEntity
                || 0 === strpos($nodeName, '@') 
                || !is_array($nodeValues)
            ){
                // attribut ou noeud sans enfants ni attributs
                $map = $xmlMap;
                if (array_key_exists($nodeName, $xmlMap)){
                    $map = $xmlMap[$nodeName];
                }
                
                $codeur = $this->updateCodeur(
                    $codeur, 
                    $nodeValues, 
                    $map,
                    $isEntity,
                    $nodeName
                );
            } else {
                foreach($nodeValues as $key => $value){
                    if (($key !== "#"  && array_key_exists($key, $xmlMap[$nodeName]))
                        || ($key === "#" && $value != "")
                    ) {
                        $this->treatNode($xmlMap[$nodeName][$key], $baseTable[$nodeName][$key], $codeur, $key, $value);
                    }
                }
            }
        }
    }

Nous touchons au but avec un dernier appel à la méthode « updateCodeur() » qui va -enfin- analyser les données du nœud et les insérer dans l’entité Codeur. Les points clés de cette méthode sont, tout d’abord, la gestion de différents types de données et leur transformation (notamment Datetime). Ensuite, il y a la gestion des données de type « entity » qui sont, finalement, un appelle à ce même denormalizer. Enfin, une fois la valeur du nœud traitée, elle appelle $codeur->$setter où $setter est le nom du setter trouvé dans le YAML schéma.

    private function updateCodeur($codeur, $value, $xmlMap, $entity = false, $nodeName = ""){
        $setter = null;

        if (array_key_exists($this->setterNodeName, $xmlMap)
            && $xmlMap[$this->setterNodeName] !== "none"
        ){
            $setter =  $xmlMap[$this->setterNodeName];
            
            // traitement de la valeur
            if (array_key_exists($this->typeNodeName, $xmlMap)){
                $type = $xmlMap[$this->typeNodeName];
                switch ($type) {
                    case "integer":
                        $value = intval($value);
                        break;
                    case "float":
                        $value = floatval($value);
                        break;
                    case "datetime":
                        $dateparts = array();
                        if (preg_match("#^(\d{4})-(\d{1,2})-(\d{1,2})$#", $value, $dateparts)){
                            if ($dateparts[1] > 1950){
                                $value = new \Datetime;
                                $value->setDate($dateparts[1], $dateparts[2], $dateparts[3]);
                            } else {
                                $value = null;
                            }
                        } else {
                            $value = null;
                        }
                        break;
                    case "entity":
                        $entity = true; // pour être sûr
                        if ($setter !== null 
                            && array_key_exists("&entity", $xmlMap)
                            && is_array($value)
                        ){
                            // Dans le cas d'une entité, on la convertit
                            $class = $xmlMap["&entity"];
                            
                            // si la 1ère clé du tableau est 0 (zéro) c'est qu'on a plusieurs entités similaires dans un même tableau
                            foreach ($value as $xmlEntityValues){
                                $arrayKeys = array_keys($xmlEntityValues);
                                if ($arrayKeys[0] === 0){
                                    foreach ($xmlEntityValues as $uniqueEntity){
                                        $entity = $this->treatBasicEntity($class, $uniqueEntity);
                                        $entity->setFacturation($codeur);
                                        $codeur->$setter($entity);
                                    }
                                } else {
                                    $entity = $this->treatBasicEntity($class, $xmlEntityValues);
                                    $entity->setFacturation($codeur);
                                    $codeur->$setter($entity);
                                }
                            }
                        }
                        break;
                    default:
                        break;
                }
            }
            
            if (!$entity){
                $codeur->$setter($value);
            }
        }
        return $codeur;
    }

Et voilà ! De manière itérative, chaque fois qu’on arrive à une fin de nœud, on insère la valeur dans l’entité. Celle-ci remonte alors finalement jusqu’à la méthode denormalize() du début et est alors insérée en base.

Dernière précision : si vous avez tout lu vous avez noté l’appel à « treatBasicEntity()« . En réalité, je n’ai pas géré le cas où les entités liées par relations à « Codeur » possèderaient également un XML spécial. Je suis parti du principe qu’elles ne contenaient que des données simples ou des attributs. Son unique boulot est donc de transformer les attributs en noeuds standards de manière à ce que la méthode denormalize() de base du serializer soit capable de l’interpréter.

    private function treatBasicEntity($class, $xmlValues){
        // Dans le cas d'une entité, on la convertit
        $attributes = array();

        foreach ($xmlValues as $key => $value) {
            $dateparts = array();
            if (preg_match("#^(\d{4})-(\d{1,2})-(\d{1,2})$#", $value, $dateparts)){
                if ($dateparts[1] > 1950){
                    $value = new \Datetime;
                    $value->setDate($dateparts[1], $dateparts[2], $dateparts[3]);
                } else {
                    $value = null;
                }
            }
            
            if (0 === strpos($key, '@')) {
                $attributes[substr($key, 1)] = $value;
            } elseif ($key !== "#") {
                $attributes[$key] = $value;
            } else {
                // cas particulier unique : "montant" pour le DetailHonoraire
                $attributes['montant'] = $value;
            }
        }

        return $this->delegate->denormalize($attributes, $class, 'xml'); 
    }
    

Je conçois que ce post était long et, certainement, imbuvable. Néanmoins si quelqu’un y trouve un intérêt, qu’il me le fasse savoir, ça fait toujours plaisir !