Graph-basierte Software-Analyse mit jQAssistant

Artikel als PDF herunterladen:
Download Graph-basierte Software-Analyse mit jQAssistant

Qualitätssicherung in der Software-Entwicklung ist seit Jahren ein Thema, mit dem es problemlos möglich ist, Zeitschriften, Bücher und Konferenzen inhaltlich zu füllen. Die Ansatzpunkte sind äußerst vielfältig – sie reichen von der Prozessorganisation über Teststrategien, technische Infrastrukturen bis hin zu vermeintlich trivialen Dingen wie der Formatierung des Quellcodes. In diesem Artikel möchte ich einen Aspekt beleuchten, der sich auf der Ebene statischer Code-Analysen abspielt: die Festlegung und Überwachung projektspezifischer Architektur- und Design-Regeln.

 

Verfallene Strukturen

Verfallene Strukturen

 

Architektur und Design – Das reale (Er-)Leben

An den Anfang möchte ich folgende Frage stellen: Wie sähen unsere Städte, Häuser und Wohnungen aus, wenn wir sie in derselben Art und Weise bauen würden, wie wir heutzutage Software entwickeln? Zugegebenermaßen ist diese Gegenüberstellung nicht ganz fair – ich habe nicht den Hauch einer Vorstellung davon, wieviel Bauwerke im Laufe der Menschheitsgeschichte vor oder nach ihrer Fertigstellung eingestürzt sind oder auf andere Art und Weise die ihnen ursprünglich zugewiesene Bestimmung nicht erfüllen konnten. Offensichtlich konnten jedoch im Verlaufe von Jahrhunderten bzw. Jahrtausenden wertvolle Erfahrungen gesammelt werden, die es ermöglichen, weitestgehend stabile Konstrukte zu planen und zu erschaffen. Eine wichtige Erkenntnis ist dabei, dass Städte und Gebäude heutzutage auf verschiedensten Ebenen – bestimmt durch den jeweils umgebenden Kulturkreis – im Sinne ihrer Bestimmung wiederkehrenden und als funktionierend erprobten Mustern folgen, trotzdem individuelle Merkmale aufweisen und immer noch Weiterentwicklungen spürbar sind.

Zur Veranschaulichung möchte ein Beispiel skizzieren: Wer in einer mitteleuropäischen Wohnung aufgewachsen ist, kann sich innerhalb weniger Minuten in der Wohnung eines Freundes orientieren. Oder konkreter ausgedrückt: wenn ein Gast sich die Hände waschen will, wird er seinen Gastgeber intuitiv nach dem Bad und nicht nach dem Schlafzimmer fragen. Wer in einer mitteleuropäischen Stadt aufgewachsen ist, wird zur Verrichtung von Amtsgeschäften deren Rathaus zielgerichtet im Zentrum und nicht in ringsherum angrenzenden Waldstücken suchen. An dieser Stelle möchte ich den Leser auffordern, einen Vergleich zu wagen und die Strukturierung ihm bekannter Softwareprojekte auf ein Bild einer Stadt mit Häusern und Wohnungen zu projizieren. Für mich sind existierende Softwareprojekte oftmals wie Labyrinthe, in denen ich mich regelmäßig verlaufe, deren Straßen nicht selten entweder im Kreis führen oder im Nichts enden und das Entfernen von an Häusern gelehnten Aktenkoffern zu Einstürzen führt. Betrete ich ein noch stehendes Haus, stößt mein Kopf als erstes an einen zu niedrigen Türrahmen und die Toilette finde ich erst nach längerer Suche hinter dem Küchentisch. Wer würde hier leben wollen – würde der Begriff Lebensqualität überhaupt existieren?

Erwartungen, Gewohnheiten und Regeln

Was möchte ich mit diesen Bildern verdeutlichen? Ein Entwickler verfügt über einen Erfahrungsschatz und Gewohnheiten, aus welchen heraus er – ob nun bewusst oder unbewusst – Erwartungen an existierende Strukturen ableitet und neue entsprechend umsetzt. Um welche Dinge kann es sich dabei handeln? Zur Veranschaulichung möchte eine kleine Liste von Beispielen geben, die Erfahrungswerte aus realen Java-Projekten widerspiegeln:

  • Aufteilung eines Projektes in fachliche bzw. technische Module sowie technische Schichten
  • Abhängigkeiten zwischen Modulen untereinander und zu externen Bibliotheken in den jeweiligen Schichten
  • Namensregeln für Module, Packages, Klassen, Methoden und Felder
  • Lokalisierung von Klassen bestimmter Rollen (z.B. Entitäten) in bestimmten Packages

Für die langfristige „Wartbarkeit“ eines Projektes kann es ein entscheidender Faktor sein, derartige Gewohnheiten bzw. Erwartungen zwischen den Beteiligten offenzulegen, abzustimmen und kohärent zu halten. Einem Entwickler, der für eine gewisse Zeit in einem Teil der Software „zu Hause“ war, sollte es möglichst leicht gemacht werden, bei Bedarf in einen anderen „Stadtteil“ umzuziehen, sich dort schnell zurechtzufinden, bestehende Strukturen entsprechend zu ergänzen und dabei nicht aus Versehen zu zerstören. Die Schaffung dieser Voraussetzung erleichtert maßgeblich die Umsetzung neuer Features und beschleunigt die Lokalisierung bzw. Behebung von Fehlern.

In der Schaffung dieser Kohärenz liegt aber eine große Herausforderung: wie kann ich es schaffen, eine Gruppe von Entwicklern mit jeweils unterschiedlichem Erfahrungsschatz, Wissen über die vorliegende Code-Basis aber auch unterschiedlichen Charakteren von der Einhaltung einheitlicher Regeln zu überzeugen? Wie kann ich sicherstellen, dass eine wachsende und sich verändernde Menge von Regeln effizient an alle Beteiligten auch bei nicht vermeidbarer personeller Fluktuation kommuniziert wird? Ist es möglich, dies auch unter Drucksituationen, wie sie kurz oder nach vor Release-Terminen oftmals entstehen, durchzuhalten?

Dokumentation – ob in Wikis oder Word-Dateien – ist erfahrungsgemäß nie aktuell und für eine große Anzahl von Regeln schnell unüberschaubar. Besser geeignet sind Methoden wie Pair-Programming oder Code-Reviews – allerdings kämpfen auch diese im Zweifelsfall immer mit der Einschränkung, dass die daran Beteiligten ggf. nur über einen Teil des Wissens verfügen. Insofern wäre eine Unterstützung durch Werkzeuge, die einem Entwickler kontinuierlich Feedback über in seinem Code vorliegende Regelverletzungen vor einem Commit ins Versionskontrollsystem geben, sehr hilfreich. Es existieren zwar zweifelsohne sehr gute Werkzeuge wie Checkstyle, PMD, FindBugs & Co., allerdings basieren deren Prüfungen auf fest-kodierten Regeln, für die keine oder nur sehr eingeschränkte Parametrisierbarkeit vorhanden ist. Es ist mit ihnen sehr schwer, eigentlich sogar unmöglich, projektspezifische Regeln auf höheren strukturellen Ebenen zu formulieren.

In letzter Zeit sind vermehrt Ansätze zu beobachten, die auf der Erfassung von Softwarestrukturen in Datenbanken basieren und Abfragen darüber erlauben. Diesen Weg geht auch das Open-Source-Projekt „jQAssistant“ (https://jqassistant.org): es wird in den Build-Prozess von Java-Anwendungen eingebunden, liest die erzeugten Artefakte und speichert Informationen über deren Strukturen in einer eingebetteten Instanz der Graphendatenbank Neo4j. Darauf können Regeln angewendet werden, die in der Neo4j-Abfragespache „Cypher“ formuliert sind. So werden beispielsweise Constraints als Abfragen ausgedrückt, welche genau dann Ergebnisse liefern, wenn Regelverletzungen vorliegen. In diesem Fall wird der laufende Build durch jQAssistant mit einem aussagekräftigen Fehler abgebrochen. Diesen Ansatz möchte ich in den folgenden Abschnitten vertiefen.

Software-Strukturen als Graph

Neo4j speichert Daten als Graph bestehend aus Knoten und Beziehungen zwischen diesen. Übertragen auf Softwarestrukturen können beispielsweise folgende Elemente als Knoten modelliert werden:

  • Artefakte
  • Packages
  • Java Typen: Klassen, Interfaces, Enumerationen, Annotationen
  • Felder, Methoden
  • Werte: Annotationen (d.h. konkrete Ausprägungen eines Annotationstyps)

Um den Typ eines Knotens festzulegen, wird er mit einem oder mehreren sogenannten Labels versehen. Darüber hinaus kann jeder Knoten über Properties (Attribute) verfügen, diese hängen von seinem Typ ab. jQAssistant markiert beispielsweise Klassen-Knoten mit den Labels „Type“ sowie „Class“ und fügt Properties wie „name“, „visibility“, usw. mit entsprechenden Werten hinzu. Zwischen einzelnen Knoten können Beziehungen hergestellt werden, die wiederum einen Typen besitzen und gerichtet sind. So erzeugt jQAssistant für Klassen-Knoten ausgehende Beziehungen vom Typ „DECLARES“ zu Knoten, welche in den Klassen deklarierte Methoden darstellen. Abbildung 1 veranschaulicht ein Beispiel, welches die in der Datenbank gespeicherten Daten originalgetreu bildet. Es gibt dabei keine Fremdschlüssel etc., wie sie aus der relationalen Welt bekannt sind. Auf diesen Strukturen können nun Abfragen formuliert werden – doch wie sehen diese aus?

 

Software-Strukturen im Graphenmodell

Abbildung 1: Software-Strukturen im Graphenmodell

Abfragen mit Cypher

Eine Abfrage in Neo4j besteht darin, Knoten und Beziehungen nach vorgegebenen Mustern zu suchen. Diese werden als ASCII-Art ausgedrückt: runde Klammern stehen für Knoten, Pfeile für Beziehungen. Am besten lässt sich das an Beispielen verdeutlichen:

match (c:Class)
return c.fqn

Die Abfrage besteht aus einer Match- und einer Return-Klausel. Sie kann folgendermaßen interpretiert werden: Suche in der Datenbank nach allen Knoten, welche mit dem Label „Class“ versehen sind, weise jedes einzelne Ergebnis der Variablen „c“ zu und gib in jeder Zeile der Ergebnismenge den Wert der Property fqn (=voll qualifizierter Name) zurück.

match (c1:Class)-->(c2:Class)
return c1.fqn, c2.fqn

Suche nach allen Knoten mit dem Label „Class“, welche über mindestens eine ausgehende Beziehung zu anderen Knoten mit einem Label „Class“ verfügen und gib in jeder Zeile der Ergebnismenge jeweils die voll qualifizierten Namen der zueinander in Beziehung stehen Knoten zurück.

match (c1:Class)-[:EXTENDS]->(c2:Class)
return c1.fqn, c2.fqn

Entspricht der vorhergehenden Abfrage, es werden aber nur Beziehungen vom Typ „EXTENDS“ betrachtet werden. Das Ergebnis repräsentiert also alle Java-Klassen und deren jeweilige Superklassen.

match h=(c1:Class)-[:EXTENDS*]->()
return c1.fqn, length(h) as Depth
order by Depth desc limit 10

Eine Abwandlung der vorhergehenden Abfrage in der Art, dass EXTENDS-Beziehungen über mehrere Knoten hinweg verfolgt werden (ausgedrückt durch *) und der entstehende Pfad aus Knoten und Beziehungen einer Variablen h zugewiesen wird. Dessen Länge (d.h. Anzahl enthaltener Knoten) wird gemeinsam mit dem voll qualifizierten Namen des Knotens, der den Ausgangspunkt des Pfades darstellt, zurückgegeben. Die Variable c2 aus der vorhergehenden Abfrage spielt für das Ergebnis keine Rolle mehr, sie wird weggelassen. Im Klartext ausgedrückt ermittelt diese Abfrage die zehn Klassen mit den tiefsten der Vererbungshierarchien – eine im Alltag durchaus nützliche Metrik.

Als ich das erste Mal mit Cypher in Kontakt kam, war ich davon überrascht, wie ich innerhalb kürzester Zeit erstaunlich gut lesbare Abfragen mit überaus interessanten Ergebnissen erzeugen konnte, die ich in anderen Sprachen (SQL) noch nicht einmal ausdrücken konnte. Abbildung 2 zeigt einen Screenshot einer weiteren Metrik über die Klassen des Java Runtime Environments (JDK 1.8.0_20) – ich wünsche viel Spaß sowohl beim Interpretieren der Abfrage als auch bei der Analyse des Ergebnisses:

match (c:Type)-[:DECLARES]->(m:Method)
return c.fqn, count(m) as Methods
order by Methods desc limit 10
Top 10 Methods Per Class

Abbildung 2: Screenshot der Top 10 Methods Per Class

Konzepte

Das durch jQAssistant beim Einlesen erzeugte Datenmodell umfasst Informationen, welche für das Werkzeug direkt zugänglich sind: Java-Sprachelemente repräsentiert als Knoten mit Labels (z.B. Package, Type, Field, Method) und den entsprechenden Beziehungen (z.B. CONTAINS, DECLARES, READS, WRITES) zwischen ihnen. Einzelne Elemente können innerhalb einer Anwendung aber „höherwertige“ Rollen einnehmen, die für Abfragen interessant sein können. Beispielsweise kann ein Klassen-Knoten eine JPA-Entität darstellen oder ein Package-Knoten für ein fachliches Modul der Anwendung stehen. jQAssistant bietet hierfür sogenannte Konzepte an, die in XML-Dateien definiert werden und bei denen es sich um Abfragen handelt, welche die Anreicherung um benötigte Informationen ermöglichen und bei Erfolg Ergebnisse zurückliefern. Das folgende Konzept versieht Knoten aller mit @javax.persistence.Entity annotierten Klassen mit den Labels „Jpa“ und „Entity“:

<concept id="jpa2:Entity">
	<description>Labels all types annotated with @javax.persistence.
Entity with JPA and ENTITY.</description>
	<cypher><![CDATA[
		MATCH (t:Type)-[:ANNOTATED_BY]->()-[:OF_TYPE]->(a:Type)
		WHERE a.fqn="javax.persistence.Entity"
		SET t:Jpa:Entity 
                RETURN t AS jpaEntity
	]]></cypher>
</concept>

JPA-Entitäten sind technischer Natur, d.h. sie sind universell und tauchen in verschiedensten fachlichen Anwendungskontexten auf. Daher bietet jQAssistant Plugins mit vordefinierten Konzepten an, die einfach mittels ihrer Id (im Beispiel „jpa2:Entity“) referenziert werden können. Anders verhält es sich mit fachlichen Modulen oder technischen Schichten einer Anwendung: diese sind nur innerhalb des jeweiligen Projekt-Kontextes gültig. Ein entsprechendes Konzept könnte beispielsweise den Package-Knoten, welcher die Wurzel eines Usermanagement-Moduls repräsentiert, mit den Labels „Module“ und „UserManagement“ markieren:

<concept id="module:UserManagement">
	<description>… </description>
	<cypher><![CDATA[
		MATCH (m:Package) WHERE m.fqn="com.buschmais.shop.usermanagement"
		SET m:Module:UserManagement
                RETURN m
	]]></cypher>
</concept>

Mit zwei weiteren Schritten möchte ich die Abbildung projektspezifischer Architektur-Regeln verdeutlichen. Eine könnte lauten: Alle fachlichen Module müssen die gleiche innere Struktur aufweisen und auf ihrer obersten Ebene zunächst je ein Package „api“ und „impl“ definieren, die jeweiligen Package-Knoten sollen mit entsprechenden Labels versehen werden:

com.buschmais.shop.usermanagement (:Module:UserManagement)
com.buschmais.shop.usermanagement.api (:Api)
com.buschmais.shop.usermanagement.impl (:Impl)
com.buschmais.shop.cart (:Module:Cart)
com.buschmais.shop.cart.api (:Api)
com.buschmais.shop.cart.impl (:Impl)

Dazu werden folgende Konzepte eingeführt:

<concept id="module:DefinedModules">
	<requiresConcept refId="module:UserManagement" />
	<requiresConcept refId="…" /> <!— references to all other module concepts-->
	<description>… </description>
	<cypher><![CDATA[
		MATCH (m:Module:Package)
                RETURN m
	]]></cypher>
</concept>
<concept id="module:API"> <!— analog dazu "module:Implementation">
	<requiresConcept refId="module:DefinedModules" />
	<description>… </description>
	<cypher><![CDATA[
		MATCH (m:Module:Package)-[:CONTAINS]->(api:Package) WHERE api.name="api" 
                SET m:Api
                RETURN api
	]]></cypher>
</concept>

Das Konzept „module:DefinedModules“ referenziert über requiresConcept-Elemente alle Konzepte, welche Modul-Packages markieren, reichert das Datenmodell selbst aber nicht an. Es wird aber erst dann ausgeführt, wenn alle referenzierten Konzepte abgearbeitet wurden. Diese Abhängigkeit wird genutzt, um die Definition generischer Konzepte zu ermöglichen. Dazu zählt „module:API“, welches alle Packages namens „api“ unterhalb jedes Modul-Packages mit dem Label „Api“ markiert. Das entsprechende Implementierungs-Konzept würde analog dazu definiert.

Constraints

Wie kann ich nun eine Regel erstellen, die ausdrückt, dass keine Packages direkt unterhalb der Wurzelpackages von Modulen angelegt werden, deren Rolle undefiniert ist, d.h. weder API noch Implementierung repräsentieren? Der folgende Constraint schafft Abhilfe:

<constraint id="module:InvalidModuleStructure">
	<requiresConcept refId="module:API" />
	<requiresConcept refId="module:Implementation" />
	<description>Modules must only consist of API and Implementation packages. </description>
	<cypher><![CDATA[
		MATCH (m:Module:Package)-[:CONTAINS]->(p:Package)
		WHERE not (p:Api or p:Implementation) 
                RETURN m.fqn, p.fqn
	]]></cypher>
</constraint>

Der Constraint baut auf den vorhergehenden Konzepten auf und liefert genau dann Ergebnisse, wenn innerhalb eines Modul-Packages Packages existieren, die weder einen Label „Api“ oder „Implementation“ besitzen – das entspräche einer Verletzung der oben definierten Regel. In diesem Fall würde jQAsisstant den Build abbrechen und Informationen inklusive der im Constraint hinterlegten Beschreibung ausgeben. Dies erfolgt im Sinne eines schnellen und verständlichen Feedbacks an den Entwickler, welcher die Regel gebrochen hat.

Zusammenfassung

Eine Anwendung kann ihren Entwicklern zugänglicher gemacht werden, wenn die Strukturen ihrer Elemente definierten, bekannten und nachvollziehbaren Konventionen bzw. Regeln entsprechen. Die Folge ist bessere Wartbarkeit, schnellere Erweiterbarkeit und als kleiner Bonus weniger Frust bei den Beteiligten. Mit jQAssistant steht ein Werkzeug zur Verfügung, welches die Definition und Überprüfung derartiger Regeln ermöglicht, indem es Anwendungsstrukturen in eine Neo4j-Datenbank einliest, die Anreicherung um technische bzw. projektspezifische Konzepte ermöglicht und darüber Abfragen ausführt, welche die Einhaltung definierter Constraints sicherstellen. Ein Entwickler kann so unmittelbares Feedback über von ihm verursachte Probleme erhalten.

Das skizzierte Beispiel zur Sicherstellung gleichartiger Strukturen in fachlichen Modulen einer Anwendung stellt dabei nur einen Ausschnitt aus der Menge möglicher Regeln dar. Naheliegend und leicht zu realisieren ist u.a. auch die Überprüfung der Einhaltung von Namenskonventionen für Klassen mit bestimmen Rollen, z.B. EJBs, JPA-Entitäten, etc. sowie deren Lokalisierung in speziellen Packages. Weitere Informationen finden sich auf der jQAssistant-Homepage – ich wünsche viel Spaß beim Probieren, Definieren und Verifizieren!

Kommentare sind abgeschaltet.