Eine der zentralen Herausforderungen in Software-Projekten stellt die Entwicklung von Software unter Einhaltung von Architekturregeln dar. Auch neue Entwickler sollen bestehende Architekturregeln einhalten oder im Falle von Wartungsarbeiten frühere Architekturregeln erkennen und beachten. Die Dokumentation von Architekturregeln unterliegt ähnlich wie die Code-Kommentare der Softwareerosion und führt in der Regel zu mehr Verwirrung denn Unterstützung. Mein lieb gewonnener Kommentar ist folgender:

/** Always returns true */
public boolean aMethod(){
  return false;
}

Daher suchte ich nach Möglichkeiten, Architekturregeln in den Build-Prozess von Projekten zu integrieren. Eine der wichtigsten Regeln ist die Einhaltung der Architekturschichten, wie bspw. Klassen der Rest-Controller-Schicht dürfen die Datenbank-Schicht nicht direkt aufrufen, sondern hierfür die Service-Schicht nutzen. Ich habe mir die folgenden Ansätze zur Lösung dieses Problems näher angesehen:

  • Java-Modules bzw. verschiedene Maven-Projekte zur Trennung von Schichten
  • AspectJ (Code-Weaving) für Methoden-Interceptoren
  • Eigene Checkstyle-Erweiterungen
  • Eigener Java-Annotation-Processor
  • jQAssistant mit einer Neo4j Graphdatenbank

Mit Java-Modulen können nur Architekturschichten definiert werden, und mit AspectJ ist es sehr schwierig, Architekturregeln zu definieren. Checkstyle und Annotationsprozessoren können den Java-Code (Bytecode, Quellcode) analysieren, aber es ist ebenso schwierig Architekturregeln zu definieren, wie bei den vorherigen Ansätzen. 

Analyse der Architektur mit jQAssistant

Schliesslich habe ich das Maven-Plugin jQAssistant gefunden, welches die geeignetste Lösung für meine Anforderungen ist. jQAssistant wird im Maven Build Prozess gestartet und analysiert unter anderem die folgenden Projektartefakte:

  • Java Bytecode inklusive Abhängigkeiten und Metainformationen
  • Properties-Files, pom.xml und andere Resourcedateien
  • Per Plugin können noch weitere Informationen analysiert werden, wie beispielsweise Excel-Tabellen

Die Informationen werden in einer lokalen Neo4j Graphdatenbank persistiert.

Um zu zeigen, was der Datenbank hinzugefügt wird, analysieren wir das Projekt Apache Commons Lang:

Apache Commons Lang für die Analyse von jQAssistant vorbereiten

Zuerst wird das Projekt geladen

git clone https://github.com/apache/commons-lang.git

 

Nachdem das Projekt importiert wurde, muss nur noch ein Maven-Plugin zur POM-Datei hinzugefügt werden.

<plugin>

 <groupId>com.buschmais.jqassistant</groupId>

 <artifactId>jqassistant-maven-plugin</artifactId>

 <executions>

   <execution>

     <goals>

       <goal>scan</goal>

       <goal>analyze</goal>

     </goals>

     <configuration>

       <warnOnSeverity>MINOR</warnOnSeverity>

       <failOnSeverity>MAJOR</failOnSeverity>

     </configuration>

   </execution>

 </executions>

</plugin>

 

Nach dem nächsten Build sind alle Informationen in der Datenbank aufgenommen. Mit dem Maven-Plugin kann auch ein Web-Server für Neo4j gestartet werden, der dann unter http://localhost:7474/ verfügbar ist.

jqassistant:server

 

Architektur-Übersicht mit jQAssistant

Unter den Klassen kann man z.B. nun nach allen “*Utils” Klassen suchen.

Die Query “MATCH (n:Class) WHERE n.name ENDS WITH 'Utils' RETURN n” ist die spezielle DB-Abfragesprache Cypher, die das Gegenstück zu SQL für Graphendatenbanken darstellt. Die Query sucht nach Knoten vom Typ “Class”, deren Property “name” mit “Utils” endet. Die runden Klammern sollen den Knoten symbolisieren.

Dies ist ein weiterer Vorteil von jQAssistant. Es ist möglich, eine Codebasis explorativ zu analysieren und dann die Architektur-Annahmen durch eine Cypher-Query zu beweisen oder zu widerlegen.

 

Einfache Architektur-Regeln

Bei einem unbekannten Projekt interessiere ich mich zunächst für die komplexen, meist langen und unstrukturierten Methoden. Dies lässt sich näherungsweise mit dem zyklomatischen Index beschreiben.

MATCH 

  p=(c:Class)-[r:DECLARES]->(m:Method) 

WHERE 

  m.cyclomaticComplexity > 50 

RETURN p

 

knotenverbindungen-im-asci-format

Knotenverbindungen im ASCI-Format

 

Hier wird nach Knotenverbindungen gesucht, die im ASCII-Format mit (…)-[…]->(…) beschrieben werden können, d.h. Mit der Syntax Knoten-Kante-Knoten.

 

Offenbar enthält die Klasse “NumberUtils” zwei relativ komplexe Methoden “boolean isCreatable(java.lang.String)” und “java.lang.Number createNumber(java.lang.String)”. Auch der “effectiveLineCount” wird berechnet und beide Methoden sind größer als 60 Zeilen.

 

Bei meinen weiteren Analysen fiel mir auf, dass sich toString-Methoden relativ häufig nacheinander aufrufen, was zu Performance-Problemen führen kann. Hier ist eine erweiterte Abfrage um diese Fälle zu finden.

MATCH 

  p=(m1)-[:INVOKES]->(m2)-[:INVOKES]->(m3) 

WHERE 

  m1.name = 'toString' AND m3.name = 'toString' 

  AND m1.cyclomaticComplexity>1 AND m2.cyclomaticComplexity>1 

  AND m3.cyclomaticComplexity>1 

RETURN p LIMIT 200

 

Interessanterweise wurde damit ein rekursiver Aufruf gefunden, den man sonst nicht sofort sehen würde.

rekursiver-aufruf

rekursiver Aufruf

 

java.lang.String classToString(java.lang.Class)” ruft hier “java.lang.String toString(java.lang.reflect.Type)” auf, welche rekursiv wieder andere Klassen mit classToString aufrufen kann. Eine StringBuffer oder StringBuilder Variante könnte die Performance für diese Methoden erhöhen.

 

Zyklische Abhängigkeiten

Um zyklische Package-Strukturen zu finden, ist die Cypher-Query viel länger, aber immer noch relativ einfach zu lesen.

MATCH
  (a:Artifact),
  (a)-[:CONTAINS]->(p1:Package),
  (a)-[:CONTAINS]->(p2:Package),
  (p1)-[:DEPENDS_ON]->(p2),
  path=shortestPath((p2)-[:DEPENDS_ON*]->(p1))
WHERE   p1.fqn <> 'org.apache.commons.lang3'   AND p2.fqn <> 'org.apache.commons.lang3'
RETURN
  a.fileName, p1.fqn AS package,   extract(p in nodes(path) | p.fqn) AS Cycle
ORDER BY
  package

 

Diese Query liefert nun nicht einen Graphen als Ergebnis, sondern eine Tabelle.

tabellarische-darstellung

tabellarische Darstellung der Query

 

Um Architekturregeln zu definieren, können diese nun mit Cypher beschrieben werden. 

 

Ideen für weitere Architektur-Regeln

Die folgenden Regeln sind zum Beispiel bei uns verwendet worden:

 

  • Zu jeder <X>Entity Klasse in einem bestimmten Package gibt es eine entsprechende <X> Klasse und eine <X>Mapping Klasse.
  • Klassen aus dem Package “controller” dürfen die Klassen aus den Package “entity” nicht direkt verwenden. 
  • Utility Klassen dürfen keinen öffentlichen Konstruktor und keine Member-Variablen haben.
  • Methoden in Enums und DTO-Klassen müssen einen niedrigen zyklomatischen Index haben.
  • Wir unterscheiden zwischen Services, die “viel berechnen”, und Services, die “nichts berechnen und nur weiter delegieren”. ComputationServices dürfen keine anderen Services verwenden. DelegatingServices müssen in allen Methoden einen niedrigen zyklomatischen Index haben.
  • Der DelegatingPasswordEncoder muss in SpringBoot verwendet werden.
  • Die Anzahl der Parameter in Methoden darf nicht zu groß werden.
  • JPA-Repository-Interfaces sollen keine Default-Methoden enthalten.
  • Reflection darf nur im Utility-Package verwendet werden.
  • Controller-Methoden müssen mit dem Http-Verb beginnen.

Integration in JUnit

Um Build-Constraints aus Cypher-Queries zu erhalten, wird für jeden Constraint eine XML-Datei im Projekt unter “jqassistant” gespeichert.

<jqa:jqassistant-rules xmlns:jqa=“http://www.buschmais.com/jqassistant/core/analysis/rules/schema/v1.0”>     <constraint id=“my-rules:TestClassName”>         <requiresConcept refId=“junit4:TestClass” />         <description>All JUnit test classes must have a name with suffix “Test”.</description>         <cypher><![CDATA[             MATCH                 (t:Junit4:Test:Class)             WHERE NOT                 t.name =~ “.*Test”             RETURN                 t AS InvalidTestClass         ]]></cypher>     </constraint>     <group id=“default”>         <includeConstraint refId=“my-rules:TestClassName” />     </group> </jqa:jqassistant-rules>

Mit jQAssistant können auch die Constraints gut parametrisiert werden. Dies hilft, projektspezifische Anpassungen einfach zu definieren. Ein weiterer Vorteil ist, dass mit Cypher Knoten mit neuen Knotentypen annotiert werden können. Dies ermöglicht Queries auch auf den neuen Knotentyp Bezug zu nehmen und erleichtert damit die Lesbarkeit. Das folgende Beispiel zeigt beide Mechanismen.

<jqa:jqassistant-rules xmlns:jqa=“http://www.buschmais.com/jqassistant/core/rule/schema/v1.3”>     <concept id=“my-rules:ApplicationRootPackage”>         <requiresParameter name=“rootPackage” type=“String” defaultValue=“com.buschmais”/>         <description>Labels the root package of the application with “Root”.</description>         <cypher><![CDATA[           MATCH             (root:Package)           WHERE             root.name = {rootPackage}           SET             root:Root           RETURN             root         ]]></cypher>     </concept> </jqa:jqassistant-rules>

Cypher im Überblick

Um alle architektonischen Constraints definieren zu können, ist eine grundlegende Kenntnis von Cypher unumgänglich. Daher werden im Folgenden die wichtigsten Cypher-Details erläutert

Zum Testen von Anfragen ist es oft hilfreich, den Neo4j-Server ohne jQAssistant zu starten. Mit ihm können echt spezifische Szenarien und interessante Konstellationen für die Abfrage erstellt werden. 

Mit Docker ist der Neo4j-Server schnell erstellt.

docker run \\\\\\\\\\\\\\\\
    --publish=7474:7474 --publish=7687:7687 \\\\\\\\\\\\\\\\
    --volume=$HOME/neo4j/data:/data \\\\\\\\\\\\\\\\
    neo4j

Ohne das Attribut “volume” werden alle Daten gelöscht, nachdem der Container gestoppt wurde.

 

Knoten, Kanten und Attribute können innerhalb von Cypher einfach erstellt werden.

CREATE (john:Person {name: 'John'})
CREATE (joe:Person {name: 'Joe'})
CREATE (steve:Person {name: 'Steve'})
CREATE (sara:Person {name: 'Sara'})
CREATE (maria:Person {name: 'Maria'})
CREATE (john)-[:FRIEND]->(joe)-[:FRIEND]->(steve)
CREATE (john)-[:FRIEND]->(sara)-[:FRIEND]->(maria)

 

 

Die allgemeine Neo4j-Query-Syntax lautet wie folgt: 

[MATCH WHERE]
[OPTIONAL MATCH WHERE]
[WITH [ORDER BY] [SKIP] [LIMIT]]
RETURN [ORDER BY] [SKIP] [LIMIT]

Der Absatz “WITH” ist ähnlich wie “RETURN”: er definiert, was für die zugrunde liegenden Absätze verwendet wird. Gewöhnlich benutzt man die Ausdrücke “… AS <name>” zur Bindung einer Variablen. Alle Variablen, die darunter verwendet werden sollen, müssen in “WITH” angegeben werden.

“SKIP” und “LIMIT” sind die aus SQL bekannten Paging-Mechanismen.

 

MATCH-Syntax

Die folgenden Optionen sind in dem Graph-Template verfügbar:

  • (n:Class): Knoten mit Label “Class” und wird mit der Variablen n referenziert.
  • (n:Class {name: ‘Object’}): Knoten mit Label “Class” und Name-Attribut “Object”. Der Knoten wird mit der Variablen n referenziert.
  • (k1)–(k2): Knoten k1 zu Knoten k2. Die Kanten-Richtung ist unerheblich.
  • (k1)–>(k2): Knoten k1 zu Knoten k2. Die Kanten-Richtung wird beachtet.
  • (k1)-[r]-(k2): Knoten k1 mit Kante r zu Knoten k2 (die Richtung ist unerheblich).
  • (k1)-[r:CONTAINS]->(k2): Knoten k1 mit Kante r und Label “CONTAINS” zu Knoten k2.
  • (k1)-[*1..3]->(k2): Knoten k1 mit 1..3 Kanten zu Knoten k2.
  • (k1)-[*]->(k2): Knoten k1 mit beliebigen Kanten zu Knoten k2.
  • shortestPath((c1:Class)-[*..6]-(c2:Class)): Kürzeste Pfade bis zu 6 Kanten von Class c1 zu Class c2.
  • allShortestPaths((c1:Class)-[*..6]->(c2:Class)): Alle kürzesten Pfade bis zu 6 Kanten von Class c1 zu Class c2. 
  • p = (k1)–>(k2): Knoten k1 zu Knoten k2. Die Kanten-Richtung wird beachtet. Der Pfad wird der Variablen p zugewiesen.
  • NOT (k1)-[r:CONTAINS]->(k2): Keine Verbindungen mit Knoten k1 über Kante r und Label “CONTAINS” zu Knoten k2 sind zugelassen.

 

Aggregations-Funktionen in RETURN / WITH

Hauptsächlich in RETURN und WITH werden die Aggregations-Funktionen verwendet:

  • count(*): Aggregations-Funktion, die alle Reihen zählt.
  • count(n): Aggregations-Funktion, die alle nicht leeren Elemente zählt.
  • sum(n.x), avg(n.x), min(n.x), max(n.x): Aggregations-Funktion für numerische Werte.
  • collect(n): Aggregiert alle Knoten des Matches zu einer Liste.

 

WHERE-Operatoren

Folgende Operatoren werden unterstützt (die Liste ist nicht vollständig):

  • n.name: Attribut “name” des Knoten n wählen.
  • n[‘name’ + x]: Dynamische Attributabfrage (‘name’+x) für den Knoten n.
  • n.x + n.y, n.x – n.y , n.x * n.y , n.x / n.y , n.x % n.y , n.x ^ n.y: Mathematische Operatoren von Attributen.
  • n.x = n.y , n.x <> n.y , n.x < n.y , n.x > n.y , n.x <= n.y , n.x >= n.y , n.x IS NULL, n.x IS NOT NULL: Vergleichsoperatoren von Attributen.
  • n.x STARTS WITH ‘search’, n.x ENDS WITH ‘search’, n.x CONTAINS ‘search’: String-Vergleich von Attributen.
  • AND, OR, NOT: Boolsche Operatoren. In der Regel werden diese für die Verknüpfung von Termen genutzt.
  • n.x + n.y, n.x =~ ‘regex’: Konkatenation von Strings und Regex-Matcher.

 

Weiterhin gibt es auch Listen-Operatoren:

  • [‘a’, ‘b’, ‘c’]: Explizit definierte Liste.
  • range(<start>, <end>, <step>): Erzeugt eine Zahlen-Liste. Step ist dabei optional.
  • labels(n): Liste von Labels des Konten n.
  • nodes(p): Liste von Knoten eines Pfads.
  • relationships(p): Liste von Kanten eines Pfads.
  • keys(n): Liste alle Attribut-Keys eines Pfads.
  • UNWIND <list> AS item MATCH (n {name: item}): Die Liste wird in einzelne Zeilen der Suche transformiert und kann weiter verwendet werden, wie z.B. mit MATCH.
  • [(a)–>(b) WHERE b.name = ‘Object’ | b.fqn]: Pattern comprehension, um eine Liste zu erzeugen.
  • <list>[0]: Listen-Zugriff auf den Index 0.
  • size(<list>): Größe der Liste.
  • reverse(<list>): Liste umkehren.
  • head(<list>), last(<list>), tail(<list>): Erstes Element, letztes Element und alles außer dem ersten Element als Liste.
  • [item IN <list> WHERE <condition> | item.name]: Mapping und Filterung einer Liste. WHERE <condition> ist optional.
  • reduce(s = <start>, x IN <list> | f(s,x)): Reduce-Operation der Liste mit vorgegebenen Start und f als Reduce-Funktion.
  • list1 + list2: Konkatenation von Listen.
  • n.x in <list>: Abfrage, ob das Attribut x des Knoten n in der Liste enthalten ist.

 

Zusätzlich sind Allquantoren mit Hilfe von Listen-Prädikaten möglich:

  • all(item IN <list> WHERE f(item)): Gibt true zurück, wenn in allen Elementen der Liste die Prädikatsfunktion true wird.
  • any(item IN <list> WHERE f(item)): Gibt true zurück, wenn in mindestens einem Element der Liste die Prädikatsfunktion true wird. 
  • none(item IN <list> WHERE f(item)): Gibt true zurück, wenn in keinem Element der Liste die Prädikatsfunktion true wird. 
  • single(item IN <list> WHERE f(item)): Gibt true zurück, wenn in genau einem Element der Liste die Prädikatsfunktion true wird. 

 

Abschließendes Beispiel: Wähle alle Klassen aus, die sich in einem bestimmten Paket befinden und für die es keine entsprechende “Daten”-Klasse gibt.

MATCH (allClass:Class)  // 1
WHERE allClass.fqn STARTS WITH "com.wogra" //2
WITH collect(allClass) as allClassList //3
MATCH (c1:Class) //4
WHERE
  NONE(c2 in allClassList WHERE c2.name = c1.name + "Data") //5
  AND c1.fqn STARTS WITH "com.wogra" //6
  AND NOT c1.name ENDS WITH "Data"
  AND NOT c1.name CONTAINS "$"
RETURN c1 //7

 

  1. Selektiere alle Knoten mit Klassen-Label.
  2. Filter Klassen, die nicht mit “com.wogra” beginnen, heraus.
  3. Aggregiere alle Knoten zu einer Liste.
  4. Starte mit einer neuen Suche und selektiere alle Knoten mit Klassen-Label. Binde die Knoten an die Referenz c1.
  5. Behalte c1, falls es keine Klasse c2 gibt, die den gleichen Namen wie c1 und dazu “Data” hat und… 
  6. Mit “com.wogra” startet, mit “Data” endet und kein “$” enthält (keine anonyme oder innere Klasse).
  7. Gebe c1 zurück.

Fazit

jQAssistant bietet eine gute Möglichkeit, Architektur-Regeln flexibel zu definieren. Es stehen viele Plugins zur Verfügung. Auch die Anbindung von neuen Projekt-Artefakten für die Graphen-Datenbank ist mit geringem Aufwand möglich. Lediglich die Syntax und Semantik von Cypher muss selbst für einfache Abfragen beherrscht werden. 

jQAssistant kann aber auch für Neo4j-Interessenten nützlich sein – hat man doch damit sehr schnell einen komplexen fachgetriebenen Graphen aufgebaut, mit dessen Hilfe man seine Abfrage-Künste erproben kann.

 

Stefan Fenn
Software Architect