Graphendatenbank: Flexible Datenabfragen mit Neo4j

Werkzeuge  –  0 Kommentare

Mit wachsender Größe eines Softwaresystems reduziert sich schnell der Überblick über darin umgesetzte Strukturen und Muster. Moderne IDEs bieten zwar umfangreiche Möglichkeiten für Code-Analysen, sie sind aber eingeschränkt, wenn es darum geht, projektspezifische Konstrukte zu finden. Helfen mag hier ein Ansatz, bei dem Softwarestrukturen aus Java-Anwendungen in einer Graphendatenbank eingelesen werden und der flexible Abfragen auf diesen Daten ermöglicht.

Am Anfang steht die Frage nach der Modellierung der Daten. Die Graphendatenbank Neo4j stellt dafür zwei grundlegende Elemente zur Verfügung: Knoten und Beziehungen zwischen diesen. Es gibt dabei kein Schema im herkömmlichen (relationalen) Sinne, die Datenbank ist vergleichbar mit einem Whiteboard, auf das einfach alle Elemente "gezeichnet" werden.

Es ist naheliegend, Java-Sprachelemente wie Packages, Klassen, Felder und Methoden als Knoten umzusetzen. Doch wie lassen sich Typen der entstehenden Knoten voneinander unterscheiden? Neo4j bietet hierfür ein komfortables Konzept: Einem Knoten kann man eine beliebige Menge sogenannter Labels hinzufügen. Ein Java-Package würde beispielsweise mit dem Label PACKAGE markiert werden, Methoden und Felder entsprechend mit METHOD und FIELD.

Etwas komplizierter gestaltet sich die Situation bei Klassen. Diese werden durch .java- beziehungsweise .class-Dateien repräsentiert und stellen immer einen Java-Typen dar, der aber unterschiedliche Ausprägungen haben kann: Klasse, Interface, Enumeration oder Annotation. Die Lösung liegt in der Formulierung des vorhergehenden Satzes: Es wird ein Knoten erzeugt, der mit dem Label TYPE und einem näher spezifizierenden Label, also entweder CLASS, INTERFACE, ENUM oder ANNOTATION, versehen ist.

Die Präsenz eines Labels impliziert das Vorhandensein spezieller Eigenschaften eines Knotens. Jeder Java-Typ besitzt beispielsweise einen voll qualifizierenden Namen und eine Sichtbarkeit. Diese werden als benannte Properties (z. B. FQN, VISIBILITY) des Knotens mit den entsprechenden Werten (java.lang.Object, public) gespeichert. Darüber hinaus kann ein Java-Typ aber auch Methoden deklarieren. Existiert also an einem Knoten ein TYPE-Label, ist zu erwarten, dass er (gerichtete) Beziehungen zu anderen Knoten besitzt, die mit dem Label METHOD versehen sind. Die Beziehungen selbst sind typisiert, im vorliegenden Fall ist dafür DECLARES naheliegend.

Besitzt der TYPE-Knoten darüber hinaus ein weiteres Label CLASS, existiert von ihm ausgehend immer genau eine Beziehung EXTENDS zu einem Knoten, der die Superklasse, des Java-Typen repräsentiert (z. B. java.lang.Object). Etwas allgemeiner ausgedrückt wird also das Schema eines Knotens durch die Menge seiner Labels bestimmt. Die Abbildung 1 veranschaulicht eine – zugegebenermaßen einfache – Java-Klasse und einen Ausschnitt aus dem entsprechenden Graphen-Modell.

Java-Strukturen als Graph mit Knoten, Beziehungen, Labels und Properties (Abb. 1)

Die Bedeutung der bisher nicht beschriebenen Labels, Properties und Beziehungen sollte schnell zu erfassen sein. An der Stelle sei noch einmal darauf hingewiesen, dass die Daten in dieser Repräsentation gespeichert und wieder zugänglich gemacht werden – es gibt keine weiteren technischen Hilfskonstrukte wie Fremdschlüsselbeziehungen.

Cypher

Einfache Abfragen und allgemeingültige Metriken

Die Daten sind nun modelliert und gespeichert, der interessante Teil sind jedoch deren Abfragen. Neo4j bietet hierfür die Sprache Cypher an. Diese folgt einem interessanten Ansatz, der sich kurz mit "Pattern-Matching by ASCII-Art" umschreiben lässt.

Die grundlegende Aufgabe einer Cypher-Abfrage ist es, in einer großen Menge von Knoten und Beziehungen nach Mustern zu suchen, diese nach bestimmten Kriterien zu filtern und ausgewählte Elemente aus dem Suchergebnis zurückzugeben. Die größte Herausforderung liegt dabei in der Beschreibung der zu suchenden Muster. Das soll ein Beispiel demonstrieren, das eine Liste aller Klassen und ihrer jeweiligen Superklassen ermittelt. Es handelt sich dabei um eine Suche nach allen Knotenpaaren t1 und t2, wobei

  • t1 die Labels TYPE und CLASS besitzen muss und
  • zwischen t1 und t2 eine Beziehung r vom Typ EXTENDS existiert.

Das lässt sich unter Zuhilfenahme runder Klammern für Knoten sowie eckiger Klammern für Beziehungen in ASCII-Art umsetzen:

match
(t1:TYPE:CLASS)-[r:EXTENDS]->(t2:TYPE)
return
t1.FQN as Class, t2.FQN as SuperClass
limit 20

Es handelt sich bereits um eine vollständige Cypher-Abfrage, die aus einer match-Klausel mit dem zu suchenden Muster sowie einer return-Klausel mit den zurückzugebenden Werten (t1.FQN und t2.FQN, also die voll qualifizierenden Namen der gefundenen Java-Typ-Knoten) besteht.

Bei näherem Hinschauen fällt auf, dass die Variable r für die Beziehung EXTENDS zwar deklariert, aber nicht weiter verwendet wird – sie kann komplett entfallen und spielt in den folgenden Beispielen daher keine Rolle mehr. Mit weiteren Cypher-Mitteln lässt sich das Szenario ausbauen: Welche Klassen haben die tiefsten Vererbungshierarchien?

match
h=(t1:TYPE:CLASS)-[:EXTENDS*]->(t2:TYPE)
return
t1.FQN as Class, length(h) as Depth
order by
Depth desc
limit 20
Top-10-Klassen der JRE 7 und die Tiefe ihrer Vererbungshierarchien (Abb. 2)

Die beschriebene Abfrage ist bei Kenntnissen von Cypher und des auf die Bedürfnisse eines Java-Entwicklers zugeschnittenen Datenmodells relativ einfach zu schreiben und zu lesen. Interessant ist der Vergleich zu relationalen Datenbanken: Abgesehen davon, dass in diesem Umfeld Fremdschlüsselbeziehungen die Lesbarkeit stark einschränken würden, wäre die Abfrage in der Form ohne spezielle Hilfsmittel nicht einmal formulierbar, da die Tiefe der Hierarchien unbekannt ist.

Kontextspezifische Abfragen

Die bisherigen Abfragen stellen Anwendungsfälle dar, die gängige Java-Werkzeuge problemlos abbilden können. Interessant wird es allerdings, wenn es darum geht, Informationen zu ermitteln, in die spezifische Eigenschaften eines Softwaresystems einfließen sollen. Das soll folgendes Szenario skizzieren: Eine Anwendung besteht aus einer Menge fachlicher Module und soll als einfache Komplexitätsmetrik die Anzahl der Packages pro Modul ermitteln. Dafür ist aber Wissen über die Strukturierung des Systems erforderlich, das für Werkzeuge im Normalfall nicht zugänglich ist: Welche Packages stellen die Wurzeln fachlicher Module dar? Das folgende Codebeispiel zeigt beispielhaft eine Package-Struktur, die darüber hinaus auch technische Schnitte (ui, ejb und model) enthält:

com.buschmais.shop.user
com.buschmais.shop.user.ui
com.buschmais.shop.user.ejb
com.buschmais.shop.user.model
com.buschmais.shop.cart
com.buschmais.shop.cart.ui
com.buschmais.shop.cart.ejb
com.buschmais.shop.cart.model
com.buschmais.shop.catalog
...

Die fachlichen Module liegen direkt unterhalb des Packages com.buschmais.shop und können wiederum eine beliebige Menge untergeordneter Packages enthalten. Die gewünschte Abfrage via Cypher sieht folgendermaßen aus:

match
(rootPackage:PACKAGE)-[:CONTAINS]->(modulePackage:PACKAGE),
(modulePackage)-[:CONTAINS*]->(subPackage:PACKAGE)
where
rootPackage.FQN = 'com.buschmais.shop'
return
modulePackage.FQN as Module, count(subPackage) as PackagesPerModule
order by
PackagesPerModule desc

Die Aussagekraft der Metrik ist allerdings begrenzt: Viele Packages müssen nicht zwangsläufig viele Java-Typen enthalten. Daher soll für zusätzlichen Informationsgewinn eine weitere Abfrage formuliert werden, die die Anzahl der Java-Typen pro fachlichem Modul ermöglicht. Diese würde aber die Teile der match- und where-Klauseln aus der ersten Abfrage duplizieren, die der Identifikation der fachlichen Module dienen – eine Wiederverwendbarkeit wäre wünschenswert. In solchen Fällen empfiehlt sich eine Anreicherung des Datenmodells um kontextspezifische Konzepte. Das bedeutet konkret, dass die Package-Knoten, die fachliche Module repräsentieren, zunächst mit einem Label MODULE versehen werden:

match
(rootPackage:PACKAGE)-[:CONTAINS]->(modulePackage:PACKAGE),
where
rootPackage.FQN = 'com.buschmais.shop'
set
modulePackage:MODULE
return
modulePackage.FQN as Module

Darauf basierend lässt sich die Metrik "Packages pro Modul" deutlich vereinfachen und die Metrik "Java-Typen pro Modul" entsprechend umsetzen:

match
(modulePackage:MODULE)-[:CONTAINS*]->(package:PACKAGE)
return
modulePackage.FQN as Module, count(package) as PackagesPerModule
order by
PackagesPerModule desc


match
(modulePackage:MODULE)-[:contains*]->(type:TYPE)
return
modulePackage.FQN as Module, count(type) as TypesPerModule
order by
PackagesInModule desc

Fazit

Auffinden technischer Konstrukte

Es ist häufig der Fall, dass für ein Refactoring spezielle Stellen im Anwendungscode zu identifizieren sind, die mit IDE-Mitteln nur schwer aufzufinden sind, da die Menge potenzieller Suchergebnisse zu groß ist. Als Szenario soll dafür beispielhaft ein Problem aus einem realen Java-EE-Projekt (Java Enterprise Edition) beschrieben werden. Im Code der Anwendung hatten die Entwickler an verschiedenen Stellen per @Inject-Annotationen Instanzen eines Dienstes MyService injiziert. Alle derartigen Injection-Points sollten um eine weitere Annotation (genau genommen einen Qualifier) ergänzt werden:

public class MyClient {
@Inject
private MyService myService; // Injection-Point
...
}

Die IDE bot keine Unterstützung für das Auffinden von Injection-Points, eine Suche nach der Verwendung des Typs MyService lieferte leider nicht nur die gewünschten, sondern auch alle anderen Stellen, an denen der Typ zum Einsatz kam. Die folgende Cypher-Abfrage hingegen enthält alle benötigten Kontextinformationen und kommt entsprechend treffsicher zum gewünschten Ergebnis:

match
(type:TYPE)-[:DECLARES]->(field:FIELD),
(field)-[:OF_TYPE]->(serviceType:TYPE),
(field)-[:ANNOTATED_BY]->(inject:ANNOTATION),
(inject)-[:OF_TYPE]->(injectType:TYPE)
where
serviceType.FQN = 'com.buschmais.shop.user.ejb.MyService'
and injectType.FQN = 'javax.inject.Inject'
return
type.FQN, field.NAME

Auch wenn sich diese Abfrage über mehrere Zeilen erstreckt, lässt sie sich doch in deutlich kürzerer Zeit formulieren und ausführen, als es dauern würde, ungenaue Suchergebnisse manuell zu filtern. Für die Exploration einer existierenden Code-Basis können Entwickler so recht schnell aussagekräftige Ergebnisse gewinnen. Ein weiteres Beispiel hierfür ist die Frage nach allen Packages, in denen sich JPA-Entitäten befinden:

match
(package:PACKAGE)-[:CONTAINS]->(entity:TYPE),
(entity)-[:ANNOTATED_BY]->(a:ANNOTATION)-[:OF_TYPE]->(annotationType:TYPE)
where
annotationType.FQN='javax.persistence.Entity'
return
package.FQN as EntityPackage, count(entity) as NumberOfEntities

Eine Frage wurde bisher nicht beantwortet: Wie kommen die Daten in der geschilderten Struktur in die Graphendatenbank? Diesen Weg eröffnet das Open-Source-Werkzeug jQAssistant, das manuell oder eingebunden im Build-Prozess die erzeugten Artefakte eines Java-Projekts (also Packages, Klassen, Deskriptoren usw.) einliest, in einer Neo4j-Datenbank ablegt und anschließend die beschriebenen Abfragen ermöglicht. Letztere lassen sich zur Anreicherung des Datenmodells um Konzepte im oben beschrieben Sinne und zur automatischen Validierung projektspezifischer Regeln (z. B. Namenskonventionen) verwenden.

Fazit

Für einen Entwickler, der zwangsläufig über strukturelle Kenntnisse der von ihm verwendeten Programmiersprache verfügt, ist das Modell von Softwarestrukturen in einer Graph-Repräsentation nahezu intuitiv zu verstehen. Mit der von Neo4j angebotenen, schnell zu erlernenden Abfragesprache Cypher kann er gezielt Informationen über Strukturen und Konstrukte innerhalb eines Softwareprojekts gewinnen. Die Möglichkeiten reichen dabei von einfachen Metriken bis hin zu stark vom vorliegenden Kontext abhängigen Informationen. (ane)

Dirk Mahler
ist Senior-Consultant der buschmais GbR, einem Beratungshaus mit Sitz in Dresden. Der Schwerpunkt seiner Tätigkeit liegt im Bereich Architektur für Java-Applikationen im Unternehmensumfeld.

Literatur
  • Michael Hunger, Peter Neubauer; Die vernetzte Welt; Abfragesprachen für Graphendatenbanken; Artikel auf heise Developer
  • Rudolf Jansen; Beziehungsmanagement; Neo4j – NoSQL-Datenbank mit graphentheoretischen Grundlagen; Artikel auf heise Developer