Fragen? Jetzt Anruf vereinbaren!

Effiziente Navigation auf großen XML-Strukturen

XML eignet sich hervorragend für den plattformunabhängigen Austausch strukturierter Daten. JAXB ist ein unglaublich praktisches Stück Java-Technologie, das den Entwickler von der mühsamen Aufgabe befreit, manuell (De-)Serialisierungscode zu schreiben, und ihm reguläre Java-POJOs als Programmierschnittstelle bietet. Wenn es jedoch um die Navigation in großen, stark vernetzten Objektgraphen geht, die aus reiner JAXB gewonnen werden, kann es zu ernsthaften Leistungsproblemen kommen.

Dieser Artikel gibt einige Einblicke, wie man diese Herausforderungen auf elegante und dennoch effiziente Weise lösen kann. Das Open-Source-Projekt ist auf GitHub zu finden.

Die Herausforderung

Das Modell, das ich in diesem Artikel als Referenzbeispiel verwende, ist der Vehicle Electric Container, das zur Beschreibung des gesamten elektrischen Netzes eines modernen Autos verwendet werden kann. Um Ihnen ein Gefühl dafür zu geben, wovon ich spreche, wenn ich von “großen, stark vernetzten Objektgraphen” spreche, hier einige Schlüsselzahlen:

Wenn Sie frei in der Struktur navigieren wollen, dann ist die Verwendung von JAXB von Haus aus nicht ausreichend. Ich werde erklären, wie man dies erreichen kann, ohne die Vorteile von JAXB zu verlieren, ohne umständlichen Code zu schreiben und ohne die SOLID-Prinzipien zu verletzen. Aber das Wichtigste zuerst.

Das Modell

Beispielabschnitt des Modells

Beispielabschnitt des Modells

Das obige Diagramm zeigt einen kleinen Teil des Modells, das ich verwenden werde, um die Herausforderungen und später die Lösung zu erklären. Es stellt den Schaltplan eines elektrischen Systems - die linke Seite (blau hervorgehoben) stellt die elektrischen Komponenten dar, die rechte Seite (grün hervorgehoben) die Verbindungen zwischen ihnen.

Eine erforderliche Funktion für dieses Modell könnte sein: Gib mir alle ComponentNodes, die mit ComponentPort X verbunden sind.

Es gibt viele Möglichkeiten, das Ergebnis zu berechnen, aber der einfachste und intuitivste Weg ist:

  1. Navigieren Sie zu den ConnectionEnds, die auf den ComponentPort X verweisen.
  2. Navigieren Sie zu der Verbindung der gefundenen Verbindungsenden und finden Sie die anderen Seiten (Verbindungsenden).
  3. Navigieren Sie von den anderen ConnectionEnds zu den referenzierten ComponentPorts und von dort in der Hierarchie nach oben, über den ComponentConnector zu dem ComponentNode.
  4. Sie haben Ihr Ergebnis erreicht!

So weit, so gut. Das Problem dabei ist, dass in reinem XML / JAXB die meisten der oben genannten Navigationen einfach nicht verfügbar sind. Warum ist das so? Werfen wir einen Blick auf die XML- und Java JAXB-Darstellung.

Das Schema

XML ist nicht speziell für die objektorientierte Serialisierung / Deserialisierung konzipiert, es ist ein viel allgemeineres Konzept. Um es für die OO-Serialisierung zu verwenden, müssen die OO-Konzepte auf definierte XML-Konzepte abgebildet werden. In unserem Fall ist das Transformationsmuster vom UML-Modell in XML-Schema wie folgt:

Die sich daraus ergebende XML-Schema-Definition für einige Klassen im obigen Diagramm finden Sie in der nachstehenden Auflistung (ich habe alle Details weggelassen, die für das Beispiel nicht relevant sind):

Teilweise Auflistung des XML-Schemas
   <xs:complexType name="ComponentPort">
      <xs:complexContent>
         <xs:extension base="vec:ConfigurableElement">
            <xs:sequence>
               <xs:element name="Identification" type="xs:string"/>
               ...
            </xs:sequence>
         </xs:extension>
      </xs:complexContent>
   </xs:complexType>

   <xs:complexType name="ComponentConnector">
      <xs:complexContent>
         <xs:extension base="vec:ConfigurableElement">
            <xs:sequence>
               <xs:element name="Identification" type="xs:string"/>
               ...
               <xs:element name="ComponentPort"
                           type="vec:ComponentPort"
                           minOccurs="0"
                           maxOccurs="unbounded"/>
            </xs:sequence>
         </xs:extension>
      </xs:complexContent>
   </xs:complexType>

   <xs:complexType name="ConnectionEnd">
      <xs:complexContent>
         <xs:extension base="vec:ConfigurableElement">
            <xs:sequence>
               <xs:element name="Identification" type="xs:string"/>
               ...
               <xs:element name="ConnectedComponentPort" type="xs:IDREF"/>
            </xs:sequence>
         </xs:extension>
      </xs:complexContent>
   </xs:complexType>

Die Java-Klassen

Wenn Sie dieses Schema in den “JAXB XML Schema to Java Compiler” (XJC) eingeben, erhalten Sie Java-Klassen, die denen in der folgenden Liste ähneln.

Es gibt einige bemerkenswerte Details in den generierten Klassen, die Sie beachten sollten:

  1. Alle Klassen (z.B. VecComponentPort) wissen nichts über eingehende Referenzen. Es gibt auch keinen Verweis auf die enthaltende Klasse (z.B. VecComponentConnector) und es gibt auch keine Möglichkeit der Rückwärtsnavigation zu referenzierenden Klassen (z.B. VecConnectionEnd). Daher wird die oben beschriebene gewünschte Navigation blockiert. Hierfür gibt es zwei Gründe:
    1. JAXB-Klassen können auch zur Serialisierung verwendet werden. Bidirektionale Assoziationen würden Informationen redundant speichern, inkonsistente Datenstrukturen zulassen und den Serialisierer noch Komplexität des Serialisierers.
    2. Aufgrund des allgemeinen Zwecks von XML ist es für den XJC unmöglich, eingehende Referenzen für alle XML-Anwendungsfälle zu bestimmen.
  2. Die Java-Darstellung von IDREF-Assoziationen (z.B. VecConnectionEnd.connectedComponentPort) sind mit dem Basistyp Object definiert, obwohl das UML-Modell sie als VecComponentPort definiert. Der Grund dafür ist, dass die vom XML-Schema definierte Syntax weniger streng ist als die Definition des UML-Modells. In XML Schema sind IDREF(S) Referenzen nicht typisiert und können auf jedes Element mit einer gültigen xs:ID zeigen. Daher ist es für XJC unmöglich, den richtigen Typ zu bestimmen.
Auflistung der von JAXB generierten Klassen
Erste Java-Klasse: VecComponentPort
   @XmlAccessorType(XmlAccessType.FIELD)
   @XmlType(name = "ComponentPort", propOrder = {
       "identification"
   })
   public class VecComponentPort
       extends VecConfigurableElement
       implements Serializable
   {
       @XmlElement(name = "Identification", required = true)
       protected String identification;

    public String getIdentification() {
        return identification;
    }

       public void setIdentification(String value) {
        this.identification = value;
       }
   }
   
Zweite Java-Klasse: VecComponentConnector
    @XmlAccessorType(XmlAccessType.FIELD)
    @XmlType(name = "ComponentConnector", propOrder = {
        "identification",
        "componentPorts"
    })
    public class VecComponentConnector
        extends VecConfigurableElement
        implements Serializable
    {

        @XmlElement(name = "Identification", required = true)
        protected String identification;

        @XmlElement(name = "ComponentPort")
        protected List<VecComponentPort> componentPorts;

        public String getIdentification() {
            return identification;
        }

        public void setIdentification(String value) {
            this.identification = value;
        }

        public List<VecComponentPort> getComponentPorts() {
            if (componentPorts == null) {
            componentPorts = new ArrayList<VecComponentPort>();
            }
            return this.componentPorts;
        }
    }
    
Dritte Java-Klasse: VecConnectionEnd
    @XmlAccessorType(XmlAccessType.FIELD)
    @XmlType(name = "ConnectionEnd", propOrder = {
        "identification",
        "connectedComponentPort"
    })
    public class VecConnectionEnd
        extends VecConfigurableElement
        implements Serializable
    {

        @XmlElement(name = "Identification", required = true)
        protected String identification;

        @XmlElement(name = "ConnectedComponentPort", required = true, type = Object.class)
        @XmlIDREF
        @XmlSchemaType(name = "IDREF")
        protected Object connectedComponentPort;

        public String getIdentification() {
            return identification;
        }

        public void setIdentification(String value) {
            this.identification = value;
        }

        public Object getConnectedComponentPort() {
            return connectedComponentPort;
        }

        public void setConnectedComponentPort(Object value) {
            this.connectedComponentPort = value;
        }
    }
    

Sackgassen-Ansätze

Für eine Implementierung mit reinen JAXB-Klassen habe ich zwei Ansätze gesehen, die beide ihre eigenen Nachteile haben. Um ein Bewusstsein für die daraus entstehenden Probleme zu schaffen, werde ich die beiden Ansätze kurz besprechen.

Der pragmatische Ansatz

Der pragmatische Ansatz lautet: “Wenn der direkte Weg versperrt ist, gehe ich außen herum!”. Wie in der realen Welt sind die Umwege jedoch normalerweise länger.

Bei diesem Ansatz würde allein die Navigation von ComponentPort zu den angeschlossenen ComponentPorts erfordern:

  1. Beginnen Sie bei der Wurzel (ConnectionSpecification), gehen Sie durch die Hierarchie und iterieren Sie über alle ConnectionEnds.
  2. Prüfen, ob der referenzierte ComponentPort derselbe ist wie der, mit dem wir begonnen haben.
  3. Wenn dies der Fall ist, wählen Sie die anderen ConnectionEnds. Für diesen Vorgang müssen Sie sich an Sie die aktuelle Connection als Kontext, da eine Rückwärtsnavigation in der Hierarchie nicht möglich ist.
  4. Navigieren Sie zu den verbundenen ComponentPorts.
  5. Wenn Sie nun die entsprechenden ComponentNodes (die transitiven Eltern der ComponentPorts) wissen wollen, müssen Sie erneut eine vollständige Baumsuche starten, dieses Mal auf der linken Seite.

Falls dieses Beispiel nicht für sich selbst spricht, hier sind einige Gründe, warum Sie es niemals so machen sollten, wie gerade beschrieben:

Der Caching-Ansatz

Bei diesem Ansatz berechnen Sie alle Rückwärtsnavigationen im Voraus und speichern die Ergebnisse irgendwo zur späteren Verwendung - nennen wir diesen “irgendwo” Assoziations-Cache. Nach der Deserialisierung der XML-Datei führen Sie im Grunde ein komplettes Modell-Traversal durch und legen alle relevanten Assoziationen in einem Cache für die Rückwärtssuche.

Dadurch wird das Leistungsproblem gelöst, indem der kontinuierliche Overhead für jede Operation durch einen einmaligen Overhead zur Startzeit ersetzt wird. Allerdings werden die Probleme der Stabilität und Wartbarkeit bleiben jedoch bestehen. Es ist immer noch ein komplexes Modell-Traversal erforderlich, Ihr Code benötigt immer noch Wissen über mögliche Navigationen und Sie verletzen das Single Responsibility Principle.

Das Prinzip der einzigen Verantwortung besagt, dass eine Codeeinheit die Verantwortung für eine einzige Funktionalität haben sollte, und diese Verantwortung exklusiv sein sollte. Im Gegensatz dazu wird die “Navigationsverantwortung” bei diesem Ansatz zwischen der von JAXB generierten Klasse (für Vorwärtsrichtungen) und dem Assoziations-Cache für Rückwärtsrichtungen.

Ein weiterer Nachteil dieses Ansatzes ist, dass der Cache eine Art Singleton-Objekt mit einem auf das aktuelle Modell beschränkten Geltungsbereich sein muss. Dieses Objekt muss für alle Stellen zugänglich gemacht werden, an denen eine Navigation in Rückwärtsrichtung erforderlich ist. Dies kann zu sehr unhandlichem Code führen.

Lösungsskizze

Es würde den Rahmen dieses Artikels sprengen, die Lösung in allen Einzelheiten mit all den kleinen Maschen und Knoten zu beschreiben. Daher werde ich nur die wichtigsten Konzepte erläutern und Sie Sie müssen die Lücken dazwischen selbst ausfüllen oder mich direkt für eine Diskussion kontaktieren.

Eine saubere Lösung der Aufgabe kann durch einige grundlegende Anforderungen definiert werden:

Der Weg, all dies zu erreichen, ist die offensichtlichste und einfachste Lösung, die man sich vorstellen kann: Wir machen einfach alle Assoziationen bidirektional navigierbar, mit fast keinen Auswirkungen auf die Laufzeit!.

Sie kennen vielleicht bidirektionale Assoziationen in Hibernate. Der Ansatz für diese Lösung ist fast die gleiche. Um sie zum Laufen zu bringen, haben wir zwei Bausteine:

  1. Wir müssen die JAXB-Klassen so modifizieren, dass sie bidirektionale Navigation unterstützen.
  2. Wir müssen die Assoziationen korrekt initialisieren.

JAXB-Klassenanpassung

Um bidirektionale Navigationen zu unterstützen, benötigen wir JAXB-Klassen, die wie das modifizierte VecComponentPort in der folgenden Liste aussehen. Wie Sie sehen können, definiert sie eine “ParentComponentConnector”-Eigenschaft für die Navigation zum enthaltenden Kontextelement, sowie eine “RefConnectionEnd”-Eigenschaft für alle “ConnectionEnds”, die Referenzen zu diesem ComponentPort definieren. Beide sind als @XmlTransient markiert, da bidirektionale Navigationen von JAXB selbst nicht unterstützt werden und sie von dem JAXB (Un)Marshaller ignoriert werden.

Geänderte VecComponentPort.java
    @XmlAccessorType(XmlAccessType.FIELD)
    @XmlType(name = "ComponentPort", propOrder = {
        "identification",
    })
    public class VecComponentPort
        extends VecConfigurableElement
        implements Serializable
    {

        @XmlElement(name = "Identification", required = true)
        protected String identification;

        @XmlTransient
        private VecComponentConnector parentComponentConnector;

        @XmlTransient
        private Set<VecConnectionEnd> refConnectionEnd = new HashSet<VecConnectionEnd>();

        public String getIdentification() {
            return identification;
        }

        public void setIdentification(String value) {
            this.identification = value;
        }

        public VecComponentConnector getParentComponentConnector() {
            return parentComponentConnector;
        }

        public Set<VecConnectionEnd> getRefConnectionEnd() {
            return refConnectionEnd;
        }
    }

Aber wie erreichen wir diese Änderungen? Wie immer gibt es viele Möglichkeiten. Sie könnten alle Klassen manuell ändern, aber das ist eine mühsame Aufgabe und die Änderung von generierten Klassen ist ein absolutes NO GO!

Glücklicherweise bietet XJC einen Plugin-Mechanismus, der die Ausführung von benutzerdefinierten Plugins während des Generierungsprozesses ermöglicht. Ein XJC-Plugin hat Zugriff auf das Code-Modell der generierten Klassen und kann diese modifizieren. Ein Beispiel für ein einfaches Plugin finden Sie hier.

Der folgende Codeschnipsel kann ein privates Feld mit einem optionalen Initialisierungswert und einer entsprechenden öffentlichen Getter-Methode in einer bestehenden Klasse erzeugen:

JCodeModel Beispiel
    public JFieldVar build() {
        final JFieldVar field = targetClass.field(JMod.PRIVATE, baseType, name);
        if (init != null) {
            field.init(init);
        }

        field.annotate(codeModel.ref(XmlTransient.class));

        final JMethod getter = targetClass.method(JMod.PUBLIC, baseType, getterName);
        getter.body()
                ._return(JExpr.ref(field.name()));

        if (getterJavadoc != null) {
            getter.javadoc()
                    .addAll(getterJavadoc);
        }

        return field;
    }

Zusätzlich können XJC-Plugins durch JAXB-Bindings parametrisiert werden. Mit einer JAXB-Bindungsanpassung, können Sie spezifische Parameter für bestimmte Schemaelemente bereitstellen. Dies funktioniert sowohl für JAXB-Standardfunktionen als auch für XJC-Plugins. Wir haben also ein JAXB-Plugin implementiert, das die Informationen über Referenzen aus der Bindung übernimmt und den generierten Code entsprechend modifiziert.

Ein Teil der resultierenden Bindung ist unten aufgeführt. Sie enthält zwei Anpassungen:

  1. <nav:parent/> definiert, dass eine übergeordnete Assoziation für diese Klasse erstellt werden soll, mit dem angegebenen Typ und Namen.
  2. <nav:backref/> definiert, dass eine Rückwärtsnavigation in der Zielklasse mit dem angegebenen Namen erstellt werden soll.

Wie Sie vielleicht bemerken, behebt diese Bindung auch das Problem der Typsicherheit von IDREF Assoziationen.

Benutzerdefinierte JAXB-Bindung
   <jxb:bindings node="//xs:complexType[@name='ComponentPort']">
      <nav:parent name="parentComponentConnector" type="VecComponentConnector"/>
   </jxb:bindings>
   <jxb:bindings node="//xs:complexType[@name='ConnectionEnd']">
      <nav:parent name="parentConnection" type="VecConnection"/>
      <jxb:bindings node=".//xs:element[@name='ConnectedComponentPort']">
         <nav:backref name="refConnectionEnd"/>
         <jxb:property>
            <jxb:baseType name="de.foursoft.harness.somepackage.VecComponentPort"/>
         </jxb:property>
      </jxb:bindings>
   </jxb:bindings>

Um die Wartbarkeit dieses Ansatzes zu verbessern, wird die benutzerdefinierte JAXB-Bindung nicht manuell erstellt. Wie eingangs erwähnt, ist die XML-Schemadarstellung nicht so prägnant wie das UML-Modell. In unserem Fall ist das zum Glück anders, wird das XML-Schema glücklicherweise aus einem UML-Modell generiert, das alle von uns benötigten Informationen enthält. Daher verwenden wir das UML-Modell auch zur Generierung der JAXB-Bindung über einige leistungsstarke XSLT-Skripte. Durch diesen Ansatz ist gewährleistet, dass die JAXB-Bindung immer konsistent mit dem XML-Schema ist und keine Elemente fehlen. Als Nebeneffekt sparen wir die Zeit, die für die manuelle Erstellung und Anpassung erforderlich ist.

Modellinitialisierung

Mit dem ersten Schritt haben wir nun JAXB-Klassen, die unseren Anforderungen entsprechen. Allerdings müssen wir sie noch korrekt initialisieren, da unsere Erweiterungen benutzerdefiniert sind und JAXB sie ignorieren wird. Für die Initialisierung unserer Erweiterungen, verwenden wir die JAXB-Funktionalität der Schnittstelle “Unmarshaller.Listener”. Eine Implementierung dieser Schnittstelle wird über jedes unmarshallte Objekt und seinen entsprechenden Elternteil im XML-Baum benachrichtigt.

Der Vorteil dieses Mechanismus ist, dass wir kein eigenes Model Traversal implementieren müssen und es ist sichergestellt, dass wir über jede einzelne Instanz benachrichtigt werden, unabhängig vom umgebenden Kontext. Dennoch gibt es zwei Themen mit denen wir uns beschäftigen müssen:

  1. Der JAXB Unmarshaller ist nur in der Lage, einen einzigen Listener zu behandeln. Aus Design-Gründen haben wir uns entschieden, eine spezifische Listener-Instanz pro Klasse zu haben. Daher wird die Listener-Schnittstelle von einem ModelPostProcessorManager implementiert, der die Benachrichtigungen an die verschiedenen ModelPostProcessor-Instanzen delegiert.
  2. Die Initialisierung erfordert zwei Phasen:
    1. Die erste Phase wird von JAXB gesteuert und wir verwenden die Listener.afterUnmarshall()-Ereignisse, um alle relevanten Objekte für die spätere Initialisierung zu sammeln.
      Die eigentliche Initialisierung kann zu diesem Zeitpunkt nicht erfolgen, da JAXB die IDREF-Assoziationen in diesem Stadium noch nicht initialisiert hat.
    2. Sobald der Unmarshalling-Prozess von JAXB abgeschlossen ist, beginnen wir die zweite Phase der eigentlichen Initialisierung. Dies wird erreicht, indem der Unmarshaller mit dem ExtendedUnmarshaller umhüllt wird. Der ExtendedUnmarshaller ist für die korrekte Konfiguration des Unmarshallers verantwortlich und löst die zweite Phase durch den Aufruf des ModelPostProcessorManager.doPostProcessing() aus.
Model Initialization Draft

Model Initialization Draft

Die Initialisierung der Assoziationen erfolgt durch einen ReflectiveAssociationPostProcessor. Diese Implementierung verwendet Reflection, um die notwendigen Informationen zu sammeln und die Assoziationen entsprechend zu initialisieren. Um sie mit allen mit allen erforderlichen Informationen zu versorgen, wurden zwei Annotationen eingeführt, die vom benutzerdefinierten XJC-Plugin hinzugefügt werden, um diese Informationen zu speichern: @XmlParent und @XmlBackreference.

@XmlParent" wird verwendet, um ein Feld in einer Klasse für die Initialisierung mit dem entsprechenden Elternobjekt zu markieren. @XmlBackreference markiert eine IDREF-Assoziation als bidirektional und definiert den Namen der umgekehrten Navigationseigenschaft in der Zielklasse.

Kommentierte VecConnectionEnd.java
    @XmlAccessorType(XmlAccessType.FIELD)
    @XmlType(name = "ConnectionEnd", propOrder = {
        "identification",
        "connectedComponentPort"
    })
    public class VecConnectionEnd
        extends VecConfigurableElement
        implements Serializable
    {
        @XmlElement(name = "Identification", required = true)
        protected String identification;

        @XmlElement(name = "ConnectedComponentPort", required = true, type = Object.class)
        @XmlIDREF
        @XmlSchemaType(name = "IDREF")
        @XmlBackReference(destinationField = "refConnectionEnd")
        protected VecComponentPort connectedComponentPort;
        @XmlTransient
        @XmlParent
        private VecConnection parentConnection;

        // Rest of the class is omitted.
        ....
    }

Weitere Gedanken zu diesem Thema

Ich denke, die skizzierte Lösung spricht für sich selbst, aber bedenken Sie, dass dies nur eine kurze Zusammenfassung ist. Insbesondere die konkrete Implementierung der Modellinitialisierung sollte mit besonderer Vorsicht durchgeführt werden, da sonst sonst gibt es zahlreiche Möglichkeiten, neue Leistungslöcher zu schaffen.

Bei korrekter Implementierung (ohne übermäßige Leistungsoptimierungen) fügt diese Lösung dem reinen JAXB-Unmarshalling etwa 10 % zusätzlichen Overhead Zeit hinzu. Dafür erhalten Sie eine komfortable, intuitive Navigation zwischen JAXB-Objekten bei gleichbleibender Laufzeitkomplexität.

Das ist aber noch nicht das Ende der Fahnenstange! Was passiert, wenn Sie im Modell navigieren wollen, z.B. um Objekten nach ID oder nach bestimmten Kriterien wie “alle Objekte einer bestimmten Klasse finden” suchen? Im Moment überlasse ich dies Ihrer eigenen Kreativität.

Picture Credits Title: © Robert Kneschke, Adobe Stock
Johannes Becker

Johannes Becker

Managing Consultant

+49 89 5307 44-523

becker@4soft.de

Wie sind Ihre Erfahrungen mit großen XML Strukturen? Haben Sie andere Lösungsansätze? Ich freue mich über einen regen Austausch - kontaktieren Sie mich gern.