Chiron-wire

11 views
Skip to first unread message

Laurent Caillette

unread,
Aug 27, 2018, 5:33:10 AM8/27/18
to tec...@googlegroups.com

Chiron-wire est une bibliothèque en `Java 8` qui unifie l'interface programmatique pour lire et écrire des données hiérarchiques, selon un format positionnel //ou// avec des métadonnées. 

Je peux le dire autrement. Chiron-wire, c'est XML et Protobuf dans le même slip. C'est mieux ? Bof ?

Je la refais. On peut classifier les bibliothèques de sérialisation de différentes façons. Avec un schéma explicite ou sans. Dans un format textuel lisible par un humain, ou du binaire juste lisible par une machine. Avec de la génération de code ou tout réglé dynamiquement. Avec des montées de version automagiques ou explicites ou pas du tout. Du point de vue de l'interface programmatique, la plus grande différence est dans la présence de métadonnées, qui va grandement contribuer à la lisibilité par un humain. La contrepartie c'est les ressources nécessaires ; si on veut de la performance on s'orientera plutôt vers un format positionnel sans métadonnées.

Mais moi je veux // une interface programmatique et une seule // pour :
- Du XML, simplifié mais expressif.
- Du positionnel qui déchire les perfs. 

Le mot-clé là-dedans c'est "XML simplifié". Si on ne garde que les `10 %` utiles de la spec du XML, il reste "quelque chose d'assez supportable"
, et si on fait en sorte d'écrire et de lire tous les attributs dans le même ordre, cet ordre est suffisant pour une écriture-lecture positionnelle. 

Ma grande source d'inspiration se situe dans "Chronicle-Wire"
, une bibliothèque de sérialisation ultra-rapide pour Java. Bien sûr on y trouve les grands classiques comme les données hors-tas et le zéro-copie, mais le choc pour moi ce fut cette idée toute simple que les métadonnées pouvaient être optionnelles. Genre : le quatrième champ il s'appelle ``expiration-date`` et si c'est que du positionnel il peut s'appeler n'importe quoi on s'en fout tant que c'est bien le quatrième champ. En réalisant cela je me suis mis à trembler de tout mon corps en bavant, c'était merveilleux.

En plus le mot "wire" ("câble") est génial, il s'abstient d'évoquer une mécanique à une seule direction. Généralement on parle de bibliothèque de sérialisation alors que les trois quarts du boulot c'est la désérialisation. Genre ça part de travers dès que tu prononces le nom du truc.


== Concepts

Chiron-wire utilise deux abstractions : le nœud ("node") et la feuille ("leaf"). 

- La feuille : c'est pour une donnée qu'on peut considérer comme élémentaire, typiquement un nom, un nombre ou une date. C'est une feuille dans l'arbre.
- Le nœud : c'est un nœud de l'arbre qui peut contenir des feuilles ou d'autres nœuds.

Hormis dans les tests, il n'y a pas d'objets ``Node`` et ``Leaf`` parce qu'on ne veut pas fournir de représentation objet du document, le but c'est de tout traiter au fil de l'eau.

Chiron-wire exige un schéma minimal avec les nœuds et feuilles préalablement énumérés, implantant les interfaces ``NodeToken`` et ``LeafToken``. Un nœud énumère ses sous-nœuds et ses feuilles. Une feuille fournit une méthode pour écrire ou lire la donnée élémentaire (avec une opération de bas niveau, ou avec un ``Converter< ?, String >`` sorti de Guava). Le schéma est du pur code Java produit avec les doigts de la main. Les ``NodeToken`` et ``LeafToken`` peuvent être ce qu'on veut mais le choix raisonnable pour commencer c'est une ``Autoconstant`` sortie de Chiron-toolbox. 

Il n'y a pas de génération de code. Si on veut gérer le versionnage la recommandation c'est d'inclure le numéro de version dans un champ pour aiguiller vers la bonne version du code, parce que les trucs de compatibilité ascendante implicite ça finit toujours par foirer.

Le schéma c'est une moitié du boulot, l'autre moitié c'est le code impératif lire ou écrire les nœuds et leurs feuilles. Il n'y a aucune tentative de faire quoique ce soit d'automagique, l'accès aux champs des objets c'est du vrai code Java normal et statiquement typé. Le truc c'est juste de minimiser l'emballage ("boilerplate") autour de ce code.


== Le code

Les impatients peuvent tout de suite aller voir "tout le code exemple d'un coup"
.

Définissons tout d'abord une structure hiérarchique non-récursive. Chiron-wire supporte les structures récursives mais on va garder cet exemple simple.

<<<
  public static class SomeA {
    public final ImmutableList< SomeB > children ;

    public SomeA( final ImmutableList< SomeB > children ) {
      this.children = children ;
    }
  }

  public static class SomeB {
    public final String x ;
    public final Integer y ;

    public SomeB( final String x, final Integer y ) {
      this.x = x ;
      this.y = y ;
    }
  }
>>>

Définissons maintenant un schéma qui inclut du typage statique pour les feuilles. Si on veut une récursion croisée il y a moyen de surcharger la méthode ``NodeToken#subnodes`` pour ne pas se retrouver coincé par les déclarations ``static`` interdisant les références à un symbole non encore défini ("illegal forward reference"). 

Notons que l'``Autoconvert`` (qui est une spécialisation de l'``Autoconstant``) supporte un paramètre de type par instance. Une enum Java ne peut pas faire ça.

<<<
  public static class MyLeafToken< ITEM > extends Wire.LeafToken.Autoconvert< ITEM > {

    public static final MyLeafToken< String > X =
        new MyLeafToken<>( Converter.identity() ) ;

    public static final MyLeafToken< Integer > Y =
        new MyLeafToken<>( Ints.stringConverter().reverse() ) ;

    private MyLeafToken( Converter< ITEM, String > converter ) {
      super( converter ) ;
    }
    
    static final ImmutableMap< String, MyLeafToken > MAP = valueMap( MyLeafToken.class ) ;
  }

  public static class MyNodeToken extends Wire.NodeToken.Auto< 
      MyNodeToken, 
      MyLeafToken 
  > {

    public static final MyNodeToken B = new MyNodeToken(
        ImmutableSet.of(), ImmutableSet.of( MyLeafToken.X, MyLeafToken.Y ) ) ;

    public static final MyNodeToken A = new MyNodeToken(
        ImmutableSet.of( MyNodeToken.B ), ImmutableSet.of() ) ;

    private MyNodeToken(
        final ImmutableSet< MyNodeToken > myNodes,
        final ImmutableSet< MyLeafToken > myLeaves
    ) {
      super( myNodes, myLeaves ) ;
    }

    static final ImmutableMap< String, MyNodeToken> MAP = valueMap( MyNodeToken.class ) ;
  }
>>>

Créons quelque chose qui transforme un document XML en flux d'événements StAX :

<<<
    final String xml = Joiner.on( "\n" ).join(
        "<a>",
        "  <b x='one' y='1' >",
        "  <b x='two' y='2' >",
        "</a>"
    ) ;
    
    final XMLStreamReader xmlStreamReader = XMLInputFactory.newInstance()
        .createXMLStreamReader( new StringReader( xml ) ) ;
>>>

Créons un ``NodeReader`` pour du XML. Nous lui passons le schéma défini plus haut :

<<<    
    final XmlNodeReader< MyNodeToken, MyLeafToken > nodeReader =
        new XmlNodeReader<>( xmlStreamReader, MyNodeToken.MAP, MyLeafToken.MAP ) ;
>>>

Maintenant on récupère un objet ``SomeA`` reconstitué à partir du XML, en indiquant le nom de l'élément racine :

<<<
    final SomeA parsedA = nodeReader.singleNode( MyNodeToken.A, WireDemo::readSomeA ) ;
>>>

Mais d'où sort la méthode ``readSomeA`` ? C'est l'implantation d'une interface ``ReadingAction`` avec une seule méthode. La ``ReadingAction`` utilise le ``NodeReader``. Qui est ce monsieur ? C'est avec lui qu'on va : 
- Lire une feuille.
- Lire un sous-nœud.
- Lire une séquence de sous-nœuds.

Voilà ce que définit Chiron-wire :

<<<
package com.otcdlink.chiron.wire ;
...
interface Wire {
  ...
  interface NodeReader< ... > {
    ...
    < ITEM_LEAF extends LeafToken< ITEM >, ITEM > ITEM 
    leaf( ITEM_LEAF leafWithTypedItem ) throws WireException ;

    < ITEM > ITEM singleNode(
        NODE node,
        ReadingAction< NODE, LEAF, ITEM > readingAction
    ) throws WireException ;

    < ITEM, COLLECTION > COLLECTION nodeSequence(
        NODE node,
        Collector< ITEM, ?, COLLECTION > collector,
        ReadingAction< NODE, LEAF, ITEM > readingAction
    ) throws WireException ;
    
    interface ReadingAction<
        NODE extends NodeToken< NODE, LEAF >,
        LEAF extends LeafToken,
        ITEM
    > {
      ITEM read( NodeReader< NODE, LEAF > nodeReader ) throws WireException ;
    }
    
>>>

Avec ça on a de quoi coder ``readSomeA`` et ``readSomeB`` (qui sont des ``ReadingAction``) :

<<<
  private static SomeA readSomeA(
      final Wire.NodeReader< MyNodeToken, MyLeafToken > nodeReader
  ) throws WireException {
    final ImmutableList< SomeB > listOfB = nodeReader.nodeSequence(
        MyNodeToken.B, ImmutableList.toImmutableList(), WireDemo::readSomeB ) ;
    return new SomeA( listOfB ) ;
  }

  private static SomeB readSomeB(
      final Wire.NodeReader< MyNodeToken, MyLeafToken > nodeReader
  ) throws WireException {
    final String x = nodeReader.leaf( MyLeafToken.X ) ;
    final Integer y = nodeReader.leaf( MyLeafToken.Y ) ;
    return new SomeB( x, y ) ;
  }
>>>

La méthode qui lit un nœud est supposée renvoyer l'objet correspondant //mais ce n'est pas obligatoire//. Elle peut renvoyer ``null`` et passer le résultat de la lecture par un effet de bord. Si on veut jouer à ça on utilise une méthode d'instance qui va modifier un champ.

Pareil pour la lecture des séquences de nœuds. Le ``Collector`` Java (passé en tant que ``ImmutableList.toImmutableList()``) offre toute liberté dans le choix du type concret de la collection et la façon de l'alimenter. Donc si en sortie de méthode il manque encore quelque chose pour fabriquer la collection, ou qu'on veut faire un truc plus tordu, il suffira de passer un ``Collector`` qui renverra ``null`` comme collection.

Notons bien que les appels à ``leaf`` renvoient le type ``ITEM`` passé en paramètre dans la déclaration du ``LeafToken< ITEM >``. Donc pas moyen de mélanger les pommes et les betteraves.

Après on peut rendre l'écriture plus compacte, avec les indentations du code qui suivent la structure des éléments XML :

<<<
    final SomeA parsedAgainWithMoreCompactCode = new XmlNodeReader<>(
        newStreamReader( xml ),  // Need a fresh one.
        MyNodeToken.MAP,
        MyLeafToken.MAP
    ).singleNode(
        MyNodeToken.A,
        nodeReader1 -> new SomeA( nodeReader1.nodeSequence(
            MyNodeToken.B,
            ImmutableList.toImmutableList(),
            nodeReader2 -> new SomeB(
                nodeReader2.leaf( MyLeafToken.X ),
                nodeReader2.leaf( MyLeafToken.Y )
            )
        ) )
    ) ;
>>>

Est-ce que quelqu'un a remarqué que les collections n'étaient pas encagées dans un élément XML dédié ? Autrement dit pas besoin d'assister le ``XmlNodeReader`` de cette façon :

<<<
<a>
  <collection-of-b>  <!-- Chiron-wire doesn't need that one. -->
    <b x='one' y='1' />
    <b x='two' y='2' />
  </collection-of-b>
</a>
>>>

Oui ça rend le ``XmlNodeReader`` et le ``XmlNodeWriter`` plus dur à coder. Mais c'est tellement plus joli.

Pour l'écriture c'est pareil, on se base sur un ``NodeWriter`` et une ``WritingAction`` :

<<<
package com.otcdlink.chiron.wire ;
...
interface Wire {
  ...
  interface NodeWriter<
      NODE extends NodeToken< NODE, LEAF >,
      LEAF extends LeafToken
  > {
  
    < ITEM_LEAF extends LeafToken< ITEM >, ITEM > void leaf(
        ITEM_LEAF leaf,
        ITEM item
    ) throws WireException ;

    < ITEM > void singleNode(
        NODE node,
        ITEM item,
        WritingAction< NODE, LEAF, ITEM > writingAction
    ) throws WireException ;

    < ITEM > void nodeSequence(
        NODE node,
        int count,
        Iterable< ITEM > items,
        WritingAction< NODE, LEAF, ITEM > writingAction
    ) throws WireException ;

    interface WritingAction<
        NODE extends NodeToken< NODE, LEAF >,
        LEAF extends LeafToken,
        ITEM
    > {
      void write( NodeWriter< NODE, LEAF > nodeWriter, ITEM item ) 
          throws WireException ;
    }

>>>

On les utilise comme suit :

<<<
  private static void writeSomeA(
      final Wire.NodeWriter< MyNodeToken, MyLeafToken > nodeWriter,
      final SomeA someA
  ) throws WireException {
    nodeWriter.nodeSequence( MyNodeToken.B, someA.children, WireDemo::writeSomeB ) ;
  }

  private static void writeSomeB(
      final Wire.NodeWriter< MyNodeToken, MyLeafToken > nodeWriter,
      final SomeB someB
  ) throws WireException {
    nodeWriter.leaf( MyLeafToken.X, someB.x ) ;
    nodeWriter.leaf( MyLeafToken.Y, someB.y ) ;
  }
>>>

Et c'est tout ! Il faut garder à l'esprit que le schéma et le code des ``ReadingAction`` et ``WritingAction`` sont utilisables aussi bien dans un ``XmlNode(Reader|Writer)`` pour du XML que dans un ``BytebufNode(Reader|Writer)`` qui accède à un ``ByteBuf`` de Netty. Voilà comment ça se passe :

<<<
    final ByteBuf byteBuf = Unpooled.buffer() ;
    final BytebufNodeWriter< MyNodeToken, MyLeafToken > bytebufNodeWriter =
        new BytebufNodeWriter<>( byteBuf ) ;
    bytebufNodeWriter.singleNode( MyNodeToken.A, someA, WireDemo::writeSomeA ) ;
    LOGGER.info( "ByteBuf content: \n" + ByteBufUtil.prettyHexDump( byteBuf ) ) ;
>>>

Et ça nous affiche :

<<<
20:42:27.781 INFO  [main] com.otcdlink.chiron.wire.WireDemo - ByteBuf content: 
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 00 00 00 02 6f 6e 65 20 31 20 74 77 6f 20 32 20 |....one 1 two 2 |
+--------+-------------------------------------------------+----------------+
>>>

C'est presque lisible par un humain grâce à la convertion des entiers en ``String`` (dans ``MyLeafToken.X``) parce que que le ``BytebufNodeWriter`` utilise des espaces comme séparateurs. Donc c'est pas du binaire très binaire (tant pis pour ceux qui croyaient qu'on ne pouvait pas être binaire à moitié) mais c'est bien du positionnel.

Une fois qu'on a vu comment ça se passait pour du XML et un ``ByteBuf``, coder l'équivalent pour du JSON ou autre chose (FIX, `ASN.1`...) ne devrait pas être excessivement compliqué.


== Pourquoi pas plus de magie ?

Dans l'exemple ci-avant on a l'impression qu'il y a moyen de projeter automatiquement les objets Java vers des nœuds et des feuilles. Surtout, on voit bien que l'écriture et la lecture sont complètement symétriques au point où c'est quasiment le même code. 

Mais quand on manipule des objets plus complexes on a souvent des méthodes de construction qui font échouer la projection automatique, et si on ne veut pas pourrir son modèle objet on en revient à une projection manuelle et impérative, qui implique bien de faire le boulot dans les deux sens. 

Écrire à la main du code simple et statiquement typé, c'est souvent plus productif que des trucs implicites qui vont générer toutes sortes d'erreurs mystérieuses.


== Composabilité

Tout est fait pour que les ``ReadingAction`` et ``WritingAction`` soient composables, c'est-à-dire qu'on puisse les réutiliser dans des contextes différents. 

L'expérience montre que c'est plus facile de produire du code réutilisable pour parser un élément si ce code ne décide pas du nom de l'élément. C'est lié au fait que souvent on a des structures identiques pour des rôles différents, comme un acheteur et un vendeur. Donc les méthodes ``nodeSequence`` et ``singleNode`` prennent le nom du nœud en paramètre.

Bien sûr typer les déclarations des nœuds et des feuilles, ça limite les chances de se mélanger avec les noms d'éléments et d'attributs XML, donc on peut dire que ça aussi ça contribue à la composabilité.

Les feuilles peuvent utiliser des ``Converter< ?, String >`` qui sont bien pratiques pour la sérialisation-désérialisation de valeurs élémentaires, et qu'on peut définir quelque part comme des constantes.

Comme Le ``NodeReader`` (ou ``NodeWriter``) est toujours passé en paramètre, il n'y a pas à dériver d'une classe de base qui garderait une référence dessus, donc on garde l'unique cartouche de l'héritage pour les besoins applicatifs.

Le contrat de la lecture ou écriture d'un nœud, c'est qu'elle ne dérange pas la lecture ou l'écriture d'un nœud qui la contiendrait. Donc on peut composer ce qu'on veut dans ce qu'on veut. 

Après ce n'est pas forcément pratique de définir toute la hiérarchie dans la même ``NodeToken``. Pour des objets complexes il peut y avoir des collisions de noms. Mais grâce à la méthode ``redefineWith``on peut demander à un ``NodeReader`` (ou ``NodeWriter``) de modifier ses définitions de nœuds et de feuilles au moment de lire un nouveau nœud. 

<<<
  final XmlNodeReader< NodeTokenX, LeafTokenX > nodeReader = 
      new XmlNodeReader<>(
          xmlStreamReader,
          NodeTokenX.MAP,
          LeafTokenX.MAP
      ) 
  ;
  nodeReader.singleNode( 
      NodeTokenX.ROOT, 
      reader0 -> {
          reader0.redefineWith( NodeTokenY.MAP, LeafTokenY.MAP )
              .singleNode( NodeTokenY.OTHER_ROOT, reader1 -> { ... } )
          return ... ;    
      }
  ) ;
>>>

Dans l'exemple ci-dessus, la méthode ``redefineWith`` modifie le ``XmlNodeReader< NodeTokenX, LeafTokenX >`` en ``XmlNodeReader< NodeTokenY, LeafTokenY >``. Par contre derrière c'est le même parseur XML donc il retombera bien sur ses pattes après avoir intégralement lu l'élément ``OTHER_ROOT``.


== Retours d'erreur

Toutes les méthodes lancent une ``WireException`` qui est une exception nécessitant un traitement explicite ("checked exception"). Elle enrobe notamment les erreurs d'entrées-sorties, et ajoute (si possible) des informations détaillées sur l'emplacement dans le document XML, ainsi que la pile des éléments.

Au moment où j'écris ce billet je n'ai pas encore codé la validation du schéma (défini par ``NodeToken`` et ``LeafToken``) mais ça n'a pas l'air bien méchant.


== Tests 

Pour reproduire dans Chiron-wire les cas qui apparaissent dans le code applicatif, le ``WireMill`` définit un mini-langage pour définir des ``(Node|Leaf)Token`` et des ``(Reading|Writing)Action``. Ça permet de multiplier les tests sans écrire une tonne de classes avec différentes combinaisons de feuilles, de séquences et de nœuds tout seuls.

Un test avec le ``WireMill`` ressemble à ça :

<<<
    final WireMill.Molder molder = WireMill.newBuilder()
        .node( "A" )
            .leaf( "x1", INT )
            .leaf( "x2", INT )
            .subnode( "B" )
        .node( "B" )
            .leaf( "y", INT )
        .beginReusableBlock( "r0" )
            .single( "B" )
        .endReusableBlock()
        .root( "A", "r0" )
        .build()
    ;

    // Serialize, deserialize, compare, serialize again, re-compare.
    final WireMill.PivotNode pivotNode = apply( molder,
        "<A x1='1' x2='2' >",
        "  <B y='3' />",
        "</A>"
    ) ;
>>>

Avec ça on doit pouvoir reproduire la grande majorité des cas d'erreur.


== Sous le capot

Le ``XmlNodeReader`` repose sur StAX. Je n'ai rien trouvé de mieux pour parser du XML en Java. Avant Chiron-wire j'ai écrit pas mal de code pour remonter des objets à partir d'un document XML et c'est StAX qui a engendré les trucs les moins désolants. 

Le ``XmlNodeWriter`` repose sur le ``MarkupWriter`` de Chiron qui repose sur le ``java.lang.Appendable``. C'est tout ce que j'ai trouvé qui fonctionne au fil de l'eau et qui me laisse formater le XML d'une façon qui préserve la lisibilité des attributs. 


== Petite parenthèse sur StAX et Aalto XML

"Aalto XML"
est une implantation de StAX avec une particularité notable : il supporte des entrées-sorties non-bloquantes. Il y parvient avec deux toutes petites extensions de StAX :
- Une pour signifier que le parseur a épuisé le XML qu'il pouvait lire.
- Une autre alimenter le parseur avec un bout de document.

Ça veut dire qu'on peut parser un très gros document au fil de l'eau, sans bloquer de fil d'exécution, au fur et à mesure qu'on reçoit des ``ByteBuf`` de Netty par exemple.

L'interface programmatique de Chiron-wire ne permet pas d'utiliser des entrées-sorties non-bloquantes de cette façon. Ça nécessiterait un sérieux effort de reconception, et avant de se lancer dans un chantier pareil ça serait aviser de s'instruire sur les "Coroutines de Kotlin"
qui pourraient faciliter ce genre d'approche.


== Conclusion 

L'aventure Chiron-wire, c'est encore une tentative de tordre le cou à ces trucs idiots comme JAXB ou XStream, tout en profitant des bienfaits des formats positionnels. Je reconnais avoir dit par le passé beaucoup de bien de XStream, mais avec le recul rien ne vaut du code typé statiquement et qui supporte la refactorisation automatique fournie par les environnements de développement. 

Comme l'engouement pour le XML a culminé bien avant la sortie de `Java 5` il n'y a pas eu beaucoup d'efforts pour intégrer les types génériques, et encore moins pour les lambdas de `Java 8`. Donc ce n'était pas trop dur de trouver un truc avec une meilleure gueule que l'existant.

Pour ceux qui apprécient vraiment cette idée des métadonnées débrayables, je conseille toutefois d'utiliser Chronicle-Wire qui bénéficie d'un développement beaucoup plus soutenu, d'un support commercial etc. Chronicle-Wire ne parle pas encore le XML mais si on veut des métadonnées il y a déjà du JSON et du YAML. Je n'utilise pas Chronicle-Wire parce que la migration aurait été beaucoup plus compliquée qu'avec une bibliothèque maison qu'on peut tordre dans tous les sens et qui ne trimballe que les fonctionnalités souhaitées. 



Reply all
Reply to author
Forward
0 new messages